Compare commits

..
61 Commits
Author SHA1 Message Date
JosephFerano d2b4f62949 Client: a press carried across a full rebuild no longer needs the new button under the pointer at once (the entrance moved it: the release clicked nothing); inputlock prints the touches it got on a failure 2026-10-21 02:17:24 +07:00
JosephFerano 0450ecd05a Client: sticky mouse buttons and keys (a press shorter than a frame was lost); drive.py gets its own Xvfb display (xvfb-run -a raced between concurrent runs) 2026-10-20 23:13:34 +07:00
JosephFerano e9c1e344b8 Client: quick grant dialog for admins (header + button, G key) 2026-10-20 23:13:34 +07:00
JosephFerano 32b3465c46 Quick admin grant dialog: admin-grant action (web, API), admin flag in /api/state 2026-10-20 22:47:18 +07:00
JosephFerano 0bf4779903 Client: shared market quotes; advisors and recent selling as personal lines on the ticket; check.py and drive.py --fixture need an explicit server 2026-10-20 22:39:43 +07:00
JosephFerano ba09caf66f Shared market quotes; advisors and selling pressure apply to the payout only 2026-10-20 22:35:15 +07:00
JosephFerano 70ebafa31d Client AGENTS: what is laid out with flex now; flex_gui tree pieces 2026-10-20 21:56:22 +07:00
JosephFerano 742e071434 Client: reveal card laid out by a flex tree (parts made at their boxes; animated groups placed there) 2026-10-20 21:54:54 +07:00
JosephFerano 11d94d38f0 Client: moon sale panel as a tree piece of the Moons section (summary, note, lines grid) 2026-10-20 21:52:58 +07:00
JosephFerano d1d9d51d5d Client: banner, toasts (body and stack), dialog panel and buy modal flex-first 2026-10-20 21:51:04 +07:00
JosephFerano ed59c6ae86 Client: new-moon teaser flex-first (claim frame, wells and class hints with wrapping chips) 2026-10-20 21:46:22 +07:00
JosephFerano 4c93f0471f Client: moon list panel flex-first (Active/Past tabs, search, rows box, pager, + New moon pinned) 2026-10-20 21:45:10 +07:00
JosephFerano 9c9a2697a8 Client: Help and Leaderboard flex-first (steps, rows, list that shrinks to the panel) 2026-10-20 21:42:18 +07:00
JosephFerano 7b6f0b75f9 Client: header flex-first (brand, tabs in slots, Claim all, second nav row when it doesn't fit) 2026-10-20 21:39:09 +07:00
JosephFerano c7839bb2ef Client: Market flex-first (heading stats, quote cards grid, detail panel, sell ticket and payout as trees; ticket panel in a slot for the sale) 2026-10-20 21:36:53 +07:00
JosephFerano 29c8034bf0 Client: Advisors flex-first (stat panels, seats grid, bench, card footers, advisor panel and preview) 2026-10-20 21:32:32 +07:00
JosephFerano b7c6a1c264 Client: Store and Equipment pages flex-first (fg.flow with card grids, buy footers, tool card name row and status); flex_gui.inline, flow 2026-10-20 21:29:07 +07:00
JosephFerano 6fb4392438 Client: drill panel flex-first (head, stats, upgrade, tier ladder as a grid, slots, spares, sale); equipment stats/upgrade as tree pieces; flex_gui.measured 2026-10-20 21:26:42 +07:00
JosephFerano 92c23f574f Client: aside well panel flex-first (deploy cards, drill lines, claims), viewport and captions as trees 2026-10-20 21:22:01 +07:00
JosephFerano 4a4897ffb0 Client: Moons section flex-first (wells table as a grid, heading, past moon, first-moon pitch); flex_gui tree pieces, button boxes, rich labels; H.claim_wait_w 2026-10-20 21:18:06 +07:00
JosephFerano 1ea59b5d70 Client flex: grid tracks, absolute/anchor, aspect_ratio, scroll content size (with tests) 2026-10-20 21:09:17 +07:00
JosephFerano e805f97a4c client AGENTS.md: new UI is flex-first; port hand-placed sections when touched 2026-10-20 18:09:50 +07:00
JosephFerano bdc8d311be Client: Refinery slot head 22.5 tall (as before); AGENTS: flex-first sections 2026-10-20 18:05:18 +07:00
JosephFerano 9d512f5abc Client: Refinery rewritten flex-first (one tree, nodes made from the solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano 8293fdca22 Client: flex.columns/grid, flex_gui label/para leaves and build (make nodes from a solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano a21d5a4371 Client: AGENTS Layout (flex) section and FLEX-NOTES 2026-10-20 17:43:47 +07:00
JosephFerano b4b6d22113 Client: header wallet cluster laid out with flex 2026-10-20 17:42:52 +07:00
JosephFerano 7ec64c39a3 Client: flex_gui (apply a solved tree to slot nodes, optional glide); Card and the Refinery laid out with flex 2026-10-20 17:39:13 +07:00
JosephFerano 9d4b712d8b Client: flex, a pure flexbox layout solver, with unit tests in make check 2026-10-20 17:24:48 +07:00
JosephFerano 8ecfa5753e Client: restore the web-style Refinery (slot cards, segmented bars, recipe cards) that the c-clickfast import dropped; kept-card grid passes card options 2026-10-20 17:14:00 +07:00
JosephFerano c31dcca280 AGENTS: blocks (kept regions, registries, keys a section reads) 2026-10-20 16:28:44 +07:00
JosephFerano bfa3e1ce65 Moon bloom in GUI reference pixels: HiDPI does the 1x work and its glow looks like 1x 2026-10-20 16:28:23 +07:00
JosephFerano 59f26dc5af Blocks: kept blocks always look for their clip stencil again (a deleted node answers until the frame ends) 2026-10-20 16:19:24 +07:00
JosephFerano bb6b34c1b6 Moon generation only native (mmterrain): Lua generator and its fallback gone, no generation timing; moon-check compares mmterrain with terrain.js 2026-10-20 16:19:24 +07:00
JosephFerano 302bb2b764 Merge branch 'c-perf-moon' into c-perf 2026-10-20 15:46:14 +07:00
JosephFerano 90722d1c04 WIP: incremental rebuilds (blocks), reveal flight defers rebuilds 2026-10-20 12:39:57 +07:00
JosephFerano 22dca72f39 make check runs moon-check (terrain.lua and mmterrain against terrain.js); AGENTS: the mmterrain extension 2026-10-20 12:36:55 +07:00
JosephFerano 27f415d4af Moon meshes on the web: native work as a share of the frame time, the buffer filled over the next frames, grid 128 2026-10-20 12:36:55 +07:00
JosephFerano b8f2422433 mmterrain: fill a range of the strip; directions stepped too 2026-10-20 12:36:55 +07:00
JosephFerano 62ebb27d25 Moon meshes through mmterrain (a thread on desktop, 3 ms a frame on the web); a bought moon's mesh starts with the buy-moon response 2026-10-20 12:22:53 +07:00
JosephFerano 979470f306 mmterrain: the moon generator in C (native extension), checked against terrain.js and terrain.lua 2026-10-20 12:22:53 +07:00
JosephFerano 18f31b322a Moon generation: checkpoints in crater placement too, so no step of it runs unchunked 2026-10-20 12:22:53 +07:00
JosephFerano 321102cc72 AGENTS: web moon budget, frames command, F3 profiler 2026-10-20 11:53:00 +07:00
JosephFerano 948e16cf16 Moon target: supersample in GUI reference pixels, so HiDPI displays don't draw it at 4x 2026-10-20 11:33:39 +07:00
JosephFerano 994c1a00ab Moon generation on the web: 8 ms a frame and finer checkpoints, so it keeps 60 fps; cube-sphere cached per grid size 2026-10-20 11:33:39 +07:00
JosephFerano 6973c517b4 Debug: frame times through the test hook (frames), F3 shows the engine profiler 2026-10-20 11:33:28 +07:00
JosephFerano 166145631b Web: variable frame rate, so the browser's display rate paces frames 2026-10-20 11:20:34 +07:00
JosephFerano 7e3bc38da4 README: minimal build and run steps for the server, web client, Defold desktop and browser builds 2026-10-20 10:26:06 +07:00
JosephFerano f0d396c2eb Fix paths for the server/ layout
- Shared AGENTS.md and README.md at the root; server/AGENTS.md and
  server/README.md keep the server-specific parts.
- docker-compose.prod.yml builds ./server (services, networks, volumes unchanged).
- .gitignore split into the root and server/; .dockerignore no longer lists client.
- The server reads .env from its working directory, else ../.env (the repo root).
- Client: server references point at ../server (AGENTS.md, predict.lua,
  moon-check.mjs default); druid-notes.md is ../../druid-notes.md.
2026-10-20 10:06:42 +07:00
JosephFerano 09aa0978bc Move the Go server into server/
Pure renames: the Go package, web/, sql/, config/, scripts/, art/, Makefile,
Dockerfile, .dockerignore, api.md, test-endpoints.restclient, AGENTS.md and
README.md move under server/. Paths are fixed in the next commit.
2026-10-20 10:01:37 +07:00
JosephFerano 80f874c452 AGENTS.md: web bundle size 2026-10-20 09:53:47 +07:00
JosephFerano 8cfc646d78 AGENTS.md: the web build; drop the hand-off notes 2026-10-20 09:45:24 +07:00
JosephFerano 1bcb5c88a2 Web: needs WebGL 2 (clear message otherwise), 8-bit moon targets without EXT_color_buffer_float 2026-10-20 09:45:07 +07:00
JosephFerano 52efc686cf Test hook in the browser (window.mmTestQueue) and scripts/web-shots.mjs (headless Chromium/Firefox check) 2026-10-20 09:36:39 +07:00
JosephFerano 604aee047a Web build: make web (wasm-web bundle), full-window page with a dark loading screen, make serve-web (scripts/serve-web.py proxies /api and /static) 2026-10-20 09:36:39 +07:00
JosephFerano 5556c24142 Web: page origin as the API base, fullscreen through the browser API, lighter moon generation 2026-10-20 09:36:39 +07:00
JosephFerano aa88e6f213 Moon render: bind every bloom sampler, remake targets on resize, 8-bit fallback (WebGL feedback loop and Firefox abort) 2026-10-20 09:36:39 +07:00
JosephFerano b27966e742 Client web build: hand-off notes (not started) 2026-10-20 09:36:39 +07:00
JosephFerano 2451792d46 Client: Claim, Redrill and Collect act on the first click; presses survive rebuilds
- Claim prediction counts a drill as ready once its end passed on our clock (the button
  turns on at zero, before the refetch), so a click there predicts instead of a spinner;
  claim-well leaves multi-well drills alone like the server; a plain claim raises the
  well's Redrill count, so Redrill shows at the click. Collect uses the same ready test.
- A press that a rebuild interrupts carries over to the button with the same test id
  under the pointer (every Druid button), so the release still clicks; navmemory and
  inputlock no longer retry clicks.
- A Claim popping in takes clicks on its full size; the well panel's Claim and redeploy
  shows at the zero with Claim; waiting Claim buttons have test id wait:<key>.
- clickfast scenario: real clicks 0-500 ms after each button turns on, and across a
  forced rebuild.
2026-10-20 09:19:03 +07:00
JosephFerano 48aa8900a8 AGENTS.md: the repo is game/ in the workspace; the Defold client is client/ 2026-10-20 09:04:51 +07:00
JosephFerano c1ad202e42 Add the Defold desktop client under client/ (moved from its own repo, history squashed) 2026-10-20 08:43:12 +07:00
414 changed files with 30389 additions and 3289 deletions

No files matched your search

+1 -12
View File
@@ -1,9 +1,3 @@
# Build output
/bin/
/moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit) # Local env files (never commit)
.env .env
.env*.local .env*.local
@@ -13,14 +7,9 @@
.DS_Store .DS_Store
# Personal files # Personal files
/sql/create-users.sql
/.dir-locals.el /.dir-locals.el
sql/queries.sql
/project.todo /project.todo
# Blender files (generated by art/equipment scripts) # Blender files
*.blend *.blend
*.blend1 *.blend1
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+34 -215
View File
@@ -1,222 +1,41 @@
# AGENTS.md — Moon Miners # AGENTS.md — Moon Miners
## What this is ## What this is
Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a Moon Miners is an idle/mining game. Players join anonymously: the server creates a
player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with player and keeps its id in the `mm_user` cookie (or `X-MM-User` for the API). Players own
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they and stake them on wells. When a stake finishes (drill cycles are seconds:
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which `completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
in slots; refined items sell for more and are parts for higher tool upgrades). Drill items (Refinery section: timed batches in slots; refined items sell for more and are parts
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
refinery for the whole account. There is also a leaderboard (Leaderboard section, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`). (Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out).
## Stack ## How the parts fit
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main` - `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
package at the repo root; templates, static files and SQL are embedded with `go:embed`. renders as HTML (htmx, three.js moon). The server is the only authority on game state.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions Details: `server/AGENTS.md`.
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their - `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine. JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals` what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild Details: `client/AGENTS.md`.
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not - Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
committed). See "web/app" below. Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Workspace
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it - The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`. outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env` - Agent worktrees go in the workspace, next to `game/`:
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis). `git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it - `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a commit it.
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded.
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
hold less); a first moon
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
and the store link to the classic client. `session.js`: like the Defold client's session
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
id; corrections change its playback rate). `fx.js`: effects at the click from the
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
for what differs.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check` flags `.value` inside JSX).
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
changes when the server answers); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
## Commands ## Conventions (all parts)
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and - The server is the authority: clients show the state it sends and send actions; no game
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every rules on the client side beyond what `server/api.md` "Prediction" allows.
game request that long, to try both clients on a slow connection. - Work in one part at a time: a client task doesn't change `server/`, and the other way
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`, round, unless the task says so.
moon traits check, the Preact client's `.value` check and build), `make migrate`,
`make load-config`. `go run . sim` prints the economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
templates like the server and renders every page and region against in-memory game states;
when you add a page, section or view model branch, make sure its states/views reach it.
- Local DB: `podman-compose up -d db` then `make migrate` (see README).
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
## Deployment
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
`pg_dump --schema-only`.
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
- Keep changes small. Don't refactor or move files unless asked. - Keep changes small. Don't refactor or move files unless asked.
-169
View File
@@ -1,169 +0,0 @@
# Hand-off: Preact web client experiment (branch `s-preact`)
## Goal
Find out whether a web client can feel as smooth and instant as the Defold client (which
predicts click results locally and eases corrections). It's built with Preact + JSX +
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
next to the classic htmx client at `/`. The user will compare them by feel: fast
claim/redrill/deploy clicks, and animations that never hitch.
## What's built
- `web/app/` (about 2,400 lines without the vendored code):
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
the `shown`/`live` computeds, and an outbox that sends one request at a time with
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
`nextEnd` and keeps the URL in sync.
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
difference: readiness is local (`s.ready || s.end <= now`).
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
- `ui.jsx`:
- `Num`: eased number, written by the ticker.
- `Countdown`.
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
`playbackRate` so the bar lands on the true end without jumping.
- `SupplyBar`: clip-path.
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
waits for the server).
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
for the API's `confirm` texts (the moon sale).
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
`sfx.js`.
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
zoom were verified. If the worker fails it falls back to the page's thread;
`?mainscene` forces that.
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
holds the path aliases, including `three`, used by the worker bundle.
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
- Build:
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
gitignored and in `.dockerignore`.
- `make dev` watches the client.
- `make build` depends on `app`.
- The Dockerfile fetches esbuild and runs `make app`.
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
inside JSX, and builds the client.
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
`test-endpoints.restclient`.
## How to try it
```
cd wt-s-preact && make app && go build -o bin/moonminers .
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
```
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
gitignored. **The servers are stopped now**; restart them with the commands above.
## Measurements
### Second run (Oct 20, after the Moons slice was finished)
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
the median of 3 alternating runs; latency from pointerdown to the first frame where the
side panel's drill line count changes. Each test starts from a known state set through the
API and admin endpoints (the wells topped up, one drone finished on the well). No other
engine was running; the machine's load average was 6 to 9 from other work.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
1.5 s after, 0 ms delay, median of 3):
| | script | layout | style | all tasks |
|---|---|---|---|---|
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
| app | 11 ms | 8 ms | 10 ms | 62 ms |
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
the first gesture, as below); the others 24–25 ms.
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
other engine.
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
for the header's Classic/Admin link.
### First run
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
where the side panel's drill lines change.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
- The classic client drops clicks while a request runs: `body.is-busy` sets
`pointer-events: none`.
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
was using about 460% CPU and the GPU.
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
than on scripts.
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
under this load.
- The first click in each run has a long task of about 120 ms in both clients. It comes
from `sfx.js` creating the AudioContext on the first gesture.
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
`runall.sh`.
## Unfinished / next steps
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
side-by-side screenshots. What's left:
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
The API has every section's state and the prediction rules for sells and refinery
batches (api.md "Prediction").
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
would need the server's split of multi-well claims and the sale's payout rules.
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
around a claim). Worth keeping in `scripts/` if the comparison continues.
4. Report: rough line counts are above. Hard parts:
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
- easing bar corrections (playbackRate);
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
`Event.type`);
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
empty spans).
## Gotchas
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
which is allowed. Never read `.value` inside JSX.
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
last `shown` change, because Preact re-renders asynchronously.
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
only for the main-thread fallback.
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
`web/static/js/scene.js` too.
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
(the sale lines' `value`): destructure them under another name.
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
-50
View File
@@ -1,50 +0,0 @@
TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss
CSS := web/static/css/app.css
ESBUILD_VERSION := 0.28.2
ESBUILD := bin/esbuild
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
# (/static/js/...) by URL.
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
.PHONY: build dev css app migrate load-config check
build: css app
go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
# client rebuild on change.
dev: $(TAILWIND) $(ESBUILD)
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
$(APP) --sourcemap --watch=forever & \
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
css: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
app: $(ESBUILD)
$(APP) --minify
migrate:
go run . migrate
load-config:
go run . load-config
check:
test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs
node scripts/check-signals-jsx.mjs web/app
$(APP) --minify --log-level=warning
$(ESBUILD):
mkdir -p bin
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
$(TAILWIND):
mkdir -p bin
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x $@
+22 -239
View File
@@ -1,252 +1,35 @@
# Moon Miners # Moon Miners
A browser mining game. Players own moons (staking sources) with resource wells, buy and An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to - `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
a three.js view of the selected moon, generated procedurally from its id. Game rules run in - `client/`: Defold client, for desktop and the browser (WebAssembly).
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
Players join anonymously: on the first visit the server creates a player and stores its id in ## Server and web client
a cookie. No wallet or sign-up needed.
## Requirements Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`).
- Go 1.25+ ```bash
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it podman-compose up -d db # or: docker compose up -d db
- Node.js, only for `make check` (syntax-checks the JS) cd server
make migrate
`make` downloads the Tailwind standalone CLI into `bin/` on first use. make dev # http://localhost:3000
## Environment
The server reads `.env` in the project root (gitignored, never commit it). Real environment
variables take precedence.
```dotenv
DB_USER=moonminers # app user: read/write access to the tables
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
``` ```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates ## Defold client
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
## Local development Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
is optional: https://defold.com/download/
1. Create `.env` as above. With the server running:
2. Start Postgres and set up the database (roles, schema, migrations, store items from
`config/game.json`):
```bash ```bash
podman-compose up -d db # or: docker compose up -d db cd client
make migrate make run # desktop build, plays against http://localhost:3000
``` make web && make serve-web # browser build: open http://localhost:8080
```
3. Run the server and open http://localhost:3000: ## Deploy
```bash Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
make dev
```
Template, CSS and JS changes show up on reload; Go changes need a restart.
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
Reset the local database: `podman-compose down -v && podman-compose up -d db && make migrate`.
## Database changes
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
store items with the game config.
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
database and one migrated from the pre-Go schema through every migration must end up with the
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
number; a player never gets the same name twice), and the refinery
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
it started with (`bonus.go`).
It's embedded in the binary, so changes need a rebuild.
Store items are also kept in the database (`migrate` and `make load-config` sync them;
items removed from the file are deleted unless a player owns one).
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
| Endpoint | Actions |
|---|---|
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
and store items are re-synced.
### Live ops
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
resources claimed. Built from `event_log`, which every game action writes to.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
(schema checks back this up); a stake is claimed at most once (unique index).
- Errors that aren't rule violations are logged and answered with a generic 500.
## Deployment (Dokploy)
`docker-compose.prod.yml` runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
First deploy:
1. Dokploy: create a **Docker Compose** service from this repository, compose path
`docker-compose.prod.yml`.
2. **Environment**:
```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
```
Never set `MM_DEV` in production: it makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
To test the production stack locally:
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
(add a `ports: ["3100:3000"]` override for `app` to reach it).
## Endpoints
See `test-endpoints.restclient` (Emacs restclient format).
+7
View File
@@ -0,0 +1,7 @@
build/
tools/
.internal/
*.der
.DS_Store
dist/
__pycache__/
+637
View File
@@ -0,0 +1,637 @@
# AGENTS.md — Moon Miners client (Defold)
## What this is
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header with sections, resources and credits; moon list on the left; the current section
in the middle; aside with the moon viewport and the selected well), made game-native.
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later.
The server is the authority. The client shows the state the server sends and sends
actions. It may show an action's result before the answer (prediction, see Conventions),
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
rules in Lua. Countdowns may tick locally as display only.
## Stack
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
native extension, so a build sends the engine to Defold's build server once (needs
network; later builds reuse it until the dependencies change). Dependencies are fetched
only when `game.project` changes (`make resolve` forces it).
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
`~/Development/Refold/Defold` (read-only).
## Game screen: core, regions, sections
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
state: every new state (a response, a prediction) or a window/UI-scale change sets
`g.dirty`, and the next update builds again what changed. The regions (banner, header,
moon list, main panel, aside) and costly parts inside them (the market's ticker, chart and
sparklines; Store, Equipment and Advisors cards through `card.grid`'s `key`/`sig`) are
blocks (`modules/blocks.lua`): one whose signature (a serialization of everything it
shows: its state keys, the view, busy buttons, ready counts on our clock) is unchanged
keeps its nodes and Druid components. A full rebuild (drops the Druid instance and every
layout node) happens on the first state, a size change, the narrow layout and
`g.dirty_all` (theme, reduced motion, mute, scroll offsets set by code, hot reload). What
a region shows from the state is listed in `core/main.lua` (`SECTION_KEYS`, `ASIDE_KEYS`,
the header's and moon list's sigs): a section that reads a new state key must add it
there, or it won't update. `g` is the gui script's `self`.
Core (`screens/game/core/`):
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
the build orchestration (banner, header, moon list, main panel, aside; widths from
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
The aside shows only when the view selects something for it (`M.aside_shown`, the
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
the web's boot and suspended pages) shows until the first state.
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
words ("Something went wrong", "Can't reach the server") with the error sound; a
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
suspended screen.
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
(eased number); keyed so values carry over a rebuild.
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
`on_submit` and the refinery's quantity; reset every build), back / forward
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
scroll area under the mouse (else the focused one's, else the biggest;
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
Ctrl +/-/0 the UI scale.
`session.lua` keeps the view history (`g.history`, like the web's pushState).
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
clicks while it pops, and the claim predictor counts a drill whose end has passed on
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
Regions (one module each, so agents can work on them separately): `banner.lua`,
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
in the server's words, counted from its `end`),
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
fullscreen),
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
`advisors.lua`, the new-moon teaser in `reveal.lua`.
**Module contract** (regions and sections):
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
center their own capped content. Regions get their box; the header and banner return
their height. `H` = `g.h`.
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
for content under the viewport.
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before its region is
built again and when the screen goes away (cancel timers, drop references to nodes).
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
again by a full rebuild), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
builds outside its block.
- Per-build registries (test ids and focus, tooltips, tickers, `g.inputs`,
`g.scroll_areas`, `g.pointers`, fx anchors, autoplay) are reset by every build; a kept
block makes its registrations again from a journal: a new registry must note them
(`blocks.note(fn, ...)`, or `blocks.push`/`blocks.put` for a list or map on g). Code
that builds outside a build (a list making rows as it scrolls, a field's on_change)
runs through `blocks.bound(fn)` so what it makes belongs to its block. A kept block
moved under another parent finds its buttons' clip stencil again.
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
when the well or drill changes, both on the first state and after the reveal closes
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
staggered. Not on other rebuilds. While the reveal card flies into the moon list (Start
mining) a new state waits for it to land.
## Components (`components/`)
Build new UI from these instead of coordinate math. Each file's header documents its API.
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
or link button has hover and a test id. After a rebuild the button under the mouse
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
button with a test id: `button.carry`, the one with the same id under the pointer is
pressed in the new build), so the release still clicks it.
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
instance: survives rebuilds, runs actions in the game script, drops page hovers
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
only way to show a dialog (the Monarch popup screen is gone). Input invariant
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
a log with `ERROR:MM`).
- `screens/game/grant.lua`: the quick grant dialog for admins (`state.admin`: header "+"
button, G key): item and amount pickers, then `admin-grant`.
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column laid out with flex (below; measured text, `title`,
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
advisor). Store, Equipment and Advisors.
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
Header and moon list.
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
history).
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
`bar.ready_label` (gold "Ready" with a pulsing glow).
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
and time on g so it picks up on the rebuilt row).
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
window, hidden by a click or a dialog; on its own layer. Give information-only text a
tooltip like the web does.
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
card once complete). A long list (components/list with `focus_id`) is walked item by
item, scrolling, since its rows out of view don't exist. So anything interactive must register
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
widths yourself); refresh innermost layouts first, synchronously, after building
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
so make a clickable container before the buttons on it.
## Layout (flex) (`modules/flex.lua`, `modules/flex_gui.lua`)
Prefer it to coordinate math (`26 + ui.text_width(name) + 7`) for new or reworked UI.
Every section, the aside (well panel, drill panel, advisor panel), header, moon list,
banner, toasts, dialogs (and the buy modal), the new-moon teaser and the reveal card are
laid out with it; component internals (chips, stat rows, charts, moon rows) still place
their own parts.
- `flex.lua` is a pure solver (no Defold calls; unit tests `scripts/flex_test.lua`, in
`make check`): a tree of `{ style, children, measure = fn(avail_w) -> w, h, node, id }`;
CSS names in snake_case (`direction`, `gap`/`row_gap`/`column_gap`, `padding`,
`justify`, `align`/`align_self`, `grow`/`shrink`/`basis`, `width`/`height` px, "N%",
"auto", `min_*`/`max_*`, `wrap`, `display = "none"`). `flex.solve(tree, x, y, w, h)`
sets absolute `x, y, w, h` on every box: top-left origin, y down (like `ui.*` calls);
`flex.gui_pos(box, pivot)` gives the GUI position. Deviations from CSS (border-box, min
sizes 0, auto = fit-content, no margins) are at the top of the file.
- `flex.apply(tree, nodes, { glide = secs })` (flex_gui) sets position (by the node's
pivot) and size (unless `box.size == false`; divided by scale) on each box's `node`,
relative to the nearest ancestor box with a node, only when it changed; it never
creates, deletes or reparents. `glide` tweens moved nodes (FLIP), off under reduced
motion; default snap. `flex_gui.text_measure(node[, lh])` measures a text node.
- Rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a label);
animations own what's inside (like CSS transform vs layout). Never give a box a node
that something animates by position or size. Kept blocks (modules/blocks) aren't
re-laid out: solve only what you build.
- Widgets whose look is built at a size (ui.button, bars, dashed edges) are built after
the solve, at their box's rect (`{ style = { grow = 1 } }` without a node, then read
`box.x/w`); text is cut (`ui.fit_text(node, box.w)`) after it.
- New or reworked UI is flex-first; don't add hand-placed coordinates. When you touch a
hand-placed section, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Flex-first (the Refinery): describe the whole section as one tree with
`flex_gui.label`/`para` leaves and boxes with `make = fn(parent, x, y, w, h)` for what is
built at a size, `flex.grid` for card grids, then `flex_gui.build(flex.solve(tree, x, y),
parent)` makes the nodes in tree order (= focus order).
- flex_gui tree pieces: `box/row/col/space/fill/made`; `label` (rich text when it has
symbols), `text_para` (H.para's leading), `node_label` (an existing text node);
`button` (a ui.button box, width from a probe, then `bind(b, nodes, w)`); `inline(parent,
h, fn)` for one-line components that return their width (chips, ui.credits, stat
rows: fn(probe, 0, 0, true) must register nothing); `measured(parent, fn)` for one that
returns its height; `flow(parent, x, y, w, parts)` mixes trees with components that lay
themselves out in place (card.grid's kept cards). `H.claim_wait_w` gives a claim
countdown's width. `make` rects are whole pixels; leaves aren't rounded.
- Beyond flexbox (flex.lua header): `columns = {...}` makes a box a CSS grid (tracks px,
"N%", "auto", "Nfr"; `span`, `rows`, `justify_items`/`justify_self`; row height from
the tallest cell): tables. `position = "absolute"` with `left/right/top/bottom` and
`translate = {fx, fy}` places a child over its parent's box; `flex.anchor(rect, box)`
places a box that way against any solved rect (badges, floating numbers, bursts).
`aspect_ratio` (image boxes). `overflow = "scroll"` (children don't shrink) and
`flex.content_size(box)` for a scroll area's content size.
- Patterns: a label is a leaf of its line-box height (pivot W lands on its middle); a
pinned footer is a `grow = 1` spacer before it; equal-height card rows are `wrap = true`
with the default `align = "stretch"`; grid columns: give items the column width.
## Animation (`modules/anim.lua`)
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
`scale_about` (scale a pivot-NW node about its middle, like CSS),
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
content fades in from under a veil and rises, stagger), `group` (invisible container),
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
(scan line), `count`.
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
translucent fills), so content fades in from under a veil (a box in the background
color, `theme.color.veil` for panels) instead of fading a subtree.
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
saved in the settings file unless save is false): every helper skips movement and keeps
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
ticks); the reveal skips the charge and shows the card complete; the ticker stands
still. New animations must honor it.
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
quick repeats of the same gain merge into one number; others stack past it; wallet
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
error shakes the button it came from, found after the rebuild by its test id
(`fx:button`, registered by `H.action`)).
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
## Text and markup
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `&lt;` `&amp;`). `ui.style_text` (so
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
`resource.get_text_metrics`) understands markup.
## Layout
- `game.project`: settings; `[mm]` holds the client's own config (see below).
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
components.
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
On desktop it also makes the window's mouse buttons and keys sticky (GLFW), so a click
or key press shorter than a frame isn't lost (the engine polls their states once a frame).
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
browser's Fullscreen API is used instead (`modules/screen.lua`).
- `mmterrain/`: native extension, the moon generator in C (`src/terrain.c`, a port of
terrain.js's generation, same doubles in the same order; there is no Lua generator).
User rule: C only (`.c`, Lua C API); C++ only where the dmSDK forces it,
as a C-style shim (free functions and plain structs only: no classes, methods,
templates, STL, references, own namespaces, exceptions or RTTI; `src/buffer_shim.cpp`:
the buffer API). The extender compiles `.c`
files as C++ ("treating 'c' input as 'c++'"), so the C must also be valid C++ and
what links across files is `extern "C"`; the registration (`DM_DECLARE_EXTENSION` is
C++) is written out in `mmterrain.c`. Lua: `mmterrain.new(terrain.native_params(p))`,
`start` (a thread of its own where there are threads: desktop), `step(gen, secs)` (the
web: no threads), `done`, `vertices`, `fill(gen, buf[, from, to])` (the triangle strip
straight into the mesh buffer's streams), `result` (the flat arrays), `free` (header of
`mmterrain.c`). `moon.script` makes every moon mesh with it.
- `web/index.html`: the web build's page (`[html5] htmlfile`; see Web build).
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
`chart.lua`
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
unseat, dismiss; an unhired one's details on a card click),
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
whenever the shown state changes: at the click for a predicted action, in the request
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
moon sale register with `fx:box(node)` while built; give a new card-like container one.
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
moon). View links carry the current moon like the web's (`View.ToSection`): without
one the server picks the first moon.
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
file),
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
(hot reload).
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
clones them (`ui.clone`).
- `widgets/moon_row.lua`: the moon list row (Druid widget).
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
fade, ring, spark, speaker, sun and moon icons; tinted per node.
- The moon viewport: `modules/moon/terrain.lua` ports the settings side of the server's
`terrain.js` (defaults, resolve, traits, colors, the cube-sphere and its strip), and
`mmterrain/` the generation, in C (`scripts/moon-check.mjs` compares them with
terrain.js, many ids and settings; in `make check`), `modules/moon/moon.lua` is the API
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`,
`prefetch`: reveal.lua starts a bought moon's mesh as the buy-moon answer arrives),
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
HDR target with bloom; the viewport node shows it through the `moon` gui material.
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
`zzfx-render.mjs`, `serve-web.py` and `web-shots.mjs` (Web build).
## Web build
`make web` bundles for the browser (`wasm-web`, release; `WEB_VARIANT=debug` keeps logs
and the test hook) into `dist/web/MoonMiners/` (release ~4.3 MB, ~2.4 MB gzipped: engine wasm 2.9 MB,
archive 1.1 MB, page and loader).
`make serve-web` (`scripts/serve-web.py`, `UPSTREAM=` default http://localhost:3000,
`WEB_PORT=` 8080) serves it and proxies `/api/*` and `/static/*` (everything the client
fetches) to the server, so the game talks to its own origin; the server stays unchanged.
- The page (`web/index.html`): the canvas fills the window in device pixels and follows
its size (the layout is native-pixel like desktop); dark loading screen; "WebGL 2
needed" without WebGL 2 (the moon's shaders don't build for WebGL 1). `[html5]` in
game.project: `stretch`, no Defold buttons or banner, heap 256 MB.
- On html5 (`if html5 then`): `api.base` is the page's origin (`?api=<url>` picks another
server, which must allow CORS); the player id and settings are saved with sys.save
(IndexedDB), X-MM-User sent as on desktop (the cookie works too, same origin).
Fullscreen (F11, display dialog) through the browser's Fullscreen API, not saved or
restored (the browser allows it only after a click or key). Moon generation is the
mmterrain extension in wasm (no threads there): stepped with a share of each frame
(30% of the frame time, 2–12 ms: ~2 ms at 144 Hz), the buffer filled over the next
frames, grid capped at 128 (`WEB_RESOLUTION` in moon.script). Moon targets fall back to 8 bits without EXT_color_buffer_float.
Variable frame rate there (`sys.set_update_frequency(0)` in main.script): the browser's
requestAnimationFrame paces at the display's rate; `display.update_frequency = 60`
(desktop's timer pacing) made the engine skip callbacks: 60 fps in a 2/3 cadence on
120/144 Hz displays, dropped frames on jittery 60 Hz ones. Use dt or the clock for motion.
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
depth buffer: the render script binds every bloom sampler and makes targets again at a
new size. Check the console after render changes.
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
by real mouse clicks (element rects from the test hook: the page pushes commands onto
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
claim; fails on console errors.
## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
bob's output; `build/x86_64-linux/dmengine` is the engine with our extensions).
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
- `make web`, `make serve-web`: the browser build (Web build).
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
- `make check`: checks (below).
- Before/after screenshots of every section (works on any build, also one from before a
change): `scripts/fixture.py <api> > fx.json`, then
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
after each other (the fixture's drills and market move on).
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
`mm.fake_reject`, shots via `SHOTS_DIR`).
- Never kill processes by name (scripts stop the game by its pid).
## Test driver (`modules/test.lua`, `scripts/drive.py`)
The engine's debug HTTP service (debug builds; port from the log line `Engine service
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
runs in the same Lua state as the gui script but outside it, so it only pushes commands
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
`$MM_DRIVE_DATA`), never the player's own settings.
- Elements: every interactive component registers a stable id while it's built:
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
over three frames through Druid: real input, hover included), `hover(id)`,
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
`restart()` (quit and start the game again, same options and saved files),
`cmd("frames", "reset")` (frame times since the last reset: fps, dt, the game screen's
update and its rebuilds in ms; the web harness reads it the same way). F3 in a debug
build shows the engine's profiler.
Those go through the test hook, which calls the screen's input function directly, so
they work even when the engine sends the game no input. Real input through the X server
(xdotool on the driver's Xvfb, its own display from `Xvfb -displayfd`: `xvfb-run -a` raced
when two runs started together; key and button held 80 ms like a hand; the engine polls
once a frame, and mmwindow's sticky keys and buttons keep a press shorter than a frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
page at once; the invariant's repair; args `reduced`, and run it with `--config
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
moon; the Moons tab returns to the last moon after other sections, back / forward, a
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
folder (`<out>/data`), never the real settings file. Run with `--config
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
grants through `/utils/players`); prints its ids as JSON.
## Fast iteration (hot reload)
`make dev` builds once and starts the game from the build folder (engine
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
registered first (a `run_script` listing them), then `.luac` files, then the game's
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
to those, to collections, game objects, `main.script`, materials, shaders or the render
script need a restart: `make dev-stop dev` (reload says so).
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
(core/main.lua) or lazily.
- `make reload` fails loudly on a compile error (and keeps the old code running).
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
## Checks (`make check`, scripts/check.py)
Fails on: the flex solver's unit tests (`luajit scripts/flex_test.lua`); Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
a fixture player in every section: symbols in sym, the rest in the text fonts); the moon
generator (`node scripts/moon-check.mjs [resolution] [seeds...]`:
`mmterrain/src/terrain.c`, built for the host with `cc` and loaded into luajit through the
FFI, and terrain.lua's settings side, against terrain.js; needs node, luajit, cc); and engine warnings in a `sections` scenario log (font batches, characters, text entries,
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
against `MM_API` (required, no default: the checks make a fixture player there, so use a test server, never the dev one) when it answers; otherwise the newest
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per
server.
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
- `view`: the view to open, `key=value&...` of `state.view` keys
(`section=equipment&drill=<id>`).
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
- `theme`: `light` or `dark` (default: the saved setting, else dark).
- `test`: `0` turns the test hook off in a debug build (it's never in release).
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
scale; not saved). Default: the saved setting (the display dialog), else 1.
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
--config mm.display_scale=2` looks like 1280x800).
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
the others on the command line.
## Conventions
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
not screens.
- Every gui_script uses one Druid instance (`druid.new(self)` and the
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
interaction (buttons, hover, scroll, input, layout, data list) through the components.
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
colors come from `theme.lua`.
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
1280x800 every cap is above what fits; keep it that way (compare shots).
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
is a token: no hex or vector colors in code (template node colors via `theme.template`,
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
tells you which).
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
`max_characters` raised in `game.project`. Text past the font batch limit silently
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
dialog) and images split font batches.
- Outline textures use slice9 2 (not 1).
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
- The server is the only source of truth: every response's `state` replaces ours and
`state.view` becomes our view; actions send the current view plus their params and
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
when `nextEnd` is due.
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
refine, collect. Others (and predictors that return false) show a busy button.
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
counts through fx.
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
it. Run `make check` before calling it done.
- Keep changes small; don't touch the server (`../server`) from here.
+70
View File
@@ -0,0 +1,70 @@
# Flex layout: notes from the first port
Solver `modules/flex.lua` (430 lines incl. docs), apply helper `modules/flex_gui.lua` (96),
tests `scripts/flex_test.lua` (53 checks, in `make check`).
## Lines, before -> after
| File | Lines | Hand-placed offsets (`x + `, `y + `, `+ 8`, `set_position`) |
|---|---|---|
| `screens/game/refinery.lua` | 524 -> 595 | 35 -> 10 |
| `components/card.lua` | 298 -> 325 | Druid Layout gone; one tree replaces three branches of padding math |
| `screens/game/header.lua` (wallet only) | 243 -> 256 | running `cx` cursor gone |
The code is longer, not shorter: every text is now a box with its line-box height, and
the tree literals add structure lines. What it gains: no running cursors, no "measure, then
come back and move it" (`gui.set_position(mx, vec(w - mw, -fy))`, the slot label's probe
node, the Collect all offset `67.9 - 44`). Spacing is in one place per container, and the
heading and stock now grow with their content (before: fixed 67.9 px, chips wrapped by
hand).
Screenshots (`shots/c-flex/before|after`, 1280x800 dark and light, 1024x640): Refinery
(ready, idle, running slots, buy slot, recipe footers), Store, Equipment, Advisors and the
header match pixel for pixel. The only differences are animations caught at another
moment (Claim all, Ready glow, pulsing buttons) and fixture data. Scenarios smoke, cards,
keys and finalcheck pass (keys timed out once on the Market tab, then passed; the baseline
passes too).
## Easier
- Rows with something pinned right (time left, "have" counts, Max button), centered
labels, and footers pinned to the bottom: `justify`, a `grow = 1` spacer.
- Wrapping chips and equal-height slot rows: `wrap = true` plus the default stretch.
- Text cut to the space left: a shrinking leaf, then `ui.fit_text(node, box.w)`.
## Harder
- Matching the old pixel offsets exactly. The old code centers text at hand-picked middles
(12 for one label, 11 for the one beside it), so some rows need leaves of different
heights inside a fixed-height row. A new screen built with flex from the start won't
have this.
- Widgets built at a size (ui.button, the cell bar, dashed edges, the quantity field):
solve with an empty box, then build at its rect. Fine, but it splits a component in two.
- Creation order is focus and Druid input order, so nodes must still be built in the old
order; only their placement moves to the end.
- bob follows `require` strings, and flex.apply -> flex_gui -> flex is a cycle; it uses a
non-literal name.
## What the solver does that CSS doesn't (or does differently)
- border-box sizes, `min-width: 0` by default (CSS's auto min size would stop labels from
being cut), auto size = fit-content even at the root, align-content always start, no
margins (spacer boxes), no baseline alignment.
- No pixel snapping. One label (buy slot) is solved, then placed on a whole pixel by hand,
as the old code did; a `round` option could do it.
- CSS grid's `minmax(200px, 1fr)` isn't flex: the columns are computed (as before) and
given to wrapped items as widths. A small `grid` helper would cover card.grid and the
slots.
## Also fixed
- `card.grid` rebuilt a kept card whose row height changed without the card options
(a recipe card would have gotten an image column).
## Recommendation for the rest
Port section by section when a section is touched anyway, not as one sweep: equipment's
tool cards and the drill panel, store and advisors (already on Card), market's ticket and
stats, the header's tab row (it has a "second row when it doesn't fit" rule, which is a
wrap). Keep kept blocks as they are: solve what a build makes. Leave lists (Data List)
and the scroll areas alone. Add a `grid` helper and rounding before the next big port.
+90
View File
@@ -0,0 +1,90 @@
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
# python3 and luajit for shot, play, drive, dev and check.
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/bob.jar
PLATFORM := x86_64-linux
VARIANT ?= debug
BUNDLE := dist/$(PLATFORM)/MoonMiners
BIN := $(BUNDLE)/MoonMiners.x86_64
SHOT ?= dist/shot.png
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?=
.PHONY: build run shot play clean resolve dev reload dev-stop drive check web serve-web
$(BOB):
mkdir -p tools
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
touch $@
build: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
--archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
WEB_VARIANT ?= release
UPSTREAM ?= http://localhost:3000
WEB_PORT ?= 8080
web: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
--archive --bundle-output dist/web build bundle
serve-web:
scripts/serve-web.py --port $(WEB_PORT) --upstream $(UPSTREAM)
# Hot reload loop: `make dev` starts the game from build/default (once), `make reload`
# rebuilds (no bundle) and posts what changed to the running engine. HEADLESS=1 make dev
# runs it under Xvfb. See AGENTS.md "Fast iteration".
dev: build
scripts/dev.py start $(ARGS)
reload: $(BOB)
@mkdir -p dist/dev
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
scripts/dev.py reload
dev-stop:
scripts/dev.py stop
# A scenario through the test hook (scripts/drive.py, AGENTS.md "Test driver") on a fresh
# build/default, e.g. make drive SCENARIO=smoke MM_API=http://localhost:3118 DRIVE=--fixture
SCENARIO ?= smoke
DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO)
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
# scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py
run: build
$(BIN) $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
play: build
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
clean:
rm -rf build dist .internal/cache
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Bold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Inter-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: "\342\211\210 !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\202\245\342\230\205\342\230\206\342\227\206\342\226\262\342\226\274\342\200\272\342\227\217\342\227\257\316\221\316\222\316\223\316\224\316\225\316\226\316\227\316\230\316\231\316\232\316\233\316\234\316\235\316\236\316\237\316\240\316\241\316\243\316\244\316\245\316\246\316\247\316\250\316\251\316\261\316\262\316\263\316\264\316\265\316\266\316\267\316\270\316\271\316\272\316\273\316\274\316\275\316\276\316\277\317\200\317\201\317\202\317\203\317\204\317\205\317\206\317\207\317\210\317\211"
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

+33
View File
@@ -0,0 +1,33 @@
Moon Miners sound effects (Defold client)
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
(credit not required, given anyway).
nav.wav Interface Sounds switch_007
toggle.wav Interface Sounds switch_005
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
buy.ogg Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
+ synth beep
upgrade.ogg Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
+ synth sweep
moon.ogg Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
deploy.ogg Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
refine.ogg Sci-Fi Sounds forceField_001 + computerNoise_000
collect.ogg Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
modal.wav, confirm.ogg, error.wav and ready.wav are synthesized with SoX (no samples).
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
https://kenney.nl/assets/impact-sounds
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js).
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file not shown.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
Binary file not shown.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
images {
image: "/assets/images/white.png"
}
images {
image: "/assets/images/frame.png"
}
images {
image: "/assets/images/outline.png"
}
images {
image: "/assets/images/sphere.png"
}
images {
image: "/assets/images/glow.png"
}
images {
image: "/assets/images/fade.png"
}
images {
image: "/assets/images/ring.png"
}
images {
image: "/assets/images/speaker.png"
}
images {
image: "/assets/images/speaker_off.png"
}
images {
image: "/assets/images/spark.png"
}
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/moon.png"
}
images {
image: "/assets/images/pentagon.png"
}
images {
image: "/assets/images/pentagon_rim.png"
}
margin: 2
extrude_borders: 2
inner_padding: 0
+85
View File
@@ -0,0 +1,85 @@
-- Progress and timer bars (the web's .drill-bar / .bar): a track and a fill. A timer bar
-- fills from start to end (ms, server clock) as display only, easing corrections (core
-- ticks, keyed); once full it takes the ready look: a soft glow in its color that breathes
-- (app.css .drill-bar.is-ready). A static bar shows a fraction.
--
-- bar.timer(g, parent, x, y, w, h, { start, ends, ready, color, none, key })
-- bar.value(parent, x, y, w, h, fraction, { color, track })
-- bar.ready_label(parent, text, x, y, pivot) -- "READY" in gold, its glow pulsing
-- -- (app.css .ready, 1.8 s)
local ui = require("modules.ui")
local anim = require("modules.anim")
local ticks = require("screens.game.core.ticks")
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The ready look on a fill: full, in its own color (cyan, amber when short), with a glow
-- under it that breathes (app.css .drill-bar.is-ready). A drill that gets nothing has no
-- glow (.is-none.is-ready).
local function ready_look(parent, fill, x, y, w, h, color, none)
if none then
return
end
local glow = ui.rect(parent, x - 4, y - 4, w + 8, h + 8, color, 0.25)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, fill)
anim.breathe(glow, "color.w", 0.35, 0.12, 1.8)
end
-- A timer bar. opts: start, ends (ms), ready (already done: full, glowing), color (default
-- "cyan"), track (default "line"), none (the drill gets nothing: an amber dashed track, a
-- muted fill, no glow; app.css .drill-bar.is-none), key (eases corrections; core ticks).
-- Returns the fill node.
function M.timer(g, parent, x, y, w, h, opts)
local color = opts.color or "cyan"
if opts.none then
color = "muted"
for dx = 0, w - 1, 4 do
ui.rect(parent, x + dx, y, math.min(2, w - dx), h, "ready", 0.45)
end
else
ui.rect(parent, x, y, w, h, opts.track or color, opts.track and 1 or 0.16)
end
local fill = ui.rect(parent, x, y, w, h, color, opts.none and 0.7 or 1)
if opts.ready then
ticks.tick_bar(g, fill, 0, 0, w, opts.key)
ready_look(parent, fill, x, y, w, h, color, opts.none)
else
ticks.tick_bar(g, fill, opts.start, opts.ends, w, opts.key, function()
-- Full at the local zero (display only; the server says when it's claimable).
ready_look(parent, fill, x, y, w, h, color, opts.none)
end)
end
return fill
end
-- A static bar showing `fraction` (0..1). opts: color, track, min (smallest fill width).
function M.value(parent, x, y, w, h, fraction, opts)
opts = opts or {}
ui.rect(parent, x, y, w, h, opts.track or "line")
local fw = math.floor(w * math.max(0, math.min(1, fraction)))
if fw > 0 then
return ui.rect(parent, x, y, math.max(opts.min or 0, fw), h, opts.color or "cyan")
end
end
-- "Ready" in gold whose glow pulses (a soft glow sprite behind it, breathing like the
-- web's text-shadow). x, y, pivot like ui.text.
function M.ready_label(parent, text, x, y, pivot, px)
local style = { font = "sans_semi", px = px or 13, color = "ready", tracking = 0.1, upper = true }
local t = ui.text(parent, text or "Ready", style, x, y, pivot)
local w = ui.text_width(t)
local left = (pivot == gui.PIVOT_E and x - w) or (pivot == gui.PIVOT_CENTER and x - w / 2) or x
local glow = ui.rect(parent, left - 12, y - 11, w + 24, 22, "ready", 0)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, t)
anim.breathe(glow, "color.w", 0, 0.3, 1.8)
return t
end
return M
+213
View File
@@ -0,0 +1,213 @@
-- Button (the web's .btn): a filled box with an edge and an uppercase label, optional
-- price ("· ₥1,234"). Variants: "secondary" (default, .btn), "primary" (.btn-primary),
-- "redrill" (.btn-redrill, violet), "danger" (.btn-danger), "ghost" (.btn-dashed: faint,
-- for "+ New moon"; opts.on lights it). States, eased like a CSS transition (120 ms):
-- hover (mouse over), press (held down), disabled (dimmed, no clicks), busy (the web's
-- aria-busy: dimmed, label hidden, a spinner; set by core/hooks while an unpredicted
-- action waits for the server).
--
-- local b = button.new(g, parent, "Sell", x, y, { variant = "danger", small = true,
-- on_click = fn, id = "sell-moon" }) -- draw + behavior; b.root, b.w, b.h
-- button.draw(parent, label, x, y, opts) -- just the nodes (root, nodes, w)
-- button.bind(g, root, nodes, opts) -- behavior on drawn nodes (what
-- -- H.action and H.link use)
--
-- opts (draw): variant, small, w, h, price, accent (the price's color), disabled, on;
-- fill / edge / text ({ color, alpha } or a color name) override the variant's colors
-- (hover then brightens the given fill).
-- opts (bind): on_click, id (test id, modules/test), label (for the test hook; default
-- the label text), zone (stencil node clipping it, when not under a scroll's view), druid
-- (another Druid instance than g.druid: the dialog's).
-- After a rebuild the button under the mouse shows its hover at once (core/main replays
-- the last pointer position; M.instant makes that skip the easing). A press that a rebuild
-- interrupts carries over, for every Druid button with a test id: the one with the same
-- id under the pointer is pressed in the new build, so the release still clicks it
-- (M.carry). opts.hit: a node to take the clicks instead of root (a Claim button popping
-- in is scaled down: core/hooks).
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local druid_button = require("druid.base.button")
local S = theme.size
local M = {}
local FADE = 0.12
-- What ui.button drew, by root node (variant, disabled, parts).
local looks = ui.button_looks
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local col = ui.color_spec
-- Ease (or set, when M.instant or reduced motion) a node's color.
local function tint(node, c, instant)
gui.cancel_animations(node, "color")
if instant or M.instant or anim.reduced then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, FADE)
end
end
-- Draw a button (ui.button). Returns root, nodes, width.
M.draw = ui.button
-- The state look: "normal", "hover", "press".
local function show(root, state, instant)
local look = looks[root]
if not look or look.disabled or look.busy then
return
end
local function c_of(x)
return type(x) == "userdata" and x or col(x)
end
local c = state == "press" and c_of(look.v.press) or (state == "hover" and c_of(look.v.hover) or look.fill)
tint(root, c, instant)
end
-- Behavior on a drawn button (or any node: then only clicks, no looks). Returns the Druid
-- button.
function M.bind(g, root, nodes, opts)
opts = opts or {}
local look = looks[root]
local b = (opts.druid or g.druid):new_button(opts.hit or root, function()
if opts.on_click and not (look and look.busy) then
opts.on_click()
end
end):set_animations_disabled()
if opts.hit then
test.name(opts.hit, opts.id)
end
if opts.zone then
b:set_click_zone(opts.zone)
end
if look and not look.disabled then
b.hover.on_mouse_hover:subscribe(function(_, on)
look.hovered = on
show(root, on and "hover" or "normal")
end)
b.hover.on_hover:subscribe(function(_, down)
show(root, down and "press" or (look.hovered and "hover" or "normal"), down)
end)
end
local label = opts.label
if not label and look then
label = gui.get_text(look.text)
end
test.register(opts.id, root, opts.kind or (look and "button" or "link"), label, opts.zone)
return b
end
---------------------------------------------------------------- a press across a rebuild
-- Every Druid button (these, tabs, rows, the moon list's widgets, raw ones), wherever it
-- is made: the ones made since the build began, and the one the mouse went down on, by
-- its test id (modules/test id_of). A rebuild drops the pressed Druid button, so the
-- release would click nothing (the web's element survives an htmx morph); M.carry
-- presses its successor instead.
local carry = { made = {}, pressed = nil }
druid_button.mm_hook = function(b)
carry.made[#carry.made + 1] = b
b.on_pressed:subscribe(function()
carry.pressed = { node = b.node, t = socket.gettime() }
end)
end
if not druid_button.mm_init then -- (once: the class outlives a hot reload of this file)
druid_button.mm_init = druid_button.init
druid_button.init = function(self, ...)
druid_button.mm_init(self, ...)
if druid_button.mm_hook then
druid_button.mm_hook(self)
end
end
end
-- A build begins (core/main): buttons made from now on are its.
function M.begin_build()
if carry.pressed and not carry.pressed.id then
carry.pressed.id = test.id_of(carry.pressed.node) -- (before the old nodes go)
end
carry.made = {}
end
-- The mouse went down (before Druid sees it) or up: no press to carry.
function M.release()
carry.pressed = nil
end
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
-- before: press the new button with its id under the pointer, so the release clicks it.
function M.carry(x, y)
local p = carry.pressed
if not p or not p.id then
return
end
-- (not only one under the pointer now: a full build plays the page's entrance, so the
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
local found
for _, b in ipairs(carry.made) do
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
b.can_action = true
b.last_pressed_time = p.t
b.hover:set_hover(true)
found = b
end
end
if found then
carry.pressed = { node = found.node, id = p.id, t = p.t }
return true
end
end
-- Draw and bind. Returns { root, nodes, w, h, button }.
function M.new(g, parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = M.draw(parent, label, x, y, opts)
local b = { root = root, nodes = nodes, w = w, h = gui.get_size(root).y }
if not opts.disabled then
b.button = M.bind(g, root, nodes, opts)
end
return b
end
-- Busy (the web's aria-busy + spinner): dimmed, label hidden, a rotating square.
function M.set_busy(root)
local look = looks[root]
local size = gui.get_size(root)
if look then
look.busy = true
for _, p in ipairs(look.parts) do
local c = gui.get_color(p[1])
gui.set_color(p[1], vmath.vector4(c.x, c.y, c.z, 0.2))
end
local f = gui.get_color(root)
gui.set_color(root, vmath.vector4(f.x, f.y, f.z, f.w * 0.6))
local e = gui.get_color(look.edge)
gui.set_color(look.edge, vmath.vector4(e.x, e.y, e.z, e.w * 0.6))
end
local spin = look and look.variant == "primary" and "on_cyan" or "cyan_hi"
local sp = ui.outline(root, size.x / 2 - 7, size.y / 2 - 7, 14, 14, spin)
gui.set_pivot(sp, gui.PIVOT_CENTER)
gui.set_position(sp, vec(size.x / 2, -size.y / 2))
gui.animate(sp, "euler.z", -360, gui.EASING_LINEAR, 0.9, 0, nil, gui.PLAYBACK_LOOP_FORWARD)
end
-- Whether draw() made this node (a button with looks).
function M.is_button(root)
return looks[root] ~= nil
end
-- Error feedback on a button (the web's fx-shake: 0.4 s).
function M.shake(root)
anim.shake(root, 6, 0.4)
end
return M
+325
View File
@@ -0,0 +1,325 @@
-- Card (the web's .card / .store-card): an image column on the left and a text column
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
-- paragraph's height comes from its wrapped lines), with an optional footer block pinned to the
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
--
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
-- c:image(item.image)
-- c:title(item.name)
-- c:para(item.description)
-- c:text("+3 per cycle", "small")
-- c:footer(52, function(f, w) ... build into f (0..w wide, 52 tall) ... end)
-- c.click = fn -- (optional) a click anywhere on the card; c.id its test id
-- c.selected = true -- (optional) cyan edge and inner line
-- c.edge_color = { "ready", 0.45 } -- (optional) another edge (a hired advisor), and
-- c.edge_hover = { "ready", 0.7 } -- its hover
-- end)
--
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
-- what's inside a slot is placed by hand, relative to it.
local theme = require("modules.theme")
local ui = require("modules.ui")
local images = require("modules.images")
local anim = require("modules.anim")
local button = require("components.button")
local panel = require("components.panel")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
local flex_gui = require("modules.flex_gui")
local S = theme.size
local M = {}
M.__index = M
local PAD = 14
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- Columns for a width, like the web's grid (minmax(250px, 1fr)): count and card width.
-- Cards at least card_min_w wide keep the image column on the left; narrower ones put the
-- image on top (the web's .card: a 200 px media strip, then the body).
function M.columns(inner)
local cols = math.max(1, math.floor((inner + S.gap) / (S.card_col_min + S.gap)))
return cols, math.floor((inner - (cols - 1) * S.gap) / cols)
end
local STACK_MEDIA = 200 -- the image strip's height on a stacked card (app.css .card-media)
-- Image column width for a card width.
function M.media_size(cw)
return math.max(120, math.min(156, math.floor(cw * 0.3)))
end
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none: just
-- the text column), pad (the text column's padding on every side; default 14 across,
-- 12 above, 14 below).
function M.new(g, parent, x, y, w, opts)
opts = opts or {}
local stacked = opts.media == nil and w < S.card_min_w
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
local c = setmetatable({ g = g, x = x, y = y, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad, items = {} }, M)
c.root = ui.rect(parent, x, y, w, 1, "panel")
if m > 0 then
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
end
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
-- The text column's padding (top, right, bottom, left) and width.
if m == 0 then
local side = opts.pad or PAD
c.padding = { opts.pad or 12, side, opts.pad or 14, side }
c.bw = w - side * 2
elseif stacked then
c.padding = { 14, PAD, 14, PAD }
c.bw = w - PAD * 2
else
c.padding = { 12, PAD, 14, PAD }
c.bw = w - m - PAD * 2
end
c.body = anim.group(c.root, 0, 0, c.bw, 1)
return c
end
-- The image (a catalog URL) in the image column, square at the top (stacked: square in
-- the middle of the strip).
function M:image(url)
local node = ui.rect(self.media, self.stacked and math.floor((self.w - self.m) / 2) or 0, 0, self.m, self.m, "none")
images.set(node, url)
self.image_node = node
end
-- A box in the text column (flex: modules/flex.lua).
local function add(self, box, gap)
if gap and gap > 0 then
self:gap(gap)
end
self.items[#self.items + 1] = box
return box.node
end
-- Vertical space.
function M:gap(px)
self.items[#self.items + 1] = { style = { height = px } }
end
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
-- line boxes), cut with ... to the column. style: theme.text name or table.
function M:text(str, style, h)
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
local t = ui.label(slot, str, style or "body", 0, 8)
if not ui.needs_rich(str) then
ui.fit_text(t, self.bw)
end
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
return t
end
-- The card's title (uppercase, semibold; a 22 px slot).
function M:title(str)
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
end
-- A paragraph that wraps at the column width (its height measured).
function M:para(str, style, gap)
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local s = gui.get_scale(n).x
gui.set_size(n, vec(self.bw / s, 10))
return add(self, { node = n, size = false, style = { width = self.bw }, measure = flex_gui.text_measure(n) }, gap)
end
-- Any node (its size, or a text's metrics, is what counts; pivot NW or W).
function M:node(n, gap)
gui.set_parent(n, self.body, false)
local measure
if gui.get_text(n) then
measure = flex_gui.text_measure(n)
else
measure = function()
local sz, sc = gui.get_size(n), gui.get_scale(n)
return sz.x * sc.x, sz.y * sc.y
end
end
return add(self, { node = n, size = false, style = { align_self = "start" }, measure = measure }, gap)
end
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
-- or c:block(fn, gap) where fn returns the height it used.
function M:block(h, fn, gap)
if type(h) == "function" then
h, fn, gap = nil, h, fn
end
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
local used = fn(n, self.bw)
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
end
-- Something clickable inside the card's text (a link): fn runs once the card's own click
-- is bound, so it gets its clicks first.
function M:after_bind(fn)
self.late = self.late or {}
self.late[#self.late + 1] = fn
end
-- The bottom block (buttons): h tall, built by fn(parent, w) once the row's height is
-- known; it sits at the bottom of the card.
function M:footer(h, fn)
self.footer_h, self.footer_fn = h, fn
end
-- The card as a flex tree (modules/flex): the image column (or strip) and the text
-- column, whose items stack from the top and whose footer a growing spacer pushes to the
-- bottom; h: the card's height (nil: its content's, at least the image column's).
function M:tree(h)
local kids = {}
for i, b in ipairs(self.items) do
kids[i] = b
end
kids[#kids + 1] = { style = { grow = 1 } }
self.footer_box = { style = { width = self.bw, height = self.footer_h } }
kids[#kids + 1] = self.footer_box
local body = { node = self.body, style = { direction = "column", align = "start", grow = 1, padding = self.padding }, children = kids }
local root = { node = self.root, style = { width = self.w, height = h }, children = { body } }
if self.stacked then
root.style.direction = "column"
table.insert(root.children, 1, { node = self.media, style = { height = self.m } })
elseif self.m > 0 then
root.style.min_height = self.m
table.insert(root.children, 1, { node = self.media, style = { width = self.m } })
else
root.style.direction = "column"
end
return flex.solve(root, self.x, self.y)
end
-- Lay out the text column; returns the card's natural height.
function M:refresh()
local t = self:tree()
flex.apply(t)
self.natural = t.h
return self.natural
end
-- The card's final height (the tallest in its row): sizes the box and places the footer.
function M:set_height(h)
self.h = h
local t = self:tree(h)
flex.apply(t)
gui.set_size(self.edge, vec(self.w, h))
if self.stacked then
ui.rect(self.root, 0, self.m, self.w, 1, "line")
elseif self.m > 0 then
ui.rect(self.root, self.m, 0, 1, h, "line")
end
if self.selected then
gui.set_color(self.edge, ui.color("cyan"))
ui.outline(self.root, 1, 1, self.w - 2, h - 2, "cyan", 0.6)
elseif self.edge_color then
gui.set_color(self.edge, ui.color_spec(self.edge_color))
end
-- Hover: the edge brightens (app.css .card:hover, 0.15 s). Bound before the footer's
-- buttons: Druid gives the last made input first, so they get their clicks.
local edge = self.edge
local base = gui.get_color(edge)
local b = button.bind(self.g, self.root, nil, { id = self.id, on_click = self.click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(edge, "color")
local lit = self.selected and ui.color("cyan") or (self.edge_color and ui.color_spec(self.edge_hover or self.edge_color)) or ui.color("line_hi")
gui.animate(edge, "color", on and lit or base, gui.EASING_OUTQUAD, 0.15)
end)
for _, fn in ipairs(self.late or {}) do
fn()
end
if self.footer_fn then
local fb = self.footer_box
local f = anim.group(self.root, fb.x - self.x, fb.y - self.y, fb.w, fb.h)
self.footer_fn(f, self.bw)
end
end
-- A grid of cards from y; returns the y under it. make(c, item) fills one card. opts: the
-- cards' (M.new); opts.key(item): keep the card (modules/blocks) while opts.sig(item) (what
-- make shows besides the item; default the item alone) and its place stay the same.
function M.grid(g, parent, x, y, inner, items, make, opts)
local cols, cw = M.columns(inner)
local keyed = opts and opts.key
local i = 1
while i <= #items do
local row = {}
for col = 1, cols do
local item = items[i]
if not item then
break
end
local r = { item = item, x = x + (col - 1) * (cw + S.gap) }
if keyed then
r.key = opts.key(item)
r.sig = blocks.sig(r.x, y, cw, item, opts.sig and opts.sig(item) or false)
r.old = blocks.peek(g, r.key, r.sig) -- (kept, if its row's height stays the same)
end
if r.old then
r.natural = r.old.card.natural
else
if r.key then
r.blk = blocks.open(g, r.key, r.sig, parent)
end
r.c = M.new(g, r.blk and r.blk.root or parent, r.x, y, cw, opts)
make(r.c, item)
r.natural = r.c:refresh()
if r.blk then
r.blk.card = r.c
blocks.close(g, r.blk)
end
end
row[#row + 1] = r
i = i + 1
end
local rh = 0
for _, r in ipairs(row) do
rh = math.max(rh, r.natural)
end
for _, r in ipairs(row) do
if r.old and r.old.card.h == rh then
r.blk = blocks.open(g, r.key, r.sig, parent) -- (kept)
r.c = r.blk.card
else
if r.old then
-- The row's height changed: made again at it.
blocks.drop(g, r.old)
r.blk = blocks.open(g, r.key, r.sig, parent)
r.c = M.new(g, r.blk.root, r.x, y, cw, opts)
make(r.c, r.item)
r.c:refresh()
r.blk.card = r.c
elseif r.blk then
blocks.reopen(g, r.blk) -- (its hover and footer are the card's)
end
r.c:set_height(rh)
if r.blk then
blocks.close(g, r.blk)
end
end
local c = r.c
if g.frames then
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
end
if g.fx then
g.fx:box(c.root) -- (an action's sweep covers the card)
end
end
y = y + rh + S.gap
end
return y
end
return M
+36
View File
@@ -0,0 +1,36 @@
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
-- a short label, optionally filled. Returns the box node and its width.
--
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
--
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
-- pad (6 each side), min_w, right (x is the right edge).
local ui = require("modules.ui")
local M = {}
function M.new(parent, label, x, y, opts)
opts = opts or {}
local color = opts.color or "line_hi"
local style = { font = opts.font or "sans_semi", px = opts.px or 11, color = opts.text or color,
tracking = opts.tracking or (opts.font == "mono" and 0 or 0.12), upper = opts.upper }
local h = opts.h or 18
local pad = opts.pad or 6
local probe = ui.text(parent, label, style, 0, 0)
local w = math.max(opts.min_w or 0, math.floor(ui.text_width(probe) + pad * 2))
gui.delete_node(probe)
local left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
gui.move_below(f, box)
end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w
end
return M
+338
View File
@@ -0,0 +1,338 @@
-- Dialog (the web's .dialog): a modal panel over the game screen, in the same gui (on its
-- own layer, with its own Druid instance), so a confirmed action runs in the game
-- script and a page rebuild doesn't touch it. One at a time: g.dialog (core/main.lua).
--
-- g.dialog:open({ title, message, w, h,
-- ok = { label, price, danger, act = "<action>", params = {...} } or { label, on_ok = fn },
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
-- In build, d.reply(action, params) closes and runs an action (effects start from the
-- clicked button); d:button(parent, label, x, y, opts) makes a button (components/button
-- opts plus on_click / id). The message wraps (ui.wrap, may hold ₥); the panel grows to
-- fit it. Test ids: "dialog:ok", "dialog:cancel".
--
-- Motion (app.css dialog-in/-out, ui.js): the backdrop fades in (0.28 s); the panel
-- scales up from .94, rises 8 px and unclips from its middle (0.28 s overshoot); a cyan
-- scan line sweeps down it; title, message and buttons rise in, 60 ms apart. Closing:
-- fade, scale .97, clip to the middle (0.18 s). Sounds: open (a confirm) or modal (custom
-- content, after the action's own), confirm on OK, close otherwise. While open the page
-- gets no input and its hovers are dropped (druid:reset_hovers); they come back on close.
local druid = require("druid.druid")
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local sfx = require("modules.sfx")
local button = require("components.button")
local test = require("modules.test")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
M.__index = M
local W0, H0 = 460, 230
local SCRIM = theme.color.scrim
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- g: the game screen; layer: a node over the page (top-left = page top-left).
function M.new(g, layer)
return setmetatable({ g = g, layer = layer }, M)
end
function M:is_open()
return self.root ~= nil and not self.closing
end
-- A button inside the dialog (its own Druid instance). opts: components/button draw
-- options plus on_click, id.
function M:button(parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = ui.button(parent, label, x, y, opts)
local d = self
if not opts.disabled then
button.bind(self.g, root, nodes, { id = opts.id, druid = self.druid, on_click = function()
d.last_button = root
if opts.on_click then
opts.on_click()
end
end })
end
return root, nodes, w
end
-- Close and run an action on the game screen (its effects start at `from`, a node, or
-- at the page button that opened the dialog: ok.from, a rect from fx:rect_of with its
-- test id). quiet: close without the confirm sound (the buy modal's buttons, main.js).
function M:act(name, params, from, quiet)
local g = self.g
local opener = self.opts and self.opts.ok and self.opts.ok.from
if opener then
g.fx.last_origin = { x = opener.x, y = opener.y, w = opener.w, h = opener.h, id = opener.id }
else
g.fx:origin(from)
end
self:close(quiet and "quiet" or "ok")
require("screens.game.core.session").act(g, name, params or {})
end
function M:open(opts)
if self.root then
self:remove()
end
local g = self.g
opts = opts or {}
self.opts = opts
self.closing = false
self.druid = druid.new(g)
test.begin_build("dialog")
test.set_scope("dialog")
g.druid:reset_hovers()
local W, H = g.size[1], g.size[2]
local w = opts.w or W0
local d = self
self.reply = function(name, params)
d:act(name, params, d.last_button, true)
end
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
gui.set_position(clip, vec(0, 0))
gui.set_pivot(clip, gui.PIVOT_CENTER)
gui.set_clipping_mode(clip, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(clip, false)
self.clip, self.w, self.h = clip, w, h
local box = ui.panel(clip, -w / 2, -h / 2, w, h)
gui.set_color(box, ui.color("field", 0.96))
self.box = box
-- Input: Druid gives the last created component input first, so: the backdrop (a
-- click on it cancels), then a blocker over the panel, then the panel's buttons.
self.druid:new_button(scrim, function()
d:close("cancel")
end):set_animations_disabled()
self.druid:new_blocker(clip)
-- (Druid's back handler takes key_back only: Esc is a hotkey.)
self.druid:new_back_handler(function()
d:close("cancel")
end)
self.druid:new_hotkey("key_esc", function()
d:close("cancel")
end)
-- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0)
local g_body = anim.group(box, 0, 0)
local g_actions = anim.group(box, 0, 0)
-- The panel's layout (geometry only: each group builds into its own part): the title
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
-- left of Cancel (Cancel is made first).
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
local ok = opts.ok
local danger = ok and ok.danger
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
{ style = { height = 20 }, id = "title" },
flex.box({ grow = 1 }),
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
})
flex.solve(tree, 0, 0)
local title = tree.children[1]
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
if opts.build then
opts.build(self, g_body, w, h)
else
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
end
local by = cancel_box.y
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
id = "dialog:cancel", on_click = function() d:close("cancel") end,
})
if ok then
local ob
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
id = "dialog:ok", price = ok.price,
variant = danger and "danger" or "primary",
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
accent = danger and "danger" or nil,
on_click = function()
if ok.on_ok then
d:close("ok")
ok.on_ok()
elseif ok.act then
d:act(ok.act, ok.params, ob)
else
d:close("ok")
end
end,
})
end
self.by = by
test.set_scope("page")
self:animate_in(g_title, g_body, g_actions)
if opts.build then
sfx.play("modal", { delay = 150 })
else
sfx.play("open")
end
end
function M:animate_in(...)
local groups = { ... }
local holder, clip, scrim = self.holder, self.clip, self.scrim
gui.set_color(scrim, vmath.vector4(SCRIM.x, SCRIM.y, SCRIM.z, 0))
gui.animate(scrim, "color.w", SCRIM.w, anim.ease.ease_out, 0.28)
-- Content fades in from under veils in the panel's color (anim.veil): title, message,
-- buttons in turn.
local field = ui.color("field")
local w, h, by = self.w, self.h, self.by
local regions = { { 2, 48 }, { 50, by - 60 }, { by - 10, h - by + 8 } } -- (the edge stays)
for i, r in ipairs(regions) do
anim.veil(self.box, 2, r[1], w - 4, r[2], field, (i - 1) * 0.06, anim.reduced and 0.12 or 0.35)
end
if anim.reduced then
return
end
local p = gui.get_position(holder)
gui.set_scale(holder, vmath.vector3(0.94, 0.94, 1))
gui.animate(holder, "scale", vmath.vector3(1, 1, 1), anim.ease.overshoot, 0.28)
gui.set_position(holder, vec(p.x, p.y - 8))
gui.animate(holder, "position.y", p.y, anim.ease.overshoot, 0.28)
local size = gui.get_size(clip)
gui.set_size(clip, vec(size.x, size.y * 0.04))
gui.animate(clip, "size", size, anim.ease.overshoot, 0.28)
anim.sweep(self.box, self.w, self.h, ui.color("cyan"), 0.7, 0.05)
for i, grp in ipairs(groups) do
anim.rise(grp, (i - 1) * 0.06, 6, 0.35) -- rise-in: 0.35 s, 60 ms apart
end
end
-- value: "ok" (the confirm sound), "quiet" (none: the action makes its own) or "cancel".
function M:close(value)
if not self.root or self.closing then
return
end
self.closing = true
self.closing_at = socket.gettime()
if value ~= "quiet" then
sfx.play(value == "ok" and "confirm" or "close")
end
local d = self
local function done()
d:remove()
end
local dur = anim.reduced and 0.12 or 0.18
gui.animate(self.scrim, "color.w", 0, anim.ease.ease_in, dur, 0, done)
anim.veil(self.box, 2, 2, self.w - 4, self.h - 4, ui.color("field"), 0, dur, true)
if not anim.reduced then
gui.animate(self.holder, "scale", vmath.vector3(0.97, 0.97, 1), anim.ease.ease_in, 0.18)
local size = gui.get_size(self.clip)
gui.animate(self.clip, "size", vec(size.x, size.y * 0.04), anim.ease.ease_in, 0.18)
end
self.g.rehover = true -- the page's hover comes back under the pointer
end
-- Gone at once (a new dialog, the screen going away).
function M:remove()
if self.druid then
-- A dialog gone in the frame it opened (another one replacing it): Druid's late
-- init (a timer.delay(0)) would still run on its deleted nodes.
if self.druid._late_init_timer_id then
timer.cancel(self.druid._late_init_timer_id)
self.druid._late_init_timer_id = nil
end
self.druid:final()
self.druid = nil
end
if self.root then
gui.delete_node(self.root)
end
test.begin_build("dialog")
self.root, self.closing, self.opts, self.closing_at = nil, false, nil, nil
end
-- How long a close may take before its end is forced (the close animation is 0.18 s; its
-- callback is what removes the backdrop, and while the backdrop exists the page gets no
-- input).
local CLOSE_LIMIT = 1
-- The input invariants (checked every frame by core/main.lua): what blocks the page's
-- input exists only while a dialog is open or closing, and a close finishes. Returns a
-- description of what was wrong (and is now repaired), or nil.
function M:check()
if not self.root then
if self.druid then
self.druid:final()
self.druid = nil
return "a dialog's Druid instance outlived it"
end
return nil
end
if not self.druid then
self:remove()
return "a dialog backdrop without its input"
end
if self.closing and socket.gettime() - (self.closing_at or 0) > CLOSE_LIMIT then
self:remove()
return "a dialog close never finished"
end
return nil
end
-- The page was resized: keep the backdrop full and the panel centered.
function M:layout(W, H)
if not self.root then
return
end
gui.set_size(self.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y")
gui.set_position(self.holder, vec(W / 2, -H / 2))
end
function M:update(dt)
if self.druid then
self.druid:update(dt)
end
end
-- Returns true while open (the page gets no input then).
function M:on_input(action_id, action)
if not self.root then
return false
end
if self.druid and not self.closing then
self.druid:on_input(action_id, action)
end
return true
end
function M:on_message(message_id, message, sender)
if self.druid then
self.druid:on_message(message_id, message, sender)
end
end
return M
+96
View File
@@ -0,0 +1,96 @@
-- A long list (Druid Data List over Scroll + Static Grid, druid-notes.md 2.3): only the
-- rows in view exist, made by create() as they scroll in and deleted as they leave, so
-- hundreds of moons cost a screenful of nodes. Fixed row height; the scroll offset is kept
-- per key on g (g.scroll_pos) so it survives rebuilds; wheel and drag scroll.
--
-- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
-- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus)
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
--
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
-- places it. A Druid component returned with it (a widget) is removed with the row.
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
M.__index = M
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
function M.new(g, parent, x, y, w, h, opts)
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = blocks.bound(opts.create), -- (rows made as it scrolls are its block's)
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
g.scroll_pos = g.scroll_pos or {}
l.view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(l.view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(l.view, false)
l.content = ui.rect(l.view, 0, 0, w, h, "none")
-- The grid's prefab: a node of the row's slot size (never shown).
l.prefab = ui.rect(l.view, 0, 0, w, opts.row_h, "none")
gui.set_enabled(l.prefab, false)
l.scroll = g.druid:new_scroll(l.view, l.content)
l.scroll:set_horizontal_scroll(false)
l.scroll:set_extra_stretch_size(0)
l.scroll.style.WHEEL_SCROLL_SPEED = 48
l.grid = g.druid:new_grid(l.content, l.prefab, 1)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index)
end)
l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset)
end
g.scroll_pos[l.key] = pos.y
l:place_thumb(pos.y)
end)
blocks.push(g, "scroll_areas", { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll })
return l
end
function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
self.items = items
self.data:set_data(items)
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
-- scroll event).
local s = self.scroll
s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then
a.max = max
end
end
-- A thin bar at the right edge (like components/scroll) when it overflows.
if self.track then
gui.delete_node(self.track)
gui.delete_node(self.thumb)
self.track, self.thumb = nil, nil
end
self.max = max
if max > 0 then
local track_h = self.h - 8
self.thumb_h = math.max(28, math.floor(track_h * self.h / (self.h + max)))
self.track = ui.rect(self.parent, self.x + self.w + 4, self.y + 4, 1, track_h, "line")
self.thumb = ui.rect(self.parent, self.x + self.w + 3, self.y + 4, 3, self.thumb_h, "cyan", 0.55)
end
self:place_thumb(self.scroll.position.y)
end
function M:place_thumb(pos_y)
if self.thumb then
local f = math.max(0, math.min(1, pos_y / self.max))
gui.set_position(self.thumb, vec(self.x + self.w + 3, -(self.y + 4 + (self.h - 8 - self.thumb_h) * f)))
end
end
return M
+47
View File
@@ -0,0 +1,47 @@
-- Panel (the web's .frame): a translucent box with a thin edge and cyan corner brackets
-- (gui/templates/panel.gui), and a body group the content goes in. enter() plays the
-- web's entrance (enter.js): the brackets spread from the middle, the content fades in
-- from under a veil and rises 6 px; frames nested in it (cards) follow, staggered.
--
-- local p = panel.new(parent, x, y, w, h) -- p.root (the fill), p.frame, p.body
-- ... build into p.body (top-left based, like any parent) ...
-- p:enter({ delay = 0, nested = { frames } }) -- once it's built (core/main.lua calls
-- -- it when the view changes)
-- panel.frame(node, edge, w, h) -- a nested frame (a card) for `nested`
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local M = {}
M.__index = M
function M.new(parent, x, y, w, h)
local root, nodes = ui.panel(parent, x, y, w, h)
local p = setmetatable({ root = root, frame = ui.part(nodes, "panel", "frame"), w = w, h = h }, M)
p.body = anim.group(root, 0, 0, w, h)
return p
end
-- A nested frame: a card (fill node holding its content, pivot NW) and its edge (an
-- outline over it). For enter's `nested` list.
function M.frame(node, edge, w, h)
return { node = node, edge = edge, w = w, h = h }
end
-- The entrance. opts: delay, clip (false: the box doesn't unclip; the aside, which holds
-- the 3D viewport), nested (frames inside, staggered after this one).
function M:enter(opts)
opts = opts or {}
local delay = opts.delay or 0
local veil = theme.color.veil
anim.enter_frame({ frame = self.frame, body = self.body, veil = { parent = self.root, color = veil },
x = 1, y = 1, w = self.w - 2, h = self.h - 2 }, { delay = delay, clip = false })
local frames = {}
for _, f in ipairs(opts.nested or {}) do
frames[#frames + 1] = { frame = f.edge, veil = { parent = f.node, color = veil }, x = 0, y = 0, w = f.w, h = f.h }
end
anim.enter(frames, { nested = true, delay = delay })
end
return M
+151
View File
@@ -0,0 +1,151 @@
-- List row (wells table rows, the moon list's rows): a full-width row that lights up
-- under the mouse (cyan wash, eased), shows its selection (wash and a 2 px cyan bar on
-- the left) and runs on_click. Buttons on the row made after it take their clicks first.
--
-- row.bind(g, node, w, h, { selected = true, on_click = fn, id = "well:<id>", label })
--
-- A new row's entrance (app.css fx-new: a moon just bought, a drill just deployed): it
-- slides in from 28 px right and fades in over a cyan wash (.35, .2 at 40%, then the
-- row's own fill), with a 3 px cyan bar on its left until halfway (0.9 s). Rows are
-- rebuilt with every state, so the entrance is kept by id and time on g and picks up
-- where it was on the rebuilt row.
--
-- local new = row.fresh(g, "moons", scope, ids) -- ids not there last build (same scope)
-- row.mark(g, id) -- start an entrance for id now
-- row.enter(g, id, node, w, h, { base = fill, slide = node, fade = fn, veil = color })
local ui = require("modules.ui")
local anim = require("modules.anim")
local button = require("components.button")
local M = {}
M.HOVER, M.SELECTED = 0.05, 0.08
function M.bind(g, node, w, h, opts)
opts = opts or {}
local base = ui.color("cyan", opts.selected and M.SELECTED or 0)
gui.set_color(node, base)
if g.fx then
g.fx:box(node) -- (an action's sweep covers the row its button is on)
end
if opts.selected then
ui.rect(node, 0, 0, 2, h - 1, "cyan")
end
local b = button.bind(g, node, nil, { id = opts.id, label = opts.label, on_click = opts.on_click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(node, "color")
local c = on and ui.color("cyan", opts.selected and M.SELECTED or M.HOVER) or base
if button.instant then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
return b
end
---------------------------------------------------------------- new rows
M.ENTER = 0.9
local function clock()
return socket.gettime()
end
-- Which of ids weren't there the last time fresh() ran for key (as a set), when scope
-- (e.g. the moon shown) is the same; nothing the first time or after scope changes.
function M.fresh(g, key, scope, ids)
g.row_known = g.row_known or {}
local was = g.row_known[key]
local now_set = {}
for _, id in ipairs(ids) do
now_set[id] = true
end
g.row_known[key] = { scope = scope, ids = now_set }
local new = {}
if was and was.scope == scope then
for _, id in ipairs(ids) do
if not was.ids[id] then
new[id] = true
end
end
end
return new, was and was.scope == scope and was.ids or nil
end
-- Start an entrance for id (at = when; default now).
function M.mark(g, id, at)
g.row_new = g.row_new or {}
g.row_new[id] = at or clock()
end
-- Play (or pick up) id's entrance on node (a row, w x h, pivot NW). opts: base (the
-- row's resting fill color, vector4; default transparent), slide (the node that slides;
-- default node), fade (fade(e): shows the content at e, 0..1, e.g. through the row's own veil), veil (without
-- fade: the background color a veil over the row fades from; default the panel's), bar
-- (false: no left bar).
function M.enter(g, id, node, w, h, opts)
local t0 = g.row_new and g.row_new[id]
if not t0 then
return
end
local age = clock() - t0
if age >= M.ENTER then
g.row_new[id] = nil
return
end
opts = opts or {}
local base = opts.base or ui.color("cyan", 0)
local cyan = ui.color("cyan")
local slide = opts.slide or node
local p0 = gui.get_position(slide)
local veil
if not opts.fade then
veil = ui.rect(node, 0, 0, w, h, opts.veil or "veil")
end
local bar = opts.bar ~= false and ui.rect(node, 0, 0, 3, h, "cyan") or nil
local ease = anim.fn.out
local function step(k)
local t = k * M.ENTER
local a = math.min(1, t / (M.ENTER * 0.4)) -- 0..40%: in
local e = ease(a)
-- The wash: .35 -> .2 (40%) -> the row's fill.
local wash
if a < 1 then
wash = vmath.lerp(e, ui.color("cyan", 0.35), ui.color("cyan", 0.2))
else
wash = vmath.lerp(ease((t - M.ENTER * 0.4) / (M.ENTER * 0.6)), ui.color("cyan", 0.2), base)
end
gui.set_color(node, wash)
if not anim.reduced then
gui.set_position(slide, vmath.vector3(p0.x + 28 * (1 - e), p0.y, p0.z))
end
if opts.fade then
opts.fade(e)
elseif veil then
anim.set(veil, "color.w", 1 - e)
end
if bar then
local b = k < 0.5 and 1 or 1 - ease((k - 0.5) / 0.5)
gui.set_color(bar, vmath.vector4(cyan.x, cyan.y, cyan.z, b))
gui.set_size(bar, vmath.vector3(3 * b, h, 0))
end
end
step(age / M.ENTER)
anim.tween(node, M.ENTER, step, -age, function()
pcall(gui.set_color, node, base)
pcall(gui.set_position, slide, p0)
if veil then
pcall(gui.delete_node, veil)
end
if bar then
pcall(gui.delete_node, bar)
end
if opts.fade then
pcall(opts.fade, 1)
end
end)
end
return M
+94
View File
@@ -0,0 +1,94 @@
-- A vertically scrolling area (Druid scroll in a stencil-clipped view) for content taller
-- than its panel: cards in the Store, Equipment and Advisors, the drill panel.
--
-- local area = scroll.area(self, parent, x, y, w, h, "store")
-- ... build into area.content (top-left based, like any parent) ...
-- area:finish(content_height)
--
-- A section built again makes its scroll area again, so the offset is kept per key in
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
-- thumb lights up under the mouse (the web's scrollbar hover). Areas register on
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
local WHEEL = 48 -- pixels per wheel step
local BAR_W = 3
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Area = {}
Area.__index = Area
function M.area(self, parent, x, y, w, h, key)
local view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(view, false)
local content = ui.rect(view, 0, 0, w, h, "none")
self.scroll_pos = self.scroll_pos or {}
return setmetatable({ self = self, parent = parent, view = view, content = content,
x = x, y = y, w = w, h = h, key = key }, Area)
end
-- Call once the content is built: content_h is its height. Returns the Druid scroll (nil
-- when everything fits).
function Area:finish(content_h)
local self_, w, h = self.self, self.w, self.h
content_h = math.max(h, math.ceil(content_h))
gui.set_size(self.content, vec(w, content_h))
local max = content_h - h
if max <= 0 then
self_.scroll_pos[self.key] = 0
return nil
end
-- An eased key scroll that a rebuild cut short (core/keys) lands where it was going.
local goal = self_.scroll_goal
if goal and goal.key == self.key and socket.gettime() - goal.t < 1 then
self_.scroll_pos[self.key] = goal.y
self_.scroll_goal = nil
end
local y0 = math.max(0, math.min(self_.scroll_pos[self.key] or 0, max))
gui.set_position(self.content, vec(0, y0))
-- The bar sits outside the clipped view (on the parent), at the view's right edge.
local track_h = h - 8
local thumb_h = math.max(28, math.floor(track_h * h / content_h))
ui.rect(self.parent, self.x + w - BAR_W, self.y + 4, 1, track_h, "line")
local thumb = ui.rect(self.parent, self.x + w - BAR_W - 1, self.y + 4, BAR_W, thumb_h, "cyan", 0.55)
local function place(pos_y)
local f = math.max(0, math.min(1, pos_y / max))
gui.set_position(thumb, vec(self.x + w - BAR_W - 1, -(self.y + 4 + (track_h - thumb_h) * f)))
end
place(y0)
-- Hover: a wider invisible strip over the bar lights the thumb.
local hot = ui.rect(self.parent, self.x + w - 10, self.y + 4, 10, track_h, "none")
local hover = self_.druid:new_hover(hot)
hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(thumb, "color")
gui.animate(thumb, "color", ui.color("cyan", on and 1 or 0.55), gui.EASING_OUTQUAD, 0.12)
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
end)
local s = self_.druid:new_scroll(self.view, self.content)
blocks.push(self_, "scroll_areas", { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s })
s:set_horizontal_scroll(false)
s:set_extra_stretch_size(0)
s.style.WHEEL_SCROLL_SPEED = WHEEL
s.on_scroll:subscribe(function(_, pos)
if self_.tearing_down then
return -- (a rebuild dropping the old scroll: keep the offset)
end
self_.scroll_pos[self.key] = pos.y
place(pos.y)
end)
return s
end
return M
+31
View File
@@ -0,0 +1,31 @@
-- Stat lines: a cap label with its value.
--
-- stat.inline(parent, "Yield", "+3", x, y, color) "YIELD +3" (y the middle); returns
-- its width (cards.stat)
-- stat.row(parent, "Supply left", "75", x, y, w, opts) label left, value right across
-- w (y the top); returns the y under
-- it (H.fact). opts: credits (₥ before
-- the value), suffix, color
local ui = require("modules.ui")
local M = {}
function M.inline(parent, label, value, x, y, color)
local l = ui.text(parent, label, "cap", x, y)
local lw = ui.text_width(l)
local v = ui.text(parent, value, { font = "mono", px = 14, color = color or "text" }, x + lw + 6, y)
return lw + 6 + ui.text_width(v)
end
function M.row(parent, label, value, x, y, w, opts)
ui.text(parent, label, "cap", x, y + 8)
if opts and opts.credits then
ui.credits(parent, value, x + w, y + 8, { pivot = gui.PIVOT_E, suffix = opts.suffix })
else
ui.text(parent, value, { font = "mono", px = 13, color = opts and opts.color or "text" }, x + w, y + 8, gui.PIVOT_E)
end
return y + 20
end
return M
+193
View File
@@ -0,0 +1,193 @@
-- Tabs: the web's nav tabs and their sliding ink (tabs.js, app.css "tab ink"). A strip's
-- ink is a highlight (transparent to cyan .1, top to bottom) with a glowing 2 px
-- underline under the active tab; when the active tab changes it slides from the old
-- one (220 ms, a slight overshoot). Its spot is kept per strip key on g (g.inks), so it
-- slides across the rebuild a navigation causes; a layout resize makes it jump.
--
-- local ink = tabs.ink(g, parent, "nav") -- before the tabs (it draws under them)
-- ... place the tabs ...
-- tabs.tab(g, root, text_node, { active = true, on_click = fn, id = "tab:market" })
-- ink:to(x, y, w, h) -- the active tab's box in parent
-- ink:none() -- no active tab (the ink goes)
--
-- tabs.tab: hover lights the label (nav -> text, eased), a click runs on_click; the label
-- keeps opts.color (default "nav", active "cyan").
--
-- A tab revealed after the page loaded (onboarding) enters once (app.css tab-reveal, 0.7 s
-- overshoot): it fades in, drops 8 px into place and grows from .9 about its middle, out
-- of a soft glow (the web's blur); kept by name and time on g across rebuilds.
-- tabs.revealed(g, "nav", names) -- once per build: which names are new
-- tabs.reveal(g, name, root, text, w, h) -- play / pick up its entrance
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local M = {}
local SLIDE = 0.22
local REVEAL = 0.7
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Ink = {}
Ink.__index = Ink
function M.ink(g, parent, key)
g.inks = g.inks or {}
local root = anim.group(parent, 0, 0)
-- Highlight: the fade texture flipped (transparent at the top).
local hl = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(hl, root, false)
gui.set_texture(hl, "ui")
gui.play_flipbook(hl, "fade")
gui.set_pivot(hl, gui.PIVOT_CENTER)
gui.set_rotation(hl, vmath.vector3(0, 0, 180))
gui.set_color(hl, ui.color("cyan", 0.1))
local glow = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_pivot(glow, gui.PIVOT_CENTER)
gui.set_color(glow, ui.color("cyan", 0.45))
local line = ui.rect(root, 0, 0, 1, 2, "cyan")
gui.set_enabled(root, false)
return setmetatable({ g = g, key = key, root = root, hl = hl, glow = glow, line = line }, Ink)
end
local function place(self, r)
gui.set_position(self.root, vec(r.x, -r.y))
gui.set_size(self.hl, vec(r.w, r.h))
gui.set_position(self.hl, vec(r.w / 2, -r.h / 2))
gui.set_size(self.glow, vec(r.w + 16, 18))
gui.set_position(self.glow, vec(r.w / 2, -(r.h - 1)))
gui.set_size(self.line, vec(r.w, 2))
gui.set_position(self.line, vec(0, -(r.h - 2)))
end
-- Show the ink on the active tab's box (x, y, w, h in the strip's parent).
function Ink:to(x, y, w, h)
local g = self.g
local to = { x = x, y = y, w = w, h = h, layout = g.size and (g.size[1] .. "x" .. g.size[2]) }
local from = g.inks[self.key]
g.inks[self.key] = to
gui.set_enabled(self.root, true)
if not from or from.layout ~= to.layout or anim.reduced
or (from.x == x and from.y == y and from.w == w and from.h == h) then
place(self, to)
return
end
-- Slide: from the old spot to the new one, a frame-driven tween (box sizes and
-- positions together).
place(self, from)
local ease = anim.fn.ink
anim.tween(self.root, SLIDE, function(k)
local e = ease(k)
place(self, { x = from.x + (x - from.x) * e, y = from.y + (y - from.y) * e,
w = from.w + (w - from.w) * e, h = from.h + (h - from.h) * e })
end)
end
-- No active tab: the ink goes (and comes back without a slide).
function Ink:none()
self.g.inks[self.key] = nil
gui.set_enabled(self.root, false)
end
-- Names not shown in the last build of this strip start their entrance now (none the
-- first time: what's there on load doesn't enter).
function M.revealed(g, key, names)
g.tabs_known = g.tabs_known or {}
g.tab_reveal = g.tab_reveal or {}
local known = g.tabs_known[key]
local now = socket.gettime()
local set = {}
for _, n in ipairs(names) do
set[n] = true
if known and not known[n] then
g.tab_reveal[key .. ":" .. n] = now
end
end
g.tabs_known[key] = set
end
-- The entrance of a revealed tab (root: pivot NW, w x h; text: its label), if it is
-- still playing. extra: more nodes that fade in with the label (a button's edge, price).
function M.reveal(g, key, name, root, text, w, h, extra)
local t0 = g.tab_reveal and g.tab_reveal[key .. ":" .. name]
if not t0 then
return
end
local age = socket.gettime() - t0
if age >= REVEAL then
g.tab_reveal[key .. ":" .. name] = nil
return
end
local p, s = gui.get_position(root), gui.get_scale(root)
local fill, tc = gui.get_color(root), gui.get_color(text)
local glow = gui.new_box_node(vec(w / 2, -h / 2), vec(w + 20, h))
local more = {}
for _, n in ipairs(extra or {}) do
more[#more + 1] = { n, gui.get_color(n) }
end
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.move_below(glow, text)
local function step(k)
local a = anim.fn.out(math.min(1, k / 0.5)) -- (opacity: most of it early)
for _, m in ipairs(more) do
local c = m[2]
gui.set_color(m[1], vmath.vector4(c.x, c.y, c.z, c.w * a))
end
if anim.reduced then
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
return
end
local e = anim.fn.overshoot(k)
anim.scale_about(root, vmath.vector3(p.x, p.y + 8 * (1 - e), p.z), s, w / 2, -h / 2, 0.9 + 0.1 * e)
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
gui.set_color(glow, ui.color("cyan_hi", 0.35 * a * (1 - k)))
end
step(age / REVEAL)
anim.tween(root, REVEAL, step, -age, function()
pcall(gui.set_position, root, p)
pcall(gui.set_scale, root, s)
pcall(gui.set_color, root, fill)
pcall(anim.set, text, "color.w", tc.w)
for _, m in ipairs(more) do
pcall(gui.set_color, m[1], m[2])
end
pcall(gui.delete_node, glow)
end)
end
-- A tab's behavior: hover lights its label, a click runs opts.on_click. opts: active,
-- color (the label's color when not hovered), on_click, id (test id), label.
function M.tab(g, root, text, opts)
opts = opts or {}
local base = ui.color(opts.color or (opts.active and "cyan" or "nav"))
local lit = ui.color(opts.active and "cyan" or "text")
local b = g.druid:new_button(root, function()
if opts.on_click then
opts.on_click()
end
end):set_animations_disabled()
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(text, "color")
local c = on and lit or base
if anim.reduced or require("components.button").instant then
gui.set_color(text, c)
else
gui.animate(text, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
test.register(opts.id, root, "tab", opts.label or gui.get_text(text))
return b
end
return M
+66
View File
@@ -0,0 +1,66 @@
[project]
title = MoonMiners
version = 0.1.0
dependencies#0 = https://github.com/Insality/defold-event/archive/refs/tags/21.zip
dependencies#1 = https://github.com/Insality/druid/archive/refs/tags/1.4.1.zip
dependencies#2 = https://github.com/britzl/monarch/archive/refs/tags/6.0.3.zip
[bootstrap]
main_collection = /main/main.collectionc
render = /render/mm.renderc
[display]
width = 1280
height = 800
high_dpi = 1
update_frequency = 60
vsync = 1
[render]
clear_color_red = 0.0157
clear_color_green = 0.0392
clear_color_blue = 0.0863
clear_color_alpha = 1
[input]
game_binding = /builtins/input/all.input_bindingc
[script]
shared_state = 1
[graphics]
max_font_batches = 1024
default_texture_min_filter = linear
default_texture_mag_filter = linear
max_characters = 32768
[gui]
max_count = 2048
[event]
use_xpcall = 1
[druid]
no_auto_input = 1
[mm]
api_base = http://localhost:3000
quit_after = 0
popup = 0
window =
ui_scale =
moon_id =
moon_spin = 1
player =
view =
[sound]
max_component_count = 64
[html5]
htmlfile = /web/index.html
scale_mode = stretch
show_fullscreen_button = 0
show_made_with_defold = 0
show_console_banner = 0
heap_size = 256
+79
View File
@@ -0,0 +1,79 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.1
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 80.0000
y: -22.0000
}
size {
x: 150.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Button"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 80.0000
y: 20.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -6.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 14.0000
y: -10.0000
}
size {
x: 70.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "text"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+32
View File
@@ -0,0 +1,32 @@
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Label"
font: "sans"
id: "root"
pivot: PIVOT_W
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+180
View File
@@ -0,0 +1,180 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
alpha: 0.0
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 10.0000
y: -11.0000
}
size {
x: 36.0000
y: 36.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/sphere"
id: "planet"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 58.0000
y: -20.0000
}
size {
x: 150.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Moon"
font: "sans_semi"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 58.0000
y: -39.0000
}
size {
x: 150.0000
y: 18.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Idle"
font: "sans"
id: "sub"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 232.0000
y: -29.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_BOX
texture: "ui/outline"
id: "badge"
pivot: PIVOT_E
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: -11.0000
y: 0.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_TEXT
text: "1"
font: "mono"
id: "badge_text"
pivot: PIVOT_CENTER
parent: "badge"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+52
View File
@@ -0,0 +1,52 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.0549
y: 0.1255
z: 0.2275
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.55
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/frame"
id: "frame"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 14.0000
y: 14.0000
z: 14.0000
w: 14.0000
}
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+25
View File
@@ -0,0 +1,25 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 100.0000
y: 100.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 110.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 55.0000
y: -22.0000
}
size {
x: 110.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Tab"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -42.0000
}
size {
x: 110.0000
y: 2.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "underline"
pivot: PIVOT_NW
parent: "root"
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+208
View File
@@ -0,0 +1,208 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 520.0000
y: 52.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -51.0000
}
size {
x: 520.0000
y: 1.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "line"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 10.0000
y: -22.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 26.0000
y: -26.0000
}
size {
x: 120.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Well"
font: "sans"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 140.0000
y: 6.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar_bg"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 70.0000
y: 6.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 150.0000
y: -38.0000
}
size {
x: 140.0000
y: 16.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "supply"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 310.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Empty"
font: "sans"
id: "drill"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 510.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: ""
font: "mono"
id: "status"
pivot: PIVOT_E
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+143
View File
@@ -0,0 +1,143 @@
name: "main"
scale_along_z: 0
embedded_instances {
id: "main"
data: "components {\n"
" id: \"main\"\n"
" component: \"/main/main.script\"\n"
"}\n"
""
}
embedded_instances {
id: "game"
data: "components {\n"
" id: \"screen_proxy\"\n"
" component: \"/monarch/screen_proxy.script\"\n"
" properties {\n"
" id: \"screen_id\"\n"
" value: \"game\"\n"
" type: PROPERTY_TYPE_HASH\n"
" }\n"
"}\n"
"embedded_components {\n"
" id: \"collectionproxy\"\n"
" type: \"collectionproxy\"\n"
" data: \"collection: \\\"/screens/game/game.collection\\\"\\n"
"\"\n"
"}\n"
""
}
embedded_instances {
id: "sfx"
data: "embedded_components {\n"
" id: \"arrive\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/arrive.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"burst\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/burst.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"buy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/buy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"charge\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"coin\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/coin.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"collect\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/collect.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"confirm\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/confirm.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"deploy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/deploy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"error\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/error.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"harvest\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/harvest.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"modal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/modal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"moon\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/moon.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"nav\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/nav.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"open\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/open.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"powerdown\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/powerdown.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"ready\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/ready.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"refine\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/refine.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"shimmer\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/shimmer.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"tick\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/tick.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"toggle\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/toggle.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"upgrade\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/upgrade.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
""
}
+55
View File
@@ -0,0 +1,55 @@
-- Bootstrap: shows the game screen once the Monarch screen proxies have registered.
--
-- Debug flags (game.project [mm], or --config=mm.<key>=<value> at launch):
-- mm.quit_after seconds after which the game exits (used by `make shot`; 0: never)
-- mm.popup 1: open a dialog on start (to check it in a shot; core/main.lua)
-- mm.window WxH: resize the window on start (to check scaling; display.width and
-- display.height are the minimum layout size and stay 1280x800)
-- mm.ui_scale the UI scale, forced (not saved; modules/screen.lua)
-- mm.display_scale pretend the display has this scale (HiDPI checks; modules/screen.lua)
--
-- The window listener: sound goes silent while the window is unfocused or minimized
-- (modules/sfx set_hidden, like the web tab going hidden).
local monarch = require("monarch.monarch")
local api = require("modules.api")
local sfx = require("modules.sfx")
local screen = require("modules.screen")
local function on_window(self, event, data)
if event == window.WINDOW_EVENT_FOCUS_LOST or event == window.WINDOW_EVENT_ICONIFIED then
sfx.set_hidden(true)
elseif event == window.WINDOW_EVENT_FOCUS_GAINED or event == window.WINDOW_EVENT_DEICONIFIED then
sfx.set_hidden(false)
end
end
function init(self)
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
-- In a browser the frames come from requestAnimationFrame at the display's rate; with
-- display.update_frequency set the engine skips callbacks to approach it (judder on
-- 120/144 Hz displays, dropped frames on jittery 60 Hz ones). Variable rate there:
-- every callback is a frame, dt is the real time.
if html5 then
sys.set_update_frequency(0)
end
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
if w then
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
end
window.set_listener(on_window)
screen.apply_saved()
msg.post("#", "start")
local quit_after = sys.get_config_number("mm.quit_after", 0)
if quit_after > 0 then
timer.delay(quit_after, false, function()
sys.exit(0)
end)
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("start") then
monarch.show(hash("game"))
end
end
+1
View File
@@ -0,0 +1 @@
name: "MMTerrain"
+37
View File
@@ -0,0 +1,37 @@
// The two calls mmterrain.c needs from the engine's C++-only buffer API, as C functions:
// the float32 x3 streams of the buffer at a Lua stack index, and marking it changed.
#include <dmsdk/sdk.h>
static float* stream(dmBuffer::HBuffer buf, const char* name, uint32_t* count, uint32_t* stride)
{
void* data = 0;
uint32_t components = 0;
if (dmBuffer::GetStream(buf, dmHashString64(name), &data, count, &components, stride) != dmBuffer::RESULT_OK)
return 0;
if (components != 3)
return 0;
dmBuffer::ValueType type;
if (dmBuffer::GetStreamType(buf, dmHashString64(name), &type, &components) != dmBuffer::RESULT_OK
|| type != dmBuffer::VALUE_TYPE_FLOAT32)
return 0;
return (float*)data;
}
extern "C" int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride)
{
dmBuffer::HBuffer buf = dmScript::CheckBufferUnpack(L, index);
uint32_t c[3], s[3];
*pos = stream(buf, "position", &c[0], &s[0]);
*nor = stream(buf, "normal", &c[1], &s[1]);
*col = stream(buf, "color", &c[2], &s[2]);
if (!*pos || !*nor || !*col || s[0] != s[1] || s[0] != s[2])
return 0;
*count = c[0];
*stride = s[0];
return 1;
}
extern "C" void mm_buffer_touch(lua_State* L, int index)
{
dmBuffer::UpdateContentVersion(dmScript::CheckBufferUnpack(L, index));
}
+201
View File
@@ -0,0 +1,201 @@
/* mmterrain: the moon generator (terrain.c) for Lua. Plain C; the engine's buffer API is
* C++ only, so buffer_shim.cpp reaches a buffer's streams for mmterrain.fill.
*
* local gen = mmterrain.new(terrain.native_params(p)) -- p: terrain.resolve's result
* mmterrain.start(gen) -> true: it runs on a thread of its own (desktop);
* false: step it (web)
* mmterrain.step(gen, secs) -> true when done (works about secs; 0: to the end)
* mmterrain.done(gen) -> true when done (threaded or stepped)
* mmterrain.vertices(gen) -> the triangle strip's length (terrain.strip)
* mmterrain.fill(gen, buf [, from, to]) fills buf's position, normal and color
* streams (float32 x3, at least vertices(gen) long) with the
* strip: its vertices from..to (1-based, inclusive; default
* all)
* mmterrain.result(gen) -> { n, count, positions, normals, colors, min_h, max_h }
* mmterrain.free(gen) frees it now (else the garbage collector does)
* Don't call anything but done/free on a started generator until done is true.
*/
#include <dmsdk/extension/extension.h>
#include <dmsdk/dlib/align.h>
#include "terrain.h"
#define META "mmterrain.gen"
/* The extender compiles .c files in C++ mode (clang++, "treating 'c' input as 'c++'"), so
* this file and terrain.c are valid as both; what links across files is extern "C". */
#ifdef __cplusplus
extern "C" {
#endif
/* buffer_shim.cpp: a buffer's three float32 x3 streams, or 0. */
int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride);
void mm_buffer_touch(lua_State* L, int index);
void MMTerrain(void);
#ifdef __cplusplus
}
#endif
static mt_gen** check_slot(lua_State* L)
{
return (mt_gen**)luaL_checkudata(L, 1, META);
}
static mt_gen* check_gen(lua_State* L)
{
mt_gen** slot = check_slot(L);
if (!*slot)
luaL_error(L, "mmterrain: generator already freed");
return *slot;
}
static mt_gen* check_done(lua_State* L)
{
mt_gen* g = check_gen(L);
if (!mt_done(g))
luaL_error(L, "mmterrain: generation not done");
return g;
}
static int New(lua_State* L)
{
double a[MT_PARAM_COUNT];
mt_params p;
mt_gen** slot;
int i;
luaL_checktype(L, 1, LUA_TTABLE);
for (i = 0; i < MT_PARAM_COUNT; ++i) {
lua_rawgeti(L, 1, i + 1);
a[i] = luaL_checknumber(L, -1);
lua_pop(L, 1);
}
mt_params_from_array(a, MT_PARAM_COUNT, &p);
slot = (mt_gen**)lua_newuserdata(L, sizeof(mt_gen*));
*slot = 0;
luaL_getmetatable(L, META);
lua_setmetatable(L, -2);
*slot = mt_new(&p);
if (!*slot)
return luaL_error(L, "mmterrain: out of memory");
return 1;
}
static int Start(lua_State* L)
{
lua_pushboolean(L, mt_start(check_gen(L)));
return 1;
}
static int Step(lua_State* L)
{
mt_gen* g = check_gen(L);
lua_pushboolean(L, mt_done(g) || mt_step(g, luaL_optnumber(L, 2, 0)));
return 1;
}
static int Done(lua_State* L)
{
lua_pushboolean(L, mt_done(check_gen(L)));
return 1;
}
static int Vertices(lua_State* L)
{
lua_pushinteger(L, mt_strip_len(mt_n(check_gen(L))));
return 1;
}
static int Fill(lua_State* L)
{
mt_gen* g = check_done(L);
float *pos, *nor, *col;
unsigned count, stride;
if (!mm_buffer_streams(L, 2, &pos, &nor, &col, &count, &stride))
return luaL_error(L, "mmterrain.fill: needs position, normal and color streams (float32 x3)");
int len = mt_strip_len(mt_n(g));
int from = (int)luaL_optinteger(L, 3, 1) - 1, to = (int)luaL_optinteger(L, 4, len);
if (count < (unsigned)len)
return luaL_error(L, "mmterrain.fill: buffer too short");
mt_fill(g, pos, nor, col, (int)stride, from < 0 ? 0 : from, to > len ? len : to);
mm_buffer_touch(L, 2);
return 0;
}
static void push_array(lua_State* L, const double* a, int n)
{
int i;
lua_createtable(L, n, 0);
for (i = 0; i < n; ++i) {
lua_pushnumber(L, a[i]);
lua_rawseti(L, -2, i + 1);
}
}
static int Result(lua_State* L)
{
mt_gen* g = check_done(L);
int c = mt_count(g);
lua_createtable(L, 0, 7);
lua_pushinteger(L, mt_n(g));
lua_setfield(L, -2, "n");
lua_pushinteger(L, c);
lua_setfield(L, -2, "count");
push_array(L, mt_positions(g), c * 3);
lua_setfield(L, -2, "positions");
push_array(L, mt_normals(g), c * 3);
lua_setfield(L, -2, "normals");
push_array(L, mt_colors(g), c * 3);
lua_setfield(L, -2, "colors");
lua_pushnumber(L, mt_min_h(g));
lua_setfield(L, -2, "min_h");
lua_pushnumber(L, mt_max_h(g));
lua_setfield(L, -2, "max_h");
return 1;
}
static int Free(lua_State* L)
{
mt_gen** slot = check_slot(L);
mt_free(*slot); /* (joins a running thread) */
*slot = 0;
return 0;
}
static const luaL_Reg Module_methods[] = {
{ "new", New },
{ "start", Start },
{ "step", Step },
{ "done", Done },
{ "vertices", Vertices },
{ "fill", Fill },
{ "result", Result },
{ "free", Free },
{ 0, 0 },
};
static ExtensionResult Initialize(ExtensionParams* params)
{
lua_State* L = params->m_L;
int top = lua_gettop(L);
luaL_newmetatable(L, META);
lua_pushcfunction(L, Free);
lua_setfield(L, -2, "__gc");
lua_pop(L, 1);
luaL_register(L, "mmterrain", Module_methods);
lua_pop(L, 1);
(void)top;
return EXTENSION_RESULT_OK;
}
static ExtensionResult Finalize(ExtensionParams* params)
{
(void)params;
return EXTENSION_RESULT_OK;
}
/* DM_DECLARE_EXTENSION, written out for C (the macro's function is extern "C"). */
static uint8_t DM_ALIGNED(16) g_Desc[128]; /* ExtensionDescBufferSize (a const, not a C constant) */
void MMTerrain(void)
{
ExtensionRegister((void*)g_Desc, sizeof(g_Desc), "MMTerrain", 0, 0, Initialize, Finalize, 0, 0);
}
+830
View File
@@ -0,0 +1,830 @@
/* The moon generator (see terrain.h), a port of the server's web/static/js/terrain.js:
* keep the two in sync (scripts/moon-check.mjs compares them). */
#if !defined(_WIN32) && !defined(_POSIX_C_SOURCE)
#define _POSIX_C_SOURCE 199309L /* clock_gettime */
#endif
#include "terrain.h"
#include <math.h>
#if MT_THREADS
#include <pthread.h>
#endif
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#include <windows.h>
#else
#include <time.h>
#endif
/* The same rounding as the JS: no fused multiply-adds. */
#if defined(__clang__)
#pragma clang fp contract(off)
#elif defined(__GNUC__)
#pragma GCC optimize("fp-contract=off")
#endif
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define MAX_OCTAVES 10
#define MAX_CRATERS 2000
#define GRID 12
static double clock_secs(void)
{
#if defined(_WIN32)
LARGE_INTEGER f, c;
QueryPerformanceFrequency(&f);
QueryPerformanceCounter(&c);
return (double)c.QuadPart / (double)f.QuadPart;
#else
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + ts.tv_nsec * 1e-9;
#endif
}
static double dmin(double a, double b) { return a < b ? a : b; }
static double dmax(double a, double b) { return a > b ? a : b; }
static double lround_(double x) { return floor(x + 0.5); }
/* ------------------------------------------------------------ random */
/* mulberry32, uniform in [0, 1). */
typedef struct { uint32_t a; } mt_rng;
static mt_rng rng_new(double seed)
{
mt_rng r;
/* bit.tobit: the number modulo 2^32 */
r.a = (uint32_t)(int64_t)fmod(seed, 4294967296.0);
return r;
}
static double rng_next(mt_rng* r)
{
uint32_t t;
r->a += 0x6d2b79f5u;
t = (r->a ^ (r->a >> 15)) * (1u | r->a);
t = (t + (t ^ (t >> 7)) * (61u | t)) ^ t;
return (double)(t ^ (t >> 14)) / 4294967296.0;
}
/* ------------------------------------------------------------ noise */
static const double GRAD3[36] = {
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
};
static int PERM[512];
static double GX[256], GY[256], GZ[256];
static int perm_ready = 0;
static void perm_init(void)
{
int p[256], i;
mt_rng r;
if (perm_ready)
return;
for (i = 0; i < 256; ++i)
p[i] = i;
r = rng_new(1337);
for (i = 255; i >= 1; --i) {
int j = (int)floor(rng_next(&r) * (i + 1));
int t = p[i];
p[i] = p[j];
p[j] = t;
}
for (i = 0; i < 512; ++i)
PERM[i] = p[i & 255];
for (i = 0; i < 256; ++i) {
int g = (i % 12) * 3;
GX[i] = GRAD3[g];
GY[i] = GRAD3[g + 1];
GZ[i] = GRAD3[g + 2];
}
perm_ready = 1;
}
#define F3 (1.0 / 3.0)
#define G3 (1.0 / 6.0)
/* floor without a libm call (x86_64's baseline has no rounding instruction); the same
* value for |x| < 2^53. */
static double ffloor(double x)
{
double t = (double)(int64_t)x;
return t > x ? t - 1 : t;
}
/* terrain.lua simplex3 (branch-free, the same values as the reference's if/else chains,
* and faster here too: the corner order is unpredictable). */
static double simplex3(double x, double y, double z)
{
const int* P = PERM;
double s = (x + y + z) * F3;
double i = ffloor(x + s), j = ffloor(y + s), k = ffloor(z + s);
double t = (i + j + k) * G3;
double x0 = x - (i - t), y0 = y - (j - t), z0 = z - (k - t);
double a = x0 >= y0 ? 1 : 0; /* 1 + floor(x0 - y0) in the Lua: |x0 - y0| < 1 */
double b = y0 >= z0 ? 1 : 0;
double c = x0 >= z0 ? 1 : 0;
double i1 = a * (b + (1 - b) * c);
double j1 = (1 - a) * b;
double k1 = 1 - i1 - j1;
double i2 = a + (1 - a) * b * c;
double j2 = a * b + 1 - a;
double k2 = 2 - i2 - j2;
int ii = (int)((int64_t)i & 255), jj = (int)((int64_t)j & 255), kk = (int)((int64_t)k & 255);
int ii1 = (int)i1, jj1 = (int)j1, kk1 = (int)k1, ii2 = (int)i2, jj2 = (int)j2, kk2 = (int)k2;
double tt, n, x1, y1, z1, x2, y2, z2, x3, y3, z3;
int g;
tt = dmax(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0);
g = P[ii + P[jj + P[kk]]];
tt = tt * tt;
n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0);
x1 = x0 - i1 + G3; y1 = y0 - j1 + G3; z1 = z0 - k1 + G3;
tt = dmax(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1);
g = P[ii + ii1 + P[jj + jj1 + P[kk + kk1]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1);
x2 = x0 - i2 + 2 * G3; y2 = y0 - j2 + 2 * G3; z2 = z0 - k2 + 2 * G3;
tt = dmax(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2);
g = P[ii + ii2 + P[jj + jj2 + P[kk + kk2]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2);
x3 = x0 - 1 + 3 * G3; y3 = y0 - 1 + 3 * G3; z3 = z0 - 1 + 3 * G3;
tt = dmax(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3);
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3);
return 32 * n;
}
static double fbm(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
{
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
sum = sum + simplex3(x * f, y * f, z * f) * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double billow(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
{
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
sum = sum + fabs(simplex3(x * f, y * f, z * f)) * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double ridged(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity,
double sharpness, double gain)
{
double sum = 0, amp = 1, norm = 0, weight = 1, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
double v = 1 - fabs(simplex3(x * f, y * f, z * f));
v = pow(v, sharpness) * weight;
weight = dmin(1, dmax(0, v * gain));
sum = sum + v * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double smooth_min(double a, double b, double k)
{
double h;
if (k <= 0)
return dmin(a, b);
h = dmin(1, dmax(0, (b - a + k) / (2 * k)));
return a * h + b * (1 - h) - k * h * (1 - h);
}
static double smooth_max(double a, double b, double k)
{
return -smooth_min(-a, -b, k);
}
static double smoothstep(double e0, double e1, double x)
{
double d = e1 - e0, t;
if (d == 0)
d = 1e-6;
t = dmin(1, dmax(0, (x - e0) / d));
return t * t * (3 - 2 * t);
}
/* ------------------------------------------------------------ colors */
int mt_params_from_array(const double* a, int n, mt_params* p)
{
int i = 0, k;
if (n < MT_PARAM_COUNT)
return 0;
p->resolution = a[i++];
p->technique = (int)a[i++];
p->shape_strength = a[i++]; p->shape_frequency = a[i++]; p->shape_octaves = a[i++];
p->shape_persistence = a[i++]; p->shape_lacunarity = a[i++]; p->shape_min = a[i++];
p->ridge_strength = a[i++]; p->ridge_frequency = a[i++]; p->ridge_octaves = a[i++];
p->ridge_persistence = a[i++]; p->ridge_lacunarity = a[i++]; p->ridge_sharpness = a[i++];
p->ridge_gain = a[i++]; p->ridge_min = a[i++];
p->warp_strength = a[i++]; p->warp_frequency = a[i++]; p->warp_octaves = a[i++];
p->crater_count = a[i++]; p->crater_min_radius = a[i++]; p->crater_max_radius = a[i++];
p->crater_size_bias = a[i++]; p->crater_depth = a[i++]; p->crater_rim_width = a[i++];
p->crater_rim_steepness = a[i++]; p->crater_floor = a[i++]; p->crater_smoothness = a[i++];
p->crater_ejecta = a[i++];
for (k = 0; k < 3; ++k) p->low[k] = a[i++];
for (k = 0; k < 3; ++k) p->mid[k] = a[i++];
for (k = 0; k < 3; ++k) p->high[k] = a[i++];
for (k = 0; k < 3; ++k) p->steep[k] = a[i++];
for (k = 0; k < 3; ++k) p->patch[k] = a[i++];
p->steep_threshold = a[i++]; p->steep_blend = a[i++]; p->patch_amount = a[i++];
p->patch_frequency = a[i++]; p->grain = a[i++];
for (k = 0; k < 3; ++k) p->offset[k] = a[i++];
p->crater_seed = a[i++];
return 1;
}
/* ------------------------------------------------------------ generator */
typedef struct crater {
double x, y, z, r, reach;
} crater;
enum { ST_DIRS, ST_CRATERS, ST_HEIGHTS, ST_MEAN, ST_POSITIONS, ST_NORMALS, ST_SEAMS, ST_NORMALIZE, ST_COLORS, ST_DONE };
struct mt_gen {
mt_params p;
#if MT_THREADS
pthread_t thread;
int threaded;
int finished; /* (set by the thread, read with atomics) */
#endif
int n, row, per, count;
int stage, i;
double* dirs;
int* keys;
double *heights, *ejecta, *positions, *normals, *colors;
crater* craters;
int n_craters;
int* cell_start; /* GRID^3 + 1 offsets into cell_list */
int* cell_list;
double x_max, mean, min_h, max_h;
};
static const double FACES[6][9] = {
{ 1, 0, 0, 0, 1, 0, 0, 0, 1 },
{ -1, 0, 0, 0, 0, 1, 0, 1, 0 },
{ 0, 1, 0, 0, 0, 1, 1, 0, 0 },
{ 0, -1, 0, 1, 0, 0, 0, 0, 1 },
{ 0, 0, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 0, -1, 0, 1, 0, 1, 0, 0 },
};
mt_gen* mt_new(const mt_params* p)
{
mt_gen* g = (mt_gen*)calloc(1, sizeof(mt_gen));
size_t c3;
if (!g)
return 0;
perm_init();
g->p = *p;
g->n = (int)dmax(8, dmin(320, lround_(p->resolution)));
g->row = g->n + 1;
g->per = g->row * g->row;
g->count = 6 * g->per;
c3 = (size_t)g->count * 3;
g->dirs = (double*)malloc(c3 * sizeof(double));
g->keys = (int*)malloc((size_t)g->count * sizeof(int));
g->heights = (double*)malloc((size_t)g->count * sizeof(double));
g->ejecta = (double*)malloc((size_t)g->count * sizeof(double));
g->positions = (double*)malloc(c3 * sizeof(double));
g->normals = (double*)calloc(c3, sizeof(double));
g->colors = (double*)malloc(c3 * sizeof(double));
if (!g->dirs || !g->keys || !g->heights || !g->ejecta || !g->positions || !g->normals || !g->colors) {
mt_free(g);
return 0;
}
g->stage = ST_DIRS;
return g;
}
void mt_free(mt_gen* g)
{
if (!g)
return;
#if MT_THREADS
if (g->threaded)
pthread_join(g->thread, 0);
#endif
free(g->dirs);
free(g->keys);
free(g->heights);
free(g->ejecta);
free(g->positions);
free(g->normals);
free(g->colors);
free(g->craters);
free(g->cell_start);
free(g->cell_list);
free(g);
}
static void dir_at(mt_gen* g, int v)
{
int n = g->n, row = g->row, f = v / g->per, r = v % g->per, j = r / row, i = r % row;
const double* F = FACES[f];
double s = ((double)i / n) * 2 - 1;
double t = ((double)j / n) * 2 - 1;
double cx = F[0] + s * F[3] + t * F[6];
double cy = F[1] + s * F[4] + t * F[7];
double cz = F[2] + s * F[5] + t * F[8];
double x2 = cx * cx, y2 = cy * cy, z2 = cz * cz;
g->dirs[v * 3] = cx * sqrt(1 - y2 / 2 - z2 / 2 + (y2 * z2) / 3);
g->dirs[v * 3 + 1] = cy * sqrt(1 - x2 / 2 - z2 / 2 + (x2 * z2) / 3);
g->dirs[v * 3 + 2] = cz * sqrt(1 - x2 / 2 - y2 / 2 + (x2 * y2) / 3);
if (i == 0 || j == 0 || i == n || j == n) {
int kx = (int)lround_(((cx + 1) / 2) * n), ky = (int)lround_(((cy + 1) / 2) * n), kz = (int)lround_(((cz + 1) / 2) * n);
g->keys[v] = (kx * row + ky) * row + kz;
} else {
g->keys[v] = -1;
}
}
static int cell_at(double v)
{
double cell = 2.0 / GRID;
return (int)dmin(GRID - 1, dmax(0, floor((v + 1) / cell)));
}
/* terrain.lua make_craters: the list, then each grid cell's craters in list order. */
static int make_craters(mt_gen* g)
{
const mt_params* p = &g->p;
int field = p->technique == MT_CRATER_FIELD;
double min_r = field ? p->crater_min_radius * 0.8 : p->crater_min_radius;
double max_r = field ? p->crater_max_radius * 0.6 : p->crater_max_radius;
double scale = field ? 3 : 1;
int count = (int)lround_(dmin(p->crater_count, MAX_CRATERS) * scale), i, c, x, y, z, total;
mt_rng r = rng_new(p->crater_seed);
int cells = GRID * GRID * GRID;
int* fill;
if (count < 0)
count = 0;
g->x_max = dmax(1 + p->crater_rim_width, sqrt(1 + dmax(0, p->crater_smoothness)));
g->craters = (crater*)malloc((size_t)(count > 0 ? count : 1) * sizeof(crater));
g->cell_start = (int*)calloc((size_t)cells + 1, sizeof(int));
if (!g->craters || !g->cell_start)
return 0;
for (i = 0; i < count; ++i) {
double zz = rng_next(&r) * 2 - 1;
double a = rng_next(&r) * M_PI * 2;
double q = sqrt(1 - zz * zz);
double radius = min_r + (max_r - min_r) * pow(rng_next(&r), p->crater_size_bias);
crater* k = &g->craters[i];
k->x = q * cos(a);
k->y = q * sin(a);
k->z = zz;
k->r = radius;
k->reach = radius * g->x_max;
}
g->n_craters = count;
/* Two passes: counts per cell, then the lists. */
for (c = 0; c < count; ++c) {
crater* k = &g->craters[c];
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z)
g->cell_start[(x * GRID + y) * GRID + z + 1]++;
}
for (i = 0; i < cells; ++i)
g->cell_start[i + 1] += g->cell_start[i];
total = g->cell_start[cells];
g->cell_list = (int*)malloc((size_t)(total > 0 ? total : 1) * sizeof(int));
fill = (int*)calloc((size_t)cells, sizeof(int));
if (!g->cell_list || !fill) {
free(fill);
return 0;
}
for (c = 0; c < count; ++c) {
crater* k = &g->craters[c];
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z) {
int cell = (x * GRID + y) * GRID + z;
g->cell_list[g->cell_start[cell] + fill[cell]++] = c;
}
}
free(fill);
return 1;
}
static void height_at(mt_gen* g, int i)
{
const mt_params* p = &g->p;
int field = p->technique == MT_CRATER_FIELD;
double x = g->dirs[i * 3], y = g->dirs[i * 3 + 1], z = g->dirs[i * 3 + 2];
double px = x + p->offset[0], py = y + p->offset[1], pz = z + p->offset[2];
double value, h, crater_h = 0, glow = 0;
double shape_strength = field ? p->shape_strength * 0.4 : p->shape_strength;
double smooth = dmax(0, p->crater_smoothness);
double ejecta_reach = 1 + p->crater_rim_width * 2.5;
double x_max = g->x_max;
int cell, c, end;
switch (p->technique) {
case MT_RIDGED:
value = ridged(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity,
p->ridge_sharpness, p->ridge_gain);
break;
case MT_BILLOW:
value = billow(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity);
break;
case MT_WARPED: {
double wf = p->warp_frequency;
double wx = fbm(px + 5.2, py + 1.3, pz, p->warp_octaves, wf, 0.5, 2);
double wy = fbm(px, py + 9.7, pz + 2.8, p->warp_octaves, wf, 0.5, 2);
double wz = fbm(px + 3.1, py, pz + 7.4, p->warp_octaves, wf, 0.5, 2);
double s = p->warp_strength;
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, p->shape_octaves, p->shape_frequency, p->shape_persistence,
p->shape_lacunarity) + 1) / 2;
break;
}
default:
value = (fbm(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity) + 1) / 2;
break;
}
h = dmax(0, value - p->shape_min) * shape_strength;
if (p->ridge_strength != 0) {
double r = ridged(px - 11.3, py + 4.1, pz - 7.9, p->ridge_octaves, p->ridge_frequency, p->ridge_persistence,
p->ridge_lacunarity, p->ridge_sharpness, p->ridge_gain);
h = h + dmax(0, r - p->ridge_min) * p->ridge_strength;
}
cell = (cell_at(x) * GRID + cell_at(y)) * GRID + cell_at(z);
end = g->cell_start[cell + 1];
for (c = g->cell_start[cell]; c < end; ++c) {
const crater* k = &g->craters[g->cell_list[c]];
double dx = x - k->x, dy = y - k->y, dz = z - k->z;
double d2 = dx * dx + dy * dy + dz * dz;
double reach = dmax(k->reach, k->r * ejecta_reach);
if (d2 <= reach * reach) {
double dist = sqrt(d2) / k->r;
if (dist < x_max) {
double cavity = dist * dist - 1;
double rim_x = dmin(dist - 1 - p->crater_rim_width, 0);
double rim = p->crater_rim_steepness * rim_x * rim_x;
double shape = smooth_max(cavity, p->crater_floor, smooth);
shape = smooth_min(shape, rim, smooth);
crater_h = crater_h + shape * k->r;
}
if (dist > 0.8 && dist < ejecta_reach)
glow = glow + 1 - smoothstep(1, ejecta_reach, dist);
}
}
g->heights[i] = h + crater_h * p->crater_depth;
g->ejecta[i] = dmin(1, glow);
}
static void tri(mt_gen* g, int a, int b, int c)
{
const double* P = g->positions;
double* N = g->normals;
double e1x, e1y, e1z, e2x, e2y, e2z, nx, ny, nz;
a *= 3;
b *= 3;
c *= 3;
e1x = P[b] - P[a]; e1y = P[b + 1] - P[a + 1]; e1z = P[b + 2] - P[a + 2];
e2x = P[c] - P[a]; e2y = P[c + 1] - P[a + 1]; e2z = P[c + 2] - P[a + 2];
nx = e1y * e2z - e1z * e2y;
ny = e1z * e2x - e1x * e2z;
nz = e1x * e2y - e1y * e2x;
N[a] += nx; N[a + 1] += ny; N[a + 2] += nz;
N[b] += nx; N[b + 1] += ny; N[b + 2] += nz;
N[c] += nx; N[c + 1] += ny; N[c + 2] += nz;
}
typedef struct seam {
int key, index;
} seam;
static int seam_cmp(const void* a, const void* b)
{
const seam* x = (const seam*)a;
const seam* y = (const seam*)b;
if (x->key != y->key)
return x->key < y->key ? -1 : 1;
return x->index < y->index ? -1 : (x->index > y->index);
}
/* Faces duplicate their edge vertices: each copy gets the sum (in index order, like the
* Lua's ipairs over its group). */
static int seams(mt_gen* g)
{
int count = g->count, i, m = 0, a, b, k;
seam* list = (seam*)malloc((size_t)count * sizeof(seam));
if (!list)
return 0;
for (i = 0; i < count; ++i)
if (g->keys[i] >= 0) {
list[m].key = g->keys[i];
list[m].index = i;
++m;
}
qsort(list, (size_t)m, sizeof(seam), seam_cmp);
for (a = 0; a < m; a = b) {
double sx = 0, sy = 0, sz = 0;
for (b = a; b < m && list[b].key == list[a].key; ++b) {
}
if (b - a < 2)
continue;
for (k = a; k < b; ++k) {
int o = list[k].index * 3;
sx = sx + g->normals[o];
sy = sy + g->normals[o + 1];
sz = sz + g->normals[o + 2];
}
for (k = a; k < b; ++k) {
int o = list[k].index * 3;
g->normals[o] = sx;
g->normals[o + 1] = sy;
g->normals[o + 2] = sz;
}
}
free(list);
return 1;
}
static void color_at(mt_gen* g, int i)
{
const mt_params* p = &g->p;
int o = i * 3;
double x = g->dirs[o], y = g->dirs[o + 1], z = g->dirs[o + 2];
double ox = p->offset[0], oy = p->offset[1], oz = p->offset[2];
double range = g->max_h - g->min_h;
double e, te, c1, c2, c3, gg, steepness, ws, gr;
const double *lo, *hi;
if (range == 0)
range = 1;
e = (g->heights[i] - g->min_h) / range;
if (e < 0.5) {
lo = p->low; hi = p->mid; te = e * 2;
} else {
lo = p->mid; hi = p->high; te = (e - 0.5) * 2;
}
c1 = lo[0] + (hi[0] - lo[0]) * te;
c2 = lo[1] + (hi[1] - lo[1]) * te;
c3 = lo[2] + (hi[2] - lo[2]) * te;
if (p->patch_amount > 0) {
double pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, p->patch_frequency, 0.5, 2);
double w = smoothstep(-0.05, 0.4, pn) * p->patch_amount * (1 - e * 0.6);
c1 = c1 + (p->patch[0] - c1) * w;
c2 = c2 + (p->patch[1] - c2) * w;
c3 = c3 + (p->patch[2] - c3) * w;
}
gg = g->ejecta[i] * p->crater_ejecta;
c1 = c1 + (p->high[0] - c1) * gg;
c2 = c2 + (p->high[1] - c2) * gg;
c3 = c3 + (p->high[2] - c3) * gg;
steepness = 1 - (g->normals[o] * x + g->normals[o + 1] * y + g->normals[o + 2] * z);
ws = smoothstep(p->steep_threshold - p->steep_blend, p->steep_threshold + p->steep_blend, steepness);
c1 = c1 + (p->steep[0] - c1) * ws;
c2 = c2 + (p->steep[1] - c2) * ws;
c3 = c3 + (p->steep[2] - c3) * ws;
gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * p->grain;
g->colors[o] = c1 * gr;
g->colors[o + 1] = c2 * gr;
g->colors[o + 2] = c3 * gr;
}
int mt_step(mt_gen* g, double budget)
{
double deadline = clock_secs() + budget;
int count = g->count, every = 0;
#define DUE() (budget > 0 && (++every & 31) == 0 && clock_secs() > deadline)
while (g->stage != ST_DONE) {
switch (g->stage) {
case ST_DIRS:
while (g->i < count) {
dir_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_CRATERS;
break;
case ST_CRATERS:
if (!make_craters(g)) {
g->stage = ST_DONE; /* (out of memory: a flat moon) */
break;
}
g->stage = ST_HEIGHTS;
g->i = 0;
break;
case ST_HEIGHTS:
while (g->i < count) {
height_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_MEAN;
break;
case ST_MEAN: {
double mean = 0;
int i;
for (i = 0; i < count; ++i)
mean = mean + g->heights[i];
g->mean = mean / count;
g->min_h = HUGE_VAL;
g->max_h = -HUGE_VAL;
g->stage = ST_POSITIONS;
g->i = 0;
break;
}
case ST_POSITIONS: {
int i;
for (i = 0; i < count; ++i) {
double hh = g->heights[i] - g->mean, r;
g->heights[i] = hh;
r = 1 + hh;
g->positions[i * 3] = g->dirs[i * 3] * r;
g->positions[i * 3 + 1] = g->dirs[i * 3 + 1] * r;
g->positions[i * 3 + 2] = g->dirs[i * 3 + 2] * r;
if (hh < g->min_h)
g->min_h = hh;
if (hh > g->max_h)
g->max_h = hh;
}
g->stage = ST_NORMALS;
g->i = 0;
break;
}
case ST_NORMALS: {
/* g->i: the row (face * n + y) */
int n = g->n, rows = 6 * n;
while (g->i < rows) {
int f = g->i / n, y = g->i % n, x;
int base = f * g->per;
for (x = 0; x < n; ++x) {
int i = base + y * g->row + x;
tri(g, i, i + 1, i + n + 2);
tri(g, i, i + n + 2, i + n + 1);
}
++g->i;
if (budget > 0 && clock_secs() > deadline)
goto out;
}
g->stage = ST_SEAMS;
break;
}
case ST_SEAMS:
seams(g);
g->stage = ST_NORMALIZE;
break;
case ST_NORMALIZE: {
int i;
for (i = 0; i < count; ++i) {
int o = i * 3;
double nx = g->normals[o], ny = g->normals[o + 1], nz = g->normals[o + 2];
double len = sqrt(nx * nx + ny * ny + nz * nz);
if (len == 0)
len = 1;
g->normals[o] = nx / len;
g->normals[o + 1] = ny / len;
g->normals[o + 2] = nz / len;
}
g->stage = ST_COLORS;
g->i = 0;
break;
}
case ST_COLORS:
while (g->i < count) {
color_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_DONE;
break;
}
}
out:
#undef DUE
return g->stage == ST_DONE;
}
int mt_n(const mt_gen* g) { return g->n; }
int mt_count(const mt_gen* g) { return g->count; }
const double* mt_positions(const mt_gen* g) { return g->positions; }
const double* mt_normals(const mt_gen* g) { return g->normals; }
const double* mt_colors(const mt_gen* g) { return g->colors; }
double mt_min_h(const mt_gen* g) { return g->min_h; }
double mt_max_h(const mt_gen* g) { return g->max_h; }
int mt_strip_len(int n)
{
/* per face n rows of 2 (n + 1), joined by 2 repeated vertices (none before the first) */
return 6 * n * 2 * (n + 1) + 2 * (6 * n - 1);
}
void mt_strip(int n, int* out)
{
int row = n + 1, per = row * row, c = 0, f, j, i;
for (f = 0; f < 6; ++f) {
int base = f * per;
for (j = 0; j < n; ++j) {
if (c > 0) {
out[c] = out[c - 1];
out[c + 1] = base + (j + 1) * row;
c += 2;
}
for (i = 0; i <= n; ++i) {
out[c] = base + (j + 1) * row + i;
out[c + 1] = base + j * row + i;
c += 2;
}
}
}
}
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to)
{
int n = g->n, row = n + 1, per = row * row, k = 0, f, j, i, last = -1;
/* the strip of mt_strip, walked without the index array */
#define PUT(v) \
do { \
int v3 = (v) * 3; \
if (k < from || k >= to) { \
last = (v); \
++k; \
break; \
} \
float* pp = pos ? pos + (size_t)k * stride : 0; \
float* nn = nor ? nor + (size_t)k * stride : 0; \
float* cc = col ? col + (size_t)k * stride : 0; \
if (pp) { pp[0] = (float)g->positions[v3]; pp[1] = (float)g->positions[v3 + 1]; pp[2] = (float)g->positions[v3 + 2]; } \
if (nn) { nn[0] = (float)g->normals[v3]; nn[1] = (float)g->normals[v3 + 1]; nn[2] = (float)g->normals[v3 + 2]; } \
if (cc) { cc[0] = (float)g->colors[v3]; cc[1] = (float)g->colors[v3 + 1]; cc[2] = (float)g->colors[v3 + 2]; } \
last = (v); \
++k; \
} while (0)
for (f = 0; f < 6; ++f) {
int base = f * per;
for (j = 0; j < n; ++j) {
if (k > 0) {
int prev = last;
PUT(prev);
PUT(base + (j + 1) * row);
}
for (i = 0; i <= n; ++i) {
PUT(base + (j + 1) * row + i);
PUT(base + j * row + i);
}
}
}
#undef PUT
}
#if MT_THREADS
static void* run_thread(void* arg)
{
mt_gen* g = (mt_gen*)arg;
mt_step(g, 0);
__atomic_store_n(&g->finished, 1, __ATOMIC_RELEASE);
return 0;
}
#endif
int mt_start(mt_gen* g)
{
#if MT_THREADS
if (g->threaded || g->stage == ST_DONE)
return g->threaded;
if (pthread_create(&g->thread, 0, run_thread, g) != 0)
return 0;
g->threaded = 1;
return 1;
#else
(void)g;
return 0;
#endif
}
int mt_done(mt_gen* g)
{
#if MT_THREADS
if (g->threaded)
return __atomic_load_n(&g->finished, __ATOMIC_ACQUIRE);
#endif
return g->stage == ST_DONE;
}
+86
View File
@@ -0,0 +1,86 @@
/* The moon generator in C: a port of the server's web/static/js/terrain.js generation.
* Same random draws (mulberry32, the simplex permutation) and the same arithmetic in the
* same order, in doubles, so the output matches terrain.js within its Float32Array
* rounding.
*
* Plain C, no engine or Lua headers: mmterrain.c binds it for the engine, and
* scripts/moon-check.mjs loads it into luajit through the FFI (a host build) to compare
* it with terrain.js. The work runs in steps with a time budget (mt_step), so a slow
* target (wasm) can spread it over frames.
*/
#ifndef MM_TERRAIN_H
#define MM_TERRAIN_H
#ifndef MT_THREADS
#if defined(__EMSCRIPTEN__) || defined(_WIN32)
#define MT_THREADS 0
#else
#define MT_THREADS 1
#endif
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* Techniques, in terrain.lua's TECHNIQUES order. */
enum { MT_FBM = 0, MT_RIDGED = 1, MT_WARPED = 2, MT_BILLOW = 3, MT_CRATER_FIELD = 4 };
/* The resolved parameters of one moon (terrain.lua resolve), colors already linear RGB. */
typedef struct mt_params {
double resolution;
int technique;
double shape_strength, shape_frequency, shape_octaves, shape_persistence, shape_lacunarity, shape_min;
double ridge_strength, ridge_frequency, ridge_octaves, ridge_persistence, ridge_lacunarity,
ridge_sharpness, ridge_gain, ridge_min;
double warp_strength, warp_frequency, warp_octaves;
double crater_count, crater_min_radius, crater_max_radius, crater_size_bias, crater_depth,
crater_rim_width, crater_rim_steepness, crater_floor, crater_smoothness, crater_ejecta;
double low[3], mid[3], high[3], steep[3], patch[3];
double steep_threshold, steep_blend, patch_amount, patch_frequency, grain;
double offset[3];
double crater_seed;
} mt_params;
typedef struct mt_gen mt_gen;
/* A generator for p (copied). NULL when out of memory. */
mt_gen* mt_new(const mt_params* p);
void mt_free(mt_gen* g);
/* Works for about `budget` seconds (<= 0: until done); returns 1 when done. */
int mt_step(mt_gen* g, double budget);
/* Where there are threads (desktop: MT_THREADS), runs the whole generation on a thread of
* its own and returns 1 (then only mt_done and, once done, the results may be used);
* else returns 0 (step it instead). */
int mt_start(mt_gen* g);
/* 1 once the generation is complete (threaded or stepped). */
int mt_done(mt_gen* g);
/* The grid size, its vertex count, and (when done) the flat xyz arrays over the grid
* (vertex v at [3v..3v+2]) and the height range, like terrain.js generateMoon's. */
int mt_n(const mt_gen* g);
int mt_count(const mt_gen* g);
const double* mt_positions(const mt_gen* g);
const double* mt_normals(const mt_gen* g);
const double* mt_colors(const mt_gen* g);
double mt_min_h(const mt_gen* g);
double mt_max_h(const mt_gen* g);
/* The triangle strip over the grid (terrain.lua strip): its length, and its 0-based grid
* indices into out (mt_strip_len(n) ints). */
int mt_strip_len(int n);
void mt_strip(int n, int* out);
/* Writes the strip's positions, normals and colors as floats, its vertices k in
* [from, to): vertex k at pos[k * stride ..], nor[k * stride ..], col[k * stride ..] (any
* may be NULL). */
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
/* The parameters from a flat array in terrain.lua native_params order (MT_PARAM_COUNT
* numbers); returns 0 when it is too short. */
#define MT_PARAM_COUNT 53
int mt_params_from_array(const double* a, int n, mt_params* p);
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -0,0 +1 @@
name: "MMWindow"
+162
View File
@@ -0,0 +1,162 @@
// mmwindow: a fullscreen toggle at runtime (Defold's window module has none; the engine
// reads display.fullscreen once at start). Desktop only: the engine's GLFW window (the
// current GL context's) goes to its monitor at the monitor's own video mode ("windowed
// fullscreen": no mode change) and back to the size and position it had.
//
// mmwindow.set_fullscreen(true | false)
// mmwindow.is_fullscreen() -> bool
//
// Also sticky mouse buttons and keys: the engine polls button and key states once a frame,
// so a press and release between two polls (a quick click or a touchpad tap at a low frame
// rate) was lost; sticky, a press released before the poll still reads as pressed once.
//
// The GLFW functions are the engine's (statically linked); declared here, no header.
#define EXTENSION_NAME MMWindow
#define LIB_NAME "MMWindow"
#define MODULE_NAME "mmwindow"
#include <dmsdk/sdk.h>
#if defined(DM_PLATFORM_LINUX) || defined(DM_PLATFORM_WINDOWS) || defined(DM_PLATFORM_OSX)
#define MM_GLFW 1
extern "C" {
typedef struct GLFWwindow GLFWwindow;
typedef struct GLFWmonitor GLFWmonitor;
typedef struct GLFWvidmode {
int width, height, redBits, greenBits, blueBits, refreshRate;
} GLFWvidmode;
GLFWwindow* glfwGetCurrentContext(void);
GLFWmonitor* glfwGetPrimaryMonitor(void);
GLFWmonitor** glfwGetMonitors(int* count);
GLFWmonitor* glfwGetWindowMonitor(GLFWwindow* window);
const GLFWvidmode* glfwGetVideoMode(GLFWmonitor* monitor);
void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
void glfwSetInputMode(GLFWwindow* window, int mode, int value);
}
#define MM_GLFW_STICKY_KEYS 0x00033002
#define MM_GLFW_STICKY_MOUSE_BUTTONS 0x00033003
static int g_X, g_Y, g_W, g_H;
static bool g_Saved = false;
// The monitor the window's middle is on (else the primary one).
static GLFWmonitor* MonitorOf(GLFWwindow* win)
{
int wx, wy, ww, wh;
glfwGetWindowPos(win, &wx, &wy);
glfwGetWindowSize(win, &ww, &wh);
int cx = wx + ww / 2, cy = wy + wh / 2;
int count = 0;
GLFWmonitor** list = glfwGetMonitors(&count);
for (int i = 0; i < count; ++i)
{
const GLFWvidmode* mode = glfwGetVideoMode(list[i]);
if (!mode)
continue;
int mx, my;
glfwGetMonitorPos(list[i], &mx, &my);
if (cx >= mx && cx < mx + mode->width && cy >= my && cy < my + mode->height)
return list[i];
}
return glfwGetPrimaryMonitor();
}
#endif
static int IsFullscreen(lua_State* L)
{
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
lua_pushboolean(L, win && glfwGetWindowMonitor(win) != 0);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static int SetFullscreen(lua_State* L)
{
bool on = lua_toboolean(L, 1);
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
if (!win)
{
lua_pushboolean(L, 0);
return 1;
}
bool is = glfwGetWindowMonitor(win) != 0;
if (on && !is)
{
GLFWmonitor* monitor = MonitorOf(win);
const GLFWvidmode* mode = monitor ? glfwGetVideoMode(monitor) : 0;
if (!mode)
{
lua_pushboolean(L, 0);
return 1;
}
glfwGetWindowPos(win, &g_X, &g_Y);
glfwGetWindowSize(win, &g_W, &g_H);
g_Saved = true;
glfwSetWindowMonitor(win, monitor, 0, 0, mode->width, mode->height, mode->refreshRate);
}
else if (!on && is)
{
if (!g_Saved)
{
// Started fullscreen: a window of 1280x800 in the monitor's corner.
g_X = 80; g_Y = 80; g_W = 1280; g_H = 800;
}
glfwSetWindowMonitor(win, 0, g_X, g_Y, g_W, g_H, 0);
}
lua_pushboolean(L, 1);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static const luaL_reg Module_methods[] = {
{"set_fullscreen", SetFullscreen},
{"is_fullscreen", IsFullscreen},
{0, 0}
};
static dmExtension::Result Initialize(dmExtension::Params* params)
{
lua_State* L = params->m_L;
int top = lua_gettop(L);
luaL_register(L, MODULE_NAME, Module_methods);
lua_pop(L, 1);
assert(top == lua_gettop(L));
return dmExtension::RESULT_OK;
}
// Sticky input on the engine's window, once it exists (see the top).
static bool g_Sticky = false;
static dmExtension::Result Update(dmExtension::Params* params)
{
#if defined(MM_GLFW)
if (!g_Sticky) {
GLFWwindow* win = glfwGetCurrentContext();
if (win) {
glfwSetInputMode(win, MM_GLFW_STICKY_MOUSE_BUTTONS, 1);
glfwSetInputMode(win, MM_GLFW_STICKY_KEYS, 1);
g_Sticky = true;
}
}
#endif
return dmExtension::RESULT_OK;
}
static dmExtension::Result Finalize(dmExtension::Params* params)
{
return dmExtension::RESULT_OK;
}
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, Update, 0, Finalize)
+448
View File
@@ -0,0 +1,448 @@
-- Animation helpers: the web build's motion (enter.js, app.css keyframes, fx.js) as GUI
-- tweens. Every helper honors the reduced-motion option (M.reduced: no movement, quick
-- fades only), like the web honors prefers-reduced-motion.
--
-- anim.ease.out / .overshoot / .ink / .back / .linear ... easing curves (gui.animate)
-- anim.to(node, prop, value, dur, ease, delay, done) one tween (instant if dur 0)
-- anim.enter_frame(f, opts) a frame grows: brackets spread from the center, the
-- fill unclips, the content fades and rises (enter.js)
-- anim.enter(frames, opts) several, staggered (30 ms, capped at 60)
-- anim.group(parent, x, y) an invisible container (content that moves together)
-- anim.veil(parent, x, y, w, h, color, delay, dur) content under it fades in
-- anim.rise(node, delay, by) a group rises into place
-- anim.breathe(node, prop, a, b, period) pingpong forever (glows, ready labels)
-- anim.pulse(node, scale, dur) one swell and back
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
-- decaying noise shake (sell power-down, reveal burst)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
-- anim.update(dt) steps the frame-driven ones (call every frame)
local M = {}
---------------------------------------------------------------- reduced motion
local settings = require("modules.settings")
M.reduced = sys.get_config_int("mm.reduced_motion", -1) == 1
or (sys.get_config_int("mm.reduced_motion", -1) == -1 and settings.load().reduced_motion == true)
-- The option (the header's toggle), saved with the other settings (modules/settings)
-- unless --config=mm.reduced_motion forces it (tests, shots) or save is false (the test
-- hook).
local forced = sys.get_config_int("mm.reduced_motion", -1) ~= -1
function M.set_reduced_motion(on, save)
M.reduced = on and true or false
if not forced and save ~= false then
settings.save({ reduced_motion = M.reduced })
end
end
---------------------------------------------------------------- easing
-- cubic-bezier(x1, y1, x2, y2) sampled for gui.animate (a vector of values over 0..1).
local function bezier(x1, y1, x2, y2, n)
n = n or 64
local function bx(t) return 3 * x1 * t * (1 - t) ^ 2 + 3 * x2 * t ^ 2 * (1 - t) + t ^ 3 end
local function by(t) return 3 * y1 * t * (1 - t) ^ 2 + 3 * y2 * t ^ 2 * (1 - t) + t ^ 3 end
local samples = {}
for i = 0, n - 1 do
local x = i / (n - 1)
local lo, hi = 0, 1
for _ = 1, 30 do -- bisection: bx is monotonic for x1, x2 in 0..1
local mid = (lo + hi) / 2
if bx(mid) < x then lo = mid else hi = mid end
end
samples[#samples + 1] = by((lo + hi) / 2)
end
local v = vmath.vector(samples)
-- The same curve as a Lua function (frame-driven tweens).
local fn = function(k)
k = math.max(0, math.min(1, k))
local f = k * (n - 1)
local i = math.floor(f)
local a, b = samples[i + 1], samples[math.min(n, i + 2)]
return a + (b - a) * (f - i)
end
return v, fn
end
M.ease, M.fn = {}, {}
local function curve(name, ...)
M.ease[name], M.fn[name] = bezier(...)
end
curve("out", 0.2, 0.8, 0.2, 1) -- enter.js EASE (frames, content)
curve("overshoot", 0.2, 0.9, 0.3, 1.2) -- dialog-in (app.css)
curve("ink", 0.3, 1.25, 0.5, 1) -- the sliding tab ink (220 ms)
curve("toast", 0.2, 1.4, 0.4, 1) -- toast-in squash and overshoot
curve("pop", 0.3, 1.6, 0.5, 1) -- ready pop .6 -> 1.12 -> 1
curve("toast_in", 0.2, 0.9, 0.3, 1.15) -- fx-toast-in, per keyframe segment
curve("ready_pop", 0.2, 0.9, 0.3, 1.4) -- fx-ready-pop, per keyframe segment
curve("gain", 0.2, 0.9, 0.3, 1.3) -- fx-gain (wallet), per keyframe segment
curve("ease_in", 0.42, 0, 1, 1)
curve("ease_out", 0, 0, 0.58, 1)
curve("ease", 0.25, 0.1, 0.25, 1)
M.ease.linear, M.fn.linear = gui.EASING_LINEAR, function(k) return k end
M.ease.back, M.fn.back = gui.EASING_OUTBACK, function(k)
local c1, c3 = 1.70158, 2.70158
return 1 + c3 * (k - 1) ^ 3 + c1 * (k - 1) ^ 2
end
M.ease.sine, M.fn.sine = gui.EASING_INOUTSINE, function(k) return -(math.cos(math.pi * k) - 1) / 2 end
---------------------------------------------------------------- basics
local function v3(x, y, z)
return vmath.vector3(x, y, z or 0)
end
-- Set a property, "color" or one component like "color.w" (what gui.animate takes).
local GETTERS = { position = gui.get_position, scale = gui.get_scale, color = gui.get_color, size = gui.get_size,
euler = gui.get_euler, outline = gui.get_outline, shadow = gui.get_shadow }
local SETTERS = { position = gui.set_position, scale = gui.set_scale, color = gui.set_color, size = gui.set_size,
euler = gui.set_euler, outline = gui.set_outline, shadow = gui.set_shadow }
function M.set(node, prop, value)
local base, comp = prop:match("^(%a+)%.(%a)$")
if base and GETTERS[base] then
local v = GETTERS[base](node)
v[comp] = value
SETTERS[base](node, v)
elseif SETTERS[prop] then
SETTERS[prop](node, value)
else
gui.set(node, prop, value)
end
end
-- One tween. dur 0 (or reduced motion for a movement, opts.move) sets the value at once.
function M.to(node, prop, value, dur, ease, delay, done)
if dur <= 0 then
M.set(node, prop, value)
if done then
done()
end
return
end
gui.animate(node, prop, value, ease or M.ease.out, dur, delay or 0, done and function() done() end or nil)
end
-- A CSS-like keyframe track at progress k (0..1): frames = { {t, v}, ... } sorted by t
-- (first at 0, last at 1), each segment eased by ease_fn (a M.fn curve; default linear),
-- like animation-timing-function applies per keyframe interval.
function M.keys(frames, k, ease_fn)
if k <= frames[1][1] then
return frames[1][2]
end
for i = 2, #frames do
local a, b = frames[i - 1], frames[i]
if k <= b[1] then
local u = (k - a[1]) / math.max(1e-6, b[1] - a[1])
u = ease_fn and ease_fn(u) or u
return a[2] + (b[2] - a[2]) * u
end
end
return frames[#frames][2]
end
-- Scale a node about a point: p = its resting position, base = its resting scale, (dx,
-- dy) = from p to the point that stays put (a pivot NW box of w x h: w/2, -h/2 for its
-- middle), sx/sy the factors (sy defaults to sx). Template roots are pivot NW, so scaling
-- them alone grows from the corner; CSS scales about the middle.
function M.scale_about(node, p, base, dx, dy, sx, sy)
sy = sy or sx
gui.set_scale(node, vmath.vector3(base.x * sx, base.y * sy, base.z))
gui.set_position(node, vmath.vector3(p.x + dx * (1 - sx), p.y + dy * (1 - sy), p.z))
end
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {}
local function now()
return socket.gettime()
end
function M.tween(node, dur, step, delay, done)
local t = { node = node, t0 = now() + (delay or 0), dur = dur, step = step, done = done }
running[#running + 1] = t
return t
end
function M.cancel(t)
if t then
t.dead = true
end
end
function M.update()
if #running == 0 then
return
end
local t1 = now()
local keep = {}
for _, t in ipairs(running) do
if not t.dead then
local k = (t1 - t.t0) / t.dur
if k >= 0 then
local ok = pcall(t.step, math.min(1, k), t.node)
if not ok then
t.dead = true -- node deleted (a rebuild)
elseif k >= 1 then
t.dead = true
if t.done then
pcall(t.done)
end
end
end
if not t.dead then
keep[#keep + 1] = t
end
end
end
running = keep
end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
end
---------------------------------------------------------------- fading content
-- GUI nodes don't pass their alpha down (inherit_alpha is off, and most containers here
-- are translucent fills: panels, cards, buttons), so content doesn't fade as a subtree.
-- Content fades in from under a veil instead: a box in the background's color over it
-- that fades out (M.veil), while a group (M.group) holding the content rises.
-- An invisible container (not drawn; alpha 1): content built into it moves with it.
function M.group(parent, x, y, w, h)
local n = gui.new_box_node(v3(x or 0, -(y or 0)), v3(w or 1, h or 1))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_color(n, vmath.vector4(1, 1, 1, 1))
gui.set_visible(n, false)
return n
end
-- A veil over (x, y, w, h) of parent (top-left based) in `color` (the background behind
-- the content) that fades out after delay over dur and goes away: the content under it
-- fades in. Make it after the content, so it draws over it. out = true: it fades in
-- instead (the content fading out) and stays. Returns the veil node.
function M.veil(parent, x, y, w, h, color, delay, dur, out)
local n = gui.new_box_node(v3(x, -y), v3(w, h))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_texture(n, "ui")
gui.play_flipbook(n, "white")
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, out and 0 or 1))
gui.animate(n, "color.w", out and 1 or 0, M.ease.out, dur or 0.14, delay or 0, (not out) and function()
gui.delete_node(n)
end or nil)
return n
end
---------------------------------------------------------------- entrances (enter.js)
local GROW, FADE, NESTED, STAGGER, MAX_STAGGER = 0.18, 0.14, 0.06, 0.03, 0.06
-- Rise: a group starts `by` px lower and moves up (0.14 s), after delay.
function M.rise(node, delay, by, dur)
if M.reduced then
return
end
local p = gui.get_position(node)
gui.set_position(node, v3(p.x, p.y - (by or 6), p.z))
gui.animate(node, "position.y", p.y, M.ease.out, dur or FADE, delay or 0)
end
-- A frame grows (enter.js grow): brackets spread from the center, the fill unclips, the
-- content fades and rises. f = { frame = the bracket node (slice9 corners, pivot NW),
-- fill = the frame's fill node (pivot NW, or nil), body = a group holding the content
-- (rises), veil = { parent, color } for the content's fade (it covers the box: x, y in
-- veil.parent, w x h). opts: delay, clip (false: the fill doesn't unclip, the aside).
function M.enter_frame(f, opts)
opts = opts or {}
local delay = opts.delay or 0
local w, h = f.w, f.h
local function fade(at)
if f.veil then
local v = M.veil(f.veil.parent, f.x or 0, f.y or 0, w, h, f.veil.color, at, M.reduced and 0.12 or FADE)
if f.frame and gui.get_parent(f.frame) == f.veil.parent then
gui.move_above(f.frame, v) -- the brackets spread over the hidden content
end
end
end
if M.reduced then
fade(delay)
return
end
local x, y = math.max(0, w / 2 - 16), math.max(0, h / 2 - 16)
local function grow(node)
local size = gui.get_size(node)
local pos = gui.get_position(node)
gui.set_size(node, v3(size.x - 2 * x, size.y - 2 * y))
gui.set_position(node, v3(pos.x + x, pos.y - y))
gui.animate(node, "size", size, M.ease.out, GROW, delay)
gui.animate(node, "position", pos, M.ease.out, GROW, delay)
end
if f.frame then
grow(f.frame) -- corner brackets (slice9 corners) start near the center and spread
end
if f.fill and opts.clip ~= false then
grow(f.fill) -- the box unclips from its middle
end
fade(delay + GROW / 2)
if f.body then
M.rise(f.body, delay + GROW / 2, 6)
end
end
-- Several frames, staggered like enter.js (nested ones start NESTED later).
function M.enter(frames, opts)
opts = opts or {}
for i, f in ipairs(frames) do
M.enter_frame(f, { delay = (opts.nested and NESTED or 0) + math.min((i - 1) * STAGGER, MAX_STAGGER) + (opts.delay or 0),
clip = f.clip })
end
end
---------------------------------------------------------------- loops and one-shots
-- Pingpong a property between a and b forever (a glow that breathes). Reduced motion:
-- holds a.
function M.breathe(node, prop, a, b, period)
gui.cancel_animations(node, prop)
M.set(node, prop, a)
if M.reduced then
return
end
gui.animate(node, prop, b, gui.EASING_INOUTSINE, (period or 1.8) / 2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end
-- Swell to `scale` and back once (pivot center nodes scale about their middle).
function M.pulse(node, scale, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.animate(node, "scale", s * (scale or 1.08), gui.EASING_OUTSINE, (dur or 0.3) / 2, 0, nil, gui.PLAYBACK_ONCE_PINGPONG)
end
-- Scale in from `from` with an overshoot (the web's ready pop: .6 -> 1.12 -> 1).
function M.pop(node, from, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.set_scale(node, s * (from or 0.6))
gui.animate(node, "scale", s, M.ease.pop, dur or 0.6)
end
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
function M.shake(node, amp, dur, delay)
if M.reduced then
return
end
amp, dur = amp or 6, dur or 0.4
local p
return M.tween(node, dur, function(k, n)
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function()
if p then
pcall(gui.set_position, node, p)
end
end)
end
-- 1D value noise (smooth), for shakes.
local function noise(x, seed)
local function h(i)
local s = math.sin((i + seed) * 127.1) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local i = math.floor(x)
local f = x - i
local u = f * f * (3 - 2 * f)
return h(i) * (1 - u) + h(i + 1) * u
end
-- A decaying noise shake in x and y (fx.js sell power-down, the reveal burst). freq:
-- noise steps per second.
function M.noise_shake(node, amp, dur, freq)
if M.reduced then
return
end
amp, dur, freq = amp or 4, dur or 0.34, freq or 30
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + noise(t, seed) * a, p.y + noise(t, seed + 50) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur)
local c = gui.get_color(node)
if M.reduced then
return
end
gui.set_color(node, color)
gui.animate(node, "color", c, M.ease.ease_out, dur or 0.5)
end
-- A scan line sweeping down a box (w x h at the parent's top-left): a bright line with a
-- glow that fades as it goes (app.css scan, 0.7-0.9 s). Returns the line node.
function M.sweep(parent, w, h, color, dur, delay)
if M.reduced then
return
end
color = color or require("modules.theme").color.cyan
local line = gui.new_box_node(v3(0, 0), v3(w, 2))
gui.set_pivot(line, gui.PIVOT_NW)
gui.set_parent(line, parent, false)
gui.set_texture(line, "ui")
gui.play_flipbook(line, "white")
gui.set_color(line, vmath.vector4(color.x, color.y, color.z, 0.9))
local glow = gui.new_box_node(v3(0, 8), v3(w, 18))
gui.set_pivot(glow, gui.PIVOT_W)
gui.set_parent(glow, line, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_color(glow, vmath.vector4(color.x, color.y, color.z, 0.35))
gui.set_inherit_alpha(glow, true)
M.tween(line, dur or 0.8, function(k, n)
local e = M.fn.ease_out(k)
gui.set_position(n, v3(0, -h * e))
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, 0.9 * (1 - k)))
end, delay or 0, function()
pcall(gui.delete_node, line)
end)
return line
end
-- A number counting from `from` to `to` (fmt formats it).
function M.count(node, from, to, dur, fmt, done)
fmt = fmt or tostring
if M.reduced or (dur or 0) <= 0 then
gui.set_text(node, fmt(to))
return
end
return M.tween(node, dur, function(k, n)
local v = from + (to - from) * M.fn.ease_out(k)
if to == math.floor(to) then
v = math.floor(v + 0.5)
end
gui.set_text(n, fmt(v))
end, 0, done)
end
return M
+200
View File
@@ -0,0 +1,200 @@
-- The game server's JSON API. The server is the authority: the client sends actions and
-- shows the state it gets back, it never computes game results itself.
--
-- GET /api/state the player's state (params: the view, e.g. moon, well)
-- POST /api/act/<action> run an action, returns the new state (or an error)
--
-- The player id travels in the X-MM-User header. The server returns one on the first
-- visit; it is kept with sys.save so the same player comes back.
--
-- Base URL: game.project [mm] api_base (default http://localhost:3000), overridable at
-- launch with --config=mm.api_base=http://host:port. In a browser: the page's origin.
local M = {}
M.base = sys.get_config_string("mm.api_base", "http://localhost:3000"):gsub("/+$", "")
-- In a browser (the web build) the page's own origin: the browser only lets the game talk
-- to it (scripts/serve-web.py serves the game there and proxies the server's paths). A
-- page URL with ?api=<url> picks another server (it must allow cross-origin requests).
if html5 then
local ok, origin = pcall(html5.run,
"(new URLSearchParams(location.search).get('api') || location.origin).replace(/\\/+$/, '')")
if ok and type(origin) == "string" and origin:match("^https?://") then
M.base = origin
end
end
M.timeout = 10
-- Debug: mm.fake_latency_ms delays every request by half of it and its response by the
-- other half (prediction checks). mm.fake_reject=<action>[:n] makes the n-th call of that
-- action (every one without :n) fail on the server with a 422: it is sent as a claim of
-- a well that doesn't exist.
M.fake_latency = sys.get_config_int("mm.fake_latency_ms", 0) / 1000
local reject_name, reject_n = sys.get_config_string("mm.fake_reject", ""):match("^([^:]*):?(%d*)$")
reject_n = tonumber(reject_n)
local reject_count = 0
-- One saved player per server, so runs against a local test server don't replace the
-- player of another one.
local SAVE_FILE = sys.get_save_file("moonminers", "player-" .. M.base:gsub("^%a+://", ""):gsub("[^%w]", "_"))
local HEADER = "X-MM-User"
local player_id = nil
local function load_player()
local ok, data = pcall(sys.load, SAVE_FILE)
if ok and type(data) == "table" and type(data.id) == "string" and data.id ~= "" then
return data.id
end
return nil
end
local function store_player(id)
if not id or id == "" or id == player_id then
return
end
player_id = id
if not sys.save(SAVE_FILE, { id = id }) then
print("api: could not save the player id")
end
end
player_id = load_player()
-- mm.new_player=1: start as a new player (screenshots of the first launch).
if sys.get_config_int("mm.new_player", 0) == 1 then
player_id = nil
sys.save(SAVE_FILE, {})
end
-- mm.player=<id>: play as this player (test setups prepared through the admin pages).
local forced = sys.get_config_string("mm.player", "")
if forced ~= "" then
player_id = forced
end
function M.player_id()
return player_id
end
-- Forget the stored player (the next request starts a new one).
function M.reset_player()
player_id = nil
sys.save(SAVE_FILE, {})
end
local function urlencode(s)
return (tostring(s):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
local function query(params)
if not params then
return ""
end
local keys = {}
for k in pairs(params) do
keys[#keys + 1] = k
end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(params[k])
end
return table.concat(parts, "&")
end
-- Header names arrive lowercased in http.request responses; look both up to be safe.
local function header(headers, name)
if not headers then
return nil
end
return headers[name:lower()] or headers[name]
end
-- cb(err, data, status, recv): err is nil on a 2xx with valid JSON; otherwise a string. On
-- an error response with a JSON body (e.g. {"error": "..."}), data is still the decoded
-- body. recv: socket.gettime() when the response arrived (before any fake latency).
local function request(method, path, params, cb)
local url = M.base .. path
local headers = { ["Accept"] = "application/json" }
if player_id then
headers[HEADER] = player_id
end
local body = nil
if method == "GET" then
local q = query(params)
if q ~= "" then
url = url .. "?" .. q
end
else
headers["Content-Type"] = "application/json"
-- An empty Lua table could encode as [] (array); the server wants an object.
body = (params and next(params) ~= nil) and json.encode(params) or "{}"
end
local half = M.fake_latency / 2
local function done(...)
if not cb then
return
end
local args = { ... }
if half > 0 then
timer.delay(half, false, function() cb(unpack(args, 1, 4)) end)
else
cb(...)
end
end
local function send()
http.request(url, method, function(_, _, response)
local recv = socket.gettime()
store_player(header(response.headers, HEADER))
local data = nil
if response.response and response.response ~= "" then
local ok, decoded = pcall(json.decode, response.response)
if ok then
data = decoded
end
end
local status = response.status or 0
local err = nil
if status == 0 then
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
err = "bad JSON"
end
done(err, data, status, recv)
end, headers, body, { timeout = M.timeout })
end
if half > 0 then
timer.delay(half, false, send)
else
send()
end
end
-- The player's state for a view (params: e.g. { s = "moons", moon = 12, well = 34 }).
function M.get_state(params, cb)
request("GET", "/api/state", params, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
reject_count = reject_count + 1
if not reject_n or reject_count == reject_n then
local p = {}
for k, v in pairs(params or {}) do
p[k] = v
end
p["claim-well"] = "00000000-0000-0000-0000-000000000000"
print("api: fake reject of " .. name)
name = "claim"
params = p
end
end
request("POST", "/api/act/" .. urlencode(name), params, cb)
end
return M
+428
View File
@@ -0,0 +1,428 @@
-- Blocks: parts of the layout that a rebuild keeps when what they show hasn't changed.
-- Every new state rebuilds the screen (core/main.lua); a block is built once into a
-- container node and kept, with its nodes and Druid components, by the next builds while
-- its signature (M.sig of everything it shows) stays the same. Regions are blocks
-- (header, moon list, main panel, aside), and so are costly parts inside them (the
-- market's chart, ticker and sparklines, cards).
--
-- local b, kept = blocks.open(g, key, sig, parent) -- build into b.root unless kept
-- if not kept then ... build into b.root ... end
-- local ret = blocks.close(g, b, ...) -- what it returns, kept or not
-- -- or: blocks.keep(g, key, sig, parent, function(root) ... return ... end)
--
-- Build registrations (test ids and keyboard focus, tooltips, tickers, fields, scroll
-- areas, effect anchors, ...) are reset by every build; those made while a block builds
-- are noted (M.note) and replayed in their order when it's kept, so a kept block is in
-- every registry like a rebuilt one. Druid components made while a block builds are its
-- own: they go when it goes (the screen's Druid instance lives on; core/main.lua).
-- A keyed block inside one that is rebuilt survives it (set aside until the new build
-- asks for it); blocks no build asked for go at the end of the build (M.finish).
local helper = require("druid.helper")
local M = {}
local stack = {} -- blocks being built, innermost last
local replaying = false
local orphans = {} -- components made outside any block's build (not a region's)
local function pack(...)
return { n = select("#", ...), ... }
end
local function alive(node)
return node ~= nil and pcall(gui.get_id, node)
end
---------------------------------------------------------------- signatures
local function ser(v, buf)
local t = type(v)
if t == "table" then
buf[#buf + 1] = "{"
local n = #v
for i = 1, n do
ser(v[i], buf)
buf[#buf + 1] = ","
end
local keys, mixed
for k in pairs(v) do
if type(k) ~= "number" or k < 1 or k > n or k % 1 ~= 0 then
keys = keys or {}
keys[#keys + 1] = k
mixed = mixed or type(k) ~= "string"
end
end
if keys then
if mixed then
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
else
table.sort(keys)
end
for _, k in ipairs(keys) do
buf[#buf + 1] = tostring(k)
buf[#buf + 1] = "="
ser(v[k], buf)
buf[#buf + 1] = ";"
end
end
buf[#buf + 1] = "}"
elseif t == "string" then
buf[#buf + 1] = #v .. ":" .. v
elseif t == "function" then
buf[#buf + 1] = "f"
else
buf[#buf + 1] = tostring(v)
end
end
-- A string that is the same exactly when the values are (tables by content, keys sorted).
function M.sig(...)
local buf = {}
for i = 1, select("#", ...) do
ser((select(i, ...)), buf)
buf[#buf + 1] = "|"
end
return table.concat(buf)
end
---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that
-- keeps the block). The registry functions call this; it does nothing outside a block.
function M.note(fn, ...)
local b = stack[#stack]
if replaying or not b then
return
end
local j = b.journal
j[#j + 1] = { fn = fn, n = select("#", ...), ... }
end
-- t[field] (a list reset by every build) gets value, noted.
local function push(t, field, value)
local l = t[field]
if not l then
l = {}
t[field] = l
end
l[#l + 1] = value
end
function M.push(t, field, value)
push(t, field, value)
M.note(push, t, field, value)
end
-- t[field][k] = value (a table reset by every build), noted.
local function put(t, field, k, value)
local m = t[field]
if not m then
m = {}
t[field] = m
end
m[k] = value
end
function M.put(t, field, k, value)
put(t, field, k, value)
M.note(put, t, field, k, value)
end
local replay
-- A kept block inside the one being built: noted in it, its registrations made again.
local function adopt(b)
local top = stack[#stack]
if top then
top.journal[#top.journal + 1] = { block = b }
end
replay(b)
end
replay = function(b)
for _, e in ipairs(b.journal) do
if e.block then
if not e.block.dead then
replay(e.block)
end
else
replaying = true
pcall(e.fn, unpack(e, 1, e.n)) -- (a node that went since: a list row scrolled out)
replaying = false
end
end
end
-- fn, made to run inside the block being built now when it's called later, outside a
-- build (a list making rows as it scrolls, a field's on_change building again): what it
-- makes and registers is that block's.
function M.bound(fn)
local b = stack[#stack]
if not b then
return fn
end
return function(...)
if #stack > 0 or b.dead then
return fn(...)
end
stack[1] = b
local r = pack(pcall(fn, ...))
stack = {}
if not r[1] then
error(r[2], 0)
end
return unpack(r, 2, r.n)
end
end
---------------------------------------------------------------- components
-- Components of g.druid made while a block builds belong to it (core/main.lua hooks the
-- instance's new once per instance).
function M.hook(druid_instance)
local new = druid_instance.new
druid_instance.new = function(self, component, ...)
local c = new(self, component, ...)
if not c._meta.parent then -- (a widget's own components go with it)
local b = stack[#stack]
if b then
b.comps[#b.comps + 1] = c
else
orphans[#orphans + 1] = c
end
end
return c
end
end
-- After a kept block moved under another parent: Druid buttons and hovers clip their
-- clicks to the stencil above them, found when they were made (perhaps deleted with the
-- old parent; a deleted node still answers until the frame ends, so always look again).
local function rezone(b)
for _, c in ipairs(b.comps) do
if c.node and c.set_click_zone and (c.hover or c.on_mouse_hover) then
pcall(c.set_click_zone, c, helper.get_closest_stencil_node(c.node))
end
end
for _, e in ipairs(b.journal) do
if e.block and not e.block.dead then
rezone(e.block)
end
end
end
-- True while a kept block's registrations are made again (test.register looks for its
-- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying()
return replaying
end
local function node_of(c)
return c.node or c.root or c.view_node
end
---------------------------------------------------------------- lifetime
local function blocks_of(g)
g.blocks = g.blocks or { by_key = {}, aside = {}, used = {} }
return g.blocks
end
-- Taken by the build going on (kept or made again under a key).
local function taken(reg, b)
return b.key ~= nil and reg.used[b.key] and reg.by_key[b.key] == b
end
local destroy
-- Removes a block: keyed blocks inside it are set aside (detached, still whole) for the
-- build going on to take back; the rest goes.
destroy = function(g, b, set_aside)
local reg = blocks_of(g)
for _, e in ipairs(b.journal) do
local c = e.block
if c and not c.dead and not taken(reg, c) then
if c.key and set_aside and alive(c.root) then
gui.set_parent(c.root, reg.limbo, false)
reg.aside[#reg.aside + 1] = c
else
destroy(g, c, set_aside)
end
end
end
g.tearing_down = true -- (components going away fire events: core/list, scroll)
for i = #b.comps, 1, -1 do
pcall(g.druid.remove, g.druid, b.comps[i])
end
g.tearing_down = false
b.comps = {}
if alive(b.root) then
gui.delete_node(b.root)
end
if b.key and reg.by_key[b.key] == b then
reg.by_key[b.key] = nil
end
b.dead = true
end
-- Components made outside blocks whose nodes went (the moon list's search filtering
-- rows again, ...).
local function sweep(g)
local keep = {}
for _, c in ipairs(orphans) do
local n = node_of(c)
if n and not alive(n) then
pcall(g.druid.remove, g.druid, c)
elseif n then
keep[#keep + 1] = c
end
end
orphans = keep
end
-- Start of a build. full: nothing is kept (core/main.lua makes the layout and the Druid
-- instance again).
function M.begin(g, full)
local reg = blocks_of(g)
stack = {}
replaying = false
if full then
reg.by_key, reg.aside = {}, {}
orphans = {}
end
reg.used = {}
if not alive(reg.limbo) then
reg.limbo = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
gui.set_enabled(reg.limbo, false)
end
end
-- Open a block under parent. Returns the block (build into b.root) and whether it was
-- kept: the last one with this key had the same sig. key nil: never kept (still a
-- block, so keyed blocks inside it can be).
function M.open(g, key, sig, parent)
local reg = blocks_of(g)
if key and reg.used[key] then
key = nil -- (asked for twice in one build: the second isn't kept)
end
local old = key and reg.by_key[key]
if old and old.dead then
old = nil
end
if old and old.sig == sig and alive(old.root) then
reg.used[key] = true
gui.set_parent(old.root, parent, false)
rezone(old)
adopt(old)
return old, true
end
if old then
destroy(g, old, true)
end
local root = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
gui.set_pivot(root, gui.PIVOT_NW)
gui.set_parent(root, parent, false)
gui.set_visible(root, false) -- (only a container: its children draw)
local b = { key = key, sig = sig, root = root, journal = {}, comps = {} }
if key then
reg.by_key[key] = b
reg.used[key] = true
end
local top = stack[#stack]
if top then
top.journal[#top.journal + 1] = { block = b }
end
stack[#stack + 1] = b
return b, false
end
-- The block open() would keep (same key and sig), without taking it; nil if none.
function M.peek(g, key, sig)
local reg = blocks_of(g)
if not key or reg.used[key] then
return nil
end
local b = reg.by_key[key]
if b and not b.dead and b.sig == sig and alive(b.root) then
return b
end
end
-- Close block b: ... is what its build returns; returns that (kept: what the build that
-- made it returned).
function M.close(g, b, ...)
for i = #stack, 1, -1 do
if stack[i] == b then
for j = #stack, i, -1 do
stack[j] = nil
end
b.ret = { n = select("#", ...), ... }
return ...
end
end
if b.ret then
return unpack(b.ret, 1, b.ret.n)
end
end
-- Open b again to add to it (a card's height, known after its row): what is made until
-- close() is its.
function M.reopen(g, b)
stack[#stack + 1] = b
end
-- Drop block b now (a kept card that has to be built again).
function M.drop(g, b)
local reg = blocks_of(g)
if b.key and reg.used[b.key] and reg.by_key[b.key] == b then
reg.used[b.key] = nil
end
if not b.dead then
destroy(g, b, false)
end
end
-- open + build + close: fn(root) builds; returns what it returns (and that again while
-- the block is kept).
function M.keep(g, key, sig, parent, fn)
local b, kept = M.open(g, key, sig, parent)
if kept then
return M.close(g, b)
end
return M.close(g, b, fn(b.root))
end
-- Destroy a keyed block now (a region not shown this build).
function M.remove(g, key)
local reg = blocks_of(g)
local b = reg.by_key[key]
if b then
destroy(g, b, true)
end
end
-- End of a build: blocks it didn't ask for go.
function M.finish(g)
local reg = blocks_of(g)
stack = {}
for k, b in pairs(reg.by_key) do
if not reg.used[k] then
destroy(g, b, true)
end
end
for _, b in ipairs(reg.aside) do
if not b.dead and not taken(reg, b) then
destroy(g, b, false)
end
end
reg.aside = {}
sweep(g)
end
-- Forget every block (the screen goes away, or before a full rebuild deletes the layout).
function M.clear(g)
local reg = blocks_of(g)
reg.by_key, reg.aside, reg.used = {}, {}, {}
stack, orphans = {}, {}
end
return M
+89
View File
@@ -0,0 +1,89 @@
-- A text field (Druid input on a cloned rect) that keeps its text and focus across
-- layout rebuilds: the screen rebuilds on every new state, so the value and whether it
-- had focus live in g.fields[key], and the new field takes them over (focus included,
-- so typing goes on through a refresh).
local ui = require("modules.ui")
local theme = require("modules.theme")
local test = require("modules.test")
local blocks = require("modules.blocks")
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The stored state of field `key` on screen g: { text, focus }.
function M.get(g, key)
g.fields = g.fields or {}
local f = g.fields[key]
if not f then
f = { text = "", focus = false }
g.fields[key] = f
end
return f
end
-- A field at (x, y), w x h, under parent. opts: hint (placeholder), font, px, allowed
-- (Lua pattern of allowed characters), max_len, keyboard (gui.KEYBOARD_TYPE_*), disabled,
-- unit (text at the right end), on_change(text), on_submit() (Enter while editing, like a
-- web form; screens/game/core/keys), min / max (Up / Down step a number field within them,
-- core/keys), esc_clears (Esc while editing empties it, like type=search). Returns the
-- Druid input (nil when disabled) and the box node.
function M.new(g, parent, key, x, y, w, h, opts)
opts = opts or {}
local f = M.get(g, key)
if opts.disabled then
f.focus = false
end
local px = opts.px or 15
local box = ui.rect(parent, x, y, w, h, "field")
local edge = ui.outline(box, 0, 0, w, h, f.focus and "cyan" or "line_hi")
local right = 12
if opts.unit then
local u = ui.text(box, opts.unit, { font = "mono", px = 12, color = "muted", tracking = 0.08 }, w - 12, h / 2, gui.PIVOT_E)
right = right + ui.text_width(u) + 8
end
local hint = ui.text(box, opts.hint or "", { font = opts.hint_font or opts.font or "sans", px = px, color = "faint" }, 12, h / 2)
local txt = ui.text(box, f.text, { font = opts.font or "sans", px = px, color = "text" }, 12, h / 2)
gui.set_size(txt, vec((w - 12 - right) / gui.get_scale(txt).x, theme.font_px * 1.4))
gui.set_enabled(hint, f.text == "")
if opts.disabled then
return nil, box
end
local input = g.druid:new_input(box, txt, opts.keyboard or gui.KEYBOARD_TYPE_DEFAULT)
test.register("field:" .. key, box, "field", opts.hint)
blocks.push(g, "inputs", { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
esc_clears = opts.esc_clears })
-- (no "<" or "&": the text node would read them as markup)
input:set_allowed_characters(opts.allowed or "[^<&]")
if opts.max_len then
input:set_max_length(opts.max_len)
end
input.style.on_select = function()
gui.set_color(edge, ui.color("cyan"))
end
input.style.on_unselect = function()
gui.set_color(edge, ui.color("line_hi"))
end
input.on_input_select:subscribe(function()
f.focus = true
end)
input.on_input_unselect:subscribe(function()
f.focus = false
end)
input.on_input_text:subscribe(function(_, text)
f.text = text
gui.set_enabled(hint, text == "")
if opts.on_change then
opts.on_change(text)
end
end)
if f.focus then
input:select()
end
return input, box
end
return M
+692
View File
@@ -0,0 +1,692 @@
-- Flex: a pure flexbox layout solver (no Defold calls; plain Lua 5.1+/LuaJIT). It only
-- computes rects; modules/flex_gui.lua puts persistent nodes there.
--
-- local flex = require("modules.flex")
-- local tree = flex.box({ direction = "column", gap = 8, padding = 12, width = 300 }, {
-- flex.box({ height = 22 }), -- fixed
-- flex.leaf(function(avail_w) return text_size(avail_w) end), -- measured (wraps)
-- flex.box({ direction = "row", justify = "space-between" }, { a, b }),
-- })
-- flex.solve(tree, 0, 0) -- or flex.solve(tree, x, y, w, h)
-- tree.children[2].x, .y, .w, .h -- absolute rect of every box
--
-- Coordinates: top-left origin, y grows DOWN (like CSS and like this client's ui.* calls,
-- which take (x, y) and place nodes at vec(x, -y)). Rects are absolute (relative to the
-- origin passed to solve); box.rx/ry are relative to the parent box. flex.gui_pos(box,
-- pivot) gives the Defold GUI position (y up) of a node with that pivot in the box.
--
-- A box: { style = {...}, children = {...}, measure = fn(avail_w) -> w, h, id = any }.
-- measure (leaves: text, images) gets the content width it may use (math.huge when
-- unbounded) and returns its content size; text should wrap to avail_w. Style keys
-- (CSS names, snake_case):
-- direction "row" (default) | "column"
-- wrap false (default) | true ("wrap")
-- gap, row_gap, column_gap px
-- padding n | {t, r, b, l} | {v, h} | {t, h, b}; padding_top/_right/_bottom/_left
-- justify "start" (default) | "end" | "center" | "space-between" | "space-around"
-- | "space-evenly" (justify-content)
-- align "stretch" (default) | "start" | "end" | "center" (align-items)
-- align_self overrides the parent's align for one child
-- grow, shrink (default 0, 1), basis (px | "N%" | "auto")
-- width, height px | "N%" (of the parent's content box) | "auto"
-- min_width, max_width, min_height, max_height px | "N%"
-- display = "none" leaves the box out (no rect: x, y, w, h = nil).
-- aspect_ratio w / h: the missing size follows the given one (a width, or a column's
-- stretch); in a row it isn't stretched (its height follows its width).
-- position = "absolute": out of the flow, placed in its parent's box by left, right,
-- top, bottom (px | "N%"), translate = {fx, fy} (fractions of its own size);
-- flex.anchor places a box the same way against any solved rect (badges,
-- floating numbers, bursts on another tree's box).
-- columns = { tracks }: a grid instead of flex (px, "N%", "auto", "Nfr"; cells' span,
-- rows, justify_items, justify_self): see grid() below. Tables.
-- overflow = "scroll": children don't shrink along the main axis (a scroll area's
-- content); flex.content_size(box) gives the scrolled size.
--
-- Deviations from CSS (on purpose):
-- - box-sizing is border-box: width/height include the padding. There are no borders or
-- margins (use padding, gap or spacer boxes).
-- - min-width/min-height default to 0, not auto (CSS's flex items won't shrink below
-- their min-content; here they do, like `min-width: 0`: labels get cut with ... instead
-- of overflowing).
-- - An auto-sized box is fit-content (max-content, capped by the available width), also
-- at the root and for a column's child that isn't stretched; CSS's block-level flex
-- container would fill the width. Give the root a width.
-- - align-content is always start (lines packed at the top); with wrap and the default
-- align = "stretch", each line's items stretch to the line's tallest one, as in CSS
-- (equal-height rows of cards). No baseline alignment, no order, no *-reverse, no
-- absolute positioning in the flow (position = "absolute" only against the parent).
-- - A % size against an auto (indefinite) parent size is treated as auto, like CSS.
-- - Sizes are not rounded (rects can be fractional; this client places at fractions).
local M = {}
local HUGE = math.huge
local EMPTY = {}
-- Constructors (plain tables work too).
function M.box(style, children, id)
return { style = style or {}, children = children or {}, id = id }
end
function M.leaf(measure, style, id)
return { style = style or {}, measure = measure, id = id }
end
local function len(v, ref)
if v == nil or v == "auto" then
return nil
end
if type(v) == "number" then
return v
end
local p = type(v) == "string" and v:match("^([%d%.%-]+)%%$")
if p then
return ref and ref ~= HUGE and ref * tonumber(p) / 100 or nil
end
error("flex: bad length " .. tostring(v))
end
local function clamp(v, lo, hi)
if hi and v > hi then
v = hi
end
if lo and v < lo then
v = lo
end
return v
end
local function pads(s)
local p = s.padding
local t, r, b, l = 0, 0, 0, 0
if type(p) == "number" then
t, r, b, l = p, p, p, p
elseif type(p) == "table" then
if #p == 1 then
t, r, b, l = p[1], p[1], p[1], p[1]
elseif #p == 2 then
t, r, b, l = p[1], p[2], p[1], p[2]
elseif #p == 3 then
t, r, b, l = p[1], p[2], p[3], p[2]
else
t, r, b, l = p[1], p[2], p[3], p[4]
end
end
return s.padding_top or t, s.padding_right or r, s.padding_bottom or b, s.padding_left or l
end
-- Width/height limits of a box: min, max (px or nil) against the parent's content size.
local function limits(s, cw, ch)
return len(s.min_width, cw) or 0, len(s.max_width, cw), len(s.min_height, ch) or 0, len(s.max_height, ch)
end
local layout, place_abs, grid
-- Children in the flow, and the absolutely positioned ones (nil when none).
local function in_flow(kids)
if not kids then
return EMPTY, nil
end
local abs
for _, c in ipairs(kids) do
if c.style and c.style.position == "absolute" then
abs = abs or {}
abs[#abs + 1] = c
end
end
if not abs then
return kids, nil
end
local flow = {}
for _, c in ipairs(kids) do
if not (c.style and c.style.position == "absolute") then
flow[#flow + 1] = c
end
end
return flow, abs
end
-- Absolutely positioned children of a box w x h (CSS position: absolute against the box's
-- border box): left/right/top/bottom (px or "N%"), width/height (else from both offsets,
-- else content), translate = {fx, fy} (fractions of its own size, CSS translate(-50%, ..)).
-- No offset on an axis: 0 (the start edge).
local function abs_one(c, w, h)
local cs = c.style
if cs.display == "none" then
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
return
end
local l, r, t, bo = len(cs.left, w), len(cs.right, w), len(cs.top, h), len(cs.bottom, h)
local cw, ch = len(cs.width, w), len(cs.height, h)
if not cw and l and r then
cw = math.max(0, w - l - r)
end
if not ch and t and bo then
ch = math.max(0, h - t - bo)
end
local ww, hh = layout(c, cw, ch, w, h)
local x = l or (r and w - r - ww) or 0
local y = t or (bo and h - bo - hh) or 0
local tr = cs.translate
if tr then
x, y = x + (tr[1] or 0) * ww, y + (tr[2] or 0) * hh
end
c.rx, c.ry = x, y
end
function place_abs(abs, w, h)
for _, c in ipairs(abs or EMPTY) do
abs_one(c, w, h)
end
end
-- Grid layout (CSS grid subset): style.columns = list of tracks: px, "N%", "auto"
-- (max-content of its cells), "Nfr" (a share of what's left: minmax(0, Nfr); auto when
-- the width is indefinite). Cells fill rows left to right (style.span on a cell: columns
-- it covers); a row is as tall as its tallest cell (style.rows: fixed heights by row,
-- missing = auto). gap/row_gap/column_gap; align (align-items, default stretch) and
-- justify_items (default stretch), align_self/justify_self on a cell.
function grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
local pt, pr, pb, pl = pads(s)
local px, py = pl + pr, pt + pb
local cols = s.columns
local n = #cols
local cg = s.column_gap or s.gap or 0
local rg = s.row_gap or s.gap or 0
local inner_w = w and (w - px) or (avail_w ~= HUGE and avail_w or nil)
local cells, r, c = {}, 1, 1
for _, k in ipairs(kids) do
local ks = k.style or EMPTY
if ks.display == "none" then
k.x, k.y, k.w, k.h, k.rx, k.ry = nil, nil, nil, nil, nil, nil
else
local span = math.max(1, math.min(n, ks.span or 1))
if c + span - 1 > n then
r, c = r + 1, 1
end
cells[#cells + 1] = { box = k, s = ks, r = r, c = c, span = span }
c = c + span
if c > n then
r, c = r + 1, 1
end
end
end
local nrows = #cells > 0 and cells[#cells].r or 0
local width, fr, frsum, auto = {}, {}, 0, {}
for i, t in ipairs(cols) do
local f = type(t) == "string" and t:match("^([%d%.]+)fr$")
if f then
fr[i] = tonumber(f)
frsum = frsum + fr[i]
if not inner_w then
auto[i] = true
end
else
width[i] = len(t, inner_w)
if not width[i] then
auto[i] = true
end
end
end
local content = {}
for _, cl in ipairs(cells) do
if cl.span == 1 and auto[cl.c] then
local cw = layout(cl.box, len(cl.s.width, inner_w), nil, HUGE, nil)
content[cl.c] = math.max(content[cl.c] or 0, cw)
end
end
local used = cg * (n - 1)
for i = 1, n do
if auto[i] then
width[i] = content[i] or 0
end
if width[i] then
used = used + width[i]
end
end
if frsum > 0 and inner_w then
local free = math.max(0, inner_w - used) / math.max(frsum, 1)
for i = 1, n do
if fr[i] then
width[i] = free * fr[i]
end
end
end
local colx, x = {}, 0
for i = 1, n do
colx[i] = x
x = x + width[i] + cg
end
w = w or clamp(x - cg + px, minw, maxw)
-- Rows: each cell at its width, then the row's height.
local rows, rowh = s.rows or EMPTY, {}
for _, cl in ipairs(cells) do
local cw = colx[cl.c + cl.span - 1] + width[cl.c + cl.span - 1] - colx[cl.c]
local iw = len(cl.s.width, cw)
local js = cl.s.justify_self or s.justify_items or "stretch"
if not iw and js == "stretch" then
iw = cw
end
cl.cw, cl.iw, cl.js = cw, iw, js
local _, ih = layout(cl.box, iw, nil, cw, nil)
cl.ih = ih
local fixed = type(rows[cl.r]) == "number" and rows[cl.r]
rowh[cl.r] = fixed or math.max(rowh[cl.r] or 0, ih)
end
local rowy, y = {}, 0
for i = 1, nrows do
rowy[i] = y
y = y + (rowh[i] or 0) + rg
end
h = h or clamp((nrows > 0 and y - rg or 0) + py, minh, maxh)
for _, cl in ipairs(cells) do
local k, ks, rh = cl.box, cl.s, rowh[cl.r]
local al = ks.align_self or s.align or "stretch"
local auto_h = ks.height == nil or ks.height == "auto"
if al == "stretch" and auto_h and not ks.aspect_ratio and cl.ih ~= rh then
layout(k, k.w, rh, cl.cw, rh)
cl.ih = rh
end
local ox, oy = 0, 0
if cl.js == "end" then
ox = cl.cw - k.w
elseif cl.js == "center" then
ox = (cl.cw - k.w) / 2
end
if al == "end" then
oy = rh - cl.ih
elseif al == "center" then
oy = (rh - cl.ih) / 2
end
k.rx, k.ry = pl + colx[cl.c] + ox, pt + rowy[cl.r] + oy
end
b.w, b.h = w, h
return w, h
end
-- Lay out b's content at border-box size w x h (either may be nil: sized by content),
-- in a parent content box cw x ch (nil/HUGE: unbounded). Sets b.w, b.h and children's
-- rx, ry (relative to b). Returns w, h.
function layout(b, w, h, cw, ch)
local s = b.style or EMPTY
local minw, maxw, minh, maxh = limits(s, cw, ch)
w = w or len(s.width, cw)
h = h or len(s.height, ch)
if w then
w = clamp(w, minw, maxw)
end
if h then
h = clamp(h, minh, maxh)
end
local ar = s.aspect_ratio
if ar then
if w and not h then
h = clamp(w / ar, minh, maxh)
elseif h and not w then
w = clamp(h * ar, minw, maxw)
end
end
local pt, pr, pb, pl = pads(s)
local px, py = pl + pr, pt + pb
-- The width content may take when w is auto: the parent's, capped by max-width.
local avail_w = w and (w - px) or (math.min(cw or HUGE, maxw or HUGE) - px)
local kids, abs = in_flow(b.children)
if #kids == 0 then
if not (w and h) then
local mw, mh = 0, 0
if b.measure then
mw, mh = b.measure(math.max(0, avail_w))
end
w = w or clamp(math.min(mw, math.max(0, avail_w)) + px, minw, maxw)
h = h or (ar and clamp(w / ar, minh, maxh)) or clamp(mh + py, minh, maxh)
end
b.w, b.h = w, h
place_abs(abs, w, h)
return w, h
end
if s.columns then
w, h = grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
place_abs(abs, w, h)
return w, h
end
local row = (s.direction or "row") == "row"
local gap = s.gap or 0
local main_gap = row and (s.column_gap or gap) or (s.row_gap or gap)
local cross_gap = row and (s.row_gap or gap) or (s.column_gap or gap)
local inner_w = w and (w - px) or nil
local inner_h = h and (h - py) or nil
local inner_main, inner_cross = inner_w, inner_h -- (either may be nil: no and/or here)
if not row then
inner_main, inner_cross = inner_h, inner_w
end
-- Where lines break and what text wraps to.
local break_main = row and avail_w or (inner_h or HUGE)
local align = s.align or "stretch"
-- Flex base sizes (outer main size; padding counts: border-box).
local items = {}
for _, c in ipairs(kids) do
local cs = c.style or EMPTY
if cs.display == "none" then
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
else
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
local it = { box = c, s = cs, grow = cs.grow or 0, shrink = s.overflow == "scroll" and 0 or cs.shrink or 1 }
if row then
it.min, it.max = cminw, cmaxw
else
it.min, it.max = cminh, cmaxh
end
local self_align = cs.align_self or align
it.stretch = self_align == "stretch" and not (row and cs.aspect_ratio)
it.align = self_align
local main_style = cs.width
if not row then
main_style = cs.height
end
local base = len(cs.basis, inner_main) or len(main_style, inner_main)
if not base then
if row then
base = layout(c, nil, nil, avail_w, inner_h)
else
-- A column's child: its width first (stretched, set, or content), then
-- its height at that width.
local cwid = len(cs.width, inner_w)
if not cwid and it.stretch and inner_w then
cwid = clamp(inner_w, cminw, cmaxw)
end
local _, hh = layout(c, cwid, nil, avail_w, inner_h)
base = hh
end
end
it.base = base
it.hyp = clamp(base, it.min, it.max)
items[#items + 1] = it
end
end
-- Lines.
local lines, cur, used = {}, {}, 0
for _, it in ipairs(items) do
local add = it.hyp + (#cur > 0 and main_gap or 0)
if s.wrap and #cur > 0 and used + add > break_main + 1e-6 then
lines[#lines + 1] = cur
cur, used = {}, 0
add = it.hyp
end
cur[#cur + 1] = it
used = used + add
end
if #cur > 0 then
lines[#lines + 1] = cur
end
-- Main sizes per line (CSS "resolve flexible lengths", with freezing at min/max).
local content_main = 0
for _, ln in ipairs(lines) do
local gaps = main_gap * (#ln - 1)
local hyp_sum = gaps
for _, it in ipairs(ln) do
hyp_sum = hyp_sum + it.hyp
it.size, it.frozen = it.hyp, false
end
local space = inner_main
if not space and row and hyp_sum > avail_w then
space = avail_w -- (an auto-width row is fit-content: it shrinks to what's there)
end
if space then
local growing = hyp_sum < space
for _, it in ipairs(ln) do
if (growing and it.grow == 0) or (not growing and it.shrink == 0)
or (growing and it.base > it.hyp) or (not growing and it.base < it.hyp) then
it.frozen = true
end
end
for _ = 1, #ln + 1 do
local free, sum = space - gaps, 0
local active = false
for _, it in ipairs(ln) do
if it.frozen then
free = free - it.size
else
free = free - it.base
sum = sum + (growing and it.grow or it.shrink * it.base)
active = true
end
end
if not active then
break
end
if growing and sum < 1 then
free = free * sum -- (CSS: grow factors under 1 take that share)
end
local total_violation = 0
for _, it in ipairs(ln) do
if not it.frozen then
local share = sum > 0 and (growing and it.grow or it.shrink * it.base) / sum or 0
local t = it.base + free * share
local c = clamp(t, it.min, it.max)
it.size, it.viol = c, c - t
total_violation = total_violation + it.viol
end
end
for _, it in ipairs(ln) do
if not it.frozen and (total_violation == 0 or (total_violation > 0 and it.viol > 0)
or (total_violation < 0 and it.viol < 0)) then
it.frozen = true
end
end
end
end
local m = gaps
for _, it in ipairs(ln) do
m = m + it.size
end
ln.main = m
content_main = math.max(content_main, m)
end
-- Cross sizes: each item at its main size; line cross = its tallest item (a single
-- line in a box of definite cross size takes that size).
local content_cross = 0
for li, ln in ipairs(lines) do
local lc = 0
for _, it in ipairs(ln) do
local c, cs = it.box, it.s
local cw2, ch2
if row then
cw2, ch2 = layout(c, it.size, nil, inner_w, inner_h)
it.cross = ch2
else
local cwid = len(cs.width, inner_w)
if not cwid and it.stretch and inner_w then
cwid = inner_w
end
cw2 = layout(c, cwid, it.size, inner_w, inner_h)
it.cross = cw2
end
lc = math.max(lc, it.cross)
end
if #lines == 1 and inner_cross then
lc = inner_cross
end
ln.cross = lc
content_cross = content_cross + lc + (li > 1 and cross_gap or 0)
end
-- The box's own size.
if row then
w = w or clamp(content_main + px, minw, maxw)
h = h or clamp(content_cross + py, minh, maxh)
else
w = w or clamp(content_cross + px, minw, maxw)
h = h or clamp(content_main + py, minh, maxh)
end
inner_w, inner_h = w - px, h - py
inner_main = row and inner_w or inner_h
if #lines == 1 then
lines[1].cross = row and inner_h or inner_w
end
-- Stretch, then place.
local cross_pos = 0
for _, ln in ipairs(lines) do
local free = inner_main - ln.main
local start, between = 0, main_gap
local j = s.justify or "start"
local n = #ln
if j == "end" then
start = free
elseif j == "center" then
start = free / 2
elseif j == "space-between" and n > 1 and free > 0 then
between = main_gap + free / (n - 1)
elseif j == "space-around" and free > 0 then
start, between = free / n / 2, main_gap + free / n
elseif j == "space-evenly" and free > 0 then
start, between = free / (n + 1), main_gap + free / (n + 1)
end
local pos = start
for _, it in ipairs(ln) do
local c, cs = it.box, it.s
local cross_style
if row then
cross_style = cs.height
else
cross_style = cs.width
end
local auto_cross = cross_style == nil or cross_style == "auto"
if it.stretch and auto_cross then
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
local target = row and clamp(ln.cross, cminh, cmaxh) or clamp(ln.cross, cminw, cmaxw)
if target ~= it.cross then
if row then
layout(c, it.size, target, inner_w, inner_h)
else
layout(c, target, it.size, inner_w, inner_h)
end
it.cross = target
end
end
local off = 0
if it.align == "end" then
off = ln.cross - it.cross
elseif it.align == "center" then
off = (ln.cross - it.cross) / 2
end
if row then
c.rx, c.ry = pl + pos, pt + cross_pos + off
else
c.rx, c.ry = pl + cross_pos + off, pt + pos
end
pos = pos + it.size + between
end
cross_pos = cross_pos + ln.cross + cross_gap
end
b.w, b.h = w, h
place_abs(abs, w, h)
return w, h
end
local function place(b, x, y)
b.x, b.y = x, y
for _, c in ipairs(b.children or EMPTY) do
if c.rx then
place(c, x + c.rx, y + c.ry)
end
end
end
-- Solve the tree with its top-left at (x, y); w, h force the root's size (otherwise its
-- style's, or its content's, capped by avail_w when given). Returns the root.
function M.solve(root, x, y, w, h, avail_w)
root.rx, root.ry = 0, 0
layout(root, w, h, avail_w, nil)
place(root, x or 0, y or 0)
return root
end
-- Anchor a box to an already solved rect (a box of another tree, a button's rect: x, y,
-- w, h, y down) like CSS position: absolute inside it: style left/right/top/bottom,
-- width/height, translate (see abs_one). Solves box (and its children) and returns it.
-- flex.anchor(button_box, flex.box({ top = -6, right = -6, translate = {0, 0} }, ...))
-- flex.anchor(r, flex.leaf(m, { left = "50%", bottom = "100%", translate = { -0.5, 0 } }))
function M.anchor(target, box)
box.style = box.style or {}
abs_one(box, target.w, target.h)
place(box, target.x + box.rx, target.y + box.ry)
return box
end
-- The scrollable content size of a solved box (CSS scrollWidth/scrollHeight): its size, or
-- the extent of its descendants plus its right/bottom padding when they reach further.
-- A box whose content should overflow (a scroll area of fixed height) wants style
-- overflow = "scroll": its children then don't shrink along its main axis.
function M.content_size(b)
local mx, my = 0, 0
local function walk(p)
for _, c in ipairs(p.children or EMPTY) do
if c.x then
mx = math.max(mx, c.x + c.w - b.x)
my = math.max(my, c.y + c.h - b.y)
walk(c)
end
end
end
walk(b)
local _, pr, pb = pads(b.style or EMPTY)
return math.max(b.w, mx + pr), math.max(b.h, my + pb)
end
-- Boxes by id (after solve).
function M.index(root, out)
out = out or {}
if root.id ~= nil then
out[root.id] = root
end
for _, c in ipairs(root.children or EMPTY) do
M.index(c, out)
end
return out
end
-- Grid columns for a width (CSS grid's repeat(auto-fill, minmax(min, 1fr))): the count and
-- the column width, in whole pixels (what's left over stays at the right).
function M.columns(w, min, gap)
local cols = math.max(1, math.floor((w + gap) / (min + gap)))
return cols, math.floor((w - gap * (cols - 1)) / cols)
end
-- A grid as a wrapping row w wide: each child gets the column width (M.columns); with the
-- default align = "stretch" a row is as tall as its tallest item. style: more of the
-- row's (row_gap, ...).
function M.grid(w, min, gap, children, style)
local _, cw = M.columns(w, min, gap)
for _, c in ipairs(children) do
c.style = c.style or {}
c.style.width, c.style.shrink = cw, 0
end
local s = { direction = "row", wrap = true, gap = gap, width = w }
for k, v in pairs(style or EMPTY) do
s[k] = v
end
return M.box(s, children)
end
-- flex.apply(tree, nodes, opts): put persistent GUI nodes on a solved tree (Defold only:
-- modules/flex_gui.lua, which documents it).
local GUI = "modules.flex_gui" -- (by a name bob doesn't follow: flex_gui requires flex)
function M.apply(tree, nodes, opts)
return require(GUI).apply(tree, nodes, opts)
end
-- Where a node with pivot (0..8 as Defold's gui.PIVOT_*: CENTER, N, NE, E, SE, S, SW, W,
-- NW) sits for the rect x, y, w, h (y down): returns its GUI position x, y (y up).
local PIVOT = { [0] = { 0.5, 0.5 }, { 0.5, 0 }, { 1, 0 }, { 1, 0.5 }, { 1, 1 }, { 0.5, 1 }, { 0, 1 }, { 0, 0.5 }, { 0, 0 } }
function M.gui_pos(r, pivot, ox, oy)
local p = PIVOT[pivot or 8] or PIVOT[8]
return r.x - (ox or 0) + r.w * p[1], -(r.y - (oy or 0) + r.h * p[2])
end
return M
+308
View File
@@ -0,0 +1,308 @@
-- Puts persistent GUI nodes where modules/flex solved their boxes. It never creates,
-- deletes, reparents or restyles a node; it sets position (by the node's pivot) and,
-- unless box.size == false, size (divided by the node's scale: a wrapping text node gets
-- its wrap width), and only when they changed.
--
-- Rule: layout owns an outer "slot" node per box (an anim.group, a card's root); an
-- animation owns the visual nodes inside it (like CSS transform vs layout). Give a box
-- only slot nodes, never one that something else animates (gui.animate on position or
-- size); boxes without a node are just geometry (their children's nodes are placed
-- relative to the nearest ancestor box that has one, or to the tree's origin).
--
-- flex.solve(tree, 0, 0, w)
-- flex_gui.apply(tree) -- box.node on the boxes
-- flex_gui.apply(tree, { head = n1, foot = n2 }) -- or nodes by box id
-- flex_gui.apply(tree, nil, { glide = 0.2 }) -- tween what moved (FLIP), default snap
--
-- glide: a node that already sits somewhere tweens from there to its new rect (eased
-- out, `glide` seconds); a later apply retargets it. Off under reduced motion.
local flex = require("modules.flex")
local anim = require("modules.anim")
local ui = require("modules.ui")
local theme = require("modules.theme")
local S = theme.size
local M = {}
local EPS = 0.01
local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end
local function put(node, prop, value, cur, glide)
if not differs(cur, value) then
return
end
if glide then
gui.animate(node, prop, value, gui.EASING_OUTQUAD, glide)
elseif prop == "position" then
gui.set_position(node, value)
else
gui.set_size(node, value)
end
end
local function walk(b, nodes, ox, oy, glide)
if not b.x then
return -- (display = "none")
end
local node = b.node or (nodes and b.id ~= nil and nodes[b.id])
if node then
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
local pos = gui.get_position(node)
put(node, "position", vmath.vector3(px, py, pos.z), pos, glide)
if b.size ~= false then
local sc = gui.get_scale(node)
put(node, "size", vmath.vector3(b.w / sc.x, b.h / sc.y, 0), gui.get_size(node), glide)
end
ox, oy = b.x, b.y
end
for _, c in ipairs(b.children or {}) do
walk(c, nodes, ox, oy, glide)
end
end
-- Apply a solved tree. nodes: optional { [box id] = node }. opts.glide: seconds.
-- opts.origin: {x, y} the tree's coordinates of the root nodes' parent (default 0, 0).
function M.apply(tree, nodes, opts)
opts = opts or {}
local glide = opts.glide and opts.glide > 0 and not anim.reduced and opts.glide or nil
local o = opts.origin
walk(tree, nodes, o and o[1] or 0, o and o[2] or 0, glide)
return tree
end
-- A measure function for a text node (flex.leaf): its metrics at the width it gets;
-- wrapping nodes (line break on) wrap to it. lh: line height in px (CSS line-height:
-- lines * lh), else the font's own line metrics.
function M.text_measure(node, lh)
return function(avail)
local s = gui.get_scale(node)
local font = gui.get_font_resource(gui.get_font(node))
local wrap = gui.get_line_break(node)
local opts = { tracking = gui.get_tracking(node), line_break = wrap, leading = lh and 1 or gui.get_leading(node) }
if wrap then
opts.width = avail / s.x
end
local m = resource.get_text_metrics(font, gui.get_text(node), opts)
if lh then
local one = resource.get_text_metrics(font, "Ag")
local lines = math.max(1, math.floor(m.height / (one.max_ascent + one.max_descent) + 0.5))
return m.width * s.x, lines * lh
end
return m.width * s.x, m.height * s.y
end
end
-- Building a section from one tree (M.build): describe it with boxes, leaves made by
-- M.label / M.para (their text node is made now, under any parent, for its metrics) and
-- boxes with make = fn(parent, x, y, w, h) for what is built at a size (frames, buttons,
-- bars); flex.solve it; then M.build makes the nodes.
-- One line of text (ui.text, pivot W: its middle at the box's) in a line box h tall, as
-- wide as its text (trailing spaces count). opts: more box style; opts.fit cuts the text
-- with ... to the width it gets. Text with symbols (ui.needs_rich) goes through ui.rich
-- (not cut).
function M.label(parent, str, style, h, opts)
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W)
local s = opts or {}
s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end }
end
local n = ui.text(parent, str, style, 0, 0)
local w, t = ui.text_width(n), gui.get_text(n)
if t:match(" $") then -- (metrics leave trailing spaces out)
gui.set_text(n, t .. "|")
w = ui.text_width(n)
gui.set_text(n, "|")
w = w - ui.text_width(n)
gui.set_text(n, t)
end
local s = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = n, size = false, fit = fit, style = s, measure = function() return w, h end }
end
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more
-- box style (max_width, ...).
function M.para(parent, str, style, lh, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line)
return { node = n, shift = (lh - line) / 2, style = opts or {}, measure = M.text_measure(n, lh) }
end
-- Tree pieces (shared by the flex-first sections). Children lists may have holes
-- (`cond and x or nil`); extra: more fields on the box (make, done, id, ...).
function M.box(style, kids, extra)
local list = {}
for i = 1, table.maxn(kids or {}) do
list[#list + 1] = kids[i]
end
local b = { style = style or {}, children = list }
for k, v in pairs(extra or {}) do
b[k] = v
end
return b
end
function M.row(style, kids, extra) style.direction = "row"; return M.box(style, kids, extra) end
function M.col(style, kids, extra) style.direction = "column"; return M.box(style, kids, extra) end
function M.space(w, h) return { style = { width = w or 0, height = h or 0, shrink = 0 } } end
function M.fill() return { style = { grow = 1 } } end
-- A box with make = fn(parent, x, y, w, h) (built at its solved rect).
function M.made(style, make, kids) return M.box(style, kids, { make = make }) end
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
-- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts)
local s = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = node, size = false, fit = fit, style = s, measure = function() return ui.text_width(node), h end }
end
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the
-- width it gets. opts: more box style (max_width = S.text_max, ...).
function M.text_para(parent, str, style, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
return { node = n, style = opts or {}, measure = M.text_measure(n) }
end
-- A component that places its own parts (cards.parts, a ui.credits amount): built by
-- fn(parent, x, y, w) -> h[, w] at its rect; its size is measured first by building it
-- on a probe under parent (deleted at once; once per width).
function M.measured(parent, fn, style)
local cache = {}
return { style = style or {}, measure = function(avail)
local finite = avail == avail and avail < math.huge
local k = finite and math.floor(avail + 0.5) or -1
if not cache[k] then
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local h, w = fn(probe, 0, 0, finite and avail or 10000)
gui.delete_node(probe)
cache[k] = { w or (finite and avail or 0), h or 0 }
end
return cache[k][1], cache[k][2]
end, make = function(p, x, y, w)
fn(p, x, y, w)
end }
end
-- A one-line component that places itself at a middle y and returns its width (chips,
-- tags, stat.inline, ui.credits): a box h tall, as wide as fn measures on a probe
-- (fn(probe, 0, 0, true): register nothing then); made by fn(parent, x, y_middle).
-- style: more box style.
function M.inline(parent, h, fn, style)
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local w = fn(probe, 0, 0, true) or 0
gui.delete_node(probe)
style = style or {}
style.width, style.height = style.width or w, h
if style.shrink == nil then
style.shrink = 0
end
return { style = style, make = function(p, x, y)
fn(p, x, y + h / 2)
end }
end
-- A page that mixes trees with components that lay themselves out and must be built in
-- place (card.grid's kept cards): parts top to bottom, w wide from (x, y); a part is a
-- box (solved at that width, built under parent) or fn(y) -> the y under it. Returns the
-- bottom.
function M.flow(parent, x, y, w, parts)
for i = 1, table.maxn(parts) do
local part = parts[i]
if type(part) == "function" then
y = part(y)
elseif part then
part.style = part.style or {}
part.style.width = part.style.width or w
M.build(flex.solve(part, x, y), parent)
y = part.y + part.h
end
end
return y
end
-- The width ui.button gives a label with these opts (a probe, deleted at once).
function M.button_w(parent, label, opts)
local b, _, w = ui.button(parent, label, 0, 0, opts)
ui.button_looks[b] = nil
gui.delete_node(b)
return w
end
-- A ui.button box: as wide as ui.button makes it (probe; style.width or grow override),
-- its height the button's; made at its rect, then bind(b, nodes, w) (H.action, ...).
function M.button(parent, label, opts, bind, style)
style = style or {}
style.height = style.height or opts.h or (opts.small and S.button_sm_h or S.button_h)
style.shrink = style.shrink or 0
if not style.width and not style.grow then
style.width = M.button_w(parent, label, opts)
end
return { style = style, make = function(p, x, y, w)
opts.w = w
local b, nodes = ui.button(p, label, x, y, opts)
if bind then
bind(b, nodes, w)
end
return nil
end }
end
-- Make the nodes of a solved tree under parent (the tree's origin), in tree order (which
-- is focus and Druid input order). A box with make gets its rect rounded to whole pixels,
-- relative to the nearest node above it (top-left, y down, like ui.*); the node it
-- returns, if any, holds its children. A box with a node (M.label, M.para) is moved under
-- that node and placed by its pivot (sized unless size == false; fit: cut to its width).
-- Then box.done(node, box), if any.
function M.build(tree, parent)
local function walk(b, p, ox, oy)
if not b.x then
return -- (display = "none")
end
local node = b.node
if b.make then
local x, y = math.floor(b.x + 0.5), math.floor(b.y + 0.5)
local w, h = math.floor(b.x + b.w + 0.5) - x, math.floor(b.y + b.h + 0.5) - y
node = b.make(p, x - ox, y - oy, w, h)
b.node = node
if node then
p, ox, oy = node, x, y
end
elseif node then
gui.set_parent(node, p, false)
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
gui.set_position(node, vmath.vector3(px, py - (b.shift or 0), gui.get_position(node).z))
if b.size ~= false then
local sc = gui.get_scale(node)
gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
end
if b.fit then
ui.fit_text(node, b.w)
end
p, ox, oy = node, b.x, b.y
end
if b.done then
b.done(node, b)
end
for _, c in ipairs(b.children or {}) do
walk(c, p, ox, oy)
end
end
walk(tree, parent, 0, 0)
return tree
end
return M
+406
View File
@@ -0,0 +1,406 @@
-- Keyboard focus (the web's Tab focus and :focus-visible ring). Every interactive element
-- the components register for the test hook (modules/test: buttons, links, tabs, rows,
-- fields, dialog buttons) is also registered here, in build order (= the page's reading
-- order: banner, header, moon list, main panel, aside), so Tab / Shift+Tab walk them and
-- Enter or Space presses the focused one: a real press and release through Druid at its
-- center (the same path a mouse click takes, so busy states, sounds and confirms all
-- apply). A field takes the press as a click and starts editing.
--
-- The focused element is kept by its id across rebuilds (every new state rebuilds the
-- page); when it goes away, Tab goes on from where it was. While a dialog is open only its
-- buttons take focus, and while a new moon's reveal card is up only the card's (scope
-- "reveal"); closing it goes back to the page's. An element in a scroll area is
-- scrolled into view when it takes focus. The ring (2 px, 2 px out, like the web's
-- outline) shows only after a key moved the focus; a mouse click hides it.
--
-- Not Druid's navigation_handler (Widget Store): that one is directional (arrow keys),
-- needs Druid button instances (ours are made again every build), and has no scopes.
local ui = require("modules.ui")
local screen = require("modules.screen")
local tooltip = require("modules.tooltip")
local M = {}
local entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page"
local seen = {} -- key -> count in this build (duplicate ids get "#n")
local function alive(node)
return pcall(gui.get_id, node)
end
-- The stencil node clipping a node (a scroll view, a list), if any.
local function zone_of(node)
local n = gui.get_parent(node)
while n do
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE then
return n
end
n = gui.get_parent(n)
end
end
-- Start of a build of `which` ("page" or "dialog"): its old elements go.
function M.begin_build(which)
which = which or "page"
local keep = {}
for _, e in ipairs(entries) do
if e.scope ~= which then
keep[#keep + 1] = e
end
end
entries = keep
seen = {}
for _, e in ipairs(entries) do
seen[e.base] = (seen[e.base] or 0) + 1
end
end
function M.set_scope(which)
scope = which or "page"
end
-- Register an element (modules/test forwards every registration).
function M.add(id, node, kind, label, zone)
if not node then
return
end
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
seen[base] = (seen[base] or 0) + 1
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end
-- The keys in the focus order (scope:id), for the test driver.
function M.keys()
local out = {}
for _, e in ipairs(entries) do
out[#out + 1] = e.key
end
return out
end
---------------------------------------------------------------- geometry
local PIVOT = { -- (fraction of the size left of / above the pivot point)
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
local function screen_at(node, lx, ly)
local probe = gui.new_box_node(vmath.vector3(lx, ly, 0), vmath.vector3(1, 1, 0))
gui.set_parent(probe, node, false)
local p = gui.get_screen_position(probe)
gui.delete_node(probe)
return p
end
-- A node's rectangle: in layout units (x, y top-left, y down; w, h) and its center in
-- action coordinates (ax, ay) and window pixels (sx, sy).
function M.rect(node)
local size = gui.get_size(node)
local k = PIVOT[gui.get_pivot(node)] or { 0.5, 0.5 }
local a = screen_at(node, -k[1] * size.x, k[2] * size.y)
local b = screen_at(node, (1 - k[1]) * size.x, -(1 - k[2]) * size.y)
local W, H = screen.layout()
local ww, wh = window.get_size()
ww, wh = math.max(1, ww), math.max(1, wh)
local sx, sy = (a.x + b.x) / 2, (a.y + b.y) / 2
local ax, ay = sx, sy
if not gui.pick_node(node, ax, ay) then
-- action coordinates span the display size (game.project), not the window
ax = sx * sys.get_config_int("display.width", 1280) / ww
ay = sy * sys.get_config_int("display.height", 800) / wh
end
return {
x = math.min(a.x, b.x) * W / ww, y = (wh - math.max(a.y, b.y)) * H / wh,
w = math.abs(b.x - a.x) * W / ww, h = math.abs(b.y - a.y) * H / wh,
ax = ax, ay = ay, sx = sx, sy = sy,
}
end
---------------------------------------------------------------- state
local function state(g)
g.focus = g.focus or { key = nil, index = 0, visible = false, page_key = nil }
return g.focus
end
local function visible(node)
return alive(node) and gui.is_enabled(node, true)
end
-- The scope taking focus now: "dialog", "reveal" or "page".
local function current_scope(g)
if g.dialog and g.dialog:is_open() then
return "dialog"
elseif g.reveal and g.reveal:card_done() then
return "reveal"
end
return "page"
end
-- The elements that can take focus now, in order.
local function list(g)
local want = current_scope(g)
local out = {}
for _, e in ipairs(entries) do
if e.scope == want and visible(e.node) then
local z = e.zone or zone_of(e.node)
-- In a clipped view: only when it shows, or its view scrolls (components/scroll).
local ok = true
if z and alive(z) then
local r = M.rect(e.node)
ok = gui.pick_node(z, r.ax, r.ay)
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z and not a.list then
ok = true
end
end
end
if ok then
out[#out + 1] = e
end
end
end
return out, want
end
local function find(g, key)
if not key then
return nil
end
for _, e in ipairs(entries) do
if e.key == key and visible(e.node) then
return e
end
end
end
-- The focused element (nil when none, or it went away).
function M.current(g)
return find(g, state(g).key)
end
-- Scroll a scroll area so the element shows (the layout rebuilds).
local function into_view(g, e)
local z = e.zone or zone_of(e.node)
if not z then
return
end
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z then
local r, v = M.rect(e.node), M.rect(z)
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
local to = pos
if r.y < v.y + 4 then
to = pos - (v.y + 4 - r.y) - 8
elseif r.y + r.h > v.y + v.h - 4 then
to = pos + (r.y + r.h - (v.y + v.h - 4)) + 8
end
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty, g.dirty_all = true, true
end
return
end
end
end
-- The long list (components/list with focus_id) whose view clips an element, if any.
local function list_of(g, e)
local z = e and (e.zone or zone_of(e.node))
if not z then
return nil
end
for _, a in ipairs(g.scroll_areas or {}) do
if a.view == z and a.list and a.list.focus_id then
return a
end
end
end
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- the item before / after the focused row (or the first / last when coming from outside),
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
-- when it moved the focus.
local function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur)
local items, i
if a then
items = a.list.items
local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do
if a.list.focus_id(item) == id then
i = n + dir
end
end
if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page)
end
else
a = list_of(g, e)
if not a then
return false
end
items = a.list.items
i = dir > 0 and 1 or #items
end
if not items[i] then
return false
end
local key = scope_ .. ":" .. a.list.focus_id(items[i])
f.key, f.visible = key, true
local slot = a.list.row_h or 1
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
local top = (i - 1) * slot
local to = pos
if top < pos then
to = top
elseif top + slot > pos + a.h then
to = top + slot - a.h
end
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty, g.dirty_all = true, true
end
local now = find(g, key)
f.tip = now and tooltip.show_for(now.node, M.rect(now.node)) or false
return true
end
-- Tab (dir 1) / Shift+Tab (dir -1).
function M.move(g, dir)
local f = state(g)
local l = list(g)
if #l == 0 then
return
end
local i
for n, e in ipairs(l) do
if e.key == f.key then
i = n
end
end
if not i then
-- Nothing focused (or it went away): from where it was, else the first / last.
i = f.key and math.max(0, math.min(#l, f.index - (dir > 0 and 1 or 0))) or (dir > 0 and 0 or #l + 1)
end
local cur = find(g, f.key)
i = (i + dir - 1) % #l + 1
local e = l[i]
if list_step(g, f, cur, e, dir, current_scope(g)) then
f.index = i
return
end
f.key, f.index, f.visible = e.key, i, true
into_view(g, e)
f.tip = tooltip.show_for(e.node, M.rect(e.node))
end
-- Enter / Space: press the focused element. input(action_id, action) is the screen's
-- input. Returns true when something was pressed.
function M.activate(g, input)
local f = state(g)
local e = (f.visible or f.soft) and M.current(g)
if not e then
return false
end
local r = M.rect(e.node)
local function a(t)
t.x, t.y, t.screen_x, t.screen_y = r.ax, r.ay, r.sx, r.sy
t.dx, t.dy, t.screen_dx, t.screen_dy = 0, 0, 0, 0
return t
end
tooltip.hide()
input(hash("touch"), a({ pressed = true }))
input(hash("touch"), a({ released = true }))
return true
end
-- A mouse click: the ring hides (focus stays where it was for the next Tab).
function M.pointer_click(g)
local f = state(g)
f.visible, f.soft = false, false
end
-- Whether a key moved the focus (the ring shows).
function M.showing(g)
local f = state(g)
return (f.visible or f.soft) and M.current(g) ~= nil
end
---------------------------------------------------------------- ring
local RING = 2 -- px
local GAP = 2 -- px outside the element
local COLORS = { danger = "danger", redrill = "violet" }
-- Every frame: draw the ring around the focused element (layer: a node over the page),
-- and switch scope when a dialog opens or closes.
function M.update(g, layer)
local f = state(g)
local want = current_scope(g)
if want ~= (f.scope or "page") then
-- A dialog (or the reveal card) opened: focus its first button (if the keyboard
-- was in use); closed: back to the page's element.
local from = f.scope or "page"
f.scope = want
if want ~= "page" then
if from == "page" then
f.page_key, f.page_index = f.key, f.index
end
f.key, f.index = nil, 0
-- A confirm's OK takes the focus (the web's autofocus): Enter confirms even
-- after a mouse click (no ring then: soft focus).
-- (by key: while the panel unclips, its buttons aren't in list(g) yet)
local ok = find(g, want .. ":dialog:ok")
if ok then
f.key, f.index = ok.key, 1
end
if ok then
f.soft = not f.visible
elseif f.visible then
M.move(g, 1)
end
else
f.key, f.index, f.soft = f.page_key, f.page_index or 0, false
end
end
if not f.ring or not alive(f.ring) then
f.ring = ui.rect(layer, 0, 0, 1, 1, "none")
f.sides = {}
for i = 1, 4 do
f.sides[i] = ui.rect(f.ring, 0, 0, 1, 1, "cyan")
end
end
local e = f.visible and M.current(g)
gui.set_enabled(f.ring, e and true or false)
if not e then
if f.tip then
tooltip.hide() -- (the focused element's tooltip)
f.tip = false
end
return
end
local r = M.rect(e.node)
local x, y, w, h = r.x - GAP - RING, r.y - GAP - RING, r.w + (GAP + RING) * 2, r.h + (GAP + RING) * 2
gui.set_position(f.ring, vmath.vector3(x, -y, 0))
local look = ui.button_looks[e.node]
local c = ui.color(look and COLORS[look.variant] or "cyan")
local s = f.sides
local rects = { { 0, 0, w, RING }, { 0, h - RING, w, RING }, { 0, 0, RING, h }, { w - RING, 0, RING, h } }
for i = 1, 4 do
local q = rects[i]
gui.set_position(s[i], vmath.vector3(q[1], -q[2], 0))
gui.set_size(s[i], vmath.vector3(q[3], q[4], 0))
gui.set_color(s[i], c)
end
end
return M
File diff suppressed because it is too large. Load diff
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff. Show more