Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85336f109f | ||
|
|
a5385a53a7 | ||
|
|
0846c017ae | ||
|
|
b4e5846dff | ||
|
|
3afd892487 | ||
|
|
cc1005c99e | ||
|
|
c998f8fda0 |
No files matched your search
@@ -7,3 +7,4 @@ node_modules
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -1,3 +1,9 @@
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
.env*.local
|
||||
@@ -7,9 +13,14 @@
|
||||
.DS_Store
|
||||
|
||||
# Personal files
|
||||
/sql/create-users.sql
|
||||
/.dir-locals.el
|
||||
sql/queries.sql
|
||||
/project.todo
|
||||
|
||||
# Blender files
|
||||
# Blender files (generated by art/equipment scripts)
|
||||
*.blend
|
||||
*.blend1
|
||||
/art/equipment/build/
|
||||
/art/advisors/build/
|
||||
/art/attachments/build/
|
||||
@@ -1,41 +1,222 @@
|
||||
# AGENTS.md — Moon Miners
|
||||
|
||||
## What this is
|
||||
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).
|
||||
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`).
|
||||
|
||||
## 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`.
|
||||
## 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.
|
||||
|
||||
## 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/`:
|
||||
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
## 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.
|
||||
- Keep changes small. Don't refactor or move files unless asked.
|
||||
@@ -5,10 +5,14 @@ ARG TAILWIND_VERSION=v4.1.14
|
||||
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
|
||||
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
|
||||
&& chmod +x /usr/local/bin/tailwindcss
|
||||
ARG ESBUILD_VERSION=0.28.2
|
||||
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
|
||||
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
||||
&& make app ESBUILD=/usr/local/bin/esbuild \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||
|
||||
FROM scratch
|
||||
@@ -0,0 +1,169 @@
|
||||
# Hand-off: Preact web client experiment (branch `s-preact`)
|
||||
|
||||
## Goal
|
||||
Find out whether a web client can feel as smooth and instant as the Defold client (which
|
||||
predicts click results locally and eases corrections). It's built with Preact + JSX +
|
||||
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
|
||||
next to the classic htmx client at `/`. The user will compare them by feel: fast
|
||||
claim/redrill/deploy clicks, and animations that never hitch.
|
||||
|
||||
## What's built
|
||||
- `web/app/` (about 2,400 lines without the vendored code):
|
||||
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
|
||||
the `shown`/`live` computeds, and an outbox that sends one request at a time with
|
||||
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
|
||||
`nextEnd` and keeps the URL in sync.
|
||||
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
|
||||
difference: readiness is local (`s.ready || s.end <= now`).
|
||||
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
|
||||
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
|
||||
- `ui.jsx`:
|
||||
- `Num`: eased number, written by the ticker.
|
||||
- `Countdown`.
|
||||
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
|
||||
`playbackRate` so the bar lands on the true end without jumping.
|
||||
- `SupplyBar`: clip-path.
|
||||
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
|
||||
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
|
||||
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
|
||||
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
|
||||
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
|
||||
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
|
||||
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
|
||||
waits for the server).
|
||||
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
|
||||
for the API's `confirm` texts (the moon sale).
|
||||
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
|
||||
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
|
||||
`sfx.js`.
|
||||
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
|
||||
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
|
||||
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
|
||||
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
|
||||
zoom were verified. If the worker fails it falls back to the page's thread;
|
||||
`?mainscene` forces that.
|
||||
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
|
||||
holds the path aliases, including `three`, used by the worker bundle.
|
||||
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
|
||||
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
|
||||
- Build:
|
||||
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
|
||||
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
|
||||
gitignored and in `.dockerignore`.
|
||||
- `make dev` watches the client.
|
||||
- `make build` depends on `app`.
|
||||
- The Dockerfile fetches esbuild and runs `make app`.
|
||||
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
|
||||
inside JSX, and builds the client.
|
||||
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
|
||||
`test-endpoints.restclient`.
|
||||
|
||||
## How to try it
|
||||
```
|
||||
cd wt-s-preact && make app && go build -o bin/moonminers .
|
||||
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
|
||||
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
|
||||
```
|
||||
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
|
||||
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
|
||||
gitignored. **The servers are stopped now**; restart them with the commands above.
|
||||
|
||||
## Measurements
|
||||
|
||||
### Second run (Oct 20, after the Moons slice was finished)
|
||||
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
|
||||
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
|
||||
the median of 3 alternating runs; latency from pointerdown to the first frame where the
|
||||
side panel's drill line count changes. Each test starts from a known state set through the
|
||||
API and admin endpoints (the wells topped up, one drone finished on the well). No other
|
||||
engine was running; the machine's load average was 6 to 9 from other work.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
|
||||
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
|
||||
|
||||
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
|
||||
1.5 s after, 0 ms delay, median of 3):
|
||||
|
||||
| | script | layout | style | all tasks |
|
||||
|---|---|---|---|---|
|
||||
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
|
||||
| app | 11 ms | 8 ms | 10 ms | 62 ms |
|
||||
|
||||
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
|
||||
the first gesture, as below); the others 24–25 ms.
|
||||
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
|
||||
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
|
||||
other engine.
|
||||
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
|
||||
for the header's Classic/Admin link.
|
||||
|
||||
### First run
|
||||
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
|
||||
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
|
||||
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
|
||||
where the side panel's drill lines change.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
|
||||
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
|
||||
|
||||
- The classic client drops clicks while a request runs: `body.is-busy` sets
|
||||
`pointer-events: none`.
|
||||
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
|
||||
was using about 460% CPU and the GPU.
|
||||
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
|
||||
than on scripts.
|
||||
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
|
||||
under this load.
|
||||
- The first click in each run has a long task of about 120 ms in both clients. It comes
|
||||
from `sfx.js` creating the AudioContext on the first gesture.
|
||||
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
|
||||
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
|
||||
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
|
||||
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
|
||||
`runall.sh`.
|
||||
|
||||
## Unfinished / next steps
|
||||
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
|
||||
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
|
||||
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
|
||||
side-by-side screenshots. What's left:
|
||||
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
|
||||
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
|
||||
The API has every section's state and the prediction rules for sells and refinery
|
||||
batches (api.md "Prediction").
|
||||
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
|
||||
would need the server's split of multi-well claims and the sale's payout rules.
|
||||
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
|
||||
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
|
||||
around a claim). Worth keeping in `scripts/` if the comparison continues.
|
||||
4. Report: rough line counts are above. Hard parts:
|
||||
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
|
||||
- easing bar corrections (playbackRate);
|
||||
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
|
||||
`Event.type`);
|
||||
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
|
||||
empty spans).
|
||||
|
||||
## Gotchas
|
||||
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
|
||||
which is allowed. Never read `.value` inside JSX.
|
||||
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
|
||||
last `shown` change, because Preact re-renders asynchronously.
|
||||
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
|
||||
only for the main-thread fallback.
|
||||
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
|
||||
`web/static/js/scene.js` too.
|
||||
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
|
||||
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
|
||||
(the sale lines' `value`): destructure them under another name.
|
||||
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
|
||||
@@ -0,0 +1,50 @@
|
||||
TAILWIND_VERSION := v4.1.14
|
||||
TAILWIND := bin/tailwindcss
|
||||
CSS := web/static/css/app.css
|
||||
ESBUILD_VERSION := 0.28.2
|
||||
ESBUILD := bin/esbuild
|
||||
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
|
||||
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
|
||||
# (/static/js/...) by URL.
|
||||
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
|
||||
|
||||
.PHONY: build dev css app migrate load-config check
|
||||
|
||||
build: css app
|
||||
go build -o bin/moonminers .
|
||||
|
||||
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
|
||||
# client rebuild on change.
|
||||
dev: $(TAILWIND) $(ESBUILD)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
|
||||
$(APP) --sourcemap --watch=forever & \
|
||||
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
|
||||
|
||||
css: $(TAILWIND)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
|
||||
|
||||
app: $(ESBUILD)
|
||||
$(APP) --minify
|
||||
|
||||
migrate:
|
||||
go run . migrate
|
||||
|
||||
load-config:
|
||||
go run . load-config
|
||||
|
||||
check:
|
||||
test -z "$$(gofmt -l .)" && go vet ./...
|
||||
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
|
||||
for f in web/static/js/*.js; do node --check $$f || exit 1; done
|
||||
node --no-warnings scripts/check-moon-traits.mjs
|
||||
node scripts/check-signals-jsx.mjs web/app
|
||||
$(APP) --minify --log-level=warning
|
||||
|
||||
$(ESBUILD):
|
||||
mkdir -p bin
|
||||
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
|
||||
|
||||
$(TAILWIND):
|
||||
mkdir -p bin
|
||||
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||
chmod +x $@
|
||||
@@ -1,35 +1,252 @@
|
||||
# Moon Miners
|
||||
|
||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||
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.
|
||||
|
||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||
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 and web client
|
||||
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.
|
||||
|
||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
`server/README.md`).
|
||||
## 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`):
|
||||
|
||||
```bash
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # http://localhost:3000
|
||||
```
|
||||
|
||||
## 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:
|
||||
3. Run the server and open http://localhost:3000:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
make dev
|
||||
```
|
||||
|
||||
## Deploy
|
||||
Template, CSS and JS changes show up on reload; Go changes need a restart.
|
||||
|
||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||
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).
|
||||
@@ -48,7 +48,6 @@ type APIState struct {
|
||||
Well *APIWell `json:"well"` // s=moons with a well selected
|
||||
Market *APIMarket `json:"market"` // once the market is unlocked
|
||||
Banner []APIBanner `json:"banner"`
|
||||
Admin bool `json:"admin"` // may use admin-grant
|
||||
|
||||
// Always present (api_sections.go).
|
||||
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
|
||||
@@ -245,7 +244,7 @@ type APIBetterWell struct {
|
||||
type APIMarket struct {
|
||||
Quotes []APIQuote `json:"quotes"`
|
||||
Selected string `json:"selected"`
|
||||
Total float64 `json:"total"` // value of everything held, at the quotes
|
||||
Total float64 `json:"total"` // value of everything held
|
||||
Held int `json:"held"` // kinds held
|
||||
Best string `json:"best"` // biggest 24h gain
|
||||
Worst string `json:"worst"` // biggest 24h loss
|
||||
@@ -256,32 +255,28 @@ type APIQuote struct {
|
||||
Item string `json:"item"`
|
||||
Refined bool `json:"refined"`
|
||||
Balance float64 `json:"balance"`
|
||||
Price float64 `json:"price"` // the shared quote: rate × boost, the same for every player
|
||||
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
|
||||
Rate float64 `json:"rate"`
|
||||
Boost float64 `json:"boost"`
|
||||
// Personal, on the payout only: the sell bonus (advisors), the selling pressure and its
|
||||
// factor now.
|
||||
Advisor float64 `json:"advisor"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
Factor float64 `json:"factor"`
|
||||
Unit float64 `json:"unit"` // what one unit pays this player now: price × advisor × factor
|
||||
Open float64 `json:"open"` // market rate 24h ago
|
||||
Change float64 `json:"change"` // percent over 24h
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Value float64 `json:"value"` // balance × price
|
||||
// For the sell ticket: what a unit pays this player before selling pressure (price ×
|
||||
// advisor), the base price (pressure is counted in base-price credits) and what selling
|
||||
// everything held pays them now.
|
||||
Value float64 `json:"value"`
|
||||
// For the sell ticket: the price before selling pressure, the base price (pressure is
|
||||
// counted in base-price credits) and what selling everything held pays now.
|
||||
Symbol string `json:"symbol"`
|
||||
Unpressed float64 `json:"unpressed"`
|
||||
Base float64 `json:"base"`
|
||||
SellAll float64 `json:"sellAll"`
|
||||
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
|
||||
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
|
||||
// (the player's recent selling lowers their payout noticeably), pressureText "−4%";
|
||||
// boosted (a live event, on the quote), boostText "×2"; advised (an advisor sell bonus,
|
||||
// on the payout), advisorText "+8%". Each text is "" when its flag is false.
|
||||
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
|
||||
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
|
||||
// text is "" when its flag is false.
|
||||
Up bool `json:"up"`
|
||||
ChangeText string `json:"changeText"`
|
||||
Pressed bool `json:"pressed"`
|
||||
@@ -381,7 +376,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
|
||||
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
|
||||
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
|
||||
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
|
||||
Banner: []APIBanner{}, Admin: p.Admin}
|
||||
Banner: []APIBanner{}}
|
||||
for _, r := range s.Refined {
|
||||
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
|
||||
}
|
||||
@@ -529,6 +524,7 @@ func (a *APIState) addSection(p *Page) {
|
||||
|
||||
// apiMarket maps the market; full adds the chart and the sparklines.
|
||||
func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
c := cfg()
|
||||
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
|
||||
if trade.Selected != nil {
|
||||
mk.Selected = trade.Selected.Resource
|
||||
@@ -541,7 +537,7 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
}
|
||||
for _, q := range trade.Quotes {
|
||||
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
|
||||
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor, Unit: q.Unit(),
|
||||
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
|
||||
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
|
||||
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
|
||||
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
|
||||
@@ -554,7 +550,8 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
if aq.Advised {
|
||||
aq.AdvisorText = q.AdvisorText()
|
||||
}
|
||||
aq.SellAll = q.SellAll()
|
||||
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
|
||||
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
|
||||
if full {
|
||||
aq.Spark = spark(q.series)
|
||||
}
|
||||
@@ -675,7 +672,6 @@ func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, e
|
||||
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
|
||||
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
|
||||
page.Banner = s.banner(ctx)
|
||||
page.Admin = s.isAdmin(page.S.UserID)
|
||||
var extra []*Page
|
||||
for _, section := range apiIncludes(get("include")) {
|
||||
if section == page.V.Section {
|
||||
@@ -767,10 +763,6 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||
apiError(w, http.StatusNotFound, "Unknown action")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errForbidden) {
|
||||
apiError(w, http.StatusForbidden, "Missing permission")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
@@ -80,7 +80,6 @@ The other sections' actions:
|
||||
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
|
||||
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
|
||||
| `new-game` | – | creates a new player and returns its id and state |
|
||||
| `admin-grant` | `grant` (`credits`, a resource or a refined item name), `amount` | admins only (`state.admin`), else `403`; logged as `admin_grant` |
|
||||
|
||||
Responses:
|
||||
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
|
||||
@@ -89,7 +88,7 @@ Responses:
|
||||
(after `buy-tool`) are one-shot results, see below.
|
||||
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
|
||||
A locked action (its part of the game not revealed yet) says "Not available yet".
|
||||
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned or not an admin (`admin-grant`),
|
||||
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
|
||||
`500` `{ok: false, error: "Something went wrong"}`.
|
||||
|
||||
## GET /api/moon-settings
|
||||
@@ -107,7 +106,6 @@ session; the web page also reads it once per page load.
|
||||
| `userId`, `now` | |
|
||||
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
|
||||
| `credits` | balance |
|
||||
| `admin` | the player is an admin (`ADMIN_USER_IDS`, or everyone with `MM_DEV=1`): may use `admin-grant` |
|
||||
| `resources`, `refined` | `[{name, amount}]` |
|
||||
| `moonPrice` | price of the next moon |
|
||||
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
|
||||
@@ -119,7 +117,7 @@ session; the web page also reads it once per page load.
|
||||
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
|
||||
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
|
||||
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
|
||||
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
|
||||
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
|
||||
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
|
||||
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
|
||||
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
|
||||
@@ -193,7 +191,7 @@ would earn with the player's advisors, `bestRate` the same for their best tool (
|
||||
|
||||
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
|
||||
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
|
||||
saved per unit, `sellPrice` what one unit pays the player now (the market quote's `unit`), `why` `busy` (no
|
||||
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
|
||||
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
|
||||
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
|
||||
"+ Slot" is disabled. Refined items sell through `sell` like resources.
|
||||
@@ -5,7 +5,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -18,11 +17,7 @@ func TestAPI(t *testing.T) {
|
||||
s.pagePastInMemory(v.Page)
|
||||
p := s.page(v.resolve(s))
|
||||
testRanking(p)
|
||||
p.Admin = len(s.Tools) > 0
|
||||
a := apiState(p)
|
||||
if a.Admin != p.Admin {
|
||||
t.Errorf("view %+v: admin %v", v, a.Admin)
|
||||
}
|
||||
if _, err := json.Marshal(a); err != nil {
|
||||
t.Fatalf("marshal %+v: %v", v, err)
|
||||
}
|
||||
@@ -43,7 +38,7 @@ func TestAPI(t *testing.T) {
|
||||
}
|
||||
if a.Market != nil {
|
||||
for _, q := range a.Market.Quotes {
|
||||
if q.SellAll > q.Balance*q.Unpressed+1e-6 || q.Value != q.Balance*q.Price || (q.Balance > 0 && q.SellAll <= 0) {
|
||||
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
|
||||
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
|
||||
}
|
||||
}
|
||||
@@ -307,45 +302,3 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedQuotes: two players with different selling pressure and advisors see the same
|
||||
// quotes (and 24h history) but get different payouts.
|
||||
func TestSharedQuotes(t *testing.T) {
|
||||
c := cfg()
|
||||
item := sortedKeys(c.ResourcePrices)[0]
|
||||
var sellAdvisor string
|
||||
for _, a := range c.Advisors.Items {
|
||||
if a.Sell > 1 {
|
||||
sellAdvisor = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if sellAdvisor == "" {
|
||||
t.Skip("no advisor with a sell bonus in the config")
|
||||
}
|
||||
boosts := PriceBoosts{item: 1.5}
|
||||
a, b := newPlayerState(), newPlayerState()
|
||||
b.UserID = testID("u", 9)
|
||||
b.Now = a.Now
|
||||
a.PriceBoosts, b.PriceBoosts = boosts, boosts
|
||||
b.Pressure = map[string]float64{item: c.MarketPressure.Scale}
|
||||
b.Advisors = []string{sellAdvisor}
|
||||
rows := []MarketRow{{ResourceBalance: ResourceBalance{Resource: item, Balance: 100}}}
|
||||
qa, qb := a.marketPage(rows, View{Section: "market"}).Quotes[0], b.marketPage(rows, View{Section: "market"}).Quotes[0]
|
||||
if qa.Price != qb.Price || qa.Value != qb.Value || qa.Change != qb.Change || qa.High != qb.High || qa.Low != qb.Low {
|
||||
t.Errorf("quotes differ: %+v vs %+v", qa, qb)
|
||||
}
|
||||
if want := marketQuote(item, a.Now, 1.5); qa.Price != want {
|
||||
t.Errorf("quote %v, want rate × boost %v", qa.Price, want)
|
||||
}
|
||||
if qa.Unit() != qa.Price || qa.SellAll() > qa.Balance*qa.Price {
|
||||
t.Errorf("no modifiers, but unit %v / sellAll %v differ from quote %v", qa.Unit(), qa.SellAll(), qa.Price)
|
||||
}
|
||||
if !qb.Advised() || !qb.Pressed() || qb.Unit() == qa.Unit() || qb.SellAll() == qa.SellAll() {
|
||||
t.Errorf("payouts should differ: %+v vs %+v", qa, qb)
|
||||
}
|
||||
want := sellPayout(qb.Balance, qb.Price, b.Bonus().Sell, c.MarketPressure.Average(qb.Pressure, qb.Balance*qb.Base()))
|
||||
if math.Abs(qb.SellAll()-want) > 1e-9 {
|
||||
t.Errorf("sellAll %v, want %v", qb.SellAll(), want)
|
||||
}
|
||||
}
|
||||
@@ -198,10 +198,9 @@ func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
|
||||
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
||||
}
|
||||
|
||||
// sellPrice is what one unit pays the player now, before selling pressure: the shared
|
||||
// quote × their advisors.
|
||||
// sellPrice is what one unit sells for now: market rate × live events × advisors.
|
||||
func (s *State) sellPrice(name string) float64 {
|
||||
return marketQuote(name, s.Now, s.PriceBoosts.For(name)) * s.Bonus().Sell
|
||||
return marketRate(name, s.Now) * s.PriceBoosts.For(name) * s.Bonus().Sell
|
||||
}
|
||||
|
||||
// BonusRow is one active bonus, for display.
|
||||
@@ -1,7 +0,0 @@
|
||||
build/
|
||||
tools/
|
||||
.internal/
|
||||
*.der
|
||||
.DS_Store
|
||||
dist/
|
||||
__pycache__/
|
||||
@@ -1,637 +0,0 @@
|
||||
# AGENTS.md — Moon Miners client (Defold)
|
||||
|
||||
## What this is
|
||||
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
|
||||
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
|
||||
(header with sections, resources and credits; moon list on the left; the current section
|
||||
in the middle; aside with the moon viewport and the selected well), made game-native.
|
||||
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
|
||||
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later.
|
||||
|
||||
The server is the authority. The client shows the state the server sends and sends
|
||||
actions. It may show an action's result before the answer (prediction, see Conventions),
|
||||
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
|
||||
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
|
||||
rules in Lua. Countdowns may tick locally as display only.
|
||||
|
||||
## Stack
|
||||
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
||||
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
|
||||
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
|
||||
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
|
||||
native extension, so a build sends the engine to Defold's build server once (needs
|
||||
network; later builds reuse it until the dependencies change). Dependencies are fetched
|
||||
only when `game.project` changes (`make resolve` forces it).
|
||||
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
|
||||
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
|
||||
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
|
||||
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
|
||||
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
|
||||
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
|
||||
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
|
||||
`~/Development/Refold/Defold` (read-only).
|
||||
|
||||
## Game screen: core, regions, sections
|
||||
|
||||
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
|
||||
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
|
||||
state: every new state (a response, a prediction) or a window/UI-scale change sets
|
||||
`g.dirty`, and the next update builds again what changed. The regions (banner, header,
|
||||
moon list, main panel, aside) and costly parts inside them (the market's ticker, chart and
|
||||
sparklines; Store, Equipment and Advisors cards through `card.grid`'s `key`/`sig`) are
|
||||
blocks (`modules/blocks.lua`): one whose signature (a serialization of everything it
|
||||
shows: its state keys, the view, busy buttons, ready counts on our clock) is unchanged
|
||||
keeps its nodes and Druid components. A full rebuild (drops the Druid instance and every
|
||||
layout node) happens on the first state, a size change, the narrow layout and
|
||||
`g.dirty_all` (theme, reduced motion, mute, scroll offsets set by code, hot reload). What
|
||||
a region shows from the state is listed in `core/main.lua` (`SECTION_KEYS`, `ASIDE_KEYS`,
|
||||
the header's and moon list's sigs): a section that reads a new state key must add it
|
||||
there, or it won't update. `g` is the gui script's `self`.
|
||||
|
||||
Core (`screens/game/core/`):
|
||||
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
|
||||
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
|
||||
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
|
||||
the build orchestration (banner, header, moon list, main panel, aside; widths from
|
||||
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
|
||||
The aside shows only when the view selects something for it (`M.aside_shown`, the
|
||||
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
|
||||
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
|
||||
the web's boot and suspended pages) shows until the first state.
|
||||
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
|
||||
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
|
||||
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
|
||||
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
|
||||
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
|
||||
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
|
||||
words ("Something went wrong", "Can't reach the server") with the error sound; a
|
||||
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
|
||||
suspended screen.
|
||||
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
|
||||
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
|
||||
(eased number); keyed so values carry over a rebuild.
|
||||
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
|
||||
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
|
||||
`on_submit` and the refinery's quantity; reset every build), back / forward
|
||||
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
|
||||
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
|
||||
scroll area under the mouse (else the focused one's, else the biggest;
|
||||
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
|
||||
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
|
||||
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
|
||||
Ctrl +/-/0 the UI scale.
|
||||
`session.lua` keeps the view history (`g.history`, like the web's pushState).
|
||||
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
|
||||
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
|
||||
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
|
||||
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
|
||||
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
|
||||
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
|
||||
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
|
||||
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
|
||||
clicks while it pops, and the claim predictor counts a drill whose end has passed on
|
||||
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
|
||||
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
|
||||
|
||||
Regions (one module each, so agents can work on them separately): `banner.lua`,
|
||||
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
|
||||
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
|
||||
in the server's words, counted from its `end`),
|
||||
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
|
||||
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
|
||||
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
|
||||
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
|
||||
fullscreen),
|
||||
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
|
||||
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
|
||||
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
|
||||
`advisors.lua`, the new-moon teaser in `reveal.lua`.
|
||||
|
||||
**Module contract** (regions and sections):
|
||||
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
|
||||
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
|
||||
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
|
||||
center their own capped content. Regions get their box; the header and banner return
|
||||
their height. `H` = `g.h`.
|
||||
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
|
||||
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
|
||||
for content under the viewport.
|
||||
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before its region is
|
||||
built again and when the screen goes away (cancel timers, drop references to nodes).
|
||||
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
|
||||
again by a full rebuild), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
|
||||
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
|
||||
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
|
||||
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
|
||||
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
|
||||
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
|
||||
builds outside its block.
|
||||
- Per-build registries (test ids and focus, tooltips, tickers, `g.inputs`,
|
||||
`g.scroll_areas`, `g.pointers`, fx anchors, autoplay) are reset by every build; a kept
|
||||
block makes its registrations again from a journal: a new registry must note them
|
||||
(`blocks.note(fn, ...)`, or `blocks.push`/`blocks.put` for a list or map on g). Code
|
||||
that builds outside a build (a list making rows as it scrolls, a field's on_change)
|
||||
runs through `blocks.bound(fn)` so what it makes belongs to its block. A kept block
|
||||
moved under another parent finds its buttons' clip stencil again.
|
||||
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
|
||||
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
|
||||
|
||||
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
|
||||
when the well or drill changes, both on the first state and after the reveal closes
|
||||
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
|
||||
staggered. Not on other rebuilds. While the reveal card flies into the moon list (Start
|
||||
mining) a new state waits for it to land.
|
||||
|
||||
## Components (`components/`)
|
||||
Build new UI from these instead of coordinate math. Each file's header documents its API.
|
||||
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
|
||||
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
|
||||
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
|
||||
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
|
||||
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
|
||||
or link button has hover and a test id. After a rebuild the button under the mouse
|
||||
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
|
||||
button with a test id: `button.carry`, the one with the same id under the pointer is
|
||||
pressed in the new build), so the release still clicks it.
|
||||
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
|
||||
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
|
||||
instance: survives rebuilds, runs actions in the game script, drops page hovers
|
||||
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
|
||||
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
|
||||
only way to show a dialog (the Monarch popup screen is gone). Input invariant
|
||||
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
|
||||
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
|
||||
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
|
||||
a log with `ERROR:MM`).
|
||||
- `screens/game/grant.lua`: the quick grant dialog for admins (`state.admin`: header "+"
|
||||
button, G key): item and amount pickers, then `admin-grant`.
|
||||
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
|
||||
- `card.lua`: image column + text column laid out with flex (below; measured text, `title`,
|
||||
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
|
||||
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
|
||||
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
|
||||
advisor). Store, Equipment and Advisors.
|
||||
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
|
||||
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
|
||||
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
|
||||
Header and moon list.
|
||||
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
|
||||
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
|
||||
history).
|
||||
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
|
||||
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
|
||||
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
|
||||
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
|
||||
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
|
||||
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
|
||||
`bar.ready_label` (gold "Ready" with a pulsing glow).
|
||||
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
|
||||
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
|
||||
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
|
||||
and time on g so it picks up on the rebuilt row).
|
||||
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
|
||||
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
|
||||
window, hidden by a click or a dialog; on its own layer. Give information-only text a
|
||||
tooltip like the web does.
|
||||
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
|
||||
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
|
||||
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
|
||||
card once complete). A long list (components/list with `focus_id`) is walked item by
|
||||
item, scrolling, since its rows out of view don't exist. So anything interactive must register
|
||||
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
|
||||
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
|
||||
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
|
||||
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
|
||||
|
||||
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
|
||||
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
|
||||
widths yourself); refresh innermost layouts first, synchronously, after building
|
||||
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
|
||||
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
|
||||
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
|
||||
so make a clickable container before the buttons on it.
|
||||
|
||||
## Layout (flex) (`modules/flex.lua`, `modules/flex_gui.lua`)
|
||||
Prefer it to coordinate math (`26 + ui.text_width(name) + 7`) for new or reworked UI.
|
||||
Every section, the aside (well panel, drill panel, advisor panel), header, moon list,
|
||||
banner, toasts, dialogs (and the buy modal), the new-moon teaser and the reveal card are
|
||||
laid out with it; component internals (chips, stat rows, charts, moon rows) still place
|
||||
their own parts.
|
||||
- `flex.lua` is a pure solver (no Defold calls; unit tests `scripts/flex_test.lua`, in
|
||||
`make check`): a tree of `{ style, children, measure = fn(avail_w) -> w, h, node, id }`;
|
||||
CSS names in snake_case (`direction`, `gap`/`row_gap`/`column_gap`, `padding`,
|
||||
`justify`, `align`/`align_self`, `grow`/`shrink`/`basis`, `width`/`height` px, "N%",
|
||||
"auto", `min_*`/`max_*`, `wrap`, `display = "none"`). `flex.solve(tree, x, y, w, h)`
|
||||
sets absolute `x, y, w, h` on every box: top-left origin, y down (like `ui.*` calls);
|
||||
`flex.gui_pos(box, pivot)` gives the GUI position. Deviations from CSS (border-box, min
|
||||
sizes 0, auto = fit-content, no margins) are at the top of the file.
|
||||
- `flex.apply(tree, nodes, { glide = secs })` (flex_gui) sets position (by the node's
|
||||
pivot) and size (unless `box.size == false`; divided by scale) on each box's `node`,
|
||||
relative to the nearest ancestor box with a node, only when it changed; it never
|
||||
creates, deletes or reparents. `glide` tweens moved nodes (FLIP), off under reduced
|
||||
motion; default snap. `flex_gui.text_measure(node[, lh])` measures a text node.
|
||||
- Rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a label);
|
||||
animations own what's inside (like CSS transform vs layout). Never give a box a node
|
||||
that something animates by position or size. Kept blocks (modules/blocks) aren't
|
||||
re-laid out: solve only what you build.
|
||||
- Widgets whose look is built at a size (ui.button, bars, dashed edges) are built after
|
||||
the solve, at their box's rect (`{ style = { grow = 1 } }` without a node, then read
|
||||
`box.x/w`); text is cut (`ui.fit_text(node, box.w)`) after it.
|
||||
- New or reworked UI is flex-first; don't add hand-placed coordinates. When you touch a
|
||||
hand-placed section, port it. The point is fewer layout bugs and less rework, not fewer lines.
|
||||
- Flex-first (the Refinery): describe the whole section as one tree with
|
||||
`flex_gui.label`/`para` leaves and boxes with `make = fn(parent, x, y, w, h)` for what is
|
||||
built at a size, `flex.grid` for card grids, then `flex_gui.build(flex.solve(tree, x, y),
|
||||
parent)` makes the nodes in tree order (= focus order).
|
||||
- flex_gui tree pieces: `box/row/col/space/fill/made`; `label` (rich text when it has
|
||||
symbols), `text_para` (H.para's leading), `node_label` (an existing text node);
|
||||
`button` (a ui.button box, width from a probe, then `bind(b, nodes, w)`); `inline(parent,
|
||||
h, fn)` for one-line components that return their width (chips, ui.credits, stat
|
||||
rows: fn(probe, 0, 0, true) must register nothing); `measured(parent, fn)` for one that
|
||||
returns its height; `flow(parent, x, y, w, parts)` mixes trees with components that lay
|
||||
themselves out in place (card.grid's kept cards). `H.claim_wait_w` gives a claim
|
||||
countdown's width. `make` rects are whole pixels; leaves aren't rounded.
|
||||
- Beyond flexbox (flex.lua header): `columns = {...}` makes a box a CSS grid (tracks px,
|
||||
"N%", "auto", "Nfr"; `span`, `rows`, `justify_items`/`justify_self`; row height from
|
||||
the tallest cell): tables. `position = "absolute"` with `left/right/top/bottom` and
|
||||
`translate = {fx, fy}` places a child over its parent's box; `flex.anchor(rect, box)`
|
||||
places a box that way against any solved rect (badges, floating numbers, bursts).
|
||||
`aspect_ratio` (image boxes). `overflow = "scroll"` (children don't shrink) and
|
||||
`flex.content_size(box)` for a scroll area's content size.
|
||||
- Patterns: a label is a leaf of its line-box height (pivot W lands on its middle); a
|
||||
pinned footer is a `grow = 1` spacer before it; equal-height card rows are `wrap = true`
|
||||
with the default `align = "stretch"`; grid columns: give items the column width.
|
||||
|
||||
## Animation (`modules/anim.lua`)
|
||||
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
|
||||
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
|
||||
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
|
||||
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
|
||||
`scale_about` (scale a pivot-NW node about its middle, like CSS),
|
||||
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
|
||||
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
|
||||
content fades in from under a veil and rises, stagger), `group` (invisible container),
|
||||
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
|
||||
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
|
||||
(scan line), `count`.
|
||||
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
|
||||
translucent fills), so content fades in from under a veil (a box in the background
|
||||
color, `theme.color.veil` for panels) instead of fading a subtree.
|
||||
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
|
||||
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
|
||||
saved in the settings file unless save is false): every helper skips movement and keeps
|
||||
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
|
||||
ticks); the reveal skips the charge and shows the card complete; the ticker stands
|
||||
still. New animations must honor it.
|
||||
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
|
||||
quick repeats of the same gain merge into one number; others stack past it; wallet
|
||||
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
|
||||
error shakes the button it came from, found after the rebuild by its test id
|
||||
(`fx:button`, registered by `H.action`)).
|
||||
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
|
||||
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
|
||||
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
|
||||
|
||||
## Text and markup
|
||||
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
|
||||
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `<` `&`). `ui.style_text` (so
|
||||
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
|
||||
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
|
||||
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
|
||||
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
|
||||
`resource.get_text_metrics`) understands markup.
|
||||
|
||||
## Layout
|
||||
- `game.project`: settings; `[mm]` holds the client's own config (see below).
|
||||
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
|
||||
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
|
||||
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
|
||||
components.
|
||||
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
|
||||
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
|
||||
On desktop it also makes the window's mouse buttons and keys sticky (GLFW), so a click
|
||||
or key press shorter than a frame isn't lost (the engine polls their states once a frame).
|
||||
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
|
||||
browser's Fullscreen API is used instead (`modules/screen.lua`).
|
||||
- `mmterrain/`: native extension, the moon generator in C (`src/terrain.c`, a port of
|
||||
terrain.js's generation, same doubles in the same order; there is no Lua generator).
|
||||
User rule: C only (`.c`, Lua C API); C++ only where the dmSDK forces it,
|
||||
as a C-style shim (free functions and plain structs only: no classes, methods,
|
||||
templates, STL, references, own namespaces, exceptions or RTTI; `src/buffer_shim.cpp`:
|
||||
the buffer API). The extender compiles `.c`
|
||||
files as C++ ("treating 'c' input as 'c++'"), so the C must also be valid C++ and
|
||||
what links across files is `extern "C"`; the registration (`DM_DECLARE_EXTENSION` is
|
||||
C++) is written out in `mmterrain.c`. Lua: `mmterrain.new(terrain.native_params(p))`,
|
||||
`start` (a thread of its own where there are threads: desktop), `step(gen, secs)` (the
|
||||
web: no threads), `done`, `vertices`, `fill(gen, buf[, from, to])` (the triangle strip
|
||||
straight into the mesh buffer's streams), `result` (the flat arrays), `free` (header of
|
||||
`mmterrain.c`). `moon.script` makes every moon mesh with it.
|
||||
- `web/index.html`: the web build's page (`[html5] htmlfile`; see Web build).
|
||||
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
|
||||
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
|
||||
`chart.lua`
|
||||
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
|
||||
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
|
||||
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
|
||||
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
|
||||
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
|
||||
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
|
||||
unseat, dismiss; an unhired one's details on a card click),
|
||||
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
|
||||
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
|
||||
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
|
||||
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
|
||||
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
|
||||
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
|
||||
whenever the shown state changes: at the click for a predicted action, in the request
|
||||
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
|
||||
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
|
||||
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
|
||||
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
|
||||
moon sale register with `fx:box(node)` while built; give a new card-like container one.
|
||||
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
|
||||
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
|
||||
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
|
||||
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
|
||||
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
|
||||
moon). View links carry the current moon like the web's (`View.ToSection`): without
|
||||
one the server picks the first moon.
|
||||
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
|
||||
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
|
||||
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
|
||||
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
|
||||
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
|
||||
file),
|
||||
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
|
||||
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
|
||||
(hot reload).
|
||||
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
|
||||
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
|
||||
clones them (`ui.clone`).
|
||||
- `widgets/moon_row.lua`: the moon list row (Druid widget).
|
||||
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
|
||||
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
|
||||
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
|
||||
fade, ring, spark, speaker, sun and moon icons; tinted per node.
|
||||
- The moon viewport: `modules/moon/terrain.lua` ports the settings side of the server's
|
||||
`terrain.js` (defaults, resolve, traits, colors, the cube-sphere and its strip), and
|
||||
`mmterrain/` the generation, in C (`scripts/moon-check.mjs` compares them with
|
||||
terrain.js, many ids and settings; in `make check`), `modules/moon/moon.lua` is the API
|
||||
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`,
|
||||
`prefetch`: reveal.lua starts a bought moon's mesh as the buy-moon answer arrives),
|
||||
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
|
||||
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
|
||||
HDR target with bloom; the viewport node shows it through the `moon` gui material.
|
||||
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
|
||||
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
|
||||
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
|
||||
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
|
||||
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
|
||||
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
|
||||
`zzfx-render.mjs`, `serve-web.py` and `web-shots.mjs` (Web build).
|
||||
|
||||
## Web build
|
||||
`make web` bundles for the browser (`wasm-web`, release; `WEB_VARIANT=debug` keeps logs
|
||||
and the test hook) into `dist/web/MoonMiners/` (release ~4.3 MB, ~2.4 MB gzipped: engine wasm 2.9 MB,
|
||||
archive 1.1 MB, page and loader).
|
||||
`make serve-web` (`scripts/serve-web.py`, `UPSTREAM=` default http://localhost:3000,
|
||||
`WEB_PORT=` 8080) serves it and proxies `/api/*` and `/static/*` (everything the client
|
||||
fetches) to the server, so the game talks to its own origin; the server stays unchanged.
|
||||
- The page (`web/index.html`): the canvas fills the window in device pixels and follows
|
||||
its size (the layout is native-pixel like desktop); dark loading screen; "WebGL 2
|
||||
needed" without WebGL 2 (the moon's shaders don't build for WebGL 1). `[html5]` in
|
||||
game.project: `stretch`, no Defold buttons or banner, heap 256 MB.
|
||||
- On html5 (`if html5 then`): `api.base` is the page's origin (`?api=<url>` picks another
|
||||
server, which must allow CORS); the player id and settings are saved with sys.save
|
||||
(IndexedDB), X-MM-User sent as on desktop (the cookie works too, same origin).
|
||||
Fullscreen (F11, display dialog) through the browser's Fullscreen API, not saved or
|
||||
restored (the browser allows it only after a click or key). Moon generation is the
|
||||
mmterrain extension in wasm (no threads there): stepped with a share of each frame
|
||||
(30% of the frame time, 2–12 ms: ~2 ms at 144 Hz), the buffer filled over the next
|
||||
frames, grid capped at 128 (`WEB_RESOLUTION` in moon.script). Moon targets fall back to 8 bits without EXT_color_buffer_float.
|
||||
Variable frame rate there (`sys.set_update_frequency(0)` in main.script): the browser's
|
||||
requestAnimationFrame paces at the display's rate; `display.update_frequency = 60`
|
||||
(desktop's timer pacing) made the engine skip callbacks: 60 fps in a 2/3 cadence on
|
||||
120/144 Hz displays, dropped frames on jittery 60 Hz ones. Use dt or the clock for motion.
|
||||
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
|
||||
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
|
||||
depth buffer: the render script binds every bloom sampler and makes targets again at a
|
||||
new size. Check the console after render changes.
|
||||
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
|
||||
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
|
||||
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
|
||||
by real mouse clicks (element rects from the test hook: the page pushes commands onto
|
||||
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
|
||||
claim; fails on console errors.
|
||||
|
||||
## Commands
|
||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
|
||||
bob's output; `build/x86_64-linux/dmengine` is the engine with our extensions).
|
||||
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
|
||||
- `make web`, `make serve-web`: the browser build (Web build).
|
||||
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
|
||||
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
|
||||
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
|
||||
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
|
||||
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
|
||||
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
|
||||
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
|
||||
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
|
||||
- `make check`: checks (below).
|
||||
- Before/after screenshots of every section (works on any build, also one from before a
|
||||
change): `scripts/fixture.py <api> > fx.json`, then
|
||||
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
|
||||
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
|
||||
after each other (the fixture's drills and market move on).
|
||||
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
|
||||
`mm.fake_reject`, shots via `SHOTS_DIR`).
|
||||
- Never kill processes by name (scripts stop the game by its pid).
|
||||
|
||||
## Test driver (`modules/test.lua`, `scripts/drive.py`)
|
||||
The engine's debug HTTP service (debug builds; port from the log line `Engine service
|
||||
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
|
||||
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
|
||||
runs in the same Lua state as the gui script but outside it, so it only pushes commands
|
||||
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
|
||||
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
|
||||
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
|
||||
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
|
||||
`$MM_DRIVE_DATA`), never the player's own settings.
|
||||
- Elements: every interactive component registers a stable id while it's built:
|
||||
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
|
||||
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
|
||||
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
|
||||
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
|
||||
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
|
||||
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
|
||||
over three frames through Druid: real input, hover included), `hover(id)`,
|
||||
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
|
||||
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
|
||||
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
|
||||
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
|
||||
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
|
||||
`restart()` (quit and start the game again, same options and saved files),
|
||||
`cmd("frames", "reset")` (frame times since the last reset: fps, dt, the game screen's
|
||||
update and its rebuilds in ms; the web harness reads it the same way). F3 in a debug
|
||||
build shows the engine's profiler.
|
||||
Those go through the test hook, which calls the screen's input function directly, so
|
||||
they work even when the engine sends the game no input. Real input through the X server
|
||||
(xdotool on the driver's Xvfb, its own display from `Xvfb -displayfd`: `xvfb-run -a` raced
|
||||
when two runs started together; key and button held 80 ms like a hand; the engine polls
|
||||
once a frame, and mmwindow's sticky keys and buttons keep a press shorter than a frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
|
||||
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
|
||||
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
|
||||
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
|
||||
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
|
||||
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
|
||||
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
|
||||
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
|
||||
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
|
||||
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
|
||||
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
|
||||
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
|
||||
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
|
||||
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
|
||||
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
|
||||
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
|
||||
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
|
||||
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
|
||||
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
|
||||
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
|
||||
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
|
||||
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
|
||||
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
|
||||
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
|
||||
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
|
||||
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
|
||||
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
|
||||
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
|
||||
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
|
||||
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
|
||||
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
|
||||
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
|
||||
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
|
||||
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
|
||||
page at once; the invariant's repair; args `reduced`, and run it with `--config
|
||||
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
|
||||
moon; the Moons tab returns to the last moon after other sections, back / forward, a
|
||||
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
|
||||
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
|
||||
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
|
||||
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
|
||||
folder (`<out>/data`), never the real settings file. Run with `--config
|
||||
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
|
||||
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
|
||||
grants through `/utils/players`); prints its ids as JSON.
|
||||
|
||||
## Fast iteration (hot reload)
|
||||
`make dev` builds once and starts the game from the build folder (engine
|
||||
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
|
||||
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
|
||||
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
|
||||
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
|
||||
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
|
||||
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
|
||||
registered first (a `run_script` listing them), then `.luac` files, then the game's
|
||||
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
|
||||
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
|
||||
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
|
||||
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
|
||||
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
|
||||
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
|
||||
to those, to collections, game objects, `main.script`, materials, shaders or the render
|
||||
script need a restart: `make dev-stop dev` (reload says so).
|
||||
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
|
||||
(core/main.lua) or lazily.
|
||||
- `make reload` fails loudly on a compile error (and keeps the old code running).
|
||||
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
|
||||
|
||||
## Checks (`make check`, scripts/check.py)
|
||||
Fails on: the flex solver's unit tests (`luajit scripts/flex_test.lua`); Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
|
||||
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
|
||||
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
|
||||
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
|
||||
a fixture player in every section: symbols in sym, the rest in the text fonts); the moon
|
||||
generator (`node scripts/moon-check.mjs [resolution] [seeds...]`:
|
||||
`mmterrain/src/terrain.c`, built for the host with `cc` and loaded into luajit through the
|
||||
FFI, and terrain.lua's settings side, against terrain.js; needs node, luajit, cc); and engine warnings in a `sections` scenario log (font batches, characters, text entries,
|
||||
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
|
||||
against `MM_API` (required, no default: the checks make a fixture player there, so use a test server, never the dev one) when it answers; otherwise the newest
|
||||
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
|
||||
|
||||
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
|
||||
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per
|
||||
server.
|
||||
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
|
||||
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
|
||||
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
|
||||
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
|
||||
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
|
||||
- `view`: the view to open, `key=value&...` of `state.view` keys
|
||||
(`section=equipment&drill=<id>`).
|
||||
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
|
||||
- `theme`: `light` or `dark` (default: the saved setting, else dark).
|
||||
- `test`: `0` turns the test hook off in a debug build (it's never in release).
|
||||
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
|
||||
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
|
||||
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
|
||||
scale; not saved). Default: the saved setting (the display dialog), else 1.
|
||||
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
|
||||
--config mm.display_scale=2` looks like 1280x800).
|
||||
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
|
||||
the others on the command line.
|
||||
|
||||
## Conventions
|
||||
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
|
||||
not screens.
|
||||
- Every gui_script uses one Druid instance (`druid.new(self)` and the
|
||||
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
|
||||
interaction (buttons, hover, scroll, input, layout, data list) through the components.
|
||||
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
|
||||
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
|
||||
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
|
||||
colors come from `theme.lua`.
|
||||
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
|
||||
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
|
||||
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
|
||||
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
|
||||
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
|
||||
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
|
||||
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
|
||||
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
|
||||
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
|
||||
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
|
||||
1280x800 every cap is above what fits; keep it that way (compare shots).
|
||||
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
|
||||
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
|
||||
is a token: no hex or vector colors in code (template node colors via `theme.template`,
|
||||
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
|
||||
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
|
||||
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
|
||||
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
|
||||
tells you which).
|
||||
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
|
||||
`max_characters` raised in `game.project`. Text past the font batch limit silently
|
||||
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
|
||||
dialog) and images split font batches.
|
||||
- Outline textures use slice9 2 (not 1).
|
||||
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
|
||||
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
|
||||
- The server is the only source of truth: every response's `state` replaces ours and
|
||||
`state.view` becomes our view; actions send the current view plus their params and
|
||||
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
|
||||
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
|
||||
when `nextEnd` is due.
|
||||
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
|
||||
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
|
||||
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
|
||||
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
|
||||
refine, collect. Others (and predictors that return false) show a busy button.
|
||||
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
|
||||
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
|
||||
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
|
||||
counts through fx.
|
||||
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
||||
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
|
||||
it. Run `make check` before calling it done.
|
||||
- Keep changes small; don't touch the server (`../server`) from here.
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
||||
# python3 and luajit for shot, play, drive, dev and check.
|
||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||
BOB := tools/bob.jar
|
||||
PLATFORM := x86_64-linux
|
||||
VARIANT ?= debug
|
||||
BUNDLE := dist/$(PLATFORM)/MoonMiners
|
||||
BIN := $(BUNDLE)/MoonMiners.x86_64
|
||||
SHOT ?= dist/shot.png
|
||||
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
|
||||
ARGS ?=
|
||||
|
||||
.PHONY: build run shot play clean resolve dev reload dev-stop drive check web serve-web
|
||||
|
||||
$(BOB):
|
||||
mkdir -p tools
|
||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||
|
||||
resolve: $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
|
||||
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
|
||||
.internal/lib/dependencies.json: game.project | $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
touch $@
|
||||
|
||||
build: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
--archive --bundle-output dist/$(PLATFORM) build bundle
|
||||
|
||||
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
|
||||
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
|
||||
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
|
||||
WEB_VARIANT ?= release
|
||||
UPSTREAM ?= http://localhost:3000
|
||||
WEB_PORT ?= 8080
|
||||
web: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
--archive --bundle-output dist/web build bundle
|
||||
|
||||
serve-web:
|
||||
scripts/serve-web.py --port $(WEB_PORT) --upstream $(UPSTREAM)
|
||||
|
||||
# Hot reload loop: `make dev` starts the game from build/default (once), `make reload`
|
||||
# rebuilds (no bundle) and posts what changed to the running engine. HEADLESS=1 make dev
|
||||
# runs it under Xvfb. See AGENTS.md "Fast iteration".
|
||||
dev: build
|
||||
scripts/dev.py start $(ARGS)
|
||||
|
||||
reload: $(BOB)
|
||||
@mkdir -p dist/dev
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
|
||||
scripts/dev.py reload
|
||||
|
||||
dev-stop:
|
||||
scripts/dev.py stop
|
||||
|
||||
# A scenario through the test hook (scripts/drive.py, AGENTS.md "Test driver") on a fresh
|
||||
# build/default, e.g. make drive SCENARIO=smoke MM_API=http://localhost:3118 DRIVE=--fixture
|
||||
SCENARIO ?= smoke
|
||||
DRIVE ?= --fixture
|
||||
drive: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||
|
||||
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
|
||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||
check: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||
scripts/check.py
|
||||
|
||||
run: build
|
||||
$(BIN) $(ARGS)
|
||||
|
||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||
shot: build
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
|
||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||
play: build
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf build dist .internal/cache
|
||||
@@ -1,92 +0,0 @@
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
|
Before Width: | Height: | Size: 111 B |
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1013 B |
|
Before Width: | Height: | Size: 330 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 234 B |
|
Before Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 310 B |
@@ -1,33 +0,0 @@
|
||||
Moon Miners sound effects (Defold client)
|
||||
|
||||
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
|
||||
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
|
||||
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
|
||||
(credit not required, given anyway).
|
||||
|
||||
nav.wav Interface Sounds switch_007
|
||||
toggle.wav Interface Sounds switch_005
|
||||
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
|
||||
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
|
||||
buy.ogg Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
|
||||
+ synth beep
|
||||
upgrade.ogg Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
|
||||
+ synth sweep
|
||||
moon.ogg Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
|
||||
deploy.ogg Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
|
||||
refine.ogg Sci-Fi Sounds forceField_001 + computerNoise_000
|
||||
collect.ogg Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
|
||||
|
||||
modal.wav, confirm.ogg, error.wav and ready.wav are synthesized with SoX (no samples).
|
||||
|
||||
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
|
||||
https://kenney.nl/assets/impact-sounds
|
||||
|
||||
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds
|
||||
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
|
||||
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
|
||||
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
|
||||
web build's parameters (server/web/static/js/sfx.js).
|
||||
|
||||
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
|
||||
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
|
||||
@@ -1,45 +0,0 @@
|
||||
images {
|
||||
image: "/assets/images/white.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/frame.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/outline.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/sphere.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/glow.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/fade.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/ring.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/speaker.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/speaker_off.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/spark.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/sun.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/moon.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/pentagon.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/pentagon_rim.png"
|
||||
}
|
||||
margin: 2
|
||||
extrude_borders: 2
|
||||
inner_padding: 0
|
||||
@@ -1,85 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,213 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,325 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,36 +0,0 @@
|
||||
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
|
||||
-- a short label, optionally filled. Returns the box node and its width.
|
||||
--
|
||||
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
||||
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
||||
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
||||
--
|
||||
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
|
||||
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
|
||||
-- pad (6 each side), min_w, right (x is the right edge).
|
||||
|
||||
local ui = require("modules.ui")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.new(parent, label, x, y, opts)
|
||||
opts = opts or {}
|
||||
local color = opts.color or "line_hi"
|
||||
local style = { font = opts.font or "sans_semi", px = opts.px or 11, color = opts.text or color,
|
||||
tracking = opts.tracking or (opts.font == "mono" and 0 or 0.12), upper = opts.upper }
|
||||
local h = opts.h or 18
|
||||
local pad = opts.pad or 6
|
||||
local probe = ui.text(parent, label, style, 0, 0)
|
||||
local w = math.max(opts.min_w or 0, math.floor(ui.text_width(probe) + pad * 2))
|
||||
gui.delete_node(probe)
|
||||
local left = opts.right and (x - w) or x
|
||||
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
|
||||
if opts.fill then
|
||||
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
|
||||
gui.move_below(f, box)
|
||||
end
|
||||
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
||||
return box, w
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,338 +0,0 @@
|
||||
-- Dialog (the web's .dialog): a modal panel over the game screen, in the same gui (on its
|
||||
-- own layer, with its own Druid instance), so a confirmed action runs in the game
|
||||
-- script and a page rebuild doesn't touch it. One at a time: g.dialog (core/main.lua).
|
||||
--
|
||||
-- g.dialog:open({ title, message, w, h,
|
||||
-- ok = { label, price, danger, act = "<action>", params = {...} } or { label, on_ok = fn },
|
||||
-- cancel = "Cancel" (the other button; "Close" without ok),
|
||||
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
|
||||
-- title_x = 24 }) -- the title's x (room for an image left of it)
|
||||
-- g.dialog:close("ok" | "cancel")
|
||||
-- g.dialog:is_open()
|
||||
--
|
||||
-- In build, d.reply(action, params) closes and runs an action (effects start from the
|
||||
-- clicked button); d:button(parent, label, x, y, opts) makes a button (components/button
|
||||
-- opts plus on_click / id). The message wraps (ui.wrap, may hold ₥); the panel grows to
|
||||
-- fit it. Test ids: "dialog:ok", "dialog:cancel".
|
||||
--
|
||||
-- Motion (app.css dialog-in/-out, ui.js): the backdrop fades in (0.28 s); the panel
|
||||
-- scales up from .94, rises 8 px and unclips from its middle (0.28 s overshoot); a cyan
|
||||
-- scan line sweeps down it; title, message and buttons rise in, 60 ms apart. Closing:
|
||||
-- fade, scale .97, clip to the middle (0.18 s). Sounds: open (a confirm) or modal (custom
|
||||
-- content, after the action's own), confirm on OK, close otherwise. While open the page
|
||||
-- gets no input and its hovers are dropped (druid:reset_hovers); they come back on close.
|
||||
|
||||
local druid = require("druid.druid")
|
||||
local theme = require("modules.theme")
|
||||
local ui = require("modules.ui")
|
||||
local anim = require("modules.anim")
|
||||
local sfx = require("modules.sfx")
|
||||
local button = require("components.button")
|
||||
local test = require("modules.test")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
local W0, H0 = 460, 230
|
||||
local SCRIM = theme.color.scrim
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
-- g: the game screen; layer: a node over the page (top-left = page top-left).
|
||||
function M.new(g, layer)
|
||||
return setmetatable({ g = g, layer = layer }, M)
|
||||
end
|
||||
|
||||
function M:is_open()
|
||||
return self.root ~= nil and not self.closing
|
||||
end
|
||||
|
||||
-- A button inside the dialog (its own Druid instance). opts: components/button draw
|
||||
-- options plus on_click, id.
|
||||
function M:button(parent, label, x, y, opts)
|
||||
opts = opts or {}
|
||||
local root, nodes, w = ui.button(parent, label, x, y, opts)
|
||||
local d = self
|
||||
if not opts.disabled then
|
||||
button.bind(self.g, root, nodes, { id = opts.id, druid = self.druid, on_click = function()
|
||||
d.last_button = root
|
||||
if opts.on_click then
|
||||
opts.on_click()
|
||||
end
|
||||
end })
|
||||
end
|
||||
return root, nodes, w
|
||||
end
|
||||
|
||||
-- Close and run an action on the game screen (its effects start at `from`, a node, or
|
||||
-- at the page button that opened the dialog: ok.from, a rect from fx:rect_of with its
|
||||
-- test id). quiet: close without the confirm sound (the buy modal's buttons, main.js).
|
||||
function M:act(name, params, from, quiet)
|
||||
local g = self.g
|
||||
local opener = self.opts and self.opts.ok and self.opts.ok.from
|
||||
if opener then
|
||||
g.fx.last_origin = { x = opener.x, y = opener.y, w = opener.w, h = opener.h, id = opener.id }
|
||||
else
|
||||
g.fx:origin(from)
|
||||
end
|
||||
self:close(quiet and "quiet" or "ok")
|
||||
require("screens.game.core.session").act(g, name, params or {})
|
||||
end
|
||||
|
||||
function M:open(opts)
|
||||
if self.root then
|
||||
self:remove()
|
||||
end
|
||||
local g = self.g
|
||||
opts = opts or {}
|
||||
self.opts = opts
|
||||
self.closing = false
|
||||
self.druid = druid.new(g)
|
||||
test.begin_build("dialog")
|
||||
test.set_scope("dialog")
|
||||
g.druid:reset_hovers()
|
||||
local W, H = g.size[1], g.size[2]
|
||||
local w = opts.w or W0
|
||||
local d = self
|
||||
self.reply = function(name, params)
|
||||
d:act(name, params, d.last_button, true)
|
||||
end
|
||||
|
||||
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
|
||||
local root = ui.rect(self.layer, 0, 0, W, H, "none")
|
||||
self.root = root
|
||||
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
|
||||
self.scrim = scrim
|
||||
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
|
||||
gui.delete_node(probe)
|
||||
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
||||
local holder = anim.group(root, W / 2, H / 2, 1, 1)
|
||||
gui.set_pivot(holder, gui.PIVOT_CENTER)
|
||||
self.holder = holder
|
||||
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
|
||||
gui.set_position(clip, vec(0, 0))
|
||||
gui.set_pivot(clip, gui.PIVOT_CENTER)
|
||||
gui.set_clipping_mode(clip, gui.CLIPPING_MODE_STENCIL)
|
||||
gui.set_clipping_visible(clip, false)
|
||||
self.clip, self.w, self.h = clip, w, h
|
||||
local box = ui.panel(clip, -w / 2, -h / 2, w, h)
|
||||
gui.set_color(box, ui.color("field", 0.96))
|
||||
self.box = box
|
||||
|
||||
-- Input: Druid gives the last created component input first, so: the backdrop (a
|
||||
-- click on it cancels), then a blocker over the panel, then the panel's buttons.
|
||||
self.druid:new_button(scrim, function()
|
||||
d:close("cancel")
|
||||
end):set_animations_disabled()
|
||||
self.druid:new_blocker(clip)
|
||||
-- (Druid's back handler takes key_back only: Esc is a hotkey.)
|
||||
self.druid:new_back_handler(function()
|
||||
d:close("cancel")
|
||||
end)
|
||||
self.druid:new_hotkey("key_esc", function()
|
||||
d:close("cancel")
|
||||
end)
|
||||
|
||||
-- Title, message (or custom content), buttons: three groups that rise in turn.
|
||||
local g_title = anim.group(box, 0, 0)
|
||||
local g_body = anim.group(box, 0, 0)
|
||||
local g_actions = anim.group(box, 0, 0)
|
||||
-- The panel's layout (geometry only: each group builds into its own part): the title
|
||||
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
|
||||
-- left of Cancel (Cancel is made first).
|
||||
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
|
||||
local ok = opts.ok
|
||||
local danger = ok and ok.danger
|
||||
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
|
||||
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
|
||||
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
|
||||
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
|
||||
{ style = { height = 20 }, id = "title" },
|
||||
flex.box({ grow = 1 }),
|
||||
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
|
||||
})
|
||||
flex.solve(tree, 0, 0)
|
||||
local title = tree.children[1]
|
||||
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
|
||||
if opts.build then
|
||||
opts.build(self, g_body, w, h)
|
||||
else
|
||||
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
|
||||
end
|
||||
local by = cancel_box.y
|
||||
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
|
||||
id = "dialog:cancel", on_click = function() d:close("cancel") end,
|
||||
})
|
||||
if ok then
|
||||
local ob
|
||||
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
|
||||
id = "dialog:ok", price = ok.price,
|
||||
variant = danger and "danger" or "primary",
|
||||
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
|
||||
accent = danger and "danger" or nil,
|
||||
on_click = function()
|
||||
if ok.on_ok then
|
||||
d:close("ok")
|
||||
ok.on_ok()
|
||||
elseif ok.act then
|
||||
d:act(ok.act, ok.params, ob)
|
||||
else
|
||||
d:close("ok")
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
self.by = by
|
||||
test.set_scope("page")
|
||||
self:animate_in(g_title, g_body, g_actions)
|
||||
if opts.build then
|
||||
sfx.play("modal", { delay = 150 })
|
||||
else
|
||||
sfx.play("open")
|
||||
end
|
||||
end
|
||||
|
||||
function M:animate_in(...)
|
||||
local groups = { ... }
|
||||
local holder, clip, scrim = self.holder, self.clip, self.scrim
|
||||
gui.set_color(scrim, vmath.vector4(SCRIM.x, SCRIM.y, SCRIM.z, 0))
|
||||
gui.animate(scrim, "color.w", SCRIM.w, anim.ease.ease_out, 0.28)
|
||||
-- Content fades in from under veils in the panel's color (anim.veil): title, message,
|
||||
-- buttons in turn.
|
||||
local field = ui.color("field")
|
||||
local w, h, by = self.w, self.h, self.by
|
||||
local regions = { { 2, 48 }, { 50, by - 60 }, { by - 10, h - by + 8 } } -- (the edge stays)
|
||||
for i, r in ipairs(regions) do
|
||||
anim.veil(self.box, 2, r[1], w - 4, r[2], field, (i - 1) * 0.06, anim.reduced and 0.12 or 0.35)
|
||||
end
|
||||
if anim.reduced then
|
||||
return
|
||||
end
|
||||
local p = gui.get_position(holder)
|
||||
gui.set_scale(holder, vmath.vector3(0.94, 0.94, 1))
|
||||
gui.animate(holder, "scale", vmath.vector3(1, 1, 1), anim.ease.overshoot, 0.28)
|
||||
gui.set_position(holder, vec(p.x, p.y - 8))
|
||||
gui.animate(holder, "position.y", p.y, anim.ease.overshoot, 0.28)
|
||||
local size = gui.get_size(clip)
|
||||
gui.set_size(clip, vec(size.x, size.y * 0.04))
|
||||
gui.animate(clip, "size", size, anim.ease.overshoot, 0.28)
|
||||
anim.sweep(self.box, self.w, self.h, ui.color("cyan"), 0.7, 0.05)
|
||||
for i, grp in ipairs(groups) do
|
||||
anim.rise(grp, (i - 1) * 0.06, 6, 0.35) -- rise-in: 0.35 s, 60 ms apart
|
||||
end
|
||||
end
|
||||
|
||||
-- value: "ok" (the confirm sound), "quiet" (none: the action makes its own) or "cancel".
|
||||
function M:close(value)
|
||||
if not self.root or self.closing then
|
||||
return
|
||||
end
|
||||
self.closing = true
|
||||
self.closing_at = socket.gettime()
|
||||
if value ~= "quiet" then
|
||||
sfx.play(value == "ok" and "confirm" or "close")
|
||||
end
|
||||
local d = self
|
||||
local function done()
|
||||
d:remove()
|
||||
end
|
||||
local dur = anim.reduced and 0.12 or 0.18
|
||||
gui.animate(self.scrim, "color.w", 0, anim.ease.ease_in, dur, 0, done)
|
||||
anim.veil(self.box, 2, 2, self.w - 4, self.h - 4, ui.color("field"), 0, dur, true)
|
||||
if not anim.reduced then
|
||||
gui.animate(self.holder, "scale", vmath.vector3(0.97, 0.97, 1), anim.ease.ease_in, 0.18)
|
||||
local size = gui.get_size(self.clip)
|
||||
gui.animate(self.clip, "size", vec(size.x, size.y * 0.04), anim.ease.ease_in, 0.18)
|
||||
end
|
||||
self.g.rehover = true -- the page's hover comes back under the pointer
|
||||
end
|
||||
|
||||
-- Gone at once (a new dialog, the screen going away).
|
||||
function M:remove()
|
||||
if self.druid then
|
||||
-- A dialog gone in the frame it opened (another one replacing it): Druid's late
|
||||
-- init (a timer.delay(0)) would still run on its deleted nodes.
|
||||
if self.druid._late_init_timer_id then
|
||||
timer.cancel(self.druid._late_init_timer_id)
|
||||
self.druid._late_init_timer_id = nil
|
||||
end
|
||||
self.druid:final()
|
||||
self.druid = nil
|
||||
end
|
||||
if self.root then
|
||||
gui.delete_node(self.root)
|
||||
end
|
||||
test.begin_build("dialog")
|
||||
self.root, self.closing, self.opts, self.closing_at = nil, false, nil, nil
|
||||
end
|
||||
|
||||
-- How long a close may take before its end is forced (the close animation is 0.18 s; its
|
||||
-- callback is what removes the backdrop, and while the backdrop exists the page gets no
|
||||
-- input).
|
||||
local CLOSE_LIMIT = 1
|
||||
|
||||
-- The input invariants (checked every frame by core/main.lua): what blocks the page's
|
||||
-- input exists only while a dialog is open or closing, and a close finishes. Returns a
|
||||
-- description of what was wrong (and is now repaired), or nil.
|
||||
function M:check()
|
||||
if not self.root then
|
||||
if self.druid then
|
||||
self.druid:final()
|
||||
self.druid = nil
|
||||
return "a dialog's Druid instance outlived it"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
if not self.druid then
|
||||
self:remove()
|
||||
return "a dialog backdrop without its input"
|
||||
end
|
||||
if self.closing and socket.gettime() - (self.closing_at or 0) > CLOSE_LIMIT then
|
||||
self:remove()
|
||||
return "a dialog close never finished"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The page was resized: keep the backdrop full and the panel centered.
|
||||
function M:layout(W, H)
|
||||
if not self.root then
|
||||
return
|
||||
end
|
||||
gui.set_size(self.root, vec(W, H))
|
||||
gui.set_size(self.scrim, vec(W, H))
|
||||
gui.cancel_animations(self.holder, "position.y")
|
||||
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
||||
end
|
||||
|
||||
function M:update(dt)
|
||||
if self.druid then
|
||||
self.druid:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Returns true while open (the page gets no input then).
|
||||
function M:on_input(action_id, action)
|
||||
if not self.root then
|
||||
return false
|
||||
end
|
||||
if self.druid and not self.closing then
|
||||
self.druid:on_input(action_id, action)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function M:on_message(message_id, message, sender)
|
||||
if self.druid then
|
||||
self.druid:on_message(message_id, message, sender)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,96 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,47 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,151 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,94 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,31 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,193 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,66 +0,0 @@
|
||||
[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
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
@@ -1,180 +0,0 @@
|
||||
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
|
||||