Compare commits

...
185 Commits
Author SHA1 Message Date
JosephFerano 64e1d8c0f4 Fix endpoint issue 2026-10-27 17:57:58 +07:00
JosephFeranoandClaude Opus 5.5 de05c7215f Merge opt-rebuild: cheap signatures, Moons panel in kept parts, fewer rebuilds on the server's answer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:28:03 +07:00
JosephFeranoandClaude Opus 5.5 864f357fd2 Aside facts are their own block on Moons, the moon list's sig is what its rows show, drills ending on our clock don't rebuild banner, header or moon list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 986012a80d Moons: heading, well cards, drill and deploy slots, the multi-well card and tray tiles are kept parts; a deploy rebuilds only what it changed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 4f0f2f13e3 blocks.sig writes a frozen state's parts as content tokens, once per state; kept blocks renew their Druid uids so a kept button keeps input priority
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 1c5d1f372a Merge opt-market: market series cached per tick, config cached, moon descriptions only where sent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:22:51 +07:00
JosephFeranoandClaude Opus 5.5 9ad12796e1 Merge opt-state: indexes (migration 022), index-friendly state queries, grouped stakes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:58 +07:00
JosephFeranoandClaude Opus 5.5 7980b7bc23 State loads: indexes for the per-player reads (migration 022), uuid[] past-moon queries that use them, last well per tool, wells and stakes grouped once per request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:12 +07:00
JosephFeranoandClaude Opus 5.5 95d3f6e2ce Merge opt-claim: claim-all in a fixed number of statements, proven equal by a golden test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:51 +07:00
JosephFeranoandClaude Opus 5.5 bfcf2a8d9a Claim-all timing test: a crowded case, 900 drills on 25 moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:20 +07:00
JosephFeranoandClaude Opus 5.5 b5373c8c62 Merge opt-equip: Equipment as a long list of kept rows; ladders sent once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:10 +07:00
JosephFeranoandClaude Opus 5.5 3f1e706741 Test the market series cache against computed rates and a config change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:55:16 +07:00
JosephFeranoandClaude Opus 5.5 20278f588f wt: a worktree's .env names its own database (mm_<task>); AGENTS.md: never git stash in a worktree (the stash list is shared)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:54:48 +07:00
JosephFeranoandClaude Opus 5.5 38a7140f26 AGENTS.md: the claim-all database test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:07 +07:00
JosephFeranoandClaude Opus 5.5 445436f943 Equipment rows are a long list (only the rows in view exist) and each row is kept while what it shows stays the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:00 +07:00
JosephFeranoandClaude Opus 5.5 e140bc30fc Claim-all in a few statements: lock and read the moons once, play the claims and redeploys through in memory, write each table with one statement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:49:19 +07:00
JosephFeranoandClaude Opus 5.5 4446cad274 Claim-all database test: golden scenarios of the current claim rules and a timing test (MM_TEST_DATABASE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:45 +07:00
JosephFeranoandClaude Opus 5.5 681927fc23 Equipment sends each upgrade ladder once per store item and levels (equipment.ladders, rows name theirs by ladderKey)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:39:49 +07:00
JosephFeranoandClaude Opus 5.5 ff656930c8 Moon descriptions: loadState leaves them out; the moon view loads the selected moon's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:41 +07:00
JosephFeranoandClaude Opus 5.5 78f611fd8c loadConfig: cache resources and store items per config snapshot (re-read every 30 s for other processes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:33 +07:00
JosephFeranoandClaude Opus 5.5 0dbe349da5 Market: cache each item's 24h series per tick and config snapshot, shared by all requests; drop the unsent SVG chart and sparkline paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:19 +07:00
JosephFeranoandClaude Opus 5.5 429199f462 Client builds run bob.jar with the Defold editor's own Java (found on Linux, macOS and Windows; DEFOLD, JAVA override), else java from PATH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:51:52 +07:00
JosephFeranoandClaude Opus 5.5 b8f877fb45 Admin: plain look, no motion: no entrance or tab-slide scripts, no dialog animations, no glows, gradients, blur or corner brackets, system fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:07:43 +07:00
JosephFeranoandClaude Opus 5.5 ec6edefd24 READMEs: the browser game at :3000 (served by the server), admin at /utils, MM_WEB_DIR, migrate after config edits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:05:17 +07:00
JosephFeranoandClaude Opus 5.5 dac62506b6 The game server serves the web build at / (client/dist/web/MoonMiners, MM_WEB_DIR); the admin stays at /utils
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:04:44 +07:00
JosephFeranoandClaude Opus 5.5 4bb2188564 Moons: the greyed deploy slot's line is the plain divider color (its own faint alpha)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:58:01 +07:00
JosephFeranoandClaude Opus 5.5 de0977f7a4 Moons: a well card's deploy slot that can't deploy (no idle drills, no room) is drawn greyed: a plain line, no fill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:57:51 +07:00
JosephFeranoandClaude Opus 5.5 8a90adb515 Dusk Alloy sells for 28 (was 24): its refining earns per second about what Cryo Cell's does; Void Core 460 (was 450), keeping its gain over its inputs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:50:02 +07:00
JosephFeranoandClaude Opus 5.5 cad48d8856 Refinery collect: motes land on the owned number's text (its pivot and text width), not right of and under it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:43:11 +07:00
JosephFeranoandClaude Opus 5.5 7da4ab8103 Client: predict deploys and claims of multi-well drills too (every drill is one now), so deploys and claims show at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:33:38 +07:00
JosephFeranoandClaude Opus 5.5 80cb1ffb07 Moon packs: 5, 10 or 20 moons of a grade at once (5, 10, 15% off; each moon worth at least 1.4, 1.5, 1.6x its price), once the store is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:32:48 +07:00
JosephFeranoandClaude Opus 5.5 9b61acd61a Every drill reaches the whole moon: a claim takes what its well lacks from the moon's next wells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:23:25 +07:00
JosephFeranoandClaude Opus 5.5 b0bc8135bb D moons: wells hold 28–56 units (was 23–46), about a fifth more
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:32 +07:00
JosephFeranoandClaude Opus 5.5 1510bf10a3 Drill upgrades cost credits only: no refined parts on any track level
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:10:09 +07:00
JosephFeranoandClaude Opus 5.5 75f1820e83 Equipment: a selected drill's panel takes the whole side pane (no moon over it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:08:25 +07:00
JosephFeranoandClaude Opus 5.5 8f57dedfe6 Equipment rows: the drill picture, square, takes the row's whole height; Status a little narrower so names fit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:04:32 +07:00
JosephFeranoandClaude Opus 5.5 afb4dc4cca Settings menu rows take real clicks again: the moon viewport ignores the mouse while a dialog is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:03:00 +07:00
JosephFeranoandClaude Opus 5.5 956d6c506e shot.sh keeps the game's saved files beside its screenshots; make play needs MM_API and passes it
make play ignored MM_API and ran with mm.new_player=1 against localhost:3000 and the
player's own save folder, clearing their saved player id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:51:48 +07:00
JosephFeranoandClaude Opus 5.5 329b954e31 Merge fix-native-build: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 b837a90f49 Merge fix-cl-ui: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 d4cf789906 Merge fix-cl-screens: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 4a8f6a5013 Merge fix-cl-moons: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 f5c796631f Merge fix-cl-core: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 623766b535 Merge fix-srv-sim: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 69ac96522c Merge fix-srv-admin: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 8ba5c6c9e2 Merge fix-srv-http: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 75faa787fd Merge fix-srv-econ: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 1e98c01cf3 Autoplay: press the reveal's Buy another (reveal-buy) instead of the hidden + New moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 0c4c955ddf Autoplay: confirm buttons register their action and their confirm, so the refinery script reaches new-game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 ebe72d9ee5 Moon lab: a save that finishes after leaving the lab still updates the game's moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 e31a597f82 Market chart: measure the crosshair's hit box on every move, so scrolling doesn't leave it stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 330da8bb17 Config: refuse resource prices for resources the database doesn't have; fail a moon buy whose well isn't stored
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:58:55 +07:00
JosephFeranoandClaude Opus 5.5 42e11b1d7e Clicker sim: reserve the dearest moon grade bought, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 4c2426370d Clicker sim: value purchases on the dearest moon grade bought, not the last
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 3595ff48e9 Economy sim: keep the dearest moon grade bought as the reserve, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:56:49 +07:00
JosephFeranoandClaude Opus 5.5 fc2eea4681 fit_text: cut whole UTF-8 characters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 caa632fc18 Shake: a new shake on a shaking node keeps its resting place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 e48b3fb512 Flex labels: copy the caller's opts so a shared table cuts every label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 0135e66636 Focus: a remade list row drops its dead entry and keeps the plain key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 05579a8fef Client: the redrill prediction picks drills by their amount with the yield event
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 8ea2989441 Client: the redeploy prediction finds the drill's own group (track levels too)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 803788cb72 Client: the well panel shows a multi-well drill's take from this well as here
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 1b6410ebfd Client: compact picks its unit after rounding (1M, not 1000k)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 22b592a533 Announcements: count the 200-character limit in characters, as the form does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 d3ff69a5eb Live ops dashboard: leave players whose return day is still running out of D1 and D7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 cbce3fab46 Live ops dashboard: count batch upgrades as spent and moon and tool sales as earned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 e3af1799bf Economy analysis: name saved tier and grade points from the saved config, no panic with no drills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 cfa617ed93 Players page: send the whole page on an htmx history restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 dfec5ec57a Economy editor: show only the newest analysis when replies come back out of order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 6d8f55c21a Economy editor: refuse a save from a page loaded before a newer save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 e34f06e1bc Config: refuse a game config with no store items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:21 +07:00
JosephFeranoandClaude Opus 5.5 3aa4017525 Economy sim: buy what the player can pay now even with no income
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:09 +07:00
JosephFeranoandClaude Opus 5.5 549de045b0 Server: HOST picks the listen address; wt servers (MM_DEV=1, no admin login) listen on 127.0.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 f3be4066e8 wt: refuse task names over 60 characters (mm_<task> must fit Postgres's 63-byte names)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 a66e32acef dev.py: a pid counts as the dev game or its Xvfb only if its command line says so
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:27 +07:00
JosephFeranoandClaude Opus 5.5 bd36b8fc4f Buy modal: suggest only wells with something left after current drills, and label the highest value among those shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 228e7af4c9 API: answer 500 when the player or ban lookup fails, instead of making a new player or skipping the ban
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 8263491301 New-player limit: count an IPv6 client by its /64
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 c7ff9b27c7 Server: wait for running requests to finish before closing the database on shutdown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 1bc3677f50 drive.py --keep: leave the Xvfb display the kept game draws on running
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:08 +07:00
JosephFeranoandClaude Opus 5.5 2a0eb04b12 buy-moon checks the new-moon gate again under the player's row lock, so two buys at once can't both pass it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:03 +07:00
JosephFeranoandClaude Opus 5.5 9ec5436fb8 drive.py and views.sh: no default server, MM_API or --api is required
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:00 +07:00
JosephFeranoandClaude Opus 5.5 61b696b08a Moon viewport: round its size once so it doesn't flip by a pixel while it moves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 7556795a65 Local Postgres: publish its port on 127.0.0.1 only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 9eb20f9e15 Client test hook: a failing multi-frame step replies with its error instead of failing every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:05 +07:00
JosephFeranoandClaude Opus 5.5 1258b774c3 Client: each recipe's Refine and the market's Sell get their own busy key and test id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:51 +07:00
JosephFeranoandClaude Opus 5.5 430b560347 Redrill on a full well tries every idle drill, not only those before the first that can't go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:48 +07:00
JosephFeranoandClaude Opus 5.5 738cb32eb1 Stake and redrill lock the moon's wells before the tools, as claims do, so they can't deadlock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:38 +07:00
JosephFeranoandClaude Opus 5.5 74ce60805a Reveal card: a well bigger than every sampled one reads 'under 1 in N moons', not '1 in 0'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:28 +07:00
JosephFeranoandClaude Opus 5.5 bc95fce37a Client: view history moves only when the back/forward answer comes, and not on a failed one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:26 +07:00
JosephFeranoandClaude Opus 5.5 bd363b3bde Count a live event on all resources once in the cycle and refinery speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:46:06 +07:00
JosephFeranoandClaude Opus 5.5 fac922ddd0 Count a speed event once in PriceBoosts.For("all")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:45:09 +07:00
JosephFerano 4c04651c52 Progression: advisor payback counts a full moon of the newest drill (one per well) 2026-10-22 22:30:52 +07:00
JosephFerano f136a2a3c4 Economy curves: legend; cache reach caps; note that bands follow the draft 2026-10-22 22:23:05 +07:00
JosephFerano 58be6c2387 server/AGENTS.md: progression charts 2026-10-22 22:21:21 +07:00
JosephFerano 1aab2394ab Economy: curves per drill tier, moon grade and upgrade level (small multiples) 2026-10-22 22:21:13 +07:00
JosephFerano be1aab1cf8 Economy: clicker sim timeline (credits, income, purchases, grades, idle stretches)
Runs on demand from the Analysis tab (POST /utils/economy/sim) for the saved
config and the draft; the sim records samples, purchases and idle stretches.
2026-10-22 22:19:32 +07:00
JosephFerano 64fe6ba6c5 Economy: payback-per-purchase chart in the Analysis tab (saved vs draft)
Server-drawn SVG charts (chart.go). payLadder lists every purchase in buying
order by moon grade, with minutes to earn it back and jumps over 2x the step
before. Dev-only ?draft= (merge patch) and ?theme=light for screenshots.
2026-10-22 22:16:58 +07:00
JosephFerano 36a10a1a67 Client checks: drop moon-check (terrain.js is gone) 2026-10-22 22:07:21 +07:00
JosephFerano a938cba25e Merge branch 'admin' 2026-10-22 22:06:33 +07:00
JosephFerano 4f1671cc48 Docs: the server is the API and the admin; Defold is the only client 2026-10-22 22:04:25 +07:00
JosephFerano e5328d4178 Moon lab: no ± in the variation note (not in the fonts) 2026-10-22 22:02:31 +07:00
JosephFerano 50231812c8 Admin login: ADMIN_PASSWORD guards /utils
A login form at /utils/login sets a random session token (mm_admin cookie,
HttpOnly, SameSite Lax, kept in memory); /utils pages redirect to it without a
session and actions get a 401. No login with MM_DEV=1; no password means the
admin is locked. Passwords are compared in constant time, a wrong one waits a
second, tries are limited per IP. POST /api/moon-settings takes the admin
cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV.

Admin actions log "admin" instead of the admin's player id. Dropped
POST /utils/clear-data (reset the admin's own player) and /utils/load-config
(the load-config command does it), and the "You" marker and self-ban check
on the player page.
2026-10-22 22:02:09 +07:00
JosephFerano 11081486dc Client: admin area with a moon lab (live moon settings editor, Save to /api/moon-settings) 2026-10-22 22:00:01 +07:00
JosephFerano 8d0579f2c3 Delete the htmx game client; keep the admin pages
The game is the Defold client on the JSON API now. GET / redirects to /utils.
Removed the game page, POST /session and /act/, the game templates and JS,
three.js, idiomorph, ZzFX, the sounds, the web moon lab (/utils/moons) and
Tailwind. The admin pages get their own plain CSS (web/static/css/admin.css),
cut from app.css.
2026-10-22 21:58:34 +07:00
JosephFerano d05d5747de Delete the Preact web client experiment 2026-10-22 21:50:49 +07:00
JosephFerano 9b6b0b1204 Merge branch 'equip-rows' 2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 8058077454 Client: Moons content as wide as a card grid (wide windows fit 5 well cards); a well card's + Deploy sends multi-well drills too (one way to place every drill); the sale row's label and offer keep their size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 4a060f8a91 Equipment is a table: a row per drill (image, name with copy number and levels, status, yield and cycle with level pips, each track's Upgrade and Sell under them, short notes kept under those); a row opens the drill panel, Sell asks in place, before the Store the upgrades and Sell stay hidden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:10:00 +07:00
JosephFeranoandClaude Opus 5.5 17a2cfbfa5 Multi-well drills mine their own well then the next ones in the moon's order (wrapping round), not the richest; an empty or soon-tapped well is passed over; the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:45:41 +07:00
JosephFeranoandClaude Opus 5.5 c59b8d9140 Refinery: batches start at 100; upgrades 250, 500, 1000, 2500, 5000, 10000 at the same prices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:39:56 +07:00
JosephFeranoandClaude Opus 5.5 ba77879350 Equipment lists every tool on its own, copies too; upgrade and sell act on that tool (no idle-copy swap)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:37:57 +07:00
JosephFeranoandClaude Opus 5.5 6363a40ba3 Client: recipe cards show what you own as a heading (OWNED over a big number in the item's color, at 0 too); a collect's motes fly into it and it counts up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:30:51 +07:00
JosephFeranoandClaude Opus 5.5 15f8ad539b Refinery: shorter batches: bulkLog 1.0 (was 1.83) and seconds Dusk 1, Cryo 2, Plasma 2, Void 3, Stellar 3: a full batch of 50 Cryo Cells takes about 10 s (was 16), 5000 about 19 s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:22:11 +07:00
JosephFeranoandClaude Opus 5.5 dd028afd3e Balance: wider steps: grades and their drills scaled together per grade (C x2, B x3, A x5, S x8: prices, wells, yields, upgrades), so the jump to the next grade outgrows income; refined items gain about 20% over inputs (was 40-100%). Sim: first B 30m (was 15m), S 2h33 (was 1h04)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:20:44 +07:00
JosephFeranoandClaude Opus 5.5 4fbef903ec Client: recipe cards put Max (as many as you can) full width and primary, Refine beside the quantity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:13:23 +07:00
JosephFeranoandClaude Opus 5.5 f79ac6f3b9 Refinery: batches start at 50 (C moons hold far more now); upgrades 100, 250, 500, 1000, 2500, 5000 for 6k to 15M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:12:34 +07:00
JosephFeranoandClaude Opus 5.5 844b181644 Refinery: Void Core and Stellar Lattice show (and refine) once a B moon or higher is bought (refined item unlockGrade; onboarding flags grade:<G>, backfilled by migration 021)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:11:56 +07:00
JosephFeranoandClaude Opus 5.5 6f90df026a Client: the main panel's entrance replays when the section changes, and the moon only on Moons: Claim all finishing a moon (the view moves to another) no longer replays the Refinery's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:07:10 +07:00
JosephFeranoandClaude Opus 5.5 0091d2a099 Client: why Sell is off shows in place, not only in a tooltip (the drill panel: "Still mining. Sell it once it's idle" for its note; the moon sale: a line under its row)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:05:07 +07:00
JosephFeranoandClaude Opus 5.5 55acf50692 Client: Sell moon (destructive) moves to the foot of the side pane, compact there; a tray drill deploys straight to the selected well on click (no picking); no Redrill on a well its drills already take in full
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:55:51 +07:00
JosephFeranoandClaude Opus 5.5 8b07425d25 Client: a well card's ready drills show Claim only (no Redeploy) when claiming leaves the well tapped out or its rest taken by other drills; the same for the multi-well card and the moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:40 +07:00
JosephFeranoandClaude Opus 5.5 04dee47571 Balance: progression pass: each drill takes about 4-5 claims per well on its grade and rips the grade below (yields PD-1 8, PX-4 60, L100 300, L300 4000, LRE 48000, EDE 110000, LCE 200000); drills priced so each tier earns a bit more per credit; upgrade levels repriced against copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:34:20 +07:00
JosephFeranoandClaude Opus 5.5 d0716bf89a Merge main (per-drill upgrade tracks) into drill-cards
Keeps the well cards and the status-only aside; names follow the tracks model (code and
levels tag, e.g. "PD-1 Y2 C1"), no tiers or attachment tags on cards, tray or pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:28:21 +07:00
JosephFeranoandClaude Opus 5.5 c278865a24 Moons: well cards with every action in the main panel; the aside only shows
Each well is a card: supply and bar, a deploy slot (sends the drill picked in the new
tray of idle drills, else the smallest one-well drill with room), a slot per drill (time
and bar; when done, gold with Claim and Redeploy on it), and an idle slot with Redrill.
Multi-well drills get a card across the grid; Sell moon moves to the foot of the main
panel. The aside keeps the viewport and shows the well's or the moon's facts, no buttons;
picking a well no longer scrolls the narrow layout to it. Scenario wellcards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:21:35 +07:00
JosephFeranoandClaude Opus 5.5 b76a189d22 Client: Equipment cards and the drill panel show the two upgrade tracks (value and next value, Upgrade or Max level, a kept need line) and each track's ladder; attachments, slots, spares and tiers removed; prediction groups copies by track levels
Scenario tracks.py (click_check on both upgrade buttons); fixture, drillonboard, sellask,
texts and others updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
JosephFeranoandClaude Opus 5.5 069bf39ad2 Drill upgrade tracks replace attachments and the tier ladder: each drill has a yield track and a cycle track of its own (flat values, 4-5 levels, refined parts from level 3, per tool); upgrade takes track=yield|cycle
Migration 020: a tool's tier N becomes cycle level N, attachments are refunded at their
price (logged refund_attachments) and dropped, upgrade_item is dropped, stake cycles are
kept to a tenth of a second. Attachments are gone from the config, actions, Store, drill
panel, cards, onboarding, sims, economy and players pages and the Preact client. Redrill
picks drills by their track yield. Prices calibrated with the sim: a first level is ~1.03x
a copy per credit; boosts player C 13m18, B 23m39, A 1h05m50, S 3h01m59 (was 13m15,
22m26, 1h08m39, 3h00m21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
JosephFeranoandClaude Opus 5.5 7e18f99796 Redrill sends the smallest drill that finishes the well's remaining room alone, else the biggest and again (two 4s and a 20 on a well with 3 left: a 4); the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:49:40 +07:00
JosephFeranoandClaude Opus 5.5 eda38810cd Refinery unlocks with the first C moon (was B)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:30:57 +07:00
JosephFeranoandClaude Opus 5.5 91c71fd9c8 Balance: no duds early: D and C moons are never worth under 1.3x their price (minValue re-rolls the poorer ones), with wells 30% smaller; B and up keep their duds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:02:46 +07:00
JosephFerano 55f28caa14 Merge branch 'drill-onboard' 2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 71dc1d1b1d Equipment: a tool card opens its drill panel as soon as Equipment is unlocked (onboarding); the panel's Store parts (upgrade, slots, spares, sale) wait for the Store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 2e6872a824 Balance: moons cost twice as much (D 300 ... S 3M) and their wells hold 2.05x (worth about 2.5% more for the price); sim: C 23 m, B 37 m, A 1 h 08, S 2 h 52-2 h 58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:38:12 +07:00
JosephFeranoandClaude Opus 5.5 fa18e8c1ed Balance: drill cycles 3-16 s (PD-1 3, PX-4 6, L100 8, L300 10, LRE 12, EDE 14, LCE 16) with the same claim sizes: income 2-4x, progression about twice as fast in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:36:08 +07:00
JosephFeranoandClaude Opus 5.5 a199fdce0c Client: moon list rows put the grade and rating chips on the second line, before the status, so they don't run into the ready badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:34:04 +07:00
JosephFeranoandClaude Opus 5.5 5bc0a3deab Balance: C moons cost 3000 with wells of 150-300 (value unchanged at ~1.7x), so the starting 1500 no longer buys one; first C at ~35 min in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:32:31 +07:00
JosephFeranoandClaude Opus 5.5 b51a6539d3 Client: the mystery moon's shroud and motes turn slowly (0.25 rad/s; off with mm.moon_spin=0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:10:58 +07:00
JosephFeranoandClaude Opus 5.5 583da23d32 Client: the side panel (Top miners) shows on the Market only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:09:29 +07:00
JosephFeranoandClaude Opus 5.5 f43f0be1a8 Client: the side panel (Top miners) never shows beside the moon pane
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:09:12 +07:00
JosephFeranoandClaude Opus 5.5 a47c4ba716 Client: the reveal card widens for big moons so the wells' supply/max numbers have room (S: +41 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:59:37 +07:00
JosephFeranoandClaude Opus 5.5 1897fc9905 READMEs: rerun make migrate after pulling (migrations and config sync), esbuild download, make build, migrations 014-019
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:23 +07:00
JosephFeranoandClaude Opus 5.5 7ec439f248 Refinery: up to 10 slots (slot prices to 2.5M); the recipe card's Refine gets its own full-width row under the quantity and Max
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:52:49 +07:00
JosephFeranoandClaude Opus 5.5 8faa1b131f Client: the announcements pane keeps the last 5 and is sized to them (the moon list gets the rest)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:47:00 +07:00
JosephFeranoandClaude Opus 5.5 6caae5d3b4 Client: tab counts are a small badge on the tab's top-right corner, so Moons and Refinery don't carry an empty gap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:45:29 +07:00
JosephFeranoandClaude Opus 5.5 dbbc194052 Client: Buy tools pinned to the Equipment panel's top-right corner, not the capped column's edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:35:40 +07:00
JosephFeranoandClaude Opus 5.5 d3ea0bfc89 Refinery: batch time grows with the log of the batch (seconds × min(n, 1 + bulkLog × ln n), bulkLog 1.83: 25 unchanged, 2500 takes 15× one unit instead of 109×)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:29:59 +07:00
JosephFeranoandClaude Opus 5.5 bafa5e0bce Client: a kept block keeps the keyed blocks inside it (a kept Equipment section lost its cards until the next rebuild); Equipment's heading row is full width so Buy tools sits at its right edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:28:55 +07:00
JosephFeranoandClaude Opus 5.5 fe86565b55 Client: Sell moon moves to the moon pane (the aside, under the selected well)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:22:14 +07:00
JosephFerano f564890ac0 Merge branch 'refinery-juice' 2026-10-21 18:15:28 +07:00
JosephFerano ef17e3b1d9 Merge branch 'market-ui'
# Conflicts:
#	client/screens/game/core/main.lua
2026-10-21 18:15:28 +07:00
JosephFeranoandClaude Opus 5.5 909cdc7412 Refinery: batch size upgrades (refinery.batchUpgrades, users.refinery_batch_level, upgrade-batch); finish and collect feedback in the client; stock chips row removed from the client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:09 +07:00
JosephFeranoandClaude Opus 5.5 07b4185cc3 Client: bigger market cards with the held amount prominent; leaderboard as a right side panel (sidepanel.lua, room for more); sells ask in place instead of a dialog (attachment, drill, moon)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:09 +07:00
JosephFeranoandClaude Opus 5.5 dd57a34a6d Client: the main panel rebuilds only for the timers it shows (no Refinery rebuild per drill); one refetch for ends within a second; refetch at a banner item's end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 0db237c1a5 Client: equipment cards keep the need line's place so buttons line up; reveal card buttons fit the card and brighten on hover instead of growing from a corner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 2723bbd842 A moon opens on its first well with supply left
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 76293f2cc1 Banner items leave at their end: the web drops them on its clock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 bfc066a1b3 Tools show their original images again: drop the generated per-tier art
The tier art from scripts/tool-art.mjs is out; StoreItem.ImageAt falls back to the base
image when no -tN file exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:21:35 +07:00
JosephFerano 09d09cbc52 Merge branch 'c-lint' 2026-10-21 13:47:33 +07:00
JosephFerano 97ed243b34 Client: layout dump and UI lint (drive.py dump/lint, make lint, clicker check), reserved slots and text fit helpers in flex_gui; keep tab count and need-line places; fix filled chip stray square 2026-10-21 13:47:12 +07:00
JosephFerano 63d8f633d3 server/AGENTS.md: the sim plays two clicker players now 2026-10-21 13:43:02 +07:00
JosephFerano daa6c9bc5a Clicker pacing: drill cycles 11-15 s, grade prices x10 per grade with wells to match, upgrades a bit better per credit than copies; sim plays two active players (copies, boosts) under a click budget 2026-10-21 13:42:28 +07:00
JosephFerano 850e2a136a Client: moon grades (grade picker on the new-moon panel, grade toggle on + New moon, grade badges, per-moon supply scale) 2026-10-21 13:17:17 +07:00
JosephFerano 48bc94ad76 Merge main into grades 2026-10-21 12:49:32 +07:00
JosephFerano 508ff12e57 Moon grades D–S with their own prices and wells, buy-moon takes a grade; upgrades speed drills up; drill, boost and refinery rebalance; active/idle balance sim 2026-10-21 12:49:07 +07:00
JosephFerano 30a2548b2a Trim client/AGENTS.md to rules and a map; document scripts/wt and make run/reload 2026-10-21 12:48:05 +07:00
JosephFerano 00391580ce Client: make run starts the game from build/default in the foreground so make reload works on it 2026-10-21 12:45:37 +07:00
JosephFerano 103d4b4ac2 Merge branch 'main' into tooling 2026-10-21 12:44:12 +07:00
JosephFerano 59812479cf scripts/wt: agent worktrees with their own server, database and Defold build cache 2026-10-21 12:44:12 +07:00
JosephFerano 34b8846976 Merge branch 'saves' 2026-10-21 10:41:09 +07:00
JosephFerano f52f28d2e9 Client: Save code and Restore from code in the settings menu, the save code nudge as an announcement, savecode scenario 2026-10-21 10:40:48 +07:00
JosephFerano 4a02faeee4 Server: a refused restore carries an error toast 2026-10-21 10:40:48 +07:00
JosephFerano 45246e8399 Server: restoring a banned player's code is a 422 2026-10-21 10:30:25 +07:00
JosephFerano f5a65d53cb Server: save codes (GET /api/save-code, actions save-code and restore, hashed in save_code, restore rate limit, a one-time nudge after the first sale) 2026-10-21 10:28:40 +07:00
JosephFerano d800baf9a9 Tool art per tier: generated drill images per tool and tier, image fields in the API
scripts/tool-art.mjs (from s-drillart) draws every tool at every tier into
web/static/assets/items (tier N: <image>-tN). StoreItem.ImageAt picks it, falling back to
the base image. API: state.tools and equipment rows show the tool's tier art; catalog
tiers get an image. TestAPI checks every tool has art at every tier.
2026-10-21 10:23:39 +07:00
JosephFerano ffbe30fa78 Merge branch 'c-reveal'
# Conflicts:
#	client/AGENTS.md
2026-10-21 10:12:18 +07:00
JosephFerano 1462e2e9f1 Client: note the reveal scenario 2026-10-21 10:11:41 +07:00
JosephFerano ef4eea8147 Client: buying a moon reveals it in a centered window with a drawer for its stats; + New moon at the top of the moon list buys at once; toasts listed in an announcements pane under the moon list; deep build-up and a bigger burst sound, Perlin shake; the price counts down at the purchase 2026-10-21 10:11:35 +07:00
JosephFerano da27a9ca02 Client: leaderboard pane in Market, settings gear menu, Claim all keeps its place
The header loses the Leaderboard and Help tabs and its small settings buttons.
The Market shows the top miners in a narrow pane on the right (under the market
when the panel is too narrow); old leaderboard links open the market. One gear
at the right opens a dropdown: How to play, UI scale, fullscreen, sound, theme,
reduced motion, Grant. Claim all's place is reserved once unlocked so the tabs
never move; when the row is short the resource chips go under the balance.
2026-10-21 10:07:29 +07:00
JosephFerano e040e3b986 Merge branch 'c-stable' 2026-10-21 09:53:49 +07:00
JosephFerano 6dd70e4cae Client: buttons stay put under repeated clicks
Well panel: the note's room is kept and After current drills always shows, so the
first deploy no longer pushes the deploy cards down; deploy cards are a grid of
web-style cards (picture on top, name, yield, Deploy, note line), all one height.
Refinery: batches show oldest first from the left, idle slots after; collecting
the leftmost slides the rest left (the frame glides, the Collect hit box sits at
the final spot at once), so repeated clicks on one spot collect each batch.
H.action takes an optional hit node. AGENTS.md: the clicker rule.
2026-10-21 09:53:36 +07:00
JosephFerano 450c371e43 Merge branch 'overdrill' 2026-10-21 09:52:00 +07:00
JosephFerano 8bbe20ba25 Wells: refuse a stake on a fully committed well; client prediction ports the server's well projection
Server: stakeInTx refuses a drill that would get nothing after the drills already on
the moon ("Nothing left to mine on this well"); redrill and claim+redeploy leave those
tools idle. State: moon.wells[].fullyCommitted, well.idle[].blocked. The web disables
the deploy card. Wells sort by resource then id everywhere (claims too), stakes by
created_at then id, so ties split the same in the projection and the claim.

Client: derive recomputed a well's remain from its one-well drills only, dropping the
multi-well drills' share, so placing a drill on a well shared with a multi-well drill
made the committed part shrink. predict.lua now ports the projection (demand, remain,
full, notes, drills' gets, deploy cards); stake take is what claiming now gets; claims
go in turn from what is left and stop drills on an emptied well; a claim no longer
counts its tool twice for Redrill; stake/redrill/redeploy aren't predicted on a well
with no room. The deploy card is disabled when blocked.
2026-10-21 09:51:21 +07:00
JosephFerano 1487f1bdcd Client: the moon sale sits at the bottom of the moon pane 2026-10-21 09:47:33 +07:00
JosephFerano 63360022c8 An owned moon always opens with a well selected (the first, when none or a stale one is asked for) 2026-10-21 09:46:59 +07:00
JosephFerano 9d6f912f13 Client: store cards keep the shortfall line's space, so Buy is at the same height in every card 2026-10-21 09:40:57 +07:00
260 changed files with 21964 additions and 22774 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@
# Blender files # Blender files
*.blend *.blend
*.blend1 *.blend1
# Agent worktree state (scripts/wt)
/.wt/
+20 -9
View File
@@ -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, `completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined 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 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 for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors 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 (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, the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out). banned players left out).
## How the parts fit ## How the parts fit
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it - `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
renders as HTML (htmx, three.js moon). The server is the only authority on game state. (`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
Details: `server/AGENTS.md`. authority on game state. Details: `server/AGENTS.md`.
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's - `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 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`. what it sends. It is the only game client, and its moon generator
Details: `client/AGENTS.md`. (`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, - Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`. Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
## Workspace ## Workspace
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace, - The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`). outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- Agent worktrees go in the workspace, next to `game/`: - Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged. 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 - `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it. commit it.
+18 -8
View File
@@ -2,10 +2,10 @@
An idle mining game: own moons, drill their wells, claim resources, sell or refine them. 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). - `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 Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`). `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 podman-compose up -d db # or: docker compose up -d db
cd server cd server
make migrate 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 ## Defold client
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
is optional: https://defold.com/download/ 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>`
With the server running: for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
```bash ```bash
cd client cd client
make run # desktop build, plays against http://localhost:3000 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 ## Deploy
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`. Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
+189 -626
View File
@@ -1,637 +1,200 @@
# AGENTS.md — Moon Miners client (Defold) # AGENTS.md — Moon Miners client (Defold)
## What this is A native client for Moon Miners (rules and state live in the Go server: `../server`,
A native client for Moon Miners, the idle mining game whose rules and state live in the Go `../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
(header with sections, resources and credits; moon list on the left; the current section 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
in the middle; aside with the moon viewport and the selected well), made game-native. show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size gamepad later. Details live in each file's header comment; this file has the rules and a
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later. map.
The server is the authority. The client shows the state the server sends and sends ## Rules
actions. It may show an action's result before the answer (prediction, see Conventions), - The server is the authority. Show the state it sends, send actions. Prediction may show
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes, an action's result early, computed only from numbers the server sent (`take`, the tool's
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game `cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
rules in Lua. Countdowns may tick locally as display only. 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 ## Testing
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1 - Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`). the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components), running game or their settings file (`~/.local/share/moonminers/settings`): give any game
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
native extension, so a build sends the engine to Defold's build server once (needs - After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
network; later builds reuse it until the dependencies change). Dependencies are fetched `scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
only when `game.project` changes (`make resolve` forces it). touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...). says what it checks and needs). Run `make check` (with `MM_API` set to your test
The game screen takes its input focus once in `init`; Druid never touches it (`[druid] server; it makes a fixture player there) before calling it done.
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for - Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse "label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else. `idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup): `expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source: `os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
`~/Development/Refold/Defold` (read-only). - `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
server, which `wt up` starts).
## Game screen: core, regions, sections - 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,
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua` sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the `drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
state: every new state (a response, a prediction) or a window/UI-scale change sets `game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
`g.dirty`, and the next update builds again what changed. The regions (banner, header, rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
moon list, main panel, aside) and costly parts inside them (the market's ticker, chart and overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
sparklines; Store, Equipment and Advisors cards through `card.grid`'s `key`/`sig`) are click_check to a feature's scenario when it has buttons people click repeatedly.
blocks (`modules/blocks.lua`): one whose signature (a serialization of everything it
shows: its state keys, the view, busy buttons, ready counts on our clock) is unchanged
keeps its nodes and Druid components. A full rebuild (drops the Druid instance and every
layout node) happens on the first state, a size change, the narrow layout and
`g.dirty_all` (theme, reduced motion, mute, scroll offsets set by code, hot reload). What
a region shows from the state is listed in `core/main.lua` (`SECTION_KEYS`, `ASIDE_KEYS`,
the header's and moon list's sigs): a section that reads a new state key must add it
there, or it won't update. `g` is the gui script's `self`.
Core (`screens/game/core/`):
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
the build orchestration (banner, header, moon list, main panel, aside; widths from
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
The aside shows only when the view selects something for it (`M.aside_shown`, the
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
the web's boot and suspended pages) shows until the first state.
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
words ("Something went wrong", "Can't reach the server") with the error sound; a
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
suspended screen.
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
(eased number); keyed so values carry over a rebuild.
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
`on_submit` and the refinery's quantity; reset every build), back / forward
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
scroll area under the mouse (else the focused one's, else the biggest;
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
Ctrl +/-/0 the UI scale.
`session.lua` keeps the view history (`g.history`, like the web's pushState).
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
clicks while it pops, and the claim predictor counts a drill whose end has passed on
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
Regions (one module each, so agents can work on them separately): `banner.lua`,
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
in the server's words, counted from its `end`),
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
fullscreen),
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
`advisors.lua`, the new-moon teaser in `reveal.lua`.
**Module contract** (regions and sections):
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
center their own capped content. Regions get their box; the header and banner return
their height. `H` = `g.h`.
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
for content under the viewport.
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before its region is
built again and when the screen goes away (cancel timers, drop references to nodes).
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
again by a full rebuild), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
builds outside its block.
- Per-build registries (test ids and focus, tooltips, tickers, `g.inputs`,
`g.scroll_areas`, `g.pointers`, fx anchors, autoplay) are reset by every build; a kept
block makes its registrations again from a journal: a new registry must note them
(`blocks.note(fn, ...)`, or `blocks.push`/`blocks.put` for a list or map on g). Code
that builds outside a build (a list making rows as it scrolls, a field's on_change)
runs through `blocks.bound(fn)` so what it makes belongs to its block. A kept block
moved under another parent finds its buttons' clip stencil again.
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
when the well or drill changes, both on the first state and after the reveal closes
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
staggered. Not on other rebuilds. While the reveal card flies into the moon list (Start
mining) a new state waits for it to land.
## Components (`components/`)
Build new UI from these instead of coordinate math. Each file's header documents its API.
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
or link button has hover and a test id. After a rebuild the button under the mouse
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
button with a test id: `button.carry`, the one with the same id under the pointer is
pressed in the new build), so the release still clicks it.
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
instance: survives rebuilds, runs actions in the game script, drops page hovers
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
only way to show a dialog (the Monarch popup screen is gone). Input invariant
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
a log with `ERROR:MM`).
- `screens/game/grant.lua`: the quick grant dialog for admins (`state.admin`: header "+"
button, G key): item and amount pickers, then `admin-grant`.
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column laid out with flex (below; measured text, `title`,
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
advisor). Store, Equipment and Advisors.
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
Header and moon list.
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
history).
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
`bar.ready_label` (gold "Ready" with a pulsing glow).
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
and time on g so it picks up on the rebuilt row).
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
window, hidden by a click or a dialog; on its own layer. Give information-only text a
tooltip like the web does.
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
card once complete). A long list (components/list with `focus_id`) is walked item by
item, scrolling, since its rows out of view don't exist. So anything interactive must register
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
widths yourself); refresh innermost layouts first, synchronously, after building
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
so make a clickable container before the buttons on it.
## Layout (flex) (`modules/flex.lua`, `modules/flex_gui.lua`)
Prefer it to coordinate math (`26 + ui.text_width(name) + 7`) for new or reworked UI.
Every section, the aside (well panel, drill panel, advisor panel), header, moon list,
banner, toasts, dialogs (and the buy modal), the new-moon teaser and the reveal card are
laid out with it; component internals (chips, stat rows, charts, moon rows) still place
their own parts.
- `flex.lua` is a pure solver (no Defold calls; unit tests `scripts/flex_test.lua`, in
`make check`): a tree of `{ style, children, measure = fn(avail_w) -> w, h, node, id }`;
CSS names in snake_case (`direction`, `gap`/`row_gap`/`column_gap`, `padding`,
`justify`, `align`/`align_self`, `grow`/`shrink`/`basis`, `width`/`height` px, "N%",
"auto", `min_*`/`max_*`, `wrap`, `display = "none"`). `flex.solve(tree, x, y, w, h)`
sets absolute `x, y, w, h` on every box: top-left origin, y down (like `ui.*` calls);
`flex.gui_pos(box, pivot)` gives the GUI position. Deviations from CSS (border-box, min
sizes 0, auto = fit-content, no margins) are at the top of the file.
- `flex.apply(tree, nodes, { glide = secs })` (flex_gui) sets position (by the node's
pivot) and size (unless `box.size == false`; divided by scale) on each box's `node`,
relative to the nearest ancestor box with a node, only when it changed; it never
creates, deletes or reparents. `glide` tweens moved nodes (FLIP), off under reduced
motion; default snap. `flex_gui.text_measure(node[, lh])` measures a text node.
- Rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a label);
animations own what's inside (like CSS transform vs layout). Never give a box a node
that something animates by position or size. Kept blocks (modules/blocks) aren't
re-laid out: solve only what you build.
- Widgets whose look is built at a size (ui.button, bars, dashed edges) are built after
the solve, at their box's rect (`{ style = { grow = 1 } }` without a node, then read
`box.x/w`); text is cut (`ui.fit_text(node, box.w)`) after it.
- New or reworked UI is flex-first; don't add hand-placed coordinates. When you touch a
hand-placed section, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Flex-first (the Refinery): describe the whole section as one tree with
`flex_gui.label`/`para` leaves and boxes with `make = fn(parent, x, y, w, h)` for what is
built at a size, `flex.grid` for card grids, then `flex_gui.build(flex.solve(tree, x, y),
parent)` makes the nodes in tree order (= focus order).
- flex_gui tree pieces: `box/row/col/space/fill/made`; `label` (rich text when it has
symbols), `text_para` (H.para's leading), `node_label` (an existing text node);
`button` (a ui.button box, width from a probe, then `bind(b, nodes, w)`); `inline(parent,
h, fn)` for one-line components that return their width (chips, ui.credits, stat
rows: fn(probe, 0, 0, true) must register nothing); `measured(parent, fn)` for one that
returns its height; `flow(parent, x, y, w, parts)` mixes trees with components that lay
themselves out in place (card.grid's kept cards). `H.claim_wait_w` gives a claim
countdown's width. `make` rects are whole pixels; leaves aren't rounded.
- Beyond flexbox (flex.lua header): `columns = {...}` makes a box a CSS grid (tracks px,
"N%", "auto", "Nfr"; `span`, `rows`, `justify_items`/`justify_self`; row height from
the tallest cell): tables. `position = "absolute"` with `left/right/top/bottom` and
`translate = {fx, fy}` places a child over its parent's box; `flex.anchor(rect, box)`
places a box that way against any solved rect (badges, floating numbers, bursts).
`aspect_ratio` (image boxes). `overflow = "scroll"` (children don't shrink) and
`flex.content_size(box)` for a scroll area's content size.
- Patterns: a label is a leaf of its line-box height (pivot W lands on its middle); a
pinned footer is a `grow = 1` spacer before it; equal-height card rows are `wrap = true`
with the default `align = "stretch"`; grid columns: give items the column width.
## Animation (`modules/anim.lua`)
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
`scale_about` (scale a pivot-NW node about its middle, like CSS),
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
content fades in from under a veil and rises, stagger), `group` (invisible container),
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
(scan line), `count`.
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
translucent fills), so content fades in from under a veil (a box in the background
color, `theme.color.veil` for panels) instead of fading a subtree.
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
saved in the settings file unless save is false): every helper skips movement and keeps
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
ticks); the reveal skips the charge and shows the card complete; the ticker stands
still. New animations must honor it.
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
quick repeats of the same gain merge into one number; others stack past it; wallet
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
error shakes the button it came from, found after the rebuild by its test id
(`fx:button`, registered by `H.action`)).
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
## Text and markup
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `&lt;` `&amp;`). `ui.style_text` (so
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
`resource.get_text_metrics`) understands markup.
## Layout
- `game.project`: settings; `[mm]` holds the client's own config (see below).
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
components.
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
On desktop it also makes the window's mouse buttons and keys sticky (GLFW), so a click
or key press shorter than a frame isn't lost (the engine polls their states once a frame).
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
browser's Fullscreen API is used instead (`modules/screen.lua`).
- `mmterrain/`: native extension, the moon generator in C (`src/terrain.c`, a port of
terrain.js's generation, same doubles in the same order; there is no Lua generator).
User rule: C only (`.c`, Lua C API); C++ only where the dmSDK forces it,
as a C-style shim (free functions and plain structs only: no classes, methods,
templates, STL, references, own namespaces, exceptions or RTTI; `src/buffer_shim.cpp`:
the buffer API). The extender compiles `.c`
files as C++ ("treating 'c' input as 'c++'"), so the C must also be valid C++ and
what links across files is `extern "C"`; the registration (`DM_DECLARE_EXTENSION` is
C++) is written out in `mmterrain.c`. Lua: `mmterrain.new(terrain.native_params(p))`,
`start` (a thread of its own where there are threads: desktop), `step(gen, secs)` (the
web: no threads), `done`, `vertices`, `fill(gen, buf[, from, to])` (the triangle strip
straight into the mesh buffer's streams), `result` (the flat arrays), `free` (header of
`mmterrain.c`). `moon.script` makes every moon mesh with it.
- `web/index.html`: the web build's page (`[html5] htmlfile`; see Web build).
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
`chart.lua`
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
unseat, dismiss; an unhired one's details on a card click),
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
whenever the shown state changes: at the click for a predicted action, in the request
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
moon sale register with `fx:box(node)` while built; give a new card-like container one.
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
moon). View links carry the current moon like the web's (`View.ToSection`): without
one the server picks the first moon.
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
file),
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
(hot reload).
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
clones them (`ui.clone`).
- `widgets/moon_row.lua`: the moon list row (Druid widget).
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
fade, ring, spark, speaker, sun and moon icons; tinted per node.
- The moon viewport: `modules/moon/terrain.lua` ports the settings side of the server's
`terrain.js` (defaults, resolve, traits, colors, the cube-sphere and its strip), and
`mmterrain/` the generation, in C (`scripts/moon-check.mjs` compares them with
terrain.js, many ids and settings; in `make check`), `modules/moon/moon.lua` is the API
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`,
`prefetch`: reveal.lua starts a bought moon's mesh as the buy-moon answer arrives),
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
HDR target with bloom; the viewport node shows it through the `moon` gui material.
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
`zzfx-render.mjs`, `serve-web.py` and `web-shots.mjs` (Web build).
## Web build
`make web` bundles for the browser (`wasm-web`, release; `WEB_VARIANT=debug` keeps logs
and the test hook) into `dist/web/MoonMiners/` (release ~4.3 MB, ~2.4 MB gzipped: engine wasm 2.9 MB,
archive 1.1 MB, page and loader).
`make serve-web` (`scripts/serve-web.py`, `UPSTREAM=` default http://localhost:3000,
`WEB_PORT=` 8080) serves it and proxies `/api/*` and `/static/*` (everything the client
fetches) to the server, so the game talks to its own origin; the server stays unchanged.
- The page (`web/index.html`): the canvas fills the window in device pixels and follows
its size (the layout is native-pixel like desktop); dark loading screen; "WebGL 2
needed" without WebGL 2 (the moon's shaders don't build for WebGL 1). `[html5]` in
game.project: `stretch`, no Defold buttons or banner, heap 256 MB.
- On html5 (`if html5 then`): `api.base` is the page's origin (`?api=<url>` picks another
server, which must allow CORS); the player id and settings are saved with sys.save
(IndexedDB), X-MM-User sent as on desktop (the cookie works too, same origin).
Fullscreen (F11, display dialog) through the browser's Fullscreen API, not saved or
restored (the browser allows it only after a click or key). Moon generation is the
mmterrain extension in wasm (no threads there): stepped with a share of each frame
(30% of the frame time, 2–12 ms: ~2 ms at 144 Hz), the buffer filled over the next
frames, grid capped at 128 (`WEB_RESOLUTION` in moon.script). Moon targets fall back to 8 bits without EXT_color_buffer_float.
Variable frame rate there (`sys.set_update_frequency(0)` in main.script): the browser's
requestAnimationFrame paces at the display's rate; `display.update_frequency = 60`
(desktop's timer pacing) made the engine skip callbacks: 60 fps in a 2/3 cadence on
120/144 Hz displays, dropped frames on jittery 60 Hz ones. Use dt or the clock for motion.
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
depth buffer: the render script binds every bloom sampler and makes targets again at a
new size. Check the console after render changes.
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
by real mouse clicks (element rects from the test hook: the page pushes commands onto
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
claim; fails on console errors.
## Commands ## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is - `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). bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start. - `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
- `make web`, `make serve-web`: the browser build (Web build). from `build/default` in the foreground (loose files, engine service on), so `make reload`
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`, works on it.
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot - `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600 `make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
`SETTLE=15` (software GL). Silent unless `SOUND=1`. - `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`. `node scripts/web-shots.mjs` (Playwright; header of the script).
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
- `make check`: checks (below).
- Before/after screenshots of every section (works on any build, also one from before a
change): `scripts/fixture.py <api> > fx.json`, then
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
after each other (the fixture's drills and market move on).
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
`mm.fake_reject`, shots via `SHOTS_DIR`).
- Never kill processes by name (scripts stop the game by its pid).
## Test driver (`modules/test.lua`, `scripts/drive.py`) ## Hot reload (`make reload`, `scripts/dev.py`)
The engine's debug HTTP service (debug builds; port from the log line `Engine service `make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script` `build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
runs in the same Lua state as the gui script but outside it, so it only pushes commands view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
onto the global `mm_test_queue`; the game screen runs them in its update (gui context) - Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`. too; restart if one looks wrong.
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or - Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
`$MM_DRIVE_DATA`), never the player's own settings. collections, game objects, `main.script`, materials, shaders, the render script; modules
- Elements: every interactive component registers a stable id while it's built: that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...` `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`, native extensions and dependencies (a new engine).
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that - New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
over three frames through Druid: real input, hover included), `hover(id)`,
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
`restart()` (quit and start the game again, same options and saved files),
`cmd("frames", "reset")` (frame times since the last reset: fps, dt, the game screen's
update and its rebuilds in ms; the web harness reads it the same way). F3 in a debug
build shows the engine's profiler.
Those go through the test hook, which calls the screen's input function directly, so
they work even when the engine sends the game no input. Real input through the X server
(xdotool on the driver's Xvfb, its own display from `Xvfb -displayfd`: `xvfb-run -a` raced
when two runs started together; key and button held 80 ms like a hand; the engine polls
once a frame, and mmwindow's sticky keys and buttons keep a press shorter than a frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
page at once; the invariant's repair; args `reduced`, and run it with `--config
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
moon; the Moons tab returns to the last moon after other sections, back / forward, a
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
folder (`<out>/data`), never the real settings file. Run with `--config
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
grants through `/utils/players`); prints its ids as JSON.
## Fast iteration (hot reload)
`make dev` builds once and starts the game from the build folder (engine
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
registered first (a `run_script` listing them), then `.luac` files, then the game's
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
to those, to collections, game objects, `main.script`, materials, shaders or the render
script need a restart: `make dev-stop dev` (reload says so).
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
(core/main.lua) or lazily. (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) ## Map
Fails on: the flex solver's unit tests (`luajit scripts/flex_test.lua`); Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`, Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack `M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
a fixture player in every section: symbols in sym, the rest in the text fonts); the moon - `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
generator (`node scripts/moon-check.mjs [resolution] [seeds...]`: - `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
`mmterrain/src/terrain.c`, built for the host with `cc` and loaded into luajit through the - `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
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, - `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs - `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
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 - Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it. 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) Components (`components/`, API in each header; build new UI from these): `button`,
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per `dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
server. `scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
- `view`: the view to open, `key=value&...` of `state.view` keys
(`section=equipment&drill=<id>`).
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
- `theme`: `light` or `dark` (default: the saved setting, else dark).
- `test`: `0` turns the test hook off in a debug build (it's never in release).
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
scale; not saved). Default: the saved setting (the display dialog), else 1.
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
--config mm.display_scale=2` looks like 1280x800).
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
the others on the command line.
## Conventions Modules (`modules/`):
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), - `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
not screens. (applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
- Every gui_script uses one Druid instance (`druid.new(self)` and the parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
interaction (buttons, hover, scroll, input, layout, data list) through the components. { fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind { lines = 2 })` (at most n lines, cut with …).
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`. - `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and `toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
colors come from `theme.lua`. `settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the - `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more `hot.lua` (hot reload).
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide - `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
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 Elsewhere:
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% - `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped - `moon/`: moon collection, `moon.script`, materials (port of scene.js).
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`, `render/mm.render_script`: HDR moon pass with bloom.
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At - `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
1280x800 every cap is above what fits; keep it that way (compare shots). game's only one (it began as a C port of the web client's terrain.js, now deleted).
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the - `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
`theme.color` vectors in place and the header toggle saves it (settings file). Every color `ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
is a token: no hex or vector colors in code (template node colors via `theme.template`, - `web/index.html`: the web build's page.
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint - `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
on light). The moon viewport stays dark (`viewport`, `space_*` tokens). `fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
- 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 ## Stack
tells you which). Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and `6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
`max_characters` raised in `game.project`. Text past the font batch limit silently (pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
doesn't render (make check catches the warning); stencils (scroll areas, lists, the engine comes from Defold's build server; bob caches it in `.internal/cache` until the
dialog) and images split font batches. native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
- Outline textures use slice9 2 (not 1). (read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
- `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`. ## Config (`--config=mm.<key>=<value>`)
- The server is the only source of truth: every response's `state` replaces ours and `api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
`state.view` becomes our view; actions send the current view plus their params and saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done, `window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
predictor, id)`. Countdowns tick locally (display only); the state is fetched again `moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
when `nextEnd` is due. `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go `reveal_freeze`, `sfx_debug`.
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.
+40 -15
View File
@@ -1,5 +1,17 @@
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool, # Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
# python3 and luajit for shot, play, drive, dev and check. # (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_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/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" # Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?= 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): $(BOB):
mkdir -p tools mkdir -p tools
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@ curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB) 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). # Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB) .internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve $(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
touch $@ touch $@
build: $(BOB) .internal/lib/dependencies.json 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 --archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release # 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). # 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 WEB_VARIANT ?= release
UPSTREAM ?= http://localhost:3000 UPSTREAM ?= http://localhost:3000
WEB_PORT ?= 8080 WEB_PORT ?= 8080
web: $(BOB) .internal/lib/dependencies.json 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 --archive --bundle-output dist/web build bundle
serve-web: serve-web:
@@ -50,9 +63,9 @@ dev: build
reload: $(BOB) reload: $(BOB)
@mkdir -p dist/dev @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; } || { 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: dev-stop:
scripts/dev.py stop scripts/dev.py stop
@@ -63,28 +76,40 @@ SCENARIO ?= smoke
DRIVE ?= --fixture DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @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; } || { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO) 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 # 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. # scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @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; } || { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py 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 run: build
$(BIN) $(ARGS) scripts/dev.py run $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too). # Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build 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/. # 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 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: clean:
rm -rf build dist .internal/cache rm -rf build dist .internal/cache
Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

+2 -1
View File
@@ -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, (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), 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 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 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. to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -31,6 +31,9 @@ images {
images { images {
image: "/assets/images/sun.png" image: "/assets/images/sun.png"
} }
images {
image: "/assets/images/gear.png"
}
images { images {
image: "/assets/images/moon.png" image: "/assets/images/moon.png"
} }
+1 -1
View File
@@ -5,7 +5,7 @@
-- on top instead (the web's card). card.grid lays cards out in columns (as many as -- 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 -- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and -- 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). -- 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) -- y = card.grid(g, parent, x, y, inner, items, function(c, item)
+22 -4
View File
@@ -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. -- 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, 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 }) -- 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 -- 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), -- 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). -- pad (6 each side), min_w, right (x is the right edge).
--
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
local ui = require("modules.ui") local 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 left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha) local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then if opts.fill then
local f = ui.rect(box, 0, 0, w, h, color, opts.fill) -- A plain box holds the fill and the edge above it (gui.move_below on a child would
gui.move_below(f, box) -- 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 end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER) ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w return box, w
end 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 return M
+19 -3
View File
@@ -7,6 +7,9 @@
-- cancel = "Cancel" (the other button; "Close" without ok), -- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message -- 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) -- 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:close("ok" | "cancel")
-- g.dialog:is_open() -- 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. -- 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") local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root 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 self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48) 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) gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24) local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1) 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) gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text") local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
@@ -139,6 +143,13 @@ function M:open(opts)
d:close("cancel") d:close("cancel")
end) 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. -- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0) local g_title = anim.group(box, 0, 0)
local g_body = 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.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H)) gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y") gui.cancel_animations(self.holder, "position.y")
local menu = self.opts and self.opts.menu
if menu then
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
else
gui.set_position(self.holder, vec(W / 2, -H / 2)) gui.set_position(self.holder, vec(W / 2, -H / 2))
end
end end
function M:update(dt) function M:update(dt)
+16 -5
View File
@@ -6,9 +6,12 @@
-- local l = list.new(g, parent, x, y, w, h, { -- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included) -- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end, -- 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 -- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus) -- -- 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) -- 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 -- 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) l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index) return l.create(item, index)
end) 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) l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset) 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 end
function M:set_data(items) 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.items = items
self.data:set_data(items) -- At the saved offset first (Druid clamps it to the rows), so the data list makes the
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the -- rows there, not the top ones only to drop them again.
-- scroll event).
local s = self.scroll local s = self.scroll
s:set_size(self.grid:get_size_for(#items))
s.target_position.y = saved s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved) s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y 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) local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then if a.list == self then
+99
View File
@@ -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
+10
View File
@@ -50,6 +50,16 @@ embedded_instances {
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n" " data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n" "}\n"
"embedded_components {\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" " id: \"close\"\n"
" type: \"sound\"\n" " type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n" " data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
+63 -7
View File
@@ -15,8 +15,9 @@
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready) -- 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.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.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 -- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
-- decaying noise shake (sell power-down, reveal burst) -- 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.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box -- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting -- 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 }. -- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {} local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now() local function now()
return socket.gettime() return socket.gettime()
@@ -207,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away). -- Drop every frame-driven tween (a screen going away).
function M.clear() function M.clear()
running = {} running = {}
shaking = {}
end end
---------------------------------------------------------------- fading content ---------------------------------------------------------------- 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/ -- 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. -- 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 } } 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) function M.shake(node, amp, dur, delay)
if M.reduced then if M.reduced then
return return
end end
amp, dur = amp or 6, dur or 0.4 amp, dur = amp or 6, dur or 0.4
local p local rest = {}
return M.tween(node, dur, function(k, n) local keep = {}
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay) 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)) gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function() end, delay or 0, function()
if p then if rest.p then
pcall(gui.set_position, node, p) pcall(gui.set_position, node, rest.p)
end end
end) end)
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
return t
end end
-- 1D value noise (smooth), for shakes. -- 1D value noise (smooth), for shakes.
@@ -389,6 +410,41 @@ function M.noise_shake(node, amp, dur, freq)
end) end)
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). -- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur) function M.flash(node, color, dur)
local c = gui.get_color(node) local c = gui.get_color(node)
+19 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error" err = "network error"
elseif status < 200 or status >= 300 then elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status) 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" err = "bad JSON"
end end
done(err, data, status, recv) done(err, data, status, recv)
@@ -179,6 +181,22 @@ function M.get_state(params, cb)
request("GET", "/api/state", params, cb) request("GET", "/api/state", params, cb)
end 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. -- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb) function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then if reject_name ~= "" and name == reject_name then
+109 -10
View File
@@ -36,9 +36,50 @@ end
---------------------------------------------------------------- signatures ---------------------------------------------------------------- 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) local t = type(v)
if t == "table" then if t == "table" then
if not own and frozen[v] then
buf[#buf + 1] = token_of(v)
return
end
buf[#buf + 1] = "{" buf[#buf + 1] = "{"
local n = #v local n = #v
for i = 1, n do for i = 1, n do
@@ -55,7 +96,7 @@ local function ser(v, buf)
end end
if keys then if keys then
if mixed then if mixed then
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) table.sort(keys, key_lt)
else else
table.sort(keys) table.sort(keys)
end end
@@ -68,7 +109,9 @@ local function ser(v, buf)
end end
buf[#buf + 1] = "}" buf[#buf + 1] = "}"
elseif t == "string" then elseif t == "string" then
buf[#buf + 1] = #v .. ":" .. v buf[#buf + 1] = #v
buf[#buf + 1] = ":"
buf[#buf + 1] = v
elseif t == "function" then elseif t == "function" then
buf[#buf + 1] = "f" buf[#buf + 1] = "f"
else else
@@ -76,7 +119,8 @@ local function ser(v, buf)
end end
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(...) function M.sig(...)
local buf = {} local buf = {}
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
@@ -86,6 +130,23 @@ function M.sig(...)
return table.concat(buf) return table.concat(buf)
end 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 ---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that -- Note a registration made while building (fn(...) makes it again in a later build that
@@ -130,19 +191,25 @@ end
local replay local replay
-- A kept block inside the one being built: noted in it, its registrations made again. -- 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] local top = stack[#stack]
if top then if top then
top.journal[#top.journal + 1] = { block = b } top.journal[#top.journal + 1] = { block = b }
end end
replay(b) replay(reg, b)
end 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 for _, e in ipairs(b.journal) do
if e.block then if e.block then
if not e.block.dead then local c = e.block
replay(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 end
else else
replaying = true replaying = true
@@ -210,6 +277,37 @@ local function rezone(b)
end end
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 -- 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). -- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying() function M.replaying()
@@ -312,7 +410,8 @@ function M.open(g, key, sig, parent)
reg.used[key] = true reg.used[key] = true
gui.set_parent(old.root, parent, false) gui.set_parent(old.root, parent, false)
rezone(old) rezone(old)
adopt(old) reorder(old)
adopt(reg, old)
return old, true return old, true
end end
if old then if old then
+86 -13
View File
@@ -28,6 +28,16 @@ local M = {}
local EPS = 0.01 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) local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end 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 -- 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 -- 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). -- (not cut).
function M.label(parent, str, style, h, opts) 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) 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) local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W) gui.set_pivot(n, gui.PIVOT_W)
local s = opts or {} local s = copy(opts)
s.fit, s.height = nil, h s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end } return { node = n, size = false, style = s, measure = function() return w, h end }
end end
@@ -123,21 +134,80 @@ function M.label(parent, str, style, h, opts)
w = w - ui.text_width(n) w = w - ui.text_width(n)
gui.set_text(n, t) gui.set_text(n, t)
end end
local s = opts or {} local s = copy(opts)
local fit = s.fit local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.height = nil, h s.fit, s.shrink_text, s.height = nil, nil, h
return { node = n, size = false, fit = fit, style = s, measure = function() return w, h end } return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
end end
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more -- 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) function M.para(parent, str, style, lh, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW) local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true) gui.set_line_break(n, true)
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag") 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 local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line) 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 end
-- Tree pieces (shared by the flex-first sections). Children lists may have holes -- 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 -- 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). -- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts) function M.node_label(node, h, opts)
local s = opts or {} local s = copy(opts)
local fit = s.fit local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.height = nil, h s.fit, s.shrink_text, s.height = nil, nil, h
return { node = node, size = false, fit = fit, style = s, measure = function() return ui.text_width(node), h end } return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
end end
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the -- 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)) gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
end end
if b.fit then 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 end
p, ox, oy = node, b.x, b.y p, ox, oy = node, b.x, b.y
end end
+35 -4
View File
@@ -24,7 +24,7 @@ local M = {}
local entries = {} -- { key, node, kind, label, zone, scope } local entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page" 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) local function alive(node)
return pcall(gui.get_id, node) return pcall(gui.get_id, node)
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
return return
end end
local base = scope .. ":" .. (id or ("label:" .. tostring(label))) 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 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 } entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end end
@@ -225,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead: -- 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), -- 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 -- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
-- when it moved the focus. -- 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 function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur) local a = list_of(g, cur)
local items, i local items, i
if a then 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 items = a.list.items
local id = cur.base:sub(#scope_ + 2) local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do for n, item in ipairs(items) do
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
i = n + dir i = n + dir
end end
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 if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page) return false -- (leaving the list: the next element on the page)
end end
+122 -12
View File
@@ -40,17 +40,17 @@ local GOLD_HI = C.ready_hi
local LOSS = C.loss local LOSS = C.loss
local FLASH = C.flash local FLASH = C.flash
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true, local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
equip = true, hire = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = 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. -- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
local SOUND = { 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", 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", ["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
refine = "refine", collect = "collect", error = "error", 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) 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, -- 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. -- .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) function M:box(node)
blocks.push(self, "boxes", node) blocks.push(self, "boxes", node)
end end
@@ -831,6 +833,16 @@ local function index(list)
return m return m
end 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 -- After a response's state is applied (g: the game screen's self). kind: the action
-- name, or "get". -- name, or "get".
function M:on_response(g, kind, data, prev, st, opts) 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 if credits > 0 then
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") }) self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
elseif credits < 0 then 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 end
if quiet then 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. -- The rest needs the new layout's positions: after the next build.
self.pending[#self.pending + 1] = function() self.pending[#self.pending + 1] = function()
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready) self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
self:collected(g, refined, at)
end end
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) function M:box_of(key)
local b = self.binds[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 end
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready) function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
@@ -1005,13 +1120,8 @@ 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 }) { key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
end end
end end
if revealing then
self.waiting.revealed = self.waiting.revealed or {}
table.insert(self.waiting.revealed, spend)
else
spend() spend()
end end
end
if BUY_KINDS[k] then if BUY_KINDS[k] then
if at and not revealing then if at and not revealing then
+21 -10
View File
@@ -6,7 +6,8 @@
-- call again after the layout moves the node (it reads the node's -- call again after the layout moves the node (it reads the node's
-- screen rect). The node gets the "moon" material (listed in the -- screen rect). The node gets the "moon" material (listed in the
-- .gui's materials), which draws the rendered moon. -- .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.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.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 -- 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 -- (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). -- `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. -- 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). -- 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.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left 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.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 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. -- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false } 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)) gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe) local br = gui.get_screen_position(probe)
gui.delete_node(probe) gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5) -- The size is rounded once (not as the difference of two rounded corners), so it stays
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5) -- the same while the node moves by fractions of a pixel: every new size remakes the
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x) -- 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 end
local function set_node(node) local function set_node(node, quiet)
if node then if node then
local old = M.rect local old = M.rect
M.rect, M.unit = node_rect(node) M.rect, M.unit = node_rect(node)
local r = M.rect 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)) print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
end end
gui.set_material(node, "moon") gui.set_material(node, "moon")
@@ -98,9 +109,9 @@ function M.show(id, node)
end end
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes. -- 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 if node and (M.id or M.mode) then
set_node(node) set_node(node, quiet)
end end
end end
+315 -56
View File
@@ -53,6 +53,12 @@ function M.copy(st)
return copy(st, true) return copy(st, true)
end 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) local function find(list, id, key)
key = key or "id" key = key or "id"
for i, x in ipairs(list or {}) do for i, x in ipairs(list or {}) do
@@ -168,7 +174,7 @@ function M.tool(st, id)
return find(st.tools, id) return find(st.tools, id)
end 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) function M.idle_copy(st, id)
local t = find(st.tools, id) local t = find(st.tools, id)
if not t then if not t then
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
return t return t
end end
for _, x in ipairs(st.tools) do 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 return x
end end
end end
@@ -189,18 +195,162 @@ local function yield_boost(st, resource)
return b[resource] or 1 return b[resource] or 1
end 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. -- 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). -- 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) function M.add_stake(st, tool, moon_id, well, ctx, group)
local cycle = group and group.cycleSecs or tool.cycleSecs local cycle = group and group.cycleSecs or tool.cycleSecs
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource) local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
local committed = 0 -- take: what claiming it now would get, as the server's stake.take (its share after
for _, s in ipairs(st.stakes) do -- the other drills is derive's projection).
if s.wellId == well.id then local take = math.max(0, math.min(amount, M.supply(st, well.id)))
committed = committed + (s.take or 0)
end
end
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
local s = { local s = {
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id, 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, 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. -- Off the selected well's idle list.
if st.well then if st.well then
for i, g in ipairs(st.well.idle or {}) do 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 g.count = g.count - 1
if g.toolId == tool.id then if g.toolId == tool.id then
for _, x in ipairs(st.tools) do 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 g.toolId = x.id
break break
end end
@@ -260,7 +410,7 @@ function M.remove_stake(st, s)
if st.well and st.well.idle then if st.well and st.well.idle then
local g = nil local g = nil
for _, x in ipairs(st.well.idle) do 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 g = x
end end
end end
@@ -270,16 +420,17 @@ function M.remove_stake(st, s)
local free = M.supply(st, st.well.id) local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource) local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = { 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, 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 end
end end
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim -- 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) function M.derive(st)
local ready, idle, next_end = 0, 0, nil local ready, idle, next_end = 0, 0, nil
local per_moon = {} local per_moon = {}
@@ -309,39 +460,71 @@ function M.derive(st)
return return
end end
local mready, mnext = 0, nil 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 = {} local old = {}
for _, d in ipairs(w.drills or {}) do for _, d in ipairs(w.drills or {}) do
old[d.id] = d old[d.id] = d
end end
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0 local drills, wready, wnext = {}, 0, nil
for _, s in ipairs(st.stakes or {}) do 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 if s.wellId == w.id and (s.reach or 1) <= 1 then
local d = old[s.id] local d = old[s.id] or copy(s)
if not d then d.gets = p.gets[s.id] or 0
d = copy(s) d.here = d.gets
d.gets, d.here = s.take, s.take d.state = d.gets <= EPS and "none" or (d.gets < s.amount - EPS and "short" or "ok")
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
end
drills[#drills + 1] = d drills[#drills + 1] = d
demand = demand + (d.here or s.take or 0)
if s.ready then if s.ready then
wready = wready + 1 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 elseif not wnext or s["end"] < wnext then
wnext = s["end"] wnext = s["end"]
end end
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.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
w.demand = demand -- The well's projection fields, as the server words them (views.go WellRow).
w.remain = math.max(0, w.supply - demand) 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 mready = mready + wready
if wnext and (not mnext or wnext < mnext) then if wnext and (not mnext or wnext < mnext) then
mnext = wnext mnext = wnext
end end
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 if moon.wide then
mready = mready + (moon.wide.ready or 0) mready = mready + (moon.wide.ready or 0)
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then 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 -- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell). -- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx) M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {} local list = {}
for _, s in ipairs(st.stakes) do for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1 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)) local scope
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then if p["claim-wide"] then
if wide then scope = wide and s.moonId == p["claim-wide"]
return false -- multi-well drills: the server splits them 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 end
if done(s, ctx) and scope then
list[#list + 1] = s list[#list + 1] = s
end end
end end
if #list == 0 then if #list == 0 then
return false return false
end 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 for _, s in ipairs(list) do
local take = (s.take or 0) * M.skew local moon = find(st.moons, s.moonId)
M.add_item(st, s.resource, take) if (s.reach or 1) > 1 and moon then
M.add_supply(st, s.wellId, -(s.take or 0)) local sup = {}
M.remove_stake(st, s) 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
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
end end
end end
else 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
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 for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId) local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1] local w = wells(st, s.wellId)[1]
if tool and not tool.stakeId and w and w.supply > 0 then local group = nil
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId") 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) M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId) 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 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 return false -- "Deploy there": the view moves to the other well, wait for it
end end
local tool = M.idle_copy(st, p.tool) local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then if not tool then
return false return false
end end
local group = nil local group = nil
if st.well and st.well.id == w.id then if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do 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 group = g
end end
end 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) M.add_stake(st, tool, moon.id, w, ctx, group)
return true return true
end) end)
@@ -499,19 +721,54 @@ M.register("redrill", function(st, p, ctx)
if #tools == 0 then if #tools == 0 then
return false return false
end end
for _, t in ipairs(tools) do -- Until the well has no room left, like the server's redrill (game.go redrillPick):
local group = nil -- 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 if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then if same_copy(g, t) then
group = g return g
end end
end 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) M.add_stake(st, t, moon.id, w, ctx, group)
staked = staked + 1
end
if staked == 0 then
return false
end end
if detail then if detail then
detail.redrill = 0 detail.redrill = math.max(0, n - staked)
end end
return true return true
end) end)
@@ -602,7 +859,9 @@ M.register("refine", function(st, p, ctx)
return false return false
end end
local rr = st.rules.refinery 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, 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 } ["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
slot.job = job slot.job = job
+8
View File
@@ -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, 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, 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, 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. -- Minimum time between two plays of the same sound, in seconds.
@@ -96,6 +97,13 @@ function M.play(name, opts)
end end
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) local function semitones(n)
return 2 ^ (n / 12) return 2 ^ (n / 12)
end end
+200 -9
View File
@@ -7,7 +7,10 @@
-- Elements: every interactive component registers a stable id while the layout builds -- Elements: every interactive component registers a stable id while the layout builds
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>", -- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells -- 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. -- component given opts.id uses that instead.
-- --
-- Commands (args after the name): -- 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) -- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
-- eval <lua expr> evaluate, reply with the value -- eval <lua expr> evaluate, reply with the value
-- list the registered elements: id, kind, label, rect, enabled -- 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) -- state a summary of the shown state (view, wallet, counts, busy)
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key> -- 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) -- 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 if not M.enabled or not id then
return return
end 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 local n = 2
while elements[id .. "#" .. n] do while elements[id .. "#" .. n] do
n = n + 1 n = n + 1
@@ -194,10 +204,11 @@ local function visible(node)
return true return true
end end
-- A run of commands spread over frames: { frame_fn, ... }. -- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
local function steps(g, list, done) -- 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 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 end
-- mm.test_dir (drive.py passes its output folder): replies go to files there. -- 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() steps(g, list, function()
reply(cmd.id, { ok = true, id = e.id, x = x, y = y }) reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
end) end, cmd.id)
end end
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds. -- 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() input(k, { released = true }) end,
}, function() }, function()
reply(cmd.id, { ok = true }) reply(cmd.id, { ok = true })
end) end, cmd.id)
end end
function COMMANDS.type(g, cmd, input) function COMMANDS.type(g, cmd, input)
@@ -428,6 +439,182 @@ function COMMANDS.list(g)
return { ok = true, elements = out } return { ok = true, elements = out }
end 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) function COMMANDS.state(g)
return { ok = true, state = summary(g) } return { ok = true, state = summary(g) }
end end
@@ -512,9 +699,13 @@ function M.update(g, input, api)
-- Running multi-frame steps. -- Running multi-frame steps.
if g.test_steps and #g.test_steps > 0 then if g.test_steps and #g.test_steps > 0 then
local s = g.test_steps[1] local s = g.test_steps[1]
s.list[s.i]() local ok, err = pcall(s.list[s.i])
s.i = s.i + 1 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) table.remove(g.test_steps, 1)
if s.done then if s.done then
s.done() s.done()
+4 -4
View File
@@ -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; -- 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). -- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
@@ -49,7 +49,7 @@ M.palettes = {
loss = { "#ff8a96" }, loss = { "#ff8a96" },
teal = { "#2dd4bf" }, teal = { "#2dd4bf" },
violet = { "#b69cff" }, 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) stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
up = { "#3ee6a0" }, up = { "#3ee6a0" },
flash = { "#ffffff" }, -- peak of a gain flash or a scan line 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_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
aside_w_max = 580, aside_w_max = 580,
content_max = 960, -- widest a section's content gets (tables, rows) in the main area 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 market_panel_max = 460, -- tallest the market's detail and ticket panels get
text_max = 720, -- widest a paragraph gets 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_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) card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
panel_pad = 14, panel_pad = 14,
+111 -23
View File
@@ -1,6 +1,9 @@
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at -- Toasts for action results (the web's .toasts). Two places:
-- most 3, each fading out on its own after a few seconds. They live on their own layer -- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
-- node, outside the rebuilt layout, so a rebuild doesn't drop them. -- 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 -- 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 -- 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 MAX = 3
local LIFE = 3.5 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 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 TEXT_X, PAD_R, PAD_Y = 46, 14, 8
local GAP, MARGIN = 6, 16 local GAP, MARGIN = 6, 16
@@ -31,23 +35,21 @@ local KIND = {
redrill = { "cyan", "▼" }, redrill = { "cyan", "▼" },
upgrade = { "cyan", "▲" }, upgrade = { "cyan", "▲" },
["buy-tool"] = { "cyan", "+" }, ["buy-tool"] = { "cyan", "+" },
["buy-attachment"] = { "cyan", "+" },
["buy-seat"] = { "cyan", "+" }, ["buy-seat"] = { "cyan", "+" },
["buy-slot"] = { "cyan", "+" }, ["buy-slot"] = { "cyan", "+" },
equip = { "cyan", "▲" },
unequip = { "muted", "▼" },
hire = { "violet", "●" }, hire = { "violet", "●" },
refine = { "violet", "◆" }, refine = { "violet", "◆" },
collect = { "teal", "◆" }, collect = { "teal", "◆" },
["sell-moon"] = { "ready", "₥" }, ["sell-moon"] = { "ready", "₥" },
["sell-tool"] = { "ready", "₥" }, ["sell-tool"] = { "ready", "₥" },
["sell-attachment"] = { "ready", "₥" }, ["save-code"] = { "violet", "★" },
restore = { "violet", "★" },
error = { "danger", "!" }, error = { "danger", "!" },
} }
-- layer: a node over the layout; its top-left is the page's top-left. -- layer: a node over the layout; its top-left is the page's top-left.
function M.new(layer) function M.new(layer)
return setmetatable({ layer = layer, items = {}, h = 800 }, M) return setmetatable({ layer = layer, items = {}, h = 800, log = {} }, M)
end end
-- The page height, to place the stack at the bottom. -- 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, -- 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). -- flash (an additive overlay in the accent, lighter: the web's brightness filter).
local function pose(it, dx, sy, alpha, flash) 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 local c = it.fill
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha)) gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
local f = it.flash_color local f = it.flash_color
@@ -110,7 +112,7 @@ local function fade(self, it)
it.gone = true it.gone = true
anim.cancel(it.tween) anim.cancel(it.tween)
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease). -- 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) it.tween = anim.tween(it.body, 0.5, function(k)
local e = anim.fn.ease(k) local e = anim.fn.ease(k)
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0) 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)
end end
-- message: the server's text; kind: the action (toast.kind), "error" for errors. -- A toast's nodes under parent (root at 0, 0; the body w wide, mx from the left).
-- opts.color: the accent instead of the kind's (a claim: the resource's color). local function make(parent, e, w, mx)
function M:push(message, kind, is_error, opts) local k = KIND[e.is_error and "error" or e.kind] or { "cyan", "›" }
local k = KIND[is_error and "error" or kind] or { "cyan", "›" } local accent = e.color or k[1]
local accent = (not is_error and opts and opts.color) or k[1] local root = anim.group(parent, 0, 0)
local it = { root = root, accent = accent, mx = mx }
local root = anim.group(self.layer, 0, 0) local h = H
local it = { root = root, accent = accent }
local w, h = W, H
local made = ui.collect(function() local made = ui.collect(function()
-- The text first (wrapped at the toast's width), to size the toast. -- 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 style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
local line, th local line, th
local text_nodes = ui.collect(function() 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) end)
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque -- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
-- but not drawn, so the runs under them inherit alpha 1.) -- 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
end end
h = math.max(H, math.floor(th + PAD_Y * 2)) 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 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. -- 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.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
ui.outline(body, 0, 0, w, h, accent, 0.7) 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.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
it.fill = ui.color("field", 0.97) it.fill = ui.color("field", 0.97)
it.scale = gui.get_scale(it.body) 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 self.items[#self.items + 1] = it
while #self.items > MAX do while #self.items > MAX do
remove(self, self.items[1]) remove(self, self.items[1])
@@ -239,7 +328,6 @@ function M:push(message, kind, is_error, opts)
it.timer = nil it.timer = nil
fade(self, it) fade(self, it)
end) end)
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
end end
return M return M
+25 -6
View File
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks; -- 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", -- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price, -- "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. -- root, nodes, width.
function M.button(parent, label, x, y, opts) function M.button(parent, label, x, y, opts)
opts = opts or {} opts = opts or {}
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
local parts = { { text, M.text_width(text) } } local parts = { { text, M.text_width(text) } }
if opts.price then if opts.price then
local accent = opts.accent or v.price 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 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) local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
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] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 } parts[#parts + 1] = { sym, M.text_width(sym), 8 }
end
parts[#parts + 1] = { num, M.text_width(num), 1 } parts[#parts + 1] = { num, M.text_width(num), 1 }
end end
local content = 0 local content = 0
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
return return
end end
while #text > 1 do while #text > 1 do
-- (a cut entity like "&am" would show as text) -- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "") text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
gui.set_text(node, text .. "…") gui.set_text(node, text .. "…")
if M.text_width(node) <= max_w then if M.text_width(node) <= max_w then
return return
@@ -333,8 +337,13 @@ function M.dur(ms)
return string.format("%d:%02d", m, sec) return string.format("%d:%02d", m, sec)
end 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) 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 if secs < 60 then
return secs .. "s" return secs .. "s"
elseif secs % 60 == 0 then elseif secs % 60 == 0 then
@@ -343,6 +352,16 @@ function M.cycle(secs)
return string.format("%dm %ds", math.floor(secs / 60), secs % 60) return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
end 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) function M.plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s") return n .. " " .. word .. (n == 1 and "" or "s")
end end
+2
View File
@@ -83,6 +83,8 @@ void main()
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2); vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
normal = normalize(abs(det) * normal - grad); 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 V = normalize(cam_pos.xyz - var_world);
vec3 albedo = var_color; vec3 albedo = var_color;
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo); vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
+10 -4
View File
@@ -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 TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts 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_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 SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4)) 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 if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION params.resolution = WEB_RESOLUTION
end 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.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen) job.threaded = native.start(job.gen)
self.job = job self.job = job
@@ -251,7 +252,9 @@ end
local function job_done(self, job) local function job_done(self, job)
local res = new_buffer_resource(job.buffer) local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res) 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 if job.key == self.want_key then
set_mesh(self, job.key, res, job.params) set_mesh(self, job.key, res, job.params)
end end
@@ -449,6 +452,7 @@ function update(self, dt)
if #self.orphans > 0 then if #self.orphans > 0 then
step_orphans(self) step_orphans(self)
end 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 if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false f.visible = false
return return
@@ -565,10 +569,12 @@ function update(self, dt)
if shroud then if shroud then
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0) local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale) 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 for _, id in ipairs({ "shroud", "halo", "motes" }) do
go.set_position(pos, id) go.set_position(pos, id)
go.set_scale(sc, 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 end
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0) f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
else else
@@ -599,7 +605,7 @@ function update(self, dt)
end end
function on_input(self, action_id, action) 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 if not rect then
self.dragging = false self.dragging = false
return false return false
+98
View File
@@ -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
+5 -3
View File
@@ -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:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
c:gap(4) c:gap(4)
local why, why_color = hire_block(a) 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 local kids
if a.hired then if a.hired then
local seat = (a.seat or 0) > 0 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) tooltip.add(b, a.whyText)
end end
end, { width = bw }), 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) store.need_text(p, why, x + w, y + 10, why_color)
end) or nil, end) or nil),
} }
end end
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f) fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
+198 -261
View File
@@ -1,27 +1,29 @@
-- The aside (region, right column): the moon viewport (the selected moon in every -- 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 -- 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 -- well's facts (supply, what the drills leave, price, its drills and their times), the
-- there (a section module's aside / aside_below, see core/main.lua), always under the -- moon's when no well is picked, or what the section puts there (a section module's
-- viewport. -- 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 theme = require("modules.theme")
local ui = require("modules.ui") local ui = require("modules.ui")
local moon_view = require("modules.moon.moon") local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal") local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons") 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 panel = require("components.panel")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local bar = require("components.bar") local bar = require("components.bar")
local tooltip = require("modules.tooltip") local tooltip = require("modules.tooltip")
local row_c = require("components.row") local row_c = require("components.row")
local button = require("components.button")
local chip = require("components.chip") local chip = require("components.chip")
local anim = require("modules.anim")
local images = require("modules.images") local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local anim = require("modules.anim")
local S = theme.size local S = theme.size
@@ -53,7 +55,10 @@ local function viewport(g, parent, x, y, size, moon)
g.viewport_node = vp g.viewport_node = vp
g.viewport_rect = g.fx:rect_of(vp) g.viewport_rect = g.fx:rect_of(vp)
g.view_was_mystery = false 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). -- An unknown moon: the shroud and its "?" (reveal.lua).
moon_view.mystery(vp) moon_view.mystery(vp)
reveal.mystery_mark(vp, size) 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") return pick and pick.short ~= "" and pick.short or (n .. " wells")
end 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 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). -- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts) local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w) 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 M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", 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 -- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
-- yield here has an amber edge; one that would get nothing (the well is spoken for) is -- pieces for the foot of the well or moon facts.
-- also dimmed to .6 (.85 under the mouse) by a veil in the panel's color (app.css local function sale_kids(g, c, moon, inner, H)
-- .deploy-card.is-short / .is-spoken). The card is a button built at its width; its if not (g.state.unlocked.store and moon.sale) then
-- content is a tree solved inside it. return {}
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
end return {
local gets = fg.label(b, gets_s, M12, 16) space(0, 14),
gets.done = function(n) made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
tooltip.add(n, "What a cycle gets here after the drills already on it" space(0, 12),
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or "")) moon_sale.box(g, H, c, moon, inner),
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)
end end
-- A drill on the well (34 tall; it slides in when the drill was just deployed): -- 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 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) local tool = find(st.tools, d.toolId)
return made({ height = 34 }, function(p, x, y, inner) return made({ height = 34 }, function(p, x, y, inner)
local dr = ui.rect(p, x, y, inner, 34, "none") 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 end
local x0 = 36 local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8) 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) local head = { fg.node_label(nt, 16) }
-- Tags while they fit (and leave 50 for the time). if tag ~= "" then
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) } local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8 head[#head + 1] = space(6)
for _, a in ipairs(tool and tool.attachments or {}) do head[#head + 1] = fg.node_label(tt, 16)
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
end end
-- The amount it gets in ready when it comes up short (engine markup runs). -- 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 "" 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)
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. -- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H) local function well_panel(g, root, pad, top, inner, H)
local st = g.state 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(fg.label(c, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, 12))
add(space(0, 10)) add(space(0, 10))
else 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)) add(space(0, 8))
-- The well note in the server's words (the web's): the lead (over-drilled, fully -- 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 -- 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 if (wrow.noteText or "") ~= "" then
local runs = {} local runs = {}
if (wrow.statusText or "") ~= "" then if (wrow.statusText or "") ~= "" then
runs[#runs + 1] = { wrow.statusText .. ". ", "ready" } runs[#runs + 1] = { wrow.statusText .. ". ", "ready" }
end end
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" } 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) })) 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)) add(space(0, 4))
end end
end
add(fact(H, "Supply left", ui.money(wrow.supply))) add(fact(H, "Supply left", ui.money(wrow.supply)))
if (wrow.demand or 0) > 0 then local demand = (wrow.demand or 0) > 0
add(fact(H, "After current drills", ui.money(wrow.remain), { color = wrow.full and "ready" or "text" })) add(fact(H, "After current drills", ui.money(demand and wrow.remain or wrow.supply), { color = (demand and wrow.full) and "ready" or "text" }))
end
add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" })) add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" }))
-- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping. -- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping.
if #(st.bonuses or {}) > 0 then 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 = moon.wide
local wide_here = {} local wide_here = {}
if wide and #(wrow.wide or {}) > 0 then if wide and #(wrow.wide or {}) > 0 then
-- 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
for _, d in ipairs(wide.drills or {}) do for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then if contains(wrow.wide, d.id) then
wide_here[#wide_here + 1] = d local dd = {}
for k, v in pairs(d) do
dd[k] = v
end end
local take = p.take[d.id]
if take and wi then
dd.here = take[wi] or 0
end end
wide_here[#wide_here + 1] = dd
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)
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
end end
@@ -471,54 +321,25 @@ local function well_panel(g, root, pad, top, inner, H)
add(col({ gap = 2 }, lines)) add(col({ gap = 2 }, lines))
add(space(0, #lines > 0 and 10 or 8)) add(space(0, #lines > 0 and 10 or 8))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim; -- Idle drills that last mined it (Redrill on its card), and a better well for the
-- Redrill. Amounts move on as more drills finish on our clock (core/ready). -- idle drills (the server's pick), as text.
local claims = {} if (wrow.redrill or 0) > 0 then
local amount = ready.amount(g, wrow, wrow.supply) add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
if wrow.ready > 0 then add(space(0, 6))
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)
end end
if #wide_here > 0 then local better = w.better
local wamount = ready.amount(g, wide, wide.cap) if better and not dry then
if wide.ready > 0 then local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
claims[#claims + 1] = claim_row({ local name, tag = moons.tool_name(st, better.toolId)
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn) add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id }) .. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
end), add(space(0, 6))
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 end
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) local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c) fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do 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))) return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
end 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. -- A heading (h2) and a line under the viewport.
local function caption(c, x, y, inner, title, text) local function caption(c, x, y, inner, title, text)
fg.build(flex.solve(col({ width = inner }, { fg.build(flex.solve(col({ width = inner }, {
@@ -562,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
}), x, y), c) }), x, y), c)
end 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. -- 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 -- 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. -- 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) function M.build(g, parent, x, y, w, h, H, mod, row)
local st = g.state local st = g.state
local p = panel.new(parent, x, y, w, h) 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 section = g.view.section
local moon = section == "moons" and st.moon or nil local moon = section == "moons" and st.moon or nil
local cy 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) local vp = M.row_viewport(w)
viewport(g, root, pad, pad, vp, shown_moon(g)) viewport(g, root, pad, pad, vp, shown_moon(g))
-- The side panel gets the rest of the row as its own box. -- 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 end
gui.delete_node(box) gui.delete_node(box)
end end
if moon and st.well then if moon then
-- The well panel scrolls under the viewport (many deploy cards or drills). -- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
local top = cy - 8 -- viewport doesn't show keeps the panel and its moon.
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id) return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
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.")
elseif mod and mod.aside_below and st[section] then elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H) mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then elseif section == "new-moon" then
+2 -2
View File
@@ -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.g.view.section == "new-moon" and shot("11-new-moon") end,
function(a) return a:press("buy-moon") end, function(a) return a:press("buy-moon") end,
function(a) return a.g.reveal:card_done() and shot("12-reveal") 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). -- Buy another, straight from the card (its button registers as reveal-buy).
function(a) return a:press("buy-moon") end, 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.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 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, function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
+5 -9
View File
@@ -105,17 +105,13 @@ local STEPS = {
return j and j.ready and a:press("collect", function(p) return p.job == j.id end) return j and j.ready and a:press("collect", function(p) return p.job == j.id end)
end, end,
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") 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 go(a, "market") end,
function(a) return a.g.view.section == "leaderboard" and shot("10-leaderboard") 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 go(a, "help") end,
function(a) return a.g.view.section == "help" and shot("11-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. -- Start over: open its confirm, then run new-game (as its OK).
function(a) function(a) return a:press("confirm:new-game") end,
a.g.help_armed = a.g.h.now(a.g) + 30000 function(a) return a.g.dialog.root and shot("12-help-confirm") end,
a.g.dirty = true
return true
end,
function() return shot("12-help-confirm") end,
function(a) return a:press("new-game") end, function(a) return a:press("new-game") end,
function(a) return a.g.state.view.section ~= "help" or true end, function(a) return a.g.state.view.section ~= "help" or true end,
function() return shot("13-new-game") end, function() return shot("13-new-game") end,
+180 -23
View File
@@ -12,6 +12,8 @@ local scroll = require("components.scroll")
local button = require("components.button") local button = require("components.button")
local chip = require("components.chip") local chip = require("components.chip")
local stat = require("components.stat") local stat = require("components.stat")
local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local S = theme.size local S = theme.size
@@ -30,11 +32,12 @@ function M.compact(n)
local function one(f) local function one(f)
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", "")) return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
end 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" return one(n / 1e6) .. "M"
elseif a >= 1e4 then elseif a >= 1e4 then
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k" 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" return one(n / 1e3) .. "k"
end end
return ui.money(math.floor(n + 0.5)) return ui.money(math.floor(n + 0.5))
@@ -47,14 +50,13 @@ function M.pct(m)
end end
local EFFECT_WORDS = { local EFFECT_WORDS = {
attachment = { { "speed", "speed" }, { "yield", "yield" } },
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } }, 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) function M.effect(effect, kind)
local parts = {} 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 local m = effect and effect[e[1]] or 1
if m > 1 then if m > 1 then
parts[#parts + 1] = M.pct(m) .. " " .. e[2] parts[#parts + 1] = M.pct(m) .. " " .. e[2]
@@ -186,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
return w return w
end 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) 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 }) 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 return w, box
end 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 -- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
-- height used. -- height used.
function M.parts(st, parent, parts, x, y, w) 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
end }) end })
self.fx:button(id, b) 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 end
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>"). -- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
+2 -2
View File
@@ -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_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none") local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
gui.set_enabled(cross, false) gui.set_enabled(cross, false)
local rect
local shown = -1 local shown = -1
local function move(action) 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 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 and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
if not inside then if not inside then
+9 -7
View File
@@ -111,10 +111,13 @@ end
-- registered for name in modules/predict) the result shows at once and the button can be -- 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 -- clicked again right away (actions queue in order); without one the button shows a
-- spinner and ignores clicks until the server answers. -- spinner and ignores clicks until the server answers.
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. Returns the -- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
-- click function (a text field's Enter runs it too). -- take the clicks instead of b (a button whose visuals glide while its spot stays put).
function M.action(g, b, nodes, name, params, on_done, predictor, id) -- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
local key = button_key(name, params) -- 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 if g.inflight[key] then
button.set_busy(b) button.set_busy(b)
end end
@@ -130,7 +133,6 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
end end
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a -- 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. -- click anywhere on it counts from the first frame.
local hit
local r = name == "claim" and ready_at(g)[key] local r = name == "claim" and ready_at(g)[key]
if r and not anim.reduced and socket.gettime() - r.t < POP then if r and not anim.reduced and socket.gettime() - r.t < POP then
local p, size = gui.get_position(b), gui.get_size(b) 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 if g.autoplay then
local p = type(params) == "function" and params() or params local p = type(params) == "function" and params() or params
g.autoplay:register(name, p, fire) 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 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) end)
end end
+115 -38
View File
@@ -10,7 +10,9 @@
-- Druid instance and every layout node first. What must survive a rebuild lives on g -- 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 -- (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 -- 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 druid = require("druid.druid")
local theme = require("modules.theme") local theme = require("modules.theme")
@@ -36,11 +38,14 @@ local banner = require("screens.game.banner")
local header = require("screens.game.header") local header = require("screens.game.header")
local moonlist = require("screens.game.moonlist") local moonlist = require("screens.game.moonlist")
local aside = require("screens.game.aside") 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 reveal = require("screens.game.reveal")
local moon_view = require("modules.moon.moon") local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
local sfx = require("modules.sfx") local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready") local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css) 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"), moons = require("screens.game.moons"),
market = require("screens.game.market"), market = require("screens.game.market"),
refinery = require("screens.game.refinery"), refinery = require("screens.game.refinery"),
leaderboard = require("screens.game.leaderboard"),
help = require("screens.game.help"), help = require("screens.game.help"),
equipment = require("screens.game.equipment"), equipment = require("screens.game.equipment"),
store = require("screens.game.store"), store = require("screens.game.store"),
@@ -84,14 +88,16 @@ end
---------------------------------------------------------------- build ---------------------------------------------------------------- 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 -- 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 -- 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 -- 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). -- the first state and after the reveal closes (g.enter_all).
function enter(g) function enter(g)
local v = g.view 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 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 local all = g.enter_all or not g.entered
if g.reveal:hiding() then 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 -- 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 -- 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 -- 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) function M.aside_shown(g)
local st, section = g.state, g.view.section 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). -- 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 if section == "advisors" and g.advisor_pick and st.advisors then
for _, a in ipairs(st.advisors.advisors or {}) do 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 -- 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. -- section's own aside or the moon's well panel.
local ALWAYS = { "rules", "unlocked", "catalogVersion", "bonuses", "liveEvents", "showNav" } 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 = { local SECTION_KEYS = {
moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" }, moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" },
market = { "market" }, market = { "market", "leaderboard" },
refinery = { "refinery", "jobs", "refined", "credits", "resources" }, refinery = { "refinery", "jobs", "refined", "credits", "resources" },
leaderboard = { "leaderboard" },
help = { "help" }, help = { "help" },
equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" }, equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" },
store = { "store", "credits", "moonPrice", "resources", "refined" }, store = { "store", "credits", "moonPrice", "resources", "refined" },
advisors = { "advisors", "credits" }, 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" } 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 end
-- Drills and batches whose end passed on our clock (core/ready counts them before the -- Drills and batches whose end passed on our clock (core/ready counts them before the
-- refetch): the counts they show change with it. -- refetch): the counts they show change with it. kinds: the state lists to look at
local function ready_key(g) -- (default both).
local function ready_key(g, kinds)
local st, now = g.state, session.now(g) local st, now = g.state, session.now(g)
local out = {} 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 for _, x in ipairs(list) do
if not x.ready and x["end"] and x["end"] <= now then if not x.ready and x["end"] and x["end"] <= now then
out[#out + 1] = tostring(x.id or x.toolId) .. "@" .. x["end"] 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 -- What every region depends on besides the state: busy buttons, Claims that turned ready
-- on our clock, the toggles. -- on our clock, the toggles.
local function local_sig(g) local function local_sig(g, kinds)
return blocks.sig(g.inflight, g.ready_at, ready_key(g), sfx.muted(), anim.reduced, theme.name) 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 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. -- The reveal hides the main panel, the aside's content and the new moon's row.
local function reveal_sig(g) local function reveal_sig(g)
local r = g.reveal local r = g.reveal
@@ -238,10 +250,11 @@ local function reveal_sig(g)
end end
-- The section's block in the main panel. -- 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 st = g.state
local section = g.view.section 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 {})) pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder) blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do for _, m in pairs(M.SECTIONS) do
@@ -270,14 +283,25 @@ local function build_main(g, parent, x, y, w, h, common)
end end
-- The aside's block (narrow: the row under the panels). -- 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 st = g.state
local section = g.view.section 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, -- (on Moons with a moon the panel only shows the viewport: its facts are a block of
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS)) -- their own, below)
return blocks.keep(g, "aside", sig, parent, function(holder) 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) return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
end) 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 end
-- A full rebuild drops every node and component and builds them all again; otherwise the -- 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 g.layout_state = true
local st = g.state local st = g.state
local common = local_sig(g) 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. -- 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) return banner.build(g, g.h, holder, W)
end) 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) st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {} g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none") 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 pad, gap = S.body_pad, S.gap
local top = header_h + pad local top = header_h + pad
local body_h = H - top - 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) local has_aside = M.aside_shown(g)
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row -- 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 -- 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 end
local main_x = pad + (list_w > 0 and (list_w + gap) or 0) 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) 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 parent, y0, page = g.layout, top, nil
local row_h local row_h
if has_aside and narrow then if has_aside and narrow then
@@ -399,49 +453,62 @@ function M.build(g)
for _, m in ipairs(st.moons) do for _, m in ipairs(st.moons) do
hides[#hides + 1] = g.reveal:hides(m.id) or false hides[#hides + 1] = g.reveal:hides(m.id) or false
end 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, blocks.keep(g, "moonlist", blocks.sig(still, 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) 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 = {} g.moon_nodes = {}
moonlist.teardown(g) moonlist.teardown(g)
moonlist.build(g, g.h, holder, pad, y0, list_w, body_h) moonlist.build(g, g.h, holder, pad, y0, list_w, body_h)
end) end)
else else
g.moon_nodes = {} 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 end
build_main(g, parent, main_x, y0, main_w, body_h, common)
if page then 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) 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) moon_view.move(g.viewport_node) -- (where the page's offset put it)
g.viewport_rect = g.fx:rect_of(g.viewport_node) g.viewport_rect = g.fx:rect_of(g.viewport_node)
end 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 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 key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
local want = g.reveal:active() or (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors" local want = (v.drill or "") ~= "" or v.section == "advisors"
if s and want and key ~= g.page_key then if s and want and key ~= g.page_key then
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0)) s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
end end
g.page_key = key g.page_key = key
if s then 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() 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) moon_view.move(g.viewport_node)
g.viewport_rect = g.fx:rect_of(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) end)
end end
elseif has_aside then 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 else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
if not g.reveal:active() then -- (the reveal's window shows the moon)
moon_view.hide() moon_view.hide()
end end
end
if not page then if not page then
g.page_key, g.page_main = nil, nil g.page_key, g.page_main = nil, nil
end end
@@ -551,6 +618,10 @@ function input(g, action_id, action)
if action_id == TOUCH and action.released then if action_id == TOUCH and action.released then
button.release() -- (carried only while the mouse is down; after this Druid clicks) button.release() -- (carried only while the mouse is down; after this Druid clicks)
end 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). -- 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 if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
return true return true
@@ -614,6 +685,9 @@ function M.update(g, dt)
g.fx:update(dt) g.fx:update(dt)
g.reveal:update(dt) g.reveal:update(dt)
g.dialog: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) check_input(g)
tooltip.update(g.dialog:is_open()) tooltip.update(g.dialog:is_open())
focus.update(g, g.focus_layer) focus.update(g, g.focus_layer)
@@ -622,6 +696,9 @@ function M.update(g, dt)
m.update(g, dt) m.update(g, dt)
end end
end) end)
if g.admin then
admin.update(g, dt)
end
g.druid:update(dt) g.druid:update(dt)
if g.rehover then if g.rehover then
-- After a rebuild: what is under the mouse shows its hover at once (the new -- After a rebuild: what is under the mouse shows its hover at once (the new
+49
View File
@@ -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 return (st.moon and st.moon.id == moon_id and st.moon.ready or 0) + n
end 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). -- Finished refinery batches (the Refinery tab's badge).
function M.batches(g) function M.batches(g)
local now = session.now(g) local now = session.now(g)
@@ -92,7 +111,37 @@ function M.watch(g)
g.ready_due = M.next_due(g) g.ready_due = M.next_due(g)
end 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) function M.update(g)
watch_batches(g)
if g.ready_due and g.state and session.now(g) >= g.ready_due then if g.ready_due and g.state and session.now(g) >= g.ready_due then
g.ready_due = nil g.ready_due = nil
g.dirty = true g.dirty = true
+93 -21
View File
@@ -14,6 +14,8 @@ local api = require("modules.api")
local predict = require("modules.predict") local predict = require("modules.predict")
local sfx = require("modules.sfx") local sfx = require("modules.sfx")
local refinery = require("screens.game.refinery") local refinery = require("screens.game.refinery")
local sidepanel = require("screens.game.sidepanel")
local blocks = require("modules.blocks")
local M = {} local M = {}
@@ -61,8 +63,20 @@ end
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like -- 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). -- state.view). page only for the Past list past its first page (the web's data-page).
function M.view_params(v, extra) -- g: the blocks the right side panel shows are asked for too (sidepanel.includes), on
local p = { s = v.section ~= "" and v.section or nil } -- 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.moon and v.moon ~= "" then p.moon = v.moon end
if v.well and v.well ~= "" then p.well = v.well end if v.well and v.well ~= "" then p.well = v.well end
if v.past then p.list = "past" end if v.past then p.list = "past" end
@@ -71,8 +85,12 @@ function M.view_params(v, extra)
if v.drill and v.drill ~= "" then p.drill = v.drill end if v.drill and v.drill ~= "" then p.drill = v.drill end
if v.adv and v.adv ~= "" then p.adv = v.adv end if v.adv and v.adv ~= "" then p.adv = v.adv end
for k, x in pairs(extra or {}) do for k, x in pairs(extra or {}) do
if k == "include" and not (","..p.include..","):find(","..x..",", 1, true) then
p.include = p.include .. "," .. x
elseif k ~= "include" then
p[k] = x p[k] = x
end end
end
return p return p
end end
@@ -111,6 +129,9 @@ local function suspend(g)
g.refetch = nil g.refetch = nil
end end
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {} 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 } g.boot = { text = SUSPENDED }
if g.dialog and g.dialog:is_open() then if g.dialog and g.dialog:is_open() then
g.dialog:close("cancel") g.dialog:close("cancel")
@@ -118,24 +139,49 @@ local function suspend(g)
g.dirty = true g.dirty = true
end 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) local function arm_refetch(g)
if g.refetch then if g.refetch then
timer.cancel(g.refetch) timer.cancel(g.refetch)
g.refetch = nil g.refetch = nil
end end
local due = g.state.nextEnd -- (a time already past is skipped: the state we have is after it)
local batch = refinery.next_end(g.state) local now = M.now(g)
if batch and (not due or batch < due) then local due
due = batch 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 end
if not due then if not due then
return return
end end
local left = due - M.now(g) -- Ends within a second after it wait for the last of them: one refetch and rebuild,
if left < -2000 then -- not one each (four batches started together end a few ms apart).
return -- already past: the state we have is after it local first = due
local function join(t)
if t and t > due and t <= first + 1000 then
due = t
end 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 = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
g.refetch = nil g.refetch = nil
-- (a navigation still on its way: its view, or this answer would take it back) -- (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. -- What we show: the last server state (g.base) with the pending predictions on top.
local function show(g) 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 g.view = g.state.view
arm_refetch(g) arm_refetch(g)
g.dirty = true g.dirty = true
@@ -173,7 +220,9 @@ end
-- The views shown, like the web's browser history (main.js syncURL: every response whose -- 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 -- 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 VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
local function view_key(v) local function view_key(v)
@@ -200,7 +249,14 @@ local function remember(g, from_history)
if from_history then if from_history then
if h.list[from_history] then if h.list[from_history] then
h.list[from_history] = v h.list[from_history] = v
h.i = from_history
end end
if h.want == from_history then
h.want = nil
end
return
end
if h.want then
return return
end end
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then 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 elseif g.base then
show(g) -- rolled back show(g) -- rolled back
end 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 if status == 403 then
suspend(g) suspend(g)
g.inflight, g.busy, g.nav_view = {}, false, nil 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 -- the click; its answer only corrects the numbers (or rolls them back, with the
-- error's). -- error's).
-- A new game is a fresh start (the web reloads the page): no toast, no wallet -- A new game is a fresh start (the web reloads the page): no toast, no wallet
-- differences, the page enters again. -- differences, the page enters again. So is a restore from a save code (another
local fresh = applied and r.kind == "new-game" and data.ok -- 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 if fresh then
g.fx.counters = {} 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.fx:on_response(g, "get", data, nil, g.state, {})
g.enter_all = true g.enter_all = true
elseif g.state and g.state ~= prev then elseif g.state and g.state ~= prev then
local d = type(data) == "table" and data or { toast = { error = true } } 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 }) g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
end end
if revealing or fresh then if revealing or (fresh and r.kind == "new-game") then
-- (toast deferred, or none) -- (toast deferred, or none)
elseif type(data) == "table" and type(data.toast) == "table" then elseif type(data) == "table" and type(data.toast) == "table" then
local color = g.fx:take_claim_color() 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) sfx.play("error") -- (a rolled-back prediction had fx's error effects)
end end
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). -- Toast a failing server once, not every retry (M.fetch tries again).
g.offline = status == 0 or status >= 500 g.offline = status == 0 or status >= 500
for k, v in pairs(g.inflight) do 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, -- 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. -- doubling up to 30 s, until it answers; the shown state stays meanwhile.
function M.fetch(g, view, nav) 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 if g.retry then
timer.cancel(g.retry) timer.cancel(g.retry)
g.retry = nil g.retry = nil
@@ -354,6 +421,10 @@ function M.navigate(g, v)
sfx.play("nav") sfx.play("nav")
end end
g.nav_view = v 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) M.fetch(g, v, v)
end end
@@ -361,12 +432,13 @@ end
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go. -- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
function M.go(g, dir) function M.go(g, dir)
local h = history(g) 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 if not v then
return false return false
end end
h.i = h.i + dir h.want = i
local to = { from_history = h.i } local to = { from_history = i }
for k, x in pairs(v) do for k, x in pairs(v) do
to[k] = x to[k] = x
end end
@@ -380,7 +452,7 @@ end
function M.act(g, name, params, on_done, fn) function M.act(g, name, params, on_done, fn)
g.seq = g.seq + 1 g.seq = g.seq + 1
local seq = g.seq 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 p.clientSeq = seq
fn = fn or predict.registry[name] fn = fn or predict.registry[name]
local predicted = false local predicted = false
@@ -390,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
if predict.run(st, e) then if predict.run(st, e) then
g.pending[#g.pending + 1] = e g.pending[#g.pending + 1] = e
local prev = g.state local prev = g.state
g.state = st g.state = blocks.freeze(st)
g.dirty = true g.dirty = true
predicted = true predicted = true
-- The action's effects play now, from the predicted change (fx.lua). -- The action's effects play now, from the predicted change (fx.lua).
File diff suppressed because it is too large. Load diff
+112 -95
View File
@@ -18,7 +18,8 @@ local S = theme.size
local M = {} local M = {}
-- Header sections in the web's order; each shows once state.unlocked has it. -- 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 flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
@@ -37,8 +38,47 @@ local function contains(list, value)
return false return false
end end
-- The wallet at the right end; returns the x where it starts. -- The Refinery tab's badge pops in (fx-ready-pop, 0.6 s) when a batch has just finished
local function wallet(g, parent, W) -- (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 local st = g.state
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they -- 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 -- 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 end
table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } }) table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } })
end 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_w = math.floor(num_w + 16 + 32 + 4)
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36) 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") local bal_root = ui.part(bal, "panel", "root")
ui.text(bal_root, theme.currency, "sym", 16, 18) ui.text(bal_root, theme.currency, "sym", 16, 18)
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root) 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 for i = #st.resources, 1, -1 do
local r = st.resources[i] local r = st.resources[i]
local root, nodes = ui.clone("chip", parent, 0, 0) local root, nodes = ui.clone("chip", parent, 0, 0)
local t = ui.part(nodes, "chip", "text") 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)) 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)) gui.set_size(root, vec(w, 20))
push(root, w, 20, gap) if gap then
gap = 18 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")) gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
g.fx:bind(r.name, t, root) g.fx:bind(r.name, t, root)
tooltip.add(root, r.name) tooltip.add(root, r.name)
end 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) local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
flex.apply(tree) 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 return row[1].x - 10
end 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 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 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 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). -- Sections (tabs), as they unlock (none until Equipment, like the web).
local section = g.view.section 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) -- (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 count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
local badge = count > 0 and count or nil local badge = count > 0 and count or nil
if badge then -- The count is a small badge on the tab's top-right corner: it takes no width,
w = w + 26 -- so the tabs don't move when it comes or goes (clicker rule).
end
names[#names + 1] = name names[#names + 1] = name
nav_w = nav_w + w + 2 nav_w = nav_w + w + 2
tabs[#tabs + 1] = { style = { width = w, height = 44, shrink = 0 }, make = function(p, tx, ty) 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 gui.set_enabled(u, false) -- the ink draws the active tab's underline
local active = name == section local active = name == section
local shine = contains(st.shine, name) 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"))) gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
if active then if active then
ink:to(tx, ty, w, 44) 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) g.fx:reticle(root, w, 44, name)
end end
if badge then 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 end
tabs_c.tab(g, root, t, { tabs_c.tab(g, root, t, {
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. name, label = name, 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 }) H.navigate(g, { section = name, moon = g.view.moon })
end, end,
}) })
if name == "help" then
tooltip.add(root, "How to play")
end
g.tab_nodes[name] = root g.tab_nodes[name] = root
tabs_c.reveal(g, "nav", name, root, t, w, 44) tabs_c.reveal(g, "nav", name, root, t, w, 44)
end } 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). -- 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 {}) 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 n_ready = ready.drills(g)
local claim_all = st.unlocked["claim-all"] and (n_ready > 0 or st.nextEnd) local reserve = st.unlocked["claim-all"]
if claim_all then local claim_all = reserve and (n_ready > 0 or st.nextEnd)
nav_w = nav_w + 170 if reserve then
nav_w = nav_w + 10 + CLAIM_W
end 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 if st.ready > 0 then
local b, nodes = ui.button(p, "Claim all", cx, cy + 6, { primary = true, small = true }) local b, nodes = ui.button(p, "Claim all", cx, cy + 6, { primary = true, small = true })
H.badge(b, nodes, n_ready) 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 = {}, 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 }) wait = "Claim all", done = "Claim all", badge = n_ready > 0 and n_ready or nil })
end 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 }) 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. -- One row: brand, tabs and Claim all's place, the wallet. When the wallet doesn't fit
local fits = pad + brand_w + 28 + nav_w <= wallet_x -- with its chips in the row, the chips go on a line under the balance (the header grows
local header_h = fits and S.header_h or S.header_h + 40 -- 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 }, { local tree = col({ width = W, height = header_h }, {
row({ height = fits and header_h or S.header_h - 8, padding_left = pad, align = "start" }, { row({ height = fits and header_h or base_h - 8, padding_left = pad, align = "start" }, {
box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }), show_brand and box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }) or nil,
fits and space(28) or nil, fits and show_brand and space(28) or nil,
fits and col({ padding_top = 10 }, { nav }) or nil, fits and col({ padding_top = 10 }, { nav }) or nil,
}), }),
not fits and row({ padding_left = pad - 12 }, { nav }) or nil, not fits and row({ padding_left = pad - 12 }, { nav }) or nil,
+78 -50
View File
@@ -1,81 +1,109 @@
-- The Leaderboard section (state.leaderboard): the top miners by credits, and the -- The leaderboard (state.leaderboard): the top miners by credits in compact rows, and the
-- player's own place under them when they're further down (`you`). Centered, capped; the -- player's own row under them when they're further down (`you`). The rows scroll when the
-- rows scroll when the panel is too short for them (components/list). -- 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 ui = require("modules.ui")
local list = require("components.list") local list = require("components.list")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local S = theme.size
local M = {} 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"), -- The side panel's contract (sidepanel.lua).
-- credits at the right; the player's own row lit. 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 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) local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
if r.me then if r.me then
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan") ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
end end
ui.rect(bg, 0, ROW_H - 1, w, 1, "line") ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
local h = ROW_H fg.build(flex.solve(fg.row({ width = w, height = ROW_H, padding_right = PAD, align = "center" }, {
fg.build(flex.solve(fg.row({ width = w, height = h, padding_right = 14, align = "center" }, { fg.row({ width = 30, justify = "end", shrink = 0 }, {
fg.row({ width = 52, justify = "end", shrink = 0 }, { fg.label(bg, r.rank, { font = "mono", px = 12, color = r.rank <= 3 and "ready" or "muted" }, ROW_H),
fg.label(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, h),
}), }),
fg.space(24), fg.space(10),
fg.label(bg, r.name, { font = "sans", px = 15, color = "text" }, h, { fit = true, shrink = 1, max_width = w - 76 - 180 }), 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 }),
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.fill(), 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) }), 0, 0), bg)
end end
function M.build(self, root, px, py, w, h, H) -- The height the leaderboard takes with every row shown.
local lb = self.state.leaderboard local function natural(lb)
local avail = w - px * 2 return HEAD_H + math.max(#lb.top, 1) * ROW_H + (lb.you and ROW_H * 2 or 0) + PAD
local inner = math.min(avail, S.leaderboard_max) end
local x = px + math.floor((avail - inner) / 2)
local fix = function(b) b.style.shrink = 0; return b end -- The leaderboard's content in p (a panel's root) w x h: title, place, the rows (they
-- The list shrinks (and scrolls) when the panel is too short for every row; what's -- scroll when h is short). Without the state block yet (it comes with the next state:
-- under it stays in view. -- sidepanel.lua asks for it), the title and a placeholder.
local tree = fg.col({ width = inner, height = h - py * 2 }, { local function body(g, p, w, h)
-- (the line starts 6 into the heading's 44; 16 under it after the line's 6) local lb = g.state.leaderboard
fix(fg.col({ height = 38 }, { fg.label(root, "Leaderboard", "h1", 40, { shrink = 0 }) })), ui.text(p, "Top miners", "cap", PAD, 18)
fix(fg.text_para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".", if not lb then
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) })), ui.text(p, "Loading…", "small", PAD, 38)
fg.space(0, 22), return
fix(fg.row({ height = 16, align = "start" }, { end
fg.row({ width = 52, justify = "end", shrink = 0 }, { fg.label(root, "Rank", "cap", 12) }), local place = lb.me and ("Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players)) or (tostring(lb.players) .. " miners")
fg.space(24), fg.label(root, "Miner", "cap", 12), fg.fill(), ui.text(p, place, "small", PAD, 38)
fg.label(root, "Credits", "cap", 12), fg.space(14), local foot = lb.you and ROW_H * 2 or 0
})), local ly = HEAD_H
#lb.top > 0 and fg.made({ height = #lb.top * ROW_H, min_height = ROW_H }, function(p, lx, ly, lw, lh) local lh = math.max(ROW_H, math.min(h, natural(lb)) - HEAD_H - foot - PAD)
ui.rect(p, lx, ly, lw, 1, "edge") ui.rect(p, 0, ly - 1, w, 1, "edge")
if #lb.top > 0 then
local l local l
l = list.new(self, p, lx, ly, lw, lh, { l = list.new(g, p, 0, ly, w, lh, {
key = "leaderboard", row_h = ROW_H, key = "leaderboard", row_h = ROW_H,
create = function(r) create = function(r)
local holder = ui.rect(l.content, 0, 0, lw, ROW_H, "none") local holder = ui.rect(l.content, 0, 0, w, ROW_H, "none")
row(holder, r, 0, 0, lw) row(holder, r, 0, 0, w)
return holder return holder
end, end,
}) })
l:set_data(lb.top) 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), end
if lb.you then
-- (the web's "⋯": no font has it; three middle dots look the same) -- (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" }, { local fy = ly + lh
fg.label(root, "···", { font = "sans", px = 15, color = "muted" }, ROW_H), ui.text(p, "···", { font = "sans", px = 13, color = "muted" }, 30, fy + ROW_H / 2, gui.PIVOT_E)
})) or nil, row(p, lb.you, 0, fy + ROW_H, w)
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, end
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, end
fg.fill(),
}) -- As a side panel (sidepanel.lua): its content in root (the panel's), w x h.
fg.build(flex.solve(tree, x, py), root) 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 end
return M return M
+73 -57
View File
@@ -16,6 +16,7 @@ local ticker = require("screens.game.ticker")
local sale = require("screens.game.sale") local sale = require("screens.game.sale")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
local leaderboard = require("screens.game.leaderboard")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") 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) return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
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. -- 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 function card(self, H, c, w, h, q, on)
local st = self.state local st = self.state
local empty = q.balance <= 0 local empty = q.balance <= 0
local wide = w >= 150 local wide, big = card_size(w)
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 muted = empty and "faint" or "muted" local muted = empty and "faint" or "muted"
local value = credits_box(c, q.value or 0, { px = 10, color = muted }, 14) 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" }
local vw = value.style.width or { font = "mono_semi", px = 13, color = empty and "muted" or "text" }, 16, { fit = true, shrink = 1 })
local sub name.done = function(n)
if wide and w - 34 - vw - 20 > 50 then tooltip.add(n, (q.refined and (q.item .. " · Refined") or q.item)
sub = fg.label(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 14, .. (wide and "" or (" · worth ≈ " .. theme.currency .. ui.money(q.value or 0))))
{ 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
end 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 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 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 price = credits_box(c, q.price, { px = 13, color = "text_2" }, 14)
local lines -- Price (and the change, on a wide card) under the amount.
-- Price and change on one line when the card is wide, else on two. local lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(),
if wide then wide and change_box(c, q, 14, { px = 11 }) or nil }) }
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 spark_color = (q.change >= 0) and "up" or "danger" local spark_color = (q.change >= 0) and "up" or "danger"
local kids = { 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), 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 }, { space(0, 4),
sub, fg.fill(), fg.label(c, "≈", { font = "sym", px = 10, color = muted }, 14), space(3), value, 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, 5), space(0, 4),
-- (kept while the series is the same: it moves on with the 5-minute tick) -- (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) 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)
end), end),
} }
@@ -217,12 +224,13 @@ local function card(self, H, c, w, h, q, on)
end }) end })
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 function cards(self, H, c, inner, quotes, title)
local per = math.min(5, #quotes) local gap = 10
local gap = 8 local per = math.min(#quotes, (inner - gap * 4) / 5 >= 112 and 5 or 3)
local w = math.floor((inner - gap * (per - 1)) / per) 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 = {}, {} local cols, kids = {}, {}
for i = 1, per do for i = 1, per do
cols[i] = w 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 }) return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
end end
function M.build(self, root, px, py, w, h, H, opts) -- The market itself in w (its content centered in it); full_w: the ticker's width (the
opts = opts or {} -- 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 st = self.state
local mk = st.market 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 c = root
local inner = math.min(w - px * 2, theme.size.market_max) 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 local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
self.quote_nodes, self.quote_edges = {}, {} self.quote_nodes, self.quote_edges = {}, {}
-- Ticker tape across the panel (kept while the quotes are the same). -- 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 for i, q in ipairs(mk.quotes) do
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText } tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
end end
local function ticker_part(cy) local function ticker_part(cy)
return cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, w, tape), root, function(r) tick_bottom = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, full_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) 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)) H.navigate(self, quote_view(self, item))
end) end)
end) + 8 end) + 8
return tick_bottom
end end
-- Top mover and biggest drop: links to their quote; in the heading row when there's -- 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, show_refined and col({}, { space(0, 4), cards(self, H, c, inner, refined, "Refined items") }) or nil,
}) })
if not q then if not q then
if area then return cy, tick_bottom
area:finish(cy + 18)
end
return
end end
cy = cy + 6 cy = cy + 6
@@ -626,7 +627,7 @@ function M.build(self, root, px, py, w, h, H, opts)
if not empty then if not empty then
sell_now = H.action(self, b1, n1, "sell", function() sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item } 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
end, { width = half }), end, { width = half }),
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2) 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 }) 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) 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 -- 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. -- 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 if sale_n then
sale.bind(self, sale_n) -- (a rebuild while it plays) sale.bind(self, sale_n) -- (a rebuild while it plays)
end 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 end
function M.update(self) function M.update(self)
+51 -15
View File
@@ -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 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 -- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
-- market prices, × the factor, capped. -- 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 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) function M.box(self, H, c, moon, w)
local sale = moon.sale local sale = moon.sale
self.sale_open = self.sale_open or {} 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. -- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
local profit = sale.delta > 0 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") -- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
local summary = box({ height = 48 }, { 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) 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") local hit = ui.rect(p, x, y, hw, hh, "none")
self.druid:new_button(hit, function() 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)) gui.set_euler(chev, vmath.vector3(0, 0, -90))
end end
end), end),
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, { row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8), -- (the label and the offer keep their size: the delta gives way, cut to fit)
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8), (function()
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10), local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }), 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, fg.button(c, "Sell moon", sell_opts, function(b)
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)
if not sale.canSell and (sale.whyText or "") ~= "" then if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText) tooltip.add(b, sale.whyText)
end end
if sale.canSell then 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 if self.autoplay then
local function reg() local function reg()
if self.autoplay then if self.autoplay then
@@ -148,7 +179,12 @@ function M.box(self, H, c, moon, w)
}) })
end 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) local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
ui.outline(b, 0, 0, bw, bh, "edge") ui.outline(b, 0, 0, bw, bh, "edge")
self.fx:box(b) -- (the sale's sweep covers the panel) self.fx:box(b) -- (the sale's sweep covers the panel)
+470
View File
@@ -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
+105 -18
View File
@@ -1,7 +1,8 @@
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by -- 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 -- 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, -- (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 -- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
-- under it, the total on its tab. -- under it, the total on its tab.
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells -- 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 moon_row = require("widgets.moon_row")
local ready = require("screens.game.core.ready") local ready = require("screens.game.core.ready")
local tabs = require("components.tabs") local tabs = require("components.tabs")
local button = require("components.button")
local list_c = require("components.list") local list_c = require("components.list")
local row_c = require("components.row") local row_c = require("components.row")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local toasts = require("modules.toasts")
local M = {} local M = {}
@@ -260,6 +263,45 @@ function M.teardown(self)
self.ml_ghosts = nil self.ml_ghosts = nil
end 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) function M.build(self, H, parent, x, y, w, h)
local st = self.state local st = self.state
local v = self.view local v = self.view
@@ -269,6 +311,55 @@ function M.build(self, H, parent, x, y, w, h)
local inner = w - pad * 2 local inner = w - pad * 2
local kids = {} -- the panel, top to bottom (one tree; full width, rows padded) 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 -- 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. -- 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 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), fg.space(0, 12),
}) })
end 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) kids[#kids + 1] = fg.made({ grow = 1, shrink = 1 }, function(_, _, ly, _, lh)
rows_y, list_h = ly, lh rows_y, list_h = ly, lh
build_rows(find.text, ly) 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) kids[#kids + 1] = fg.space(0, 4)
end end
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel. -- The announcements pane at the panel's foot: room for the last 5 (modules/toasts lists
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal). -- them there), at most half the panel; the moon list gets the rest.
if st.unlocked["new-moon"] then kids[#kids + 1] = fg.space(0, 12)
kids[#kids + 1] = fg.space(0, 10) kids[#kids + 1] = fg.col({ height = math.min(math.floor(h / 2), 31 + toasts.LIST_H), padding = { 0, pad, 0, pad } }, {
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad } }, { fg.made({ height = 1 }, function(p, lx, ly, lw)
fg.button(root, "+ New moon", { price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" }, function(b, nodes) ui.rect(p, lx, ly, lw, 1, "line")
H.link(self, b, { section = "new-moon", moon = v.moon }) end),
local extra = { ui.part(nodes, "button", "edge") } fg.space(0, 10),
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do fg.label(root, "Announcements", "cap", 12), fg.space(0, 8),
if i > 1 then fg.made({ grow = 1, shrink = 1 }, function(p, px, py, pw, ph)
extra[#extra + 1] = part[1] -- (the price) self.toasts:attach(ui.rect(p, px, py, pw, ph, "none"), pw, ph)
end end),
end
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner, S.button_h, extra)
end, { width = inner }),
}) })
end
for _, k in ipairs(kids) do for _, k in ipairs(kids) do
k.style.shrink = k.style.shrink or 0 k.style.shrink = k.style.shrink or 0
end end
File diff suppressed because it is too large. Load diff
+207 -54
View File
@@ -1,5 +1,5 @@
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect -- 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: -- 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 -- 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 -- 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: -- components.bar needs core/ticks, which needs core/session, which needs this module:
-- required when used (by a name bob doesn't follow). -- required when used (by a name bob doesn't follow).
local BAR = "components.bar" local BAR = "components.bar"
local READY = "screens.game.core.ready"
local S = theme.size 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. -- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
function M.next_end(st) function M.next_end(st)
local due local due
@@ -80,7 +101,6 @@ local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- Styles (app.css). -- Styles (app.css).
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 } local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
local BODY14 = { font = "sans", px = 14, color = "text" } 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 MONO15 = { font = "mono", px = 15, color = "text" }
local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true } local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true }
local BTN_SM = 34 -- .btn-sm height local BTN_SM = 34 -- .btn-sm height
@@ -156,12 +176,45 @@ local function section_label(c, str, num)
}) })
end 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 ---------------------------------------------------------------- slot cards
-- The web's .bar: 8 px of 6 px cells (2 px apart) in a faint cyan, the fill masked into -- 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 -- the same cells. The fill follows a hidden probe the timer ticks (H.tick_bar); M.update
-- lights the cells from its width. -- 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) return { style = { height = 8 }, make = function(p, x, y, w)
local cells = {} local cells = {}
for cx = 0, w - 1, 8 do 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) 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 } cells[#cells + 1] = { node = ui.rect(p, x + cx, y, cw, 8, color), x = cx, w = cw }
end end
local bar = { cells = cells, full = job.ready } local bar = { cells = cells, full = ready, w = w, color = ui.color(color) }
if not job.ready then if not ready then
bar.probe = ui.rect(p, x, y, w, 8, "none") bar.probe = ui.rect(p, x, y, w, 8, "none")
H.tick_bar(self, bar.probe, job.start, job["end"], w) 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 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 self.rf_bars[#self.rf_bars + 1] = bar
end } end }
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 function light_cells(b)
local fw = b.full and math.huge or gui.get_size(b.probe).x local fw = b.full and math.huge or gui.get_size(b.probe).x
for _, c in ipairs(b.cells) do for _, c in ipairs(b.cells) do
@@ -193,12 +270,15 @@ function M.update(self)
for _, b in ipairs(self.rf_bars or {}) do for _, b in ipairs(self.rf_bars or {}) do
if not b.full then if not b.full then
light_cells(b) light_cells(b)
elseif b.sweep then
sweep_cells(b)
end end
end end
end end
function M.teardown(self) 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 end
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off). -- 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. -- 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 function inset_glow(p, w, h, color, alpha)
local d = 14 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 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) local n = ui.rect(p, 0, 0, e[3], d, color, alpha)
gui.play_flipbook(n, "fade") 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_position(n, vmath.vector3(e[1], -e[2], 0))
gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4]))) gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4])))
gui.set_blend_mode(n, theme.glow_blend()) gui.set_blend_mode(n, theme.glow_blend())
nodes[#nodes + 1] = n
end end
return nodes
end end
-- A panel frame (fill and 1 px edge), registered for the entrance and the action sweep. -- 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 end
-- A slot (.refinery-slot, at least 112 tall, padding 12 + border): idle, running or ready. -- 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 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 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 local kids
if not job then 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 else
local head = fg.label(c, "Slot " .. slot.n, "cap", 22) local head = fg.label(c, "Slot " .. n, "cap", 22)
local right local right
if ready then if ready then
right = { style = { height = 22 }, make = function(p, x, y, w, h) 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 } end }
else else
local left = function(ms) return ui.dur(ms) .. " left" end 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), 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 } }, { style = { grow = 1, min_height = ready and 8 or 0 } },
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes) 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, { width = "100%" }) or nil,
} }
end end
return col({ padding = 13, min_height = 112 }, kids), function(p, x, y, w, h) return col({ padding = 13, min_height = SLOT_H }, kids), function(p, x, y, w, h)
local f = frame(self, p, x, y, w, h, edge) outer = ui.rect(p, x, y, w, h, "none")
local f = frame(self, outer, 0, 0, w, h, edge)
if ready then 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 end
return f return f
end 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 }) return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
end 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). -- One recipe card (the web's .card.recipe).
local function recipe_card(self, H, c, st, rf, r) local function recipe_card(self, H, c, st, rf, r)
local color = item_color(st, r.item) 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 why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
local owned = (r.owned or 0) > 0 local owned = (r.owned or 0) > 0
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N · -- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
-- owned X" over the name. -- 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 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) local g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon") gui.play_flipbook(g, "pentagon")
@@ -370,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
end } end }
local head = row({ height = 43.3, gap = 12, align = "start" }, { local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }), col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, align = "start" }, { col({ grow = 1, shrink = 1, align = "start" }, {
row({}, { fg.label(c, "Tier " .. r.tier, "cap", 24),
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,
}),
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }), 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). -- 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 return n
end }) 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) local qty, input = qty_field(self, r, off, rf.maxBatch)
qty.style.width, qty.style.grow = nil, 1
local refine local refine
local form = row({ height = BTN_SM, gap = 6 }, { local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
qty,
btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then if off then
return tooltip.add(b, why) return tooltip.add(b, why)
end end
refine = H.action(self, b, nodes, "refine", function() refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1" local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q 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) end)
end, { grow = 1 }), local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
if off then if off then
return tooltip.add(b, why) return tooltip.add(b, why)
end end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" }) H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")") tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
-- Enter in the quantity refines (the web's form). end, { grow = 1 })
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } }) local form = col({ shrink = 0 }, {
end), 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) 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), col({ gap = 4 }, inputs),
space(0, 8), space(0, 8),
fill(), -- (the footer at the bottom: buttons line up across a row) 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 }, { 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) }), function(p, x, y, w, h)
-- The card (.card): edge brightens on hover (0.15 s); bound before the footer's -- 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 st = self.state
local rf = st.refinery local rf = st.refinery
self.rf_bars = {} self.rf_bars = {}
self.rf_slots = {} -- by batch id: { frame, bar } (fx.lua's batch_ready)
self.frames = self.frames or {} self.frames = self.frames or {}
local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge) local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge)
local c = area.content local c = area.content
@@ -492,36 +639,38 @@ function M.build(self, root, px, py, w, h, H)
end) or nil, 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). -- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
local slots = {} local slots = {}
for _, slot in ipairs(rf.slots) do local order = {}
local tree, make = slot_card(self, H, c, st, slot) 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 tree.make = make
slots[#slots + 1] = tree slots[#slots + 1] = tree
end end
if (rf.nextSlotPrice or 0) > 0 then if (rf.nextSlotPrice or 0) > 0 then
local b = buy_slot(self, H, c, rf) local b = buy_slot(self, H, c, rf)
b.style.min_height = 112 b.style.min_height = SLOT_H
slots[#slots + 1] = b slots[#slots + 1] = b
end end
-- Recipes (.cards). -- Recipes (.cards).
local recipes = {} local recipes = {}
counted = {}
for _, r in ipairs(rf.recipes) do for _, r in ipairs(rf.recipes) do
local tree, make = recipe_card(self, H, c, st, rf, r) local tree, make = recipe_card(self, H, c, st, rf, r)
tree.make = make tree.make = make
@@ -530,13 +679,17 @@ function M.build(self, root, px, py, w, h, H)
local page = col({ gap = 18, width = inner }, { local page = col({ gap = 18, width = inner }, {
heading, heading,
#chips > 0 and row({ wrap = true, column_gap = 16, row_gap = 8, align = "start" }, chips) or nil, slots_label(self, H, c, rf),
section_label(c, "Slots", rf.slotCount .. "/" .. rf.maxSlots),
flex.grid(inner, 200, 12, slots), flex.grid(inner, 200, 12, slots),
section_label(c, "Recipes"), section_label(c, "Recipes"),
flex.grid(inner, S.card_col_min, S.gap, recipes), flex.grid(inner, S.card_col_min, S.gap, recipes),
}) })
fg.build(flex.solve(page, x, y), c) 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) area:finish(page.y + page.h + py)
for _, b in ipairs(self.rf_bars) do for _, b in ipairs(self.rf_bars) do
light_cells(b) light_cells(b)
+362 -258
View File
@@ -6,20 +6,25 @@
-- the moon itself is known before the purchase. -- the moon itself is known before the purchase.
-- --
-- The reveal (reveal.new(g, layer), started with :start(data) by a buy-moon response's -- 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: -- `reveal`), all on its own layer over the dimmed page, in a window at the center of the
-- 1. Charge: the page dims around the viewport, the shroud charges up and shakes (moon.script). -- screen (the same size for every moon):
-- 2. Burst: when the moon's mesh is ready, a flash, sparks and a thump; the moon pops out. -- 1. Charge: the moon's stage (the 3D moon in its own node) shows the shroud charging up
-- 3. Card: name, class, the verdict and wells pop in one by one, then the badge stamps down -- (moon.script), the "?" trembling, a low rumble building.
-- (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet note). -- 2. Burst: when the moon's mesh is ready, a flash, sparks, the reveal boom and a decaying
-- 4. Back: the card flies into the moon list, the page comes back and the balance counts -- Perlin-noise shake of the window and the page; the moon pops out.
-- down (fx "revealed"), the toast shows. -- 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 -- 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. -- (g.reveal:hiding(), :hides(id)), and the aside's viewport leaves the 3D moon to the
-- A click or Space/Enter skips ahead (charge -> burst, card -> its end state), then closes; -- stage. A click or Space/Enter skips ahead (charge -> burst, drawer -> its end state),
-- Esc closes. "Buy another" (reveal.next: hidden while more moons are locked, disabled -- then closes; Esc closes. "Buy another" (reveal.next: hidden while more moons are locked,
-- with the server's reason when unaffordable) closes the card and buys the next moon, -- disabled with the server's reason when unaffordable) closes the window and buys the
-- whose response plays a new reveal. The card's buttons take keyboard focus (scope -- next moon, whose response plays a new reveal. The buttons take keyboard focus (scope
-- "reveal", modules/focus) once it's complete. -- "reveal", modules/focus) once the drawer is complete.
-- --
-- Times follow moon.revealing.t (the reveal clock, which mm.reveal_freeze can hold for -- 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. -- 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 tooltip = require("modules.tooltip")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local chip = require("components.chip")
local M = {} local M = {}
M.__index = M M.__index = M
@@ -41,8 +47,6 @@ local C = theme.color
local GOLD_HI = C.ready_hi local GOLD_HI = C.ready_hi
local FLASH = C.flash local FLASH = C.flash
local MIST = C.mist local MIST = C.mist
local CARD_W = 440
local CARD_PAD = 22
local function v3(x, y) local function v3(x, y)
return vmath.vector3(x, y, 0) 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) gui.animate(n, "color.w", 0.45, gui.EASING_INOUTSINE, 1.2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end 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 gap = 14
local bw = math.max(220, math.floor((inner - gap) / 3)) local grades = nm.grades or {}
local hw = inner - bw - gap local pick = st.moonGrade
local bh = 196 for _, e in ipairs(grades) do
local small = "small" if e.grade == g.nm_grade then pick = e.grade end
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) }
end end
local buy = panel_box({ width = bw, height = bh, padding = 18, shrink = 0 }, { local function range(lo, hi, money)
made({ position = "absolute", left = bw - 150, top = -40, width = 180, height = 150 }, function(p, x, y, w, h) local f = money and ui.money or tostring
gui.play_flipbook(ui.rect(p, x, y, w, h, "violet", 0.16), "glow") 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), end),
fg.label(root, "Claim price", "cap", 12), space(0, 18), col({ grow = 1, shrink = 1, gap = 4 }, {
credits(nm.price, { px = 34, color = "text", font = "mono_semi" }, 20), space(0, 16), row({ height = 18, align = "center", gap = 10 }, {
row({ height = 16, align = "center" }, left), credits(e.price, { px = 16, color = e.canAfford and "text" or "muted", font = "mono_semi" }, 18),
fg.fill(), why and fg.label(root, why, { font = "sans", px = 13, color = "ready" }, 18, { fit = true, shrink = 1 }) or nil,
fg.button(root, "Claim and reveal", { primary = true, disabled = not nm.canAfford }, function(b, nodes) }),
if not nm.canAfford then fg.label(root, info, { font = "sans", px = 13, color = "text_2" }, 16, { fit = true, shrink = 1 }),
tooltip.add(b, (nm.whyText or "") ~= "" and nm.whyText or "Not enough credits") -- (the web's title) }),
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 return
end end
-- The web's nm-glow: a breathing halo around the button. -- (then "+ New moon" follows the grade just bought: state.moonGrade)
local halo = ui.rect(b, -12, -12, bw - 36 + 24, S.button_h + 24, "cyan", 0.0) g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, include = "new-moon" },
gui.play_flipbook(halo, "glow") function() g.nm_grade = nil end, nil, "newmoon:grade:" .. e.grade)
gui.move_below(halo, ui.part(nodes, "button", "edge")) end, { width = BW }),
gui.animate(halo, "color.w", 0.35, gui.EASING_INOUTSINE, 1, 0, nil, gui.PLAYBACK_LOOP_PINGPONG) }, i > 1 and { make = function(p, x, y, w)
g.h.action(g, b, nodes, "buy-moon", { include = "new-moon" }) ui.rect(p, x + 16, y, w - 32, 1, "edge")
end, { width = bw - 36 }), 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
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). -- Hints: what the wells can hold, then the classes it could be.
local half = math.floor((hw - gap) / 2) local bw = math.max(220, math.floor((inner - gap) / 3))
local ww = half < 230 and hw or half local hw = inner - bw - gap
-- (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"
local dots = {} local dots = {}
for _, r in ipairs(nm.resources or {}) do 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) dots[#dots + 1] = made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, r) end)
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), fg.label(root, "Wells", "cap", 12), space(0, 12),
row({ height = 20, align = "start" }, { fg.label(root, "Any of these resources", { font = "sans", px = 14, color = "text_2" }, 20, { fit = true, shrink = 1 }),
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 }),
}),
space(0, 10), space(0, 10),
row({ gap = 4 }, dots), 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") ui.outline(p, x, y, w, h, "line_hi")
end }) end })
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), fg.label(root, "Its class could be", "cap", 12), space(0, 10),
row({ wrap = true, column_gap = 6, row_gap = 6 }, chips), 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?", 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) }), { font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) }),
space(0, 22), 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) fg.build(flex.solve(tree, px, py), root)
end end
@@ -309,77 +344,130 @@ local function name_letters(parent, name, x, y, px)
return letters, cx - x - tracking return letters, cx - x - tracking
end 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). -- Starts the reveal for a buy-moon response's `reveal` (toast: shown when it closes).
function M:start(data, toast) function M:start(data, toast)
if self.r then if self.r then
self:finish(true) self:finish(true)
end end
local g = self.g 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.r = r
self.bound, self.card_bound = nil, nil self.bound, self.card_bound = nil, nil
-- Reduced motion (reveal.js quick): no charge-up, no particles or shake, the card fades -- Reduced motion (reveal.js quick): no charge-up, slide, particles or shake; the window
-- in complete, silently; the moon only fades out of its shroud (moon.script). -- is laid out complete, the drawer fades in, silently; the moon only fades out of its
-- shroud (moon.script).
r.reduced = anim.reduced r.reduced = anim.reduced
moon_view.prefetch(data.moonId) -- (its mesh starts now, whether the viewport shows yet or not) if r.reduced then
if g.viewport_node then r.k = 1
moon_view.reveal(data.moonId, g.viewport_node)
if moon_view.revealing then
moon_view.revealing.reduced = r.reduced
end end
else -- On the layer: a full-screen click target, the dim, the window (shaken as a whole):
r.need_view = true -- (bought from a view without the aside: it shows on the rebuild) -- the drawer (under the stage: it slides out from behind it), the stage (halo, the moon
end -- node, edge, charge ring, the "?").
-- 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]
r.root = group(self.layer, 0, 0) r.root = group(self.layer, 0, 0)
gui.set_inherit_alpha(r.root, false) -- the layer node itself is transparent gui.set_inherit_alpha(r.root, false) -- the layer node itself is transparent
gui.set_color(r.root, vmath.vector4(1, 1, 1, 0)) 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.hit = node_box(r.root, 0, 0, 1, 1, vmath.vector4(0, 0, 0, 0))
r.dim = {} r.dim = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.86))
for i = 1, 4 do r.win = group(r.root, 0, 0)
r.dim[i] = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.82)) r.drawer = group(r.win, 0, 0)
end r.stage_n = group(r.win, 0, 0)
r.ring = node_box(r.root, 0, 0, 1, 1, rgba(MIST, 0), "outline") 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_slice9(r.ring, vmath.vector4(2, 2, 2, 2))
r.halo = node_box(r.root, 0, 0, 1, 1, rgba(C.violet, 0), "glow")
gui.set_blend_mode(r.halo, theme.glow_blend()) gui.set_blend_mode(r.halo, theme.glow_blend())
gui.move_below(r.halo, r.ring) r.vp = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.viewport, 1))
if g.view_was_mystery then local edge = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.line_hi, 0.8), "outline")
local vr = g.viewport_rect gui.set_slice9(edge, vmath.vector4(2, 2, 2, 2))
r.question = text(r.root, "?", { font = "mono_semi", px = math.floor(math.max(64, math.min(140, vr.w * 0.32))), color = MIST }, r.ring = node_box(r.stage_n, -2, -2, STAGE + 4, STAGE + 4, rgba(MIST, 0), "outline")
vr.x + vr.w / 2, vr.y + vr.h / 2, gui.PIVOT_CENTER) 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 r.question_scale = gui.get_scale(r.question).x
end gui.set_color(r.question, rgba(MIST, r.reduced and 0.85 or 0.5))
self:place() 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 if not r.reduced then
sfx.play("charge") sfx.play("rumble")
end end
self:bind() self:bind()
print("mm: reveal " .. tostring(data.name) .. " (" .. tostring(data.rating and data.rating.tier) .. ")") print("mm: reveal " .. tostring(data.name) .. " (" .. tostring(data.rating and data.rating.tier) .. ")")
end 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() function M:place()
local r = self.r local r = self.r
local g = self.g local g = self.g
local W, H = g.size[1], g.size[2] 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)) gui.set_size(r.hit, v3(W, H))
local rects = { gui.set_position(r.dim, v3(-80, 80)) -- (bigger than the screen: the shake moves it)
{ 0, 0, W, v.y }, { 0, v.y + v.h, W, H - v.y - v.h }, gui.set_size(r.dim, v3(W + 160, H + 160))
{ 0, v.y, v.x, v.h }, { v.x + v.w, v.y, W - v.x - v.w, v.h }, r.L = math.floor((W - drawer_w(r) - STAGE) / 2)
} r.T = math.floor((H - STAGE) / 2)
for i, q in ipairs(rects) do self:pose()
gui.set_position(r.dim[i], v3(q[1], -q[2])) end
gui.set_size(r.dim[i], v3(math.max(0, q[3]), math.max(0, q[4])))
-- 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 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 end
-- Druid components for the layer: once per Druid instance (the game screen's lives until -- Druid components for the layer: once per Druid instance (the game screen's lives until
@@ -419,23 +507,37 @@ function M:bind()
end end
if r.card and self.card_bound ~= g.druid then if r.card and self.card_bound ~= g.druid then
self.card_bound = g.druid 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 if r.done then
self:close() self:close()
else else
self:advance() self:advance()
end end
end)) end):set_animations_disabled(), r.close_btn))
if r.another_btn and r.another_ok then 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() self:buy_another()
end)) end):set_animations_disabled(), r.another_btn))
end end
end end
-- (every build resets these) -- (every build resets these)
if r.card then if r.card then
if g.autoplay then if g.autoplay then
g.autoplay:register("reveal-close", {}, function() self:close() end) 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 end
if r.another_btn and not r.another_ok then if r.another_btn and not r.another_ok then
tooltip.add(r.another_btn, r.another_why) tooltip.add(r.another_btn, r.another_why)
@@ -445,13 +547,6 @@ end
function M:after_build() function M:after_build()
if self.r then 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:place()
self:bind() self:bind()
end end
@@ -464,7 +559,7 @@ function M:card_t()
if not (mr and r.card_at) then if not (mr and r.card_at) then
return -1 return -1
end end
return mr.t - r.card_at + r.offset return mr.t - r.card_at + r.offset - (r.reduced and 0 or LEAD)
end end
-- Skip ahead: charge -> burst, card -> its end state; then close. -- Skip ahead: charge -> burst, card -> its end state; then close.
@@ -543,48 +638,72 @@ function M:on_burst()
local r = self.r local r = self.r
local g = self.g local g = self.g
r.stage = "burst" r.stage = "burst"
local v = r.v local cx, cy = r.L + drawer_w(r) / 2 * (1 + r.k) + STAGE / 2, r.T + STAGE / 2
local cx, cy = v.x + v.w / 2, v.y + v.h / 2
local colors = {} local colors = {}
for _, w in ipairs(r.data.wells or {}) do for _, w in ipairs(r.data.wells or {}) do
colors[#colors + 1] = theme.resource[w.resource] or C.cyan colors[#colors + 1] = theme.resource[w.resource] or C.cyan
end end
self:play("burst") sfx.stop("rumble")
self:play("moon", { delay = 80 }) self:play("reveal")
g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 90, 1.8) self:play("moon", { delay = 60, gain = 0.55 })
g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 40, 1.1) g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 110, 2.1)
g.fx:flash(cx, cy, math.max(v.w, v.h) * 0.75, 0.55) g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 50, 1.3)
r.shake_at = socket.gettime() g.fx:flash(cx, cy, STAGE * 0.8, 0.6)
-- The burst shakes the aside and the header (reveal.js, 340 ms of decaying noise). r.q_out = socket.gettime()
for _, n in ipairs({ g.aside_panel and g.aside_panel.root, g.header_node }) do if not r.reduced then
anim.noise_shake(n, 5, 0.34) -- 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 end
local mr = moon_view.revealing 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 end
---------------------------------------------------------------- the card ---------------------------------------------------------------- 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() function M:build_card()
local r = self.r local r = self.r
local g = self.g
local d = r.data 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 tier = d.rating and d.rating.tier or "fair"
local tc, thi = tier_colors(tier) local tc, thi = tier_colors(tier)
local big = tier == "great" or tier == "exceptional" local big = tier == "great" or tier == "exceptional"
local w = CARD_W local w, h = drawer_w(r), STAGE
local inner = w - CARD_PAD * 2 local inner = w - PAD * 2
-- The card: a group at its center (it scales from there), the body top-left inside. local body = group(r.drawer, 0, 0)
local card = group(r.root, 0, 0) node_box(body, 0, 0, w, h, rgba(C.field, 1))
local body = group(card, 0, 0)
local bg = node_box(body, 0, 0, w, 10, rgba(C.field, 0.97))
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 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)) 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)) gui.set_slice9(corners, vmath.vector4(10, 10, 10, 10))
local el = {} -- animated elements: { node, kind, delay, dur, base } 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 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 local bw, bh = math.max(88, math.ceil(ui.text_width(probe)) + 20), 46
gui.delete_node(probe) 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_style = { font = "sans", px = 13, color = thi }
local trait_boxes = {} local trait_boxes, trait_names = {}, {}
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do 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) local t = text(body, tr, trait_style, 0, 0)
ui.fit_text(t, inner - 16) 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) gui.delete_node(t)
if tx + tw > inner then
break
end end
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner) trait_boxes[#trait_boxes + 1] = fixed(tw, 22)
trait_names[#trait_names + 1] = tr
tx = tx + tw + 6
end
local _, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
local well_boxes = {} local well_boxes = {}
for i = 1, #(d.wells or {}) do for i = 1, MAX_WELLS do
well_boxes[i] = fixed(inner, 26) well_boxes[i] = fixed(inner, 22)
end 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 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 for _, n in pairs(close_nodes) do
gui.set_inherit_alpha(n, true) gui.set_inherit_alpha(n, true)
end end
@@ -627,7 +755,8 @@ function M:build_card()
local price = next_moon.price local price = next_moon.price
local ok = next_moon.canBuy == true local ok = next_moon.canBuy == true
local ab, an 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 for _, n in pairs(an) do
gui.set_inherit_alpha(n, true) gui.set_inherit_alpha(n, true)
end 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 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 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 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 } }, { local function solve(pad, gap)
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(8, 0), local row = B({ align = "center", height = hgt, padding = { 0, pad } }, {
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(8, 0), { 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 = 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 } }, { node = num, size = false, style = { width = ui.text_width(num), height = hgt } },
}) })
flex.solve(row, 0, 0) flex.solve(row, 0, 0)
aw = math.floor(row.w) 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 for _, k in ipairs(row.children) do
if k.node then if k.node then
gui.set_position(k.node, v3(k.x + (aw - row.w) / 2, -hgt / 2)) gui.set_position(k.node, v3(k.x + (aw - row.w) / 2, -hgt / 2))
@@ -659,26 +801,23 @@ function M:build_card()
end end
local has_class = d.kind and d.kind ~= "" and d.kind ~= d.name local has_class = d.kind and d.kind ~= "" and d.kind ~= d.name
local tree = B({ direction = "column", width = w, padding = CARD_PAD }, { local tree = B({ direction = "column", width = w, height = h, padding = PAD }, {
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(14), B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(10),
B({ height = 52, shrink = 0 }, { B({ height = 36, shrink = 0 }, {}, "name"), vspace(2),
B({ width = 52, height = 52, shrink = 0 }, {}, "planet"), fixed(14, 0), B({ height = 12, shrink = 0 }, {}, "class"), vspace(14),
B({ direction = "column", grow = 1 }, { B({ height = 36 }, {}, "name"), B({ height = 12 }, {}, "class") }),
}),
vspace(16),
B({ height = 56, shrink = 0 }, { B({ height = 56, shrink = 0 }, {
B({ width = bw, height = 52, shrink = 0 }, {}, "badge"), fixed(12, 0), B({ width = bw, height = 52, shrink = 0 }, {}, "badge"), fixed(12, 0),
B({ grow = 1, shrink = 1 }, {}, "head"), B({ grow = 1, shrink = 1 }, {}, "head"),
}), }),
vspace(14), vspace(12),
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(8), B({ height = 18, shrink = 0 }, {}, "worth"), vspace(10),
#trait_boxes > 0 and B({ direction = "column", shrink = 0 }, { #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), }) 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({ direction = "column", shrink = 0 }, well_boxes), vspace(12),
B({ height = theme.size.button_h, justify = "end", shrink = 0 }, { 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"), }, "foot"),
}) })
flex.solve(tree, 0, 0) 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) 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 } 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. -- 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 }
local nb = at.name local nb = at.name
local px = 30 local px = 30
local letters, nw = name_letters(body, d.name or "", nb.x, nb.y + nb.h / 2, px) 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 end
-- The verdict: badge (stars and tier), headline and sub. Placed now, shown last. -- 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 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") 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)) gui.set_slice9(bedge, vmath.vector4(2, 2, 2, 2))
local stars = d.rating and d.rating.stars or 0 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. -- Traits: chips in the tier's color.
local traits = {} 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 tb = trait_boxes[i]
local chip = group(body, tb.x, tb.y) local chip = group(body, tb.x, tb.y)
local t = text(chip, tr, trait_style, 8, 11) local t = text(chip, tr, trait_style, 8, 11)
@@ -772,17 +915,21 @@ function M:build_card()
traits[#traits + 1] = chip traits[#traits + 1] = chip
end end
-- Description. -- Description, cut to its room.
gui.set_position(desc, v3(at.desc.x, -at.desc.y)) 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 } el[#el + 1] = { node = desc, kind = "rise", delay = 0.52 }
-- Wells: they pop in one by one, their bars filling. -- Wells: they pop in one by one, their bars filling.
local wells = {} local wells = {}
local max = (d.maxSupply and d.maxSupply > 0) and d.maxSupply or 90 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 local bar_w = inner - bar_x - num_w - 10
for i, wl in ipairs(d.wells or {}) do for i, wl in ipairs(d.wells or {}) do
local wb = well_boxes[i] local wb = well_boxes[i]
if not wb then
break
end
local row = group(body, wb.x, wb.y) local row = group(body, wb.x, wb.y)
local full = wl.full local full = wl.full
node_box(row, 0, 7, 8, 8, theme.resource[wl.resource] or C.cyan) node_box(row, 0, 7, 8, 8, theme.resource[wl.resource] or C.cyan)
@@ -804,7 +951,7 @@ function M:build_card()
end) end)
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 local fb = at.foot
gui.set_position(foot, v3(fb.x, -fb.y)) gui.set_position(foot, v3(fb.x, -fb.y))
local fk = fb.children local fk = fb.children
@@ -820,24 +967,6 @@ function M:build_card()
test.register("reveal:buy", r.another_btn, "button", "Buy another") test.register("reveal:buy", r.another_btn, "button", "Buy another")
end end
test.set_scope("page") 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. -- The verdict lands last.
local n = #wells local n = #wells
@@ -852,9 +981,8 @@ function M:build_card()
local quiet_tier = tier == "poor" or tier == "fair" 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 } 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)) 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:at(hit, function()
self:verdict(tier, badge, card, h) self:verdict(tier)
end) end)
-- Complete once the foot is in: the next click closes it. -- Complete once the foot is in: the next click closes it.
self:at(r.card_end, function() self:at(r.card_end, function()
@@ -867,7 +995,7 @@ function M:build_card()
e.base = gui.get_position(e.node) e.base = gui.get_position(e.node)
e.scale = gui.get_scale(e.node) e.scale = gui.get_scale(e.node)
end 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 if r.reduced then
-- Complete at once: no typed name, pops, stamp or verdict sounds. -- Complete at once: no typed name, pops, stamp or verdict sounds.
r.events, r.shimmer, r.done = {}, nil, true r.events, r.shimmer, r.done = {}, nil, true
@@ -877,20 +1005,17 @@ function M:build_card()
end end
-- The badge stamps down: sounds and sparks by tier (reveal.js). -- 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 r = self.r
local g = self.g local fx = self.g.fx
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 }
if r.quiet then if r.quiet then
return return
end 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 if big then
self:play("burst", { rate = 1.5, gain = 0.5 }) self:play("burst", { rate = 1.5, gain = 0.5 })
self:play("shimmer", { delay = 60 }) 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 local t = self:card_t() + 0.08 + i * 0.07
self:at(t, function() self:at(t, function()
if not self.r.quiet then 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) end)
end end
@@ -928,29 +1053,9 @@ function M:verdict(tier, badge, card, h)
end end
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) function M:animate_card(t)
local r = self.r 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. -- Letters: rise, shrink from 1.4 and go from white to the text color.
for i, l in ipairs(r.letters) do for i, l in ipairs(r.letters) do
local lk = ease(prog(t, 0.18 + (i - 1) * 0.04, 0.26)) 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 if not r then
return return
end end
local mr = moon_view.revealing
if r.closing then if r.closing then
self:step_close() self:step_close()
return return
end end
if r.need_view then local mr = moon_view.revealing
return
end
if not mr then 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) self:finish(true)
return return
end end
local T = mr.t 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)))) 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 if r.stage == "charge" then
local k = r.reduced and 0 or math.min(1, T / 1.1) 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.ring, rgba(vmath.lerp(k, C.cyan, MIST), 0.9 * k * k))
gui.set_color(r.halo, rgba(C.violet, 0.55 * 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 if mr.stage ~= "charge" then
gui.set_color(r.ring, rgba(MIST, 0)) gui.set_color(r.ring, rgba(MIST, 0))
gui.set_color(r.halo, rgba(C.violet, 0)) gui.set_color(r.halo, rgba(C.violet, 0))
self:on_burst() self:on_burst()
end end
end end
if r.question then if r.question and r.q_out then
local q = math.min(1, T / 0.5) -- The burst blows the "?" away.
local s = r.question_scale * (r.reduced and 1 or (1 + 0.8 * q * q)) 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_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 if q >= 1 then
gui.delete_node(r.question) gui.delete_node(r.question)
r.question = nil r.question = nil
@@ -1067,6 +1176,9 @@ function M:update(dt)
self:build_card() self:build_card()
end end
if r.card then 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() local t = self:card_t()
self:run_events(t) self:run_events(t)
if self.r and r.reduced then if self.r and r.reduced then
@@ -1074,9 +1186,12 @@ function M:update(dt)
self:animate_card(math.huge) 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))) gui.set_color(r.card, vmath.vector4(1, 1, 1, math.min(1, (socket.gettime() - r.faded_at) / 0.2)))
elseif self.r then elseif self.r then
self:animate_card(self:card_t()) self:animate_card(t)
end end
end end
if self.r then
self:pose()
end
end end
function M:close() function M:close()
@@ -1086,40 +1201,27 @@ function M:close()
end end
if r.card and not r.done then if r.card and not r.done then
self:flush() self:flush()
r.k = 1
end end
r.closing = socket.gettime() r.closing = socket.gettime()
sfx.stop("rumble")
sfx.play("close") 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 end
-- The window fades and sinks away (reveal.js: down 30 px); the page comes back.
function M:step_close() function M:step_close()
local r = self.r 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)) gui.set_color(r.root, vmath.vector4(1, 1, 1, 1 - k * k))
if r.card and r.reduced then r.drop = r.reduced and 0 or 30 * k * k * k
gui.set_color(r.card, vmath.vector4(1, 1, 1, 1 - k)) -- (fades where it is) self:pose()
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
if k >= 1 then if k >= 1 then
self:finish(false) self:finish(false)
end end
end end
-- Ends the reveal: removes the layer and brings the page back. quick: no entrance (another -- 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) function M:finish(quick)
local r = self.r local r = self.r
if not r then if not r then
@@ -1145,7 +1247,9 @@ function M:finish(quick)
end end
g.fx:signal("revealed") g.fx:signal("revealed")
if r.buy_next then 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
end end
+122
View File
@@ -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
+169 -40
View File
@@ -1,61 +1,190 @@
-- Display settings (the header's display button): the UI scale (window pixels per layout -- The settings menu (the header's gear): a dropdown (components/dialog `menu`) under the
-- unit, times the display's own scale; the browser's zoom) and fullscreen. Both are saved -- gear, a text button per setting or action with its icon on the right: How to play (the
-- in the settings file (modules/screen). Keys: Ctrl + / Ctrl - / Ctrl 0, F11 (core/keys). -- 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 ui = require("modules.ui")
local screen = require("modules.screen") local screen = require("modules.screen")
local sfx = require("modules.sfx")
local theme = require("modules.theme")
local anim = require("modules.anim")
local M = {} 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) local function pct(s)
return string.format("%d%%", math.floor(s * 100 + 0.5)) return string.format("%d%%", math.floor(s * 100 + 0.5))
end end
function M.open(g) -- Icons, drawn in a 20x20 box at (x, y) of p.
g.dialog:open({ local ICONS = {
title = "Display", help = function(p, x, y)
w = 440, ui.outline(p, x, y, 20, 20, "nav")
h = 340, ui.text(p, "?", { font = "sans_semi", px = 14, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
build = function(d, box, w) end,
local x, y = 24, 64 scale = function(p, x, y)
local value ui.text(p, "A", { font = "sans_semi", px = 11, color = "nav" }, x + 5, y + 13, gui.PIVOT_CENTER)
local function show() ui.text(p, "A", { font = "sans_semi", px = 17, color = "nav" }, x + 14, y + 11, gui.PIVOT_CENTER)
ui.style_text(value, { font = "mono", px = 15, color = "text" }, pct(screen.ui_scale)) 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
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({
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) local function step(dir)
return function() return function()
screen.step_ui_scale(dir) screen.step_ui_scale(dir)
show() show()
end end
end end
ui.text(box, "UI scale", "cap", x, y + 6) 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) })
ui.text(box, "Bigger text and controls; the window keeps its size.", { font = "sans", px = 14, color = "muted" }, x, y + 26) 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) })
local by = y + 44 y = y + ROW_H
local _, _, mw = d:button(box, "−", x, by, { w = 40, id = "dialog:scale-down", on_click = step(-1) }) item(d, box, y, screen.fullscreen() and "Leave fullscreen" or "Fullscreen", "fullscreen", "dialog:fullscreen",
value = ui.text(box, "", { font = "mono", px = 15, color = "text" }, x + mw + 34, by + 18, gui.PIVOT_CENTER) close_then(screen.toggle_fullscreen), { disabled = not screen.can_fullscreen() })
show() y = y + ROW_H
local _, _, pw = d:button(box, "+", x + mw + 68, by, { w = 40, id = "dialog:scale-up", on_click = step(1) }) item(d, box, y, sfx.muted() and "Sound: off" or "Sound: on", "sound", "mute", close_then(function()
d:button(box, "Reset", x + mw + 68 + pw + 10, by, { id = "dialog:scale-reset", on_click = step(0) }) sfx.toggle_mute()
toggled()
y = by + 58 end))
ui.text(box, "Fullscreen", "cap", x, y + 6) y = y + ROW_H
ui.text(box, screen.can_fullscreen() and "F11 switches it too." or "Not available in this build.", item(d, box, y, theme.light and "Dark theme" or "Light theme", "theme", "theme", close_then(function()
{ font = "sans", px = 14, color = "muted" }, x, y + 26) theme.toggle()
local fnodes toggled()
local function label() end))
return screen.fullscreen() and "Leave fullscreen" or "Go fullscreen" 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 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,
}) })
end end
+90
View File
@@ -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
+10 -32
View File
@@ -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 -- 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). -- (the response's buyModal: up to 3 wells to deploy it on).
@@ -129,7 +129,8 @@ local function buy_modal(self, modal, item)
end end
-- A store card's footer, bw wide (a tree): a rule, the stats (30 tall), Buy (its price; -- 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) function M.buy_footer(f, bw, stats, price, short, why, bind)
local items = {} local items = {}
for _, it in ipairs(stats) do for _, it in ipairs(stats) do
@@ -141,7 +142,11 @@ function M.buy_footer(f, bw, stats, price, short, why, bind)
end), end),
fg.row({ height = 30, column_gap = 16 }, items), fg.row({ height = 30, column_gap = 16 }, items),
fg.button(f, "Buy", { primary = true, small = true, price = price, disabled = short }, bind, { width = bw }), 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) }), 0, 0), f)
end end
@@ -162,7 +167,7 @@ local function tool_card(self, H, st, c, t)
c:text(line, { font = "mono", px = 12, color = "muted" }) c:text(line, { font = "mono", px = 12, color = "muted" })
end end
local short = not t.canAfford 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, { M.buy_footer(f, bw, {
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned }, { "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"), }, 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)
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) function M.build(self, root, px, py, w, h, H)
local st = self.state local st = self.state
local sto = st.store local sto = st.store
@@ -212,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
local function cap(label, before) local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) }) return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end end
local attachments = #(sto.attachments or {}) > 0
y = fg.flow(c, x, y, inner, { y = fg.flow(c, x, y, inner, {
fg.col({}, { fg.col({}, {
fg.label(c, "Store", "h1", 40), fg.space(0, 4), 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) }), { font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
fg.space(0, 20), 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) tool_card(self, H, st, cd, t)
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared }) end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
end, 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) area:finish(y + py)
end end
-12
View File
@@ -8,8 +8,6 @@
- characters the fonts lack: non-ASCII characters in Lua string literals and in a - 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 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 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 - 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, over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors script errors
@@ -231,15 +229,6 @@ def check_scenario(api):
check_log(os.path.join(ROOT, "dist/drive/check/game.log")) 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(): def check_flex():
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE, r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True) stderr=subprocess.STDOUT, text=True)
@@ -257,7 +246,6 @@ def main():
check_lua() check_lua()
check_flags() check_flags()
check_flex() check_flex()
check_moon()
check_fonts(api) check_fonts(api)
if "--static" not in sys.argv: if "--static" not in sys.argv:
check_scenario(api) check_scenario(api)
+42 -3
View File
@@ -11,6 +11,8 @@
first, then the game's gui_script (its first, then the game's gui_script (its
on_reload rebuilds the screen; modules/hot.lua) on_reload rebuilds the screen; modules/hot.lua)
(--all: every Lua module again) (--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 stop stop it (by its pid)
scripts/dev.py status scripts/dev.py status
@@ -61,11 +63,14 @@ def read(name, default=None):
def alive(pid): 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: try:
os.kill(int(pid), 0) with open("/proc/%d/cmdline" % int(pid), "rb") as f:
return True cmd = f.read()
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
return False return False
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
def snapshot(): def snapshot():
@@ -86,7 +91,7 @@ def post(port, path, body):
return urllib.request.urlopen(req, timeout=5).read() return urllib.request.urlopen(req, timeout=5).read()
def start(args): def start(args, fg=False):
os.makedirs(DEV, exist_ok=True) os.makedirs(DEV, exist_ok=True)
pid = read("pid") pid = read("pid")
if pid and alive(pid): if pid and alive(pid):
@@ -125,6 +130,9 @@ def start(args):
except FileNotFoundError: except FileNotFoundError:
pass pass
log = os.path.join(DEV, "game.log") log = os.path.join(DEV, "game.log")
if fg:
run_fg(cmd, env, log)
return
with open(log, "w") as f: with open(log, "w") as f:
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT, proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
start_new_session=True) 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)) 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): def reload(everything=False):
port, pid = read("port"), read("pid") port, pid = read("port"), read("pid")
if not port or not alive(pid): if not port or not alive(pid):
@@ -227,6 +264,8 @@ def main():
cmd = sys.argv[1] cmd = sys.argv[1]
if cmd == "start": if cmd == "start":
start(sys.argv[2:]) start(sys.argv[2:])
elif cmd == "run":
start(sys.argv[2:], fg=True)
elif cmd == "reload": elif cmd == "reload":
reload("--all" in sys.argv) reload("--all" in sys.argv)
elif cmd == "stop": elif cmd == "stop":
+51 -8
View File
@@ -11,18 +11,19 @@ Usage:
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
with run(game, args). with run(game, args).
Options: 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) --size WxH window and display size (default 1280x800)
--out DIR screenshots and game.log (default dist/drive/<scenario>) --out DIR screenshots and game.log (default dist/drive/<scenario>)
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default --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 --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 (`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY) from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log) --log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable) --config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api, which must --fixture make a test player first (scripts/fixture.py against --api) and play as
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture it; the scenario gets its ids as game.fixture
--fixture-file F use the ids saved in F (a fixture.py output) instead --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 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.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.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
game.lua(src) (raw run_script, <1 KB). 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 argparse
import importlib.util import importlib.util
@@ -48,6 +53,8 @@ import urllib.request
HERE = os.path.dirname(os.path.realpath(__file__)) HERE = os.path.dirname(os.path.realpath(__file__))
ROOT = os.path.dirname(HERE) ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ui_lint # noqa: E402
def varint(n): def varint(n):
@@ -86,6 +93,7 @@ class Game:
self.window_id = None self.window_id = None
self.test_dir = None # where the game writes its replies (mm.test_dir) 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.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 # ------------------------------------------------------------ transport
def lua(self, src): def lua(self, src):
@@ -173,6 +181,37 @@ class Game:
def state(self): def state(self):
return self.ok(self.cmd("state"), "state")["state"] 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): def set(self, k, v):
return self.ok(self.cmd("set", k, v), "set " + k) return self.ok(self.cmd("set", k, v), "set " + k)
@@ -343,7 +382,7 @@ def main():
ap = argparse.ArgumentParser(add_help=True) ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("scenario") ap.add_argument("scenario")
ap.add_argument("rest", nargs="*") 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("--size", default="1280x800")
ap.add_argument("--out") ap.add_argument("--out")
ap.add_argument("--bundle", action="store_true") ap.add_argument("--bundle", action="store_true")
@@ -354,6 +393,9 @@ def main():
ap.add_argument("--fixture", action="store_true") ap.add_argument("--fixture", action="store_true")
ap.add_argument("--fixture-file") ap.add_argument("--fixture-file")
args = ap.parse_args() 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: if args.attach:
disp = os.path.join(ROOT, "dist/dev/display") disp = os.path.join(ROOT, "dist/dev/display")
@@ -375,6 +417,10 @@ def main():
try: try:
code = subprocess.call([sys.executable] + sys.argv, env=env) code = subprocess.call([sys.executable] + sys.argv, env=env)
finally: finally:
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.terminate()
xvfb.wait() xvfb.wait()
sys.exit(code) sys.exit(code)
@@ -390,9 +436,6 @@ def main():
if not args.attach: if not args.attach:
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
elif args.fixture: 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])) fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
print("fixture player", fixture["player"]) print("fixture player", fixture["player"])
+7 -7
View File
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...} Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
that scenarios (scripts/drive.py) use for views. The player: every section unlocked, 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, 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. --plain: just a new player (nothing granted), for the first-moon views.
""" """
import json import json
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance", for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]: "Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
admin("resources", resource=r, amount=400) admin("resources", resource=r, amount=400)
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]: for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
admin("tool", item=item, tier=tier) admin("tool", item=item, **{"yield": yl, "cycle": cl})
act("buy-moon") act("buy-moon")
d = 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[2][0]["id"], target=wells[1 % len(wells)]["id"])
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"]) act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
act("buy-attachment", item="Turbo Core") # The idle PD-1 a level up on each track (its yield track twice).
act("buy-attachment", item="Diamond Bit")
act("buy-attachment", item="Diamond Bit")
idle = by_item[1][1]["id"] 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("buy-slot")
act("refine", recipe="Dusk Alloy", qty=5) act("refine", recipe="Dusk Alloy", qty=5)
-143
View File
@@ -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);
+2 -2
View File
@@ -26,7 +26,7 @@ def run(game, args):
game.navigate("{section='new-moon'}") game.navigate("{section='new-moon'}")
game.idle() game.idle()
n0 = game.eval("#st.moons") n0 = game.eval("#st.moons")
game.click("act:buy-moon?include=new-moon") game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game) card_done(game)
first = game.eval("g.reveal.r.data.moonId") first = game.eval("g.reveal.r.data.moonId")
els = [e["id"] for e in game.elements()] 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") admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='new-moon'}") game.navigate("{section='new-moon'}")
game.idle() game.idle()
game.click("act:buy-moon?include=new-moon") game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game) card_done(game)
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()") 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) game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
+1 -1
View File
@@ -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 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.""" close are focusable. Shots of each."""
+8 -7
View File
@@ -1,13 +1,14 @@
"""The confirm dialog (needs --fixture): open it from the drill panel's spare attachment """The confirm dialog (needs --fixture): open it from the refinery's buy-slot button
Sell, shots mid-open and open, Esc cancels, open again, OK sells (a toast).""" (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): 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): for _ in range(3):
game.scroll_to("confirm:sell-attachment") game.scroll_to("confirm:buy-slot")
game.idle() game.idle()
game.click("confirm:sell-attachment") game.click("confirm:buy-slot")
if shot: if shot:
game.shot(shot, settle=0.08) game.shot(shot, settle=0.08)
shot = None shot = None
@@ -21,7 +22,7 @@ def open_sell(game, shot=None):
def run(game, args): def run(game, args):
fx = game.fixture fx = game.fixture
game.idle() game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"]) game.navigate("{section='refinery'}")
game.idle() game.idle()
open_sell(game, "dialog-opening") open_sell(game, "dialog-opening")
game.shot("dialog-open", settle=0.6) game.shot("dialog-open", settle=0.6)
@@ -32,5 +33,5 @@ def run(game, args):
game.sleep(0.5) game.sleep(0.5)
game.click("dialog:ok") game.click("dialog:ok")
game.wait_for("not g.dialog.root and not g.busy") 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) game.shot("dialog-after", settle=0.3)
+7 -6
View File
@@ -1,7 +1,7 @@
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch """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; directory: it saves the settings file): the header's gear opens the settings menu;
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the + / - 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 menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
silent while the window is hidden.""" silent while the window is hidden."""
@@ -9,9 +9,9 @@ def run(game, args):
pre = (args[0] + "-") if args else "" pre = (args[0] + "-") if args else ""
game.idle() game.idle()
W0 = game.eval("g.size[1]") W0 = game.eval("g.size[1]")
game.click("display") game.click("settings")
game.wait_for("g.dialog:is_open()", timeout=5) 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.click("dialog:scale-up") game.click("dialog:scale-up")
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5) 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]") W1 = game.eval("g.size[1]")
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1)) 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.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.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
game.key("key_esc") game.key("key_esc")
game.wait_for("not g.dialog:is_open()", timeout=5) game.wait_for("not g.dialog:is_open()", timeout=5)
+67
View File
@@ -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))
+13
View File
@@ -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")
+73
View File
@@ -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)
+5 -4
View File
@@ -44,13 +44,14 @@ def run(game, args):
game.wait_for("not g.dialog:is_open()", 5) game.wait_for("not g.dialog:is_open()", 5)
game.idle() game.idle()
# A confirm: OK has the focus, Enter sells (after a mouse click). # A confirm: OK has the focus, Enter buys (after a mouse click). (Sells ask in place:
game.navigate("{section='equipment', drill='%s'}" % game.fixture["tool_idle"]) # sellask.py.)
game.navigate("{section='refinery'}")
game.idle() game.idle()
game.scroll_to("confirm:sell-attachment") game.scroll_to("confirm:buy-slot")
game.idle() game.idle()
for _ in range(3): for _ in range(3):
game.click("confirm:sell-attachment") game.click("confirm:buy-slot")
try: try:
game.wait_for("g.dialog:is_open()", 6) game.wait_for("g.dialog:is_open()", 6)
break break
+41
View File
@@ -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)
+6 -6
View File
@@ -45,12 +45,12 @@ def alive(game, what):
def open_confirm(game, fx): def open_confirm(game, fx):
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"]) game.navigate("{section='refinery'}")
game.idle() game.idle()
game.scroll_to("confirm:sell-attachment") game.scroll_to("confirm:buy-slot")
game.idle() game.idle()
for _ in range(3): for _ in range(3):
game.click("confirm:sell-attachment") game.click("confirm:buy-slot")
for _ in range(30): for _ in range(30):
if game.eval("g.dialog:is_open()"): if game.eval("g.dialog:is_open()"):
game.sleep(0.4) # (opened: its animation done) game.sleep(0.4) # (opened: its animation done)
@@ -61,10 +61,10 @@ def open_confirm(game, fx):
def open_display(game): 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): for _ in range(3):
game.idle() game.idle()
game.os_click("display") game.os_click("settings")
for _ in range(15): for _ in range(15):
if game.eval("g.dialog:is_open()"): if game.eval("g.dialog:is_open()"):
game.sleep(0.4) game.sleep(0.4)
@@ -210,7 +210,7 @@ def run(game, args):
for how in ("close", "esc"): for how in ("close", "esc"):
game.navigate("{section='new-moon'}") game.navigate("{section='new-moon'}")
game.idle() 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.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4) game.sleep(0.4)
if how == "close": if how == "close":
+52
View File
@@ -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)
+9 -16
View File
@@ -2,7 +2,7 @@
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER, 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 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 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 json
import os import os
import subprocess import subprocess
@@ -98,9 +98,9 @@ def run(game, args):
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2]) burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
game.idle() game.idle()
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake # Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
# from the well panel (the new drill enters in the table and the panel), then the # its card's slot (the new drill enters in the card and the well panel), then the slot
# other cards come up short or spoken for. # comes up short ("Only +N left here"), then "No room here" once nothing is left.
game.navigate("{section='moons', moon='%s'}" % fx["moon"]) game.navigate("{section='moons', moon='%s'}" % fx["moon"])
game.idle() game.idle()
if game.eval("st.ready") > 0: 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.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle() game.idle()
game.shot("deploy") game.shot("deploy")
# (ids from the state: the elements reply can be long) slot = "act:stake?target=%s" % well
def card(i): first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i)) game.expect(first, "the well's deploy slot is there")
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)
if first: if first:
game.scroll_to(first)
game.click(first) game.click(first)
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2]) burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
game.idle() game.idle()
game.shot("deploy-short", settle=0.5) 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) psql("update resource_well set supply = 2 where id = '%s'" % well)
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well)) game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle() game.idle()
game.shot("deploy-spoken", settle=0.5) 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)
+66
View File
@@ -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)
+8 -8
View File
@@ -1,8 +1,8 @@
"""The moon view and well panel in the web's look (needs --fixture and a local dev server """The moon view and well panel (needs --fixture and a local dev server whose database the
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
sale opened (its lines scroll with the moon). Args: an optional name prefix.""" with the moon). Args: an optional name prefix."""
import os import os
import sys import sys
@@ -19,13 +19,13 @@ def run(game, args):
game.idle() game.idle()
game.shot(pre + "table", settle=0.8) game.shot(pre + "table", settle=0.8)
ids = [e["id"] for e in game.elements()] ids = [e["id"] for e in game.elements()]
wells = [w for w in fx["wells"] if ("manage:" + w) in ids] wells = [w for w in fx["wells"] if ("well:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"]))) 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. # Drills that get nothing: six more on a well, then the well holds almost nothing.
well = fx["wells"][1 % len(fx["wells"])] well = fx["wells"][1 % len(fx["wells"])]
for _ in range(6): 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"] 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] idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
for tid in idle: for tid in idle:
+8 -7
View File
@@ -1,13 +1,13 @@
"""Parity polish (needs --fixture). Phases (args, default all but ready): """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 bars: six more drills on a well: their slots on the well's card, the multi-well card,
diamond caps, the well panel in the web's order (deploy, drills, then the claims). 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 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 held back; the header badge, Claim all, moon row badge and Claim buttons count up on
our clock ("Claim N drills", "Claim <amount>"). our clock ("Claim N drills", "Claim <amount>").
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced, 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. so nothing is saved), the still ticker, a reduced reveal.
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
recipes.""" "Better" well is plain text in the well panel now: its link shot is skipped)."""
import json import json
import time import time
import urllib.request import urllib.request
@@ -47,7 +47,7 @@ def idle_tools(game, item):
def stake_many(game, item, n, well): def stake_many(game, item, n, well):
for _ in range(n): 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]: for tid in idle_tools(game, item)[:n]:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well}) r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error")) game.expect(r.get("ok"), "stake: %s" % r.get("error"))
@@ -84,7 +84,7 @@ def ready(game):
free = [w] free = [w]
well = free[0]["id"] well = free[0]["id"]
for _ in range(2): 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] copies = idle_tools(game, 3)[:2]
for tid in copies: for tid in copies:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well}) 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.navigate("{section='market', pick='Sollux'}")
game.idle() game.idle()
game.shot("motion-off-market", settle=0.5) game.shot("motion-off-market", settle=0.5)
game.click("settings")
game.click("motion") game.click("motion")
game.idle() game.idle()
if game.eval('require("modules.anim").reduced') is not True: if game.eval('require("modules.anim").reduced') is not True:
@@ -142,7 +143,7 @@ def links(game):
fx = game.fixture fx = game.fixture
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one. # Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
for _ in range(2): 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) copies = idle_tools(game, 3)
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]}) 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")) game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
+53
View File
@@ -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)
+73
View File
@@ -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")
+57
View File
@@ -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)
+64
View File
@@ -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)
+118
View File
@@ -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)")
+25
View File
@@ -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)
+7 -2
View File
@@ -1,6 +1,6 @@
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row """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 under the moon list and the main panel and the page scrolls to it (the drill panel; a
panel); sections without the aside use the whole width; the banner wraps; keys scroll 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.""" the page. Args: an optional name prefix."""
@@ -18,6 +18,11 @@ def run(game, args):
game.click("well:" + well) game.click("well:" + well)
game.wait_for("view.well == '%s' and not busy" % well) game.wait_for("view.well == '%s' and not busy" % well)
game.idle() 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.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
game.shot(pre + "small-well", settle=1.0) game.shot(pre + "small-well", settle=1.0)
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit, # (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
+26 -5
View File
@@ -1,6 +1,7 @@
"""Server texts in the confirm dialogs (needs --fixture): sell drill, buy seat, buy """Server texts in the confirms (needs --fixture): selling an idle drill asks in place
refinery slot and start a new game open the server's confirm (each cancelled with Esc, (cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
nothing bought); the theme toggle switches live. Shots of each.""" confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
Shots of each."""
def confirm(game, el, shot): def confirm(game, el, shot):
@@ -21,13 +22,31 @@ def confirm(game, el, shot):
game.wait_for("not g.dialog.root") 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): def run(game, args):
fx = game.fixture fx = game.fixture
pre = (args[0] + "-") if args else "" pre = (args[0] + "-") if args else ""
game.idle() game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"]) game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.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.navigate("{section='advisors'}")
game.idle() game.idle()
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat") confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
@@ -38,9 +57,11 @@ def run(game, args):
game.idle() game.idle()
confirm(game, "confirm:new-game", pre + "texts-new-game") confirm(game, "confirm:new-game", pre + "texts-new-game")
before = game.eval("require('modules.theme').name") before = game.eval("require('modules.theme').name")
game.click("settings")
game.click("theme") game.click("theme")
game.wait_for("require('modules.theme').name ~= '%s'" % before) game.wait_for("require('modules.theme').name ~= '%s'" % before)
game.idle() game.idle()
game.shot(pre + "texts-theme-toggled", settle=0.6) game.shot(pre + "texts-theme-toggled", settle=0.6)
game.click("settings")
game.click("theme") game.click("theme")
game.wait_for("require('modules.theme').name == '%s'" % before) game.wait_for("require('modules.theme').name == '%s'" % before)
+34
View File
@@ -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)
+129
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
"""The well panel scrolled down (needs --fixture): deploy cards with pictures, pips and """The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
reach, drill lines with thumbnails and attachment tags, the drills heading's share note; 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.""" then the market (movers, quote values, Refined tag) and a past moon in the list."""
+4
View File
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
fi fi
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1} 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")" cd "$(dirname "$bin")"
log=$(dirname "$out")/shot.log log=$(dirname "$out")/shot.log
TIMEOUT=${TIMEOUT:-120} TIMEOUT=${TIMEOUT:-120}
+205
View File
@@ -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()
+2 -2
View File
@@ -5,10 +5,10 @@
# any build (no test hook needed), so an old bundle can be compared with a new one: # 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/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 # 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 set -euo pipefail
bin=$1; out=$2; fx=$3; size=${4:-1280x800} 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'])"; } 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) player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
settle=4; [ "$size" != 1280x800 ] && settle=15 settle=4; [ "$size" != 1280x800 ] && settle=15
+1 -1
View File
@@ -122,7 +122,7 @@ await shot("new-moon");
// The display dialog: its fullscreen button goes through the browser's Fullscreen API. // The display dialog: its fullscreen button goes through the browser's Fullscreen API.
await cmd("navigate", `{section='moons', moon='${fx.moon}'}`); await cmd("navigate", `{section='moons', moon='${fx.moon}'}`);
await idle(); await idle();
await click("display"); await click("settings");
await shot("display"); await shot("display");
await click("dialog:fullscreen"); await click("dialog:fullscreen");
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
+86
View File
@@ -21,6 +21,84 @@ const SYNTH = {
const RATE = ZZFX.sampleRate; 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) { function wav(samples) {
const n = samples.length; const n = samples.length;
const buf = Buffer.alloc(44 + n * 2); const buf = Buffer.alloc(44 + n * 2);
@@ -44,6 +122,14 @@ function wav(samples) {
return buf; 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)) { for (const [name, [gain, params]] of Object.entries(SYNTH)) {
const samples = ZZFX.buildSamples(...params).map((v) => v * gain); const samples = ZZFX.buildSamples(...params).map((v) => v * gain);
const peak = samples.reduce((m, v) => Math.max(m, Math.abs(v)), 0); const peak = samples.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
+18 -14
View File
@@ -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. -- The name may hold a Greek letter (sym font): drawn as a label at the name's place.
local p = gui.get_position(name) local p = gui.get_position(name)
gui.set_enabled(name, false) 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 if moon.rating then
-- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing. -- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing.
local tier = moon.rating.tier local tier = moon.rating.tier
local color = TIER[tier] or "line_hi" 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", 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, font = "sym", px = 10, h = 16, pad = 4, fill = tier == "exceptional" and 0.15 or nil,
}) })
cx = cx + bw + 6
self.chip = { box } self.chip = { box }
if tier == "exceptional" then if tier == "exceptional" then
local bs, bp = gui.get_size(box), gui.get_position(box) 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") status = moon.drills > 0 and ui.plural(moon.drills, "drill") or (dry and "Tapped out" or "Idle")
end end
ui.style_text(sub, "small", status) 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 if moon.over and not moon.finished then
-- A well here has more drills than supply (the web's warn dot). -- 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, cx, -sp.y - 4, 7, 7, "ready")
local dot = ui.rect(self.content, sp.x, -sp.y - 4, 7, 7, "ready")
gui.play_flipbook(dot, "sphere") gui.play_flipbook(dot, "sphere")
tooltip.add(dot, "A well here has more drills than supply") 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 end
gui.set_position(sub, vmath.vector3(cx, sp.y, sp.z))
-- (opts.ready: the count on our clock, core/ready) -- (opts.ready: the count on our clock, core/ready)
local n_ready = opts.ready or moon.ready or 0 local n_ready = opts.ready or moon.ready or 0
@@ -145,15 +158,6 @@ function M:set_size(w)
end end
local badge = self:get_node("badge") local badge = self:get_node("badge")
gui.set_position(badge, vmath.vector3(w - 10, -h / 2, 0)) 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 end
-- The veil's alpha at rest (dimmed, or lifted by hover / selection). -- The veil's alpha at rest (dimmed, or lifted by hover / selection).
+1
View File
@@ -13,6 +13,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
<<: *db-env <<: *db-env
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose: expose:
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers POSTGRES_DB: moonminers
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
Executable
+195
View File
@@ -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
-2
View File
@@ -5,6 +5,4 @@ node_modules
.env* .env*
.vscode .vscode
.DS_Store .DS_Store
web/static/css/app.css
art art
web/static/app
-3
View File
@@ -1,8 +1,6 @@
# Build output # Build output
/bin/ /bin/
/moonminers /moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit) # Local env files (never commit)
.env .env
@@ -16,4 +14,3 @@ sql/queries.sql
# Generated by the art scripts # Generated by the art scripts
/art/equipment/build/ /art/equipment/build/
/art/advisors/build/ /art/advisors/build/
/art/attachments/build/
+124 -137
View File
@@ -1,67 +1,74 @@
# AGENTS.md — Moon Miners server # AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`). admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
every command below from this directory (`server/`).
## Stack ## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main` - 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`. 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 - The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their `api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine. - The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals` `web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild light themes). No bundler, no TypeScript.
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
committed). See "web/app" below.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- Postgres 16, stock image. No functions or extensions in the database. - Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Layout
- This is `server/` in the repo (`../AGENTS.md`); the Defold client is `../client` (its own - 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. AGENTS.md), the compose files are at the repo root.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env` - `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 - `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 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 `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 empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they can't be sold. The Past tab loads them 10 at a
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view). `loadView` (state, the past page, the resolved view). `loadState` leaves moon
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
`/?s=&moon=&well=`), the `Page` view model, formatting helpers. `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 - `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 limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an action).
action and responds the same way. - `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same `admin_test.go`: the gate.
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422). - `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). 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, `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 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, sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or 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 map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view. for every test view (the test states are in `templates_test.go`).
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well - `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
name, a placeholder description and resource `affinities`, refinery), embedded. description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
hold less); a first moon older moons are C); `buy-moon` takes `grade=`, else the newest moon's grade
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and (`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 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). `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` `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 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. `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`. - `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. The quote (`marketQuote`) is shared, the same for every - `market.go`: market prices. The quote (`marketQuote`) is shared, the same for every
player: config price × a deterministic drift (name + 5-minute tick, within 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 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 `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, 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, 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). watchlist, chart, sell ticket; `pick=` selects a quote).
`web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at - `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 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 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 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`. drill on it is running; finished drills are claimed in the sale.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
credit price (a tool: price + its upgrades; refined parts not refunded): only idle its track levels; refined parts not refunded): only idle tools with nothing to claim,
tools with nothing to claim, never the last tool; attachments on it return to the inventory. never the last tool.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules - `refinery.go`: refinery config (`refinery` in game.json, `parts` on track levels), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch (start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`. batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments (action `upgrade-batch`, `users.refinery_batch_level`). Tables
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on `refined_account`, `refinery_job`, `users.refinery_slots`.
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by - `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`. `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). Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items - `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip `tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
and unequip any time, they count from the next stake), store cards and the drill panel flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
(`drill=` in the URL, Equipment section, in the aside). Table `attachment` (`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
(`inventory_item_id` null: unequipped). interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the section, in the aside; the toast text).
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`. - `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` - `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`, 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 `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, 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 moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`). view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players` - `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 (search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page. `ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put Banned players (`users.banned_at`) get a 403 from the API.
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`. `web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon (`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
list, sections, side panel). the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's - `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`) `enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene. toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view - `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game - `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
and admin pages. `ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored `go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for any more) and the wells.
outside control (e.g. ads). - `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached `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 sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict `staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header. and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live - `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 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`). via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`. - `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and use; the tools show their original images.
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table - `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
and the store link to the classic client. `session.js`: like the Defold client's session
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
id; corrections change its playback rate). `fx.js`: effects at the click from the
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
for what differs.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check` flags `.value` inside JSX).
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
changes when the server answers); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent. 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. - `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`. they write into `web/static/assets`.
## Commands ## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and - `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
game request that long, to try both clients on a slow connection. long, to try the client on a slow connection.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`, - `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
moon traits check, the Preact client's `.value` check and build), `make migrate`, every `web/static/js/*.js`), `make migrate`, `make load-config`. `go run . sim` prints the
`make load-config`. `go run . sim` prints the economy analysis. economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the - 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; admin templates like the server and renders every page against in-memory data; when you
when you add a page, section or view model branch, make sure its states/views reach it. 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). - 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 Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
distribution, value by well count, resale odds, sim seeds). 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 ## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`; - 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 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`, 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 `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` commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
(unset: nobody); with `MM_DEV=1` everyone is an admin. `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 ## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin. - 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 - New section: add it to `sections` and `State.page` (views.go) and map it in the API
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps (`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
the address bar in sync. `api.md`. The client sends the current view (s, moon, well, drill) with every action, so
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section` action parameters must use other names.
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and - The server is the authority; clients predict only what `api.md` "Prediction" allows.
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically, - New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
so action parameters must use other names. Give it a sound in fx.js if it needs one. that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- SQL is lowercase with snake_case identifiers. - SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`. - 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 - 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). transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
+159
View File
@@ -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