Compare commits

..
240 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
EmilNielsenandClaude Opus 5.5 c8daa086e1 Merge s-preact: the Preact client at /app
The branch predates the move to server/; its new files go there too
(server/web/app, server/scripts/check-signals-jsx.mjs,
server/web/templates/spa.html, server/HANDOFF-preact.md), its AGENTS.md
notes into server/AGENTS.md, and the built bundle is ignored in
server/.gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:30:09 +07:00
JosephFerano d2b4f62949 Client: a press carried across a full rebuild no longer needs the new button under the pointer at once (the entrance moved it: the release clicked nothing); inputlock prints the touches it got on a failure 2026-10-21 02:17:24 +07:00
JosephFerano 0450ecd05a Client: sticky mouse buttons and keys (a press shorter than a frame was lost); drive.py gets its own Xvfb display (xvfb-run -a raced between concurrent runs) 2026-10-20 23:13:34 +07:00
JosephFerano e9c1e344b8 Client: quick grant dialog for admins (header + button, G key) 2026-10-20 23:13:34 +07:00
JosephFerano 32b3465c46 Quick admin grant dialog: admin-grant action (web, API), admin flag in /api/state 2026-10-20 22:47:18 +07:00
JosephFerano 0bf4779903 Client: shared market quotes; advisors and recent selling as personal lines on the ticket; check.py and drive.py --fixture need an explicit server 2026-10-20 22:39:43 +07:00
JosephFerano ba09caf66f Shared market quotes; advisors and selling pressure apply to the payout only 2026-10-20 22:35:15 +07:00
JosephFerano 70ebafa31d Client AGENTS: what is laid out with flex now; flex_gui tree pieces 2026-10-20 21:56:22 +07:00
JosephFerano 742e071434 Client: reveal card laid out by a flex tree (parts made at their boxes; animated groups placed there) 2026-10-20 21:54:54 +07:00
JosephFerano 11d94d38f0 Client: moon sale panel as a tree piece of the Moons section (summary, note, lines grid) 2026-10-20 21:52:58 +07:00
JosephFerano d1d9d51d5d Client: banner, toasts (body and stack), dialog panel and buy modal flex-first 2026-10-20 21:51:04 +07:00
JosephFerano ed59c6ae86 Client: new-moon teaser flex-first (claim frame, wells and class hints with wrapping chips) 2026-10-20 21:46:22 +07:00
JosephFerano 4c93f0471f Client: moon list panel flex-first (Active/Past tabs, search, rows box, pager, + New moon pinned) 2026-10-20 21:45:10 +07:00
JosephFerano 9c9a2697a8 Client: Help and Leaderboard flex-first (steps, rows, list that shrinks to the panel) 2026-10-20 21:42:18 +07:00
JosephFerano 7b6f0b75f9 Client: header flex-first (brand, tabs in slots, Claim all, second nav row when it doesn't fit) 2026-10-20 21:39:09 +07:00
JosephFerano c7839bb2ef Client: Market flex-first (heading stats, quote cards grid, detail panel, sell ticket and payout as trees; ticket panel in a slot for the sale) 2026-10-20 21:36:53 +07:00
JosephFerano 29c8034bf0 Client: Advisors flex-first (stat panels, seats grid, bench, card footers, advisor panel and preview) 2026-10-20 21:32:32 +07:00
JosephFerano b7c6a1c264 Client: Store and Equipment pages flex-first (fg.flow with card grids, buy footers, tool card name row and status); flex_gui.inline, flow 2026-10-20 21:29:07 +07:00
JosephFerano 6fb4392438 Client: drill panel flex-first (head, stats, upgrade, tier ladder as a grid, slots, spares, sale); equipment stats/upgrade as tree pieces; flex_gui.measured 2026-10-20 21:26:42 +07:00
JosephFerano 92c23f574f Client: aside well panel flex-first (deploy cards, drill lines, claims), viewport and captions as trees 2026-10-20 21:22:01 +07:00
JosephFerano 4a4897ffb0 Client: Moons section flex-first (wells table as a grid, heading, past moon, first-moon pitch); flex_gui tree pieces, button boxes, rich labels; H.claim_wait_w 2026-10-20 21:18:06 +07:00
JosephFerano 1ea59b5d70 Client flex: grid tracks, absolute/anchor, aspect_ratio, scroll content size (with tests) 2026-10-20 21:09:17 +07:00
JosephFerano e805f97a4c client AGENTS.md: new UI is flex-first; port hand-placed sections when touched 2026-10-20 18:09:50 +07:00
JosephFerano bdc8d311be Client: Refinery slot head 22.5 tall (as before); AGENTS: flex-first sections 2026-10-20 18:05:18 +07:00
JosephFerano 9d512f5abc Client: Refinery rewritten flex-first (one tree, nodes made from the solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano 8293fdca22 Client: flex.columns/grid, flex_gui label/para leaves and build (make nodes from a solved tree) 2026-10-20 18:00:25 +07:00
JosephFerano a21d5a4371 Client: AGENTS Layout (flex) section and FLEX-NOTES 2026-10-20 17:43:47 +07:00
JosephFerano b4b6d22113 Client: header wallet cluster laid out with flex 2026-10-20 17:42:52 +07:00
JosephFerano 7ec64c39a3 Client: flex_gui (apply a solved tree to slot nodes, optional glide); Card and the Refinery laid out with flex 2026-10-20 17:39:13 +07:00
JosephFerano 9d4b712d8b Client: flex, a pure flexbox layout solver, with unit tests in make check 2026-10-20 17:24:48 +07:00
JosephFerano 8ecfa5753e Client: restore the web-style Refinery (slot cards, segmented bars, recipe cards) that the c-clickfast import dropped; kept-card grid passes card options 2026-10-20 17:14:00 +07:00
JosephFerano c31dcca280 AGENTS: blocks (kept regions, registries, keys a section reads) 2026-10-20 16:28:44 +07:00
JosephFerano bfa3e1ce65 Moon bloom in GUI reference pixels: HiDPI does the 1x work and its glow looks like 1x 2026-10-20 16:28:23 +07:00
JosephFerano 59f26dc5af Blocks: kept blocks always look for their clip stencil again (a deleted node answers until the frame ends) 2026-10-20 16:19:24 +07:00
JosephFerano bb6b34c1b6 Moon generation only native (mmterrain): Lua generator and its fallback gone, no generation timing; moon-check compares mmterrain with terrain.js 2026-10-20 16:19:24 +07:00
JosephFerano 302bb2b764 Merge branch 'c-perf-moon' into c-perf 2026-10-20 15:46:14 +07:00
EmilNielsenandClaude Opus 5.5 85336f109f Hand-off: second measurements, what's done and what's left
Re-measured on a quiet GPU (Chrome 155, Metal): claim and redrill show in
13–15 ms in the Preact client at 0 and +300 ms (classic 30 ms and 330–347
ms), every deploy click counts, no dropped frames for either; main-thread
time per claim: 11 ms of script against 25 ms. Side-by-side screenshots
match. The open list is now the other sections, the actions that still
wait for the server, and keeping the harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:42:16 +07:00
EmilNielsenandClaude Opus 5.5 a5385a53a7 Preact client: past moons, search, multi-well drills, moon sale, better well
The rest of the Moons slice from the hand-off's list:
- Moon list: Active/Past tabs, the search box, past moons (what each gave,
  when it finished) a page at a time; a past moon's page with its haul.
- Wells table: the multi-well row (what's left on the moon, its drills,
  claim-wide), and each well's drill bars include the multi-well drills
  that mine it. Their readiness is now local like the others' (derive).
- Moon resale under the table: the offer against a new moon, the wells'
  values, the hold countdown (the session wakes at holdEnd), and the API's
  confirm text in the classic dialog (dialog.jsx, the classic ui.js).
- Well panel: "Better: … well, N free · Deploy there" (it moves the view,
  so it waits for the server), multi-well drill lines (reach, here / in
  all) with their claim and claim-and-redeploy, and the store link.
- Effects: the relay click when following a link, the sale's gold ring and
  cash count, and the mined-out sound and Past tab flash (onAnswer, from
  the server's fx.finished, also after a predicted claim).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:19:07 +07:00
JosephFerano 90722d1c04 WIP: incremental rebuilds (blocks), reveal flight defers rebuilds 2026-10-20 12:39:57 +07:00
JosephFerano 22dca72f39 make check runs moon-check (terrain.lua and mmterrain against terrain.js); AGENTS: the mmterrain extension 2026-10-20 12:36:55 +07:00
JosephFerano 27f415d4af Moon meshes on the web: native work as a share of the frame time, the buffer filled over the next frames, grid 128 2026-10-20 12:36:55 +07:00
JosephFerano b8f2422433 mmterrain: fill a range of the strip; directions stepped too 2026-10-20 12:36:55 +07:00
JosephFerano 62ebb27d25 Moon meshes through mmterrain (a thread on desktop, 3 ms a frame on the web); a bought moon's mesh starts with the buy-moon response 2026-10-20 12:22:53 +07:00
JosephFerano 979470f306 mmterrain: the moon generator in C (native extension), checked against terrain.js and terrain.lua 2026-10-20 12:22:53 +07:00
JosephFerano 18f31b322a Moon generation: checkpoints in crater placement too, so no step of it runs unchunked 2026-10-20 12:22:53 +07:00
JosephFerano 321102cc72 AGENTS: web moon budget, frames command, F3 profiler 2026-10-20 11:53:00 +07:00
JosephFerano 948e16cf16 Moon target: supersample in GUI reference pixels, so HiDPI displays don't draw it at 4x 2026-10-20 11:33:39 +07:00
JosephFerano 994c1a00ab Moon generation on the web: 8 ms a frame and finer checkpoints, so it keeps 60 fps; cube-sphere cached per grid size 2026-10-20 11:33:39 +07:00
JosephFerano 6973c517b4 Debug: frame times through the test hook (frames), F3 shows the engine profiler 2026-10-20 11:33:28 +07:00
JosephFerano 166145631b Web: variable frame rate, so the browser's display rate paces frames 2026-10-20 11:20:34 +07:00
JosephFerano 0846c017ae Hand-off notes for the Preact client experiment 2026-10-20 08:42:29 +07:00
JosephFerano b4e5846dff Keep the built Preact client out of the Docker context 2026-10-20 08:34:14 +07:00
JosephFerano 3afd892487 Preact client: .value-in-JSX check, docs, Docker build, template test
scripts/check-signals-jsx.mjs (in make check) flags signal reads inside JSX; make check
also builds the client. The Dockerfile fetches esbuild and runs make app. AGENTS.md
describes web/app and its rules; /app is in the template check and the endpoint list.
2026-10-20 08:33:46 +07:00
JosephFerano cc1005c99e Preact client: moon viewport in a worker on an OffscreenCanvas
The classic MoonScene runs in scene-worker.js against stand-ins for the canvas, its
container, ResizeObserver and the document; the page forwards pointer and wheel events
and the size. Falls back to the classic scene on the page's thread (?mainscene forces it).
Also: FLIP positions relative to their container, so scrolling isn't a move.
2026-10-20 08:31:35 +07:00
JosephFerano c998f8fda0 Preact client at /app: session with prediction, Moons section, well panel, viewport
Signals over /api/state, an outbox sending one request at a time with clientSeq, the
Defold client's predictors (claim, stake, redrill) applied at the click and replaced by
the server's state. Bundled by esbuild (make app) into web/static/app; the classic
client's scene, effects and sounds are loaded by URL. MM_API_DELAY_MS (dev) delays game
requests for both clients.
2026-10-20 08:19:58 +07:00
258 changed files with 28277 additions and 21870 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@
# Blender files
*.blend
*.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,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
items (Refinery section: timed batches in slots; refined items sell for more and are parts
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
for the higher upgrades). Each drill has two upgrade tracks of its own (Equipment: one raises
its claim per cycle, the other shortens its cycle, a level at a time, per tool); advisors
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out).
## How the parts fit
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
Details: `server/AGENTS.md`.
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
authority on game state. Details: `server/AGENTS.md`.
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
Details: `client/AGENTS.md`.
what it sends. It is the only game client, and its moon generator
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
`client/AGENTS.md`.
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
## Workspace
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- Agent worktrees go in the workspace, next to `game/`:
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
- A worktree's `.env` names its own database (`DB_DATABASE=mm_<task>`), so `migrate`,
tests and servers run there reach only `mm_<task>`. Never point anything at the main
database (`moonminers`): it holds the author's game.
- Never use `git stash` in a worktree: the stash list is shared by every worktree, so a
pop can take another agent's work. Commit on your branch, or save a patch file.
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it.
+18 -8
View File
@@ -2,10 +2,10 @@
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
- `client/`: Defold client, for desktop and the browser (WebAssembly).
## Server and web client
## Server
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`).
@@ -14,22 +14,32 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
podman-compose up -d db # or: docker compose up -d db
cd server
make migrate
make dev # http://localhost:3000
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
```
After pulling changes or editing `server/config/game.json`, run `make migrate` again before
starting the server: it applies new migrations and syncs the tools and prices with the
config (the server alone doesn't).
## Defold client
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor
is optional: https://defold.com/download/
With the server running:
Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
Defold's command-line builder (`bob.jar`) and runs it with the Java the editor ships, found
in the usual places on Linux, macOS and Windows (`make DEFOLD=<folder holding its packages>`
for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
```bash
cd client
make run # desktop build, plays against http://localhost:3000
make web && make serve-web # browser build: open http://localhost:8080
make web # browser build into dist/web/MoonMiners/
```
The server serves the browser build at http://localhost:3000 (from
`client/dist/web/MoonMiners`, or `MM_WEB_DIR`). It looks for the build when it starts:
after the first `make web`, restart the server; later builds only need a page reload.
Without a build, `/` goes to the admin. The desktop and browser games keep separate saved
players.
## Deploy
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
+189 -527
View File
@@ -1,538 +1,200 @@
# AGENTS.md — Moon Miners client (Defold)
## What this is
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header with sections, resources and credits; moon list on the left; the current section
in the middle; aside with the moon viewport and the selected well), made game-native.
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus later.
A native client for Moon Miners (rules and state live in the Go server: `../server`,
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
gamepad later. Details live in each file's header comment; this file has the rules and a
map.
The server is the authority. The client shows the state the server sends and sends
actions. It may show an action's result before the answer (prediction, see Conventions),
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
rules in Lua. Countdowns may tick locally as display only.
## Rules
- The server is the authority. Show the state it sends, send actions. Prediction may show
an action's result early, computed only from numbers the server sent (`take`, the tool's
`cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
replaces it. No other game rules in Lua. Countdowns tick locally as display only.
- Keep changes small; don't touch `../server` from here.
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
label); animations own what's inside (CSS layout vs transform). Never give a box a node
that something animates by position or size. Widgets built at a size (ui.button, bars,
dashed edges) are built after the solve at their box's rect; text is cut after it
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
a button changes its position (reserve room: fixed heights, placeholders), and when a
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
cards), so repeated clicks at one place keep working.
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
whole gui script, so closing a dialog left the game deaf). Don't post
acquire/release_input_focus anywhere else. The dialog's input invariant
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
`ERROR:MM`.
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
One Druid instance per gui_script (the dialog has its own). Interaction goes through
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
keys, fx and the test driver depend on it.
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
layout is top-left based (x right, y down).
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
`ASIDE_KEYS` and the header's sig likewise; the moon list's rows: `moonlist.rows_sig`;
the moon's facts in the aside: `aside.facts_sig`; the Moons parts: their models in
moons.lua) or it won't update. The shown state is frozen (`blocks.freeze`: `blocks.sig`
writes each part once per state, by content): never change `g.state` in place; a
prediction changes its copy before it is shown. What must survive
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
builds outside its block. A new per-build registry notes its entries in the block journal
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
`blocks.bound(fn)`.
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
server text is safe to pass. Add missing characters to the `.font` (make check names them).
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
doesn't render (make check catches the warning); stencils and images split batches.
Outline textures use slice9 2.
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
children: fade content in from under a veil, not by fading a subtree. Every new
animation honors reduced motion (`anim.reduced`). Effects for action results go in
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
below 1250 wide the aside gets its own row and the page scrolls. Content caps
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
browser console (WebGL feedback loops, Firefox target resizes).
- Native code (`mmwindow/`, `mmterrain/`): user rule, C only (`.c`, Lua C API); C++ only
where the dmSDK forces it, as a C-style shim (free functions and plain structs: no
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
files. Changing a native extension (or the dependencies) sends a remote engine build.
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
`display.width/height`.
- Never kill processes by name; scripts stop the game by its pid.
## Stack
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
native extension, so a build sends the engine to Defold's build server once (needs
network; later builds reuse it until the dependencies change). Dependencies are fetched
only when `game.project` changes (`make resolve` forces it).
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
`~/Development/Refold/Defold` (read-only).
## Game screen: core, regions, sections
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
state: every new state (a response, a prediction) or a window/UI-scale change sets
`g.dirty`, and the next update drops the Druid instance and every layout node and builds
them again. `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 every rebuild
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 every build), `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.
- 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.
## 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`).
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column on Druid Layout (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.
## 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).
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`).
- `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 server's `terrain.js`
(`scripts/moon-check.mjs` compares them), `modules/moon/moon.lua` is the API
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`),
`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 plain
Lua there (no LuaJIT): grid capped at 96 (`WEB_RESOLUTION`) and 12 ms a frame, a few
seconds per moon. Moon targets fall back to 8 bits without EXT_color_buffer_float.
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
depth buffer: the render script binds every bloom sampler and makes targets again at a
new size. Check the console after render changes.
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
by real mouse clicks (element rects from the test hook: the page pushes commands onto
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
claim; fails on console errors.
## Testing
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
says what it checks and needs). Run `make check` (with `MM_API` set to your test
server; it makes a fixture player there) before calling it done.
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
server, which `wt up` starts).
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
click_check to a feature's scenario when it has buttons people click repeatedly.
## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
bob's output; `build/x86_64-linux/dmengine` is the engine with our extension).
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
- `make web`, `make serve-web`: the browser build (Web build).
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
- `make check`: checks (below).
- Before/after screenshots of every section (works on any build, also one from before a
change): `scripts/fixture.py <api> > fx.json`, then
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
after each other (the fixture's drills and market move on).
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
`mm.fake_reject`, shots via `SHOTS_DIR`).
- Never kill processes by name (scripts stop the game by its pid).
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
from `build/default` in the foreground (loose files, engine service on), so `make reload`
works on it.
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
`node scripts/web-shots.mjs` (Playwright; header of the script).
## Test driver (`modules/test.lua`, `scripts/drive.py`)
The engine's debug HTTP service (debug builds; port from the log line `Engine service
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
runs in the same Lua state as the gui script but outside it, so it only pushes commands
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
`$MM_DRIVE_DATA`), never the player's own settings.
- Elements: every interactive component registers a stable id while it's built:
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
over three frames through Druid: real input, hover included), `hover(id)`,
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
`restart()` (quit and start the game again, same options and saved files).
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; key and button held 80 ms, since the engine polls once a
frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
page at once; the invariant's repair; args `reduced`, and run it with `--config
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
moon; the Moons tab returns to the last moon after other sections, back / forward, a
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
folder (`<out>/data`), never the real settings file. Run with `--config
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
grants through `/utils/players`); prints its ids as JSON.
## Fast iteration (hot reload)
`make dev` builds once and starts the game from the build folder (engine
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
registered first (a `run_script` listing them), then `.luac` files, then the game's
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
to those, to collections, game objects, `main.script`, materials, shaders or the render
script need a restart: `make dev-stop dev` (reload says so).
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
## Hot reload (`make reload`, `scripts/dev.py`)
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
too; restart if one looks wrong.
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
collections, game objects, `main.script`, materials, shaders, the render script; modules
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
native extensions and dependencies (a new engine).
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
(core/main.lua) or lazily.
- `make reload` fails loudly on a compile error (and keeps the old code running).
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
## Checks (`make check`, scripts/check.py)
Fails on: Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
a fixture player in every section: symbols in sym, the rest in the text fonts); and
engine warnings in a `sections` scenario log (font batches, characters, text entries,
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
against `MM_API` (default http://localhost:3000) when it answers; otherwise the newest
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
## Map
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
(shared by card sections).
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per
server.
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
- `view`: the view to open, `key=value&...` of `state.view` keys
(`section=equipment&drill=<id>`).
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
- `theme`: `light` or `dark` (default: the saved setting, else dark).
- `test`: `0` turns the test hook off in a debug build (it's never in release).
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
scale; not saved). Default: the saved setting (the display dialog), else 1.
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
--config mm.display_scale=2` looks like 1280x800).
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
the others on the command line.
Components (`components/`, API in each header; build new UI from these): `button`,
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
## Conventions
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
not screens.
- Every gui_script uses one Druid instance (`druid.new(self)` and the
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
interaction (buttons, hover, scroll, input, layout, data list) through the components.
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
colors come from `theme.lua`.
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
1280x800 every cap is above what fits; keep it that way (compare shots).
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
is a token: no hex or vector colors in code (template node colors via `theme.template`,
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
tells you which).
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
`max_characters` raised in `game.project`. Text past the font batch limit silently
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
dialog) and images split font batches.
- Outline textures use slice9 2 (not 1).
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
- The server is the only source of truth: every response's `state` replaces ours and
`state.view` becomes our view; actions send the current view plus their params and
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
when `nextEnd` is due.
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
refine, collect. Others (and predictors that return false) show a busy button.
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
counts through fx.
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
it. Run `make check` before calling it done.
- Keep changes small; don't touch the server (`../server`) from here.
Modules (`modules/`):
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
{ lines = 2 })` (at most n lines, cut with …).
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
`hot.lua` (hot reload).
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
Elsewhere:
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
`render/mm.render_script`: HDR moon pass with bloom.
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
game's only one (it began as a C port of the web client's terrain.js, now deleted).
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
- `web/index.html`: the web build's page.
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
## Stack
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
## Config (`--config=mm.<key>=<value>`)
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
`reveal_freeze`, `sfx_debug`.
+70
View File
@@ -0,0 +1,70 @@
# Flex layout: notes from the first port
Solver `modules/flex.lua` (430 lines incl. docs), apply helper `modules/flex_gui.lua` (96),
tests `scripts/flex_test.lua` (53 checks, in `make check`).
## Lines, before -> after
| File | Lines | Hand-placed offsets (`x + `, `y + `, `+ 8`, `set_position`) |
|---|---|---|
| `screens/game/refinery.lua` | 524 -> 595 | 35 -> 10 |
| `components/card.lua` | 298 -> 325 | Druid Layout gone; one tree replaces three branches of padding math |
| `screens/game/header.lua` (wallet only) | 243 -> 256 | running `cx` cursor gone |
The code is longer, not shorter: every text is now a box with its line-box height, and
the tree literals add structure lines. What it gains: no running cursors, no "measure, then
come back and move it" (`gui.set_position(mx, vec(w - mw, -fy))`, the slot label's probe
node, the Collect all offset `67.9 - 44`). Spacing is in one place per container, and the
heading and stock now grow with their content (before: fixed 67.9 px, chips wrapped by
hand).
Screenshots (`shots/c-flex/before|after`, 1280x800 dark and light, 1024x640): Refinery
(ready, idle, running slots, buy slot, recipe footers), Store, Equipment, Advisors and the
header match pixel for pixel. The only differences are animations caught at another
moment (Claim all, Ready glow, pulsing buttons) and fixture data. Scenarios smoke, cards,
keys and finalcheck pass (keys timed out once on the Market tab, then passed; the baseline
passes too).
## Easier
- Rows with something pinned right (time left, "have" counts, Max button), centered
labels, and footers pinned to the bottom: `justify`, a `grow = 1` spacer.
- Wrapping chips and equal-height slot rows: `wrap = true` plus the default stretch.
- Text cut to the space left: a shrinking leaf, then `ui.fit_text(node, box.w)`.
## Harder
- Matching the old pixel offsets exactly. The old code centers text at hand-picked middles
(12 for one label, 11 for the one beside it), so some rows need leaves of different
heights inside a fixed-height row. A new screen built with flex from the start won't
have this.
- Widgets built at a size (ui.button, the cell bar, dashed edges, the quantity field):
solve with an empty box, then build at its rect. Fine, but it splits a component in two.
- Creation order is focus and Druid input order, so nodes must still be built in the old
order; only their placement moves to the end.
- bob follows `require` strings, and flex.apply -> flex_gui -> flex is a cycle; it uses a
non-literal name.
## What the solver does that CSS doesn't (or does differently)
- border-box sizes, `min-width: 0` by default (CSS's auto min size would stop labels from
being cut), auto size = fit-content even at the root, align-content always start, no
margins (spacer boxes), no baseline alignment.
- No pixel snapping. One label (buy slot) is solved, then placed on a whole pixel by hand,
as the old code did; a `round` option could do it.
- CSS grid's `minmax(200px, 1fr)` isn't flex: the columns are computed (as before) and
given to wrapped items as widths. A small `grid` helper would cover card.grid and the
slots.
## Also fixed
- `card.grid` rebuilt a kept card whose row height changed without the card options
(a recipe card would have gotten an image column).
## Recommendation for the rest
Port section by section when a section is touched anyway, not as one sweep: equipment's
tool cards and the drill panel, store and advisors (already on Card), market's ticket and
stats, the header's tab row (it has a "second row when it doesn't fit" rule, which is a
wrap). Keep kept blocks as they are: solve what a build makes. Leave lists (Data List)
and the scroll areas alone. Add a `grid` helper and rounding before the next big port.
+41 -16
View File
@@ -1,5 +1,17 @@
# Moon Miners Defold client. Needs Java 21+ (bob.jar); xvfb-run, Xvfb, ImageMagick, xdotool,
# python3 and luajit for shot, play, drive, dev and check.
# Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
# (its packages/jdk-*), so the editor is all a build needs. The editor is looked for where it
# usually is on Linux (~/Defold, /opt/Defold), macOS (Defold.app in /Applications or
# ~/Applications) and Windows (~/Defold, C:/Defold, Git Bash or MSYS make); DEFOLD=<the folder
# holding its packages> for another place, JAVA=<path> for a Java of your own. With no editor
# found it is java (21+) from PATH. xvfb-run, Xvfb, ImageMagick, xdotool, python3 and luajit
# for shot, play, drive, dev and check (Linux).
DEFOLD_DIRS := $(DEFOLD) $(HOME)/Defold /opt/Defold \
/Applications/Defold.app/Contents/Resources $(HOME)/Applications/Defold.app/Contents/Resources \
$(USERPROFILE)/Defold C:/Defold
# (make's own wildcard and sort, no shell tools; the last after sorting is the newest)
DEFOLD_JAVA := $(lastword $(sort $(foreach d,$(DEFOLD_DIRS),$(wildcard \
$(d)/packages/jdk*/bin/java $(d)/packages/jdk*/bin/java.exe $(d)/packages/jdk*/Contents/Home/bin/java))))
JAVA ?= $(or $(DEFOLD_JAVA),java)
BOB_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/bob.jar
@@ -11,32 +23,33 @@ SHOT ?= dist/shot.png
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?=
.PHONY: build run shot play clean resolve dev reload dev-stop drive check web serve-web
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
$(BOB):
mkdir -p tools
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
touch $@
build: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
--archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
# `make serve-web` serves it on :8080 with /api and /static proxied to UPSTREAM.
# The game server serves it at / (server.go, MM_WEB_DIR); `make serve-web` serves it on :8080
# with /api and /static proxied to UPSTREAM (a test server).
WEB_VARIANT ?= release
UPSTREAM ?= http://localhost:3000
WEB_PORT ?= 8080
web: $(BOB) .internal/lib/dependencies.json
java -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
$(JAVA) -jar $(BOB) --platform wasm-web --architectures wasm-web --variant $(WEB_VARIANT) \
--archive --bundle-output dist/web build bundle
serve-web:
@@ -50,9 +63,9 @@ dev: build
reload: $(BOB)
@mkdir -p dist/dev
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
scripts/dev.py reload
@scripts/dev.py reload
dev-stop:
scripts/dev.py stop
@@ -63,28 +76,40 @@ SCENARIO ?= smoke
DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO)
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
# dist/drive/lint-<size>/. Needs MM_API (a test server).
lint: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
# scenario run (sections) against MM_API (default http://localhost:3000) if it answers.
# scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py
# The game from build/default (loose files, engine service on), so `make reload` works on it.
# The bundle in dist/ is for shot, play and shipping.
run: build
$(BIN) $(ARGS)
scripts/dev.py run $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build
scripts/shot.sh $(BIN) $(SHOT) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
scripts/shot.sh $(BIN) $(SHOT) $(if $(MM_API),--config=mm.api_base=$(MM_API)) $(ARGS) $(if $(POPUP),--config=mm.popup=1)
# Scripted play-through as a new player (mm.autoplay), a screenshot per stage in dist/play/.
# Needs MM_API (a test server): it makes a new player there.
play: build
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
@test -n "$(MM_API)" || { echo "make play: set MM_API to a test server (no default)"; exit 1; }
SHOTS_DIR=$(abspath dist/play) scripts/shot.sh $(BIN) dist/play/last.png --config=mm.api_base=$(MM_API) --config=mm.autoplay=1 --config=mm.new_player=1 $(ARGS)
clean:
rm -rf build dist .internal/cache
Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 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,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js).
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
reveal's build-up and burst, client only) are synthesized by the same script.
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
Binary file not shown.
Binary file not shown.
+9
View File
@@ -31,9 +31,18 @@ images {
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/gear.png"
}
images {
image: "/assets/images/moon.png"
}
images {
image: "/assets/images/pentagon.png"
}
images {
image: "/assets/images/pentagon_rim.png"
}
margin: 2
extrude_borders: 2
inner_padding: 0
+10 -6
View File
@@ -29,7 +29,6 @@ local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local helper = require("druid.helper")
local druid_button = require("druid.base.button")
local S = theme.size
@@ -144,23 +143,28 @@ function M.release()
carry.pressed = nil
end
-- After a rebuild, with the mouse still down at (x, y) (screen) on a button of the build
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
-- before: press the new button with its id under the pointer, so the release clicks it.
function M.carry(x, y)
local p = carry.pressed
if not p or not p.id then
return
end
-- (not only one under the pointer now: a full build plays the page's entrance, so the
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
local found
for _, b in ipairs(carry.made) do
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true)
and helper.pick_node(b.node, x, y, b.click_zone) then
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
b.can_action = true
b.last_pressed_time = p.t
b.hover:set_hover(true)
carry.pressed = { node = b.node, id = p.id, t = p.t }
return true
found = b
end
end
if found then
carry.pressed = { node = found.node, id = p.id, t = p.t }
return true
end
end
-- Draw and bind. Returns { root, nodes, w, h, button }.
+134 -55
View File
@@ -1,12 +1,12 @@
-- Card (the web's .card / .store-card): an image column on the left and a text column
-- that hugs its content, built on Druid Layout (vertical, measured text: a paragraph's
-- height comes from its wrapped lines), with an optional footer block pinned to the
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
-- paragraph's height comes from its wrapped lines), with an optional footer block pinned to the
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
-- Advisors use it.
-- registers them for the panel entrance (g.frames, staggered). Store and
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
--
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
-- c:image(item.image)
@@ -20,10 +20,9 @@
-- c.edge_hover = { "ready", 0.7 } -- its hover
-- end)
--
-- Rules from druid-notes.md: the layout node is an invisible box (no slice9), pivot NW;
-- it's refreshed synchronously once the card is built (Layout never resizes children:
-- widths are set here); text nodes are measured by their text (pivot W lines, wrapped
-- NW paragraphs).
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
-- what's inside a slot is placed by hand, relative to it.
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -31,6 +30,9 @@ local images = require("modules.images")
local anim = require("modules.anim")
local button = require("components.button")
local panel = require("components.panel")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
local flex_gui = require("modules.flex_gui")
local S = theme.size
@@ -58,23 +60,32 @@ function M.media_size(cw)
return math.max(120, math.min(156, math.floor(cw * 0.3)))
end
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none).
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none: just
-- the text column), pad (the text column's padding on every side; default 14 across,
-- 12 above, 14 below).
function M.new(g, parent, x, y, w, opts)
opts = opts or {}
local stacked = opts.media == nil and w < S.card_min_w
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
local c = setmetatable({ g = g, w = w, m = m, footer_h = 0, stacked = stacked }, M)
local c = setmetatable({ g = g, x = x, y = y, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad, items = {} }, M)
c.root = ui.rect(parent, x, y, w, 1, "panel")
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
if stacked then
c.bw = w - PAD * 2
c.body = anim.group(c.root, PAD, m + 14, c.bw, 1)
else
c.bw = w - m - PAD * 2
c.body = anim.group(c.root, m + PAD, 12, c.bw, 1)
if m > 0 then
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
end
c.layout = g.druid:new_layout(c.body, "vertical"):set_padding(0, 0, 0, 0):set_margin(0, 0):set_hug_content(false, true)
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
-- The text column's padding (top, right, bottom, left) and width.
if m == 0 then
local side = opts.pad or PAD
c.padding = { opts.pad or 12, side, opts.pad or 14, side }
c.bw = w - side * 2
elseif stacked then
c.padding = { 14, PAD, 14, PAD }
c.bw = w - PAD * 2
else
c.padding = { 12, PAD, 14, PAD }
c.bw = w - m - PAD * 2
end
c.body = anim.group(c.root, 0, 0, c.bw, 1)
return c
end
@@ -86,29 +97,29 @@ function M:image(url)
self.image_node = node
end
local function add(self, node, gap)
-- A box in the text column (flex: modules/flex.lua).
local function add(self, box, gap)
if gap and gap > 0 then
self:gap(gap)
end
self.layout:add(node)
return node
self.items[#self.items + 1] = box
return box.node
end
-- Vertical space.
function M:gap(px)
local n = anim.group(self.body, 0, 0, 1, px)
self.layout:add(n)
self.items[#self.items + 1] = { style = { height = px } }
end
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
-- line boxes), cut with … to the column. style: theme.text name or table.
-- line boxes), cut with ... to the column. style: theme.text name or table.
function M:text(str, style, h)
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
local t = ui.label(slot, str, style or "body", 0, 8)
if not ui.needs_rich(str) then
ui.fit_text(t, self.bw)
end
self.layout:add(slot)
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
return t
end
@@ -117,20 +128,29 @@ function M:title(str)
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
end
-- A paragraph that wraps at the column width (its height measured by the layout).
-- A paragraph that wraps at the column width (its height measured).
function M:para(str, style, gap)
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local s = gui.get_scale(n).x
gui.set_size(n, vec(self.bw / s, 10))
return add(self, n, gap)
return add(self, { node = n, size = false, style = { width = self.bw }, measure = flex_gui.text_measure(n) }, gap)
end
-- Any node (its size is what the layout counts; pivot NW or W).
-- Any node (its size, or a text's metrics, is what counts; pivot NW or W).
function M:node(n, gap)
gui.set_parent(n, self.body, false)
return add(self, n, gap)
local measure
if gui.get_text(n) then
measure = flex_gui.text_measure(n)
else
measure = function()
local sz, sc = gui.get_size(n), gui.get_scale(n)
return sz.x * sc.x, sz.y * sc.y
end
end
return add(self, { node = n, size = false, style = { align_self = "start" }, measure = measure }, gap)
end
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
@@ -141,10 +161,7 @@ function M:block(h, fn, gap)
end
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
local used = fn(n, self.bw)
if not h then
gui.set_size(n, vec(self.bw, used or 0))
end
return add(self, n, gap)
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
end
-- Something clickable inside the card's text (a link): fn runs once the card's own click
@@ -160,27 +177,48 @@ function M:footer(h, fn)
self.footer_h, self.footer_fn = h, fn
end
-- The card as a flex tree (modules/flex): the image column (or strip) and the text
-- column, whose items stack from the top and whose footer a growing spacer pushes to the
-- bottom; h: the card's height (nil: its content's, at least the image column's).
function M:tree(h)
local kids = {}
for i, b in ipairs(self.items) do
kids[i] = b
end
kids[#kids + 1] = { style = { grow = 1 } }
self.footer_box = { style = { width = self.bw, height = self.footer_h } }
kids[#kids + 1] = self.footer_box
local body = { node = self.body, style = { direction = "column", align = "start", grow = 1, padding = self.padding }, children = kids }
local root = { node = self.root, style = { width = self.w, height = h }, children = { body } }
if self.stacked then
root.style.direction = "column"
table.insert(root.children, 1, { node = self.media, style = { height = self.m } })
elseif self.m > 0 then
root.style.min_height = self.m
table.insert(root.children, 1, { node = self.media, style = { width = self.m } })
else
root.style.direction = "column"
end
return flex.solve(root, self.x, self.y)
end
-- Lay out the text column; returns the card's natural height.
function M:refresh()
self.layout:refresh_layout(true)
local body_h = self.layout:get_size().y
if self.stacked then
self.natural = self.m + 14 + body_h + self.footer_h + 14
else
self.natural = math.max(self.m, 12 + body_h + self.footer_h + 14)
end
local t = self:tree()
flex.apply(t)
self.natural = t.h
return self.natural
end
-- The card's final height (the tallest in its row): sizes the box and places the footer.
function M:set_height(h)
self.h = h
gui.set_size(self.root, vec(self.w, h))
local t = self:tree(h)
flex.apply(t)
gui.set_size(self.edge, vec(self.w, h))
if self.stacked then
ui.rect(self.root, 0, self.m, self.w, 1, "line")
else
gui.set_size(self.media, vec(self.m, h))
elseif self.m > 0 then
ui.rect(self.root, self.m, 0, 1, h, "line")
end
if self.selected then
@@ -203,14 +241,18 @@ function M:set_height(h)
fn()
end
if self.footer_fn then
local f = anim.group(self.root, self.stacked and PAD or (self.m + PAD), h - 14 - self.footer_h, self.bw, self.footer_h)
local fb = self.footer_box
local f = anim.group(self.root, fb.x - self.x, fb.y - self.y, fb.w, fb.h)
self.footer_fn(f, self.bw)
end
end
-- A grid of cards from y; returns the y under it. make(c, item) fills one card.
function M.grid(g, parent, x, y, inner, items, make)
-- A grid of cards from y; returns the y under it. make(c, item) fills one card. opts: the
-- cards' (M.new); opts.key(item): keep the card (modules/blocks) while opts.sig(item) (what
-- make shows besides the item; default the item alone) and its place stay the same.
function M.grid(g, parent, x, y, inner, items, make, opts)
local cols, cw = M.columns(inner)
local keyed = opts and opts.key
local i = 1
while i <= #items do
local row = {}
@@ -219,18 +261,55 @@ function M.grid(g, parent, x, y, inner, items, make)
if not item then
break
end
local c = M.new(g, parent, x + (col - 1) * (cw + S.gap), y, cw)
make(c, item)
c:refresh()
row[#row + 1] = c
local r = { item = item, x = x + (col - 1) * (cw + S.gap) }
if keyed then
r.key = opts.key(item)
r.sig = blocks.sig(r.x, y, cw, item, opts.sig and opts.sig(item) or false)
r.old = blocks.peek(g, r.key, r.sig) -- (kept, if its row's height stays the same)
end
if r.old then
r.natural = r.old.card.natural
else
if r.key then
r.blk = blocks.open(g, r.key, r.sig, parent)
end
r.c = M.new(g, r.blk and r.blk.root or parent, r.x, y, cw, opts)
make(r.c, item)
r.natural = r.c:refresh()
if r.blk then
r.blk.card = r.c
blocks.close(g, r.blk)
end
end
row[#row + 1] = r
i = i + 1
end
local rh = 0
for _, c in ipairs(row) do
rh = math.max(rh, c.natural)
for _, r in ipairs(row) do
rh = math.max(rh, r.natural)
end
for _, c in ipairs(row) do
c:set_height(rh)
for _, r in ipairs(row) do
if r.old and r.old.card.h == rh then
r.blk = blocks.open(g, r.key, r.sig, parent) -- (kept)
r.c = r.blk.card
else
if r.old then
-- The row's height changed: made again at it.
blocks.drop(g, r.old)
r.blk = blocks.open(g, r.key, r.sig, parent)
r.c = M.new(g, r.blk.root, r.x, y, cw, opts)
make(r.c, r.item)
r.c:refresh()
r.blk.card = r.c
elseif r.blk then
blocks.reopen(g, r.blk) -- (its hover and footer are the card's)
end
r.c:set_height(rh)
if r.blk then
blocks.close(g, r.blk)
end
end
local c = r.c
if g.frames then
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
end
+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.
--
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
--
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
-- pad (6 each side), min_w, right (x is the right edge).
--
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
local ui = require("modules.ui")
@@ -26,11 +28,27 @@ function M.new(parent, label, x, y, opts)
local left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
gui.move_below(f, box)
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
-- move the fill out to box's parent, at its 0, 0).
local edge = box
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
gui.set_parent(edge, box, false)
gui.set_position(edge, vmath.vector3(0, 0, 0))
end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w
end
-- Moon grade badges: edge/text color per grade, cheapest to best.
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
M.GRADE_W = 18
function M.grade(parent, grade, x, y, opts)
opts = opts or {}
local color = M.GRADE[grade] or "line_hi"
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
right = opts.right })
end
return M
+45 -16
View File
@@ -7,6 +7,9 @@
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
-- backdrop (a click outside still closes it), no title or buttons, its top-right
-- corner `right` from the page's right edge and `top` down; build makes the content.
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
@@ -29,6 +32,8 @@ local anim = require("modules.anim")
local sfx = require("modules.sfx")
local button = require("components.button")
local test = require("modules.test")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
@@ -105,12 +110,13 @@ function M:open(opts)
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
local menu = opts.menu
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1)
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
@@ -137,27 +143,46 @@ function M:open(opts)
d:close("cancel")
end)
if menu then
opts.build(self, box, w, h)
test.set_scope("page")
sfx.play("toggle")
return
end
-- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0)
local g_body = anim.group(box, 0, 0)
local g_actions = anim.group(box, 0, 0)
ui.label(g_title, opts.title or "Notice", "h2", opts.title_x or 24, 34)
-- The panel's layout (geometry only: each group builds into its own part): the title
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
-- left of Cancel (Cancel is made first).
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
local ok = opts.ok
local danger = ok and ok.danger
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
{ style = { height = 20 }, id = "title" },
flex.box({ grow = 1 }),
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
})
flex.solve(tree, 0, 0)
local title = tree.children[1]
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
if opts.build then
opts.build(self, g_body, w, h)
else
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, 58, w - 48)
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
end
local by = h - 24 - S.button_h
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
local cb, _, cw = self:button(g_actions, cancel_label, 0, 0, {
local by = cancel_box.y
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
id = "dialog:cancel", on_click = function() d:close("cancel") end,
})
gui.set_position(cb, vec(w - 24 - cw, -by))
if opts.ok then
local ok = opts.ok
local danger = ok.danger
local ob, _, ow
ob, _, ow = self:button(g_actions, ok.label or "OK", 0, 0, {
if ok then
local ob
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
id = "dialog:ok", price = ok.price,
variant = danger and "danger" or "primary",
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
@@ -173,7 +198,6 @@ function M:open(opts)
end
end,
})
gui.set_position(ob, vec(w - 24 - cw - 10 - ow, -by))
end
self.by = by
@@ -296,7 +320,12 @@ function M:layout(W, H)
gui.set_size(self.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y")
gui.set_position(self.holder, vec(W / 2, -H / 2))
local menu = self.opts and self.opts.menu
if menu then
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
else
gui.set_position(self.holder, vec(W / 2, -H / 2))
end
end
function M:update(dt)
+19 -8
View File
@@ -6,15 +6,19 @@
-- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
-- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus)
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
-- -- its node is deleted (not when the
-- -- list itself goes in a rebuild)
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
--
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
-- places it. A Druid component returned with it (a widget) is removed with the row.
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
M.__index = M
@@ -24,7 +28,7 @@ local function vec(x, y)
end
function M.new(g, parent, x, y, w, h, opts)
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = opts.create,
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = blocks.bound(opts.create), -- (rows made as it scrolls are its block's)
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
g.scroll_pos = g.scroll_pos or {}
l.view = ui.rect(parent, x, y, w, h, "text")
@@ -42,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index)
end)
if opts.on_remove then
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
if not g.tearing_down then
opts.on_remove(node, item)
end
end)
end
l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset)
@@ -49,21 +60,21 @@ function M.new(g, parent, x, y, w, h, opts)
g.scroll_pos[l.key] = pos.y
l:place_thumb(pos.y)
end)
g.scroll_areas = g.scroll_areas or {}
g.scroll_areas[#g.scroll_areas + 1] = { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll }
blocks.push(g, "scroll_areas", { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll })
return l
end
function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
local saved = self.g.scroll_pos[self.key] or 0
self.items = items
self.data:set_data(items)
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
-- scroll event).
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
-- rows there, not the top ones only to drop them again.
local s = self.scroll
s:set_size(self.grid:get_size_for(#items))
s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y
self.data:set_data(items)
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then
+3 -3
View File
@@ -5,7 +5,7 @@
-- ... build into area.content (top-left based, like any parent) ...
-- area:finish(content_height)
--
-- Screens rebuild their layout on every new state, so the offset is kept per key in
-- A section built again makes its scroll area again, so the offset is kept per key in
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
@@ -13,6 +13,7 @@
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
@@ -75,8 +76,7 @@ function Area:finish(content_h)
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
end)
local s = self_.druid:new_scroll(self.view, self.content)
self_.scroll_areas = self_.scroll_areas or {}
self_.scroll_areas[#self_.scroll_areas + 1] = { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s }
blocks.push(self_, "scroll_areas", { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s })
s:set_horizontal_scroll(false)
s:set_extra_stretch_size(0)
+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"
"}\n"
"embedded_components {\n"
" id: \"rumble\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"reveal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
+7
View File
@@ -26,6 +26,13 @@ end
function init(self)
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
-- In a browser the frames come from requestAnimationFrame at the display's rate; with
-- display.update_frequency set the engine skips callbacks to approach it (judder on
-- 120/144 Hz displays, dropped frames on jittery 60 Hz ones). Variable rate there:
-- every callback is a frame, dt is the real time.
if html5 then
sys.set_update_frequency(0)
end
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
if w then
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
+1
View File
@@ -0,0 +1 @@
name: "MMTerrain"
+37
View File
@@ -0,0 +1,37 @@
// The two calls mmterrain.c needs from the engine's C++-only buffer API, as C functions:
// the float32 x3 streams of the buffer at a Lua stack index, and marking it changed.
#include <dmsdk/sdk.h>
static float* stream(dmBuffer::HBuffer buf, const char* name, uint32_t* count, uint32_t* stride)
{
void* data = 0;
uint32_t components = 0;
if (dmBuffer::GetStream(buf, dmHashString64(name), &data, count, &components, stride) != dmBuffer::RESULT_OK)
return 0;
if (components != 3)
return 0;
dmBuffer::ValueType type;
if (dmBuffer::GetStreamType(buf, dmHashString64(name), &type, &components) != dmBuffer::RESULT_OK
|| type != dmBuffer::VALUE_TYPE_FLOAT32)
return 0;
return (float*)data;
}
extern "C" int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride)
{
dmBuffer::HBuffer buf = dmScript::CheckBufferUnpack(L, index);
uint32_t c[3], s[3];
*pos = stream(buf, "position", &c[0], &s[0]);
*nor = stream(buf, "normal", &c[1], &s[1]);
*col = stream(buf, "color", &c[2], &s[2]);
if (!*pos || !*nor || !*col || s[0] != s[1] || s[0] != s[2])
return 0;
*count = c[0];
*stride = s[0];
return 1;
}
extern "C" void mm_buffer_touch(lua_State* L, int index)
{
dmBuffer::UpdateContentVersion(dmScript::CheckBufferUnpack(L, index));
}
+201
View File
@@ -0,0 +1,201 @@
/* mmterrain: the moon generator (terrain.c) for Lua. Plain C; the engine's buffer API is
* C++ only, so buffer_shim.cpp reaches a buffer's streams for mmterrain.fill.
*
* local gen = mmterrain.new(terrain.native_params(p)) -- p: terrain.resolve's result
* mmterrain.start(gen) -> true: it runs on a thread of its own (desktop);
* false: step it (web)
* mmterrain.step(gen, secs) -> true when done (works about secs; 0: to the end)
* mmterrain.done(gen) -> true when done (threaded or stepped)
* mmterrain.vertices(gen) -> the triangle strip's length (terrain.strip)
* mmterrain.fill(gen, buf [, from, to]) fills buf's position, normal and color
* streams (float32 x3, at least vertices(gen) long) with the
* strip: its vertices from..to (1-based, inclusive; default
* all)
* mmterrain.result(gen) -> { n, count, positions, normals, colors, min_h, max_h }
* mmterrain.free(gen) frees it now (else the garbage collector does)
* Don't call anything but done/free on a started generator until done is true.
*/
#include <dmsdk/extension/extension.h>
#include <dmsdk/dlib/align.h>
#include "terrain.h"
#define META "mmterrain.gen"
/* The extender compiles .c files in C++ mode (clang++, "treating 'c' input as 'c++'"), so
* this file and terrain.c are valid as both; what links across files is extern "C". */
#ifdef __cplusplus
extern "C" {
#endif
/* buffer_shim.cpp: a buffer's three float32 x3 streams, or 0. */
int mm_buffer_streams(lua_State* L, int index, float** pos, float** nor, float** col, unsigned* count, unsigned* stride);
void mm_buffer_touch(lua_State* L, int index);
void MMTerrain(void);
#ifdef __cplusplus
}
#endif
static mt_gen** check_slot(lua_State* L)
{
return (mt_gen**)luaL_checkudata(L, 1, META);
}
static mt_gen* check_gen(lua_State* L)
{
mt_gen** slot = check_slot(L);
if (!*slot)
luaL_error(L, "mmterrain: generator already freed");
return *slot;
}
static mt_gen* check_done(lua_State* L)
{
mt_gen* g = check_gen(L);
if (!mt_done(g))
luaL_error(L, "mmterrain: generation not done");
return g;
}
static int New(lua_State* L)
{
double a[MT_PARAM_COUNT];
mt_params p;
mt_gen** slot;
int i;
luaL_checktype(L, 1, LUA_TTABLE);
for (i = 0; i < MT_PARAM_COUNT; ++i) {
lua_rawgeti(L, 1, i + 1);
a[i] = luaL_checknumber(L, -1);
lua_pop(L, 1);
}
mt_params_from_array(a, MT_PARAM_COUNT, &p);
slot = (mt_gen**)lua_newuserdata(L, sizeof(mt_gen*));
*slot = 0;
luaL_getmetatable(L, META);
lua_setmetatable(L, -2);
*slot = mt_new(&p);
if (!*slot)
return luaL_error(L, "mmterrain: out of memory");
return 1;
}
static int Start(lua_State* L)
{
lua_pushboolean(L, mt_start(check_gen(L)));
return 1;
}
static int Step(lua_State* L)
{
mt_gen* g = check_gen(L);
lua_pushboolean(L, mt_done(g) || mt_step(g, luaL_optnumber(L, 2, 0)));
return 1;
}
static int Done(lua_State* L)
{
lua_pushboolean(L, mt_done(check_gen(L)));
return 1;
}
static int Vertices(lua_State* L)
{
lua_pushinteger(L, mt_strip_len(mt_n(check_gen(L))));
return 1;
}
static int Fill(lua_State* L)
{
mt_gen* g = check_done(L);
float *pos, *nor, *col;
unsigned count, stride;
if (!mm_buffer_streams(L, 2, &pos, &nor, &col, &count, &stride))
return luaL_error(L, "mmterrain.fill: needs position, normal and color streams (float32 x3)");
int len = mt_strip_len(mt_n(g));
int from = (int)luaL_optinteger(L, 3, 1) - 1, to = (int)luaL_optinteger(L, 4, len);
if (count < (unsigned)len)
return luaL_error(L, "mmterrain.fill: buffer too short");
mt_fill(g, pos, nor, col, (int)stride, from < 0 ? 0 : from, to > len ? len : to);
mm_buffer_touch(L, 2);
return 0;
}
static void push_array(lua_State* L, const double* a, int n)
{
int i;
lua_createtable(L, n, 0);
for (i = 0; i < n; ++i) {
lua_pushnumber(L, a[i]);
lua_rawseti(L, -2, i + 1);
}
}
static int Result(lua_State* L)
{
mt_gen* g = check_done(L);
int c = mt_count(g);
lua_createtable(L, 0, 7);
lua_pushinteger(L, mt_n(g));
lua_setfield(L, -2, "n");
lua_pushinteger(L, c);
lua_setfield(L, -2, "count");
push_array(L, mt_positions(g), c * 3);
lua_setfield(L, -2, "positions");
push_array(L, mt_normals(g), c * 3);
lua_setfield(L, -2, "normals");
push_array(L, mt_colors(g), c * 3);
lua_setfield(L, -2, "colors");
lua_pushnumber(L, mt_min_h(g));
lua_setfield(L, -2, "min_h");
lua_pushnumber(L, mt_max_h(g));
lua_setfield(L, -2, "max_h");
return 1;
}
static int Free(lua_State* L)
{
mt_gen** slot = check_slot(L);
mt_free(*slot); /* (joins a running thread) */
*slot = 0;
return 0;
}
static const luaL_Reg Module_methods[] = {
{ "new", New },
{ "start", Start },
{ "step", Step },
{ "done", Done },
{ "vertices", Vertices },
{ "fill", Fill },
{ "result", Result },
{ "free", Free },
{ 0, 0 },
};
static ExtensionResult Initialize(ExtensionParams* params)
{
lua_State* L = params->m_L;
int top = lua_gettop(L);
luaL_newmetatable(L, META);
lua_pushcfunction(L, Free);
lua_setfield(L, -2, "__gc");
lua_pop(L, 1);
luaL_register(L, "mmterrain", Module_methods);
lua_pop(L, 1);
(void)top;
return EXTENSION_RESULT_OK;
}
static ExtensionResult Finalize(ExtensionParams* params)
{
(void)params;
return EXTENSION_RESULT_OK;
}
/* DM_DECLARE_EXTENSION, written out for C (the macro's function is extern "C"). */
static uint8_t DM_ALIGNED(16) g_Desc[128]; /* ExtensionDescBufferSize (a const, not a C constant) */
void MMTerrain(void)
{
ExtensionRegister((void*)g_Desc, sizeof(g_Desc), "MMTerrain", 0, 0, Initialize, Finalize, 0, 0);
}
+830
View File
@@ -0,0 +1,830 @@
/* The moon generator (see terrain.h), a port of the server's web/static/js/terrain.js:
* keep the two in sync (scripts/moon-check.mjs compares them). */
#if !defined(_WIN32) && !defined(_POSIX_C_SOURCE)
#define _POSIX_C_SOURCE 199309L /* clock_gettime */
#endif
#include "terrain.h"
#include <math.h>
#if MT_THREADS
#include <pthread.h>
#endif
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#include <windows.h>
#else
#include <time.h>
#endif
/* The same rounding as the JS: no fused multiply-adds. */
#if defined(__clang__)
#pragma clang fp contract(off)
#elif defined(__GNUC__)
#pragma GCC optimize("fp-contract=off")
#endif
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define MAX_OCTAVES 10
#define MAX_CRATERS 2000
#define GRID 12
static double clock_secs(void)
{
#if defined(_WIN32)
LARGE_INTEGER f, c;
QueryPerformanceFrequency(&f);
QueryPerformanceCounter(&c);
return (double)c.QuadPart / (double)f.QuadPart;
#else
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + ts.tv_nsec * 1e-9;
#endif
}
static double dmin(double a, double b) { return a < b ? a : b; }
static double dmax(double a, double b) { return a > b ? a : b; }
static double lround_(double x) { return floor(x + 0.5); }
/* ------------------------------------------------------------ random */
/* mulberry32, uniform in [0, 1). */
typedef struct { uint32_t a; } mt_rng;
static mt_rng rng_new(double seed)
{
mt_rng r;
/* bit.tobit: the number modulo 2^32 */
r.a = (uint32_t)(int64_t)fmod(seed, 4294967296.0);
return r;
}
static double rng_next(mt_rng* r)
{
uint32_t t;
r->a += 0x6d2b79f5u;
t = (r->a ^ (r->a >> 15)) * (1u | r->a);
t = (t + (t ^ (t >> 7)) * (61u | t)) ^ t;
return (double)(t ^ (t >> 14)) / 4294967296.0;
}
/* ------------------------------------------------------------ noise */
static const double GRAD3[36] = {
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
};
static int PERM[512];
static double GX[256], GY[256], GZ[256];
static int perm_ready = 0;
static void perm_init(void)
{
int p[256], i;
mt_rng r;
if (perm_ready)
return;
for (i = 0; i < 256; ++i)
p[i] = i;
r = rng_new(1337);
for (i = 255; i >= 1; --i) {
int j = (int)floor(rng_next(&r) * (i + 1));
int t = p[i];
p[i] = p[j];
p[j] = t;
}
for (i = 0; i < 512; ++i)
PERM[i] = p[i & 255];
for (i = 0; i < 256; ++i) {
int g = (i % 12) * 3;
GX[i] = GRAD3[g];
GY[i] = GRAD3[g + 1];
GZ[i] = GRAD3[g + 2];
}
perm_ready = 1;
}
#define F3 (1.0 / 3.0)
#define G3 (1.0 / 6.0)
/* floor without a libm call (x86_64's baseline has no rounding instruction); the same
* value for |x| < 2^53. */
static double ffloor(double x)
{
double t = (double)(int64_t)x;
return t > x ? t - 1 : t;
}
/* terrain.lua simplex3 (branch-free, the same values as the reference's if/else chains,
* and faster here too: the corner order is unpredictable). */
static double simplex3(double x, double y, double z)
{
const int* P = PERM;
double s = (x + y + z) * F3;
double i = ffloor(x + s), j = ffloor(y + s), k = ffloor(z + s);
double t = (i + j + k) * G3;
double x0 = x - (i - t), y0 = y - (j - t), z0 = z - (k - t);
double a = x0 >= y0 ? 1 : 0; /* 1 + floor(x0 - y0) in the Lua: |x0 - y0| < 1 */
double b = y0 >= z0 ? 1 : 0;
double c = x0 >= z0 ? 1 : 0;
double i1 = a * (b + (1 - b) * c);
double j1 = (1 - a) * b;
double k1 = 1 - i1 - j1;
double i2 = a + (1 - a) * b * c;
double j2 = a * b + 1 - a;
double k2 = 2 - i2 - j2;
int ii = (int)((int64_t)i & 255), jj = (int)((int64_t)j & 255), kk = (int)((int64_t)k & 255);
int ii1 = (int)i1, jj1 = (int)j1, kk1 = (int)k1, ii2 = (int)i2, jj2 = (int)j2, kk2 = (int)k2;
double tt, n, x1, y1, z1, x2, y2, z2, x3, y3, z3;
int g;
tt = dmax(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0);
g = P[ii + P[jj + P[kk]]];
tt = tt * tt;
n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0);
x1 = x0 - i1 + G3; y1 = y0 - j1 + G3; z1 = z0 - k1 + G3;
tt = dmax(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1);
g = P[ii + ii1 + P[jj + jj1 + P[kk + kk1]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1);
x2 = x0 - i2 + 2 * G3; y2 = y0 - j2 + 2 * G3; z2 = z0 - k2 + 2 * G3;
tt = dmax(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2);
g = P[ii + ii2 + P[jj + jj2 + P[kk + kk2]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2);
x3 = x0 - 1 + 3 * G3; y3 = y0 - 1 + 3 * G3; z3 = z0 - 1 + 3 * G3;
tt = dmax(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3);
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]];
tt = tt * tt;
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3);
return 32 * n;
}
static double fbm(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
{
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
sum = sum + simplex3(x * f, y * f, z * f) * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double billow(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity)
{
double sum = 0, amp = 1, norm = 0, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
sum = sum + fabs(simplex3(x * f, y * f, z * f)) * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double ridged(double x, double y, double z, double octaves, double frequency, double persistence, double lacunarity,
double sharpness, double gain)
{
double sum = 0, amp = 1, norm = 0, weight = 1, f = frequency, o, top = dmin(octaves, MAX_OCTAVES);
for (o = 1; o <= top; o += 1) {
double v = 1 - fabs(simplex3(x * f, y * f, z * f));
v = pow(v, sharpness) * weight;
weight = dmin(1, dmax(0, v * gain));
sum = sum + v * amp;
norm = norm + amp;
amp = amp * persistence;
f = f * lacunarity;
}
return sum / norm;
}
static double smooth_min(double a, double b, double k)
{
double h;
if (k <= 0)
return dmin(a, b);
h = dmin(1, dmax(0, (b - a + k) / (2 * k)));
return a * h + b * (1 - h) - k * h * (1 - h);
}
static double smooth_max(double a, double b, double k)
{
return -smooth_min(-a, -b, k);
}
static double smoothstep(double e0, double e1, double x)
{
double d = e1 - e0, t;
if (d == 0)
d = 1e-6;
t = dmin(1, dmax(0, (x - e0) / d));
return t * t * (3 - 2 * t);
}
/* ------------------------------------------------------------ colors */
int mt_params_from_array(const double* a, int n, mt_params* p)
{
int i = 0, k;
if (n < MT_PARAM_COUNT)
return 0;
p->resolution = a[i++];
p->technique = (int)a[i++];
p->shape_strength = a[i++]; p->shape_frequency = a[i++]; p->shape_octaves = a[i++];
p->shape_persistence = a[i++]; p->shape_lacunarity = a[i++]; p->shape_min = a[i++];
p->ridge_strength = a[i++]; p->ridge_frequency = a[i++]; p->ridge_octaves = a[i++];
p->ridge_persistence = a[i++]; p->ridge_lacunarity = a[i++]; p->ridge_sharpness = a[i++];
p->ridge_gain = a[i++]; p->ridge_min = a[i++];
p->warp_strength = a[i++]; p->warp_frequency = a[i++]; p->warp_octaves = a[i++];
p->crater_count = a[i++]; p->crater_min_radius = a[i++]; p->crater_max_radius = a[i++];
p->crater_size_bias = a[i++]; p->crater_depth = a[i++]; p->crater_rim_width = a[i++];
p->crater_rim_steepness = a[i++]; p->crater_floor = a[i++]; p->crater_smoothness = a[i++];
p->crater_ejecta = a[i++];
for (k = 0; k < 3; ++k) p->low[k] = a[i++];
for (k = 0; k < 3; ++k) p->mid[k] = a[i++];
for (k = 0; k < 3; ++k) p->high[k] = a[i++];
for (k = 0; k < 3; ++k) p->steep[k] = a[i++];
for (k = 0; k < 3; ++k) p->patch[k] = a[i++];
p->steep_threshold = a[i++]; p->steep_blend = a[i++]; p->patch_amount = a[i++];
p->patch_frequency = a[i++]; p->grain = a[i++];
for (k = 0; k < 3; ++k) p->offset[k] = a[i++];
p->crater_seed = a[i++];
return 1;
}
/* ------------------------------------------------------------ generator */
typedef struct crater {
double x, y, z, r, reach;
} crater;
enum { ST_DIRS, ST_CRATERS, ST_HEIGHTS, ST_MEAN, ST_POSITIONS, ST_NORMALS, ST_SEAMS, ST_NORMALIZE, ST_COLORS, ST_DONE };
struct mt_gen {
mt_params p;
#if MT_THREADS
pthread_t thread;
int threaded;
int finished; /* (set by the thread, read with atomics) */
#endif
int n, row, per, count;
int stage, i;
double* dirs;
int* keys;
double *heights, *ejecta, *positions, *normals, *colors;
crater* craters;
int n_craters;
int* cell_start; /* GRID^3 + 1 offsets into cell_list */
int* cell_list;
double x_max, mean, min_h, max_h;
};
static const double FACES[6][9] = {
{ 1, 0, 0, 0, 1, 0, 0, 0, 1 },
{ -1, 0, 0, 0, 0, 1, 0, 1, 0 },
{ 0, 1, 0, 0, 0, 1, 1, 0, 0 },
{ 0, -1, 0, 1, 0, 0, 0, 0, 1 },
{ 0, 0, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 0, -1, 0, 1, 0, 1, 0, 0 },
};
mt_gen* mt_new(const mt_params* p)
{
mt_gen* g = (mt_gen*)calloc(1, sizeof(mt_gen));
size_t c3;
if (!g)
return 0;
perm_init();
g->p = *p;
g->n = (int)dmax(8, dmin(320, lround_(p->resolution)));
g->row = g->n + 1;
g->per = g->row * g->row;
g->count = 6 * g->per;
c3 = (size_t)g->count * 3;
g->dirs = (double*)malloc(c3 * sizeof(double));
g->keys = (int*)malloc((size_t)g->count * sizeof(int));
g->heights = (double*)malloc((size_t)g->count * sizeof(double));
g->ejecta = (double*)malloc((size_t)g->count * sizeof(double));
g->positions = (double*)malloc(c3 * sizeof(double));
g->normals = (double*)calloc(c3, sizeof(double));
g->colors = (double*)malloc(c3 * sizeof(double));
if (!g->dirs || !g->keys || !g->heights || !g->ejecta || !g->positions || !g->normals || !g->colors) {
mt_free(g);
return 0;
}
g->stage = ST_DIRS;
return g;
}
void mt_free(mt_gen* g)
{
if (!g)
return;
#if MT_THREADS
if (g->threaded)
pthread_join(g->thread, 0);
#endif
free(g->dirs);
free(g->keys);
free(g->heights);
free(g->ejecta);
free(g->positions);
free(g->normals);
free(g->colors);
free(g->craters);
free(g->cell_start);
free(g->cell_list);
free(g);
}
static void dir_at(mt_gen* g, int v)
{
int n = g->n, row = g->row, f = v / g->per, r = v % g->per, j = r / row, i = r % row;
const double* F = FACES[f];
double s = ((double)i / n) * 2 - 1;
double t = ((double)j / n) * 2 - 1;
double cx = F[0] + s * F[3] + t * F[6];
double cy = F[1] + s * F[4] + t * F[7];
double cz = F[2] + s * F[5] + t * F[8];
double x2 = cx * cx, y2 = cy * cy, z2 = cz * cz;
g->dirs[v * 3] = cx * sqrt(1 - y2 / 2 - z2 / 2 + (y2 * z2) / 3);
g->dirs[v * 3 + 1] = cy * sqrt(1 - x2 / 2 - z2 / 2 + (x2 * z2) / 3);
g->dirs[v * 3 + 2] = cz * sqrt(1 - x2 / 2 - y2 / 2 + (x2 * y2) / 3);
if (i == 0 || j == 0 || i == n || j == n) {
int kx = (int)lround_(((cx + 1) / 2) * n), ky = (int)lround_(((cy + 1) / 2) * n), kz = (int)lround_(((cz + 1) / 2) * n);
g->keys[v] = (kx * row + ky) * row + kz;
} else {
g->keys[v] = -1;
}
}
static int cell_at(double v)
{
double cell = 2.0 / GRID;
return (int)dmin(GRID - 1, dmax(0, floor((v + 1) / cell)));
}
/* terrain.lua make_craters: the list, then each grid cell's craters in list order. */
static int make_craters(mt_gen* g)
{
const mt_params* p = &g->p;
int field = p->technique == MT_CRATER_FIELD;
double min_r = field ? p->crater_min_radius * 0.8 : p->crater_min_radius;
double max_r = field ? p->crater_max_radius * 0.6 : p->crater_max_radius;
double scale = field ? 3 : 1;
int count = (int)lround_(dmin(p->crater_count, MAX_CRATERS) * scale), i, c, x, y, z, total;
mt_rng r = rng_new(p->crater_seed);
int cells = GRID * GRID * GRID;
int* fill;
if (count < 0)
count = 0;
g->x_max = dmax(1 + p->crater_rim_width, sqrt(1 + dmax(0, p->crater_smoothness)));
g->craters = (crater*)malloc((size_t)(count > 0 ? count : 1) * sizeof(crater));
g->cell_start = (int*)calloc((size_t)cells + 1, sizeof(int));
if (!g->craters || !g->cell_start)
return 0;
for (i = 0; i < count; ++i) {
double zz = rng_next(&r) * 2 - 1;
double a = rng_next(&r) * M_PI * 2;
double q = sqrt(1 - zz * zz);
double radius = min_r + (max_r - min_r) * pow(rng_next(&r), p->crater_size_bias);
crater* k = &g->craters[i];
k->x = q * cos(a);
k->y = q * sin(a);
k->z = zz;
k->r = radius;
k->reach = radius * g->x_max;
}
g->n_craters = count;
/* Two passes: counts per cell, then the lists. */
for (c = 0; c < count; ++c) {
crater* k = &g->craters[c];
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z)
g->cell_start[(x * GRID + y) * GRID + z + 1]++;
}
for (i = 0; i < cells; ++i)
g->cell_start[i + 1] += g->cell_start[i];
total = g->cell_start[cells];
g->cell_list = (int*)malloc((size_t)(total > 0 ? total : 1) * sizeof(int));
fill = (int*)calloc((size_t)cells, sizeof(int));
if (!g->cell_list || !fill) {
free(fill);
return 0;
}
for (c = 0; c < count; ++c) {
crater* k = &g->craters[c];
for (x = cell_at(k->x - k->reach); x <= cell_at(k->x + k->reach); ++x)
for (y = cell_at(k->y - k->reach); y <= cell_at(k->y + k->reach); ++y)
for (z = cell_at(k->z - k->reach); z <= cell_at(k->z + k->reach); ++z) {
int cell = (x * GRID + y) * GRID + z;
g->cell_list[g->cell_start[cell] + fill[cell]++] = c;
}
}
free(fill);
return 1;
}
static void height_at(mt_gen* g, int i)
{
const mt_params* p = &g->p;
int field = p->technique == MT_CRATER_FIELD;
double x = g->dirs[i * 3], y = g->dirs[i * 3 + 1], z = g->dirs[i * 3 + 2];
double px = x + p->offset[0], py = y + p->offset[1], pz = z + p->offset[2];
double value, h, crater_h = 0, glow = 0;
double shape_strength = field ? p->shape_strength * 0.4 : p->shape_strength;
double smooth = dmax(0, p->crater_smoothness);
double ejecta_reach = 1 + p->crater_rim_width * 2.5;
double x_max = g->x_max;
int cell, c, end;
switch (p->technique) {
case MT_RIDGED:
value = ridged(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity,
p->ridge_sharpness, p->ridge_gain);
break;
case MT_BILLOW:
value = billow(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity);
break;
case MT_WARPED: {
double wf = p->warp_frequency;
double wx = fbm(px + 5.2, py + 1.3, pz, p->warp_octaves, wf, 0.5, 2);
double wy = fbm(px, py + 9.7, pz + 2.8, p->warp_octaves, wf, 0.5, 2);
double wz = fbm(px + 3.1, py, pz + 7.4, p->warp_octaves, wf, 0.5, 2);
double s = p->warp_strength;
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, p->shape_octaves, p->shape_frequency, p->shape_persistence,
p->shape_lacunarity) + 1) / 2;
break;
}
default:
value = (fbm(px, py, pz, p->shape_octaves, p->shape_frequency, p->shape_persistence, p->shape_lacunarity) + 1) / 2;
break;
}
h = dmax(0, value - p->shape_min) * shape_strength;
if (p->ridge_strength != 0) {
double r = ridged(px - 11.3, py + 4.1, pz - 7.9, p->ridge_octaves, p->ridge_frequency, p->ridge_persistence,
p->ridge_lacunarity, p->ridge_sharpness, p->ridge_gain);
h = h + dmax(0, r - p->ridge_min) * p->ridge_strength;
}
cell = (cell_at(x) * GRID + cell_at(y)) * GRID + cell_at(z);
end = g->cell_start[cell + 1];
for (c = g->cell_start[cell]; c < end; ++c) {
const crater* k = &g->craters[g->cell_list[c]];
double dx = x - k->x, dy = y - k->y, dz = z - k->z;
double d2 = dx * dx + dy * dy + dz * dz;
double reach = dmax(k->reach, k->r * ejecta_reach);
if (d2 <= reach * reach) {
double dist = sqrt(d2) / k->r;
if (dist < x_max) {
double cavity = dist * dist - 1;
double rim_x = dmin(dist - 1 - p->crater_rim_width, 0);
double rim = p->crater_rim_steepness * rim_x * rim_x;
double shape = smooth_max(cavity, p->crater_floor, smooth);
shape = smooth_min(shape, rim, smooth);
crater_h = crater_h + shape * k->r;
}
if (dist > 0.8 && dist < ejecta_reach)
glow = glow + 1 - smoothstep(1, ejecta_reach, dist);
}
}
g->heights[i] = h + crater_h * p->crater_depth;
g->ejecta[i] = dmin(1, glow);
}
static void tri(mt_gen* g, int a, int b, int c)
{
const double* P = g->positions;
double* N = g->normals;
double e1x, e1y, e1z, e2x, e2y, e2z, nx, ny, nz;
a *= 3;
b *= 3;
c *= 3;
e1x = P[b] - P[a]; e1y = P[b + 1] - P[a + 1]; e1z = P[b + 2] - P[a + 2];
e2x = P[c] - P[a]; e2y = P[c + 1] - P[a + 1]; e2z = P[c + 2] - P[a + 2];
nx = e1y * e2z - e1z * e2y;
ny = e1z * e2x - e1x * e2z;
nz = e1x * e2y - e1y * e2x;
N[a] += nx; N[a + 1] += ny; N[a + 2] += nz;
N[b] += nx; N[b + 1] += ny; N[b + 2] += nz;
N[c] += nx; N[c + 1] += ny; N[c + 2] += nz;
}
typedef struct seam {
int key, index;
} seam;
static int seam_cmp(const void* a, const void* b)
{
const seam* x = (const seam*)a;
const seam* y = (const seam*)b;
if (x->key != y->key)
return x->key < y->key ? -1 : 1;
return x->index < y->index ? -1 : (x->index > y->index);
}
/* Faces duplicate their edge vertices: each copy gets the sum (in index order, like the
* Lua's ipairs over its group). */
static int seams(mt_gen* g)
{
int count = g->count, i, m = 0, a, b, k;
seam* list = (seam*)malloc((size_t)count * sizeof(seam));
if (!list)
return 0;
for (i = 0; i < count; ++i)
if (g->keys[i] >= 0) {
list[m].key = g->keys[i];
list[m].index = i;
++m;
}
qsort(list, (size_t)m, sizeof(seam), seam_cmp);
for (a = 0; a < m; a = b) {
double sx = 0, sy = 0, sz = 0;
for (b = a; b < m && list[b].key == list[a].key; ++b) {
}
if (b - a < 2)
continue;
for (k = a; k < b; ++k) {
int o = list[k].index * 3;
sx = sx + g->normals[o];
sy = sy + g->normals[o + 1];
sz = sz + g->normals[o + 2];
}
for (k = a; k < b; ++k) {
int o = list[k].index * 3;
g->normals[o] = sx;
g->normals[o + 1] = sy;
g->normals[o + 2] = sz;
}
}
free(list);
return 1;
}
static void color_at(mt_gen* g, int i)
{
const mt_params* p = &g->p;
int o = i * 3;
double x = g->dirs[o], y = g->dirs[o + 1], z = g->dirs[o + 2];
double ox = p->offset[0], oy = p->offset[1], oz = p->offset[2];
double range = g->max_h - g->min_h;
double e, te, c1, c2, c3, gg, steepness, ws, gr;
const double *lo, *hi;
if (range == 0)
range = 1;
e = (g->heights[i] - g->min_h) / range;
if (e < 0.5) {
lo = p->low; hi = p->mid; te = e * 2;
} else {
lo = p->mid; hi = p->high; te = (e - 0.5) * 2;
}
c1 = lo[0] + (hi[0] - lo[0]) * te;
c2 = lo[1] + (hi[1] - lo[1]) * te;
c3 = lo[2] + (hi[2] - lo[2]) * te;
if (p->patch_amount > 0) {
double pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, p->patch_frequency, 0.5, 2);
double w = smoothstep(-0.05, 0.4, pn) * p->patch_amount * (1 - e * 0.6);
c1 = c1 + (p->patch[0] - c1) * w;
c2 = c2 + (p->patch[1] - c2) * w;
c3 = c3 + (p->patch[2] - c3) * w;
}
gg = g->ejecta[i] * p->crater_ejecta;
c1 = c1 + (p->high[0] - c1) * gg;
c2 = c2 + (p->high[1] - c2) * gg;
c3 = c3 + (p->high[2] - c3) * gg;
steepness = 1 - (g->normals[o] * x + g->normals[o + 1] * y + g->normals[o + 2] * z);
ws = smoothstep(p->steep_threshold - p->steep_blend, p->steep_threshold + p->steep_blend, steepness);
c1 = c1 + (p->steep[0] - c1) * ws;
c2 = c2 + (p->steep[1] - c2) * ws;
c3 = c3 + (p->steep[2] - c3) * ws;
gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * p->grain;
g->colors[o] = c1 * gr;
g->colors[o + 1] = c2 * gr;
g->colors[o + 2] = c3 * gr;
}
int mt_step(mt_gen* g, double budget)
{
double deadline = clock_secs() + budget;
int count = g->count, every = 0;
#define DUE() (budget > 0 && (++every & 31) == 0 && clock_secs() > deadline)
while (g->stage != ST_DONE) {
switch (g->stage) {
case ST_DIRS:
while (g->i < count) {
dir_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_CRATERS;
break;
case ST_CRATERS:
if (!make_craters(g)) {
g->stage = ST_DONE; /* (out of memory: a flat moon) */
break;
}
g->stage = ST_HEIGHTS;
g->i = 0;
break;
case ST_HEIGHTS:
while (g->i < count) {
height_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_MEAN;
break;
case ST_MEAN: {
double mean = 0;
int i;
for (i = 0; i < count; ++i)
mean = mean + g->heights[i];
g->mean = mean / count;
g->min_h = HUGE_VAL;
g->max_h = -HUGE_VAL;
g->stage = ST_POSITIONS;
g->i = 0;
break;
}
case ST_POSITIONS: {
int i;
for (i = 0; i < count; ++i) {
double hh = g->heights[i] - g->mean, r;
g->heights[i] = hh;
r = 1 + hh;
g->positions[i * 3] = g->dirs[i * 3] * r;
g->positions[i * 3 + 1] = g->dirs[i * 3 + 1] * r;
g->positions[i * 3 + 2] = g->dirs[i * 3 + 2] * r;
if (hh < g->min_h)
g->min_h = hh;
if (hh > g->max_h)
g->max_h = hh;
}
g->stage = ST_NORMALS;
g->i = 0;
break;
}
case ST_NORMALS: {
/* g->i: the row (face * n + y) */
int n = g->n, rows = 6 * n;
while (g->i < rows) {
int f = g->i / n, y = g->i % n, x;
int base = f * g->per;
for (x = 0; x < n; ++x) {
int i = base + y * g->row + x;
tri(g, i, i + 1, i + n + 2);
tri(g, i, i + n + 2, i + n + 1);
}
++g->i;
if (budget > 0 && clock_secs() > deadline)
goto out;
}
g->stage = ST_SEAMS;
break;
}
case ST_SEAMS:
seams(g);
g->stage = ST_NORMALIZE;
break;
case ST_NORMALIZE: {
int i;
for (i = 0; i < count; ++i) {
int o = i * 3;
double nx = g->normals[o], ny = g->normals[o + 1], nz = g->normals[o + 2];
double len = sqrt(nx * nx + ny * ny + nz * nz);
if (len == 0)
len = 1;
g->normals[o] = nx / len;
g->normals[o + 1] = ny / len;
g->normals[o + 2] = nz / len;
}
g->stage = ST_COLORS;
g->i = 0;
break;
}
case ST_COLORS:
while (g->i < count) {
color_at(g, g->i++);
if (DUE())
goto out;
}
g->stage = ST_DONE;
break;
}
}
out:
#undef DUE
return g->stage == ST_DONE;
}
int mt_n(const mt_gen* g) { return g->n; }
int mt_count(const mt_gen* g) { return g->count; }
const double* mt_positions(const mt_gen* g) { return g->positions; }
const double* mt_normals(const mt_gen* g) { return g->normals; }
const double* mt_colors(const mt_gen* g) { return g->colors; }
double mt_min_h(const mt_gen* g) { return g->min_h; }
double mt_max_h(const mt_gen* g) { return g->max_h; }
int mt_strip_len(int n)
{
/* per face n rows of 2 (n + 1), joined by 2 repeated vertices (none before the first) */
return 6 * n * 2 * (n + 1) + 2 * (6 * n - 1);
}
void mt_strip(int n, int* out)
{
int row = n + 1, per = row * row, c = 0, f, j, i;
for (f = 0; f < 6; ++f) {
int base = f * per;
for (j = 0; j < n; ++j) {
if (c > 0) {
out[c] = out[c - 1];
out[c + 1] = base + (j + 1) * row;
c += 2;
}
for (i = 0; i <= n; ++i) {
out[c] = base + (j + 1) * row + i;
out[c + 1] = base + j * row + i;
c += 2;
}
}
}
}
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to)
{
int n = g->n, row = n + 1, per = row * row, k = 0, f, j, i, last = -1;
/* the strip of mt_strip, walked without the index array */
#define PUT(v) \
do { \
int v3 = (v) * 3; \
if (k < from || k >= to) { \
last = (v); \
++k; \
break; \
} \
float* pp = pos ? pos + (size_t)k * stride : 0; \
float* nn = nor ? nor + (size_t)k * stride : 0; \
float* cc = col ? col + (size_t)k * stride : 0; \
if (pp) { pp[0] = (float)g->positions[v3]; pp[1] = (float)g->positions[v3 + 1]; pp[2] = (float)g->positions[v3 + 2]; } \
if (nn) { nn[0] = (float)g->normals[v3]; nn[1] = (float)g->normals[v3 + 1]; nn[2] = (float)g->normals[v3 + 2]; } \
if (cc) { cc[0] = (float)g->colors[v3]; cc[1] = (float)g->colors[v3 + 1]; cc[2] = (float)g->colors[v3 + 2]; } \
last = (v); \
++k; \
} while (0)
for (f = 0; f < 6; ++f) {
int base = f * per;
for (j = 0; j < n; ++j) {
if (k > 0) {
int prev = last;
PUT(prev);
PUT(base + (j + 1) * row);
}
for (i = 0; i <= n; ++i) {
PUT(base + (j + 1) * row + i);
PUT(base + j * row + i);
}
}
}
#undef PUT
}
#if MT_THREADS
static void* run_thread(void* arg)
{
mt_gen* g = (mt_gen*)arg;
mt_step(g, 0);
__atomic_store_n(&g->finished, 1, __ATOMIC_RELEASE);
return 0;
}
#endif
int mt_start(mt_gen* g)
{
#if MT_THREADS
if (g->threaded || g->stage == ST_DONE)
return g->threaded;
if (pthread_create(&g->thread, 0, run_thread, g) != 0)
return 0;
g->threaded = 1;
return 1;
#else
(void)g;
return 0;
#endif
}
int mt_done(mt_gen* g)
{
#if MT_THREADS
if (g->threaded)
return __atomic_load_n(&g->finished, __ATOMIC_ACQUIRE);
#endif
return g->stage == ST_DONE;
}
+86
View File
@@ -0,0 +1,86 @@
/* The moon generator in C: a port of the server's web/static/js/terrain.js generation.
* Same random draws (mulberry32, the simplex permutation) and the same arithmetic in the
* same order, in doubles, so the output matches terrain.js within its Float32Array
* rounding.
*
* Plain C, no engine or Lua headers: mmterrain.c binds it for the engine, and
* scripts/moon-check.mjs loads it into luajit through the FFI (a host build) to compare
* it with terrain.js. The work runs in steps with a time budget (mt_step), so a slow
* target (wasm) can spread it over frames.
*/
#ifndef MM_TERRAIN_H
#define MM_TERRAIN_H
#ifndef MT_THREADS
#if defined(__EMSCRIPTEN__) || defined(_WIN32)
#define MT_THREADS 0
#else
#define MT_THREADS 1
#endif
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* Techniques, in terrain.lua's TECHNIQUES order. */
enum { MT_FBM = 0, MT_RIDGED = 1, MT_WARPED = 2, MT_BILLOW = 3, MT_CRATER_FIELD = 4 };
/* The resolved parameters of one moon (terrain.lua resolve), colors already linear RGB. */
typedef struct mt_params {
double resolution;
int technique;
double shape_strength, shape_frequency, shape_octaves, shape_persistence, shape_lacunarity, shape_min;
double ridge_strength, ridge_frequency, ridge_octaves, ridge_persistence, ridge_lacunarity,
ridge_sharpness, ridge_gain, ridge_min;
double warp_strength, warp_frequency, warp_octaves;
double crater_count, crater_min_radius, crater_max_radius, crater_size_bias, crater_depth,
crater_rim_width, crater_rim_steepness, crater_floor, crater_smoothness, crater_ejecta;
double low[3], mid[3], high[3], steep[3], patch[3];
double steep_threshold, steep_blend, patch_amount, patch_frequency, grain;
double offset[3];
double crater_seed;
} mt_params;
typedef struct mt_gen mt_gen;
/* A generator for p (copied). NULL when out of memory. */
mt_gen* mt_new(const mt_params* p);
void mt_free(mt_gen* g);
/* Works for about `budget` seconds (<= 0: until done); returns 1 when done. */
int mt_step(mt_gen* g, double budget);
/* Where there are threads (desktop: MT_THREADS), runs the whole generation on a thread of
* its own and returns 1 (then only mt_done and, once done, the results may be used);
* else returns 0 (step it instead). */
int mt_start(mt_gen* g);
/* 1 once the generation is complete (threaded or stepped). */
int mt_done(mt_gen* g);
/* The grid size, its vertex count, and (when done) the flat xyz arrays over the grid
* (vertex v at [3v..3v+2]) and the height range, like terrain.js generateMoon's. */
int mt_n(const mt_gen* g);
int mt_count(const mt_gen* g);
const double* mt_positions(const mt_gen* g);
const double* mt_normals(const mt_gen* g);
const double* mt_colors(const mt_gen* g);
double mt_min_h(const mt_gen* g);
double mt_max_h(const mt_gen* g);
/* The triangle strip over the grid (terrain.lua strip): its length, and its 0-based grid
* indices into out (mt_strip_len(n) ints). */
int mt_strip_len(int n);
void mt_strip(int n, int* out);
/* Writes the strip's positions, normals and colors as floats, its vertices k in
* [from, to): vertex k at pos[k * stride ..], nor[k * stride ..], col[k * stride ..] (any
* may be NULL). */
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
/* The parameters from a flat array in terrain.lua native_params order (MT_PARAM_COUNT
* numbers); returns 0 when it is too short. */
#define MT_PARAM_COUNT 53
int mt_params_from_array(const double* a, int n, mt_params* p);
#ifdef __cplusplus
}
#endif
#endif
+27 -1
View File
@@ -6,6 +6,10 @@
// mmwindow.set_fullscreen(true | false)
// mmwindow.is_fullscreen() -> bool
//
// Also sticky mouse buttons and keys: the engine polls button and key states once a frame,
// so a press and release between two polls (a quick click or a touchpad tap at a low frame
// rate) was lost; sticky, a press released before the poll still reads as pressed once.
//
// The GLFW functions are the engine's (statically linked); declared here, no header.
#define EXTENSION_NAME MMWindow
@@ -32,8 +36,12 @@ void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
void glfwSetInputMode(GLFWwindow* window, int mode, int value);
}
#define MM_GLFW_STICKY_KEYS 0x00033002
#define MM_GLFW_STICKY_MOUSE_BUTTONS 0x00033003
static int g_X, g_Y, g_W, g_H;
static bool g_Saved = false;
@@ -128,9 +136,27 @@ static dmExtension::Result Initialize(dmExtension::Params* params)
return dmExtension::RESULT_OK;
}
// Sticky input on the engine's window, once it exists (see the top).
static bool g_Sticky = false;
static dmExtension::Result Update(dmExtension::Params* params)
{
#if defined(MM_GLFW)
if (!g_Sticky) {
GLFWwindow* win = glfwGetCurrentContext();
if (win) {
glfwSetInputMode(win, MM_GLFW_STICKY_MOUSE_BUTTONS, 1);
glfwSetInputMode(win, MM_GLFW_STICKY_KEYS, 1);
g_Sticky = true;
}
}
#endif
return dmExtension::RESULT_OK;
}
static dmExtension::Result Finalize(dmExtension::Params* params)
{
return dmExtension::RESULT_OK;
}
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, 0, 0, Finalize)
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, Update, 0, Finalize)
+63 -7
View File
@@ -15,8 +15,9 @@
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
-- decaying noise shake (sell power-down, reveal burst)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
-- reveal burst) or anim.noise_shake for a
-- decaying value-noise shake (sell power-down)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
@@ -159,6 +160,7 @@ end
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now()
return socket.gettime()
@@ -207,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
shaking = {}
end
---------------------------------------------------------------- fading content
@@ -342,21 +345,39 @@ end
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
-- A shake that starts while another runs on the node replaces it and keeps its resting
-- place (read mid-shake, the position is off by up to amp and would stay so).
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
function M.shake(node, amp, dur, delay)
if M.reduced then
return
end
amp, dur = amp or 6, dur or 0.4
local p
return M.tween(node, dur, function(k, n)
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
local rest = {}
local keep = {}
for _, s in ipairs(shaking) do
if not s.t.dead then
local ok, same = pcall(function() return s.node == node end)
if ok and same then
M.cancel(s.t)
rest = s.rest
else
keep[#keep + 1] = s
end
end
end
shaking = keep
local t = M.tween(node, dur, function(k, n)
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
local p = rest.p
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function()
if p then
pcall(gui.set_position, node, p)
if rest.p then
pcall(gui.set_position, node, rest.p)
end
end)
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
return t
end
-- 1D value noise (smooth), for shakes.
@@ -389,6 +410,41 @@ function M.noise_shake(node, amp, dur, freq)
end)
end
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
-- screen shake.
local function grad(i, seed)
local s = math.sin((i + seed) * 12.9898) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local function perlin1(x, seed)
local i = math.floor(x)
local f = x - i
local u = f * f * f * (f * (f * 6 - 15) + 10)
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
return (a + (b - a) * u) * 2
end
function M.perlin(x, seed)
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
end
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
function M.perlin_shake(node, amp, dur, freq)
if M.reduced or not node then
return
end
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur)
local c = gui.get_color(node)
+19 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
elseif data == nil and status ~= 204 and method == "POST" then
err = "bad JSON"
elseif data == nil and method == "GET" and response.response ~= "null" then
err = "bad JSON"
end
done(err, data, status, recv)
@@ -179,6 +181,22 @@ function M.get_state(params, cb)
request("GET", "/api/state", params, cb)
end
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
-- itself comes only from the save-code action; restore is the action restore (code).
function M.get_save_code(cb)
request("GET", "/api/save-code", nil, cb)
end
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
function M.get_moon_settings(cb)
request("GET", "/api/moon-settings", nil, cb)
end
function M.save_moon_settings(settings, cb)
request("POST", "/api/moon-settings", settings, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
+527
View File
@@ -0,0 +1,527 @@
-- Blocks: parts of the layout that a rebuild keeps when what they show hasn't changed.
-- Every new state rebuilds the screen (core/main.lua); a block is built once into a
-- container node and kept, with its nodes and Druid components, by the next builds while
-- its signature (M.sig of everything it shows) stays the same. Regions are blocks
-- (header, moon list, main panel, aside), and so are costly parts inside them (the
-- market's chart, ticker and sparklines, cards).
--
-- local b, kept = blocks.open(g, key, sig, parent) -- build into b.root unless kept
-- if not kept then ... build into b.root ... end
-- local ret = blocks.close(g, b, ...) -- what it returns, kept or not
-- -- or: blocks.keep(g, key, sig, parent, function(root) ... return ... end)
--
-- Build registrations (test ids and keyboard focus, tooltips, tickers, fields, scroll
-- areas, effect anchors, ...) are reset by every build; those made while a block builds
-- are noted (M.note) and replayed in their order when it's kept, so a kept block is in
-- every registry like a rebuilt one. Druid components made while a block builds are its
-- own: they go when it goes (the screen's Druid instance lives on; core/main.lua).
-- A keyed block inside one that is rebuilt survives it (set aside until the new build
-- asks for it); blocks no build asked for go at the end of the build (M.finish).
local helper = require("druid.helper")
local M = {}
local stack = {} -- blocks being built, innermost last
local replaying = false
local orphans = {} -- components made outside any block's build (not a region's)
local function pack(...)
return { n = select("#", ...), ... }
end
local function alive(node)
return node ~= nil and pcall(gui.get_id, node)
end
---------------------------------------------------------------- signatures
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
-- as a token of their content, worked out once per table: two tables with the same
-- content get the same token, so a block whose inputs the server's answer left as the
-- prediction had them is kept, and a list as long as the moons is serialized once per
-- state, not once per block and build. (A frozen table must not change.)
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
local ser
local function token_of(v)
local tok = frozen[v]
if tok ~= true then
return tok
end
local buf = {}
frozen[v] = false -- (while it's written: a table inside itself is written out)
ser(v, buf, true)
local s = table.concat(buf)
tok = tokens[s]
if not tok then
if token_bytes > TOKEN_BYTES then
tokens, token_bytes = {}, 0
end
token_n = token_n + 1
tok = "#" .. token_n
tokens[s], token_bytes = tok, token_bytes + #s
end
frozen[v] = tok
return tok
end
local function key_lt(a, b)
return tostring(a) < tostring(b)
end
ser = function(v, buf, own)
local t = type(v)
if t == "table" then
if not own and frozen[v] then
buf[#buf + 1] = token_of(v)
return
end
buf[#buf + 1] = "{"
local n = #v
for i = 1, n do
ser(v[i], buf)
buf[#buf + 1] = ","
end
local keys, mixed
for k in pairs(v) do
if type(k) ~= "number" or k < 1 or k > n or k % 1 ~= 0 then
keys = keys or {}
keys[#keys + 1] = k
mixed = mixed or type(k) ~= "string"
end
end
if keys then
if mixed then
table.sort(keys, key_lt)
else
table.sort(keys)
end
for _, k in ipairs(keys) do
buf[#buf + 1] = tostring(k)
buf[#buf + 1] = "="
ser(v[k], buf)
buf[#buf + 1] = ";"
end
end
buf[#buf + 1] = "}"
elseif t == "string" then
buf[#buf + 1] = #v
buf[#buf + 1] = ":"
buf[#buf + 1] = v
elseif t == "function" then
buf[#buf + 1] = "f"
else
buf[#buf + 1] = tostring(v)
end
end
-- A string that is the same exactly when the values are (tables by content, keys sorted;
-- frozen ones by their token, the same for the same content).
function M.sig(...)
local buf = {}
for i = 1, select("#", ...) do
ser((select(i, ...)), buf)
buf[#buf + 1] = "|"
end
return table.concat(buf)
end
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
function M.freeze(t)
if type(t) ~= "table" then
return t
end
if frozen[t] == nil then
frozen[t] = true
end
for _, v in pairs(t) do
if type(v) == "table" and frozen[v] == nil then
frozen[v] = true
end
end
return t
end
---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that
-- keeps the block). The registry functions call this; it does nothing outside a block.
function M.note(fn, ...)
local b = stack[#stack]
if replaying or not b then
return
end
local j = b.journal
j[#j + 1] = { fn = fn, n = select("#", ...), ... }
end
-- t[field] (a list reset by every build) gets value, noted.
local function push(t, field, value)
local l = t[field]
if not l then
l = {}
t[field] = l
end
l[#l + 1] = value
end
function M.push(t, field, value)
push(t, field, value)
M.note(push, t, field, value)
end
-- t[field][k] = value (a table reset by every build), noted.
local function put(t, field, k, value)
local m = t[field]
if not m then
m = {}
t[field] = m
end
m[k] = value
end
function M.put(t, field, k, value)
put(t, field, k, value)
M.note(put, t, field, k, value)
end
local replay
-- A kept block inside the one being built: noted in it, its registrations made again.
local function adopt(reg, b)
local top = stack[#stack]
if top then
top.journal[#top.journal + 1] = { block = b }
end
replay(reg, b)
end
-- (the keyed blocks inside a kept one are used in this build too, or finish() would
-- destroy them: a kept Equipment section lost its cards)
replay = function(reg, b)
for _, e in ipairs(b.journal) do
if e.block then
local c = e.block
if not c.dead then
if c.key and reg.by_key[c.key] == c then
reg.used[c.key] = true
end
replay(reg, c)
end
else
replaying = true
pcall(e.fn, unpack(e, 1, e.n)) -- (a node that went since: a list row scrolled out)
replaying = false
end
end
end
-- fn, made to run inside the block being built now when it's called later, outside a
-- build (a list making rows as it scrolls, a field's on_change building again): what it
-- makes and registers is that block's.
function M.bound(fn)
local b = stack[#stack]
if not b then
return fn
end
return function(...)
if #stack > 0 or b.dead then
return fn(...)
end
stack[1] = b
local r = pack(pcall(fn, ...))
stack = {}
if not r[1] then
error(r[2], 0)
end
return unpack(r, 2, r.n)
end
end
---------------------------------------------------------------- components
-- Components of g.druid made while a block builds belong to it (core/main.lua hooks the
-- instance's new once per instance).
function M.hook(druid_instance)
local new = druid_instance.new
druid_instance.new = function(self, component, ...)
local c = new(self, component, ...)
if not c._meta.parent then -- (a widget's own components go with it)
local b = stack[#stack]
if b then
b.comps[#b.comps + 1] = c
else
orphans[#orphans + 1] = c
end
end
return c
end
end
-- After a kept block moved under another parent: Druid buttons and hovers clip their
-- clicks to the stencil above them, found when they were made (perhaps deleted with the
-- old parent; a deleted node still answers until the frame ends, so always look again).
local function rezone(b)
for _, c in ipairs(b.comps) do
if c.node and c.set_click_zone and (c.hover or c.on_mouse_hover) then
pcall(c.set_click_zone, c, helper.get_closest_stencil_node(c.node))
end
end
for _, e in ipairs(b.journal) do
if e.block and not e.block.dead then
rezone(e.block)
end
end
end
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
-- get new uids, in their order, as if made now. Druid hands input to the newest first
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
-- around it (a slot in a well card, which selects the well when clicked) still gets its
-- clicks first, as a rebuilt one would.
local new_uid = require("druid.component").create_uid
local function renew(c)
local cc = c._component
if cc and cc._uid then
cc._uid = new_uid()
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
end
local kids = c.get_childrens and c:get_childrens() or {}
for i = 1, #kids do -- (already the whole subtree, in its order)
local k = kids[i]._component
if k and k._uid then
k._uid = new_uid()
end
end
end
local function reorder(b)
for _, c in ipairs(b.comps) do
pcall(renew, c)
end
for _, e in ipairs(b.journal) do
if e.block and not e.block.dead then
reorder(e.block)
end
end
end
-- True while a kept block's registrations are made again (test.register looks for its
-- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying()
return replaying
end
local function node_of(c)
return c.node or c.root or c.view_node
end
---------------------------------------------------------------- lifetime
local function blocks_of(g)
g.blocks = g.blocks or { by_key = {}, aside = {}, used = {} }
return g.blocks
end
-- Taken by the build going on (kept or made again under a key).
local function taken(reg, b)
return b.key ~= nil and reg.used[b.key] and reg.by_key[b.key] == b
end
local destroy
-- Removes a block: keyed blocks inside it are set aside (detached, still whole) for the
-- build going on to take back; the rest goes.
destroy = function(g, b, set_aside)
local reg = blocks_of(g)
for _, e in ipairs(b.journal) do
local c = e.block
if c and not c.dead and not taken(reg, c) then
if c.key and set_aside and alive(c.root) then
gui.set_parent(c.root, reg.limbo, false)
reg.aside[#reg.aside + 1] = c
else
destroy(g, c, set_aside)
end
end
end
g.tearing_down = true -- (components going away fire events: core/list, scroll)
for i = #b.comps, 1, -1 do
pcall(g.druid.remove, g.druid, b.comps[i])
end
g.tearing_down = false
b.comps = {}
if alive(b.root) then
gui.delete_node(b.root)
end
if b.key and reg.by_key[b.key] == b then
reg.by_key[b.key] = nil
end
b.dead = true
end
-- Components made outside blocks whose nodes went (the moon list's search filtering
-- rows again, ...).
local function sweep(g)
local keep = {}
for _, c in ipairs(orphans) do
local n = node_of(c)
if n and not alive(n) then
pcall(g.druid.remove, g.druid, c)
elseif n then
keep[#keep + 1] = c
end
end
orphans = keep
end
-- Start of a build. full: nothing is kept (core/main.lua makes the layout and the Druid
-- instance again).
function M.begin(g, full)
local reg = blocks_of(g)
stack = {}
replaying = false
if full then
reg.by_key, reg.aside = {}, {}
orphans = {}
end
reg.used = {}
if not alive(reg.limbo) then
reg.limbo = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
gui.set_enabled(reg.limbo, false)
end
end
-- Open a block under parent. Returns the block (build into b.root) and whether it was
-- kept: the last one with this key had the same sig. key nil: never kept (still a
-- block, so keyed blocks inside it can be).
function M.open(g, key, sig, parent)
local reg = blocks_of(g)
if key and reg.used[key] then
key = nil -- (asked for twice in one build: the second isn't kept)
end
local old = key and reg.by_key[key]
if old and old.dead then
old = nil
end
if old and old.sig == sig and alive(old.root) then
reg.used[key] = true
gui.set_parent(old.root, parent, false)
rezone(old)
reorder(old)
adopt(reg, old)
return old, true
end
if old then
destroy(g, old, true)
end
local root = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(1, 1, 0))
gui.set_pivot(root, gui.PIVOT_NW)
gui.set_parent(root, parent, false)
gui.set_visible(root, false) -- (only a container: its children draw)
local b = { key = key, sig = sig, root = root, journal = {}, comps = {} }
if key then
reg.by_key[key] = b
reg.used[key] = true
end
local top = stack[#stack]
if top then
top.journal[#top.journal + 1] = { block = b }
end
stack[#stack + 1] = b
return b, false
end
-- The block open() would keep (same key and sig), without taking it; nil if none.
function M.peek(g, key, sig)
local reg = blocks_of(g)
if not key or reg.used[key] then
return nil
end
local b = reg.by_key[key]
if b and not b.dead and b.sig == sig and alive(b.root) then
return b
end
end
-- Close block b: ... is what its build returns; returns that (kept: what the build that
-- made it returned).
function M.close(g, b, ...)
for i = #stack, 1, -1 do
if stack[i] == b then
for j = #stack, i, -1 do
stack[j] = nil
end
b.ret = { n = select("#", ...), ... }
return ...
end
end
if b.ret then
return unpack(b.ret, 1, b.ret.n)
end
end
-- Open b again to add to it (a card's height, known after its row): what is made until
-- close() is its.
function M.reopen(g, b)
stack[#stack + 1] = b
end
-- Drop block b now (a kept card that has to be built again).
function M.drop(g, b)
local reg = blocks_of(g)
if b.key and reg.used[b.key] and reg.by_key[b.key] == b then
reg.used[b.key] = nil
end
if not b.dead then
destroy(g, b, false)
end
end
-- open + build + close: fn(root) builds; returns what it returns (and that again while
-- the block is kept).
function M.keep(g, key, sig, parent, fn)
local b, kept = M.open(g, key, sig, parent)
if kept then
return M.close(g, b)
end
return M.close(g, b, fn(b.root))
end
-- Destroy a keyed block now (a region not shown this build).
function M.remove(g, key)
local reg = blocks_of(g)
local b = reg.by_key[key]
if b then
destroy(g, b, true)
end
end
-- End of a build: blocks it didn't ask for go.
function M.finish(g)
local reg = blocks_of(g)
stack = {}
for k, b in pairs(reg.by_key) do
if not reg.used[k] then
destroy(g, b, true)
end
end
for _, b in ipairs(reg.aside) do
if not b.dead and not taken(reg, b) then
destroy(g, b, false)
end
end
reg.aside = {}
sweep(g)
end
-- Forget every block (the screen goes away, or before a full rebuild deletes the layout).
function M.clear(g)
local reg = blocks_of(g)
reg.by_key, reg.aside, reg.used = {}, {}, {}
stack, orphans = {}, {}
end
return M
+3 -3
View File
@@ -6,6 +6,7 @@
local ui = require("modules.ui")
local theme = require("modules.theme")
local test = require("modules.test")
local blocks = require("modules.blocks")
local M = {}
@@ -53,9 +54,8 @@ function M.new(g, parent, key, x, y, w, h, opts)
end
local input = g.druid:new_input(box, txt, opts.keyboard or gui.KEYBOARD_TYPE_DEFAULT)
test.register("field:" .. key, box, "field", opts.hint)
g.inputs = g.inputs or {} -- (core/keys: Enter submits; other keys skip while editing)
g.inputs[#g.inputs + 1] = { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
esc_clears = opts.esc_clears }
blocks.push(g, "inputs", { input = input, submit = opts.on_submit, step = { min = opts.min, max = opts.max },
esc_clears = opts.esc_clears })
-- (no "<" or "&": the text node would read them as markup)
input:set_allowed_characters(opts.allowed or "[^<&]")
if opts.max_len then
+692
View File
@@ -0,0 +1,692 @@
-- Flex: a pure flexbox layout solver (no Defold calls; plain Lua 5.1+/LuaJIT). It only
-- computes rects; modules/flex_gui.lua puts persistent nodes there.
--
-- local flex = require("modules.flex")
-- local tree = flex.box({ direction = "column", gap = 8, padding = 12, width = 300 }, {
-- flex.box({ height = 22 }), -- fixed
-- flex.leaf(function(avail_w) return text_size(avail_w) end), -- measured (wraps)
-- flex.box({ direction = "row", justify = "space-between" }, { a, b }),
-- })
-- flex.solve(tree, 0, 0) -- or flex.solve(tree, x, y, w, h)
-- tree.children[2].x, .y, .w, .h -- absolute rect of every box
--
-- Coordinates: top-left origin, y grows DOWN (like CSS and like this client's ui.* calls,
-- which take (x, y) and place nodes at vec(x, -y)). Rects are absolute (relative to the
-- origin passed to solve); box.rx/ry are relative to the parent box. flex.gui_pos(box,
-- pivot) gives the Defold GUI position (y up) of a node with that pivot in the box.
--
-- A box: { style = {...}, children = {...}, measure = fn(avail_w) -> w, h, id = any }.
-- measure (leaves: text, images) gets the content width it may use (math.huge when
-- unbounded) and returns its content size; text should wrap to avail_w. Style keys
-- (CSS names, snake_case):
-- direction "row" (default) | "column"
-- wrap false (default) | true ("wrap")
-- gap, row_gap, column_gap px
-- padding n | {t, r, b, l} | {v, h} | {t, h, b}; padding_top/_right/_bottom/_left
-- justify "start" (default) | "end" | "center" | "space-between" | "space-around"
-- | "space-evenly" (justify-content)
-- align "stretch" (default) | "start" | "end" | "center" (align-items)
-- align_self overrides the parent's align for one child
-- grow, shrink (default 0, 1), basis (px | "N%" | "auto")
-- width, height px | "N%" (of the parent's content box) | "auto"
-- min_width, max_width, min_height, max_height px | "N%"
-- display = "none" leaves the box out (no rect: x, y, w, h = nil).
-- aspect_ratio w / h: the missing size follows the given one (a width, or a column's
-- stretch); in a row it isn't stretched (its height follows its width).
-- position = "absolute": out of the flow, placed in its parent's box by left, right,
-- top, bottom (px | "N%"), translate = {fx, fy} (fractions of its own size);
-- flex.anchor places a box the same way against any solved rect (badges,
-- floating numbers, bursts on another tree's box).
-- columns = { tracks }: a grid instead of flex (px, "N%", "auto", "Nfr"; cells' span,
-- rows, justify_items, justify_self): see grid() below. Tables.
-- overflow = "scroll": children don't shrink along the main axis (a scroll area's
-- content); flex.content_size(box) gives the scrolled size.
--
-- Deviations from CSS (on purpose):
-- - box-sizing is border-box: width/height include the padding. There are no borders or
-- margins (use padding, gap or spacer boxes).
-- - min-width/min-height default to 0, not auto (CSS's flex items won't shrink below
-- their min-content; here they do, like `min-width: 0`: labels get cut with ... instead
-- of overflowing).
-- - An auto-sized box is fit-content (max-content, capped by the available width), also
-- at the root and for a column's child that isn't stretched; CSS's block-level flex
-- container would fill the width. Give the root a width.
-- - align-content is always start (lines packed at the top); with wrap and the default
-- align = "stretch", each line's items stretch to the line's tallest one, as in CSS
-- (equal-height rows of cards). No baseline alignment, no order, no *-reverse, no
-- absolute positioning in the flow (position = "absolute" only against the parent).
-- - A % size against an auto (indefinite) parent size is treated as auto, like CSS.
-- - Sizes are not rounded (rects can be fractional; this client places at fractions).
local M = {}
local HUGE = math.huge
local EMPTY = {}
-- Constructors (plain tables work too).
function M.box(style, children, id)
return { style = style or {}, children = children or {}, id = id }
end
function M.leaf(measure, style, id)
return { style = style or {}, measure = measure, id = id }
end
local function len(v, ref)
if v == nil or v == "auto" then
return nil
end
if type(v) == "number" then
return v
end
local p = type(v) == "string" and v:match("^([%d%.%-]+)%%$")
if p then
return ref and ref ~= HUGE and ref * tonumber(p) / 100 or nil
end
error("flex: bad length " .. tostring(v))
end
local function clamp(v, lo, hi)
if hi and v > hi then
v = hi
end
if lo and v < lo then
v = lo
end
return v
end
local function pads(s)
local p = s.padding
local t, r, b, l = 0, 0, 0, 0
if type(p) == "number" then
t, r, b, l = p, p, p, p
elseif type(p) == "table" then
if #p == 1 then
t, r, b, l = p[1], p[1], p[1], p[1]
elseif #p == 2 then
t, r, b, l = p[1], p[2], p[1], p[2]
elseif #p == 3 then
t, r, b, l = p[1], p[2], p[3], p[2]
else
t, r, b, l = p[1], p[2], p[3], p[4]
end
end
return s.padding_top or t, s.padding_right or r, s.padding_bottom or b, s.padding_left or l
end
-- Width/height limits of a box: min, max (px or nil) against the parent's content size.
local function limits(s, cw, ch)
return len(s.min_width, cw) or 0, len(s.max_width, cw), len(s.min_height, ch) or 0, len(s.max_height, ch)
end
local layout, place_abs, grid
-- Children in the flow, and the absolutely positioned ones (nil when none).
local function in_flow(kids)
if not kids then
return EMPTY, nil
end
local abs
for _, c in ipairs(kids) do
if c.style and c.style.position == "absolute" then
abs = abs or {}
abs[#abs + 1] = c
end
end
if not abs then
return kids, nil
end
local flow = {}
for _, c in ipairs(kids) do
if not (c.style and c.style.position == "absolute") then
flow[#flow + 1] = c
end
end
return flow, abs
end
-- Absolutely positioned children of a box w x h (CSS position: absolute against the box's
-- border box): left/right/top/bottom (px or "N%"), width/height (else from both offsets,
-- else content), translate = {fx, fy} (fractions of its own size, CSS translate(-50%, ..)).
-- No offset on an axis: 0 (the start edge).
local function abs_one(c, w, h)
local cs = c.style
if cs.display == "none" then
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
return
end
local l, r, t, bo = len(cs.left, w), len(cs.right, w), len(cs.top, h), len(cs.bottom, h)
local cw, ch = len(cs.width, w), len(cs.height, h)
if not cw and l and r then
cw = math.max(0, w - l - r)
end
if not ch and t and bo then
ch = math.max(0, h - t - bo)
end
local ww, hh = layout(c, cw, ch, w, h)
local x = l or (r and w - r - ww) or 0
local y = t or (bo and h - bo - hh) or 0
local tr = cs.translate
if tr then
x, y = x + (tr[1] or 0) * ww, y + (tr[2] or 0) * hh
end
c.rx, c.ry = x, y
end
function place_abs(abs, w, h)
for _, c in ipairs(abs or EMPTY) do
abs_one(c, w, h)
end
end
-- Grid layout (CSS grid subset): style.columns = list of tracks: px, "N%", "auto"
-- (max-content of its cells), "Nfr" (a share of what's left: minmax(0, Nfr); auto when
-- the width is indefinite). Cells fill rows left to right (style.span on a cell: columns
-- it covers); a row is as tall as its tallest cell (style.rows: fixed heights by row,
-- missing = auto). gap/row_gap/column_gap; align (align-items, default stretch) and
-- justify_items (default stretch), align_self/justify_self on a cell.
function grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
local pt, pr, pb, pl = pads(s)
local px, py = pl + pr, pt + pb
local cols = s.columns
local n = #cols
local cg = s.column_gap or s.gap or 0
local rg = s.row_gap or s.gap or 0
local inner_w = w and (w - px) or (avail_w ~= HUGE and avail_w or nil)
local cells, r, c = {}, 1, 1
for _, k in ipairs(kids) do
local ks = k.style or EMPTY
if ks.display == "none" then
k.x, k.y, k.w, k.h, k.rx, k.ry = nil, nil, nil, nil, nil, nil
else
local span = math.max(1, math.min(n, ks.span or 1))
if c + span - 1 > n then
r, c = r + 1, 1
end
cells[#cells + 1] = { box = k, s = ks, r = r, c = c, span = span }
c = c + span
if c > n then
r, c = r + 1, 1
end
end
end
local nrows = #cells > 0 and cells[#cells].r or 0
local width, fr, frsum, auto = {}, {}, 0, {}
for i, t in ipairs(cols) do
local f = type(t) == "string" and t:match("^([%d%.]+)fr$")
if f then
fr[i] = tonumber(f)
frsum = frsum + fr[i]
if not inner_w then
auto[i] = true
end
else
width[i] = len(t, inner_w)
if not width[i] then
auto[i] = true
end
end
end
local content = {}
for _, cl in ipairs(cells) do
if cl.span == 1 and auto[cl.c] then
local cw = layout(cl.box, len(cl.s.width, inner_w), nil, HUGE, nil)
content[cl.c] = math.max(content[cl.c] or 0, cw)
end
end
local used = cg * (n - 1)
for i = 1, n do
if auto[i] then
width[i] = content[i] or 0
end
if width[i] then
used = used + width[i]
end
end
if frsum > 0 and inner_w then
local free = math.max(0, inner_w - used) / math.max(frsum, 1)
for i = 1, n do
if fr[i] then
width[i] = free * fr[i]
end
end
end
local colx, x = {}, 0
for i = 1, n do
colx[i] = x
x = x + width[i] + cg
end
w = w or clamp(x - cg + px, minw, maxw)
-- Rows: each cell at its width, then the row's height.
local rows, rowh = s.rows or EMPTY, {}
for _, cl in ipairs(cells) do
local cw = colx[cl.c + cl.span - 1] + width[cl.c + cl.span - 1] - colx[cl.c]
local iw = len(cl.s.width, cw)
local js = cl.s.justify_self or s.justify_items or "stretch"
if not iw and js == "stretch" then
iw = cw
end
cl.cw, cl.iw, cl.js = cw, iw, js
local _, ih = layout(cl.box, iw, nil, cw, nil)
cl.ih = ih
local fixed = type(rows[cl.r]) == "number" and rows[cl.r]
rowh[cl.r] = fixed or math.max(rowh[cl.r] or 0, ih)
end
local rowy, y = {}, 0
for i = 1, nrows do
rowy[i] = y
y = y + (rowh[i] or 0) + rg
end
h = h or clamp((nrows > 0 and y - rg or 0) + py, minh, maxh)
for _, cl in ipairs(cells) do
local k, ks, rh = cl.box, cl.s, rowh[cl.r]
local al = ks.align_self or s.align or "stretch"
local auto_h = ks.height == nil or ks.height == "auto"
if al == "stretch" and auto_h and not ks.aspect_ratio and cl.ih ~= rh then
layout(k, k.w, rh, cl.cw, rh)
cl.ih = rh
end
local ox, oy = 0, 0
if cl.js == "end" then
ox = cl.cw - k.w
elseif cl.js == "center" then
ox = (cl.cw - k.w) / 2
end
if al == "end" then
oy = rh - cl.ih
elseif al == "center" then
oy = (rh - cl.ih) / 2
end
k.rx, k.ry = pl + colx[cl.c] + ox, pt + rowy[cl.r] + oy
end
b.w, b.h = w, h
return w, h
end
-- Lay out b's content at border-box size w x h (either may be nil: sized by content),
-- in a parent content box cw x ch (nil/HUGE: unbounded). Sets b.w, b.h and children's
-- rx, ry (relative to b). Returns w, h.
function layout(b, w, h, cw, ch)
local s = b.style or EMPTY
local minw, maxw, minh, maxh = limits(s, cw, ch)
w = w or len(s.width, cw)
h = h or len(s.height, ch)
if w then
w = clamp(w, minw, maxw)
end
if h then
h = clamp(h, minh, maxh)
end
local ar = s.aspect_ratio
if ar then
if w and not h then
h = clamp(w / ar, minh, maxh)
elseif h and not w then
w = clamp(h * ar, minw, maxw)
end
end
local pt, pr, pb, pl = pads(s)
local px, py = pl + pr, pt + pb
-- The width content may take when w is auto: the parent's, capped by max-width.
local avail_w = w and (w - px) or (math.min(cw or HUGE, maxw or HUGE) - px)
local kids, abs = in_flow(b.children)
if #kids == 0 then
if not (w and h) then
local mw, mh = 0, 0
if b.measure then
mw, mh = b.measure(math.max(0, avail_w))
end
w = w or clamp(math.min(mw, math.max(0, avail_w)) + px, minw, maxw)
h = h or (ar and clamp(w / ar, minh, maxh)) or clamp(mh + py, minh, maxh)
end
b.w, b.h = w, h
place_abs(abs, w, h)
return w, h
end
if s.columns then
w, h = grid(b, kids, s, w, h, avail_w, minw, maxw, minh, maxh)
place_abs(abs, w, h)
return w, h
end
local row = (s.direction or "row") == "row"
local gap = s.gap or 0
local main_gap = row and (s.column_gap or gap) or (s.row_gap or gap)
local cross_gap = row and (s.row_gap or gap) or (s.column_gap or gap)
local inner_w = w and (w - px) or nil
local inner_h = h and (h - py) or nil
local inner_main, inner_cross = inner_w, inner_h -- (either may be nil: no and/or here)
if not row then
inner_main, inner_cross = inner_h, inner_w
end
-- Where lines break and what text wraps to.
local break_main = row and avail_w or (inner_h or HUGE)
local align = s.align or "stretch"
-- Flex base sizes (outer main size; padding counts: border-box).
local items = {}
for _, c in ipairs(kids) do
local cs = c.style or EMPTY
if cs.display == "none" then
c.x, c.y, c.w, c.h, c.rx, c.ry = nil, nil, nil, nil, nil, nil
else
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
local it = { box = c, s = cs, grow = cs.grow or 0, shrink = s.overflow == "scroll" and 0 or cs.shrink or 1 }
if row then
it.min, it.max = cminw, cmaxw
else
it.min, it.max = cminh, cmaxh
end
local self_align = cs.align_self or align
it.stretch = self_align == "stretch" and not (row and cs.aspect_ratio)
it.align = self_align
local main_style = cs.width
if not row then
main_style = cs.height
end
local base = len(cs.basis, inner_main) or len(main_style, inner_main)
if not base then
if row then
base = layout(c, nil, nil, avail_w, inner_h)
else
-- A column's child: its width first (stretched, set, or content), then
-- its height at that width.
local cwid = len(cs.width, inner_w)
if not cwid and it.stretch and inner_w then
cwid = clamp(inner_w, cminw, cmaxw)
end
local _, hh = layout(c, cwid, nil, avail_w, inner_h)
base = hh
end
end
it.base = base
it.hyp = clamp(base, it.min, it.max)
items[#items + 1] = it
end
end
-- Lines.
local lines, cur, used = {}, {}, 0
for _, it in ipairs(items) do
local add = it.hyp + (#cur > 0 and main_gap or 0)
if s.wrap and #cur > 0 and used + add > break_main + 1e-6 then
lines[#lines + 1] = cur
cur, used = {}, 0
add = it.hyp
end
cur[#cur + 1] = it
used = used + add
end
if #cur > 0 then
lines[#lines + 1] = cur
end
-- Main sizes per line (CSS "resolve flexible lengths", with freezing at min/max).
local content_main = 0
for _, ln in ipairs(lines) do
local gaps = main_gap * (#ln - 1)
local hyp_sum = gaps
for _, it in ipairs(ln) do
hyp_sum = hyp_sum + it.hyp
it.size, it.frozen = it.hyp, false
end
local space = inner_main
if not space and row and hyp_sum > avail_w then
space = avail_w -- (an auto-width row is fit-content: it shrinks to what's there)
end
if space then
local growing = hyp_sum < space
for _, it in ipairs(ln) do
if (growing and it.grow == 0) or (not growing and it.shrink == 0)
or (growing and it.base > it.hyp) or (not growing and it.base < it.hyp) then
it.frozen = true
end
end
for _ = 1, #ln + 1 do
local free, sum = space - gaps, 0
local active = false
for _, it in ipairs(ln) do
if it.frozen then
free = free - it.size
else
free = free - it.base
sum = sum + (growing and it.grow or it.shrink * it.base)
active = true
end
end
if not active then
break
end
if growing and sum < 1 then
free = free * sum -- (CSS: grow factors under 1 take that share)
end
local total_violation = 0
for _, it in ipairs(ln) do
if not it.frozen then
local share = sum > 0 and (growing and it.grow or it.shrink * it.base) / sum or 0
local t = it.base + free * share
local c = clamp(t, it.min, it.max)
it.size, it.viol = c, c - t
total_violation = total_violation + it.viol
end
end
for _, it in ipairs(ln) do
if not it.frozen and (total_violation == 0 or (total_violation > 0 and it.viol > 0)
or (total_violation < 0 and it.viol < 0)) then
it.frozen = true
end
end
end
end
local m = gaps
for _, it in ipairs(ln) do
m = m + it.size
end
ln.main = m
content_main = math.max(content_main, m)
end
-- Cross sizes: each item at its main size; line cross = its tallest item (a single
-- line in a box of definite cross size takes that size).
local content_cross = 0
for li, ln in ipairs(lines) do
local lc = 0
for _, it in ipairs(ln) do
local c, cs = it.box, it.s
local cw2, ch2
if row then
cw2, ch2 = layout(c, it.size, nil, inner_w, inner_h)
it.cross = ch2
else
local cwid = len(cs.width, inner_w)
if not cwid and it.stretch and inner_w then
cwid = inner_w
end
cw2 = layout(c, cwid, it.size, inner_w, inner_h)
it.cross = cw2
end
lc = math.max(lc, it.cross)
end
if #lines == 1 and inner_cross then
lc = inner_cross
end
ln.cross = lc
content_cross = content_cross + lc + (li > 1 and cross_gap or 0)
end
-- The box's own size.
if row then
w = w or clamp(content_main + px, minw, maxw)
h = h or clamp(content_cross + py, minh, maxh)
else
w = w or clamp(content_cross + px, minw, maxw)
h = h or clamp(content_main + py, minh, maxh)
end
inner_w, inner_h = w - px, h - py
inner_main = row and inner_w or inner_h
if #lines == 1 then
lines[1].cross = row and inner_h or inner_w
end
-- Stretch, then place.
local cross_pos = 0
for _, ln in ipairs(lines) do
local free = inner_main - ln.main
local start, between = 0, main_gap
local j = s.justify or "start"
local n = #ln
if j == "end" then
start = free
elseif j == "center" then
start = free / 2
elseif j == "space-between" and n > 1 and free > 0 then
between = main_gap + free / (n - 1)
elseif j == "space-around" and free > 0 then
start, between = free / n / 2, main_gap + free / n
elseif j == "space-evenly" and free > 0 then
start, between = free / (n + 1), main_gap + free / (n + 1)
end
local pos = start
for _, it in ipairs(ln) do
local c, cs = it.box, it.s
local cross_style
if row then
cross_style = cs.height
else
cross_style = cs.width
end
local auto_cross = cross_style == nil or cross_style == "auto"
if it.stretch and auto_cross then
local cminw, cmaxw, cminh, cmaxh = limits(cs, inner_w, inner_h)
local target = row and clamp(ln.cross, cminh, cmaxh) or clamp(ln.cross, cminw, cmaxw)
if target ~= it.cross then
if row then
layout(c, it.size, target, inner_w, inner_h)
else
layout(c, target, it.size, inner_w, inner_h)
end
it.cross = target
end
end
local off = 0
if it.align == "end" then
off = ln.cross - it.cross
elseif it.align == "center" then
off = (ln.cross - it.cross) / 2
end
if row then
c.rx, c.ry = pl + pos, pt + cross_pos + off
else
c.rx, c.ry = pl + cross_pos + off, pt + pos
end
pos = pos + it.size + between
end
cross_pos = cross_pos + ln.cross + cross_gap
end
b.w, b.h = w, h
place_abs(abs, w, h)
return w, h
end
local function place(b, x, y)
b.x, b.y = x, y
for _, c in ipairs(b.children or EMPTY) do
if c.rx then
place(c, x + c.rx, y + c.ry)
end
end
end
-- Solve the tree with its top-left at (x, y); w, h force the root's size (otherwise its
-- style's, or its content's, capped by avail_w when given). Returns the root.
function M.solve(root, x, y, w, h, avail_w)
root.rx, root.ry = 0, 0
layout(root, w, h, avail_w, nil)
place(root, x or 0, y or 0)
return root
end
-- Anchor a box to an already solved rect (a box of another tree, a button's rect: x, y,
-- w, h, y down) like CSS position: absolute inside it: style left/right/top/bottom,
-- width/height, translate (see abs_one). Solves box (and its children) and returns it.
-- flex.anchor(button_box, flex.box({ top = -6, right = -6, translate = {0, 0} }, ...))
-- flex.anchor(r, flex.leaf(m, { left = "50%", bottom = "100%", translate = { -0.5, 0 } }))
function M.anchor(target, box)
box.style = box.style or {}
abs_one(box, target.w, target.h)
place(box, target.x + box.rx, target.y + box.ry)
return box
end
-- The scrollable content size of a solved box (CSS scrollWidth/scrollHeight): its size, or
-- the extent of its descendants plus its right/bottom padding when they reach further.
-- A box whose content should overflow (a scroll area of fixed height) wants style
-- overflow = "scroll": its children then don't shrink along its main axis.
function M.content_size(b)
local mx, my = 0, 0
local function walk(p)
for _, c in ipairs(p.children or EMPTY) do
if c.x then
mx = math.max(mx, c.x + c.w - b.x)
my = math.max(my, c.y + c.h - b.y)
walk(c)
end
end
end
walk(b)
local _, pr, pb = pads(b.style or EMPTY)
return math.max(b.w, mx + pr), math.max(b.h, my + pb)
end
-- Boxes by id (after solve).
function M.index(root, out)
out = out or {}
if root.id ~= nil then
out[root.id] = root
end
for _, c in ipairs(root.children or EMPTY) do
M.index(c, out)
end
return out
end
-- Grid columns for a width (CSS grid's repeat(auto-fill, minmax(min, 1fr))): the count and
-- the column width, in whole pixels (what's left over stays at the right).
function M.columns(w, min, gap)
local cols = math.max(1, math.floor((w + gap) / (min + gap)))
return cols, math.floor((w - gap * (cols - 1)) / cols)
end
-- A grid as a wrapping row w wide: each child gets the column width (M.columns); with the
-- default align = "stretch" a row is as tall as its tallest item. style: more of the
-- row's (row_gap, ...).
function M.grid(w, min, gap, children, style)
local _, cw = M.columns(w, min, gap)
for _, c in ipairs(children) do
c.style = c.style or {}
c.style.width, c.style.shrink = cw, 0
end
local s = { direction = "row", wrap = true, gap = gap, width = w }
for k, v in pairs(style or EMPTY) do
s[k] = v
end
return M.box(s, children)
end
-- flex.apply(tree, nodes, opts): put persistent GUI nodes on a solved tree (Defold only:
-- modules/flex_gui.lua, which documents it).
local GUI = "modules.flex_gui" -- (by a name bob doesn't follow: flex_gui requires flex)
function M.apply(tree, nodes, opts)
return require(GUI).apply(tree, nodes, opts)
end
-- Where a node with pivot (0..8 as Defold's gui.PIVOT_*: CENTER, N, NE, E, SE, S, SW, W,
-- NW) sits for the rect x, y, w, h (y down): returns its GUI position x, y (y up).
local PIVOT = { [0] = { 0.5, 0.5 }, { 0.5, 0 }, { 1, 0 }, { 1, 0.5 }, { 1, 1 }, { 0.5, 1 }, { 0, 1 }, { 0, 0.5 }, { 0, 0 } }
function M.gui_pos(r, pivot, ox, oy)
local p = PIVOT[pivot or 8] or PIVOT[8]
return r.x - (ox or 0) + r.w * p[1], -(r.y - (oy or 0) + r.h * p[2])
end
return M
+381
View File
@@ -0,0 +1,381 @@
-- Puts persistent GUI nodes where modules/flex solved their boxes. It never creates,
-- deletes, reparents or restyles a node; it sets position (by the node's pivot) and,
-- unless box.size == false, size (divided by the node's scale: a wrapping text node gets
-- its wrap width), and only when they changed.
--
-- Rule: layout owns an outer "slot" node per box (an anim.group, a card's root); an
-- animation owns the visual nodes inside it (like CSS transform vs layout). Give a box
-- only slot nodes, never one that something else animates (gui.animate on position or
-- size); boxes without a node are just geometry (their children's nodes are placed
-- relative to the nearest ancestor box that has one, or to the tree's origin).
--
-- flex.solve(tree, 0, 0, w)
-- flex_gui.apply(tree) -- box.node on the boxes
-- flex_gui.apply(tree, { head = n1, foot = n2 }) -- or nodes by box id
-- flex_gui.apply(tree, nil, { glide = 0.2 }) -- tween what moved (FLIP), default snap
--
-- glide: a node that already sits somewhere tweens from there to its new rect (eased
-- out, `glide` seconds); a later apply retargets it. Off under reduced motion.
local flex = require("modules.flex")
local anim = require("modules.anim")
local ui = require("modules.ui")
local theme = require("modules.theme")
local S = theme.size
local M = {}
local EPS = 0.01
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
-- several labels).
local function copy(opts)
local s = {}
for k, v in pairs(opts or {}) do
s[k] = v
end
return s
end
local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end
local function put(node, prop, value, cur, glide)
if not differs(cur, value) then
return
end
if glide then
gui.animate(node, prop, value, gui.EASING_OUTQUAD, glide)
elseif prop == "position" then
gui.set_position(node, value)
else
gui.set_size(node, value)
end
end
local function walk(b, nodes, ox, oy, glide)
if not b.x then
return -- (display = "none")
end
local node = b.node or (nodes and b.id ~= nil and nodes[b.id])
if node then
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
local pos = gui.get_position(node)
put(node, "position", vmath.vector3(px, py, pos.z), pos, glide)
if b.size ~= false then
local sc = gui.get_scale(node)
put(node, "size", vmath.vector3(b.w / sc.x, b.h / sc.y, 0), gui.get_size(node), glide)
end
ox, oy = b.x, b.y
end
for _, c in ipairs(b.children or {}) do
walk(c, nodes, ox, oy, glide)
end
end
-- Apply a solved tree. nodes: optional { [box id] = node }. opts.glide: seconds.
-- opts.origin: {x, y} the tree's coordinates of the root nodes' parent (default 0, 0).
function M.apply(tree, nodes, opts)
opts = opts or {}
local glide = opts.glide and opts.glide > 0 and not anim.reduced and opts.glide or nil
local o = opts.origin
walk(tree, nodes, o and o[1] or 0, o and o[2] or 0, glide)
return tree
end
-- A measure function for a text node (flex.leaf): its metrics at the width it gets;
-- wrapping nodes (line break on) wrap to it. lh: line height in px (CSS line-height:
-- lines * lh), else the font's own line metrics.
function M.text_measure(node, lh)
return function(avail)
local s = gui.get_scale(node)
local font = gui.get_font_resource(gui.get_font(node))
local wrap = gui.get_line_break(node)
local opts = { tracking = gui.get_tracking(node), line_break = wrap, leading = lh and 1 or gui.get_leading(node) }
if wrap then
opts.width = avail / s.x
end
local m = resource.get_text_metrics(font, gui.get_text(node), opts)
if lh then
local one = resource.get_text_metrics(font, "Ag")
local lines = math.max(1, math.floor(m.height / (one.max_ascent + one.max_descent) + 0.5))
return m.width * s.x, lines * lh
end
return m.width * s.x, m.height * s.y
end
end
-- Building a section from one tree (M.build): describe it with boxes, leaves made by
-- M.label / M.para (their text node is made now, under any parent, for its metrics) and
-- boxes with make = fn(parent, x, y, w, h) for what is built at a size (frames, buttons,
-- bars); flex.solve it; then M.build makes the nodes.
-- One line of text (ui.text, pivot W: its middle at the box's) in a line box h tall, as
-- wide as its text (trailing spaces count). opts: more box style; opts.fit cuts the text
-- with ... to the width it gets; opts.shrink_text = k (0..1) first scales it down to fit,
-- to k of its size at most (then cuts). Text with symbols (ui.needs_rich) goes through ui.rich
-- (not cut).
function M.label(parent, str, style, h, opts)
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W)
local s = copy(opts)
s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end }
end
local n = ui.text(parent, str, style, 0, 0)
local w, t = ui.text_width(n), gui.get_text(n)
if t:match(" $") then -- (metrics leave trailing spaces out)
gui.set_text(n, t .. "|")
w = ui.text_width(n)
gui.set_text(n, "|")
w = w - ui.text_width(n)
gui.set_text(n, t)
end
local s = copy(opts)
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
end
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more
-- box style (max_width, ...); opts.lines = n: at most n lines, the rest cut with "…" (so
-- long text can't push what's under it).
function M.para(parent, str, style, lh, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line)
local s = copy(opts)
local lines = s.lines
s.lines = nil
local measure = M.text_measure(n, lh)
return { node = n, shift = (lh - line) / 2, style = s, clamp = lines and { lines, lh },
measure = lines and function(avail)
local w, h = measure(avail)
return w, math.min(h, lines * lh)
end or measure }
end
-- Cut a wrapping text node's text with "…" (whole words, then letters) until it takes at
-- most n lines lh apart at width w.
function M.clamp_lines(node, n, lh, w)
local measure = M.text_measure(node, lh)
local text = gui.get_text(node)
local function over()
local _, h = measure(w)
return h > n * lh + 0.5
end
if not over() then
return
end
while #text > 1 do
local cut = text:gsub("%s*%S+%s*$", "")
if cut == "" or cut == text then -- (one word left: a letter at a time, whole UTF-8 chars)
cut = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", "")
end
text = cut:gsub("[%s,;:.]+$", "")
gui.set_text(node, text .. "…")
if not over() then
return
end
end
end
-- Fit one line of text to max_w: scaled down to `shrink` (0..1) of its size at most, if
-- given, then cut with "…" (ui.fit_text).
function M.fit_line(node, max_w, shrink)
local w = ui.text_width(node)
if shrink and w > max_w then
local sc = gui.get_scale(node)
local k = math.max(shrink, max_w / w)
gui.set_scale(node, vmath.vector3(sc.x * k, sc.y * k, sc.z))
end
ui.fit_text(node, max_w)
end
-- A reserved slot: a box of a fixed size whether its content shows or not (the "Need ₥
-- more" line under Buy, a note above cards, Claim all's place), so what shows in it can
-- never move what's around it. style: its size (width and/or height) and more box style;
-- kid: the content box, or nil (shown empty). The content is laid out inside the slot
-- and cut to it if it's bigger (clip text with fit/shrink/lines).
function M.reserve(style, kid)
style = style or {}
if style.shrink == nil then
style.shrink = 0
end
return M.box(style, { kid })
end
-- Tree pieces (shared by the flex-first sections). Children lists may have holes
-- (`cond and x or nil`); extra: more fields on the box (make, done, id, ...).
function M.box(style, kids, extra)
local list = {}
for i = 1, table.maxn(kids or {}) do
list[#list + 1] = kids[i]
end
local b = { style = style or {}, children = list }
for k, v in pairs(extra or {}) do
b[k] = v
end
return b
end
function M.row(style, kids, extra) style.direction = "row"; return M.box(style, kids, extra) end
function M.col(style, kids, extra) style.direction = "column"; return M.box(style, kids, extra) end
function M.space(w, h) return { style = { width = w or 0, height = h or 0, shrink = 0 } } end
function M.fill() return { style = { grow = 1 } } end
-- A box with make = fn(parent, x, y, w, h) (built at its solved rect).
function M.made(style, make, kids) return M.box(style, kids, { make = make }) end
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
-- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts)
local s = copy(opts)
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
end
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the
-- width it gets. opts: more box style (max_width = S.text_max, ...).
function M.text_para(parent, str, style, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
return { node = n, style = opts or {}, measure = M.text_measure(n) }
end
-- A component that places its own parts (cards.parts, a ui.credits amount): built by
-- fn(parent, x, y, w) -> h[, w] at its rect; its size is measured first by building it
-- on a probe under parent (deleted at once; once per width).
function M.measured(parent, fn, style)
local cache = {}
return { style = style or {}, measure = function(avail)
local finite = avail == avail and avail < math.huge
local k = finite and math.floor(avail + 0.5) or -1
if not cache[k] then
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local h, w = fn(probe, 0, 0, finite and avail or 10000)
gui.delete_node(probe)
cache[k] = { w or (finite and avail or 0), h or 0 }
end
return cache[k][1], cache[k][2]
end, make = function(p, x, y, w)
fn(p, x, y, w)
end }
end
-- A one-line component that places itself at a middle y and returns its width (chips,
-- tags, stat.inline, ui.credits): a box h tall, as wide as fn measures on a probe
-- (fn(probe, 0, 0, true): register nothing then); made by fn(parent, x, y_middle).
-- style: more box style.
function M.inline(parent, h, fn, style)
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local w = fn(probe, 0, 0, true) or 0
gui.delete_node(probe)
style = style or {}
style.width, style.height = style.width or w, h
if style.shrink == nil then
style.shrink = 0
end
return { style = style, make = function(p, x, y)
fn(p, x, y + h / 2)
end }
end
-- A page that mixes trees with components that lay themselves out and must be built in
-- place (card.grid's kept cards): parts top to bottom, w wide from (x, y); a part is a
-- box (solved at that width, built under parent) or fn(y) -> the y under it. Returns the
-- bottom.
function M.flow(parent, x, y, w, parts)
for i = 1, table.maxn(parts) do
local part = parts[i]
if type(part) == "function" then
y = part(y)
elseif part then
part.style = part.style or {}
part.style.width = part.style.width or w
M.build(flex.solve(part, x, y), parent)
y = part.y + part.h
end
end
return y
end
-- The width ui.button gives a label with these opts (a probe, deleted at once).
function M.button_w(parent, label, opts)
local b, _, w = ui.button(parent, label, 0, 0, opts)
ui.button_looks[b] = nil
gui.delete_node(b)
return w
end
-- A ui.button box: as wide as ui.button makes it (probe; style.width or grow override),
-- its height the button's; made at its rect, then bind(b, nodes, w) (H.action, ...).
function M.button(parent, label, opts, bind, style)
style = style or {}
style.height = style.height or opts.h or (opts.small and S.button_sm_h or S.button_h)
style.shrink = style.shrink or 0
if not style.width and not style.grow then
style.width = M.button_w(parent, label, opts)
end
return { style = style, make = function(p, x, y, w)
opts.w = w
local b, nodes = ui.button(p, label, x, y, opts)
if bind then
bind(b, nodes, w)
end
return nil
end }
end
-- Make the nodes of a solved tree under parent (the tree's origin), in tree order (which
-- is focus and Druid input order). A box with make gets its rect rounded to whole pixels,
-- relative to the nearest node above it (top-left, y down, like ui.*); the node it
-- returns, if any, holds its children. A box with a node (M.label, M.para) is moved under
-- that node and placed by its pivot (sized unless size == false; fit: cut to its width).
-- Then box.done(node, box), if any.
function M.build(tree, parent)
local function walk(b, p, ox, oy)
if not b.x then
return -- (display = "none")
end
local node = b.node
if b.make then
local x, y = math.floor(b.x + 0.5), math.floor(b.y + 0.5)
local w, h = math.floor(b.x + b.w + 0.5) - x, math.floor(b.y + b.h + 0.5) - y
node = b.make(p, x - ox, y - oy, w, h)
b.node = node
if node then
p, ox, oy = node, x, y
end
elseif node then
gui.set_parent(node, p, false)
local px, py = flex.gui_pos(b, gui.get_pivot(node), ox, oy)
gui.set_position(node, vmath.vector3(px, py - (b.shift or 0), gui.get_position(node).z))
if b.size ~= false then
local sc = gui.get_scale(node)
gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
end
if b.fit then
M.fit_line(node, b.w, b.shrink)
end
if b.clamp then
M.clamp_lines(node, b.clamp[1], b.clamp[2], b.w)
end
p, ox, oy = node, b.x, b.y
end
if b.done then
b.done(node, b)
end
for _, c in ipairs(b.children or {}) do
walk(c, p, ox, oy)
end
end
walk(tree, parent, 0, 0)
return tree
end
return M
+37 -6
View File
@@ -24,7 +24,7 @@ local M = {}
local entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page"
local seen = {} -- key -> count in this build (duplicate ids get "#n")
local seen = {} -- base -> entries with it (duplicate ids get "#n")
local function alive(node)
return pcall(gui.get_id, node)
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
return
end
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
local key = base
if (seen[base] or 0) > 0 then
-- A row made again (a long list scrolled back, a search, a kept block replayed)
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
-- plain key that list_step and find look for.
local keep, taken = {}, {}
for _, e in ipairs(entries) do
if e.base ~= base then
keep[#keep + 1] = e
elseif alive(e.node) then
keep[#keep + 1] = e
taken[e.key] = true
else
seen[base] = seen[base] - 1
end
end
entries = keep
local n = 1
while taken[key] do
n = n + 1
key = base .. "#" .. n
end
end
seen[base] = (seen[base] or 0) + 1
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end
@@ -203,7 +225,7 @@ local function into_view(g, e)
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty = true
g.dirty, g.dirty_all = true, true
end
return
end
@@ -225,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- the item before / after the focused row (or the first / last when coming from outside),
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
-- when it moved the focus.
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
-- from that row (the one under it). Returns true when it moved the focus.
local function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur)
local items, i
if a then
if list_of(g, e) == a then
return false -- (the next element is in view in the same list)
end
items = a.list.items
local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
i = n + dir
end
end
if not i and a.list.row_h then
local r, v = M.rect(cur.node), M.rect(a.view)
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
end
if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page)
end
@@ -266,7 +297,7 @@ local function list_step(g, f, cur, e, dir, scope_)
to = math.max(0, math.min(a.max or 0, to))
if to ~= pos then
g.scroll_pos[a.key] = to
g.dirty = true
g.dirty, g.dirty_all = true, true
end
local now = find(g, key)
f.tip = now and tooltip.show_for(now.node, M.rect(now.node)) or false
+129 -20
View File
@@ -29,6 +29,7 @@ local ui = require("modules.ui")
local theme = require("modules.theme")
local sfx = require("modules.sfx")
local anim = require("modules.anim")
local blocks = require("modules.blocks")
local M = {}
M.__index = M
@@ -39,17 +40,17 @@ local GOLD_HI = C.ready_hi
local LOSS = C.loss
local FLASH = C.flash
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
equip = true, hire = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
local SOUND = {
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
["buy-tool"] = "buy",
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade",
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
refine = "refine", collect = "collect", error = "error",
-- (sell-tool and sell-attachment: the dialog's confirm, then the cash as the coins land)
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
}
local function v3(x, y)
@@ -101,16 +102,16 @@ end
-- A box an action's sweep covers when its button is inside (fx.js: the button's .card,
-- .line, row or heading): cards and list rows register while they're built.
-- Sweeps cover the clicked button's box (fx.js closest(".card, .line, tr, ...")): cards,
-- rows and the moon sale register here while built; a new card-like container must too.
function M:box(node)
self.boxes = self.boxes or {}
self.boxes[#self.boxes + 1] = node
blocks.push(self, "boxes", node)
end
-- An action button of this build by its test id (H.action): effects that come after a
-- rebuild find the new node (the error shake, the ready burst).
function M:button(id, node)
self.buttons = self.buttons or {}
self.buttons[id] = node
blocks.put(self, "buttons", id, node)
end
-- The layout rect {x, y, w, h} (x right, y down, from the page's top-left) of a node with
@@ -193,8 +194,8 @@ end
-- A header number node for `key` (box: its chip or frame, the target for particles).
function M:bind(key, node, box)
self.binds[key] = { node = node, box = box, color = gui.get_color(node), p = gui.get_position(node),
scale = gui.get_scale(node), w = ui.text_width(node) }
blocks.put(self, "binds", key, { node = node, box = box, color = gui.get_color(node), p = gui.get_position(node),
scale = gui.get_scale(node), w = ui.text_width(node) })
end
local function drop_halo(c)
@@ -740,7 +741,7 @@ function M:reticle(root, w, h, name)
gui.set_pivot(glow, gui.PIVOT_CENTER)
gui.set_blend_mode(glow, theme.glow_blend())
gui.move_below(glow, parts[1])
self.reticles[#self.reticles + 1] = { node = ret, w = w, h = h, name = name, arms = arms, ticks = ticks, glow = glow }
blocks.push(self, "reticles", { node = ret, w = w, h = h, name = name, arms = arms, ticks = ticks, glow = glow })
if self.shine_known and not self.shine_known[name] then
-- It appeared shining: the reticle locks on after the tab's entrance.
self.shine_known[name] = true
@@ -832,6 +833,16 @@ local function index(list)
return m
end
-- A refined item's color (catalog.refined, as refinery.lua's item_color).
local function refined_color(st, name)
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
if r.name == name and r.color then
return theme.item(r.color)
end
end
return theme.resource[name] or C.violet
end
-- After a response's state is applied (g: the game screen's self). kind: the action
-- name, or "get".
function M:on_response(g, kind, data, prev, st, opts)
@@ -879,7 +890,25 @@ function M:on_response(g, kind, data, prev, st, opts)
if credits > 0 then
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
elseif credits < 0 then
self:count("credits", self:value("credits", prev.credits), st.credits, { held = revealing or self:holding("credits") })
-- (a bought moon's price too: it counts down at the purchase, while the reveal plays)
self:count("credits", self:value("credits", prev.credits), st.credits, { held = self:holding("credits") })
end
-- Refined stock (the Refinery's recipe cards' owned numbers, keys "refined:<name>"):
-- what a collect brings is held until its motes land there; other changes count only if
-- one is running.
local refined = {}
if prev.refinery and st.refinery then
local rwas = by_name(prev.refinery.stock)
for _, r in ipairs(st.refinery.stock or {}) do
local key, old = "refined:" .. r.name, rwas[r.name] or 0
if r.amount > old and k == "collect" then
refined[#refined + 1] = { name = r.name, key = key, delta = r.amount - old, color = refined_color(st, r.name) }
self:count(key, self:value(key, old), r.amount, { held = not quiet or self:holding(key) })
elseif r.amount ~= old and self.counters[key] then
self:count(key, self:value(key, old), r.amount, { held = self:holding(key) })
end
end
end
if quiet then
@@ -916,12 +945,97 @@ function M:on_response(g, kind, data, prev, st, opts)
-- The rest needs the new layout's positions: after the next build.
self.pending[#self.pending + 1] = function()
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
self:collected(g, refined, at)
end
end
-- Refined items a collect brought (on_response): the amount pops up in the item's color
-- above the slot it came from (clear of the batch sliding into it), and motes stream from
-- the Collect button into the recipe card's owned number, which counts up as they land
-- (an arrival blip; the "collect" sound plays with the action). Without that number on
-- screen it just counts.
function M:collected(g, refined, at)
if #refined == 0 then
return
end
local from = at and center(at) or { x = g.size[1] / 2, y = g.size[2] / 2 }
local above = at and at.box and { x = at.box.x + at.box.w / 2, y = at.box.y - 8 } or { x = from.x, y = from.y - 10 }
local colors = {}
for i, rg in ipairs(refined) do
colors[#colors + 1] = rg.color
self:float_text("+" .. ui.money(rg.delta) .. " " .. rg.name, above.x, above.y - (i - 1) * 30, rg.color, (i - 1) * 0.09, false, -1,
{ key = "gain:" .. rg.key, amount = rg.delta, fmt = function(a) return "+" .. ui.money(a) .. " " .. rg.name end })
local r = self:box_of(rg.key)
if r then
local to = center(r)
self:stream(from, to, rg.color, 10, 120 + (i - 1) * 90, function()
sfx.arrive(i - 1, anim.reduced)
self:release(rg.key)
self:burst(to.x, to.y, { rg.color }, 12, 0.5)
self:glint(r)
end)
else
self:release(rg.key)
end
end
colors[#colors + 1] = GOLD_HI
self:burst(from.x, from.y, colors, 30, 0.8)
end
-- Refinery batches that finished on our clock (core/ready), whichever section is open:
-- the ready sound (once for batches finishing together or with a drill), then, after the
-- build that shows them, with the Refinery open each slot's edge lights up (a gold pulse)
-- and sparks leave the full end of its bar (refinery.lua pops its Ready label and Collect
-- button and runs a light along the bar); elsewhere the Refinery tab pulses and sparks
-- leave its badge (header.lua pops the badge).
function M:batch_ready(g, jobs)
local t = now()
if not (self.ready_sound and t - self.ready_sound < 1) then
sfx.play("ready")
self.ready_sound = t
end
self.pending[#self.pending + 1] = function()
if g.view and g.view.section == "refinery" then
for _, j in ipairs(jobs) do
local slot = g.rf_slots and g.rf_slots[j.id]
if slot then
self:pulse(self:rect_of(slot.frame), GOLD, 0.9)
local b = self:rect_of(slot.bar)
self:burst(b.x + b.w, b.y + b.h / 2, { GOLD, GOLD_HI }, 18, 0.6)
end
end
else
local tab = g.tab_nodes and g.tab_nodes.refinery
if tab then
local r = self:rect_of(tab)
self:pulse(r, GOLD, 0.8)
self:burst(r.x + r.w - 9, r.y + 9, { GOLD, GOLD_HI }, 14, 0.5) -- (the badge on its top-right corner)
end
end
end
end
-- (a text node's position is its pivot point and its size not its text's: the box is the
-- text's own, round that point)
local PIVOT = { -- (fraction of the box left of / above the pivot point)
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
function M:box_of(key)
local b = self.binds[key]
return b and self:rect_of(b.box or b.node)
if not b then
return nil
end
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
return self:rect_of(b.box or b.node)
end
local r = self:rect_of(b.node)
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
local w = ui.text_width(b.node)
local h = r.h * gui.get_scale(b.node).y
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
end
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
@@ -1006,12 +1120,7 @@ function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
{ key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
end
end
if revealing then
self.waiting.revealed = self.waiting.revealed or {}
table.insert(self.waiting.revealed, spend)
else
spend()
end
spend()
end
if BUY_KINDS[k] then
+37 -13
View File
@@ -1,4 +1,4 @@
-- The moon in the aside viewport: the procedural moon of the web build (terrain.lua, a
-- The moon in the aside viewport: the procedural moon of the web build (mmterrain and terrain.lua, a
-- port of the server's terrain.js; look of scene.js), drawn in 3D behind a GUI node.
--
-- Use from a gui_script:
@@ -6,23 +6,26 @@
-- call again after the layout moves the node (it reads the node's
-- screen rect). The node gets the "moon" material (listed in the
-- .gui's materials), which draws the rendered moon.
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
-- moon.move(node[, quiet]) the node moved (the narrow layout's page scroll, the reveal's
-- window sliding and shaking): follow it; quiet: no log line.
-- moon.hide() no moon (the node keeps its own look).
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
-- appearing in front of the moon shown) charges up, then bursts
-- into moon `id` once its mesh is built. moon.show(id, node) during
-- it only moves the viewport. Progress in moon.revealing (below).
-- moon.prefetch(id) start making moon `id`'s mesh ahead (a bought moon, before its
-- reveal is shown).
-- moon.skip_reveal() jump ahead: a charge bursts as soon as the moon is ready, a
-- burst ends.
--
-- How it works: moon/moon.script (in moon/moon.collection, a collection instance in the
-- game screen) generates meshes (cached per id, spread over frames), spins and orbits the
-- camera (drag to rotate, wheel to zoom), and fills `moon.frame`; the render script
-- game screen) generates meshes (cached per id; natively through the mmterrain extension:
-- on a thread on desktop, a few ms a frame on the web), spins and orbits the camera (drag to rotate, wheel to zoom), and fills `moon.frame`; the render script
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
--
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
-- defaults like the web does; until the request answers nothing is generated.
--
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
@@ -35,10 +38,17 @@ local M = {}
M.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
-- Settings: nil until loaded (or failed: then the defaults).
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
M.settings = nil
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
M.building = false
M.last_build = nil
-- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false }
@@ -64,17 +74,20 @@ local function node_rect(node)
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe)
gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
-- The size is rounded once (not as the difference of two rounded corners), so it stays
-- the same while the node moves by fractions of a pixel: every new size remakes the
-- render targets.
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
return { x = x0, y = y0, w = w, h = h }, w / math.max(1, size.x)
end
local function set_node(node)
local function set_node(node, quiet)
if node then
local old = M.rect
M.rect, M.unit = node_rect(node)
local r = M.rect
if not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h then
if not quiet and (not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h) then
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
end
gui.set_material(node, "moon")
@@ -96,9 +109,9 @@ function M.show(id, node)
end
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
function M.move(node)
function M.move(node, quiet)
if node and (M.id or M.mode) then
set_node(node)
set_node(node, quiet)
end
end
@@ -126,6 +139,17 @@ function M.reveal(id, node)
set_node(node)
end
-- Start making moon `id`'s mesh now (a moon just bought, before its reveal shows it), so
-- it's ready when asked for.
function M.prefetch(id)
if debug_id ~= "" then
id = debug_id
end
if id ~= nil and tostring(id) ~= M.id then
M.prefetch_id = tostring(id)
end
end
function M.skip_reveal()
local r = M.revealing
if r then
+37 -427
View File
@@ -1,7 +1,7 @@
-- Procedural moon terrain: a port of the server's web/static/js/terrain.js (generation,
-- deterministic from the moon id). Same seed and settings give the same moon as the web:
-- the integer hashing and random draws are bit-exact, the floats differ only by the JS
-- Float32Array rounding (~1e-7).
-- Moon terrain settings and helpers, a port of the server's web/static/js/terrain.js: the
-- settings (defaults, merge, per-moon resolve, the description traits), the colors and
-- the cube-sphere grid and its triangle strip. The generation itself is native: the
-- mmterrain extension (mmterrain/src/terrain.c) takes native_params(resolve(...)).
--
-- Pure Lua (LuaJIT `bit`), no engine APIs, so it also runs in plain luajit for checks
-- (scripts/moon-check.mjs). Keep it in sync with terrain.js when that changes.
@@ -131,146 +131,6 @@ function M.rng(seed)
end
end
---------------------------------------------------------------- noise
-- 3D simplex noise (Stefan Gustavson's reference implementation), roughly in [-1, 1].
-- 0-based tables (LuaJIT keeps index 0 in the array part). GX/GY/GZ[v] is the gradient of
-- permutation value v (GRAD3 at v % 12, i.e. terrain.js PERM12).
local GRAD3 = {
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
}
local PERM, GX, GY, GZ = {}, {}, {}, {}
do
local p = {}
for i = 0, 255 do
p[i] = i
end
local r = M.rng(1337)
for i = 255, 1, -1 do
local j = floor(r() * (i + 1))
p[i], p[j] = p[j], p[i]
end
for i = 0, 511 do
PERM[i] = p[band(i, 255)]
end
for v = 0, 255 do
local g = (v % 12) * 3
GX[v], GY[v], GZ[v] = GRAD3[g + 1], GRAD3[g + 2], GRAD3[g + 3]
end
end
M.PERM = PERM
local F3 = 1 / 3
local G3 = 1 / 6
-- Branch-free (LuaJIT side traces of the reference's if/else chains abort on register
-- pressure): the simplex corner order comes from 0/1 flags, and corners outside their
-- radius get a zero weight. Bit-identical results: for floats, x >= y iff x - y >= 0, and
-- here |x - y| < 1, so 1 + floor(x - y) is that flag.
local function simplex3(x, y, z)
local P = PERM
local s = (x + y + z) * F3
local i, j, k = floor(x + s), floor(y + s), floor(z + s)
local t = (i + j + k) * G3
local x0, y0, z0 = x - (i - t), y - (j - t), z - (k - t)
local a = 1 + floor(x0 - y0) -- x0 >= y0
local b = 1 + floor(y0 - z0) -- y0 >= z0
local c = 1 + floor(x0 - z0) -- x0 >= z0
local i1 = a * (b + (1 - b) * c)
local j1 = (1 - a) * b
local k1 = 1 - i1 - j1
local i2 = a + (1 - a) * b * c
local j2 = a * b + 1 - a
local k2 = 2 - i2 - j2
local ii, jj, kk = band(i, 255), band(j, 255), band(k, 255)
local tt = max(0, 0.6 - x0 * x0 - y0 * y0 - z0 * z0)
local g = P[ii + P[jj + P[kk]]]
tt = tt * tt
local n = tt * tt * (GX[g] * x0 + GY[g] * y0 + GZ[g] * z0)
local x1, y1, z1 = x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3
tt = max(0, 0.6 - x1 * x1 - y1 * y1 - z1 * z1)
g = P[ii + i1 + P[jj + j1 + P[kk + k1]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x1 + GY[g] * y1 + GZ[g] * z1)
local x2, y2, z2 = x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3
tt = max(0, 0.6 - x2 * x2 - y2 * y2 - z2 * z2)
g = P[ii + i2 + P[jj + j2 + P[kk + k2]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x2 + GY[g] * y2 + GZ[g] * z2)
local x3, y3, z3 = x0 - 1 + 3 * G3, y0 - 1 + 3 * G3, z0 - 1 + 3 * G3
tt = max(0, 0.6 - x3 * x3 - y3 * y3 - z3 * z3)
g = P[ii + 1 + P[jj + 1 + P[kk + 1]]]
tt = tt * tt
n = n + tt * tt * (GX[g] * x3 + GY[g] * y3 + GZ[g] * z3)
return 32 * n
end
M.simplex3 = simplex3
-- Caps on saved settings, which come from the admin page.
local MAX_OCTAVES = 10
local MAX_CRATERS = 2000
-- Fractal (fBm) noise in [-1, 1].
local function fbm(x, y, z, octaves, frequency, persistence, lacunarity)
local sum, amp, norm, f = 0, 1, 0, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
sum = sum + simplex3(x * f, y * f, z * f) * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Billow noise (fBm of |noise|) in [0, 1].
local function billow(x, y, z, octaves, frequency, persistence, lacunarity)
local sum, amp, norm, f = 0, 1, 0, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
sum = sum + abs(simplex3(x * f, y * f, z * f)) * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Ridged multifractal in [0, 1]: sharp crests; each octave is weighted by the previous.
local function ridged(x, y, z, octaves, frequency, persistence, lacunarity, sharpness, gain)
local sum, amp, norm, weight, f = 0, 1, 0, 1, frequency
for _ = 1, min(octaves, MAX_OCTAVES) do
local v = 1 - abs(simplex3(x * f, y * f, z * f))
v = pow(v, sharpness) * weight
weight = min(1, max(0, v * gain))
sum = sum + v * amp
norm = norm + amp
amp = amp * persistence
f = f * lacunarity
end
return sum / norm
end
-- Polynomial smooth min/max (Inigo Quilez), k = smoothing width.
local function smooth_min(a, b, k)
if k <= 0 then
return min(a, b)
end
local h = min(1, max(0, (b - a + k) / (2 * k)))
return a * h + b * (1 - h) - k * h * (1 - h)
end
local function smooth_max(a, b, k)
return -smooth_min(-a, -b, k)
end
local function smoothstep(e0, e1, x)
local d = e1 - e0
if d == 0 then
d = 1e-6
end
local t = min(1, max(0, (x - e0) / d))
return t * t * (3 - 2 * t)
end
---------------------------------------------------------------- colors
local function round(x)
@@ -410,6 +270,32 @@ function M.traits(settings, seed)
}
end
-- The resolved parameters as the flat number list the native generator takes
-- (mmterrain/src/terrain.h mt_params_from_array; colors linear, like generate's).
local TECHNIQUE_INDEX = { fbm = 0, ridged = 1, warped = 2, billow = 3, craterField = 4 }
function M.native_params(p)
local sh, rd, wp, cr, sd = p.shape, p.ridge, p.warp, p.craters, p.shading
local off = p.offset or { 0, 0, 0 }
local a = {
p.resolution, TECHNIQUE_INDEX[p.technique] or 0,
sh.strength, sh.frequency, sh.octaves, sh.persistence, sh.lacunarity, sh.minValue,
rd.strength, rd.frequency, rd.octaves, rd.persistence, rd.lacunarity, rd.sharpness, rd.gain, rd.minValue,
wp.strength, wp.frequency, wp.octaves,
cr.count, cr.minRadius, cr.maxRadius, cr.sizeBias, cr.depth, cr.rimWidth, cr.rimSteepness,
cr.floorHeight, cr.smoothness, cr.ejecta,
}
for _, key in ipairs({ "low", "mid", "high", "steep", "patch" }) do
local c = M.linear_color(sd[key])
a[#a + 1], a[#a + 2], a[#a + 3] = c[1], c[2], c[3]
end
local rest = { sd.steepThreshold, sd.steepBlend, sd.patchAmount, sd.patchFrequency, sd.grain,
off[1], off[2], off[3], p.craterSeed or 1 }
for _, v in ipairs(rest) do
a[#a + 1] = v
end
return a
end
---------------------------------------------------------------- mesh
-- Cube faces: outward normal and two tangent axes (a x b = up, so triangles face out).
@@ -424,7 +310,13 @@ local FACES = {
-- Unit-sphere directions of the cube-sphere grid (6 faces of (n+1)^2 vertices, flat xyz,
-- vertex v at [3v+1..3v+3], v 0-based) and the shared key of each seam vertex (-1 inside).
-- (Read-only for callers: cached per n, like the strip.)
local sphere_cache = {}
function M.cube_sphere(n)
local hit = sphere_cache[n]
if hit then
return hit[1], hit[2]
end
local row = n + 1
local dirs, keys = {}, {}
local v = 0
@@ -453,6 +345,7 @@ function M.cube_sphere(n)
end
end
end
sphere_cache[n] = { dirs, keys }
return dirs, keys
end
@@ -488,287 +381,4 @@ function M.strip(n)
return out
end
-- Craters scattered on the unit sphere, bucketed in a 3D grid for fast lookup.
local function make_craters(c, seed, count_scale)
local r = M.rng(seed)
local count = round(min(c.count, MAX_CRATERS) * count_scale)
local x_max = max(1 + c.rimWidth, sqrt(1 + max(0, c.smoothness)))
local list = {}
for i = 1, count do
local z = r() * 2 - 1
local a = r() * pi * 2
local q = sqrt(1 - z * z)
local radius = c.minRadius + (c.maxRadius - c.minRadius) * pow(r(), c.sizeBias)
list[i] = { x = q * cos(a), y = q * sin(a), z = z, r = radius, reach = radius * x_max }
end
local G = 12
local cell = 2 / G
local grid = {}
for i = 1, G * G * G do
grid[i] = {}
end
local function at(v)
return min(G - 1, max(0, floor((v + 1) / cell)))
end
for _, cr in ipairs(list) do
for x = at(cr.x - cr.reach), at(cr.x + cr.reach) do
for y = at(cr.y - cr.reach), at(cr.y + cr.reach) do
for z = at(cr.z - cr.reach), at(cr.z + cr.reach) do
local g = grid[(x * G + y) * G + z + 1]
g[#g + 1] = cr
end
end
end
end
return {
cell_of = function(x, y, z)
return grid[(at(x) * G + at(y)) * G + at(z) + 1]
end,
x_max = x_max,
}
end
-- Builds the moon for resolved parameters (see M.resolve): flat arrays over the cube-sphere
-- grid (vertex v 0-based at [3v+1..3v+3]): positions (unit radius plus elevation), normals,
-- colors (linear RGB). `checkpoint` (optional) is called every few hundred vertices, e.g.
-- to yield a coroutine and spread the work over frames.
function M.generate(p, checkpoint)
checkpoint = checkpoint or function() end
local n = max(8, min(320, round(p.resolution)))
local row = n + 1
local per = row * row
local count = 6 * per
local dirs, keys = M.cube_sphere(n)
local heights, ejecta = {}, {}
local technique = p.technique
local sh, rd, wp, cr = p.shape, p.ridge, p.warp, p.craters
local off = p.offset or { 0, 0, 0 }
local ox, oy, oz = off[1], off[2], off[3]
local crater_field = technique == "craterField"
local cparams = cr
if crater_field then
cparams = clone(cr)
cparams.minRadius = cr.minRadius * 0.8
cparams.maxRadius = cr.maxRadius * 0.6
end
local craters = make_craters(cparams, p.craterSeed or 1, crater_field and 3 or 1)
local x_max = craters.x_max
local shape_strength = crater_field and sh.strength * 0.4 or sh.strength
local smooth = max(0, cr.smoothness)
local ejecta_reach = 1 + cr.rimWidth * 2.5
local rim_width, rim_steep, floor_h = cr.rimWidth, cr.rimSteepness, cr.floorHeight
local s_oct, s_freq, s_pers, s_lac, s_min = sh.octaves, sh.frequency, sh.persistence, sh.lacunarity, sh.minValue
local r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain, r_min, r_str =
rd.octaves, rd.frequency, rd.persistence, rd.lacunarity, rd.sharpness, rd.gain, rd.minValue, rd.strength
for i = 0, count - 1 do
local x, y, z = dirs[i * 3 + 1], dirs[i * 3 + 2], dirs[i * 3 + 3]
local px, py, pz = x + ox, y + oy, z + oz
-- Primary shape layer.
local value
if technique == "ridged" then
value = ridged(px, py, pz, s_oct, s_freq, s_pers, s_lac, r_sharp, r_gain)
elseif technique == "billow" then
value = billow(px, py, pz, s_oct, s_freq, s_pers, s_lac)
elseif technique == "warped" then
local wf = wp.frequency
local wx = fbm(px + 5.2, py + 1.3, pz, wp.octaves, wf, 0.5, 2)
local wy = fbm(px, py + 9.7, pz + 2.8, wp.octaves, wf, 0.5, 2)
local wz = fbm(px + 3.1, py, pz + 7.4, wp.octaves, wf, 0.5, 2)
local s = wp.strength
value = (fbm(px + wx * s, py + wy * s, pz + wz * s, s_oct, s_freq, s_pers, s_lac) + 1) / 2
else
value = (fbm(px, py, pz, s_oct, s_freq, s_pers, s_lac) + 1) / 2
end
local h = max(0, value - s_min) * shape_strength
-- Ridge layer.
if r_str ~= 0 then
local r = ridged(px - 11.3, py + 4.1, pz - 7.9, r_oct, r_freq, r_pers, r_lac, r_sharp, r_gain)
h = h + max(0, r - r_min) * r_str
end
-- Craters: parabola cavity, flattened floor, smooth rim (smooth min/max blend them).
local crater_h, glow = 0, 0
local list = craters.cell_of(x, y, z)
for c = 1, #list do
local k = list[c]
local dx, dy, dz = x - k.x, y - k.y, z - k.z
local d2 = dx * dx + dy * dy + dz * dz
local reach = max(k.reach, k.r * ejecta_reach)
if d2 <= reach * reach then
local dist = sqrt(d2) / k.r
if dist < x_max then
local cavity = dist * dist - 1
local rim_x = min(dist - 1 - rim_width, 0)
local rim = rim_steep * rim_x * rim_x
local shape = smooth_max(cavity, floor_h, smooth)
shape = smooth_min(shape, rim, smooth)
crater_h = crater_h + shape * k.r
end
if dist > 0.8 and dist < ejecta_reach then
glow = glow + 1 - smoothstep(1, ejecta_reach, dist)
end
end
end
heights[i + 1] = h + crater_h * cr.depth
ejecta[i + 1] = min(1, glow)
if i % 256 == 0 then
checkpoint()
end
end
-- Positions on a unit sphere plus elevation, re-centered so the mean radius stays 1.
local mean = 0
for i = 1, count do
mean = mean + heights[i]
end
mean = mean / count
local positions = {}
local min_h, max_h = math.huge, -math.huge
for i = 0, count - 1 do
local hh = heights[i + 1] - mean
heights[i + 1] = hh
local r = 1 + hh
positions[i * 3 + 1] = dirs[i * 3 + 1] * r
positions[i * 3 + 2] = dirs[i * 3 + 2] * r
positions[i * 3 + 3] = dirs[i * 3 + 3] * r
if hh < min_h then
min_h = hh
end
if hh > max_h then
max_h = hh
end
end
checkpoint()
-- Area-weighted vertex normals (the same two triangles per quad as terrain.js).
local normals = {}
for i = 1, count * 3 do
normals[i] = 0
end
local P = positions
local function tri(a, b, c)
a, b, c = a * 3, b * 3, c * 3
local e1x, e1y, e1z = P[b + 1] - P[a + 1], P[b + 2] - P[a + 2], P[b + 3] - P[a + 3]
local e2x, e2y, e2z = P[c + 1] - P[a + 1], P[c + 2] - P[a + 2], P[c + 3] - P[a + 3]
local nx = e1y * e2z - e1z * e2y
local ny = e1z * e2x - e1x * e2z
local nz = e1x * e2y - e1y * e2x
normals[a + 1] = normals[a + 1] + nx
normals[a + 2] = normals[a + 2] + ny
normals[a + 3] = normals[a + 3] + nz
normals[b + 1] = normals[b + 1] + nx
normals[b + 2] = normals[b + 2] + ny
normals[b + 3] = normals[b + 3] + nz
normals[c + 1] = normals[c + 1] + nx
normals[c + 2] = normals[c + 2] + ny
normals[c + 3] = normals[c + 3] + nz
end
for f = 0, 5 do
local base = f * per
for y = 0, n - 1 do
for x = 0, n - 1 do
local i = base + y * row + x
tri(i, i + 1, i + n + 2)
tri(i, i + n + 2, i + n + 1)
end
checkpoint()
end
end
-- Faces duplicate their edge vertices; sum the copies so there are no lighting seams.
local seams = {}
for i = 0, count - 1 do
local key = keys[i + 1]
if key >= 0 then
local g = seams[key]
if g then
g[#g + 1] = i
else
seams[key] = { i }
end
end
end
for _, group in pairs(seams) do
if #group > 1 then
local sx, sy, sz = 0, 0, 0
for _, i in ipairs(group) do
sx, sy, sz = sx + normals[i * 3 + 1], sy + normals[i * 3 + 2], sz + normals[i * 3 + 3]
end
for _, i in ipairs(group) do
normals[i * 3 + 1], normals[i * 3 + 2], normals[i * 3 + 3] = sx, sy, sz
end
end
end
for i = 0, count - 1 do
local o = i * 3
local nx, ny, nz = normals[o + 1], normals[o + 2], normals[o + 3]
local len = sqrt(nx * nx + ny * ny + nz * nz)
if len == 0 then
len = 1
end
normals[o + 1], normals[o + 2], normals[o + 3] = nx / len, ny / len, nz / len
end
checkpoint()
-- Colors: elevation gradient, dark patches, steep slopes, crater halos, grain.
local sd = p.shading
local low, mid, high = M.linear_color(sd.low), M.linear_color(sd.mid), M.linear_color(sd.high)
local steep, patch = M.linear_color(sd.steep), M.linear_color(sd.patch)
local colors = {}
local range = max_h - min_h
if range == 0 then
range = 1
end
local patch_amount, patch_freq, grain = sd.patchAmount, sd.patchFrequency, sd.grain
local st_lo, st_hi = sd.steepThreshold - sd.steepBlend, sd.steepThreshold + sd.steepBlend
local cr_ejecta = cr.ejecta
for i = 0, count - 1 do
local o = i * 3
local x, y, z = dirs[o + 1], dirs[o + 2], dirs[o + 3]
local e = (heights[i + 1] - min_h) / range
local lo, hi, te
if e < 0.5 then
lo, hi, te = low, mid, e * 2
else
lo, hi, te = mid, high, (e - 0.5) * 2
end
local c1 = lo[1] + (hi[1] - lo[1]) * te
local c2 = lo[2] + (hi[2] - lo[2]) * te
local c3 = lo[3] + (hi[3] - lo[3]) * te
if patch_amount > 0 then
local pn = fbm(x + ox + 31.7, y + oy - 12.2, z + oz + 5.5, 4, patch_freq, 0.5, 2)
-- Patches favor low ground, like maria.
local w = smoothstep(-0.05, 0.4, pn) * patch_amount * (1 - e * 0.6)
c1, c2, c3 = c1 + (patch[1] - c1) * w, c2 + (patch[2] - c2) * w, c3 + (patch[3] - c3) * w
end
local g = ejecta[i + 1] * cr_ejecta
c1, c2, c3 = c1 + (high[1] - c1) * g, c2 + (high[2] - c2) * g, c3 + (high[3] - c3) * g
local steepness = 1 - (normals[o + 1] * x + normals[o + 2] * y + normals[o + 3] * z)
local ws = smoothstep(st_lo, st_hi, steepness)
c1, c2, c3 = c1 + (steep[1] - c1) * ws, c2 + (steep[2] - c2) * ws, c3 + (steep[3] - c3) * ws
local gr = 1 + fbm(x + ox, y + oy, z + oz, 2, 14, 0.5, 2) * grain
colors[o + 1], colors[o + 2], colors[o + 3] = c1 * gr, c2 * gr, c3 * gr
if i % 512 == 0 then
checkpoint()
end
end
return {
n = n,
count = count,
positions = positions,
normals = normals,
colors = colors,
min_h = min_h,
max_h = max_h,
}
end
return M
+325 -61
View File
@@ -53,6 +53,12 @@ function M.copy(st)
return copy(st, true)
end
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
local function same_copy(a, b)
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
end
local function find(list, id, key)
key = key or "id"
for i, x in ipairs(list or {}) do
@@ -168,7 +174,7 @@ function M.tool(st, id)
return find(st.tools, id)
end
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
function M.idle_copy(st, id)
local t = find(st.tools, id)
if not t then
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
return t
end
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
if not x.stakeId and same_copy(x, t) then
return x
end
end
@@ -189,18 +195,162 @@ local function yield_boost(st, resource)
return b[resource] or 1
end
---------------------------------------------------------------- shared wells
-- Ports of the server's well split (game.go coverWells, splitClaim; views.go split,
-- project, takeAfter), api.md "Prediction": claims go oldest first, each from what the
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
local EPS = 1e-9
-- The wells a stake with this reach mines: its anchor first, then the next wells in the
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
local function cover_wells(anchor, supply, reach)
local n = #supply
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
local out = {}
local want = math.max(1, reach or 1)
for k = 0, n - 1 do
if #out >= want then
break
end
local i = (start - 1 + k) % n + 1
if supply[i] > 0 then
out[#out + 1] = i
end
end
return out
end
local function well_index(moon, id)
for i, w in ipairs(moon.wells or {}) do
if w.id == id then
return i
end
end
end
-- What claiming a stake (wellId, amount, reach) takes from each well, given supply.
local function split(moon, s, supply)
local take = {}
for i = 1, #supply do
take[i] = 0
end
local anchor = well_index(moon, s.wellId)
if (s.reach or 1) <= 1 then
if anchor then
take[anchor] = math.max(0, math.min(s.amount, supply[anchor]))
end
return take
end
local amount = s.amount
for _, i in ipairs(cover_wells(anchor, supply, s.reach)) do
take[i] = math.max(0, math.min(amount, supply[i]))
amount = amount - take[i]
if amount <= 0 then
break
end
end
return take
end
local function supplies(moon)
local out = {}
for i, w in ipairs(moon.wells or {}) do
out[i] = w.supply
end
return out
end
local function sum(t)
local n = 0
for _, x in ipairs(t) do
n = n + x
end
return n
end
-- The moon's stakes, oldest first (ties in the server's order).
local function moon_stakes(st, moon)
local list = {}
for i, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
list[#list + 1] = { s = s, i = i }
end
end
table.sort(list, function(a, b)
if a.s.start ~= b.s.start then
return a.s.start < b.s.start
end
return a.i < b.i
end)
for i, x in ipairs(list) do
list[i] = x.s
end
return list
end
-- The server's projection (views.go project) of the moon's unclaimed drills.
function M.project(st, moon)
local n = #(moon.wells or {})
local p = { take = {}, gets = {}, demand = {}, remain = supplies(moon), claims = {}, dry_after = {}, dry_at = {} }
for i = 1, n do
p.demand[i], p.claims[i], p.dry_after[i] = 0, 0, 0
end
local current, last = supplies(moon), {}
for _, s in ipairs(moon_stakes(st, moon)) do
if (s.reach or 1) <= 1 then
local i = well_index(moon, s.wellId)
if i then
p.demand[i] = p.demand[i] + s.amount
p.claims[i] = p.claims[i] + 1
last[i] = math.max(last[i] or 0, s["end"])
end
else
for i, t in ipairs(split(moon, s, current)) do
p.demand[i] = p.demand[i] + t
end
end
local take = split(moon, s, p.remain)
p.take[s.id], p.gets[s.id] = take, sum(take)
for i, t in ipairs(take) do
if (s.reach or 1) > 1 and t > 0 then
p.claims[i] = p.claims[i] + 1
last[i] = math.max(last[i] or 0, s["end"])
end
p.remain[i] = p.remain[i] - t
if t > 0 and p.remain[i] <= EPS and p.dry_after[i] == 0 then
p.remain[i] = 0
p.dry_after[i], p.dry_at[i] = p.claims[i], last[i]
end
end
end
return p
end
-- What a new stake (amount, reach) on the well would get after the moon's drills (the
-- server's takeAfter; 0: the server refuses it, "Nothing left to mine on this well").
function M.take_after(moon, p, well_id, amount, reach)
return sum(split(moon, { wellId = well_id, amount = amount, reach = reach }, p.remain))
end
-- Room for a new one-well stake of amount on the well (0 when fully committed); nil when
-- the well isn't on the selected moon (no projection to go by).
function M.room(st, well_id, amount, reach)
local moon = st.moon
if not moon or not well_index(moon, well_id) then
return nil
end
return M.take_after(moon, M.project(st, moon), well_id, amount, reach)
end
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
-- group: the well panel's idle group for it (cycle and yield already for this well).
function M.add_stake(st, tool, moon_id, well, ctx, group)
local cycle = group and group.cycleSecs or tool.cycleSecs
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
local committed = 0
for _, s in ipairs(st.stakes) do
if s.wellId == well.id then
committed = committed + (s.take or 0)
end
end
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
-- take: what claiming it now would get, as the server's stake.take (its share after
-- the other drills is derive's projection).
local take = math.max(0, math.min(amount, M.supply(st, well.id)))
local s = {
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
resource = well.resource, amount = amount, reach = tool.reach or 1,
@@ -212,11 +362,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
-- Off the selected well's idle list.
if st.well then
for i, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
g.count = g.count - 1
if g.toolId == tool.id then
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
if not x.stakeId and same_copy(x, g) then
g.toolId = x.id
break
end
@@ -260,7 +410,7 @@ function M.remove_stake(st, s)
if st.well and st.well.idle then
local g = nil
for _, x in ipairs(st.well.idle) do
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
if same_copy(x, tool) then
g = x
end
end
@@ -270,16 +420,17 @@ function M.remove_stake(st, s)
local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = {
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
short = free < y, attachments = tool.attachments or {},
short = free < y,
}
end
end
end
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
-- buttons' amounts, next ends). Wide drills (reach > 1) are left as the server sent them.
-- buttons' amounts, next ends) and the selected moon's well projection (M.project: demand,
-- remain, full, notes, deploy cards). The wide group (reach > 1) is left as the server sent it.
function M.derive(st)
local ready, idle, next_end = 0, 0, nil
local per_moon = {}
@@ -309,39 +460,71 @@ function M.derive(st)
return
end
local mready, mnext = 0, nil
for _, w in ipairs(moon.wells or {}) do
local p = M.project(st, moon)
local ui = require("modules.ui")
for wi, w in ipairs(moon.wells or {}) do
local old = {}
for _, d in ipairs(w.drills or {}) do
old[d.id] = d
end
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0
for _, s in ipairs(st.stakes or {}) do
local drills, wready, wnext = {}, 0, nil
local supply = supplies(moon)
local amount = 0 -- the ready drills claimed in turn (the server's claimable)
for _, s in ipairs(moon_stakes(st, moon)) do
if s.wellId == w.id and (s.reach or 1) <= 1 then
local d = old[s.id]
if not d then
d = copy(s)
d.gets, d.here = s.take, s.take
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
end
local d = old[s.id] or copy(s)
d.gets = p.gets[s.id] or 0
d.here = d.gets
d.state = d.gets <= EPS and "none" or (d.gets < s.amount - EPS and "short" or "ok")
drills[#drills + 1] = d
demand = demand + (d.here or s.take or 0)
if s.ready then
wready = wready + 1
amount = amount + (s.take or 0)
local take = split(moon, s, supply)
for i, t in ipairs(take) do
supply[i] = supply[i] - t
amount = amount + t
end
elseif not wnext or s["end"] < wnext then
wnext = s["end"]
end
end
end
table.sort(drills, function(a, b) return a.start < b.start end)
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
w.demand = demand
w.remain = math.max(0, w.supply - demand)
-- The well's projection fields, as the server words them (views.go WellRow).
local demand, remain = p.demand[wi], p.remain[wi]
w.demand, w.remain, w.dryAfter, w.dryAt = demand, remain, p.dry_after[wi], p.dry_at[wi]
local taken = math.max(0, w.supply - remain)
w.over = w.supply > 0 and demand > w.supply + EPS
w.full = w.supply > 0 and ((demand > 0 and demand >= w.supply - EPS) or (remain <= EPS and taken > 0))
w.fullyCommitted = w.supply > 0 and remain <= EPS
w.statusText = w.over and "Over-drilled — next claims will come up short"
or (w.full and "Fully committed" or "")
if demand == 0 then
w.noteText = ""
else
w.noteText = ui.money(w.supply) .. " left · " .. ui.money(demand) .. " committed · "
.. (w.dryAfter > 0 and ("runs dry after " .. w.dryAfter .. (w.dryAfter == 1 and " claim" or " claims"))
or (ui.money(remain) .. " free after"))
end
mready = mready + wready
if wnext and (not mnext or wnext < mnext) then
mnext = wnext
end
end
-- The deploy cards: what each idle tool would get here after the drills on the moon
-- (the server's here, free, short; blocked: here is 0, the stake is refused).
local wi = st.well and well_index(moon, st.well.id)
if wi then
for _, g in ipairs(st.well.idle or {}) do
g.here = M.take_after(moon, p, st.well.id, g.yield, g.reach)
g.free = 0
for _, j in ipairs(cover_wells(wi, p.remain, g.reach)) do
g.free = g.free + p.remain[j]
end
g.short = g.here < g.yield - EPS
g.blocked = g.here <= EPS
end
end
if moon.wide then
mready = mready + (moon.wide.ready or 0)
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
@@ -399,43 +582,77 @@ end
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {}
for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
if wide then
return false -- multi-well drills: the server splits them
end
local scope
if p["claim-wide"] then
scope = wide and s.moonId == p["claim-wide"]
else
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
end
if done(s, ctx) and scope then
list[#list + 1] = s
end
end
if #list == 0 then
return false
end
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
-- A multi-well drill takes from its well, then the moon's next ones (split).
table.sort(list, function(a, b) return a.start < b.start end)
local emptied = {}
for _, s in ipairs(list) do
local take = (s.take or 0) * M.skew
M.add_item(st, s.resource, take)
M.add_supply(st, s.wellId, -(s.take or 0))
M.remove_stake(st, s)
end
M.refresh_market(st)
if not p.redeploy then
for _, s in ipairs(list) do
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail then
detail.redrill = (detail.redrill or 0) + 1
local moon = find(st.moons, s.moonId)
if (s.reach or 1) > 1 and moon then
local sup = {}
for i, w in ipairs(moon.wells or {}) do
sup[i] = M.supply(st, w.id)
end
for i, take in ipairs(split(moon, s, sup)) do
local w = moon.wells[i]
if take > 0 then
M.add_item(st, w.resource or s.resource, take * M.skew)
M.add_supply(st, w.id, -take)
if M.supply(st, w.id) <= 0 then
emptied[w.id] = true
end
end
end
else
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
M.add_item(st, s.resource, take * M.skew)
M.add_supply(st, s.wellId, -take)
if take > 0 and M.supply(st, s.wellId) <= 0 then
emptied[s.wellId] = true
end
end
else
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end
-- A claim that empties a well stops the other one-well drills on it (stopTapped).
for i = #st.stakes, 1, -1 do
local s = st.stakes[i]
if emptied[s.wellId] and (s.reach or 1) <= 1 then
M.remove_stake(st, s)
end
end
M.refresh_market(st)
if p.redeploy then
for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1]
if tool and not tool.stakeId and w and w.supply > 0 then
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
local group = nil
if tool and w and st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if same_copy(g, tool) then
group = g
end
end
end
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
-- Not on a drained or fully committed well (the server leaves the tool idle).
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, tool.reach or 1) or 1) > EPS then
M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
@@ -466,17 +683,22 @@ M.register("stake", function(st, p, ctx)
return false -- "Deploy there": the view moves to the other well, wait for it
end
local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then
if not tool then
return false
end
local group = nil
if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
group = g
end
end
end
-- Refused when the drills on the well take all it has left (fullyCommitted).
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
if (M.room(st, w.id, amount, tool.reach or 1) or 0) <= EPS then
return false
end
M.add_stake(st, tool, moon.id, w, ctx, group)
return true
end)
@@ -499,24 +721,60 @@ M.register("redrill", function(st, p, ctx)
if #tools == 0 then
return false
end
for _, t in ipairs(tools) do
local group = nil
-- Until the well has no room left, like the server's redrill (game.go redrillPick):
-- the smallest drill that fills what is left alone, else the biggest and again; the
-- rest stay idle.
local function group_of(t)
if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then
group = g
if same_copy(g, t) then
return g
end
end
end
end
local staked = 0
while #tools > 0 do
local left = M.room(st, id, 1e18, 1) or 0
if left <= EPS then
break
end
-- What each takes a claim, with the well's yield event (the server's t.Amount).
local amounts = {}
for i, t in ipairs(tools) do
local g = group_of(t)
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
end
local pick
for i, a in ipairs(amounts) do
if a >= left - EPS and (not pick or a < amounts[pick]) then
pick = i
end
end
if not pick then
pick = 1
for i, a in ipairs(amounts) do
if a > amounts[pick] then
pick = i
end
end
end
local t = table.remove(tools, pick)
local group = group_of(t)
M.add_stake(st, t, moon.id, w, ctx, group)
staked = staked + 1
end
if staked == 0 then
return false
end
if detail then
detail.redrill = 0
detail.redrill = math.max(0, n - staked)
end
return true
end)
-- sell: amount × unpressed × the pressure average (api.md), or the whole balance.
-- sell: amount × unpressed (shared quote × advisors) × the pressure average (api.md), or
-- the whole balance.
M.register("sell", function(st, p, ctx)
local q = st.market and find(st.market.quotes, p.resource, "item")
if not q or not q.unpressed or not q.base then
@@ -534,8 +792,12 @@ M.register("sell", function(st, p, ctx)
local after = math.min(pnow + d, rules.pressure.cap)
-- Kept as of st.now, like the server's value (pressure_at decays it from there).
q.pressure = after / (0.5 ^ (math.max(0, ctx.now - st.now) / 1000 / rules.pressure.halfLifeSecs))
-- Personal: the shared quote (price, value) doesn't move; the payout does.
q.factor = M.pressure_avg(rules, after, 0)
q.price = q.unpressed * q.factor
q.unit = q.unpressed * q.factor
local pc = math.floor((1 - q.factor) * 100 + 0.5)
q.pressed = q.factor < 0.995
q.pressureText = q.pressed and string.format("−%d%%", pc) or ""
M.add_item(st, q.item, -amount)
M.refresh_market(st)
return true
@@ -597,7 +859,9 @@ M.register("refine", function(st, p, ctx)
return false
end
local rr = st.rules.refinery
local secs = math.floor(r.seconds * qty ^ rr.bulkExponent / (rr.speed or 1) + 0.5)
-- (refinery.go bulkUnits: seconds × min(n, 1 + bulkLog × ln n))
local units = math.min(qty, 1 + (rr.bulkLog or 0) * math.log(math.max(1, qty)))
local secs = math.floor(r.seconds * units / (rr.speed or 1) + 0.5)
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
slot.job = job
+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,
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
rumble = 1, reveal = 1,
}
-- Minimum time between two plays of the same sound, in seconds.
@@ -96,6 +97,13 @@ function M.play(name, opts)
end
end
-- Stops every play of a sound (the reveal's build-up when the burst comes early).
function M.stop(name)
if GAIN[name] then
pcall(sound.stop, "main:/sfx#" .. name)
end
end
local function semitones(n)
return 2 ^ (n / 12)
end
+276 -14
View File
@@ -7,7 +7,10 @@
-- Elements: every interactive component registers a stable id while the layout builds
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel". A
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
-- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), the gear "settings" (its menu: "mute", "theme", "motion",
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
-- component given opts.id uses that instead.
--
-- Commands (args after the name):
@@ -21,11 +24,16 @@
-- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
-- eval <lua expr> evaluate, reply with the value
-- list the registered elements: id, kind, label, rect, enabled
-- dump the screen's laid-out nodes (rects, kinds, texts; see dump below)
-- state a summary of the shown state (view, wallet, counts, busy)
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key>
-- mark <name> print "mm: shot <name>" (scripts/shot.sh SHOTS_DIR picks it up)
-- frames [reset] frame times since the last reset (the last 600 frames): fps, dt,
-- the game screen's update and its rebuilds, in ms (n, mean, p50,
-- p95, max); "reset" starts over
local focus = require("modules.focus")
local blocks = require("modules.blocks")
local M = {}
@@ -70,15 +78,31 @@ end
-- Register an interactive element. kind: "button", "link", "tab", "field", "row", ...
-- label: its text (for click "label=..."); zone: the stencil node clipping it, if any;
-- no_focus: not in the keyboard focus order (the web's tabindex=-1).
local function name(node, id)
if id then
names[gui.get_id(node)] = { id = id, scope = scope }
end
end
function M.register(id, node, kind, label, zone, no_focus)
blocks.note(M.register, id, node, kind, label, zone, no_focus) -- (a kept block registers again)
if zone and blocks.replaying() then
zone = require("druid.helper").get_closest_stencil_node(node) -- (kept, perhaps under a new stencil)
end
if not no_focus then
focus.add(id, node, kind, label, zone)
end
M.name(node, id)
name(node, id)
if not M.enabled or not id then
return
end
if elements[id] then
local old = elements[id]
if old and not pcall(gui.get_id, old.node) then
-- (its node went: a long list's row scrolled out and back keeps its plain id)
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
return id
end
if old then
local n = 2
while elements[id .. "#" .. n] do
n = n + 1
@@ -94,9 +118,8 @@ end
-- Give a node an element's id (register does; also a node that takes the clicks for a
-- registered one). id_of: the id of a node, or nil.
function M.name(node, id)
if id then
names[gui.get_id(node)] = { id = id, scope = scope }
end
blocks.note(name, node, id)
name(node, id)
end
function M.id_of(node)
@@ -181,10 +204,11 @@ local function visible(node)
return true
end
-- A run of commands spread over frames: { frame_fn, ... }.
local function steps(g, list, done)
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
-- error reply when a step fails.
local function steps(g, list, done, id)
g.test_steps = g.test_steps or {}
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
end
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
@@ -326,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
}
steps(g, list, function()
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
end)
end, cmd.id)
end
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
@@ -344,7 +368,7 @@ function COMMANDS.scroll_to(g, cmd)
local scale = g.size[1] / sys.get_config_int("display.width", 1280)
local off = (g.scroll_pos and g.scroll_pos[a.key] or 0) + (vy - ey) * scale
g.scroll_pos[a.key] = math.max(0, math.min(a.max, off))
g.dirty = true
g.dirty, g.dirty_all = true, true
return { ok = true, key = a.key, offset = g.scroll_pos[a.key] }
end
end
@@ -368,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
function() input(k, { released = true }) end,
}, function()
reply(cmd.id, { ok = true })
end)
end, cmd.id)
end
function COMMANDS.type(g, cmd, input)
@@ -415,6 +439,182 @@ function COMMANDS.list(g)
return { ok = true, elements = out }
end
-- Layout dump: every shown node under g.page that matters for layout, in render order:
-- registered elements (kind = their kind, id = test id), text nodes ("text"), drawn boxes
-- ("panel") and stencils ("clip"). Rects in layout px (x right, y down from the page's
-- top-left); a text's rect is where its glyphs go (metrics, by pivot), bw/bh its node box.
-- p: the index of the nearest dumped ancestor (0 none); layer: the page's top child it is
-- in ("layout", "dialog", ...); clip: "part"/"all" when a stencil cuts it; en: false for
-- a disabled button; cut: text ends with "…" (ui.fit_text). scripts/ui_lint.py reads it.
local PIV = {
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
local function r1(v)
return math.floor(v * 2 + 0.5) / 2
end
local dump_ids = 0
local function cut80(s) -- (between UTF-8 characters)
if #s <= 80 then
return s
end
local n = 80
while n > 1 and s:byte(n + 1) >= 0x80 and s:byte(n + 1) < 0xC0 do
n = n - 1
end
return s:sub(1, n)
end
local walk_dump
local function dump(g)
local page = g.page
local probe = gui.new_box_node(vmath.vector3(100, -100, 0), vmath.vector3(1, 1, 0))
gui.set_parent(probe, page, false)
local o, q = gui.get_screen_position(page), gui.get_screen_position(probe)
gui.set_enabled(probe, false)
local kx, ky = (q.x - o.x) / 100, (o.y - q.y) / 100
local size = gui.get_size(page)
-- gui.get_tree maps ids to nodes, and ids repeat (gui.new_*_node all have the empty id,
-- Druid widgets give clones their template ids): it shows one node per id. So name
-- every node uniquely for the dump (one round per repeat) and put the ids back after.
local orig = {} -- dump id -> the node's own id
local tree
repeat
tree = gui.get_tree(page)
local fresh = false
for k, n in pairs(tree) do
if not orig[k] then
dump_ids = dump_ids + 1
local t = hash("mm_dump_" .. dump_ids)
gui.set_id(n, t)
orig[t], fresh = k, true
end
end
until not fresh
local ok, res = pcall(walk_dump, g, page, tree, orig, o, kx, ky)
for t, k in pairs(orig) do
gui.set_id(tree[t], k)
end
gui.delete_node(probe)
if not ok then
error(res)
end
res.w, res.h = r1(size.x), r1(size.y)
return res
end
-- The dump's walk over a tree whose ids are all unique (orig: their own ids).
walk_dump = function(g, page, tree, orig, o, kx, ky)
local ui = require("modules.ui")
-- Children by parent, in render order.
local kids = {}
for _, n in pairs(tree) do
local p = gui.get_parent(n)
if p then
local k = gui.get_id(p)
kids[k] = kids[k] or {}
table.insert(kids[k], n)
end
end
for _, list in pairs(kids) do
table.sort(list, function(a, b) return gui.get_index(a) < gui.get_index(b) end)
end
local layer_names = {}
for _, k in ipairs({ "layout", "toast_layer", "tip_layer", "focus_layer", "dialog", "fx", "reveal" }) do
local v = g[k]
for _, x in ipairs({ v or false, type(v) == "table" and (v.layer or v.root) or false }) do
local ok, p = pcall(gui.get_parent, x)
if ok and p and gui.get_id(p) == gui.get_id(page) and not layer_names[gui.get_id(x)] then
layer_names[gui.get_id(x)] = (k:gsub("_layer$", ""))
end
end
end
local out = {}
local function walk(n, sx, sy, zone, layer, parent)
if not gui.is_enabled(n) then
return
end
local sc = gui.get_scale(n)
sx, sy = sx * sc.x, sy * sc.y
local t = gui.get_type(n)
local sp = gui.get_screen_position(n)
local px, py = (sp.x - o.x) / kx, (o.y - sp.y) / ky
local sz = gui.get_size(n)
local a = PIV[gui.get_pivot(n)] or { 0.5, 0.5 }
local key = gui.get_id(n)
local named = names[orig[key]]
local e = named and elements[named.id]
local alpha = gui.get_visible(n) and gui.get_color(n).w or 0 -- (visible: this node only)
local item
local w, h = sz.x * sx, sz.y * sy
if t == gui.TYPE_TEXT then
local text = gui.get_text(n)
if text ~= "" and alpha > 0.01 then
local wrap = gui.get_line_break(n)
local m = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), text,
{ tracking = gui.get_tracking(n), line_break = wrap, width = wrap and sz.x or nil, leading = gui.get_leading(n) })
local tw, th = m.width * sx, m.height * sy
item = { kind = "text", x = px - a[1] * tw, y = py - a[2] * th, w = tw, h = th,
text = cut80(text), cut = text:find("…", 1, true) and true or nil,
wrap = wrap or nil, bw = r1(w), bh = r1(h) }
end
elseif e or (t == gui.TYPE_BOX and (alpha > 0.01 or gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE) and w * h >= 16) then
local look = ui.button_looks[n]
item = { kind = e and e.kind or (gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and "clip" or "panel"),
id = e and e.id or nil, x = px - a[1] * w, y = py - a[2] * h, w = w, h = h,
en = look and look.disabled and false or nil }
end
if item then
local nm = tostring(orig[key]):match("%[(.-)%]")
item.name = nm and nm:match("^[%a_]") and nm or nil
item.p, item.layer = parent, layer
if zone then
local ix = math.min(item.x + item.w, zone[3]) - math.max(item.x, zone[1])
local iy = math.min(item.y + item.h, zone[4]) - math.max(item.y, zone[2])
if ix <= 0 or iy <= 0 then
item.clip = "all"
elseif ix < item.w - 0.5 or iy < item.h - 0.5 then
item.clip = "part"
-- (what shows of it: the lint's overlap rule only counts that)
local vx, vy = math.max(item.x, zone[1]), math.max(item.y, zone[2])
item.vis = { r1(vx), r1(vy), r1(ix), r1(iy) }
end
end
item.x, item.y, item.w, item.h = r1(item.x), r1(item.y), r1(item.w), r1(item.h)
out[#out + 1] = item
item.i = #out
parent = #out
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and not gui.get_clipping_inverted(n) then
local z = { px - a[1] * w, py - a[2] * h, px + (1 - a[1]) * w, py + (1 - a[2]) * h }
if zone then
z = { math.max(z[1], zone[1]), math.max(z[2], zone[2]), math.min(z[3], zone[3]), math.min(z[4], zone[4]) }
end
zone = z
end
end
for _, c in ipairs(kids[key] or {}) do
walk(c, sx, sy, zone, layer, parent)
end
end
local pk = gui.get_id(page)
for i, c in ipairs(kids[pk] or {}) do
walk(c, 1, 1, nil, layer_names[gui.get_id(c)] or ("layer" .. i), 0)
end
return { view = plain(g.view), dialog = g.dialog and g.dialog:is_open() or false, nodes = out }
end
function COMMANDS.dump(g, cmd)
-- On a later frame: nodes deleted this frame (a rebuild) are only gone after it.
steps(g, { function() end, function()
local ok, d = pcall(dump, g)
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
end }, nil, cmd.id)
end
function COMMANDS.state(g)
return { ok = true, state = summary(g) }
end
@@ -427,6 +627,64 @@ function COMMANDS.set(g, cmd, _, api)
return { ok = true }
end
-- Frame times (the frames command): the engine's dt, the game screen's update time and
-- the rebuilds' time, recorded by M.frame every frame (core/main.lua) with M.clock (on the
-- web the page's performance.now: socket.gettime has whole milliseconds there).
local FRAMES = 600
local frames
local function frames_reset()
frames = { n = 0, i = 0, dt = {}, lua = {}, builds = {} }
end
frames_reset()
function M.clock()
if html5 then
return (tonumber(html5.run("performance.now()")) or 0) / 1000
end
return socket.gettime()
end
function M.frame(dt, lua_secs, build_secs)
frames.i = frames.i % FRAMES + 1
frames.n = math.min(frames.n + 1, FRAMES)
frames.dt[frames.i] = dt
frames.lua[frames.i] = lua_secs
if build_secs and #frames.builds < FRAMES then
frames.builds[#frames.builds + 1] = build_secs
end
end
local function stats(list, n)
local t, sum = {}, 0
for i = 1, n do
t[i] = list[i] * 1000
sum = sum + t[i]
end
table.sort(t)
local function r(v)
return math.floor(v * 100 + 0.5) / 100
end
local function q(p)
return n > 0 and r(t[math.min(n, math.floor(p * n) + 1)]) or 0
end
return { n = n, mean = n > 0 and r(sum / n) or 0, p50 = q(0.5), p95 = q(0.95), max = q(1) }
end
function COMMANDS.frames(g, cmd)
local n = frames.n
local total = 0
for i = 1, n do
total = total + frames.dt[i]
end
local res = { ok = true, fps = total > 0 and math.floor(n / total * 10 + 0.5) / 10 or 0,
dt = stats(frames.dt, n), lua = stats(frames.lua, n), build = stats(frames.builds, #frames.builds) }
if cmd.args[1] == "reset" then
frames_reset()
end
return res
end
function COMMANDS.mark(g, cmd)
print("mm: shot " .. tostring(cmd.args[1]))
return { ok = true }
@@ -441,9 +699,13 @@ function M.update(g, input, api)
-- Running multi-frame steps.
if g.test_steps and #g.test_steps > 0 then
local s = g.test_steps[1]
s.list[s.i]()
local ok, err = pcall(s.list[s.i])
s.i = s.i + 1
if s.i > #s.list then
if not ok then
-- (a failing step ends its run: reply with the error, or the command never answers)
table.remove(g.test_steps, 1)
reply(s.id, { ok = false, error = tostring(err) })
elseif s.i > #s.list then
table.remove(g.test_steps, 1)
if s.done then
s.done()
+4 -8
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;
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
@@ -49,7 +49,7 @@ M.palettes = {
loss = { "#ff8a96" },
teal = { "#2dd4bf" },
violet = { "#b69cff" },
violet_hi = { "#dccfff" }, -- attachment and advisor effects
violet_hi = { "#dccfff" }, -- advisor effects
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
up = { "#3ee6a0" },
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
@@ -168,10 +168,10 @@ M.size = {
aside_w_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
aside_w_max = 580,
content_max = 960, -- widest a section's content gets (tables, rows) in the main area
market_max = 960, -- the market's (quotes, detail and ticket)
market_max = 1320, -- the market's (quotes, detail and ticket)
market_panel_max = 460, -- tallest the market's detail and ticket panels get
text_max = 720, -- widest a paragraph gets
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered
cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
panel_pad = 14,
@@ -183,10 +183,6 @@ M.size = {
button_sm_h = 32,
well_row_h = 52,
corner = 14,
refinery_max = 960, -- the refinery's content (slots, recipe rows), centered
slot_w_max = 240, -- widest a refinery slot card gets
slot_h = 92,
recipe_row_h = 112, -- (an estimate: the description wraps)
leaderboard_max = 640, -- the leaderboard table, centered
}
+136 -34
View File
@@ -1,6 +1,9 @@
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at
-- most 3, each fading out on its own after a few seconds. They live on their own layer
-- node, outside the rebuilt layout, so a rebuild doesn't drop them.
-- Toasts for action results (the web's .toasts). Two places:
-- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
-- toast is listed there, newest at the top, the last LOG_MAX (5) kept (the log survives
-- rebuilds; a rebuilt pane lists it again without entrances).
-- - Without a pane (no moon list shown): floating, bottom-left, newest at the bottom, at
-- most 3, each fading out on its own after a few seconds, on their own layer node.
--
-- Motion (app.css): in, the toast slides from -60 px with a squash and overshoot and a
-- bright flash (fx-toast-in, 0.55 s; errors 0.45 s, then a shake), a scan line wipes
@@ -10,12 +13,15 @@
local ui = require("modules.ui")
local theme = require("modules.theme")
local anim = require("modules.anim")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
M.__index = M
local MAX = 3
local LIFE = 3.5
local LOG_MAX = 5 -- (the pane's list: the last 5)
local W, H = 320, 44 -- (app.css .toasts: 320 px wide; a toast grows taller as its text wraps)
local TEXT_X, PAD_R, PAD_Y = 46, 14, 8
local GAP, MARGIN = 6, 16
@@ -29,23 +35,21 @@ local KIND = {
redrill = { "cyan", "▼" },
upgrade = { "cyan", "▲" },
["buy-tool"] = { "cyan", "+" },
["buy-attachment"] = { "cyan", "+" },
["buy-seat"] = { "cyan", "+" },
["buy-slot"] = { "cyan", "+" },
equip = { "cyan", "▲" },
unequip = { "muted", "▼" },
hire = { "violet", "●" },
refine = { "violet", "◆" },
collect = { "teal", "◆" },
["sell-moon"] = { "ready", "₥" },
["sell-tool"] = { "ready", "₥" },
["sell-attachment"] = { "ready", "₥" },
["save-code"] = { "violet", "★" },
restore = { "violet", "★" },
error = { "danger", "!" },
}
-- layer: a node over the layout; its top-left is the page's top-left.
function M.new(layer)
return setmetatable({ layer = layer, items = {}, h = 800 }, M)
return setmetatable({ layer = layer, items = {}, h = 800, log = {} }, M)
end
-- The page height, to place the stack at the bottom.
@@ -57,11 +61,14 @@ end
-- it.root: an invisible group the stack moves (place); it.body: the toast, which the
-- entrance and exit move, squash and fade inside it.
function M:place(animate)
local y = self.h - MARGIN
for i = #self.items, 1, -1 do
local it = self.items[i]
y = y - it.h
local pos = vmath.vector3(0, -y, 0)
-- A column at the bottom of the page (newest last), GAP apart.
local boxes = {}
for i, it in ipairs(self.items) do
boxes[i] = { style = { height = it.h, shrink = 0 } }
end
flex.solve(flex.box({ direction = "column", justify = "end", gap = GAP, height = self.h - MARGIN }, boxes), 0, 0)
for i, it in ipairs(self.items) do
local pos = vmath.vector3(0, -boxes[i].y, 0)
if animate then
gui.animate(it.root, "position.y", pos.y, gui.EASING_OUTCUBIC, 0.25)
else
@@ -70,7 +77,6 @@ function M:place(animate)
-- Older toasts dim (still opaque).
local dim = (#self.items - i == 0) and 1 or ((#self.items - i == 1) and 0.8 or 0.6)
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
y = y - GAP
end
end
@@ -92,7 +98,7 @@ end
-- The body's look at a moment: x offset, vertical squash (about its middle), opacity,
-- flash (an additive overlay in the accent, lighter: the web's brightness filter).
local function pose(it, dx, sy, alpha, flash)
anim.scale_about(it.body, vmath.vector3(MARGIN + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
anim.scale_about(it.body, vmath.vector3(it.mx + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
local c = it.fill
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
local f = it.flash_color
@@ -106,7 +112,7 @@ local function fade(self, it)
it.gone = true
anim.cancel(it.tween)
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease).
local x0 = gui.get_position(it.body).x - MARGIN
local x0 = gui.get_position(it.body).x - it.mx
it.tween = anim.tween(it.body, 0.5, function(k)
local e = anim.fn.ease(k)
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0)
@@ -161,21 +167,19 @@ local function enter(it, is_error)
end)
end
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
function M:push(message, kind, is_error, opts)
local k = KIND[is_error and "error" or kind] or { "cyan", "›" }
local accent = (not is_error and opts and opts.color) or k[1]
local root = anim.group(self.layer, 0, 0)
local it = { root = root, accent = accent }
local w, h = W, H
-- A toast's nodes under parent (root at 0, 0; the body w wide, mx from the left).
local function make(parent, e, w, mx)
local k = KIND[e.is_error and "error" or e.kind] or { "cyan", "›" }
local accent = e.color or k[1]
local root = anim.group(parent, 0, 0)
local it = { root = root, accent = accent, mx = mx }
local h = H
local made = ui.collect(function()
-- The text first (wrapped at the toast's width), to size the toast.
local style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
local line, th
local text_nodes = ui.collect(function()
line, th = ui.wrap(root, message, style, 0, 0, W - TEXT_X - PAD_R)
line, th = ui.wrap(root, e.message, style, 0, 0, w - TEXT_X - PAD_R)
end)
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
-- but not drawn, so the runs under them inherit alpha 1.)
@@ -186,18 +190,28 @@ function M:push(message, kind, is_error, opts)
end
end
h = math.max(H, math.floor(th + PAD_Y * 2))
local body = ui.rect(root, MARGIN, 0, w, h, "field")
local body = ui.rect(root, mx, 0, w, h, "field")
it.body = body
local tint = ui.rect(body, 0, 0, w, h, accent, 0.1)
ui.rect(body, 0, 0, w, h, accent, 0.1)
-- A stronger tint toward the icon (the web's gradient), in two steps.
ui.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
ui.outline(body, 0, 0, w, h, accent, 0.7)
ui.rect(body, 0, 0, 3, h, accent)
local icon = ui.outline(body, 11, math.floor(h / 2 - 14), 28, 28, accent)
ui.rect(icon, 1, 1, 26, 26, accent, 0.18)
ui.text(icon, k[2], { font = "sym", px = 16, color = accent }, 14, 14, gui.PIVOT_CENTER)
gui.set_parent(line, body, false)
gui.set_position(line, vmath.vector3(TEXT_X, -math.floor((h - th) / 2), 0))
-- The icon (28, 11 in) and the text (from TEXT_X), centered on the toast's height.
local icon
local tree = flex.box({ width = w, height = h, padding_left = 11, align = "center" }, {
{ style = { width = 28, height = 28, shrink = 0 }, make = function(p, x, y)
icon = ui.outline(p, x, y, 28, 28, accent)
ui.rect(icon, 1, 1, 26, 26, accent, 0.18)
ui.text(icon, k[2], { font = "sym", px = 16, color = accent }, 14, 14, gui.PIVOT_CENTER)
end },
{ style = { width = TEXT_X - 11 - 28, shrink = 0 } },
{ style = { height = th, grow = 1 }, make = function(p, x, y)
gui.set_parent(line, p, false)
gui.set_position(line, vmath.vector3(x, -y, 0))
end },
})
fg.build(flex.solve(tree, 0, 0), body)
gui.move_above(line, icon)
it.flash = ui.rect(body, 0, 0, w, h, "white", 0)
gui.set_blend_mode(it.flash, theme.glow_blend())
@@ -214,7 +228,96 @@ function M:push(message, kind, is_error, opts)
it.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
it.fill = ui.color("field", 0.97)
it.scale = gui.get_scale(it.body)
return it
end
---------------------------------------------------------------- the pane
-- The list's height for the last LOG_MAX one-line toasts (the pane is sized to it).
M.LIST_H = LOG_MAX * (H + GAP) - GAP
-- holder: a node at the pane's top-left (w x h), in the moon list (rebuilt with it).
function M:attach(holder, w, h)
self.log = self.log or {} -- (a hot reload keeps the old instance)
gui.set_clipping_mode(holder, gui.CLIPPING_MODE_STENCIL)
self.pane = { holder = holder, w = w, h = h, items = {} }
self:fill(nil)
end
-- No pane (no moon list): toasts float again.
function M:detach()
self.pane = nil
end
local function pane(self)
local p = self.pane
if p and pcall(gui.get_position, p.holder) then
return p
end
self.pane = nil -- (its moon list went away)
end
-- Lists the log in the pane, newest at the top, as many as fit. fresh: the entry just
-- pushed (it enters; the others slide down to make room).
function M:fill(fresh)
local p = self.pane
for _, it in ipairs(p.items) do
anim.cancel(it.tween)
pcall(gui.delete_node, it.root)
end
p.items = {}
if p.empty then
pcall(gui.delete_node, p.empty)
p.empty = nil
end
if #self.log == 0 then
p.empty = ui.text(p.holder, "Nothing yet.", "small", 0, 10)
ui.fit_text(p.empty, p.w)
return
end
local y, shift = 0, 0
for i, e in ipairs(self.log) do
if y >= p.h then
break
end
local it = make(p.holder, e, p.w, 0)
p.items[#p.items + 1] = it
local dim = i == 1 and 1 or (i == 2 and 0.85 or 0.7)
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
if e == fresh then
gui.set_position(it.root, vmath.vector3(0, -y, 0))
shift = it.h + GAP
enter(it, e.is_error)
else
pose(it, 0, 1, 1, 0)
gui.set_position(it.root, vmath.vector3(0, -(y - shift), 0))
if shift > 0 and not anim.reduced then
gui.animate(it.root, "position.y", -y, gui.EASING_OUTCUBIC, 0.25)
else
gui.set_position(it.root, vmath.vector3(0, -y, 0))
end
end
y = y + it.h + GAP
end
end
---------------------------------------------------------------- push
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
function M:push(message, kind, is_error, opts)
local e = { message = message, kind = kind, is_error = is_error, color = (not is_error and opts and opts.color) or nil }
self.log = self.log or {}
table.insert(self.log, 1, e)
while #self.log > LOG_MAX do
table.remove(self.log)
end
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
if pane(self) then
self:fill(e)
return
end
local it = make(self.layer, e, W, MARGIN)
self.items[#self.items + 1] = it
while #self.items > MAX do
remove(self, self.items[1])
@@ -225,7 +328,6 @@ function M:push(message, kind, is_error, opts)
it.timer = nil
fade(self, it)
end)
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
end
return M
+2
View File
@@ -15,6 +15,7 @@
local ui = require("modules.ui")
local screen = require("modules.screen")
local blocks = require("modules.blocks")
local M = {}
@@ -59,6 +60,7 @@ function M.add(node, text)
if not node or not text or text == "" then
return
end
blocks.note(M.add, node, text) -- (a kept block adds it again)
tips[#tips + 1] = { node = node, text = tostring(text), zone = zone_of(node) }
end
+8 -1
View File
@@ -9,6 +9,8 @@
-- difference at the rebuild is kept as an offset that eases to 0 while the value goes on
-- ticking. Static values are the same thing with a constant function.
local blocks = require("modules.blocks")
local M = {}
M.__index = M
@@ -46,9 +48,14 @@ end
-- source (the number itself for a static value, e.g. "start:end" for a timer): while it
-- stays the same a running ease goes on; when it changes, the ease starts from what is
-- shown. Returns the entry.
local function seen(self, key)
self.seen[key] = true
end
function M:track(key, value, sig)
sig = sig == nil and value or sig
self.seen[key] = true
seen(self, key)
blocks.note(seen, self, key) -- (a kept block keeps the key)
local e = self.vals[key]
if not e then
e = { shown = value, off = 0, t0 = 0, sig = sig }
+27 -8
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;
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns
-- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
-- root, nodes, width.
function M.button(parent, label, x, y, opts)
opts = opts or {}
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
local parts = { { text, M.text_width(text) } }
if opts.price then
local accent = opts.accent or v.price
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
if opts.bare then -- (no "·" between: "+ ₥290")
parts[#parts + 1] = { sym, M.text_width(sym), 5 }
else
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
end
parts[#parts + 1] = { num, M.text_width(num), 1 }
end
local content = 0
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
return
end
while #text > 1 do
-- (a cut entity like "&am" would show as text)
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
gui.set_text(node, text .. "…")
if M.text_width(node) <= max_w then
return
@@ -333,8 +337,13 @@ function M.dur(ms)
return string.format("%d:%02d", m, sec)
end
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
function M.cycle(secs)
if secs < 60 and secs ~= math.floor(secs) then
local t = math.floor(secs * 10 + 0.5) / 10
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
end
secs = math.floor(secs + 0.5)
if secs < 60 then
return secs .. "s"
elseif secs % 60 == 0 then
@@ -343,6 +352,16 @@ function M.cycle(secs)
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
end
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
function M.levels_tag(t)
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
if y == 0 and c == 0 then
return ""
end
return " Y" .. y .. " C" .. c
end
function M.plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s")
end
+2 -2
View File
@@ -1,8 +1,8 @@
#version 140
// The viewport node: shows the moon scene the render script drew into `moon_tex` (HDR,
// 2x the node's size: linear filtering at the pixel centers averages 2x2 samples) plus
// its bloom (`bloom_tex`, half size, upsampled), with three.js's output pass: ACES
// up to 2x the node's size: linear filtering at the pixel centers averages 2x2 samples) plus
// its bloom (`bloom_tex`, half the GUI reference size, upsampled), with three.js's output pass: ACES
// filmic tone mapping (exposure) and the sRGB transfer.
// Without a moon (moon_rect.z == 0) the node keeps its plain color.
+2
View File
@@ -83,6 +83,8 @@ void main()
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
normal = normalize(abs(det) * normal - grad);
}
// cam_pos matches the view matrix (a debug render of |V(cam_pos) - V(view matrix)| was 0
// over the whole moon); moon_glow and the halo take V in view space (-mv.xyz) instead.
vec3 V = normalize(cam_pos.xyz - var_world);
vec3 albedo = var_color;
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
+1 -1
View File
@@ -1,6 +1,6 @@
#version 140
// The moon surface (vertex colors are linear RGB from terrain.lua).
// The moon surface (vertex colors are linear RGB from mmterrain).
const float MOON_RADIUS = 2.0; // scene.js MOON_RADIUS
in highp vec4 position;
+130 -69
View File
@@ -1,5 +1,5 @@
-- The moon scene (see modules/moon/moon.lua): builds meshes for the requested moon id
-- (terrain.lua, spread over frames, cached per id), spins the moon, orbits the camera
-- (the mmterrain extension; cached per id), spins the moon, orbits the camera
-- (drag to rotate, wheel to zoom, like three.js OrbitControls with damping) and writes
-- what the render script needs into moon.frame. Values follow server scene.js.
--
@@ -24,15 +24,16 @@ local FOV = math.rad(35)
local RESUME = 1.5 -- seconds before auto-rotation resumes after a drag
local ROTATE_SPEED = 0.6
local DAMPING = 0.05
local BUDGET = html5 and 0.012 or 0.006 -- seconds of generation work per frame (the web runs
-- plain Lua, no LuaJIT: several times slower, so it gets more of each frame)
local CACHE_SIZE = 6
local SUPERSAMPLE = 2 -- the scene renders at 2x and is averaged down (antialiasing)
local SUPERSAMPLE = 2 -- the scene renders at 2x and is averaged down (antialiasing),
-- counted in GUI reference pixels: a HiDPI display (or UI zoom) already gives the viewport
-- more window pixels, so the target is 2x the reference size, at least the window size
local CHARGE = 1.1 -- reveal: charge-up before the burst (s)
local BURST = 0.9 -- reveal: the burst itself (s)
local TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts
local SHROUD_ROT = vmath.quat_rotation_x(0.28) * vmath.quat_rotation_z(-0.22)
local SHROUD_SPIN = 0.25 -- radians a second the shroud turns
local SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4))
@@ -182,42 +183,21 @@ local function mesh_key(id, settings)
return id .. "|" .. terrain.serialize(rest)
end
-- A job: a coroutine that generates the moon and fills a buffer, yielding when the
-- frame's budget is spent.
local WEB_RESOLUTION = 96 -- the web's cap on the grid (plain Lua: a quarter of the work of 180)
-- A job generates one moon (the mmterrain extension, mmterrain/src/terrain.c) and fills
-- its buffer: on a thread of its own on desktop; on the web (wasm, no threads) a share of
-- each frame, then the buffer a part a frame.
local native = mmterrain
local NATIVE_SHARE, NATIVE_MIN, NATIVE_MAX = 0.3, 0.002, 0.012 -- (seconds of work a frame there)
local NATIVE_FILL = 32768 -- strip vertices copied into the buffer a frame there
local WEB_RESOLUTION = 128 -- the web's grid (desktop: the settings')
local function start_job(self, id, key, params)
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION
end
local job = { id = id, key = key, params = params, started = now(), frames = 0, work = 0 }
job.co = coroutine.create(function()
local function checkpoint()
if now() > job.deadline then
coroutine.yield()
end
end
local m = terrain.generate(params, checkpoint)
job.gen_work = job.work + (now() - job.resume_at)
local strip = terrain.strip(m.n)
local buf = buffer.create(#strip, STREAMS)
local pos = buffer.get_stream(buf, hash("position"))
local nor = buffer.get_stream(buf, hash("normal"))
local col = buffer.get_stream(buf, hash("color"))
local P, N, C = m.positions, m.normals, m.colors
for k = 1, #strip do
local v = strip[k] * 3
local o = (k - 1) * 3
pos[o + 1], pos[o + 2], pos[o + 3] = P[v + 1], P[v + 2], P[v + 3]
nor[o + 1], nor[o + 2], nor[o + 3] = N[v + 1], N[v + 2], N[v + 3]
col[o + 1], col[o + 2], col[o + 3] = C[v + 1], C[v + 2], C[v + 3]
if k % 2048 == 0 then
checkpoint()
end
end
job.buffer = buf
job.vertices = #strip
end)
local job = { id = id, key = key, params = params, t0 = now() }
job.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen)
self.job = job
end
@@ -267,30 +247,90 @@ local function set_mesh(self, key, res, params)
self.shown_params = params
end
local function step_job(self)
local job = self.job
local t0 = now()
job.resume_at = t0
job.deadline = t0 + BUDGET
job.frames = job.frames + 1
local ok, err = coroutine.resume(job.co)
job.work = job.work + (now() - t0)
if not ok then
print("moon: generation failed: " .. tostring(err))
self.job = nil
return
-- A finished job's buffer becomes a mesh resource in the cache (and the shown moon when
-- it's the wanted one).
local function job_done(self, job)
local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res)
local ms = (now() - job.t0) * 1000
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
if job.key == self.want_key then
set_mesh(self, job.key, res, job.params)
end
if coroutine.status(job.co) == "dead" then
self.job = nil
local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res)
print(string.format("moon: %s (%s) built: %.0f ms of work (terrain %.0f, buffer %.0f) over %d frames (%.0f ms wall), %d vertices",
job.id, job.params.technique, job.work * 1000, job.gen_work * 1000, (job.work - job.gen_work) * 1000,
job.frames, (now() - job.started) * 1000, job.vertices))
if job.key == self.want_key then
set_mesh(self, job.key, res, job.params)
end
-- Done (threaded) or stepped to the end, then the buffer is filled (on the web a part a
-- frame, the frames after the last step). Returns true when complete.
local function step_native(self, job)
if not job.buffer then
local done
if job.threaded then
done = native.done(job.gen)
else
done = native.step(job.gen, math.max(NATIVE_MIN, math.min(NATIVE_MAX, (self.frame_dt or 0) * NATIVE_SHARE)))
end
if not done then
return false
end
job.vertices = native.vertices(job.gen)
job.buffer = buffer.create(job.vertices, STREAMS)
job.filled = 0
if not job.threaded then
return false -- (the fill starts next frame)
end
end
local to = job.threaded and job.vertices or math.min(job.vertices, job.filled + NATIVE_FILL)
native.fill(job.gen, job.buffer, job.filled + 1, to)
job.filled = to
if to < job.vertices then
return false
end
native.free(job.gen)
job.gen = nil
return true
end
-- A job left behind for another moon: a thread runs to the end (freeing it would wait
-- for it), and the moon goes into the cache; a stepped one is dropped.
local function orphan(self, job)
if job.threaded and type(job.gen) == "userdata" then
self.orphans[#self.orphans + 1] = job
elseif type(job.gen) == "userdata" then
native.free(job.gen)
end
end
local function step_orphans(self)
for i = #self.orphans, 1, -1 do
local job = self.orphans[i]
if native.done(job.gen) then
table.remove(self.orphans, i)
step_native(self, job)
job_done(self, job)
end
end
end
local function step_job(self)
local job = self.job
if step_native(self, job) then
self.job = nil
job_done(self, job)
end
end
-- The mesh for moon `id` (from the cache, or a job for it); returns its key and params.
local function want(self, id)
local params = terrain.resolve(moon.settings, id)
local key = mesh_key(id, moon.settings)
if not cache_get(self, key) and not (self.job and self.job.key == key) then
if self.job then
orphan(self, self.job)
end
start_job(self, id, key, params)
end
return key, params
end
---------------------------------------------------------------- camera
@@ -320,6 +360,7 @@ end
function init(self)
msg.post(".", "acquire_input_focus")
self.cache = {}
self.orphans = {}
self.spin = 0
self.spin_speed = 0
self.dragging = false
@@ -361,6 +402,12 @@ function final(self)
for _, e in ipairs(self.cache) do
resource.release(e.res)
end
if self.job and type(self.job.gen) == "userdata" then
native.free(self.job.gen)
end
for _, job in ipairs(self.orphans) do
native.free(job.gen)
end
resource.release(self.shell_res)
resource.release(self.stars_res)
resource.release(self.motes_res)
@@ -368,18 +415,13 @@ end
function update(self, dt)
dt = math.min(dt, 0.1)
self.frame_dt = self.frame_dt and (self.frame_dt * 0.8 + dt * 0.2) or dt -- (smoothed, for the native budget)
local f = moon.frame
local rect = moon.rect
local mystery = moon.mode == "mystery"
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false
return
end
if mystery and moon.settings and not self.mystery_values then
self.mystery_values = render_values(terrain.resolve(moon.settings, "mystery"))
end
-- The wanted mesh: from the cache, or start (or keep) a job for it.
-- The wanted mesh: from the cache, or start (or keep) a job for it. A moon asked for
-- ahead (moon.prefetch: a bought one, before its reveal shows) gets one too.
if moon.settings and moon.id then
if moon.id ~= self.want_id or moon.settings ~= self.want_settings then
self.want_id = moon.id
@@ -395,12 +437,29 @@ function update(self, dt)
end
end
if self.shown_key ~= self.want_key and not (self.job and self.job.key == self.want_key) then
start_job(self, moon.id, self.want_key, self.want_params)
want(self, moon.id)
end
elseif moon.settings and moon.prefetch_id and not self.job then
want(self, moon.prefetch_id)
moon.prefetch_id = nil
end
if moon.prefetch_id == moon.id then
moon.prefetch_id = nil
end
if self.job then
step_job(self)
end
if #self.orphans > 0 then
step_orphans(self)
end
moon.building = self.job ~= nil or #self.orphans > 0
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false
return
end
if mystery and moon.settings and not self.mystery_values then
self.mystery_values = render_values(terrain.resolve(moon.settings, "mystery"))
end
local values = self.values
if mystery then
@@ -510,10 +569,12 @@ function update(self, dt)
if shroud then
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale)
-- The shroud and its motes turn slowly, like a moon (the halo, a glow around it, stays).
local turn = spin_enabled and vmath.quat_rotation_y(self.shroud_t * SHROUD_SPIN) or vmath.quat()
for _, id in ipairs({ "shroud", "halo", "motes" }) do
go.set_position(pos, id)
go.set_scale(sc, id)
go.set_rotation(SHROUD_ROT, id)
go.set_rotation(id == "halo" and SHROUD_ROT or SHROUD_ROT * turn, id)
end
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
else
@@ -528,13 +589,13 @@ function update(self, dt)
self.spin = self.spin + self.spin_speed * dt
go.set_rotation(TILT * vmath.quat_rotation_y(self.spin), "surface")
local ss = SUPERSAMPLE
local ss = math.max(1, SUPERSAMPLE / math.max(1, moon.unit))
local aspect = rect.w / rect.h
local eye = update_camera(self, aspect)
f.visible = values ~= nil
f.has_mesh = self.shown_key ~= nil and show_moon
f.rect = rect
f.rt_w, f.rt_h = rect.w * ss, rect.h * ss
f.rt_w, f.rt_h = math.floor(rect.w * ss + 0.5), math.floor(rect.h * ss + 0.5)
f.unit = moon.unit * ss
f.view = vmath.matrix4_look_at(eye, vmath.vector3(0, 0, 0), vmath.vector3(0, 1, 0))
f.proj = vmath.matrix4_perspective(self.fov, aspect, 0.1, 500)
@@ -544,7 +605,7 @@ function update(self, dt)
end
function on_input(self, action_id, action)
local rect = moon.frame.visible and moon.rect
local rect = moon.frame.visible and not moon.covered and moon.rect
if not rect then
self.dragging = false
return false
+6 -3
View File
@@ -1,7 +1,7 @@
-- Moon Miners render script: Defold's default render script (builtins
-- default.render_script of the pinned engine) plus the moon pass. Before the GUI, the moon
-- scene (moon/moon.collection, see modules/moon/moon.lua) is drawn into an HDR render
-- target at 2x the viewport size: backdrop, stars, the moon, the atmosphere shell; then
-- target at 2x the viewport's GUI reference size (at least its window size; moon.script): backdrop, stars, the moon, the atmosphere shell; then
-- its bloom (three.js UnrealBloomPass) into smaller targets. The GUI then draws it in its viewport node (material moon/materials/moon_gui, sampler
-- `moon_tex`), tone mapped, so HUD panels, captions and popups layer over it as usual.
@@ -57,7 +57,9 @@ local BLOOM_KERNELS = { 3, 5, 7, 9, 11 }
local CLEAR_BLACK = { [graphics.BUFFER_TYPE_COLOR0_BIT] = vmath.vector4(0, 0, 0, 0) }
-- three.js UnrealBloomPass on the moon scene (moon_post material): high pass at half the
-- viewport size, 5 mips of separable blur, composite into "bloom_h1".
-- viewport's GUI reference size (the web's CSS pixels: a HiDPI display or UI zoom does the
-- same work as 1x, and the blur's texels cover the same UI pixels, so the glow looks the
-- same), 5 mips of separable blur, composite into "bloom_h1".
local function draw_bloom(self, f, scene_rt)
local p = self.predicates.moon_post
local c = self.post_constants
@@ -69,7 +71,8 @@ local function draw_bloom(self, f, scene_rt)
render.clear(CLEAR_BLACK)
render.draw(p, options)
end
local w, h = math.floor(f.rect.w / 2 + 0.5), math.floor(f.rect.h / 2 + 0.5)
local u = math.max(1, moon.unit) -- (window pixels per GUI reference pixel)
local w, h = math.max(1, math.floor(f.rect.w / u / 2 + 0.5)), math.max(1, math.floor(f.rect.h / u / 2 + 0.5))
local bright = hdr_target(self, "bloom_bright", w, h)
c.post = vmath.vector4(0, 0, 0, 0)
c.post2 = vmath.vector4(0, 0, b.z, 0)
+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
+288 -231
View File
@@ -15,12 +15,16 @@ local scroll = require("components.scroll")
local store = require("screens.game.store")
local card = require("components.card")
local button = require("components.button")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local vec = cards.vec
local M = {}
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
local BONUS = { speed = "Drill speed", yield = "Yield", sell = "Sell prices", refine = "Refinery" } -- (older servers)
local H3 = { font = "sans_semi", px = 12, color = "text_2", tracking = 0.16, upper = true }
@@ -58,33 +62,30 @@ local function pick(self, H, name)
self.dirty = true
end
-- Bonus chips ("Drill speed +10%"), wrapping at w; returns the number of lines.
local function chips(parent, bonuses, x, y, w)
local cx, lines = x, 1
-- Bonus chips ("Drill speed +10%"), wrapping (lines 24 tall, 6 apart). A tree piece.
local function chips(parent, bonuses)
local kids = {}
for _, b in ipairs(bonuses or {}) do
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local label = (b.label or BONUS[b.kind] or b.kind) .. " " .. (b.text or cards.pct(b.mult))
local tw = cards.tag(probe, label, 0, 0)
gui.delete_node(probe)
if cx > x and cx + tw > x + w then
cx, lines = x, lines + 1
end
if parent then
local cw, box = cards.tag(parent, label, cx, y + (lines - 1) * 24)
if b.capped then
tooltip.add(box, "At the cap")
kids[#kids + 1] = fg.inline(parent, 24, function(p, x, y, probing)
local cw, tag = cards.tag(p, label, x, y)
if b.capped and not probing then
tooltip.add(tag, "At the cap")
end
cx = cx + cw + 6
end
return cw
end)
end
return lines
return row({ wrap = true, column_gap = 6 }, kids)
end
local function stat_box(parent, x, y, w, h, label, fill)
local _, nodes = ui.panel(parent, x, y, w, h)
local r = ui.part(nodes, "panel", "root")
ui.text(r, label, "cap", 14, 18)
fill(r)
-- A stat panel (ui.panel): a cap label, then content (a tree piece) under it.
local function stat_box(parent, w, label, content)
return col({ width = w, padding = { 10, 14, 8, 14 }, shrink = 0 }, {
fg.label(parent, label, "cap", 16), space(0, 4), content,
}, { make = function(p, x, y, bw, bh)
local _, nodes = ui.panel(p, x, y, bw, bh)
return ui.part(nodes, "panel", "root")
end })
end
-- Why Hire is disabled (the server's whyText) and its color: red when it's credits.
@@ -110,60 +111,82 @@ end
-- The seats: one tile per seat up to maxSeats, in rows when narrow (tiles >= 210 wide):
-- the owned ones from seatList (who sits there, or empty), then the next one to buy and
-- the locked ones. Returns the y under it.
local function board(self, H, ad, parent, x, y, inner)
-- the locked ones. A grid; each tile is built at its size, its content a tree inside.
local function board(self, H, ad, parent, inner)
local list = ad.seatList or {}
local n = math.max(ad.maxSeats or 0, #list)
local gap = 10
local cols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
local tw = math.floor((inner - (cols - 1) * gap) / cols)
local ncols = math.max(1, math.min(n, math.floor((inner + gap) / (210 + gap))))
local tw = math.floor((inner - (ncols - 1) * gap) / ncols)
local th = 86
local sel = selected(self, ad)
local cols, tiles = {}, {}
for i = 1, ncols do
cols[i] = tw
end
local SEAT = { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }
for i = 1, n do
local tx = x + (i - 1) % cols * (tw + gap)
local ty = y + math.floor((i - 1) / cols) * (th + gap)
local seat = list[i]
local a = seat and seat.advisor ~= "" and find(ad, seat.advisor)
local owned = seat ~= nil
local num = seat and seat.n or i
local tile = ui.rect(parent, tx, ty, tw, th, a and "panel" or "none")
local on = a and sel and sel.name == a.name
local edge = ui.outline(tile, 0, 0, tw, th, on and "cyan" or (a and "ready" or (owned and "line_hi" or "line")), (a and not on) and 0.45 or nil)
local seat_lbl = ui.text(tile, "Seat " .. num, { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 10, 12)
if a then
gui.set_position(seat_lbl, vec(tw - 8 - ui.text_width(seat_lbl), -12))
local img = th - 2
local media = ui.rect(tile, 1, 1, img, img, "bg")
cards.image(media, a.image, 0, 0, img)
local nx = img + 12
local nm = ui.text(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, nx, 32)
ui.fit_text(nm, tw - nx - 8)
local tt = ui.text(tile, a.title, { font = "sans", px = 12, color = "muted" }, nx, 50)
ui.fit_text(tt, tw - nx - 8)
local ef = ui.text(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, nx, 68)
ui.fit_text(ef, tw - nx - 8)
button.bind(self, tile, nil, { id = "seat:" .. num, label = a.name, on_click = function()
open(self, H, a.name)
end })
elseif owned then
ui.text(tile, "Empty", { font = "sans", px = 14, color = "muted" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
elseif i == ad.nextSeat and ad.nextSeatPrice > 0 then
-- The next seat to buy: the tile is the button.
ui.text(tile, "+ Add seat", { font = "sans_semi", px = 14, color = ad.canBuySeat and "cyan" or "muted", tracking = 0.12, upper = true }, tw / 2, th / 2 - 8, gui.PIVOT_CENTER)
local probe = ui.rect(tile, 0, 0, 1, 1, "none")
local cw = ui.credits(probe, ad.nextSeatPrice, 0, 0, { px = 14 })
gui.delete_node(probe)
ui.credits(tile, ad.nextSeatPrice, (tw - cw) / 2, th / 2 + 14, { px = 14, color = ad.canBuySeat and "cyan" or "muted" })
gui.set_color(edge, ui.color("cyan", ad.canBuySeat and 0.6 or 0.25))
if ad.canBuySeat then
gui.set_color(tile, ui.color("cyan", 0.04))
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
tiles[#tiles + 1] = made({ height = th }, function(p, x, y, w, h)
local tile = ui.rect(p, x, y, w, h, a and "panel" or "none")
local on = a and sel and sel.name == a.name
local edge = ui.outline(tile, 0, 0, w, h, on and "cyan" or (a and "ready" or (owned and "line_hi" or "line")), (a and not on) and 0.45 or nil)
local tree
if a then
local img = h - 2
local fit = { fit = true, shrink = 1 }
tree = row({ width = w, height = h, padding = 1 }, {
made({ width = img, height = img, shrink = 0 }, function(q, ix, iy)
cards.image(ui.rect(q, ix, iy, img, img, "bg"), a.image, 0, 0, img)
end),
space(11),
col({ grow = 1, shrink = 1, padding = { 23, 7, 0, 0 } }, {
fg.label(tile, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, fit), space(0, 2),
fg.label(tile, a.title, { font = "sans", px = 12, color = "muted" }, 16, fit), space(0, 2),
fg.label(tile, effect_of(a), { font = "mono", px = 12, color = "violet_hi" }, 16, fit),
}),
fg.label(tile, "Seat " .. num, SEAT, 12, { position = "absolute", top = 6, right = 8 }),
})
else
local center = {}
if owned then
center = { fg.label(tile, "Empty", { font = "sans", px = 14, color = "muted" }, 16), space(0, 0) }
elseif i == ad.nextSeat and ad.nextSeatPrice > 0 then
-- The next seat to buy: the tile is the button.
local color = ad.canBuySeat and "cyan" or "muted"
center = {
space(0, 0),
fg.label(tile, "+ Add seat", { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, 16),
space(0, 6),
fg.inline(tile, 16, function(q, cx, cy)
return ui.credits(q, ad.nextSeatPrice, cx, cy, { px = 14, color = color })
end),
}
gui.set_color(edge, ui.color("cyan", ad.canBuySeat and 0.6 or 0.25))
if ad.canBuySeat then
gui.set_color(tile, ui.color("cyan", 0.04))
cards.confirm_server_button(self, tile, ad.buySeat, "buy-seat", {})
end
else
center = { fg.label(tile, "Locked", { font = "sans", px = 13, color = "faint" }, 16), space(0, 0) }
end
-- (text centered 4 below the middle; Add seat: its two lines around it)
tree = col({ width = w, height = h, align = "center", justify = "center", padding_top = (#center > 2) and 6 or 8 }, center)
table.insert(tree.children, 1, fg.label(tile, "Seat " .. num, SEAT, 12, { position = "absolute", top = 6, left = 10 }))
end
else
ui.text(tile, "Locked", { font = "sans", px = 13, color = "faint" }, tw / 2, th / 2 + 4, gui.PIVOT_CENTER)
end
fg.build(flex.solve(tree, 0, 0), tile)
if a then
button.bind(self, tile, nil, { id = "seat:" .. num, label = a.name, on_click = function()
open(self, H, a.name)
end })
end
return tile
end)
end
return y + math.ceil(n / cols) * (th + gap) - gap
return box({ columns = cols, gap = gap }, tiles)
end
-- The hired advisors not seated, in the web's order (the server's bench: hire order; older
@@ -187,36 +210,49 @@ local function benched(ad)
end
-- Hired, not seated (no bonus): a row each, the name opens the panel, Seat puts them in
-- the lowest free seat. Returns the y under it.
local function bench(self, H, ad, parent, x, y, inner)
-- the lowest free seat. A tree piece (nil when none).
local function bench(self, H, ad, parent, inner)
local list = benched(ad)
if #list == 0 then
return y
return nil
end
local t = ui.text(parent, "Hired, not seated", "cap", x, y + 6)
ui.text(parent, " · no bonus", { font = "sans_semi", px = 11, color = "faint", tracking = 0.2, upper = true }, x + ui.text_width(t), y + 6)
y = y + 18
local sel = selected(self, ad)
local rh = 50
local rows = {}
for _, a in ipairs(list) do
local on = sel and sel.name == a.name
local row = ui.rect(parent, x, y, inner, rh, on and "cyan" or "panel", on and 0.06 or nil)
ui.outline(row, 0, 0, inner, rh, on and "cyan" or "line", on and 0.7 or nil)
local b, nodes, bw = ui.button(row, "Seat", 0, 0, { primary = true, small = true, disabled = not a.canSeat })
gui.set_position(b, vec(inner - 10 - bw, -(rh - S.button_sm_h) / 2))
local hit = ui.rect(row, 0, 0, inner - bw - 20, rh, "none")
local nm = ui.text(row, a.name, { font = "sans_semi", px = 15, color = "text" }, 12, 18)
ui.fit_text(nm, inner - bw - 40)
ui.text(row, effect_of(a), { font = "mono", px = 12, color = "muted" }, 12, 35)
H.link(self, hit, { section = "advisors", moon = self.view.moon, adv = a.name })
if a.canSeat then
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
elseif (a.seatWhyText or "") ~= "" then
tooltip.add(b, a.seatWhyText)
end
y = y + rh + 6
local bw = fg.button_w(parent, "Seat", { primary = true, small = true, disabled = not a.canSeat })
rows[#rows + 1] = row({ height = rh, padding = { 0, 10, 0, 12 }, align = "center" }, {
-- (the row's text links to the panel; Seat takes its own clicks)
made({ position = "absolute", left = 0, top = 0, bottom = 0, right = bw + 20 }, function(p, x, y, w, h)
H.link(self, ui.rect(p, x, y, w, h, "none"), { section = "advisors", moon = self.view.moon, adv = a.name })
end),
col({ grow = 1, shrink = 1, padding_right = 18, padding_top = 10, align_self = "start" }, {
fg.label(parent, a.name, { font = "sans_semi", px = 15, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
fg.label(parent, effect_of(a), { font = "mono", px = 12, color = "muted" }, 16),
}),
fg.button(parent, "Seat", { primary = true, small = true, disabled = not a.canSeat }, function(b, nodes)
if a.canSeat then
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = ad.freeSeat })
elseif (a.seatWhyText or "") ~= "" then
tooltip.add(b, a.seatWhyText)
end
end),
}, { make = function(p, x, y, w, h)
local r = ui.rect(p, x, y, w, h, on and "cyan" or "panel", on and 0.06 or nil)
ui.outline(r, 0, 0, w, h, on and "cyan" or "line", on and 0.7 or nil)
return r
end })
end
return y + 4
return col({}, {
row({ height = 12 }, {
fg.label(parent, "Hired, not seated", "cap", 12),
fg.label(parent, " · no bonus", { font = "sans_semi", px = 11, color = "faint", tracking = 0.2, upper = true }, 12),
}),
space(0, 6),
col({ gap = 6 }, rows),
space(0, 10),
})
end
local function advisor_card(self, H, ad, c, a)
@@ -227,24 +263,32 @@ local function advisor_card(self, H, ad, c, a)
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
c:gap(4)
local why, why_color = hire_block(a)
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
-- (an advisor for hire keeps the reason's place, shown or not: credits coming and going
-- don't resize the card)
c:footer(S.button_sm_h + (a.hired and 0 or 18), function(f, bw)
local kids
if a.hired then
local seat = (a.seat or 0) > 0
local b = ui.button(f, (seat and ("Seat " .. a.seat) or "Not seated") .. " · Manage", 0, 0, { small = true, w = bw,
edge = seat and { "ready", 0.5 } or nil })
H.link(self, b, { section = "advisors", moon = self.view.moon, adv = a.name }, "manage:" .. a.name)
return
end
local can = a.canHire
local b, nodes = ui.button(f, "Hire", 0, 0, { primary = true, small = true, w = bw, price = a.price, disabled = not can })
if not can then
tooltip.add(b, a.whyText)
end
if can then
H.action(self, b, nodes, "hire", { advisor = a.name })
elseif why then
store.need_text(f, why, bw, S.button_sm_h + 10, why_color)
kids = { fg.button(f, (seat and ("Seat " .. a.seat) or "Not seated") .. " · Manage", { small = true,
edge = seat and { "ready", 0.5 } or nil }, function(b)
H.link(self, b, { section = "advisors", moon = self.view.moon, adv = a.name }, "manage:" .. a.name)
end, { width = bw }) }
else
local can = a.canHire
kids = {
fg.button(f, "Hire", { primary = true, small = true, price = a.price, disabled = not can }, function(b, nodes)
if can then
H.action(self, b, nodes, "hire", { advisor = a.name })
else
tooltip.add(b, a.whyText)
end
end, { width = bw }),
fg.reserve({ height = 20 }, (not can and why) and made({ width = bw, height = 20 }, function(p, x, y, w)
store.need_text(p, why, x + w, y + 10, why_color)
end) or nil),
}
end
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
end)
if a.hired then
c.edge_color, c.edge_hover = { "ready", 0.45 }, { "ready", 0.7 }
@@ -266,39 +310,21 @@ function M.build(self, root, px, py, w, h, H)
local ad = st.advisors
local area, x, inner, y = cards.page(self, root, px, py, w, h, "advisors")
local c = area.content
y = H.heading(c, x, y, "Advisors")
local right = 0
if ad.nextSeatPrice > 0 then
local _, bw = buy_seat(self, ad, c, x + inner, py + 10, { right = true, small = true })
right = bw + 16
right = fg.button_w(c, "+ Seat", { small = true, price = ad.nextSeatPrice }) + 16
end
y = y + cards.para(c, "Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.",
{ font = "sans", px = 15, color = "muted" }, x, y - 8, math.min(inner - right, S.text_max)) + 12
local sw = math.floor((inner - 12) / 2)
local probe = ui.rect(c, 0, 0, 1, 1, "none")
local lines = chips(probe, ad.bonuses, 0, 0, sw - 28)
gui.delete_node(probe)
local bh = 62 + (lines - 1) * 24
stat_box(c, x, y, sw, bh, "Seats filled", function(r)
local t = ui.text(r, ad.hired, { font = "mono_semi", px = 20, color = "text" }, 14, 42)
ui.text(r, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 14 + ui.text_width(t), 42)
end)
stat_box(c, x + sw + 12, y, sw, bh, "Active bonuses", function(r)
if #(ad.bonuses or {}) == 0 then
ui.text(r, "None yet", { font = "sans", px = 15, color = "muted" }, 14, 42)
else
chips(r, ad.bonuses, 14, 42, sw - 28)
end
end)
y = y + bh + 20
ui.text(c, "Seats", "cap", x, y + 6)
y = board(self, H, ad, c, x, y + 18, inner) + 22
y = bench(self, H, ad, c, x, y, inner)
ui.text(c, "Advisors for hire", "cap", x, y + 6)
y = y + 20
local seats_line = row({ height = 24, align = "center" }, {
fg.label(c, tostring(ad.hired), { font = "mono_semi", px = 20, color = "text" }, 24),
fg.label(c, " / " .. ad.seats .. " · max " .. ad.maxSeats, { font = "mono", px = 14, color = "muted" }, 24),
})
local bonuses = #(ad.bonuses or {}) == 0
and row({ height = 24, align = "center" }, { fg.label(c, "None yet", { font = "sans", px = 15, color = "muted" }, 24) })
or chips(c, ad.bonuses)
local function cap(label, after)
return col({}, { fg.label(c, label, "cap", 12), space(0, after) })
end
-- (hired advisors no longer in the config have no card, like the web)
local listed = {}
for _, a in ipairs(ad.advisors) do
@@ -306,45 +332,80 @@ function M.build(self, root, px, py, w, h, H)
listed[#listed + 1] = a
end
end
y = card.grid(self, c, x, y, inner, listed, function(cd, a)
advisor_card(self, H, ad, cd, a)
end)
y = fg.flow(c, x, y, inner, {
col({}, {
-- (the line under the heading starts 8 into its 44)
col({ height = 36 }, { fg.label(c, "Advisors", "h1", 40, { shrink = 0 }) }),
fg.text_para(c, "Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.",
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner - right, S.text_max) }),
space(0, 20), -- (the 8 it starts early, then 12)
ad.nextSeatPrice > 0 and made({ position = "absolute", top = 12, right = 0 }, function(p, bx, by)
buy_seat(self, ad, p, bx, by, { right = true, small = true })
end) or nil,
row({ gap = 12 }, { stat_box(c, sw, "Seats filled", seats_line), stat_box(c, sw, "Active bonuses", bonuses) }),
space(0, 20),
cap("Seats", 6),
board(self, H, ad, c, inner),
space(0, 22),
bench(self, H, ad, c, inner),
cap("Advisors for hire", 8),
}),
function(cy)
return card.grid(self, c, x, cy, inner, listed, function(cd, a)
advisor_card(self, H, ad, cd, a)
end, { key = function(a) return "adv:" .. tostring(a.name) end, sig = function()
-- (kept while what it shows stays the same: modules/blocks)
return { ad.selected or false, self.advisor_pick or false, self.inflight, self.view }
end })
end,
})
area:finish(y + py)
end
---------------------------------------------------------------- aside
-- A line of the panel: text on the left (wrapping), a small button on the right.
-- fill(parent, x, y, w) draws the text and returns its height. Returns the y under it.
local function line(c, pad, y, inner, label, opts, fill)
local b, nodes, bw = ui.button(c, label, 0, 0, { small = true, primary = opts.primary, variant = opts.variant })
local th = fill(c, pad, y, inner - bw - 12)
local hgt = math.max(th, S.button_sm_h)
gui.set_position(b, vec(pad + inner - bw, -(y + (hgt - S.button_sm_h) / 2)))
opts.bind(b, nodes)
return y + hgt + 8
-- A line of the panel: text on the left (a tree piece), a small button on the right,
-- centered on it; 8 under it.
local function line(c, label, opts, piece)
return col({}, {
row({ align = "center", gap = 12, min_height = S.button_sm_h }, {
col({ grow = 1, shrink = 1 }, { piece }),
fg.button(c, label, { small = true, primary = opts.primary, variant = opts.variant }, opts.bind),
}),
space(0, 8),
})
end
local function name_effect(a)
return function(c, x, y, w)
local nm = ui.text(c, a.name, { font = "sans_semi", px = 14, color = "text" }, x, y + 8)
ui.fit_text(nm, w)
local ef = ui.text(c, effect_of(a), { font = "mono", px = 12, color = "muted" }, x, y + 25)
ui.fit_text(ef, w)
return 34
end
local function name_effect(c, a)
return col({ height = 34 }, {
fg.label(c, a.name, { font = "sans_semi", px = 14, color = "text" }, 16, { fit = true, shrink = 1 }), space(0, 1),
fg.label(c, effect_of(a), { font = "mono", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
end
local function small_para(str)
return function(c, x, y, w)
return cards.para(c, str, { font = "sans", px = 13, color = "muted" }, x, y, w)
end
local function small_para(c, str)
return fg.text_para(c, str, { font = "sans", px = 13, color = "muted" })
end
local function heading(c, pad, y, inner, str)
ui.rect(c, pad, y, inner, 1, "line")
ui.text(c, str, H3, pad, y + 20)
return y + 34
-- A rule, then the heading (H3) 20 under it; 34 in all.
local function heading(c, str)
return col({ padding_top = 12 }, {
made({ position = "absolute", top = 0, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "line")
end),
fg.label(c, str, H3, 16), space(0, 6),
})
end
-- The square picture, centered.
local function media(a, size, edge, alpha)
return row({ justify = "center" }, {
made({ width = size, height = size, shrink = 0 }, function(p, x, y)
local m = ui.rect(p, x, y, size, size, "bg")
ui.outline(m, 0, 0, size, size, edge, alpha)
cards.image(m, a.image, 0, 0, size)
end),
})
end
-- A hired advisor's panel (the web's advisor-panel).
@@ -353,26 +414,27 @@ local function panel(self, H, ad, a, root, w, h)
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor:" .. a.name)
local c = area.content
local y = pad
local close, _, cbw = ui.button(c, "×", 0, 0, { small = true, w = 32 })
gui.set_position(close, vec(pad + inner - cbw, -y))
H.link(self, close, { section = "advisors", moon = self.view.moon }, "advisor:close")
ui.text(c, a.title, "cap", pad, y + 6)
ui.text(c, a.name, "h2", pad, y + 26)
y = y + 44
local seat = a.seat or 0
local size = math.min(inner, 140)
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
ui.outline(media, 0, 0, size, size, seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1)
cards.image(media, a.image, 0, 0, size)
y = y + size + 14
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
y = y + 24
y = y + cards.para(c, a.description, { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 10
y = H.fact(c, "Seat", pad, y, inner, seat > 0 and tostring(seat) or "Not seated")
y = H.fact(c, "Bonus", pad, y, inner, seat > 0 and "Active" or "Off until seated", { color = seat > 0 and "ready" or "muted" })
y = y + 6
y = y + cards.para(c, "Changes count from your next drill, sale or batch.", { font = "sans", px = 13, color = "muted" }, pad, y, inner) + 14
local kids = {
fg.button(c, "×", { small = true, w = 32 }, function(b)
H.link(self, b, { section = "advisors", moon = self.view.moon }, "advisor:close")
end, { width = 32, position = "absolute", top = 0, right = 0 }),
fg.label(c, a.title, "cap", 12), space(0, 4),
fg.label(c, a.name, "h2", 20), space(0, 8),
media(a, math.min(inner, 140), seat > 0 and "ready" or "line", seat > 0 and 0.45 or 1),
space(0, 14),
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
fg.text_para(c, a.description, { font = "sans", px = 13, color = "muted" }), space(0, 10),
made({ height = 20 }, function(p, x, y, iw) H.fact(p, "Seat", x, y, iw, seat > 0 and tostring(seat) or "Not seated") end),
made({ height = 20 }, function(p, x, y, iw)
H.fact(p, "Bonus", x, y, iw, seat > 0 and "Active" or "Off until seated", { color = seat > 0 and "ready" or "muted" })
end),
space(0, 6),
small_para(c, "Changes count from your next drill, sale or batch."), space(0, 14),
}
local function add(b)
kids[#kids + 1] = b
end
-- Every other seat: move there (empty) or swap with who sits there.
local moves = {}
@@ -382,55 +444,53 @@ local function panel(self, H, ad, a, root, w, h)
end
end
if #moves > 0 then
y = heading(c, pad, y, inner, seat > 0 and "Move to" or "Seat in")
add(heading(c, seat > 0 and "Move to" or "Seat in"))
for _, s in ipairs(moves) do
local other = s.advisor ~= "" and find(ad, s.advisor)
local function fill(cc, x, yy, ww)
local n = ui.text(cc, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, x, yy + 17)
local nx = x + math.max(22, ui.text_width(n) + 10)
if other then
return name_effect(other)(cc, nx, yy, ww - (nx - x))
end
ui.text(cc, "Empty", { font = "sans", px = 14, color = "muted" }, nx, yy + 17)
return 34
end
local piece = row({ height = 34, align = "center" }, {
fg.label(c, tostring(s.n), { font = "mono_semi", px = 14, color = "faint" }, 34, { min_width = 22, padding_right = 10 }),
other and name_effect(c, other) or fg.label(c, "Empty", { font = "sans", px = 14, color = "muted" }, 34),
})
if other then
y = line(c, pad, y, inner, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
add(line(c, seat > 0 and "Swap" or "Replace", { bind = function(b, nodes)
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = other.name })
end }, fill)
end }, piece))
else
y = line(c, pad, y, inner, seat > 0 and "Move" or "Seat", { primary = true, bind = function(b, nodes)
add(line(c, seat > 0 and "Move" or "Seat", { primary = true, bind = function(b, nodes)
H.action(self, b, nodes, "seat-advisor", { advisor = a.name, seat = s.n })
end }, fill)
end }, piece))
end
end
y = y + 6
add(space(0, 6))
end
if seat > 0 then
local swaps = benched(ad)
if #swaps > 0 then
y = heading(c, pad, y, inner, "Give the seat to")
add(heading(c, "Give the seat to"))
for _, x in ipairs(swaps) do
y = line(c, pad, y, inner, "Swap in", { bind = function(b, nodes)
add(line(c, "Swap in", { bind = function(b, nodes)
H.action(self, b, nodes, "swap-advisors", { advisor = a.name, with = x.name })
end }, name_effect(x))
end }, name_effect(c, x)))
end
y = y + 6
add(space(0, 6))
end
y = heading(c, pad, y, inner, "Unseat")
y = line(c, pad, y, inner, "Unseat", { bind = function(b, nodes)
add(heading(c, "Unseat"))
add(line(c, "Unseat", { bind = function(b, nodes)
H.action(self, b, nodes, "unseat-advisor", { advisor = a.name })
end }, small_para("Frees seat " .. seat .. ". " .. a.name .. " stays hired, without a bonus, until seated again.")) + 6
end }, small_para(c, "Frees seat " .. seat .. ". " .. a.name .. " stays hired, without a bonus, until seated again.")))
add(space(0, 6))
end
y = heading(c, pad, y, inner, "Dismiss")
y = line(c, pad, y, inner, "Dismiss", { variant = "danger", bind = function(b)
add(heading(c, "Dismiss"))
add(line(c, "Dismiss", { variant = "danger", bind = function(b)
if a.dismiss then
cards.confirm_server_button(self, b, a.dismiss, "dismiss-advisor", { advisor = a.name })
end
end }, small_para("Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded."))
area:finish(y + pad)
end }, small_para(c, "Lets " .. a.name .. " go" .. (seat > 0 and (" and frees seat " .. seat) or "") .. ". Nothing is refunded.")))
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, pad), c)
area:finish(tree.y + tree.h + pad)
return true
end
@@ -440,36 +500,33 @@ local function preview(self, H, a, root, w, h)
local inner = w - pad * 2
local area = scroll.area(self, root, 0, 2, w, h - 4, "advisor")
local c = area.content
local y = pad
local size = math.min(inner, 300)
local media = ui.rect(c, pad + (inner - size) / 2, y, size, size, "bg")
ui.outline(media, 0, 0, size, size, "line", 1)
cards.image(media, a.image, 0, 0, size)
local close, _, cbw = ui.button(c, "×", 0, 0, { small = true, w = 32 })
gui.set_position(close, vec(pad + inner - cbw, -y))
button.bind(self, close, nil, { id = "advisor:close", label = "Close", on_click = function()
self.advisor_pick = nil
self.dirty = true
end })
y = y + size + 18
ui.text(c, a.title, "cap", pad, y + 6)
ui.text(c, a.name, "h2", pad, y + 26)
y = y + 42
ui.text(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, pad, y + 8)
y = y + 24
y = y + cards.para(c, a.description, { font = "sans", px = 14, color = "text_2" }, pad, y, inner) + 14
ui.rect(c, pad, y, inner, 1, "line")
y = y + 10
local why, why_color = hire_block(a)
local b, nodes = ui.button(c, "Hire", pad, y, { primary = true, w = inner, price = a.price, disabled = not a.canHire })
y = y + S.button_h + 6
if a.canHire then
H.action(self, b, nodes, "hire", { advisor = a.name })
elseif why then
store.need_text(c, why, pad + inner, y + 8, why_color)
y = y + 18
end
area:finish(y + pad)
local tree = col({ width = inner }, {
media(a, math.min(inner, 300), "line", 1),
fg.button(c, "×", { small = true, w = 32 }, function(b)
button.bind(self, b, nil, { id = "advisor:close", label = "Close", on_click = function()
self.advisor_pick = nil
self.dirty = true
end })
end, { width = 32, position = "absolute", top = 0, right = 0 }),
space(0, 18),
fg.label(c, a.title, "cap", 12), space(0, 4),
fg.label(c, a.name, "h2", 20), space(0, 6),
fg.label(c, effect_of(a), { font = "mono", px = 15, color = "violet_hi" }, 16), space(0, 8),
fg.text_para(c, a.description, { font = "sans", px = 14, color = "text_2" }), space(0, 14),
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end), space(0, 9),
fg.button(c, "Hire", { primary = true, price = a.price, disabled = not a.canHire }, function(b, nodes)
if a.canHire then
H.action(self, b, nodes, "hire", { advisor = a.name })
end
end, { width = inner }),
space(0, 6),
(not a.canHire and why) and made({ height = 18 }, function(p, x, y, iw)
store.need_text(p, why, x + iw, y + 8, why_color)
end) or nil,
})
fg.build(flex.solve(tree, pad, pad), c)
area:finish(tree.y + tree.h + pad)
return true
end
+369 -323
View File
@@ -1,25 +1,29 @@
-- The aside (region, right column): the moon viewport (the selected moon in every
-- section, the shroud on New moon, a grid when there is none) and under it the selected
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
-- there (a section module's aside / aside_below, see core/main.lua), always under the
-- viewport.
-- well's facts (supply, what the drills leave, price, its drills and their times), the
-- moon's when no well is picked, or what the section puts there (a section module's
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
-- section it only shows, but for destructive actions (Sell moon, at its foot): every
-- other action is in the main panel (moons.lua's well cards).
local theme = require("modules.theme")
local ui = require("modules.ui")
local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale")
local predict = require("modules.predict")
local panel = require("components.panel")
local scroll = require("components.scroll")
local bar = require("components.bar")
local tooltip = require("modules.tooltip")
local row_c = require("components.row")
local button = require("components.button")
local chip = require("components.chip")
local anim = require("modules.anim")
local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local anim = require("modules.anim")
local S = theme.size
@@ -51,7 +55,10 @@ local function viewport(g, parent, x, y, size, moon)
g.viewport_node = vp
g.viewport_rect = g.fx:rect_of(vp)
g.view_was_mystery = false
if g.view.section == "new-moon" and not g.reveal:active() then
if g.reveal:active() then
-- The reveal shows the moon in its own window (reveal.lua): leave the 3D moon there.
g.viewport_node = nil
elseif g.view.section == "new-moon" then
-- An unknown moon: the shroud and its "?" (reveal.lua).
moon_view.mystery(vp)
reveal.mystery_mark(vp, size)
@@ -88,346 +95,257 @@ local function reach_short(st, n)
return pick and pick.short ~= "" and pick.short or (n .. " wells")
end
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
-- moon"; "" for one.
local function reach_label(st, n)
local list = st.catalog and st.catalog.reach or {}
local pick = list[#list]
for _, r in ipairs(list) do
if r.reach == n then
pick = r
end
end
return pick and pick.label or ""
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w)
H.fact(p, label, x, y, w, value, opts)
end)
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
local CARD_NAME = { font = "sans_semi", px = 14, color = "text" }
local M11 = { font = "mono", px = 11, color = "muted" }
local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" }
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
-- pieces for the foot of the well or moon facts.
local function sale_kids(g, c, moon, inner, H)
if not (g.state.unlocked.store and moon.sale) then
return {}
end
return {
space(0, 14),
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
space(0, 12),
moon_sale.box(g, H, c, moon, inner),
}
end
-- Deploy cards: one per idle tool group (well.idle). A tool that would get less than its
-- yield here has an amber edge; one that would get nothing (the well is spoken for) is
-- also dimmed to .6 (.85 under the mouse) by a veil in the panel's color (app.css
-- .deploy-card.is-short / .is-spoken).
local function deploy_cards(g, root, pad, cy, inner, well, H)
for _, grp in ipairs(well.idle) do
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
local b, bn = ui.button(root, "", pad, cy, { w = inner, small = true, edge = edge })
gui.set_size(b, vec(inner, hgt))
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
-- The tool's picture (grey when spoken for), its attachments as pips under it.
local tool = find(g.state.tools, grp.toolId)
local img = ui.rect(b, 6, 4, 32, 32, "none")
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
-- thumbnail, name and track levels; what it gets; time left or Ready.
local function drill_line(g, c, st, d, H, enters)
local name, tag = moons.tool_name(st, d.toolId)
local tool = find(st.tools, d.toolId)
return made({ height = 34 }, function(p, x, y, inner)
local dr = ui.rect(p, x, y, inner, 34, "none")
local thumb = ui.rect(dr, 0, 3, 28, 28, "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(b, 6, 4, 32, 32, "off", 0.6)
images.set(thumb, tool.image)
end
if d.state == "none" then
-- Gets nothing: the picture greys (app.css .drill-line.is-none: grayscale, .6),
-- a veil in the panel's color over it.
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
end
local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
local head = { fg.node_label(nt, 16) }
if tag ~= "" then
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
head[#head + 1] = space(6)
head[#head + 1] = fg.node_label(tt, 16)
end
-- The amount it gets in ready when it comes up short (engine markup runs).
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
local line2
if d.state == "none" then
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
local tag_style = { color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true }
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
local _, tw = chip.new(probe, "Nothing left", 0, 0, tag_style)
gui.delete_node(probe)
line2 = {
fg.label(dr, "+" .. ui.money(d.amount) .. "/cycle" .. reach, S12, 16), space(6),
made({ width = tw, height = 16, shrink = 0 }, function(q, ax, ay)
local tag = chip.new(q, "Nothing left", ax, ay + 8, tag_style)
tooltip.add(tag, "Nothing left by its turn: stops and goes idle when the well runs dry")
end),
}
else
local short = d.state ~= "ok" and "ready" or "muted"
line2 = { fg.label(dr, ui.runs({
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
{ ui.money(d.here or d.take), short },
{ ((d.reach or 1) > 1 and (" here" .. (d.gets and d.gets ~= d.here and (" · " .. ui.money(d.gets) .. " in all") or "")) or ""), "muted" },
{ d.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
}), { font = "sans", px = 12, color = "white", markup = true }, 16) }
end
local right = made({ position = "absolute", right = 0, top = 6, width = 0, height = 16 }, function(q, rx, ry)
if d.ready then
bar.ready_label(q, "Ready", rx, ry + 8, gui.PIVOT_E)
else
local t = ui.text(q, ui.dur(d["end"] - H.now(g)), M12, rx, ry + 8, gui.PIVOT_E)
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
end
end
local n_att = #(grp.attachments or {})
if n_att > 0 then
cards.pips(b, n_att, n_att, 22 - n_att * 13 / 2, hgt - 8)
end
local x0 = 46
local name = ui.text(b, grp.name, { font = "sans_semi", px = 14, color = "text" }, x0, 13)
ui.fit_text(name, inner - 150 - x0 + 12)
local tx = x0 + ui.text_width(name) + 6
local tier = ui.text(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, tx, 13)
if grp.count > 1 then
ui.text(b, "×" .. grp.count, { font = "mono", px = 11, color = "muted" }, tx + ui.text_width(tier) + 6, 13)
end
local gets = ui.text(b, "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs), { font = "mono", px = 12, color = "muted" }, x0, 29)
tooltip.add(gets, "What a cycle gets here after the drills already on it"
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
if (grp.reach or 1) > 1 then
-- Its reach ("· 3 wells"), the long words in its tooltip ("Mines 3 wells").
local rt = ui.text(b, "· " .. reach_short(g.state, grp.reach), { font = "sans", px = 12, color = "muted" }, x0 + ui.text_width(gets) + 5, 29)
local label = reach_label(g.state, grp.reach)
if label ~= "" then
tooltip.add(rt, label)
end
end
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)
ui.text(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, inner - 12, hgt / 2, gui.PIVOT_E)
if grp.short then
local note = grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
or "Well spoken for: nothing left after current drills"
ui.fit_text(ui.text(b, note, { font = "sans", px = 12, color = "ready" }, x0, 43), inner - x0 - 8)
end
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
cy = cy + hgt + 6
end
return cy
end)
local tree = box({ width = inner, height = 34, padding_left = x0, direction = "column" }, {
row({ height = 16, align = "center" }, head),
row({ height = 16, align = "center" }, line2),
right,
})
fg.build(flex.solve(tree, 0, 0), dr)
enters[#enters + 1] = function() row_c.enter(g, "drill:" .. d.toolId, dr, inner, 34) end
end)
end
-- The selected well under the viewport; returns the height used (it scrolls).
local function well_panel(g, root, pad, cy, inner, H)
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H)
local st = g.state
local moon = st.moon
local w = st.well
local row = find(moon.wells, w.id) or { supply = 0, drills = {}, ready = 0 }
local dry = row.supply <= 0
-- (tapped out: the heading greys and its dot dims, app.css .side.depleted)
ui.rect(root, pad, cy + 5, 8, 8, w.resource, dry and 0.4 or 1)
local h2 = ui.text(root, w.resource .. " well", "h2", pad + 16, cy + 9)
if dry then
gui.set_color(h2, ui.color("muted"))
end
cy = cy + 26
if dry then
ui.rect(root, pad, cy + 4, inner, 6, "line")
cy = cy + 18
ui.text(root, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, pad, cy + 6)
cy = cy + 22
else
moons.supply_bar(root, pad, cy, inner, row, moons.supply_max(st))
cy = cy + 14
-- The well note in the server's words (the web's): the lead (over-drilled, fully
-- committed) in the warning color, then what the drills take of it, amber once
-- they want it all; one paragraph (engine markup runs).
if (row.noteText or "") ~= "" then
local runs = {}
if (row.statusText or "") ~= "" then
runs[#runs + 1] = { row.statusText .. ". ", "ready" }
end
runs[#runs + 1] = { row.noteText, (row.full or row.over) and "ready" or "muted" }
cy = cy + H.para(root, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, pad, cy, inner) + 4
local c = root
local wrow = find(moon.wells, w.id) or { supply = 0, drills = {}, ready = 0 }
local dry = wrow.supply <= 0
local kids = {}
local function add(b)
if b then
kids[#kids + 1] = b
end
end
cy = H.fact(root, "Supply left", pad, cy, inner, ui.money(row.supply))
if (row.demand or 0) > 0 then
cy = H.fact(root, "After current drills", pad, cy, inner, ui.money(row.remain), { color = row.full and "ready" or "text" })
-- (tapped out: the heading greys and its dot dims, app.css .side.depleted)
local h2 = fg.label(c, w.resource .. " well", "h2", 18)
if dry then
gui.set_color(h2.node, ui.color("muted"))
end
cy = H.fact(root, "Sells for", pad, cy, inner, w.rate, { credits = true, suffix = " each" })
-- Advisor bonuses (the web's bonus chips; gold at the cap).
add(row({ height = 18, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, w.resource, dry and 0.4 or 1) end),
space(8), h2,
}))
add(space(0, 8))
if dry then
add(space(0, 4))
add(made({ height = 6 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 6, "line") end))
add(space(0, 8))
add(fg.label(c, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, 12))
add(space(0, 10))
else
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st, moon)) end))
add(space(0, 8))
-- The well note in the server's words (the web's): the lead (over-drilled, fully
-- committed) in the warning color, then what the drills take of it, amber once
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
-- when there is no note, so a deploy doesn't make the facts jump.
local note = nil
if (wrow.noteText or "") ~= "" then
local runs = {}
if (wrow.statusText or "") ~= "" then
runs[#runs + 1] = { wrow.statusText .. ". ", "ready" }
end
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" }
note = fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) })
end
add(col({ min_height = 32, shrink = 0 }, { note }))
add(space(0, 4))
end
add(fact(H, "Supply left", ui.money(wrow.supply)))
local demand = (wrow.demand or 0) > 0
add(fact(H, "After current drills", ui.money(demand and wrow.remain or wrow.supply), { color = (demand and wrow.full) and "ready" or "text" }))
add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" }))
-- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping.
if #(st.bonuses or {}) > 0 then
local cx = pad
cy = cy + 6
local chips = {}
for _, bn in ipairs(st.bonuses) do
local label = bn.label .. " " .. bn.text
local style = { color = bn.capped and "ready" or "violet", alpha = bn.capped and 1 or 0.4, text = bn.capped and "ready" or "violet_hi", font = "mono", px = 11 }
local probe = ui.rect(root, 0, 0, 1, 1, "none")
local probe = ui.rect(c, 0, 0, 1, 1, "none")
local _, cw = chip.new(probe, label, 0, 0, style)
gui.delete_node(probe)
if cx > pad and cx + cw > pad + inner then
cx, cy = pad, cy + 24
end
local c = chip.new(root, label, cx, cy + 10, style)
if bn.capped then
tooltip.add(c, "At the cap")
end
cx = cx + cw + 6
chips[#chips + 1] = made({ width = cw, height = 18, shrink = 0 }, function(p, x, y)
local ch = chip.new(p, label, x, y + 9, style)
if bn.capped then
tooltip.add(ch, "At the cap")
end
end)
end
cy = cy + 24
add(space(0, 7))
add(row({ wrap = true, column_gap = 6, row_gap = 6 }, chips))
add(space(0, 5))
end
cy = cy + 6
add(space(0, 6))
-- Multi-well drills reaching this well (claimed together, from the moon).
local wide = moon.wide
local wide_here = {}
if wide and #(row.wide or {}) > 0 then
for _, d in ipairs(wide.drills or {}) do
if contains(row.wide, d.id) then
wide_here[#wide_here + 1] = d
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
end
-- The web's order: deploy, drills, then the claims and Redrill.
if not dry then
ui.text(root, "Deploy a tool", "h3", pad, cy + 6)
cy = cy + 18
if #w.idle > 0 then
cy = deploy_cards(g, root, pad, cy, inner, w, H)
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 b, bn, bw = ui.button(root, "Deploy there", 0, 0, { small = true, primary = true })
gui.set_position(b, vec(pad + inner - bw, -cy))
local ty = cy + 16
local lead = ui.text(root, "Better:", "small", pad, ty)
local lx = pad + ui.text_width(lead) + 4
ui.rect(root, lx, ty - 4, 8, 8, better.resource)
local _, lw = H.text_link(g, root, better.resource .. " well", "small", lx + 12, ty,
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well")
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
local t = ui.label(root, rest, "small", lx + 12 + lw, ty)
if not ui.needs_rich(rest) then
ui.fit_text(t, pad + inner - bw - 8 - (lx + 12 + lw))
for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then
local dd = {}
for k, v in pairs(d) do
dd[k] = v
end
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
local name, tier = moons.tool_name(st, better.toolId)
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
cy = cy + 40
local take = p.take[d.id]
if take and wi then
dd.here = take[wi] or 0
end
wide_here[#wide_here + 1] = dd
end
else
local t = ui.text(root, "No idle tools.", "small", pad, cy + 8)
if st.unlocked.store then
local lx = pad + ui.text_width(t) + 5
local _, lw = H.text_link(g, root, "Buy one in the store", { font = "sans", px = 13 }, lx, cy + 8, { section = "store", moon = g.view.moon })
ui.text(root, ".", "small", lx + lw, cy + 8)
end
cy = cy + 22
end
end
-- Drills on this well, its own and multi-well ones, oldest first (the web's Queue).
do
local drills = {}
for _, d in ipairs(row.drills) do
drills[#drills + 1] = d
end
for _, d in ipairs(wide_here) do
drills[#drills + 1] = d
end
table.sort(drills, function(a, b) return (a.start or 0) < (b.start or 0) end)
local head = ui.text(root, "Drills", "h3", pad, cy + 6)
if #drills > 1 then
local share = ui.text(root, "· " .. #drills .. " share this well, oldest claims first", "small", pad + ui.text_width(head) + 8, cy + 6)
ui.fit_text(share, inner - ui.text_width(head) - 8)
end
cy = cy + 18
if #row.drills == 0 and #(row.wide or {}) == 0 then
ui.text(root, "Nothing is mining here yet.", "small", pad, cy + 8)
cy = cy + 22
end
moons.track_drills(g)
for _, d in ipairs(drills) do
local name, tier = moons.tool_name(st, d.toolId)
-- A row of its own (it slides in when the drill was just deployed).
local dr = ui.rect(root, pad, cy, inner, 34, "none")
-- Thumbnail, name, tier and the attachments' tags.
local tool = find(st.tools, d.toolId)
local thumb = ui.rect(dr, 0, 3, 28, 28, "none")
if tool and tool.image then
images.set(thumb, tool.image)
end
if d.state == "none" then
-- Gets nothing: the picture greys (app.css .drill-line.is-none: grayscale, .6),
-- a veil in the panel's color over it.
ui.rect(dr, 0, 3, 28, 28, "veil", 0.5)
end
local x0 = 36
local nt = ui.text(dr, name, { font = "sans_semi", px = 14, color = "text" }, x0, 8)
local tt = ui.text(dr, "T" .. tier, { font = "mono", px = 11, color = "muted" }, x0 + ui.text_width(nt) + 6, 8)
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
for _, a in ipairs(tool and tool.attachments or {}) do
if tx + 60 > inner - 50 then
break
end
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
local aw = cards.tag(probe, a, 0, 0)
gui.delete_node(probe)
if tx + aw > inner - 50 then
break
end
local tw = cards.tag(dr, a, tx, 8)
local fx_text = effect_of(st, a)
if fx_text then
tooltip.add(ui.rect(dr, tx, 0, tw, 16, "none"), fx_text)
end
tx = tx + tw + 4
end
-- The amount it gets in ready when it comes up short (engine markup runs).
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
local gets
if d.state == "none" then
-- "+X/cycle · reach", then the amber outlined NOTHING LEFT tag (app.css .drill-tag).
gets = "+" .. ui.money(d.amount) .. "/cycle" .. reach
local lead = ui.text(dr, gets, { font = "sans", px = 12, color = "muted" }, x0, 24)
local tag, tw = chip.new(dr, "Nothing left", x0 + ui.text_width(lead) + 6, 24,
{ color = "ready", alpha = 0.5, text = "ready", px = 10, h = 14, pad = 5, tracking = 0.08, upper = true })
tooltip.add(tag, "Nothing left by its turn: stops and goes idle when the well runs dry")
gets = nil
else
local short = d.state ~= "ok" and "ready" or "muted"
gets = ui.runs({
{ "+" .. ui.money(d.amount) .. "/cycle" .. reach .. " · Gets ", "muted" },
{ ui.money(d.here or d.take), short },
{ ((d.reach or 1) > 1 and (" here" .. (d.gets and d.gets ~= d.here and (" · " .. ui.money(d.gets) .. " in all") or "")) or ""), "muted" },
{ d.state == "short" and (" of " .. ui.money(d.amount)) or "", "muted" },
})
end
if gets then
ui.text(dr, gets, { font = "sans", px = 12, color = "white", markup = true }, x0, 24)
end
if d.ready then
bar.ready_label(dr, "Ready", inner, 14, gui.PIVOT_E)
else
local t = ui.text(dr, ui.dur(d["end"] - H.now(g)), { font = "mono", px = 12, color = "muted" }, inner, 14, gui.PIVOT_E)
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
end
row_c.enter(g, "drill:" .. d.toolId, dr, inner, 34)
cy = cy + 36
end
local drills = {}
for _, d in ipairs(wrow.drills) do
drills[#drills + 1] = d
end
cy = cy + 8
for _, d in ipairs(wide_here) do
drills[#drills + 1] = d
end
table.sort(drills, function(a, b) return (a.start or 0) < (b.start or 0) end)
add(row({ height = 12, align = "center" }, {
fg.label(c, "Drills", "h3", 12),
#drills > 1 and space(8) or nil,
#drills > 1 and fg.label(c, "· " .. #drills .. " share this well, oldest claims first", "small", 12, { fit = true, shrink = 1 }) or nil,
}))
add(space(0, 6))
if #wrow.drills == 0 and #(wrow.wide or {}) == 0 then
add(fg.label(c, "Nothing is mining here yet.", "small", 16))
add(space(0, 6))
end
moons.track_drills(g)
local enters = {}
local lines = {}
for _, d in ipairs(drills) do
lines[#lines + 1] = drill_line(g, c, st, d, H, enters)
end
add(col({ gap = 2 }, lines))
add(space(0, #lines > 0 and 10 or 8))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
local amount = ready.amount(g, row, row.supply)
if row.ready > 0 then
local b, bn, bw = ui.button(root, "Claim " .. ui.money(amount), pad, cy, { primary = true })
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
if not dry and st.unlocked.redeploy then
local b2, bn2 = ui.button(root, "Claim and redeploy", pad + bw + 8, cy)
H.action(g, b2, bn2, "claim", { ["claim-well"] = w.id, redeploy = "1" })
end
cy = cy + S.button_h + 10
elseif row.nextEnd then
H.claim_wait(g, root, pad, cy, row.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 })
cy = cy + S.button_h + 10
-- Idle drills that last mined it (Redrill on its card), and a better well for the
-- idle drills (the server's pick), as text.
if (wrow.redrill or 0) > 0 then
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
add(space(0, 6))
end
if #wide_here > 0 then
local wamount = ready.amount(g, wide, wide.cap)
if wide.ready > 0 then
local b, bn, bw = ui.button(root, "Claim " .. ui.money(wamount), pad, cy, { primary = true })
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
if st.unlocked.redeploy then
local b2, bn2 = ui.button(root, "Claim and redeploy", pad + bw + 8, cy)
H.action(g, b2, bn2, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
end
cy = cy + S.button_h + 10
elseif wide.nextEnd then
H.claim_wait(g, root, pad, cy, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
cy = cy + S.button_h + 10
end
local better = w.better
if better and not dry then
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
local name, tag = moons.tool_name(st, better.toolId)
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
add(space(0, 6))
end
if (row.redrill or 0) > 0 and not dry and st.unlocked.redrill then
local b, bn = ui.button(root, "Redrill" .. (row.redrill > 1 and (" " .. row.redrill) or ""), pad, cy, { variant = "redrill" })
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
cy = cy + S.button_h + 10
-- (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
return cy
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do
f()
end
return tree.y + tree.h
end
-- The moon the viewport shows: the selected moon in every section (but New moon).
@@ -457,10 +375,132 @@ function M.row_viewport(w)
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
end
-- The selected moon's facts under the viewport when no well is picked (read only).
local function moon_status(g, c, x, y, inner, H)
local st = g.state
local moon = st.moon
local supply, dry, mining, done = 0, 0, 0, 0
for _, w in ipairs(moon.wells or {}) do
supply = supply + w.supply
dry = dry + (w.supply <= 0 and 1 or 0)
end
local now = H.now(g)
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
local idle = 0
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
local kids = {
fg.label(c, moon.name, "h2", 18), space(0, 10),
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
space(0, 10),
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
fact(H, "Supply left", ui.money(supply)),
fact(H, "Drills mining", tostring(mining)),
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
fact(H, "Idle drills", tostring(idle)),
}
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
kids[#kids + 1] = k
end
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, x, y), c)
return tree.y + tree.h
end
-- A heading (h2) and a line under the viewport.
local function caption(c, x, y, inner, title, text)
fg.build(flex.solve(col({ width = inner }, {
fg.label(c, title, "h2", 18), space(0, 10),
fg.text_para(c, text, "small", { max_width = math.min(inner, S.text_max) }),
}), x, y), c)
end
-- The facts under the viewport on the Moons section, built into root (at where.root's
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
-- block of their own (core/main.lua), kept while M.facts_sig stays.
function M.facts(g, root, where, H)
local st = g.state
local moon = st.moon
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
if st.well then
-- The well panel scrolls under the viewport (many drills).
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon.finished then
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
else
-- (scrolls like the well panel: Sell moon at its foot)
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
end
end
-- What M.facts shows (its block's signature, with where): from the state and our clock.
function M.facts_sig(g, where, H)
local st = g.state
local moon = st.moon
local now = H.now(g)
local sale = st.unlocked.store and moon.sale or false
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
if moon.finished then
return blocks.sig(common)
end
local supplies = {}
for i, x in ipairs(moon.wells or {}) do
supplies[i] = x.supply
end
if st.well then
local w = st.well
local wrow = find(moon.wells, w.id) or false
local tools = {}
for i, d in ipairs(wrow and wrow.drills or {}) do
local t = find(st.tools, d.toolId)
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
for _, d in ipairs(wide and wide.drills or {}) do
local t = find(st.tools, d.toolId)
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local better = w.better or false
local bt = better and find(st.tools, better.toolId)
local bm = better and find(st.moons, better.moonId)
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
end
local mining, done, idle = 0, 0, 0
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
return blocks.sig(common, supplies, mining, done, idle)
end
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
-- Returns the panel (components/panel: its entrance).
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
-- moon, where its facts go (M.facts).
function M.build(g, parent, x, y, w, h, H, mod, row)
local st = g.state
local p = panel.new(parent, x, y, w, h)
@@ -470,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
local section = g.view.section
local moon = section == "moons" and st.moon or nil
local cy
if row then
-- A section panel that wants the whole aside (the drill panel): no moon over it.
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
if full then
moon_view.hide()
g.viewport_node, g.viewport_rect = nil, nil
cy = 8
elseif row then
local vp = M.row_viewport(w)
viewport(g, root, pad, pad, vp, shown_moon(g))
-- The side panel gets the rest of the row as its own box.
@@ -480,9 +526,15 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
inner = w - pad * 2
cy = pad + 10
else
-- The square viewport, centered, leaving 300 under it for the panel.
local vp = math.min(inner, h - pad * 2 - 300)
viewport(g, root, pad + (inner - vp) / 2, pad, vp, shown_moon(g))
cy = pad + vp + 18
local top = col({ width = w, padding = { pad, pad, 0, pad }, align = "center" }, {
made({ width = vp, aspect_ratio = 1 }, function(q, vx, vy, size)
viewport(g, q, vx, vy, size, shown_moon(g))
end),
})
fg.build(flex.solve(top, 0, 0), root)
cy = top.h + 18
end
local limit = h - pad
if g.reveal:hiding() then
@@ -497,20 +549,14 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
end
gui.delete_node(box)
end
if moon and st.well then
-- The well panel scrolls under the viewport (many deploy cards or drills).
local top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon then
ui.label(root, moon.name, "h2", pad, cy + 9)
H.para(root, moon.finished and "Mined out. Nothing left to dig." or "Pick a well in the table to deploy tools and claim resources.",
"small", pad, cy + 28, inner)
if moon then
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
-- viewport doesn't show keeps the panel and its moon.
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then
ui.text(root, "Unknown moon", "h2", pad, cy + 9)
H.para(root, "Scan resolution too low to make out the surface. Claim it to see what it holds.", "small", pad, cy + 28, inner)
caption(root, pad, cy, inner, "Unknown moon", "Scan resolution too low to make out the surface. Claim it to see what it holds.")
end
return p
end
+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:press("buy-moon") end,
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
-- Buy another, straight from the card (its button registers as buy-moon).
function(a) return a:press("buy-moon") end,
-- Buy another, straight from the card (its button registers as reveal-buy).
function(a) return a:press("reveal-buy") end,
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
function(a) return a:press("reveal-close") end,
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
+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)
end,
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") end,
function(a) return go(a, "leaderboard") end,
function(a) return a.g.view.section == "leaderboard" and shot("10-leaderboard") end,
function(a) return go(a, "market") end,
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
function(a) return go(a, "help") end,
function(a) return a.g.view.section == "help" and shot("11-help") end,
-- Start over: arm the button (its first click), then run new-game.
function(a)
a.g.help_armed = a.g.h.now(a.g) + 30000
a.g.dirty = true
return true
end,
function() return shot("12-help-confirm") end,
-- Start over: open its confirm, then run new-game (as its OK).
function(a) return a:press("confirm:new-game") end,
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
function(a) return a:press("new-game") end,
function(a) return a.g.state.view.section ~= "help" or true end,
function() return shot("13-new-game") end,
+42 -51
View File
@@ -5,6 +5,8 @@
-- padding 6px 16px).
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local M = {}
@@ -22,75 +24,64 @@ function M.time_left(ms)
return string.format("%dm left", mins)
end
-- One item (dot, text, time left) at (0, 0) of a new node; returns it and its width.
-- One item (dot, text, time left), a tree piece at most max_w wide: on one line, or when
-- longer than the window is wide, the text wraps and the time left goes under it.
local function item(self, H, parent, b, max_w)
local root = ui.rect(parent, 0, 0, 1, ROW_H, "none")
local color = b.level == "warning" and "danger_text" or (b.level == "event" and "ready" or "text")
ui.rect(root, 0, ROW_H / 2 - 3, 6, 6, DOT[b.level] or "cyan")
local style = { font = "sans_semi", px = 14, color = color, tracking = 0.06 }
local node, tw = ui.label(root, b.text, style, 14, ROW_H / 2)
local w, h = 14 + tw, ROW_H
if w > max_w then
-- Longer than the window is wide: the text wraps (the time left goes under it).
gui.delete_node(node)
local _, th = ui.wrap(root, b.text, style, 14, 0, max_w - 14)
w, h = max_w, math.max(ROW_H, th)
end
local dot = fg.made({ width = 6, height = 6, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 6, 6, DOT[b.level] or "cyan") end)
local left
if b.kind == "event" and (b.left or "") ~= "" then
local text = "— " .. b.left
local ends = b["end"]
local text = (ends and ends > 0) and ("— " .. M.time_left(ends - H.now(self))) or ("— " .. b.left)
left = fg.label(parent, text, { font = "sans", px = 14, color = "text_2", tracking = 0.06 }, ROW_H)
if ends and ends > 0 then
text = "— " .. M.time_left(ends - H.now(self))
left.done = function(n)
H.tick_text(self, n, ends, function(ms)
return "— " .. M.time_left(ms)
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
end
end
local lx, ly = w + 5, ROW_H / 2
if h > ROW_H then
lx, ly, h = 14, h + ROW_H / 2, h + ROW_H
end
local left = ui.text(root, text, { font = "sans", px = 14, color = "text_2", tracking = 0.06 }, lx, ly)
if ends and ends > 0 then
H.tick_text(self, left, ends, function(ms)
return "— " .. M.time_left(ms)
end, "— " .. M.time_left(1)) -- (at the end: the last text, like the web's static one)
end
w = math.max(w, lx + ui.text_width(left))
end
return root, math.min(w, max_w), h
local label = fg.label(parent, b.text, style, ROW_H)
if 14 + label.measure() <= max_w then
return fg.row({ height = ROW_H, align = "center", shrink = 0 }, { dot, fg.space(8), label, left and fg.space(5) or nil, left })
end
gui.delete_node(label.node)
return fg.col({ width = max_w, shrink = 0 }, {
fg.row({ align = "start" }, {
fg.row({ height = ROW_H, align = "center" }, { dot }), fg.space(8),
fg.measured(parent, function(p, x, y, w)
local _, th = ui.wrap(p, b.text, style, x, y, w)
return math.max(ROW_H, th)
end, { grow = 1 }),
}),
left and fg.row({ padding_left = 14 }, { left }) or nil,
})
end
-- The banner across the top of parent, W wide. Returns its height (0 when empty).
-- The banner across the top of parent, W wide: the items centered in rows that wrap
-- (gap 4 24, padding 6 16), a line under it. Returns its height (0 when empty).
function M.build(self, H, parent, W)
local st = self.state
if not st.banner or #st.banner == 0 then
return 0
end
local root = ui.rect(parent, 0, 0, W, 1, "cyan", 0.08)
-- Rows of items: fill a row, then center it.
local max_w = W - PAD_X * 2
local rows, row = {}, { w = 0, h = 0 }
local items = {}
for _, b in ipairs(st.banner) do
local node, w, h = item(self, H, root, b, max_w)
if #row > 0 and row.w + GAP_X + w > max_w then
rows[#rows + 1] = row
row = { w = 0, h = 0 }
end
row.w = row.w + (#row > 0 and GAP_X or 0) + w
row.h = math.max(row.h or 0, h)
row[#row + 1] = { node = node, w = w }
items[#items + 1] = item(self, H, parent, b, max_w)
end
rows[#rows + 1] = row
local y = PAD_Y
for i, r in ipairs(rows) do
local x = math.floor((W - r.w) / 2)
for _, it in ipairs(r) do
gui.set_position(it.node, vmath.vector3(x, -y, 0))
x = x + it.w + GAP_X
end
y = y + r.h + (i < #rows and GAP_Y or 0)
end
local h = y + PAD_Y
gui.set_size(root, vmath.vector3(W, h, 0))
ui.rect(root, 0, h - 1, W, 1, "line_hi")
return h
local tree = fg.col({ width = W, padding = { PAD_Y, PAD_X } }, {
fg.row({ wrap = true, justify = "center", align = "start", column_gap = GAP_X, row_gap = GAP_Y }, items),
fg.made({ position = "absolute", left = 0, right = 0, bottom = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "line_hi")
end),
}, { make = function(p, x, y, w, h)
return ui.rect(p, x, y, w, h, "cyan", 0.08)
end })
fg.build(flex.solve(tree, 0, 0), parent)
return tree.h
end
return M
+180 -23
View File
@@ -12,6 +12,8 @@ local scroll = require("components.scroll")
local button = require("components.button")
local chip = require("components.chip")
local stat = require("components.stat")
local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local S = theme.size
@@ -30,11 +32,12 @@ function M.compact(n)
local function one(f)
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
end
if a >= 1e6 then
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
if a >= 1e6 - 500 then
return one(n / 1e6) .. "M"
elseif a >= 1e4 then
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
elseif a >= 1e3 then
elseif a >= 1e3 - 0.5 then
return one(n / 1e3) .. "k"
end
return ui.money(math.floor(n + 0.5))
@@ -47,14 +50,13 @@ function M.pct(m)
end
local EFFECT_WORDS = {
attachment = { { "speed", "speed" }, { "yield", "yield" } },
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
}
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
-- "+5% drill speed, +5% yield" (kind: "advisor").
function M.effect(effect, kind)
local parts = {}
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
local m = effect and effect[e[1]] or 1
if m > 1 then
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
@@ -186,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
return w
end
-- A violet tag (attachment names); returns its width.
-- A violet tag (advisor labels); returns its width and the box.
function M.tag(parent, text, x, y, color)
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
return w, box
end
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
function M.pips(parent, used, slots, x, y)
for i = 1, slots do
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
gui.set_pivot(p, gui.PIVOT_CENTER)
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(p, vmath.vector3(0, 0, 45))
if i > used then
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
gui.set_pivot(o, gui.PIVOT_CENTER)
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(o, vmath.vector3(0, 0, 45))
end
end
return slots * 13
end
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
-- height used.
function M.parts(st, parent, parts, x, y, w)
@@ -277,6 +262,178 @@ function M.confirm_server_button(self, b, c, act, params, id)
end
end })
self.fx:button(id, b)
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
-- its confirm is open), and presses `id` to open the confirm.
if self.autoplay then
local function confirmed()
local d = self.dialog
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
d:act(act, params)
else
self.fx:origin(b, id)
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
end
end
local function ask()
M.confirm_server(self, c, act, params, opener(self, b, id))
end
local function register()
if self.autoplay then
self.autoplay:register(act, params, confirmed)
self.autoplay:register(id, params, ask)
end
end
register()
blocks.note(register) -- (a kept block registers again)
end
end
---------------------------------------------------------------- inline confirm
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
-- left of it, so a small move left confirms, and the server's question takes the room of
-- what was left of it. Nothing else moves. Confirm takes the keyboard focus (soft, like
-- the dialog's OK: Enter confirms). It cancels on Esc, a click anywhere else, ASK_IDLE
-- seconds without a key or button press, navigating, and when what it asks about changes (its words or
-- params: core/main and session call M.cancel_ask). State: self.ask = { id, act, params,
-- key, t, nodes }.
M.ASK_IDLE = 6
local function ask_key(c, params)
local parts = { c and c.text or "", c and c.ok or "" }
local ks = {}
for k in pairs(params or {}) do
ks[#ks + 1] = k
end
table.sort(ks)
for _, k in ipairs(ks) do
parts[#parts + 1] = k .. "=" .. tostring(params[k])
end
return table.concat(parts, "|")
end
function M.cancel_ask(self)
if self.ask then
self.ask = nil
self.dirty = true
end
end
-- The ask in progress for the button `id`, when its words and params are still the same
-- (else it is dropped: the state changed under it).
function M.asking(self, id, c, params)
local a = self.ask
if not a or a.id ~= id then
return nil
end
if a.key ~= ask_key(c, params) then
self.ask = nil
return nil
end
a.nodes = {}
return a
end
-- Every frame (core/main): the idle timeout.
function M.ask_update(self)
if self.ask and socket.gettime() - self.ask.t > M.ASK_IDLE then
M.cancel_ask(self)
end
end
-- Input (core/main, before the page gets it): a key or button press keeps the ask alive
-- (moves don't: a rebuild replays the last one); Esc and a press outside its buttons
-- cancel it. Returns true when the input is used up (Esc).
function M.ask_input(self, action_id, action)
local a = self.ask
if not a then
return false
end
if action.pressed then
a.t = socket.gettime()
end
if action_id == hash("key_esc") and action.pressed then
M.cancel_ask(self)
return true
end
if action_id == hash("touch") and action.pressed and action.x then
for _, n in ipairs(a.nodes or {}) do
if pcall(gui.get_id, n) and gui.pick_node(n, action.x, action.y) then
return false
end
end
M.cancel_ask(self)
end
return false
end
-- The button that asks (bound like confirm_server_button, test id `id`).
function M.ask_button(self, b, c, act, params, id)
id = id or ("confirm:" .. act)
button.bind(self, b, nil, { id = id, on_click = function()
if self.busy then
return
end
self.ask = { id = id, act = act, params = params, key = ask_key(c, params), t = socket.gettime() }
-- Confirm takes the focus (soft after a mouse click: Enter confirms, no ring).
self.focus = self.focus or { index = 0 }
self.focus.key = "page:" .. id .. ":ok"
self.focus.soft = not self.focus.visible
require("modules.sfx").play("open")
self.dirty = true
end })
self.fx:button(id, b)
end
-- The asking state's buttons, tree pieces for a row (made under parent): Confirm then
-- Cancel (cancel_w: the asking button's width: Cancel is at least as wide, so set at the
-- row's right end it covers the button's spot).
function M.ask_buttons(self, parent, c, cancel_w, opts)
local a = self.ask
opts = opts or {}
local label, price = (c.ok or "Confirm"), nil
local head, num = label:match("^(.-)%s*₥%s*([%d,%.]+)$")
if head then
label = head:gsub("%s+for$", "")
price = tonumber((num:gsub(",", "")))
end
local small = opts.small ~= false
local bopts = { small = small, variant = "danger", price = price, fill = { "danger", 0.32 }, edge = { "danger", 1 } }
local ok = fg.button(parent, label, bopts, function(b, nodes)
a.nodes[#a.nodes + 1] = b
button.bind(self, b, nodes, { id = a.id .. ":ok", on_click = function()
if self.ask ~= a then
return
end
self.ask = nil
-- (the effects start where the asking button was, like the dialog's opener)
self.fx:origin(b, a.id)
require("modules.sfx").play("confirm")
self.h.act(self, a.act, a.params or {}) -- (core/session, through the H table: no require cycle)
self.dirty = true
end })
self.fx:button(a.id, b)
end)
local cancel = fg.button(parent, "Cancel", { small = small }, function(b, nodes)
a.nodes[#a.nodes + 1] = b
button.bind(self, b, nodes, { id = a.id .. ":cancel", on_click = function()
require("modules.sfx").play("close")
M.cancel_ask(self)
end })
end, { width = math.max(cancel_w, fg.button_w(parent, "Cancel", { small = small })) })
return ok, cancel
end
-- The server's question, compact: at most `lines` lines (default 2) in what room is left.
function M.ask_text(c, parent, opts)
opts = opts or {}
local t = fg.para(parent, c.text or "", { font = "sans", px = 12, color = "text_2" }, 15,
{ lines = opts.lines or 2, grow = 1, shrink = 1, basis = 0 })
t.done = function(n) tooltip.add(n, c.text) end -- (the whole question, when it's cut)
return t
end
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
+4 -3
View File
@@ -5,6 +5,7 @@
-- thinned to about one point per few pixels so a chart stays a few hundred nodes.
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
@@ -167,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
gui.set_enabled(cross, false)
local rect
local shown = -1
local function move(action)
rect = rect or screen_rect(box, cw, ch)
-- Measured on every move: scrolling or the entrance slide moves the box.
local rect = screen_rect(box, cw, ch)
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
if not inside then
@@ -199,7 +200,7 @@ function M.chart(parent, x, y, w, h, data, opts)
gui.set_position(tip, vec(tx, -math.max(0, math.min(ch - 22, cy - 30))))
end
if opts.pointers then
opts.pointers[#opts.pointers + 1] = move
blocks.push(opts.pointers, "pointers", move) -- (opts.pointers: the screen, g)
end
return box
end
+36 -7
View File
@@ -10,6 +10,7 @@ local button = require("components.button")
local stat = require("components.stat")
local anim = require("modules.anim")
local test = require("modules.test")
local blocks = require("modules.blocks")
local S = theme.size
@@ -110,10 +111,13 @@ end
-- registered for name in modules/predict) the result shows at once and the button can be
-- clicked again right away (actions queue in order); without one the button shows a
-- spinner and ignores clicks until the server answers.
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. Returns the
-- click function (a text field's Enter runs it too).
function M.action(g, b, nodes, name, params, on_done, predictor, id)
local key = button_key(name, params)
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
-- busy key, so each such button has its own (else they all share the action's name).
-- Returns the click function (a text field's Enter runs it too).
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
local key = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
if g.inflight[key] then
button.set_busy(b)
end
@@ -129,7 +133,6 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
end
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a
-- click anywhere on it counts from the first frame.
local hit
local r = name == "claim" and ready_at(g)[key]
if r and not anim.reduced and socket.gettime() - r.t < POP then
local p, size = gui.get_position(b), gui.get_size(b)
@@ -141,7 +144,13 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
ready_look(g, b, key)
end
if g.autoplay then
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
local p = type(params) == "function" and params() or params
g.autoplay:register(name, p, fire)
blocks.note(function() -- (a kept block registers again; params made now)
if g.autoplay then
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
end
end)
end
return fire
end
@@ -246,6 +255,26 @@ function M.claim_wait(g, parent, x, y, ends, opts)
return b, bw
end
-- The width claim_wait's button will have now (a probe; registers nothing), for layout.
function M.claim_wait_w(g, parent, ends, opts)
opts = opts or {}
local key = opts.params and button_key("claim", opts.params)
local r = key and ready_at(g)[key]
local label
if opts.params and (ends - session.now(g) <= 0 or (r and r.ends == ends)) then
label = opts.done or "Claim"
else
label = (opts.wait or "Claim") .. " " .. ui.dur(ends - session.now(g))
end
local b, nodes, bw = ui.button(parent, label, 0, 0, { primary = true, small = opts.small, w = opts.w })
if opts.badge then
bw = M.badge(b, nodes, opts.badge)
end
ui.button_looks[b] = nil
gui.delete_node(b)
return bw
end
-- A count badge inside a button after its label (the web's Claim all .badge: gold edge
-- and number), made by ui.button(b, nodes); widens the button. Returns its new width.
function M.badge(b, nodes, n)
@@ -296,7 +325,7 @@ end
-- The H table section modules get.
function M.table()
return {
action = M.action, link = M.link, text_link = M.text_link, claim_wait = M.claim_wait, badge = M.badge,
action = M.action, link = M.link, text_link = M.text_link, claim_wait = M.claim_wait, claim_wait_w = M.claim_wait_w, badge = M.badge,
navigate = session.navigate, act = session.act, now = session.now,
para = M.para, heading = M.heading, fact = M.fact,
tick_text = ticks.tick_text, tick_bar = ticks.tick_bar, tick_num = ticks.tick_num,
+12 -1
View File
@@ -8,7 +8,8 @@
-- the focus ring) scroll the scroll area under the mouse
-- (else the one holding the focused element, else the biggest one), like a browser.
-- F11 toggles fullscreen; Ctrl + / Ctrl - / Ctrl 0 change the UI scale (modules/screen),
-- like the browser's zoom.
-- like the browser's zoom. F3 (debug builds) shows the engine's profiler (frame times).
-- G opens the quick grant dialog for admins (state.admin, screens/game/grant.lua).
--
-- Text fields made in a build register in g.inputs ({ input = Druid input, submit = fn,
-- step = { min, max } }: modules/field, the refinery's quantity); core/main resets the list
@@ -30,7 +31,9 @@ local SHIFT = { [hash("key_lshift")] = true, [hash("key_rshift")] = true }
local ALT = { [hash("key_lalt")] = true, [hash("key_ralt")] = true }
local CTRL = { [hash("key_lctrl")] = true, [hash("key_rctrl")] = true }
local F11 = hash("key_f11")
local F3 = hash("key_f3")
local ESC = hash("key_esc")
local KEY_G = hash("key_g")
local ZOOM = { [hash("key_plus")] = 1, [hash("key_equals")] = 1, [hash("key_numpad_add")] = 1,
[hash("key_minus")] = -1, [hash("key_numpad_subtract")] = -1, [hash("key_0")] = 0, [hash("key_numpad_0")] = 0 }
local UP, DOWN = hash("key_up"), hash("key_down")
@@ -172,6 +175,11 @@ function M.before(g, action_id, action, input)
screen.toggle_fullscreen()
return true
end
if action_id == F3 and action.pressed and profiler and sys.get_engine_info().is_debug then
g.profiler_ui = not g.profiler_ui
profiler.enable_ui(g.profiler_ui)
return true
end
if g.mods.ctrl and action_id and ZOOM[action_id] and action.pressed then
screen.step_ui_scale(ZOOM[action_id])
return true
@@ -237,6 +245,9 @@ function M.before(g, action_id, action, input)
elseif action_id == MOUSE_FORWARD or (action_id == RIGHT and g.mods.alt) then
session.go(g, 1)
return true
elseif action_id == KEY_G and not g.mods.ctrl and not g.mods.alt and g.state and g.state.admin then
require("screens.game.grant").open(g) -- (admins: the quick grant dialog)
return true
end
return false
end
+289 -73
View File
@@ -3,12 +3,16 @@
-- for the module contract the regions and sections follow.
--
-- Rebuild model: every new state (a response, a prediction) or a window/UI-scale change
-- sets g.dirty and the next update rebuilds the whole layout from the state: the Druid
-- instance and every layout node are dropped and made again. What must survive a rebuild
-- lives on g (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects
-- g.fx and toasts g.toasts on their own layers). Regions are laid out here: banner and
-- header across the top, then the moon list, the main panel (the current section) and
-- the aside.
-- sets g.dirty and the next update rebuilds the layout from the state. The regions and the
-- costly parts inside them are blocks (modules/blocks): one whose inputs (its signature)
-- didn't change is kept with its nodes and Druid components, the rest is built again; a
-- full rebuild (g.dirty_all: the theme, reduced motion, a size change, ...) drops the
-- Druid instance and every layout node first. What must survive a rebuild lives on g
-- (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects g.fx and
-- toasts g.toasts on their own layers). Regions are laid out here: banner and header
-- across the top, then the moon list, the main panel (the current section), the right
-- side panel (sidepanel.lua: the leaderboard; folded away when there's no room) and the
-- aside.
local druid = require("druid.druid")
local theme = require("modules.theme")
@@ -34,8 +38,14 @@ local banner = require("screens.game.banner")
local header = require("screens.game.header")
local moonlist = require("screens.game.moonlist")
local aside = require("screens.game.aside")
local sidepanel = require("screens.game.sidepanel")
local cards = require("screens.game.cards")
local reveal = require("screens.game.reveal")
local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks")
local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
@@ -52,7 +62,6 @@ M.SECTIONS = {
moons = require("screens.game.moons"),
market = require("screens.game.market"),
refinery = require("screens.game.refinery"),
leaderboard = require("screens.game.leaderboard"),
help = require("screens.game.help"),
equipment = require("screens.game.equipment"),
store = require("screens.game.store"),
@@ -79,14 +88,16 @@ end
---------------------------------------------------------------- build
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when
-- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
-- the first state and after the reveal closes (g.enter_all).
function enter(g)
local v = g.view
local main_key = v.section .. "|" .. (v.moon or "")
-- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
-- Claim all finishing one, mustn't replay the section's entrance)
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
local all = g.enter_all or not g.entered
if g.reveal:hiding() then
@@ -130,36 +141,13 @@ local function boot_screen(g, W, H)
end
end
local function build_main(g, parent, x, y, w, h)
local st = g.state
local p = panel.new(parent, x, y, w, h)
g.main_panel = p
local root = p.body
local px, py = S.main_pad_x, S.main_pad_y
local section = g.view.section
local mod = M.SECTIONS[section]
if g.reveal:hiding() then
return -- what the new moon holds stays hidden until its card is closed
elseif section == "new-moon" then
local inner = math.min(w - px * 2, S.content_max)
reveal.build_teaser(g, root, px + math.floor((w - px * 2 - inner) / 2), py, inner)
elseif mod and (NO_BLOCK[section] or st[section]) then
mod.build(g, root, px, py, w, h, g.h) -- modules center their own content
elseif not mod then
coming_soon(g, root, px, py, w)
end
end
-- Whether the aside shows, like the web's (page.html: hidden unless the view selects
-- something for the viewport, views.go Scene.Select): a moon (s=moons with a moon), the
-- new-moon teaser, the drill panel (Equipment with a drill) or an advisor's panel. Other
-- views (Market, Store, Refinery, Leaderboard, Help, Equipment, Advisors, the first-moon
-- pitch) give the main panel the whole width. The reveal keeps it (it plays in the viewport).
-- pitch) give the main panel the whole width. (The reveal plays in its own window, reveal.lua.)
function M.aside_shown(g)
local st, section = g.state, g.view.section
if g.reveal:active() then
return true
end
-- An advisor not hired yet, picked on its card (the client's own preview).
if section == "advisors" and g.advisor_pick and st.advisors then
for _, a in ipairs(st.advisors.advisors or {}) do
@@ -193,65 +181,238 @@ function M.aside_shown(g)
return false
end
-- What each section shows from the state (besides the view and the screen-wide inputs):
-- its block is kept while these stay the same (modules/blocks). The aside shows the
-- section's own aside or the moon's well panel.
local ALWAYS = { "rules", "unlocked", "catalogVersion", "bonuses", "liveEvents", "showNav" }
-- The state keys each section shows (its block's signature): a section that reads a new
-- state key must add it here, or it won't update. ASIDE_KEYS and the header's and moon
-- list's sigs work the same way.
local SECTION_KEYS = {
moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" },
market = { "market", "leaderboard" },
refinery = { "refinery", "jobs", "refined", "credits", "resources" },
help = { "help" },
equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" },
store = { "store", "credits", "moonPrice", "resources", "refined" },
advisors = { "advisors", "credits" },
["new-moon"] = { "newMoon", "moonPrice", "moonGrade", "credits" },
}
local ASIDE_KEYS = { "moon", "moons", "past", "tools", "well", "stakes", "equipment", "advisors", "newMoon", "moonPrice", "credits", "refined" }
local function pick_keys(st, ...)
local out = {}
for _, list in ipairs({ ... }) do
for _, k in ipairs(list) do
if out[k] == nil then
out[k] = st[k] == nil and false or st[k]
end
end
end
if st.catalogVersion == nil then
out.catalog = st.catalog or false
end
return out
end
-- Drills and batches whose end passed on our clock (core/ready counts them before the
-- refetch): the counts they show change with it. kinds: the state lists to look at
-- (default both).
local function ready_key(g, kinds)
local st, now = g.state, session.now(g)
local out = {}
for _, k in ipairs(kinds or { "stakes", "jobs" }) do
local list = st[k] or {}
for _, x in ipairs(list) do
if not x.ready and x["end"] and x["end"] <= now then
out[#out + 1] = tostring(x.id or x.toolId) .. "@" .. x["end"]
end
end
end
return table.concat(out, ",")
end
-- What every region depends on besides the state: busy buttons, Claims that turned ready
-- on our clock, the toggles.
local function local_sig(g, kinds)
return blocks.sig(g.inflight, g.ready_at, ready_key(g, kinds), sfx.muted(), anim.reduced, theme.name, g.ask and g.ask.id or false)
end
-- The ready lists each section shows: a drill turning ready doesn't rebuild the Refinery
-- (a 60-90 ms build, twice per drill: on our clock, then at the refetch), nor a batch the
-- Moons. Sections not listed show neither.
local MAIN_READY = { moons = { "stakes" }, equipment = { "stakes" }, refinery = { "jobs" } }
-- The reveal hides the main panel, the aside's content and the new moon's row.
local function reveal_sig(g)
local r = g.reveal
return blocks.sig(r:active(), r:hiding(), r.r and r.r.data and r.r.data.moonId or "")
end
-- The section's block in the main panel.
local function build_main(g, parent, x, y, w, h)
local st = g.state
local section = g.view.section
local common = local_sig(g, MAIN_READY[section] or {})
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do
if m.teardown then
m.teardown(g)
end
end
g.well_nodes = {}
g.frames = {} -- nested frames (cards) the main panel's entrance staggers
local p = panel.new(holder, x, y, w, h)
g.main_panel = p
local root = p.body
local px, py = S.main_pad_x, S.main_pad_y
local mod = M.SECTIONS[section]
if g.reveal:hiding() then
return -- what the new moon holds stays hidden until its card is closed
elseif section == "new-moon" then
local inner = math.min(w - px * 2, S.content_max)
reveal.build_teaser(g, root, px + math.floor((w - px * 2 - inner) / 2), py, inner)
elseif mod and (NO_BLOCK[section] or st[section]) then
mod.build(g, root, px, py, w, h, g.h) -- modules center their own content
elseif not mod then
coming_soon(g, root, px, py, w)
end
end)
end
-- The aside's block (narrow: the row under the panels).
local function build_aside(g, parent, x, y, w, h, row, common, still)
local st = g.state
local section = g.view.section
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
-- their own, below)
local moon_only = section == "moons" and st.moon ~= nil
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
end)
if facts then
-- The moon's facts: a block of their own inside the panel (aside.facts).
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
aside.facts(g, holder, facts, g.h)
end)
end
return p
end
-- A full rebuild drops every node and component and builds them all again; otherwise the
-- regions (and the blocks inside them) whose inputs didn't change are kept (modules/
-- blocks). Full: the first state, a window or UI-scale change, the narrow layout (one
-- page scroll), the theme or another screen-wide toggle (g.dirty_all).
function M.build(g)
g.builds = (g.builds or 0) + 1 -- (test counter)
local W, H = screen.fit(g.root)
local size = { W, H, select(3, screen.layout()) }
local narrow = W < NARROW_W
local full = not g.layout or not g.state or not g.layout_state or g.dirty_all or narrow
or not g.size or g.size[1] ~= size[1] or g.size[2] ~= size[2] or g.size[3] ~= size[3]
g.dirty_all = false
g.full_builds = (g.full_builds or 0) + (full and 1 or 0) -- (test counter)
-- The page's ground and its top-right glow (game.gui) in the theme's colors.
gui.set_color(gui.get_node("bg"), theme.color.bg)
gui.set_color(gui.get_node("glow"), theme.color.bg_glow)
button.begin_build() -- (a press the rebuild interrupts carries over: components/button)
each_module(function(m)
if m.teardown then
m.teardown(g)
if full then
each_module(function(m)
if m.teardown then
m.teardown(g)
end
end)
if g.layout then
g.tearing_down = true -- (components going away fire events: core/list, scroll)
g.druid:final()
g.tearing_down = false
g.druid = druid.new(g)
blocks.hook(g.druid)
gui.delete_node(g.layout)
g.layout = nil
end
end)
if g.layout then
g.tearing_down = true -- (components going away fire events: core/list, scroll)
g.druid:final()
g.tearing_down = false
g.druid = druid.new(g)
gui.delete_node(g.layout)
blocks.clear(g)
end
local W, H = screen.fit(g.root)
g.size = { W, H, select(3, screen.layout()) }
blocks.begin(g, full)
g.size = size
gui.set_size(g.page, vec(W, H))
gui.set_position(g.page, vec(-W / 2, H / 2))
g.tickers = {}
g.scroll_areas = {}
g.tween:begin()
g.tab_nodes, g.moon_nodes, g.well_nodes = {}, {}, {}
g.fx:begin_build()
test.begin_build()
tooltip.begin_build()
g.inputs = {} -- text fields (core/keys: Enter submits, keys skip while editing)
g.pointers = {} -- mouse-move handlers (the market chart's crosshair)
if g.autoplay then
g.autoplay:reset()
end
g.layout = ui.rect(g.page, 0, 0, W, H, "none")
gui.move_below(g.layout, g.toast_layer)
if not g.layout then
g.layout = ui.rect(g.page, 0, 0, W, H, "none")
gui.move_below(g.layout, g.toast_layer)
end
g.toasts:set_height(H)
g.dialog:layout(W, H)
if not g.state then
g.tab_nodes, g.moon_nodes, g.well_nodes = {}, {}, {}
boot_screen(g, W, H)
g.layout_state = false
g.dirty = false
blocks.finish(g)
return
end
g.pointers = {} -- mouse-move handlers (the market chart's crosshair)
-- Announcements and live events above the header.
local banner_h = banner.build(g, g.h, g.layout, W)
local header_root = ui.rect(g.layout, 0, banner_h, W, 1, "none")
g.header_node = header_root
local header_h = header.build(g, header_root, 0, 0, W, nil, g.h) + banner_h
g.layout_state = true
local st = g.state
local common = local_sig(g)
-- (without the drills and batches that ended on our clock: the banner, header and moon
-- list show those counts through their own signatures, and a drill ending mustn't
-- rebuild them)
local still = local_sig(g, {})
-- Announcements and live events above the header.
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
return banner.build(g, g.h, holder, W)
end)
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
g.header_node = header_root
return header.build(g, header_root, 0, 0, W, nil, g.h) + banner_h
end)
local pad, gap = S.body_pad, S.gap
local top = header_h + pad
local body_h = H - top - pad
if g.admin then
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
g.toasts:detach()
g.moon_nodes, g.well_nodes = {}, {}
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
g.page_key, g.page_main, g.side_w = nil, nil, 0
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
admin.build(g, holder, pad, top, W - pad * 2, body_h)
end)
blocks.finish(g)
g.tween:finish()
ticks.run(g)
g.dirty = false
g.fx:after_build(g)
g.reveal:after_build()
g.rehover = true
return
end
local has_aside = M.aside_shown(g)
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
-- under the moon list and the main panel, the viewport and the side panel side by
-- side, and the page scrolls to it (a page scroll area under the header).
local narrow = W < NARROW_W
local aside_w = has_aside and math.max(S.aside_w_min, math.min(S.aside_w_max, math.floor(W * 0.33))) or 0
-- The moon list shows once there is a moon (or the store is open), like the web.
local list_w = math.max(S.moonlist_w, math.min(S.moonlist_w_max, math.floor(W * 0.15)))
@@ -263,6 +424,13 @@ function M.build(g)
end
local main_x = pad + (list_w > 0 and (list_w + gap) or 0)
local main_w = W - pad - main_x - ((has_aside and not narrow) and (aside_w + gap) or 0)
-- The right side panel takes its width from the main panel's, or folds away.
-- (the side panel is the Market's: Top miners beside it)
local side_w = sidepanel.width(g, W, main_w, narrow or has_aside or g.view.section ~= "market")
g.side_w = side_w
if side_w > 0 then
main_w = main_w - side_w - gap
end
local parent, y0, page = g.layout, top, nil
local row_h
if has_aside and narrow then
@@ -279,46 +447,73 @@ function M.build(g)
parent, y0 = page.content, pad
end
if list_w > 0 then
moonlist.build(g, g.h, parent, pad, y0, list_w, body_h)
-- (the rows' ready counts: per moon, from the stakes on our clock)
local v = g.view
local hides = {}
for _, m in ipairs(st.moons) do
hides[#hides + 1] = g.reveal:hides(m.id) or false
end
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
moonlist.rows_sig(g), st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
g.moon_nodes = {}
moonlist.teardown(g)
moonlist.build(g, g.h, holder, pad, y0, list_w, body_h)
end)
else
g.moon_nodes = {}
g.toasts:detach() -- (no moon list: toasts float)
end
g.frames = {} -- nested frames (cards) the main panel's entrance staggers
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
if page then
g.aside_panel = aside.build(g, parent, pad, y0 + body_h + gap, aside_w, row_h, g.h, M.SECTIONS[g.view.section], true)
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
local s = page:finish(y0 + body_h + gap + row_h + pad)
if g.viewport_node then
if g.viewport_node and not g.reveal:active() then
moon_view.move(g.viewport_node) -- (where the page's offset put it)
g.viewport_rect = g.fx:rect_of(g.viewport_node)
end
-- Picking a well, a drill or an advisor (or a reveal) brings the aside into view.
-- Picking a drill or an advisor brings the aside into view (not a well: its card,
-- where its actions are, stays in view; the aside only shows its facts).
local v = g.view
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick), tostring(g.reveal:active()) }, "|")
local want = g.reveal:active() or (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
local want = (v.drill or "") ~= "" or v.section == "advisors"
if s and want and key ~= g.page_key then
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
end
g.page_key = key
if s then
-- The viewport moves with the page: the 3D moon follows it (and a reveal's dim).
-- The viewport moves with the page: the 3D moon follows it.
s.on_scroll:subscribe(function()
if g.viewport_node and not g.tearing_down then
if g.viewport_node and not g.tearing_down and not g.reveal:active() then
moon_view.move(g.viewport_node)
g.viewport_rect = g.fx:rect_of(g.viewport_node)
if g.reveal:active() then
g.reveal:place()
end
end
end)
end
elseif has_aside then
g.aside_panel = aside.build(g, parent, W - pad - aside_w, top, aside_w, body_h, g.h, M.SECTIONS[g.view.section])
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
moon_view.hide()
if not g.reveal:active() then -- (the reveal's window shows the moon)
moon_view.hide()
end
end
if not page then
g.page_key, g.page_main = nil, nil
end
blocks.finish(g)
ready.watch(g) -- (rebuild when the next drill or batch end passes)
enter(g)
g.tween:finish()
ticks.run(g)
@@ -370,6 +565,7 @@ function M.init(g)
end
msg.post(".", "acquire_input_focus")
g.druid = druid.new(g)
blocks.hook(g.druid)
g.root = gui.get_node("root")
g.page = gui.get_node("page")
theme.set(theme.name) -- the saved theme's clear color (the render script starts dark)
@@ -403,6 +599,7 @@ function M.final(g)
end)
g.dialog:remove()
g.druid:final()
blocks.clear(g)
end
local TOUCH = hash("touch")
@@ -421,6 +618,10 @@ function input(g, action_id, action)
if action_id == TOUCH and action.released then
button.release() -- (carried only while the mouse is down; after this Druid clicks)
end
-- A sell asking in place (cards.ask_button): Esc or a press elsewhere cancels it.
if not g.dialog:is_open() and cards.ask_input(g, action_id, action) then
return true
end
-- Tab focus, Enter/Space on the focused element, back/forward (core/keys).
if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
return true
@@ -460,14 +661,20 @@ local TEST_API = {
g.scroll_pos = g.scroll_pos or {}
g.scroll_pos[k:sub(8)] = tonumber(v) or 0
end
g.dirty = true
g.dirty, g.dirty_all = true, true
end,
}
function M.update(g, dt)
local t0 = test.enabled and test.clock()
local built
local W, H, scale = screen.layout()
if g.dirty or W ~= g.size[1] or H ~= g.size[2] or scale ~= g.size[3] then
local resized = W ~= g.size[1] or H ~= g.size[2] or scale ~= g.size[3]
-- (while the reveal card flies into the moon list, a new state waits for it to land:
-- the page is hidden under it anyway, and the flight keeps its frames)
if resized or (g.dirty and not g.reveal:closing()) then
M.build(g)
built = t0 and test.clock() - t0
end
if g.boot_tick and not g.state then
local left = math.max(0, math.ceil(g.boot_tick.at - socket.gettime()))
@@ -478,6 +685,9 @@ function M.update(g, dt)
g.fx:update(dt)
g.reveal:update(dt)
g.dialog:update(dt)
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
moon_view.covered = g.dialog:is_open()
cards.ask_update(g)
check_input(g)
tooltip.update(g.dialog:is_open())
focus.update(g, g.focus_layer)
@@ -486,6 +696,9 @@ function M.update(g, dt)
m.update(g, dt)
end
end)
if g.admin then
admin.update(g, dt)
end
g.druid:update(dt)
if g.rehover then
-- After a rebuild: what is under the mouse shows its hover at once (the new
@@ -505,6 +718,9 @@ function M.update(g, dt)
g.autoplay:update(dt)
end
test.update(g, function(id, a) return input(g, id, a) end, TEST_API)
if t0 then
test.frame(dt, test.clock() - t0, built)
end
end
function M.on_message(g, message_id, message, sender)
@@ -528,7 +744,7 @@ function M.on_reload(g)
theme.set(theme.name) -- (the page's clear color)
ensure(g)
g.h = hooks.table()
g.dirty = true
g.dirty, g.dirty_all = true, true
end
return M
+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
end
-- A batch shown as finished: ready in the state or its end passed on our clock.
function M.batch_done(g, j)
return j.ready or crossed(j, session.now(g))
end
-- Seconds since batch j finished (0 when it just did and the watch below hasn't seen it
-- yet), or nil when it isn't finished or already was when first seen: the effects that
-- follow a batch's end through rebuilds go by it.
function M.batch_age(g, j)
if not g.batches_done or not M.batch_done(g, j) then
return nil
end
local t = g.batches_done[j.id]
if t == nil then
return 0
end
return t > 0 and socket.gettime() - t or nil
end
-- Finished refinery batches (the Refinery tab's badge).
function M.batches(g)
local now = session.now(g)
@@ -92,7 +111,37 @@ function M.watch(g)
g.ready_due = M.next_due(g)
end
-- Batches that finished since the last look (ready in the state, or their end passed on
-- our clock), whichever section is open: each one's moment goes in g.batches_done[id]
-- (seconds; 0 for those already finished when first seen, which get no effect), for
-- effects that carry on through rebuilds (refinery.lua's slot, header.lua's badge), and
-- fx:batch_ready plays once for them (sound, the slot or the Refinery tab).
local function watch_batches(g)
local st = g.state
if not st or not st.jobs then
return
end
local now, t = session.now(g), socket.gettime()
local seen, out, fresh = g.batches_done, {}, nil
for _, j in ipairs(st.jobs) do
if j.id and (j.ready or crossed(j, now)) then
out[j.id] = seen and (seen[j.id] or t) or 0
if seen and not seen[j.id] then
fresh = fresh or {}
fresh[#fresh + 1] = j
end
end
end
g.batches_done = out
if fresh then
g.batch_ready_t = t
g.dirty = true
g.fx:batch_ready(g, fresh)
end
end
function M.update(g)
watch_batches(g)
if g.ready_due and g.state and session.now(g) >= g.ready_due then
g.ready_due = nil
g.dirty = true
+94 -22
View File
@@ -14,6 +14,8 @@ local api = require("modules.api")
local predict = require("modules.predict")
local sfx = require("modules.sfx")
local refinery = require("screens.game.refinery")
local sidepanel = require("screens.game.sidepanel")
local blocks = require("modules.blocks")
local M = {}
@@ -61,8 +63,20 @@ end
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like
-- state.view). page only for the Past list past its first page (the web's data-page).
function M.view_params(v, extra)
local p = { s = v.section ~= "" and v.section or nil }
-- g: the blocks the right side panel shows are asked for too (sidepanel.includes), on
-- every request while it shows, so they never drop out of the state.
function M.view_params(v, extra, g)
-- (the leaderboard is a side panel, or a pane in the market when that is folded away:
-- an old link to it opens the market, which asks for its block)
local section = v.section == "leaderboard" and "market" or v.section
local p = { s = section ~= "" and section or nil }
-- (new-moon always: "+ New moon" in the moon list shows each grade's price)
p.include = section == "market" and "leaderboard,new-moon" or "new-moon"
for _, x in ipairs(g and sidepanel.includes(g) or {}) do
if not (","..p.include..","):find(","..x..",", 1, true) then
p.include = p.include .. "," .. x
end
end
if v.moon and v.moon ~= "" then p.moon = v.moon end
if v.well and v.well ~= "" then p.well = v.well end
if v.past then p.list = "past" end
@@ -71,7 +85,11 @@ function M.view_params(v, extra)
if v.drill and v.drill ~= "" then p.drill = v.drill end
if v.adv and v.adv ~= "" then p.adv = v.adv end
for k, x in pairs(extra or {}) do
p[k] = x
if k == "include" and not (","..p.include..","):find(","..x..",", 1, true) then
p.include = p.include .. "," .. x
elseif k ~= "include" then
p[k] = x
end
end
return p
end
@@ -111,6 +129,9 @@ local function suspend(g)
g.refetch = nil
end
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
if g.history then
g.history.want = nil -- (its request was in the outbox)
end
g.boot = { text = SUSPENDED }
if g.dialog and g.dialog:is_open() then
g.dialog:close("cancel")
@@ -118,24 +139,49 @@ local function suspend(g)
g.dirty = true
end
-- Refetch when the next drill or refinery batch is due (the server decides what is ready).
-- Refetch when the next drill or refinery batch is due (the server decides what is ready),
-- or a banner item ends (the server's state no longer has it).
local function arm_refetch(g)
if g.refetch then
timer.cancel(g.refetch)
g.refetch = nil
end
local due = g.state.nextEnd
local batch = refinery.next_end(g.state)
if batch and (not due or batch < due) then
due = batch
-- (a time already past is skipped: the state we have is after it)
local now = M.now(g)
local due
local function consider(t)
if t and t > 0 and t - now >= -2000 and (not due or t < due) then
due = t
end
end
consider(g.state.nextEnd)
consider(refinery.next_end(g.state))
for _, b in ipairs(g.state.banner or {}) do
consider(b["end"])
end
if not due then
return
end
local left = due - M.now(g)
if left < -2000 then
return -- already past: the state we have is after it
-- Ends within a second after it wait for the last of them: one refetch and rebuild,
-- not one each (four batches started together end a few ms apart).
local first = due
local function join(t)
if t and t > due and t <= first + 1000 then
due = t
end
end
join(g.state.nextEnd)
for _, j in ipairs(g.state.jobs or {}) do
if not j.ready then
join(j["end"])
end
end
for _, s in ipairs(g.state.stakes or {}) do
if not s.ready then
join(s["end"])
end
end
local left = due - now
g.refetch = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
g.refetch = nil
-- (a navigation still on its way: its view, or this answer would take it back)
@@ -145,7 +191,8 @@ end
-- What we show: the last server state (g.base) with the pending predictions on top.
local function show(g)
g.state = predict.apply(g.base, g.pending)
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
g.state = blocks.freeze(predict.apply(g.base, g.pending))
g.view = g.state.view
arm_refetch(g)
g.dirty = true
@@ -173,7 +220,9 @@ end
-- The views shown, like the web's browser history (main.js syncURL: every response whose
-- view differs from the current entry pushes one; back and forward fetch an entry again
-- without pushing). g.history = { list = { view, ... }, i = current }.
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
-- to requests sent before it (an action, a refetch) push nothing.
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
local function view_key(v)
@@ -200,7 +249,14 @@ local function remember(g, from_history)
if from_history then
if h.list[from_history] then
h.list[from_history] = v
h.i = from_history
end
if h.want == from_history then
h.want = nil
end
return
end
if h.want then
return
end
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
@@ -248,6 +304,10 @@ pump = function(g)
elseif g.base then
show(g) -- rolled back
end
-- (a back/forward that failed: the history stays where it was)
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
history(g).want = nil
end
if status == 403 then
suspend(g)
g.inflight, g.busy, g.nav_view = {}, false, nil
@@ -265,17 +325,19 @@ pump = function(g)
-- the click; its answer only corrects the numbers (or rolls them back, with the
-- error's).
-- A new game is a fresh start (the web reloads the page): no toast, no wallet
-- differences, the page enters again.
local fresh = applied and r.kind == "new-game" and data.ok
-- differences, the page enters again. So is a restore from a save code (another
-- player: api.lua stored its id from the response), with its toast.
local fresh = applied and (r.kind == "new-game" or r.kind == "restore") and data.ok
if fresh then
g.fx.counters = {}
g.batches_done = nil -- (another player's batches: no ready effects for them)
g.fx:on_response(g, "get", data, nil, g.state, {})
g.enter_all = true
elseif g.state and g.state ~= prev then
local d = type(data) == "table" and data or { toast = { error = true } }
g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
end
if revealing or fresh then
if revealing or (fresh and r.kind == "new-game") then
-- (toast deferred, or none)
elseif type(data) == "table" and type(data.toast) == "table" then
local color = g.fx:take_claim_color()
@@ -287,6 +349,11 @@ pump = function(g)
sfx.play("error") -- (a rolled-back prediction had fx's error effects)
end
end
-- A one-time announcement besides the toast: where the save code is (the server's
-- words name no menu).
if applied and type(data.notice) == "table" and data.notice.kind == "save-code" then
g.toasts:push("Your progress is kept on this device only. Make a save code in the settings (the gear, top right) to play it on another device.", "save-code", false)
end
-- Toast a failing server once, not every retry (M.fetch tries again).
g.offline = status == 0 or status >= 500
for k, v in pairs(g.inflight) do
@@ -314,7 +381,7 @@ end
-- 5xx) it tries again: at start as g.boot says (and a 429 too), in play every 3 s,
-- doubling up to 30 s, until it answers; the shown state stays meanwhile.
function M.fetch(g, view, nav)
request(g, "get", M.view_params(view or {}), function(err, data, status)
request(g, "get", M.view_params(view or {}, nil, g), function(err, data, status)
if g.retry then
timer.cancel(g.retry)
g.retry = nil
@@ -354,6 +421,10 @@ function M.navigate(g, v)
sfx.play("nav")
end
g.nav_view = v
g.ask = nil -- (a sell asking in place: cards.ask_button)
if g.admin then
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
end
M.fetch(g, v, v)
end
@@ -361,12 +432,13 @@ end
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
function M.go(g, dir)
local h = history(g)
local v = h.list[h.i + dir]
local i = (h.want or h.i) + dir
local v = h.list[i]
if not v then
return false
end
h.i = h.i + dir
local to = { from_history = h.i }
h.want = i
local to = { from_history = i }
for k, x in pairs(v) do
to[k] = x
end
@@ -380,7 +452,7 @@ end
function M.act(g, name, params, on_done, fn)
g.seq = g.seq + 1
local seq = g.seq
local p = M.view_params(g.nav_view or g.view, params)
local p = M.view_params(g.nav_view or g.view, params, g)
p.clientSeq = seq
fn = fn or predict.registry[name]
local predicted = false
@@ -390,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
if predict.run(st, e) then
g.pending[#g.pending + 1] = e
local prev = g.state
g.state = st
g.state = blocks.freeze(st)
g.dirty = true
predicted = true
-- The action's effects play now, from the predicted change (fx.lua).
+5 -4
View File
@@ -5,6 +5,7 @@
local ui = require("modules.ui")
local session = require("screens.game.core.session")
local blocks = require("modules.blocks")
local M = {}
@@ -20,7 +21,7 @@ function M.tick_text(g, node, ends, fmt, done, key, on_done)
if key then
g.tween:track(key, ends - now(g), ends)
end
g.tickers[#g.tickers + 1] = { kind = "time", node = node, ends = ends, fmt = fmt, done = done, key = key, on_done = on_done }
blocks.push(g, "tickers", { kind = "time", node = node, ends = ends, fmt = fmt, done = done, key = key, on_done = on_done })
end
local function bar_fill(t, start, ends)
@@ -33,7 +34,7 @@ function M.tick_bar(g, node, start, ends, w, key, on_full)
if key then
g.tween:track(key, bar_fill(now(g), start, ends), start .. ":" .. ends)
end
g.tickers[#g.tickers + 1] = { kind = "bar", node = node, start = start, ends = ends, w = w, key = key, on_done = on_full }
blocks.push(g, "tickers", { kind = "bar", node = node, start = start, ends = ends, w = w, key = key, on_done = on_full })
end
-- A number text node showing fmt(value), easing from the shown value when it changes.
@@ -41,7 +42,7 @@ end
function M.tick_num(g, node, key, value, fmt)
g.tween:track(key, value)
local k = { kind = "num", node = node, key = key, value = value, fmt = fmt or ui.money }
g.tickers[#g.tickers + 1] = k
blocks.push(g, "tickers", k)
local v = g.tween:value(key, value)
if v ~= value and value == math.floor(value) then
v = math.floor(v + 0.5) -- counts tick as whole numbers
@@ -55,7 +56,7 @@ function M.run(g)
local t = now(g)
local tw = g.tween
for _, k in ipairs(g.tickers) do
if k.dead then
if k.dead or not pcall(gui.get_id, k.node) then
-- node gone (deleted outside a rebuild)
elseif k.kind == "time" then
local left = k.ends - t
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
-- Quick grant (admins only, state.admin): give yourself credits, a resource or a refined
-- item (server action admin-grant). Opened by the header's grant button or the G key
-- (core/keys). An item picker and an amount picker (buttons, the pick ringed), then Grant.
-- Test ids: "dialog:grant:<item>", "dialog:amount:<n>", "dialog:ok".
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local S = theme.size
local M = {}
local AMOUNTS = { { 100, "100" }, { 1000, "1K" }, { 10000, "10K" }, { 100000, "100K" }, { 1000000, "1M" } }
local W = 520
-- The choices: credits, then the catalog's resources and refined items.
local function items(st)
local out = { { "credits", "Credits" } }
local cat = st.catalog or {}
for _, list in ipairs({ cat.resources or {}, cat.refined or {} }) do
for _, r in ipairs(list) do
table.insert(out, { r.name, r.name })
end
end
return out
end
-- A wrapping row of small buttons (choices: { value, label }) at (x, y), inner width w.
local function picker(parent, choices, x, y, w)
local kids = {}
for i, c in ipairs(choices) do
kids[i] = { style = { width = fg.button_w(parent, c[2], { small = true }), height = S.button_sm_h, shrink = 0 } }
end
local tree = flex.box({ wrap = true, gap = 8, width = w }, kids)
flex.solve(tree, x, y)
return tree
end
function M.open(g)
local st = g.state
if not (st and st.admin) or g.dialog:is_open() then
return
end
local choices, amounts = items(st), {}
for i, a in ipairs(AMOUNTS) do
amounts[i] = { a[1], a[2] }
end
-- (laid out first: the dialog's height comes from it)
local x, y, iw = 24, 64, W - 48
local item_tree = picker(g.dialog.layer, choices, x, y + 22, iw)
local ay = y + 22 + item_tree.h + 20
local amount_tree = picker(g.dialog.layer, amounts, x, ay + 22, iw)
local h = ay + 22 + amount_tree.h + 24 + S.button_h + 24
local params = { grant = "credits", amount = "1000" }
g.dialog:open({
title = "Grant",
w = W,
h = h,
ok = { label = "Grant", act = "admin-grant", params = params },
build = function(d, box)
local rings = {}
local function pick(kind, tree, list, key, value_of)
for i, c in ipairs(list) do
local b = tree.children[i]
local function ring()
if rings[kind] then
gui.delete_node(rings[kind])
end
rings[kind] = ui.outline(box, b.x - 3, b.y - 3, b.w + 6, b.h + 6, "cyan")
end
d:button(box, c[2], b.x, b.y, { small = true, w = b.w, id = "dialog:" .. kind .. ":" .. tostring(c[1]),
on_click = function()
params[key] = value_of(c[1])
ring()
end })
if value_of(c[1]) == params[key] then
ring()
end
end
end
ui.text(box, "Item", "cap", x, y + 6)
pick("grant", item_tree, choices, "grant", tostring)
ui.text(box, "Amount", "cap", x, ay + 6)
pick("amount", amount_tree, amounts, "amount", function(n) return string.format("%d", n) end)
end,
})
end
return M
+194 -146
View File
@@ -18,7 +18,12 @@ local S = theme.size
local M = {}
-- Header sections in the web's order; each shows once state.unlocked has it.
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
-- (the leaderboard is the right side panel, sidepanel.lua; How to play is in the settings menu)
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors" }
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
local function vec(x, y)
return vmath.vector3(x, y, 0)
@@ -33,8 +38,47 @@ local function contains(list, value)
return false
end
-- The wallet at the right end; returns the x where it starts.
local function wallet(g, parent, W)
-- The Refinery tab's badge pops in (fx-ready-pop, 0.6 s) when a batch has just finished
-- (core/ready), by the clock, so the refetch's rebuild carries on. Reduced motion: none.
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.25 }, { 1, 1 } }
local function pop_badge(g, node, w)
local age
for _, j in ipairs(g.state.jobs or {}) do
local a = ready.batch_age(g, j)
if a and (not age or a < age) then
age = a
end
end
if anim.reduced or not age or age >= 0.6 then
return
end
local p, base = gui.get_position(node), gui.get_scale(node)
anim.tween(node, 0.6, function(k, n)
anim.scale_about(n, p, base, w / 2, -7.5, anim.keys(POP_SCALE, k, anim.fn.ready_pop)) -- (about its center: 15 tall)
end, -age)
end
local GEAR = 32
local CLAIM_W = 200 -- Claim all's place in the header (its widest label and badge)
local CHIP_GAP = 18
-- The resource chips' widths (probes: nodes made and dropped), for the layout.
local function chip_widths(g, parent)
local out = {}
for i, r in ipairs(g.state.resources) do
local probe = ui.text(parent, ui.money(r.amount), "num", 0, 0)
local tw = ui.text_width(probe)
gui.set_text(probe, g.fx:text(r.name, r.amount))
out[i] = 14 + math.max(tw, ui.text_width(probe))
gui.delete_node(probe)
end
return out
end
-- The wallet at the right end: the resource chips (unless chips is false: then they go on
-- a line under the balance, see M.build), the credit balance, the settings gear. Returns
-- the x where it starts.
local function wallet(g, parent, W, chips_w, chips_below)
local st = g.state
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they
-- count up and down as effects land) and are sized for the wider of what they show now
@@ -45,194 +89,198 @@ local function wallet(g, parent, W)
gui.set_text(probe, ui.money(st.credits))
num_w = math.max(num_w, ui.text_width(probe))
gui.delete_node(probe)
-- Built right to left (the order of focus and input), laid out as a row (modules/flex)
-- that ends at the header's padding, everything on the balance's middle.
local row = {}
local function push(node, w, h, gap_after)
if gap_after then
table.insert(row, 1, { style = { width = gap_after } })
end
table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } })
end
-- Settings (screens/game/settings.lua): a menu under the gear.
local gear = ui.outline(parent, 0, 0, GEAR, GEAR, "line_hi")
push(gear, GEAR, GEAR)
local gicon = ui.rect(gear, 6, 6, 20, 20, "nav")
gui.play_flipbook(gicon, "gear")
test.register("settings", gear, "button", "settings")
tooltip.add(gear, "Settings")
g.druid:new_button(gear, function()
local top = -gui.get_position(g.header_node).y + 16 + 36 / 2 + GEAR / 2 + 6
require("screens.game.settings").open(g, S.header_pad_x, top)
end):set_animations_disabled()
local bal_w = math.floor(num_w + 16 + 32 + 4)
local bal_x = W - S.header_pad_x - bal_w
local _, bal = ui.panel(parent, bal_x, 16, bal_w, 36)
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36)
push(bal_panel, bal_w, 36, 12)
local bal_root = ui.part(bal, "panel", "root")
ui.text(bal_root, theme.currency, "sym", 16, 18)
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
local cx = bal_x - 22
-- Chips: in the row before the balance, or a row of their own under it (chips_below).
local chip_row = chips_below and {} or row
local gap = chips_below and nil or 22
for i = #st.resources, 1, -1 do
local r = st.resources[i]
local root, nodes = ui.clone("chip", parent, 0, 0)
local t = ui.part(nodes, "chip", "text")
ui.style_text(t, "num", ui.money(r.amount))
local tw = ui.text_width(t)
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
local w = 14 + math.max(tw, ui.text_width(t))
cx = cx - w
gui.set_position(root, vec(cx, -24))
local w = chips_w[i]
gui.set_size(root, vec(w, 20))
if gap then
table.insert(chip_row, 1, { style = { width = gap } })
end
table.insert(chip_row, 1, { node = root, size = false, style = { width = w, height = 20 } })
gap = CHIP_GAP
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
g.fx:bind(r.name, t, root)
tooltip.add(root, r.name)
cx = cx - 18
end
-- Sound on/off (saved).
local mute = ui.outline(parent, cx - 28 + 14, 20, 28, 28, "line_hi")
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 = true
end):set_animations_disabled()
-- Light / dark theme (saved); the icon shows the theme it switches to, like the web.
local tx = cx - 14 - 28 - 8
local tgl = ui.outline(parent, tx, 20, 28, 28, "line_hi")
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 = 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 mx = tx - 28 - 8
local mot = ui.outline(parent, mx, 20, 28, 28, "line_hi")
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)
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
flex.apply(tree)
if chips_below then
flex.apply(flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = chip_row }, 0, 16 + 36 + 8, W, 20))
end
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 = true
end):set_animations_disabled()
-- Display settings (UI scale, fullscreen: screens/game/settings.lua); a screen icon.
local dx = mx - 28 - 8
local disp = ui.outline(parent, dx, 20, 28, 28, "line_hi")
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()
return dx - 10
return row[1].x - 10
end
-- Builds the header across parent (W wide); returns its height.
-- Builds the header across parent (W wide); returns its height. One tree: the brand,
-- the tabs and Claim all after it (on a second row when they don't fit before the
-- wallet), the edge line under it. Each tab is a template clone in a slot (its reveal
-- moves and scales it).
function M.build(g, parent, x, y, W, h, H)
local st = g.state
x = S.header_pad_x
local brand = ui.text(parent, "Moon Miners", "brand", x, 24)
local cap = ui.text(parent, "Extraction command", "brand_cap", x, 44)
local brand_w = math.max(ui.text_width(brand), ui.text_width(cap))
local wallet_x = wallet(g, parent, W)
local pad = S.header_pad_x
local brand = fg.label(parent, "Moon Miners", "brand", 48)
local cap = fg.label(parent, "Extraction command", "brand_cap", 16, { position = "absolute", top = 36, left = 0 })
local brand_w = math.max(brand.measure(), cap.measure())
-- Sections (tabs), after the brand, as they unlock (none until Equipment, like the
-- web); on a second row when they don't fit.
local tabs = {}
local nav_w = 0
-- Sections (tabs), as they unlock (none until Equipment, like the web).
local section = g.view.section
local ink = tabs_c.ink(g, parent, "nav") -- under the tabs (made first)
local active_tab = false
local names, tabs = {}, {}
local nav_w = 0
if st.unlocked.equipment then
for _, name in ipairs(M.SECTIONS) do
if st.unlocked[name] then
local root, nodes = ui.clone("tab", parent, 0, 0)
local t = ui.part(nodes, "tab", "text")
ui.style_text(t, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, name)
local w = math.floor(ui.text_width(t) + 24)
local label = fg.label(parent, name, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, 44)
local w = math.floor(label.measure() + 24)
gui.delete_node(label.node)
-- (counts move on as drills and batches finish on our clock: core/ready)
local count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
local badge = count > 0 and count or nil
if badge then
w = w + 26
end
tabs[#tabs + 1] = { name = name, root = root, nodes = nodes, w = w, badge = badge }
-- The count is a small badge on the tab's top-right corner: it takes no width,
-- so the tabs don't move when it comes or goes (clicker rule).
names[#names + 1] = name
nav_w = nav_w + w + 2
tabs[#tabs + 1] = { style = { width = w, height = 44, shrink = 0 }, make = function(p, tx, ty)
local slot = ui.rect(p, tx, ty, w, 44, "none")
local root, nodes = ui.clone("tab", slot, 0, 0)
local t = ui.part(nodes, "tab", "text")
local u = ui.part(nodes, "tab", "underline")
ui.style_text(t, { font = "sans_semi", px = 14, color = "nav", tracking = 0.1, upper = true }, name)
gui.set_size(root, vec(w, 44))
gui.set_enabled(u, false) -- the ink draws the active tab's underline
local active = name == section
local shine = contains(st.shine, name)
gui.set_position(t, vec(w / 2, -22))
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
if active then
ink:to(tx, ty, w, 44)
active_tab = true
end
if shine then
-- Revealed, not opened yet: a lit tab held in a targeting reticle (fx.lua).
gui.set_color(root, ui.color("cyan", 0.1))
g.fx:reticle(root, w, 44, name)
end
if badge then
local cb, cw = chip.new(root, badge, w - 1, 9, { right = true, color = "ready", text = "bg", fill = 1,
font = "mono_semi", px = 10, h = 15, min_w = 15, pad = 3 })
if name == "refinery" then
pop_badge(g, cb, cw)
end
end
tabs_c.tab(g, root, t, {
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. name, label = name,
on_click = function()
-- Like the web's tabs: only the moon carries over (not the well).
H.navigate(g, { section = name, moon = g.view.moon })
end,
})
g.tab_nodes[name] = root
tabs_c.reveal(g, "nav", name, root, t, w, 44)
end }
end
end
end
local n_ready = ready.drills(g)
local claim_all = st.unlocked["claim-all"] and (n_ready > 0 or st.nextEnd)
if claim_all then
nav_w = nav_w + 170
end
local nav_x = x + brand_w + 28
local nav_y = 10
local header_h = S.header_h
if nav_x + nav_w > wallet_x then
nav_x = S.header_pad_x - 12
nav_y = header_h - 8
header_h = header_h + 40
end
local section = g.view.section
local active_tab = false
local names = {}
for i, tab in ipairs(tabs) do
names[i] = tab.name
end
tabs_c.revealed(g, "nav", names)
-- "+ New moon" (moonlist.lua) enters too when revealed after load; tracked here, where
-- 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 {})
for _, tab in ipairs(tabs) do
local t = ui.part(tab.nodes, "tab", "text")
local u = ui.part(tab.nodes, "tab", "underline")
gui.set_position(tab.root, vec(nav_x, -nav_y))
gui.set_size(tab.root, vec(tab.w, 44))
gui.set_enabled(u, false) -- the ink draws the active tab's underline
local active = tab.name == section
local shine = contains(st.shine, tab.name)
gui.set_position(t, vec(tab.badge and (tab.w - 26) / 2 or tab.w / 2, -22))
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
if active then
ink:to(nav_x, nav_y, tab.w, 44)
active_tab = true
end
if shine then
-- Revealed, not opened yet: a lit tab held in a targeting reticle (fx.lua).
gui.set_color(tab.root, ui.color("cyan", 0.1))
g.fx:reticle(tab.root, tab.w, 44, tab.name)
end
if tab.badge then
chip.new(tab.root, tab.badge, tab.w - 34, 22, { color = "ready", font = "mono", px = 12, min_w = 22, pad = 0 })
end
tabs_c.tab(g, tab.root, t, {
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. tab.name, label = tab.name,
on_click = function()
-- Like the web's tabs: only the moon carries over (not the well).
H.navigate(g, { section = tab.name, moon = g.view.moon })
end,
})
if tab.name == "help" then
tooltip.add(tab.root, "How to play")
end
g.tab_nodes[tab.name] = tab.root
tabs_c.reveal(g, "nav", tab.name, tab.root, t, tab.w, 44)
nav_x = nav_x + tab.w + 2
end
if not active_tab then
ink:none()
end
-- Claim all: every finished drill; waits with a countdown while none is ready.
if claim_all then
-- 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 reserve = st.unlocked["claim-all"]
local claim_all = reserve and (n_ready > 0 or st.nextEnd)
if reserve then
nav_w = nav_w + 10 + CLAIM_W
end
local claim = reserve and { style = { width = CLAIM_W, height = 44, shrink = 0 }, make = claim_all and function(p, cx, cy)
if st.ready > 0 then
local b, nodes = ui.button(parent, "Claim all", nav_x + 8, nav_y + 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.action(g, b, nodes, "claim", {})
else
H.claim_wait(g, parent, nav_x + 8, nav_y + 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 })
end
end or nil } or nil
local nav = row({ align = "start" }, { row({ gap = 2 }, tabs), claim and space(10) or nil, claim })
-- One row: brand, tabs and Claim all's place, the wallet. When the wallet doesn't fit
-- with its chips in the row, the chips go on a line under the balance (the header grows
-- by it); then, if still short, the brand goes. Last resort (a very high UI scale):
-- the tabs on a row of their own. None of it depends on Claim all being shown.
local chips_w = chip_widths(g, parent)
local chips_total = 22
for _, cw in ipairs(chips_w) do
chips_total = chips_total + cw + CHIP_GAP
end
local bal_probe = ui.text(parent, ui.money(math.max(st.credits, 0)), "balance", 0, 0)
local wallet_min = math.floor(ui.text_width(bal_probe) + 52) + 12 + GEAR + pad + 10
gui.delete_node(bal_probe)
local left = pad + brand_w + 28 + nav_w
local chips_below = left + chips_total + wallet_min > W
local fits = left + wallet_min <= W
local show_brand = fits or pad + nav_w + wallet_min > W -- (two rows: the brand stays)
fits = fits or not show_brand
if not show_brand then
gui.delete_node(brand.node)
gui.delete_node(cap.node)
end
wallet(g, parent, W, chips_w, chips_below)
local base_h = chips_below and S.header_h + 16 or S.header_h
local header_h = fits and base_h or base_h + 40
local tree = col({ width = W, height = header_h }, {
row({ height = fits and header_h or base_h - 8, padding_left = pad, align = "start" }, {
show_brand and box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }) or nil,
fits and show_brand and space(28) or nil,
fits and col({ padding_top = 10 }, { nav }) or nil,
}),
not fits and row({ padding_left = pad - 12 }, { nav }) or nil,
made({ position = "absolute", left = 0, right = 0, bottom = 0, height = 1 }, function(p, lx, ly, lw)
ui.rect(p, lx, ly, lw, 1, "edge")
end),
})
fg.build(flex.solve(tree, 0, 0), parent)
if not active_tab then
ink:none()
end
ready.watch(g)
ui.rect(parent, 0, header_h - 1, W, 1, "edge")
return header_h
end
+25 -14
View File
@@ -4,6 +4,8 @@
local theme = require("modules.theme")
local ui = require("modules.ui")
local cards = require("screens.game.cards")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
@@ -13,23 +15,32 @@ function M.build(self, root, px, py, w, h, H)
local avail = w - px * 2
local inner = math.min(avail, S.text_max)
local x = px + math.floor((avail - inner) / 2)
local cy = H.heading(root, x, py, "How to play") + 8
local style = { font = "sans", px = 16, color = "text_2" }
-- Steps: the number right-aligned at 22, the text from 32.
local steps = {}
for i, step in ipairs(hp.steps) do
ui.text(root, i .. ".", { font = "mono", px = 14, color = "cyan" }, x + 22, cy + 11, gui.PIVOT_E)
cy = cy + H.para(root, step, style, x + 32, cy, inner - 32) + 12
steps[#steps + 1] = fg.row({ align = "start", width = inner }, {
-- (out of the flow: the row is as tall as its text)
fg.label(root, i .. ".", { font = "mono", px = 14, color = "cyan" }, 22, { position = "absolute", top = 0, right = inner - 22 }),
fg.space(32),
fg.text_para(root, step, style, { width = inner - 32 }),
})
end
cy = cy + 6
if hp.note and hp.note ~= "" then
cy = cy + H.para(root, hp.note, { font = "sans", px = 14, color = "muted" }, x, cy, inner) + 18
end
-- Start over: asks with the server's question (help.newGame, the web's confirm).
local b = ui.button(root, "Start a new game", x, cy)
cards.confirm_server_button(self, b, hp.newGame or {
title = "Start over", ok = "Start over", danger = true,
text = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it.",
}, "new-game", {})
local note = hp.note and hp.note ~= ""
local tree = fg.col({ width = inner, align = "start" }, {
fg.label(root, "How to play", "h1", 40), fg.space(0, 12),
fg.col({ gap = 12, align = "stretch" }, steps), fg.space(0, 18),
note and fg.text_para(root, hp.note, { font = "sans", px = 14, color = "muted" }) or nil,
note and fg.space(0, 18) or nil,
-- Start over: asks with the server's question (help.newGame, the web's confirm).
fg.button(root, "Start a new game", {}, function(b)
cards.confirm_server_button(self, b, hp.newGame or {
title = "Start over", ok = "Start over", danger = true,
text = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it.",
}, "new-game", {})
end),
})
fg.build(flex.solve(tree, x, py), root)
end
return M
+78 -39
View File
@@ -1,70 +1,109 @@
-- The Leaderboard section (state.leaderboard): the top miners by credits, and the
-- player's own place under them when they're further down (`you`). Centered, capped; the
-- rows scroll when the panel is too short for them (components/list).
-- The leaderboard (state.leaderboard): the top miners by credits in compact rows, and the
-- player's own row under them when they're further down (`you`). The rows scroll when the
-- room is too short (components/list). Shown in the right side panel (sidepanel.lua; this
-- module is one of its panels), or, when that panel is folded away (narrow windows), as a
-- pane under the Market (market.lua, M.pane).
local theme = require("modules.theme")
local ui = require("modules.ui")
local list = require("components.list")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
local ROW_H = 38
local ROW_H = 30
local PAD = 12
local HEAD_H = 52 -- title and "Your place" line
-- The side panel's contract (sidepanel.lua).
M.key = "leaderboard"
M.include = "leaderboard" -- the state block it shows (asked for while the panel shows)
function M.unlocked(g)
local u = g.state and g.state.unlocked
return u ~= nil and u.leaderboard == true
end
function M.sig(g)
return g.state.leaderboard or false
end
-- A row (ROW_H tall, w wide) at (x, y): rank, the name, credits at the right; the
-- player's own row lit.
local function row(root, r, x, y, w)
local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
if r.me then
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
end
ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
ui.text(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, 52, ROW_H / 2, gui.PIVOT_E)
local name = ui.text(bg, r.name, { font = "sans", px = 15, color = "text" }, 76, ROW_H / 2)
ui.fit_text(name, w - 76 - 180)
if r.me then
ui.text(bg, "You", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 76 + ui.text_width(name) + 10, ROW_H / 2)
end
ui.credits(bg, r.credits, w - 14, ROW_H / 2, { pivot = gui.PIVOT_E, px = 14 })
fg.build(flex.solve(fg.row({ width = w, height = ROW_H, padding_right = PAD, align = "center" }, {
fg.row({ width = 30, justify = "end", shrink = 0 }, {
fg.label(bg, r.rank, { font = "mono", px = 12, color = r.rank <= 3 and "ready" or "muted" }, ROW_H),
}),
fg.space(10),
fg.label(bg, r.name, { font = "sans", px = 13, color = r.me and "cyan" or "text" }, ROW_H, { fit = true, shrink = 1, max_width = w - 52 - 90 }),
fg.fill(),
fg.space(8),
fg.inline(bg, ROW_H, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 12 })) end),
}), 0, 0), bg)
end
function M.build(self, root, px, py, w, h, H)
local lb = self.state.leaderboard
local avail = w - px * 2
local inner = math.min(avail, S.leaderboard_max)
local x = px + math.floor((avail - inner) / 2)
local cy = H.heading(root, x, py, "Leaderboard")
cy = cy + H.para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".",
{ font = "sans", px = 15, color = "text_2" }, x, cy - 6, inner) + 16
-- The height the leaderboard takes with every row shown.
local function natural(lb)
return HEAD_H + math.max(#lb.top, 1) * ROW_H + (lb.you and ROW_H * 2 or 0) + PAD
end
ui.text(root, "Rank", "cap", x + 52, cy + 6, gui.PIVOT_E)
ui.text(root, "Miner", "cap", x + 76, cy + 6)
ui.text(root, "Credits", "cap", x + inner - 14, cy + 6, gui.PIVOT_E)
cy = cy + 16
ui.rect(root, x, cy, inner, 1, "edge")
local bottom = h - py - (lb.you and ROW_H * 2 or 0) - (lb.me and 34 or 0)
local list_h = math.max(ROW_H, math.min(#lb.top * ROW_H, bottom - cy))
-- The leaderboard's content in p (a panel's root) w x h: title, place, the rows (they
-- scroll when h is short). Without the state block yet (it comes with the next state:
-- sidepanel.lua asks for it), the title and a placeholder.
local function body(g, p, w, h)
local lb = g.state.leaderboard
ui.text(p, "Top miners", "cap", PAD, 18)
if not lb then
ui.text(p, "Loading…", "small", PAD, 38)
return
end
local place = lb.me and ("Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players)) or (tostring(lb.players) .. " miners")
ui.text(p, place, "small", PAD, 38)
local foot = lb.you and ROW_H * 2 or 0
local ly = HEAD_H
local lh = math.max(ROW_H, math.min(h, natural(lb)) - HEAD_H - foot - PAD)
ui.rect(p, 0, ly - 1, w, 1, "edge")
if #lb.top > 0 then
local l
l = list.new(self, root, x, cy, inner, list_h, {
l = list.new(g, p, 0, ly, w, lh, {
key = "leaderboard", row_h = ROW_H,
create = function(r)
local holder = ui.rect(l.content, 0, 0, inner, ROW_H, "none")
row(holder, r, 0, 0, inner)
local holder = ui.rect(l.content, 0, 0, w, ROW_H, "none")
row(holder, r, 0, 0, w)
return holder
end,
})
l:set_data(lb.top)
end
cy = cy + list_h
if lb.you then
-- (the web's "⋯": no font has it; three middle dots look the same)
ui.text(root, "···", { font = "sans", px = 15, color = "muted" }, x + 52, cy + ROW_H / 2, gui.PIVOT_E)
cy = cy + ROW_H
row(root, lb.you, x, cy, inner)
cy = cy + ROW_H
end
if lb.me then
ui.text(root, "Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players), "small", x, cy + 22)
local fy = ly + lh
ui.text(p, "···", { font = "sans", px = 13, color = "muted" }, 30, fy + ROW_H / 2, gui.PIVOT_E)
row(p, lb.you, 0, fy + ROW_H, w)
end
end
-- As a side panel (sidepanel.lua): its content in root (the panel's), w x h.
function M.build(g, root, x, y, w, h)
local p = ui.rect(root, x, y, w, h, "none")
body(g, p, w, h)
end
-- As a pane of its own at (x, y), w wide, in its own frame (the Market, when the side
-- panel is folded away); h: the most it may take (rows scroll), or nil for every row.
-- Returns its height.
function M.pane(g, root, x, y, w, h)
local n = natural(g.state.leaderboard)
h = h and math.min(h, n) or n
local _, nodes = ui.panel(root, x, y, w, h)
body(g, ui.part(nodes, "panel", "root"), w, h)
return h
end
return M
+437 -303
View File
@@ -15,6 +15,10 @@ local chart = require("screens.game.chart")
local ticker = require("screens.game.ticker")
local sale = require("screens.game.sale")
local scroll = require("components.scroll")
local blocks = require("modules.blocks")
local leaderboard = require("screens.game.leaderboard")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
-- Windows this narrow (layout units) stack the detail and the sell ticket, and the
-- section scrolls (app.css @media max-width 1100px: .mk-grid one column).
@@ -22,6 +26,8 @@ local STACK_W = 1100
local M = {}
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
@@ -114,7 +120,8 @@ local function tint(node, color)
end
end
-- The server's words for what makes a price (each "" when it doesn't apply).
-- The server's words for what makes a price: the live event on the shared quote, the
-- advisors on the player's payout (each "" when it doesn't apply).
local function boost_text(q)
return q.boostText or mult(q.boost or 1)
end
@@ -129,109 +136,144 @@ local function quote_view(self, item)
return { section = "market", moon = self.view.moon, pick = item }
end
-- A quote card: name (or symbol when narrow), what you hold, price, change and sparkline.
local function card(self, H, root, x, y, w, h, q, on)
local st = self.state
local c = ui.rect(root, x, y, w, h, "cyan", on and 0.12 or 0.03)
-- (the edge token keeps its own alpha: an alpha here would replace it)
local edge = ui.outline(c, 0, 0, w, h, on and "cyan" or "edge")
local empty = q.balance <= 0
local col = color_of(st, q.item)
ui.rect(c, 10, 13, 8, 8, col)
-- A quote's 24h change as an inline box h tall.
local function change_box(c, q, h, opts)
return fg.inline(c, h, function(p, x, y) return change(p, q, x, y, opts) end)
end
-- A ui.credits amount as an inline box h tall.
local function credits_box(c, amount, opts, h)
return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
end
-- A quote card'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
-- The name (the symbol when narrow), the symbol and "Refined" under it when wide.
local name = ui.text(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 }, 24, 17)
local hold = ui.text(c, ui.money(q.balance), { font = "mono", px = 11, color = empty and "faint" or "text_2" }, w - 10, 17, gui.PIVOT_E)
ui.fit_text(name, w - 34 - ui.text_width(hold) - 6) -- (sized for the final number)
H.tick_num(self, hold, "mk:bal:" .. q.item, q.balance)
-- What it's worth (≈ ₥X) under the holding; "Refined" under the name when there's room.
local vw = ui.credits(c, q.value or 0, w - 10, 31, { px = 10, color = empty and "faint" or "muted", pivot = gui.PIVOT_E })
ui.text(c, "≈", { font = "sym", px = 10, color = empty and "faint" or "muted" }, w - 10 - vw - 3, 31, gui.PIVOT_E)
if wide and w - 34 - vw - 20 > 50 then
local sub = ui.text(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 24, 31)
ui.fit_text(sub, w - 34 - vw - 20)
elseif q.refined then
tooltip.add(name, q.item .. " · Refined")
else
tooltip.add(name, q.item)
local big = w >= 220 and 40 or w >= 160 and 34 or 30
return wide, big, 10 + 16 + 4 + big + 4 + 14 + 6 + 16 + 8
end
-- A quote card (w x h): name (or symbol when narrow) and what the holding is worth, the
-- amount held in large type (how much you own reads at a glance), price, change and
-- sparkline. A tree piece; the card's node takes the hover and the click.
local function card(self, H, c, w, h, q, on)
local st = self.state
local empty = q.balance <= 0
local wide, big = card_size(w)
local muted = empty and "faint" or "muted"
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
or { font = "mono_semi", px = 13, color = empty and "muted" or "text" }, 16, { fit = true, shrink = 1 })
name.done = function(n)
tooltip.add(n, (q.refined and (q.item .. " · Refined") or q.item)
.. (wide and "" or (" · worth ≈ " .. theme.currency .. ui.money(q.value or 0))))
end
-- Price and change on one line when the card is wide, else on two.
local py = 50
local pw = ui.credits(c, q.price, 10, py, { px = 13, color = "text" })
local cy = wide and py or py + 16
change(c, q, wide and (w - 10) or 10, cy, { pivot = wide and gui.PIVOT_E or nil, px = 11 })
if q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001) then
if wide then
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 10 + pw + 6, py)
else
ui.text(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, w - 10, cy, gui.PIVOT_E)
-- What it's worth (≈ ₥X), at the name's right on a wide card.
local value = wide and credits_box(c, q.value or 0, { px = 11, color = muted }, 16) or nil
-- The amount held: the card's big number (shrinks, then cuts, when it is very long).
local hold = fg.label(c, ui.money(q.balance), { font = "mono_semi", px = big, color = empty and "faint" or "text" }, big,
{ fit = true, shrink = 1, shrink_text = 0.6 })
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
local owned = w >= 170 and fg.label(c, "owned", { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 14,
{ shrink = 0 }) or nil
local boosted = q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001)
local boost = boosted and fg.label(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 14) or nil
local price = credits_box(c, q.price, { px = 13, color = "text_2" }, 14)
-- Price (and the change, on a wide card) under the amount.
local lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(),
wide and change_box(c, q, 14, { px = 11 }) or nil }) }
local spark_color = (q.change >= 0) and "up" or "danger"
local kids = {
row({ height = 16, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, color_of(st, q.item)) end),
space(6), name, space(6), fg.fill(),
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,
}),
space(0, 4),
row({ height = big, align = "end" }, { hold, owned and space(6) or nil,
owned and col({ padding_bottom = math.floor(big * 0.12), shrink = 0 }, { owned }) or nil }),
space(0, 4),
-- (kept while the series is the same: it moves on with the 5-minute tick)
made({ position = "absolute", left = 10, top = h - 24, width = w - 20, height = 16 }, function(p, x, y)
blocks.keep(self, "mk:spark:" .. q.item, blocks.sig(w, h, q.spark, spark_color), p, function(r)
chart.spark(r, x, y, w - 20, 16, q.spark, spark_color)
end)
end),
}
for _, l in ipairs(lines) do
kids[#kids + 1] = l
end
return col({ width = w, height = h, padding = { 10, 10, 0, 10 }, shrink = 0 }, kids, { make = function(p, x, y)
local cn = ui.rect(p, x, y, w, h, "cyan", on and 0.12 or 0.03)
-- (the edge token keeps its own alpha: an alpha here would replace it)
local edge = ui.outline(cn, 0, 0, w, h, on and "cyan" or "edge")
-- Hover (app.css .mk-card:hover): a brighter edge and fill; the selected card keeps its look.
local b = H.link(self, cn, quote_view(self, q.item))
if not on then
b.hover.on_mouse_hover:subscribe(function(_, over)
tint(cn, ui.color(over and "panel_hi" or "cyan", not over and 0.03 or nil))
tint(edge, ui.color(over and "line_hi" or "edge"))
end)
end
end
chart.spark(c, 10, h - 20, w - 20, 13, q.spark, (q.change >= 0) and "up" or "danger")
-- Hover (app.css .mk-card:hover): a brighter edge and fill; the selected card keeps its look.
local b = H.link(self, c, quote_view(self, q.item))
if not on then
b.hover.on_mouse_hover:subscribe(function(_, over)
tint(c, ui.color(over and "panel_hi" or "cyan", not over and 0.03 or nil))
tint(edge, ui.color(over and "line_hi" or "edge"))
end)
end
self.quote_nodes[q.item] = c
self.quote_edges[q.item] = edge
self.quote_nodes[q.item] = cn
self.quote_edges[q.item] = edge
return cn
end })
end
-- A group of cards ("Resources", "Refined items"), up to 5 per row. Returns the new y.
local function cards(self, H, root, x, y, inner, quotes, title)
ui.text(root, title, "cap", x, y + 6)
y = y + 18
local per = math.min(5, #quotes)
local gap = 8
-- 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 gap = 10
local per = math.min(#quotes, (inner - gap * 4) / 5 >= 112 and 5 or 3)
local w = math.floor((inner - gap * (per - 1)) / per)
local h = w >= 150 and 82 or 90
for i, q in ipairs(quotes) do
local col = (i - 1) % per
local row = math.floor((i - 1) / per)
card(self, H, root, x + col * (w + gap), y + row * (h + gap), w, h, q, q.item == self.state.market.selected)
local _, _, h = card_size(w)
local cols, kids = {}, {}
for i = 1, per do
cols[i] = w
end
return y + math.ceil(#quotes / per) * (h + gap) + 6
for _, q in ipairs(quotes) do
kids[#kids + 1] = card(self, H, c, w, h, q, q.item == self.state.market.selected)
end
return col({}, {
fg.label(c, title, "cap", 12), space(0, 6),
box({ columns = cols, gap = gap }, kids),
space(0, 14),
})
end
-- A stat in the heading row: caption over a value; fill(parent, x, y) draws the value
-- and returns its width. Placed right to left; returns the new right edge.
local function stat(root, right, y, label, fill)
local box = ui.rect(root, 0, y, 1, 1, "none")
local w = fill(box, 0, 26)
local cap = ui.text(box, label, "cap", 0, 6)
w = math.max(w, ui.text_width(cap))
gui.set_position(box, vec(right - w, -y))
return right - w - 28
-- A stat in the heading row: a caption over a value (a tree piece).
local function stat(c, label, value)
return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
end
function M.build(self, root, px, py, w, h, H, opts)
opts = opts or {}
-- The market itself in w (its content centered in it); full_w: the ticker's width (the
-- whole panel). Returns the bottom of what it built and the y under the ticker.
local function build_market(self, root, px, py, w, h, H, stacked, full_w)
local st = self.state
local mk = st.market
local stacked = self.size and self.size[1] <= STACK_W
local area
if stacked then
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
root = area.content
end
local c = root
local inner = math.min(w - px * 2, theme.size.market_max)
local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
self.quote_nodes, self.quote_edges = {}, {}
-- Ticker tape across the panel.
local cy = 14
cy = cy + ticker.build(self, root, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
H.navigate(self, quote_view(self, item))
end) + 8
-- Ticker tape across the panel (kept while the quotes are the same).
local tape, tick_bottom = {}, 14
for i, q in ipairs(mk.quotes) do
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
end
local function ticker_part(cy)
tick_bottom = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, full_w, tape), root, function(r)
return ticker.build(self, r, 14, cy, full_w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
H.navigate(self, quote_view(self, item))
end)
end) + 8
return tick_bottom
end
-- Heading, with the holdings stats on its right.
local top = cy
cy = H.heading(root, ox, cy, "Market") - 8
local right = ox + inner
-- Top mover and biggest drop: links to their quote; in the heading row when there's
-- room, else on a line of their own under it.
local movers = {}
@@ -242,52 +284,62 @@ function M.build(self, root, px, py, w, h, H, opts)
end
end
end
local function mover(b, x, y, q)
ui.rect(b, x, y - 4, 8, 8, color_of(st, q.item))
local n = ui.text(b, q.item, { font = "sans", px = 15, color = "text" }, x + 14, y)
return 14 + ui.text_width(n) + 6 + change(b, q, x + 14 + ui.text_width(n) + 6, y), n
end
-- A mover links to its quote; its name brightens under the mouse (.mk-stat-link:hover).
local function link(b, x, y, w, q, name)
local hit = ui.rect(b, x, y - 10, w, 20, "none")
local btn = H.link(self, hit, quote_view(self, q.item))
btn.hover.on_mouse_hover:subscribe(function(_, over)
tint(name, ui.color(over and "cyan_hi" or "text"))
end)
end
if inner >= 700 then
for _, mv in ipairs(movers) do
right = stat(root, right, top + 6, mv.label, function(b, x, y)
local mw, n = mover(b, x, y, mv.q)
link(b, x, y, mw, mv.q, n)
return mw
-- A mover: its dot, name and change; links to its quote, the name brightens under the
-- mouse (.mk-stat-link:hover).
local function mover(q)
local name = fg.label(c, q.item, { font = "sans", px = 15, color = "text" }, 20)
return row({ height = 20, align = "center", shrink = 0 }, {
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), change_box(c, q, 20),
}, { make = function(p, x, y, mw, mh)
local hit = ui.rect(p, x, y, mw, mh, "none")
local btn = H.link(self, hit, quote_view(self, q.item))
btn.hover.on_mouse_hover:subscribe(function(_, over)
tint(name.node, ui.color(over and "cyan_hi" or "text"))
end)
end
elseif #movers > 0 then
local mx = ox
for i = #movers, 1, -1 do
local mv = movers[i]
local cap = ui.text(root, mv.label:gsub(" 24h", ""), "cap", mx, cy + 18)
mx = mx + ui.text_width(cap) + 8
local mw, n = mover(root, mx, cy + 18, mv.q)
link(root, mx, cy + 18, mw, mv.q, n)
mx = mx + mw + 20
end
cy = cy + 26
return hit
end })
end
-- (placed right to left: the web's order is Holdings value, Assets held, Top mover,
-- Biggest drop)
right = stat(root, right, top + 6, "Assets held", function(b, x, y)
local t = ui.text(b, mk.held or 0, { font = "mono_semi", px = 18, color = "text" }, x, y)
local s = ui.text(b, " / " .. #mk.quotes, { font = "mono", px = 13, color = "muted" }, x + ui.text_width(t), y)
return ui.text_width(t) + ui.text_width(s)
end)
right = stat(root, right, top + 6, "Holdings value", function(b, x, y)
local cw, num = ui.credits(b, mk.total, x, y, { px = 18, color = "text", font = "mono_semi" })
H.tick_num(self, num, "mk:total", mk.total)
local held = row({ height = 20, align = "center" }, {
fg.label(c, tostring(mk.held or 0), { font = "mono_semi", px = 18, color = "text" }, 20),
fg.label(c, " / " .. #mk.quotes, { font = "mono", px = 13, color = "muted" }, 20),
})
local total = fg.inline(c, 20, function(p, x, y, probing)
local cw, num = ui.credits(p, mk.total, x, y, { px = 18, color = "text", font = "mono_semi" })
if not probing then
H.tick_num(self, num, "mk:total", mk.total)
end
return cw
end)
cy = cy + 6
-- (the web's order: Holdings value, Assets held, Top mover, Biggest drop)
local stats = { stat(c, "Holdings value", total), stat(c, "Assets held", held) }
local in_row = inner >= 700
if in_row then
for i = #movers, 1, -1 do
stats[#stats + 1] = stat(c, movers[i].label, mover(movers[i].q))
end
end
local below
if not in_row and #movers > 0 then
local kids = {}
for i = #movers, 1, -1 do
local mv = movers[i]
kids[#kids + 1] = fg.label(c, (mv.label:gsub(" 24h", "")), "cap", 20)
kids[#kids + 1] = space(8)
kids[#kids + 1] = mover(mv.q)
kids[#kids + 1] = space(20)
end
below = row({ height = 18, align = "start" }, kids)
end
local head = col({}, {
row({ height = 36, align = "start" }, {
fg.label(c, "Market", "h1", 40, { shrink = 0 }),
fg.fill(),
row({ column_gap = 28, padding_top = 6 }, stats),
}),
below and space(0, 8) or nil, below,
space(0, 6),
})
-- Quote cards.
local raw, refined = {}, {}
@@ -298,21 +350,21 @@ function M.build(self, root, px, py, w, h, H, opts)
raw[#raw + 1] = q
end
end
cy = cards(self, H, root, ox, cy, inner, raw, "Resources")
if st.unlocked.refinery and #refined > 0 then
cy = cards(self, H, root, ox, cy + 4, inner, refined, "Refined items")
end
local q
for _, x in ipairs(mk.quotes) do
if x.item == mk.selected then
q = x
end
end
local show_refined = st.unlocked.refinery and #refined > 0
local cy = fg.flow(c, ox, 14, inner, {
ticker_part,
head,
cards(self, H, c, inner, raw, "Resources"),
show_refined and col({}, { space(0, 4), cards(self, H, c, inner, refined, "Refined items") }) or nil,
})
if not q then
if area then
area:finish(cy + 18)
end
return
return cy, tick_bottom
end
cy = cy + 6
@@ -322,98 +374,111 @@ function M.build(self, root, px, py, w, h, H, opts)
local bh = math.max(250, math.min(h - cy - 18, theme.size.market_panel_max))
if stacked then
tw, dw, bh = inner, inner, 320
area:finish(cy + bh * 2 + 12 + 18)
end
local tight = bh < 300 -- short window (or a banner): a more compact ticket
local col = color_of(st, q.item)
local col_ = color_of(st, q.item)
local up = q.change >= 0
local _, dn = ui.panel(root, ox, cy, dw, bh)
local d = ui.part(dn, "panel", "root")
ui.rect(d, 14, 14, 12, 12, col)
local name = ui.text(d, q.item, "h2", 34, 20)
local nx = 34 + ui.text_width(name) + 10
---------------------------------------------------------------- detail
-- Name row: dot, name, Refined, "SYM / ₥" (when it fits); the live event's chip on
-- the name's row, or under the price when that's full.
local nm = fg.label(c, q.item, "h2", 16)
local nx = 34 + nm.measure() + 10
local refined_chip
if q.refined then
local _, rw = chip.new(d, "Refined", nx, 20, { color = "violet", alpha = 0.5, text = "violet_hi", upper = true })
nx = nx + rw + 8
local style = { color = "violet", alpha = 0.5, text = "violet_hi", upper = true }
refined_chip = fg.inline(c, 16, function(p, x, y)
local _, rw = chip.new(p, "Refined", x, y, style)
return rw
end)
nx = nx + refined_chip.style.width + 8
end
local sym = ui.text(d, (q.symbol or "") .. " / ", "cap", nx, 21)
local cur = ui.text(d, theme.currency, { font = "sym", px = 10, color = "muted" }, nx + ui.text_width(sym), 21)
if nx + ui.text_width(sym) + ui.text_width(cur) > dw - 14 then
gui.delete_node(sym) -- (no room: the symbol is on the card and the ticket)
gui.delete_node(cur)
local sym = fg.label(c, (q.symbol or "") .. " / ", "cap", 16)
local cur = fg.label(c, theme.currency, { font = "sym", px = 10, color = "muted" }, 16)
local sym_fits = nx + sym.measure() + cur.measure() <= dw - 14
if not sym_fits then
gui.delete_node(sym.node) -- (no room: the symbol is on the card and the ticket)
gui.delete_node(cur.node)
end
-- The live event's chip: on the name's row, or under the price when that's full.
local ev_low = false
local boosted = q.boosted
if boosted == nil then
boosted = (q.boost or 1) > 1.001
end
local ev_low = boosted and dw < 340
local event
if boosted then
local ev = ui.text(d, "Event " .. boost_text(q), { font = "mono", px = 11, color = "ready", tracking = 0.1, upper = true }, 0, 20, gui.PIVOT_E)
local ew = ui.text_width(ev) + 16
ev_low = dw < 340
local ex, ey = ev_low and 14 or (dw - 14 - ew), ev_low and 66 or 9
gui.set_position(ev, vec(ex + ew - 8, -(ey + 11)))
tooltip.add(ui.outline(d, ex, ey, ew, 22, "ready"), "A live event boosts this sell price")
local ev = fg.label(c, "Event " .. boost_text(q), { font = "mono", px = 11, color = "ready", tracking = 0.1, upper = true }, 22)
local ew = ev.measure() + 16
event = box({ position = "absolute", width = ew, height = 22, padding_left = 8,
left = ev_low and 14 or nil, right = (not ev_low) and 14 or nil, top = ev_low and 66 or 9 }, { ev }, { make = function(p, x, y)
local o = ui.outline(p, x, y, ew, 22, "ready")
tooltip.add(o, "A live event boosts this sell price")
return o
end })
end
local pw = ui.credits(d, q.price, 14, 52, { px = 26, color = "text", font = "mono_semi" })
local cw = change(d, q, 14 + pw + 14, 54, { px = 13 })
ui.text(d, "24h", { font = "mono", px = 12, color = "muted" }, 14 + pw + 14 + cw + 5, 54)
local y = ev_low and 100 or 74
-- What makes the price: market rate × live event × advisors × recent selling.
local pressed, advised = q.pressed, q.advised
if pressed == nil then -- (older servers)
pressed, advised = (q.factor or 1) < 0.995, (q.advisor or 1) > 1.001
-- What makes the quote (shared by every player): market rate × live event. The
-- personal lines (advisors, recent selling) are on the ticket.
local notes = {}
if boosted then
local sx = "Market " .. theme.currency .. ui.money(q.rate) .. " × live event " .. boost_text(q)
notes[#notes + 1] = fg.measured(c, function(p, x, y, ww)
local _, lh = ui.wrap(p, sx, { font = "sans", px = 12, color = "muted" }, x, y, ww)
return math.max(18, lh + 4)
end)
end
local pct = q.pressureText or string.format("-%d%%", math.floor((1 - (q.factor or 1)) * 100 + 0.5))
if pressed then
local st12 = { font = "sans", px = 12, color = "white", markup = true }
local line = ui.text(d, ui.runs({ { pct .. " from recent selling", "danger" }, { " · wears off over a few minutes", "muted" } }), st12, 14, y)
if ui.text_width(line) > dw - 28 then -- (narrow: on two lines)
gui.delete_node(line)
ui.text(d, pct .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14, y)
y = y + 16
ui.text(d, "wears off over a few minutes", { font = "sans", px = 12, color = "muted" }, 14, y)
end
y = y + 18
end
if boosted or advised or pressed then
local s = "Market " .. theme.currency .. ui.money(q.rate)
if boosted then s = s .. " × live event " .. boost_text(q) end
if advised then s = s .. " × advisors " .. advisor_text(q) end
if pressed then s = s .. " × recent selling " .. pct end
local _, lh = ui.wrap(d, s, { font = "sans", px = 12, color = "muted" }, 14, y - 8, dw - 28)
y = y + math.max(18, lh + 4)
end
-- Open, high, low, held: a row at the bottom; the chart takes the room between.
local ohl_y = bh - 40
local colw = math.floor((dw - 28) / 4)
for i, f in ipairs({ { colw >= 76 and "Open 24h" or "Open", q.open, "text" }, { "High", q.high, "up" }, { "Low", q.low, "danger" }, { colw >= 76 and "You hold" or "Held", nil, "text" } }) do
local fx = 14 + (i - 1) * colw
ui.text(d, f[1], "cap", fx, ohl_y + 6)
if f[2] then
ui.credits(d, f[2], fx, ohl_y + 24, { color = f[3] })
else
H.tick_num(self, ui.text(d, ui.money(q.balance), "num", fx, ohl_y + 24), "mk:hold", q.balance)
end
end
ui.rect(d, 14, ohl_y - 8, dw - 28, 1, "line")
-- The chart takes the room between the notes and the open/high/low row.
local chart_box = { style = { grow = 1 } }
if mk.chart and mk.chart.item == q.item then
chart.chart(d, 14, y + 4, dw - 28, ohl_y - 16 - (y + 4), mk.chart, {
color = up and "up" or "danger", events = price_events(st, q.item), pointers = self.pointers,
})
local events = price_events(st, q.item)
chart_box.make = function(p, x, y, cw, ch)
blocks.keep(self, "mk:chart", blocks.sig(y, dw, ch, up, mk.chart, events), p, function(r)
chart.chart(r, x, y, cw, ch, mk.chart, { color = up and "up" or "danger", events = events, pointers = self })
end)
end
end
-- Open, high, low, held: a row at the bottom.
local colw = math.floor((dw - 28) / 4)
local ohl = {}
for _, f in ipairs({ { colw >= 76 and "Open 24h" or "Open", q.open, "text" }, { "High", q.high, "up" }, { "Low", q.low, "danger" }, { colw >= 76 and "You hold" or "Held", nil, "text" } }) do
local v
if f[2] then
v = credits_box(c, f[2], { color = f[3] }, 16)
else
v = fg.label(c, ui.money(q.balance), "num", 16)
v.done = function(n) H.tick_num(self, n, "mk:hold", q.balance) end
end
ohl[#ohl + 1] = col({ align = "start" }, { fg.label(c, f[1], "cap", 12), space(0, 4), v })
end
local detail = col({ width = dw, height = bh, padding = { 12, 14, 8, 14 }, shrink = 0 }, {
row({ height = 16, align = "center" }, {
made({ width = 12, height = 12, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 12, 12, col_) end),
space(8), nm, space(10),
refined_chip, refined_chip and space(8) or nil,
sym_fits and row({ padding_top = 2 }, { sym, cur }) or nil,
}),
event,
space(0, 16),
row({ height = 20, align = "start" }, {
credits_box(c, q.price, { px = 26, color = "text", font = "mono_semi" }, 16), space(14),
change_box(c, q, 20, { px = 13 }), space(5),
fg.label(c, "24h", { font = "mono", px = 12, color = "muted" }, 20),
}),
space(0, ev_low and 28 or 2),
col({}, notes),
space(0, 12),
chart_box,
space(0, 8),
made({ height = 1 }, function(p, x, y, lw) ui.rect(p, x, y, lw, 1, "line") end),
space(0, 7),
box({ columns = { colw, colw, colw, colw } }, ohl),
}, { make = function(p, x, y, bw, bhh)
local _, dn = ui.panel(p, x, y, bw, bhh)
return ui.part(dn, "panel", "root")
end })
-- Sell ticket.
local tr, tn = ui.panel(root, stacked and ox or (ox + dw + 12), stacked and (cy + bh + 12) or cy, tw, bh)
local t = ui.part(tn, "panel", "root")
---------------------------------------------------------------- sell ticket
local tin = tw - 28
ui.text(t, tw >= 260 and "Sell order · Market" or "Sell order", "cap", 14, 18)
local at = ui.credits(t, q.price, tw - 14, 18, { pivot = gui.PIVOT_E, color = "muted", px = 11 })
ui.text(t, "@", { font = "mono", px = 11, color = "muted" }, tw - 14 - at - 4, 18, gui.PIVOT_E)
ui.text(t, "Amount", "cap", 14, 42)
local t, tr -- (the ticket's panel root; tr: its node, which the sale moves)
local empty = q.balance <= 0
local f = field.get(self, "sell")
if f.item ~= q.item then
@@ -421,9 +486,11 @@ function M.build(self, root, px, py, w, h, H, opts)
end
-- Payout: what the amount typed would earn, or Sell all with none (display only).
-- After a sale: what it earned, powering down (sale.lua).
-- After a sale: what it earned, powering down (sale.lua). Made again by the field and
-- the sale, as a tree of its own under the ticket.
local pay, pay_tick, sale_n
local function payout()
local payout
payout = function()
if pay_tick then
pay_tick.dead = true
pay_tick = nil
@@ -435,102 +502,151 @@ function M.build(self, root, px, py, w, h, H, opts)
sale_n = nil
local py0 = tight and 140 or 146
local sold = sale.active(self, q.item)
local kids
if sold then
ui.text(pay, "You receive", "cap", 14, py0)
local hero = anim.group(pay, 14, py0 + 22)
local glow = ui.rect(hero, -16, -24, 10, 48, "ready", 0)
gui.play_flipbook(glow, "glow")
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
gui.set_size(glow, vec(cw + 32, 48))
local room = tin / math.max(1, cw)
sale_n = { ticket = tr, panel = t, hero = hero, num = num, glow = glow, w = tw, h = bh, cx = cw / 2,
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
return
end
local typed = f.text ~= "" and (tonumber(f.text) or 0) or nil
local amount = typed or q.balance
local factor = pressure_factor(st, q, amount)
local unit = (q.unpressed or q.price) * factor
ui.text(pay, (typed or empty) and "You receive" or "Sell all pays", "cap", 14, py0)
if advised then
ui.text(pay, "Advisors " .. advisor_text(q), { font = "mono", px = 11, color = "ready" }, tw - 14, py0, gui.PIVOT_E)
end
local total = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
local _, tn = ui.credits(pay, total, 14, py0 + 22, { px = 22, color = "ready", font = "mono_semi" })
if not typed then
pay_tick = H.tick_num(self, tn, "mk:value", total)
end
if empty then
return -- (the note under it says why)
end
local uw = ui.credits(pay, unit, 14, py0 + 45, { px = 11, color = "muted", suffix = " / unit" })
-- Recent selling (market.js): the averaged factor for the amount typed, else the
-- whole balance; shown from 1%.
local pct = math.floor((1 - factor) * 100 + 0.5)
local press = pct >= 1 and string.format("−%d%%", pct) or nil
if press then
if 14 + uw + 160 < tw then
ui.text(pay, "· " .. press .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14 + uw + 6, py0 + 45)
elseif py0 + 63 < bh - 14 - theme.size.button_h - 8 then
ui.text(pay, press .. " from recent selling", { font = "sans", px = 12, color = "danger" }, 14, py0 + 63)
end
end
end
local sell_now -- (the Sell button's click: Enter in the field sells, like the web's form)
local input = field.new(self, t, "sell", 14, 52, tin, 36, {
hint = "0", font = "mono", hint_font = "mono", allowed = "[%d%.]",
max_len = 12, keyboard = gui.KEYBOARD_TYPE_NUMBER_PAD, disabled = empty, unit = q.symbol,
min = 0, max = q.balance, -- (Up / Down step it like the web's type=number)
on_change = function()
sale.stop(self) -- (touching the ticket ends the power-down)
payout()
end,
on_submit = function()
if sell_now and tonumber(f.text) and tonumber(f.text) > 0 then
sell_now()
end
end,
})
self.sell_input = input
-- Quick amounts fill the field.
local qw = math.floor((tin - 12) / 3)
for i, fr in ipairs({ { "25%", 0.25 }, { "50%", 0.5 }, { "Max", 1 } }) do
local b = ui.button(t, fr[1], 14 + (i - 1) * (qw + 6), 96, { small = true, w = qw, disabled = empty })
if input then
button.bind(self, b, nil, { id = "amount:" .. fr[1], on_click = function()
input:set_text(amount_text(q.balance, fr[2]))
end })
end
end
payout()
local by = bh - 14 - theme.size.button_h
if empty then
local note = ui.text(t, "You don't hold any " .. q.item .. ".", "small", 14, by - 14)
ui.fit_text(note, tin)
end
local half = math.floor((tin - 8) / 2)
local b1, n1 = ui.button(t, "Sell " .. (q.symbol or ""), 14, by, { primary = true, w = half, disabled = empty })
local b2, n2 = ui.button(t, "Sell all", 14 + half + 8, by, { w = half, disabled = empty })
if not empty then
-- Clear the amount once it sold, but not while more sells are queued (prediction).
local function clear(err)
if err then
return
end
for _, e in ipairs(self.pending) do
if e.name == "sell" then
return
kids = {
fg.label(pay, "You receive", "cap", 16), space(0, 6),
made({ height = 16 }, function(p, x, y)
local hero = anim.group(p, x, y + 8)
local glow = ui.rect(hero, -16, -24, 10, 48, "ready", 0)
gui.play_flipbook(glow, "glow")
local cw, num = ui.credits(hero, sold.earned, 0, 0, { px = 22, color = "ready_hi", font = "mono_semi" })
gui.set_size(glow, vec(cw + 32, 48))
local room = tin / math.max(1, cw)
sale_n = { ticket = tr, panel = t, hero = hero, num = num, glow = glow, w = tw, h = bh, cx = cw / 2,
peak = math.min(0.4, math.max(0.1, room * 1.1 - 1)), restore = payout }
end),
}
else
local advised = q.advised or (q.advised == nil and (q.advisor or 1) > 1.001)
local typed = f.text ~= "" and (tonumber(f.text) or 0) or nil
local amount = typed or q.balance
local factor = pressure_factor(st, q, amount)
local unit = (q.unpressed or q.price) * factor
local tot = typed and amount * unit or (empty and 0 or q.sellAll or q.value)
kids = {
row({ height = 16, align = "center" }, {
fg.label(pay, (typed or empty) and "You receive" or "Sell all pays", "cap", 16), fg.fill(),
-- Personal lines (the quote is the same for everyone): advisors here,
-- recent selling under the unit price.
advised and fg.label(pay, advisor_text(q) .. " for you", { font = "mono", px = 11, color = "ready" }, 16) or nil,
}),
space(0, 6),
fg.inline(pay, 16, function(p, x, y, probing)
local cw, tn = ui.credits(p, tot, x, y, { px = 22, color = "ready", font = "mono_semi" })
if not typed and not probing then
pay_tick = H.tick_num(self, tn, "mk:value", tot)
end
return cw
end),
}
if not empty then
local unit_box = credits_box(pay, unit, { px = 11, color = "muted", suffix = " / unit" }, 16)
local uw = unit_box.style.width
-- Recent selling (market.js): the averaged factor for the amount typed, else
-- the whole balance; shown from 1%.
local pc = math.floor((1 - factor) * 100 + 0.5)
local press = pc >= 1 and string.format("−%d%%", pc) or nil
local danger = { font = "sans", px = 12, color = "danger" }
kids[#kids + 1] = space(0, 7)
local beside = press and 14 + uw + 200 < tw
kids[#kids + 1] = row({ height = 16, align = "center" }, {
unit_box, beside and space(6) or nil,
beside and fg.label(pay, "· " .. press .. " from your recent selling", danger, 16) or nil,
})
if press and not beside and py0 + 63 < bh - 14 - theme.size.button_h - 8 then
kids[#kids + 1] = space(0, 2)
kids[#kids + 1] = fg.label(pay, press .. " from your recent selling", danger, 16)
end
end
f.text, f.focus = "", false
end
sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item }
end, clear)
H.action(self, b2, n2, "sell", { resource = q.item, all = "1", pick = q.item }, clear)
fg.build(flex.solve(col({ width = tin }, kids), 14, py0 - 8), pay)
end
payout = blocks.bound(payout) -- (made again by the field and the sale: the market's block)
local sell_now -- (the Sell button's click: Enter in the field sells, like the web's form)
local input
local qw = math.floor((tin - 12) / 3)
local quick = {}
for _, fr in ipairs({ { "25%", 0.25 }, { "50%", 0.5 }, { "Max", 1 } }) do
quick[#quick + 1] = fg.button(c, fr[1], { small = true, disabled = empty }, function(b)
if input then
button.bind(self, b, nil, { id = "amount:" .. fr[1], on_click = function()
input:set_text(amount_text(q.balance, fr[2]))
end })
end
end, { width = qw })
end
local half = math.floor((tin - 8) / 2)
-- Clear the amount once it sold, but not while more sells are queued (prediction).
local function clear(err)
if err then
return
end
for _, e in ipairs(self.pending) do
if e.name == "sell" then
return
end
end
f.text, f.focus = "", false
end
local ticket = col({ width = tw, height = bh, padding = { 10, 14, 14, 14 }, shrink = 0 }, {
row({ height = 16, align = "center" }, {
fg.label(c, tw >= 260 and "Sell order · Market" or "Sell order", "cap", 16), fg.fill(),
fg.label(c, "@", { font = "mono", px = 11, color = "muted" }, 16), space(4),
credits_box(c, q.price, { color = "muted", px = 11 }, 16),
}),
space(0, 8),
fg.label(c, "Amount", "cap", 16),
space(0, 2),
made({ height = 36 }, function(p, x, y, fw, fh)
input = field.new(self, p, "sell", x, y, fw, fh, {
hint = "0", font = "mono", hint_font = "mono", allowed = "[%d%.]",
max_len = 12, keyboard = gui.KEYBOARD_TYPE_NUMBER_PAD, disabled = empty, unit = q.symbol,
min = 0, max = q.balance, -- (Up / Down step it like the web's type=number)
on_change = function()
sale.stop(self) -- (touching the ticket ends the power-down)
payout()
end,
on_submit = function()
if sell_now and tonumber(f.text) and tonumber(f.text) > 0 then
sell_now()
end
end,
})
self.sell_input = input
end),
space(0, 8),
-- Quick amounts fill the field.
row({ gap = 6 }, quick),
made({ height = 0 }, function() payout() end),
fg.fill(),
empty and fg.label(c, "You don't hold any " .. q.item .. ".", "small", 16, { fit = true, shrink = 1 }) or nil,
empty and space(0, 6) or nil,
row({ gap = 8 }, {
fg.button(c, "Sell " .. (q.symbol or ""), { primary = true, disabled = empty }, function(b1, n1)
if not empty then
sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item }
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
end
end, { width = half }),
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
if not empty then
H.action(self, b2, n2, "sell", { resource = q.item, all = "1", pick = q.item }, clear)
end
end, { width = half }),
}),
}, { make = function(p, x, y, bw, bhh)
-- (a slot: the sale moves the ticket's panel inside it)
local slot = ui.rect(p, x, y, bw, bhh, "none")
local tn
tr, tn = ui.panel(slot, 0, 0, bw, bhh)
t = ui.part(tn, "panel", "root")
return t
end })
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)
-- 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.
@@ -546,6 +662,24 @@ function M.build(self, root, px, py, w, h, H, opts)
if sale_n then
sale.bind(self, sale_n) -- (a rebuild while it plays)
end
return panels.y + panels.h, tick_bottom
end
-- The leaderboard is a side panel (sidepanel.lua); when that is folded away (narrow
-- windows) it is a pane under the market here (state.leaderboard, asked for with the
-- market: session.view_params) and the section scrolls. The section scrolls too when the
-- window is too short for the cards and the panels.
function M.build(self, root, px, py, w, h, H)
local lb = (self.side_w or 0) == 0 and self.state.leaderboard or nil
local stacked = self.size and self.size[1] <= STACK_W
local area = scroll.area(self, root, 0, 2, w, h - 4, "market")
local bottom = build_market(self, area.content, px, py, w, h, H, stacked, w)
if lb then
local avail = w - px * 2
local inner = math.min(avail, theme.size.market_max)
bottom = bottom + 18 + leaderboard.pane(self, area.content, px + math.floor((avail - inner) / 2), bottom + 18, inner)
end
area:finish(bottom + 14) -- (the area is 4 shorter than the panel: what fits doesn't scroll)
end
function M.update(self)
+145 -78
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 server's confirm dialog; why it can't be sold yet is its tooltip), and on demand the
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
-- market prices, × the factor, capped.
@@ -9,6 +10,8 @@ local theme = require("modules.theme")
local ui = require("modules.ui")
local tooltip = require("modules.tooltip")
local test = require("modules.test")
local blocks = require("modules.blocks")
local fg = require("modules.flex_gui")
local M = {}
@@ -16,58 +19,91 @@ local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The panel at (x, y), w wide. Returns the new y.
function M.build(self, H, root, x, y, w, moon)
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
function M.box(self, H, c, moon, w)
local sale = moon.sale
if not sale then
return y
end
self.sale_open = self.sale_open or {}
local open = self.sale_open[moon.id]
local box = ui.rect(root, x, y, w, 10, "cyan", 0.03)
local edge = ui.outline(box, 0, 0, w, 10, "edge")
self.fx:box(box) -- (the sale's sweep covers the panel)
local inline = function(fn) return fg.inline(c, 16, fn) end
-- Summary: toggle, offer, delta; Sell moon on the right.
local hit = ui.rect(box, 0, 0, w - 140, 48, "none")
local cx = 14
local chev = ui.text(box, "›", { font = "sym", px = 16, color = "muted" }, cx, 23, gui.PIVOT_CENTER)
if open then
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
cx = cx + 16
local t = ui.text(box, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, cx, 24)
cx = cx + ui.text_width(t) + 8
local o = ui.text(box, "· offer", { font = "sans", px = 14, color = "muted" }, cx, 24)
cx = cx + ui.text_width(o) + 8
cx = cx + ui.credits(box, sale.total, cx, 24, { px = 15, color = "text", font = "mono_semi" }) + 10
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
local profit = sale.delta > 0
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
local dn = ui.label(box, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, cx, 24)
if not ui.needs_rich(dtxt) then
ui.fit_text(dn, w - 140 - cx)
end
local b, bn, bw = ui.button(box, "Sell moon", 0, 0, { small = true, disabled = not sale.canSell,
-- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
local narrow = w < 460
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
local sell_opts = { small = true, disabled = not sale.canSell,
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
edge = sale.canSell and { "danger", 0.8 } or nil })
gui.set_position(b, vec(w - 10 - bw, -8))
if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText)
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
if sale.canSell then
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
if self.autoplay then
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
end)
end
end
self.druid:new_button(hit, function()
self.sale_open[moon.id] = not open
self.dirty = true
end):set_animations_disabled()
test.register("moon-sale:" .. moon.id, hit, "button", open and "Hide the offer" or "Show the offer")
local summary = box({ height = 48 }, asking and { ask_row } or {
made({ position = "absolute", left = 0, top = 0, width = w - 140, height = 48 }, function(p, x, y, hw, hh)
local hit = ui.rect(p, x, y, hw, hh, "none")
self.druid:new_button(hit, function()
self.sale_open[moon.id] = not open
self.dirty = true
end):set_animations_disabled()
test.register("moon-sale:" .. moon.id, hit, "button", open and "Hide the offer" or "Show the offer")
end),
made({ position = "absolute", left = 14, top = 23, width = 0, height = 0 }, function(p, x, y)
local chev = ui.text(p, "›", { font = "sym", px = 16, color = "muted" }, x, y, gui.PIVOT_CENTER)
if open then
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
end),
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
-- (the label and the offer keep their size: the delta gives way, cut to fit)
(function()
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
l.style.shrink = 0
return l
end)(), space(10),
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
(function()
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
b.style.shrink = 0
return b
end)(), space(10),
(function()
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
return l
end)(),
}),
fg.button(c, "Sell moon", sell_opts, function(b)
if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText)
end
if sale.canSell then
cards.ask_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
if self.autoplay then
local function reg()
if self.autoplay then
self.autoplay:register("sell-moon", { ["sell-moon"] = moon.id }, function()
H.act(self, "sell-moon", { ["sell-moon"] = moon.id })
end)
end
end
reg()
blocks.note(reg) -- (a kept block registers again)
end
end
end, { position = "absolute", top = 8, right = 10 }),
})
-- Fetch the state again when the hold ends (the server enables the sale).
if self.sale_timer then
@@ -81,48 +117,79 @@ function M.build(self, H, root, x, y, w, moon)
end)
end
local cy = 48
-- Details: the note and a line per well.
-- Details: the note and a line per well (a grid: well, left, price, value).
local details
if open then
local note = "What's left in its wells at today's prices, ×" .. sale.factor
.. (sale.capped and (", capped at " .. theme.currency .. ui.money(sale.total)) or "")
.. ". A new moon would cost " .. theme.currency .. ui.money(sale.replace) .. " after the sale."
.. ((sale.ready or 0) > 0 and (" " .. ui.plural(sale.ready, "finished drill") .. " claimed first.") or "")
local _, nh = ui.wrap(box, note, { font = "sans", px = 13, color = "muted" }, 14, cy + 4, math.min(w - 28, theme.size.text_max))
cy = cy + nh + 12
if sale.holdEnd and sale.holdEnd > H.now(self) then
local hold = sale.holdEnd and sale.holdEnd > H.now(self)
local sellable
if hold then
-- (the web's "Sellable in <countdown>.", ticking)
local n = ui.text(box, "Sellable in " .. ui.dur(sale.holdEnd - H.now(self)) .. ".", { font = "sans", px = 13, color = "muted" }, 14, cy + 2)
H.tick_text(self, n, sale.holdEnd, function(left)
return "Sellable in " .. ui.dur(left) .. "."
end, "Sellable in 0:00.")
cy = cy + 20
sellable = fg.label(c, "Sellable in " .. ui.dur(sale.holdEnd - H.now(self)) .. ".", { font = "sans", px = 13, color = "muted" }, 16)
sellable.done = function(n)
H.tick_text(self, n, sale.holdEnd, function(left)
return "Sellable in " .. ui.dur(left) .. "."
end, "Sellable in 0:00.")
end
end
local inner = w - 28
local cols = { 0, inner - 200, inner - 100, inner }
for i, label in ipairs({ "Well", "Left", "Price", "Value" }) do
ui.text(box, label, "cap", 14 + cols[i], cy + 6, i == 1 and gui.PIVOT_W or gui.PIVOT_E)
local cols = { "1fr", 100, 100, 100 }
local function line(top)
return made({ position = "absolute", left = 0, right = 0, top = top, height = 1 }, function(p, x, y, lw)
ui.rect(p, x, y, lw, 1, top == 0 and "edge" or "line")
end)
end
cy = cy + 16
ui.rect(box, 14, cy, inner, 1, "edge")
local function right(b)
b.style.justify_self = "end"
return b
end
local function credits(v)
return right(inline(function(p, x, y) return (ui.credits(p, v, x, y)) end))
end
local lines = { line(0) }
for _, l in ipairs(sale.lines or {}) do
ui.rect(box, 16, cy + 11, 8, 8, l.resource)
ui.text(box, l.resource, "body", 32, cy + 15)
ui.text(box, ui.money(l.supply), "num", 14 + cols[2], cy + 15, gui.PIVOT_E)
ui.credits(box, l.rate, 14 + cols[3], cy + 15, { pivot = gui.PIVOT_E })
ui.credits(box, l.value, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
cy = cy + 30
ui.rect(box, 14, cy, inner, 1, "line")
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
row({ padding_left = 2, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, l.resource) end),
space(8), fg.label(c, l.resource, "body", 16),
}),
right(fg.label(c, ui.money(l.supply), "num", 16)), credits(l.rate), credits(l.value),
line(30),
})
end
ui.text(box, "Market value", { font = "sans_semi", px = 14, color = "text" }, 32, cy + 15)
ui.credits(box, sale.market, 14 + cols[4], cy + 15, { pivot = gui.PIVOT_E })
cy = cy + 34
lines[#lines + 1] = box({ columns = cols, rows = { 30 }, align = "center" }, {
row({ padding_left = 18 }, { fg.label(c, "Market value", { font = "sans_semi", px = 14, color = "text" }, 16) }),
space(0), space(0), credits(sale.market),
})
details = col({ padding = { 0, 14, 0, 14 } }, {
space(0, 4),
fg.measured(c, function(p, x, y, nw)
local _, nh = ui.wrap(p, note, { font = "sans", px = 13, color = "muted" }, x, y, nw)
return nh
end, { max_width = theme.size.text_max }),
space(0, hold and 2 or 8), sellable, hold and space(0, 10) or nil,
box({ columns = cols, height = 16, align = "start" }, {
fg.label(c, "Well", "cap", 12), right(fg.label(c, "Left", "cap", 12)),
right(fg.label(c, "Price", "cap", 12)), right(fg.label(c, "Value", "cap", 12)),
}),
col({}, lines),
space(0, 4),
})
end
cy = cy + 4
gui.set_size(box, vec(w, cy))
gui.set_size(edge, vec(w, cy))
return y + cy
local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
fg.measured(c, function(p, x, y, bw)
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
end),
}) or nil
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
ui.outline(b, 0, 0, bw, bh, "edge")
self.fx:box(b) -- (the sale's sweep covers the panel)
return b
end })
end
return M
+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
+175 -64
View File
@@ -1,7 +1,8 @@
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
-- ready badge; a virtualized list, components/list) and "+ New moon" pinned to the bottom.
-- ready badge; a virtualized list, components/list), "+ New moon" at the top (buys one) and
-- the announcements pane (modules/toasts, the last 5) at the foot.
-- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
-- under it, the total on its tab.
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells
@@ -18,8 +19,13 @@ local field = require("modules.field")
local moon_row = require("widgets.moon_row")
local ready = require("screens.game.core.ready")
local tabs = require("components.tabs")
local button = require("components.button")
local list_c = require("components.list")
local row_c = require("components.row")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local toasts = require("modules.toasts")
local M = {}
@@ -257,6 +263,45 @@ function M.teardown(self)
self.ml_ghosts = nil
end
-- The grade "+ New moon" buys: the toggle's pick if the server still lists it, else
-- state.moonGrade.
function M.grade(g)
local st = g.state
for _, e in ipairs(st.newMoon and st.newMoon.grades or {}) do
if e.grade == g.nm_grade then return g.nm_grade end
end
return st.moonGrade
end
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
-- badge, selection), both lists: the moon list's block signature has this instead of the
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
-- keeps the list.
function M.rows_sig(self)
local st, v = self.state, self.view
local out, n = {}, 0
for _, list in ipairs({ st.moons, st.past }) do
for _, m in ipairs(list) do
local dry = not m.finished and #(m.wells or {}) > 0
if dry then
for _, w in ipairs(m.wells) do
if w.supply > 0 then
dry = false
break
end
end
end
n = n + 1
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
end
n = n + 1
out[n] = "|"
end
return blocks.sig(out)
end
function M.build(self, H, parent, x, y, w, h)
local st = self.state
local v = self.view
@@ -264,25 +309,66 @@ function M.build(self, H, parent, x, y, w, h)
local root = ui.panel(parent, x, y, w, h)
local pad = S.panel_pad
local inner = w - pad * 2
local cy = pad
local kids = {} -- the panel, top to bottom (one tree; full width, rows padded)
-- Active / Past tabs (once the store is open, like the web).
-- "+ New moon" (dashed in the web build), at the top: buys a moon straight away (the
-- new-moon section's Claim and reveal); disabled with the reason when short.
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
if st.unlocked["new-moon"] then
-- The grade it buys: the one picked with the toggle at its right (g.nm_grade, kept
-- across builds), else the server's default (state.moonGrade).
local grades = st.newMoon and st.newMoon.grades or {}
local grade = M.grade(self)
local entry
for _, e in ipairs(grades) do
if e.grade == grade then entry = e end
end
local price = entry and entry.price or st.moonPrice or 0
local ok = entry and entry.canAfford or (not entry and (st.credits or 0) >= price)
local why = entry and (entry.whyText or "") ~= "" and entry.whyText
or ("Need " .. theme.currency .. ui.money(math.max(0, price - (st.credits or 0))) .. " more")
local label = grade and ("+ New " .. grade .. " moon") or "+ New moon"
local tw = #grades > 1 and 34 or 0
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad }, gap = tw > 0 and 6 or 0 }, {
fg.button(root, label, { price = price, variant = "ghost", on = v.section == "new-moon", disabled = not ok }, function(b, nodes)
if ok then
H.action(self, b, nodes, "buy-moon", { grade = grade, include = "new-moon" }, nil, nil, "newmoon:buy")
else
tooltip.add(b, why)
end
local extra = { ui.part(nodes, "button", "edge") }
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
if i > 1 then
extra[#extra + 1] = part[1] -- (the price)
end
end
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner - tw - (tw > 0 and 6 or 0), S.button_h, extra)
end, { grow = 1, shrink = 1 }),
-- The grade toggle: steps through the grades (cheapest first), stays in place.
tw > 0 and fg.button(root, grade or "?", { variant = "ghost" }, function(b)
button.bind(self, b, nil, { id = "newmoon:grade", label = "Grade " .. (grade or ""), on_click = function()
local at = 0
for i, e in ipairs(grades) do
if e.grade == grade then at = i end
end
self.nm_grade = grades[at % #grades + 1].grade
self.dirty = true
end })
tooltip.add(b, "Grade to buy: " .. (grade or "") .. " (click to change)")
end, { width = tw }) or nil,
})
kids[#kids + 1] = fg.space(0, 12)
end
-- Active / Past tabs (once the store is open, like the web): a tab is its label and
-- count in a hit box 4 wider on the left; a line under them.
if (#st.moons + #st.past) > 0 and st.unlocked.store then
local tx = pad
local ink = tabs.ink(self, root, "moonlist") -- the sliding underline (under the tabs)
local past_total = st.pastPage and st.pastPage.total or #st.past
local tab_boxes = {}
for _, tab in ipairs({ { "Active", #st.moons, false }, { "Past", past_total, true } }) do
local on = v.past == tab[3]
local hit = ui.rect(root, tx - 4, cy, 10, 30, "none")
local color = on and "cyan" or (tab[2] > 0 and "nav" or "faint")
local t = ui.text(root, tab[1], { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, tx, cy + 14)
local tw = ui.text_width(t)
local n = ui.text(root, tab[2], { font = "mono", px = 12, color = "muted" }, tx + tw + 6, cy + 14)
local full = tw + 6 + ui.text_width(n) + 8
gui.set_size(hit, vec(full, 30))
if on then
ink:to(tx - 4, cy, full, 30)
end
local to
if tab[3] == false then
local first = st.moons[1]
@@ -291,28 +377,39 @@ function M.build(self, H, parent, x, y, w, h)
-- (the newest past moons: page 1, the server picks the moon)
to = { section = "moons", moon = not v.past and st.past[1].id or "", past = true }
end
if not to then
tooltip.add(hit, "Moons you mine out go here")
else
tabs.tab(self, hit, t, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
H.navigate(self, to)
end })
end
local label = fg.label(root, tab[1], { font = "sans_semi", px = 14, color = color, tracking = 0.12, upper = true }, 28)
if tab[3] then
past_flash(self, root, t)
label.done = function(n) past_flash(self, root, n) end
end
tx = tx + full + 22
tab_boxes[#tab_boxes + 1] = fg.row({ height = 30, padding = { 0, 4, 0, 4 }, align = "start" }, {
label, fg.space(6), fg.label(root, tab[2], { font = "mono", px = 12, color = "muted" }, 28),
}, { make = function(p, hx, hy, hw, hh)
-- (the labels stay on the panel: past_flash puts its glow there)
local hit = ui.rect(p, hx, hy, hw, hh, "none")
if on then
ink:to(hx, hy, hw, hh)
end
if not to then
tooltip.add(hit, "Moons you mine out go here")
else
tabs.tab(self, hit, label.node, { active = on, color = color, id = "list:" .. tab[1]:lower(), on_click = function()
H.navigate(self, to)
end })
end
return nil
end })
end
ui.rect(root, pad, cy + 30, inner, 1, "line")
cy = cy + 44
kids[#kids + 1] = fg.col({ height = 44 }, {
fg.row({ gap = 22, padding_left = pad - 4 }, tab_boxes),
fg.made({ position = "absolute", top = 30, left = pad, right = pad, height = 1 }, function(p, lx, ly, lw)
ui.rect(p, lx, ly, lw, 1, "line")
end),
})
end
local list = v.past and st.past or st.moons
local bottom = h - pad - (st.unlocked["new-moon"] and (S.button_h + 10) or 0)
local paging = v.past and st.pastPage and (st.pastPage.pages or 1) > 1 and st.pastPage
if paging then
bottom = bottom - S.button_sm_h - 12
end
local list_h -- (the rows' box: what's left between the search and the bottom)
local rows, note = nil, nil
local slot = S.moon_row_h + 6
local ghosts -- the mined-out copies shown in this list, by where they go
@@ -345,7 +442,7 @@ function M.build(self, H, parent, x, y, w, h)
end
self.ml_ghosts = #ghosts.list > 0 and ghosts or nil
if not rows then
rows = list_c.new(self, root, pad, y0, inner, bottom - y0, {
rows = list_c.new(self, root, pad, y0, inner, list_h, {
key = v.past and ("moons:past:" .. (v.page or 1)) or "moons", row_h = slot,
focus_id = function(moon) return "moon:" .. moon.id end,
create = function(moon, index)
@@ -395,51 +492,65 @@ function M.build(self, H, parent, x, y, w, h)
-- The search stays across Active / Past (like the web's input, which the swap keeps).
local find = field.get(self, "find")
local rows_y
if #list > 0 and st.unlocked.store then
ui.text(root, "Find a moon", "cap", pad, cy + 6)
cy = cy + 16
local rows_y = cy + 34 + 12
field.new(self, root, "find", pad, cy, inner, 34, {
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
on_change = function(text)
build_rows(text, rows_y)
end,
kids[#kids + 1] = fg.col({ padding = { 0, pad, 0, pad } }, {
fg.label(root, "Find a moon", "cap", 12), fg.space(0, 4),
fg.made({ height = 34 }, function(p, fx, fy, fw, fh)
field.new(self, p, "find", fx, fy, fw, fh, {
hint = "Name", px = 14, max_len = 40, esc_clears = true, -- (type=search)
on_change = blocks.bound(function(text) -- (what it makes is the moon list's: modules/blocks)
build_rows(text, rows_y)
end),
})
end),
fg.space(0, 12),
})
cy = rows_y
end
build_rows(find.text, cy)
-- The rows (a virtualized list) take the room down to the pager and the announcements.
kids[#kids + 1] = fg.made({ grow = 1, shrink = 1 }, function(_, _, ly, _, lh)
rows_y, list_h = ly, lh
build_rows(find.text, ly)
end)
-- Past pages: ‹ Newer, "2 / 3", Older › (the web's past-paging).
if paging then
local py = bottom + 8
local function pager(label, page, x, right)
local b, nodes, bw = ui.button(root, label, 0, 0, { small = true, disabled = not page })
gui.set_position(b, vec(right and (x - bw) or x, -py))
if page then
H.link(self, b, { section = "moons", past = true, page = page })
end
return bw
local function pager(label, page)
return fg.button(root, label, { small = true, disabled = not page }, function(b)
if page then
H.link(self, b, { section = "moons", past = true, page = page })
end
end)
end
pager("‹ Newer", paging.page > 1 and paging.page - 1 or nil, pad)
pager("Older ›", paging.page < paging.pages and paging.page + 1 or nil, pad + inner, true)
local t = ui.text(root, paging.page .. " / " .. paging.pages, { font = "mono", px = 12, color = "muted" }, pad + inner / 2, py + S.button_sm_h / 2, gui.PIVOT_CENTER)
tooltip.add(t, paging.total .. " past moons, " .. paging.perPage .. " a page")
local count = fg.label(root, paging.page .. " / " .. paging.pages, { font = "mono", px = 12, color = "muted" }, S.button_sm_h,
{ position = "absolute", top = 0, left = "50%", translate = { -0.5, 0 } })
count.done = function(n) tooltip.add(n, paging.total .. " past moons, " .. paging.perPage .. " a page") end
kids[#kids + 1] = fg.space(0, 8)
kids[#kids + 1] = fg.row({ height = S.button_sm_h, padding = { 0, pad, 0, pad } }, {
pager("‹ Newer", paging.page > 1 and paging.page - 1 or nil), fg.fill(),
pager("Older ›", paging.page < paging.pages and paging.page + 1 or nil),
count,
})
kids[#kids + 1] = fg.space(0, 4)
end
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel.
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
if st.unlocked["new-moon"] then
local by = h - pad - S.button_h
local b, nodes = ui.button(root, "+ New moon", pad, by, { w = inner, price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" })
H.link(self, b, { section = "new-moon", moon = v.moon })
local extra = { ui.part(nodes, "button", "edge") }
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
if i > 1 then
extra[#extra + 1] = part[1] -- (the price)
end
end
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner, S.button_h, extra)
-- The announcements pane at the panel's foot: room for the last 5 (modules/toasts lists
-- them there), at most half the panel; the moon list gets the rest.
kids[#kids + 1] = fg.space(0, 12)
kids[#kids + 1] = fg.col({ height = math.min(math.floor(h / 2), 31 + toasts.LIST_H), padding = { 0, pad, 0, pad } }, {
fg.made({ height = 1 }, function(p, lx, ly, lw)
ui.rect(p, lx, ly, lw, 1, "line")
end),
fg.space(0, 10),
fg.label(root, "Announcements", "cap", 12), fg.space(0, 8),
fg.made({ grow = 1, shrink = 1 }, function(p, px, py, pw, ph)
self.toasts:attach(ui.rect(p, px, py, pw, ph, "none"), pw, ph)
end),
})
for _, k in ipairs(kids) do
k.style.shrink = k.style.shrink or 0
end
fg.build(flex.solve(fg.col({ width = w, height = h, padding = { pad, 0, pad, 0 } }, kids), 0, 0), root)
end
return M
File diff suppressed because it is too large. Load diff
+615 -258
View File
@@ -1,21 +1,54 @@
-- The Refinery section (state.refinery): slots with their batches (bars fill in real time
-- from start to end against the server clock; the state is fetched again when a batch is
-- due, see next_end), recipe rows (inputs, held, a quantity field, Refine and Max), and
-- the refined stock as chips at the top (the web's; no aside here). The server decides what is finished and
-- what a batch makes; batch times shown are the server's (bulkSecs for bulkQty).
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect
-- all when more than one batch is ready), the slot cards (running:
-- time left and a segmented bar filling in real time against the server clock; ready:
-- Collect; idle; "+ Slot n" while more can be bought) and the recipe cards (glyph, tier
-- and owned, description, inputs, stats, the bulk batch, quantity, Refine and Max). The
-- whole section scrolls, like the web's page. The state is fetched again when a batch is
-- due (next_end); the server decides what is finished and what a batch makes.
--
-- Built flex-first: the page is one tree (modules/flex), described below with text leaves
-- (flex_gui.label / para) and boxes whose make builds what has a size (frames, buttons,
-- bars) once solved; flex_gui.build then makes the nodes in reading (= focus) order.
local theme = require("modules.theme")
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local tooltip = require("modules.tooltip")
local test = require("modules.test")
local scroll = require("components.scroll")
local anim = require("modules.anim")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local button = require("components.button")
local panel = require("components.panel")
local cards = require("screens.game.cards")
local S = theme.size
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
-- components.bar needs core/ticks, which needs core/session, which needs this module:
-- required when used (by a name bob doesn't follow).
local BAR = "components.bar"
local READY = "screens.game.core.ready"
local S = theme.size
-- A batch that finishes (core/ready: on our clock, before the refetch says so) turns its
-- slot ready at once: a light runs along its bar (SWEEP s), the inset glow flares and
-- settles (FLARE s), the Ready label and Collect pop in (POP s, fx-ready-pop); fx.lua's
-- batch_ready lights the edge and plays the sound. All go by the clock from the batch's
-- end (ready.batch_age), so the refetch's rebuild carries on where they were. Reduced
-- motion: no pop and no sweep.
local POP, SWEEP, FLARE = 0.6, 0.7, 1.4
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.12 }, { 1, 1 } }
-- Pops node in about the point (dx, dy) from its position, age seconds in.
local function pop(node, dx, dy, age)
if anim.reduced or not age or age >= POP then
return
end
local p, base = gui.get_position(node), gui.get_scale(node)
anim.tween(node, POP, function(k, n)
anim.scale_about(n, p, base, dx, dy, anim.keys(POP_SCALE, k, anim.fn.ready_pop))
end, -age)
end
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
@@ -54,127 +87,7 @@ local function item_color(st, name)
end
M.item_color = item_color
-- The web's faceted crystal: a diamond in the item's color.
local function glyph(parent, x, y, size, color)
local g = ui.rect(parent, 0, 0, size, size, color)
gui.set_pivot(g, gui.PIVOT_CENTER)
gui.set_position(g, vec(x, -y))
gui.set_rotation(g, vmath.quat_rotation_z(math.rad(45)))
local o = ui.outline(g, 0, 0, size, size, "text", 0.35)
gui.set_pivot(o, gui.PIVOT_CENTER)
gui.set_position(o, vec(0, 0))
return g
end
-- Content width (capped) and its left edge, centered in the panel.
local function frame(w, px, cap)
local avail = w - px * 2
local inner = math.min(avail, cap)
return inner, px + math.floor((avail - inner) / 2)
end
local function slot_card(self, root, H, st, slot, x, y, w)
local job = slot.job
local h = S.slot_h
local card = ui.rect(root, x, y, w, h, "panel")
local edge = job and (job.ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
ui.outline(card, 0, 0, w, h, edge)
if job and job.ready then
gui.set_color(card, ui.color("ready", 0.06))
end
ui.text(card, "Slot " .. slot.n, "cap", 12, 16)
if not job then
ui.text(card, w >= 180 and "Idle. Pick a recipe below." or "Idle", "small", 12, 40)
return
end
if job.ready then
ui.text(card, "Ready", { font = "sans_semi", px = 12, color = "ready", tracking = 0.12, upper = true }, w - 12, 16, gui.PIVOT_E)
else
local left = function(ms) return ui.dur(ms) .. " left" end
local t = ui.text(card, left(job["end"] - H.now(self)), { font = "mono", px = 12, color = "muted" }, w - 12, 16, gui.PIVOT_E)
H.tick_text(self, t, job["end"], left, "…")
end
local color = item_color(st, job.item)
ui.rect(card, 12, 32, 8, 8, color)
local q = ui.text(card, job.qty .. "×", { font = "mono", px = 13, color = "text" }, 26, 36)
local name = ui.text(card, job.item, { font = "sans", px = 15, color = "text" }, 26 + ui.text_width(q) + 5, 36)
ui.fit_text(name, w - 12 - (26 + ui.text_width(q) + 5))
local bw = w - 24
if job.ready then
-- Done: the full bar, and Collect under it (the card is lit).
ui.rect(card, 12, 50, bw, 4, color)
local b, nodes = ui.button(card, "Collect " .. job.qty, 12, h - 2 - S.button_sm_h, { primary = true, small = true, w = bw })
H.action(self, b, nodes, "collect", { job = job.id })
else
ui.rect(card, 12, 56, bw, 4, "line")
local fill = ui.rect(card, 12, 56, bw, 4, color)
H.tick_bar(self, fill, job.start, job["end"], bw)
end
end
-- Price centered at (cx, y): the ₥ sign and the number.
local function credits_center(parent, amount, cx, y, opts)
local probe = ui.rect(parent, 0, 0, 1, 1, "none")
local w = ui.credits(probe, amount, 0, 0, opts)
gui.delete_node(probe)
ui.credits(parent, amount, cx - w / 2, y, opts)
end
local function buy_slot_card(self, root, H, rf, x, y, w)
local h = S.slot_h
local off = not rf.canBuySlot
local b, nodes = ui.button(root, "", x, y, { w = w, h = h, disabled = off,
fill = { "cyan", off and 0.02 or 0.05 }, edge = { "cyan", off and 0.25 or 0.5 } })
ui.text(b, "+ Slot " .. rf.nextSlot, { font = "sans_semi", px = 15, color = off and "muted" or "cyan", tracking = 0.12, upper = true }, w / 2, h / 2 - 10, gui.PIVOT_CENTER)
credits_center(b, rf.nextSlotPrice, w / 2, h / 2 + 12, { px = 13, color = off and "muted" or "cyan" })
if off then
-- Why: the tooltip (the web's title).
tooltip.add(b, (rf.buySlotWhyText or "") ~= "" and rf.buySlotWhyText or ("Short " .. ui.money(rf.nextSlotPrice - self.state.credits)))
elseif rf.buySlot then
-- Asks first, with the server's question (the web's hx-confirm).
cards.confirm_server_button(self, b, rf.buySlot, "buy-slot", {})
else
H.action(self, b, nodes, "buy-slot", {})
end
end
-- Quantity field for a recipe (Druid input, digits only, at most the batch cap
-- rf.maxBatch, like the web's max); the text is sent as typed. Returns the input.
local function qty_field(self, row, r, x, y, w, h, off, max)
self.refine_qty = self.refine_qty or {}
local value = self.refine_qty[r.item] or "1"
local field = ui.rect(row, x, y, w, h, "field")
local edge = ui.outline(field, 0, 0, w, h, off and "line" or "line_hi")
local txt = ui.text(field, off and "–" or value, { font = "mono", px = 14, color = off and "faint" or "text" }, w / 2, h / 2, gui.PIVOT_CENTER)
if off then
return
end
gui.set_size(txt, vec((w - 12) / gui.get_scale(txt).x, theme.font_px * 1.4))
local input = self.druid:new_input(field, txt, gui.KEYBOARD_TYPE_NUMBER_PAD)
input:set_allowed_characters("[%d]")
input:set_max_length(max and #tostring(max) or 3)
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:set_text(value)
input.on_input_text:subscribe(function(_, text)
if max and (tonumber(text) or 0) > max then
input:set_text(tostring(max)) -- (comes back here with the cap)
return
end
self.refine_qty[r.item] = text
end)
test.register("field:qty:" .. r.item, field, "field", r.item)
return input
end
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- A recipe's description (catalog.refined).
local function description(st, item)
local function catalog_desc(st, item)
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
if r.name == item then
return r.description or ""
@@ -183,160 +96,604 @@ local function description(st, item)
return ""
end
-- One recipe; returns its height (the description wraps).
local function recipe_row(self, root, H, st, rf, r, x, y, w)
local row = ui.rect(root, x, y, w, 10, "none")
local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- Styles (app.css).
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
local BODY14 = { font = "sans", px = 14, color = "text" }
local MONO15 = { font = "mono", px = 15, color = "text" }
local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true }
local BTN_SM = 34 -- .btn-sm height
-- Tree pieces. Children lists may have holes (`cond and x or nil`).
local function box(style, kids, extra)
local list = {}
for i = 1, table.maxn(kids or {}) do
list[#list + 1] = kids[i]
end
local b = { style = style, children = list }
for k, v in pairs(extra or {}) do
b[k] = v
end
return b
end
local function row(style, kids) style.direction = "row"; return box(style, kids) end
local function col(style, kids) style.direction = "column"; return box(style, kids) end
local function space(w, h) return { style = { width = w or 0, height = h or 0, shrink = 0 } } end
local function fill() return { style = { grow = 1 } } end
-- An 8 px color square.
local function swatch(color)
return { style = { width = 8, height = 8, shrink = 0 }, make = function(p, x, y)
ui.rect(p, x, y, 8, 8, color)
end }
end
-- An invisible node holding its children (for a tooltip over all of them).
local function group(p, x, y, w, h)
return ui.rect(p, x, y, w, h, "none")
end
-- The build's scratch parent, for measuring labels of what is built at a size.
local scratch
local function text_w(str, style)
local t = ui.text(scratch, str, style, 0, 0)
local w = ui.text_width(t)
gui.delete_node(t)
return w
end
-- A button box (h tall; its label's width + pad unless style says), made at its rect
-- with ui.button opts, then bind(b, nodes) (H.action, tooltips). .btn:disabled: dimmed.
local function btn(label, h, opts, bind, style)
style = style or {}
style.height, style.shrink = h, 0
if not style.grow then
style.width = style.width or math.floor(text_w(label, BTN) + (opts.pad or 30))
end
return { style = style, make = function(p, x, y, w)
opts.w, opts.h = w, h
if opts.disabled then
opts.edge = { "line_hi", 0.45 }
if opts.primary then
opts.text = { "on_cyan", 0.45 }
else
opts.fill, opts.text = { "cyan", 0.045 }, { "text", 0.45 }
end
end
local b, nodes = ui.button(p, label, x, y, opts)
bind(b, nodes)
end }
end
-- A section label (.section-label: "Slots 2/4").
local function section_label(c, str, num)
return col({ padding = { 20, 0, 10, 0 } }, {
row({}, {
fg.label(c, num and (str .. " ") or str, "cap", 16.5),
num and fg.label(c, num, CAP_NUM, 16.5) or nil,
}),
})
end
-- The Slots label and, on its right, the batch size and its upgrade (the whole refinery:
-- every slot makes batches up to rf.maxBatch; upgrade-batch raises it to rf.nextBatch).
-- One button-high row whether or not the upgrade shows; the button keeps about one width
-- from level to level, so repeated clicks stay on it (clicker rule).
local function slots_label(self, H, c, rf)
local kids = {
row({ grow = 1, align = "center" }, {
fg.label(c, "Slots ", "cap", 16.5), fg.label(c, rf.slotCount .. "/" .. rf.maxSlots, CAP_NUM, 16.5),
}),
fg.label(c, "Batch size ", "cap", 16.5), fg.label(c, ui.money(rf.maxBatch), CAP_NUM, 16.5),
space(12),
}
if (rf.nextBatch or 0) > 0 then
local ok = rf.canUpgradeBatch
local label = "Upgrade to " .. ui.money(rf.nextBatch)
local opts = { small = true, price = rf.nextBatchPrice, disabled = not ok }
kids[#kids + 1] = fg.button(c, label, opts, function(b, nodes)
if not ok then
tooltip.add(b, rf.upgradeBatchWhyText or "")
else
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade-batch", {})
tooltip.add(b, "Every slot makes batches of up to " .. ui.money(rf.nextBatch))
end
end, { width = math.max(240, fg.button_w(c, label, opts)) })
else
kids[#kids + 1] = fg.label(c, "Biggest batches", "small", 20)
end
return col({ padding = { 20, 0, 10, 0 } }, { row({ align = "center", height = S.button_sm_h }, kids) })
end
---------------------------------------------------------------- slot cards
-- The web's .bar: 8 px of 6 px cells (2 px apart) in a faint cyan, the fill masked into
-- the same cells. The fill follows a hidden probe the timer ticks (H.tick_bar); M.update
-- lights the cells from its width.
-- ready: finished (in the state or on our clock); age: seconds since it finished, when
-- that was just now (the sweep).
local function cell_bar(self, H, color, job, ready, age)
return { style = { height = 8 }, make = function(p, x, y, w)
local cells = {}
for cx = 0, w - 1, 8 do
local cw = math.min(6, w - cx)
ui.rect(p, x + cx, y, cw, 8, "cyan", 0.14)
cells[#cells + 1] = { node = ui.rect(p, x + cx, y, cw, 8, color), x = cx, w = cw }
end
local bar = { cells = cells, full = ready, w = w, color = ui.color(color) }
if not ready then
bar.probe = ui.rect(p, x, y, w, 8, "none")
H.tick_bar(self, bar.probe, job.start, job["end"], w)
elseif age and age < SWEEP and not anim.reduced then
bar.sweep = socket.gettime() - age
end
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
self.rf_slots[job.id].bar = ui.rect(p, x, y, w, 8, "none") -- (fx: where its sparks leave)
self.rf_bars[#self.rf_bars + 1] = bar
end }
end
-- The sweep: a bright head runs left to right over the full cells, lighting each to the
-- flash color as it passes, then the cells settle back to the item's color.
local function sweep_cells(b)
local k = (socket.gettime() - b.sweep) / SWEEP
if k >= 1 then
b.sweep = nil
for _, c in ipairs(b.cells) do
gui.set_color(c.node, b.color)
end
return
end
local head = 1.3 * (1 - (1 - k) ^ 2) - 0.15
local flash = theme.color.flash
for _, c in ipairs(b.cells) do
local d = (c.x + c.w / 2) / b.w
local lit = math.max(0, 1 - math.abs(d - head) * 7) + (d < head and 0.35 * (1 - k) or 0)
gui.set_color(c.node, vmath.lerp(math.min(1, lit), b.color, vmath.vector4(flash.x, flash.y, flash.z, b.color.w)))
end
end
local function light_cells(b)
local fw = b.full and math.huge or gui.get_size(b.probe).x
for _, c in ipairs(b.cells) do
local lit = math.max(0, math.min(c.w, fw - c.x))
gui.set_enabled(c.node, lit > 0)
if lit > 0 then
gui.set_size(c.node, vmath.vector3(lit, 8, 0))
end
end
end
function M.update(self)
for _, b in ipairs(self.rf_bars or {}) do
if not b.full then
light_cells(b)
elseif b.sweep then
sweep_cells(b)
end
end
end
function M.teardown(self)
self.rf_bars = nil -- (rf_now stays: the next build glides the batches from it)
self.rf_slots = nil
end
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off).
local function dashed(p, w, h, color, alpha)
for d = 0, w - 1, 5 do
ui.rect(p, d, 0, math.min(3, w - d), 1, color, alpha)
ui.rect(p, d, h - 1, math.min(3, w - d), 1, color, alpha)
end
for d = 0, h - 1, 5 do
ui.rect(p, 0, d, 1, math.min(3, h - d), color, alpha)
ui.rect(p, w - 1, d, 1, math.min(3, h - d), color, alpha)
end
end
-- An inset glow (box-shadow: inset 0 0 18px): the fade texture along each edge, inwards.
local function inset_glow(p, w, h, color, alpha)
local d = 14
local nodes = {}
for _, e in ipairs({ { w / 2, d / 2, w, 0 }, { w / 2, h - d / 2, w, 180 }, { d / 2, h / 2, h, 90 }, { w - d / 2, h / 2, h, -90 } }) do
local n = ui.rect(p, 0, 0, e[3], d, color, alpha)
gui.play_flipbook(n, "fade")
gui.set_pivot(n, gui.PIVOT_CENTER)
gui.set_position(n, vmath.vector3(e[1], -e[2], 0))
gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4])))
gui.set_blend_mode(n, theme.glow_blend())
nodes[#nodes + 1] = n
end
return nodes
end
-- A panel frame (fill and 1 px edge), registered for the entrance and the action sweep.
local function frame(self, p, x, y, w, h, edge_color)
local f = ui.rect(p, x, y, w, h, "panel")
local edge = ui.outline(f, 0, 0, w, h, edge_color)
self.frames[#self.frames + 1] = panel.frame(f, edge, w, h)
self.fx:box(f)
return f, edge
end
-- A slot (.refinery-slot, at least 112 tall, padding 12 + border): idle, running or ready.
-- 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 age = job and require(READY).batch_age(self, job)
local ready = job and require(READY).batch_done(self, job) -- (on our clock: display only)
local edge = job and (ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
local outer -- (the card's slot node, made first: the Collect button's hit box goes in it)
local kids
if not job then
kids = { fg.label(c, "Slot " .. n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
else
local head = fg.label(c, "Slot " .. n, "cap", 22)
local right
if ready then
right = { style = { height = 22 }, make = function(p, x, y, w, h)
local t = require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
pop(t, -ui.text_width(t) / 2, 0, age)
end }
else
local left = function(ms) return ui.dur(ms) .. " left" end
right = fg.label(c, left(job["end"] - H.now(self)), { font = "mono", px = 15, color = "muted" }, 22)
gui.set_pivot(right.node, gui.PIVOT_E) -- (it ticks: pinned by its right edge)
H.tick_text(self, right.node, job["end"], left, "…")
end
local color = item_color(st, job.item)
kids = {
row({ height = 22.5, justify = "space-between", align = "center" }, { head, right }),
space(0, 8),
row({ height = 22, align = "center", gap = 12 }, {
swatch(color),
row({ align = "center", gap = 4 }, {
fg.label(c, job.qty .. "×", { font = "mono_semi", px = 15, color = "text" }, 22, { shrink = 0 }),
fg.label(c, job.item, { font = "sans", px = 15, color = "text" }, 22, { fit = true }),
}),
}),
space(0, 8.5),
cell_bar(self, H, color, job, ready, age),
{ style = { grow = 1, min_height = ready and 8 or 0 } },
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes)
local p, size = gui.get_position(b), gui.get_size(b)
local hit = ui.rect(outer, p.x, -p.y, size.x, size.y, "none")
H.action(self, b, nodes, "collect", { job = job.id }, nil, nil, nil, hit)
pop(b, size.x / 2, -size.y / 2, age)
end, { width = "100%" }) or nil,
}
end
return col({ padding = 13, min_height = SLOT_H }, kids), function(p, x, y, w, h)
outer = ui.rect(p, x, y, w, h, "none")
local f = frame(self, outer, 0, 0, w, h, edge)
if ready then
local glow = inset_glow(f, w, h, "ready", 0.08)
if age and age < FLARE then
-- Just finished: the glow flares and settles to its rest.
anim.tween(glow[1], FLARE, function(k)
local a = 0.08 + 0.32 * (1 - k) ^ 2
for _, n in ipairs(glow) do
anim.set(n, "color.w", a)
end
end, -age)
end
end
if job then
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
self.rf_slots[job.id].frame = f
-- Where the card is drawn now (its last rect, less what its glide has done).
local was = self.rf_was and self.rf_was[job.id]
local now = socket.gettime()
local fx, fy = 0, 0
if was and not anim.reduced then
local u = math.min(1, (now - was.t) / GLIDE)
local k = 1 - (1 - u) * (1 - u) -- (EASING_OUTQUAD)
fx = was.x + was.fx * (1 - k) - x
fy = was.y + was.fy * (1 - k) - y
if math.abs(fx) < 0.5 and math.abs(fy) < 0.5 then
fx, fy = 0, 0
end
end
self.rf_now[job.id] = { x = x, y = y, fx = fx, fy = fy, t = now }
if fx ~= 0 or fy ~= 0 then
gui.set_position(f, vmath.vector3(fx, -fy, 0))
gui.animate(f, "position", vmath.vector3(0, 0, 0), gui.EASING_OUTQUAD, GLIDE)
end
end
return f
end
end
-- "+ Slot n · ₥price" (.btn-dashed): a dashed cyan edge, the label centered in text_2.
local function buy_slot(self, H, c, rf)
local off = not rf.canBuySlot
local k = off and 0.45 or 1 -- (.btn:disabled opacity)
local label = {
fg.label(c, "+ Slot " .. rf.nextSlot .. " · ", { font = "sans_semi", px = 15, color = ui.color("text_2", k), tracking = 0.12, upper = true }, 22),
fg.label(c, theme.currency, { font = "sym", px = 14, color = ui.color("cyan", k) }, 22),
fg.label(c, ui.money(rf.nextSlotPrice), { font = "mono_semi", px = 15, color = ui.color("cyan", k) }, 22),
}
return box({ justify = "center", align = "center" }, { box({}, label, { make = anim.group }) }, {
make = function(p, x, y, w, h)
local b, nodes = ui.button(p, "", x, y, { w = w, h = h, disabled = off, fill = { "cyan", 0 }, edge = { "cyan", 0 } })
dashed(b, w, h, off and "line_hi" or "cyan", k)
local look = ui.button_looks[b]
for _, l in ipairs(look and label or {}) do
look.parts[#look.parts + 1] = { l.node, 0 } -- (dimmed while busy)
end
if off then
tooltip.add(b, (rf.buySlotWhyText or "") ~= "" and rf.buySlotWhyText or ("Short " .. ui.money(rf.nextSlotPrice - self.state.credits)))
elseif rf.buySlot then
cards.confirm_server_button(self, b, rf.buySlot, "buy-slot", {}) -- (asks first, the web's hx-confirm)
else
H.action(self, b, nodes, "buy-slot", {})
end
return b
end,
})
end
---------------------------------------------------------------- recipe cards
-- The quantity field (.input-sm: Druid input, digits only, at most the batch cap
-- rf.maxBatch); the text is sent as typed. Disabled: dimmed, no input. Returns its box and
-- a function giving the Druid input once made.
local function qty_field(self, r, off, max)
local made
return { style = { width = 64, height = BTN_SM, shrink = 0 }, make = function(p, x, y, w, h)
self.refine_qty = self.refine_qty or {}
local value = self.refine_qty[r.item] or "1"
local k = off and 0.45 or 1
local field = ui.rect(p, x, y, w, h, "field", k)
local edge = ui.outline(field, 0, 0, w, h, "line_hi", k)
local txt = ui.text(field, value, { font = "mono", px = 15, color = ui.color("text", k) }, 13, h / 2)
if off then
return
end
gui.set_size(txt, vmath.vector3((w - 16) / gui.get_scale(txt).x, theme.font_px * 1.4, 0))
local input = self.druid:new_input(field, txt, gui.KEYBOARD_TYPE_NUMBER_PAD)
input:set_allowed_characters("[%d]")
input:set_max_length(max and #tostring(max) or 3)
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:set_text(value)
input.on_input_text:subscribe(function(_, text)
if max and (tonumber(text) or 0) > max then
input:set_text(tostring(max)) -- (comes back here with the cap)
return
end
self.refine_qty[r.item] = text
end)
test.register("field:qty:" .. r.item, field, "field", r.item)
made = input
end }, function() return made end
end
-- A stat (.stat: a cap label over a 15 px value).
local function stat(c, label, value)
return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
end
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
-- a collect's motes land there and the number counts up. Laid out at the final amount.
local counted = {}
local function own_label(c, r, color)
local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
return l
end
-- One recipe card (the web's .card.recipe).
local function recipe_card(self, H, c, st, rf, r)
local color = item_color(st, r.item)
local off = r.why ~= ""
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
local owned = (r.owned or 0) > 0
-- Line 1: glyph, name, tier and what you own (the web's "Tier N · owned X"); controls
-- on the right (quantity, Refine, Max). Why it can't run is the buttons' tooltip.
glyph(row, 9, 17, 11, color)
local name = ui.text(row, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.08, upper = true }, 26, 17)
local tx = 26 + ui.text_width(name) + 7
local tier = ui.text(row, "Tier " .. r.tier, { font = "mono", px = 11, color = "cyan" }, tx, 17)
tx = tx + ui.text_width(tier) + 4
local own
if (r.owned or 0) > 0 then
own = ui.text(row, "· owned " .. ui.money(r.owned), { font = "sans", px = 13, color = "muted" }, tx, 17)
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
-- the name, and on the right what you own, big, in the item's color (a collect's motes
-- fly into it and it counts up), shown at 0 too.
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon")
gui.play_flipbook(ui.rect(g, 0, 0, 36, 36, "white", 0.25), "pentagon_rim")
end }
local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, shrink = 1, align = "start" }, {
fg.label(c, "Tier " .. r.tier, "cap", 24),
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
}),
col({ align = "end", shrink = 0 }, {
fg.label(c, "Owned", "cap", 14),
own_label(c, r, owned and color or "muted"),
}),
})
-- Inputs per unit: need and name, what you hold on the right (red when short).
local inputs = {}
for _, input in ipairs(r.inputs) do
local have = fg.label(c, ui.money(input.have), { font = "mono", px = 14, color = input.have < input.need and "danger" or "text" }, 21, { shrink = 0 })
tooltip.add(have.node, "You have " .. ui.money(input.have))
inputs[#inputs + 1] = row({ height = 21, align = "center", gap = 8 }, {
swatch(item_color(st, input.name)),
fg.label(c, ui.money(input.need) .. " " .. input.name, BODY14, 21, { grow = 1, fit = true }),
have,
})
end
local bh = S.button_sm_h
local by = 2
local mx, mn, mw = ui.button(row, off and "Max" or ("Max " .. r.maxQty), 0, 0, { small = true, disabled = off })
gui.set_position(mx, vec(w - mw, -by))
local rb, rn, rw = ui.button(row, "Refine", 0, 0, { small = true, primary = true, disabled = off })
gui.set_position(rb, vec(w - mw - 6 - rw, -by))
local fw = 48
local input = qty_field(self, row, r, w - mw - 6 - rw - 6 - fw, by, fw, bh, off, rf.maxBatch)
if own then
ui.fit_text(own, w - mw - 6 - rw - 6 - fw - 10 - tx) -- (left of the quantity, Refine and Max)
-- Stats: three equal columns.
local gain = fg.label(c, string.format("+%.0f%%", r.gain), { font = "mono", px = 15, color = "cyan" }, 22.5)
if r.inputValue then
tooltip.add(gain.node, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
end
if not off then
local refine = H.action(self, rb, rn, "refine", function()
local stats = row({ gap = 8 }, {
stat(c, "Time each", fg.label(c, ui.cycle(r.seconds), MONO15, 22.5)),
stat(c, "Sells", row({ gap = 2 }, {
fg.label(c, theme.currency, { font = "sym", px = 14, color = "text" }, 22.5),
fg.label(c, ui.money(r.price or r.sellPrice), MONO15, 22.5),
})),
stat(c, "Gain", gain),
})
-- "Batch of 25: 7s · bulk bonus −72%" (.text-xs, numbers in mono), one tooltip.
local xs = { font = "sans", px = 11.25, color = "muted" }
local xn = { font = "mono", px = 11.25, color = "muted" }
local batch = box({ height = 16 }, {
fg.label(c, "Batch of ", xs, 16), fg.label(c, tostring(r.bulkQty), xn, 16), fg.label(c, ": ", xs, 16),
fg.label(c, ui.cycle(r.bulkSecs), xn, 16), fg.label(c, " · bulk bonus ", xs, 16),
fg.label(c, string.format("−%.0f%%", r.bulkBonus), { font = "mono", px = 11.25, color = "cyan" }, 16),
}, { make = function(p, x, y, w, h)
local n = group(p, x, y, w, h)
tooltip.add(n, "Bigger batches take less time per unit")
return n
end })
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
-- usual click) the card's width under them, primary.
local qty, input = qty_field(self, r, off, rf.maxBatch)
qty.style.width, qty.style.grow = nil, 1
local refine
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end)
H.action(self, mx, mn, "refine", { recipe = r.item, all = "1" })
tooltip.add(mx, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
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).
self.inputs = self.inputs or {}
self.inputs[#self.inputs + 1] = { input = input, submit = refine, step = { min = 1, max = rf.maxBatch } }
else
tooltip.add(rb, why)
tooltip.add(mx, why)
end
local cy = 32
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end)
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
end, { grow = 1 })
local form = col({ shrink = 0 }, {
row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
})
-- The description (wraps).
local desc = description(st, r.item)
if desc ~= "" then
cy = cy + H.para(row, desc, { font = "sans", px = 13, color = "muted" }, 26, cy, w - 26) + 6
local desc = catalog_desc(st, r.item)
return col({ padding = 17 }, {
head,
desc ~= "" and space(0, 8) or nil,
desc ~= "" and fg.para(c, desc, { font = "sans", px = 14, color = "text_2" }, 19.6) or nil,
space(0, 8),
col({ gap = 4 }, inputs),
space(0, 8),
fill(), -- (the footer at the bottom: buttons line up across a row)
col({ height = 162 }, { -- (rule, stats, batch line, the form's two rows)
{ style = { height = 1 }, make = function(p, x, y, w) ui.rect(p, x, y, w, 1, "line") end },
space(0, 12), stats, space(0, 8), batch, fill(), space(0, 8), form,
}),
}), function(p, x, y, w, h)
-- The card (.card): edge brightens on hover (0.15 s); bound before the footer's
-- buttons (Druid gives the last made input first).
local f, edge = frame(self, p, x, y, w, h, "edge")
local base = gui.get_color(edge)
button.bind(self, f, nil, {}).hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(edge, "color")
gui.animate(edge, "color", on and ui.color("line_hi") or base, gui.EASING_OUTQUAD, 0.15)
end)
return f
end
-- Inputs per unit (need, then what you hold; red when short).
local ix = 26
for _, input in ipairs(r.inputs) do
ui.rect(row, ix, cy + 4, 7, 7, item_color(st, input.name))
local t = ui.text(row, ui.money(input.need) .. " " .. input.name, { font = "sans", px = 14, color = "text_2" }, ix + 12, cy + 8)
local have = ui.text(row, ui.money(input.have), { font = "mono", px = 12, color = input.have < input.need and "danger" or "muted" },
ix + 12 + ui.text_width(t) + 5, cy + 8)
tooltip.add(have, "You have " .. ui.money(input.have))
ix = ix + 12 + ui.text_width(t) + 5 + ui.text_width(have) + 16
end
cy = cy + 21
-- The bulk batch (server numbers); time each, sell price and gain on the right.
local bulk = ui.text(row, string.format("Batch of %d: %s · bulk bonus −%.0f%%", r.bulkQty, ui.cycle(r.bulkSecs), r.bulkBonus),
{ font = "sans", px = 13, color = "muted" }, 26, cy + 8)
tooltip.add(bulk, "Bigger batches take less time per unit")
local gain = ui.text(row, string.format("+%.0f%%", r.gain), { font = "mono", px = 12, color = "up" }, w, cy + 8, gui.PIVOT_E)
if r.inputValue then
tooltip.add(gain, "Inputs sell for " .. theme.currency .. ui.money(r.inputValue))
end
local sw = ui.credits(row, r.price or r.sellPrice, w - ui.text_width(gain) - 10, cy + 8, { pivot = gui.PIVOT_E })
tooltip.add(ui.rect(row, w - ui.text_width(gain) - 10 - sw, cy, sw, 16, "none"), "Sells for")
local each = ui.text(row, ui.cycle(r.seconds) .. " each", { font = "mono", px = 12, color = "muted" }, w - ui.text_width(gain) - 10 - sw - 10, cy + 8, gui.PIVOT_E)
tooltip.add(each, "Time each")
ui.fit_text(bulk, w - ui.text_width(gain) - 10 - sw - 10 - ui.text_width(each) - 12 - 26)
cy = cy + 24
ui.rect(row, 0, cy - 1, w, 1, "line")
gui.set_size(row, vec(w, cy))
return cy
end
---------------------------------------------------------------- the page
function M.build(self, root, px, py, w, h, H)
local st = self.state
local rf = st.refinery
local inner, x = frame(w, px, S.refinery_max)
self.rf_bars = {}
self.rf_slots = {} -- by batch id: { frame, bar } (fx.lua's batch_ready)
self.frames = self.frames or {}
local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge)
local c = area.content
scratch = c
local cy = H.heading(root, x, py, "Refinery")
if rf.ready > 1 then
local b, nodes, bw = ui.button(root, "Collect all · " .. rf.ready, 0, 0, { primary = true })
gui.set_position(b, vec(x + inner - bw, -(py + 4)))
H.action(self, b, nodes, "collect", {})
end
cy = cy + H.para(root, "Refined items are needed for higher tool upgrades.", { font = "sans", px = 15, color = "text_2" }, x, cy - 6, inner) + 10
-- Heading (.heading: h1 and its line; Collect all at the bottom right).
local heading = row({ align = "end" }, {
col({ grow = 1, align = "start" }, {
fg.label(c, "Refinery", "h1", 41.4),
space(0, 4),
fg.para(c, "Refined items are needed for higher tool upgrades.", { font = "sans", px = 15, color = "text_2" }, 22.5, { max_width = 470 }),
}),
rf.ready > 1 and btn("Collect all · " .. rf.ready, S.button_h, { primary = true, pad = 40 }, function(b, nodes)
H.action(self, b, nodes, "collect", {})
end) or nil,
})
-- Refined items owned (the web's .refinery-stock chips), wrapping.
local sx, any = x, false
for _, s in ipairs(rf.stock) do
if s.amount > 0 then
local chip = ui.rect(root, sx, cy, 1, 20, "none")
ui.rect(chip, 0, 6, 8, 8, item_color(st, s.name))
local t = ui.text(chip, s.name, { font = "sans_semi", px = 14, color = "text" }, 14, 10)
local n = ui.text(chip, ui.money(s.amount), { font = "mono", px = 13, color = "text" }, 14 + ui.text_width(t) + 5, 10)
local cw = 14 + ui.text_width(t) + 5 + ui.text_width(n)
if sx > x and sx + cw > x + inner then
sx, cy = x, cy + 24
gui.set_position(chip, vec(sx, -cy))
end
gui.set_size(chip, vec(cw, 20))
tooltip.add(chip, s.name)
sx, any = sx + cw + 16, true
end
end
if any then
cy = cy + 28
end
-- Slots: one row of cards (plus "+ Slot n" while more can be bought).
local cap = ui.text(root, "Slots", "cap", x, cy + 6)
ui.text(root, rf.slotCount .. "/" .. rf.maxSlots, { font = "mono", px = 12, color = "muted" }, x + ui.text_width(cap) + 8, cy + 6)
cy = cy + 20
local n = #rf.slots + ((rf.nextSlotPrice or 0) > 0 and 1 or 0)
local gap = 12
local cw = math.min(S.slot_w_max, math.floor((inner - gap * (n - 1)) / n))
-- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
local slots = {}
local order = {}
for i, slot in ipairs(rf.slots) do
slot_card(self, root, H, st, slot, x + (i - 1) * (cw + gap), cy, cw)
order[i] = { slot = slot, i = i }
end
table.sort(order, function(a, b)
local ja, jb = a.slot.job, b.slot.job
if (ja ~= nil) ~= (jb ~= nil) then
return ja ~= nil
end
local sa, sb = ja and ja.start or 0, jb and jb.start or 0
if sa ~= sb then
return sa < sb
end
return a.i < b.i
end)
self.rf_was, self.rf_now = self.rf_now, {}
for n, o in ipairs(order) do
local tree, make = slot_card(self, H, c, st, o.slot, n)
tree.make = make
slots[#slots + 1] = tree
end
if (rf.nextSlotPrice or 0) > 0 then
buy_slot_card(self, root, H, rf, x + #rf.slots * (cw + gap), cy, cw)
local b = buy_slot(self, H, c, rf)
b.style.min_height = SLOT_H
slots[#slots + 1] = b
end
cy = cy + S.slot_h + 18
-- Recipes: a row each; two columns when one doesn't fit the panel's height.
ui.text(root, "Recipes", "cap", x, cy + 6)
cy = cy + 16
local rows = #rf.recipes
local cols = 1
local rh = S.recipe_row_h
if cy + rows * rh > h - py and inner >= 900 then
cols = 2
-- Recipes (.cards).
local recipes = {}
counted = {}
for _, r in ipairs(rf.recipes) do
local tree, make = recipe_card(self, H, c, st, rf, r)
tree.make = make
recipes[#recipes + 1] = tree
end
local colw = math.floor((inner - (cols - 1) * 24) / cols)
local per = math.ceil(rows / cols)
-- The rows scroll when they still don't fit (short windows), like the web's page.
local area = scroll.area(self, root, 0, cy, w, h - cy - 6, "recipes")
local col_y, bottom = {}, 0
for i, r in ipairs(rf.recipes) do
local c = math.floor((i - 1) / per)
local ry = col_y[c] or 0
col_y[c] = ry + recipe_row(self, area.content, H, st, rf, r, x + c * (colw + 24), ry, colw)
bottom = math.max(bottom, col_y[c])
local page = col({ gap = 18, width = inner }, {
heading,
slots_label(self, H, c, rf),
flex.grid(inner, 200, 12, slots),
section_label(c, "Recipes"),
flex.grid(inner, S.card_col_min, S.gap, recipes),
})
fg.build(flex.solve(page, x, y), c)
-- The recipes' owned numbers count what a collect brings (fx.lua collected).
for _, e in ipairs(counted) do
self.fx:bind(e.key, e.node)
gui.set_text(e.node, self.fx:text(e.key, e.amount))
end
area:finish(page.y + page.h + py)
for _, b in ipairs(self.rf_bars) do
light_cells(b)
end
area:finish(bottom + 4)
end
return M
File diff suppressed because it is too large. Load diff
+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
+168 -39
View File
@@ -1,61 +1,190 @@
-- Display settings (the header's display button): the UI scale (window pixels per layout
-- unit, times the display's own scale; the browser's zoom) and fullscreen. Both are saved
-- in the settings file (modules/screen). Keys: Ctrl + / Ctrl - / Ctrl 0, F11 (core/keys).
-- The settings menu (the header's gear): a dropdown (components/dialog `menu`) under the
-- gear, a text button per setting or action with its icon on the right: How to play (the
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
-- admins and in debug builds.
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
local ui = require("modules.ui")
local screen = require("modules.screen")
local sfx = require("modules.sfx")
local theme = require("modules.theme")
local anim = require("modules.anim")
local M = {}
local W = 260
local ROW_H = 34
local PAD = 6
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function pct(s)
return string.format("%d%%", math.floor(s * 100 + 0.5))
end
function M.open(g)
-- Icons, drawn in a 20x20 box at (x, y) of p.
local ICONS = {
help = function(p, x, y)
ui.outline(p, x, y, 20, 20, "nav")
ui.text(p, "?", { font = "sans_semi", px = 14, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
scale = function(p, x, y)
ui.text(p, "A", { font = "sans_semi", px = 11, color = "nav" }, x + 5, y + 13, gui.PIVOT_CENTER)
ui.text(p, "A", { font = "sans_semi", px = 17, color = "nav" }, x + 14, y + 11, gui.PIVOT_CENTER)
end,
fullscreen = function(p, x, y)
for _, c in ipairs({ { 1, 1, 1, 1 }, { 13, 1, -1, 1 }, { 1, 13, 1, -1 }, { 13, 13, -1, -1 } }) do
local cx, cy = x + c[1] + (c[3] < 0 and 6 or 0), y + c[2] + (c[4] < 0 and 6 or 0)
ui.rect(p, c[3] > 0 and cx or cx - 6, c[4] > 0 and cy or cy - 1, 6, 2, "nav")
ui.rect(p, c[3] > 0 and cx or cx - 1 - 1, c[4] > 0 and cy or cy - 6, 2, 6, "nav")
end
end,
sound = function(p, x, y)
local icon = ui.rect(p, x, y, 20, 20, sfx.muted() and "muted" or "cyan")
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
end,
theme = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "nav")
gui.play_flipbook(icon, theme.light and "moon" or "sun")
end,
motion = function(p, x, y)
local lit = anim.reduced and "muted" or "nav"
for i, l in ipairs({ { 4, 5, 14 }, { 1, 9, 18 }, { 6, 13, 11 } }) do
ui.rect(p, x + l[1], y + l[2] + (i - 2), l[3], 2, lit)
end
if anim.reduced then
local slash = ui.rect(p, x, y, 20, 2, "muted")
gui.set_pivot(slash, gui.PIVOT_CENTER)
gui.set_position(slash, vec(x + 10, -(y + 10)))
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
end
end,
-- A floppy disk; restore: the disk with an arrow into it.
save = function(p, x, y)
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
ui.rect(p, x + 6, y + 2, 8, 5, "nav")
ui.rect(p, x + 5, y + 11, 10, 5, "nav")
end,
restore = function(p, x, y)
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
lab = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
gui.play_flipbook(icon, "moon")
end,
grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
}
-- A menu row: a text button the menu's width, the label left, the icon right. Returns the
-- button's label node.
local function item(d, box, y, label, icon, id, on_click, opts)
opts = opts or {}
local w = W - PAD * 2
local b, nodes = d:button(box, label, PAD, y, { variant = "ghost", w = w, h = ROW_H, id = id, disabled = opts.disabled,
fill = { "cyan", 0 }, edge = { "cyan", 0 }, on_click = on_click })
local t = ui.part(nodes, "button", "text")
ui.style_text(t, { font = "sans", px = 14, color = opts.disabled and "muted" or "text" }, label)
gui.set_pivot(t, gui.PIVOT_W)
gui.set_position(t, vec(12, -ROW_H / 2))
ICONS[icon](b, w - 12 - 20, (ROW_H - 20) / 2)
return t, b
end
-- Opens the menu with its top-right corner at `right` from the page's right edge, `top`
-- down (under the gear).
function M.open(g, right, top)
local st = g.state
local help = st and st.unlocked and st.unlocked.help
local admin = require("screens.game.admin")
local lab = st and admin.allowed(g)
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
local h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({
title = "Display",
w = 440,
h = 340,
build = function(d, box, w)
local x, y = 24, 64
local value
local function show()
ui.style_text(value, { font = "mono", px = 15, color = "text" }, pct(screen.ui_scale))
w = W,
h = h,
menu = { right = right or 16, top = top or 60 },
build = function(d, box)
local y = PAD
local function close_then(fn)
return function()
d:close("quiet")
fn()
end
end
local function toggled()
g.dirty, g.dirty_all = true, true
end
if help then
item(d, box, y, "How to play", "help", "menu:help", close_then(function()
g.h.navigate(g, { section = "help", moon = g.view.moon })
end))
y = y + ROW_H
end
-- UI scale: the row's label shows it; − and + on its right (the icon's place).
local w = W - PAD * 2
-- (Ctrl 0 resets it)
local t = ui.text(box, "", { font = "sans", px = 14, color = "text" }, PAD + 12, y + ROW_H / 2)
ICONS.scale(box, PAD + w - 12 - 20, y + (ROW_H - 20) / 2)
local function show()
ui.style_text(t, { font = "sans", px = 14, color = "text" }, "UI scale " .. pct(screen.ui_scale))
end
show()
local function step(dir)
return function()
screen.step_ui_scale(dir)
show()
end
end
ui.text(box, "UI scale", "cap", x, y + 6)
ui.text(box, "Bigger text and controls; the window keeps its size.", { font = "sans", px = 14, color = "muted" }, x, y + 26)
local by = y + 44
local _, _, mw = d:button(box, "−", x, by, { w = 40, id = "dialog:scale-down", on_click = step(-1) })
value = ui.text(box, "", { font = "mono", px = 15, color = "text" }, x + mw + 34, by + 18, gui.PIVOT_CENTER)
show()
local _, _, pw = d:button(box, "+", x + mw + 68, by, { w = 40, id = "dialog:scale-up", on_click = step(1) })
d:button(box, "Reset", x + mw + 68 + pw + 10, by, { id = "dialog:scale-reset", on_click = step(0) })
y = by + 58
ui.text(box, "Fullscreen", "cap", x, y + 6)
ui.text(box, screen.can_fullscreen() and "F11 switches it too." or "Not available in this build.",
{ font = "sans", px = 14, color = "muted" }, x, y + 26)
local fnodes
local function label()
return screen.fullscreen() and "Leave fullscreen" or "Go fullscreen"
d:button(box, "+", PAD + w - 40 - 40, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-up", on_click = step(1) })
d:button(box, "−", PAD + w - 40 - 80, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-down", on_click = step(-1) })
y = y + ROW_H
item(d, box, y, screen.fullscreen() and "Leave fullscreen" or "Fullscreen", "fullscreen", "dialog:fullscreen",
close_then(screen.toggle_fullscreen), { disabled = not screen.can_fullscreen() })
y = y + ROW_H
item(d, box, y, sfx.muted() and "Sound: off" or "Sound: on", "sound", "mute", close_then(function()
sfx.toggle_mute()
toggled()
end))
y = y + ROW_H
item(d, box, y, theme.light and "Dark theme" or "Light theme", "theme", "theme", close_then(function()
theme.toggle()
toggled()
end))
y = y + ROW_H
item(d, box, y, anim.reduced and "Reduced motion: on" or "Reduced motion: off", "motion", "motion", close_then(function()
anim.set_reduced_motion(not anim.reduced)
sfx.play("toggle")
toggled()
end))
y = y + ROW_H
-- Save codes (savecode.lua): progress on another device.
item(d, box, y, "Save code", "save", "menu:save-code", close_then(function()
require("screens.game.savecode").open_save(g)
end), { disabled = not st })
y = y + ROW_H
item(d, box, y, "Restore from code", "restore", "menu:restore", close_then(function()
require("screens.game.savecode").open_restore(g)
end), { disabled = not st })
y = y + ROW_H
if st and st.admin then
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
require("screens.game.grant").open(g)
end))
y = y + ROW_H
end
if lab then
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
admin.open(g, "moonlab")
end), { disabled = g.reveal:active() })
end
fnodes = select(2, d:button(box, label(), x, y + 44, { w = 180, id = "dialog:fullscreen", disabled = not screen.can_fullscreen(),
on_click = function()
screen.toggle_fullscreen()
-- (the label changes: centered again in the button)
local t = ui.part(fnodes, "button", "text")
local color = gui.get_color(t)
ui.style_text(t, { font = "sans_semi", px = 15, color = "text", tracking = 0.12, upper = true }, label())
local p = gui.get_position(t)
gui.set_position(t, vmath.vector3(math.floor((180 - ui.text_width(t)) / 2), p.y, p.z))
gui.set_color(t, color)
end }))
end,
})
end
+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
+136 -104
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
-- (the response's buyModal: up to 3 wells to deploy it on).
@@ -8,6 +8,9 @@ local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards")
local card = require("components.card")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local vec = cards.vec
@@ -35,59 +38,89 @@ end
-- The buy modal after buy-tool (popup): the new tool and the best wells for it.
local function buy_modal(self, modal, item)
local st = self.state
local rows = modal.suggestions or {}
local empty = modal.noMoons or modal.allEmpty or #rows == 0
local h = 92 + (empty and 70 or (22 + #rows * 58)) + 72
local rows_ = modal.suggestions or {}
local empty = modal.noMoons or modal.allEmpty or #rows_ == 0
local h = 92 + (empty and 70 or (22 + #rows_ * 58)) + 72
local function build(p, box, W)
-- Head: the picture left of the title (dialog title_x) and the yield line under it.
cards.image(box, item and item.image, 24, 14, 56)
ui.text(box, "+" .. ui.money(modal.yield) .. " per " .. ui.cycle(modal.cycleSecs) .. " cycle"
.. (cards.reach(st, modal.reach) ~= "" and (" · " .. cards.reach(st, modal.reach)) or ""),
{ font = "mono", px = 13, color = "muted" }, 24 + 56 + 14, 60)
local y = 92
local kids = {
fg.row({ height = 56, align = "start" }, {
fg.made({ width = 56, height = 56, shrink = 0 }, function(q, x, y) cards.image(q, item and item.image, x, y, 56) end),
fg.space(14),
fg.col({ padding_top = 38 }, {
fg.label(box, "+" .. ui.money(modal.yield) .. " per " .. ui.cycle(modal.cycleSecs) .. " cycle"
.. (cards.reach(st, modal.reach) ~= "" and (" · " .. cards.reach(st, modal.reach)) or ""),
{ font = "mono", px = 13, color = "muted" }, 16),
}),
}),
fg.space(0, 22),
}
local function add(b)
kids[#kids + 1] = b
end
if empty then
local msg_ = modal.noMoons and "You don't own a moon yet. Claim one to start mining."
or "All your wells are empty. Claim a new moon for fresh supply."
cards.para(box, msg_, { font = "sans", px = 15, color = "text_2" }, 24, y, W - 48)
local bx = 24
if modal.noMoons then
local _, _, mw = p:button(box, "Moons", bx, h - 24 - S.button_h, { id = "dialog:moons",
on_click = function()
self.dialog:close("cancel")
self.h.navigate(self, { section = "moons", moon = self.view.moon })
end,
})
bx = bx + mw + 10
end
add(fg.text_para(box, msg_, { font = "sans", px = 15, color = "text_2" }))
add(fg.fill())
local short = st.credits < st.moonPrice
local _, _, bw = p:button(box, "Claim moon", bx, h - 24 - S.button_h, {
primary = true, price = st.moonPrice, disabled = short, id = "dialog:buy-moon",
on_click = function() p.reply("buy-moon", {}) end,
})
if short then
-- Why it's off (the web's title; tooltips don't show over a dialog).
M.need_text(box, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", bx + bw, h - 24 - S.button_h - 12)
local buttons = {}
if modal.noMoons then
buttons[#buttons + 1] = fg.made({ width = fg.button_w(box, "Moons", {}), height = S.button_h, shrink = 0 }, function(q, x, y)
p:button(q, "Moons", x, y, { id = "dialog:moons",
on_click = function()
self.dialog:close("cancel")
self.h.navigate(self, { section = "moons", moon = self.view.moon })
end,
})
end)
end
return bx - 24 + bw
end
ui.text(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 24, y + 6)
y = y + 22
for _, sg in ipairs(rows) do
local row = ui.rect(box, 24, y, W - 48, 50, "cyan", 0.04)
ui.outline(row, 0, 0, W - 48, 50, "line")
ui.rect(row, 12, 14, 8, 8, sg.resource)
local n = ui.text(row, sg.resource, { font = "sans_semi", px = 15, color = "text" }, 28, 18)
ui.label(row, "· " .. sg.moonName, { font = "sans", px = 14, color = "muted" }, 28 + ui.text_width(n) + 6, 18)
ui.label(row, (sg.reasonText or REASONS[sg.reason] or sg.reason) .. " · " .. ui.money(sg.supply) .. " left · +"
.. theme.currency .. ui.money(sg.credits) .. "/cycle", { font = "sans", px = 12, color = "text_2" }, 12, 36)
local b, _, bw = p:button(row, "Deploy", 0, 0, { primary = true, small = true, id = "dialog:stake:" .. sg.wellId,
on_click = function()
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
end,
})
gui.set_position(b, vec(W - 48 - 10 - bw, -9))
y = y + 58
local claim = { primary = true, price = st.moonPrice, disabled = short }
buttons[#buttons + 1] = fg.box({ width = fg.button_w(box, "Claim moon", claim), height = S.button_h, shrink = 0 }, {
-- Why it's off (the web's title; tooltips don't show over a dialog).
short and fg.made({ position = "absolute", top = -20, right = 0, width = 0, height = 16 }, function(q, x, y)
M.need_text(q, "Need " .. theme.currency .. ui.money(st.moonPrice - st.credits) .. " more", x, y + 8)
end) or nil,
}, { make = function(q, x, y)
claim.id = "dialog:buy-moon"
claim.on_click = function() p.reply("buy-moon", {}) end
p:button(q, "Claim moon", x, y, claim)
return nil
end })
add(fg.row({ gap = 10 }, buttons))
else
add(fg.label(box, "Best wells for it:", { font = "sans", px = 15, color = "text_2" }, 12))
add(fg.space(0, 10))
local rows = {}
for _, sg in ipairs(rows_) do
rows[#rows + 1] = fg.row({ height = 50, padding = { 9, 10, 0, 12 }, align = "start" }, {
fg.col({ grow = 1, shrink = 1, padding_top = 1 }, {
fg.row({ height = 16, align = "center" }, {
fg.made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, sg.resource) end),
fg.space(8),
fg.label(box, sg.resource, { font = "sans_semi", px = 15, color = "text" }, 16), fg.space(6),
fg.label(box, "· " .. sg.moonName, { font = "sans", px = 14, color = "muted" }, 16),
}),
fg.space(0, 2),
fg.label(box, (sg.reasonText or REASONS[sg.reason] or sg.reason) .. " · " .. ui.money(sg.supply) .. " left · +"
.. theme.currency .. ui.money(sg.credits) .. "/cycle", { font = "sans", px = 12, color = "text_2" }, 16),
}),
fg.made({ width = fg.button_w(box, "Deploy", { primary = true, small = true }), height = S.button_sm_h, shrink = 0 }, function(q, x, y)
p:button(q, "Deploy", x, y, { primary = true, small = true, id = "dialog:stake:" .. sg.wellId,
on_click = function()
p.reply("stake", { tool = modal.toolId, target = sg.wellId, s = "moons", moon = sg.moonId, well = sg.wellId })
end,
})
end),
}, { make = function(q, x, y, rw, rh)
local r = ui.rect(q, x, y, rw, rh, "cyan", 0.04)
ui.outline(r, 0, 0, rw, rh, "line")
return r
end })
end
add(fg.col({ gap = 8 }, rows))
end
fg.build(flex.solve(fg.col({ width = W, height = h, padding = { 14, 24, 24, 24 } }, kids), 0, 0), box)
end
self.dialog:open({
title = (item and item.name or "Tool") .. " ready", w = 520, h = h, cancel = "Later", title_x = 24 + 56 + 14,
@@ -95,6 +128,28 @@ local function buy_modal(self, modal, item)
})
end
-- A store card's footer, bw wide (a tree): a rule, the stats (30 tall), Buy (its price;
-- disabled when short, with why under it, right-aligned; the line's space is always kept).
-- bind(b, nodes).
function M.buy_footer(f, bw, stats, price, short, why, bind)
local items = {}
for _, it in ipairs(stats) do
items[#items + 1] = fg.inline(f, 30, function(p, x, y) return cards.stat(p, it[1], it[2], x, y) end)
end
fg.build(flex.solve(fg.col({ width = bw }, {
fg.made({ position = "absolute", top = 0, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "line")
end),
fg.row({ height = 30, column_gap = 16 }, items),
fg.button(f, "Buy", { primary = true, small = true, price = price, disabled = short }, bind, { width = bw }),
-- The need line's place, kept whether it shows or not: Buy sits at the same height in
-- every card, and a card doesn't change size when credits come and go.
fg.reserve({ height = 20 }, short and fg.made({ width = bw, height = 20 }, function(p, x, y, w)
M.need_text(p, why, x + w, y + 10)
end) or nil),
}), 0, 0), f)
end
local function tool_card(self, H, st, c, t)
c:image(t.image)
c:title(t.name)
@@ -112,48 +167,21 @@ local function tool_card(self, H, st, c, t)
c:text(line, { font = "mono", px = 12, color = "muted" })
end
local short = not t.canAfford
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
ui.rect(f, 0, 0, bw, 1, "line")
local sx = 0
sx = sx + cards.stat(f, "Yield", "+" .. ui.money(t.yield), sx, 15) + 16
sx = sx + cards.stat(f, "Cycle", ui.cycle(t.cycleSecs), sx, 15) + 16
cards.stat(f, "Owned", t.owned, sx, 15)
local b, nodes = ui.button(f, "Buy", 0, 30, { primary = true, small = true, w = bw, price = t.price, disabled = short })
if short then
tooltip.add(b, t.whyText)
end
if short then
M.need_text(f, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"), bw, 30 + S.button_sm_h + 10)
else
H.action(self, b, nodes, "buy-tool", { id = tostring(t.storeItemId) }, function(err, data)
if not err and type(data) == "table" and data.buyModal then
buy_modal(self, data.buyModal, t)
end
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)
ui.rect(f, 0, 0, bw, 1, "line")
local sx = cards.stat(f, "Spare", a.spare, 0, 15) + 16
cards.stat(f, "Equipped", a.equipped, sx, 15)
local b, nodes = ui.button(f, "Buy", 0, 30, { primary = true, small = true, w = bw, price = a.price, disabled = short })
if short then
tooltip.add(b, a.whyText)
end
if short then
M.need_text(f, (a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"), bw, 30 + S.button_sm_h + 10)
else
H.action(self, b, nodes, "buy-attachment", { item = a.name })
end
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
M.buy_footer(f, bw, {
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned },
}, t.price, short, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"),
function(b, nodes)
if short then
tooltip.add(b, t.whyText)
else
H.action(self, b, nodes, "buy-tool", { id = tostring(t.storeItemId) }, function(err, data)
if not err and type(data) == "table" and data.buyModal then
buy_modal(self, data.buyModal, t)
end
end)
end
end)
end)
end
@@ -162,23 +190,27 @@ function M.build(self, root, px, py, w, h, H)
local sto = st.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "store")
local c = area.content
y = H.heading(c, x, y, "Store")
y = y + cards.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.",
{ font = "sans", px = 15, color = "muted" }, x, y, math.min(inner, S.text_max)) + 20
ui.text(c, "Tools", "cap", x, y + 6)
y = y + 20
y = card.grid(self, c, x, y, inner, sto.tools, function(cd, t)
tool_card(self, H, st, cd, t)
end)
if #(sto.attachments or {}) > 0 then
y = y + 8
ui.text(c, "Attachments", "cap", x, y + 6)
y = y + 20
y = card.grid(self, c, x, y, inner, sto.attachments, function(cd, a)
gear_card(self, H, st, cd, a)
end)
-- (cards are kept while what they show stays the same: modules/blocks)
local function shared()
return { st.credits, st.moonPrice, st.catalogVersion, self.inflight, self.view }
end
local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end
y = fg.flow(c, x, y, inner, {
fg.col({}, {
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
fg.space(0, 20),
}),
cap("Tools", 0),
function(cy)
return card.grid(self, c, x, cy, inner, sto.tools, function(cd, t)
tool_card(self, H, st, cd, t)
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
end,
})
area:finish(y + py)
end
+1 -1
View File
@@ -1,6 +1,6 @@
-- The Market's ticker tape (the web's .mk-tape): every quote's symbol, price and 24h
-- change, scrolling left in a clipped strip; a click picks the quote. The strip is
-- rebuilt with every state, so its phase comes from a clock kept on the screen
-- rebuilt when its quotes change (a block), so its phase comes from a clock kept on the screen
-- (g.tape_t0): a refresh doesn't jump it back to the start. It pauses under the mouse
-- (app.css .mk-tape:hover): the clock shifts by the time it held. Still with reduced motion.
+19 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Checks (make check). Fails on:
- the flex solver's unit tests (scripts/flex_test.lua)
- Lua syntax errors (luajit -bl on every .lua, .script, .gui_script, .render_script)
- stale debug flags: game.project [mm] values that aren't the shipped defaults, and
DEBUG markers left in sources or shaders
@@ -11,8 +12,10 @@
over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors
The scenario part runs scripts/drive.py sections --fixture against MM_API (default
http://localhost:3000) when that server answers, after a bob build; otherwise it checks
MM_API (the server to check against, e.g. http://localhost:3251) must be set: there is no
default, since the checks create a fixture player there (never point it at a server whose
database you care about). The scenario part runs scripts/drive.py sections --fixture against
it when that server answers, after a bob build; otherwise it checks
the newest dist/drive/*/game.log there is (and says so). `--static` skips it.
"""
import glob
@@ -226,10 +229,23 @@ def check_scenario(api):
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
def check_flex():
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
fail("flex_test.lua failed:\n" + r.stdout[-2000:])
else:
print(r.stdout.strip().splitlines()[-1])
def main():
api = os.environ.get("MM_API", "http://localhost:3000").rstrip("/")
api = os.environ.get("MM_API", "").rstrip("/")
if not api:
sys.exit("check.py: set MM_API to a test server (e.g. MM_API=http://localhost:3251); "
"it creates a fixture player there, so there is no default")
check_lua()
check_flags()
check_flex()
check_fonts(api)
if "--static" not in sys.argv:
check_scenario(api)
+42 -3
View File
@@ -11,6 +11,8 @@
first, then the game's gui_script (its
on_reload rebuilds the screen; modules/hot.lua)
(--all: every Lua module again)
scripts/dev.py run [engine args...] the same in the foreground, output also on the
terminal (`make run`); reload works on it too
scripts/dev.py stop stop it (by its pid)
scripts/dev.py status
@@ -61,11 +63,14 @@ def read(name, default=None):
def alive(pid):
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
# number can be reused by another process (stop would kill that one's group).
try:
os.kill(int(pid), 0)
return True
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
cmd = f.read()
except (OSError, ValueError, TypeError):
return False
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
def snapshot():
@@ -86,7 +91,7 @@ def post(port, path, body):
return urllib.request.urlopen(req, timeout=5).read()
def start(args):
def start(args, fg=False):
os.makedirs(DEV, exist_ok=True)
pid = read("pid")
if pid and alive(pid):
@@ -125,6 +130,9 @@ def start(args):
except FileNotFoundError:
pass
log = os.path.join(DEV, "game.log")
if fg:
run_fg(cmd, env, log)
return
with open(log, "w") as f:
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
start_new_session=True)
@@ -148,6 +156,35 @@ def start(args):
print("dev game pid %d, engine service port %s, log %s" % (proc.pid, port, log))
def run_fg(cmd, env, log):
# Output to the terminal and the log; Ctrl+C stops the game.
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True)
with open(os.path.join(DEV, "pid"), "w") as f:
f.write(str(proc.pid))
try:
with open(log, "wb") as f:
for line in proc.stdout:
f.write(line)
f.flush()
sys.stdout.buffer.write(line)
sys.stdout.buffer.flush()
m = re.search(rb"Engine service started on port (\d+)", line)
if m:
with open(os.path.join(DEV, "port"), "w") as p:
p.write(m.group(1).decode())
with open(SNAP, "w") as p:
json.dump(snapshot(), p)
except KeyboardInterrupt:
proc.terminate()
proc.wait()
for n in ("pid", "port"):
try:
os.remove(os.path.join(DEV, n))
except FileNotFoundError:
pass
def reload(everything=False):
port, pid = read("port"), read("pid")
if not port or not alive(pid):
@@ -227,6 +264,8 @@ def main():
cmd = sys.argv[1]
if cmd == "start":
start(sys.argv[2:])
elif cmd == "run":
start(sys.argv[2:], fg=True)
elif cmd == "reload":
reload("--all" in sys.argv)
elif cmd == "stop":
+71 -10
View File
@@ -11,18 +11,19 @@ Usage:
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
with run(game, args).
Options:
--api URL server (default http://localhost:3000; MM_API env)
--api URL server (or MM_API; required, no default: a test server, never :3000)
--size WxH window and display size (default 1280x800)
--out DIR screenshots and game.log (default dist/drive/<scenario>)
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
--keep don't stop the game at the end (prints its pid and port)
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
port and the display)
--attach PORT don't start a game: drive the one whose engine service is on PORT
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api) and play
as it; the scenario gets its ids as game.fixture
--fixture make a test player first (scripts/fixture.py against --api) and play as
it; the scenario gets its ids as game.fixture
--fixture-file F use the ids saved in F (a fixture.py output) instead
The game it starts saves its files (player ids, settings) under <out>/data (or
@@ -34,6 +35,10 @@ In a scenario:
game.eval("g.view.section"), game.elements(), game.state(), game.set(k, v),
game.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
game.lua(src) (raw run_script, <1 KB).
Layout (scripts/ui_lint.py has the rules): game.dump(name) (the screen's laid-out nodes,
saved as <out>/<name>.layout.json), game.lint(name) (dump + lint, prints the issues,
saves <name>.lint.json), game.click_check(id, allow=[prefixes]) (dump, click, idle,
dump: the clicker rule). Issues so far: game.issues. Scenarios `dump` and `lint`.
"""
import argparse
import importlib.util
@@ -48,6 +53,8 @@ import urllib.request
HERE = os.path.dirname(os.path.realpath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ui_lint # noqa: E402
def varint(n):
@@ -86,6 +93,7 @@ class Game:
self.window_id = None
self.test_dir = None # where the game writes its replies (mm.test_dir)
self.args = None # the driver's options, when it started the game (restart)
self.issues = [] # ui_lint issues from lint / click_check, each with "where"
# ------------------------------------------------------------ transport
def lua(self, src):
@@ -173,6 +181,37 @@ class Game:
def state(self):
return self.ok(self.cmd("state"), "state")["state"]
# ------------------------------------------------------------ layout lint
def dump(self, name=None):
d = self.ok(self.cmd("dump"), "dump")["dump"]
if name:
with open(os.path.join(self.out, name + ".layout.json"), "w") as f:
json.dump(d, f, ensure_ascii=False)
return d
def _report(self, name, issues):
for i in issues:
i["where"] = name
self.issues += issues
with open(os.path.join(self.out, name + ".lint.json"), "w") as f:
json.dump(issues, f, indent=1, ensure_ascii=False)
print(" lint %s: %d issue(s)" % (name, len(issues)))
for line in ui_lint.text(issues).splitlines() if issues else []:
print(" " + line)
return issues
def lint(self, name="screen", min_hit=24):
return self._report(name, ui_lint.lint(self.dump(name), min_hit))
def click_check(self, id_, allow=(), settle=1.0, name=None):
name = name or "click-" + re.sub(r"[^A-Za-z0-9_.-]+", "_", id_)
before = self.dump(name + ".before")
r = self.click(id_)
self.idle()
time.sleep(settle)
after = self.dump(name + ".after")
return self._report(name, ui_lint.diff(before, after, r.get("id") or id_, allow))
def set(self, k, v):
return self.ok(self.cmd("set", k, v), "set " + k)
@@ -236,7 +275,8 @@ class Game:
self.window_origin()
if self.window_id:
self.xdo("windowfocus", "--sync", self.window_id)
# (the engine polls keys once a frame: a press and release between two frames is lost)
# (the engine polls keys once a frame; sticky keys (mmwindow) keep a press shorter
# than a frame, held like a hand anyway)
self.xdo("keydown", name)
time.sleep(0.08)
self.xdo("keyup", name)
@@ -342,7 +382,7 @@ def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("scenario")
ap.add_argument("rest", nargs="*")
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
ap.add_argument("--api", default=os.environ.get("MM_API"))
ap.add_argument("--size", default="1280x800")
ap.add_argument("--out")
ap.add_argument("--bundle", action="store_true")
@@ -353,16 +393,37 @@ def main():
ap.add_argument("--fixture", action="store_true")
ap.add_argument("--fixture-file")
args = ap.parse_args()
if not args.api:
# (No default: the game makes a player and plays on whatever server this is.)
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
if args.attach:
disp = os.path.join(ROOT, "dist/dev/display")
if os.path.exists(disp):
os.environ["DISPLAY"] = open(disp).read().strip() # the headless make dev
elif not os.environ.get("DISPLAY_FOR_DRIVE"):
# Re-run inside a fresh Xvfb display of the wanted size.
cmd = ["xvfb-run", "-a", "-s", "-screen 0 %sx24" % args.size, sys.executable] + sys.argv
env = dict(os.environ, DISPLAY_FOR_DRIVE="1")
sys.exit(subprocess.call(cmd, env=env))
# Re-run inside a fresh Xvfb display of the wanted size. Xvfb picks a free display
# itself (-displayfd): xvfb-run -a races when two runs start together, and both
# games then share one display (each run's clicks land on the other's window).
r, w = os.pipe()
xvfb = subprocess.Popen(["Xvfb", "-displayfd", str(w), "-screen", "0", "%sx24" % args.size, "-nolisten", "tcp"],
pass_fds=(w,), stderr=subprocess.DEVNULL)
os.close(w)
num = os.read(r, 32).decode().strip()
os.close(r)
if not num:
raise SystemExit("Xvfb didn't start")
env = dict(os.environ, DISPLAY_FOR_DRIVE="1", DISPLAY=":" + num)
try:
code = subprocess.call([sys.executable] + sys.argv, env=env)
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.wait()
sys.exit(code)
path = find_scenario(args.scenario)
name = os.path.splitext(os.path.basename(path))[0]
+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", ...}
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
credits, resources and refined items granted, two moons, several tools (some drilling,
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
--plain: just a new player (nothing granted), for the first-moon views.
"""
import json
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
admin("resources", resource=r, amount=400)
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
admin("tool", item=item, tier=tier)
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
admin("tool", item=item, **{"yield": yl, "cycle": cl})
act("buy-moon")
d = act("buy-moon")
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
act("buy-attachment", item="Turbo Core")
act("buy-attachment", item="Diamond Bit")
act("buy-attachment", item="Diamond Bit")
# The idle PD-1 a level up on each track (its yield track twice).
idle = by_item[1][1]["id"]
act("equip", item="Turbo Core", to=idle)
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="cycle")
act("buy-slot")
act("refine", recipe="Dusk Alloy", qty=5)
+277
View File
@@ -0,0 +1,277 @@
-- Unit tests for modules/flex.lua (plain Lua: `lua scripts/flex_test.lua` or luajit, from
-- the client folder or anywhere). Exits 1 on a failure.
local here = (arg and arg[0] or ""):match("^(.*)/scripts/[^/]*$") or "."
package.path = here .. "/?.lua;" .. package.path
local flex = require("modules.flex")
local B, L = flex.box, flex.leaf
local failed, count = 0, 0
local function rect(b)
return { b.x, b.y, b.w, b.h }
end
local function eq(name, got, want)
count = count + 1
local ok = #got == #want
for i = 1, #want do
if not got[i] or math.abs(got[i] - want[i]) > 1e-6 then
ok = false
end
end
if not ok then
failed = failed + 1
print(("FAIL %s: got {%s}, want {%s}"):format(name, table.concat(got, ", "), table.concat(want, ", ")))
end
end
-- A fixed-size leaf, and text: n chars of 10 px each, 20 px lines, wrapping by chars.
local function fixed(w, h, style)
style = style or {}
style.width, style.height = style.width or w, style.height or h
return B(style)
end
local function text(n, style)
return L(function(avail)
local per = math.max(1, math.floor(avail / 10))
local lines = math.ceil(n / per)
return math.min(n, per) * 10, lines * 20
end, style)
end
-- Row, gap, padding, content size.
do
local a, b = fixed(50, 20), fixed(30, 40)
local r = flex.solve(B({ gap = 10, padding = { 5, 6, 7, 8 } }, { a, b }), 100, 200)
eq("row size", rect(r), { 100, 200, 8 + 50 + 10 + 30 + 6, 5 + 40 + 7 })
eq("row a (stretched)", rect(a), { 108, 205, 50, 20 })
eq("row b", rect(b), { 168, 205, 30, 40 })
end
-- Stretch: auto height takes the line's height.
do
local a, b = B({ width = 50 }), fixed(30, 40)
flex.solve(B({}, { a, b }), 0, 0)
eq("stretch a", rect(a), { 0, 0, 50, 40 })
end
-- Column: children stretch to the width; text wraps to it.
do
local t, f = text(25), fixed(nil, 10)
local r = flex.solve(B({ direction = "column", width = 100, padding = 10, gap = 4 }, { t, f }), 0, 0)
eq("column text", rect(t), { 10, 10, 80, 80 }) -- 8 per line, 4 lines
eq("column fixed", rect(f), { 10, 94, 80, 10 })
eq("column size", rect(r), { 0, 0, 100, 114 })
end
-- Justify.
do
local function j(mode)
local a, b = fixed(20, 10), fixed(30, 10)
flex.solve(B({ width = 100, justify = mode }, { a, b }), 0, 0)
return { a.x, b.x }
end
eq("justify start", j("start"), { 0, 20 })
eq("justify end", j("end"), { 50, 70 })
eq("justify center", j("center"), { 25, 45 })
eq("justify space-between", j("space-between"), { 0, 70 })
eq("justify space-around", j("space-around"), { 12.5, 57.5 })
eq("justify space-evenly", j("space-evenly"), { 50 / 3, 50 / 3 * 2 + 20 })
end
-- Align and align_self.
do
local a, b, c, d = fixed(10, 10), fixed(10, 10, { align_self = "end" }), fixed(10, 10, { align_self = "center" }), B({ width = 10, align_self = "stretch" })
flex.solve(B({ height = 50, align = "start" }, { a, b, c, d }), 0, 0)
eq("align start", rect(a), { 0, 0, 10, 10 })
eq("align_self end", rect(b), { 10, 40, 10, 10 })
eq("align_self center", rect(c), { 20, 20, 10, 10 })
eq("align_self stretch", rect(d), { 30, 0, 10, 50 })
local e = fixed(10, 10)
flex.solve(B({ direction = "column", width = 50, align = "end" }, { e }), 0, 0)
eq("column align end", rect(e), { 40, 0, 10, 10 })
local t = text(3)
flex.solve(B({ direction = "column", width = 50, align = "center" }, { t }), 0, 0)
eq("column align center (fit content)", rect(t), { 10, 0, 30, 20 })
end
-- Grow (by factor), basis, shrink (weighted by basis), min/max freezing.
do
local a, b, c = fixed(10, 10, { grow = 1 }), fixed(10, 10, { grow = 3 }), fixed(20, 10)
flex.solve(B({ width = 120 }, { a, b, c }), 0, 0)
eq("grow 1", rect(a), { 0, 0, 30, 10 })
eq("grow 3", rect(b), { 30, 0, 70, 10 })
eq("grow 0", rect(c), { 100, 0, 20, 10 })
local d, e = fixed(10, 10, { basis = 60 }), fixed(10, 10, { basis = "50%", grow = 1 })
flex.solve(B({ width = 200 }, { d, e }), 0, 0)
eq("basis px", rect(d), { 0, 0, 60, 10 })
eq("basis % + grow", rect(e), { 60, 0, 140, 10 })
local f, g = fixed(100, 10), fixed(50, 10, { shrink = 1 })
flex.solve(B({ width = 120 }, { f, g }), 0, 0)
eq("shrink weighted a", rect(f), { 0, 0, 80, 10 })
eq("shrink weighted b", rect(g), { 80, 0, 40, 10 })
local h, i = fixed(100, 10, { shrink = 0 }), fixed(50, 10)
flex.solve(B({ width = 120 }, { h, i }), 0, 0)
eq("shrink 0", { h.w, i.w }, { 100, 20 })
local k, l = fixed(10, 10, { grow = 1, max_width = 30 }), fixed(10, 10, { grow = 1 })
flex.solve(B({ width = 100 }, { k, l }), 0, 0)
eq("max freezes, rest grows", { k.w, l.w }, { 30, 70 })
local m, n = fixed(100, 10, { min_width = 90 }), fixed(100, 10)
flex.solve(B({ width = 120 }, { m, n }), 0, 0)
eq("min freezes, rest shrinks", { m.w, n.w }, { 90, 30 })
local o = fixed(10, 10, { grow = 0.5 })
flex.solve(B({ width = 110 }, { o }), 0, 0)
eq("grow under 1 takes its share", { o.w }, { 60 })
end
-- Width/height in px, %, auto; min/max height; root max_width.
do
local a, b = B({ width = "25%", height = "50%" }), B({ width = "auto", height = 10 })
flex.solve(B({ width = 200, height = 80, align = "start" }, { a, b }), 0, 0)
eq("% sizes", rect(a), { 0, 0, 50, 40 })
eq("auto width, nothing inside", rect(b), { 50, 0, 0, 10 })
local c = B({ width = 10, min_height = 30 })
local r = flex.solve(B({ direction = "column", max_height = 20 }, { c }), 0, 0)
eq("min_height", rect(c), { 0, 0, 10, 30 })
eq("max_height", { r.h }, { 20 })
local t = text(30)
local r2 = flex.solve(B({ max_width = 105, padding = 5 }, { t }), 0, 0)
eq("max_width caps the text", rect(r2), { 0, 0, 100, 4 * 20 + 10 })
end
-- Text in a row with a fixed sibling: shrinks (min-width 0) and wraps taller.
do
local t, f = text(10, { grow = 1 }), fixed(40, 20, { shrink = 0 })
flex.solve(B({ width = 100 }, { t, f }), 0, 0)
eq("row text shrinks", rect(t), { 0, 0, 60, 40 })
end
-- Wrap: lines, gaps, equal heights per line (stretch), fixed-width cards like a grid.
do
local kids = {}
for i = 1, 5 do
kids[i] = B({ width = 90, direction = "column" }, { fixed(nil, i == 2 and 50 or 30) })
end
local r = flex.solve(B({ width = 300, wrap = true, gap = 10 }, kids), 0, 0)
eq("wrap 1", rect(kids[1]), { 0, 0, 90, 50 })
eq("wrap 3", rect(kids[3]), { 200, 0, 90, 50 })
eq("wrap 4 (line 2)", rect(kids[4]), { 0, 60, 90, 30 })
eq("wrap 5", rect(kids[5]), { 100, 60, 90, 30 })
eq("wrap size", rect(r), { 0, 0, 300, 90 })
local s = B({ width = 90, height = 20, align_self = "start" })
local t = fixed(90, 40)
flex.solve(B({ width = 300, wrap = true }, { s, t }), 0, 0)
eq("wrap align_self start keeps its height", rect(s), { 0, 0, 90, 20 })
-- Column wrap with a definite height.
local a, b, c = fixed(20, 30), fixed(20, 30), fixed(25, 30)
flex.solve(B({ direction = "column", height = 70, wrap = true, gap = 5, align = "start" }, { a, b, c }), 0, 0)
eq("column wrap", rect(c), { 25, 0, 25, 30 })
end
-- Nested: a card (column) with a footer pushed down by a growing spacer.
do
local head, spacer, foot = fixed(nil, 20), B({ grow = 1 }), fixed(nil, 30)
flex.solve(B({ direction = "column", width = 100, height = 120, padding = 10 }, { head, spacer, foot }), 0, 0)
eq("footer at the bottom", rect(foot), { 10, 80, 80, 30 })
-- A row with a column inside: the column's height is its content.
local t = text(12)
local col = B({ direction = "column", grow = 1 }, { t })
local img = fixed(40, 40, { shrink = 0 })
local r = flex.solve(B({ width = 100, align = "start" }, { img, col }), 0, 0)
eq("nested text", rect(t), { 40, 0, 60, 40 })
eq("nested height", { r.h }, { 40 })
end
-- display none, ids, gui_pos.
do
local a, hid, b = fixed(10, 10, nil), B({ display = "none", width = 99 }), fixed(10, 10)
a.id = "a"
flex.solve(B({ gap = 5 }, { a, hid, b }), 0, 0)
eq("display none skipped", { b.x }, { 15 })
eq("display none no rect", { hid.w == nil and 1 or 0 }, { 1 })
local idx = flex.index(flex.solve(B({}, { a }), 0, 0))
eq("index", { idx.a.w }, { 10 })
local r = { x = 10, y = 20, w = 100, h = 40 }
eq("gui_pos NW", { flex.gui_pos(r, 8) }, { 10, -20 })
eq("gui_pos W", { flex.gui_pos(r, 7) }, { 10, -40 })
eq("gui_pos E", { flex.gui_pos(r, 3) }, { 110, -40 })
eq("gui_pos CENTER rel", { flex.gui_pos(r, 0, 10, 20) }, { 50, -20 })
end
-- columns, grid
do
eq("columns 3", { flex.columns(924, 200, 12) }, { 4, 222 })
eq("columns at least 1", { flex.columns(150, 200, 12) }, { 1, 150 })
local a, b, c = L(function() return 10, 30 end), fixed(10, 50), fixed(10, 20) -- (a stretches)
local g = flex.solve(flex.grid(100, 40, 10, { a, b, c }), 0, 0)
eq("grid a", rect(a), { 0, 0, 45, 50 })
eq("grid b", rect(b), { 55, 0, 45, 50 })
eq("grid c next row", rect(c), { 0, 60, 45, 20 })
eq("grid size", { g.w, g.h }, { 100, 80 })
end
-- CSS grid subset: fixed, fr and auto columns, gaps, row height from the tallest cell, span.
do
local name, sup, drill, st = L(function() return 70, 20 end), fixed(nil, 30), text(5), fixed(50, 20)
local w2 = L(function() return 40, 20 end)
local spanned = fixed(nil, 10, { span = 2 })
local g = flex.solve(B({ columns = { "auto", 100, "1fr", "auto" }, column_gap = 10, row_gap = 4, width = 400, padding = 5 },
{ name, sup, drill, st, w2, spanned }), 0, 0)
-- auto col 1: max(70, 40) = 70; col 4: 50; fr: 390 - 70 - 100 - 50 - 30 = 140
eq("grid auto col", rect(name), { 5, 5, 70, 30 })
eq("grid fixed col", rect(sup), { 85, 5, 100, 30 })
eq("grid fr col (stretched)", rect(drill), { 195, 5, 140, 30 })
eq("grid auto end col", rect(st), { 345, 5, 50, 20 })
eq("grid row 2", rect(w2), { 5, 39, 70, 20 })
eq("grid span", rect(spanned), { 85, 39, 250, 10 })
eq("grid size", { g.w, g.h }, { 400, 5 + 30 + 4 + 20 + 5 })
-- auto width: fr columns size to content.
local a, b2 = L(function() return 30, 10 end), L(function() return 20, 12 end)
local g2 = flex.solve(B({ columns = { "1fr", "auto" }, gap = 6, align = "center" }, { a, b2 }), 0, 0)
eq("grid fit size", { g2.w, g2.h }, { 56, 12 })
eq("grid align center", rect(a), { 0, 1, 30, 10 })
local c1 = L(function() return 30, 10 end, { justify_self = "end" })
flex.solve(B({ columns = { 100 }, rows = { 24 } }, { c1 }), 0, 0)
eq("grid justify end, fixed row", rect(c1), { 70, 0, 30, 24 })
end
-- Absolute children, anchor, aspect ratio, scroll content size.
do
local badge = fixed(16, 12, { position = "absolute", top = -6, right = -6 })
local mid = fixed(20, 10, { position = "absolute", left = "50%", top = "50%", translate = { -0.5, -0.5 } })
local fill = B({ position = "absolute", left = 2, right = 2, top = 0, bottom = 0 })
local a = fixed(30, 20)
local r = flex.solve(B({ width = 100, padding = 4 }, { a, badge, mid, fill }), 10, 10)
eq("abs out of flow", { r.h }, { 28 })
eq("abs top right", rect(badge), { 10 + 100 + 6 - 16, 4, 16, 12 })
eq("abs centered", rect(mid), { 10 + 40, 10 + 9, 20, 10 })
eq("abs inset", rect(fill), { 12, 10, 96, 28 })
local num = L(function() return 24, 14 end, { left = "50%", bottom = "100%", translate = { -0.5, 0 } })
flex.anchor({ x = 100, y = 50, w = 80, h = 30 }, num)
eq("anchor above, centered", rect(num), { 128, 36, 24, 14 })
local img = B({ aspect_ratio = 2 })
local img2 = B({ height = 30, aspect_ratio = 1.5 })
flex.solve(B({ direction = "column", width = 120 }, { img, B({}, { img2 }) }), 0, 0)
eq("aspect stretched width", rect(img), { 0, 0, 120, 60 })
eq("aspect from height", rect(img2), { 0, 60, 45, 30 })
local rows = {}
for i = 1, 5 do
rows[i] = fixed(nil, 40)
end
local sc = flex.solve(B({ direction = "column", height = 100, padding = 5, gap = 10, overflow = "scroll" }, rows), 0, 0)
eq("scroll no shrink", { rows[5].y, rows[5].h }, { 5 + 4 * 50, 40 })
eq("scroll content size", { flex.content_size(sc) }, { 10, 250 })
end
if failed > 0 then
print(("flex: %d of %d failed"):format(failed, count))
os.exit(1)
end
print(("flex: %d ok"):format(count))
-62
View File
@@ -1,62 +0,0 @@
// Checks the Lua moon generator (modules/moon/terrain.lua) against the server's
// terrain.js: same random draws, traits and colors, and the same mesh within float32
// rounding. Needs node and luajit.
//
// node scripts/moon-check.mjs [resolution] [seeds...]
//
// terrain.js is read from $MM_SERVER (default ../server).
import { execFileSync } from "node:child_process";
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);
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9"];
const lua = `
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
local T = require("modules.moon.terrain")
local function num(x) return string.format("%.17g", x) end
local function arr(a) local o = {} for k = 1, #a do o[k] = num(a[k]) end return "[" .. table.concat(o, ",") .. "]" end
local out = {}
for i = 2, #arg do -- arg[1]: resolution, then the seeds
local seed = arg[i]
local s = T.merge_settings({})
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])
local m = T.generate(p)
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s}',
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
num(tr.maxRadius), arr(m.positions), arr(m.normals), arr(m.colors))
end
print("[" .. table.concat(out, ",") .. "]")
`;
const got = JSON.parse(execFileSync("luajit", ["-", res, ...seeds], { input: lua, maxBuffer: 1 << 30 }).toString());
let failed = false;
seeds.forEach((seed, i) => {
const s = T.mergeSettings({});
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 maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
const d = {
positions: maxDiff(m.positions, got[i].positions),
normals: maxDiff(m.normals, got[i].normals),
colors: maxDiff(m.colors, got[i].colors),
};
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
const ok = exact && d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4;
failed ||= !ok;
console.log(`${ok ? "ok " : "FAIL"} ${seed} ${t.technique}: draws and traits ${exact ? "identical" : "DIFFER"}, max diff positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`);
});
process.exit(failed ? 1 : 0);
+2 -2
View File
@@ -26,7 +26,7 @@ def run(game, args):
game.navigate("{section='new-moon'}")
game.idle()
n0 = game.eval("#st.moons")
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
first = game.eval("g.reveal.r.data.moonId")
els = [e["id"] for e in game.elements()]
@@ -53,7 +53,7 @@ def run(game, args):
admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
+1 -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
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
Sell, shots mid-open and open, Esc cancels, open again, OK sells (a toast)."""
"""The confirm dialog (needs --fixture): open it from the refinery's buy-slot button
(sells ask in place now: sellask.py), shots mid-open and open, Esc cancels, open again,
OK buys (a toast)."""
def open_sell(game, shot=None):
"""Click Sell until the dialog opens: a rebuild (a drill finishing) can drop a press."""
"""Click until the dialog opens: a rebuild (a drill finishing) can drop a press."""
for _ in range(3):
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
if shot:
game.shot(shot, settle=0.08)
shot = None
@@ -21,7 +22,7 @@ def open_sell(game, shot=None):
def run(game, args):
fx = game.fixture
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='refinery'}")
game.idle()
open_sell(game, "dialog-opening")
game.shot("dialog-open", settle=0.6)
@@ -32,5 +33,5 @@ def run(game, args):
game.sleep(0.5)
game.click("dialog:ok")
game.wait_for("not g.dialog.root and not g.busy")
game.expect(game.log_has(r"mm: toast Sold"), "selling shows a toast")
game.expect(game.log_has(r"mm: toast"), "buying shows a toast")
game.shot("dialog-after", settle=0.3)
+7 -6
View File
@@ -1,7 +1,7 @@
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
directory: it saves the settings file): the header's display button opens the dialog;
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
dialog's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
directory: it saves the settings file): the header's gear opens the settings menu;
+ / - change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
silent while the window is hidden."""
@@ -9,9 +9,9 @@ def run(game, args):
pre = (args[0] + "-") if args else ""
game.idle()
W0 = game.eval("g.size[1]")
game.click("display")
game.click("settings")
game.wait_for("g.dialog:is_open()", timeout=5)
game.shot(pre + "display-dialog", settle=0.8)
game.shot(pre + "settings-menu", settle=0.8)
game.click("dialog:scale-up")
game.click("dialog:scale-up")
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
@@ -19,7 +19,8 @@ def run(game, args):
W1 = game.eval("g.size[1]")
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
game.shot(pre + "display-125", settle=0.8)
game.click("dialog:scale-reset")
game.click("dialog:scale-down")
game.click("dialog:scale-down")
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
game.key("key_esc")
game.wait_for("not g.dialog:is_open()", timeout=5)
+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.idle()
# A confirm: OK has the focus, Enter sells (after a mouse click).
game.navigate("{section='equipment', drill='%s'}" % game.fixture["tool_idle"])
# A confirm: OK has the focus, Enter buys (after a mouse click). (Sells ask in place:
# sellask.py.)
game.navigate("{section='refinery'}")
game.idle()
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
for _ in range(3):
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
try:
game.wait_for("g.dialog:is_open()", 6)
break
+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)
+13 -6
View File
@@ -28,6 +28,7 @@ def alive(game, what):
target = next(t for t in TABS if t != section)
b0 = game.eval("g.builds")
n0 = game.eval("g.engine_inputs or 0")
game.eval("(function() g.il_touch = {} return true end)()")
ok = False
game.os_click("tab:" + target) # (once: a rebuild between the press and the release carries it)
for _ in range(15): # (the navigation started: with fake latency it lands seconds later)
@@ -36,18 +37,20 @@ def alive(game, what):
break
game.sleep(0.1)
got = game.eval("g.engine_inputs or 0") - n0
if not ok: # (what the engine delivered: touch presses and releases, where, over what)
print(" touches:", game.eval("g.il_touch"))
print(" %s: %d engine inputs, builds %d before the click" % (what, got, b0))
game.expect(ok and got > 0, "%s: a real click reaches the page (%d inputs)" % (what, got))
game.idle()
def open_confirm(game, fx):
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='refinery'}")
game.idle()
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
for _ in range(3):
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
for _ in range(30):
if game.eval("g.dialog:is_open()"):
game.sleep(0.4) # (opened: its animation done)
@@ -58,10 +61,10 @@ def open_confirm(game, fx):
def open_display(game):
"""The header's display button, a real click (again if a rebuild dropped it)."""
"""The header's settings gear (its menu), a real click (again if a rebuild dropped it)."""
for _ in range(3):
game.idle()
game.os_click("display")
game.os_click("settings")
for _ in range(15):
if game.eval("g.dialog:is_open()"):
game.sleep(0.4)
@@ -81,6 +84,10 @@ def backdrop(game):
def run(game, args):
fx = game.fixture
game.eval("(function() local m=require('screens.game.core.main') if not m.il_i then m.il_i=m.on_input "
"m.on_input=function(g,a,x) if a==hash('touch') and (x.pressed or x.released) and g.il_touch then "
"table.insert(g.il_touch,{p=x.pressed and 1 or 0,x=x.x,y=x.y,b=g.builds,d=g.dialog.root and 1 or 0,"
"busy=g.busy and 1 or 0,f=socket.gettime()}) end return m.il_i(g,a,x) end end return true end)()")
if "reduced" in args:
game.set("reduced_motion", "1")
game.idle()
@@ -203,7 +210,7 @@ def run(game, args):
for how in ("close", "esc"):
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
game.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4)
if how == "close":
+3
View File
@@ -94,6 +94,7 @@ def run(game, args):
els = [e for e in game.elements() if e["id"].startswith("field:qty:")]
if els:
item = els[0]["id"][len("field:qty:"):]
game.scroll_to(els[0]["id"]) # (the recipe cards are under the slots, like the web's)
game.click(els[0]["id"])
for _ in range(4):
game.key("key_backspace")
@@ -101,6 +102,7 @@ def run(game, args):
mx = game.eval("st.refinery.maxBatch")
got = game.eval("g.refine_qty['%s']" % item)
game.expect(got == str(mx), "quantity caps at the batch size (%s, max %s)" % (got, mx))
game.scroll_to(els[0]["id"])
game.click(els[0]["id"])
for _ in range(6):
game.key("key_backspace")
@@ -116,6 +118,7 @@ def run(game, args):
game.expect(game.log_has(r"mm: toast") and game.eval("#st.jobs") >= jobs, "Enter in the quantity refines")
maxes = [e for e in game.elements() if e["id"].startswith("act:refine?all=1")]
if maxes:
game.scroll_to(maxes[0]["id"])
game.hover(maxes[0]["id"])
game.sleep(0.8)
tip = game.eval(TIP)
+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)
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more