Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a47c4ba716 | ||
|
|
1897fc9905 | ||
|
|
7ec439f248 | ||
|
|
8faa1b131f | ||
|
|
6caae5d3b4 | ||
|
|
dbbc194052 | ||
|
|
d3ea0bfc89 | ||
|
|
bafa5e0bce | ||
|
|
fe86565b55 | ||
|
|
f564890ac0 | ||
|
|
ef17e3b1d9 | ||
|
|
909cdc7412 | ||
|
|
07b4185cc3 | ||
|
|
dd57a34a6d | ||
|
|
0db237c1a5 | ||
|
|
2723bbd842 | ||
|
|
76293f2cc1 | ||
|
|
bfc066a1b3 | ||
|
|
09d09cbc52 | ||
|
|
97ed243b34 | ||
|
|
63d8f633d3 | ||
|
|
daa6c9bc5a | ||
|
|
850e2a136a | ||
|
|
48bc94ad76 | ||
|
|
508ff12e57 | ||
|
|
30a2548b2a | ||
|
|
00391580ce | ||
|
|
103d4b4ac2 | ||
|
|
59812479cf | ||
|
|
34b8846976 | ||
|
|
f52f28d2e9 | ||
|
|
4a02faeee4 | ||
|
|
45246e8399 | ||
|
|
f5a65d53cb | ||
|
|
d800baf9a9 | ||
|
|
ffbe30fa78 | ||
|
|
1462e2e9f1 | ||
|
|
ef4eea8147 | ||
|
|
da27a9ca02 | ||
|
|
e040e3b986 | ||
|
|
6dd70e4cae | ||
|
|
450c371e43 | ||
|
|
8bbe20ba25 | ||
|
|
1487f1bdcd | ||
|
|
63360022c8 | ||
|
|
9d6f912f13 | ||
|
|
c8daa086e1 | ||
|
|
d2b4f62949 | ||
|
|
0450ecd05a | ||
|
|
e9c1e344b8 | ||
|
|
32b3465c46 | ||
|
|
0bf4779903 | ||
|
|
ba09caf66f | ||
|
|
70ebafa31d | ||
|
|
742e071434 | ||
|
|
11d94d38f0 | ||
|
|
d1d9d51d5d | ||
|
|
ed59c6ae86 | ||
|
|
4c93f0471f | ||
|
|
9c9a2697a8 | ||
|
|
7b6f0b75f9 | ||
|
|
c7839bb2ef | ||
|
|
29c8034bf0 | ||
|
|
b7c6a1c264 | ||
|
|
6fb4392438 | ||
|
|
92c23f574f | ||
|
|
4a4897ffb0 | ||
|
|
1ea59b5d70 | ||
|
|
e805f97a4c | ||
|
|
bdc8d311be | ||
|
|
9d512f5abc | ||
|
|
8293fdca22 | ||
|
|
a21d5a4371 | ||
|
|
b4b6d22113 | ||
|
|
7ec64c39a3 | ||
|
|
9d4b712d8b | ||
|
|
8ecfa5753e | ||
|
|
c31dcca280 | ||
|
|
bfa3e1ce65 | ||
|
|
59f26dc5af | ||
|
|
bb6b34c1b6 | ||
|
|
302bb2b764 | ||
|
|
90722d1c04 | ||
|
|
22dca72f39 | ||
|
|
27f415d4af | ||
|
|
b8f2422433 | ||
|
|
62ebb27d25 | ||
|
|
979470f306 | ||
|
|
18f31b322a | ||
|
|
321102cc72 | ||
|
|
948e16cf16 | ||
|
|
994c1a00ab | ||
|
|
6973c517b4 | ||
|
|
166145631b | ||
|
|
7e3bc38da4 | ||
|
|
f0d396c2eb | ||
|
|
09aa0978bc | ||
|
|
80f874c452 | ||
|
|
8cfc646d78 | ||
|
|
1bcb5c88a2 | ||
|
|
52efc686cf | ||
|
|
604aee047a | ||
|
|
5556c24142 | ||
|
|
aa88e6f213 | ||
|
|
b27966e742 | ||
|
|
2451792d46 | ||
|
|
48aa8900a8 | ||
|
|
c1ad202e42 |
No files matched your search
@@ -1,9 +1,3 @@
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
.env*.local
|
||||
@@ -13,14 +7,12 @@
|
||||
.DS_Store
|
||||
|
||||
# Personal files
|
||||
/sql/create-users.sql
|
||||
/.dir-locals.el
|
||||
sql/queries.sql
|
||||
/project.todo
|
||||
|
||||
# Blender files (generated by art/equipment scripts)
|
||||
# Blender files
|
||||
*.blend
|
||||
*.blend1
|
||||
/art/equipment/build/
|
||||
/art/advisors/build/
|
||||
/art/attachments/build/
|
||||
|
||||
# Agent worktree state (scripts/wt)
|
||||
/.wt/
|
||||
@@ -1,222 +1,46 @@
|
||||
# AGENTS.md — Moon Miners
|
||||
|
||||
## What this is
|
||||
Moon Miners is a browser 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
|
||||
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
|
||||
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
|
||||
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
|
||||
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
|
||||
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
|
||||
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
|
||||
a modifier that makes it faster or bigger; advisors (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, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`).
|
||||
Moon Miners is an idle/mining game. Players join anonymously: the server creates a
|
||||
player and keeps its id in the `mm_user` cookie (or `X-MM-User` for the API). Players own
|
||||
moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
|
||||
and stake them on wells. When a stake finishes (drill cycles are seconds:
|
||||
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
|
||||
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
|
||||
items (Refinery section: timed batches in slots; refined items sell for more and are parts
|
||||
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
|
||||
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
|
||||
(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
|
||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
|
||||
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
|
||||
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
||||
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
||||
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
|
||||
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
|
||||
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
|
||||
committed). See "web/app" below.
|
||||
- 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.
|
||||
## How the parts fit
|
||||
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
|
||||
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
|
||||
Details: `server/AGENTS.md`.
|
||||
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
|
||||
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
|
||||
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
|
||||
Details: `client/AGENTS.md`.
|
||||
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
|
||||
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
|
||||
|
||||
## Layout
|
||||
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
|
||||
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
|
||||
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
|
||||
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
|
||||
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
|
||||
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
|
||||
`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.
|
||||
## Workspace
|
||||
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
|
||||
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
|
||||
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
|
||||
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
|
||||
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
|
||||
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
|
||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
|
||||
## Commands
|
||||
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
|
||||
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
|
||||
game request that long, to try both clients on a slow connection.
|
||||
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
||||
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.
|
||||
## Conventions (all parts)
|
||||
- The server is the authority: clients show the state it sends and send actions; no game
|
||||
rules on the client side beyond what `server/api.md` "Prediction" allows.
|
||||
- Work in one part at a time: a client task doesn't change `server/`, and the other way
|
||||
round, unless the task says so.
|
||||
- Keep changes small. Don't refactor or move files unless asked.
|
||||
@@ -1,252 +1,38 @@
|
||||
# Moon Miners
|
||||
|
||||
A browser mining game. Players own moons (staking sources) with resource wells, buy and
|
||||
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.
|
||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||
|
||||
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
||||
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
|
||||
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.
|
||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||
|
||||
Players join anonymously: on the first visit the server creates a player and stores its id in
|
||||
a cookie. No wallet or sign-up needed.
|
||||
## Server and web client
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.25+
|
||||
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||
- Node.js, only for `make check` (syntax-checks the JS)
|
||||
|
||||
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
1. Create `.env` as above.
|
||||
2. Start Postgres and set up the database (roles, schema, migrations, store items from
|
||||
`config/game.json`):
|
||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
`server/README.md`).
|
||||
|
||||
```bash
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # http://localhost:3000
|
||||
```
|
||||
|
||||
3. Run the server and open http://localhost:3000:
|
||||
After pulling changes, run `make migrate` again before starting the server: it applies new
|
||||
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
|
||||
|
||||
## Defold client
|
||||
|
||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
||||
is optional: https://defold.com/download/
|
||||
|
||||
With the server running:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
```
|
||||
|
||||
Template, CSS and JS changes show up on reload; Go changes need a restart.
|
||||
## Deploy
|
||||
|
||||
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).
|
||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||
@@ -0,0 +1,7 @@
|
||||
build/
|
||||
tools/
|
||||
.internal/
|
||||
*.der
|
||||
.DS_Store
|
||||
dist/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,194 @@
|
||||
# AGENTS.md — Moon Miners client (Defold)
|
||||
|
||||
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
||||
`../server/AGENTS.md`; the repo: `../AGENTS.md`). Same layout and HUD look as the web build
|
||||
(header, moon list left, the 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. Mouse now,
|
||||
gamepad later. Details live in each file's header comment; this file has the rules and a
|
||||
map.
|
||||
|
||||
## Rules
|
||||
- The server is the authority. Show the state it sends, send actions. Prediction may show
|
||||
an action's result early, 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 tick locally as display only.
|
||||
- Keep changes small; don't touch `../server` from here.
|
||||
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
|
||||
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
|
||||
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
|
||||
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
|
||||
label); animations own what's inside (CSS layout vs transform). Never give a box a node
|
||||
that something animates by position or size. Widgets built at a size (ui.button, bars,
|
||||
dashed edges) are built after the solve at their box's rect; text is cut after it
|
||||
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
|
||||
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
|
||||
a button changes its position (reserve room: fixed heights, placeholders), and when a
|
||||
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
|
||||
cards), so repeated clicks at one place keep working.
|
||||
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
|
||||
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
|
||||
whole gui script, so closing a dialog left the game deaf). Don't post
|
||||
acquire/release_input_focus anywhere else. The dialog's input invariant
|
||||
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
|
||||
`ERROR:MM`.
|
||||
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
|
||||
One Druid instance per gui_script (the dialog has its own). Interaction goes through
|
||||
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
|
||||
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
|
||||
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
|
||||
keys, fx and the test driver depend on it.
|
||||
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
|
||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||
layout is top-left based (x right, y down).
|
||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
|
||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||
`blocks.bound(fn)`.
|
||||
- Theme: every color is a token (`theme.color`, dark and light from app.css): no hex or
|
||||
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
||||
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
|
||||
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
||||
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
|
||||
server text is safe to pass. Add missing characters to the `.font` (make check names them).
|
||||
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
||||
doesn't render (make check catches the warning); stencils and images split batches.
|
||||
Outline textures use slice9 2.
|
||||
- Motion: easing curves match the web (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||
children: fade content in from under a veil, not by fading a subtree. Every new
|
||||
animation honors reduced motion (`anim.reduced`). Effects for action results go in
|
||||
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
||||
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
|
||||
below 1250 wide the aside gets its own row and the page scrolls. Content caps
|
||||
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
|
||||
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
|
||||
browser console (WebGL feedback loops, Firefox target resizes).
|
||||
- Native code (`mmwindow/`, `mmterrain/`): 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: no
|
||||
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
|
||||
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
|
||||
files. Changing a native extension (or the dependencies) sends a remote engine build.
|
||||
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
|
||||
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
|
||||
`display.width/height`.
|
||||
- Never kill processes by name; scripts stop the game by its pid.
|
||||
|
||||
## Testing
|
||||
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
|
||||
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
|
||||
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
|
||||
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
|
||||
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
||||
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
|
||||
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
|
||||
says what it checks and needs). Run `make check` (with `MM_API` set to your test
|
||||
server; it makes a fixture player there) before calling it done.
|
||||
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
|
||||
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
|
||||
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
|
||||
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
|
||||
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
|
||||
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
|
||||
server, which `wt up` starts).
|
||||
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
|
||||
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
|
||||
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
|
||||
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
|
||||
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
|
||||
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
|
||||
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
|
||||
click_check to a feature's scenario when it has buttons people click repeatedly.
|
||||
|
||||
## Commands
|
||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
|
||||
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
|
||||
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
|
||||
from `build/default` in the foreground (loose files, engine service on), so `make reload`
|
||||
works on it.
|
||||
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
|
||||
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
|
||||
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
|
||||
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
|
||||
`node scripts/web-shots.mjs` (Playwright; header of the script).
|
||||
|
||||
## Hot reload (`make reload`, `scripts/dev.py`)
|
||||
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
|
||||
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
|
||||
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
|
||||
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
|
||||
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
|
||||
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
|
||||
too; restart if one looks wrong.
|
||||
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
|
||||
collections, game objects, `main.script`, materials, shaders, the render script; modules
|
||||
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
|
||||
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
|
||||
native extensions and dependencies (a new engine).
|
||||
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
|
||||
(core/main.lua) or lazily.
|
||||
|
||||
## Map
|
||||
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
|
||||
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
|
||||
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
|
||||
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
||||
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
|
||||
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
|
||||
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
|
||||
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
||||
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
|
||||
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog).
|
||||
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
||||
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
|
||||
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
||||
(shared by card sections).
|
||||
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
|
||||
|
||||
Components (`components/`, API in each header; build new UI from these): `button`,
|
||||
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
||||
`scroll`, `bar`, `chip`, `stat`, `row`. `widgets/moon_row.lua`: the moon list row.
|
||||
|
||||
Modules (`modules/`):
|
||||
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
||||
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
|
||||
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
|
||||
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
|
||||
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
|
||||
{ lines = 2 })` (at most n lines, cut with …).
|
||||
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
|
||||
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
|
||||
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
||||
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
||||
`hot.lua` (hot reload).
|
||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, terrain.js settings port).
|
||||
|
||||
Elsewhere:
|
||||
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
||||
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
||||
`render/mm.render_script`: HDR moon pass with bloom.
|
||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator
|
||||
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
|
||||
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
||||
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
||||
- `web/index.html`: the web build's page.
|
||||
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
|
||||
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
|
||||
|
||||
## Stack
|
||||
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
||||
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
|
||||
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
|
||||
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
|
||||
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
|
||||
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
|
||||
|
||||
## Config (`--config=mm.<key>=<value>`)
|
||||
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
|
||||
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
|
||||
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
|
||||
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
|
||||
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
|
||||
`reveal_freeze`, `sfx_debug`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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 lint 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)
|
||||
|
||||
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
|
||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||
lint: $(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; }
|
||||
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||
|
||||
# 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
|
||||
|
||||
# The game from build/default (loose files, engine service on), so `make reload` works on it.
|
||||
# The bundle in dist/ is for shot, play and shipping.
|
||||
run: build
|
||||
scripts/dev.py run $(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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 111 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 877 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1,34 @@
|
||||
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). rumble.wav and reveal.wav (the new-moon
|
||||
reveal's build-up and burst, client only) are synthesized by the same script.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,48 @@
|
||||
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/gear.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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 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).
|
||||
--
|
||||
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
|
||||
|
||||
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
|
||||
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
|
||||
-- move the fill out to box's parent, at its 0, 0).
|
||||
local edge = box
|
||||
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
|
||||
gui.set_parent(edge, box, false)
|
||||
gui.set_position(edge, vmath.vector3(0, 0, 0))
|
||||
end
|
||||
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
||||
return box, w
|
||||
end
|
||||
|
||||
-- Moon grade badges: edge/text color per grade, cheapest to best.
|
||||
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
|
||||
M.GRADE_W = 18
|
||||
|
||||
function M.grade(parent, grade, x, y, opts)
|
||||
opts = opts or {}
|
||||
local color = M.GRADE[grade] or "line_hi"
|
||||
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
|
||||
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
|
||||
right = opts.right })
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,354 @@
|
||||
-- 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)
|
||||
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
|
||||
-- backdrop (a click outside still closes it), no title or buttons, its top-right
|
||||
-- corner `right` from the page's right edge and `top` down; build makes the content.
|
||||
-- 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 menu = opts.menu
|
||||
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or 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 = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
||||
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or 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)
|
||||
|
||||
if menu then
|
||||
opts.build(self, box, w, h)
|
||||
test.set_scope("page")
|
||||
sfx.play("toggle")
|
||||
return
|
||||
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")
|
||||
local menu = self.opts and self.opts.menu
|
||||
if menu then
|
||||
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
|
||||
else
|
||||
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
||||
end
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,153 @@
|
||||
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: \"rumble\"\n"
|
||||
" type: \"sound\"\n"
|
||||
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||
"}\n"
|
||||
"embedded_components {\n"
|
||||
" id: \"reveal\"\n"
|
||||
" type: \"sound\"\n"
|
||||
" data: \"sound: \\\"/assets/sfx/reveal.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"
|
||||
""
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
name: "MMTerrain"
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
name: "MMWindow"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,484 @@
|
||||
-- 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.perlin_shake (the
|
||||
-- reveal burst) or anim.noise_shake for a
|
||||
-- decaying value-noise shake (sell power-down)
|
||||
-- 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
|
||||
|
||||
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
|
||||
-- screen shake.
|
||||
local function grad(i, seed)
|
||||
local s = math.sin((i + seed) * 12.9898) * 43758.5453
|
||||
return (s - math.floor(s)) * 2 - 1
|
||||
end
|
||||
|
||||
local function perlin1(x, seed)
|
||||
local i = math.floor(x)
|
||||
local f = x - i
|
||||
local u = f * f * f * (f * (f * 6 - 15) + 10)
|
||||
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
|
||||
return (a + (b - a) * u) * 2
|
||||
end
|
||||
|
||||
function M.perlin(x, seed)
|
||||
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
|
||||
end
|
||||
|
||||
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
|
||||
function M.perlin_shake(node, amp, dur, freq)
|
||||
if M.reduced or not node then
|
||||
return
|
||||
end
|
||||
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 + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * 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
|
||||
@@ -0,0 +1,206 @@
|
||||
-- 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
|
||||
|
||||
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
|
||||
-- itself comes only from the save-code action; restore is the action restore (code).
|
||||
function M.get_save_code(cb)
|
||||
request("GET", "/api/save-code", nil, 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
|
||||
@@ -0,0 +1,434 @@
|
||||
-- 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(reg, b)
|
||||
local top = stack[#stack]
|
||||
if top then
|
||||
top.journal[#top.journal + 1] = { block = b }
|
||||
end
|
||||
replay(reg, b)
|
||||
end
|
||||
|
||||
-- (the keyed blocks inside a kept one are used in this build too, or finish() would
|
||||
-- destroy them: a kept Equipment section lost its cards)
|
||||
replay = function(reg, b)
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block then
|
||||
local c = e.block
|
||||
if not c.dead then
|
||||
if c.key and reg.by_key[c.key] == c then
|
||||
reg.used[c.key] = true
|
||||
end
|
||||
replay(reg, c)
|
||||
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(reg, 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,371 @@
|
||||
-- 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; opts.shrink_text = k (0..1) first scales it down to fit,
|
||||
-- to k of its size at most (then cuts). 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, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = n, size = false, fit = fit, shrink = shrink, 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, ...); opts.lines = n: at most n lines, the rest cut with "…" (so
|
||||
-- long text can't push what's under it).
|
||||
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)
|
||||
local s = opts or {}
|
||||
local lines = s.lines
|
||||
s.lines = nil
|
||||
local measure = M.text_measure(n, lh)
|
||||
return { node = n, shift = (lh - line) / 2, style = s, clamp = lines and { lines, lh },
|
||||
measure = lines and function(avail)
|
||||
local w, h = measure(avail)
|
||||
return w, math.min(h, lines * lh)
|
||||
end or measure }
|
||||
end
|
||||
|
||||
-- Cut a wrapping text node's text with "…" (whole words, then letters) until it takes at
|
||||
-- most n lines lh apart at width w.
|
||||
function M.clamp_lines(node, n, lh, w)
|
||||
local measure = M.text_measure(node, lh)
|
||||
local text = gui.get_text(node)
|
||||
local function over()
|
||||
local _, h = measure(w)
|
||||
return h > n * lh + 0.5
|
||||
end
|
||||
if not over() then
|
||||
return
|
||||
end
|
||||
while #text > 1 do
|
||||
local cut = text:gsub("%s*%S+%s*$", "")
|
||||
if cut == "" or cut == text then -- (one word left: a letter at a time, whole UTF-8 chars)
|
||||
cut = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", "")
|
||||
end
|
||||
text = cut:gsub("[%s,;:.]+$", "")
|
||||
gui.set_text(node, text .. "…")
|
||||
if not over() then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fit one line of text to max_w: scaled down to `shrink` (0..1) of its size at most, if
|
||||
-- given, then cut with "…" (ui.fit_text).
|
||||
function M.fit_line(node, max_w, shrink)
|
||||
local w = ui.text_width(node)
|
||||
if shrink and w > max_w then
|
||||
local sc = gui.get_scale(node)
|
||||
local k = math.max(shrink, max_w / w)
|
||||
gui.set_scale(node, vmath.vector3(sc.x * k, sc.y * k, sc.z))
|
||||
end
|
||||
ui.fit_text(node, max_w)
|
||||
end
|
||||
|
||||
-- A reserved slot: a box of a fixed size whether its content shows or not (the "Need ₥
|
||||
-- more" line under Buy, a note above cards, Claim all's place), so what shows in it can
|
||||
-- never move what's around it. style: its size (width and/or height) and more box style;
|
||||
-- kid: the content box, or nil (shown empty). The content is laid out inside the slot
|
||||
-- and cut to it if it's bigger (clip text with fit/shrink/lines).
|
||||
function M.reserve(style, kid)
|
||||
style = style or {}
|
||||
if style.shrink == nil then
|
||||
style.shrink = 0
|
||||
end
|
||||
return M.box(style, { kid })
|
||||
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, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = node, size = false, fit = fit, shrink = shrink, 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
|
||||
M.fit_line(node, b.w, b.shrink)
|
||||
end
|
||||
if b.clamp then
|
||||
M.clamp_lines(node, b.clamp[1], b.clamp[2], 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
|
||||
@@ -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
|
||||