Compare commits
185
Commits
c8daa086e1
...
main
No files matched your search
@@ -13,3 +13,6 @@
|
||||
# Blender files
|
||||
*.blend
|
||||
*.blend1
|
||||
|
||||
# Agent worktree state (scripts/wt)
|
||||
/.wt/
|
||||
@@ -8,28 +8,39 @@ 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
|
||||
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
|
||||
its claim per cycle, the other shortens its cycle, a level at a time, per tool); 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).
|
||||
|
||||
## 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`.
|
||||
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
|
||||
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). 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`.
|
||||
what it sends. It is the only game client, and its moon generator
|
||||
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. 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`.
|
||||
|
||||
## 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.
|
||||
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
|
||||
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
|
||||
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
|
||||
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
|
||||
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
|
||||
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
|
||||
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
|
||||
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
|
||||
tests and servers run there reach only `mm_<task>`. Never point anything at the main
|
||||
database (`moonminers`): it holds the author's game.
|
||||
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
|
||||
pop can take another agent's work. Commit on your branch, or save a patch file.
|
||||
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
|
||||
commit it.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
|
||||
|
||||
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
|
||||
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
|
||||
- `client/`: Defold client, for desktop and the browser (WebAssembly).
|
||||
|
||||
## Server and web client
|
||||
## Server
|
||||
|
||||
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
`server/README.md`).
|
||||
@@ -14,22 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
cd server
|
||||
make migrate
|
||||
make dev # http://localhost:3000
|
||||
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
|
||||
```
|
||||
|
||||
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
|
||||
starting the server: it applies new migrations and syncs the tools and prices with the
|
||||
config (the server alone doesn't).
|
||||
|
||||
## Defold client
|
||||
|
||||
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
|
||||
is optional: https://defold.com/download/
|
||||
|
||||
With the server running:
|
||||
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
|
||||
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
|
||||
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
|
||||
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
|
||||
|
||||
```bash
|
||||
cd client
|
||||
make run # desktop build, plays against http://localhost:3000
|
||||
make web && make serve-web # browser build: open http://localhost:8080
|
||||
make web # browser build into dist/web/MoonMiners/
|
||||
```
|
||||
|
||||
The server serves the browser build at http://localhost:3000 (from
|
||||
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
|
||||
after the first `make web`, restart the server; later builds only need a page reload.
|
||||
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
|
||||
players.
|
||||
|
||||
## Deploy
|
||||
|
||||
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
|
||||
+189
-626
@@ -1,637 +1,200 @@
|
||||
# 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.
|
||||
A native client for Moon Miners (rules and state live in the Go server: `../server`,
|
||||
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
|
||||
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
|
||||
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
|
||||
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
|
||||
gamepad later. Details live in each file's header comment; this file has the rules and a
|
||||
map.
|
||||
|
||||
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.
|
||||
## Rules
|
||||
- The server is the authority. Show the state it sends, send actions. Prediction may show
|
||||
an action's result early, computed only from numbers the server sent (`take`, the tool's
|
||||
`cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
|
||||
replaces it. No other game rules in Lua. Countdowns tick locally as display only.
|
||||
- Keep changes small; don't touch `../server` from here.
|
||||
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
|
||||
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
|
||||
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
|
||||
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
|
||||
label); animations own what's inside (CSS layout vs transform). Never give a box a node
|
||||
that something animates by position or size. Widgets built at a size (ui.button, bars,
|
||||
dashed edges) are built after the solve at their box's rect; text is cut after it
|
||||
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
|
||||
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
|
||||
a button changes its position (reserve room: fixed heights, placeholders), and when a
|
||||
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
|
||||
cards), so repeated clicks at one place keep working.
|
||||
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
|
||||
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
|
||||
whole gui script, so closing a dialog left the game deaf). Don't post
|
||||
acquire/release_input_focus anywhere else. The dialog's input invariant
|
||||
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
|
||||
`ERROR:MM`.
|
||||
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
|
||||
One Druid instance per gui_script (the dialog has its own). Interaction goes through
|
||||
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
|
||||
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
|
||||
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
|
||||
keys, fx and the test driver depend on it.
|
||||
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
|
||||
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
|
||||
layout is top-left based (x right, y down).
|
||||
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
|
||||
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
|
||||
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
|
||||
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
|
||||
writes each part once per state, by content): never change `g.state` in place; a
|
||||
prediction changes its copy before it is shown. What must survive
|
||||
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
|
||||
builds outside its block. A new per-build registry notes its entries in the block journal
|
||||
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
|
||||
`blocks.bound(fn)`.
|
||||
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
|
||||
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
|
||||
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
|
||||
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
|
||||
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
|
||||
server text is safe to pass. Add missing characters to the `.font` (make check names them).
|
||||
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
|
||||
doesn't render (make check catches the warning); stencils and images split batches.
|
||||
Outline textures use slice9 2.
|
||||
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
|
||||
children: fade content in from under a veil, not by fading a subtree. Every new
|
||||
animation honors reduced motion (`anim.reduced`). Effects for action results go in
|
||||
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
|
||||
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
|
||||
below 1250 wide the aside gets its own row and the page scrolls. Content caps
|
||||
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
|
||||
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
|
||||
browser console (WebGL feedback loops, Firefox target resizes).
|
||||
- Native code (`mmwindow/`, `mmterrain/`): user rule, C only (`.c`, Lua C API); C++ only
|
||||
where the dmSDK forces it, as a C-style shim (free functions and plain structs: no
|
||||
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
|
||||
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
|
||||
files. Changing a native extension (or the dependencies) sends a remote engine build.
|
||||
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
|
||||
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
|
||||
`display.width/height`.
|
||||
- Never kill processes by name; scripts stop the game by its pid.
|
||||
|
||||
## 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.
|
||||
## Testing
|
||||
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
|
||||
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
|
||||
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
|
||||
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
|
||||
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
|
||||
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
|
||||
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
|
||||
says what it checks and needs). Run `make check` (with `MM_API` set to your test
|
||||
server; it makes a fixture player there) before calling it done.
|
||||
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
|
||||
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
|
||||
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
|
||||
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
|
||||
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
|
||||
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
|
||||
server, which `wt up` starts).
|
||||
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
|
||||
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
|
||||
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
|
||||
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
|
||||
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
|
||||
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
|
||||
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
|
||||
click_check to a feature's scenario when it has buttons people click repeatedly.
|
||||
|
||||
## Commands
|
||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
|
||||
bob's output; `build/x86_64-linux/dmengine` 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).
|
||||
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
|
||||
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
|
||||
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
|
||||
from `build/default` in the foreground (loose files, engine service on), so `make reload`
|
||||
works on it.
|
||||
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
|
||||
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
|
||||
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
|
||||
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
|
||||
`node scripts/web-shots.mjs` (Playwright; header of the script).
|
||||
|
||||
## 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`
|
||||
## Hot reload (`make reload`, `scripts/dev.py`)
|
||||
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
|
||||
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
|
||||
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
|
||||
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
|
||||
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
|
||||
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
|
||||
too; restart if one looks wrong.
|
||||
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
|
||||
collections, game objects, `main.script`, materials, shaders, the render script; modules
|
||||
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
|
||||
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
|
||||
native extensions and dependencies (a new engine).
|
||||
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
|
||||
(core/main.lua) or lazily.
|
||||
- `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.
|
||||
## Map
|
||||
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
|
||||
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
|
||||
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
|
||||
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
|
||||
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
|
||||
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
|
||||
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
|
||||
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
|
||||
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
|
||||
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
|
||||
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
|
||||
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
|
||||
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
|
||||
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
|
||||
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
|
||||
(shared by card sections).
|
||||
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
|
||||
|
||||
## 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.
|
||||
Components (`components/`, API in each header; build new UI from these): `button`,
|
||||
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
|
||||
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
|
||||
|
||||
## 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.
|
||||
Modules (`modules/`):
|
||||
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
|
||||
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
|
||||
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
|
||||
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
|
||||
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
|
||||
{ lines = 2 })` (at most n lines, cut with …).
|
||||
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
|
||||
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
|
||||
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
|
||||
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
|
||||
`hot.lua` (hot reload).
|
||||
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
|
||||
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
|
||||
|
||||
Elsewhere:
|
||||
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
|
||||
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
|
||||
`render/mm.render_script`: HDR moon pass with bloom.
|
||||
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
|
||||
game's only one (it began as a C port of the web client's terrain.js, now deleted).
|
||||
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
|
||||
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
|
||||
- `web/index.html`: the web build's page.
|
||||
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
|
||||
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
|
||||
|
||||
## Stack
|
||||
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
|
||||
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
|
||||
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
|
||||
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
|
||||
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
|
||||
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
|
||||
|
||||
## Config (`--config=mm.<key>=<value>`)
|
||||
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
|
||||
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
|
||||
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
|
||||
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
|
||||
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
|
||||
`reveal_freeze`, `sfx_debug`.
|
||||
+40
-15
@@ -1,5 +1,17 @@
|
||||
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
|
||||
# python3 and luajit for shot, play, drive, dev and check.
|
||||
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
|
||||
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
|
||||
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
|
||||
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
|
||||
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
|
||||
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
|
||||
# for shot, play, drive, dev and check (Linux).
|
||||
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
|
||||
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
|
||||
$(USERPROFILE)/Defold C:/Defold
|
||||
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
|
||||
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
|
||||
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
|
||||
JAVA ?= $(or $(DEFOLD_JAVA),java)
|
||||
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
|
||||
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
|
||||
BOB := tools/bob.jar
|
||||
@@ -11,32 +23,33 @@ 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
|
||||
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
|
||||
|
||||
$(BOB):
|
||||
mkdir -p tools
|
||||
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
|
||||
|
||||
resolve: $(BOB)
|
||||
java -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
$(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
|
||||
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
|
||||
touch $@
|
||||
|
||||
build: $(BOB) .internal/lib/dependencies.json
|
||||
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
|
||||
$(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.
|
||||
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
|
||||
# with /api and /static proxied to UPSTREAM (a test server).
|
||||
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) \
|
||||
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
|
||||
--archive --bundle-output dist/web build bundle
|
||||
|
||||
serve-web:
|
||||
@@ -50,9 +63,9 @@ dev: build
|
||||
|
||||
reload: $(BOB)
|
||||
@mkdir -p dist/dev
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|
||||
@$(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
|
||||
@scripts/dev.py reload
|
||||
|
||||
dev-stop:
|
||||
scripts/dev.py stop
|
||||
@@ -63,28 +76,40 @@ 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 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
scripts/drive.py $(DRIVE) $(SCENARIO)
|
||||
|
||||
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
|
||||
# dist/drive/lint-<size>/. Needs MM_API (a test server).
|
||||
lint: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
|
||||
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
|
||||
|
||||
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
|
||||
# scenario run (sections) against MM_API (required: a test server) if it answers.
|
||||
check: $(BOB) .internal/lib/dependencies.json
|
||||
@mkdir -p dist
|
||||
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|
||||
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
|
||||
scripts/check.py
|
||||
|
||||
# The game from build/default (loose files, engine service on), so `make reload` works on it.
|
||||
# The bundle in dist/ is for shot, play and shipping.
|
||||
run: build
|
||||
$(BIN) $(ARGS)
|
||||
scripts/dev.py run $(ARGS)
|
||||
|
||||
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
|
||||
shot: build
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
|
||||
|
||||
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
|
||||
# Needs MM_API (a test server): it makes a new player there.
|
||||
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)
|
||||
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
|
||||
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf build dist .internal/cache
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 877 B |
@@ -27,7 +27,8 @@ The claim whir, counter ticks, claim blips, credit blips and the moon reveal sou
|
||||
(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).
|
||||
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
|
||||
reveal's build-up and burst, client only) are synthesized by the same script.
|
||||
|
||||
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
|
||||
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
|
||||
Binary file not shown.
Binary file not shown.
@@ -31,6 +31,9 @@ images {
|
||||
images {
|
||||
image: "/assets/images/sun.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/gear.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/moon.png"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-- 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
|
||||
-- registers them for the panel entrance (g.frames, staggered). Store 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)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
|
||||
-- Chip / badge (the web's .badge, level chips, advisor 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, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
|
||||
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
|
||||
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
|
||||
--
|
||||
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
|
||||
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
|
||||
-- pad (6 each side), min_w, right (x is the right edge).
|
||||
--
|
||||
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
|
||||
|
||||
local ui = require("modules.ui")
|
||||
|
||||
@@ -26,11 +28,27 @@ function M.new(parent, label, x, y, opts)
|
||||
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)
|
||||
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
|
||||
-- move the fill out to box's parent, at its 0, 0).
|
||||
local edge = box
|
||||
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
|
||||
gui.set_parent(edge, box, false)
|
||||
gui.set_position(edge, vmath.vector3(0, 0, 0))
|
||||
end
|
||||
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
|
||||
return box, w
|
||||
end
|
||||
|
||||
-- Moon grade badges: edge/text color per grade, cheapest to best.
|
||||
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
|
||||
M.GRADE_W = 18
|
||||
|
||||
function M.grade(parent, grade, x, y, opts)
|
||||
opts = opts or {}
|
||||
local color = M.GRADE[grade] or "line_hi"
|
||||
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
|
||||
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
|
||||
right = opts.right })
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -7,6 +7,9 @@
|
||||
-- cancel = "Cancel" (the other button; "Close" without ok),
|
||||
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
|
||||
-- title_x = 24 }) -- the title's x (room for an image left of it)
|
||||
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
|
||||
-- backdrop (a click outside still closes it), no title or buttons, its top-right
|
||||
-- corner `right` from the page's right edge and `top` down; build makes the content.
|
||||
-- g.dialog:close("ok" | "cancel")
|
||||
-- g.dialog:is_open()
|
||||
--
|
||||
@@ -107,12 +110,13 @@ function M:open(opts)
|
||||
-- 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)
|
||||
local menu = opts.menu
|
||||
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
|
||||
self.scrim = scrim
|
||||
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
|
||||
gui.delete_node(probe)
|
||||
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
||||
local holder = anim.group(root, W / 2, H / 2, 1, 1)
|
||||
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
|
||||
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
|
||||
gui.set_pivot(holder, gui.PIVOT_CENTER)
|
||||
self.holder = holder
|
||||
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
|
||||
@@ -139,6 +143,13 @@ function M:open(opts)
|
||||
d:close("cancel")
|
||||
end)
|
||||
|
||||
if menu then
|
||||
opts.build(self, box, w, h)
|
||||
test.set_scope("page")
|
||||
sfx.play("toggle")
|
||||
return
|
||||
end
|
||||
|
||||
-- Title, message (or custom content), buttons: three groups that rise in turn.
|
||||
local g_title = anim.group(box, 0, 0)
|
||||
local g_body = anim.group(box, 0, 0)
|
||||
@@ -309,7 +320,12 @@ function M:layout(W, H)
|
||||
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))
|
||||
local menu = self.opts and self.opts.menu
|
||||
if menu then
|
||||
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
|
||||
else
|
||||
gui.set_position(self.holder, vec(W / 2, -H / 2))
|
||||
end
|
||||
end
|
||||
|
||||
function M:update(dt)
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
-- 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
|
||||
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
|
||||
-- -- id its row registers: Tab walks
|
||||
-- -- every row, scrolling (modules/focus)
|
||||
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
|
||||
-- -- its node is deleted (not when the
|
||||
-- -- list itself goes in a rebuild)
|
||||
-- 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
|
||||
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
|
||||
return l.create(item, index)
|
||||
end)
|
||||
if opts.on_remove then
|
||||
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
|
||||
if not g.tearing_down then
|
||||
opts.on_remove(node, item)
|
||||
end
|
||||
end)
|
||||
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)
|
||||
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
|
||||
end
|
||||
|
||||
function M:set_data(items)
|
||||
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
|
||||
local saved = self.g.scroll_pos[self.key] or 0
|
||||
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).
|
||||
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
|
||||
-- rows there, not the top ones only to drop them again.
|
||||
local s = self.scroll
|
||||
s:set_size(self.grid:get_size_for(#items))
|
||||
s.target_position.y = saved
|
||||
s:_set_scroll_position(s.position.x, saved)
|
||||
s.target_position.y = s.position.y
|
||||
self.data:set_data(items)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
|
||||
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
|
||||
-- exactly (screen position mapped into the track, not summed deltas).
|
||||
--
|
||||
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
|
||||
-- on_change = fn(v), on_release = fn(v) })
|
||||
-- s:set(v) -- move it without on_change (another control changed the value)
|
||||
--
|
||||
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
|
||||
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
|
||||
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
|
||||
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
|
||||
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local test = require("modules.test")
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
local TOUCH = hash("touch")
|
||||
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local function snap(v, o)
|
||||
local n = math.floor((v - o.min) / o.step + 0.5)
|
||||
v = o.min + n * o.step
|
||||
-- (decimal steps: drop float noise like 0.30000000000000004)
|
||||
v = tonumber(string.format("%.6f", v))
|
||||
return math.max(o.min, math.min(o.max, v))
|
||||
end
|
||||
|
||||
function M.new(g, parent, x, y, w, h, opts)
|
||||
local self = setmetatable({ opts = opts, w = w }, M)
|
||||
local hit = ui.rect(parent, x, y, w, h, "none")
|
||||
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
|
||||
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
|
||||
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
|
||||
gui.set_pivot(knob, gui.PIVOT_CENTER)
|
||||
gui.set_position(knob, vec(0, -h / 2))
|
||||
self.knob = knob
|
||||
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
|
||||
local v = snap(opts.min + f * (opts.max - opts.min), opts)
|
||||
self:_fill(f)
|
||||
if v ~= self.value then
|
||||
self.value = v
|
||||
if opts.on_change then
|
||||
opts.on_change(v)
|
||||
end
|
||||
end
|
||||
end)
|
||||
self.s = s
|
||||
local zone = opts.zone
|
||||
s.on_input = function(sl, action_id, action)
|
||||
if action_id ~= TOUCH or not action.screen_x then
|
||||
return false
|
||||
end
|
||||
if not sl.is_drag then
|
||||
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
|
||||
and (not zone or gui.pick_node(zone, action.x, action.y))) then
|
||||
return false
|
||||
end
|
||||
sl.is_drag = true
|
||||
end
|
||||
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
|
||||
local f = math.max(0, math.min(1, p.x / w))
|
||||
if f ~= sl.value then
|
||||
sl:set(f)
|
||||
end
|
||||
if action.released then
|
||||
sl.is_drag = false
|
||||
if opts.on_release then
|
||||
opts.on_release(self.value)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
self:set(opts.value)
|
||||
test.register(opts.id, hit, "slider", opts.label, zone, true)
|
||||
return self
|
||||
end
|
||||
|
||||
function M:_fill(f)
|
||||
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
|
||||
end
|
||||
|
||||
function M:set(v)
|
||||
local o = self.opts
|
||||
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
|
||||
self.value = v
|
||||
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
|
||||
self.s:set(f, true)
|
||||
self:_fill(f)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -50,6 +50,16 @@ embedded_instances {
|
||||
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||
"}\n"
|
||||
"embedded_components {\n"
|
||||
" id: \"rumble\"\n"
|
||||
" type: \"sound\"\n"
|
||||
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||
"}\n"
|
||||
"embedded_components {\n"
|
||||
" id: \"reveal\"\n"
|
||||
" type: \"sound\"\n"
|
||||
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||
"}\n"
|
||||
"embedded_components {\n"
|
||||
" id: \"close\"\n"
|
||||
" type: \"sound\"\n"
|
||||
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
|
||||
|
||||
+63
-7
@@ -15,8 +15,9 @@
|
||||
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
|
||||
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
|
||||
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
|
||||
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
|
||||
-- decaying noise shake (sell power-down, reveal burst)
|
||||
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
|
||||
-- reveal burst) or anim.noise_shake for a
|
||||
-- decaying value-noise shake (sell power-down)
|
||||
-- anim.flash(node, color, dur) tint to a color and back
|
||||
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
|
||||
-- anim.count(node, from, to, dur, fmt) a number counting
|
||||
@@ -159,6 +160,7 @@ end
|
||||
|
||||
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
|
||||
local running = {}
|
||||
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
|
||||
|
||||
local function now()
|
||||
return socket.gettime()
|
||||
@@ -207,6 +209,7 @@ end
|
||||
-- Drop every frame-driven tween (a screen going away).
|
||||
function M.clear()
|
||||
running = {}
|
||||
shaking = {}
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- fading content
|
||||
@@ -342,21 +345,39 @@ end
|
||||
|
||||
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
|
||||
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
|
||||
-- A shake that starts while another runs on the node replaces it and keeps its resting
|
||||
-- place (read mid-shake, the position is off by up to amp and would stay so).
|
||||
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
|
||||
function M.shake(node, amp, dur, delay)
|
||||
if M.reduced then
|
||||
return
|
||||
end
|
||||
amp, dur = amp or 6, dur or 0.4
|
||||
local p
|
||||
return M.tween(node, dur, function(k, n)
|
||||
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local rest = {}
|
||||
local keep = {}
|
||||
for _, s in ipairs(shaking) do
|
||||
if not s.t.dead then
|
||||
local ok, same = pcall(function() return s.node == node end)
|
||||
if ok and same then
|
||||
M.cancel(s.t)
|
||||
rest = s.rest
|
||||
else
|
||||
keep[#keep + 1] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
shaking = keep
|
||||
local t = M.tween(node, dur, function(k, n)
|
||||
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
|
||||
local p = rest.p
|
||||
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
|
||||
end, delay or 0, function()
|
||||
if p then
|
||||
pcall(gui.set_position, node, p)
|
||||
if rest.p then
|
||||
pcall(gui.set_position, node, rest.p)
|
||||
end
|
||||
end)
|
||||
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
|
||||
return t
|
||||
end
|
||||
|
||||
-- 1D value noise (smooth), for shakes.
|
||||
@@ -389,6 +410,41 @@ function M.noise_shake(node, amp, dur, freq)
|
||||
end)
|
||||
end
|
||||
|
||||
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
|
||||
-- screen shake.
|
||||
local function grad(i, seed)
|
||||
local s = math.sin((i + seed) * 12.9898) * 43758.5453
|
||||
return (s - math.floor(s)) * 2 - 1
|
||||
end
|
||||
|
||||
local function perlin1(x, seed)
|
||||
local i = math.floor(x)
|
||||
local f = x - i
|
||||
local u = f * f * f * (f * (f * 6 - 15) + 10)
|
||||
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
|
||||
return (a + (b - a) * u) * 2
|
||||
end
|
||||
|
||||
function M.perlin(x, seed)
|
||||
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
|
||||
end
|
||||
|
||||
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
|
||||
function M.perlin_shake(node, amp, dur, freq)
|
||||
if M.reduced or not node then
|
||||
return
|
||||
end
|
||||
local p = gui.get_position(node)
|
||||
local seed = math.random() * 100
|
||||
M.tween(node, dur, function(k, n)
|
||||
local a = amp * (1 - k) ^ 2
|
||||
local t = k * dur * freq
|
||||
gui.set_position(n, v3(p.x + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * a, p.z))
|
||||
end, 0, function()
|
||||
pcall(gui.set_position, node, p)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Tint to `color` and back over dur (a flash).
|
||||
function M.flash(node, color, dur)
|
||||
local c = gui.get_color(node)
|
||||
|
||||
+19
-1
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
|
||||
err = "network error"
|
||||
elseif status < 200 or status >= 300 then
|
||||
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
|
||||
elseif data == nil then
|
||||
elseif data == nil and status ~= 204 and method == "POST" then
|
||||
err = "bad JSON"
|
||||
elseif data == nil and method == "GET" and response.response ~= "null" then
|
||||
err = "bad JSON"
|
||||
end
|
||||
done(err, data, status, recv)
|
||||
@@ -179,6 +181,22 @@ function M.get_state(params, cb)
|
||||
request("GET", "/api/state", params, cb)
|
||||
end
|
||||
|
||||
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
|
||||
-- itself comes only from the save-code action; restore is the action restore (code).
|
||||
function M.get_save_code(cb)
|
||||
request("GET", "/api/save-code", nil, cb)
|
||||
end
|
||||
|
||||
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
|
||||
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
|
||||
function M.get_moon_settings(cb)
|
||||
request("GET", "/api/moon-settings", nil, cb)
|
||||
end
|
||||
|
||||
function M.save_moon_settings(settings, cb)
|
||||
request("POST", "/api/moon-settings", settings, cb)
|
||||
end
|
||||
|
||||
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
|
||||
function M.act(name, params, cb)
|
||||
if reject_name ~= "" and name == reject_name then
|
||||
|
||||
+109
-10
@@ -36,9 +36,50 @@ end
|
||||
|
||||
---------------------------------------------------------------- signatures
|
||||
|
||||
local function ser(v, buf)
|
||||
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
|
||||
-- as a token of their content, worked out once per table: two tables with the same
|
||||
-- content get the same token, so a block whose inputs the server's answer left as the
|
||||
-- prediction had them is kept, and a list as long as the moons is serialized once per
|
||||
-- state, not once per block and build. (A frozen table must not change.)
|
||||
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
|
||||
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
|
||||
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
|
||||
|
||||
local ser
|
||||
|
||||
local function token_of(v)
|
||||
local tok = frozen[v]
|
||||
if tok ~= true then
|
||||
return tok
|
||||
end
|
||||
local buf = {}
|
||||
frozen[v] = false -- (while it's written: a table inside itself is written out)
|
||||
ser(v, buf, true)
|
||||
local s = table.concat(buf)
|
||||
tok = tokens[s]
|
||||
if not tok then
|
||||
if token_bytes > TOKEN_BYTES then
|
||||
tokens, token_bytes = {}, 0
|
||||
end
|
||||
token_n = token_n + 1
|
||||
tok = "#" .. token_n
|
||||
tokens[s], token_bytes = tok, token_bytes + #s
|
||||
end
|
||||
frozen[v] = tok
|
||||
return tok
|
||||
end
|
||||
|
||||
local function key_lt(a, b)
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
|
||||
ser = function(v, buf, own)
|
||||
local t = type(v)
|
||||
if t == "table" then
|
||||
if not own and frozen[v] then
|
||||
buf[#buf + 1] = token_of(v)
|
||||
return
|
||||
end
|
||||
buf[#buf + 1] = "{"
|
||||
local n = #v
|
||||
for i = 1, n do
|
||||
@@ -55,7 +96,7 @@ local function ser(v, buf)
|
||||
end
|
||||
if keys then
|
||||
if mixed then
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
table.sort(keys, key_lt)
|
||||
else
|
||||
table.sort(keys)
|
||||
end
|
||||
@@ -68,7 +109,9 @@ local function ser(v, buf)
|
||||
end
|
||||
buf[#buf + 1] = "}"
|
||||
elseif t == "string" then
|
||||
buf[#buf + 1] = #v .. ":" .. v
|
||||
buf[#buf + 1] = #v
|
||||
buf[#buf + 1] = ":"
|
||||
buf[#buf + 1] = v
|
||||
elseif t == "function" then
|
||||
buf[#buf + 1] = "f"
|
||||
else
|
||||
@@ -76,7 +119,8 @@ local function ser(v, buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted).
|
||||
-- A string that is the same exactly when the values are (tables by content, keys sorted;
|
||||
-- frozen ones by their token, the same for the same content).
|
||||
function M.sig(...)
|
||||
local buf = {}
|
||||
for i = 1, select("#", ...) do
|
||||
@@ -86,6 +130,23 @@ function M.sig(...)
|
||||
return table.concat(buf)
|
||||
end
|
||||
|
||||
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
|
||||
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
|
||||
function M.freeze(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
if frozen[t] == nil then
|
||||
frozen[t] = true
|
||||
end
|
||||
for _, v in pairs(t) do
|
||||
if type(v) == "table" and frozen[v] == nil then
|
||||
frozen[v] = true
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- registrations
|
||||
|
||||
-- Note a registration made while building (fn(...) makes it again in a later build that
|
||||
@@ -130,19 +191,25 @@ end
|
||||
local replay
|
||||
|
||||
-- A kept block inside the one being built: noted in it, its registrations made again.
|
||||
local function adopt(b)
|
||||
local function adopt(reg, b)
|
||||
local top = stack[#stack]
|
||||
if top then
|
||||
top.journal[#top.journal + 1] = { block = b }
|
||||
end
|
||||
replay(b)
|
||||
replay(reg, b)
|
||||
end
|
||||
|
||||
replay = function(b)
|
||||
-- (the keyed blocks inside a kept one are used in this build too, or finish() would
|
||||
-- destroy them: a kept Equipment section lost its cards)
|
||||
replay = function(reg, b)
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block then
|
||||
if not e.block.dead then
|
||||
replay(e.block)
|
||||
local c = e.block
|
||||
if not c.dead then
|
||||
if c.key and reg.by_key[c.key] == c then
|
||||
reg.used[c.key] = true
|
||||
end
|
||||
replay(reg, c)
|
||||
end
|
||||
else
|
||||
replaying = true
|
||||
@@ -210,6 +277,37 @@ local function rezone(b)
|
||||
end
|
||||
end
|
||||
|
||||
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
|
||||
-- get new uids, in their order, as if made now. Druid hands input to the newest first
|
||||
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
|
||||
-- around it (a slot in a well card, which selects the well when clicked) still gets its
|
||||
-- clicks first, as a rebuilt one would.
|
||||
local new_uid = require("druid.component").create_uid
|
||||
local function renew(c)
|
||||
local cc = c._component
|
||||
if cc and cc._uid then
|
||||
cc._uid = new_uid()
|
||||
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
|
||||
end
|
||||
local kids = c.get_childrens and c:get_childrens() or {}
|
||||
for i = 1, #kids do -- (already the whole subtree, in its order)
|
||||
local k = kids[i]._component
|
||||
if k and k._uid then
|
||||
k._uid = new_uid()
|
||||
end
|
||||
end
|
||||
end
|
||||
local function reorder(b)
|
||||
for _, c in ipairs(b.comps) do
|
||||
pcall(renew, c)
|
||||
end
|
||||
for _, e in ipairs(b.journal) do
|
||||
if e.block and not e.block.dead then
|
||||
reorder(e.block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- True while a kept block's registrations are made again (test.register looks for its
|
||||
-- clip zone again then: the one it was given may have gone with the old parent).
|
||||
function M.replaying()
|
||||
@@ -312,7 +410,8 @@ function M.open(g, key, sig, parent)
|
||||
reg.used[key] = true
|
||||
gui.set_parent(old.root, parent, false)
|
||||
rezone(old)
|
||||
adopt(old)
|
||||
reorder(old)
|
||||
adopt(reg, old)
|
||||
return old, true
|
||||
end
|
||||
if old then
|
||||
|
||||
+86
-13
@@ -28,6 +28,16 @@ local M = {}
|
||||
|
||||
local EPS = 0.01
|
||||
|
||||
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
|
||||
-- several labels).
|
||||
local function copy(opts)
|
||||
local s = {}
|
||||
for k, v in pairs(opts or {}) do
|
||||
s[k] = v
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function differs(a, b)
|
||||
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
|
||||
end
|
||||
@@ -104,13 +114,14 @@ end
|
||||
|
||||
-- One line of text (ui.text, pivot W: its middle at the box's) in a line box h tall, as
|
||||
-- wide as its text (trailing spaces count). opts: more box style; opts.fit cuts the text
|
||||
-- with ... to the width it gets. Text with symbols (ui.needs_rich) goes through ui.rich
|
||||
-- with ... to the width it gets; opts.shrink_text = k (0..1) first scales it down to fit,
|
||||
-- to k of its size at most (then cuts). Text with symbols (ui.needs_rich) goes through ui.rich
|
||||
-- (not cut).
|
||||
function M.label(parent, str, style, h, opts)
|
||||
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
|
||||
local n, w = ui.rich(parent, str, style, 0, 0)
|
||||
gui.set_pivot(n, gui.PIVOT_W)
|
||||
local s = opts or {}
|
||||
local s = copy(opts)
|
||||
s.fit, s.height = nil, h
|
||||
return { node = n, size = false, style = s, measure = function() return w, h end }
|
||||
end
|
||||
@@ -123,21 +134,80 @@ function M.label(parent, str, style, h, opts)
|
||||
w = w - ui.text_width(n)
|
||||
gui.set_text(n, t)
|
||||
end
|
||||
local s = opts or {}
|
||||
local fit = s.fit
|
||||
s.fit, s.height = nil, h
|
||||
return { node = n, size = false, fit = fit, style = s, measure = function() return w, h end }
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
|
||||
end
|
||||
|
||||
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more
|
||||
-- box style (max_width, ...).
|
||||
-- box style (max_width, ...); opts.lines = n: at most n lines, the rest cut with "…" (so
|
||||
-- long text can't push what's under it).
|
||||
function M.para(parent, str, style, lh, opts)
|
||||
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
|
||||
gui.set_line_break(n, true)
|
||||
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
|
||||
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
|
||||
gui.set_leading(n, lh / line)
|
||||
return { node = n, shift = (lh - line) / 2, style = opts or {}, measure = M.text_measure(n, lh) }
|
||||
local s = copy(opts)
|
||||
local lines = s.lines
|
||||
s.lines = nil
|
||||
local measure = M.text_measure(n, lh)
|
||||
return { node = n, shift = (lh - line) / 2, style = s, clamp = lines and { lines, lh },
|
||||
measure = lines and function(avail)
|
||||
local w, h = measure(avail)
|
||||
return w, math.min(h, lines * lh)
|
||||
end or measure }
|
||||
end
|
||||
|
||||
-- Cut a wrapping text node's text with "…" (whole words, then letters) until it takes at
|
||||
-- most n lines lh apart at width w.
|
||||
function M.clamp_lines(node, n, lh, w)
|
||||
local measure = M.text_measure(node, lh)
|
||||
local text = gui.get_text(node)
|
||||
local function over()
|
||||
local _, h = measure(w)
|
||||
return h > n * lh + 0.5
|
||||
end
|
||||
if not over() then
|
||||
return
|
||||
end
|
||||
while #text > 1 do
|
||||
local cut = text:gsub("%s*%S+%s*$", "")
|
||||
if cut == "" or cut == text then -- (one word left: a letter at a time, whole UTF-8 chars)
|
||||
cut = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", "")
|
||||
end
|
||||
text = cut:gsub("[%s,;:.]+$", "")
|
||||
gui.set_text(node, text .. "…")
|
||||
if not over() then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fit one line of text to max_w: scaled down to `shrink` (0..1) of its size at most, if
|
||||
-- given, then cut with "…" (ui.fit_text).
|
||||
function M.fit_line(node, max_w, shrink)
|
||||
local w = ui.text_width(node)
|
||||
if shrink and w > max_w then
|
||||
local sc = gui.get_scale(node)
|
||||
local k = math.max(shrink, max_w / w)
|
||||
gui.set_scale(node, vmath.vector3(sc.x * k, sc.y * k, sc.z))
|
||||
end
|
||||
ui.fit_text(node, max_w)
|
||||
end
|
||||
|
||||
-- A reserved slot: a box of a fixed size whether its content shows or not (the "Need ₥
|
||||
-- more" line under Buy, a note above cards, Claim all's place), so what shows in it can
|
||||
-- never move what's around it. style: its size (width and/or height) and more box style;
|
||||
-- kid: the content box, or nil (shown empty). The content is laid out inside the slot
|
||||
-- and cut to it if it's bigger (clip text with fit/shrink/lines).
|
||||
function M.reserve(style, kid)
|
||||
style = style or {}
|
||||
if style.shrink == nil then
|
||||
style.shrink = 0
|
||||
end
|
||||
return M.box(style, { kid })
|
||||
end
|
||||
|
||||
-- Tree pieces (shared by the flex-first sections). Children lists may have holes
|
||||
@@ -163,10 +233,10 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
|
||||
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
|
||||
-- the box's middle, h tall, as wide as its text (set the text first).
|
||||
function M.node_label(node, h, opts)
|
||||
local s = opts or {}
|
||||
local fit = s.fit
|
||||
s.fit, s.height = nil, h
|
||||
return { node = node, size = false, fit = fit, style = s, measure = function() return ui.text_width(node), h end }
|
||||
local s = copy(opts)
|
||||
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
|
||||
s.fit, s.shrink_text, s.height = nil, nil, h
|
||||
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
|
||||
end
|
||||
|
||||
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the
|
||||
@@ -290,7 +360,10 @@ function M.build(tree, parent)
|
||||
gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
|
||||
end
|
||||
if b.fit then
|
||||
ui.fit_text(node, b.w)
|
||||
M.fit_line(node, b.w, b.shrink)
|
||||
end
|
||||
if b.clamp then
|
||||
M.clamp_lines(node, b.clamp[1], b.clamp[2], b.w)
|
||||
end
|
||||
p, ox, oy = node, b.x, b.y
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ local M = {}
|
||||
|
||||
local entries = {} -- { key, node, kind, label, zone, scope }
|
||||
local scope = "page"
|
||||
local seen = {} -- key -> count in this build (duplicate ids get "#n")
|
||||
local seen = {} -- base -> entries with it (duplicate ids get "#n")
|
||||
|
||||
local function alive(node)
|
||||
return pcall(gui.get_id, node)
|
||||
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
|
||||
return
|
||||
end
|
||||
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
|
||||
local key = base
|
||||
if (seen[base] or 0) > 0 then
|
||||
-- A row made again (a long list scrolled back, a search, a kept block replayed)
|
||||
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
|
||||
-- plain key that list_step and find look for.
|
||||
local keep, taken = {}, {}
|
||||
for _, e in ipairs(entries) do
|
||||
if e.base ~= base then
|
||||
keep[#keep + 1] = e
|
||||
elseif alive(e.node) then
|
||||
keep[#keep + 1] = e
|
||||
taken[e.key] = true
|
||||
else
|
||||
seen[base] = seen[base] - 1
|
||||
end
|
||||
end
|
||||
entries = keep
|
||||
local n = 1
|
||||
while taken[key] do
|
||||
n = n + 1
|
||||
key = base .. "#" .. n
|
||||
end
|
||||
end
|
||||
seen[base] = (seen[base] or 0) + 1
|
||||
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
|
||||
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
|
||||
end
|
||||
|
||||
@@ -225,12 +247,16 @@ end
|
||||
|
||||
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
|
||||
-- the item before / after the focused row (or the first / last when coming from outside),
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
|
||||
-- when it moved the focus.
|
||||
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
|
||||
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
|
||||
-- from that row (the one under it). Returns true when it moved the focus.
|
||||
local function list_step(g, f, cur, e, dir, scope_)
|
||||
local a = list_of(g, cur)
|
||||
local items, i
|
||||
if a then
|
||||
if list_of(g, e) == a then
|
||||
return false -- (the next element is in view in the same list)
|
||||
end
|
||||
items = a.list.items
|
||||
local id = cur.base:sub(#scope_ + 2)
|
||||
for n, item in ipairs(items) do
|
||||
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
|
||||
i = n + dir
|
||||
end
|
||||
end
|
||||
if not i and a.list.row_h then
|
||||
local r, v = M.rect(cur.node), M.rect(a.view)
|
||||
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
|
||||
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
|
||||
end
|
||||
if not i or i < 1 or i > #items then
|
||||
return false -- (leaving the list: the next element on the page)
|
||||
end
|
||||
|
||||
+123
-13
@@ -40,17 +40,17 @@ local GOLD_HI = C.ready_hi
|
||||
local LOSS = C.loss
|
||||
local FLASH = C.flash
|
||||
|
||||
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
|
||||
equip = true, hire = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
||||
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
|
||||
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
|
||||
|
||||
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
|
||||
local SOUND = {
|
||||
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
|
||||
["buy-tool"] = "buy",
|
||||
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
|
||||
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade",
|
||||
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
|
||||
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
|
||||
refine = "refine", collect = "collect", error = "error",
|
||||
-- (sell-tool and sell-attachment: the dialog's confirm, then the cash as the coins land)
|
||||
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
|
||||
}
|
||||
|
||||
local function v3(x, y)
|
||||
@@ -102,6 +102,8 @@ end
|
||||
|
||||
-- A box an action's sweep covers when its button is inside (fx.js: the button's .card,
|
||||
-- .line, row or heading): cards and list rows register while they're built.
|
||||
-- Sweeps cover the clicked button's box (fx.js closest(".card, .line, tr, ...")): cards,
|
||||
-- rows and the moon sale register here while built; a new card-like container must too.
|
||||
function M:box(node)
|
||||
blocks.push(self, "boxes", node)
|
||||
end
|
||||
@@ -831,6 +833,16 @@ local function index(list)
|
||||
return m
|
||||
end
|
||||
|
||||
-- A refined item's color (catalog.refined, as refinery.lua's item_color).
|
||||
local function refined_color(st, name)
|
||||
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
|
||||
if r.name == name and r.color then
|
||||
return theme.item(r.color)
|
||||
end
|
||||
end
|
||||
return theme.resource[name] or C.violet
|
||||
end
|
||||
|
||||
-- After a response's state is applied (g: the game screen's self). kind: the action
|
||||
-- name, or "get".
|
||||
function M:on_response(g, kind, data, prev, st, opts)
|
||||
@@ -878,7 +890,25 @@ function M:on_response(g, kind, data, prev, st, opts)
|
||||
if credits > 0 then
|
||||
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
|
||||
elseif credits < 0 then
|
||||
self:count("credits", self:value("credits", prev.credits), st.credits, { held = revealing or self:holding("credits") })
|
||||
-- (a bought moon's price too: it counts down at the purchase, while the reveal plays)
|
||||
self:count("credits", self:value("credits", prev.credits), st.credits, { held = self:holding("credits") })
|
||||
end
|
||||
|
||||
-- Refined stock (the Refinery's recipe cards' owned numbers, keys "refined:<name>"):
|
||||
-- what a collect brings is held until its motes land there; other changes count only if
|
||||
-- one is running.
|
||||
local refined = {}
|
||||
if prev.refinery and st.refinery then
|
||||
local rwas = by_name(prev.refinery.stock)
|
||||
for _, r in ipairs(st.refinery.stock or {}) do
|
||||
local key, old = "refined:" .. r.name, rwas[r.name] or 0
|
||||
if r.amount > old and k == "collect" then
|
||||
refined[#refined + 1] = { name = r.name, key = key, delta = r.amount - old, color = refined_color(st, r.name) }
|
||||
self:count(key, self:value(key, old), r.amount, { held = not quiet or self:holding(key) })
|
||||
elseif r.amount ~= old and self.counters[key] then
|
||||
self:count(key, self:value(key, old), r.amount, { held = self:holding(key) })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if quiet then
|
||||
@@ -915,12 +945,97 @@ function M:on_response(g, kind, data, prev, st, opts)
|
||||
-- The rest needs the new layout's positions: after the next build.
|
||||
self.pending[#self.pending + 1] = function()
|
||||
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||
self:collected(g, refined, at)
|
||||
end
|
||||
end
|
||||
|
||||
-- Refined items a collect brought (on_response): the amount pops up in the item's color
|
||||
-- above the slot it came from (clear of the batch sliding into it), and motes stream from
|
||||
-- the Collect button into the recipe card's owned number, which counts up as they land
|
||||
-- (an arrival blip; the "collect" sound plays with the action). Without that number on
|
||||
-- screen it just counts.
|
||||
function M:collected(g, refined, at)
|
||||
if #refined == 0 then
|
||||
return
|
||||
end
|
||||
local from = at and center(at) or { x = g.size[1] / 2, y = g.size[2] / 2 }
|
||||
local above = at and at.box and { x = at.box.x + at.box.w / 2, y = at.box.y - 8 } or { x = from.x, y = from.y - 10 }
|
||||
local colors = {}
|
||||
for i, rg in ipairs(refined) do
|
||||
colors[#colors + 1] = rg.color
|
||||
self:float_text("+" .. ui.money(rg.delta) .. " " .. rg.name, above.x, above.y - (i - 1) * 30, rg.color, (i - 1) * 0.09, false, -1,
|
||||
{ key = "gain:" .. rg.key, amount = rg.delta, fmt = function(a) return "+" .. ui.money(a) .. " " .. rg.name end })
|
||||
local r = self:box_of(rg.key)
|
||||
if r then
|
||||
local to = center(r)
|
||||
self:stream(from, to, rg.color, 10, 120 + (i - 1) * 90, function()
|
||||
sfx.arrive(i - 1, anim.reduced)
|
||||
self:release(rg.key)
|
||||
self:burst(to.x, to.y, { rg.color }, 12, 0.5)
|
||||
self:glint(r)
|
||||
end)
|
||||
else
|
||||
self:release(rg.key)
|
||||
end
|
||||
end
|
||||
colors[#colors + 1] = GOLD_HI
|
||||
self:burst(from.x, from.y, colors, 30, 0.8)
|
||||
end
|
||||
|
||||
-- Refinery batches that finished on our clock (core/ready), whichever section is open:
|
||||
-- the ready sound (once for batches finishing together or with a drill), then, after the
|
||||
-- build that shows them, with the Refinery open each slot's edge lights up (a gold pulse)
|
||||
-- and sparks leave the full end of its bar (refinery.lua pops its Ready label and Collect
|
||||
-- button and runs a light along the bar); elsewhere the Refinery tab pulses and sparks
|
||||
-- leave its badge (header.lua pops the badge).
|
||||
function M:batch_ready(g, jobs)
|
||||
local t = now()
|
||||
if not (self.ready_sound and t - self.ready_sound < 1) then
|
||||
sfx.play("ready")
|
||||
self.ready_sound = t
|
||||
end
|
||||
self.pending[#self.pending + 1] = function()
|
||||
if g.view and g.view.section == "refinery" then
|
||||
for _, j in ipairs(jobs) do
|
||||
local slot = g.rf_slots and g.rf_slots[j.id]
|
||||
if slot then
|
||||
self:pulse(self:rect_of(slot.frame), GOLD, 0.9)
|
||||
local b = self:rect_of(slot.bar)
|
||||
self:burst(b.x + b.w, b.y + b.h / 2, { GOLD, GOLD_HI }, 18, 0.6)
|
||||
end
|
||||
end
|
||||
else
|
||||
local tab = g.tab_nodes and g.tab_nodes.refinery
|
||||
if tab then
|
||||
local r = self:rect_of(tab)
|
||||
self:pulse(r, GOLD, 0.8)
|
||||
self:burst(r.x + r.w - 9, r.y + 9, { GOLD, GOLD_HI }, 14, 0.5) -- (the badge on its top-right corner)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- (a text node's position is its pivot point and its size not its text's: the box is the
|
||||
-- text's own, round that point)
|
||||
local PIVOT = { -- (fraction of the box left of / above the pivot point)
|
||||
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||
}
|
||||
|
||||
function M:box_of(key)
|
||||
local b = self.binds[key]
|
||||
return b and self:rect_of(b.box or b.node)
|
||||
if not b then
|
||||
return nil
|
||||
end
|
||||
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
|
||||
return self:rect_of(b.box or b.node)
|
||||
end
|
||||
local r = self:rect_of(b.node)
|
||||
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
|
||||
local w = ui.text_width(b.node)
|
||||
local h = r.h * gui.get_scale(b.node).y
|
||||
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
|
||||
end
|
||||
|
||||
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||
@@ -1005,12 +1120,7 @@ function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
|
||||
{ key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
|
||||
end
|
||||
end
|
||||
if revealing then
|
||||
self.waiting.revealed = self.waiting.revealed or {}
|
||||
table.insert(self.waiting.revealed, spend)
|
||||
else
|
||||
spend()
|
||||
end
|
||||
spend()
|
||||
end
|
||||
|
||||
if BUY_KINDS[k] then
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
-- call again after the layout moves the node (it reads the node's
|
||||
-- screen rect). The node gets the "moon" material (listed in the
|
||||
-- .gui's materials), which draws the rendered moon.
|
||||
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
|
||||
-- moon.move(node[, quiet]) the node moved (the narrow layout's page scroll, the reveal's
|
||||
-- window sliding and shaking): follow it; quiet: no log line.
|
||||
-- moon.hide() no moon (the node keeps its own look).
|
||||
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
|
||||
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
|
||||
@@ -24,7 +25,7 @@
|
||||
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
|
||||
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
|
||||
--
|
||||
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
|
||||
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
|
||||
-- defaults like the web does; until the request answers nothing is generated.
|
||||
--
|
||||
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
|
||||
@@ -37,10 +38,17 @@ local M = {}
|
||||
M.id = nil
|
||||
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
|
||||
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
|
||||
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
|
||||
|
||||
-- Settings: nil until loaded (or failed: then the defaults).
|
||||
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
|
||||
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
|
||||
M.settings = nil
|
||||
|
||||
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
|
||||
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
|
||||
M.building = false
|
||||
M.last_build = nil
|
||||
|
||||
-- What the render script draws, written by moon.script every frame.
|
||||
M.frame = { visible = false, has_mesh = false }
|
||||
|
||||
@@ -66,17 +74,20 @@ local function node_rect(node)
|
||||
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
|
||||
local br = gui.get_screen_position(probe)
|
||||
gui.delete_node(probe)
|
||||
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
|
||||
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
|
||||
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
|
||||
-- The size is rounded once (not as the difference of two rounded corners), so it stays
|
||||
-- the same while the node moves by fractions of a pixel: every new size remakes the
|
||||
-- render targets.
|
||||
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
|
||||
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
|
||||
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
|
||||
end
|
||||
|
||||
local function set_node(node)
|
||||
local function set_node(node, quiet)
|
||||
if node then
|
||||
local old = M.rect
|
||||
M.rect, M.unit = node_rect(node)
|
||||
local r = M.rect
|
||||
if not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h then
|
||||
if not quiet and (not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h) then
|
||||
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
|
||||
end
|
||||
gui.set_material(node, "moon")
|
||||
@@ -98,9 +109,9 @@ function M.show(id, node)
|
||||
end
|
||||
|
||||
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
|
||||
function M.move(node)
|
||||
function M.move(node, quiet)
|
||||
if node and (M.id or M.mode) then
|
||||
set_node(node)
|
||||
set_node(node, quiet)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+318
-59
@@ -53,6 +53,12 @@ function M.copy(st)
|
||||
return copy(st, true)
|
||||
end
|
||||
|
||||
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
|
||||
local function same_copy(a, b)
|
||||
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
|
||||
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
|
||||
end
|
||||
|
||||
local function find(list, id, key)
|
||||
key = key or "id"
|
||||
for i, x in ipairs(list or {}) do
|
||||
@@ -168,7 +174,7 @@ function M.tool(st, id)
|
||||
return find(st.tools, id)
|
||||
end
|
||||
|
||||
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
|
||||
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
|
||||
function M.idle_copy(st, id)
|
||||
local t = find(st.tools, id)
|
||||
if not t then
|
||||
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
|
||||
return t
|
||||
end
|
||||
for _, x in ipairs(st.tools) do
|
||||
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
|
||||
if not x.stakeId and same_copy(x, t) then
|
||||
return x
|
||||
end
|
||||
end
|
||||
@@ -189,18 +195,162 @@ local function yield_boost(st, resource)
|
||||
return b[resource] or 1
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- shared wells
|
||||
|
||||
-- Ports of the server's well split (game.go coverWells, splitClaim; views.go split,
|
||||
-- project, takeAfter), api.md "Prediction": claims go oldest first, each from what the
|
||||
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
|
||||
local EPS = 1e-9
|
||||
|
||||
-- The wells a stake with this reach mines: its anchor first, then the next wells in the
|
||||
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
|
||||
local function cover_wells(anchor, supply, reach)
|
||||
local n = #supply
|
||||
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
|
||||
local out = {}
|
||||
local want = math.max(1, reach or 1)
|
||||
for k = 0, n - 1 do
|
||||
if #out >= want then
|
||||
break
|
||||
end
|
||||
local i = (start - 1 + k) % n + 1
|
||||
if supply[i] > 0 then
|
||||
out[#out + 1] = i
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function well_index(moon, id)
|
||||
for i, w in ipairs(moon.wells or {}) do
|
||||
if w.id == id then
|
||||
return i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- What claiming a stake (wellId, amount, reach) takes from each well, given supply.
|
||||
local function split(moon, s, supply)
|
||||
local take = {}
|
||||
for i = 1, #supply do
|
||||
take[i] = 0
|
||||
end
|
||||
local anchor = well_index(moon, s.wellId)
|
||||
if (s.reach or 1) <= 1 then
|
||||
if anchor then
|
||||
take[anchor] = math.max(0, math.min(s.amount, supply[anchor]))
|
||||
end
|
||||
return take
|
||||
end
|
||||
local amount = s.amount
|
||||
for _, i in ipairs(cover_wells(anchor, supply, s.reach)) do
|
||||
take[i] = math.max(0, math.min(amount, supply[i]))
|
||||
amount = amount - take[i]
|
||||
if amount <= 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
return take
|
||||
end
|
||||
|
||||
local function supplies(moon)
|
||||
local out = {}
|
||||
for i, w in ipairs(moon.wells or {}) do
|
||||
out[i] = w.supply
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sum(t)
|
||||
local n = 0
|
||||
for _, x in ipairs(t) do
|
||||
n = n + x
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- The moon's stakes, oldest first (ties in the server's order).
|
||||
local function moon_stakes(st, moon)
|
||||
local list = {}
|
||||
for i, s in ipairs(st.stakes or {}) do
|
||||
if s.moonId == moon.id then
|
||||
list[#list + 1] = { s = s, i = i }
|
||||
end
|
||||
end
|
||||
table.sort(list, function(a, b)
|
||||
if a.s.start ~= b.s.start then
|
||||
return a.s.start < b.s.start
|
||||
end
|
||||
return a.i < b.i
|
||||
end)
|
||||
for i, x in ipairs(list) do
|
||||
list[i] = x.s
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
-- The server's projection (views.go project) of the moon's unclaimed drills.
|
||||
function M.project(st, moon)
|
||||
local n = #(moon.wells or {})
|
||||
local p = { take = {}, gets = {}, demand = {}, remain = supplies(moon), claims = {}, dry_after = {}, dry_at = {} }
|
||||
for i = 1, n do
|
||||
p.demand[i], p.claims[i], p.dry_after[i] = 0, 0, 0
|
||||
end
|
||||
local current, last = supplies(moon), {}
|
||||
for _, s in ipairs(moon_stakes(st, moon)) do
|
||||
if (s.reach or 1) <= 1 then
|
||||
local i = well_index(moon, s.wellId)
|
||||
if i then
|
||||
p.demand[i] = p.demand[i] + s.amount
|
||||
p.claims[i] = p.claims[i] + 1
|
||||
last[i] = math.max(last[i] or 0, s["end"])
|
||||
end
|
||||
else
|
||||
for i, t in ipairs(split(moon, s, current)) do
|
||||
p.demand[i] = p.demand[i] + t
|
||||
end
|
||||
end
|
||||
local take = split(moon, s, p.remain)
|
||||
p.take[s.id], p.gets[s.id] = take, sum(take)
|
||||
for i, t in ipairs(take) do
|
||||
if (s.reach or 1) > 1 and t > 0 then
|
||||
p.claims[i] = p.claims[i] + 1
|
||||
last[i] = math.max(last[i] or 0, s["end"])
|
||||
end
|
||||
p.remain[i] = p.remain[i] - t
|
||||
if t > 0 and p.remain[i] <= EPS and p.dry_after[i] == 0 then
|
||||
p.remain[i] = 0
|
||||
p.dry_after[i], p.dry_at[i] = p.claims[i], last[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- What a new stake (amount, reach) on the well would get after the moon's drills (the
|
||||
-- server's takeAfter; 0: the server refuses it, "Nothing left to mine on this well").
|
||||
function M.take_after(moon, p, well_id, amount, reach)
|
||||
return sum(split(moon, { wellId = well_id, amount = amount, reach = reach }, p.remain))
|
||||
end
|
||||
|
||||
-- Room for a new one-well stake of amount on the well (0 when fully committed); nil when
|
||||
-- the well isn't on the selected moon (no projection to go by).
|
||||
function M.room(st, well_id, amount, reach)
|
||||
local moon = st.moon
|
||||
if not moon or not well_index(moon, well_id) then
|
||||
return nil
|
||||
end
|
||||
return M.take_after(moon, M.project(st, moon), well_id, amount, reach)
|
||||
end
|
||||
|
||||
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
|
||||
-- group: the well panel's idle group for it (cycle and yield already for this well).
|
||||
function M.add_stake(st, tool, moon_id, well, ctx, group)
|
||||
local cycle = group and group.cycleSecs or tool.cycleSecs
|
||||
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
|
||||
local committed = 0
|
||||
for _, s in ipairs(st.stakes) do
|
||||
if s.wellId == well.id then
|
||||
committed = committed + (s.take or 0)
|
||||
end
|
||||
end
|
||||
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
|
||||
-- take: what claiming it now would get, as the server's stake.take (its share after
|
||||
-- the other drills is derive's projection).
|
||||
local take = math.max(0, math.min(amount, M.supply(st, well.id)))
|
||||
local s = {
|
||||
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
|
||||
resource = well.resource, amount = amount, reach = tool.reach or 1,
|
||||
@@ -212,11 +362,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
|
||||
-- Off the selected well's idle list.
|
||||
if st.well then
|
||||
for i, g in ipairs(st.well.idle or {}) do
|
||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
||||
if same_copy(g, tool) then
|
||||
g.count = g.count - 1
|
||||
if g.toolId == tool.id then
|
||||
for _, x in ipairs(st.tools) do
|
||||
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
|
||||
if not x.stakeId and same_copy(x, g) then
|
||||
g.toolId = x.id
|
||||
break
|
||||
end
|
||||
@@ -260,7 +410,7 @@ function M.remove_stake(st, s)
|
||||
if st.well and st.well.idle then
|
||||
local g = nil
|
||||
for _, x in ipairs(st.well.idle) do
|
||||
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
|
||||
if same_copy(x, tool) then
|
||||
g = x
|
||||
end
|
||||
end
|
||||
@@ -270,16 +420,17 @@ function M.remove_stake(st, s)
|
||||
local free = M.supply(st, st.well.id)
|
||||
local y = tool.yield * yield_boost(st, st.well.resource)
|
||||
st.well.idle[#st.well.idle + 1] = {
|
||||
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
|
||||
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
|
||||
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
|
||||
short = free < y, attachments = tool.attachments or {},
|
||||
short = free < y,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
|
||||
-- buttons' amounts, next ends). Wide drills (reach > 1) are left as the server sent them.
|
||||
-- buttons' amounts, next ends) and the selected moon's well projection (M.project: demand,
|
||||
-- remain, full, notes, deploy cards). The wide group (reach > 1) is left as the server sent it.
|
||||
function M.derive(st)
|
||||
local ready, idle, next_end = 0, 0, nil
|
||||
local per_moon = {}
|
||||
@@ -309,39 +460,71 @@ function M.derive(st)
|
||||
return
|
||||
end
|
||||
local mready, mnext = 0, nil
|
||||
for _, w in ipairs(moon.wells or {}) do
|
||||
local p = M.project(st, moon)
|
||||
local ui = require("modules.ui")
|
||||
for wi, w in ipairs(moon.wells or {}) do
|
||||
local old = {}
|
||||
for _, d in ipairs(w.drills or {}) do
|
||||
old[d.id] = d
|
||||
end
|
||||
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0
|
||||
for _, s in ipairs(st.stakes or {}) do
|
||||
local drills, wready, wnext = {}, 0, nil
|
||||
local supply = supplies(moon)
|
||||
local amount = 0 -- the ready drills claimed in turn (the server's claimable)
|
||||
for _, s in ipairs(moon_stakes(st, moon)) do
|
||||
if s.wellId == w.id and (s.reach or 1) <= 1 then
|
||||
local d = old[s.id]
|
||||
if not d then
|
||||
d = copy(s)
|
||||
d.gets, d.here = s.take, s.take
|
||||
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
|
||||
end
|
||||
local d = old[s.id] or copy(s)
|
||||
d.gets = p.gets[s.id] or 0
|
||||
d.here = d.gets
|
||||
d.state = d.gets <= EPS and "none" or (d.gets < s.amount - EPS and "short" or "ok")
|
||||
drills[#drills + 1] = d
|
||||
demand = demand + (d.here or s.take or 0)
|
||||
if s.ready then
|
||||
wready = wready + 1
|
||||
amount = amount + (s.take or 0)
|
||||
local take = split(moon, s, supply)
|
||||
for i, t in ipairs(take) do
|
||||
supply[i] = supply[i] - t
|
||||
amount = amount + t
|
||||
end
|
||||
elseif not wnext or s["end"] < wnext then
|
||||
wnext = s["end"]
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(drills, function(a, b) return a.start < b.start end)
|
||||
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
|
||||
w.demand = demand
|
||||
w.remain = math.max(0, w.supply - demand)
|
||||
-- The well's projection fields, as the server words them (views.go WellRow).
|
||||
local demand, remain = p.demand[wi], p.remain[wi]
|
||||
w.demand, w.remain, w.dryAfter, w.dryAt = demand, remain, p.dry_after[wi], p.dry_at[wi]
|
||||
local taken = math.max(0, w.supply - remain)
|
||||
w.over = w.supply > 0 and demand > w.supply + EPS
|
||||
w.full = w.supply > 0 and ((demand > 0 and demand >= w.supply - EPS) or (remain <= EPS and taken > 0))
|
||||
w.fullyCommitted = w.supply > 0 and remain <= EPS
|
||||
w.statusText = w.over and "Over-drilled — next claims will come up short"
|
||||
or (w.full and "Fully committed" or "")
|
||||
if demand == 0 then
|
||||
w.noteText = ""
|
||||
else
|
||||
w.noteText = ui.money(w.supply) .. " left · " .. ui.money(demand) .. " committed · "
|
||||
.. (w.dryAfter > 0 and ("runs dry after " .. w.dryAfter .. (w.dryAfter == 1 and " claim" or " claims"))
|
||||
or (ui.money(remain) .. " free after"))
|
||||
end
|
||||
mready = mready + wready
|
||||
if wnext and (not mnext or wnext < mnext) then
|
||||
mnext = wnext
|
||||
end
|
||||
end
|
||||
-- The deploy cards: what each idle tool would get here after the drills on the moon
|
||||
-- (the server's here, free, short; blocked: here is 0, the stake is refused).
|
||||
local wi = st.well and well_index(moon, st.well.id)
|
||||
if wi then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
g.here = M.take_after(moon, p, st.well.id, g.yield, g.reach)
|
||||
g.free = 0
|
||||
for _, j in ipairs(cover_wells(wi, p.remain, g.reach)) do
|
||||
g.free = g.free + p.remain[j]
|
||||
end
|
||||
g.short = g.here < g.yield - EPS
|
||||
g.blocked = g.here <= EPS
|
||||
end
|
||||
end
|
||||
if moon.wide then
|
||||
mready = mready + (moon.wide.ready or 0)
|
||||
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
|
||||
@@ -399,43 +582,77 @@ end
|
||||
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
|
||||
-- count goes up (the server's LastWell).
|
||||
M.register("claim", function(st, p, ctx)
|
||||
if p["claim-wide"] then
|
||||
return false
|
||||
end
|
||||
local list = {}
|
||||
for _, s in ipairs(st.stakes) do
|
||||
local wide = (s.reach or 1) > 1
|
||||
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
|
||||
if wide then
|
||||
return false -- multi-well drills: the server splits them
|
||||
end
|
||||
local scope
|
||||
if p["claim-wide"] then
|
||||
scope = wide and s.moonId == p["claim-wide"]
|
||||
else
|
||||
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
|
||||
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
|
||||
end
|
||||
if done(s, ctx) and scope then
|
||||
list[#list + 1] = s
|
||||
end
|
||||
end
|
||||
if #list == 0 then
|
||||
return false
|
||||
end
|
||||
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
|
||||
-- A multi-well drill takes from its well, then the moon's next ones (split).
|
||||
table.sort(list, function(a, b) return a.start < b.start end)
|
||||
local emptied = {}
|
||||
for _, s in ipairs(list) do
|
||||
local take = (s.take or 0) * M.skew
|
||||
M.add_item(st, s.resource, take)
|
||||
M.add_supply(st, s.wellId, -(s.take or 0))
|
||||
M.remove_stake(st, s)
|
||||
end
|
||||
M.refresh_market(st)
|
||||
if not p.redeploy then
|
||||
for _, s in ipairs(list) do
|
||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||
if detail then
|
||||
detail.redrill = (detail.redrill or 0) + 1
|
||||
local moon = find(st.moons, s.moonId)
|
||||
if (s.reach or 1) > 1 and moon then
|
||||
local sup = {}
|
||||
for i, w in ipairs(moon.wells or {}) do
|
||||
sup[i] = M.supply(st, w.id)
|
||||
end
|
||||
for i, take in ipairs(split(moon, s, sup)) do
|
||||
local w = moon.wells[i]
|
||||
if take > 0 then
|
||||
M.add_item(st, w.resource or s.resource, take * M.skew)
|
||||
M.add_supply(st, w.id, -take)
|
||||
if M.supply(st, w.id) <= 0 then
|
||||
emptied[w.id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
|
||||
M.add_item(st, s.resource, take * M.skew)
|
||||
M.add_supply(st, s.wellId, -take)
|
||||
if take > 0 and M.supply(st, s.wellId) <= 0 then
|
||||
emptied[s.wellId] = true
|
||||
end
|
||||
end
|
||||
else
|
||||
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
|
||||
end
|
||||
-- A claim that empties a well stops the other one-well drills on it (stopTapped).
|
||||
for i = #st.stakes, 1, -1 do
|
||||
local s = st.stakes[i]
|
||||
if emptied[s.wellId] and (s.reach or 1) <= 1 then
|
||||
M.remove_stake(st, s)
|
||||
end
|
||||
end
|
||||
M.refresh_market(st)
|
||||
if p.redeploy then
|
||||
for _, s in ipairs(list) do
|
||||
local tool = M.tool(st, s.toolId)
|
||||
local w = wells(st, s.wellId)[1]
|
||||
if tool and not tool.stakeId and w and w.supply > 0 then
|
||||
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
|
||||
local group = nil
|
||||
if tool and w and st.well and st.well.id == w.id then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
if same_copy(g, tool) then
|
||||
group = g
|
||||
end
|
||||
end
|
||||
end
|
||||
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
|
||||
-- Not on a drained or fully committed well (the server leaves the tool idle).
|
||||
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
|
||||
M.add_stake(st, tool, s.moonId, w, ctx, group)
|
||||
local detail = st.moon and find(st.moon.wells, s.wellId)
|
||||
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
|
||||
@@ -466,17 +683,22 @@ M.register("stake", function(st, p, ctx)
|
||||
return false -- "Deploy there": the view moves to the other well, wait for it
|
||||
end
|
||||
local tool = M.idle_copy(st, p.tool)
|
||||
if not tool or (tool.reach or 1) > 1 then
|
||||
if not tool then
|
||||
return false
|
||||
end
|
||||
local group = nil
|
||||
if st.well and st.well.id == w.id then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
|
||||
if same_copy(g, tool) then
|
||||
group = g
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Refused when the drills on the well take all it has left (fullyCommitted).
|
||||
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
|
||||
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
|
||||
return false
|
||||
end
|
||||
M.add_stake(st, tool, moon.id, w, ctx, group)
|
||||
return true
|
||||
end)
|
||||
@@ -499,19 +721,54 @@ M.register("redrill", function(st, p, ctx)
|
||||
if #tools == 0 then
|
||||
return false
|
||||
end
|
||||
for _, t in ipairs(tools) do
|
||||
local group = nil
|
||||
-- Until the well has no room left, like the server's redrill (game.go redrillPick):
|
||||
-- the smallest drill that fills what is left alone, else the biggest and again; the
|
||||
-- rest stay idle.
|
||||
local function group_of(t)
|
||||
if st.well and st.well.id == id then
|
||||
for _, g in ipairs(st.well.idle or {}) do
|
||||
if g.storeItemId == t.storeItemId and g.tier == t.tier then
|
||||
group = g
|
||||
if same_copy(g, t) then
|
||||
return g
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local staked = 0
|
||||
while #tools > 0 do
|
||||
local left = M.room(st, id, 1e18, 1) or 0
|
||||
if left <= EPS then
|
||||
break
|
||||
end
|
||||
-- What each takes a claim, with the well's yield event (the server's t.Amount).
|
||||
local amounts = {}
|
||||
for i, t in ipairs(tools) do
|
||||
local g = group_of(t)
|
||||
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
|
||||
end
|
||||
local pick
|
||||
for i, a in ipairs(amounts) do
|
||||
if a >= left - EPS and (not pick or a < amounts[pick]) then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
if not pick then
|
||||
pick = 1
|
||||
for i, a in ipairs(amounts) do
|
||||
if a > amounts[pick] then
|
||||
pick = i
|
||||
end
|
||||
end
|
||||
end
|
||||
local t = table.remove(tools, pick)
|
||||
local group = group_of(t)
|
||||
M.add_stake(st, t, moon.id, w, ctx, group)
|
||||
staked = staked + 1
|
||||
end
|
||||
if staked == 0 then
|
||||
return false
|
||||
end
|
||||
if detail then
|
||||
detail.redrill = 0
|
||||
detail.redrill = math.max(0, n - staked)
|
||||
end
|
||||
return true
|
||||
end)
|
||||
@@ -602,7 +859,9 @@ M.register("refine", function(st, p, ctx)
|
||||
return false
|
||||
end
|
||||
local rr = st.rules.refinery
|
||||
local secs = math.floor(r.seconds * qty ^ rr.bulkExponent / (rr.speed or 1) + 0.5)
|
||||
-- (refinery.go bulkUnits: seconds × min(n, 1 + bulkLog × ln n))
|
||||
local units = math.min(qty, 1 + (rr.bulkLog or 0) * math.log(math.max(1, qty)))
|
||||
local secs = math.floor(r.seconds * units / (rr.speed or 1) + 0.5)
|
||||
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
|
||||
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
|
||||
slot.job = job
|
||||
|
||||
@@ -18,6 +18,7 @@ local GAIN = {
|
||||
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
|
||||
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
|
||||
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
|
||||
rumble = 1, reveal = 1,
|
||||
}
|
||||
|
||||
-- Minimum time between two plays of the same sound, in seconds.
|
||||
@@ -96,6 +97,13 @@ function M.play(name, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stops every play of a sound (the reveal's build-up when the burst comes early).
|
||||
function M.stop(name)
|
||||
if GAIN[name] then
|
||||
pcall(sound.stop, "main:/sfx#" .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function semitones(n)
|
||||
return 2 ^ (n / 12)
|
||||
end
|
||||
|
||||
+200
-9
@@ -7,7 +7,10 @@
|
||||
-- Elements: every interactive component registers a stable id while the layout builds
|
||||
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
|
||||
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
|
||||
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel". A
|
||||
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
|
||||
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
|
||||
-- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), the gear "settings" (its menu: "mute", "theme", "motion",
|
||||
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
|
||||
-- component given opts.id uses that instead.
|
||||
--
|
||||
-- Commands (args after the name):
|
||||
@@ -21,6 +24,7 @@
|
||||
-- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
|
||||
-- eval <lua expr> evaluate, reply with the value
|
||||
-- list the registered elements: id, kind, label, rect, enabled
|
||||
-- dump the screen's laid-out nodes (rects, kinds, texts; see dump below)
|
||||
-- state a summary of the shown state (view, wallet, counts, busy)
|
||||
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key>
|
||||
-- mark <name> print "mm: shot <name>" (scripts/shot.sh SHOTS_DIR picks it up)
|
||||
@@ -92,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
|
||||
if not M.enabled or not id then
|
||||
return
|
||||
end
|
||||
if elements[id] then
|
||||
local old = elements[id]
|
||||
if old and not pcall(gui.get_id, old.node) then
|
||||
-- (its node went: a long list's row scrolled out and back keeps its plain id)
|
||||
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
|
||||
return id
|
||||
end
|
||||
if old then
|
||||
local n = 2
|
||||
while elements[id .. "#" .. n] do
|
||||
n = n + 1
|
||||
@@ -194,10 +204,11 @@ local function visible(node)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A run of commands spread over frames: { frame_fn, ... }.
|
||||
local function steps(g, list, done)
|
||||
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
|
||||
-- error reply when a step fails.
|
||||
local function steps(g, list, done, id)
|
||||
g.test_steps = g.test_steps or {}
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
|
||||
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
|
||||
end
|
||||
|
||||
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
|
||||
@@ -339,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
|
||||
}
|
||||
steps(g, list, function()
|
||||
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
|
||||
@@ -381,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
|
||||
function() input(k, { released = true }) end,
|
||||
}, function()
|
||||
reply(cmd.id, { ok = true })
|
||||
end)
|
||||
end, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.type(g, cmd, input)
|
||||
@@ -428,6 +439,182 @@ function COMMANDS.list(g)
|
||||
return { ok = true, elements = out }
|
||||
end
|
||||
|
||||
-- Layout dump: every shown node under g.page that matters for layout, in render order:
|
||||
-- registered elements (kind = their kind, id = test id), text nodes ("text"), drawn boxes
|
||||
-- ("panel") and stencils ("clip"). Rects in layout px (x right, y down from the page's
|
||||
-- top-left); a text's rect is where its glyphs go (metrics, by pivot), bw/bh its node box.
|
||||
-- p: the index of the nearest dumped ancestor (0 none); layer: the page's top child it is
|
||||
-- in ("layout", "dialog", ...); clip: "part"/"all" when a stencil cuts it; en: false for
|
||||
-- a disabled button; cut: text ends with "…" (ui.fit_text). scripts/ui_lint.py reads it.
|
||||
local PIV = {
|
||||
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
|
||||
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
|
||||
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
|
||||
}
|
||||
|
||||
local function r1(v)
|
||||
return math.floor(v * 2 + 0.5) / 2
|
||||
end
|
||||
|
||||
local dump_ids = 0
|
||||
|
||||
local function cut80(s) -- (between UTF-8 characters)
|
||||
if #s <= 80 then
|
||||
return s
|
||||
end
|
||||
local n = 80
|
||||
while n > 1 and s:byte(n + 1) >= 0x80 and s:byte(n + 1) < 0xC0 do
|
||||
n = n - 1
|
||||
end
|
||||
return s:sub(1, n)
|
||||
end
|
||||
local walk_dump
|
||||
|
||||
local function dump(g)
|
||||
local page = g.page
|
||||
local probe = gui.new_box_node(vmath.vector3(100, -100, 0), vmath.vector3(1, 1, 0))
|
||||
gui.set_parent(probe, page, false)
|
||||
local o, q = gui.get_screen_position(page), gui.get_screen_position(probe)
|
||||
gui.set_enabled(probe, false)
|
||||
local kx, ky = (q.x - o.x) / 100, (o.y - q.y) / 100
|
||||
local size = gui.get_size(page)
|
||||
-- gui.get_tree maps ids to nodes, and ids repeat (gui.new_*_node all have the empty id,
|
||||
-- Druid widgets give clones their template ids): it shows one node per id. So name
|
||||
-- every node uniquely for the dump (one round per repeat) and put the ids back after.
|
||||
local orig = {} -- dump id -> the node's own id
|
||||
local tree
|
||||
repeat
|
||||
tree = gui.get_tree(page)
|
||||
local fresh = false
|
||||
for k, n in pairs(tree) do
|
||||
if not orig[k] then
|
||||
dump_ids = dump_ids + 1
|
||||
local t = hash("mm_dump_" .. dump_ids)
|
||||
gui.set_id(n, t)
|
||||
orig[t], fresh = k, true
|
||||
end
|
||||
end
|
||||
until not fresh
|
||||
local ok, res = pcall(walk_dump, g, page, tree, orig, o, kx, ky)
|
||||
for t, k in pairs(orig) do
|
||||
gui.set_id(tree[t], k)
|
||||
end
|
||||
gui.delete_node(probe)
|
||||
if not ok then
|
||||
error(res)
|
||||
end
|
||||
res.w, res.h = r1(size.x), r1(size.y)
|
||||
return res
|
||||
end
|
||||
|
||||
-- The dump's walk over a tree whose ids are all unique (orig: their own ids).
|
||||
walk_dump = function(g, page, tree, orig, o, kx, ky)
|
||||
local ui = require("modules.ui")
|
||||
-- Children by parent, in render order.
|
||||
local kids = {}
|
||||
for _, n in pairs(tree) do
|
||||
local p = gui.get_parent(n)
|
||||
if p then
|
||||
local k = gui.get_id(p)
|
||||
kids[k] = kids[k] or {}
|
||||
table.insert(kids[k], n)
|
||||
end
|
||||
end
|
||||
for _, list in pairs(kids) do
|
||||
table.sort(list, function(a, b) return gui.get_index(a) < gui.get_index(b) end)
|
||||
end
|
||||
local layer_names = {}
|
||||
for _, k in ipairs({ "layout", "toast_layer", "tip_layer", "focus_layer", "dialog", "fx", "reveal" }) do
|
||||
local v = g[k]
|
||||
for _, x in ipairs({ v or false, type(v) == "table" and (v.layer or v.root) or false }) do
|
||||
local ok, p = pcall(gui.get_parent, x)
|
||||
if ok and p and gui.get_id(p) == gui.get_id(page) and not layer_names[gui.get_id(x)] then
|
||||
layer_names[gui.get_id(x)] = (k:gsub("_layer$", ""))
|
||||
end
|
||||
end
|
||||
end
|
||||
local out = {}
|
||||
local function walk(n, sx, sy, zone, layer, parent)
|
||||
if not gui.is_enabled(n) then
|
||||
return
|
||||
end
|
||||
local sc = gui.get_scale(n)
|
||||
sx, sy = sx * sc.x, sy * sc.y
|
||||
local t = gui.get_type(n)
|
||||
local sp = gui.get_screen_position(n)
|
||||
local px, py = (sp.x - o.x) / kx, (o.y - sp.y) / ky
|
||||
local sz = gui.get_size(n)
|
||||
local a = PIV[gui.get_pivot(n)] or { 0.5, 0.5 }
|
||||
local key = gui.get_id(n)
|
||||
local named = names[orig[key]]
|
||||
local e = named and elements[named.id]
|
||||
local alpha = gui.get_visible(n) and gui.get_color(n).w or 0 -- (visible: this node only)
|
||||
local item
|
||||
local w, h = sz.x * sx, sz.y * sy
|
||||
if t == gui.TYPE_TEXT then
|
||||
local text = gui.get_text(n)
|
||||
if text ~= "" and alpha > 0.01 then
|
||||
local wrap = gui.get_line_break(n)
|
||||
local m = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), text,
|
||||
{ tracking = gui.get_tracking(n), line_break = wrap, width = wrap and sz.x or nil, leading = gui.get_leading(n) })
|
||||
local tw, th = m.width * sx, m.height * sy
|
||||
item = { kind = "text", x = px - a[1] * tw, y = py - a[2] * th, w = tw, h = th,
|
||||
text = cut80(text), cut = text:find("…", 1, true) and true or nil,
|
||||
wrap = wrap or nil, bw = r1(w), bh = r1(h) }
|
||||
end
|
||||
elseif e or (t == gui.TYPE_BOX and (alpha > 0.01 or gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE) and w * h >= 16) then
|
||||
local look = ui.button_looks[n]
|
||||
item = { kind = e and e.kind or (gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and "clip" or "panel"),
|
||||
id = e and e.id or nil, x = px - a[1] * w, y = py - a[2] * h, w = w, h = h,
|
||||
en = look and look.disabled and false or nil }
|
||||
end
|
||||
if item then
|
||||
local nm = tostring(orig[key]):match("%[(.-)%]")
|
||||
item.name = nm and nm:match("^[%a_]") and nm or nil
|
||||
item.p, item.layer = parent, layer
|
||||
if zone then
|
||||
local ix = math.min(item.x + item.w, zone[3]) - math.max(item.x, zone[1])
|
||||
local iy = math.min(item.y + item.h, zone[4]) - math.max(item.y, zone[2])
|
||||
if ix <= 0 or iy <= 0 then
|
||||
item.clip = "all"
|
||||
elseif ix < item.w - 0.5 or iy < item.h - 0.5 then
|
||||
item.clip = "part"
|
||||
-- (what shows of it: the lint's overlap rule only counts that)
|
||||
local vx, vy = math.max(item.x, zone[1]), math.max(item.y, zone[2])
|
||||
item.vis = { r1(vx), r1(vy), r1(ix), r1(iy) }
|
||||
end
|
||||
end
|
||||
item.x, item.y, item.w, item.h = r1(item.x), r1(item.y), r1(item.w), r1(item.h)
|
||||
out[#out + 1] = item
|
||||
item.i = #out
|
||||
parent = #out
|
||||
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and not gui.get_clipping_inverted(n) then
|
||||
local z = { px - a[1] * w, py - a[2] * h, px + (1 - a[1]) * w, py + (1 - a[2]) * h }
|
||||
if zone then
|
||||
z = { math.max(z[1], zone[1]), math.max(z[2], zone[2]), math.min(z[3], zone[3]), math.min(z[4], zone[4]) }
|
||||
end
|
||||
zone = z
|
||||
end
|
||||
end
|
||||
for _, c in ipairs(kids[key] or {}) do
|
||||
walk(c, sx, sy, zone, layer, parent)
|
||||
end
|
||||
end
|
||||
local pk = gui.get_id(page)
|
||||
for i, c in ipairs(kids[pk] or {}) do
|
||||
walk(c, 1, 1, nil, layer_names[gui.get_id(c)] or ("layer" .. i), 0)
|
||||
end
|
||||
return { view = plain(g.view), dialog = g.dialog and g.dialog:is_open() or false, nodes = out }
|
||||
end
|
||||
|
||||
function COMMANDS.dump(g, cmd)
|
||||
-- On a later frame: nodes deleted this frame (a rebuild) are only gone after it.
|
||||
steps(g, { function() end, function()
|
||||
local ok, d = pcall(dump, g)
|
||||
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
|
||||
end }, nil, cmd.id)
|
||||
end
|
||||
|
||||
function COMMANDS.state(g)
|
||||
return { ok = true, state = summary(g) }
|
||||
end
|
||||
@@ -512,9 +699,13 @@ function M.update(g, input, api)
|
||||
-- Running multi-frame steps.
|
||||
if g.test_steps and #g.test_steps > 0 then
|
||||
local s = g.test_steps[1]
|
||||
s.list[s.i]()
|
||||
local ok, err = pcall(s.list[s.i])
|
||||
s.i = s.i + 1
|
||||
if s.i > #s.list then
|
||||
if not ok then
|
||||
-- (a failing step ends its run: reply with the error, or the command never answers)
|
||||
table.remove(g.test_steps, 1)
|
||||
reply(s.id, { ok = false, error = tostring(err) })
|
||||
elseif s.i > #s.list then
|
||||
table.remove(g.test_steps, 1)
|
||||
if s.done then
|
||||
s.done()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
|
||||
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
|
||||
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
|
||||
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
|
||||
|
||||
@@ -49,7 +49,7 @@ M.palettes = {
|
||||
loss = { "#ff8a96" },
|
||||
teal = { "#2dd4bf" },
|
||||
violet = { "#b69cff" },
|
||||
violet_hi = { "#dccfff" }, -- attachment and advisor effects
|
||||
violet_hi = { "#dccfff" }, -- advisor effects
|
||||
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
|
||||
up = { "#3ee6a0" },
|
||||
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
|
||||
@@ -168,10 +168,10 @@ M.size = {
|
||||
aside_w_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
|
||||
aside_w_max = 580,
|
||||
content_max = 960, -- widest a section's content gets (tables, rows) in the main area
|
||||
market_max = 960, -- the market's (quotes, detail and ticket)
|
||||
market_max = 1320, -- the market's (quotes, detail and ticket)
|
||||
market_panel_max = 460, -- tallest the market's detail and ticket panels get
|
||||
text_max = 720, -- widest a paragraph gets
|
||||
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered
|
||||
cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
|
||||
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
|
||||
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
|
||||
panel_pad = 14,
|
||||
|
||||
+111
-23
@@ -1,6 +1,9 @@
|
||||
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at
|
||||
-- most 3, each fading out on its own after a few seconds. They live on their own layer
|
||||
-- node, outside the rebuilt layout, so a rebuild doesn't drop them.
|
||||
-- Toasts for action results (the web's .toasts). Two places:
|
||||
-- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
|
||||
-- toast is listed there, newest at the top, the last LOG_MAX (5) kept (the log survives
|
||||
-- rebuilds; a rebuilt pane lists it again without entrances).
|
||||
-- - Without a pane (no moon list shown): floating, bottom-left, newest at the bottom, at
|
||||
-- most 3, each fading out on its own after a few seconds, on their own layer node.
|
||||
--
|
||||
-- Motion (app.css): in, the toast slides from -60 px with a squash and overshoot and a
|
||||
-- bright flash (fx-toast-in, 0.55 s; errors 0.45 s, then a shake), a scan line wipes
|
||||
@@ -18,6 +21,7 @@ M.__index = M
|
||||
|
||||
local MAX = 3
|
||||
local LIFE = 3.5
|
||||
local LOG_MAX = 5 -- (the pane's list: the last 5)
|
||||
local W, H = 320, 44 -- (app.css .toasts: 320 px wide; a toast grows taller as its text wraps)
|
||||
local TEXT_X, PAD_R, PAD_Y = 46, 14, 8
|
||||
local GAP, MARGIN = 6, 16
|
||||
@@ -31,23 +35,21 @@ local KIND = {
|
||||
redrill = { "cyan", "▼" },
|
||||
upgrade = { "cyan", "▲" },
|
||||
["buy-tool"] = { "cyan", "+" },
|
||||
["buy-attachment"] = { "cyan", "+" },
|
||||
["buy-seat"] = { "cyan", "+" },
|
||||
["buy-slot"] = { "cyan", "+" },
|
||||
equip = { "cyan", "▲" },
|
||||
unequip = { "muted", "▼" },
|
||||
hire = { "violet", "●" },
|
||||
refine = { "violet", "◆" },
|
||||
collect = { "teal", "◆" },
|
||||
["sell-moon"] = { "ready", "₥" },
|
||||
["sell-tool"] = { "ready", "₥" },
|
||||
["sell-attachment"] = { "ready", "₥" },
|
||||
["save-code"] = { "violet", "★" },
|
||||
restore = { "violet", "★" },
|
||||
error = { "danger", "!" },
|
||||
}
|
||||
|
||||
-- layer: a node over the layout; its top-left is the page's top-left.
|
||||
function M.new(layer)
|
||||
return setmetatable({ layer = layer, items = {}, h = 800 }, M)
|
||||
return setmetatable({ layer = layer, items = {}, h = 800, log = {} }, M)
|
||||
end
|
||||
|
||||
-- The page height, to place the stack at the bottom.
|
||||
@@ -96,7 +98,7 @@ end
|
||||
-- The body's look at a moment: x offset, vertical squash (about its middle), opacity,
|
||||
-- flash (an additive overlay in the accent, lighter: the web's brightness filter).
|
||||
local function pose(it, dx, sy, alpha, flash)
|
||||
anim.scale_about(it.body, vmath.vector3(MARGIN + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
|
||||
anim.scale_about(it.body, vmath.vector3(it.mx + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
|
||||
local c = it.fill
|
||||
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
|
||||
local f = it.flash_color
|
||||
@@ -110,7 +112,7 @@ local function fade(self, it)
|
||||
it.gone = true
|
||||
anim.cancel(it.tween)
|
||||
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease).
|
||||
local x0 = gui.get_position(it.body).x - MARGIN
|
||||
local x0 = gui.get_position(it.body).x - it.mx
|
||||
it.tween = anim.tween(it.body, 0.5, function(k)
|
||||
local e = anim.fn.ease(k)
|
||||
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0)
|
||||
@@ -165,21 +167,19 @@ local function enter(it, is_error)
|
||||
end)
|
||||
end
|
||||
|
||||
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
|
||||
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
|
||||
function M:push(message, kind, is_error, opts)
|
||||
local k = KIND[is_error and "error" or kind] or { "cyan", "›" }
|
||||
local accent = (not is_error and opts and opts.color) or k[1]
|
||||
|
||||
local root = anim.group(self.layer, 0, 0)
|
||||
local it = { root = root, accent = accent }
|
||||
local w, h = W, H
|
||||
-- A toast's nodes under parent (root at 0, 0; the body w wide, mx from the left).
|
||||
local function make(parent, e, w, mx)
|
||||
local k = KIND[e.is_error and "error" or e.kind] or { "cyan", "›" }
|
||||
local accent = e.color or k[1]
|
||||
local root = anim.group(parent, 0, 0)
|
||||
local it = { root = root, accent = accent, mx = mx }
|
||||
local h = H
|
||||
local made = ui.collect(function()
|
||||
-- The text first (wrapped at the toast's width), to size the toast.
|
||||
local style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
|
||||
local line, th
|
||||
local text_nodes = ui.collect(function()
|
||||
line, th = ui.wrap(root, message, style, 0, 0, W - TEXT_X - PAD_R)
|
||||
line, th = ui.wrap(root, e.message, style, 0, 0, w - TEXT_X - PAD_R)
|
||||
end)
|
||||
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
|
||||
-- but not drawn, so the runs under them inherit alpha 1.)
|
||||
@@ -190,9 +190,9 @@ function M:push(message, kind, is_error, opts)
|
||||
end
|
||||
end
|
||||
h = math.max(H, math.floor(th + PAD_Y * 2))
|
||||
local body = ui.rect(root, MARGIN, 0, w, h, "field")
|
||||
local body = ui.rect(root, mx, 0, w, h, "field")
|
||||
it.body = body
|
||||
local tint = ui.rect(body, 0, 0, w, h, accent, 0.1)
|
||||
ui.rect(body, 0, 0, w, h, accent, 0.1)
|
||||
-- A stronger tint toward the icon (the web's gradient), in two steps.
|
||||
ui.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
|
||||
ui.outline(body, 0, 0, w, h, accent, 0.7)
|
||||
@@ -228,7 +228,96 @@ function M:push(message, kind, is_error, opts)
|
||||
it.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
|
||||
it.fill = ui.color("field", 0.97)
|
||||
it.scale = gui.get_scale(it.body)
|
||||
return it
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- the pane
|
||||
|
||||
-- The list's height for the last LOG_MAX one-line toasts (the pane is sized to it).
|
||||
M.LIST_H = LOG_MAX * (H + GAP) - GAP
|
||||
|
||||
-- holder: a node at the pane's top-left (w x h), in the moon list (rebuilt with it).
|
||||
function M:attach(holder, w, h)
|
||||
self.log = self.log or {} -- (a hot reload keeps the old instance)
|
||||
gui.set_clipping_mode(holder, gui.CLIPPING_MODE_STENCIL)
|
||||
self.pane = { holder = holder, w = w, h = h, items = {} }
|
||||
self:fill(nil)
|
||||
end
|
||||
|
||||
-- No pane (no moon list): toasts float again.
|
||||
function M:detach()
|
||||
self.pane = nil
|
||||
end
|
||||
|
||||
local function pane(self)
|
||||
local p = self.pane
|
||||
if p and pcall(gui.get_position, p.holder) then
|
||||
return p
|
||||
end
|
||||
self.pane = nil -- (its moon list went away)
|
||||
end
|
||||
|
||||
-- Lists the log in the pane, newest at the top, as many as fit. fresh: the entry just
|
||||
-- pushed (it enters; the others slide down to make room).
|
||||
function M:fill(fresh)
|
||||
local p = self.pane
|
||||
for _, it in ipairs(p.items) do
|
||||
anim.cancel(it.tween)
|
||||
pcall(gui.delete_node, it.root)
|
||||
end
|
||||
p.items = {}
|
||||
if p.empty then
|
||||
pcall(gui.delete_node, p.empty)
|
||||
p.empty = nil
|
||||
end
|
||||
if #self.log == 0 then
|
||||
p.empty = ui.text(p.holder, "Nothing yet.", "small", 0, 10)
|
||||
ui.fit_text(p.empty, p.w)
|
||||
return
|
||||
end
|
||||
local y, shift = 0, 0
|
||||
for i, e in ipairs(self.log) do
|
||||
if y >= p.h then
|
||||
break
|
||||
end
|
||||
local it = make(p.holder, e, p.w, 0)
|
||||
p.items[#p.items + 1] = it
|
||||
local dim = i == 1 and 1 or (i == 2 and 0.85 or 0.7)
|
||||
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
|
||||
if e == fresh then
|
||||
gui.set_position(it.root, vmath.vector3(0, -y, 0))
|
||||
shift = it.h + GAP
|
||||
enter(it, e.is_error)
|
||||
else
|
||||
pose(it, 0, 1, 1, 0)
|
||||
gui.set_position(it.root, vmath.vector3(0, -(y - shift), 0))
|
||||
if shift > 0 and not anim.reduced then
|
||||
gui.animate(it.root, "position.y", -y, gui.EASING_OUTCUBIC, 0.25)
|
||||
else
|
||||
gui.set_position(it.root, vmath.vector3(0, -y, 0))
|
||||
end
|
||||
end
|
||||
y = y + it.h + GAP
|
||||
end
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- push
|
||||
|
||||
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
|
||||
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
|
||||
function M:push(message, kind, is_error, opts)
|
||||
local e = { message = message, kind = kind, is_error = is_error, color = (not is_error and opts and opts.color) or nil }
|
||||
self.log = self.log or {}
|
||||
table.insert(self.log, 1, e)
|
||||
while #self.log > LOG_MAX do
|
||||
table.remove(self.log)
|
||||
end
|
||||
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
|
||||
if pane(self) then
|
||||
self:fill(e)
|
||||
return
|
||||
end
|
||||
local it = make(self.layer, e, W, MARGIN)
|
||||
self.items[#self.items + 1] = it
|
||||
while #self.items > MAX do
|
||||
remove(self, self.items[1])
|
||||
@@ -239,7 +328,6 @@ function M:push(message, kind, is_error, opts)
|
||||
it.timer = nil
|
||||
fade(self, it)
|
||||
end)
|
||||
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
|
||||
end
|
||||
|
||||
return M
|
||||
+27
-8
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
|
||||
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
|
||||
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
|
||||
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
|
||||
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
||||
-- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
|
||||
-- root, nodes, width.
|
||||
function M.button(parent, label, x, y, opts)
|
||||
opts = opts or {}
|
||||
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
|
||||
local parts = { { text, M.text_width(text) } }
|
||||
if opts.price then
|
||||
local accent = opts.accent or v.price
|
||||
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
||||
gui.set_color(sep, M.color_spec(tc))
|
||||
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
|
||||
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
|
||||
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
||||
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
||||
if opts.bare then -- (no "·" between: "+ ₥290")
|
||||
parts[#parts + 1] = { sym, M.text_width(sym), 5 }
|
||||
else
|
||||
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
|
||||
gui.set_color(sep, M.color_spec(tc))
|
||||
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
|
||||
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
|
||||
end
|
||||
parts[#parts + 1] = { num, M.text_width(num), 1 }
|
||||
end
|
||||
local content = 0
|
||||
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
|
||||
return
|
||||
end
|
||||
while #text > 1 do
|
||||
-- (a cut entity like "&am" would show as text)
|
||||
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
|
||||
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
|
||||
gui.set_text(node, text .. "…")
|
||||
if M.text_width(node) <= max_w then
|
||||
return
|
||||
@@ -333,8 +337,13 @@ function M.dur(ms)
|
||||
return string.format("%d:%02d", m, sec)
|
||||
end
|
||||
|
||||
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
|
||||
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
|
||||
function M.cycle(secs)
|
||||
if secs < 60 and secs ~= math.floor(secs) then
|
||||
local t = math.floor(secs * 10 + 0.5) / 10
|
||||
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
|
||||
end
|
||||
secs = math.floor(secs + 0.5)
|
||||
if secs < 60 then
|
||||
return secs .. "s"
|
||||
elseif secs % 60 == 0 then
|
||||
@@ -343,6 +352,16 @@ function M.cycle(secs)
|
||||
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
|
||||
end
|
||||
|
||||
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
|
||||
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
|
||||
function M.levels_tag(t)
|
||||
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
|
||||
if y == 0 and c == 0 then
|
||||
return ""
|
||||
end
|
||||
return " Y" .. y .. " C" .. c
|
||||
end
|
||||
|
||||
function M.plural(n, word)
|
||||
return n .. " " .. word .. (n == 1 and "" or "s")
|
||||
end
|
||||
|
||||
@@ -83,6 +83,8 @@ void main()
|
||||
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
|
||||
normal = normalize(abs(det) * normal - grad);
|
||||
}
|
||||
// cam_pos matches the view matrix (a debug render of |V(cam_pos) - V(view matrix)| was 0
|
||||
// over the whole moon); moon_glow and the halo take V in view space (-mv.xyz) instead.
|
||||
vec3 V = normalize(cam_pos.xyz - var_world);
|
||||
vec3 albedo = var_color;
|
||||
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
|
||||
|
||||
+10
-4
@@ -33,6 +33,7 @@ local BURST = 0.9 -- reveal: the burst itself (s)
|
||||
local TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
|
||||
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts
|
||||
local SHROUD_ROT = vmath.quat_rotation_x(0.28) * vmath.quat_rotation_z(-0.22)
|
||||
local SHROUD_SPIN = 0.25 -- radians a second the shroud turns
|
||||
|
||||
local SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
|
||||
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4))
|
||||
@@ -194,7 +195,7 @@ local function start_job(self, id, key, params)
|
||||
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
|
||||
params.resolution = WEB_RESOLUTION
|
||||
end
|
||||
local job = { id = id, key = key, params = params }
|
||||
local job = { id = id, key = key, params = params, t0 = now() }
|
||||
job.gen = native.new(terrain.native_params(params))
|
||||
job.threaded = native.start(job.gen)
|
||||
self.job = job
|
||||
@@ -251,7 +252,9 @@ end
|
||||
local function job_done(self, job)
|
||||
local res = new_buffer_resource(job.buffer)
|
||||
cache_put(self, job.key, res)
|
||||
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
|
||||
local ms = (now() - job.t0) * 1000
|
||||
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
|
||||
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
|
||||
if job.key == self.want_key then
|
||||
set_mesh(self, job.key, res, job.params)
|
||||
end
|
||||
@@ -449,6 +452,7 @@ function update(self, dt)
|
||||
if #self.orphans > 0 then
|
||||
step_orphans(self)
|
||||
end
|
||||
moon.building = self.job ~= nil or #self.orphans > 0
|
||||
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
|
||||
f.visible = false
|
||||
return
|
||||
@@ -565,10 +569,12 @@ function update(self, dt)
|
||||
if shroud then
|
||||
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
|
||||
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale)
|
||||
-- The shroud and its motes turn slowly, like a moon (the halo, a glow around it, stays).
|
||||
local turn = spin_enabled and vmath.quat_rotation_y(self.shroud_t * SHROUD_SPIN) or vmath.quat()
|
||||
for _, id in ipairs({ "shroud", "halo", "motes" }) do
|
||||
go.set_position(pos, id)
|
||||
go.set_scale(sc, id)
|
||||
go.set_rotation(SHROUD_ROT, id)
|
||||
go.set_rotation(id == "halo" and SHROUD_ROT or SHROUD_ROT * turn, id)
|
||||
end
|
||||
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
|
||||
else
|
||||
@@ -599,7 +605,7 @@ function update(self, dt)
|
||||
end
|
||||
|
||||
function on_input(self, action_id, action)
|
||||
local rect = moon.frame.visible and moon.rect
|
||||
local rect = moon.frame.visible and not moon.covered and moon.rect
|
||||
if not rect then
|
||||
self.dragging = false
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
|
||||
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
|
||||
-- builds it under the header instead of the moon list, the section and the aside; a bar
|
||||
-- on top lists the screens and leads back to the game (so does any navigation: a header
|
||||
-- tab, Back).
|
||||
--
|
||||
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
|
||||
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
|
||||
-- Test ids: "admin:<screen>", "admin:back".
|
||||
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local theme = require("modules.theme")
|
||||
local button = require("components.button")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
|
||||
local SCREENS = {
|
||||
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
|
||||
}
|
||||
|
||||
local function find(id)
|
||||
for _, s in ipairs(SCREENS) do
|
||||
if s.id == id then
|
||||
return s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.allowed(g)
|
||||
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
|
||||
end
|
||||
|
||||
function M.open(g, id)
|
||||
local s = find(id or SCREENS[1].id)
|
||||
if not s then
|
||||
return
|
||||
end
|
||||
if g.admin and g.admin ~= s.id then
|
||||
M.close(g)
|
||||
end
|
||||
if g.admin ~= s.id then
|
||||
g.admin = s.id
|
||||
if s.mod.open then
|
||||
s.mod.open(g)
|
||||
end
|
||||
end
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end
|
||||
|
||||
function M.close(g)
|
||||
local s = g.admin and find(g.admin)
|
||||
if s and s.mod.close then
|
||||
s.mod.close(g)
|
||||
end
|
||||
g.admin = nil
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end
|
||||
|
||||
-- The block's inputs besides the rect.
|
||||
function M.sig(g)
|
||||
local s = find(g.admin)
|
||||
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
|
||||
end
|
||||
|
||||
-- The bar and the screen in (x, y, w, h).
|
||||
function M.build(g, parent, x, y, w, h)
|
||||
local s = find(g.admin)
|
||||
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
|
||||
for _, sc in ipairs(SCREENS) do
|
||||
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
|
||||
function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
|
||||
end)
|
||||
end
|
||||
kids[#kids + 1] = fg.fill()
|
||||
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
|
||||
end)
|
||||
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
|
||||
flex.solve(bar, x, y)
|
||||
fg.build(bar, parent)
|
||||
local top = y + bar.h + S.gap
|
||||
if s then
|
||||
s.mod.build(g, parent, x, top, w, y + h - top)
|
||||
end
|
||||
end
|
||||
|
||||
function M.update(g, dt)
|
||||
local s = g.admin and find(g.admin)
|
||||
if s and s.mod.update then
|
||||
s.mod.update(g, dt)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -263,7 +263,9 @@ local function advisor_card(self, H, ad, c, a)
|
||||
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
|
||||
c:gap(4)
|
||||
local why, why_color = hire_block(a)
|
||||
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
|
||||
-- (an advisor for hire keeps the reason's place, shown or not: credits coming and going
|
||||
-- don't resize the card)
|
||||
c:footer(S.button_sm_h + (a.hired and 0 or 18), function(f, bw)
|
||||
local kids
|
||||
if a.hired then
|
||||
local seat = (a.seat or 0) > 0
|
||||
@@ -281,9 +283,9 @@ local function advisor_card(self, H, ad, c, a)
|
||||
tooltip.add(b, a.whyText)
|
||||
end
|
||||
end, { width = bw }),
|
||||
(not can and why) and made({ height = 20 }, function(p, x, y, w)
|
||||
fg.reserve({ height = 20 }, (not can and why) and made({ width = bw, height = 20 }, function(p, x, y, w)
|
||||
store.need_text(p, why, x + w, y + 10, why_color)
|
||||
end) or nil,
|
||||
end) or nil),
|
||||
}
|
||||
end
|
||||
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
|
||||
|
||||
+201
-264
@@ -1,27 +1,29 @@
|
||||
-- The aside (region, right column): the moon viewport (the selected moon in every
|
||||
-- section, the shroud on New moon, a grid when there is none) and under it the selected
|
||||
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
|
||||
-- there (a section module's aside / aside_below, see core/main.lua), always under the
|
||||
-- viewport.
|
||||
-- well's facts (supply, what the drills leave, price, its drills and their times), the
|
||||
-- moon's when no well is picked, or what the section puts there (a section module's
|
||||
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
|
||||
-- section it only shows, but for destructive actions (Sell moon, at its foot): every
|
||||
-- other action is in the main panel (moons.lua's well cards).
|
||||
|
||||
local theme = require("modules.theme")
|
||||
local ui = require("modules.ui")
|
||||
local moon_view = require("modules.moon.moon")
|
||||
local reveal = require("screens.game.reveal")
|
||||
local moons = require("screens.game.moons")
|
||||
local moon_sale = require("screens.game.moon_sale")
|
||||
local predict = require("modules.predict")
|
||||
local panel = require("components.panel")
|
||||
local scroll = require("components.scroll")
|
||||
local bar = require("components.bar")
|
||||
local tooltip = require("modules.tooltip")
|
||||
local row_c = require("components.row")
|
||||
local button = require("components.button")
|
||||
local chip = require("components.chip")
|
||||
local anim = require("modules.anim")
|
||||
local images = require("modules.images")
|
||||
local cards = require("screens.game.cards")
|
||||
local ready = require("screens.game.core.ready")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
local anim = require("modules.anim")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -53,7 +55,10 @@ local function viewport(g, parent, x, y, size, moon)
|
||||
g.viewport_node = vp
|
||||
g.viewport_rect = g.fx:rect_of(vp)
|
||||
g.view_was_mystery = false
|
||||
if g.view.section == "new-moon" and not g.reveal:active() then
|
||||
if g.reveal:active() then
|
||||
-- The reveal shows the moon in its own window (reveal.lua): leave the 3D moon there.
|
||||
g.viewport_node = nil
|
||||
elseif g.view.section == "new-moon" then
|
||||
-- An unknown moon: the shroud and its "?" (reveal.lua).
|
||||
moon_view.mystery(vp)
|
||||
reveal.mystery_mark(vp, size)
|
||||
@@ -90,45 +95,8 @@ local function reach_short(st, n)
|
||||
return pick and pick.short ~= "" and pick.short or (n .. " wells")
|
||||
end
|
||||
|
||||
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
|
||||
-- moon"; "" for one.
|
||||
local function reach_label(st, n)
|
||||
local list = st.catalog and st.catalog.reach or {}
|
||||
local pick = list[#list]
|
||||
for _, r in ipairs(list) do
|
||||
if r.reach == n then
|
||||
pick = r
|
||||
end
|
||||
end
|
||||
return pick and pick.label or ""
|
||||
end
|
||||
|
||||
-- An attachment's effect ("+20% speed", catalog), for its tag's tooltip.
|
||||
local function effect_of(st, name)
|
||||
for _, a in ipairs(st.catalog and st.catalog.attachments or {}) do
|
||||
if a.name == name then
|
||||
return a.effectText
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||
|
||||
-- The width of a text in a style (a probe).
|
||||
local function text_w(c, str, style)
|
||||
local t = ui.text(c, str, style, 0, 0)
|
||||
local w = ui.text_width(t)
|
||||
gui.delete_node(t)
|
||||
return w
|
||||
end
|
||||
|
||||
-- A text link (H.text_link) as a box h tall.
|
||||
local function link_box(g, c, H, str, style, h, v, id)
|
||||
return made({ width = text_w(c, str, style), height = h, shrink = 0 }, function(p, x, y)
|
||||
H.text_link(g, p, str, style, x, y + h / 2, v, id)
|
||||
end)
|
||||
end
|
||||
|
||||
-- A stat line (H.fact: cap label, value at the right; 20 tall).
|
||||
local function fact(H, label, value, opts)
|
||||
return made({ height = 20 }, function(p, x, y, w)
|
||||
@@ -141,95 +109,24 @@ local M11 = { font = "mono", px = 11, color = "muted" }
|
||||
local M12 = { font = "mono", px = 12, color = "muted" }
|
||||
local S12 = { font = "sans", px = 12, color = "muted" }
|
||||
|
||||
-- Deploy cards: one per idle tool group (well.idle). A tool that would get less than its
|
||||
-- yield here has an amber edge; one that would get nothing (the well is spoken for) is
|
||||
-- also dimmed to .6 (.85 under the mouse) by a veil in the panel's color (app.css
|
||||
-- .deploy-card.is-short / .is-spoken). The card is a button built at its width; its
|
||||
-- content is a tree solved inside it.
|
||||
local function deploy_card(g, c, well, grp, H)
|
||||
local hgt = grp.short and 52 or 40
|
||||
local spoken = grp.short and grp.here <= 0
|
||||
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
|
||||
return made({ height = hgt }, function(p, x, y, inner)
|
||||
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge })
|
||||
gui.set_size(b, vec(inner, hgt))
|
||||
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
|
||||
local tool = find(g.state.tools, grp.toolId)
|
||||
local n_att = #(grp.attachments or {})
|
||||
local gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
|
||||
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16) or nil
|
||||
if reach then
|
||||
local label = reach_label(g.state, grp.reach)
|
||||
if label ~= "" then
|
||||
reach.done = function(n) tooltip.add(n, label) end
|
||||
end
|
||||
end
|
||||
local gets = fg.label(b, gets_s, M12, 16)
|
||||
gets.done = function(n)
|
||||
tooltip.add(n, "What a cycle gets here after the drills already on it"
|
||||
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
|
||||
end
|
||||
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
|
||||
or "Well spoken for: nothing left after current drills", { font = "sans", px = 12, color = "ready" }, 14, { fit = true, shrink = 1 }) or nil
|
||||
local tree = row({ width = inner, height = hgt, padding = { 0, 12, 0, 6 } }, {
|
||||
-- The tool's picture (grey when spoken for), its attachments as pips under it.
|
||||
col({ width = 32, shrink = 0, padding_top = 4 }, {
|
||||
made({ width = 32, height = 32 }, function(q, ix, iy)
|
||||
local img = ui.rect(q, ix, iy, 32, 32, "none")
|
||||
if tool and tool.image then
|
||||
images.set(img, tool.image)
|
||||
if spoken then
|
||||
-- Greyed (no grayscale filter here): a neutral grey over the picture.
|
||||
ui.rect(q, ix, iy, 32, 32, "off", 0.6)
|
||||
end
|
||||
end
|
||||
end),
|
||||
n_att > 0 and made({ position = "absolute", left = 16 - n_att * 13 / 2, top = hgt - 8, width = 0, height = 0 }, function(q, ix, iy)
|
||||
cards.pips(q, n_att, n_att, ix, iy)
|
||||
end) or nil,
|
||||
}),
|
||||
space(8),
|
||||
col({ grow = 1, shrink = 1, padding_top = 5 }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
fg.label(b, grp.name, CARD_NAME, 16, { fit = true, shrink = 1, max_width = inner - 150 - 46 + 12 }),
|
||||
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16),
|
||||
grp.count > 1 and space(6) or nil,
|
||||
grp.count > 1 and fg.label(b, "×" .. grp.count, M11, 16) or nil,
|
||||
}),
|
||||
row({ height = 16, align = "center" }, { gets, reach and space(5) or nil, reach }),
|
||||
note and space(0, 0) or nil, note,
|
||||
}),
|
||||
space(8),
|
||||
col({ shrink = 0, justify = "center" }, {
|
||||
fg.label(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 16),
|
||||
}),
|
||||
})
|
||||
fg.build(flex.solve(tree, 0, 0), b)
|
||||
local title = "Deploy " .. grp.name .. " T" .. grp.tier
|
||||
for _, a in ipairs(grp.attachments or {}) do
|
||||
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
|
||||
end
|
||||
tooltip.add(b, title)
|
||||
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
|
||||
if spoken then
|
||||
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
|
||||
g.druid:new_hover(b, nil, function(_, on)
|
||||
gui.cancel_animations(veil, "color.w")
|
||||
local a = on and 0.15 or 0.4
|
||||
if anim.reduced or button.instant then
|
||||
anim.set(veil, "color.w", a)
|
||||
else
|
||||
gui.animate(veil, "color.w", a, gui.EASING_OUTQUAD, 0.15)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
|
||||
-- pieces for the foot of the well or moon facts.
|
||||
local function sale_kids(g, c, moon, inner, H)
|
||||
if not (g.state.unlocked.store and moon.sale) then
|
||||
return {}
|
||||
end
|
||||
return {
|
||||
space(0, 14),
|
||||
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
|
||||
space(0, 12),
|
||||
moon_sale.box(g, H, c, moon, inner),
|
||||
}
|
||||
end
|
||||
|
||||
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
|
||||
-- thumbnail, name, tier and the attachments' tags; what it gets; time left or Ready.
|
||||
-- thumbnail, name and track levels; what it gets; time left or Ready.
|
||||
local function drill_line(g, c, st, d, H, enters)
|
||||
local name, tier = moons.tool_name(st, d.toolId)
|
||||
local name, tag = moons.tool_name(st, d.toolId)
|
||||
local tool = find(st.tools, d.toolId)
|
||||
return made({ height = 34 }, function(p, x, y, inner)
|
||||
local dr = ui.rect(p, x, y, inner, 34, "none")
|
||||
@@ -244,29 +141,11 @@ local function drill_line(g, c, st, d, H, enters)
|
||||
end
|
||||
local x0 = 36
|
||||
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
|
||||
local tt = ui.text(dr, "T" .. tier, M11, x0 + ui.text_width(nt) + 6, 8)
|
||||
-- Tags while they fit (and leave 50 for the time).
|
||||
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) }
|
||||
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
|
||||
for _, a in ipairs(tool and tool.attachments or {}) do
|
||||
if tx + 60 > inner - 50 then
|
||||
break
|
||||
end
|
||||
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
|
||||
local aw = cards.tag(probe, a, 0, 0)
|
||||
gui.delete_node(probe)
|
||||
if tx + aw > inner - 50 then
|
||||
break
|
||||
end
|
||||
head[#head + 1] = made({ width = aw, height = 16, shrink = 0 }, function(q, ax, ay)
|
||||
cards.tag(q, a, ax, ay + 8)
|
||||
local fx_text = effect_of(st, a)
|
||||
if fx_text then
|
||||
tooltip.add(ui.rect(q, ax, ay, aw, 16, "none"), fx_text)
|
||||
end
|
||||
end)
|
||||
head[#head + 1] = space(4)
|
||||
tx = tx + aw + 4
|
||||
local head = { fg.node_label(nt, 16) }
|
||||
if tag ~= "" then
|
||||
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
|
||||
head[#head + 1] = space(6)
|
||||
head[#head + 1] = fg.node_label(tt, 16)
|
||||
end
|
||||
-- The amount it gets in ready when it comes up short (engine markup runs).
|
||||
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
|
||||
@@ -311,11 +190,6 @@ local function drill_line(g, c, st, d, H, enters)
|
||||
end)
|
||||
end
|
||||
|
||||
-- A row of claim buttons (Claim / its countdown, and Claim and redeploy beside it).
|
||||
local function claim_row(kids)
|
||||
return row({ height = S.button_h, gap = 8 }, kids)
|
||||
end
|
||||
|
||||
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
|
||||
local function well_panel(g, root, pad, top, inner, H)
|
||||
local st = g.state
|
||||
@@ -347,25 +221,27 @@ local function well_panel(g, root, pad, top, inner, H)
|
||||
add(fg.label(c, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, 12))
|
||||
add(space(0, 10))
|
||||
else
|
||||
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st)) end))
|
||||
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st, moon)) end))
|
||||
add(space(0, 8))
|
||||
-- The well note in the server's words (the web's): the lead (over-drilled, fully
|
||||
-- committed) in the warning color, then what the drills take of it, amber once
|
||||
-- they want it all; one paragraph (engine markup runs).
|
||||
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
|
||||
-- when there is no note, so a deploy doesn't make the facts jump.
|
||||
local note = nil
|
||||
if (wrow.noteText or "") ~= "" then
|
||||
local runs = {}
|
||||
if (wrow.statusText or "") ~= "" then
|
||||
runs[#runs + 1] = { wrow.statusText .. ". ", "ready" }
|
||||
end
|
||||
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" }
|
||||
add(fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) }))
|
||||
add(space(0, 4))
|
||||
note = fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) })
|
||||
end
|
||||
add(col({ min_height = 32, shrink = 0 }, { note }))
|
||||
add(space(0, 4))
|
||||
end
|
||||
add(fact(H, "Supply left", ui.money(wrow.supply)))
|
||||
if (wrow.demand or 0) > 0 then
|
||||
add(fact(H, "After current drills", ui.money(wrow.remain), { color = wrow.full and "ready" or "text" }))
|
||||
end
|
||||
local demand = (wrow.demand or 0) > 0
|
||||
add(fact(H, "After current drills", ui.money(demand and wrow.remain or wrow.supply), { color = (demand and wrow.full) and "ready" or "text" }))
|
||||
add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" }))
|
||||
-- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping.
|
||||
if #(st.bonuses or {}) > 0 then
|
||||
@@ -393,53 +269,27 @@ local function well_panel(g, root, pad, top, inner, H)
|
||||
local wide = moon.wide
|
||||
local wide_here = {}
|
||||
if wide and #(wrow.wide or {}) > 0 then
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
if contains(wrow.wide, d.id) then
|
||||
wide_here[#wide_here + 1] = d
|
||||
-- The server's here on these is the whole moon (what it gets in all): this well's
|
||||
-- share comes from the projection (the take from this well).
|
||||
local p = predict.project(st, moon)
|
||||
local wi
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
if x.id == w.id then
|
||||
wi = i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The web's order: deploy, drills, then the claims and Redrill.
|
||||
if not dry then
|
||||
add(fg.label(c, "Deploy a tool", "h3", 12))
|
||||
add(space(0, 6))
|
||||
if #w.idle > 0 then
|
||||
local cards_ = {}
|
||||
for _, grp in ipairs(w.idle) do
|
||||
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H)
|
||||
for _, d in ipairs(wide.drills or {}) do
|
||||
if contains(wrow.wide, d.id) then
|
||||
local dd = {}
|
||||
for k, v in pairs(d) do
|
||||
dd[k] = v
|
||||
end
|
||||
local take = p.take[d.id]
|
||||
if take and wi then
|
||||
dd.here = take[wi] or 0
|
||||
end
|
||||
wide_here[#wide_here + 1] = dd
|
||||
end
|
||||
add(col({ gap = 6 }, cards_))
|
||||
add(space(0, 6))
|
||||
local better = w.better
|
||||
if better then
|
||||
-- "Better: <resource> well on <moon>, X free": the well is a link.
|
||||
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
||||
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
|
||||
local name, tier = moons.tool_name(st, better.toolId)
|
||||
add(row({ height = 40, align = "start" }, {
|
||||
row({ height = S.button_sm_h, grow = 1, shrink = 1, align = "center", padding_right = 8 }, {
|
||||
fg.label(c, "Better:", "small", 16), space(4),
|
||||
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, better.resource) end),
|
||||
space(4),
|
||||
link_box(g, c, H, better.resource .. " well", "small", 16,
|
||||
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well"),
|
||||
fg.label(c, rest, "small", 16, { fit = not ui.needs_rich(rest), shrink = 1 }),
|
||||
}),
|
||||
fg.button(c, "Deploy there", { small = true, primary = true }, function(b, bn)
|
||||
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
|
||||
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
|
||||
end),
|
||||
}))
|
||||
end
|
||||
else
|
||||
add(row({ height = 16, align = "center" }, {
|
||||
fg.label(c, "No idle tools.", "small", 16),
|
||||
st.unlocked.store and space(5) or nil,
|
||||
st.unlocked.store and link_box(g, c, H, "Buy one in the store", { font = "sans", px = 13 }, 16, { section = "store", moon = g.view.moon }) or nil,
|
||||
st.unlocked.store and fg.label(c, ".", "small", 16) or nil,
|
||||
}))
|
||||
add(space(0, 6))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -471,54 +321,25 @@ local function well_panel(g, root, pad, top, inner, H)
|
||||
add(col({ gap = 2 }, lines))
|
||||
add(space(0, #lines > 0 and 10 or 8))
|
||||
|
||||
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
|
||||
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
|
||||
local claims = {}
|
||||
local amount = ready.amount(g, wrow, wrow.supply)
|
||||
if wrow.ready > 0 then
|
||||
claims[#claims + 1] = claim_row({
|
||||
fg.button(c, "Claim " .. ui.money(amount), { primary = true }, function(b, bn)
|
||||
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
|
||||
end),
|
||||
(not dry and st.unlocked.redeploy) and fg.button(c, "Claim and redeploy", {}, function(b, bn)
|
||||
H.action(g, b, bn, "claim", { ["claim-well"] = w.id, redeploy = "1" })
|
||||
end) or nil,
|
||||
})
|
||||
elseif wrow.nextEnd then
|
||||
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
|
||||
H.claim_wait(g, p, x, y, wrow.nextEnd, { key = "wait:well", params = { ["claim-well"] = w.id }, moon = moon.id,
|
||||
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil,
|
||||
also = not dry and st.unlocked.redeploy
|
||||
and { label = "Claim and redeploy", params = { ["claim-well"] = w.id, redeploy = "1" } } or nil })
|
||||
end)
|
||||
-- Idle drills that last mined it (Redrill on its card), and a better well for the
|
||||
-- idle drills (the server's pick), as text.
|
||||
if (wrow.redrill or 0) > 0 then
|
||||
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
|
||||
add(space(0, 6))
|
||||
end
|
||||
if #wide_here > 0 then
|
||||
local wamount = ready.amount(g, wide, wide.cap)
|
||||
if wide.ready > 0 then
|
||||
claims[#claims + 1] = claim_row({
|
||||
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn)
|
||||
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
|
||||
end),
|
||||
st.unlocked.redeploy and fg.button(c, "Claim and redeploy", {}, function(b, bn)
|
||||
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
|
||||
end) or nil,
|
||||
})
|
||||
elseif wide.nextEnd then
|
||||
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
|
||||
H.claim_wait(g, p, x, y, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
|
||||
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
|
||||
end)
|
||||
end
|
||||
local better = w.better
|
||||
if better and not dry then
|
||||
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
|
||||
local name, tag = moons.tool_name(st, better.toolId)
|
||||
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
|
||||
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
|
||||
add(space(0, 6))
|
||||
end
|
||||
if (wrow.redrill or 0) > 0 and not dry and st.unlocked.redrill then
|
||||
claims[#claims + 1] = claim_row({
|
||||
fg.button(c, "Redrill" .. (wrow.redrill > 1 and (" " .. wrow.redrill) or ""), { variant = "redrill" }, function(b, bn)
|
||||
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
|
||||
end),
|
||||
})
|
||||
end
|
||||
add(col({ gap = 10, padding_bottom = #claims > 0 and 10 or 0 }, claims))
|
||||
|
||||
-- (no buttons here but Sell moon: every action on the well is on its card)
|
||||
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||
add(k)
|
||||
end
|
||||
local tree = col({ width = inner }, kids)
|
||||
fg.build(flex.solve(tree, pad, top), c)
|
||||
for _, f in ipairs(enters) do
|
||||
@@ -554,6 +375,47 @@ function M.row_viewport(w)
|
||||
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
|
||||
end
|
||||
|
||||
-- The selected moon's facts under the viewport when no well is picked (read only).
|
||||
local function moon_status(g, c, x, y, inner, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local supply, dry, mining, done = 0, 0, 0, 0
|
||||
for _, w in ipairs(moon.wells or {}) do
|
||||
supply = supply + w.supply
|
||||
dry = dry + (w.supply <= 0 and 1 or 0)
|
||||
end
|
||||
local now = H.now(g)
|
||||
for _, s in ipairs(st.stakes or {}) do
|
||||
if s.moonId == moon.id then
|
||||
if s.ready or (s["end"] or math.huge) <= now then
|
||||
done = done + 1
|
||||
else
|
||||
mining = mining + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
local idle = 0
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
idle = idle + (t.stakeId and 0 or 1)
|
||||
end
|
||||
local kids = {
|
||||
fg.label(c, moon.name, "h2", 18), space(0, 10),
|
||||
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
|
||||
space(0, 10),
|
||||
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
|
||||
fact(H, "Supply left", ui.money(supply)),
|
||||
fact(H, "Drills mining", tostring(mining)),
|
||||
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
|
||||
fact(H, "Idle drills", tostring(idle)),
|
||||
}
|
||||
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
|
||||
kids[#kids + 1] = k
|
||||
end
|
||||
local tree = col({ width = inner }, kids)
|
||||
fg.build(flex.solve(tree, x, y), c)
|
||||
return tree.y + tree.h
|
||||
end
|
||||
|
||||
-- A heading (h2) and a line under the viewport.
|
||||
local function caption(c, x, y, inner, title, text)
|
||||
fg.build(flex.solve(col({ width = inner }, {
|
||||
@@ -562,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
|
||||
}), x, y), c)
|
||||
end
|
||||
|
||||
-- The facts under the viewport on the Moons section, built into root (at where.root's
|
||||
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
|
||||
-- block of their own (core/main.lua), kept while M.facts_sig stays.
|
||||
function M.facts(g, root, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
|
||||
if st.well then
|
||||
-- The well panel scrolls under the viewport (many drills).
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon.finished then
|
||||
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
|
||||
else
|
||||
-- (scrolls like the well panel: Sell moon at its foot)
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
|
||||
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
|
||||
end
|
||||
end
|
||||
|
||||
-- What M.facts shows (its block's signature, with where): from the state and our clock.
|
||||
function M.facts_sig(g, where, H)
|
||||
local st = g.state
|
||||
local moon = st.moon
|
||||
local now = H.now(g)
|
||||
local sale = st.unlocked.store and moon.sale or false
|
||||
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
|
||||
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
|
||||
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
|
||||
if moon.finished then
|
||||
return blocks.sig(common)
|
||||
end
|
||||
local supplies = {}
|
||||
for i, x in ipairs(moon.wells or {}) do
|
||||
supplies[i] = x.supply
|
||||
end
|
||||
if st.well then
|
||||
local w = st.well
|
||||
local wrow = find(moon.wells, w.id) or false
|
||||
local tools = {}
|
||||
for i, d in ipairs(wrow and wrow.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
|
||||
for _, d in ipairs(wide and wide.drills or {}) do
|
||||
local t = find(st.tools, d.toolId)
|
||||
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
|
||||
end
|
||||
local better = w.better or false
|
||||
local bt = better and find(st.tools, better.toolId)
|
||||
local bm = better and find(st.moons, better.moonId)
|
||||
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
|
||||
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
|
||||
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
|
||||
end
|
||||
local mining, done, idle = 0, 0, 0
|
||||
for _, s in ipairs(st.stakes or {}) do
|
||||
if s.moonId == moon.id then
|
||||
if s.ready or (s["end"] or math.huge) <= now then
|
||||
done = done + 1
|
||||
else
|
||||
mining = mining + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, t in ipairs(st.tools or {}) do
|
||||
idle = idle + (t.stakeId and 0 or 1)
|
||||
end
|
||||
return blocks.sig(common, supplies, mining, done, idle)
|
||||
end
|
||||
|
||||
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
|
||||
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
|
||||
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
|
||||
-- Returns the panel (components/panel: its entrance).
|
||||
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
|
||||
-- moon, where its facts go (M.facts).
|
||||
function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local st = g.state
|
||||
local p = panel.new(parent, x, y, w, h)
|
||||
@@ -575,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
local section = g.view.section
|
||||
local moon = section == "moons" and st.moon or nil
|
||||
local cy
|
||||
if row then
|
||||
-- A section panel that wants the whole aside (the drill panel): no moon over it.
|
||||
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
|
||||
if full then
|
||||
moon_view.hide()
|
||||
g.viewport_node, g.viewport_rect = nil, nil
|
||||
cy = 8
|
||||
elseif row then
|
||||
local vp = M.row_viewport(w)
|
||||
viewport(g, root, pad, pad, vp, shown_moon(g))
|
||||
-- The side panel gets the rest of the row as its own box.
|
||||
@@ -608,14 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
|
||||
end
|
||||
gui.delete_node(box)
|
||||
end
|
||||
if moon and st.well then
|
||||
-- The well panel scrolls under the viewport (many deploy cards or drills).
|
||||
local top = cy - 8
|
||||
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
|
||||
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
|
||||
elseif moon then
|
||||
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig."
|
||||
or "Pick a well in the table to deploy tools and claim resources.")
|
||||
if moon then
|
||||
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
|
||||
-- viewport doesn't show keeps the panel and its moon.
|
||||
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
|
||||
elseif mod and mod.aside_below and st[section] then
|
||||
mod.aside_below(g, root, pad, cy, inner, limit, H)
|
||||
elseif section == "new-moon" then
|
||||
|
||||
@@ -103,8 +103,8 @@ local STEPS = {
|
||||
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
|
||||
function(a) return a:press("buy-moon") end,
|
||||
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
|
||||
-- Buy another, straight from the card (its button registers as buy-moon).
|
||||
function(a) return a:press("buy-moon") end,
|
||||
-- Buy another, straight from the card (its button registers as reveal-buy).
|
||||
function(a) return a:press("reveal-buy") end,
|
||||
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
|
||||
function(a) return a:press("reveal-close") end,
|
||||
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
|
||||
|
||||
@@ -105,17 +105,13 @@ local STEPS = {
|
||||
return j and j.ready and a:press("collect", function(p) return p.job == j.id end)
|
||||
end,
|
||||
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") end,
|
||||
function(a) return go(a, "leaderboard") end,
|
||||
function(a) return a.g.view.section == "leaderboard" and shot("10-leaderboard") end,
|
||||
function(a) return go(a, "market") end,
|
||||
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
|
||||
function(a) return go(a, "help") end,
|
||||
function(a) return a.g.view.section == "help" and shot("11-help") end,
|
||||
-- Start over: arm the button (its first click), then run new-game.
|
||||
function(a)
|
||||
a.g.help_armed = a.g.h.now(a.g) + 30000
|
||||
a.g.dirty = true
|
||||
return true
|
||||
end,
|
||||
function() return shot("12-help-confirm") end,
|
||||
-- Start over: open its confirm, then run new-game (as its OK).
|
||||
function(a) return a:press("confirm:new-game") end,
|
||||
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
|
||||
function(a) return a:press("new-game") end,
|
||||
function(a) return a.g.state.view.section ~= "help" or true end,
|
||||
function() return shot("13-new-game") end,
|
||||
|
||||
+180
-23
@@ -12,6 +12,8 @@ local scroll = require("components.scroll")
|
||||
local button = require("components.button")
|
||||
local chip = require("components.chip")
|
||||
local stat = require("components.stat")
|
||||
local fg = require("modules.flex_gui")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
@@ -30,11 +32,12 @@ function M.compact(n)
|
||||
local function one(f)
|
||||
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
|
||||
end
|
||||
if a >= 1e6 then
|
||||
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
|
||||
if a >= 1e6 - 500 then
|
||||
return one(n / 1e6) .. "M"
|
||||
elseif a >= 1e4 then
|
||||
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
|
||||
elseif a >= 1e3 then
|
||||
elseif a >= 1e3 - 0.5 then
|
||||
return one(n / 1e3) .. "k"
|
||||
end
|
||||
return ui.money(math.floor(n + 0.5))
|
||||
@@ -47,14 +50,13 @@ function M.pct(m)
|
||||
end
|
||||
|
||||
local EFFECT_WORDS = {
|
||||
attachment = { { "speed", "speed" }, { "yield", "yield" } },
|
||||
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
|
||||
}
|
||||
|
||||
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
|
||||
-- "+5% drill speed, +5% yield" (kind: "advisor").
|
||||
function M.effect(effect, kind)
|
||||
local parts = {}
|
||||
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
|
||||
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
|
||||
local m = effect and effect[e[1]] or 1
|
||||
if m > 1 then
|
||||
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
|
||||
@@ -186,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
|
||||
return w
|
||||
end
|
||||
|
||||
-- A violet tag (attachment names); returns its width.
|
||||
-- A violet tag (advisor labels); returns its width and the box.
|
||||
function M.tag(parent, text, x, y, color)
|
||||
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
|
||||
return w, box
|
||||
end
|
||||
|
||||
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
|
||||
function M.pips(parent, used, slots, x, y)
|
||||
for i = 1, slots do
|
||||
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
|
||||
gui.set_pivot(p, gui.PIVOT_CENTER)
|
||||
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
|
||||
gui.set_rotation(p, vmath.vector3(0, 0, 45))
|
||||
if i > used then
|
||||
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
|
||||
gui.set_pivot(o, gui.PIVOT_CENTER)
|
||||
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
|
||||
gui.set_rotation(o, vmath.vector3(0, 0, 45))
|
||||
end
|
||||
end
|
||||
return slots * 13
|
||||
end
|
||||
|
||||
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
|
||||
-- height used.
|
||||
function M.parts(st, parent, parts, x, y, w)
|
||||
@@ -277,6 +262,178 @@ function M.confirm_server_button(self, b, c, act, params, id)
|
||||
end
|
||||
end })
|
||||
self.fx:button(id, b)
|
||||
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
|
||||
-- its confirm is open), and presses `id` to open the confirm.
|
||||
if self.autoplay then
|
||||
local function confirmed()
|
||||
local d = self.dialog
|
||||
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
|
||||
d:act(act, params)
|
||||
else
|
||||
self.fx:origin(b, id)
|
||||
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
|
||||
end
|
||||
end
|
||||
local function ask()
|
||||
M.confirm_server(self, c, act, params, opener(self, b, id))
|
||||
end
|
||||
local function register()
|
||||
if self.autoplay then
|
||||
self.autoplay:register(act, params, confirmed)
|
||||
self.autoplay:register(id, params, ask)
|
||||
end
|
||||
end
|
||||
register()
|
||||
blocks.note(register) -- (a kept block registers again)
|
||||
end
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- inline confirm
|
||||
|
||||
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
|
||||
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
|
||||
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
|
||||
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
|
||||
-- left of it, so a small move left confirms, and the server's question takes the room of
|
||||
-- what was left of it. Nothing else moves. Confirm takes the keyboard focus (soft, like
|
||||
-- the dialog's OK: Enter confirms). It cancels on Esc, a click anywhere else, ASK_IDLE
|
||||
-- seconds without a key or button press, navigating, and when what it asks about changes (its words or
|
||||
-- params: core/main and session call M.cancel_ask). State: self.ask = { id, act, params,
|
||||
-- key, t, nodes }.
|
||||
M.ASK_IDLE = 6
|
||||
|
||||
local function ask_key(c, params)
|
||||
local parts = { c and c.text or "", c and c.ok or "" }
|
||||
local ks = {}
|
||||
for k in pairs(params or {}) do
|
||||
ks[#ks + 1] = k
|
||||
end
|
||||
table.sort(ks)
|
||||
for _, k in ipairs(ks) do
|
||||
parts[#parts + 1] = k .. "=" .. tostring(params[k])
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function M.cancel_ask(self)
|
||||
if self.ask then
|
||||
self.ask = nil
|
||||
self.dirty = true
|
||||
end
|
||||
end
|
||||
|
||||
-- The ask in progress for the button `id`, when its words and params are still the same
|
||||
-- (else it is dropped: the state changed under it).
|
||||
function M.asking(self, id, c, params)
|
||||
local a = self.ask
|
||||
if not a or a.id ~= id then
|
||||
return nil
|
||||
end
|
||||
if a.key ~= ask_key(c, params) then
|
||||
self.ask = nil
|
||||
return nil
|
||||
end
|
||||
a.nodes = {}
|
||||
return a
|
||||
end
|
||||
|
||||
-- Every frame (core/main): the idle timeout.
|
||||
function M.ask_update(self)
|
||||
if self.ask and socket.gettime() - self.ask.t > M.ASK_IDLE then
|
||||
M.cancel_ask(self)
|
||||
end
|
||||
end
|
||||
|
||||
-- Input (core/main, before the page gets it): a key or button press keeps the ask alive
|
||||
-- (moves don't: a rebuild replays the last one); Esc and a press outside its buttons
|
||||
-- cancel it. Returns true when the input is used up (Esc).
|
||||
function M.ask_input(self, action_id, action)
|
||||
local a = self.ask
|
||||
if not a then
|
||||
return false
|
||||
end
|
||||
if action.pressed then
|
||||
a.t = socket.gettime()
|
||||
end
|
||||
if action_id == hash("key_esc") and action.pressed then
|
||||
M.cancel_ask(self)
|
||||
return true
|
||||
end
|
||||
if action_id == hash("touch") and action.pressed and action.x then
|
||||
for _, n in ipairs(a.nodes or {}) do
|
||||
if pcall(gui.get_id, n) and gui.pick_node(n, action.x, action.y) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
M.cancel_ask(self)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The button that asks (bound like confirm_server_button, test id `id`).
|
||||
function M.ask_button(self, b, c, act, params, id)
|
||||
id = id or ("confirm:" .. act)
|
||||
button.bind(self, b, nil, { id = id, on_click = function()
|
||||
if self.busy then
|
||||
return
|
||||
end
|
||||
self.ask = { id = id, act = act, params = params, key = ask_key(c, params), t = socket.gettime() }
|
||||
-- Confirm takes the focus (soft after a mouse click: Enter confirms, no ring).
|
||||
self.focus = self.focus or { index = 0 }
|
||||
self.focus.key = "page:" .. id .. ":ok"
|
||||
self.focus.soft = not self.focus.visible
|
||||
require("modules.sfx").play("open")
|
||||
self.dirty = true
|
||||
end })
|
||||
self.fx:button(id, b)
|
||||
end
|
||||
|
||||
-- The asking state's buttons, tree pieces for a row (made under parent): Confirm then
|
||||
-- Cancel (cancel_w: the asking button's width: Cancel is at least as wide, so set at the
|
||||
-- row's right end it covers the button's spot).
|
||||
function M.ask_buttons(self, parent, c, cancel_w, opts)
|
||||
local a = self.ask
|
||||
opts = opts or {}
|
||||
local label, price = (c.ok or "Confirm"), nil
|
||||
local head, num = label:match("^(.-)%s*₥%s*([%d,%.]+)$")
|
||||
if head then
|
||||
label = head:gsub("%s+for$", "")
|
||||
price = tonumber((num:gsub(",", "")))
|
||||
end
|
||||
local small = opts.small ~= false
|
||||
local bopts = { small = small, variant = "danger", price = price, fill = { "danger", 0.32 }, edge = { "danger", 1 } }
|
||||
local ok = fg.button(parent, label, bopts, function(b, nodes)
|
||||
a.nodes[#a.nodes + 1] = b
|
||||
button.bind(self, b, nodes, { id = a.id .. ":ok", on_click = function()
|
||||
if self.ask ~= a then
|
||||
return
|
||||
end
|
||||
self.ask = nil
|
||||
-- (the effects start where the asking button was, like the dialog's opener)
|
||||
self.fx:origin(b, a.id)
|
||||
require("modules.sfx").play("confirm")
|
||||
self.h.act(self, a.act, a.params or {}) -- (core/session, through the H table: no require cycle)
|
||||
self.dirty = true
|
||||
end })
|
||||
self.fx:button(a.id, b)
|
||||
end)
|
||||
local cancel = fg.button(parent, "Cancel", { small = small }, function(b, nodes)
|
||||
a.nodes[#a.nodes + 1] = b
|
||||
button.bind(self, b, nodes, { id = a.id .. ":cancel", on_click = function()
|
||||
require("modules.sfx").play("close")
|
||||
M.cancel_ask(self)
|
||||
end })
|
||||
end, { width = math.max(cancel_w, fg.button_w(parent, "Cancel", { small = small })) })
|
||||
return ok, cancel
|
||||
end
|
||||
|
||||
-- The server's question, compact: at most `lines` lines (default 2) in what room is left.
|
||||
function M.ask_text(c, parent, opts)
|
||||
opts = opts or {}
|
||||
local t = fg.para(parent, c.text or "", { font = "sans", px = 12, color = "text_2" }, 15,
|
||||
{ lines = opts.lines or 2, grow = 1, shrink = 1, basis = 0 })
|
||||
t.done = function(n) tooltip.add(n, c.text) end -- (the whole question, when it's cut)
|
||||
return t
|
||||
end
|
||||
|
||||
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
|
||||
|
||||
@@ -168,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
|
||||
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
|
||||
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
|
||||
gui.set_enabled(cross, false)
|
||||
local rect
|
||||
local shown = -1
|
||||
local function move(action)
|
||||
rect = rect or screen_rect(box, cw, ch)
|
||||
-- Measured on every move: scrolling or the entrance slide moves the box.
|
||||
local rect = screen_rect(box, cw, ch)
|
||||
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
|
||||
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
|
||||
if not inside then
|
||||
|
||||
@@ -111,10 +111,13 @@ end
|
||||
-- registered for name in modules/predict) the result shows at once and the button can be
|
||||
-- clicked again right away (actions queue in order); without one the button shows a
|
||||
-- spinner and ignores clicks until the server answers.
|
||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. Returns the
|
||||
-- click function (a text field's Enter runs it too).
|
||||
function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
||||
local key = button_key(name, params)
|
||||
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
|
||||
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
|
||||
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
|
||||
-- busy key, so each such button has its own (else they all share the action's name).
|
||||
-- Returns the click function (a text field's Enter runs it too).
|
||||
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
|
||||
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
|
||||
if g.inflight[key] then
|
||||
button.set_busy(b)
|
||||
end
|
||||
@@ -130,7 +133,6 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
||||
end
|
||||
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a
|
||||
-- click anywhere on it counts from the first frame.
|
||||
local hit
|
||||
local r = name == "claim" and ready_at(g)[key]
|
||||
if r and not anim.reduced and socket.gettime() - r.t < POP then
|
||||
local p, size = gui.get_position(b), gui.get_size(b)
|
||||
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
|
||||
if g.autoplay then
|
||||
local p = type(params) == "function" and params() or params
|
||||
g.autoplay:register(name, p, fire)
|
||||
blocks.note(function() -- (a kept block registers again)
|
||||
blocks.note(function() -- (a kept block registers again; params made now)
|
||||
if g.autoplay then
|
||||
g.autoplay:register(name, p, fire)
|
||||
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
-- Druid instance and every layout node first. What must survive a rebuild lives on g
|
||||
-- (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects g.fx and
|
||||
-- toasts g.toasts on their own layers). Regions are laid out here: banner and header
|
||||
-- across the top, then the moon list, the main panel (the current section) and the aside.
|
||||
-- across the top, then the moon list, the main panel (the current section), the right
|
||||
-- side panel (sidepanel.lua: the leaderboard; folded away when there's no room) and the
|
||||
-- aside.
|
||||
|
||||
local druid = require("druid.druid")
|
||||
local theme = require("modules.theme")
|
||||
@@ -36,11 +38,14 @@ local banner = require("screens.game.banner")
|
||||
local header = require("screens.game.header")
|
||||
local moonlist = require("screens.game.moonlist")
|
||||
local aside = require("screens.game.aside")
|
||||
local sidepanel = require("screens.game.sidepanel")
|
||||
local cards = require("screens.game.cards")
|
||||
local reveal = require("screens.game.reveal")
|
||||
local moon_view = require("modules.moon.moon")
|
||||
local blocks = require("modules.blocks")
|
||||
local sfx = require("modules.sfx")
|
||||
local ready = require("screens.game.core.ready")
|
||||
local admin = require("screens.game.admin")
|
||||
|
||||
local S = theme.size
|
||||
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
|
||||
@@ -57,7 +62,6 @@ M.SECTIONS = {
|
||||
moons = require("screens.game.moons"),
|
||||
market = require("screens.game.market"),
|
||||
refinery = require("screens.game.refinery"),
|
||||
leaderboard = require("screens.game.leaderboard"),
|
||||
help = require("screens.game.help"),
|
||||
equipment = require("screens.game.equipment"),
|
||||
store = require("screens.game.store"),
|
||||
@@ -84,14 +88,16 @@ end
|
||||
|
||||
---------------------------------------------------------------- build
|
||||
|
||||
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when
|
||||
-- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
|
||||
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
|
||||
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
|
||||
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
|
||||
-- the first state and after the reveal closes (g.enter_all).
|
||||
function enter(g)
|
||||
local v = g.view
|
||||
local main_key = v.section .. "|" .. (v.moon or "")
|
||||
-- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
|
||||
-- Claim all finishing one, mustn't replay the section's entrance)
|
||||
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
|
||||
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
|
||||
local all = g.enter_all or not g.entered
|
||||
if g.reveal:hiding() then
|
||||
@@ -139,12 +145,9 @@ end
|
||||
-- something for the viewport, views.go Scene.Select): a moon (s=moons with a moon), the
|
||||
-- new-moon teaser, the drill panel (Equipment with a drill) or an advisor's panel. Other
|
||||
-- views (Market, Store, Refinery, Leaderboard, Help, Equipment, Advisors, the first-moon
|
||||
-- pitch) give the main panel the whole width. The reveal keeps it (it plays in the viewport).
|
||||
-- pitch) give the main panel the whole width. (The reveal plays in its own window, reveal.lua.)
|
||||
function M.aside_shown(g)
|
||||
local st, section = g.state, g.view.section
|
||||
if g.reveal:active() then
|
||||
return true
|
||||
end
|
||||
-- An advisor not hired yet, picked on its card (the client's own preview).
|
||||
if section == "advisors" and g.advisor_pick and st.advisors then
|
||||
for _, a in ipairs(st.advisors.advisors or {}) do
|
||||
@@ -182,16 +185,18 @@ end
|
||||
-- its block is kept while these stay the same (modules/blocks). The aside shows the
|
||||
-- section's own aside or the moon's well panel.
|
||||
local ALWAYS = { "rules", "unlocked", "catalogVersion", "bonuses", "liveEvents", "showNav" }
|
||||
-- The state keys each section shows (its block's signature): a section that reads a new
|
||||
-- state key must add it here, or it won't update. ASIDE_KEYS and the header's and moon
|
||||
-- list's sigs work the same way.
|
||||
local SECTION_KEYS = {
|
||||
moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" },
|
||||
market = { "market" },
|
||||
market = { "market", "leaderboard" },
|
||||
refinery = { "refinery", "jobs", "refined", "credits", "resources" },
|
||||
leaderboard = { "leaderboard" },
|
||||
help = { "help" },
|
||||
equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" },
|
||||
store = { "store", "credits", "moonPrice", "resources", "refined" },
|
||||
advisors = { "advisors", "credits" },
|
||||
["new-moon"] = { "newMoon", "moonPrice", "credits" },
|
||||
["new-moon"] = { "newMoon", "moonPrice", "moonGrade", "credits" },
|
||||
}
|
||||
local ASIDE_KEYS = { "moon", "moons", "past", "tools", "well", "stakes", "equipment", "advisors", "newMoon", "moonPrice", "credits", "refined" }
|
||||
|
||||
@@ -211,11 +216,13 @@ local function pick_keys(st, ...)
|
||||
end
|
||||
|
||||
-- Drills and batches whose end passed on our clock (core/ready counts them before the
|
||||
-- refetch): the counts they show change with it.
|
||||
local function ready_key(g)
|
||||
-- refetch): the counts they show change with it. kinds: the state lists to look at
|
||||
-- (default both).
|
||||
local function ready_key(g, kinds)
|
||||
local st, now = g.state, session.now(g)
|
||||
local out = {}
|
||||
for _, list in ipairs({ st.stakes or {}, st.jobs or {} }) do
|
||||
for _, k in ipairs(kinds or { "stakes", "jobs" }) do
|
||||
local list = st[k] or {}
|
||||
for _, x in ipairs(list) do
|
||||
if not x.ready and x["end"] and x["end"] <= now then
|
||||
out[#out + 1] = tostring(x.id or x.toolId) .. "@" .. x["end"]
|
||||
@@ -227,10 +234,15 @@ end
|
||||
|
||||
-- What every region depends on besides the state: busy buttons, Claims that turned ready
|
||||
-- on our clock, the toggles.
|
||||
local function local_sig(g)
|
||||
return blocks.sig(g.inflight, g.ready_at, ready_key(g), sfx.muted(), anim.reduced, theme.name)
|
||||
local function local_sig(g, kinds)
|
||||
return blocks.sig(g.inflight, g.ready_at, ready_key(g, kinds), sfx.muted(), anim.reduced, theme.name, g.ask and g.ask.id or false)
|
||||
end
|
||||
|
||||
-- The ready lists each section shows: a drill turning ready doesn't rebuild the Refinery
|
||||
-- (a 60-90 ms build, twice per drill: on our clock, then at the refetch), nor a batch the
|
||||
-- Moons. Sections not listed show neither.
|
||||
local MAIN_READY = { moons = { "stakes" }, equipment = { "stakes" }, refinery = { "jobs" } }
|
||||
|
||||
-- The reveal hides the main panel, the aside's content and the new moon's row.
|
||||
local function reveal_sig(g)
|
||||
local r = g.reveal
|
||||
@@ -238,10 +250,11 @@ local function reveal_sig(g)
|
||||
end
|
||||
|
||||
-- The section's block in the main panel.
|
||||
local function build_main(g, parent, x, y, w, h, common)
|
||||
local function build_main(g, parent, x, y, w, h)
|
||||
local st = g.state
|
||||
local section = g.view.section
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.view, g.advisor_pick, g.refine_qty, g.sale_open,
|
||||
local common = local_sig(g, MAIN_READY[section] or {})
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
|
||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
|
||||
blocks.keep(g, "main", sig, parent, function(holder)
|
||||
for _, m in pairs(M.SECTIONS) do
|
||||
@@ -270,14 +283,25 @@ local function build_main(g, parent, x, y, w, h, common)
|
||||
end
|
||||
|
||||
-- The aside's block (narrow: the row under the panels).
|
||||
local function build_aside(g, parent, x, y, w, h, row, common)
|
||||
local function build_aside(g, parent, x, y, w, h, row, common, still)
|
||||
local st = g.state
|
||||
local section = g.view.section
|
||||
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
return blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
|
||||
-- their own, below)
|
||||
local moon_only = section == "moons" and st.moon ~= nil
|
||||
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
|
||||
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
|
||||
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
|
||||
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
|
||||
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
|
||||
end)
|
||||
if facts then
|
||||
-- The moon's facts: a block of their own inside the panel (aside.facts).
|
||||
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
|
||||
aside.facts(g, holder, facts, g.h)
|
||||
end)
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- A full rebuild drops every node and component and builds them all again; otherwise the
|
||||
@@ -347,11 +371,15 @@ function M.build(g)
|
||||
g.layout_state = true
|
||||
local st = g.state
|
||||
local common = local_sig(g)
|
||||
-- (without the drills and batches that ended on our clock: the banner, header and moon
|
||||
-- list show those counts through their own signatures, and a drill ending mustn't
|
||||
-- rebuild them)
|
||||
local still = local_sig(g, {})
|
||||
-- Announcements and live events above the header.
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
|
||||
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
|
||||
return banner.build(g, g.h, holder, W)
|
||||
end)
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
|
||||
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
|
||||
g.tab_nodes = {}
|
||||
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
|
||||
@@ -362,6 +390,25 @@ function M.build(g)
|
||||
local pad, gap = S.body_pad, S.gap
|
||||
local top = header_h + pad
|
||||
local body_h = H - top - pad
|
||||
if g.admin then
|
||||
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
|
||||
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
|
||||
g.toasts:detach()
|
||||
g.moon_nodes, g.well_nodes = {}, {}
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
g.page_key, g.page_main, g.side_w = nil, nil, 0
|
||||
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
|
||||
admin.build(g, holder, pad, top, W - pad * 2, body_h)
|
||||
end)
|
||||
blocks.finish(g)
|
||||
g.tween:finish()
|
||||
ticks.run(g)
|
||||
g.dirty = false
|
||||
g.fx:after_build(g)
|
||||
g.reveal:after_build()
|
||||
g.rehover = true
|
||||
return
|
||||
end
|
||||
local has_aside = M.aside_shown(g)
|
||||
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
|
||||
-- under the moon list and the main panel, the viewport and the side panel side by
|
||||
@@ -377,6 +424,13 @@ function M.build(g)
|
||||
end
|
||||
local main_x = pad + (list_w > 0 and (list_w + gap) or 0)
|
||||
local main_w = W - pad - main_x - ((has_aside and not narrow) and (aside_w + gap) or 0)
|
||||
-- The right side panel takes its width from the main panel's, or folds away.
|
||||
-- (the side panel is the Market's: Top miners beside it)
|
||||
local side_w = sidepanel.width(g, W, main_w, narrow or has_aside or g.view.section ~= "market")
|
||||
g.side_w = side_w
|
||||
if side_w > 0 then
|
||||
main_w = main_w - side_w - gap
|
||||
end
|
||||
local parent, y0, page = g.layout, top, nil
|
||||
local row_h
|
||||
if has_aside and narrow then
|
||||
@@ -399,48 +453,61 @@ function M.build(g)
|
||||
for _, m in ipairs(st.moons) do
|
||||
hides[#hides + 1] = g.reveal:hides(m.id) or false
|
||||
end
|
||||
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, hides), parent, function(holder)
|
||||
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
|
||||
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
|
||||
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
|
||||
g.moon_nodes = {}
|
||||
moonlist.teardown(g)
|
||||
moonlist.build(g, g.h, holder, pad, y0, list_w, body_h)
|
||||
end)
|
||||
else
|
||||
g.moon_nodes = {}
|
||||
g.toasts:detach() -- (no moon list: toasts float)
|
||||
end
|
||||
build_main(g, parent, main_x, y0, main_w, body_h)
|
||||
if side_w > 0 then
|
||||
sidepanel.build(g, parent, main_x + main_w + gap, y0, side_w, body_h, common)
|
||||
if sidepanel.missing(g) then
|
||||
if not g.side_asked then
|
||||
g.side_asked = true
|
||||
session.fetch(g, g.nav_view or g.view)
|
||||
end
|
||||
else
|
||||
g.side_asked = false
|
||||
end
|
||||
end
|
||||
build_main(g, parent, main_x, y0, main_w, body_h, common)
|
||||
if page then
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
|
||||
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
|
||||
local s = page:finish(y0 + body_h + gap + row_h + pad)
|
||||
if g.viewport_node then
|
||||
if g.viewport_node and not g.reveal:active() then
|
||||
moon_view.move(g.viewport_node) -- (where the page's offset put it)
|
||||
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
||||
end
|
||||
-- Picking a well, a drill or an advisor (or a reveal) brings the aside into view.
|
||||
-- Picking a drill or an advisor brings the aside into view (not a well: its card,
|
||||
-- where its actions are, stays in view; the aside only shows its facts).
|
||||
local v = g.view
|
||||
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick), tostring(g.reveal:active()) }, "|")
|
||||
local want = g.reveal:active() or (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
|
||||
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
|
||||
local want = (v.drill or "") ~= "" or v.section == "advisors"
|
||||
if s and want and key ~= g.page_key then
|
||||
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
|
||||
end
|
||||
g.page_key = key
|
||||
if s then
|
||||
-- The viewport moves with the page: the 3D moon follows it (and a reveal's dim).
|
||||
-- The viewport moves with the page: the 3D moon follows it.
|
||||
s.on_scroll:subscribe(function()
|
||||
if g.viewport_node and not g.tearing_down then
|
||||
if g.viewport_node and not g.tearing_down and not g.reveal:active() then
|
||||
moon_view.move(g.viewport_node)
|
||||
g.viewport_rect = g.fx:rect_of(g.viewport_node)
|
||||
if g.reveal:active() then
|
||||
g.reveal:place()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
elseif has_aside then
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
|
||||
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
|
||||
else
|
||||
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
|
||||
moon_view.hide()
|
||||
if not g.reveal:active() then -- (the reveal's window shows the moon)
|
||||
moon_view.hide()
|
||||
end
|
||||
end
|
||||
if not page then
|
||||
g.page_key, g.page_main = nil, nil
|
||||
@@ -551,6 +618,10 @@ function input(g, action_id, action)
|
||||
if action_id == TOUCH and action.released then
|
||||
button.release() -- (carried only while the mouse is down; after this Druid clicks)
|
||||
end
|
||||
-- A sell asking in place (cards.ask_button): Esc or a press elsewhere cancels it.
|
||||
if not g.dialog:is_open() and cards.ask_input(g, action_id, action) then
|
||||
return true
|
||||
end
|
||||
-- Tab focus, Enter/Space on the focused element, back/forward (core/keys).
|
||||
if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
|
||||
return true
|
||||
@@ -614,6 +685,9 @@ function M.update(g, dt)
|
||||
g.fx:update(dt)
|
||||
g.reveal:update(dt)
|
||||
g.dialog:update(dt)
|
||||
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
|
||||
moon_view.covered = g.dialog:is_open()
|
||||
cards.ask_update(g)
|
||||
check_input(g)
|
||||
tooltip.update(g.dialog:is_open())
|
||||
focus.update(g, g.focus_layer)
|
||||
@@ -622,6 +696,9 @@ function M.update(g, dt)
|
||||
m.update(g, dt)
|
||||
end
|
||||
end)
|
||||
if g.admin then
|
||||
admin.update(g, dt)
|
||||
end
|
||||
g.druid:update(dt)
|
||||
if g.rehover then
|
||||
-- After a rebuild: what is under the mouse shows its hover at once (the new
|
||||
|
||||
@@ -33,6 +33,25 @@ function M.drills(g, moon_id)
|
||||
return (st.moon and st.moon.id == moon_id and st.moon.ready or 0) + n
|
||||
end
|
||||
|
||||
-- A batch shown as finished: ready in the state or its end passed on our clock.
|
||||
function M.batch_done(g, j)
|
||||
return j.ready or crossed(j, session.now(g))
|
||||
end
|
||||
|
||||
-- Seconds since batch j finished (0 when it just did and the watch below hasn't seen it
|
||||
-- yet), or nil when it isn't finished or already was when first seen: the effects that
|
||||
-- follow a batch's end through rebuilds go by it.
|
||||
function M.batch_age(g, j)
|
||||
if not g.batches_done or not M.batch_done(g, j) then
|
||||
return nil
|
||||
end
|
||||
local t = g.batches_done[j.id]
|
||||
if t == nil then
|
||||
return 0
|
||||
end
|
||||
return t > 0 and socket.gettime() - t or nil
|
||||
end
|
||||
|
||||
-- Finished refinery batches (the Refinery tab's badge).
|
||||
function M.batches(g)
|
||||
local now = session.now(g)
|
||||
@@ -92,7 +111,37 @@ function M.watch(g)
|
||||
g.ready_due = M.next_due(g)
|
||||
end
|
||||
|
||||
-- Batches that finished since the last look (ready in the state, or their end passed on
|
||||
-- our clock), whichever section is open: each one's moment goes in g.batches_done[id]
|
||||
-- (seconds; 0 for those already finished when first seen, which get no effect), for
|
||||
-- effects that carry on through rebuilds (refinery.lua's slot, header.lua's badge), and
|
||||
-- fx:batch_ready plays once for them (sound, the slot or the Refinery tab).
|
||||
local function watch_batches(g)
|
||||
local st = g.state
|
||||
if not st or not st.jobs then
|
||||
return
|
||||
end
|
||||
local now, t = session.now(g), socket.gettime()
|
||||
local seen, out, fresh = g.batches_done, {}, nil
|
||||
for _, j in ipairs(st.jobs) do
|
||||
if j.id and (j.ready or crossed(j, now)) then
|
||||
out[j.id] = seen and (seen[j.id] or t) or 0
|
||||
if seen and not seen[j.id] then
|
||||
fresh = fresh or {}
|
||||
fresh[#fresh + 1] = j
|
||||
end
|
||||
end
|
||||
end
|
||||
g.batches_done = out
|
||||
if fresh then
|
||||
g.batch_ready_t = t
|
||||
g.dirty = true
|
||||
g.fx:batch_ready(g, fresh)
|
||||
end
|
||||
end
|
||||
|
||||
function M.update(g)
|
||||
watch_batches(g)
|
||||
if g.ready_due and g.state and session.now(g) >= g.ready_due then
|
||||
g.ready_due = nil
|
||||
g.dirty = true
|
||||
|
||||
@@ -14,6 +14,8 @@ local api = require("modules.api")
|
||||
local predict = require("modules.predict")
|
||||
local sfx = require("modules.sfx")
|
||||
local refinery = require("screens.game.refinery")
|
||||
local sidepanel = require("screens.game.sidepanel")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -61,8 +63,20 @@ end
|
||||
|
||||
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like
|
||||
-- state.view). page only for the Past list past its first page (the web's data-page).
|
||||
function M.view_params(v, extra)
|
||||
local p = { s = v.section ~= "" and v.section or nil }
|
||||
-- g: the blocks the right side panel shows are asked for too (sidepanel.includes), on
|
||||
-- every request while it shows, so they never drop out of the state.
|
||||
function M.view_params(v, extra, g)
|
||||
-- (the leaderboard is a side panel, or a pane in the market when that is folded away:
|
||||
-- an old link to it opens the market, which asks for its block)
|
||||
local section = v.section == "leaderboard" and "market" or v.section
|
||||
local p = { s = section ~= "" and section or nil }
|
||||
-- (new-moon always: "+ New moon" in the moon list shows each grade's price)
|
||||
p.include = section == "market" and "leaderboard,new-moon" or "new-moon"
|
||||
for _, x in ipairs(g and sidepanel.includes(g) or {}) do
|
||||
if not (","..p.include..","):find(","..x..",", 1, true) then
|
||||
p.include = p.include .. "," .. x
|
||||
end
|
||||
end
|
||||
if v.moon and v.moon ~= "" then p.moon = v.moon end
|
||||
if v.well and v.well ~= "" then p.well = v.well end
|
||||
if v.past then p.list = "past" end
|
||||
@@ -71,7 +85,11 @@ function M.view_params(v, extra)
|
||||
if v.drill and v.drill ~= "" then p.drill = v.drill end
|
||||
if v.adv and v.adv ~= "" then p.adv = v.adv end
|
||||
for k, x in pairs(extra or {}) do
|
||||
p[k] = x
|
||||
if k == "include" and not (","..p.include..","):find(","..x..",", 1, true) then
|
||||
p.include = p.include .. "," .. x
|
||||
elseif k ~= "include" then
|
||||
p[k] = x
|
||||
end
|
||||
end
|
||||
return p
|
||||
end
|
||||
@@ -111,6 +129,9 @@ local function suspend(g)
|
||||
g.refetch = nil
|
||||
end
|
||||
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
|
||||
if g.history then
|
||||
g.history.want = nil -- (its request was in the outbox)
|
||||
end
|
||||
g.boot = { text = SUSPENDED }
|
||||
if g.dialog and g.dialog:is_open() then
|
||||
g.dialog:close("cancel")
|
||||
@@ -118,24 +139,49 @@ local function suspend(g)
|
||||
g.dirty = true
|
||||
end
|
||||
|
||||
-- Refetch when the next drill or refinery batch is due (the server decides what is ready).
|
||||
-- Refetch when the next drill or refinery batch is due (the server decides what is ready),
|
||||
-- or a banner item ends (the server's state no longer has it).
|
||||
local function arm_refetch(g)
|
||||
if g.refetch then
|
||||
timer.cancel(g.refetch)
|
||||
g.refetch = nil
|
||||
end
|
||||
local due = g.state.nextEnd
|
||||
local batch = refinery.next_end(g.state)
|
||||
if batch and (not due or batch < due) then
|
||||
due = batch
|
||||
-- (a time already past is skipped: the state we have is after it)
|
||||
local now = M.now(g)
|
||||
local due
|
||||
local function consider(t)
|
||||
if t and t > 0 and t - now >= -2000 and (not due or t < due) then
|
||||
due = t
|
||||
end
|
||||
end
|
||||
consider(g.state.nextEnd)
|
||||
consider(refinery.next_end(g.state))
|
||||
for _, b in ipairs(g.state.banner or {}) do
|
||||
consider(b["end"])
|
||||
end
|
||||
if not due then
|
||||
return
|
||||
end
|
||||
local left = due - M.now(g)
|
||||
if left < -2000 then
|
||||
return -- already past: the state we have is after it
|
||||
-- Ends within a second after it wait for the last of them: one refetch and rebuild,
|
||||
-- not one each (four batches started together end a few ms apart).
|
||||
local first = due
|
||||
local function join(t)
|
||||
if t and t > due and t <= first + 1000 then
|
||||
due = t
|
||||
end
|
||||
end
|
||||
join(g.state.nextEnd)
|
||||
for _, j in ipairs(g.state.jobs or {}) do
|
||||
if not j.ready then
|
||||
join(j["end"])
|
||||
end
|
||||
end
|
||||
for _, s in ipairs(g.state.stakes or {}) do
|
||||
if not s.ready then
|
||||
join(s["end"])
|
||||
end
|
||||
end
|
||||
local left = due - now
|
||||
g.refetch = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
|
||||
g.refetch = nil
|
||||
-- (a navigation still on its way: its view, or this answer would take it back)
|
||||
@@ -145,7 +191,8 @@ end
|
||||
|
||||
-- What we show: the last server state (g.base) with the pending predictions on top.
|
||||
local function show(g)
|
||||
g.state = predict.apply(g.base, g.pending)
|
||||
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
|
||||
g.state = blocks.freeze(predict.apply(g.base, g.pending))
|
||||
g.view = g.state.view
|
||||
arm_refetch(g)
|
||||
g.dirty = true
|
||||
@@ -173,7 +220,9 @@ end
|
||||
|
||||
-- The views shown, like the web's browser history (main.js syncURL: every response whose
|
||||
-- view differs from the current entry pushes one; back and forward fetch an entry again
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current }.
|
||||
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
|
||||
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
|
||||
-- to requests sent before it (an action, a refetch) push nothing.
|
||||
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
|
||||
|
||||
local function view_key(v)
|
||||
@@ -200,7 +249,14 @@ local function remember(g, from_history)
|
||||
if from_history then
|
||||
if h.list[from_history] then
|
||||
h.list[from_history] = v
|
||||
h.i = from_history
|
||||
end
|
||||
if h.want == from_history then
|
||||
h.want = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
if h.want then
|
||||
return
|
||||
end
|
||||
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
|
||||
@@ -248,6 +304,10 @@ pump = function(g)
|
||||
elseif g.base then
|
||||
show(g) -- rolled back
|
||||
end
|
||||
-- (a back/forward that failed: the history stays where it was)
|
||||
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
|
||||
history(g).want = nil
|
||||
end
|
||||
if status == 403 then
|
||||
suspend(g)
|
||||
g.inflight, g.busy, g.nav_view = {}, false, nil
|
||||
@@ -265,17 +325,19 @@ pump = function(g)
|
||||
-- the click; its answer only corrects the numbers (or rolls them back, with the
|
||||
-- error's).
|
||||
-- A new game is a fresh start (the web reloads the page): no toast, no wallet
|
||||
-- differences, the page enters again.
|
||||
local fresh = applied and r.kind == "new-game" and data.ok
|
||||
-- differences, the page enters again. So is a restore from a save code (another
|
||||
-- player: api.lua stored its id from the response), with its toast.
|
||||
local fresh = applied and (r.kind == "new-game" or r.kind == "restore") and data.ok
|
||||
if fresh then
|
||||
g.fx.counters = {}
|
||||
g.batches_done = nil -- (another player's batches: no ready effects for them)
|
||||
g.fx:on_response(g, "get", data, nil, g.state, {})
|
||||
g.enter_all = true
|
||||
elseif g.state and g.state ~= prev then
|
||||
local d = type(data) == "table" and data or { toast = { error = true } }
|
||||
g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
|
||||
end
|
||||
if revealing or fresh then
|
||||
if revealing or (fresh and r.kind == "new-game") then
|
||||
-- (toast deferred, or none)
|
||||
elseif type(data) == "table" and type(data.toast) == "table" then
|
||||
local color = g.fx:take_claim_color()
|
||||
@@ -287,6 +349,11 @@ pump = function(g)
|
||||
sfx.play("error") -- (a rolled-back prediction had fx's error effects)
|
||||
end
|
||||
end
|
||||
-- A one-time announcement besides the toast: where the save code is (the server's
|
||||
-- words name no menu).
|
||||
if applied and type(data.notice) == "table" and data.notice.kind == "save-code" then
|
||||
g.toasts:push("Your progress is kept on this device only. Make a save code in the settings (the gear, top right) to play it on another device.", "save-code", false)
|
||||
end
|
||||
-- Toast a failing server once, not every retry (M.fetch tries again).
|
||||
g.offline = status == 0 or status >= 500
|
||||
for k, v in pairs(g.inflight) do
|
||||
@@ -314,7 +381,7 @@ end
|
||||
-- 5xx) it tries again: at start as g.boot says (and a 429 too), in play every 3 s,
|
||||
-- doubling up to 30 s, until it answers; the shown state stays meanwhile.
|
||||
function M.fetch(g, view, nav)
|
||||
request(g, "get", M.view_params(view or {}), function(err, data, status)
|
||||
request(g, "get", M.view_params(view or {}, nil, g), function(err, data, status)
|
||||
if g.retry then
|
||||
timer.cancel(g.retry)
|
||||
g.retry = nil
|
||||
@@ -354,6 +421,10 @@ function M.navigate(g, v)
|
||||
sfx.play("nav")
|
||||
end
|
||||
g.nav_view = v
|
||||
g.ask = nil -- (a sell asking in place: cards.ask_button)
|
||||
if g.admin then
|
||||
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
|
||||
end
|
||||
M.fetch(g, v, v)
|
||||
end
|
||||
|
||||
@@ -361,12 +432,13 @@ end
|
||||
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
|
||||
function M.go(g, dir)
|
||||
local h = history(g)
|
||||
local v = h.list[h.i + dir]
|
||||
local i = (h.want or h.i) + dir
|
||||
local v = h.list[i]
|
||||
if not v then
|
||||
return false
|
||||
end
|
||||
h.i = h.i + dir
|
||||
local to = { from_history = h.i }
|
||||
h.want = i
|
||||
local to = { from_history = i }
|
||||
for k, x in pairs(v) do
|
||||
to[k] = x
|
||||
end
|
||||
@@ -380,7 +452,7 @@ end
|
||||
function M.act(g, name, params, on_done, fn)
|
||||
g.seq = g.seq + 1
|
||||
local seq = g.seq
|
||||
local p = M.view_params(g.nav_view or g.view, params)
|
||||
local p = M.view_params(g.nav_view or g.view, params, g)
|
||||
p.clientSeq = seq
|
||||
fn = fn or predict.registry[name]
|
||||
local predicted = false
|
||||
@@ -390,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
|
||||
if predict.run(st, e) then
|
||||
g.pending[#g.pending + 1] = e
|
||||
local prev = g.state
|
||||
g.state = st
|
||||
g.state = blocks.freeze(st)
|
||||
g.dirty = true
|
||||
predicted = true
|
||||
-- The action's effects play now, from the predicted change (fx.lua).
|
||||
|
||||
+573
-335
File diff suppressed because it is too large.
Load diff
+112
-95
@@ -18,7 +18,8 @@ local S = theme.size
|
||||
local M = {}
|
||||
|
||||
-- Header sections in the web's order; each shows once state.unlocked has it.
|
||||
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
|
||||
-- (the leaderboard is the right side panel, sidepanel.lua; How to play is in the settings menu)
|
||||
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors" }
|
||||
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
@@ -37,8 +38,47 @@ local function contains(list, value)
|
||||
return false
|
||||
end
|
||||
|
||||
-- The wallet at the right end; returns the x where it starts.
|
||||
local function wallet(g, parent, W)
|
||||
-- The Refinery tab's badge pops in (fx-ready-pop, 0.6 s) when a batch has just finished
|
||||
-- (core/ready), by the clock, so the refetch's rebuild carries on. Reduced motion: none.
|
||||
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.25 }, { 1, 1 } }
|
||||
local function pop_badge(g, node, w)
|
||||
local age
|
||||
for _, j in ipairs(g.state.jobs or {}) do
|
||||
local a = ready.batch_age(g, j)
|
||||
if a and (not age or a < age) then
|
||||
age = a
|
||||
end
|
||||
end
|
||||
if anim.reduced or not age or age >= 0.6 then
|
||||
return
|
||||
end
|
||||
local p, base = gui.get_position(node), gui.get_scale(node)
|
||||
anim.tween(node, 0.6, function(k, n)
|
||||
anim.scale_about(n, p, base, w / 2, -7.5, anim.keys(POP_SCALE, k, anim.fn.ready_pop)) -- (about its center: 15 tall)
|
||||
end, -age)
|
||||
end
|
||||
|
||||
local GEAR = 32
|
||||
local CLAIM_W = 200 -- Claim all's place in the header (its widest label and badge)
|
||||
local CHIP_GAP = 18
|
||||
|
||||
-- The resource chips' widths (probes: nodes made and dropped), for the layout.
|
||||
local function chip_widths(g, parent)
|
||||
local out = {}
|
||||
for i, r in ipairs(g.state.resources) do
|
||||
local probe = ui.text(parent, ui.money(r.amount), "num", 0, 0)
|
||||
local tw = ui.text_width(probe)
|
||||
gui.set_text(probe, g.fx:text(r.name, r.amount))
|
||||
out[i] = 14 + math.max(tw, ui.text_width(probe))
|
||||
gui.delete_node(probe)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The wallet at the right end: the resource chips (unless chips is false: then they go on
|
||||
-- a line under the balance, see M.build), the credit balance, the settings gear. Returns
|
||||
-- the x where it starts.
|
||||
local function wallet(g, parent, W, chips_w, chips_below)
|
||||
local st = g.state
|
||||
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they
|
||||
-- count up and down as effects land) and are sized for the wider of what they show now
|
||||
@@ -58,95 +98,49 @@ local function wallet(g, parent, W)
|
||||
end
|
||||
table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } })
|
||||
end
|
||||
-- Settings (screens/game/settings.lua): a menu under the gear.
|
||||
local gear = ui.outline(parent, 0, 0, GEAR, GEAR, "line_hi")
|
||||
push(gear, GEAR, GEAR)
|
||||
local gicon = ui.rect(gear, 6, 6, 20, 20, "nav")
|
||||
gui.play_flipbook(gicon, "gear")
|
||||
test.register("settings", gear, "button", "settings")
|
||||
tooltip.add(gear, "Settings")
|
||||
g.druid:new_button(gear, function()
|
||||
local top = -gui.get_position(g.header_node).y + 16 + 36 / 2 + GEAR / 2 + 6
|
||||
require("screens.game.settings").open(g, S.header_pad_x, top)
|
||||
end):set_animations_disabled()
|
||||
|
||||
local bal_w = math.floor(num_w + 16 + 32 + 4)
|
||||
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36)
|
||||
push(bal_panel, bal_w, 36)
|
||||
push(bal_panel, bal_w, 36, 12)
|
||||
local bal_root = ui.part(bal, "panel", "root")
|
||||
ui.text(bal_root, theme.currency, "sym", 16, 18)
|
||||
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
|
||||
|
||||
local gap = 22
|
||||
-- Chips: in the row before the balance, or a row of their own under it (chips_below).
|
||||
local chip_row = chips_below and {} or row
|
||||
local gap = chips_below and nil or 22
|
||||
for i = #st.resources, 1, -1 do
|
||||
local r = st.resources[i]
|
||||
local root, nodes = ui.clone("chip", parent, 0, 0)
|
||||
local t = ui.part(nodes, "chip", "text")
|
||||
ui.style_text(t, "num", ui.money(r.amount))
|
||||
local tw = ui.text_width(t)
|
||||
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
|
||||
local w = 14 + math.max(tw, ui.text_width(t))
|
||||
local w = chips_w[i]
|
||||
gui.set_size(root, vec(w, 20))
|
||||
push(root, w, 20, gap)
|
||||
gap = 18
|
||||
if gap then
|
||||
table.insert(chip_row, 1, { style = { width = gap } })
|
||||
end
|
||||
table.insert(chip_row, 1, { node = root, size = false, style = { width = w, height = 20 } })
|
||||
gap = CHIP_GAP
|
||||
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
|
||||
g.fx:bind(r.name, t, root)
|
||||
tooltip.add(root, r.name)
|
||||
end
|
||||
-- Sound on/off (saved).
|
||||
local mute = ui.outline(parent, 0, 0, 28, 28, "line_hi")
|
||||
push(mute, 28, 28, gap == 18 and 4 or 8)
|
||||
local icon = ui.rect(mute, 4, 4, 20, 20, sfx.muted() and "muted" or "cyan")
|
||||
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
|
||||
test.register("mute", mute, "button", "mute")
|
||||
tooltip.add(mute, sfx.muted() and "Sound off" or "Sound on")
|
||||
g.druid:new_button(mute, function()
|
||||
sfx.toggle_mute()
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end):set_animations_disabled()
|
||||
-- Light / dark theme (saved); the icon shows the theme it switches to, like the web.
|
||||
local tgl = ui.outline(parent, 0, 0, 28, 28, "line_hi")
|
||||
push(tgl, 28, 28, 8)
|
||||
local ticon = ui.rect(tgl, 5, 5, 18, 18, "nav")
|
||||
gui.play_flipbook(ticon, theme.light and "moon" or "sun")
|
||||
test.register("theme", tgl, "button", "theme")
|
||||
tooltip.add(tgl, "Light / dark theme")
|
||||
g.druid:new_button(tgl, function()
|
||||
theme.toggle()
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end):set_animations_disabled()
|
||||
-- Reduced motion (saved): speed lines, lit while motion is on (the web follows the
|
||||
-- system's setting; here it's the player's).
|
||||
local mot = ui.outline(parent, 0, 0, 28, 28, "line_hi")
|
||||
push(mot, 28, 28, 8)
|
||||
local lit = anim.reduced and "muted" or "nav"
|
||||
for i, l in ipairs({ { 8, 9, 14 }, { 5, 13, 18 }, { 10, 17, 11 } }) do
|
||||
ui.rect(mot, l[1], l[2] + (i - 2) * 1, l[3], 2, lit)
|
||||
end
|
||||
if anim.reduced then
|
||||
local slash = ui.rect(mot, 14, 14, 20, 2, "muted")
|
||||
gui.set_pivot(slash, gui.PIVOT_CENTER)
|
||||
gui.set_position(slash, vec(14, -14))
|
||||
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
|
||||
end
|
||||
test.register("motion", mot, "button", "motion")
|
||||
tooltip.add(mot, anim.reduced and "Reduced motion: on" or "Reduced motion: off")
|
||||
g.druid:new_button(mot, function()
|
||||
anim.set_reduced_motion(not anim.reduced)
|
||||
sfx.play("toggle")
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end):set_animations_disabled()
|
||||
-- Display settings (UI scale, fullscreen: screens/game/settings.lua); a screen icon.
|
||||
local disp = ui.outline(parent, 0, 0, 28, 28, "line_hi")
|
||||
push(disp, 28, 28, 8)
|
||||
ui.outline(disp, 6, 7, 16, 11, "nav")
|
||||
ui.rect(disp, 11, 20, 6, 1, "nav")
|
||||
test.register("display", disp, "button", "display")
|
||||
tooltip.add(disp, "Display: UI scale, fullscreen")
|
||||
g.druid:new_button(disp, function()
|
||||
require("screens.game.settings").open(g)
|
||||
end):set_animations_disabled()
|
||||
if st.admin then
|
||||
-- Admins: the quick grant dialog (screens/game/grant.lua; G opens it too).
|
||||
local gr = ui.outline(parent, 0, 0, 28, 28, "line_hi")
|
||||
push(gr, 28, 28, 8)
|
||||
ui.text(gr, "+", { font = "mono", px = 18, color = "cyan" }, 14, 14, gui.PIVOT_CENTER)
|
||||
test.register("grant", gr, "button", "grant")
|
||||
tooltip.add(gr, "Grant (G): credits, resources, refined items")
|
||||
g.druid:new_button(gr, function()
|
||||
require("screens.game.grant").open(g)
|
||||
end):set_animations_disabled()
|
||||
end
|
||||
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
|
||||
flex.apply(tree)
|
||||
if chips_below then
|
||||
flex.apply(flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = chip_row }, 0, 16 + 36 + 8, W, 20))
|
||||
end
|
||||
return row[1].x - 10
|
||||
end
|
||||
|
||||
@@ -160,7 +154,6 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
local brand = fg.label(parent, "Moon Miners", "brand", 48)
|
||||
local cap = fg.label(parent, "Extraction command", "brand_cap", 16, { position = "absolute", top = 36, left = 0 })
|
||||
local brand_w = math.max(brand.measure(), cap.measure())
|
||||
local wallet_x = wallet(g, parent, W)
|
||||
|
||||
-- Sections (tabs), as they unlock (none until Equipment, like the web).
|
||||
local section = g.view.section
|
||||
@@ -177,9 +170,8 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
-- (counts move on as drills and batches finish on our clock: core/ready)
|
||||
local count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
|
||||
local badge = count > 0 and count or nil
|
||||
if badge then
|
||||
w = w + 26
|
||||
end
|
||||
-- The count is a small badge on the tab's top-right corner: it takes no width,
|
||||
-- so the tabs don't move when it comes or goes (clicker rule).
|
||||
names[#names + 1] = name
|
||||
nav_w = nav_w + w + 2
|
||||
tabs[#tabs + 1] = { style = { width = w, height = 44, shrink = 0 }, make = function(p, tx, ty)
|
||||
@@ -192,7 +184,7 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
gui.set_enabled(u, false) -- the ink draws the active tab's underline
|
||||
local active = name == section
|
||||
local shine = contains(st.shine, name)
|
||||
gui.set_position(t, vec(badge and (w - 26) / 2 or w / 2, -22))
|
||||
gui.set_position(t, vec(w / 2, -22))
|
||||
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
|
||||
if active then
|
||||
ink:to(tx, ty, w, 44)
|
||||
@@ -204,7 +196,11 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
g.fx:reticle(root, w, 44, name)
|
||||
end
|
||||
if badge then
|
||||
chip.new(root, badge, w - 34, 22, { color = "ready", font = "mono", px = 12, min_w = 22, pad = 0 })
|
||||
local cb, cw = chip.new(root, badge, w - 1, 9, { right = true, color = "ready", text = "bg", fill = 1,
|
||||
font = "mono_semi", px = 10, h = 15, min_w = 15, pad = 3 })
|
||||
if name == "refinery" then
|
||||
pop_badge(g, cb, cw)
|
||||
end
|
||||
end
|
||||
tabs_c.tab(g, root, t, {
|
||||
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. name, label = name,
|
||||
@@ -213,9 +209,6 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
H.navigate(g, { section = name, moon = g.view.moon })
|
||||
end,
|
||||
})
|
||||
if name == "help" then
|
||||
tooltip.add(root, "How to play")
|
||||
end
|
||||
g.tab_nodes[name] = root
|
||||
tabs_c.reveal(g, "nav", name, root, t, w, 44)
|
||||
end }
|
||||
@@ -227,13 +220,16 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
-- every state is seen (the moon list may not be shown yet).
|
||||
tabs_c.revealed(g, "newmoon", st.unlocked["new-moon"] and { "new-moon" } or {})
|
||||
|
||||
-- Claim all: every finished drill; waits with a countdown while none is ready.
|
||||
-- Claim all: every finished drill; waits with a countdown while none is ready. Its
|
||||
-- place is kept (CLAIM_W) once it's unlocked, shown or not, whatever its label, so the
|
||||
-- tabs never move for it.
|
||||
local n_ready = ready.drills(g)
|
||||
local claim_all = st.unlocked["claim-all"] and (n_ready > 0 or st.nextEnd)
|
||||
if claim_all then
|
||||
nav_w = nav_w + 170
|
||||
local reserve = st.unlocked["claim-all"]
|
||||
local claim_all = reserve and (n_ready > 0 or st.nextEnd)
|
||||
if reserve then
|
||||
nav_w = nav_w + 10 + CLAIM_W
|
||||
end
|
||||
local claim = claim_all and { style = {}, make = function(p, cx, cy)
|
||||
local claim = reserve and { style = { width = CLAIM_W, height = 44, shrink = 0 }, make = claim_all and function(p, cx, cy)
|
||||
if st.ready > 0 then
|
||||
local b, nodes = ui.button(p, "Claim all", cx, cy + 6, { primary = true, small = true })
|
||||
H.badge(b, nodes, n_ready)
|
||||
@@ -242,16 +238,37 @@ function M.build(g, parent, x, y, W, h, H)
|
||||
H.claim_wait(g, p, cx, cy + 6, st.nextEnd, { small = true, key = "wait:all", params = {},
|
||||
wait = "Claim all", done = "Claim all", badge = n_ready > 0 and n_ready or nil })
|
||||
end
|
||||
end } or nil
|
||||
end or nil } or nil
|
||||
local nav = row({ align = "start" }, { row({ gap = 2 }, tabs), claim and space(10) or nil, claim })
|
||||
|
||||
-- On the brand's row when it fits before the wallet, else on a row of its own.
|
||||
local fits = pad + brand_w + 28 + nav_w <= wallet_x
|
||||
local header_h = fits and S.header_h or S.header_h + 40
|
||||
-- One row: brand, tabs and Claim all's place, the wallet. When the wallet doesn't fit
|
||||
-- with its chips in the row, the chips go on a line under the balance (the header grows
|
||||
-- by it); then, if still short, the brand goes. Last resort (a very high UI scale):
|
||||
-- the tabs on a row of their own. None of it depends on Claim all being shown.
|
||||
local chips_w = chip_widths(g, parent)
|
||||
local chips_total = 22
|
||||
for _, cw in ipairs(chips_w) do
|
||||
chips_total = chips_total + cw + CHIP_GAP
|
||||
end
|
||||
local bal_probe = ui.text(parent, ui.money(math.max(st.credits, 0)), "balance", 0, 0)
|
||||
local wallet_min = math.floor(ui.text_width(bal_probe) + 52) + 12 + GEAR + pad + 10
|
||||
gui.delete_node(bal_probe)
|
||||
local left = pad + brand_w + 28 + nav_w
|
||||
local chips_below = left + chips_total + wallet_min > W
|
||||
local fits = left + wallet_min <= W
|
||||
local show_brand = fits or pad + nav_w + wallet_min > W -- (two rows: the brand stays)
|
||||
fits = fits or not show_brand
|
||||
if not show_brand then
|
||||
gui.delete_node(brand.node)
|
||||
gui.delete_node(cap.node)
|
||||
end
|
||||
wallet(g, parent, W, chips_w, chips_below)
|
||||
local base_h = chips_below and S.header_h + 16 or S.header_h
|
||||
local header_h = fits and base_h or base_h + 40
|
||||
local tree = col({ width = W, height = header_h }, {
|
||||
row({ height = fits and header_h or S.header_h - 8, padding_left = pad, align = "start" }, {
|
||||
box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }),
|
||||
fits and space(28) or nil,
|
||||
row({ height = fits and header_h or base_h - 8, padding_left = pad, align = "start" }, {
|
||||
show_brand and box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }) or nil,
|
||||
fits and show_brand and space(28) or nil,
|
||||
fits and col({ padding_top = 10 }, { nav }) or nil,
|
||||
}),
|
||||
not fits and row({ padding_left = pad - 12 }, { nav }) or nil,
|
||||
|
||||
@@ -1,81 +1,109 @@
|
||||
-- The Leaderboard section (state.leaderboard): the top miners by credits, and the
|
||||
-- player's own place under them when they're further down (`you`). Centered, capped; the
|
||||
-- rows scroll when the panel is too short for them (components/list).
|
||||
-- The leaderboard (state.leaderboard): the top miners by credits in compact rows, and the
|
||||
-- player's own row under them when they're further down (`you`). The rows scroll when the
|
||||
-- room is too short (components/list). Shown in the right side panel (sidepanel.lua; this
|
||||
-- module is one of its panels), or, when that panel is folded away (narrow windows), as a
|
||||
-- pane under the Market (market.lua, M.pane).
|
||||
|
||||
local theme = require("modules.theme")
|
||||
local ui = require("modules.ui")
|
||||
local list = require("components.list")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
|
||||
local S = theme.size
|
||||
local M = {}
|
||||
|
||||
local ROW_H = 38
|
||||
local ROW_H = 30
|
||||
local PAD = 12
|
||||
local HEAD_H = 52 -- title and "Your place" line
|
||||
|
||||
-- A row (ROW_H tall, w wide) at (x, y): rank right-aligned in 52, the name (and "You"),
|
||||
-- credits at the right; the player's own row lit.
|
||||
-- The side panel's contract (sidepanel.lua).
|
||||
M.key = "leaderboard"
|
||||
M.include = "leaderboard" -- the state block it shows (asked for while the panel shows)
|
||||
|
||||
function M.unlocked(g)
|
||||
local u = g.state and g.state.unlocked
|
||||
return u ~= nil and u.leaderboard == true
|
||||
end
|
||||
|
||||
function M.sig(g)
|
||||
return g.state.leaderboard or false
|
||||
end
|
||||
|
||||
-- A row (ROW_H tall, w wide) at (x, y): rank, the name, credits at the right; the
|
||||
-- player's own row lit.
|
||||
local function row(root, r, x, y, w)
|
||||
local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
|
||||
if r.me then
|
||||
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
|
||||
end
|
||||
ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
|
||||
local h = ROW_H
|
||||
fg.build(flex.solve(fg.row({ width = w, height = h, padding_right = 14, align = "center" }, {
|
||||
fg.row({ width = 52, justify = "end", shrink = 0 }, {
|
||||
fg.label(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, h),
|
||||
fg.build(flex.solve(fg.row({ width = w, height = ROW_H, padding_right = PAD, align = "center" }, {
|
||||
fg.row({ width = 30, justify = "end", shrink = 0 }, {
|
||||
fg.label(bg, r.rank, { font = "mono", px = 12, color = r.rank <= 3 and "ready" or "muted" }, ROW_H),
|
||||
}),
|
||||
fg.space(24),
|
||||
fg.label(bg, r.name, { font = "sans", px = 15, color = "text" }, h, { fit = true, shrink = 1, max_width = w - 76 - 180 }),
|
||||
r.me and fg.space(10) or nil,
|
||||
r.me and fg.label(bg, "You", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, h) or nil,
|
||||
fg.space(10),
|
||||
fg.label(bg, r.name, { font = "sans", px = 13, color = r.me and "cyan" or "text" }, ROW_H, { fit = true, shrink = 1, max_width = w - 52 - 90 }),
|
||||
fg.fill(),
|
||||
fg.inline(bg, h, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 14 })) end),
|
||||
fg.space(8),
|
||||
fg.inline(bg, ROW_H, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 12 })) end),
|
||||
}), 0, 0), bg)
|
||||
end
|
||||
|
||||
function M.build(self, root, px, py, w, h, H)
|
||||
local lb = self.state.leaderboard
|
||||
local avail = w - px * 2
|
||||
local inner = math.min(avail, S.leaderboard_max)
|
||||
local x = px + math.floor((avail - inner) / 2)
|
||||
local fix = function(b) b.style.shrink = 0; return b end
|
||||
-- The list shrinks (and scrolls) when the panel is too short for every row; what's
|
||||
-- under it stays in view.
|
||||
local tree = fg.col({ width = inner, height = h - py * 2 }, {
|
||||
-- (the line starts 6 into the heading's 44; 16 under it after the line's 6)
|
||||
fix(fg.col({ height = 38 }, { fg.label(root, "Leaderboard", "h1", 40, { shrink = 0 }) })),
|
||||
fix(fg.text_para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".",
|
||||
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) })),
|
||||
fg.space(0, 22),
|
||||
fix(fg.row({ height = 16, align = "start" }, {
|
||||
fg.row({ width = 52, justify = "end", shrink = 0 }, { fg.label(root, "Rank", "cap", 12) }),
|
||||
fg.space(24), fg.label(root, "Miner", "cap", 12), fg.fill(),
|
||||
fg.label(root, "Credits", "cap", 12), fg.space(14),
|
||||
})),
|
||||
#lb.top > 0 and fg.made({ height = #lb.top * ROW_H, min_height = ROW_H }, function(p, lx, ly, lw, lh)
|
||||
ui.rect(p, lx, ly, lw, 1, "edge")
|
||||
local l
|
||||
l = list.new(self, p, lx, ly, lw, lh, {
|
||||
key = "leaderboard", row_h = ROW_H,
|
||||
create = function(r)
|
||||
local holder = ui.rect(l.content, 0, 0, lw, ROW_H, "none")
|
||||
row(holder, r, 0, 0, lw)
|
||||
return holder
|
||||
end,
|
||||
})
|
||||
l:set_data(lb.top)
|
||||
end) or fg.made({ height = ROW_H }, function(p, lx, ly, lw) ui.rect(p, lx, ly, lw, 1, "edge") end),
|
||||
-- The height the leaderboard takes with every row shown.
|
||||
local function natural(lb)
|
||||
return HEAD_H + math.max(#lb.top, 1) * ROW_H + (lb.you and ROW_H * 2 or 0) + PAD
|
||||
end
|
||||
|
||||
-- The leaderboard's content in p (a panel's root) w x h: title, place, the rows (they
|
||||
-- scroll when h is short). Without the state block yet (it comes with the next state:
|
||||
-- sidepanel.lua asks for it), the title and a placeholder.
|
||||
local function body(g, p, w, h)
|
||||
local lb = g.state.leaderboard
|
||||
ui.text(p, "Top miners", "cap", PAD, 18)
|
||||
if not lb then
|
||||
ui.text(p, "Loading…", "small", PAD, 38)
|
||||
return
|
||||
end
|
||||
local place = lb.me and ("Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players)) or (tostring(lb.players) .. " miners")
|
||||
ui.text(p, place, "small", PAD, 38)
|
||||
local foot = lb.you and ROW_H * 2 or 0
|
||||
local ly = HEAD_H
|
||||
local lh = math.max(ROW_H, math.min(h, natural(lb)) - HEAD_H - foot - PAD)
|
||||
ui.rect(p, 0, ly - 1, w, 1, "edge")
|
||||
if #lb.top > 0 then
|
||||
local l
|
||||
l = list.new(g, p, 0, ly, w, lh, {
|
||||
key = "leaderboard", row_h = ROW_H,
|
||||
create = function(r)
|
||||
local holder = ui.rect(l.content, 0, 0, w, ROW_H, "none")
|
||||
row(holder, r, 0, 0, w)
|
||||
return holder
|
||||
end,
|
||||
})
|
||||
l:set_data(lb.top)
|
||||
end
|
||||
if lb.you then
|
||||
-- (the web's "⋯": no font has it; three middle dots look the same)
|
||||
lb.you and fix(fg.row({ width = 52, height = ROW_H, justify = "end" }, {
|
||||
fg.label(root, "···", { font = "sans", px = 15, color = "muted" }, ROW_H),
|
||||
})) or nil,
|
||||
lb.you and fg.made({ height = ROW_H, shrink = 0 }, function(p, rx, ry, rw) row(p, lb.you, rx, ry, rw) end) or nil,
|
||||
lb.me and fix(fg.col({}, { fg.space(0, 14), fg.label(root, "Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players), "small", 16) })) or nil,
|
||||
fg.fill(),
|
||||
})
|
||||
fg.build(flex.solve(tree, x, py), root)
|
||||
local fy = ly + lh
|
||||
ui.text(p, "···", { font = "sans", px = 13, color = "muted" }, 30, fy + ROW_H / 2, gui.PIVOT_E)
|
||||
row(p, lb.you, 0, fy + ROW_H, w)
|
||||
end
|
||||
end
|
||||
|
||||
-- As a side panel (sidepanel.lua): its content in root (the panel's), w x h.
|
||||
function M.build(g, root, x, y, w, h)
|
||||
local p = ui.rect(root, x, y, w, h, "none")
|
||||
body(g, p, w, h)
|
||||
end
|
||||
|
||||
-- As a pane of its own at (x, y), w wide, in its own frame (the Market, when the side
|
||||
-- panel is folded away); h: the most it may take (rows scroll), or nil for every row.
|
||||
-- Returns its height.
|
||||
function M.pane(g, root, x, y, w, h)
|
||||
local n = natural(g.state.leaderboard)
|
||||
h = h and math.min(h, n) or n
|
||||
local _, nodes = ui.panel(root, x, y, w, h)
|
||||
body(g, ui.part(nodes, "panel", "root"), w, h)
|
||||
return h
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -16,6 +16,7 @@ local ticker = require("screens.game.ticker")
|
||||
local sale = require("screens.game.sale")
|
||||
local scroll = require("components.scroll")
|
||||
local blocks = require("modules.blocks")
|
||||
local leaderboard = require("screens.game.leaderboard")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
|
||||
@@ -145,54 +146,60 @@ local function credits_box(c, amount, opts, h)
|
||||
return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
|
||||
end
|
||||
|
||||
-- A quote card (w x h): name (or symbol when narrow), what you hold, price, change and
|
||||
-- A quote card's sizes for its width: the amount held (the card's big number) and the
|
||||
-- card's height (every card of a grid is as tall). Wide cards (w >= 150) show the name and
|
||||
-- what the holding is worth over it, price and change under it; narrow ones the symbol and
|
||||
-- the change over it, the price under it (the worth in the name's tooltip).
|
||||
local function card_size(w)
|
||||
local wide = w >= 150
|
||||
local big = w >= 220 and 40 or w >= 160 and 34 or 30
|
||||
return wide, big, 10 + 16 + 4 + big + 4 + 14 + 6 + 16 + 8
|
||||
end
|
||||
|
||||
-- A quote card (w x h): name (or symbol when narrow) and what the holding is worth, the
|
||||
-- amount held in large type (how much you own reads at a glance), price, change and
|
||||
-- sparkline. A tree piece; the card's node takes the hover and the click.
|
||||
local function card(self, H, c, w, h, q, on)
|
||||
local st = self.state
|
||||
local empty = q.balance <= 0
|
||||
local wide = w >= 150
|
||||
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
|
||||
or { font = "mono_semi", px = 12, color = empty and "muted" or "text", tracking = 0.06 }, 14, { fit = true, shrink = 1 })
|
||||
local hold = fg.label(c, ui.money(q.balance), { font = "mono", px = 11, color = empty and "faint" or "text_2" }, 14)
|
||||
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
|
||||
-- What it's worth (≈ ₥X) under the holding; "Refined" under the name when there's room.
|
||||
local wide, big = card_size(w)
|
||||
local muted = empty and "faint" or "muted"
|
||||
local value = credits_box(c, q.value or 0, { px = 10, color = muted }, 14)
|
||||
local vw = value.style.width
|
||||
local sub
|
||||
if wide and w - 34 - vw - 20 > 50 then
|
||||
sub = fg.label(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 14,
|
||||
{ fit = true, shrink = 1, max_width = w - 34 - vw - 20 })
|
||||
else
|
||||
name.done = function(n) tooltip.add(n, q.refined and (q.item .. " · Refined") or q.item) end
|
||||
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
|
||||
or { font = "mono_semi", px = 13, color = empty and "muted" or "text" }, 16, { fit = true, shrink = 1 })
|
||||
name.done = function(n)
|
||||
tooltip.add(n, (q.refined and (q.item .. " · Refined") or q.item)
|
||||
.. (wide and "" or (" · worth ≈ " .. theme.currency .. ui.money(q.value or 0))))
|
||||
end
|
||||
-- What it's worth (≈ ₥X), at the name's right on a wide card.
|
||||
local value = wide and credits_box(c, q.value or 0, { px = 11, color = muted }, 16) or nil
|
||||
-- The amount held: the card's big number (shrinks, then cuts, when it is very long).
|
||||
local hold = fg.label(c, ui.money(q.balance), { font = "mono_semi", px = big, color = empty and "faint" or "text" }, big,
|
||||
{ fit = true, shrink = 1, shrink_text = 0.6 })
|
||||
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
|
||||
local owned = w >= 170 and fg.label(c, "owned", { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 14,
|
||||
{ shrink = 0 }) or nil
|
||||
local boosted = q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001)
|
||||
local boost = boosted and fg.label(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 14) or nil
|
||||
local price = credits_box(c, q.price, { px = 13, color = "text" }, 14)
|
||||
local lines
|
||||
-- Price and change on one line when the card is wide, else on two.
|
||||
if wide then
|
||||
lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(), change_box(c, q, 14, { px = 11 }) }) }
|
||||
else
|
||||
lines = {
|
||||
row({ height = 14 }, { price }), space(0, 2),
|
||||
row({ height = 14 }, { change_box(c, q, 14, { px = 11 }), fg.fill(), boost }),
|
||||
}
|
||||
end
|
||||
local price = credits_box(c, q.price, { px = 13, color = "text_2" }, 14)
|
||||
-- Price (and the change, on a wide card) under the amount.
|
||||
local lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(),
|
||||
wide and change_box(c, q, 14, { px = 11 }) or nil }) }
|
||||
local spark_color = (q.change >= 0) and "up" or "danger"
|
||||
local kids = {
|
||||
row({ height = 14, align = "center" }, {
|
||||
row({ height = 16, align = "center" }, {
|
||||
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, color_of(st, q.item)) end),
|
||||
space(6), name, space(6), fg.fill(), hold,
|
||||
space(6), name, space(6), fg.fill(),
|
||||
wide and fg.label(c, "≈", { font = "sym", px = 11, color = muted }, 16) or nil, wide and space(3) or nil, value,
|
||||
(not wide) and change_box(c, q, 16, { px = 10 }) or nil,
|
||||
}),
|
||||
row({ height = 14, padding_left = 14 }, {
|
||||
sub, fg.fill(), fg.label(c, "≈", { font = "sym", px = 10, color = muted }, 14), space(3), value,
|
||||
}),
|
||||
space(0, 5),
|
||||
space(0, 4),
|
||||
row({ height = big, align = "end" }, { hold, owned and space(6) or nil,
|
||||
owned and col({ padding_bottom = math.floor(big * 0.12), shrink = 0 }, { owned }) or nil }),
|
||||
space(0, 4),
|
||||
-- (kept while the series is the same: it moves on with the 5-minute tick)
|
||||
made({ position = "absolute", left = 10, top = h - 20, width = w - 20, height = 13 }, function(p, x, y)
|
||||
made({ position = "absolute", left = 10, top = h - 24, width = w - 20, height = 16 }, function(p, x, y)
|
||||
blocks.keep(self, "mk:spark:" .. q.item, blocks.sig(w, h, q.spark, spark_color), p, function(r)
|
||||
chart.spark(r, x, y, w - 20, 13, q.spark, spark_color)
|
||||
chart.spark(r, x, y, w - 20, 16, q.spark, spark_color)
|
||||
end)
|
||||
end),
|
||||
}
|
||||
@@ -217,12 +224,13 @@ local function card(self, H, c, w, h, q, on)
|
||||
end })
|
||||
end
|
||||
|
||||
-- A group of cards ("Resources", "Refined items"), up to 5 per row: a grid.
|
||||
-- A group of cards ("Resources", "Refined items"), up to 5 per row (3 when that would
|
||||
-- make them too narrow): a grid.
|
||||
local function cards(self, H, c, inner, quotes, title)
|
||||
local per = math.min(5, #quotes)
|
||||
local gap = 8
|
||||
local gap = 10
|
||||
local per = math.min(#quotes, (inner - gap * 4) / 5 >= 112 and 5 or 3)
|
||||
local w = math.floor((inner - gap * (per - 1)) / per)
|
||||
local h = w >= 150 and 82 or 90
|
||||
local _, _, h = card_size(w)
|
||||
local cols, kids = {}, {}
|
||||
for i = 1, per do
|
||||
cols[i] = w
|
||||
@@ -242,32 +250,28 @@ local function stat(c, label, value)
|
||||
return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
|
||||
end
|
||||
|
||||
function M.build(self, root, px, py, w, h, H, opts)
|
||||
opts = opts or {}
|
||||
-- The market itself in w (its content centered in it); full_w: the ticker's width (the
|
||||
-- whole panel). Returns the bottom of what it built and the y under the ticker.
|
||||
local function build_market(self, root, px, py, w, h, H, stacked, full_w)
|
||||
local st = self.state
|
||||
local mk = st.market
|
||||
local stacked = self.size and self.size[1] <= STACK_W
|
||||
local area
|
||||
if stacked then
|
||||
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
|
||||
root = area.content
|
||||
end
|
||||
local c = root
|
||||
local inner = math.min(w - px * 2, theme.size.market_max)
|
||||
local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
|
||||
self.quote_nodes, self.quote_edges = {}, {}
|
||||
|
||||
-- Ticker tape across the panel (kept while the quotes are the same).
|
||||
local tape = {}
|
||||
local tape, tick_bottom = {}, 14
|
||||
for i, q in ipairs(mk.quotes) do
|
||||
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
|
||||
end
|
||||
local function ticker_part(cy)
|
||||
return cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, w, tape), root, function(r)
|
||||
return ticker.build(self, r, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
|
||||
tick_bottom = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, full_w, tape), root, function(r)
|
||||
return ticker.build(self, r, 14, cy, full_w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
|
||||
H.navigate(self, quote_view(self, item))
|
||||
end)
|
||||
end) + 8
|
||||
return tick_bottom
|
||||
end
|
||||
|
||||
-- Top mover and biggest drop: links to their quote; in the heading row when there's
|
||||
@@ -360,10 +364,7 @@ function M.build(self, root, px, py, w, h, H, opts)
|
||||
show_refined and col({}, { space(0, 4), cards(self, H, c, inner, refined, "Refined items") }) or nil,
|
||||
})
|
||||
if not q then
|
||||
if area then
|
||||
area:finish(cy + 18)
|
||||
end
|
||||
return
|
||||
return cy, tick_bottom
|
||||
end
|
||||
cy = cy + 6
|
||||
|
||||
@@ -626,7 +627,7 @@ function M.build(self, root, px, py, w, h, H, opts)
|
||||
if not empty then
|
||||
sell_now = H.action(self, b1, n1, "sell", function()
|
||||
return { resource = q.item, amount = f.text, pick = q.item }
|
||||
end, clear)
|
||||
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
|
||||
end
|
||||
end, { width = half }),
|
||||
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
|
||||
@@ -646,9 +647,6 @@ function M.build(self, root, px, py, w, h, H, opts)
|
||||
|
||||
local panels = stacked and col({ gap = 12 }, { detail, ticket }) or row({ gap = 12, align = "start" }, { detail, ticket })
|
||||
fg.build(flex.solve(panels, ox, cy, inner), c)
|
||||
if area then
|
||||
area:finish(panels.y + panels.h + 18)
|
||||
end
|
||||
|
||||
-- A sale (fx.lua calls this after the build that shows it, with what it earned): the
|
||||
-- ticket powers down; the coins leave from the payout.
|
||||
@@ -664,6 +662,24 @@ function M.build(self, root, px, py, w, h, H, opts)
|
||||
if sale_n then
|
||||
sale.bind(self, sale_n) -- (a rebuild while it plays)
|
||||
end
|
||||
return panels.y + panels.h, tick_bottom
|
||||
end
|
||||
|
||||
-- The leaderboard is a side panel (sidepanel.lua); when that is folded away (narrow
|
||||
-- windows) it is a pane under the market here (state.leaderboard, asked for with the
|
||||
-- market: session.view_params) and the section scrolls. The section scrolls too when the
|
||||
-- window is too short for the cards and the panels.
|
||||
function M.build(self, root, px, py, w, h, H)
|
||||
local lb = (self.side_w or 0) == 0 and self.state.leaderboard or nil
|
||||
local stacked = self.size and self.size[1] <= STACK_W
|
||||
local area = scroll.area(self, root, 0, 2, w, h - 4, "market")
|
||||
local bottom = build_market(self, area.content, px, py, w, h, H, stacked, w)
|
||||
if lb then
|
||||
local avail = w - px * 2
|
||||
local inner = math.min(avail, theme.size.market_max)
|
||||
bottom = bottom + 18 + leaderboard.pane(self, area.content, px + math.floor((avail - inner) / 2), bottom + 18, inner)
|
||||
end
|
||||
area:finish(bottom + 14) -- (the area is 4 shorter than the panel: what fits doesn't scroll)
|
||||
end
|
||||
|
||||
function M.update(self)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel under the wells table):
|
||||
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
|
||||
-- Moons section's main panel, moons.lua):
|
||||
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
|
||||
-- the server's confirm dialog; why it can't be sold yet is its tooltip), and on demand the
|
||||
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
|
||||
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
|
||||
-- market prices, × the factor, capped.
|
||||
|
||||
@@ -20,7 +21,7 @@ end
|
||||
|
||||
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
|
||||
|
||||
-- The panel, w wide: a tree piece (the Moons section puts it under the wells table).
|
||||
-- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
|
||||
function M.box(self, H, c, moon, w)
|
||||
local sale = moon.sale
|
||||
self.sale_open = self.sale_open or {}
|
||||
@@ -29,8 +30,27 @@ function M.box(self, H, c, moon, w)
|
||||
|
||||
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
|
||||
local profit = sale.delta > 0
|
||||
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
|
||||
local summary = box({ height = 48 }, {
|
||||
-- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
|
||||
local narrow = w < 460
|
||||
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
|
||||
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
|
||||
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
|
||||
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
|
||||
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
|
||||
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
|
||||
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
|
||||
local sell_opts = { small = true, disabled = not sale.canSell,
|
||||
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
|
||||
edge = sale.canSell and { "danger", 0.8 } or nil }
|
||||
local ask_row
|
||||
if asking then
|
||||
local sell_w = fg.button_w(c, "Sell moon", sell_opts)
|
||||
local ok, cancel = cards.ask_buttons(self, c, sale.confirm, sell_w)
|
||||
ask_row = row({ position = "absolute", left = 30, right = 10, top = 0, height = 48, align = "center", gap = 6 }, {
|
||||
cards.ask_text(sale.confirm, c), space(4), ok, cancel,
|
||||
})
|
||||
end
|
||||
local summary = box({ height = 48 }, asking and { ask_row } or {
|
||||
made({ position = "absolute", left = 0, top = 0, width = w - 140, height = 48 }, function(p, x, y, hw, hh)
|
||||
local hit = ui.rect(p, x, y, hw, hh, "none")
|
||||
self.druid:new_button(hit, function()
|
||||
@@ -45,20 +65,31 @@ function M.box(self, H, c, moon, w)
|
||||
gui.set_euler(chev, vmath.vector3(0, 0, -90))
|
||||
end
|
||||
end),
|
||||
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, {
|
||||
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8),
|
||||
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8),
|
||||
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10),
|
||||
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }),
|
||||
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
|
||||
-- (the label and the offer keep their size: the delta gives way, cut to fit)
|
||||
(function()
|
||||
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
|
||||
l.style.shrink = 0
|
||||
return l
|
||||
end)(), space(10),
|
||||
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
|
||||
(function()
|
||||
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
|
||||
b.style.shrink = 0
|
||||
return b
|
||||
end)(), space(10),
|
||||
(function()
|
||||
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
|
||||
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
|
||||
return l
|
||||
end)(),
|
||||
}),
|
||||
fg.button(c, "Sell moon", { small = true, disabled = not sale.canSell,
|
||||
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
|
||||
edge = sale.canSell and { "danger", 0.8 } or nil }, function(b)
|
||||
fg.button(c, "Sell moon", sell_opts, function(b)
|
||||
if not sale.canSell and (sale.whyText or "") ~= "" then
|
||||
tooltip.add(b, sale.whyText)
|
||||
end
|
||||
if sale.canSell then
|
||||
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
|
||||
cards.ask_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
|
||||
if self.autoplay then
|
||||
local function reg()
|
||||
if self.autoplay then
|
||||
@@ -148,7 +179,12 @@ function M.box(self, H, c, moon, w)
|
||||
})
|
||||
end
|
||||
|
||||
return col({ width = w, padding_bottom = 4 }, { summary, details }, { make = function(p, x, y, bw, bh)
|
||||
local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
|
||||
fg.measured(c, function(p, x, y, bw)
|
||||
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
|
||||
end),
|
||||
}) or nil
|
||||
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
|
||||
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
|
||||
ui.outline(b, 0, 0, bw, bh, "edge")
|
||||
self.fx:box(b) -- (the sale's sweep covers the panel)
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
|
||||
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
|
||||
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
|
||||
-- object, as terrain.lua's DEFAULTS shape it).
|
||||
--
|
||||
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
|
||||
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
|
||||
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
|
||||
-- the saved settings), the save status and Save; under it the settings in a scroll area,
|
||||
-- grouped like the web page: a slider and a number field per number, a hex field and a
|
||||
-- swatch per color, a toggle per flag, a button row for the technique.
|
||||
--
|
||||
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
|
||||
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
|
||||
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
|
||||
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
|
||||
-- once (moon.script's cache).
|
||||
--
|
||||
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
|
||||
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
|
||||
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
|
||||
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
|
||||
-- technique) or the size does: a slider's drag never rebuilds it.
|
||||
--
|
||||
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
|
||||
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
|
||||
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local theme = require("modules.theme")
|
||||
local field = require("modules.field")
|
||||
local api = require("modules.api")
|
||||
local test = require("modules.test")
|
||||
local terrain = require("modules.moon.terrain")
|
||||
local moon_view = require("modules.moon.moon")
|
||||
local panel = require("components.panel")
|
||||
local scroll = require("components.scroll")
|
||||
local button = require("components.button")
|
||||
local slider = require("components.slider")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
|
||||
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
|
||||
-- [key, "bool", label], [key, "select", label] (the technique).
|
||||
local SCHEMA = {
|
||||
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
|
||||
{ title = "Shape noise", path = "shape", fields = {
|
||||
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
|
||||
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||
} },
|
||||
{ title = "Ridges", path = "ridge", fields = {
|
||||
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
|
||||
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
|
||||
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
|
||||
} },
|
||||
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
|
||||
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
|
||||
} },
|
||||
{ title = "Craters", path = "craters", fields = {
|
||||
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
|
||||
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
|
||||
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
|
||||
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
|
||||
} },
|
||||
{ title = "Shading", path = "shading", fields = {
|
||||
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
|
||||
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
|
||||
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
|
||||
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
|
||||
} },
|
||||
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
|
||||
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
|
||||
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
|
||||
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
|
||||
} },
|
||||
{ title = "Render", path = "render", fields = {
|
||||
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
|
||||
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
|
||||
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
|
||||
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
|
||||
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
|
||||
} },
|
||||
}
|
||||
|
||||
local SEED = "moon-lab"
|
||||
local ROW_H = 30
|
||||
local LABEL_W = 150
|
||||
local NUM_W = 76
|
||||
local PAD = S.panel_pad
|
||||
local CTRL_MIN, CTRL_MAX = 440, 560
|
||||
local STATS_H = 22
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local function clone(t)
|
||||
if type(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
local c = {}
|
||||
for k, v in pairs(t) do
|
||||
c[k] = clone(v)
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
-- "minValue" -> "Min value" (the web's label()).
|
||||
local function label_of(key)
|
||||
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
|
||||
return s:sub(1, 1):upper() .. s:sub(2)
|
||||
end
|
||||
|
||||
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
|
||||
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
|
||||
local function fmt(v, step)
|
||||
if step >= 1 then
|
||||
return string.format("%d", math.floor(v + 0.5))
|
||||
end
|
||||
local dec = #(tostring(step):match("%.(%d+)") or "00")
|
||||
return string.format("%." .. math.min(4, dec) .. "f", v)
|
||||
end
|
||||
|
||||
local function group_of(settings, path)
|
||||
return path and settings[path] or settings
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- state
|
||||
|
||||
local function lab(g)
|
||||
g.lab = g.lab or { seed = SEED, rev = 0 }
|
||||
return g.lab
|
||||
end
|
||||
|
||||
local function status(L)
|
||||
if L.saving then
|
||||
return "Saving…"
|
||||
elseif L.error then
|
||||
return L.error
|
||||
elseif not L.loaded then
|
||||
return "Loading…"
|
||||
end
|
||||
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
|
||||
end
|
||||
|
||||
local function show_status(L)
|
||||
if L.w and L.w.status then
|
||||
ui.style_text(L.w.status, "small", status(L))
|
||||
ui.fit_text(L.w.status, L.w.status_w)
|
||||
end
|
||||
end
|
||||
|
||||
-- A value changed: the moon gets it on the next free frame (update).
|
||||
local function changed(L)
|
||||
L.pending = true
|
||||
L.error = nil
|
||||
show_status(L)
|
||||
end
|
||||
|
||||
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
|
||||
local function rebuild(g)
|
||||
local L = lab(g)
|
||||
L.rev = L.rev + 1
|
||||
L.pending = true
|
||||
L.error = nil
|
||||
g.dirty = true
|
||||
end
|
||||
|
||||
function M.open(g)
|
||||
local L = lab(g)
|
||||
L.saved = terrain.merge_settings(moon_view.settings)
|
||||
L.draft = clone(L.saved)
|
||||
L.loaded, L.error, L.saving = false, nil, false
|
||||
L.pending = true
|
||||
api.get_moon_settings(function(err, data)
|
||||
if g.lab ~= L then
|
||||
return
|
||||
end
|
||||
if err then
|
||||
L.loaded = true
|
||||
L.error = "Couldn't load the saved settings (" .. err .. ")"
|
||||
show_status(L)
|
||||
return
|
||||
end
|
||||
L.saved = terrain.merge_settings(data)
|
||||
L.draft = clone(L.saved)
|
||||
L.loaded = true
|
||||
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
|
||||
rebuild(g)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
|
||||
-- for the lab).
|
||||
function M.close(g)
|
||||
local L = g.lab
|
||||
if L and L.saved then
|
||||
moon_view.settings = clone(L.saved)
|
||||
end
|
||||
g.lab = nil
|
||||
end
|
||||
|
||||
local function save(g)
|
||||
local L = lab(g)
|
||||
if L.saving then
|
||||
return
|
||||
end
|
||||
L.saving, L.error = true, nil
|
||||
show_status(L)
|
||||
local sent = clone(L.draft)
|
||||
api.save_moon_settings(sent, function(err)
|
||||
-- The game's moons take the saved settings even if the lab was left meanwhile.
|
||||
if err then
|
||||
g.toasts:push("Moon settings not saved: " .. err, "error", true)
|
||||
else
|
||||
moon_view.settings = clone(sent)
|
||||
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
|
||||
end
|
||||
if g.lab ~= L then
|
||||
return
|
||||
end
|
||||
L.saving = false
|
||||
if err then
|
||||
L.error = "Save failed: " .. err
|
||||
else
|
||||
L.saved = sent
|
||||
end
|
||||
show_status(L)
|
||||
end)
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- controls
|
||||
|
||||
local function small_button(parent, label, id, on_click, opts)
|
||||
opts = opts or {}
|
||||
local bopts = { small = true, variant = opts.variant, on = opts.on }
|
||||
return fg.button(parent, label, bopts, function(b, nodes)
|
||||
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
|
||||
end, opts.style)
|
||||
end
|
||||
|
||||
-- One setting's row: label, then its control.
|
||||
local function setting_row(g, L, parent, zone, path, f)
|
||||
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
|
||||
local kind = type(a) == "string" and a or "number"
|
||||
local text = (kind ~= "number" and b or name) or label_of(key)
|
||||
local gid = (path or "root") .. "." .. key
|
||||
local target = group_of(L.draft, path)
|
||||
local kids = {}
|
||||
if kind ~= "select" then
|
||||
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
|
||||
end
|
||||
if kind == "number" then
|
||||
local o = { min = a, max = b, step = step }
|
||||
local fkey = "lab:" .. gid
|
||||
local f_state = field.get(g, fkey)
|
||||
f_state.text = fmt(target[key], step)
|
||||
local ctl = {}
|
||||
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
|
||||
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
|
||||
id = "slider:" .. gid, label = text, zone = zone,
|
||||
on_change = function(v)
|
||||
target[key] = v
|
||||
if ctl.input then
|
||||
ctl.syncing = true
|
||||
ctl.input:set_text(fmt(v, step))
|
||||
f_state.text = fmt(v, step)
|
||||
ctl.syncing = false
|
||||
end
|
||||
changed(L)
|
||||
end })
|
||||
end)
|
||||
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
|
||||
min = o.min, max = o.max,
|
||||
on_change = function(t)
|
||||
local v = tonumber(t)
|
||||
if ctl.syncing or not v then
|
||||
return
|
||||
end
|
||||
v = math.max(o.min, math.min(o.max, v))
|
||||
target[key] = v
|
||||
ctl.slider:set(v)
|
||||
changed(L)
|
||||
end })
|
||||
end)
|
||||
elseif kind == "color" then
|
||||
local fkey = "lab:" .. gid
|
||||
field.get(g, fkey).text = target[key]
|
||||
local sw
|
||||
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
|
||||
sw = ui.rect(p, x, y, w, h, "line_hi")
|
||||
-- (the setting's own color, not a theme color: the swatch shows the data)
|
||||
local c = terrain.hex_to_rgb(target[key])
|
||||
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||
end)
|
||||
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
|
||||
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
|
||||
on_change = function(t)
|
||||
if t:match("^#%x%x%x%x%x%x$") then
|
||||
target[key] = t:lower()
|
||||
local c = terrain.hex_to_rgb(t)
|
||||
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
|
||||
changed(L)
|
||||
end
|
||||
end })
|
||||
end)
|
||||
elseif kind == "bool" then
|
||||
local on = target[key] == true
|
||||
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
|
||||
target[key] = not target[key]
|
||||
rebuild(g)
|
||||
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
|
||||
else -- the technique
|
||||
local row = {}
|
||||
for _, t in ipairs(terrain.TECHNIQUES) do
|
||||
local cur = target[key] == t
|
||||
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
|
||||
target[key] = t
|
||||
rebuild(g)
|
||||
end, { g = g, variant = cur and "primary" or nil })
|
||||
end
|
||||
return fg.col({ gap = 6 }, {
|
||||
fg.label(parent, text, "small", 20, { fit = true }),
|
||||
fg.box({ wrap = true, gap = 6 }, row),
|
||||
})
|
||||
end
|
||||
return fg.row({ height = ROW_H, align = "center" }, kids)
|
||||
end
|
||||
|
||||
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
|
||||
local function settings_list(g, L, parent, x, y, w, h)
|
||||
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
|
||||
local p = area.content
|
||||
local groups = {}
|
||||
for _, sec in ipairs(SCHEMA) do
|
||||
local rows = { fg.label(p, sec.title, "h3", 22) }
|
||||
if sec.note then
|
||||
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
|
||||
end
|
||||
for _, f in ipairs(sec.fields) do
|
||||
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
|
||||
end
|
||||
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
|
||||
end
|
||||
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
|
||||
flex.solve(tree, 0, 0)
|
||||
fg.build(tree, p)
|
||||
area:finish(tree.h)
|
||||
end
|
||||
|
||||
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
|
||||
-- Returns its height.
|
||||
local function toolbar(g, L, parent, x, y, w)
|
||||
local st = g.state or {}
|
||||
field.get(g, "lab-seed").text = L.seed
|
||||
local function seed_to(s)
|
||||
L.seed = s
|
||||
field.get(g, "lab-seed").text = s
|
||||
rebuild(g)
|
||||
end
|
||||
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
|
||||
for i, m in ipairs(st.moons or {}) do
|
||||
if i > 3 then
|
||||
break
|
||||
end
|
||||
local id = tostring(m.id)
|
||||
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
|
||||
{ g = g, style = { max_width = 160 } })
|
||||
end
|
||||
local status_w = 170
|
||||
local tree = fg.col({ width = w, gap = 10 }, {
|
||||
fg.label(parent, "Seed (moon id)", "cap", 14),
|
||||
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
|
||||
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
|
||||
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
|
||||
on_change = function(t)
|
||||
L.seed = t ~= "" and t or SEED
|
||||
L.pending = true
|
||||
end })
|
||||
end),
|
||||
small_button(parent, "New seed", "lab:seed-new", function()
|
||||
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
|
||||
end, { g = g }),
|
||||
}),
|
||||
fg.box({ wrap = true, gap = 6 }, presets),
|
||||
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
|
||||
small_button(parent, "Defaults", "lab:defaults", function()
|
||||
L.draft = clone(terrain.DEFAULTS)
|
||||
rebuild(g)
|
||||
end, { g = g }),
|
||||
small_button(parent, "Reset", "lab:reset", function()
|
||||
L.draft = clone(L.saved)
|
||||
rebuild(g)
|
||||
end, { g = g }),
|
||||
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
|
||||
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
|
||||
L.w.status_w = math.min(status_w, bw - 8)
|
||||
show_status(L)
|
||||
end),
|
||||
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
|
||||
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
|
||||
end, { width = 110 }),
|
||||
}),
|
||||
})
|
||||
flex.solve(tree, x, y)
|
||||
fg.build(tree, parent)
|
||||
return tree.h
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- build
|
||||
|
||||
-- The block's inputs (core/main keeps it while they stay the same).
|
||||
function M.sig(g)
|
||||
local L = lab(g)
|
||||
return { L.rev, L.loaded }
|
||||
end
|
||||
|
||||
function M.build(g, parent, x, y, w, h)
|
||||
local L = lab(g)
|
||||
L.w = {}
|
||||
local gap = S.gap
|
||||
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
|
||||
local mw = w - cw - gap
|
||||
-- The moon.
|
||||
local mp = panel.new(parent, x, y, mw, h)
|
||||
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
|
||||
local vx = math.floor((mw - size) / 2)
|
||||
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
|
||||
L.vp = vp
|
||||
moon_view.show(L.seed, vp)
|
||||
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
|
||||
L.w.stats_w = size
|
||||
L.shown_build = nil
|
||||
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
|
||||
-- The settings.
|
||||
local cp = panel.new(parent, x + mw + gap, y, cw, h)
|
||||
local inner = cw - PAD * 2
|
||||
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
|
||||
local ly = PAD + th + 12
|
||||
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
|
||||
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
|
||||
end
|
||||
|
||||
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
|
||||
-- show the last build.
|
||||
function M.update(g, dt)
|
||||
local L = g.lab
|
||||
if not L or not L.vp then
|
||||
return
|
||||
end
|
||||
if L.pending and not moon_view.building then
|
||||
L.pending = false
|
||||
moon_view.settings = clone(L.draft)
|
||||
moon_view.show(L.seed, L.vp)
|
||||
moon_view.building = true -- (until moon.script looks: one handed over at a time)
|
||||
end
|
||||
local b = moon_view.last_build
|
||||
if b and b ~= L.shown_build and L.w and L.w.stats then
|
||||
L.shown_build = b
|
||||
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
|
||||
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,7 +1,8 @@
|
||||
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
|
||||
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
|
||||
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
|
||||
-- ready badge; a virtualized list, components/list) and "+ New moon" pinned to the bottom.
|
||||
-- ready badge; a virtualized list, components/list), "+ New moon" at the top (buys one) and
|
||||
-- the announcements pane (modules/toasts, the last 5) at the foot.
|
||||
-- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
|
||||
-- under it, the total on its tab.
|
||||
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells
|
||||
@@ -18,11 +19,13 @@ local field = require("modules.field")
|
||||
local moon_row = require("widgets.moon_row")
|
||||
local ready = require("screens.game.core.ready")
|
||||
local tabs = require("components.tabs")
|
||||
local button = require("components.button")
|
||||
local list_c = require("components.list")
|
||||
local row_c = require("components.row")
|
||||
local blocks = require("modules.blocks")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local toasts = require("modules.toasts")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -260,6 +263,45 @@ function M.teardown(self)
|
||||
self.ml_ghosts = nil
|
||||
end
|
||||
|
||||
-- The grade "+ New moon" buys: the toggle's pick if the server still lists it, else
|
||||
-- state.moonGrade.
|
||||
function M.grade(g)
|
||||
local st = g.state
|
||||
for _, e in ipairs(st.newMoon and st.newMoon.grades or {}) do
|
||||
if e.grade == g.nm_grade then return g.nm_grade end
|
||||
end
|
||||
return st.moonGrade
|
||||
end
|
||||
|
||||
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
|
||||
-- badge, selection), both lists: the moon list's block signature has this instead of the
|
||||
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
|
||||
-- keeps the list.
|
||||
function M.rows_sig(self)
|
||||
local st, v = self.state, self.view
|
||||
local out, n = {}, 0
|
||||
for _, list in ipairs({ st.moons, st.past }) do
|
||||
for _, m in ipairs(list) do
|
||||
local dry = not m.finished and #(m.wells or {}) > 0
|
||||
if dry then
|
||||
for _, w in ipairs(m.wells) do
|
||||
if w.supply > 0 then
|
||||
dry = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
|
||||
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
|
||||
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
|
||||
end
|
||||
n = n + 1
|
||||
out[n] = "|"
|
||||
end
|
||||
return blocks.sig(out)
|
||||
end
|
||||
|
||||
function M.build(self, H, parent, x, y, w, h)
|
||||
local st = self.state
|
||||
local v = self.view
|
||||
@@ -269,6 +311,55 @@ function M.build(self, H, parent, x, y, w, h)
|
||||
local inner = w - pad * 2
|
||||
local kids = {} -- the panel, top to bottom (one tree; full width, rows padded)
|
||||
|
||||
-- "+ New moon" (dashed in the web build), at the top: buys a moon straight away (the
|
||||
-- new-moon section's Claim and reveal); disabled with the reason when short.
|
||||
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
|
||||
if st.unlocked["new-moon"] then
|
||||
-- The grade it buys: the one picked with the toggle at its right (g.nm_grade, kept
|
||||
-- across builds), else the server's default (state.moonGrade).
|
||||
local grades = st.newMoon and st.newMoon.grades or {}
|
||||
local grade = M.grade(self)
|
||||
local entry
|
||||
for _, e in ipairs(grades) do
|
||||
if e.grade == grade then entry = e end
|
||||
end
|
||||
local price = entry and entry.price or st.moonPrice or 0
|
||||
local ok = entry and entry.canAfford or (not entry and (st.credits or 0) >= price)
|
||||
local why = entry and (entry.whyText or "") ~= "" and entry.whyText
|
||||
or ("Need " .. theme.currency .. ui.money(math.max(0, price - (st.credits or 0))) .. " more")
|
||||
local label = grade and ("+ New " .. grade .. " moon") or "+ New moon"
|
||||
local tw = #grades > 1 and 34 or 0
|
||||
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad }, gap = tw > 0 and 6 or 0 }, {
|
||||
fg.button(root, label, { price = price, variant = "ghost", on = v.section == "new-moon", disabled = not ok }, function(b, nodes)
|
||||
if ok then
|
||||
H.action(self, b, nodes, "buy-moon", { grade = grade, include = "new-moon" }, nil, nil, "newmoon:buy")
|
||||
else
|
||||
tooltip.add(b, why)
|
||||
end
|
||||
local extra = { ui.part(nodes, "button", "edge") }
|
||||
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
|
||||
if i > 1 then
|
||||
extra[#extra + 1] = part[1] -- (the price)
|
||||
end
|
||||
end
|
||||
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner - tw - (tw > 0 and 6 or 0), S.button_h, extra)
|
||||
end, { grow = 1, shrink = 1 }),
|
||||
-- The grade toggle: steps through the grades (cheapest first), stays in place.
|
||||
tw > 0 and fg.button(root, grade or "?", { variant = "ghost" }, function(b)
|
||||
button.bind(self, b, nil, { id = "newmoon:grade", label = "Grade " .. (grade or ""), on_click = function()
|
||||
local at = 0
|
||||
for i, e in ipairs(grades) do
|
||||
if e.grade == grade then at = i end
|
||||
end
|
||||
self.nm_grade = grades[at % #grades + 1].grade
|
||||
self.dirty = true
|
||||
end })
|
||||
tooltip.add(b, "Grade to buy: " .. (grade or "") .. " (click to change)")
|
||||
end, { width = tw }) or nil,
|
||||
})
|
||||
kids[#kids + 1] = fg.space(0, 12)
|
||||
end
|
||||
|
||||
-- Active / Past tabs (once the store is open, like the web): a tab is its label and
|
||||
-- count in a hit box 4 wider on the left; a line under them.
|
||||
if (#st.moons + #st.past) > 0 and st.unlocked.store then
|
||||
@@ -416,7 +507,7 @@ function M.build(self, H, parent, x, y, w, h)
|
||||
fg.space(0, 12),
|
||||
})
|
||||
end
|
||||
-- The rows (a virtualized list) take the room down to the pager and "+ New moon".
|
||||
-- The rows (a virtualized list) take the room down to the pager and the announcements.
|
||||
kids[#kids + 1] = fg.made({ grow = 1, shrink = 1 }, function(_, _, ly, _, lh)
|
||||
rows_y, list_h = ly, lh
|
||||
build_rows(find.text, ly)
|
||||
@@ -443,23 +534,19 @@ function M.build(self, H, parent, x, y, w, h)
|
||||
kids[#kids + 1] = fg.space(0, 4)
|
||||
end
|
||||
|
||||
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel.
|
||||
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
|
||||
if st.unlocked["new-moon"] then
|
||||
kids[#kids + 1] = fg.space(0, 10)
|
||||
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad } }, {
|
||||
fg.button(root, "+ New moon", { price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" }, function(b, nodes)
|
||||
H.link(self, b, { section = "new-moon", moon = v.moon })
|
||||
local extra = { ui.part(nodes, "button", "edge") }
|
||||
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
|
||||
if i > 1 then
|
||||
extra[#extra + 1] = part[1] -- (the price)
|
||||
end
|
||||
end
|
||||
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner, S.button_h, extra)
|
||||
end, { width = inner }),
|
||||
})
|
||||
end
|
||||
-- The announcements pane at the panel's foot: room for the last 5 (modules/toasts lists
|
||||
-- them there), at most half the panel; the moon list gets the rest.
|
||||
kids[#kids + 1] = fg.space(0, 12)
|
||||
kids[#kids + 1] = fg.col({ height = math.min(math.floor(h / 2), 31 + toasts.LIST_H), padding = { 0, pad, 0, pad } }, {
|
||||
fg.made({ height = 1 }, function(p, lx, ly, lw)
|
||||
ui.rect(p, lx, ly, lw, 1, "line")
|
||||
end),
|
||||
fg.space(0, 10),
|
||||
fg.label(root, "Announcements", "cap", 12), fg.space(0, 8),
|
||||
fg.made({ grow = 1, shrink = 1 }, function(p, px, py, pw, ph)
|
||||
self.toasts:attach(ui.rect(p, px, py, pw, ph, "none"), pw, ph)
|
||||
end),
|
||||
})
|
||||
for _, k in ipairs(kids) do
|
||||
k.style.shrink = k.style.shrink or 0
|
||||
end
|
||||
|
||||
+695
-241
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect
|
||||
-- all when more than one batch is ready), the refined stock chips, the slot cards (running:
|
||||
-- all when more than one batch is ready), the slot cards (running:
|
||||
-- time left and a segmented bar filling in real time against the server clock; ready:
|
||||
-- Collect; idle; "+ Slot n" while more can be bought) and the recipe cards (glyph, tier
|
||||
-- and owned, description, inputs, stats, the bulk batch, quantity, Refine and Max). The
|
||||
@@ -27,9 +27,30 @@ local M = {}
|
||||
-- components.bar needs core/ticks, which needs core/session, which needs this module:
|
||||
-- required when used (by a name bob doesn't follow).
|
||||
local BAR = "components.bar"
|
||||
local READY = "screens.game.core.ready"
|
||||
|
||||
local S = theme.size
|
||||
|
||||
-- A batch that finishes (core/ready: on our clock, before the refetch says so) turns its
|
||||
-- slot ready at once: a light runs along its bar (SWEEP s), the inset glow flares and
|
||||
-- settles (FLARE s), the Ready label and Collect pop in (POP s, fx-ready-pop); fx.lua's
|
||||
-- batch_ready lights the edge and plays the sound. All go by the clock from the batch's
|
||||
-- end (ready.batch_age), so the refetch's rebuild carries on where they were. Reduced
|
||||
-- motion: no pop and no sweep.
|
||||
local POP, SWEEP, FLARE = 0.6, 0.7, 1.4
|
||||
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.12 }, { 1, 1 } }
|
||||
|
||||
-- Pops node in about the point (dx, dy) from its position, age seconds in.
|
||||
local function pop(node, dx, dy, age)
|
||||
if anim.reduced or not age or age >= POP then
|
||||
return
|
||||
end
|
||||
local p, base = gui.get_position(node), gui.get_scale(node)
|
||||
anim.tween(node, POP, function(k, n)
|
||||
anim.scale_about(n, p, base, dx, dy, anim.keys(POP_SCALE, k, anim.fn.ready_pop))
|
||||
end, -age)
|
||||
end
|
||||
|
||||
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
|
||||
function M.next_end(st)
|
||||
local due
|
||||
@@ -80,7 +101,6 @@ local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
|
||||
-- Styles (app.css).
|
||||
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
|
||||
local BODY14 = { font = "sans", px = 14, color = "text" }
|
||||
local MONO14 = { font = "mono", px = 14, color = "text" }
|
||||
local MONO15 = { font = "mono", px = 15, color = "text" }
|
||||
local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true }
|
||||
local BTN_SM = 34 -- .btn-sm height
|
||||
@@ -156,12 +176,45 @@ local function section_label(c, str, num)
|
||||
})
|
||||
end
|
||||
|
||||
-- The Slots label and, on its right, the batch size and its upgrade (the whole refinery:
|
||||
-- every slot makes batches up to rf.maxBatch; upgrade-batch raises it to rf.nextBatch).
|
||||
-- One button-high row whether or not the upgrade shows; the button keeps about one width
|
||||
-- from level to level, so repeated clicks stay on it (clicker rule).
|
||||
local function slots_label(self, H, c, rf)
|
||||
local kids = {
|
||||
row({ grow = 1, align = "center" }, {
|
||||
fg.label(c, "Slots ", "cap", 16.5), fg.label(c, rf.slotCount .. "/" .. rf.maxSlots, CAP_NUM, 16.5),
|
||||
}),
|
||||
fg.label(c, "Batch size ", "cap", 16.5), fg.label(c, ui.money(rf.maxBatch), CAP_NUM, 16.5),
|
||||
space(12),
|
||||
}
|
||||
if (rf.nextBatch or 0) > 0 then
|
||||
local ok = rf.canUpgradeBatch
|
||||
local label = "Upgrade to " .. ui.money(rf.nextBatch)
|
||||
local opts = { small = true, price = rf.nextBatchPrice, disabled = not ok }
|
||||
kids[#kids + 1] = fg.button(c, label, opts, function(b, nodes)
|
||||
if not ok then
|
||||
tooltip.add(b, rf.upgradeBatchWhyText or "")
|
||||
else
|
||||
cards.pulse(nodes)
|
||||
H.action(self, b, nodes, "upgrade-batch", {})
|
||||
tooltip.add(b, "Every slot makes batches of up to " .. ui.money(rf.nextBatch))
|
||||
end
|
||||
end, { width = math.max(240, fg.button_w(c, label, opts)) })
|
||||
else
|
||||
kids[#kids + 1] = fg.label(c, "Biggest batches", "small", 20)
|
||||
end
|
||||
return col({ padding = { 20, 0, 10, 0 } }, { row({ align = "center", height = S.button_sm_h }, kids) })
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- slot cards
|
||||
|
||||
-- The web's .bar: 8 px of 6 px cells (2 px apart) in a faint cyan, the fill masked into
|
||||
-- the same cells. The fill follows a hidden probe the timer ticks (H.tick_bar); M.update
|
||||
-- lights the cells from its width.
|
||||
local function cell_bar(self, H, color, job)
|
||||
-- ready: finished (in the state or on our clock); age: seconds since it finished, when
|
||||
-- that was just now (the sweep).
|
||||
local function cell_bar(self, H, color, job, ready, age)
|
||||
return { style = { height = 8 }, make = function(p, x, y, w)
|
||||
local cells = {}
|
||||
for cx = 0, w - 1, 8 do
|
||||
@@ -169,15 +222,39 @@ local function cell_bar(self, H, color, job)
|
||||
ui.rect(p, x + cx, y, cw, 8, "cyan", 0.14)
|
||||
cells[#cells + 1] = { node = ui.rect(p, x + cx, y, cw, 8, color), x = cx, w = cw }
|
||||
end
|
||||
local bar = { cells = cells, full = job.ready }
|
||||
if not job.ready then
|
||||
local bar = { cells = cells, full = ready, w = w, color = ui.color(color) }
|
||||
if not ready then
|
||||
bar.probe = ui.rect(p, x, y, w, 8, "none")
|
||||
H.tick_bar(self, bar.probe, job.start, job["end"], w)
|
||||
elseif age and age < SWEEP and not anim.reduced then
|
||||
bar.sweep = socket.gettime() - age
|
||||
end
|
||||
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
|
||||
self.rf_slots[job.id].bar = ui.rect(p, x, y, w, 8, "none") -- (fx: where its sparks leave)
|
||||
self.rf_bars[#self.rf_bars + 1] = bar
|
||||
end }
|
||||
end
|
||||
|
||||
-- The sweep: a bright head runs left to right over the full cells, lighting each to the
|
||||
-- flash color as it passes, then the cells settle back to the item's color.
|
||||
local function sweep_cells(b)
|
||||
local k = (socket.gettime() - b.sweep) / SWEEP
|
||||
if k >= 1 then
|
||||
b.sweep = nil
|
||||
for _, c in ipairs(b.cells) do
|
||||
gui.set_color(c.node, b.color)
|
||||
end
|
||||
return
|
||||
end
|
||||
local head = 1.3 * (1 - (1 - k) ^ 2) - 0.15
|
||||
local flash = theme.color.flash
|
||||
for _, c in ipairs(b.cells) do
|
||||
local d = (c.x + c.w / 2) / b.w
|
||||
local lit = math.max(0, 1 - math.abs(d - head) * 7) + (d < head and 0.35 * (1 - k) or 0)
|
||||
gui.set_color(c.node, vmath.lerp(math.min(1, lit), b.color, vmath.vector4(flash.x, flash.y, flash.z, b.color.w)))
|
||||
end
|
||||
end
|
||||
|
||||
local function light_cells(b)
|
||||
local fw = b.full and math.huge or gui.get_size(b.probe).x
|
||||
for _, c in ipairs(b.cells) do
|
||||
@@ -193,12 +270,15 @@ function M.update(self)
|
||||
for _, b in ipairs(self.rf_bars or {}) do
|
||||
if not b.full then
|
||||
light_cells(b)
|
||||
elseif b.sweep then
|
||||
sweep_cells(b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.teardown(self)
|
||||
self.rf_bars = nil
|
||||
self.rf_bars = nil -- (rf_now stays: the next build glides the batches from it)
|
||||
self.rf_slots = nil
|
||||
end
|
||||
|
||||
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off).
|
||||
@@ -216,6 +296,7 @@ end
|
||||
-- An inset glow (box-shadow: inset 0 0 18px): the fade texture along each edge, inwards.
|
||||
local function inset_glow(p, w, h, color, alpha)
|
||||
local d = 14
|
||||
local nodes = {}
|
||||
for _, e in ipairs({ { w / 2, d / 2, w, 0 }, { w / 2, h - d / 2, w, 180 }, { d / 2, h / 2, h, 90 }, { w - d / 2, h / 2, h, -90 } }) do
|
||||
local n = ui.rect(p, 0, 0, e[3], d, color, alpha)
|
||||
gui.play_flipbook(n, "fade")
|
||||
@@ -223,7 +304,9 @@ local function inset_glow(p, w, h, color, alpha)
|
||||
gui.set_position(n, vmath.vector3(e[1], -e[2], 0))
|
||||
gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4])))
|
||||
gui.set_blend_mode(n, theme.glow_blend())
|
||||
nodes[#nodes + 1] = n
|
||||
end
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- A panel frame (fill and 1 px edge), registered for the entrance and the action sweep.
|
||||
@@ -236,19 +319,32 @@ local function frame(self, p, x, y, w, h, edge_color)
|
||||
end
|
||||
|
||||
-- A slot (.refinery-slot, at least 112 tall, padding 12 + border): idle, running or ready.
|
||||
local function slot_card(self, H, c, st, slot)
|
||||
-- Batches are shown oldest first from the left, idle slots after them ("Slot n" is the
|
||||
-- position), so a new order lands right of the others and collecting the leftmost slides
|
||||
-- the rest left: repeated clicks on one spot collect one batch after another. A batch's
|
||||
-- card glides from where it was (GLIDE): the layout's slot node (outer) sits at the new
|
||||
-- rect at once, the frame inside it moves; the Collect button's clicks go to a box in the
|
||||
-- slot node at the button's final rect, so a click there counts during the glide.
|
||||
local GLIDE = 0.18
|
||||
-- Every slot is as tall as a ready one (its Collect button under the bar), so a batch
|
||||
-- finishing doesn't push the recipes down under the pointer (clicker rule).
|
||||
local SLOT_H = 13 * 2 + 22.5 + 8 + 22 + 8.5 + 8 + 8 + BTN_SM
|
||||
local function slot_card(self, H, c, st, slot, n)
|
||||
local job = slot.job
|
||||
local ready = job and job.ready
|
||||
local age = job and require(READY).batch_age(self, job)
|
||||
local ready = job and require(READY).batch_done(self, job) -- (on our clock: display only)
|
||||
local edge = job and (ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
|
||||
local outer -- (the card's slot node, made first: the Collect button's hit box goes in it)
|
||||
local kids
|
||||
if not job then
|
||||
kids = { fg.label(c, "Slot " .. slot.n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
|
||||
kids = { fg.label(c, "Slot " .. n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
|
||||
else
|
||||
local head = fg.label(c, "Slot " .. slot.n, "cap", 22)
|
||||
local head = fg.label(c, "Slot " .. n, "cap", 22)
|
||||
local right
|
||||
if ready then
|
||||
right = { style = { height = 22 }, make = function(p, x, y, w, h)
|
||||
require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
|
||||
local t = require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
|
||||
pop(t, -ui.text_width(t) / 2, 0, age)
|
||||
end }
|
||||
else
|
||||
local left = function(ms) return ui.dur(ms) .. " left" end
|
||||
@@ -268,17 +364,52 @@ local function slot_card(self, H, c, st, slot)
|
||||
}),
|
||||
}),
|
||||
space(0, 8.5),
|
||||
cell_bar(self, H, color, job),
|
||||
cell_bar(self, H, color, job, ready, age),
|
||||
{ style = { grow = 1, min_height = ready and 8 or 0 } },
|
||||
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes)
|
||||
H.action(self, b, nodes, "collect", { job = job.id })
|
||||
local p, size = gui.get_position(b), gui.get_size(b)
|
||||
local hit = ui.rect(outer, p.x, -p.y, size.x, size.y, "none")
|
||||
H.action(self, b, nodes, "collect", { job = job.id }, nil, nil, nil, hit)
|
||||
pop(b, size.x / 2, -size.y / 2, age)
|
||||
end, { width = "100%" }) or nil,
|
||||
}
|
||||
end
|
||||
return col({ padding = 13, min_height = 112 }, kids), function(p, x, y, w, h)
|
||||
local f = frame(self, p, x, y, w, h, edge)
|
||||
return col({ padding = 13, min_height = SLOT_H }, kids), function(p, x, y, w, h)
|
||||
outer = ui.rect(p, x, y, w, h, "none")
|
||||
local f = frame(self, outer, 0, 0, w, h, edge)
|
||||
if ready then
|
||||
inset_glow(f, w, h, "ready", 0.08)
|
||||
local glow = inset_glow(f, w, h, "ready", 0.08)
|
||||
if age and age < FLARE then
|
||||
-- Just finished: the glow flares and settles to its rest.
|
||||
anim.tween(glow[1], FLARE, function(k)
|
||||
local a = 0.08 + 0.32 * (1 - k) ^ 2
|
||||
for _, n in ipairs(glow) do
|
||||
anim.set(n, "color.w", a)
|
||||
end
|
||||
end, -age)
|
||||
end
|
||||
end
|
||||
if job then
|
||||
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
|
||||
self.rf_slots[job.id].frame = f
|
||||
-- Where the card is drawn now (its last rect, less what its glide has done).
|
||||
local was = self.rf_was and self.rf_was[job.id]
|
||||
local now = socket.gettime()
|
||||
local fx, fy = 0, 0
|
||||
if was and not anim.reduced then
|
||||
local u = math.min(1, (now - was.t) / GLIDE)
|
||||
local k = 1 - (1 - u) * (1 - u) -- (EASING_OUTQUAD)
|
||||
fx = was.x + was.fx * (1 - k) - x
|
||||
fy = was.y + was.fy * (1 - k) - y
|
||||
if math.abs(fx) < 0.5 and math.abs(fy) < 0.5 then
|
||||
fx, fy = 0, 0
|
||||
end
|
||||
end
|
||||
self.rf_now[job.id] = { x = x, y = y, fx = fx, fy = fy, t = now }
|
||||
if fx ~= 0 or fy ~= 0 then
|
||||
gui.set_position(f, vmath.vector3(fx, -fy, 0))
|
||||
gui.animate(f, "position", vmath.vector3(0, 0, 0), gui.EASING_OUTQUAD, GLIDE)
|
||||
end
|
||||
end
|
||||
return f
|
||||
end
|
||||
@@ -354,6 +485,15 @@ local function stat(c, label, value)
|
||||
return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
|
||||
end
|
||||
|
||||
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
|
||||
-- a collect's motes land there and the number counts up. Laid out at the final amount.
|
||||
local counted = {}
|
||||
local function own_label(c, r, color)
|
||||
local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
|
||||
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
|
||||
return l
|
||||
end
|
||||
|
||||
-- One recipe card (the web's .card.recipe).
|
||||
local function recipe_card(self, H, c, st, rf, r)
|
||||
local color = item_color(st, r.item)
|
||||
@@ -361,8 +501,9 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
|
||||
local owned = (r.owned or 0) > 0
|
||||
|
||||
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N ·
|
||||
-- owned X" over the name.
|
||||
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
|
||||
-- the name, and on the right what you own, big, in the item's color (a collect's motes
|
||||
-- fly into it and it counts up), shown at 0 too.
|
||||
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
|
||||
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
|
||||
gui.play_flipbook(g, "pentagon")
|
||||
@@ -370,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
end }
|
||||
local head = row({ height = 43.3, gap = 12, align = "start" }, {
|
||||
col({ padding_top = 3.6 }, { glyph }),
|
||||
col({ grow = 1, align = "start" }, {
|
||||
row({}, {
|
||||
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
|
||||
owned and fg.label(c, ui.money(r.owned), CAP_NUM, 24) or nil,
|
||||
}),
|
||||
col({ grow = 1, shrink = 1, align = "start" }, {
|
||||
fg.label(c, "Tier " .. r.tier, "cap", 24),
|
||||
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
|
||||
}),
|
||||
col({ align = "end", shrink = 0 }, {
|
||||
fg.label(c, "Owned", "cap", 14),
|
||||
own_label(c, r, owned and color or "muted"),
|
||||
}),
|
||||
})
|
||||
|
||||
-- Inputs per unit: need and name, what you hold on the right (red when short).
|
||||
@@ -418,29 +560,33 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
return n
|
||||
end })
|
||||
|
||||
-- The form: quantity, Refine (takes the rest), Max.
|
||||
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
|
||||
-- usual click) the card's width under them, primary.
|
||||
local qty, input = qty_field(self, r, off, rf.maxBatch)
|
||||
qty.style.width, qty.style.grow = nil, 1
|
||||
local refine
|
||||
local form = row({ height = BTN_SM, gap = 6 }, {
|
||||
qty,
|
||||
btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes)
|
||||
if off then
|
||||
return tooltip.add(b, why)
|
||||
end
|
||||
refine = H.action(self, b, nodes, "refine", function()
|
||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||
end)
|
||||
end, { grow = 1 }),
|
||||
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
|
||||
if off then
|
||||
return tooltip.add(b, why)
|
||||
end
|
||||
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
|
||||
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
||||
-- Enter in the quantity refines (the web's form).
|
||||
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||
end),
|
||||
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
|
||||
if off then
|
||||
return tooltip.add(b, why)
|
||||
end
|
||||
refine = H.action(self, b, nodes, "refine", function()
|
||||
local q = self.refine_qty and self.refine_qty[r.item] or "1"
|
||||
return { recipe = r.item, qty = (q ~= "" and q or "1") }
|
||||
end, nil, nil, "act:refine?recipe=" .. r.item)
|
||||
tooltip.add(b, "Refine the number in the box")
|
||||
-- Enter in the quantity refines (the web's form).
|
||||
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
|
||||
end)
|
||||
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
|
||||
if off then
|
||||
return tooltip.add(b, why)
|
||||
end
|
||||
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
|
||||
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
|
||||
end, { grow = 1 })
|
||||
local form = col({ shrink = 0 }, {
|
||||
row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
|
||||
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
|
||||
})
|
||||
|
||||
local desc = catalog_desc(st, r.item)
|
||||
@@ -452,9 +598,9 @@ local function recipe_card(self, H, c, st, rf, r)
|
||||
col({ gap = 4 }, inputs),
|
||||
space(0, 8),
|
||||
fill(), -- (the footer at the bottom: buttons line up across a row)
|
||||
col({ height = 126 }, {
|
||||
col({ height = 162 }, { -- (rule, stats, batch line, the form's two rows)
|
||||
{ style = { height = 1 }, make = function(p, x, y, w) ui.rect(p, x, y, w, 1, "line") end },
|
||||
space(0, 12), stats, space(0, 8), batch, fill(), form,
|
||||
space(0, 12), stats, space(0, 8), batch, fill(), space(0, 8), form,
|
||||
}),
|
||||
}), function(p, x, y, w, h)
|
||||
-- The card (.card): edge brightens on hover (0.15 s); bound before the footer's
|
||||
@@ -475,6 +621,7 @@ function M.build(self, root, px, py, w, h, H)
|
||||
local st = self.state
|
||||
local rf = st.refinery
|
||||
self.rf_bars = {}
|
||||
self.rf_slots = {} -- by batch id: { frame, bar } (fx.lua's batch_ready)
|
||||
self.frames = self.frames or {}
|
||||
local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge)
|
||||
local c = area.content
|
||||
@@ -492,36 +639,38 @@ function M.build(self, root, px, py, w, h, H)
|
||||
end) or nil,
|
||||
})
|
||||
|
||||
-- Refined items owned (.refinery-stock: a swatch, the name and the amount), wrapping.
|
||||
local chips = {}
|
||||
for _, s in ipairs(rf.stock) do
|
||||
if s.amount > 0 then
|
||||
chips[#chips + 1] = box({ height = 21, align = "center" }, {
|
||||
swatch(item_color(st, s.name)), space(6),
|
||||
fg.label(c, s.name .. " ", BODY14, 21), fg.label(c, ui.money(s.amount), MONO14, 21),
|
||||
}, { make = function(p, ...)
|
||||
local n = group(p, ...)
|
||||
tooltip.add(n, s.name)
|
||||
return n
|
||||
end })
|
||||
end
|
||||
end
|
||||
|
||||
-- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
|
||||
local slots = {}
|
||||
for _, slot in ipairs(rf.slots) do
|
||||
local tree, make = slot_card(self, H, c, st, slot)
|
||||
local order = {}
|
||||
for i, slot in ipairs(rf.slots) do
|
||||
order[i] = { slot = slot, i = i }
|
||||
end
|
||||
table.sort(order, function(a, b)
|
||||
local ja, jb = a.slot.job, b.slot.job
|
||||
if (ja ~= nil) ~= (jb ~= nil) then
|
||||
return ja ~= nil
|
||||
end
|
||||
local sa, sb = ja and ja.start or 0, jb and jb.start or 0
|
||||
if sa ~= sb then
|
||||
return sa < sb
|
||||
end
|
||||
return a.i < b.i
|
||||
end)
|
||||
self.rf_was, self.rf_now = self.rf_now, {}
|
||||
for n, o in ipairs(order) do
|
||||
local tree, make = slot_card(self, H, c, st, o.slot, n)
|
||||
tree.make = make
|
||||
slots[#slots + 1] = tree
|
||||
end
|
||||
if (rf.nextSlotPrice or 0) > 0 then
|
||||
local b = buy_slot(self, H, c, rf)
|
||||
b.style.min_height = 112
|
||||
b.style.min_height = SLOT_H
|
||||
slots[#slots + 1] = b
|
||||
end
|
||||
|
||||
-- Recipes (.cards).
|
||||
local recipes = {}
|
||||
counted = {}
|
||||
for _, r in ipairs(rf.recipes) do
|
||||
local tree, make = recipe_card(self, H, c, st, rf, r)
|
||||
tree.make = make
|
||||
@@ -530,13 +679,17 @@ function M.build(self, root, px, py, w, h, H)
|
||||
|
||||
local page = col({ gap = 18, width = inner }, {
|
||||
heading,
|
||||
#chips > 0 and row({ wrap = true, column_gap = 16, row_gap = 8, align = "start" }, chips) or nil,
|
||||
section_label(c, "Slots", rf.slotCount .. "/" .. rf.maxSlots),
|
||||
slots_label(self, H, c, rf),
|
||||
flex.grid(inner, 200, 12, slots),
|
||||
section_label(c, "Recipes"),
|
||||
flex.grid(inner, S.card_col_min, S.gap, recipes),
|
||||
})
|
||||
fg.build(flex.solve(page, x, y), c)
|
||||
-- The recipes' owned numbers count what a collect brings (fx.lua collected).
|
||||
for _, e in ipairs(counted) do
|
||||
self.fx:bind(e.key, e.node)
|
||||
gui.set_text(e.node, self.fx:text(e.key, e.amount))
|
||||
end
|
||||
area:finish(page.y + page.h + py)
|
||||
for _, b in ipairs(self.rf_bars) do
|
||||
light_cells(b)
|
||||
|
||||
+369
-265
@@ -6,20 +6,25 @@
|
||||
-- the moon itself is known before the purchase.
|
||||
--
|
||||
-- The reveal (reveal.new(g, layer), started with :start(data) by a buy-moon response's
|
||||
-- `reveal`), all on its own layer over the layout:
|
||||
-- 1. Charge: the page dims around the viewport, the shroud charges up and shakes (moon.script).
|
||||
-- 2. Burst: when the moon's mesh is ready, a flash, sparks and a thump; the moon pops out.
|
||||
-- 3. Card: name, class, the verdict and wells pop in one by one, then the badge stamps down
|
||||
-- (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet note).
|
||||
-- 4. Back: the card flies into the moon list, the page comes back and the balance counts
|
||||
-- down (fx "revealed"), the toast shows.
|
||||
-- `reveal`), all on its own layer over the dimmed page, in a window at the center of the
|
||||
-- screen (the same size for every moon):
|
||||
-- 1. Charge: the moon's stage (the 3D moon in its own node) shows the shroud charging up
|
||||
-- (moon.script), the "?" trembling, a low rumble building.
|
||||
-- 2. Burst: when the moon's mesh is ready, a flash, sparks, the reveal boom and a decaying
|
||||
-- Perlin-noise shake of the window and the page; the moon pops out.
|
||||
-- 3. Drawer: the stage slides right while a drawer slides out from behind it to the left;
|
||||
-- in it name, class, the verdict and wells pop in one by one, then the badge stamps
|
||||
-- down (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet
|
||||
-- note). Buy another and Start mining sit in fixed slots at the drawer's foot.
|
||||
-- 4. Close: the window fades and sinks, the page comes back, the new moon's row is marked
|
||||
-- (fx "revealed"), the toast shows. (The balance counts down at the purchase, fx.lua.)
|
||||
-- While it plays, the main area, the well panel and the new moon's list entry stay hidden
|
||||
-- (g.reveal:hiding(), :hides(id)): nothing about the wells shows before the card does.
|
||||
-- A click or Space/Enter skips ahead (charge -> burst, card -> its end state), then closes;
|
||||
-- Esc closes. "Buy another" (reveal.next: hidden while more moons are locked, disabled
|
||||
-- with the server's reason when unaffordable) closes the card and buys the next moon,
|
||||
-- whose response plays a new reveal. The card's buttons take keyboard focus (scope
|
||||
-- "reveal", modules/focus) once it's complete.
|
||||
-- (g.reveal:hiding(), :hides(id)), and the aside's viewport leaves the 3D moon to the
|
||||
-- stage. A click or Space/Enter skips ahead (charge -> burst, drawer -> its end state),
|
||||
-- then closes; Esc closes. "Buy another" (reveal.next: hidden while more moons are locked,
|
||||
-- disabled with the server's reason when unaffordable) closes the window and buys the
|
||||
-- next moon, whose response plays a new reveal. The buttons take keyboard focus (scope
|
||||
-- "reveal", modules/focus) once the drawer is complete.
|
||||
--
|
||||
-- Times follow moon.revealing.t (the reveal clock, which mm.reveal_freeze can hold for
|
||||
-- screenshots), so every frame of the card can be captured.
|
||||
@@ -33,6 +38,7 @@ local test = require("modules.test")
|
||||
local tooltip = require("modules.tooltip")
|
||||
local flex = require("modules.flex")
|
||||
local fg = require("modules.flex_gui")
|
||||
local chip = require("components.chip")
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
@@ -41,8 +47,6 @@ local C = theme.color
|
||||
local GOLD_HI = C.ready_hi
|
||||
local FLASH = C.flash
|
||||
local MIST = C.mist
|
||||
local CARD_W = 440
|
||||
local CARD_PAD = 22
|
||||
|
||||
local function v3(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
@@ -98,57 +102,87 @@ function M.build_teaser(g, root, px, py, inner)
|
||||
gui.animate(n, "color.w", 0.45, gui.EASING_INOUTSINE, 1.2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
|
||||
end
|
||||
|
||||
-- Grid: the claim frame (1fr, at least 220) and the hints (2fr).
|
||||
-- The grade picker: a row per grade (cheapest first, the server's order), its price,
|
||||
-- wells and units per well, and its Claim button at the right (the same spot whatever
|
||||
-- is affordable; disabled with the server's reason). The grade "+ New moon" buys
|
||||
-- (moonlist.grade) has the primary button.
|
||||
local gap = 14
|
||||
local bw = math.max(220, math.floor((inner - gap) / 3))
|
||||
local hw = inner - bw - gap
|
||||
local bh = 196
|
||||
local small = "small"
|
||||
local left
|
||||
if nm.canAfford then
|
||||
left = { fg.label(root, "Leaves you", small, 16), space(6), credits(nm.after, { px = 13, color = "muted" }, 16) }
|
||||
else
|
||||
left = { fg.label(root, "You need", small, 16), space(6), credits(nm.need, { px = 13, color = "ready" }, 16), space(6),
|
||||
fg.label(root, "more", small, 16) }
|
||||
local grades = nm.grades or {}
|
||||
local pick = st.moonGrade
|
||||
for _, e in ipairs(grades) do
|
||||
if e.grade == g.nm_grade then pick = e.grade end
|
||||
end
|
||||
local buy = panel_box({ width = bw, height = bh, padding = 18, shrink = 0 }, {
|
||||
made({ position = "absolute", left = bw - 150, top = -40, width = 180, height = 150 }, function(p, x, y, w, h)
|
||||
gui.play_flipbook(ui.rect(p, x, y, w, h, "violet", 0.16), "glow")
|
||||
end),
|
||||
fg.label(root, "Claim price", "cap", 12), space(0, 18),
|
||||
credits(nm.price, { px = 34, color = "text", font = "mono_semi" }, 20), space(0, 16),
|
||||
row({ height = 16, align = "center" }, left),
|
||||
fg.fill(),
|
||||
fg.button(root, "Claim and reveal", { primary = true, disabled = not nm.canAfford }, function(b, nodes)
|
||||
if not nm.canAfford then
|
||||
tooltip.add(b, (nm.whyText or "") ~= "" and nm.whyText or "Not enough credits") -- (the web's title)
|
||||
return
|
||||
local function range(lo, hi, money)
|
||||
local f = money and ui.money or tostring
|
||||
return lo == hi and f(lo) or (f(lo) .. "–" .. f(hi))
|
||||
end
|
||||
local BW = 128
|
||||
local rows = {}
|
||||
for i, e in ipairs(grades) do
|
||||
local info = range(e.wellsMin, e.wellsMax) .. (e.wellsMin == 1 and e.wellsMax == 1 and " well" or " wells")
|
||||
.. " · " .. range(e.supplyMin, e.supplyMax, true) .. " units each"
|
||||
local why = not e.canAfford and ((e.whyText or "") ~= "" and e.whyText or "Not enough credits") or nil
|
||||
rows[#rows + 1] = row({ height = 52, align = "center", padding = { 0, 16, 0, 16 }, gap = 12 }, {
|
||||
made({ width = 28, height = 22, shrink = 0 }, function(p, x, y)
|
||||
chip.grade(p, e.grade, x, y + 11, { h = 22, w = 28, px = 14 })
|
||||
return nil
|
||||
end),
|
||||
col({ grow = 1, shrink = 1, gap = 4 }, {
|
||||
row({ height = 18, align = "center", gap = 10 }, {
|
||||
credits(e.price, { px = 16, color = e.canAfford and "text" or "muted", font = "mono_semi" }, 18),
|
||||
why and fg.label(root, why, { font = "sans", px = 13, color = "ready" }, 18, { fit = true, shrink = 1 }) or nil,
|
||||
}),
|
||||
fg.label(root, info, { font = "sans", px = 13, color = "text_2" }, 16, { fit = true, shrink = 1 }),
|
||||
}),
|
||||
fg.button(root, "Claim " .. e.grade, { primary = e.grade == pick, disabled = not e.canAfford }, function(b, nodes)
|
||||
if not e.canAfford then
|
||||
tooltip.add(b, why)
|
||||
return
|
||||
end
|
||||
-- (then "+ New moon" follows the grade just bought: state.moonGrade)
|
||||
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, include = "new-moon" },
|
||||
function() g.nm_grade = nil end, nil, "newmoon:grade:" .. e.grade)
|
||||
end, { width = BW }),
|
||||
}, i > 1 and { make = function(p, x, y, w)
|
||||
ui.rect(p, x + 16, y, w - 32, 1, "edge")
|
||||
return nil
|
||||
end } or nil)
|
||||
-- Moon packs (once the store is open): several at once, cheaper and better, a button
|
||||
-- each under the grade's facts.
|
||||
if #(e.packs or {}) > 0 then
|
||||
local packs = {}
|
||||
for _, pk in ipairs(e.packs) do
|
||||
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
|
||||
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
|
||||
local save = math.floor(pk.discount * 100 + 0.5)
|
||||
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
|
||||
.. " (" .. save .. "% off), each a little richer than a single one")
|
||||
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
|
||||
if pk.canAfford then
|
||||
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
|
||||
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
|
||||
end
|
||||
end)
|
||||
end
|
||||
-- The web's nm-glow: a breathing halo around the button.
|
||||
local halo = ui.rect(b, -12, -12, bw - 36 + 24, S.button_h + 24, "cyan", 0.0)
|
||||
gui.play_flipbook(halo, "glow")
|
||||
gui.move_below(halo, ui.part(nodes, "button", "edge"))
|
||||
gui.animate(halo, "color.w", 0.35, gui.EASING_INOUTSINE, 1, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
|
||||
g.h.action(g, b, nodes, "buy-moon", { include = "new-moon" })
|
||||
end, { width = bw - 36 }),
|
||||
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
|
||||
end
|
||||
end
|
||||
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
|
||||
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
|
||||
space(0, 4),
|
||||
col({}, rows),
|
||||
})
|
||||
|
||||
-- Hints: wells (and what they can hold), then the classes it could be (full width).
|
||||
local half = math.floor((hw - gap) / 2)
|
||||
local ww = half < 230 and hw or half
|
||||
-- (the web's WellRange: "3" when the bounds are equal)
|
||||
local range = (nm.wellsMin and nm.wellsMax) and (nm.wellsMin == nm.wellsMax and tostring(nm.wellsMin)
|
||||
or (nm.wellsMin .. "–" .. nm.wellsMax)) or "2–5"
|
||||
-- Hints: what the wells can hold, then the classes it could be.
|
||||
local bw = math.max(220, math.floor((inner - gap) / 3))
|
||||
local hw = inner - bw - gap
|
||||
local dots = {}
|
||||
for _, r in ipairs(nm.resources or {}) do
|
||||
dots[#dots + 1] = made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, r) end)
|
||||
end
|
||||
local wells = panel_box({ width = ww, height = 92, padding = { 16, 16, 0, 16 } }, {
|
||||
local wells = panel_box({ width = bw, height = 92, padding = { 16, 16, 0, 16 }, shrink = 0 }, {
|
||||
fg.label(root, "Wells", "cap", 12), space(0, 12),
|
||||
row({ height = 20, align = "start" }, {
|
||||
fg.label(root, range, { font = "mono_semi", px = 22, color = "text" }, 20), space(8),
|
||||
fg.label(root, "of these resources", { font = "sans", px = 14, color = "text_2" }, 24, { fit = true, shrink = 1 }),
|
||||
}),
|
||||
fg.label(root, "Any of these resources", { font = "sans", px = 14, color = "text_2" }, 20, { fit = true, shrink = 1 }),
|
||||
space(0, 10),
|
||||
row({ gap = 4 }, dots),
|
||||
})
|
||||
@@ -161,7 +195,7 @@ function M.build_teaser(g, root, px, py, inner)
|
||||
ui.outline(p, x, y, w, h, "line_hi")
|
||||
end })
|
||||
end
|
||||
local classes = panel_box({ width = hw, min_height = bh - 92 - gap, padding = { 16, 16, 14, 16 } }, {
|
||||
local classes = panel_box({ width = hw, min_height = 92, padding = { 16, 16, 14, 16 } }, {
|
||||
fg.label(root, "Its class could be", "cap", 12), space(0, 10),
|
||||
row({ wrap = true, column_gap = 6, row_gap = 6 }, chips),
|
||||
})
|
||||
@@ -172,7 +206,8 @@ function M.build_teaser(g, root, px, py, inner)
|
||||
fg.text_para(root, "Something is out there. Its surface, its wells and how rich they run stay hidden until you claim it. What will you find?",
|
||||
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) }),
|
||||
space(0, 22),
|
||||
row({ gap = gap, align = "start" }, { buy, col({ width = hw, gap = gap, shrink = 0 }, { wells, classes }) }),
|
||||
picker, space(0, gap),
|
||||
row({ gap = gap, align = "start" }, { wells, classes }),
|
||||
})
|
||||
fg.build(flex.solve(tree, px, py), root)
|
||||
end
|
||||
@@ -309,77 +344,130 @@ local function name_letters(parent, name, x, y, px)
|
||||
return letters, cx - x - tracking
|
||||
end
|
||||
|
||||
-- The window (fixed size for every moon: nothing about it hints at the moon before the
|
||||
-- drawer shows): the moon's square stage, centered while it charges and bursts; then the
|
||||
-- drawer slides out to its left while the stage slides right, the whole window centered.
|
||||
local STAGE = 500 -- the stage's side (= the window's and the drawer's height)
|
||||
local DRAWER_W = 400 -- (with numbers up to "960/960"; bigger moons widen it: drawer_w)
|
||||
local PAD = 20
|
||||
local SLIDE = 0.55 -- the drawer slides out (s)
|
||||
local LEAD = 0.3 -- the drawer's content starts this long into the slide (s)
|
||||
local CLOSE_W, ANOTHER_W = 150, 200 -- fixed: the buttons sit in the same spot every time
|
||||
local MAX_WELLS = 5 -- (one per resource: the wells' rows take this room always)
|
||||
local HOLD = 0.65 -- the moon alone, centered, after the burst (s)
|
||||
local SHAKE_AMP, SHAKE_DUR, SHAKE_FREQ = 18, 0.8, 9 -- the burst's Perlin shake
|
||||
|
||||
-- Starts the reveal for a buy-moon response's `reveal` (toast: shown when it closes).
|
||||
function M:start(data, toast)
|
||||
if self.r then
|
||||
self:finish(true)
|
||||
end
|
||||
local g = self.g
|
||||
local r = { data = data, toast = toast, stage = "charge", events = {}, offset = 0, quiet = false }
|
||||
local r = { data = data, toast = toast, stage = "charge", events = {}, offset = 0, quiet = false, k = 0 }
|
||||
self.r = r
|
||||
self.bound, self.card_bound = nil, nil
|
||||
-- Reduced motion (reveal.js quick): no charge-up, no particles or shake, the card fades
|
||||
-- in complete, silently; the moon only fades out of its shroud (moon.script).
|
||||
-- Reduced motion (reveal.js quick): no charge-up, slide, particles or shake; the window
|
||||
-- is laid out complete, the drawer fades in, silently; the moon only fades out of its
|
||||
-- shroud (moon.script).
|
||||
r.reduced = anim.reduced
|
||||
moon_view.prefetch(data.moonId) -- (its mesh starts now, whether the viewport shows yet or not)
|
||||
if g.viewport_node then
|
||||
moon_view.reveal(data.moonId, g.viewport_node)
|
||||
if moon_view.revealing then
|
||||
moon_view.revealing.reduced = r.reduced
|
||||
end
|
||||
else
|
||||
r.need_view = true -- (bought from a view without the aside: it shows on the rebuild)
|
||||
if r.reduced then
|
||||
r.k = 1
|
||||
end
|
||||
-- Nothing else on the layer: a full-screen click target, the dim with a hole for the
|
||||
-- viewport, the viewport's charge glow, the "?" leaving, then the card.
|
||||
local W, H = g.size[1], g.size[2]
|
||||
-- On the layer: a full-screen click target, the dim, the window (shaken as a whole):
|
||||
-- the drawer (under the stage: it slides out from behind it), the stage (halo, the moon
|
||||
-- node, edge, charge ring, the "?").
|
||||
r.root = group(self.layer, 0, 0)
|
||||
gui.set_inherit_alpha(r.root, false) -- the layer node itself is transparent
|
||||
gui.set_color(r.root, vmath.vector4(1, 1, 1, 0))
|
||||
r.hit = node_box(r.root, 0, 0, W, H, vmath.vector4(0, 0, 0, 0))
|
||||
r.dim = {}
|
||||
for i = 1, 4 do
|
||||
r.dim[i] = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.82))
|
||||
end
|
||||
r.ring = node_box(r.root, 0, 0, 1, 1, rgba(MIST, 0), "outline")
|
||||
gui.set_slice9(r.ring, vmath.vector4(2, 2, 2, 2))
|
||||
r.halo = node_box(r.root, 0, 0, 1, 1, rgba(C.violet, 0), "glow")
|
||||
r.hit = node_box(r.root, 0, 0, 1, 1, vmath.vector4(0, 0, 0, 0))
|
||||
r.dim = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.86))
|
||||
r.win = group(r.root, 0, 0)
|
||||
r.drawer = group(r.win, 0, 0)
|
||||
r.stage_n = group(r.win, 0, 0)
|
||||
r.halo = node_box(r.stage_n, -STAGE * 0.2, -STAGE * 0.2, STAGE * 1.4, STAGE * 1.4, rgba(C.violet, 0), "glow")
|
||||
gui.set_blend_mode(r.halo, theme.glow_blend())
|
||||
gui.move_below(r.halo, r.ring)
|
||||
if g.view_was_mystery then
|
||||
local vr = g.viewport_rect
|
||||
r.question = text(r.root, "?", { font = "mono_semi", px = math.floor(math.max(64, math.min(140, vr.w * 0.32))), color = MIST },
|
||||
vr.x + vr.w / 2, vr.y + vr.h / 2, gui.PIVOT_CENTER)
|
||||
r.question_scale = gui.get_scale(r.question).x
|
||||
end
|
||||
r.vp = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.viewport, 1))
|
||||
local edge = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.line_hi, 0.8), "outline")
|
||||
gui.set_slice9(edge, vmath.vector4(2, 2, 2, 2))
|
||||
r.ring = node_box(r.stage_n, -2, -2, STAGE + 4, STAGE + 4, rgba(MIST, 0), "outline")
|
||||
gui.set_slice9(r.ring, vmath.vector4(2, 2, 2, 2))
|
||||
r.question = text(r.stage_n, "?", { font = "mono_semi", px = 140, color = MIST }, STAGE / 2, STAGE / 2, gui.PIVOT_CENTER)
|
||||
r.question_scale = gui.get_scale(r.question).x
|
||||
gui.set_color(r.question, rgba(MIST, r.reduced and 0.85 or 0.5))
|
||||
self:place()
|
||||
moon_view.prefetch(data.moonId) -- (its mesh starts now)
|
||||
moon_view.reveal(data.moonId, r.vp)
|
||||
if moon_view.revealing then
|
||||
moon_view.revealing.reduced = r.reduced
|
||||
end
|
||||
if not r.reduced then
|
||||
sfx.play("charge")
|
||||
sfx.play("rumble")
|
||||
end
|
||||
self:bind()
|
||||
print("mm: reveal " .. tostring(data.name) .. " (" .. tostring(data.rating and data.rating.tier) .. ")")
|
||||
end
|
||||
|
||||
-- Puts the dim around the viewport (it moves with the layout).
|
||||
-- The drawer's width for this moon: the wells' "supply/max" column fits the grade's
|
||||
-- biggest numbers ("50,440/50,600" on an S moon), and the drawer grows by what it needs.
|
||||
local NUM_W = 64
|
||||
local function drawer_w(r)
|
||||
if r.dw then
|
||||
return r.dw, r.num_w
|
||||
end
|
||||
local d = r.data
|
||||
if not d then
|
||||
return DRAWER_W, NUM_W
|
||||
end
|
||||
local max = (d.maxSupply and d.maxSupply > 0) and d.maxSupply or 90
|
||||
local n = #ui.money(max)
|
||||
r.num_w = math.max(NUM_W, math.ceil(n * 8 + (n + 1) * 7.3 + 6)) -- (mono 13 px, then "/max" in mono 12 px)
|
||||
r.dw = DRAWER_W + r.num_w - NUM_W
|
||||
return r.dw, r.num_w
|
||||
end
|
||||
|
||||
-- The window's place for the layout size (centered; it moves with nothing else).
|
||||
function M:place()
|
||||
local r = self.r
|
||||
local g = self.g
|
||||
local W, H = g.size[1], g.size[2]
|
||||
local v = g.viewport_rect or { x = W - 400, y = 100, w = 360, h = 360 }
|
||||
gui.set_size(r.hit, v3(W, H))
|
||||
local rects = {
|
||||
{ 0, 0, W, v.y }, { 0, v.y + v.h, W, H - v.y - v.h },
|
||||
{ 0, v.y, v.x, v.h }, { v.x + v.w, v.y, W - v.x - v.w, v.h },
|
||||
}
|
||||
for i, q in ipairs(rects) do
|
||||
gui.set_position(r.dim[i], v3(q[1], -q[2]))
|
||||
gui.set_size(r.dim[i], v3(math.max(0, q[3]), math.max(0, q[4])))
|
||||
gui.set_position(r.dim, v3(-80, 80)) -- (bigger than the screen: the shake moves it)
|
||||
gui.set_size(r.dim, v3(W + 160, H + 160))
|
||||
r.L = math.floor((W - drawer_w(r) - STAGE) / 2)
|
||||
r.T = math.floor((H - STAGE) / 2)
|
||||
self:pose()
|
||||
end
|
||||
|
||||
-- Positions at the slide's progress r.k, with the shake, a verdict's jolt and the close's
|
||||
-- drop; the 3D moon follows its node.
|
||||
function M:pose()
|
||||
local r = self.r
|
||||
local half = drawer_w(r) / 2
|
||||
gui.set_position(r.stage_n, v3(r.L + half * (1 + r.k), -r.T))
|
||||
local jx, jy = 0, 0
|
||||
if r.jolt_at then
|
||||
local j = (socket.gettime() - r.jolt_at) / 0.18
|
||||
if j < 1 then
|
||||
local q = math.sin(j * math.pi)
|
||||
jx, jy = 2 * q, 4 * q
|
||||
end
|
||||
end
|
||||
gui.set_position(r.drawer, v3(r.L + half * (1 - r.k) + jx, -(r.T + jy)))
|
||||
local ox, oy = 0, 0
|
||||
if r.shake_at then
|
||||
local e = socket.gettime() - r.shake_at
|
||||
if e < SHAKE_DUR then
|
||||
local a = SHAKE_AMP * (1 - e / SHAKE_DUR) ^ 2
|
||||
ox = anim.perlin(e * SHAKE_FREQ, r.seed) * a
|
||||
oy = anim.perlin(e * SHAKE_FREQ, r.seed + 31.7) * a
|
||||
end
|
||||
end
|
||||
gui.set_position(r.win, v3(ox, oy - (r.drop or 0)))
|
||||
local at = gui.get_screen_position(r.vp)
|
||||
if moon_view.revealing and at ~= r.vp_at then
|
||||
r.vp_at = at
|
||||
moon_view.move(r.vp, true)
|
||||
end
|
||||
gui.set_position(r.ring, v3(v.x - 2, -(v.y - 2)))
|
||||
gui.set_size(r.ring, v3(v.w + 4, v.h + 4))
|
||||
gui.set_position(r.halo, v3(v.x - v.w * 0.18, -(v.y - v.h * 0.18)))
|
||||
gui.set_size(r.halo, v3(v.w * 1.36, v.h * 1.36))
|
||||
r.v = v
|
||||
end
|
||||
|
||||
-- Druid components for the layer: once per Druid instance (the game screen's lives until
|
||||
@@ -419,23 +507,37 @@ function M:bind()
|
||||
end
|
||||
if r.card and self.card_bound ~= g.druid then
|
||||
self.card_bound = g.druid
|
||||
self.components[#self.components + 1] = first(g.druid:new_button(r.close_btn, function()
|
||||
-- No Druid scale on hover (it grows from the corner): the fill brightens instead.
|
||||
local function lit(b, root)
|
||||
local base = gui.get_color(root)
|
||||
b.hover.on_mouse_hover:subscribe(function(_, on)
|
||||
local a = gui.get_color(root).w
|
||||
local c = on and vmath.vector4(math.min(1, base.x * 1.15 + 0.04), math.min(1, base.y * 1.15 + 0.04), math.min(1, base.z * 1.15 + 0.04), a)
|
||||
or vmath.vector4(base.x, base.y, base.z, a)
|
||||
gui.animate(root, "color", c, gui.EASING_OUTQUAD, 0.12)
|
||||
end)
|
||||
return b
|
||||
end
|
||||
self.components[#self.components + 1] = first(lit(g.druid:new_button(r.close_btn, function()
|
||||
if r.done then
|
||||
self:close()
|
||||
else
|
||||
self:advance()
|
||||
end
|
||||
end))
|
||||
end):set_animations_disabled(), r.close_btn))
|
||||
if r.another_btn and r.another_ok then
|
||||
self.components[#self.components + 1] = first(g.druid:new_button(r.another_btn, function()
|
||||
self.components[#self.components + 1] = first(lit(g.druid:new_button(r.another_btn, function()
|
||||
self:buy_another()
|
||||
end))
|
||||
end):set_animations_disabled(), r.another_btn))
|
||||
end
|
||||
end
|
||||
-- (every build resets these)
|
||||
if r.card then
|
||||
if g.autoplay then
|
||||
g.autoplay:register("reveal-close", {}, function() self:close() end)
|
||||
if r.another_btn and r.another_ok then
|
||||
g.autoplay:register("reveal-buy", {}, function() self:buy_another() end)
|
||||
end
|
||||
end
|
||||
if r.another_btn and not r.another_ok then
|
||||
tooltip.add(r.another_btn, r.another_why)
|
||||
@@ -445,13 +547,6 @@ end
|
||||
|
||||
function M:after_build()
|
||||
if self.r then
|
||||
if self.r.need_view and self.g.viewport_node then
|
||||
self.r.need_view = nil
|
||||
moon_view.reveal(self.r.data.moonId, self.g.viewport_node)
|
||||
if moon_view.revealing then
|
||||
moon_view.revealing.reduced = self.r.reduced
|
||||
end
|
||||
end
|
||||
self:place()
|
||||
self:bind()
|
||||
end
|
||||
@@ -464,7 +559,7 @@ function M:card_t()
|
||||
if not (mr and r.card_at) then
|
||||
return -1
|
||||
end
|
||||
return mr.t - r.card_at + r.offset
|
||||
return mr.t - r.card_at + r.offset - (r.reduced and 0 or LEAD)
|
||||
end
|
||||
|
||||
-- Skip ahead: charge -> burst, card -> its end state; then close.
|
||||
@@ -543,48 +638,72 @@ function M:on_burst()
|
||||
local r = self.r
|
||||
local g = self.g
|
||||
r.stage = "burst"
|
||||
local v = r.v
|
||||
local cx, cy = v.x + v.w / 2, v.y + v.h / 2
|
||||
local cx, cy = r.L + drawer_w(r) / 2 * (1 + r.k) + STAGE / 2, r.T + STAGE / 2
|
||||
local colors = {}
|
||||
for _, w in ipairs(r.data.wells or {}) do
|
||||
colors[#colors + 1] = theme.resource[w.resource] or C.cyan
|
||||
end
|
||||
self:play("burst")
|
||||
self:play("moon", { delay = 80 })
|
||||
g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 90, 1.8)
|
||||
g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 40, 1.1)
|
||||
g.fx:flash(cx, cy, math.max(v.w, v.h) * 0.75, 0.55)
|
||||
r.shake_at = socket.gettime()
|
||||
-- The burst shakes the aside and the header (reveal.js, 340 ms of decaying noise).
|
||||
for _, n in ipairs({ g.aside_panel and g.aside_panel.root, g.header_node }) do
|
||||
anim.noise_shake(n, 5, 0.34)
|
||||
sfx.stop("rumble")
|
||||
self:play("reveal")
|
||||
self:play("moon", { delay = 60, gain = 0.55 })
|
||||
g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 110, 2.1)
|
||||
g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 50, 1.3)
|
||||
g.fx:flash(cx, cy, STAGE * 0.8, 0.6)
|
||||
r.q_out = socket.gettime()
|
||||
if not r.reduced then
|
||||
-- A strong, smooth shake (two octaves of Perlin noise, decaying): the window and,
|
||||
-- under the dim, the page.
|
||||
r.shake_at, r.seed = socket.gettime(), math.random() * 100
|
||||
anim.perlin_shake(g.layout, SHAKE_AMP * 0.6, SHAKE_DUR, SHAKE_FREQ)
|
||||
end
|
||||
local mr = moon_view.revealing
|
||||
r.card_at = (mr and mr.t or 0) + (r.reduced and 0 or 0.42)
|
||||
r.card_at = (mr and mr.t or 0) + (r.reduced and 0 or HOLD)
|
||||
end
|
||||
|
||||
---------------------------------------------------------------- the card
|
||||
|
||||
-- Cuts a paragraph node's text at a word to fit maxh (with "...").
|
||||
local function fit_para(node, str, w, maxh)
|
||||
local s = gui.get_scale(node).x
|
||||
local font = gui.get_font_resource(gui.get_font(node))
|
||||
local function height(t)
|
||||
return resource.get_text_metrics(font, ui.esc(t), { line_break = true, width = w / s, leading = 1 }).height * s
|
||||
end
|
||||
if height(str) <= maxh then
|
||||
return
|
||||
end
|
||||
local words = {}
|
||||
for wd in str:gmatch("%S+") do
|
||||
words[#words + 1] = wd
|
||||
end
|
||||
for n = #words - 1, 1, -1 do
|
||||
local t = table.concat(words, " ", 1, n) .. "..."
|
||||
if height(t) <= maxh then
|
||||
gui.set_text(node, ui.esc(t))
|
||||
return
|
||||
end
|
||||
end
|
||||
gui.set_text(node, "")
|
||||
end
|
||||
|
||||
-- The drawer's content (built when it starts sliding out), laid out at the drawer's fixed
|
||||
-- size: the rows of wells always take MAX_WELLS rows, the description the room left.
|
||||
function M:build_card()
|
||||
local r = self.r
|
||||
local g = self.g
|
||||
local d = r.data
|
||||
local st = g.state
|
||||
local W, H = g.size[1], g.size[2]
|
||||
local tier = d.rating and d.rating.tier or "fair"
|
||||
local tc, thi = tier_colors(tier)
|
||||
local big = tier == "great" or tier == "exceptional"
|
||||
local w = CARD_W
|
||||
local inner = w - CARD_PAD * 2
|
||||
local w, h = drawer_w(r), STAGE
|
||||
local inner = w - PAD * 2
|
||||
|
||||
-- The card: a group at its center (it scales from there), the body top-left inside.
|
||||
local card = group(r.root, 0, 0)
|
||||
local body = group(card, 0, 0)
|
||||
local bg = node_box(body, 0, 0, w, 10, rgba(C.field, 0.97))
|
||||
local body = group(r.drawer, 0, 0)
|
||||
node_box(body, 0, 0, w, h, rgba(C.field, 1))
|
||||
local glow = node_box(body, w * 0.1, -40, w * 0.8, 160, rgba(big and C.ready or C.cyan, tier == "poor" and 0 or 0.12), "glow")
|
||||
local edge = node_box(body, 0, 0, w, 10, rgba(tier == "poor" and C.line_hi or tc, tier == "exceptional" and 0.7 or 0.45), "outline")
|
||||
gui.set_blend_mode(glow, theme.glow_blend())
|
||||
local edge = node_box(body, 0, 0, w, h, rgba(tier == "poor" and C.line_hi or tc, tier == "exceptional" and 0.7 or 0.45), "outline")
|
||||
gui.set_slice9(edge, vmath.vector4(2, 2, 2, 2))
|
||||
local corners = node_box(body, 0, 0, w, 10, rgba(tc, 0.9), "frame")
|
||||
local corners = node_box(body, 0, 0, w, h, rgba(tc, 0.9), "frame")
|
||||
gui.set_slice9(corners, vmath.vector4(10, 10, 10, 10))
|
||||
local el = {} -- animated elements: { node, kind, delay, dur, base }
|
||||
|
||||
@@ -599,24 +718,33 @@ function M:build_card()
|
||||
local probe = ui.text(body, label, { font = "sans_semi", px = 12, tracking = 0.16, upper = true }, 0, 0)
|
||||
local bw, bh = math.max(88, math.ceil(ui.text_width(probe)) + 20), 46
|
||||
gui.delete_node(probe)
|
||||
-- Trait chips (their widths), the description (made now, placed after), the wells.
|
||||
-- Trait chips (their widths): one line, as many as fit.
|
||||
local trait_style = { font = "sans", px = 13, color = thi }
|
||||
local trait_boxes = {}
|
||||
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
|
||||
local trait_boxes, trait_names = {}, {}
|
||||
local tx = 0
|
||||
for _, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
|
||||
local t = text(body, tr, trait_style, 0, 0)
|
||||
ui.fit_text(t, inner - 16)
|
||||
trait_boxes[i] = fixed(ui.text_width(t) + 16, 22)
|
||||
local tw = ui.text_width(t) + 16
|
||||
gui.delete_node(t)
|
||||
if tx + tw > inner then
|
||||
break
|
||||
end
|
||||
trait_boxes[#trait_boxes + 1] = fixed(tw, 22)
|
||||
trait_names[#trait_names + 1] = tr
|
||||
tx = tx + tw + 6
|
||||
end
|
||||
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
|
||||
local _, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
|
||||
local well_boxes = {}
|
||||
for i = 1, #(d.wells or {}) do
|
||||
well_boxes[i] = fixed(inner, 26)
|
||||
for i = 1, MAX_WELLS do
|
||||
well_boxes[i] = fixed(inner, 22)
|
||||
end
|
||||
|
||||
-- The foot's buttons are made now (in a group placed after the solve): their widths.
|
||||
-- The foot's buttons (fixed widths, in fixed slots: Buy another's stays empty when
|
||||
-- there's no next moon).
|
||||
local foot = group(body, 0, 0)
|
||||
local close_b, close_nodes, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true })
|
||||
local close_b, close_nodes, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true, w = CLOSE_W })
|
||||
close_w = close_w or CLOSE_W
|
||||
for _, n in pairs(close_nodes) do
|
||||
gui.set_inherit_alpha(n, true)
|
||||
end
|
||||
@@ -627,7 +755,8 @@ function M:build_card()
|
||||
local price = next_moon.price
|
||||
local ok = next_moon.canBuy == true
|
||||
local ab, an
|
||||
ab, an, aw = ui.button(foot, "Buy another", 0, 0, { disabled = not ok })
|
||||
local label = (next_moon.grade or "") ~= "" and ("Buy another " .. next_moon.grade) or "Buy another"
|
||||
ab, an = ui.button(foot, label, 0, 0, { disabled = not ok, w = ANOTHER_W })
|
||||
for _, n in pairs(an) do
|
||||
gui.set_inherit_alpha(n, true)
|
||||
end
|
||||
@@ -640,14 +769,27 @@ function M:build_card()
|
||||
local sep = text(ab, "·", { font = "sans_semi", px = 15, color = ok and "text" or "muted" }, 0, 0)
|
||||
local sym = text(ab, theme.currency, { font = "sym", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
|
||||
local num = text(ab, ui.money(price), { font = "mono", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
|
||||
local row = B({ align = "center", height = hgt, padding = { 0, 20 } }, {
|
||||
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(8, 0),
|
||||
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(8, 0),
|
||||
{ node = sym, size = false, style = { width = ui.text_width(sym), height = hgt } }, fixed(1, 0),
|
||||
{ node = num, size = false, style = { width = ui.text_width(num), height = hgt } },
|
||||
})
|
||||
flex.solve(row, 0, 0)
|
||||
aw = math.floor(row.w)
|
||||
local function solve(pad, gap)
|
||||
local row = B({ align = "center", height = hgt, padding = { 0, pad } }, {
|
||||
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(gap, 0),
|
||||
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(gap, 0),
|
||||
{ node = sym, size = false, style = { width = ui.text_width(sym), height = hgt } }, fixed(1, 0),
|
||||
{ node = num, size = false, style = { width = ui.text_width(num), height = hgt } },
|
||||
})
|
||||
flex.solve(row, 0, 0)
|
||||
return row
|
||||
end
|
||||
-- It must fit the card beside Start mining: else tighter, then a shorter label.
|
||||
local room = inner - 10 - close_w
|
||||
local row = solve(16, 8)
|
||||
if row.w > room then
|
||||
row = solve(12, 6)
|
||||
end
|
||||
if row.w > room then
|
||||
gui.set_text(pt, ui.esc(string.upper((next_moon.grade or "") ~= "" and ("Another " .. next_moon.grade) or "Another")))
|
||||
row = solve(12, 6)
|
||||
end
|
||||
aw = math.floor(math.max(math.min(ANOTHER_W, room), row.w))
|
||||
for _, k in ipairs(row.children) do
|
||||
if k.node then
|
||||
gui.set_position(k.node, v3(k.x + (aw - row.w) / 2, -hgt / 2))
|
||||
@@ -659,26 +801,23 @@ function M:build_card()
|
||||
end
|
||||
|
||||
local has_class = d.kind and d.kind ~= "" and d.kind ~= d.name
|
||||
local tree = B({ direction = "column", width = w, padding = CARD_PAD }, {
|
||||
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(14),
|
||||
B({ height = 52, shrink = 0 }, {
|
||||
B({ width = 52, height = 52, shrink = 0 }, {}, "planet"), fixed(14, 0),
|
||||
B({ direction = "column", grow = 1 }, { B({ height = 36 }, {}, "name"), B({ height = 12 }, {}, "class") }),
|
||||
}),
|
||||
vspace(16),
|
||||
local tree = B({ direction = "column", width = w, height = h, padding = PAD }, {
|
||||
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(10),
|
||||
B({ height = 36, shrink = 0 }, {}, "name"), vspace(2),
|
||||
B({ height = 12, shrink = 0 }, {}, "class"), vspace(14),
|
||||
B({ height = 56, shrink = 0 }, {
|
||||
B({ width = bw, height = 52, shrink = 0 }, {}, "badge"), fixed(12, 0),
|
||||
B({ grow = 1, shrink = 1 }, {}, "head"),
|
||||
}),
|
||||
vspace(14),
|
||||
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(8),
|
||||
vspace(12),
|
||||
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(10),
|
||||
#trait_boxes > 0 and B({ direction = "column", shrink = 0 }, {
|
||||
B({ wrap = true, column_gap = 6, row_gap = 6 }, trait_boxes, "traits"), vspace(10),
|
||||
B({ column_gap = 6 }, trait_boxes, "traits"), vspace(10),
|
||||
}) or vspace(0),
|
||||
B({ height = dh, shrink = 0 }, {}, "desc"), vspace(14),
|
||||
B({ grow = 1, shrink = 1, basis = 0 }, {}, "desc"), vspace(10),
|
||||
B({ direction = "column", shrink = 0 }, well_boxes), vspace(12),
|
||||
B({ height = theme.size.button_h, justify = "end", shrink = 0 }, {
|
||||
aw and fixed(aw, theme.size.button_h) or fixed(0, 0), fixed(aw and 10 or 0, 0), fixed(close_w, theme.size.button_h),
|
||||
fixed(aw or ANOTHER_W, theme.size.button_h), fixed(10, 0), fixed(close_w, theme.size.button_h),
|
||||
}, "foot"),
|
||||
})
|
||||
flex.solve(tree, 0, 0)
|
||||
@@ -686,14 +825,18 @@ function M:build_card()
|
||||
|
||||
local kicker = text(body, "New moon claimed", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.2, upper = true }, at.kicker.x, at.kicker.y + 6)
|
||||
el[#el + 1] = { node = kicker, kind = "rise", delay = 0.06 }
|
||||
if (d.grade or "") ~= "" then
|
||||
-- Its grade badge after the kicker (fades with its box: inherit_alpha down the chip).
|
||||
local gb
|
||||
for _, n in ipairs(ui.collect(function()
|
||||
gb = chip.grade(body, d.grade, at.kicker.x + ui.text_width(kicker) + 10, at.kicker.y + 6)
|
||||
end)) do
|
||||
gui.set_inherit_alpha(n, true)
|
||||
end
|
||||
el[#el + 1] = { node = gb, kind = "rise", delay = 0.06 }
|
||||
end
|
||||
|
||||
-- Head: the planet and the name (typed in), its class under it.
|
||||
local pb = at.planet
|
||||
local planet = group(body, pb.x, pb.y)
|
||||
local pglow = node_box(planet, -14, 14, 80, 80, rgba(C.cyan, 0.3), "glow")
|
||||
gui.set_blend_mode(pglow, theme.glow_blend())
|
||||
node_box(planet, 0, 0, 52, 52, ui.moon_tint(d.moonId), "sphere")
|
||||
el[#el + 1] = { node = planet, kind = "rise", delay = 0.12 }
|
||||
-- The name (typed in), its class under it.
|
||||
local nb = at.name
|
||||
local px = 30
|
||||
local letters, nw = name_letters(body, d.name or "", nb.x, nb.y + nb.h / 2, px)
|
||||
@@ -717,9 +860,9 @@ function M:build_card()
|
||||
end
|
||||
|
||||
-- The verdict: badge (stars and tier), headline and sub. Placed now, shown last.
|
||||
local verdict_y = at.badge.y
|
||||
local badge = group(body, at.badge.x + bw / 2, at.badge.y + 26)
|
||||
local bbg = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba(tc, (tier == "poor" or tier == "fair") and 0 or 0.1))
|
||||
r.badge_c = { x = at.badge.x + bw / 2, y = at.badge.y + 26 }
|
||||
node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba(tc, (tier == "poor" or tier == "fair") and 0 or 0.1))
|
||||
local bedge = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba((tier == "poor" or tier == "fair") and C.line_hi or tc, 1), "outline")
|
||||
gui.set_slice9(bedge, vmath.vector4(2, 2, 2, 2))
|
||||
local stars = d.rating and d.rating.stars or 0
|
||||
@@ -762,7 +905,7 @@ function M:build_card()
|
||||
|
||||
-- Traits: chips in the tier's color.
|
||||
local traits = {}
|
||||
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
|
||||
for i, tr in ipairs(trait_names) do
|
||||
local tb = trait_boxes[i]
|
||||
local chip = group(body, tb.x, tb.y)
|
||||
local t = text(chip, tr, trait_style, 8, 11)
|
||||
@@ -772,17 +915,21 @@ function M:build_card()
|
||||
traits[#traits + 1] = chip
|
||||
end
|
||||
|
||||
-- Description.
|
||||
-- Description, cut to its room.
|
||||
gui.set_position(desc, v3(at.desc.x, -at.desc.y))
|
||||
fit_para(desc, d.description or "", inner, at.desc.h)
|
||||
el[#el + 1] = { node = desc, kind = "rise", delay = 0.52 }
|
||||
|
||||
-- Wells: they pop in one by one, their bars filling.
|
||||
local wells = {}
|
||||
local max = (d.maxSupply and d.maxSupply > 0) and d.maxSupply or 90
|
||||
local bar_x, num_w = 132, 64
|
||||
local bar_x, num_w = 120, select(2, drawer_w(r))
|
||||
local bar_w = inner - bar_x - num_w - 10
|
||||
for i, wl in ipairs(d.wells or {}) do
|
||||
local wb = well_boxes[i]
|
||||
if not wb then
|
||||
break
|
||||
end
|
||||
local row = group(body, wb.x, wb.y)
|
||||
local full = wl.full
|
||||
node_box(row, 0, 7, 8, 8, theme.resource[wl.resource] or C.cyan)
|
||||
@@ -804,7 +951,7 @@ function M:build_card()
|
||||
end)
|
||||
end
|
||||
|
||||
-- Foot: Buy another (the next moon, straight away) and Start mining.
|
||||
-- Foot: Buy another (the next moon, straight away) and Start mining, in their slots.
|
||||
local fb = at.foot
|
||||
gui.set_position(foot, v3(fb.x, -fb.y))
|
||||
local fk = fb.children
|
||||
@@ -820,24 +967,6 @@ function M:build_card()
|
||||
test.register("reveal:buy", r.another_btn, "button", "Buy another")
|
||||
end
|
||||
test.set_scope("page")
|
||||
local h = tree.h
|
||||
|
||||
for _, n in ipairs({ bg, edge, corners }) do
|
||||
gui.set_size(n, v3(w, h))
|
||||
end
|
||||
gui.set_position(body, v3(-w / 2, h / 2))
|
||||
|
||||
-- Beside the viewport where there's room (so the moon stays in sight), else centered.
|
||||
local v = r.v
|
||||
local cx = W / 2
|
||||
if v.x >= w + 32 then
|
||||
cx = v.x / 2
|
||||
elseif W - (v.x + v.w) >= w + 32 then
|
||||
cx = (v.x + v.w + W) / 2
|
||||
end
|
||||
local ccy = math.max(h / 2 + 16, math.min(H - h / 2 - 16, H / 2))
|
||||
gui.set_position(card, v3(cx, -ccy))
|
||||
r.card_pos = vmath.vector3(cx, -ccy, 0)
|
||||
|
||||
-- The verdict lands last.
|
||||
local n = #wells
|
||||
@@ -852,9 +981,8 @@ function M:build_card()
|
||||
local quiet_tier = tier == "poor" or tier == "fair"
|
||||
el[#el + 1] = { node = badge, kind = quiet_tier and "fade" or "stamp", delay = wend, dur = quiet_tier and 0.36 or (big and 0.38 or 0.32), big = big }
|
||||
local hit = wend + (quiet_tier and 0 or (big and 0.27 or 0.22))
|
||||
r.verdict_y = verdict_y
|
||||
self:at(hit, function()
|
||||
self:verdict(tier, badge, card, h)
|
||||
self:verdict(tier)
|
||||
end)
|
||||
-- Complete once the foot is in: the next click closes it.
|
||||
self:at(r.card_end, function()
|
||||
@@ -867,7 +995,7 @@ function M:build_card()
|
||||
e.base = gui.get_position(e.node)
|
||||
e.scale = gui.get_scale(e.node)
|
||||
end
|
||||
r.card, r.body, r.el, r.wells, r.h = card, body, el, wells, h
|
||||
r.card, r.el, r.wells = body, el, wells
|
||||
if r.reduced then
|
||||
-- Complete at once: no typed name, pops, stamp or verdict sounds.
|
||||
r.events, r.shimmer, r.done = {}, nil, true
|
||||
@@ -877,20 +1005,17 @@ function M:build_card()
|
||||
end
|
||||
|
||||
-- The badge stamps down: sounds and sparks by tier (reveal.js).
|
||||
function M:verdict(tier, badge, card, h)
|
||||
function M:verdict(tier)
|
||||
local r = self.r
|
||||
local g = self.g
|
||||
local fx = g.fx
|
||||
local cp = r.card_pos
|
||||
local bp = gui.get_position(badge)
|
||||
local body = gui.get_position(r.body)
|
||||
local c = { x = cp.x + body.x + bp.x, y = -(cp.y + body.y + bp.y) }
|
||||
local top = { x = cp.x - CARD_W / 2, y = -cp.y - h / 2 }
|
||||
local big = tier == "great" or tier == "exceptional"
|
||||
local gold = { C.ready, GOLD_HI }
|
||||
local fx = self.g.fx
|
||||
if r.quiet then
|
||||
return
|
||||
end
|
||||
-- (the drawer is out by now: its place is the window's left)
|
||||
local c = { x = r.L + r.badge_c.x, y = r.T + r.badge_c.y }
|
||||
local top = { x = r.L, y = r.T }
|
||||
local big = tier == "great" or tier == "exceptional"
|
||||
local gold = { C.ready, GOLD_HI }
|
||||
if big then
|
||||
self:play("burst", { rate = 1.5, gain = 0.5 })
|
||||
self:play("shimmer", { delay = 60 })
|
||||
@@ -901,7 +1026,7 @@ function M:verdict(tier, badge, card, h)
|
||||
local t = self:card_t() + 0.08 + i * 0.07
|
||||
self:at(t, function()
|
||||
if not self.r.quiet then
|
||||
fx:burst(top.x + CARD_W * (i + 0.5) / n, top.y, gold, 18, 0.8)
|
||||
fx:burst(top.x + drawer_w(self.r) * (i + 0.5) / n, top.y, gold, 18, 0.8)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -928,29 +1053,9 @@ function M:verdict(tier, badge, card, h)
|
||||
end
|
||||
end
|
||||
|
||||
-- Positions, scales and alphas of the card's parts at card time t.
|
||||
-- Positions, scales and alphas of the drawer's parts at card time t.
|
||||
function M:animate_card(t)
|
||||
local r = self.r
|
||||
-- The card: scale 0.7 -> 1.04 (60%) -> 1, fading in, over 0.48 s.
|
||||
local k = prog(t, 0, 0.48)
|
||||
local s, a
|
||||
if k < 0.6 then
|
||||
local e = ease(k / 0.6)
|
||||
s, a = 0.7 + 0.34 * e, e
|
||||
else
|
||||
s, a = 1.04 - 0.04 * ease((k - 0.6) / 0.4), 1
|
||||
end
|
||||
local pos = r.card_pos
|
||||
if r.jolt_at then
|
||||
local j = (socket.gettime() - r.jolt_at) / 0.18
|
||||
if j < 1 then
|
||||
local q = math.sin(j * math.pi)
|
||||
pos = pos + v3(2 * q, -4 * q)
|
||||
end
|
||||
end
|
||||
gui.set_position(r.card, pos)
|
||||
gui.set_scale(r.card, v3(s, s))
|
||||
gui.set_color(r.card, vmath.vector4(1, 1, 1, a))
|
||||
-- Letters: rise, shrink from 1.4 and go from white to the text color.
|
||||
for i, l in ipairs(r.letters) do
|
||||
local lk = ease(prog(t, 0.18 + (i - 1) * 0.04, 0.26))
|
||||
@@ -1026,37 +1131,41 @@ function M:update(dt)
|
||||
if not r then
|
||||
return
|
||||
end
|
||||
local mr = moon_view.revealing
|
||||
if r.closing then
|
||||
self:step_close()
|
||||
return
|
||||
end
|
||||
if r.need_view then
|
||||
return
|
||||
end
|
||||
local mr = moon_view.revealing
|
||||
if not mr then
|
||||
-- The viewport moved on (another moon shown): end at once.
|
||||
-- The moon view moved on (another moon shown): end at once.
|
||||
self:finish(true)
|
||||
return
|
||||
end
|
||||
local T = mr.t
|
||||
-- The dim fades in over 0.4 s; the viewport's edge charges up over 1.1 s.
|
||||
-- The dim and the window fade in over 0.4 s; the stage's edge charges up over 1.1 s.
|
||||
gui.set_color(r.root, vmath.vector4(1, 1, 1, math.min(1, T / (r.reduced and 0.12 or 0.4))))
|
||||
if r.stage == "charge" then
|
||||
local k = r.reduced and 0 or math.min(1, T / 1.1)
|
||||
gui.set_color(r.ring, rgba(vmath.lerp(k, C.cyan, MIST), 0.9 * k * k))
|
||||
gui.set_color(r.halo, rgba(C.violet, 0.55 * k * k))
|
||||
if r.question and not r.reduced then
|
||||
-- The "?" brightens and trembles as the charge builds.
|
||||
local s = r.question_scale * (1 + 0.08 * k + 0.025 * k * k * math.sin(T * 47))
|
||||
gui.set_scale(r.question, v3(s, s))
|
||||
gui.set_color(r.question, rgba(MIST, 0.5 + 0.45 * k))
|
||||
end
|
||||
if mr.stage ~= "charge" then
|
||||
gui.set_color(r.ring, rgba(MIST, 0))
|
||||
gui.set_color(r.halo, rgba(C.violet, 0))
|
||||
self:on_burst()
|
||||
end
|
||||
end
|
||||
if r.question then
|
||||
local q = math.min(1, T / 0.5)
|
||||
local s = r.question_scale * (r.reduced and 1 or (1 + 0.8 * q * q))
|
||||
if r.question and r.q_out then
|
||||
-- The burst blows the "?" away.
|
||||
local q = math.min(1, (socket.gettime() - r.q_out) / 0.35)
|
||||
local s = r.question_scale * (r.reduced and 1 or (1.08 + 1.2 * q * q))
|
||||
gui.set_scale(r.question, v3(s, s))
|
||||
gui.set_color(r.question, rgba(MIST, 0.85 * (1 - q)))
|
||||
gui.set_color(r.question, rgba(MIST, 0.95 * (1 - q)))
|
||||
if q >= 1 then
|
||||
gui.delete_node(r.question)
|
||||
r.question = nil
|
||||
@@ -1067,6 +1176,9 @@ function M:update(dt)
|
||||
self:build_card()
|
||||
end
|
||||
if r.card then
|
||||
if not r.reduced then
|
||||
r.k = ease(prog(T - r.card_at + r.offset, 0, SLIDE))
|
||||
end
|
||||
local t = self:card_t()
|
||||
self:run_events(t)
|
||||
if self.r and r.reduced then
|
||||
@@ -1074,9 +1186,12 @@ function M:update(dt)
|
||||
self:animate_card(math.huge)
|
||||
gui.set_color(r.card, vmath.vector4(1, 1, 1, math.min(1, (socket.gettime() - r.faded_at) / 0.2)))
|
||||
elseif self.r then
|
||||
self:animate_card(self:card_t())
|
||||
self:animate_card(t)
|
||||
end
|
||||
end
|
||||
if self.r then
|
||||
self:pose()
|
||||
end
|
||||
end
|
||||
|
||||
function M:close()
|
||||
@@ -1086,40 +1201,27 @@ function M:close()
|
||||
end
|
||||
if r.card and not r.done then
|
||||
self:flush()
|
||||
r.k = 1
|
||||
end
|
||||
r.closing = socket.gettime()
|
||||
sfx.stop("rumble")
|
||||
sfx.play("close")
|
||||
-- The card flies into the moon's list entry (or shrinks away).
|
||||
local g = self.g
|
||||
local row = g.moon_nodes and g.moon_nodes[r.data.moonId]
|
||||
if r.card and row then
|
||||
local rr = g.fx:rect_of(row)
|
||||
r.fly_to, r.fly_scale = v3(rr.x + rr.w / 2, -(rr.y + rr.h / 2)), 0.12
|
||||
elseif r.card then
|
||||
r.fly_to, r.fly_scale = r.card_pos + v3(0, -30), 0.6 -- (reveal.js: down 30 px, scale .6)
|
||||
end
|
||||
end
|
||||
|
||||
-- The window fades and sinks away (reveal.js: down 30 px); the page comes back.
|
||||
function M:step_close()
|
||||
local r = self.r
|
||||
local k = math.min(1, (socket.gettime() - r.closing) / (r.reduced and 0.2 or 0.42))
|
||||
local k = math.min(1, (socket.gettime() - r.closing) / (r.reduced and 0.2 or 0.4))
|
||||
gui.set_color(r.root, vmath.vector4(1, 1, 1, 1 - k * k))
|
||||
if r.card and r.reduced then
|
||||
gui.set_color(r.card, vmath.vector4(1, 1, 1, 1 - k)) -- (fades where it is)
|
||||
elseif r.card then
|
||||
local q = k * k * k -- cubic-bezier(0.5, 0, 0.75, 0): eases in
|
||||
gui.set_position(r.card, vmath.lerp(q, r.card_pos, r.fly_to))
|
||||
local s = 1 - (1 - (r.fly_scale or 0.12)) * q
|
||||
gui.set_scale(r.card, v3(s, s))
|
||||
gui.set_color(r.card, vmath.vector4(1, 1, 1, 1 - q))
|
||||
end
|
||||
r.drop = r.reduced and 0 or 30 * k * k * k
|
||||
self:pose()
|
||||
if k >= 1 then
|
||||
self:finish(false)
|
||||
end
|
||||
end
|
||||
|
||||
-- Ends the reveal: removes the layer and brings the page back. quick: no entrance (another
|
||||
-- reveal starts, or the viewport moved on).
|
||||
-- reveal starts, or the moon view moved on).
|
||||
function M:finish(quick)
|
||||
local r = self.r
|
||||
if not r then
|
||||
@@ -1145,7 +1247,9 @@ function M:finish(quick)
|
||||
end
|
||||
g.fx:signal("revealed")
|
||||
if r.buy_next then
|
||||
g.h.act(g, "buy-moon", { include = "new-moon" })
|
||||
local grade = r.data.next and r.data.next.grade
|
||||
g.nm_grade = nil -- ("+ New moon" follows the grade just bought: state.moonGrade)
|
||||
g.h.act(g, "buy-moon", { grade = (grade or "") ~= "" and grade or nil, include = "new-moon" })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Save codes (the settings menu's "Save code" and "Restore from code"; ../server/api.md
|
||||
-- "Save codes"): a code brings the player's progress to another device, or back here if
|
||||
-- the saved player id is gone. The server keeps only a hash, so a code shows once, when
|
||||
-- it is made; making a new one turns the old one off.
|
||||
-- open_save(g): asks the server whether there is a code (GET /api/save-code), then a
|
||||
-- dialog to make one (action save-code), then the code (Copy, "dialog:ok", only in a
|
||||
-- browser: Defold has no clipboard on desktop).
|
||||
-- open_restore(g): a text field and Restore (action restore): the response is the other
|
||||
-- player's state and id (session.lua treats it as a fresh start; api.lua stores the id).
|
||||
-- Test ids: "dialog:ok" (Make a code / Restore / Copy), "field:restore-code".
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local api = require("modules.api")
|
||||
local test = require("modules.test")
|
||||
|
||||
local M = {}
|
||||
|
||||
local W = 480
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local TEXT = { font = "sans", px = 15, color = "text_2" }
|
||||
|
||||
-- The height a wrapped text takes in the dialog (laid out first: the dialog's height
|
||||
-- comes from it).
|
||||
local function text_h(g, str)
|
||||
local probe, h = ui.wrap(g.dialog.layer, str, TEXT, 0, 0, W - 48)
|
||||
gui.delete_node(probe)
|
||||
return h
|
||||
end
|
||||
|
||||
local function show_code(g, code)
|
||||
local note = "Write it down. On another device: settings (the gear), Restore from code. It is shown only this once; anyone with it can play as you."
|
||||
local nh = text_h(g, note)
|
||||
g.dialog:open({
|
||||
title = "Your save code",
|
||||
w = W,
|
||||
h = 58 + 40 + 14 + nh + 24 + 44 + 24,
|
||||
cancel = "Close",
|
||||
ok = html5 and { label = "Copy", on_ok = function()
|
||||
pcall(html5.run, "navigator.clipboard && navigator.clipboard.writeText('" .. code .. "')")
|
||||
g.toasts:push("Save code copied", "save-code", false)
|
||||
end } or nil,
|
||||
build = function(d, box)
|
||||
ui.text(box, code, { font = "mono", px = 26, color = "cyan" }, W / 2, 58 + 20, gui.PIVOT_CENTER)
|
||||
ui.wrap(box, note, TEXT, 24, 58 + 40 + 14, W - 48)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function make(g)
|
||||
g.h.act(g, "save-code", {}, function(err, data)
|
||||
local sc = type(data) == "table" and data.saveCode
|
||||
if not err and type(sc) == "table" and type(sc.code) == "string" then
|
||||
g.save_code = sc.code -- (the last code made, for the test driver)
|
||||
show_code(g, sc.code)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function M.open_save(g)
|
||||
api.get_save_code(function(err, data)
|
||||
local made = not err and type(data) == "table" and type(data.saveCode) == "table" and tonumber(data.saveCode.madeAt) or 0
|
||||
if err then
|
||||
g.toasts:push("Can't reach the server", "error", true)
|
||||
return
|
||||
end
|
||||
local message
|
||||
if made > 0 then
|
||||
message = "You made a save code on " .. os.date("%Y-%m-%d", math.floor(made / 1000)) ..
|
||||
". It was shown only then. Lost it? Make a new one; the old one stops working."
|
||||
else
|
||||
message = "A save code brings your progress to another device, or back here if this one forgets you. Keep it safe: anyone with it can play as you."
|
||||
end
|
||||
g.dialog:open({
|
||||
title = "Save code",
|
||||
w = W,
|
||||
message = message,
|
||||
ok = { label = made > 0 and "Make a new code" or "Make a code", on_ok = function() make(g) end },
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
function M.open_restore(g)
|
||||
local code = ""
|
||||
local msg = "Enter a save code to play as that miner on this device. This device's current miner can only come back with its own code."
|
||||
local mh = text_h(g, msg)
|
||||
local fy, fw, fh = 58 + mh + 14, W - 48, 40
|
||||
g.dialog:open({
|
||||
title = "Restore from code",
|
||||
w = W,
|
||||
h = fy + fh + 24 + 44 + 24,
|
||||
ok = { label = "Restore", on_ok = function()
|
||||
if code:match("%w") then
|
||||
g.h.act(g, "restore", { code = code })
|
||||
end
|
||||
end },
|
||||
build = function(d, box)
|
||||
ui.wrap(box, msg, TEXT, 24, 58, W - 48)
|
||||
local fbox = ui.rect(box, 24, fy, fw, fh, "field")
|
||||
local edge = ui.outline(fbox, 0, 0, fw, fh, "cyan")
|
||||
local hint = ui.text(fbox, "ABCD-EFGH-JKMN-PQRS", { font = "mono", px = 16, color = "faint" }, 12, fh / 2)
|
||||
local txt = ui.text(fbox, "", { font = "mono", px = 16, color = "text" }, 12, fh / 2)
|
||||
gui.set_size(txt, vec((fw - 24) / gui.get_scale(txt).x, 40))
|
||||
local input = d.druid:new_input(fbox, txt, gui.KEYBOARD_TYPE_DEFAULT)
|
||||
input:set_allowed_characters("[%w%- ]")
|
||||
input:set_max_length(24)
|
||||
input.style.on_select = function() gui.set_color(edge, ui.color("cyan")) end
|
||||
input.style.on_unselect = function() gui.set_color(edge, ui.color("line_hi")) end
|
||||
input.on_input_text:subscribe(function(_, text)
|
||||
code = text
|
||||
gui.set_enabled(hint, text == "")
|
||||
end)
|
||||
test.register("field:restore-code", fbox, "field", "Save code")
|
||||
input:select()
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,61 +1,190 @@
|
||||
-- Display settings (the header's display button): the UI scale (window pixels per layout
|
||||
-- unit, times the display's own scale; the browser's zoom) and fullscreen. Both are saved
|
||||
-- in the settings file (modules/screen). Keys: Ctrl + / Ctrl - / Ctrl 0, F11 (core/keys).
|
||||
-- The settings menu (the header's gear): a dropdown (components/dialog `menu`) under the
|
||||
-- gear, a text button per setting or action with its icon on the right: How to play (the
|
||||
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
|
||||
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
|
||||
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
|
||||
-- admins and in debug builds.
|
||||
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
|
||||
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
|
||||
|
||||
local ui = require("modules.ui")
|
||||
local screen = require("modules.screen")
|
||||
local sfx = require("modules.sfx")
|
||||
local theme = require("modules.theme")
|
||||
local anim = require("modules.anim")
|
||||
|
||||
local M = {}
|
||||
|
||||
local W = 260
|
||||
local ROW_H = 34
|
||||
local PAD = 6
|
||||
|
||||
local function vec(x, y)
|
||||
return vmath.vector3(x, y, 0)
|
||||
end
|
||||
|
||||
local function pct(s)
|
||||
return string.format("%d%%", math.floor(s * 100 + 0.5))
|
||||
end
|
||||
|
||||
function M.open(g)
|
||||
-- Icons, drawn in a 20x20 box at (x, y) of p.
|
||||
local ICONS = {
|
||||
help = function(p, x, y)
|
||||
ui.outline(p, x, y, 20, 20, "nav")
|
||||
ui.text(p, "?", { font = "sans_semi", px = 14, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||
end,
|
||||
scale = function(p, x, y)
|
||||
ui.text(p, "A", { font = "sans_semi", px = 11, color = "nav" }, x + 5, y + 13, gui.PIVOT_CENTER)
|
||||
ui.text(p, "A", { font = "sans_semi", px = 17, color = "nav" }, x + 14, y + 11, gui.PIVOT_CENTER)
|
||||
end,
|
||||
fullscreen = function(p, x, y)
|
||||
for _, c in ipairs({ { 1, 1, 1, 1 }, { 13, 1, -1, 1 }, { 1, 13, 1, -1 }, { 13, 13, -1, -1 } }) do
|
||||
local cx, cy = x + c[1] + (c[3] < 0 and 6 or 0), y + c[2] + (c[4] < 0 and 6 or 0)
|
||||
ui.rect(p, c[3] > 0 and cx or cx - 6, c[4] > 0 and cy or cy - 1, 6, 2, "nav")
|
||||
ui.rect(p, c[3] > 0 and cx or cx - 1 - 1, c[4] > 0 and cy or cy - 6, 2, 6, "nav")
|
||||
end
|
||||
end,
|
||||
sound = function(p, x, y)
|
||||
local icon = ui.rect(p, x, y, 20, 20, sfx.muted() and "muted" or "cyan")
|
||||
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
|
||||
end,
|
||||
theme = function(p, x, y)
|
||||
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "nav")
|
||||
gui.play_flipbook(icon, theme.light and "moon" or "sun")
|
||||
end,
|
||||
motion = function(p, x, y)
|
||||
local lit = anim.reduced and "muted" or "nav"
|
||||
for i, l in ipairs({ { 4, 5, 14 }, { 1, 9, 18 }, { 6, 13, 11 } }) do
|
||||
ui.rect(p, x + l[1], y + l[2] + (i - 2), l[3], 2, lit)
|
||||
end
|
||||
if anim.reduced then
|
||||
local slash = ui.rect(p, x, y, 20, 2, "muted")
|
||||
gui.set_pivot(slash, gui.PIVOT_CENTER)
|
||||
gui.set_position(slash, vec(x + 10, -(y + 10)))
|
||||
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
|
||||
end
|
||||
end,
|
||||
-- A floppy disk; restore: the disk with an arrow into it.
|
||||
save = function(p, x, y)
|
||||
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||
ui.rect(p, x + 6, y + 2, 8, 5, "nav")
|
||||
ui.rect(p, x + 5, y + 11, 10, 5, "nav")
|
||||
end,
|
||||
restore = function(p, x, y)
|
||||
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
|
||||
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||
end,
|
||||
lab = function(p, x, y)
|
||||
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
|
||||
gui.play_flipbook(icon, "moon")
|
||||
end,
|
||||
grant = function(p, x, y)
|
||||
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
|
||||
end,
|
||||
}
|
||||
|
||||
-- A menu row: a text button the menu's width, the label left, the icon right. Returns the
|
||||
-- button's label node.
|
||||
local function item(d, box, y, label, icon, id, on_click, opts)
|
||||
opts = opts or {}
|
||||
local w = W - PAD * 2
|
||||
local b, nodes = d:button(box, label, PAD, y, { variant = "ghost", w = w, h = ROW_H, id = id, disabled = opts.disabled,
|
||||
fill = { "cyan", 0 }, edge = { "cyan", 0 }, on_click = on_click })
|
||||
local t = ui.part(nodes, "button", "text")
|
||||
ui.style_text(t, { font = "sans", px = 14, color = opts.disabled and "muted" or "text" }, label)
|
||||
gui.set_pivot(t, gui.PIVOT_W)
|
||||
gui.set_position(t, vec(12, -ROW_H / 2))
|
||||
ICONS[icon](b, w - 12 - 20, (ROW_H - 20) / 2)
|
||||
return t, b
|
||||
end
|
||||
|
||||
-- Opens the menu with its top-right corner at `right` from the page's right edge, `top`
|
||||
-- down (under the gear).
|
||||
function M.open(g, right, top)
|
||||
local st = g.state
|
||||
local help = st and st.unlocked and st.unlocked.help
|
||||
local admin = require("screens.game.admin")
|
||||
local lab = st and admin.allowed(g)
|
||||
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
|
||||
local h = PAD * 2 + rows * ROW_H + 8
|
||||
g.dialog:open({
|
||||
title = "Display",
|
||||
w = 440,
|
||||
h = 340,
|
||||
build = function(d, box, w)
|
||||
local x, y = 24, 64
|
||||
local value
|
||||
local function show()
|
||||
ui.style_text(value, { font = "mono", px = 15, color = "text" }, pct(screen.ui_scale))
|
||||
w = W,
|
||||
h = h,
|
||||
menu = { right = right or 16, top = top or 60 },
|
||||
build = function(d, box)
|
||||
local y = PAD
|
||||
local function close_then(fn)
|
||||
return function()
|
||||
d:close("quiet")
|
||||
fn()
|
||||
end
|
||||
end
|
||||
local function toggled()
|
||||
g.dirty, g.dirty_all = true, true
|
||||
end
|
||||
if help then
|
||||
item(d, box, y, "How to play", "help", "menu:help", close_then(function()
|
||||
g.h.navigate(g, { section = "help", moon = g.view.moon })
|
||||
end))
|
||||
y = y + ROW_H
|
||||
end
|
||||
-- UI scale: the row's label shows it; − and + on its right (the icon's place).
|
||||
local w = W - PAD * 2
|
||||
-- (Ctrl 0 resets it)
|
||||
local t = ui.text(box, "", { font = "sans", px = 14, color = "text" }, PAD + 12, y + ROW_H / 2)
|
||||
ICONS.scale(box, PAD + w - 12 - 20, y + (ROW_H - 20) / 2)
|
||||
local function show()
|
||||
ui.style_text(t, { font = "sans", px = 14, color = "text" }, "UI scale " .. pct(screen.ui_scale))
|
||||
end
|
||||
show()
|
||||
local function step(dir)
|
||||
return function()
|
||||
screen.step_ui_scale(dir)
|
||||
show()
|
||||
end
|
||||
end
|
||||
ui.text(box, "UI scale", "cap", x, y + 6)
|
||||
ui.text(box, "Bigger text and controls; the window keeps its size.", { font = "sans", px = 14, color = "muted" }, x, y + 26)
|
||||
local by = y + 44
|
||||
local _, _, mw = d:button(box, "−", x, by, { w = 40, id = "dialog:scale-down", on_click = step(-1) })
|
||||
value = ui.text(box, "", { font = "mono", px = 15, color = "text" }, x + mw + 34, by + 18, gui.PIVOT_CENTER)
|
||||
show()
|
||||
local _, _, pw = d:button(box, "+", x + mw + 68, by, { w = 40, id = "dialog:scale-up", on_click = step(1) })
|
||||
d:button(box, "Reset", x + mw + 68 + pw + 10, by, { id = "dialog:scale-reset", on_click = step(0) })
|
||||
|
||||
y = by + 58
|
||||
ui.text(box, "Fullscreen", "cap", x, y + 6)
|
||||
ui.text(box, screen.can_fullscreen() and "F11 switches it too." or "Not available in this build.",
|
||||
{ font = "sans", px = 14, color = "muted" }, x, y + 26)
|
||||
local fnodes
|
||||
local function label()
|
||||
return screen.fullscreen() and "Leave fullscreen" or "Go fullscreen"
|
||||
d:button(box, "+", PAD + w - 40 - 40, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-up", on_click = step(1) })
|
||||
d:button(box, "−", PAD + w - 40 - 80, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-down", on_click = step(-1) })
|
||||
y = y + ROW_H
|
||||
item(d, box, y, screen.fullscreen() and "Leave fullscreen" or "Fullscreen", "fullscreen", "dialog:fullscreen",
|
||||
close_then(screen.toggle_fullscreen), { disabled = not screen.can_fullscreen() })
|
||||
y = y + ROW_H
|
||||
item(d, box, y, sfx.muted() and "Sound: off" or "Sound: on", "sound", "mute", close_then(function()
|
||||
sfx.toggle_mute()
|
||||
toggled()
|
||||
end))
|
||||
y = y + ROW_H
|
||||
item(d, box, y, theme.light and "Dark theme" or "Light theme", "theme", "theme", close_then(function()
|
||||
theme.toggle()
|
||||
toggled()
|
||||
end))
|
||||
y = y + ROW_H
|
||||
item(d, box, y, anim.reduced and "Reduced motion: on" or "Reduced motion: off", "motion", "motion", close_then(function()
|
||||
anim.set_reduced_motion(not anim.reduced)
|
||||
sfx.play("toggle")
|
||||
toggled()
|
||||
end))
|
||||
y = y + ROW_H
|
||||
-- Save codes (savecode.lua): progress on another device.
|
||||
item(d, box, y, "Save code", "save", "menu:save-code", close_then(function()
|
||||
require("screens.game.savecode").open_save(g)
|
||||
end), { disabled = not st })
|
||||
y = y + ROW_H
|
||||
item(d, box, y, "Restore from code", "restore", "menu:restore", close_then(function()
|
||||
require("screens.game.savecode").open_restore(g)
|
||||
end), { disabled = not st })
|
||||
y = y + ROW_H
|
||||
if st and st.admin then
|
||||
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
|
||||
require("screens.game.grant").open(g)
|
||||
end))
|
||||
y = y + ROW_H
|
||||
end
|
||||
if lab then
|
||||
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
|
||||
admin.open(g, "moonlab")
|
||||
end), { disabled = g.reveal:active() })
|
||||
end
|
||||
fnodes = select(2, d:button(box, label(), x, y + 44, { w = 180, id = "dialog:fullscreen", disabled = not screen.can_fullscreen(),
|
||||
on_click = function()
|
||||
screen.toggle_fullscreen()
|
||||
-- (the label changes: centered again in the button)
|
||||
local t = ui.part(fnodes, "button", "text")
|
||||
local color = gui.get_color(t)
|
||||
ui.style_text(t, { font = "sans_semi", px = 15, color = "text", tracking = 0.12, upper = true }, label())
|
||||
local p = gui.get_position(t)
|
||||
gui.set_position(t, vmath.vector3(math.floor((180 - ui.text_width(t)) / 2), p.y, p.z))
|
||||
gui.set_color(t, color)
|
||||
end }))
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
-- The right side panel (region): a column right of the main panel (left of the aside when
|
||||
-- that shows), framed like the moon list, holding side panels stacked top to bottom: for
|
||||
-- now the leaderboard. A side panel is a module in M.PANELS with key, include (the state
|
||||
-- block it shows: core/session asks for it while the column shows), unlocked(g), sig(g)
|
||||
-- (what it shows: its block is kept while this stays the same) and build(g, root, x, y,
|
||||
-- w, h). A second one goes in M.PANELS; the column's height is shared evenly.
|
||||
--
|
||||
-- When it shows (core/main.lua lays it out, M.width): on the Market only, once a panel is
|
||||
-- unlocked, never while the aside (the moon pane) shows, and only while the main panel keeps at least
|
||||
-- MAIN_MIN beside it; otherwise it folds away and the main panel gets the width (the
|
||||
-- Market then shows the leaderboard under its content). It depends on the window, the
|
||||
-- aside and the unlocks only, never on counts, so nothing shifts while playing.
|
||||
|
||||
local theme = require("modules.theme")
|
||||
local ui = require("modules.ui")
|
||||
local blocks = require("modules.blocks")
|
||||
|
||||
local S = theme.size
|
||||
|
||||
local M = {}
|
||||
|
||||
M.PANELS = { require("screens.game.leaderboard") }
|
||||
|
||||
local MAIN_MIN = 680 -- narrowest the main panel gets with the column beside it
|
||||
local W_MIN, W_MAX, W_PART = 240, 320, 0.13 -- clamp(240, 13% of the window, 320)
|
||||
|
||||
-- The panels unlocked now.
|
||||
function M.panels(g)
|
||||
local out = {}
|
||||
for _, p in ipairs(M.PANELS) do
|
||||
if p.unlocked(g) then
|
||||
out[#out + 1] = p
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The column's width for a layout W wide with `room` left for the main panel and the
|
||||
-- column together; 0 when it folds away (or no panel is unlocked).
|
||||
function M.width(g, W, room, narrow)
|
||||
if narrow or #M.panels(g) == 0 then
|
||||
return 0
|
||||
end
|
||||
local w = math.max(W_MIN, math.min(W_MAX, math.floor(W * W_PART)))
|
||||
if room - w - S.gap < MAIN_MIN then
|
||||
return 0
|
||||
end
|
||||
return w
|
||||
end
|
||||
|
||||
-- The state blocks the shown panels need (core/session adds them to every request). Before
|
||||
-- the first layout (the first state) all of them: the column may show at once.
|
||||
function M.includes(g)
|
||||
local out = {}
|
||||
if g.side_w == nil or g.side_w > 0 then
|
||||
for _, p in ipairs(g.side_w == nil and M.PANELS or M.panels(g)) do
|
||||
out[#out + 1] = p.include
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- After a build: a shown panel whose block the state doesn't have yet (the window grew,
|
||||
-- the aside closed or it just unlocked) asks for it once (fetch: core/session).
|
||||
function M.missing(g)
|
||||
if (g.side_w or 0) == 0 then
|
||||
return false
|
||||
end
|
||||
for _, p in ipairs(M.panels(g)) do
|
||||
if g.state[p.include] == nil then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Builds the column at (x, y), w x h: a framed panel per side panel, stacked.
|
||||
function M.build(g, parent, x, y, w, h, common)
|
||||
local list = M.panels(g)
|
||||
local each = math.floor((h - S.gap * (#list - 1)) / math.max(1, #list))
|
||||
for i, p in ipairs(list) do
|
||||
local py = y + (i - 1) * (each + S.gap)
|
||||
blocks.keep(g, "side:" .. p.key, blocks.sig(common, x, py, w, each, p.sig(g)), parent, function(holder)
|
||||
local _, nodes = ui.panel(holder, x, py, w, each)
|
||||
p.build(g, ui.part(nodes, "panel", "root"), 0, 0, w, each)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,4 +1,4 @@
|
||||
-- The Store section (state.store): tool cards and attachment cards with their images,
|
||||
-- The Store section (state.store): tool cards with their images,
|
||||
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
|
||||
-- (the response's buyModal: up to 3 wells to deploy it on).
|
||||
|
||||
@@ -129,7 +129,8 @@ local function buy_modal(self, modal, item)
|
||||
end
|
||||
|
||||
-- A store card's footer, bw wide (a tree): a rule, the stats (30 tall), Buy (its price;
|
||||
-- disabled when short, with why under it, right-aligned). bind(b, nodes).
|
||||
-- disabled when short, with why under it, right-aligned; the line's space is always kept).
|
||||
-- bind(b, nodes).
|
||||
function M.buy_footer(f, bw, stats, price, short, why, bind)
|
||||
local items = {}
|
||||
for _, it in ipairs(stats) do
|
||||
@@ -141,7 +142,11 @@ function M.buy_footer(f, bw, stats, price, short, why, bind)
|
||||
end),
|
||||
fg.row({ height = 30, column_gap = 16 }, items),
|
||||
fg.button(f, "Buy", { primary = true, small = true, price = price, disabled = short }, bind, { width = bw }),
|
||||
short and fg.made({ height = 20 }, function(p, x, y, w) M.need_text(p, why, x + w, y + 10) end) or nil,
|
||||
-- The need line's place, kept whether it shows or not: Buy sits at the same height in
|
||||
-- every card, and a card doesn't change size when credits come and go.
|
||||
fg.reserve({ height = 20 }, short and fg.made({ width = bw, height = 20 }, function(p, x, y, w)
|
||||
M.need_text(p, why, x + w, y + 10)
|
||||
end) or nil),
|
||||
}), 0, 0), f)
|
||||
end
|
||||
|
||||
@@ -162,7 +167,7 @@ local function tool_card(self, H, st, c, t)
|
||||
c:text(line, { font = "mono", px = 12, color = "muted" })
|
||||
end
|
||||
local short = not t.canAfford
|
||||
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
|
||||
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
|
||||
M.buy_footer(f, bw, {
|
||||
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned },
|
||||
}, t.price, short, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"),
|
||||
@@ -180,26 +185,6 @@ local function tool_card(self, H, st, c, t)
|
||||
end)
|
||||
end
|
||||
|
||||
local function gear_card(self, H, st, c, a)
|
||||
c:image(a.image)
|
||||
c:title(a.name)
|
||||
c:text(a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 14, color = "violet_hi" }, 22)
|
||||
c:para(a.description, { font = "sans", px = 13, color = "text_2" })
|
||||
c:gap(4)
|
||||
local short = not a.canAfford
|
||||
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
|
||||
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
|
||||
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
|
||||
function(b, nodes)
|
||||
if short then
|
||||
tooltip.add(b, a.whyText)
|
||||
else
|
||||
H.action(self, b, nodes, "buy-attachment", { item = a.name })
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.build(self, root, px, py, w, h, H)
|
||||
local st = self.state
|
||||
local sto = st.store
|
||||
@@ -212,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
|
||||
local function cap(label, before)
|
||||
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
|
||||
end
|
||||
local attachments = #(sto.attachments or {}) > 0
|
||||
y = fg.flow(c, x, y, inner, {
|
||||
fg.col({}, {
|
||||
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
|
||||
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.",
|
||||
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
|
||||
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
|
||||
fg.space(0, 20),
|
||||
}),
|
||||
@@ -226,12 +210,6 @@ function M.build(self, root, px, py, w, h, H)
|
||||
tool_card(self, H, st, cd, t)
|
||||
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
|
||||
end,
|
||||
attachments and cap("Attachments", 8) or nil,
|
||||
attachments and function(cy)
|
||||
return card.grid(self, c, x, cy, inner, sto.attachments, function(cd, a)
|
||||
gear_card(self, H, st, cd, a)
|
||||
end, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
|
||||
end or nil,
|
||||
})
|
||||
area:finish(y + py)
|
||||
end
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
|
||||
sample of server texts (GET /api/state of a fixture player, every section) must be in
|
||||
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
|
||||
- the moon generator (scripts/moon-check.mjs: the mmterrain extension's C, built for the
|
||||
host, and terrain.lua's settings side, against the server's terrain.js)
|
||||
- engine warnings in a scenario run's log: font batches / characters / text entries
|
||||
over their limits, out of nodes or animations, stencil overflow, invalid markup,
|
||||
script errors
|
||||
@@ -231,15 +229,6 @@ def check_scenario(api):
|
||||
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
|
||||
|
||||
|
||||
def check_moon():
|
||||
r = subprocess.run(["node", os.path.join(HERE, "moon-check.mjs")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
if r.returncode != 0:
|
||||
fail("moon-check.mjs failed:\n" + "\n".join(l for l in r.stdout.splitlines() if not l.startswith("ok "))[-2000:])
|
||||
else:
|
||||
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
|
||||
|
||||
|
||||
def check_flex():
|
||||
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
@@ -257,7 +246,6 @@ def main():
|
||||
check_lua()
|
||||
check_flags()
|
||||
check_flex()
|
||||
check_moon()
|
||||
check_fonts(api)
|
||||
if "--static" not in sys.argv:
|
||||
check_scenario(api)
|
||||
|
||||
+42
-3
@@ -11,6 +11,8 @@
|
||||
first, then the game's gui_script (its
|
||||
on_reload rebuilds the screen; modules/hot.lua)
|
||||
(--all: every Lua module again)
|
||||
scripts/dev.py run [engine args...] the same in the foreground, output also on the
|
||||
terminal (`make run`); reload works on it too
|
||||
scripts/dev.py stop stop it (by its pid)
|
||||
scripts/dev.py status
|
||||
|
||||
@@ -61,11 +63,14 @@ def read(name, default=None):
|
||||
|
||||
|
||||
def alive(pid):
|
||||
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
|
||||
# number can be reused by another process (stop would kill that one's group).
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
||||
cmd = f.read()
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
|
||||
|
||||
|
||||
def snapshot():
|
||||
@@ -86,7 +91,7 @@ def post(port, path, body):
|
||||
return urllib.request.urlopen(req, timeout=5).read()
|
||||
|
||||
|
||||
def start(args):
|
||||
def start(args, fg=False):
|
||||
os.makedirs(DEV, exist_ok=True)
|
||||
pid = read("pid")
|
||||
if pid and alive(pid):
|
||||
@@ -125,6 +130,9 @@ def start(args):
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
log = os.path.join(DEV, "game.log")
|
||||
if fg:
|
||||
run_fg(cmd, env, log)
|
||||
return
|
||||
with open(log, "w") as f:
|
||||
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
@@ -148,6 +156,35 @@ def start(args):
|
||||
print("dev game pid %d, engine service port %s, log %s" % (proc.pid, port, log))
|
||||
|
||||
|
||||
def run_fg(cmd, env, log):
|
||||
# Output to the terminal and the log; Ctrl+C stops the game.
|
||||
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
with open(os.path.join(DEV, "pid"), "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
try:
|
||||
with open(log, "wb") as f:
|
||||
for line in proc.stdout:
|
||||
f.write(line)
|
||||
f.flush()
|
||||
sys.stdout.buffer.write(line)
|
||||
sys.stdout.buffer.flush()
|
||||
m = re.search(rb"Engine service started on port (\d+)", line)
|
||||
if m:
|
||||
with open(os.path.join(DEV, "port"), "w") as p:
|
||||
p.write(m.group(1).decode())
|
||||
with open(SNAP, "w") as p:
|
||||
json.dump(snapshot(), p)
|
||||
except KeyboardInterrupt:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
for n in ("pid", "port"):
|
||||
try:
|
||||
os.remove(os.path.join(DEV, n))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def reload(everything=False):
|
||||
port, pid = read("port"), read("pid")
|
||||
if not port or not alive(pid):
|
||||
@@ -227,6 +264,8 @@ def main():
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "start":
|
||||
start(sys.argv[2:])
|
||||
elif cmd == "run":
|
||||
start(sys.argv[2:], fg=True)
|
||||
elif cmd == "reload":
|
||||
reload("--all" in sys.argv)
|
||||
elif cmd == "stop":
|
||||
|
||||
+53
-10
@@ -11,18 +11,19 @@ Usage:
|
||||
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
|
||||
with run(game, args).
|
||||
Options:
|
||||
--api URL server (default http://localhost:3000; MM_API env)
|
||||
--api URL server (or MM_API; required, no default: a test server, never :3000)
|
||||
--size WxH window and display size (default 1280x800)
|
||||
--out DIR screenshots and game.log (default dist/drive/<scenario>)
|
||||
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
|
||||
--keep don't stop the game at the end (prints its pid and port)
|
||||
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
|
||||
port and the display)
|
||||
--attach PORT don't start a game: drive the one whose engine service is on PORT
|
||||
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
|
||||
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
|
||||
--log FILE the attached game's log (default dist/dev/game.log)
|
||||
--config K=V extra engine config (mm.player=..., repeatable)
|
||||
--fixture make a test player first (scripts/fixture.py against --api, which must
|
||||
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
|
||||
--fixture make a test player first (scripts/fixture.py against --api) and play as
|
||||
it; the scenario gets its ids as game.fixture
|
||||
--fixture-file F use the ids saved in F (a fixture.py output) instead
|
||||
|
||||
The game it starts saves its files (player ids, settings) under <out>/data (or
|
||||
@@ -34,6 +35,10 @@ In a scenario:
|
||||
game.eval("g.view.section"), game.elements(), game.state(), game.set(k, v),
|
||||
game.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
|
||||
game.lua(src) (raw run_script, <1 KB).
|
||||
Layout (scripts/ui_lint.py has the rules): game.dump(name) (the screen's laid-out nodes,
|
||||
saved as <out>/<name>.layout.json), game.lint(name) (dump + lint, prints the issues,
|
||||
saves <name>.lint.json), game.click_check(id, allow=[prefixes]) (dump, click, idle,
|
||||
dump: the clicker rule). Issues so far: game.issues. Scenarios `dump` and `lint`.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
@@ -48,6 +53,8 @@ import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, HERE)
|
||||
import ui_lint # noqa: E402
|
||||
|
||||
|
||||
def varint(n):
|
||||
@@ -86,6 +93,7 @@ class Game:
|
||||
self.window_id = None
|
||||
self.test_dir = None # where the game writes its replies (mm.test_dir)
|
||||
self.args = None # the driver's options, when it started the game (restart)
|
||||
self.issues = [] # ui_lint issues from lint / click_check, each with "where"
|
||||
|
||||
# ------------------------------------------------------------ transport
|
||||
def lua(self, src):
|
||||
@@ -173,6 +181,37 @@ class Game:
|
||||
def state(self):
|
||||
return self.ok(self.cmd("state"), "state")["state"]
|
||||
|
||||
# ------------------------------------------------------------ layout lint
|
||||
def dump(self, name=None):
|
||||
d = self.ok(self.cmd("dump"), "dump")["dump"]
|
||||
if name:
|
||||
with open(os.path.join(self.out, name + ".layout.json"), "w") as f:
|
||||
json.dump(d, f, ensure_ascii=False)
|
||||
return d
|
||||
|
||||
def _report(self, name, issues):
|
||||
for i in issues:
|
||||
i["where"] = name
|
||||
self.issues += issues
|
||||
with open(os.path.join(self.out, name + ".lint.json"), "w") as f:
|
||||
json.dump(issues, f, indent=1, ensure_ascii=False)
|
||||
print(" lint %s: %d issue(s)" % (name, len(issues)))
|
||||
for line in ui_lint.text(issues).splitlines() if issues else []:
|
||||
print(" " + line)
|
||||
return issues
|
||||
|
||||
def lint(self, name="screen", min_hit=24):
|
||||
return self._report(name, ui_lint.lint(self.dump(name), min_hit))
|
||||
|
||||
def click_check(self, id_, allow=(), settle=1.0, name=None):
|
||||
name = name or "click-" + re.sub(r"[^A-Za-z0-9_.-]+", "_", id_)
|
||||
before = self.dump(name + ".before")
|
||||
r = self.click(id_)
|
||||
self.idle()
|
||||
time.sleep(settle)
|
||||
after = self.dump(name + ".after")
|
||||
return self._report(name, ui_lint.diff(before, after, r.get("id") or id_, allow))
|
||||
|
||||
def set(self, k, v):
|
||||
return self.ok(self.cmd("set", k, v), "set " + k)
|
||||
|
||||
@@ -343,7 +382,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("scenario")
|
||||
ap.add_argument("rest", nargs="*")
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
|
||||
ap.add_argument("--api", default=os.environ.get("MM_API"))
|
||||
ap.add_argument("--size", default="1280x800")
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--bundle", action="store_true")
|
||||
@@ -354,6 +393,9 @@ def main():
|
||||
ap.add_argument("--fixture", action="store_true")
|
||||
ap.add_argument("--fixture-file")
|
||||
args = ap.parse_args()
|
||||
if not args.api:
|
||||
# (No default: the game makes a player and plays on whatever server this is.)
|
||||
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
|
||||
|
||||
if args.attach:
|
||||
disp = os.path.join(ROOT, "dist/dev/display")
|
||||
@@ -375,8 +417,12 @@ def main():
|
||||
try:
|
||||
code = subprocess.call([sys.executable] + sys.argv, env=env)
|
||||
finally:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
if args.keep:
|
||||
# The kept game draws on this display: leave it running too.
|
||||
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
|
||||
else:
|
||||
xvfb.terminate()
|
||||
xvfb.wait()
|
||||
sys.exit(code)
|
||||
|
||||
path = find_scenario(args.scenario)
|
||||
@@ -390,9 +436,6 @@ def main():
|
||||
if not args.attach:
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
elif args.fixture:
|
||||
if "--api" not in sys.argv and not os.environ.get("MM_API"):
|
||||
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
|
||||
"to a test server (no default)")
|
||||
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
|
||||
args.config.append("mm.player=" + fixture["player"])
|
||||
print("fixture player", fixture["player"])
|
||||
|
||||
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
|
||||
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
|
||||
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
|
||||
credits, resources and refined items granted, two moons, several tools (some drilling,
|
||||
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
|
||||
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
|
||||
--plain: just a new player (nothing granted), for the first-moon views.
|
||||
"""
|
||||
import json
|
||||
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
|
||||
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
|
||||
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
|
||||
admin("resources", resource=r, amount=400)
|
||||
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
|
||||
admin("tool", item=item, tier=tier)
|
||||
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
|
||||
admin("tool", item=item, **{"yield": yl, "cycle": cl})
|
||||
|
||||
act("buy-moon")
|
||||
d = act("buy-moon")
|
||||
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
|
||||
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
|
||||
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
|
||||
|
||||
act("buy-attachment", item="Turbo Core")
|
||||
act("buy-attachment", item="Diamond Bit")
|
||||
act("buy-attachment", item="Diamond Bit")
|
||||
# The idle PD-1 a level up on each track (its yield track twice).
|
||||
idle = by_item[1][1]["id"]
|
||||
act("equip", item="Turbo Core", to=idle)
|
||||
act("upgrade", tool=idle, track="yield")
|
||||
act("upgrade", tool=idle, track="yield")
|
||||
act("upgrade", tool=idle, track="cycle")
|
||||
|
||||
act("buy-slot")
|
||||
act("refine", recipe="Dusk Alloy", qty=5)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// Checks the moon generator against the server's terrain.js: the native one
|
||||
// (mmterrain/src/terrain.c, built here for the host and loaded into luajit through the
|
||||
// FFI) gives the same mesh within float32 rounding, and the Lua settings side
|
||||
// (modules/moon/terrain.lua: random draws, traits, resolve, the strip) matches too.
|
||||
// Every seed runs with the default settings and a few others (each technique, many
|
||||
// craters, no ridges or patches). Needs node, luajit and a C compiler (cc).
|
||||
//
|
||||
// node scripts/moon-check.mjs [resolution] [seeds...]
|
||||
//
|
||||
// terrain.js is read from $MM_SERVER (default ../server).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const server = process.env.MM_SERVER || path.join(root, "..", "server");
|
||||
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
|
||||
const [res = "32", ...rest] = process.argv.slice(2);
|
||||
// The fixed seeds, plus moon-id-like ones (deterministic).
|
||||
const ids = [];
|
||||
{
|
||||
let h = 0x9e3779b9;
|
||||
const next = () => ((h = (Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0), h.toString(16).padStart(8, "0"));
|
||||
for (let i = 0; i < 24; i++) ids.push(`${next()}-${next().slice(0, 4)}-4${next().slice(0, 3)}-a${next().slice(0, 3)}-${next()}${next().slice(0, 4)}`);
|
||||
}
|
||||
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9", ...ids];
|
||||
const fixed = (t) => ({ technique: t, variation: { technique: false } });
|
||||
const SETTINGS = [
|
||||
{}, fixed("fbm"), fixed("ridged"), fixed("warped"), fixed("billow"), fixed("craterField"),
|
||||
{ craters: { count: 1500, smoothness: 0, maxRadius: 0.4 } }, { ridge: { strength: 0 }, shading: { patchAmount: 0 } },
|
||||
];
|
||||
// Each case: a seed and a settings index (the default settings for every seed, the
|
||||
// others over a rotating subset).
|
||||
const cases = [];
|
||||
seeds.forEach((seed, i) => {
|
||||
cases.push([seed, 0]);
|
||||
for (let s = 1; s < SETTINGS.length; s++) if ((i + s) % 4 === 0 || rest.length) cases.push([seed, s]);
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mmterrain-"));
|
||||
const lib = path.join(dir, "libmmterrain.so");
|
||||
execFileSync("cc", ["-O2", "-std=c99", "-shared", "-fPIC", "-o", lib, path.join(root, "mmterrain/src/terrain.c"), "-lm"]);
|
||||
|
||||
const luaValue = (v) => (typeof v === "object" ? "{" + Object.entries(v).map(([k, x]) => `${k}=${luaValue(x)}`).join(",") + "}" : JSON.stringify(v));
|
||||
const lua = `
|
||||
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
|
||||
local T = require("modules.moon.terrain")
|
||||
local SETTINGS = { ${SETTINGS.map(luaValue).join(", ")} }
|
||||
local ffi = require("ffi")
|
||||
ffi.cdef[[
|
||||
typedef struct mt_params mt_params;
|
||||
typedef struct mt_gen mt_gen;
|
||||
int mt_params_from_array(const double* a, int n, mt_params* p);
|
||||
mt_gen* mt_new(const mt_params* p);
|
||||
void mt_free(mt_gen* g);
|
||||
int mt_step(mt_gen* g, double budget);
|
||||
int mt_count(const mt_gen* g);
|
||||
int mt_n(const mt_gen* g);
|
||||
const double* mt_positions(const mt_gen* g);
|
||||
const double* mt_normals(const mt_gen* g);
|
||||
const double* mt_colors(const mt_gen* g);
|
||||
int mt_strip_len(int n);
|
||||
void mt_strip(int n, int* out);
|
||||
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
|
||||
]]
|
||||
local N = ffi.load(${JSON.stringify(lib)})
|
||||
local function num(x) return string.format("%.17g", x) end
|
||||
local function carr(p, n) local o = {} for k = 0, n - 1 do o[k + 1] = num(p[k]) end return "[" .. table.concat(o, ",") .. "]" end
|
||||
local out = {}
|
||||
for i = 2, #arg, 2 do -- arg[1]: resolution, then seed, settings index pairs
|
||||
local seed, si = arg[i], tonumber(arg[i + 1])
|
||||
local s = T.merge_settings(SETTINGS[si + 1])
|
||||
local r = T.rng(T.hash_string(seed))
|
||||
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
|
||||
local tr = T.traits(s, seed)
|
||||
local p = T.resolve(s, seed)
|
||||
p.resolution = tonumber(arg[1])
|
||||
do
|
||||
local a = T.native_params(p)
|
||||
local buf = ffi.new("double[?]", #a, a)
|
||||
local params = ffi.new("char[1024]") -- (larger than mt_params)
|
||||
assert(N.mt_params_from_array(buf, #a, ffi.cast("mt_params*", params)) == 1)
|
||||
local g = N.mt_new(ffi.cast("mt_params*", params))
|
||||
local steps = 0
|
||||
repeat steps = steps + 1 until N.mt_step(g, 0.0005) == 1
|
||||
local c = N.mt_count(g) * 3
|
||||
-- The strip: the native order equals the Lua's (terrain.strip), and its fill writes
|
||||
-- those vertices.
|
||||
local strip = T.strip(N.mt_n(g))
|
||||
local len = N.mt_strip_len(N.mt_n(g))
|
||||
local sidx = ffi.new("int[?]", len)
|
||||
N.mt_strip(N.mt_n(g), sidx)
|
||||
local same = len == #strip
|
||||
for k = 1, #strip do if not same then break end same = sidx[k - 1] == strip[k] end
|
||||
local fill = ffi.new("float[?]", len * 9)
|
||||
local half = math.floor(len / 2) -- (in two ranges, like the web's chunks)
|
||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, 0, half)
|
||||
N.mt_fill(g, fill, fill + 3, fill + 6, 9, half, len)
|
||||
local P = N.mt_positions(g)
|
||||
local fdiff = 0
|
||||
for k = 1, #strip do
|
||||
local v = strip[k]
|
||||
for d = 0, 2 do fdiff = math.max(fdiff, math.abs(fill[(k - 1) * 9 + d] - P[v * 3 + d])) end
|
||||
end
|
||||
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s,"strip":%s,"fill":%s,"steps":%d}',
|
||||
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
|
||||
num(tr.maxRadius), carr(N.mt_positions(g), c), carr(N.mt_normals(g), c), carr(N.mt_colors(g), c),
|
||||
same and "true" or "false", num(fdiff), steps)
|
||||
N.mt_free(g)
|
||||
end
|
||||
end
|
||||
print("[" .. table.concat(out, ",") .. "]")
|
||||
`;
|
||||
const got = JSON.parse(execFileSync("luajit", ["-", res, ...cases.flat().map(String)], { input: lua, maxBuffer: 1 << 30 }).toString());
|
||||
|
||||
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
|
||||
const close = (m, g) => {
|
||||
const d = { positions: maxDiff(m.positions, g.positions), normals: maxDiff(m.normals, g.normals), colors: maxDiff(m.colors, g.colors) };
|
||||
return [d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4, d];
|
||||
};
|
||||
const fmt = (d) => `positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`;
|
||||
let failed = false;
|
||||
cases.forEach(([seed, si], i) => {
|
||||
const s = T.mergeSettings(SETTINGS[si]);
|
||||
const r = T.rng(T.hashString(seed));
|
||||
const draws = Array.from({ length: 16 }, () => r());
|
||||
const t = T.moonTraits(s, seed);
|
||||
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
|
||||
const p = T.resolveMoon(s, seed);
|
||||
p.resolution = Number(res);
|
||||
const m = T.generateMoon(p);
|
||||
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
|
||||
const nat = got[i];
|
||||
const [natOk, d] = close(m, nat);
|
||||
const ok = exact && natOk && nat.strip && nat.fill < 1e-5;
|
||||
const line = `draws and traits ${exact ? "identical" : "DIFFER"} | native: ${fmt(d)}; strip ${nat.strip ? "same" : "DIFFERS"}, fill ${nat.fill.toExponential(1)}, ${nat.steps} steps`;
|
||||
failed ||= !ok;
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${seed} [settings ${si}] ${t.technique}: ${line}`);
|
||||
});
|
||||
console.log(`${cases.length} cases at resolution ${res}: ${failed ? "FAILED" : "all ok"}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -26,7 +26,7 @@ def run(game, args):
|
||||
game.navigate("{section='new-moon'}")
|
||||
game.idle()
|
||||
n0 = game.eval("#st.moons")
|
||||
game.click("act:buy-moon?include=new-moon")
|
||||
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||
card_done(game)
|
||||
first = game.eval("g.reveal.r.data.moonId")
|
||||
els = [e["id"] for e in game.elements()]
|
||||
@@ -53,7 +53,7 @@ def run(game, args):
|
||||
admin(game, "credits", amount=round(extra, 2), op="remove")
|
||||
game.navigate("{section='new-moon'}")
|
||||
game.idle()
|
||||
game.click("act:buy-moon?include=new-moon")
|
||||
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||
card_done(game)
|
||||
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
|
||||
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Card sections (needs --fixture): a click on a tool card opens its drill panel, a click
|
||||
"""Card sections (needs --fixture): a click on a tool row (Equipment) opens its drill panel, a click
|
||||
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
|
||||
close are focusable. Shots of each."""
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""The confirm dialog (needs --fixture): open it from the drill panel's spare attachment
|
||||
Sell, shots mid-open and open, Esc cancels, open again, OK sells (a toast)."""
|
||||
"""The confirm dialog (needs --fixture): open it from the refinery's buy-slot button
|
||||
(sells ask in place now: sellask.py), shots mid-open and open, Esc cancels, open again,
|
||||
OK buys (a toast)."""
|
||||
|
||||
|
||||
def open_sell(game, shot=None):
|
||||
"""Click Sell until the dialog opens: a rebuild (a drill finishing) can drop a press."""
|
||||
"""Click until the dialog opens: a rebuild (a drill finishing) can drop a press."""
|
||||
for _ in range(3):
|
||||
game.scroll_to("confirm:sell-attachment")
|
||||
game.scroll_to("confirm:buy-slot")
|
||||
game.idle()
|
||||
game.click("confirm:sell-attachment")
|
||||
game.click("confirm:buy-slot")
|
||||
if shot:
|
||||
game.shot(shot, settle=0.08)
|
||||
shot = None
|
||||
@@ -21,7 +22,7 @@ def open_sell(game, shot=None):
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
game.idle()
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
||||
game.navigate("{section='refinery'}")
|
||||
game.idle()
|
||||
open_sell(game, "dialog-opening")
|
||||
game.shot("dialog-open", settle=0.6)
|
||||
@@ -32,5 +33,5 @@ def run(game, args):
|
||||
game.sleep(0.5)
|
||||
game.click("dialog:ok")
|
||||
game.wait_for("not g.dialog.root and not g.busy")
|
||||
game.expect(game.log_has(r"mm: toast Sold"), "selling shows a toast")
|
||||
game.expect(game.log_has(r"mm: toast"), "buying shows a toast")
|
||||
game.shot("dialog-after", settle=0.3)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
|
||||
directory: it saves the settings file): the header's display button opens the dialog;
|
||||
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
|
||||
dialog's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
|
||||
directory: it saves the settings file): the header's gear opens the settings menu;
|
||||
+ / - change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
|
||||
menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
|
||||
silent while the window is hidden."""
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ def run(game, args):
|
||||
pre = (args[0] + "-") if args else ""
|
||||
game.idle()
|
||||
W0 = game.eval("g.size[1]")
|
||||
game.click("display")
|
||||
game.click("settings")
|
||||
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||
game.shot(pre + "display-dialog", settle=0.8)
|
||||
game.shot(pre + "settings-menu", settle=0.8)
|
||||
game.click("dialog:scale-up")
|
||||
game.click("dialog:scale-up")
|
||||
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
|
||||
@@ -19,7 +19,8 @@ def run(game, args):
|
||||
W1 = game.eval("g.size[1]")
|
||||
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
|
||||
game.shot(pre + "display-125", settle=0.8)
|
||||
game.click("dialog:scale-reset")
|
||||
game.click("dialog:scale-down")
|
||||
game.click("dialog:scale-down")
|
||||
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
|
||||
game.key("key_esc")
|
||||
game.wait_for("not g.dialog:is_open()", timeout=5)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""The drill panel during onboarding (needs --fixture-file of a plain player:
|
||||
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
|
||||
Equipment, a tool row opens its panel (stats, the tracks' ladders) without the Store's
|
||||
parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
|
||||
the same panel has them. Shots "before" and "after", each linted."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin, call # noqa: E402
|
||||
|
||||
|
||||
def labels(game):
|
||||
"""Every shown text on screen, lower case."""
|
||||
return {(n.get("text") or "").lower() for n in game.dump()["nodes"] if n.get("text")}
|
||||
|
||||
|
||||
def open_panel(game, tool):
|
||||
game.navigate("{section='equipment'}")
|
||||
game.idle()
|
||||
game.click("drill:" + tool)
|
||||
game.wait_for("g.view.drill ~= '' and g.state.equipment.drill ~= nil", 10)
|
||||
game.idle()
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
admin(game, "credits", amount=5000, op="grant")
|
||||
r = call(game, "POST", "/api/act/buy-moon", {})
|
||||
game.expect(r.get("ok"), "buy-moon: %s" % r.get("error"))
|
||||
st = r["state"]
|
||||
tool = st["tools"][0]["id"]
|
||||
well = st["moon"]["wells"][0]["id"]
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": tool, "target": well})
|
||||
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||
u = r["state"]["unlocked"]
|
||||
game.expect(u["equipment"] and not u["store"], "after the first deploy: %s" % u)
|
||||
|
||||
open_panel(game, tool)
|
||||
game.sleep(2) # (the wallet counts down from the moon purchase)
|
||||
game.shot("before")
|
||||
game.lint("before")
|
||||
texts = labels(game)
|
||||
game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
|
||||
game.expect("sell drill" not in texts, "sell drill hidden before the Store")
|
||||
ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
|
||||
game.expect(not ups, "no upgrade before the Store: %s" % ups)
|
||||
|
||||
# First claim (Market), first sale (Store).
|
||||
admin(game, "finish-stakes")
|
||||
r = call(game, "POST", "/api/act/claim", {"claim-well": well})
|
||||
game.expect(r.get("ok"), "claim: %s" % r.get("error"))
|
||||
res = [x for x in r["state"]["resources"] if x["amount"] > 0][0]["name"]
|
||||
r = call(game, "POST", "/api/act/sell", {"resource": res, "all": "1"})
|
||||
game.expect(r.get("ok"), "sell: %s" % r.get("error"))
|
||||
game.expect(r["state"]["unlocked"]["store"], "store unlocked after the sale")
|
||||
|
||||
open_panel(game, tool)
|
||||
game.sleep(3) # (the sale's effects and the wallet)
|
||||
game.shot("after")
|
||||
game.lint("after")
|
||||
texts = labels(game)
|
||||
for t in ("yield per cycle", "sell drill"):
|
||||
game.expect(t in texts, "%s shown after the Store" % t)
|
||||
ups = {e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")}
|
||||
for kind in ("yield", "cycle"):
|
||||
game.expect(any(("track=" + kind) in i for i in ups), "%s upgrade after the Store: %s" % (kind, ups))
|
||||
@@ -0,0 +1,13 @@
|
||||
"""The current screen's layout dump and its lint (no fixture needed; works with --attach).
|
||||
Args: [view] [name]: a view to go to first ("{section='market'}"), the file name (default
|
||||
"screen"). Writes <out>/<name>.layout.json and <name>.lint.json, prints the issues."""
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
if args and args[0].startswith("{"):
|
||||
game.navigate(args[0])
|
||||
game.idle()
|
||||
game.sleep(1.0)
|
||||
args = args[1:]
|
||||
game.lint(args[0] if args else "screen")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The Equipment table (needs --fixture): a row per tool, copies too (the fixture's two
|
||||
PD-1s), one mining, one ready; the idle PD-1's cycle track taken to its top (Max in place
|
||||
of its button) and the credits cut to a few hundred (some upgrades short). Shots of the
|
||||
table, a row's drill panel, a row's Sell asking in place (Cancel on Sell's spot), and two
|
||||
clicks on an affordable upgrade button (click_check: it stays put). Each linted. Args: an
|
||||
optional name prefix."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin, call # noqa: E402
|
||||
from sellask import ask, covers, rect_of # noqa: E402
|
||||
|
||||
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
pre = (args[0] + "-") if args else ""
|
||||
idle = fx["tool_idle"]
|
||||
game.idle()
|
||||
for _ in range(12):
|
||||
r = call(game, "POST", "/api/act/upgrade", {"tool": idle, "track": "cycle"})
|
||||
if not r.get("ok"):
|
||||
break
|
||||
st = call(game, "GET", "/api/state?s=equipment")["state"]
|
||||
tr = [t for t in st["equipment"]["tools"] if t["id"] == idle][0]["tracks"][1]
|
||||
game.expect(tr["next"] is None, "cycle track at its top: %s/%s" % (tr["level"], tr["max"]))
|
||||
# One mining for sure (the fixture's stakes can find their well empty): an idle tool
|
||||
# other than the PD-1s on the second moon.
|
||||
ms = call(game, "GET", "/api/state?s=moons&moon=" + fx["moon2"])["state"]
|
||||
wells = [w for w in ms["moon"]["wells"] if w["supply"] > 0]
|
||||
spare = [t for t in st["equipment"]["tools"] if not t["stake"] and t["storeItemId"] != 1]
|
||||
if wells and spare:
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": spare[0]["id"], "target": wells[0]["id"]})
|
||||
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||
admin(game, "credits", amount=max(1, st["credits"] - 260), op="remove")
|
||||
game.navigate("{section='equipment'}")
|
||||
game.idle()
|
||||
game.sleep(1)
|
||||
game.shot(pre + "eqrows-table", settle=1.0)
|
||||
game.lint(pre + "eqrows-table")
|
||||
|
||||
# A row opens its drill panel.
|
||||
game.scroll_to("drill:" + idle)
|
||||
game.idle()
|
||||
game.click("drill:" + idle)
|
||||
game.wait_for("view.drill == '%s' and not g.busy" % idle)
|
||||
game.idle()
|
||||
game.shot(pre + "eqrows-panel", settle=1.0)
|
||||
game.lint(pre + "eqrows-panel")
|
||||
|
||||
# A row's Sell asks in place.
|
||||
sell = "confirm:sell-tool:" + idle
|
||||
game.scroll_to(sell)
|
||||
game.idle()
|
||||
before = rect_of(game, sell)
|
||||
ask(game, sell)
|
||||
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
|
||||
game.expect(covers(rect_of(game, sell + ":cancel"), before), "Cancel takes Sell's spot")
|
||||
game.shot(pre + "eqrows-asking", settle=0.3)
|
||||
game.lint(pre + "eqrows-asking")
|
||||
game.key("key_esc")
|
||||
game.wait_for("g.ask == nil", 5)
|
||||
game.idle()
|
||||
|
||||
# Two clicks on an affordable upgrade keep hitting the same row's button.
|
||||
ups = [e for e in game.elements() if e["id"].startswith("act:upgrade?") and e.get("enabled", True)]
|
||||
game.expect(ups, "an affordable upgrade")
|
||||
if ups:
|
||||
id_ = ups[0]["id"]
|
||||
for n in range(2):
|
||||
if game.eval("el(%r)" % id_):
|
||||
game.click_check(id_, name=pre + "eqrows-click-%d" % n)
|
||||
game.shot(pre + "eqrows-after", settle=1.0)
|
||||
@@ -44,13 +44,14 @@ def run(game, args):
|
||||
game.wait_for("not g.dialog:is_open()", 5)
|
||||
game.idle()
|
||||
|
||||
# A confirm: OK has the focus, Enter sells (after a mouse click).
|
||||
game.navigate("{section='equipment', drill='%s'}" % game.fixture["tool_idle"])
|
||||
# A confirm: OK has the focus, Enter buys (after a mouse click). (Sells ask in place:
|
||||
# sellask.py.)
|
||||
game.navigate("{section='refinery'}")
|
||||
game.idle()
|
||||
game.scroll_to("confirm:sell-attachment")
|
||||
game.scroll_to("confirm:buy-slot")
|
||||
game.idle()
|
||||
for _ in range(3):
|
||||
game.click("confirm:sell-attachment")
|
||||
game.click("confirm:buy-slot")
|
||||
try:
|
||||
game.wait_for("g.dialog:is_open()", 6)
|
||||
break
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Moon grades (needs --fixture and an MM_DEV=1 server: grants through the admin page).
|
||||
The new-moon panel lists a row per grade with its Claim button (shot); the grade toggle
|
||||
next to "+ New moon" changes the grade it buys and its price without moving it; it buys
|
||||
that grade, the reveal shows the grade and Buy another names it (shot); the moon list
|
||||
row and the moon heading show the grade badge (shot)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin # noqa: E402
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
admin(game, "credits", amount=5000, op="grant")
|
||||
game.navigate("{section='new-moon'}")
|
||||
game.idle()
|
||||
grades = game.eval("(function() local t = {} for _, e in ipairs(st.newMoon.grades) do t[#t+1] = e.grade .. (e.canAfford and '+' or '-') end return table.concat(t, ',') end)()")
|
||||
print("grades:", grades)
|
||||
els = [e["id"] for e in game.elements()]
|
||||
game.expect(any(i.startswith("newmoon:grade:") for i in els), "grade rows registered")
|
||||
game.shot("grades-panel", settle=0.4)
|
||||
g0 = game.eval("require('screens.game.moonlist').grade(g)")
|
||||
game.click("newmoon:grade")
|
||||
game.idle()
|
||||
g1 = game.eval("require('screens.game.moonlist').grade(g)")
|
||||
game.expect(g0 != g1, "toggle changes the grade: %s -> %s" % (g0, g1))
|
||||
game.click("newmoon:grade")
|
||||
game.idle()
|
||||
game.shot("grades-toggled", settle=0.3)
|
||||
gb = game.eval("require('screens.game.moonlist').grade(g)")
|
||||
game.click("newmoon:buy")
|
||||
game.wait_for("g.reveal:active() and g.reveal:card_done()", 40)
|
||||
game.sleep(0.4)
|
||||
got = game.eval("g.reveal.r.data.grade .. '|' .. (g.reveal.r.data.next.grade or '')")
|
||||
game.expect(got == gb + "|" + gb, "bought grade %s: %s" % (gb, got))
|
||||
game.shot("grades-reveal", settle=0.3)
|
||||
game.key("key_esc")
|
||||
game.wait_for("not g.reveal:active()", 10)
|
||||
game.idle()
|
||||
game.shot("grades-moon", settle=0.4)
|
||||
@@ -45,12 +45,12 @@ def alive(game, what):
|
||||
|
||||
|
||||
def open_confirm(game, fx):
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
||||
game.navigate("{section='refinery'}")
|
||||
game.idle()
|
||||
game.scroll_to("confirm:sell-attachment")
|
||||
game.scroll_to("confirm:buy-slot")
|
||||
game.idle()
|
||||
for _ in range(3):
|
||||
game.click("confirm:sell-attachment")
|
||||
game.click("confirm:buy-slot")
|
||||
for _ in range(30):
|
||||
if game.eval("g.dialog:is_open()"):
|
||||
game.sleep(0.4) # (opened: its animation done)
|
||||
@@ -61,10 +61,10 @@ def open_confirm(game, fx):
|
||||
|
||||
|
||||
def open_display(game):
|
||||
"""The header's display button, a real click (again if a rebuild dropped it)."""
|
||||
"""The header's settings gear (its menu), a real click (again if a rebuild dropped it)."""
|
||||
for _ in range(3):
|
||||
game.idle()
|
||||
game.os_click("display")
|
||||
game.os_click("settings")
|
||||
for _ in range(15):
|
||||
if game.eval("g.dialog:is_open()"):
|
||||
game.sleep(0.4)
|
||||
@@ -210,7 +210,7 @@ def run(game, args):
|
||||
for how in ("close", "esc"):
|
||||
game.navigate("{section='new-moon'}")
|
||||
game.idle()
|
||||
game.click("act:buy-moon?include=new-moon")
|
||||
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
|
||||
game.wait_for("g.reveal:card_done()", 30)
|
||||
game.sleep(0.4)
|
||||
if how == "close":
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Layout lint of every section (needs --fixture), then the clicker rule on a few actions
|
||||
(claim a well, collect a batch, sell, buy a tool, upgrade).
|
||||
Run at each size (`make lint` does 1280x800 and 1920x1080). Writes <out>/<view>.lint.json
|
||||
and lint-summary.json; prints the issues. Args: an optional name prefix (the size)."""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
pre = (args[0] + "-") if args else ""
|
||||
moon, well, drill = fx["moon"], fx["well"], fx["drill_mining"]
|
||||
views = [
|
||||
("moons", "{section='moons', moon='%s'}" % moon),
|
||||
("well", "{section='moons', moon='%s', well='%s'}" % (moon, well)),
|
||||
("equipment", "{section='equipment'}"),
|
||||
("drill", "{section='equipment', drill='%s'}" % drill),
|
||||
("store", "{section='store'}"),
|
||||
("market", "{section='market', pick='Sollux'}"),
|
||||
("refinery", "{section='refinery'}"),
|
||||
("advisors", "{section='advisors'}"),
|
||||
("leaderboard", "{section='leaderboard'}"),
|
||||
("help", "{section='help'}"),
|
||||
("new-moon", "{section='new-moon'}"),
|
||||
]
|
||||
game.set("reduced_motion", "true")
|
||||
game.idle()
|
||||
for name, view in views:
|
||||
game.navigate(view)
|
||||
game.idle()
|
||||
game.sleep(0.5)
|
||||
game.lint(pre + name)
|
||||
# The clicker rule: the first of each, where the fixture has one.
|
||||
clicks = [
|
||||
("moons", "act:claim?claim-well"),
|
||||
("refinery", "act:collect?job"),
|
||||
("market", "act:sell?pick"),
|
||||
("store", "act:buy-tool?id"),
|
||||
("equipment", "act:upgrade?tool"),
|
||||
]
|
||||
for name, id_ in clicks:
|
||||
game.navigate(dict(views)[name])
|
||||
game.idle()
|
||||
game.sleep(0.5)
|
||||
if game.eval("el(%r)" % id_):
|
||||
game.click_check(id_, name=pre + "click-" + name)
|
||||
with open(os.path.join(game.out, pre + "lint-summary.json"), "w") as f:
|
||||
json.dump(game.issues, f, indent=1, ensure_ascii=False)
|
||||
counts = {}
|
||||
for i in game.issues:
|
||||
counts[i["rule"]] = counts.get(i["rule"], 0) + 1
|
||||
print("lint total:", counts)
|
||||
@@ -2,7 +2,7 @@
|
||||
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER,
|
||||
default moonminers_db_1). Shots: a depleted moon row (and hovered), the mined-out
|
||||
collapse and the Past tab flash, the greyed Past list, a new moon row entering, a new
|
||||
drill entering (wells table and well panel), deploy cards short and spoken for."""
|
||||
drill entering (well card and well panel), the deploy slot short and with no room."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -98,9 +98,9 @@ def run(game, args):
|
||||
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
|
||||
game.idle()
|
||||
|
||||
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake
|
||||
# from the well panel (the new drill enters in the table and the panel), then the
|
||||
# other cards come up short or spoken for.
|
||||
# Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
|
||||
# its card's slot (the new drill enters in the card and the well panel), then the slot
|
||||
# comes up short ("Only +N left here"), then "No room here" once nothing is left.
|
||||
game.navigate("{section='moons', moon='%s'}" % fx["moon"])
|
||||
game.idle()
|
||||
if game.eval("st.ready") > 0:
|
||||
@@ -112,24 +112,17 @@ def run(game, args):
|
||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
||||
game.idle()
|
||||
game.shot("deploy")
|
||||
# (ids from the state: the elements reply can be long)
|
||||
def card(i):
|
||||
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i))
|
||||
return tool and "act:stake?target=%s&tool=%s" % (well, tool)
|
||||
first = card(1)
|
||||
game.expect(first, "deploy cards are there")
|
||||
game.scroll_to(first) # (the cards and the drills list under them in view)
|
||||
slot = "act:stake?target=%s" % well
|
||||
first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
|
||||
game.expect(first, "the well's deploy slot is there")
|
||||
if first:
|
||||
game.scroll_to(first)
|
||||
game.click(first)
|
||||
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
|
||||
game.idle()
|
||||
game.shot("deploy-short", settle=0.5)
|
||||
# Nothing left after the drills: spoken for (dimmed; less so under the mouse).
|
||||
# Nothing left after the drills: no room.
|
||||
psql("update resource_well set supply = 2 where id = '%s'" % well)
|
||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
|
||||
game.idle()
|
||||
game.shot("deploy-spoken", settle=0.5)
|
||||
n = game.eval("#st.well.idle")
|
||||
if n:
|
||||
game.hover(card(n))
|
||||
game.shot("deploy-hover", settle=0.4)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Moon lab (admin area, screens/game/moonlab.lua): the gear menu's Moon lab opens it (needs
|
||||
an MM_DEV=1 server, where everyone is an admin, or a debug build), the saved settings load,
|
||||
the layout lints, a real mouse drag on a slider follows the pointer and rebuilds the moon
|
||||
(the build times are printed: "moon: ... built, N vertices, M ms"), Save posts the
|
||||
settings (GET answers them back), and Back to the game leaves the lab with the game's
|
||||
moon on the saved settings."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
M = "require('modules.moon.moon')"
|
||||
|
||||
|
||||
def built(game):
|
||||
game.wait_for("not %s.building and not g.lab.pending" % M)
|
||||
return game.eval("%s.last_build and %s.last_build.ms" % (M, M))
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
game.click("settings")
|
||||
game.wait_for("g.dialog:is_open()")
|
||||
game.sleep(0.3)
|
||||
game.click("menu:moon-lab")
|
||||
game.wait_for("g.admin == 'moonlab' and g.lab and g.lab.loaded")
|
||||
game.idle()
|
||||
ms0 = built(game)
|
||||
game.shot("moonlab", settle=0.5)
|
||||
game.lint("moonlab")
|
||||
|
||||
# A click on a slider's track: the value under the pointer (the middle).
|
||||
game.scroll_to("slider:craters.count")
|
||||
game.click("slider:craters.count")
|
||||
game.expect(game.eval("g.lab.draft.craters.count") == 1000, "a click in the middle sets the middle")
|
||||
ms1 = built(game)
|
||||
|
||||
# A real drag across a slider: the knob follows, the moon rebuilds as it goes.
|
||||
game.scroll_to("slider:shape.strength")
|
||||
x, y = game.os_point("slider:shape.strength")
|
||||
game.os_move(x - 40, y)
|
||||
game.xdo("mousedown", "1")
|
||||
t0 = time.time()
|
||||
for i in range(20):
|
||||
game.os_move(x - 40 + i * 8, y)
|
||||
time.sleep(0.03)
|
||||
game.xdo("mouseup", "1")
|
||||
game.idle()
|
||||
v = game.eval("g.lab.draft.shape.strength")
|
||||
ms2 = built(game)
|
||||
print("moonlab: build ms first %s, click %s, after drag %s (drag ended at strength %s, %.1f s)" % (ms0, ms1, ms2, v, time.time() - t0))
|
||||
game.expect(v and v > 0.1, "the drag moved the slider right of the middle: %r" % v)
|
||||
game.shot("moonlab-dragged", settle=0.3)
|
||||
|
||||
game.click_check("lab:save")
|
||||
game.wait_for("not g.lab.saving")
|
||||
game.expect(game.eval("g.lab.error") is None, "saved")
|
||||
with urllib.request.urlopen(game.api + "/api/moon-settings") as r:
|
||||
saved = json.load(r)
|
||||
game.expect(saved["craters"]["count"] == 1000, "the server has the new settings")
|
||||
|
||||
game.click("admin:back")
|
||||
game.wait_for("g.admin == nil")
|
||||
game.idle()
|
||||
game.expect(game.eval("%s.settings.craters.count" % M) == 1000, "the game's moon uses the saved settings")
|
||||
game.shot("moonlab-back", settle=0.5)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The moon view and well panel in the web's look (needs --fixture and a local dev server
|
||||
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked
|
||||
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed
|
||||
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon
|
||||
sale opened (its lines scroll with the moon). Args: an optional name prefix."""
|
||||
"""The moon view and well panel (needs --fixture and a local dev server whose database the
|
||||
scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
|
||||
chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
|
||||
LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
|
||||
with the moon). Args: an optional name prefix."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -19,13 +19,13 @@ def run(game, args):
|
||||
game.idle()
|
||||
game.shot(pre + "table", settle=0.8)
|
||||
ids = [e["id"] for e in game.elements()]
|
||||
wells = [w for w in fx["wells"] if ("manage:" + w) in ids]
|
||||
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"])))
|
||||
wells = [w for w in fx["wells"] if ("well:" + w) in ids]
|
||||
game.expect(len(wells) == len(fx["wells"]), "a card for every well (%d of %d)" % (len(wells), len(fx["wells"])))
|
||||
|
||||
# Drills that get nothing: six more on a well, then the well holds almost nothing.
|
||||
well = fx["wells"][1 % len(fx["wells"])]
|
||||
for _ in range(6):
|
||||
admin(game, "tool", item=2, tier=0)
|
||||
admin(game, "tool", item=2, **{"yield": 0, "cycle": 0})
|
||||
st = call(game, "GET", "/api/state")["state"]
|
||||
idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
|
||||
for tid in idle:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Parity polish (needs --fixture). Phases (args, default all but ready):
|
||||
bars: six more drills on a well: the wells table's bars with "+N" and the multi-well
|
||||
diamond caps, the well panel in the web's order (deploy, drills, then the claims).
|
||||
bars: six more drills on a well: their slots on the well's card, the multi-well card,
|
||||
the well panel's drill lines (it only shows: the actions are on the cards).
|
||||
ready: run with --config mm.fake_latency_ms=2500: drills finish while the refetch is
|
||||
held back; the header badge, Claim all, moon row badge and Claim buttons count up on
|
||||
our clock ("Claim N drills", "Claim <amount>").
|
||||
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced,
|
||||
so nothing is saved), the still ticker, a reduced reveal.
|
||||
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery
|
||||
recipes."""
|
||||
links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
|
||||
"Better" well is plain text in the well panel now: its link shot is skipped)."""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -47,7 +47,7 @@ def idle_tools(game, item):
|
||||
|
||||
def stake_many(game, item, n, well):
|
||||
for _ in range(n):
|
||||
admin(game, "tool", item=item, tier=0)
|
||||
admin(game, "tool", item=item, **{"yield": 0, "cycle": 0})
|
||||
for tid in idle_tools(game, item)[:n]:
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
||||
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
|
||||
@@ -84,7 +84,7 @@ def ready(game):
|
||||
free = [w]
|
||||
well = free[0]["id"]
|
||||
for _ in range(2):
|
||||
admin(game, "tool", item=3, tier=0)
|
||||
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
|
||||
copies = idle_tools(game, 3)[:2]
|
||||
for tid in copies:
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
|
||||
@@ -111,6 +111,7 @@ def motion(game):
|
||||
game.navigate("{section='market', pick='Sollux'}")
|
||||
game.idle()
|
||||
game.shot("motion-off-market", settle=0.5)
|
||||
game.click("settings")
|
||||
game.click("motion")
|
||||
game.idle()
|
||||
if game.eval('require("modules.anim").reduced') is not True:
|
||||
@@ -142,7 +143,7 @@ def links(game):
|
||||
fx = game.fixture
|
||||
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
|
||||
for _ in range(2):
|
||||
admin(game, "tool", item=3, tier=0)
|
||||
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
|
||||
copies = idle_tools(game, 3)
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]})
|
||||
game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Refinery batch size upgrade (needs --fixture): the Slots row shows the batch size and
|
||||
"Upgrade to N · price"; clicking it (click_check: nothing moves under the pointer) raises
|
||||
the size, the quantity field's cap follows; shots before and after. Then the upgrades are
|
||||
bought up to the top (through the API) for the "Biggest batches" row."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin, call # noqa: E402
|
||||
|
||||
UPGRADE = "act:upgrade-batch?"
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
call(game, "POST", "/api/act/buy-slot", {}) # (a free slot: the recipes' fields are on)
|
||||
game.navigate("{section='refinery'}")
|
||||
game.idle()
|
||||
before = game.eval("st.refinery.maxBatch")
|
||||
nxt = game.eval("st.refinery.nextBatch")
|
||||
game.expect(nxt and nxt > before, "a next batch size (%s after %s)" % (nxt, before))
|
||||
game.shot("before", settle=0.4)
|
||||
game.click_check(UPGRADE)
|
||||
game.shot("clicked", settle=0.15)
|
||||
game.wait_for("not g.busy and #g.pending == 0 and st.refinery.maxBatch == %d" % nxt, 10)
|
||||
game.idle()
|
||||
game.shot("after", settle=0.8)
|
||||
item = game.eval("st.refinery.recipes[1].item")
|
||||
game.expect(game.eval("st.rules.refinery.maxBatch") == nxt, "rules follow the batch size")
|
||||
game.scroll_to("field:qty:" + item)
|
||||
game.idle()
|
||||
game.click("field:qty:" + item)
|
||||
game.key("key_backspace")
|
||||
game.type("99")
|
||||
game.idle()
|
||||
got = game.eval("g.refine_qty['%s']" % item)
|
||||
game.expect(got == str(nxt), "the quantity caps at the new size: %s" % got)
|
||||
game.key("key_esc")
|
||||
|
||||
# Up to the top.
|
||||
admin(game, "credits", amount=20000000, op="grant")
|
||||
for _ in range(10):
|
||||
r = call(game, "POST", "/api/act/upgrade-batch", {})
|
||||
if not r.get("ok"):
|
||||
break
|
||||
game.navigate("{section='market'}")
|
||||
game.idle()
|
||||
game.navigate("{section='refinery'}")
|
||||
game.idle()
|
||||
game.expect(not game.eval("st.refinery.nextBatch and st.refinery.nextBatch > 0"), "at the top")
|
||||
game.key("key_home")
|
||||
game.idle()
|
||||
game.shot("top", settle=0.6)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Refinery feedback (needs --fixture): a short batch finishing with the Refinery open
|
||||
(shots just before, during and after: the light along the bar, the edge pulse, Ready and
|
||||
Collect popping in), Collect (the amount rising above the slot, motes flying into the recipe card's
|
||||
owned number, which counts up), then a batch finishing while another section is open (the Refinery tab's pulse and badge
|
||||
pop). With "reduced": the same with reduced motion (no pop, sweep or sparks)."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import burst, call # noqa: E402
|
||||
|
||||
# Seconds to the end of the newest batch (the one start_batch made).
|
||||
LEFT = ("(function() local n for _,j in ipairs(g.base.jobs or {}) do if not n or j.start>n.start then n=j end end "
|
||||
"return n and (n['end']-g.offset)/1000-socket.gettime() end)()")
|
||||
|
||||
|
||||
def start_batch(game, section):
|
||||
call(game, "POST", "/api/act/collect", {})
|
||||
r = call(game, "POST", "/api/act/refine", {"recipe": "Dusk Alloy", "qty": "1"})
|
||||
if not r.get("ok"): # (the fixture's batches hold every slot: buy one)
|
||||
call(game, "POST", "/api/act/buy-slot", {})
|
||||
r = call(game, "POST", "/api/act/refine", {"recipe": "Dusk Alloy", "qty": "1"})
|
||||
game.expect(r.get("ok"), "refine: %s" % r.get("error"))
|
||||
game.navigate("{section='%s'}" % section)
|
||||
game.idle()
|
||||
left = game.eval(LEFT)
|
||||
game.expect(left is not None, "a running batch")
|
||||
return left
|
||||
|
||||
|
||||
def wait_until(game, left, before):
|
||||
"""Sleep until `before` s ahead of the batch's end (left: seconds to it, measured now)."""
|
||||
t = left - before
|
||||
if t > 0:
|
||||
time.sleep(t)
|
||||
|
||||
|
||||
def run(game, args):
|
||||
reduced = "reduced" in args
|
||||
pre = "reduced-" if reduced else ""
|
||||
game.idle()
|
||||
if reduced:
|
||||
game.set("reduced_motion", "1")
|
||||
game.idle()
|
||||
|
||||
# 1. Refinery open: running batches (the fixture's) leave a slot free; collect what is
|
||||
# ready first so the new batch is the only one finishing.
|
||||
left = start_batch(game, "refinery")
|
||||
print(" batch ends in %.1f s" % left)
|
||||
game.shot(pre + "running", settle=0.3)
|
||||
wait_until(game, game.eval(LEFT), 0.25)
|
||||
burst(game, pre + "finish", [0.0, 0.35, 0.55, 0.8, 1.3, 2.5])
|
||||
game.expect(game.eval("(function() for _,t in pairs(g.batches_done or {}) do if t>0 then return true end end end)()"),
|
||||
"the batch's end was seen")
|
||||
game.wait_for("(function() for _,j in ipairs(g.base.jobs or {}) do if j.ready then return true end end end)()", 10)
|
||||
game.idle()
|
||||
|
||||
# 2. Collect: the amount flies into the recipe card's owned number.
|
||||
job = game.eval("(function() for _,j in ipairs(g.base.jobs or {}) do if j.ready then return j.id end end end)()")
|
||||
game.click("act:collect?job=" + job)
|
||||
burst(game, pre + "collect", [0.1, 0.35, 0.6, 0.9, 1.4, 2.4])
|
||||
game.idle()
|
||||
|
||||
# 3. Another section open: the Refinery tab reacts.
|
||||
left = start_batch(game, "market")
|
||||
wait_until(game, game.eval(LEFT), 0.2)
|
||||
burst(game, pre + "tab", [0.0, 0.3, 0.5, 0.8, 1.5])
|
||||
game.idle()
|
||||
game.expect(game.eval("g.view.section") == "market", "still on the Market")
|
||||
if reduced:
|
||||
game.set("reduced_motion", "0")
|
||||
game.expect(not game.log_has(r"ERROR:MM|SCRIPT ERROR|stack traceback"), "no script errors")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""The new-moon reveal window (needs --fixture and an MM_DEV=1 server: grants through the
|
||||
admin page). "+ New moon" at the top of the moon list buys a moon at once; the reveal plays
|
||||
in the centered window (shots: charge, the drawer out); the credits count down during it;
|
||||
Start mining and Buy another are in the same spot on two reveals; after closing, the
|
||||
announcements pane lists the toast. Then without the credits for a moon the button is
|
||||
disabled."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin, call # noqa: E402
|
||||
|
||||
POS = "(function() local r = g.reveal.r local p = gui.get_screen_position(r.close_btn) local q = r.another_btn and gui.get_screen_position(r.another_btn) return string.format('%d,%d|%s', p.x, p.y, q and string.format('%d,%d', q.x, q.y) or '-') end)()"
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
admin(game, "credits", amount=50000, op="grant")
|
||||
game.navigate("{section='moons'}")
|
||||
game.idle()
|
||||
n0 = game.eval("#st.moons")
|
||||
game.click("newmoon:buy")
|
||||
game.wait_for("g.reveal:active()", 10)
|
||||
game.shot("reveal-charge", settle=0.4)
|
||||
game.wait_for("g.reveal.r and g.reveal.r.card ~= nil", 20)
|
||||
game.sleep(1.0)
|
||||
shown = game.eval("g.fx:value('credits', st.credits)")
|
||||
real = game.eval("st.credits")
|
||||
game.expect(abs(shown - real) < 1, "credits counted down during the reveal: shown %s, state %s" % (shown, real))
|
||||
game.wait_for("g.reveal:card_done()", 30)
|
||||
game.sleep(0.4)
|
||||
game.shot("reveal-drawer", settle=0.3)
|
||||
first = game.eval(POS)
|
||||
first_id = game.eval("g.reveal.r.data.moonId")
|
||||
game.click("reveal:buy")
|
||||
game.wait_for("g.reveal.r and g.reveal.r.data.moonId ~= '%s' and g.reveal:card_done()" % first_id, 40)
|
||||
game.sleep(0.4)
|
||||
second = game.eval(POS)
|
||||
game.expect(first == second, "buttons in the same spot: %s vs %s" % (first, second))
|
||||
game.shot("reveal-second", settle=0.3)
|
||||
game.click("reveal:close")
|
||||
game.wait_for("not g.reveal:active()", 10)
|
||||
game.idle()
|
||||
game.expect(game.eval("#st.moons") == n0 + 2, "two moons bought")
|
||||
game.expect(game.eval("#g.toasts.log") >= 1, "the toast is in the announcements log")
|
||||
game.sleep(0.8)
|
||||
game.shot("announcements", settle=0.3)
|
||||
|
||||
st = call(game, "GET", "/api/state?catalog=0")["state"]
|
||||
extra = st["credits"] - st["moonPrice"] + 5
|
||||
if extra > 0:
|
||||
admin(game, "credits", amount=round(extra, 2), op="remove")
|
||||
game.navigate("{section='moons'}")
|
||||
game.idle()
|
||||
els = [e["id"] for e in game.elements()]
|
||||
game.expect("newmoon:buy" not in els, "short: the button is disabled")
|
||||
game.shot("newmoon-short", settle=0.3)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Save codes (a new player, no fixture): the gear menu's Save code makes one and shows it;
|
||||
Restore from code with another player's code (made here through the API) switches to that
|
||||
player; a wrong code is an error toast and keeps the player."""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
|
||||
def api(game, path, user=None, body=None):
|
||||
h = {"Content-Type": "application/json"}
|
||||
if user:
|
||||
h["X-MM-User"] = user
|
||||
req = urllib.request.Request(game.api + path, headers=h, method="POST" if body is not None else "GET",
|
||||
data=json.dumps(body).encode() if body is not None else None)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def menu(game, item, title):
|
||||
game.idle()
|
||||
game.click("settings")
|
||||
game.wait_for("g.dialog:is_open()")
|
||||
game.sleep(0.3)
|
||||
game.click(item)
|
||||
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == '%s'" % title)
|
||||
game.sleep(0.5)
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
me = game.eval("require('modules.api').player_id()")
|
||||
menu(game, "menu:save-code", "Save code")
|
||||
game.shot("save-code", settle=0.3)
|
||||
game.click("dialog:ok")
|
||||
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == 'Your save code'")
|
||||
code = game.eval("g.save_code")
|
||||
game.expect(isinstance(code, str) and len(code) == 19, "a code is shown: %r" % code)
|
||||
game.shot("save-code-shown", settle=0.5)
|
||||
game.key("key_esc")
|
||||
game.wait_for("not g.dialog.root")
|
||||
|
||||
# A wrong code: an error, same player.
|
||||
menu(game, "menu:restore", "Restore from code")
|
||||
game.type("AAAA-AAAA-AAAA-AAAA")
|
||||
game.click("dialog:ok")
|
||||
game.wait_for("not g.dialog.root and not g.busy")
|
||||
game.expect(game.log_has(r"doesn't match"), "a wrong code says so")
|
||||
game.expect(game.eval("require('modules.api').player_id()") == me, "a wrong code keeps the player")
|
||||
|
||||
# Another player's code switches to it.
|
||||
other = api(game, "/api/state?catalog=0")["userId"]
|
||||
other_code = api(game, "/api/act/save-code?catalog=0", other, {})["saveCode"]["code"]
|
||||
menu(game, "menu:restore", "Restore from code")
|
||||
game.type(other_code.lower())
|
||||
game.shot("restore", settle=0.3)
|
||||
game.click("dialog:ok")
|
||||
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % other)
|
||||
game.expect(game.log_has(r"Welcome back"), "restoring says welcome back")
|
||||
game.shot("restored", settle=0.5)
|
||||
# The old code still brings the first player back.
|
||||
menu(game, "menu:restore", "Restore from code")
|
||||
game.type(code)
|
||||
game.click("dialog:ok")
|
||||
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % me)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
|
||||
panel's Sell (an idle copy) turns into Cancel on its spot with Confirm just left of it and
|
||||
the server's question where the text was; no dialog opens; nothing else moves. It cancels
|
||||
on Esc, on Cancel, on a click elsewhere and after ASK_IDLE seconds; Enter confirms (Confirm
|
||||
has the soft focus) and sells the drill. Then the moon's Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
|
||||
|
||||
|
||||
def rect_of(game, id_):
|
||||
for e in game.elements():
|
||||
if e["id"] == id_:
|
||||
return e["rect"]
|
||||
return None
|
||||
|
||||
|
||||
def ask(game, id_):
|
||||
"""Click until it asks (a rebuild can drop a press)."""
|
||||
game.scroll_to(id_)
|
||||
game.idle()
|
||||
for i in range(3):
|
||||
game.click(id_)
|
||||
try:
|
||||
game.wait_for("g.ask ~= nil", 5)
|
||||
break
|
||||
except Exception:
|
||||
if i == 2:
|
||||
raise
|
||||
game.idle()
|
||||
game.idle()
|
||||
|
||||
|
||||
def edges(r):
|
||||
"""An element's rect (the test hook gives its center x, y) as left, top, right, bottom."""
|
||||
return r["x"] - r["w"] / 2, r["y"] - r["h"] / 2, r["x"] + r["w"] / 2, r["y"] + r["h"] / 2
|
||||
|
||||
|
||||
def same_spot(a, b):
|
||||
return a and b and all(abs(p - q) <= 1 for p, q in zip(edges(a), edges(b)))
|
||||
|
||||
|
||||
def covers(outer, inner):
|
||||
"""outer takes inner's spot: same row, same right edge, at least as wide."""
|
||||
if not (outer and inner):
|
||||
return False
|
||||
o, i = edges(outer), edges(inner)
|
||||
return abs(o[1] - i[1]) <= 1 and abs(o[3] - i[3]) <= 1 and abs(o[2] - i[2]) <= 1 and o[0] <= i[0] + 1
|
||||
|
||||
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
pre = (args[0] + "-") if args else ""
|
||||
sell = "confirm:sell-tool"
|
||||
game.idle()
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||
game.idle()
|
||||
game.scroll_to(sell)
|
||||
before = rect_of(game, sell)
|
||||
game.shot(pre + "sellask-before", settle=0.3)
|
||||
ask(game, sell)
|
||||
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
|
||||
cancel, ok = rect_of(game, sell + ":cancel"), rect_of(game, sell + ":ok")
|
||||
game.expect(covers(cancel, before), "Cancel takes Sell's spot (%s vs %s)" % (before, cancel))
|
||||
game.expect(ok is not None and edges(ok)[2] <= edges(cancel)[0] and abs(ok["y"] - cancel["y"]) <= 1,
|
||||
"Confirm sits just left of Cancel")
|
||||
game.lint(pre + "sellask-asking")
|
||||
game.shot(pre + "sellask-asking", settle=0.3)
|
||||
|
||||
game.key("key_esc")
|
||||
game.wait_for("g.ask == nil", 5)
|
||||
game.idle()
|
||||
game.expect(same_spot(before, rect_of(game, sell)), "Esc: Sell is back on its spot")
|
||||
|
||||
ask(game, sell)
|
||||
game.click(sell + ":cancel")
|
||||
game.wait_for("g.ask == nil", 5)
|
||||
game.idle()
|
||||
|
||||
ask(game, sell)
|
||||
game.click("settings") # (a click elsewhere: the gear's menu)
|
||||
game.wait_for("g.ask == nil", 5)
|
||||
game.key("key_esc")
|
||||
game.wait_for("not g.dialog:is_open()", 5)
|
||||
game.idle()
|
||||
|
||||
ask(game, sell)
|
||||
game.wait_for("g.ask == nil", 12) # (idle timeout)
|
||||
game.idle()
|
||||
|
||||
# Enter confirms: Confirm has the (soft) focus after the mouse click.
|
||||
ask(game, sell)
|
||||
game.expect(game.eval("g.focus and g.focus.key") == "page:" + sell + ":ok", "Confirm has the focus")
|
||||
game.key("key_enter")
|
||||
game.wait_for("g.ask == nil and not g.busy", 10)
|
||||
game.idle()
|
||||
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
|
||||
game.shot(pre + "sellask-sold", settle=0.4)
|
||||
|
||||
# The moon's Sell moon (when it can be sold yet).
|
||||
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
|
||||
game.idle()
|
||||
can = game.eval("g.state.moon and g.state.moon.sale and g.state.moon.sale.canSell")
|
||||
if not can and game.eval("g.state.moon and g.state.moon.sale ~= nil"):
|
||||
# (a fresh moon is on hold: enable the button locally for the layout only; the
|
||||
# question is the server's; this run never confirms it)
|
||||
game.eval("(function() g.state.moon.sale.canSell = true; g.dirty = true; return true end)()")
|
||||
game.wait_for("not g.dirty", 5)
|
||||
can = True
|
||||
if can and rect_of(game, "sell-moon"):
|
||||
game.scroll_to("sell-moon") # (at the foot of the aside's well panel, which scrolls)
|
||||
game.idle()
|
||||
before = rect_of(game, "sell-moon")
|
||||
ask(game, "sell-moon")
|
||||
game.expect(covers(rect_of(game, "sell-moon:cancel"), before), "Cancel takes Sell moon's spot")
|
||||
game.lint(pre + "sellask-moon")
|
||||
game.shot(pre + "sellask-moon", settle=0.3)
|
||||
game.key("key_esc")
|
||||
game.wait_for("g.ask == nil", 5)
|
||||
else:
|
||||
print(" (moon not sellable yet: no Sell moon shot)")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
|
||||
the moon viewport (its drag used to take the press)."""
|
||||
|
||||
|
||||
def run(game, args):
|
||||
game.idle()
|
||||
checks = [
|
||||
("mute", "tostring(require('modules.sfx').muted())"),
|
||||
("theme", "tostring(require('modules.theme').light)"),
|
||||
("motion", "tostring(require('modules.anim').reduced)"),
|
||||
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
|
||||
("menu:save-code", "tostring(g.dialog:is_open())"),
|
||||
]
|
||||
for id_, expr in checks:
|
||||
game.os_click("settings")
|
||||
game.wait_for("g.dialog:is_open()", timeout=5)
|
||||
game.sleep(0.8)
|
||||
before = game.eval(expr)
|
||||
game.os_click(id_)
|
||||
game.sleep(1.2)
|
||||
after = game.eval(expr)
|
||||
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
|
||||
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
|
||||
game.key("key_esc")
|
||||
game.sleep(0.6)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row
|
||||
under the moon list and the main panel and the page scrolls to it (a well, the drill
|
||||
panel); sections without the aside use the whole width; the banner wraps; keys scroll
|
||||
under the moon list and the main panel and the page scrolls to it (the drill panel; a
|
||||
well's card stays in view); sections without the aside use the whole width; the banner wraps; keys scroll
|
||||
the page. Args: an optional name prefix."""
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ def run(game, args):
|
||||
game.click("well:" + well)
|
||||
game.wait_for("view.well == '%s' and not busy" % well)
|
||||
game.idle()
|
||||
# (picking a well card keeps the cards in view: its actions are there)
|
||||
game.expect((game.eval("g.scroll_pos.page or 0") or 0) < 1, "picking a well doesn't scroll the page")
|
||||
game.shot(pre + "small-well-card", settle=0.8)
|
||||
game.hover("tab:market")
|
||||
game.key("key_end")
|
||||
game.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
|
||||
game.shot(pre + "small-well", settle=1.0)
|
||||
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Server texts in the confirm dialogs (needs --fixture): sell drill, buy seat, buy
|
||||
refinery slot and start a new game open the server's confirm (each cancelled with Esc,
|
||||
nothing bought); the theme toggle switches live. Shots of each."""
|
||||
"""Server texts in the confirms (needs --fixture): selling an idle drill asks in place
|
||||
(cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
|
||||
confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
|
||||
Shots of each."""
|
||||
|
||||
|
||||
def confirm(game, el, shot):
|
||||
@@ -21,13 +22,31 @@ def confirm(game, el, shot):
|
||||
game.wait_for("not g.dialog.root")
|
||||
|
||||
|
||||
def ask(game, el, shot):
|
||||
game.scroll_to(el)
|
||||
game.idle()
|
||||
for i in range(3):
|
||||
game.click(el)
|
||||
try:
|
||||
game.wait_for("g.ask ~= nil", 6)
|
||||
break
|
||||
except Exception:
|
||||
if i == 2:
|
||||
raise
|
||||
game.idle()
|
||||
game.idle()
|
||||
game.shot(shot, settle=0.4)
|
||||
game.key("key_esc")
|
||||
game.wait_for("g.ask == nil")
|
||||
|
||||
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
pre = (args[0] + "-") if args else ""
|
||||
game.idle()
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||
game.idle()
|
||||
confirm(game, "confirm:sell-attachment", pre + "texts-sell-attachment")
|
||||
ask(game, "confirm:sell-tool", pre + "texts-sell-tool")
|
||||
game.navigate("{section='advisors'}")
|
||||
game.idle()
|
||||
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
|
||||
@@ -38,9 +57,11 @@ def run(game, args):
|
||||
game.idle()
|
||||
confirm(game, "confirm:new-game", pre + "texts-new-game")
|
||||
before = game.eval("require('modules.theme').name")
|
||||
game.click("settings")
|
||||
game.click("theme")
|
||||
game.wait_for("require('modules.theme').name ~= '%s'" % before)
|
||||
game.idle()
|
||||
game.shot(pre + "texts-theme-toggled", settle=0.6)
|
||||
game.click("settings")
|
||||
game.click("theme")
|
||||
game.wait_for("require('modules.theme').name == '%s'" % before)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Drill upgrade tracks (needs --fixture): the Equipment table's two track columns (yield and
|
||||
cycle: value ▸ next, level pips, Upgrade or Max, the kept need line) and the
|
||||
drill panel of the fixture's upgraded idle PD-1 (both boxes, both ladders, Sell drill).
|
||||
Clicking each Upgrade twice in the panel keeps the buttons where they are (click_check).
|
||||
Shots: tracks-equipment, tracks-panel, tracks-panel-after. Args: an optional name prefix."""
|
||||
|
||||
|
||||
def ups(game):
|
||||
return sorted(e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?"))
|
||||
|
||||
|
||||
def run(game, args):
|
||||
fx = game.fixture
|
||||
pre = (args[0] + "-") if args else ""
|
||||
game.idle()
|
||||
game.navigate("{section='equipment'}")
|
||||
game.idle()
|
||||
game.shot(pre + "tracks-equipment", settle=1.0)
|
||||
game.lint(pre + "tracks-equipment")
|
||||
|
||||
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
|
||||
game.idle()
|
||||
game.shot(pre + "tracks-panel", settle=1.0)
|
||||
game.lint(pre + "tracks-panel")
|
||||
tool = game.eval("g.state.equipment.drill.toolId")
|
||||
for kind in ("yield", "cycle"):
|
||||
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
|
||||
game.expect(ids, "%s upgrade button in the panel: %s" % (kind, ups(game)))
|
||||
for n in range(2):
|
||||
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
|
||||
if not ids:
|
||||
break # (the top level: Max level in its place)
|
||||
game.click_check(ids[-1], name=pre + "tracks-click-%s-%d" % (kind, n))
|
||||
game.shot(pre + "tracks-panel-after", settle=1.0)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Well cards (needs --fixture and an MM_DEV=1 server: admin finish-stakes): every action
|
||||
on a well is on its card in the main panel, the aside only shows. Buys a fresh moon, then:
|
||||
the cards with their empty "+ Deploy" slots and the tray of idle drills (shot cards-empty);
|
||||
a tray tile deploys straight to the selected well (cards-picked); the card's deploy slot
|
||||
keeps its spot (click_check); a multi-well drill (the wide card); finishes the drills: the
|
||||
ready slots, gold with Claim and Redeploy (cards-ready); Claim on a slot (click_check), the
|
||||
idle slot with Redrill (cards-idle), Redrill; selling the moon is in the aside. The
|
||||
aside, with a well and with none selected, has no buttons but Sell moon, the one destructive
|
||||
action kept there (aside-well, aside-moon).
|
||||
Args: an optional name prefix."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lists import admin, call # noqa: E402
|
||||
|
||||
|
||||
def aside_buttons(game):
|
||||
"""Registered elements drawn inside the aside panel (below the header)."""
|
||||
box = game.eval("(function() local p = g.aside_panel and g.aside_panel.root if not p then return nil end "
|
||||
"local s = gui.get_screen_position(p) return {s.x, s.y} end)()")
|
||||
if not box:
|
||||
return []
|
||||
left, top = box[0], box[1]
|
||||
out = []
|
||||
for e in game.elements():
|
||||
r = e.get("rect")
|
||||
if not r or not e.get("visible") or e.get("kind") == "wait":
|
||||
continue
|
||||
if r["x"] >= left - 1 and r["y"] <= top + 1 and not e["id"].startswith(("sell-moon", "moon-sale:")):
|
||||
out.append(e["id"])
|
||||
return out
|
||||
|
||||
|
||||
def ids(game, prefix):
|
||||
return [e["id"] for e in game.elements() if e["id"].startswith(prefix) and e.get("visible", True)]
|
||||
|
||||
|
||||
def run(game, args):
|
||||
pre = (args[0] + "-") if args else ""
|
||||
game.idle()
|
||||
admin(game, "credits", amount=100000, op="grant")
|
||||
had = {m["id"] for m in call(game, "GET", "/api/state?catalog=0")["state"]["moons"]}
|
||||
r = call(game, "POST", "/api/act/buy-moon", {})
|
||||
game.expect(r.get("ok"), "buy a moon: %s" % r.get("error"))
|
||||
st = call(game, "GET", "/api/state?catalog=0")["state"]
|
||||
moon = next(m for m in st["moons"] if m["id"] not in had)
|
||||
wells = [w["id"] for w in moon["wells"]]
|
||||
game.navigate("{section='moons', moon='%s'}" % moon["id"])
|
||||
game.idle()
|
||||
game.wait_for("st.moon and st.moon.id == '%s'" % moon["id"], 10)
|
||||
game.idle()
|
||||
game.shot(pre + "cards-empty", settle=0.8)
|
||||
game.lint(pre + "cards-empty")
|
||||
|
||||
# A tray tile deploys straight to the selected well (no picking): select the first well,
|
||||
# click the lowest "stake" button there (the tray sits under the cards).
|
||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
|
||||
game.idle()
|
||||
slot = "act:stake?target=" + wells[0]
|
||||
btns = [e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)]
|
||||
game.expect(len(btns) > 1, "the tray's tiles deploy to the selected well")
|
||||
n0 = game.eval("#st.stakes")
|
||||
tile = max(btns, key=lambda e: e["rect"]["y"])["id"]
|
||||
game.scroll_to(tile)
|
||||
game.click(tile)
|
||||
game.wait_for("#st.stakes > %d" % n0, 10)
|
||||
game.idle()
|
||||
game.shot(pre + "cards-picked", settle=0.5)
|
||||
# The card's deploy slot (topmost) keeps its spot under repeated clicks.
|
||||
top = min((e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)),
|
||||
key=lambda e: e["rect"]["y"], default=None)
|
||||
if top:
|
||||
game.scroll_to(top["id"])
|
||||
game.click_check(top["id"], name=pre + "click-deploy")
|
||||
game.idle()
|
||||
|
||||
# A multi-well drill (bought when none is idle).
|
||||
st = call(game, "GET", "/api/state")["state"]
|
||||
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
|
||||
if not wide:
|
||||
item = next((t for t in st["catalog"]["tools"] if (t.get("reach") or 1) > 1), None)
|
||||
if item:
|
||||
call(game, "POST", "/api/act/buy-tool", {"id": str(item["storeItemId"])})
|
||||
st = call(game, "GET", "/api/state?catalog=0")["state"]
|
||||
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
|
||||
if wide:
|
||||
r = call(game, "POST", "/api/act/stake", {"tool": wide[0]["id"], "target": wells[-1]})
|
||||
game.expect(r.get("ok"), "stake a multi-well drill: %s" % r.get("error"))
|
||||
# One more one-well drill on another well, so a card has two drill slots.
|
||||
one = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) == 1]
|
||||
if one and len(wells) > 1:
|
||||
call(game, "POST", "/api/act/stake", {"tool": one[0]["id"], "target": wells[1]})
|
||||
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
|
||||
game.idle()
|
||||
game.shot(pre + "cards-mining", settle=0.8)
|
||||
game.lint(pre + "cards-mining")
|
||||
left = aside_buttons(game)
|
||||
game.expect(not left, "the aside (a well) has no buttons: %s" % left)
|
||||
game.shot(pre + "aside-well", settle=0.3)
|
||||
|
||||
# Finish them: ready slots.
|
||||
admin(game, "finish-stakes")
|
||||
game.navigate("{section='moons', moon='%s'}" % moon["id"])
|
||||
game.idle()
|
||||
game.wait_for("(function() for _, w in ipairs(st.moon.wells) do if w.ready > 0 then return true end end end)()", 15)
|
||||
game.idle()
|
||||
game.shot(pre + "cards-ready", settle=0.8)
|
||||
game.lint(pre + "cards-ready")
|
||||
claims = [i for i in ids(game, "act:claim?claim-well=" + wells[0]) if "redeploy" not in i]
|
||||
game.expect(len(claims) > 0, "a ready slot has Claim")
|
||||
game.click_check(claims[0], name=pre + "click-claim")
|
||||
game.wait_for("not g.busy and #g.pending == 0", 20)
|
||||
game.idle()
|
||||
game.shot(pre + "cards-idle", settle=0.8)
|
||||
redrill = ids(game, "act:redrill?redrill-well=" + wells[0])
|
||||
left_here = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.supply end end end)()" % wells[0])
|
||||
remain = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.remain end end end)()" % wells[0])
|
||||
# (none while the drills on it take all it has: the server would refuse it)
|
||||
game.expect(len(redrill) > 0 or not left_here or not remain, "the idle slot has Redrill (the well has %s left, %s free)" % (left_here, remain))
|
||||
if redrill:
|
||||
game.click(redrill[0])
|
||||
game.wait_for("not g.busy and #g.pending == 0", 20)
|
||||
game.idle()
|
||||
game.expect(ids(game, "moon-sale:" + moon["id"]) or ids(game, "sell-moon"), "selling the moon is in the aside")
|
||||
game.shot(pre + "cards-redrilled", settle=0.8)
|
||||
left = aside_buttons(game)
|
||||
game.expect(not left, "the aside (the moon) has no buttons: %s" % left)
|
||||
game.shot(pre + "aside-moon", settle=0.3)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""The well panel scrolled down (needs --fixture): deploy cards with pictures, pips and
|
||||
reach, drill lines with thumbnails and attachment tags, the drills heading's share note;
|
||||
"""The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
|
||||
cards): drill lines with thumbnails and level tags, the drills heading's share note;
|
||||
then the market (movers, quote values, Refined tag) and a past moon in the list."""
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
|
||||
fi
|
||||
|
||||
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
|
||||
# The game's saved files (player ids, settings) go beside the screenshot, never into the
|
||||
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
|
||||
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
|
||||
mkdir -p "$XDG_DATA_HOME"
|
||||
cd "$(dirname "$bin")"
|
||||
log=$(dirname "$out")/shot.log
|
||||
TIMEOUT=${TIMEOUT:-120}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint a layout dump (the test hook's `dump`; drive.py game.dump / the `dump` scenario).
|
||||
|
||||
Usage:
|
||||
scripts/ui_lint.py DUMP.json lint one screen
|
||||
scripts/ui_lint.py BEFORE.json AFTER.json --clicked ID [--allow PREFIX ...]
|
||||
the clicker rule across an action
|
||||
--json print the issues as JSON (default: one line each)
|
||||
--min N minimum hit size in layout px (default 24)
|
||||
|
||||
Rules (one screen):
|
||||
overlap (warn) two texts/buttons on top of each other (neither inside the other)
|
||||
overflow (warn) a text or button sticking out of the panel/button it's in, or a
|
||||
wrapped text taller than its box
|
||||
off_screen (warn) a shown text/button not inside the window
|
||||
small_hit (warn) a button/link/tab smaller than --min in width or height
|
||||
text_cut (info) text ended with "…" (cut to fit; fine if meant)
|
||||
Rules (before/after an action):
|
||||
clicked_moved (error) the clicked button is still shown but moved
|
||||
moved (warn) another button stayed shown but moved (--allow PREFIX for ids
|
||||
that may: a declared animation; the next of the clicked one's
|
||||
kind sliding into its spot is fine)
|
||||
clicked_gone (info) the clicked button went and nothing of its kind took its spot
|
||||
|
||||
Dump format: modules/test.lua (dump). Each issue: {rule, sev, msg, nodes: [...]}.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
INTERACTIVE_SKIP = {"text", "panel", "clip"}
|
||||
TOL = 1.5
|
||||
# A text's rect is its font's full line box (ascent + descent); glyphs fill less of it.
|
||||
TEXT_INSET = 0.15
|
||||
MIN_HIT = {"button": 24, "tab": 24, "field": 24} # others (links, rows): 18
|
||||
|
||||
|
||||
def interactive(n):
|
||||
return n["kind"] not in INTERACTIVE_SKIP
|
||||
|
||||
|
||||
def shown(n):
|
||||
return n.get("clip") != "all"
|
||||
|
||||
|
||||
def desc(n):
|
||||
who = n.get("id") or (repr(n["text"][:40]) if n.get("text") else None) or n.get("name") or "#%d" % n["i"]
|
||||
return "%s %s [%d,%d %dx%d]" % (n["kind"], who, n["x"], n["y"], n["w"], n["h"])
|
||||
|
||||
|
||||
def brief(n):
|
||||
out = {k: n[k] for k in ("i", "kind", "id", "name", "text", "layer") if n.get(k) is not None}
|
||||
out["rect"] = [n["x"], n["y"], n["w"], n["h"]]
|
||||
return out
|
||||
|
||||
|
||||
def issue(rule, sev, msg, *nodes):
|
||||
return {"rule": rule, "sev": sev, "msg": msg, "nodes": [brief(n) for n in nodes]}
|
||||
|
||||
|
||||
def visible(n):
|
||||
"""The part of n inside its stencils (dump's vis, for a partly clipped node)."""
|
||||
v = n.get("vis")
|
||||
return dict(n, x=v[0], y=v[1], w=v[2], h=v[3]) if v else n
|
||||
|
||||
|
||||
def inter(a, b):
|
||||
a, b = glyphs(visible(a)), glyphs(visible(b))
|
||||
return (min(a["x"] + a["w"], b["x"] + b["w"]) - max(a["x"], b["x"]),
|
||||
min(a["y"] + a["h"], b["y"] + b["h"]) - max(a["y"], b["y"]))
|
||||
|
||||
|
||||
def glyphs(n):
|
||||
if n["kind"] != "text":
|
||||
return n
|
||||
d = n["h"] * TEXT_INSET
|
||||
return dict(n, y=n["y"] + d, h=n["h"] - 2 * d)
|
||||
|
||||
|
||||
def ancestors(nodes, n):
|
||||
seen = set()
|
||||
p = n.get("p", 0)
|
||||
while p:
|
||||
seen.add(p)
|
||||
p = nodes[p - 1].get("p", 0)
|
||||
return seen
|
||||
|
||||
|
||||
def container(nodes, n):
|
||||
"""The nearest panel/button above n that is bigger than it."""
|
||||
p = n.get("p", 0)
|
||||
while p:
|
||||
c = nodes[p - 1]
|
||||
if c["kind"] != "text" and c["kind"] != "clip" and c["w"] * c["h"] > n["w"] * n["h"] and c["w"] > 4 and c["h"] > 4:
|
||||
return c
|
||||
p = c.get("p", 0)
|
||||
return None
|
||||
|
||||
|
||||
def lint(dump, min_hit=24):
|
||||
nodes = dump["nodes"]
|
||||
W, H = dump["w"], dump["h"]
|
||||
out = []
|
||||
cands = [n for n in nodes if shown(n) and n["w"] > 0 and n["h"] > 0 and (n["kind"] == "text" or interactive(n))]
|
||||
anc = {n["i"]: ancestors(nodes, n) for n in cands}
|
||||
# overlap
|
||||
for i, a in enumerate(cands):
|
||||
for b in cands[i + 1:]:
|
||||
if a.get("id") and a.get("id") == b.get("id"):
|
||||
continue # (a node that takes the clicks for its element)
|
||||
if a.get("layer") != b.get("layer") or a["i"] in anc[b["i"]] or b["i"] in anc[a["i"]]:
|
||||
continue
|
||||
ix, iy = inter(a, b)
|
||||
if ix <= 2 or iy <= 2:
|
||||
continue
|
||||
if (a["kind"] == "text") != (b["kind"] == "text"):
|
||||
t, e = (a, b) if a["kind"] == "text" else (b, a)
|
||||
if ix >= t["w"] - TOL and iy >= glyphs(t)["h"] - TOL:
|
||||
continue # (a label on its button)
|
||||
if a.get("text") and a.get("text") == b.get("text") and abs(a["x"] - b["x"]) < 3 and abs(a["y"] - b["y"]) < 3:
|
||||
continue # (a shadow or glow copy)
|
||||
out.append(issue("overlap", "warn", "%s overlaps %s by %dx%d" % (desc(a), desc(b), ix, iy), a, b))
|
||||
for n in cands:
|
||||
if n["kind"] == "text" and n.get("wrap") and n.get("bh", 0) >= 8 and n["h"] > n["bh"] + TOL:
|
||||
out.append(issue("overflow", "warn", "%s is %d px taller than its box (%d)" % (desc(n), n["h"] - n["bh"], n["bh"]), n))
|
||||
c = container(nodes, n)
|
||||
if c and n.get("clip") != "part":
|
||||
l, t = c["x"] - n["x"], c["y"] - n["y"]
|
||||
r, b = n["x"] + n["w"] - c["x"] - c["w"], n["y"] + n["h"] - c["y"] - c["h"]
|
||||
if max(l, t, r, b) > TOL:
|
||||
out.append(issue("overflow", "warn", "%s sticks out of %s (left %d, top %d, right %d, bottom %d)" % (
|
||||
desc(n), desc(c), max(l, 0), max(t, 0), max(r, 0), max(b, 0)), n, c))
|
||||
if n.get("clip") is None and (n["x"] < -TOL or n["y"] < -TOL or n["x"] + n["w"] > W + TOL or n["y"] + n["h"] > H + TOL):
|
||||
out.append(issue("off_screen", "warn", "%s is outside the %dx%d window" % (desc(n), W, H), n))
|
||||
m = min_hit if n["kind"] in MIN_HIT else min(min_hit, 18)
|
||||
if interactive(n) and (n["w"] < m or n["h"] < m):
|
||||
out.append(issue("small_hit", "warn", "%s is smaller than %d px" % (desc(n), m), n))
|
||||
if n.get("cut"):
|
||||
out.append(issue("text_cut", "info", "%s was cut to fit" % desc(n), n))
|
||||
return out
|
||||
|
||||
|
||||
def by_id(dump):
|
||||
return {n["id"]: n for n in dump["nodes"] if n.get("id") and shown(n)}
|
||||
|
||||
|
||||
def family(id_):
|
||||
return id_.split("?")[0].split("#")[0] if "?" in id_ else id_.rsplit(":", 1)[0]
|
||||
|
||||
|
||||
def contains(m, x, y):
|
||||
return m["x"] <= x <= m["x"] + m["w"] and m["y"] <= y <= m["y"] + m["h"]
|
||||
|
||||
|
||||
def diff(before, after, clicked=None, allow=()):
|
||||
"""The clicker rule across an action."""
|
||||
a, b = by_id(before), by_id(after)
|
||||
out = []
|
||||
spot = None # the clicked button's center, when it went (the next of its kind may slide in)
|
||||
if clicked and clicked in a and clicked not in b:
|
||||
n = a[clicked]
|
||||
spot = (n["x"] + n["w"] / 2, n["y"] + n["h"] / 2, family(clicked))
|
||||
for id_, n in a.items():
|
||||
m = b.get(id_)
|
||||
if not m or any(id_.startswith(p) for p in allow if id_ != clicked):
|
||||
continue
|
||||
if spot and family(id_) == spot[2] and contains(m, spot[0], spot[1]):
|
||||
continue # (took the clicked one's place)
|
||||
dx, dy = m["x"] - n["x"], m["y"] - n["y"]
|
||||
if abs(dx) > TOL or abs(dy) > TOL:
|
||||
rule, sev = ("clicked_moved", "error") if id_ == clicked else ("moved", "warn")
|
||||
out.append(issue(rule, sev, "%s moved by %+d,%+d" % (desc(n), dx, dy), n, m))
|
||||
if clicked and clicked in a and clicked not in b:
|
||||
n = a[clicked]
|
||||
cx, cy = n["x"] + n["w"] / 2, n["y"] + n["h"] / 2
|
||||
fam = family(clicked)
|
||||
hit = [m for m in b.values() if family(m["id"]) == fam and m["x"] <= cx <= m["x"] + m["w"] and m["y"] <= cy <= m["y"] + m["h"]]
|
||||
if not hit:
|
||||
under = [m for m in b.values() if m["x"] <= cx <= m["x"] + m["w"] and m["y"] <= cy <= m["y"] + m["h"]]
|
||||
out.append(issue("clicked_gone", "info", "%s went; at its spot now: %s" % (
|
||||
desc(n), ", ".join(desc(m) for m in under) or "nothing"), n, *under))
|
||||
return out
|
||||
|
||||
|
||||
def text(issues):
|
||||
return "\n".join("%-5s %-13s %s" % (i["sev"], i["rule"], i["msg"]) for i in issues) or "no issues"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dump")
|
||||
ap.add_argument("after", nargs="?")
|
||||
ap.add_argument("--clicked")
|
||||
ap.add_argument("--allow", action="append", default=[])
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--min", type=int, default=24)
|
||||
args = ap.parse_args()
|
||||
d = json.load(open(args.dump))
|
||||
issues = diff(d, json.load(open(args.after)), args.clicked, args.allow) if args.after else lint(d, args.min)
|
||||
print(json.dumps(issues, indent=1, ensure_ascii=False) if args.json else text(issues))
|
||||
sys.exit(1 if any(i["sev"] == "error" for i in issues) else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,10 +5,10 @@
|
||||
# any build (no test hook needed), so an old bundle can be compared with a new one:
|
||||
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
|
||||
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
|
||||
# API base: MM_API (default http://localhost:3000).
|
||||
# API base: MM_API (required: a test server, no default).
|
||||
set -euo pipefail
|
||||
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
|
||||
api=${MM_API:-http://localhost:3000}
|
||||
api=${MM_API:?views.sh: set MM_API to a test server (no default)}
|
||||
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
|
||||
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
|
||||
settle=4; [ "$size" != 1280x800 ] && settle=15
|
||||
|
||||
@@ -122,7 +122,7 @@ await shot("new-moon");
|
||||
// The display dialog: its fullscreen button goes through the browser's Fullscreen API.
|
||||
await cmd("navigate", `{section='moons', moon='${fx.moon}'}`);
|
||||
await idle();
|
||||
await click("display");
|
||||
await click("settings");
|
||||
await shot("display");
|
||||
await click("dialog:fullscreen");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -21,6 +21,84 @@ const SYNTH = {
|
||||
|
||||
const RATE = ZZFX.sampleRate;
|
||||
|
||||
// Client-only sounds, synthesized here (not in the web's table): the new-moon reveal's
|
||||
// build-up and its burst (screens/game/reveal.lua). Deterministic (a seeded noise).
|
||||
function noiseGen(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return (s / 4294967296) * 2 - 1;
|
||||
};
|
||||
}
|
||||
function lowpass(cut) {
|
||||
const a = 1 - Math.exp((-2 * Math.PI * cut) / RATE);
|
||||
let y = 0;
|
||||
return (x) => (y += a * (x - y));
|
||||
}
|
||||
|
||||
// rumble: a low, deep build (~1.3 s): a sub tone rising 36 -> 72 Hz with its octave and a
|
||||
// soft-clipped edge (so small speakers hear it), filtered noise like a far quake, a
|
||||
// tremolo that speeds up, all swelling to the burst at 1.1 s, then a short fade.
|
||||
function rumble() {
|
||||
const len = 1.35, peak = 1.1;
|
||||
const n = Math.floor(len * RATE);
|
||||
const out = new Float64Array(n);
|
||||
const rnd = noiseGen(7);
|
||||
const lp = lowpass(140), lp2 = lowpass(140);
|
||||
let ph = 0, trem = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / RATE;
|
||||
const k = Math.min(1, t / peak);
|
||||
const f = 36 + 36 * k * k;
|
||||
ph += (2 * Math.PI * f) / RATE;
|
||||
trem += (2 * Math.PI * (5 + 17 * k * k)) / RATE;
|
||||
const tone = Math.sin(ph) + 0.45 * k * Math.sin(2 * ph) + 0.2 * k * Math.sin(3 * ph + 0.3);
|
||||
const quake = lp2(lp(rnd())) * 9;
|
||||
let x = Math.tanh((tone * 0.8 + quake * (0.4 + 0.6 * k)) * (1.2 + 1.6 * k));
|
||||
x *= 1 - 0.35 * k * (0.5 + 0.5 * Math.sin(trem));
|
||||
const env = Math.min(1, t / 0.08) * (0.25 + 0.75 * k * k) * (t > peak ? Math.max(0, 1 - (t - peak) / (len - peak)) : 1);
|
||||
out[i] = x * env;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// reveal: the burst: a sub drop (150 -> 34 Hz) that hits hard, a noise crack opening into
|
||||
// a long low roar, and a bright chord swelling out of it (a bell-like shimmer) for the
|
||||
// "there it is" moment (~2.6 s).
|
||||
function reveal() {
|
||||
const len = 2.6;
|
||||
const n = Math.floor(len * RATE);
|
||||
const out = new Float64Array(n);
|
||||
const rnd = noiseGen(11);
|
||||
const crackLp = lowpass(2400), roarLp = lowpass(180), roarLp2 = lowpass(180);
|
||||
const chord = [130.81, 196.0, 261.63, 329.63, 392.0, 523.25]; // C major, spread
|
||||
const phs = chord.map(() => 0);
|
||||
let ph = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / RATE;
|
||||
const f = 34 + 116 * Math.exp(-t / 0.07);
|
||||
ph += (2 * Math.PI * f) / RATE;
|
||||
const sub = Math.sin(ph) * Math.exp(-t / 0.55) * Math.min(1, t / 0.003);
|
||||
const w = rnd();
|
||||
const crack = crackLp(w) * Math.exp(-t / 0.06) * 1.6;
|
||||
const roar = roarLp2(roarLp(w)) * 7 * Math.exp(-t / 0.9) * Math.min(1, t / 0.02);
|
||||
let bell = 0;
|
||||
for (let j = 0; j < chord.length; j++) {
|
||||
const fj = chord[j] * (1 + 0.002 * Math.sin(2 * Math.PI * (0.7 + j * 0.3) * t));
|
||||
phs[j] += (2 * Math.PI * fj) / RATE;
|
||||
bell += Math.sin(phs[j]) * (j < 2 ? 0.6 : 0.4);
|
||||
}
|
||||
const swell = t < 0.06 ? 0 : Math.min(1, (t - 0.06) / 0.25) * Math.exp(-(t - 0.06) / 1.1);
|
||||
const x = Math.tanh((sub * 1.3 + crack + roar) * 1.4) + bell * 0.16 * swell;
|
||||
const tail = t > len - 0.3 ? (len - t) / 0.3 : 1;
|
||||
out[i] = x * tail;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// name: [peak, render]: normalized to that peak.
|
||||
const CUSTOM = { rumble: [0.27, rumble], reveal: [0.55, reveal] }; // (RMS about -20 and -15 dB)
|
||||
|
||||
function wav(samples) {
|
||||
const n = samples.length;
|
||||
const buf = Buffer.alloc(44 + n * 2);
|
||||
@@ -44,6 +122,14 @@ function wav(samples) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
for (const [name, [peak, render]] of Object.entries(CUSTOM)) {
|
||||
const raw = render();
|
||||
const max = raw.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
const samples = Array.from(raw, (v) => (v / max) * peak);
|
||||
writeFileSync(new URL(`../assets/sfx/${name}.wav`, import.meta.url), wav(samples));
|
||||
console.log(`${name}.wav: ${samples.length} samples (${(samples.length / RATE).toFixed(2)} s), peak ${peak}`);
|
||||
}
|
||||
|
||||
for (const [name, [gain, params]] of Object.entries(SYNTH)) {
|
||||
const samples = ZZFX.buildSamples(...params).map((v) => v * gain);
|
||||
const peak = samples.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
|
||||
+18
-14
@@ -46,15 +46,25 @@ function M:init(moon, on_select, opts)
|
||||
-- The name may hold a Greek letter (sym font): drawn as a label at the name's place.
|
||||
local p = gui.get_position(name)
|
||||
gui.set_enabled(name, false)
|
||||
local _, name_w = ui.label(self.content, moon.name, "moon", p.x, -p.y)
|
||||
ui.label(self.content, moon.name, "moon", p.x, -p.y)
|
||||
-- The second line: grade and rating chips, then the status ("1 drill"); the name has
|
||||
-- the first line to itself and the ready badge the right side.
|
||||
local sp = gui.get_position(sub)
|
||||
local cx = sp.x
|
||||
if (moon.grade or "") ~= "" then
|
||||
local gb = chip.grade(self.content, moon.grade, cx, -sp.y)
|
||||
tooltip.add(gb, "Grade " .. moon.grade .. " moon")
|
||||
cx = cx + chip.GRADE_W + 6
|
||||
end
|
||||
if moon.rating then
|
||||
-- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing.
|
||||
local tier = moon.rating.tier
|
||||
local color = TIER[tier] or "line_hi"
|
||||
local box = chip.new(self.content, "★" .. moon.rating.stars, p.x + name_w + 8, -p.y, {
|
||||
local box, bw = chip.new(self.content, "★" .. moon.rating.stars, cx, -sp.y, {
|
||||
color = color, alpha = tier == "good" and 0.5 or nil, text = TIER[tier] or "muted",
|
||||
font = "sym", px = 10, h = 16, pad = 4, fill = tier == "exceptional" and 0.15 or nil,
|
||||
})
|
||||
cx = cx + bw + 6
|
||||
self.chip = { box }
|
||||
if tier == "exceptional" then
|
||||
local bs, bp = gui.get_size(box), gui.get_position(box)
|
||||
@@ -90,14 +100,17 @@ function M:init(moon, on_select, opts)
|
||||
status = moon.drills > 0 and ui.plural(moon.drills, "drill") or (dry and "Tapped out" or "Idle")
|
||||
end
|
||||
ui.style_text(sub, "small", status)
|
||||
if cx > sp.x then
|
||||
cx = cx + 2 -- (a little more room before the text)
|
||||
end
|
||||
if moon.over and not moon.finished then
|
||||
-- A well here has more drills than supply (the web's warn dot).
|
||||
local sp = gui.get_position(sub)
|
||||
local dot = ui.rect(self.content, sp.x, -sp.y - 4, 7, 7, "ready")
|
||||
local dot = ui.rect(self.content, cx, -sp.y - 4, 7, 7, "ready")
|
||||
gui.play_flipbook(dot, "sphere")
|
||||
tooltip.add(dot, "A well here has more drills than supply")
|
||||
gui.set_position(sub, vmath.vector3(sp.x + 12, sp.y, sp.z))
|
||||
cx = cx + 12
|
||||
end
|
||||
gui.set_position(sub, vmath.vector3(cx, sp.y, sp.z))
|
||||
|
||||
-- (opts.ready: the count on our clock, core/ready)
|
||||
local n_ready = opts.ready or moon.ready or 0
|
||||
@@ -145,15 +158,6 @@ function M:set_size(w)
|
||||
end
|
||||
local badge = self:get_node("badge")
|
||||
gui.set_position(badge, vmath.vector3(w - 10, -h / 2, 0))
|
||||
if self.chip then
|
||||
-- A narrow row: the rating chip goes rather than run into the ready badge.
|
||||
local box = self.chip[1]
|
||||
local right = gui.get_position(box).x + gui.get_size(box).x
|
||||
local limit = w - 16 - (gui.is_enabled(badge) and gui.get_size(badge).x or 0)
|
||||
for _, n in ipairs(self.chip) do
|
||||
gui.set_enabled(n, right <= limit)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The veil's alpha at rest (dimmed, or lifted by hover / selection).
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *db-env
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
|
||||
expose:
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: moonminers
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# Agent worktrees with their own server and database. Run from anywhere, e.g. from the
|
||||
# workspace: game/scripts/wt up <task>
|
||||
#
|
||||
# up <task> worktree ../wt-<task> on branch <task> (made, or reused), the main
|
||||
# checkout's Defold build cache and bob.jar, .env, database mm_<task>
|
||||
# (migrated), and a server (MM_DEV=1) on a free port; prints MM_API
|
||||
# down <task> stop that server and the worktree's dev game (their recorded pids only),
|
||||
# drop the database, delete the copied .env
|
||||
# rm <task> down, remove the worktree, delete the branch if it's merged
|
||||
# status worktrees with their port, pid and database
|
||||
# restart <task> rebuild and restart the server (keeps the database)
|
||||
#
|
||||
# State in <worktree>/.wt/: port, pid, db, server.log.
|
||||
set -euo pipefail
|
||||
|
||||
MAIN=$(dirname "$(git -C "$(dirname "$(realpath "$0")")" rev-parse --path-format=absolute --git-common-dir)")
|
||||
WS=$(dirname "$MAIN")
|
||||
|
||||
die() { echo "wt: $*" >&2; exit 1; }
|
||||
|
||||
task_vars() {
|
||||
TASK=${1:-}
|
||||
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
|
||||
# mm_<task> must fit Postgres's 63-byte names (longer ones are cut short silently).
|
||||
((${#TASK} <= 60)) || die "task name: at most 60 characters"
|
||||
WT=$WS/wt-$TASK
|
||||
DB=mm_${TASK//-/_}
|
||||
ST=$WT/.wt
|
||||
}
|
||||
|
||||
# psql as the admin user from the main checkout's .env (never printed).
|
||||
admin_sql() {
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$MAIN/.env"
|
||||
set +a
|
||||
PGPASSWORD=${ADMIN_DB_PASSWORD:-} psql -X -q -v ON_ERROR_STOP=1 -tA \
|
||||
-h "${DB_HOST:-localhost}" -p "${DB_PORT:-5432}" -U "${ADMIN_DB_USER:-postgres}" -d postgres -c "$1"
|
||||
)
|
||||
}
|
||||
|
||||
alive() { [[ -n ${1:-} ]] && kill -0 "$1" 2>/dev/null; }
|
||||
|
||||
port_free() { ! ss -ltnH "( sport = :$1 )" 2>/dev/null | grep -q .; }
|
||||
|
||||
pick_port() {
|
||||
local p used
|
||||
used=$(cat "$WS"/wt-*/.wt/port 2>/dev/null || true)
|
||||
for p in $(seq 3101 3199); do
|
||||
grep -qx "$p" <<<"$used" && continue
|
||||
port_free "$p" && { echo "$p"; return; }
|
||||
done
|
||||
die "no free port in 3101-3199"
|
||||
}
|
||||
|
||||
start_server() {
|
||||
local port pid
|
||||
port=$(cat "$ST/port" 2>/dev/null || true)
|
||||
if [[ -z $port ]] || ! port_free "$port"; then port=$(pick_port); fi
|
||||
echo "$port" >"$ST/port"
|
||||
(cd "$WT/server" && go build -o bin/moonminers .) || die "server build failed"
|
||||
(cd "$WT/server" && DB_DATABASE=$DB bin/moonminers migrate) >"$ST/migrate.log" 2>&1 ||
|
||||
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
|
||||
rm -f "$ST/pid"
|
||||
# Its own session (not killed with the caller's), pid written by the process itself.
|
||||
# On 127.0.0.1 only: MM_DEV=1 has no admin login.
|
||||
(cd "$WT/server" && DB_DATABASE=$DB HOST=127.0.0.1 PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
|
||||
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
|
||||
>"$ST/server.log" 2>&1 </dev/null)
|
||||
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
|
||||
pid=$(cat "$ST/pid")
|
||||
for _ in $(seq 50); do
|
||||
port_free "$port" || break
|
||||
alive "$pid" || { tail -5 "$ST/server.log"; die "server exited (log $ST/server.log)"; }
|
||||
sleep 0.2
|
||||
done
|
||||
port_free "$port" && die "server not listening on $port (log $ST/server.log)"
|
||||
echo "server pid $pid, log $ST/server.log"
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
local pid
|
||||
pid=$(cat "$ST/pid" 2>/dev/null || true)
|
||||
if alive "$pid" && grep -q moonminers "/proc/$pid/cmdline" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
for _ in $(seq 25); do alive "$pid" || break; sleep 0.2; done
|
||||
echo "stopped server $pid"
|
||||
fi
|
||||
rm -f "$ST/pid"
|
||||
}
|
||||
|
||||
up() {
|
||||
task_vars "$1"
|
||||
if [[ ! -d $WT ]]; then
|
||||
if git -C "$MAIN" show-ref -q --verify "refs/heads/$TASK"; then
|
||||
git -C "$MAIN" worktree add "$WT" "$TASK"
|
||||
else
|
||||
git -C "$MAIN" worktree add "$WT" -b "$TASK"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$ST"
|
||||
# Defold: the main checkout's build cache, so bob reuses its engine (no remote build
|
||||
# while the native extensions are unchanged), and its bob.jar.
|
||||
local c=$WT/client m=$MAIN/client d
|
||||
for d in build .internal; do
|
||||
[[ -d $c/$d || ! -d $m/$d ]] || cp -a "$m/$d" "$c/$d"
|
||||
done
|
||||
# The extender cache (.internal/cache/.buildcache) is keyed by the absolute path of each
|
||||
# cached build.zip: point the copied entries at this worktree.
|
||||
[[ ! -f $c/.internal/cache/.buildcache ]] || grep -q "^$c/" "$c/.internal/cache/.buildcache" ||
|
||||
sed -i "s|^$m/\(.*\)\$|&\n$c/\1|" "$c/.internal/cache/.buildcache"
|
||||
mkdir -p "$c/tools"
|
||||
[[ -e $c/tools/bob.jar || ! -e $m/tools/bob.jar ]] || ln -s "$m/tools/bob.jar" "$c/tools/bob.jar"
|
||||
# Web client CSS and the Preact bundle (built, not committed), for the server's pages.
|
||||
local s=$WT/server ms=$MAIN/server
|
||||
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
|
||||
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
|
||||
# The copied .env names this worktree's own database, so anything run here (migrate,
|
||||
# tests, the server) reaches mm_<task>, never the main checkout's.
|
||||
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
|
||||
if grep -q '^DB_DATABASE=' "$WT/.env"; then
|
||||
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
|
||||
else
|
||||
echo "DB_DATABASE=$DB" >>"$WT/.env"
|
||||
fi
|
||||
echo "$DB" >"$ST/db"
|
||||
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
||||
admin_sql "create database $DB"
|
||||
echo "created database $DB"
|
||||
fi
|
||||
if alive "$(cat "$ST/pid" 2>/dev/null)"; then
|
||||
echo "server already running (pid $(cat "$ST/pid"))"
|
||||
else
|
||||
start_server
|
||||
fi
|
||||
echo "worktree $WT"
|
||||
echo "MM_API=http://127.0.0.1:$(cat "$ST/port")"
|
||||
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
|
||||
}
|
||||
|
||||
down() {
|
||||
task_vars "$1"
|
||||
[[ -d $WT ]] || die "no worktree $WT"
|
||||
stop_server
|
||||
if [[ -f $WT/client/dist/dev/pid ]]; then
|
||||
(cd "$WT/client" && scripts/dev.py stop) || true
|
||||
fi
|
||||
if [[ -n $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
|
||||
admin_sql "drop database $DB with (force)"
|
||||
echo "dropped database $DB"
|
||||
fi
|
||||
rm -f "$WT/.env" "$ST/db"
|
||||
}
|
||||
|
||||
remove() {
|
||||
task_vars "$1"
|
||||
down "$1"
|
||||
rm -rf "$ST"
|
||||
git -C "$MAIN" worktree remove "$WT" || die "worktree not removed (uncommitted changes?)"
|
||||
if git -C "$MAIN" branch -d "$TASK" 2>/dev/null; then
|
||||
echo "deleted branch $TASK"
|
||||
else
|
||||
echo "kept branch $TASK (not merged)"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
local path branch st pid run
|
||||
printf '%-28s %-16s %-6s %-14s %s\n' WORKTREE BRANCH PORT PID DB
|
||||
git -C "$MAIN" worktree list --porcelain | while read -r k v; do
|
||||
case $k in
|
||||
worktree) path=$v; branch=- ;;
|
||||
branch) branch=${v#refs/heads/} ;;
|
||||
"")
|
||||
st=$path/.wt
|
||||
pid=$(cat "$st/pid" 2>/dev/null || true)
|
||||
run=-
|
||||
[[ -n $pid ]] && { alive "$pid" && run=$pid || run="$pid(dead)"; }
|
||||
printf '%-28s %-16s %-6s %-14s %s\n' "${path#"$WS"/}" "$branch" \
|
||||
"$(cat "$st/port" 2>/dev/null || echo -)" "$run" "$(cat "$st/db" 2>/dev/null || echo -)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
case ${1:-} in
|
||||
up) up "${2:-}" ;;
|
||||
down) down "${2:-}" ;;
|
||||
rm) remove "${2:-}" ;;
|
||||
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://127.0.0.1:$(cat "$ST/port")" ;;
|
||||
status) status ;;
|
||||
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
esac
|
||||
@@ -5,6 +5,4 @@ node_modules
|
||||
.env*
|
||||
.vscode
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -1,8 +1,6 @@
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
@@ -16,4 +14,3 @@ sql/queries.sql
|
||||
# Generated by the art scripts
|
||||
/art/equipment/build/
|
||||
/art/advisors/build/
|
||||
/art/attachments/build/
|
||||
+124
-137
@@ -1,67 +1,74 @@
|
||||
# AGENTS.md — Moon Miners server
|
||||
|
||||
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
|
||||
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
|
||||
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
|
||||
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
|
||||
every command below from this directory (`server/`).
|
||||
|
||||
## Stack
|
||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||
package in `server/`; 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.
|
||||
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
|
||||
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
|
||||
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
|
||||
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
|
||||
light themes). No bundler, no TypeScript.
|
||||
- Postgres 16, stock image. No functions or extensions in the database.
|
||||
|
||||
## Layout
|
||||
- This is `server/` in the repo (`../AGENTS.md`); the Defold client is `../client` (its own
|
||||
AGENTS.md), the compose files are at the repo root.
|
||||
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
|
||||
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
|
||||
loading. `balance.go`: the `sim` command (`gradesim.go`: grade table and the two clicker
|
||||
players (copies, boosts; 1 click/s), then the `sim.go` analysis). Results: `BALANCE-NOTES.md`.
|
||||
- `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
|
||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they 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.
|
||||
`loadView` (state, the past page, the resolved view). `loadState` leaves moon
|
||||
descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
|
||||
`loadConfig` (resources, store items) is cached per config snapshot.
|
||||
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
|
||||
the `Page` view model, formatting helpers, the admin templates' functions.
|
||||
- `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).
|
||||
limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
|
||||
action).
|
||||
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
|
||||
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
|
||||
`admin_test.go`: the gate.
|
||||
- `api.go`, `api.md`: the JSON API: `GET /api/state` (the view models mapped to JSON DTOs;
|
||||
times in unix ms) and `POST /api/act/<action>` (`runAction`, 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:
|
||||
`include=`; reasons are codes with the player-facing 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
|
||||
for every test view (the test states are in `templates_test.go`).
|
||||
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
|
||||
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
|
||||
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
|
||||
cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
|
||||
affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
|
||||
older moons are C); `buy-moon` takes `grade=`, else the newest moon's grade
|
||||
(`State.MoonGrade`), else the lowest. `rollWells` (game.go) rolls a new moon's wells: the
|
||||
count by its grade's weights, resources by the affinities, each well's supply in the
|
||||
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
|
||||
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
|
||||
Track levels set a tool's yield and cycle (read from cfg(), not stored; `StoreItem.ToolYield`, `ToolSecs`). 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
|
||||
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). 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
|
||||
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim; `progression.go`: the Analysis tab's charts, saved config against the draft: payback per purchase, the clicker sim timeline (on demand, `POST /utils/economy/sim`), curves; drawn as SVG by `chart.go`). Read config values through
|
||||
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
||||
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
|
||||
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
|
||||
`save_code`, only a hash; restore tries limited per IP; the once-only nudge after the
|
||||
first sale, onboarding flag `nudge:save-code`, as `notice` in the API response).
|
||||
- `db.go`: connections, `inTx`, `GameError`.
|
||||
- `market.go`: market prices. The quote (`marketQuote`) is shared, the same for every
|
||||
player: config price × a deterministic drift (name + 5-minute tick, within
|
||||
@@ -70,119 +77,100 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
|
||||
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). Personal modifiers never change the quote, cards,
|
||||
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
|
||||
ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
|
||||
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
|
||||
are applied on top per request); the sell ticket shows them as lines ("+8%
|
||||
for you", "−4% from your recent selling"). 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.
|
||||
watchlist, chart, sell ticket; `pick=` selects a quote).
|
||||
- `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
|
||||
drill on it is running; finished drills are claimed in the sale.
|
||||
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
|
||||
its track levels; refined parts not refunded): only idle tools with nothing to claim,
|
||||
never the last tool.
|
||||
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on track levels), rules
|
||||
(start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
|
||||
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
|
||||
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
|
||||
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
|
||||
`refined_account`, `refinery_job`, `users.refinery_slots`.
|
||||
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (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 (cycles to a tenth of a second, at least 1 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`.
|
||||
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
|
||||
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
|
||||
flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
|
||||
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
|
||||
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
|
||||
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
|
||||
ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
|
||||
section, in the aside; the toast text).
|
||||
- `advisors.go`: 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 view, 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.
|
||||
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
|
||||
tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
|
||||
view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
|
||||
opened (`markSeen` in `handleAPIState`). 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`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
|
||||
dialog (header Grant button or G; action `admin-grant`, 403 for others). `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).
|
||||
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
|
||||
as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
|
||||
`ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
|
||||
Banned players (`users.banned_at`) get a 403 from the API.
|
||||
`web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
|
||||
(`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
|
||||
the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
|
||||
- `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
|
||||
`enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
|
||||
toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
|
||||
- `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
|
||||
- `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
|
||||
`ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
|
||||
generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
|
||||
`go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
|
||||
any more) and the wells.
|
||||
- `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
|
||||
in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
|
||||
`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):
|
||||
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
|
||||
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
|
||||
use; the tools show their original images.
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
|
||||
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.
|
||||
- `art/`: sources of the store, attachment and advisor images (Blender scripts, prompts);
|
||||
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
|
||||
they write into `web/static/assets`.
|
||||
|
||||
## 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.
|
||||
- `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
|
||||
from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
|
||||
long, to try the client on a slow connection.
|
||||
- `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
|
||||
every `web/static/js/*.js`), `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.
|
||||
admin templates like the server and renders every page against in-memory data; when you
|
||||
add a page or a branch, make sure its data reaches it.
|
||||
- Local DB: `podman-compose up -d db` (from the repo root) 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).
|
||||
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
|
||||
Other tests: `go test ./...` (game rules on in-memory states, no database);
|
||||
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
|
||||
resale odds, sim seeds). `claim_db_test.go` needs a database of its own
|
||||
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
|
||||
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
|
||||
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
|
||||
|
||||
## Deployment
|
||||
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
|
||||
@@ -198,23 +186,22 @@ distribution, value by well count, resale odds, sim seeds).
|
||||
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
|
||||
the first one found is read): `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.
|
||||
commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
|
||||
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
|
||||
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
|
||||
player is an admin.
|
||||
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
|
||||
|
||||
## 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.
|
||||
- New section: add it to `sections` and `State.page` (views.go) and map it in the API
|
||||
(`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
|
||||
`api.md`. The client sends the current view (s, moon, well, drill) with every action, so
|
||||
action parameters must use other names.
|
||||
- The server is the authority; clients predict only what `api.md` "Prediction" allows.
|
||||
- New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
|
||||
that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
|
||||
- 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.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Wider steps (2026-10-22)
|
||||
|
||||
Playing the progression pass, the author had a LunarLite L100 (and money for more) about
|
||||
33 minutes in, first C moon at 19 minutes: too fast. Their run's money: mostly raw sales
|
||||
from five C moons, plus one Plasma Weave sale (₥32k, of which the refining margin was only
|
||||
about ₥9k). The fix is a steeper grade ladder: each grade's price and wells, and its
|
||||
drill's yield, price and upgrade prices, scaled by the same factor (C ×2, B ×3, A ×5,
|
||||
S ×8), which keeps a moon's value (1.3–1.8× its price) and a drill's 4–5 claims per well on
|
||||
its grade, while the jump to the next grade grows faster than income. D and the PD-1 are
|
||||
unchanged. Refined items now gain about 20% over their inputs (was 40–100%).
|
||||
|
||||
Grades: D 300, C 12k, B 90k, A 1.5M, S 24M. Drills: PD-1 1,200 (8), PX-4 8,000 (120), L100
|
||||
40,200 (900), L300 645k (20,000), LRE 9.44M (384k), EDE 17M (880k), LCE 24.8M (1.6M).
|
||||
Sim, first moon of each grade: copies C 12m, B 30m, A 58m, S 2h33; boosts C 10m, B 21m,
|
||||
A 40m, S 1h49 (was C 7m, B 15m, A 32m, S 1h04). A real player ran about 2.5× slower than
|
||||
the sim's (C at 19m against the sim's 7m), so expect roughly C 30m, B 1h15 in play.
|
||||
|
||||
# Progression: each new drill rips through the current grade (2026-10-22)
|
||||
|
||||
The loop: on a grade, its drill needs about 3–6 claims per well; the next drill (bought
|
||||
before the next grade, cheaper than that grade's moons) empties the current grade's wells
|
||||
in about one claim; on the next grade it is back to 3–6. Each drill's base yield is about
|
||||
its grade's average well ÷ 4.5 (multi-well drills × their reach): PD-1 8 (D), PX-4 60 (C),
|
||||
L100 300 (B), L300 4,000 (A), LRE 48,000 (S); EDE 110,000 and LCE 200,000 rip S further
|
||||
(more drills than grades until planets and stars). Sim, claims per average well on its own
|
||||
grade: PD-1 4.3, PX-4 5.4, L100 4.5, L300 4.5, LRE 4.6, EDE 3.3, LCE 1.8.
|
||||
|
||||
Prices: each drill earns a bit more per credit than the one before on its grade (sim "a
|
||||
drill alone on its grade", ₥/min per 1k₥: 981, 1.6k, 2k, 2.4k, 2.6k, 2.9k, 3.1k), else the
|
||||
sim (and a player) just buys more of the cheapest drill: PD-1 1,200, PX-4 4,000, L100
|
||||
13,400, L300 129,000, LRE 1.18M, EDE 2.12M, LCE 3.1M. Each upgrade level costs 0.95 × what
|
||||
a copy would cost for the same added output (so upgrades are slightly better per credit).
|
||||
|
||||
Sim, first moon of each grade: copies C 7m, B 15m, A 32m, S 1h04; boosts C 6m, B 14m,
|
||||
A 31m, S 49m (was about C 13m, B 22m, A 1h10, S 3h). Faster overall; the lever for a longer
|
||||
game is moon and drill prices with wells scaled alike.
|
||||
|
||||
# Drill upgrade tracks (2026-10-22)
|
||||
|
||||
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
|
||||
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
|
||||
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
|
||||
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
|
||||
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
|
||||
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
|
||||
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
|
||||
the level before. So with both tracks moving together a level adds output for about a
|
||||
copy's price.
|
||||
|
||||
| drill | yield track | cycle track |
|
||||
|---|---|---|
|
||||
| PD-1 | Twin rotors 4 → 5, 6, 7, 8 | Flight computer 3 → 2.6, 2.2, 1.9, 1.7, 1.5 s |
|
||||
| PX-4 | Wider chisel 20 → 24 … 40 | Pneumatic hammer 6 → 5.2, 4.5, 3.9, 3.4, 3 s |
|
||||
| L100 | Wide-bore bit 110 → 130 … 220 | Spindle motor 8 → 7, 6, 5.2, 4.6, 4 s |
|
||||
| L300 | Wider beam 1350 → 1600 … 2700 | Laser capacitors 10 → 8.7 … 5 s |
|
||||
| LRE | Fine sieves 17k → 20k … 34k | Auger gearbox 12 → 10.4 … 6 s |
|
||||
| EDE | Larger plates 36k → 43k … 72k | Charge coils 14 → 12.2 … 7 s |
|
||||
| LCE | Core barrel 47k → 56k … 94k | Drill string 16 → 14 … 8 s |
|
||||
|
||||
Per ₥ against a copy (a drill alone on its grade): the first yield level ×1.02–1.05 (₥/min),
|
||||
×1.04–1.09 (₥/click); whole yield track ×0.79–0.88; first cycle level ×0.94–1.01 (₥/click
|
||||
×0.28–0.67: shorter cycles cost claims); both tracks to the top ×1.12–1.19. PD-1's cycle
|
||||
levels add nothing in the sim: its player claims at most every 3 s (`gsClaimGap`).
|
||||
|
||||
| first moon of each grade | before (tiers, attachments) | after (tracks) |
|
||||
|---|---|---|
|
||||
| copies | C 13m15, B 22m26, A 1h10m37, S 2h59m52 | the same |
|
||||
| boosts | C 13m15, B 22m26, A 1h08m39, S 3h00m21 (bought only copies and 2 advisors) | C 13m18, B 23m39, A 1h05m50, S 3h01m59 (233 copies, 234 levels, 2 advisors) |
|
||||
|
||||
At 0.85 (levels ~15% better than a copy) the boosts player reached A at 54 m and S at
|
||||
2h26; at 1.0 and above it bought almost no levels.
|
||||
|
||||
# Balance notes: moon grades, clicker pacing (2026-10-21)
|
||||
|
||||
Moon Miners is a clicker: the player is always clicking. `go run . sim` prints the grade
|
||||
table, a table of each drill against its boosts, and two active sim players (gradesim.go).
|
||||
Both claim (claim all, with redeploy) as soon as a drill is ready (at most every 3 s), stake
|
||||
drills whose well ran dry, sell every 30 s (a click per resource) and buy, within a budget of
|
||||
1 click/s. "copies" buys only drill copies; "boosts" buys whatever adds the most ₥/minute
|
||||
per ₥. Both save up to 10 min of income for a purchase and buy a moon when their drills are
|
||||
about to run dry (the dearest grade costing at most half an hour of income).
|
||||
|
||||
Clicks: one claim per distinct drill cycle (copies of one drill finish together), a stake
|
||||
when a drill's well runs dry (a multi-well drill: its moon), sells, purchases (attachments
|
||||
and seats 2). So a copy adds ₥ to every claim; a speed boost adds claims.
|
||||
|
||||
## Grades (5000 rolls each, base prices)
|
||||
|
||||
Prices ×10 per grade (were ×4), wells scaled with the price, so the value multiples stay.
|
||||
|
||||
| grade | price | wells | units | ₥/unit | value × price (mean, p10–p90) | drill meant for it |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D | 150 | 1.6 | 38 | 6.5 | 1.62 (0.64–2.84) | PD-1, 6.0 claims per well, 12 s |
|
||||
| C | 1.5k | 2.5 | 280 | 9.3 | 1.73 (0.79–2.79) | PX-4, 5.6, 12 s |
|
||||
| B | 15k | 3.0 | 1988 | 12.7 | 1.68 (0.82–2.59) | L100, 6.0, 13 s |
|
||||
| A | 150k | 4.0 | 17.3k | 13.4 | 1.54 (1.01–2.01) | L300, 6.4, 14 s |
|
||||
| S | 1.5M | 4.5 | 161k | 13.2 | 1.42 (1.13–1.69) | LRE 6.3 (15 s), EDE 4.9 (13 s), LCE 3.8 (11 s) |
|
||||
|
||||
Drills: cycles 11–15 s (were 10–90 s); claim amounts scaled with their grade's wells; each
|
||||
tier ~6% more units/s per ₥ than the one below (PD-1 1.2k … LCE 11M). Upgrades cost
|
||||
(speed gained × drill price) / 1.31 minus the parts.
|
||||
|
||||
## Boosts against copies (a drill alone on its grade, per ₥ spent)
|
||||
|
||||
| drill | upgrades t1–5: ₥/min, ₥/click vs a copy | Turbo Core | Diamond Bit | Cryo Cooler | Seam Scanner |
|
||||
|---|---|---|---|---|---|
|
||||
| PD-1 (D) | ×1.23, ×0.38 | ×0.25, ×0.10 | ×0.19, ×0.20 | ~0 | ~0 |
|
||||
| PX-4 (C) | ×1.22, ×0.38 | **×1.21, ×0.49** | **×0.90, ×0.97** | ~0 | ~0 |
|
||||
| L100 (B) | ×1.22, ×0.50 | ×5.2, ×2.8 | ×4.3, ×4.5 | ×0.09 | ×0.09 |
|
||||
| L300 (A) | ×1.24, ×0.63 | ×52 | ×46 | **×1.30, ×0.74** | **×0.97, ×0.98** |
|
||||
| LRE–LCE (S) | ×1.22–1.23, ×0.53–0.70 | ×500+ | ×500+ | ×13–46 | ×11–37 |
|
||||
|
||||
Attachments have one price for every drill: Turbo Core 900 / Diamond Bit 950 are set for
|
||||
PX-4, Cryo Cooler 85k / Seam Scanner 90k for L300; on bigger drills they're nearly free.
|
||||
|
||||
## Runs (final config)
|
||||
|
||||
| | copies | boosts |
|
||||
|---|---|---|
|
||||
| first B / A / S moon | 1h08 / 1h50 / 3h18 | 1h03 / 1h53 / 2h26 |
|
||||
| clicks by the first S moon | 6.4k | 5.4k |
|
||||
| income at 30 min / 1h / 2h / 3h | 37k / 119k / 1.5M / 15M ₥/h | 47k / 100k / 3.5M / 47M ₥/h |
|
||||
| income at 6h | 108M ₥/h | 146M ₥/h (click budget full) |
|
||||
| ₥ per click at 1h / 3h | 72 / 5.2k | 61 / 15.8k |
|
||||
| purchases, ₥/min added per 1k₥ | 24 copies, 292 | 21 copies 370, 90 upgrades 842, 37 attachments 13k, 3 advisors 932 |
|
||||
| longest stretch with nothing to click | 12 s | 12 s |
|
||||
| longest stretch with nothing affordable to buy | 9.5 min (at 7 min: the 3rd PD-1) | 4 min |
|
||||
| claims to drain a well (first 20 per grade) | D 6.2, C 27, B 40, A 82, S 77 | D 6.3, C 21, B 38, A 41, S 83 |
|
||||
|
||||
- Upgrades are ×1.22–1.24 a copy's ₥/min per ₥ and ×0.38–0.70 its ₥/click, on every drill.
|
||||
- The two paths reach B and A within ~5 min of each other; S is 52 min apart.
|
||||
- Selling pressure `scale` 200k → 5M (incomes are ~10× bigger); advisor seats 4k/15k/50k →
|
||||
20k/400k/8M; starting credits 700 → 1.5k (the second PD-1 at ~5 min).
|
||||
|
||||
## Iterations
|
||||
|
||||
1. Cycles 10–15 s, grades ×10, drills on the 6% ladder, upgrades for ×1.22: S at 3h15/4h05,
|
||||
but one PD-1 for the first 20 min (~30 ₥/min net).
|
||||
2. Starting credits 1.5k, seats dearer: early game moves; upgrades measured ×1.07 (the
|
||||
half-second wait for a claim and whole-second cycles), so
|
||||
3. upgrades ~12% cheaper: ×1.22.
|
||||
|
||||
## Doubts
|
||||
|
||||
- Fast early and a few hours to S pull against each other: a drill's payback is the same at
|
||||
every tier (or faster, to keep drones from winning), so only the grade price ladder (now ×10)
|
||||
stretches the game. Early copies still take ~10 min each.
|
||||
- Yield boosts (Diamond Bit, Seam Scanner, Rook) add ₥/click as much as ₥/min, so they can't
|
||||
beat copies on one and lose on the other; they sit at about a copy's worth.
|
||||
- Attachments and advisors have one price for all drills; past their target drill they're
|
||||
nearly free (attachments ×50–2500 a copy on S drills).
|
||||
- Stakes: a one-well drill needs a stake click every ~6 claims on its own grade, so many
|
||||
copies on small wells cost clicks too.
|
||||
- After S the fleet empties an S moon every few seconds; moons and stakes fill the 1 click/s
|
||||
budget and purchases stop (income flat from ~5h at ~150M ₥/h).
|
||||
- Short cycles in whole seconds: at 6–8 s a +10% speed step can round to nothing (e.g. L100
|
||||
t3 with two speed attachments).
|
||||
- The sim's first moon is a C (1.5k credits, cheap next to income); the economy page's sim
|
||||
(sim.go) is the old one.
|
||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user