Compare commits

...
374 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 7e3bc38da4 README: minimal build and run steps for the server, web client, Defold desktop and browser builds 2026-10-20 10:26:06 +07:00
JosephFerano f0d396c2eb Fix paths for the server/ layout
- Shared AGENTS.md and README.md at the root; server/AGENTS.md and
  server/README.md keep the server-specific parts.
- docker-compose.prod.yml builds ./server (services, networks, volumes unchanged).
- .gitignore split into the root and server/; .dockerignore no longer lists client.
- The server reads .env from its working directory, else ../.env (the repo root).
- Client: server references point at ../server (AGENTS.md, predict.lua,
  moon-check.mjs default); druid-notes.md is ../../druid-notes.md.
2026-10-20 10:06:42 +07:00
JosephFerano 09aa0978bc Move the Go server into server/
Pure renames: the Go package, web/, sql/, config/, scripts/, art/, Makefile,
Dockerfile, .dockerignore, api.md, test-endpoints.restclient, AGENTS.md and
README.md move under server/. Paths are fixed in the next commit.
2026-10-20 10:01:37 +07:00
JosephFerano 80f874c452 AGENTS.md: web bundle size 2026-10-20 09:53:47 +07:00
JosephFerano 8cfc646d78 AGENTS.md: the web build; drop the hand-off notes 2026-10-20 09:45:24 +07:00
JosephFerano 1bcb5c88a2 Web: needs WebGL 2 (clear message otherwise), 8-bit moon targets without EXT_color_buffer_float 2026-10-20 09:45:07 +07:00
JosephFerano 52efc686cf Test hook in the browser (window.mmTestQueue) and scripts/web-shots.mjs (headless Chromium/Firefox check) 2026-10-20 09:36:39 +07:00
JosephFerano 604aee047a Web build: make web (wasm-web bundle), full-window page with a dark loading screen, make serve-web (scripts/serve-web.py proxies /api and /static) 2026-10-20 09:36:39 +07:00
JosephFerano 5556c24142 Web: page origin as the API base, fullscreen through the browser API, lighter moon generation 2026-10-20 09:36:39 +07:00
JosephFerano aa88e6f213 Moon render: bind every bloom sampler, remake targets on resize, 8-bit fallback (WebGL feedback loop and Firefox abort) 2026-10-20 09:36:39 +07:00
JosephFerano b27966e742 Client web build: hand-off notes (not started) 2026-10-20 09:36:39 +07:00
JosephFerano 2451792d46 Client: Claim, Redrill and Collect act on the first click; presses survive rebuilds
- Claim prediction counts a drill as ready once its end passed on our clock (the button
  turns on at zero, before the refetch), so a click there predicts instead of a spinner;
  claim-well leaves multi-well drills alone like the server; a plain claim raises the
  well's Redrill count, so Redrill shows at the click. Collect uses the same ready test.
- A press that a rebuild interrupts carries over to the button with the same test id
  under the pointer (every Druid button), so the release still clicks; navmemory and
  inputlock no longer retry clicks.
- A Claim popping in takes clicks on its full size; the well panel's Claim and redeploy
  shows at the zero with Claim; waiting Claim buttons have test id wait:<key>.
- clickfast scenario: real clicks 0-500 ms after each button turns on, and across a
  forced rebuild.
2026-10-20 09:19:03 +07:00
JosephFerano 48aa8900a8 AGENTS.md: the repo is game/ in the workspace; the Defold client is client/ 2026-10-20 09:04:51 +07:00
JosephFerano c1ad202e42 Add the Defold desktop client under client/ (moved from its own repo, history squashed) 2026-10-20 08:43:12 +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
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
JosephFerano bc34f98e4c api.md: Texts section after the market chart 2026-10-19 18:09:22 +07:00
JosephFerano e6088994ef API: web texts beside reason codes, confirm dialogs, rating label/top, banner ids and end, skippable catalog
- Ratings everywhere carry label and top (as the reveal card).
- Banner items: kind, id, end, left; live events: id (matches the banner).
- Reason codes get the web's text (noSaleText, whyText, blockedText, reasonText, ...);
  confirm dialogs (sell moon/tool/attachment, buy seat/slot, new game) as {title, text, ok, danger}.
  The dynamic confirm and tooltip texts move from the templates into view-model methods
  (same output).
- Other labels: bonus chips, effect texts, reach wording (catalog.reach), well drill labels,
  drill fate lines, past moon dates.
- catalog=0 or catalog=<catalogVersion> leaves the catalog out.
2026-10-19 18:07:53 +07:00
JosephFerano 474542abc7 API: help note and clientSeq docs; test that section blocks come only with their section 2026-10-19 16:36:42 +07:00
JosephFerano c7073e53b9 JSON API: every section's state, reveal and buy modal, prediction rules, catalog, clientSeq
GET /api/state adds section blocks for s= (and include=): equipment with the
drill panel, store, refinery, advisors, leaderboard, help, the new-moon teaser
and the market chart; the moon sale offer in moon. Always: rules (pressure
curve, refinery timing, bonuses, live boosts, supply scale), catalog (image
URLs, tool codes), refinery jobs, advisor bonuses, live events, showNav.
Act responses carry the reveal card after buy-moon, the deploy suggestions
after buy-tool, and echo clientSeq. Reasons are codes. Mapped from the
existing view models; HTML unchanged.
2026-10-19 16:34:58 +07:00
JosephFerano 66705d9aba api.md: document GET /api/moon-settings for the Defold client 2026-10-19 14:43:01 +07:00
JosephFerano 0d2a790506 API new-game: one Set-Cookie, for the new player 2026-10-19 13:39:54 +07:00
JosephFerano 960a03a4f6 JSON API for game clients: GET /api/state, POST /api/act/<action>
The action switch moves from handleAction into runAction, shared by the htmx
handler and the API. The state maps the view models to JSON (unix ms times).
Player id from X-MM-User or the cookie, else a new player. api.md documents it;
api_test.go builds it for every test view and runs in make check.
2026-10-19 13:38:23 +07:00
JosephFerano d8a6ae1419 Workspace layout: repo in server/, worktrees beside it; pin compose project name 2026-10-19 13:26:28 +07:00
JosephFerano 85c8219c0d Refinery: shorter batch times (1/2/3/5/6s) and a stronger bulk bonus (n^0.6) 2026-10-18 22:55:44 +07:00
EmilNielsenandClaude Opus 5.5 1b595a413e Leaderboard section: top miners by credits, with the player's own place
Revealed with the store gate (like Help). Banned players no longer rank,
in the section or in /api/leaderboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:32:16 +07:00
JosephFerano 33f9589caf Wells table: a timer bar per drill instead of the note row; no dry countdown 2026-10-18 13:47:01 +07:00
JosephFerano 4954cc8be6 Merge idiomorph swaps, the template check and the reveal fix 2026-10-18 13:34:45 +07:00
JosephFerano a52ae701f9 Merge branch 'worktree-agent-af46c79678d692771' 2026-10-18 13:29:39 +07:00
JosephFerano d5e592c6b9 Show well panel drills as compact 50px rows 2026-10-18 13:29:20 +07:00
JosephFerano da3b343bc8 Merge branch 'worktree-agent-a03c8b90fd218ee5c' 2026-10-18 13:26:20 +07:00
JosephFerano 93963c0eed Show tool upgrades as stat rows (yield, reach, cycle: now ▸ next) in a stat-up accent, not money green 2026-10-18 13:25:37 +07:00
JosephFerano 8c1456d5e6 Hide the new moon's page until its reveal ends 2026-10-18 13:25:06 +07:00
JosephFerano 9f7538bdb8 Morph the page regions with idiomorph instead of replacing them 2026-10-18 13:25:06 +07:00
JosephFerano 56888730e2 Show shared well supply: drill pips, committed vs left, over-drilled warning, per-drill projection, effective deploy yields; deploy cards above the drill list 2026-10-18 13:14:14 +07:00
JosephFerano 469e64b596 Stop the drills on a well when a claim taps it out 2026-10-18 13:12:44 +07:00
JosephFerano 0393ba2cc8 Add a template check to make check 2026-10-18 12:57:26 +07:00
JosephFerano 7944cd73c7 Merge branch 'worktree-agent-ae88760616889d09c' 2026-10-18 12:56:49 +07:00
JosephFerano eee2728b1d Show past moons only from the Past tab (list=past); fall back to an active moon or New moon 2026-10-18 12:55:04 +07:00
JosephFerano d70f71b33f Show what each tool upgrade gives: before/after yield, reach, credits/hour and payback, tier ladder, store comparison 2026-10-18 12:52:07 +07:00
JosephFerano 1b2d563b12 Merge branch 'worktree-agent-ad509b9fdba374129' 2026-10-18 12:40:26 +07:00
JosephFerano 1f612f28a6 Give the well panel's deploy cards a big image and a compact info block 2026-10-18 12:39:48 +07:00
JosephFerano 46f390126e Speed up Void Core and Stellar Lattice 2026-10-18 12:37:00 +07:00
JosephFerano 39a1faeb6b Merge the moon rating; renumber its migration to 015 2026-10-18 12:36:46 +07:00
JosephFerano 494d65f837 Call a well full only at the top roll; document rating.go 2026-10-18 12:35:52 +07:00
JosephFerano 2bc602f278 Rate each new moon against all possible moons and give the reveal a verdict 2026-10-18 12:31:12 +07:00
JosephFerano 78a076fffc Update the deployment steps in the README 2026-10-18 12:28:16 +07:00
JosephFerano 42c8b84a7c Fix the wells table's Empty label and show reach on equipment cards 2026-10-18 12:22:39 +07:00
JosephFerano f061317ee8 Give the L300 its second well at tier 2, so tier 1 mines one well as before 2026-10-18 12:15:01 +07:00
JosephFerano 3e4ddfe88d Claim a moon's multi-well drills from the wells table and well panel 2026-10-18 12:12:26 +07:00
JosephFerano 7750682e23 Drop the moon's Drills tab: the wells table and well panel show the drills 2026-10-18 12:12:26 +07:00
JosephFerano cd30dcbc95 Drills with a reach mine several wells of a moon per cycle; retune the top tools for it 2026-10-18 12:12:26 +07:00
JosephFerano 18748f9984 Give tool cards an edge-to-edge image and a compact info block 2026-10-18 12:04:50 +07:00
JosephFerano 65d153a3b8 Raise Dusk Alloy to 30 2026-10-18 11:53:19 +07:00
JosephFerano 9a5330d637 Roll each well's supply on its own in 30-90, so no well holds more than 90 2026-10-18 11:36:09 +07:00
JosephFerano ecf0bdce8d Merge branch 'main' into worktree-agent-aba4a2f074db0707a 2026-10-18 11:33:40 +07:00
JosephFerano bb431e1fe5 Let the sim go through thousands of moons now that they're a flat-price consumable 2026-10-18 11:30:07 +07:00
JosephFerano 0468346ac4 Check that the richest fresh moon never resells for its price 2026-10-18 11:30:07 +07:00
JosephFerano 6f7e3dde56 Flat moon price and well supply; resale at 0.1 of market value, capped at 0.9x the price 2026-10-18 11:30:07 +07:00
JosephFerano 188ced7ffe Merge branch 'worktree-agent-ac9d939117186a616' 2026-10-18 11:26:34 +07:00
JosephFerano 8ccf932b55 Theme the effect particles and reveal glows from the color tokens 2026-10-18 11:24:26 +07:00
JosephFerano c2376b2166 Show word lists in the economy editor's game.json column 2026-10-18 11:16:13 +07:00
JosephFerano b7362a53ab Generated moon names, unique per player; moon type in its own column 2026-10-18 11:14:11 +07:00
JosephFerano f8f2260867 Add a light theme: color tokens in app.css, toggle in the header and admin nav 2026-10-18 11:10:05 +07:00
JosephFerano 34cb3076a7 Variable wells per moon: 2-5 by weight, resources by moon type, same value per moon 2026-10-18 11:08:43 +07:00
JosephFerano d7a62abd1b Merge branch 'worktree-agent-a07ace2b9256e399c' 2026-10-18 10:47:47 +07:00
JosephFerano f52064fc71 Draw the new moon's scan sweep inside its list entry so it stays on it while the entry animates 2026-10-18 10:47:24 +07:00
JosephFerano c30c2f83cb Replace the new-tab shine with a targeting reticle that locks on 2026-10-18 10:45:40 +07:00
JosephFerano 7abfe21989 Show Redrill from the first claim during onboarding 2026-10-18 10:32:51 +07:00
JosephFerano 5801a86fc4 Start new players with no resources 2026-10-18 10:32:24 +07:00
JosephFerano 3a7f0e9498 Show + New moon to fully unlocked players with no moon 2026-10-18 10:24:55 +07:00
JosephFerano 5c484aa4c4 Onboarding by progressive reveal: sections and controls unlock as the player plays, new tabs shine until opened 2026-10-18 10:20:04 +07:00
JosephFerano 343e8e79ae Merge branch 'worktree-agent-ae5b379d907169822' 2026-10-18 09:54:51 +07:00
JosephFerano 282e8509e6 Themed thin scrollbars everywhere; fix stray overflow on the reveal card, lab panel and narrow tool cards 2026-10-18 09:54:18 +07:00
JosephFerano 410d773349 Merge branch 'worktree-agent-acc89c09c237841b5' 2026-10-18 09:53:30 +07:00
JosephFerano 13e87966ba Tool copies: actions and selection use an idle copy; busy drill panel offers an idle one 2026-10-18 09:53:14 +07:00
JosephFerano f14c308d62 Flatten moon price growth (1.24 to 1.15), well supply growth 1.21 to 1.13 2026-10-18 09:52:47 +07:00
JosephFerano e5af7274c7 Merge branch 'worktree-agent-a465dc2239b8312f0' 2026-10-18 09:45:11 +07:00
JosephFerano 6482a67fcf Toasts: compact stack in the bottom-left corner, max 3, slide in from the left 2026-10-18 09:44:52 +07:00
JosephFerano f57391e16c Merge branch 'worktree-agent-add85bf1547309157' 2026-10-18 09:44:31 +07:00
JosephFerano d40ed58a77 Replace advisor portraits with Codex-generated art 2026-10-18 09:44:12 +07:00
JosephFerano e4a22b6e6a Sell ticket: bigger payout number; keep the count out of the live region 2026-10-18 09:38:25 +07:00
JosephFerano 679792e0bf Sell ticket: payout swell, drain and power-down effect on a sale 2026-10-18 09:34:36 +07:00
JosephFerano 0c3a3667a2 Sell ticket: big payout number with per-unit price; Sell all preview 2026-10-18 09:34:36 +07:00
JosephFerano 35f9399bba Show the claim amount on Claim buttons as soon as drills finish 2026-10-18 09:20:56 +07:00
JosephFerano d7afb49035 Merge branch 'worktree-agent-a876271d1fae758dd' 2026-10-18 09:09:23 +07:00
JosephFerano 103ad668f1 Past moons: mined-out moons leave the list for a Past tab 2026-10-18 09:07:34 +07:00
JosephFerano fe782ee636 Lock a moon's wells before checking its drills on sale; document resale 2026-10-18 09:00:16 +07:00
JosephFerano ef6083747d Hide well supply on the mystery moon 2026-10-18 08:59:09 +07:00
JosephFerano dfef6d9866 Add advisor portraits and attachment art
Blender scripts for the five advisor busts and the four drill attachments,
rendered with the equipment pipeline.
2026-10-18 08:57:55 +07:00
JosephFerano 715abd7518 Sell tools and spare attachments back for part of their price 2026-10-18 08:56:21 +07:00
JosephFerano 1a95f8d26b Well panel: one-click tool cards instead of the deploy dropdown 2026-10-18 08:52:22 +07:00
JosephFerano ee6738fafb Sell moons back for what's left in their wells at market prices 2026-10-18 08:52:07 +07:00
JosephFerano 9c7fbf9963 Add a mystery moon panel and a reveal animation for new moons 2026-10-18 08:50:56 +07:00
JosephFerano b905f3c980 Copy pass on Equipment, Advisors and attachments; show the shortfall on unaffordable buttons 2026-10-18 08:44:01 +07:00
JosephFerano 171ab7b9ca Trim player-facing text; say how many credits or inputs are missing 2026-10-18 08:42:24 +07:00
JosephFerano c2f8213cdc Make the sound effects more sci-fi 2026-10-18 08:42:07 +07:00
JosephFerano 19ea99add5 Rebalance tool and upgrade prices
Upgrades now cost about what their extra income is worth, at the tool's own
payback rate (a little more per tier), so no tier costs more than the next tool
for less income. The PX-4 drops to 2400. The economy analysis flags upgrades
that cost more than the next tool and earn less, or pay back over twice as
slowly as their tool.
2026-10-18 08:40:26 +07:00
JosephFerano 94d455689e Market: card grid for assets, chart and sell ticket side by side 2026-10-18 08:37:04 +07:00
JosephFerano a41339e4f1 Cheaper moons and a stronger starter drill
Moons start at 400 (growth 1.24), wells hold 30-90, and the PD-1 mines 2 per
4s cycle. Attachments cost more so they don't come before the first hour. The
sim keeps about 2 moons per 10 minutes for the first hour.
2026-10-18 08:35:37 +07:00
JosephFerano 5d2e89efcc Add drill attachments and advisors
Attachments are bought in the Store and equipped on a drill from its panel in
the Equipment section (now cards with tool art). Advisors are hired into seats
in a new Advisors section. Their bonuses combine in bonus.go, capped, and apply
when a stake, sale or refinery batch starts.
2026-10-18 08:31:42 +07:00
JosephFerano d169574122 Lower sell prices a little under a player's own heavy selling 2026-10-18 08:27:15 +07:00
JosephFerano 76164fc24f Gray out tapped-out wells and moons 2026-10-18 08:21:12 +07:00
JosephFerano ede02fa040 Keep a Manage button on every well row, next to Redrill 2026-10-18 08:10:03 +07:00
JosephFerano d1fde538ea Speed up refining to seconds per unit and add a bulk bonus 2026-10-18 08:09:22 +07:00
JosephFerano 1970434422 Give Redrill button its own violet color 2026-10-18 08:05:32 +07:00
JosephFerano cad693a803 Fix wallet count-up flicker after claims 2026-10-18 07:45:10 +07:00
JosephFerano c6798ec32e Stop claims from redeploying drills; add Redrill 2026-10-18 07:41:29 +07:00
JosephFerano 23bc696203 Move Claim all next to the Help tab and rename ? to Help 2026-10-18 07:37:24 +07:00
JosephFerano 62e3051a17 Clean up the frontend
Share the confirm dialog and toasts in ui.js, tick badges and Claim
buttons on the server's clock, merge duplicate CSS, add focus styles
and drop the moon image column from the economy editor.
2026-10-17 22:30:51 +07:00
JosephFerano cbf48fb9dc Fix server-side correctness issues and update docs
Accurate claim/redeploy messages, no default admin id, 403 on every
admin page, data for live badges, and migration 009 so migrated
databases match a fresh schema (drops the unused wallet and moon image
columns).
2026-10-17 22:30:51 +07:00
JosephFerano 0333a5b8ca Harden the server and game actions
Body limits, timeouts and safe headers; new players rate-limited per IP; no ids on the
leaderboard. Claims skip rows a concurrent claim holds and lock in a fixed order; yield
events apply at stake time. Migration 008 fixes timestamp defaults and adds constraints.
2026-10-17 21:57:37 +07:00
JosephFerano 18d346e3a9 Run drills in seconds for faster, active play
Cycles are now seconds (migration 007) and the economy is rebalanced around them.
Claim buttons count down and enable themselves; a Claim all button redeploys every
finished drill. The sim plays an active player.
2026-10-17 21:57:24 +07:00
JosephFerano 238fabc7f2 Add sound effects and header controls
sfx.js plays Kenney CC0 clips and ZzFX synth sounds for action results, dialogs and
errors. The header gets a mute button and an Admin link.
2026-10-17 21:56:40 +07:00
JosephFerano 993b0e9109 Add sliding tab ink and section enter animations
tabs.js slides a highlight between the active tabs of any data-tabs strip; enter.js
animates frames in when a section, moon, tab or well is entered, and on admin page loads.
2026-10-17 21:56:05 +07:00
JosephFerano 40d4054496 Add ideas list 2026-10-17 21:35:38 +07:00
JosephFerano 6e082666a5 Add game feel effects
fx.js plays effects for action results, keyed by the toast's kind: wallet
count-ups, floating numbers, particles and scan sweeps.
2026-10-17 21:35:38 +07:00
JosephFerano c954c34172 Generate moon descriptions from their look
moondesc.go mirrors terrain.js to describe each moon from its look and
wells; scripts/check-moon-traits.mjs (in make check) keeps the two in sync.
2026-10-17 21:35:38 +07:00
JosephFerano ffc873802f Add the players admin page
Search and inspect players, grant credits, resources and tools, finish
drills, reset and ban. Every admin page shares one nav.
2026-10-17 21:35:38 +07:00
JosephFerano de61d40f8c Add the economy editor and rebalance the game
Admins edit the game config live on /utils/economy; each save is an
override merged over game.json. sim.go analyzes a config and simulates
progression. The numbers are rebalanced with it, and moon price and well
supply now grow with the moons a player owns.
2026-10-17 21:35:38 +07:00
JosephFerano f36f0b4b6b Redesign the market
Prices drift within marketVolatility of the config price on a deterministic
5-minute tick. The market shows a ticker, a watchlist, 24h charts and a
sell ticket.
2026-10-17 21:35:38 +07:00
JosephFerano bdf579d8a7 Add live ops
Player actions go to an event log that feeds a dashboard on /utils/liveops,
which also schedules timed yield, price and speed events and announcements
shown in a banner in the game.
2026-10-17 21:35:38 +07:00
JosephFerano 5052401f77 Add the refinery
Resources are refined in timed batches, in slots players buy. Refined items
sell for more and are needed as parts for higher tool upgrades.
2026-10-17 21:35:38 +07:00
JosephFerano 5b20ea2be2 Suggest wells after buying a tool and tick countdowns
After a purchase a dialog ranks the player's wells for the new tool.
Countdown labels tick once a second as display only; the server still
decides what is ready.
2026-10-17 21:35:38 +07:00
JosephFerano e336b57ca0 Add equipment art
Blender scripts that model and render the seven tools, their .glb models
and the store images. The store shows the tools as cards.
2026-10-17 21:35:38 +07:00
JosephFerano 0c51dbd749 Add the moon lab
Admins tune the procedural moon look live on /utils/moons. The settings are
saved in moon_settings and every moon in the game starts from them.
2026-10-17 21:35:38 +07:00
JosephFerano 459c7a5ded Port the game to Go
A Go server renders the interface as HTML (htmx for in-place updates) over
a three.js viewport of procedurally generated moons. Game rules run in Go,
one Postgres transaction per action; the game config lives in game.json.
2026-10-17 21:35:37 +07:00
JosephFerano 610d26eee9 Remove the Next.js/TypeScript app
The game is being ported to a Go server with server-rendered HTML.
This removes the old frontend, API routes, PL/pgSQL procedures,
Google Sheet import script and custom Postgres image.
2026-10-17 20:43:21 +07:00
JosephFerano 827bcbcd8f Clean up unused code 2026-10-17 17:02:21 +07:00
454 changed files with 66123 additions and 12765 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+12 -40
View File
@@ -1,46 +1,18 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# Local env files (never commit)
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vscode
# Editors and OS
.vscode
.DS_Store
database.db
.env
/sql/create-users.sql
# Personal files
/.dir-locals.el
sql/queries.sql
/project.todo
# Blender files
*.blend
*.blend1
# Agent worktree state (scripts/wt)
/.wt/
+45 -67
View File
@@ -1,74 +1,52 @@
# AGENTS.md — Moon Miners
## What this is
Moon Miners is a browser idle/mining game (package name `mooner`). Players join anonymously:
the client calls `POST /api/user/anonymous` and stores the user id in `localStorage`. Players
own moons ("staking sources") with resource wells, buy and upgrade mining tools (store
items) and stake them on wells. When a stake finishes they claim resources (Sollux,
Shadowstone, Azurium, Novafor, Nebulance), which they can sell into a bank account
balance. There is also a leaderboard.
Moon Miners is an idle/mining game. Players join anonymously: the server creates a
player and keeps its id in the `mm_user` cookie (or `X-MM-User` for the API). Players own
moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
and stake them on wells. When a stake finishes (drill cycles are seconds:
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
items (Refinery section: timed batches in slots; refined items sell for more and are parts
for 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).
## Stack
- Next.js 13.1 (experimental `appDir`) + TypeScript 4.9 (strict) + three.js 0.170.
three is pinned at 0.170 because newer `@types/three` needs TypeScript 5.
- The game UI is plain three.js + DOM in `src/game`; `src/app/page.tsx` only mounts it.
The API lives in the Pages Router (`src/pages/api`).
- Postgres via `pg` (`postgresConnection` in `db.ts`). Most game logic is written as
PL/pgSQL functions, and the API routes are thin wrappers that run `SELECT * FROM fn(...)`.
- SQLite (`dbConnection`, `database.db`) is legacy. Only `api/seed.ts`, `api/drop.ts` and
`add-balance` still use it. Don't build anything new on it.
## How the parts fit
- `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. 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`.
## Layout
- `src/game/Game.ts`: controller. It creates/reuses the anonymous player, loads state from
the API, and handles HUD actions and 3D picks.
- `src/game/MoonScene.ts`: three.js scene (moons, well crystals, drills, progress rings,
camera focus, raycast picking). It's rebuilt from state on every `sync()`.
- `src/game/Hud.ts`: DOM overlay (top bar, side panels, hints, toasts). It uses event
delegation via `data-action` attributes. Styles are in `src/app/globals.css` (plain CSS).
- `src/game/api.ts` (typed fetch client) and `src/game/state.ts` (GameState plus helpers).
- `src/app/utils/page.tsx`: admin utilities (clear data, import config).
- `src/pages/api/**`: REST endpoints. Per-user routes are under `user/[userId]/`.
- `src/utils/helpers.ts`: time and formatting helpers. `src/utils/gameLogic.ts` exposes `gameConfig`.
- `typings.d.ts` (repo root, imported as `"typings"`): shared interfaces (`IStake`,
`IStoreItem`, `IGameConfig`, ...).
- `config/game-config.json`: static game config. `next.config.js` injects it as `process.env.gameConfig`.
- `sql/tables.sql`: schema. `sql/procedures.sql`: game functions (create_user, stake, claim,
purchase_item, upgrade_item, sell_resources, get_leaderboard, ...). `sql/import-config.sql`:
`import_config()`, which pulls store/staking config from a Google Sheet into the DB.
`sql/data.sql`: seed data.
- `scripts/import-game-config.py`: dumps the config Google Sheet as CSV (debug helper).
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `public/assets/`: moon and item images.
## Workspace
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
- 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.
## Commands
- `npm run dev`: dev server on :3000 (the Dockerfile also runs `dev`, on Node 16).
- `npm run lint`: next lint (`next/core-web-vitals`).
- `npm run build`: type-check and build. There is no test suite, so lint and build are the checks.
Don't build while `npm run dev` is running (they share `.next`).
- Local DB: `docker compose up -d db` (see README).
## Deployment
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app` (root `Dockerfile`),
`db` (`docker/db/Dockerfile`, with the SQL baked in for first-time init), and `db-update`
(`docker/db/update.sh`, which runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
`sql/migrations/NNN-name.sql` (existing DBs). Function changes in `sql/procedures.sql` are
reloaded automatically on deploy.
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`. Never read, print or commit `.env`.
## Conventions
- Put game rules in SQL functions (`sql/procedures.sql`) and keep API routes thin.
If you change a function's signature, update the route that calls it and the matching type in `typings.d.ts`.
- API route pattern: branch on `req.method`, return 200 with JSON, 400 with
`{ error }` for DB/business errors, and 500 for unexpected errors.
- Use the `@/*` alias for `src/*`. Import shared types from `"typings"`.
- Client components need `"use client"`. Game UI is not React: add HUD markup in `Hud.ts`,
handle the action in `Game.handleAction`, and dispose any three.js geometry and materials you create.
- SQL is lowercase, with snake_case identifiers and `p_`-prefixed params. Schema changes go in `sql/tables.sql`.
There is no migration tool, so mention any manual DB steps you need.
- When you add an endpoint, add it to `test-endpoints.restclient`.
## Conventions (all parts)
- The server is the authority: clients show the state it sends and send actions; no game
rules on the client side beyond what `server/api.md` "Prediction" allows.
- Work in one part at a time: a client task doesn't change `server/`, and the other way
round, unless the task says so.
- Keep changes small. Don't refactor or move files unless asked.
-33
View File
@@ -1,33 +0,0 @@
# Production image for the Next.js app (game UI + API routes).
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/.next ./.next
COPY package.json next.config.js ./
COPY config ./config
COPY public ./public
# db.ts opens a (legacy) SQLite file in the working directory, so it must be writable.
RUN chown node:node /app
USER node
EXPOSE 3000
CMD ["node_modules/.bin/next", "start"]
+65
View File
@@ -0,0 +1,65 @@
# Ideas
Not built yet. Rough priority within each section.
## Player interactions
1. **Contracts.** A player posts "need 40 Cryo Cells, paying ₥3,000"; anyone can fill it. Works with
few players and fits the idle pace. NPC buyers fill expired contracts so they never sit dead.
2. **Megaprojects.** Server-wide builds (space elevator, orbital refinery) everyone feeds resources
into; finishing unlocks a bonus for all. Runs as a live-ops event and burns surplus resources.
3. **Mining rights.** Lease idle wells to other players for a cut of what they mine.
4. **Corporations.** Groups of 5–20 with a shared weekly goal, a corporation leaderboard and a message
board.
5. **Player market.** Buy/sell orders between players with a small fee (a credit sink). Needs many
active players, so later.
6. **Banks and loans.** Borrow credits to buy a moon or tool early, repaid with interest from
sales; deposits earn interest. Start with an NPC bank (interest as a credit sink, loan limits by
net worth, default = repossess a tool). Later, player-to-player loans or player-run banks.
7. **Light rivalry (optional).** Scouting, contested rare moons, seasonal claim races. Keep it out of
the core loop: idle players hate losing progress while offline.
Needed first: display names (with a filter for offensive names); limits against alt accounts
feeding a main one (fees, trade limits for new accounts, gift caps). Trades stay in credits only.
## Story events (live ops)
Wrap the live-ops multipliers in news-style events. Needs moons to belong to regions/sectors first.
- **New cluster discovered.** A limited-time region opens with new moon types, a rare resource or
richer wells; first players in get the best ones.
- **War in a region.** Demand spikes (higher sell prices for alloys/fuel), but drills there
risk slowdowns or losses; players choose risk vs reward.
- **Solar storm.** Drills slow down everywhere except shielded or upgraded ones.
- **Trade embargo.** One resource can't be sold for a few days; prices jump when it lifts.
- **Comet pass.** A short window where a moon type yields a one-off resource.
- **Corporate rush / tender.** An NPC corporation orders a huge amount of something by a
deadline; contributors split the reward (ties into contracts and megaprojects).
Shown in the banner and a "news feed" with flavour text; the effect is still a multiplier,
restriction or new moon type under the hood.
## Economy
- Bigger tools barely earn more per hour than the free drone; tune so each tier earns ~2× per hour
and pays back within a session or two (see /utils/economy).
- A moon costs ₥700 but holds ~₥4,167 of ore; the sim hits the 300-moon cap in ~13.5 h.
- All resources sell for the same price, so which well you mine doesn't matter yet.
- Wells (50–200 supply) are smaller than what big tools claim per cycle.
- Sinks: moons (flat price, used up by mining), refinery slots, upgrade parts.
- Rare moons (planets, stars) with a resource only they have, needed for top recipes.
## Platform and money
- Web first (CrazyGames, Poki), then installable with push notifications, then Capacitor apps
for the stores once players come back on their own. Idle games fit mobile best.
- Money: opt-in rewarded ads ("finish this drill now"), cosmetics, a supporter pack.
## Security later
- Content-Security-Policy with nonces: needs the inline import map and boot script
(page.html) and the inline `onerror` handlers (app.html, economy.js) moved to nonce'd or
external scripts first.
- `frame-ancestors` allowing the game portals, with the cookie switched to `SameSite=None; Secure`
so it works inside their iframes.
- Real accounts and session tokens instead of the player id in the cookie.
+31 -133
View File
@@ -1,147 +1,45 @@
# Moon Miners
A browser mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for balance. The game is a three.js scene with a small HTML overlay, served by
Next.js 13 (TypeScript), on top of Postgres, where most game logic lives as PL/pgSQL functions.
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
Players join anonymously: on the first visit the browser creates a player
(`POST /api/user/anonymous`) and keeps its id in `localStorage`. No wallet or sign-up needed.
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
- `client/`: Defold client, for desktop and the browser (WebAssembly).
## Requirements
## Server
- Node.js 16+ and npm
- PostgreSQL 13+ (uses `gen_random_uuid()`) with the `plpython3u` language available and the
Python `requests` package installed for the server's Python (needed by `import_config()`)
- A Postgres superuser for the one-time database setup
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`).
## Environment
Create a `.env` file in the project root (it is gitignored, never commit it):
```dotenv
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
DB_DATABASE=
```bash
podman-compose up -d db # or: docker compose up -d db
cd server
make migrate
make dev # http://localhost:3000: the game (once built, below); /utils: the admin
```
## Local development
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).
`docker-compose.yml` runs a local Postgres (with `plpython3u` + `requests`) and loads the
`sql/` files automatically on first start. The app itself runs on the host.
## Defold client
1. Create `.env` for the local database:
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.
```dotenv
DB_USER=moonminers
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
```
```bash
cd client
make run # desktop build, plays against http://localhost:3000
make web # browser build into dist/web/MoonMiners/
```
2. Start the database and import the game config (needs internet, fetches the Google Sheet):
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.
```bash
docker compose up -d db
docker compose exec db psql -U moonminers -d moonminers -c "select import_config();"
```
## Deploy
3. Run the app and open http://localhost:3000. A new anonymous player is created
automatically; "Start a new game" in the help panel (`?`) switches to a fresh one.
```bash
npm install
npm run dev
```
Other commands: `npm run lint`, `npm run build` (type-check + production build). Don't run
`npm run build` while `npm run dev` is running; both use `.next` and the dev server breaks
(fix: stop it, `rm -rf .next`, start again).
Reset the database (re-runs the SQL files): `docker compose down -v && docker compose up -d db --build`.
Apply new migrations to an existing local database:
`docker compose exec -T db psql -U postgres -d moonminers -f - < sql/migrations/<file>.sql`.
## Deployment (Dokploy)
`docker-compose.prod.yml` contains the full stack:
- `app`: the Next.js server (root `Dockerfile`), on port 3000.
- `db`: Postgres 16 with plpython3u, storing data in the `db-data` volume. The SQL schema is
loaded on first start.
- `db-update`: a one-shot job that runs before the app on every deploy. It applies
`sql/migrations/*.sql`, reloads the SQL functions, syncs the app user's password, and imports
the game config from the Google Sheet.
Steps in Dokploy:
1. Create a **Docker Compose** service from this Git repository and set the compose path to
`docker-compose.prod.yml`.
2. In **Environment**, set two strong passwords:
```dotenv
POSTGRES_PASSWORD=... # Postgres superuser (used by db-update)
DB_PASSWORD=... # app user "moonminers"
```
3. In **Domains**, add your domain for the service `app` with container port `3000`. Enable
HTTPS if you want Let's Encrypt.
4. Deploy. The first deploy initializes the database and imports the config. The database
server needs outbound internet access to fetch the Google Sheet.
To redeploy after changes, push and deploy again. Data lives in the `db-data` volume, which
survives redeploys. Back it up with `pg_dump` or Dokploy's volume backups.
Database changes for existing deployments go in a new `sql/migrations/NNN-name.sql`. Migrations
run on every deploy, so write them to be idempotent (`if not exists`, `if exists`). Also update
`sql/tables.sql` so fresh databases match.
To test the production stack locally:
`POSTGRES_PASSWORD=x DB_PASSWORD=y docker compose -p mmprod -f docker-compose.prod.yml up --build`
(add a `ports: ["3100:3000"]` override for `app` to reach it).
## Manual database setup (without Docker)
Run once against a fresh database, as a superuser, in this order:
1. Create the app role(s). The SQL grants permissions to a role named `devs`, so it must exist,
and the `DB_USER` used by the app must be a member of it (or be that role):
```sql
create role devs;
create user moonminers with password '...' in role devs;
```
2. Load the schema, seed data, and functions:
```bash
psql -d <db> -f sql/tables.sql # tables
psql -d <db> -f sql/data.sql # grants to devs + resource types
psql -d <db> -f sql/procedures.sql # game functions (stake, claim, purchase_item, ...)
psql -d <db> -f sql/import-config.sql # import_config() + plpython3u download_sheets()
```
Note: `sql/import-config.sql` marks `plpython3u` as trusted and `data.sql` grants
`pg_read_server_files` to `devs`. Both are broad permissions; review them for production.
3. Import the game config (store items, upgrades, staking sources, constants) from the Google
Sheet. `import_config()` downloads CSVs to `/tmp` on the database server, so the DB host needs
outbound internet access. Either run it directly:
```sql
select import_config();
```
or, with the app running, `POST /api/import-config`.
Re-run this whenever the game config sheet changes.
Then run the app anywhere with Node 20: set the `DB_*` variables and run `npm ci && npm run build && npm run start`
(port 3000). The root `Dockerfile` builds the same thing as an image.
## API endpoints
Refer to `test-endpoints.restclient` for an up-to-date list of endpoints. It uses the Emacs
restclient format, but the requests are easy to replay with any HTTP client.
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
+7
View File
@@ -0,0 +1,7 @@
build/
tools/
.internal/
*.der
.DS_Store
dist/
__pycache__/
+200
View File
@@ -0,0 +1,200 @@
# AGENTS.md — Moon Miners client (Defold)
A native client for Moon Miners (rules and state live in the Go server: `../server`,
`../server/AGENTS.md`; the repo: `../AGENTS.md`), 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.
## 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.
## Testing
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
says what it checks and needs). Run `make check` (with `MM_API` set to your test
server; it makes a fixture player there) before calling it done.
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
server, which `wt up` starts).
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
click_check to a feature's scenario when it has buttons people click repeatedly.
## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
from `build/default` in the foreground (loose files, engine service on), so `make reload`
works on it.
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
`node scripts/web-shots.mjs` (Playwright; header of the script).
## Hot reload (`make reload`, `scripts/dev.py`)
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
too; restart if one looks wrong.
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
collections, game objects, `main.script`, materials, shaders, the render script; modules
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
native extensions and dependencies (a new engine).
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
(core/main.lua) or lazily.
## Map
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `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`.
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.
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.
+115
View File
@@ -0,0 +1,115 @@
# 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
PLATFORM := x86_64-linux
VARIANT ?= debug
BUNDLE := dist/$(PLATFORM)/MoonMiners
BIN := $(BUNDLE)/MoonMiners.x86_64
SHOT ?= dist/shot.png
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?=
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
$(BOB):
mkdir -p tools
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB)
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
# Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB)
$(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
touch $@
build: $(BOB) .internal/lib/dependencies.json
$(JAVA) -jar $(BOB) --platform $(PLATFORM) --architectures $(PLATFORM) --variant $(VARIANT) \
--archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release
# by default; WEB_VARIANT=debug keeps logs and the test hook (window.mmTestQueue).
# 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) \
--archive --bundle-output dist/web build bundle
serve-web:
scripts/serve-web.py --port $(WEB_PORT) --upstream $(UPSTREAM)
# Hot reload loop: `make dev` starts the game from build/default (once), `make reload`
# rebuilds (no bundle) and posts what changed to the running engine. HEADLESS=1 make dev
# runs it under Xvfb. See AGENTS.md "Fast iteration".
dev: build
scripts/dev.py start $(ARGS)
reload: $(BOB)
@mkdir -p dist/dev
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
@scripts/dev.py reload
dev-stop:
scripts/dev.py stop
# A scenario through the test hook (scripts/drive.py, AGENTS.md "Test driver") on a fresh
# build/default, e.g. make drive SCENARIO=smoke MM_API=http://localhost:3118 DRIVE=--fixture
SCENARIO ?= smoke
DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO)
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
# dist/drive/lint-<size>/. Needs MM_API (a test server).
lint: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
# scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@$(JAVA) -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-check.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py
# The game from build/default (loose files, engine service on), so `make reload` works on it.
# The bundle in dist/ is for shot, play and shipping.
run: build
scripts/dev.py run $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build
scripts/shot.sh $(BIN) $(SHOT) $(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
@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.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Oxanium-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Medium.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-Bold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Rajdhani-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\210\222\342\200\271\342\200\272"
+9
View File
@@ -0,0 +1,9 @@
font: "/assets/fonts/Inter-SemiBold.ttf"
material: "/builtins/fonts/font-df.material"
size: 40
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
output_format: TYPE_DISTANCE_FIELD
render_mode: MODE_SINGLE_LAYER
characters: "\342\211\210 !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\302\267\303\227\342\200\224\342\200\223\342\200\246\342\200\242\342\200\231\342\200\234\342\200\235\342\202\245\342\230\205\342\230\206\342\227\206\342\226\262\342\226\274\342\200\272\342\227\217\342\227\257\316\221\316\222\316\223\316\224\316\225\316\226\316\227\316\230\316\231\316\232\316\233\316\234\316\235\316\236\316\237\316\240\316\241\316\243\316\244\316\245\316\246\316\247\316\250\316\251\316\261\316\262\316\263\316\264\316\265\316\266\316\267\316\270\316\271\316\272\316\273\316\274\316\275\316\276\316\277\317\200\317\201\317\202\317\203\317\204\317\205\317\206\317\207\317\210\317\211"
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

+34
View File
@@ -0,0 +1,34 @@
Moon Miners sound effects (Defold client)
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
(credit not required, given anyway).
nav.wav Interface Sounds switch_007
toggle.wav Interface Sounds switch_005
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
buy.ogg Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
+ synth beep
upgrade.ogg Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
+ synth sweep
moon.ogg Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
deploy.ogg Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
refine.ogg Sci-Fi Sounds forceField_001 + computerNoise_000
collect.ogg Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
modal.wav, confirm.ogg, error.wav and ready.wav are synthesized with SoX (no samples).
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
https://kenney.nl/assets/impact-sounds
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
reveal's build-up and burst, client only) are synthesized by the same script.
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Frank Force
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
images {
image: "/assets/images/white.png"
}
images {
image: "/assets/images/frame.png"
}
images {
image: "/assets/images/outline.png"
}
images {
image: "/assets/images/sphere.png"
}
images {
image: "/assets/images/glow.png"
}
images {
image: "/assets/images/fade.png"
}
images {
image: "/assets/images/ring.png"
}
images {
image: "/assets/images/speaker.png"
}
images {
image: "/assets/images/speaker_off.png"
}
images {
image: "/assets/images/spark.png"
}
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/gear.png"
}
images {
image: "/assets/images/moon.png"
}
images {
image: "/assets/images/pentagon.png"
}
images {
image: "/assets/images/pentagon_rim.png"
}
margin: 2
extrude_borders: 2
inner_padding: 0
+85
View File
@@ -0,0 +1,85 @@
-- Progress and timer bars (the web's .drill-bar / .bar): a track and a fill. A timer bar
-- fills from start to end (ms, server clock) as display only, easing corrections (core
-- ticks, keyed); once full it takes the ready look: a soft glow in its color that breathes
-- (app.css .drill-bar.is-ready). A static bar shows a fraction.
--
-- bar.timer(g, parent, x, y, w, h, { start, ends, ready, color, none, key })
-- bar.value(parent, x, y, w, h, fraction, { color, track })
-- bar.ready_label(parent, text, x, y, pivot) -- "READY" in gold, its glow pulsing
-- -- (app.css .ready, 1.8 s)
local ui = require("modules.ui")
local anim = require("modules.anim")
local ticks = require("screens.game.core.ticks")
local M = {}
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- The ready look on a fill: full, in its own color (cyan, amber when short), with a glow
-- under it that breathes (app.css .drill-bar.is-ready). A drill that gets nothing has no
-- glow (.is-none.is-ready).
local function ready_look(parent, fill, x, y, w, h, color, none)
if none then
return
end
local glow = ui.rect(parent, x - 4, y - 4, w + 8, h + 8, color, 0.25)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, fill)
anim.breathe(glow, "color.w", 0.35, 0.12, 1.8)
end
-- A timer bar. opts: start, ends (ms), ready (already done: full, glowing), color (default
-- "cyan"), track (default "line"), none (the drill gets nothing: an amber dashed track, a
-- muted fill, no glow; app.css .drill-bar.is-none), key (eases corrections; core ticks).
-- Returns the fill node.
function M.timer(g, parent, x, y, w, h, opts)
local color = opts.color or "cyan"
if opts.none then
color = "muted"
for dx = 0, w - 1, 4 do
ui.rect(parent, x + dx, y, math.min(2, w - dx), h, "ready", 0.45)
end
else
ui.rect(parent, x, y, w, h, opts.track or color, opts.track and 1 or 0.16)
end
local fill = ui.rect(parent, x, y, w, h, color, opts.none and 0.7 or 1)
if opts.ready then
ticks.tick_bar(g, fill, 0, 0, w, opts.key)
ready_look(parent, fill, x, y, w, h, color, opts.none)
else
ticks.tick_bar(g, fill, opts.start, opts.ends, w, opts.key, function()
-- Full at the local zero (display only; the server says when it's claimable).
ready_look(parent, fill, x, y, w, h, color, opts.none)
end)
end
return fill
end
-- A static bar showing `fraction` (0..1). opts: color, track, min (smallest fill width).
function M.value(parent, x, y, w, h, fraction, opts)
opts = opts or {}
ui.rect(parent, x, y, w, h, opts.track or "line")
local fw = math.floor(w * math.max(0, math.min(1, fraction)))
if fw > 0 then
return ui.rect(parent, x, y, math.max(opts.min or 0, fw), h, opts.color or "cyan")
end
end
-- "Ready" in gold whose glow pulses (a soft glow sprite behind it, breathing like the
-- web's text-shadow). x, y, pivot like ui.text.
function M.ready_label(parent, text, x, y, pivot, px)
local style = { font = "sans_semi", px = px or 13, color = "ready", tracking = 0.1, upper = true }
local t = ui.text(parent, text or "Ready", style, x, y, pivot)
local w = ui.text_width(t)
local left = (pivot == gui.PIVOT_E and x - w) or (pivot == gui.PIVOT_CENTER and x - w / 2) or x
local glow = ui.rect(parent, left - 12, y - 11, w + 24, 22, "ready", 0)
gui.play_flipbook(glow, "glow")
gui.move_below(glow, t)
anim.breathe(glow, "color.w", 0, 0.3, 1.8)
return t
end
return M
+213
View File
@@ -0,0 +1,213 @@
-- Button (the web's .btn): a filled box with an edge and an uppercase label, optional
-- price ("· ₥1,234"). Variants: "secondary" (default, .btn), "primary" (.btn-primary),
-- "redrill" (.btn-redrill, violet), "danger" (.btn-danger), "ghost" (.btn-dashed: faint,
-- for "+ New moon"; opts.on lights it). States, eased like a CSS transition (120 ms):
-- hover (mouse over), press (held down), disabled (dimmed, no clicks), busy (the web's
-- aria-busy: dimmed, label hidden, a spinner; set by core/hooks while an unpredicted
-- action waits for the server).
--
-- local b = button.new(g, parent, "Sell", x, y, { variant = "danger", small = true,
-- on_click = fn, id = "sell-moon" }) -- draw + behavior; b.root, b.w, b.h
-- button.draw(parent, label, x, y, opts) -- just the nodes (root, nodes, w)
-- button.bind(g, root, nodes, opts) -- behavior on drawn nodes (what
-- -- H.action and H.link use)
--
-- opts (draw): variant, small, w, h, price, accent (the price's color), disabled, on;
-- fill / edge / text ({ color, alpha } or a color name) override the variant's colors
-- (hover then brightens the given fill).
-- opts (bind): on_click, id (test id, modules/test), label (for the test hook; default
-- the label text), zone (stencil node clipping it, when not under a scroll's view), druid
-- (another Druid instance than g.druid: the dialog's).
-- After a rebuild the button under the mouse shows its hover at once (core/main replays
-- the last pointer position; M.instant makes that skip the easing). A press that a rebuild
-- interrupts carries over, for every Druid button with a test id: the one with the same
-- id under the pointer is pressed in the new build, so the release still clicks it
-- (M.carry). opts.hit: a node to take the clicks instead of root (a Claim button popping
-- in is scaled down: core/hooks).
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local druid_button = require("druid.base.button")
local S = theme.size
local M = {}
local FADE = 0.12
-- What ui.button drew, by root node (variant, disabled, parts).
local looks = ui.button_looks
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local col = ui.color_spec
-- Ease (or set, when M.instant or reduced motion) a node's color.
local function tint(node, c, instant)
gui.cancel_animations(node, "color")
if instant or M.instant or anim.reduced then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, FADE)
end
end
-- Draw a button (ui.button). Returns root, nodes, width.
M.draw = ui.button
-- The state look: "normal", "hover", "press".
local function show(root, state, instant)
local look = looks[root]
if not look or look.disabled or look.busy then
return
end
local function c_of(x)
return type(x) == "userdata" and x or col(x)
end
local c = state == "press" and c_of(look.v.press) or (state == "hover" and c_of(look.v.hover) or look.fill)
tint(root, c, instant)
end
-- Behavior on a drawn button (or any node: then only clicks, no looks). Returns the Druid
-- button.
function M.bind(g, root, nodes, opts)
opts = opts or {}
local look = looks[root]
local b = (opts.druid or g.druid):new_button(opts.hit or root, function()
if opts.on_click and not (look and look.busy) then
opts.on_click()
end
end):set_animations_disabled()
if opts.hit then
test.name(opts.hit, opts.id)
end
if opts.zone then
b:set_click_zone(opts.zone)
end
if look and not look.disabled then
b.hover.on_mouse_hover:subscribe(function(_, on)
look.hovered = on
show(root, on and "hover" or "normal")
end)
b.hover.on_hover:subscribe(function(_, down)
show(root, down and "press" or (look.hovered and "hover" or "normal"), down)
end)
end
local label = opts.label
if not label and look then
label = gui.get_text(look.text)
end
test.register(opts.id, root, opts.kind or (look and "button" or "link"), label, opts.zone)
return b
end
---------------------------------------------------------------- a press across a rebuild
-- Every Druid button (these, tabs, rows, the moon list's widgets, raw ones), wherever it
-- is made: the ones made since the build began, and the one the mouse went down on, by
-- its test id (modules/test id_of). A rebuild drops the pressed Druid button, so the
-- release would click nothing (the web's element survives an htmx morph); M.carry
-- presses its successor instead.
local carry = { made = {}, pressed = nil }
druid_button.mm_hook = function(b)
carry.made[#carry.made + 1] = b
b.on_pressed:subscribe(function()
carry.pressed = { node = b.node, t = socket.gettime() }
end)
end
if not druid_button.mm_init then -- (once: the class outlives a hot reload of this file)
druid_button.mm_init = druid_button.init
druid_button.init = function(self, ...)
druid_button.mm_init(self, ...)
if druid_button.mm_hook then
druid_button.mm_hook(self)
end
end
end
-- A build begins (core/main): buttons made from now on are its.
function M.begin_build()
if carry.pressed and not carry.pressed.id then
carry.pressed.id = test.id_of(carry.pressed.node) -- (before the old nodes go)
end
carry.made = {}
end
-- The mouse went down (before Druid sees it) or up: no press to carry.
function M.release()
carry.pressed = nil
end
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
-- before: press the new button with its id under the pointer, so the release clicks it.
function M.carry(x, y)
local p = carry.pressed
if not p or not p.id then
return
end
-- (not only one under the pointer now: a full build plays the page's entrance, so the
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
local found
for _, b in ipairs(carry.made) do
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
b.can_action = true
b.last_pressed_time = p.t
b.hover:set_hover(true)
found = b
end
end
if found then
carry.pressed = { node = found.node, id = p.id, t = p.t }
return true
end
end
-- Draw and bind. Returns { root, nodes, w, h, button }.
function M.new(g, parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = M.draw(parent, label, x, y, opts)
local b = { root = root, nodes = nodes, w = w, h = gui.get_size(root).y }
if not opts.disabled then
b.button = M.bind(g, root, nodes, opts)
end
return b
end
-- Busy (the web's aria-busy + spinner): dimmed, label hidden, a rotating square.
function M.set_busy(root)
local look = looks[root]
local size = gui.get_size(root)
if look then
look.busy = true
for _, p in ipairs(look.parts) do
local c = gui.get_color(p[1])
gui.set_color(p[1], vmath.vector4(c.x, c.y, c.z, 0.2))
end
local f = gui.get_color(root)
gui.set_color(root, vmath.vector4(f.x, f.y, f.z, f.w * 0.6))
local e = gui.get_color(look.edge)
gui.set_color(look.edge, vmath.vector4(e.x, e.y, e.z, e.w * 0.6))
end
local spin = look and look.variant == "primary" and "on_cyan" or "cyan_hi"
local sp = ui.outline(root, size.x / 2 - 7, size.y / 2 - 7, 14, 14, spin)
gui.set_pivot(sp, gui.PIVOT_CENTER)
gui.set_position(sp, vec(size.x / 2, -size.y / 2))
gui.animate(sp, "euler.z", -360, gui.EASING_LINEAR, 0.9, 0, nil, gui.PLAYBACK_LOOP_FORWARD)
end
-- Whether draw() made this node (a button with looks).
function M.is_button(root)
return looks[root] ~= nil
end
-- Error feedback on a button (the web's fx-shake: 0.4 s).
function M.shake(root)
anim.shake(root, 6, 0.4)
end
return M
+325
View File
@@ -0,0 +1,325 @@
-- Card (the web's .card / .store-card): an image column on the left and a text column
-- that hugs its content, laid out with modules/flex (a column of boxes, measured text: a
-- paragraph's height comes from its wrapped lines), with an optional footer block pinned to the
-- bottom so buttons line up across a row; a card narrower than card_min_w puts the image
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store and
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
--
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
-- c:image(item.image)
-- c:title(item.name)
-- c:para(item.description)
-- c:text("+3 per cycle", "small")
-- c:footer(52, function(f, w) ... build into f (0..w wide, 52 tall) ... end)
-- c.click = fn -- (optional) a click anywhere on the card; c.id its test id
-- c.selected = true -- (optional) cyan edge and inner line
-- c.edge_color = { "ready", 0.45 } -- (optional) another edge (a hired advisor), and
-- c.edge_hover = { "ready", 0.7 } -- its hover
-- end)
--
-- Layout: c:tree(h) is the card as a flex tree; refresh solves it at its content height,
-- set_height at the row's. Slots (anim.group) and NW paragraphs are the boxes' nodes;
-- what's inside a slot is placed by hand, relative to it.
local theme = require("modules.theme")
local ui = require("modules.ui")
local images = require("modules.images")
local anim = require("modules.anim")
local button = require("components.button")
local panel = require("components.panel")
local blocks = require("modules.blocks")
local flex = require("modules.flex")
local flex_gui = require("modules.flex_gui")
local S = theme.size
local M = {}
M.__index = M
local PAD = 14
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- Columns for a width, like the web's grid (minmax(250px, 1fr)): count and card width.
-- Cards at least card_min_w wide keep the image column on the left; narrower ones put the
-- image on top (the web's .card: a 200 px media strip, then the body).
function M.columns(inner)
local cols = math.max(1, math.floor((inner + S.gap) / (S.card_col_min + S.gap)))
return cols, math.floor((inner - (cols - 1) * S.gap) / cols)
end
local STACK_MEDIA = 200 -- the image strip's height on a stacked card (app.css .card-media)
-- Image column width for a card width.
function M.media_size(cw)
return math.max(120, math.min(156, math.floor(cw * 0.3)))
end
-- A card at (x, y) of parent, w wide. opts: media (image column width, 0 for none: just
-- the text column), pad (the text column's padding on every side; default 14 across,
-- 12 above, 14 below).
function M.new(g, parent, x, y, w, opts)
opts = opts or {}
local stacked = opts.media == nil and w < S.card_min_w
local m = stacked and STACK_MEDIA or (opts.media or M.media_size(w))
local c = setmetatable({ g = g, x = x, y = y, w = w, m = m, footer_h = 0, stacked = stacked, pad = opts.pad, items = {} }, M)
c.root = ui.rect(parent, x, y, w, 1, "panel")
if m > 0 then
c.media = ui.rect(c.root, 0, 0, stacked and w or m, stacked and m or 1, "bg")
end
c.edge = ui.outline(c.root, 0, 0, w, 1, "edge")
-- The text column's padding (top, right, bottom, left) and width.
if m == 0 then
local side = opts.pad or PAD
c.padding = { opts.pad or 12, side, opts.pad or 14, side }
c.bw = w - side * 2
elseif stacked then
c.padding = { 14, PAD, 14, PAD }
c.bw = w - PAD * 2
else
c.padding = { 12, PAD, 14, PAD }
c.bw = w - m - PAD * 2
end
c.body = anim.group(c.root, 0, 0, c.bw, 1)
return c
end
-- The image (a catalog URL) in the image column, square at the top (stacked: square in
-- the middle of the strip).
function M:image(url)
local node = ui.rect(self.media, self.stacked and math.floor((self.w - self.m) / 2) or 0, 0, self.m, self.m, "none")
images.set(node, url)
self.image_node = node
end
-- A box in the text column (flex: modules/flex.lua).
local function add(self, box, gap)
if gap and gap > 0 then
self:gap(gap)
end
self.items[#self.items + 1] = box
return box.node
end
-- Vertical space.
function M:gap(px)
self.items[#self.items + 1] = { style = { height = px } }
end
-- One line of text in a slot h tall (default 18; its middle 8 px down, like the web's
-- line boxes), cut with ... to the column. style: theme.text name or table.
function M:text(str, style, h)
local slot = anim.group(self.body, 0, 0, self.bw, h or 18)
local t = ui.label(slot, str, style or "body", 0, 8)
if not ui.needs_rich(str) then
ui.fit_text(t, self.bw)
end
add(self, { node = slot, style = { width = self.bw, height = h or 18 } })
return t
end
-- The card's title (uppercase, semibold; a 22 px slot).
function M:title(str)
return self:text(str, { font = "sans_semi", px = 15, color = "text", tracking = 0.1, upper = true }, 22)
end
-- A paragraph that wraps at the column width (its height measured).
function M:para(str, style, gap)
style = type(style) == "string" and theme.text[style] or style or { font = "sans", px = 13, color = "text_2" }
local n = ui.text(self.body, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local s = gui.get_scale(n).x
gui.set_size(n, vec(self.bw / s, 10))
return add(self, { node = n, size = false, style = { width = self.bw }, measure = flex_gui.text_measure(n) }, gap)
end
-- Any node (its size, or a text's metrics, is what counts; pivot NW or W).
function M:node(n, gap)
gui.set_parent(n, self.body, false)
local measure
if gui.get_text(n) then
measure = flex_gui.text_measure(n)
else
measure = function()
local sz, sc = gui.get_size(n), gui.get_scale(n)
return sz.x * sc.x, sz.y * sc.y
end
end
return add(self, { node = n, size = false, style = { align_self = "start" }, measure = measure }, gap)
end
-- A block built by fn(parent, w) (stat rows, chips), top-left based: of fixed height h,
-- or c:block(fn, gap) where fn returns the height it used.
function M:block(h, fn, gap)
if type(h) == "function" then
h, fn, gap = nil, h, fn
end
local n = anim.group(self.body, 0, 0, self.bw, h or 1)
local used = fn(n, self.bw)
return add(self, { node = n, style = { width = self.bw, height = h or used or 0 } }, gap)
end
-- Something clickable inside the card's text (a link): fn runs once the card's own click
-- is bound, so it gets its clicks first.
function M:after_bind(fn)
self.late = self.late or {}
self.late[#self.late + 1] = fn
end
-- The bottom block (buttons): h tall, built by fn(parent, w) once the row's height is
-- known; it sits at the bottom of the card.
function M:footer(h, fn)
self.footer_h, self.footer_fn = h, fn
end
-- The card as a flex tree (modules/flex): the image column (or strip) and the text
-- column, whose items stack from the top and whose footer a growing spacer pushes to the
-- bottom; h: the card's height (nil: its content's, at least the image column's).
function M:tree(h)
local kids = {}
for i, b in ipairs(self.items) do
kids[i] = b
end
kids[#kids + 1] = { style = { grow = 1 } }
self.footer_box = { style = { width = self.bw, height = self.footer_h } }
kids[#kids + 1] = self.footer_box
local body = { node = self.body, style = { direction = "column", align = "start", grow = 1, padding = self.padding }, children = kids }
local root = { node = self.root, style = { width = self.w, height = h }, children = { body } }
if self.stacked then
root.style.direction = "column"
table.insert(root.children, 1, { node = self.media, style = { height = self.m } })
elseif self.m > 0 then
root.style.min_height = self.m
table.insert(root.children, 1, { node = self.media, style = { width = self.m } })
else
root.style.direction = "column"
end
return flex.solve(root, self.x, self.y)
end
-- Lay out the text column; returns the card's natural height.
function M:refresh()
local t = self:tree()
flex.apply(t)
self.natural = t.h
return self.natural
end
-- The card's final height (the tallest in its row): sizes the box and places the footer.
function M:set_height(h)
self.h = h
local t = self:tree(h)
flex.apply(t)
gui.set_size(self.edge, vec(self.w, h))
if self.stacked then
ui.rect(self.root, 0, self.m, self.w, 1, "line")
elseif self.m > 0 then
ui.rect(self.root, self.m, 0, 1, h, "line")
end
if self.selected then
gui.set_color(self.edge, ui.color("cyan"))
ui.outline(self.root, 1, 1, self.w - 2, h - 2, "cyan", 0.6)
elseif self.edge_color then
gui.set_color(self.edge, ui.color_spec(self.edge_color))
end
-- Hover: the edge brightens (app.css .card:hover, 0.15 s). Bound before the footer's
-- buttons: Druid gives the last made input first, so they get their clicks.
local edge = self.edge
local base = gui.get_color(edge)
local b = button.bind(self.g, self.root, nil, { id = self.id, on_click = self.click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(edge, "color")
local lit = self.selected and ui.color("cyan") or (self.edge_color and ui.color_spec(self.edge_hover or self.edge_color)) or ui.color("line_hi")
gui.animate(edge, "color", on and lit or base, gui.EASING_OUTQUAD, 0.15)
end)
for _, fn in ipairs(self.late or {}) do
fn()
end
if self.footer_fn then
local fb = self.footer_box
local f = anim.group(self.root, fb.x - self.x, fb.y - self.y, fb.w, fb.h)
self.footer_fn(f, self.bw)
end
end
-- A grid of cards from y; returns the y under it. make(c, item) fills one card. opts: the
-- cards' (M.new); opts.key(item): keep the card (modules/blocks) while opts.sig(item) (what
-- make shows besides the item; default the item alone) and its place stay the same.
function M.grid(g, parent, x, y, inner, items, make, opts)
local cols, cw = M.columns(inner)
local keyed = opts and opts.key
local i = 1
while i <= #items do
local row = {}
for col = 1, cols do
local item = items[i]
if not item then
break
end
local r = { item = item, x = x + (col - 1) * (cw + S.gap) }
if keyed then
r.key = opts.key(item)
r.sig = blocks.sig(r.x, y, cw, item, opts.sig and opts.sig(item) or false)
r.old = blocks.peek(g, r.key, r.sig) -- (kept, if its row's height stays the same)
end
if r.old then
r.natural = r.old.card.natural
else
if r.key then
r.blk = blocks.open(g, r.key, r.sig, parent)
end
r.c = M.new(g, r.blk and r.blk.root or parent, r.x, y, cw, opts)
make(r.c, item)
r.natural = r.c:refresh()
if r.blk then
r.blk.card = r.c
blocks.close(g, r.blk)
end
end
row[#row + 1] = r
i = i + 1
end
local rh = 0
for _, r in ipairs(row) do
rh = math.max(rh, r.natural)
end
for _, r in ipairs(row) do
if r.old and r.old.card.h == rh then
r.blk = blocks.open(g, r.key, r.sig, parent) -- (kept)
r.c = r.blk.card
else
if r.old then
-- The row's height changed: made again at it.
blocks.drop(g, r.old)
r.blk = blocks.open(g, r.key, r.sig, parent)
r.c = M.new(g, r.blk.root, r.x, y, cw, opts)
make(r.c, r.item)
r.c:refresh()
r.blk.card = r.c
elseif r.blk then
blocks.reopen(g, r.blk) -- (its hover and footer are the card's)
end
r.c:set_height(rh)
if r.blk then
blocks.close(g, r.blk)
end
end
local c = r.c
if g.frames then
g.frames[#g.frames + 1] = panel.frame(c.root, c.edge, cw, rh) -- staggered entrance
end
if g.fx then
g.fx:box(c.root) -- (an action's sweep covers the card)
end
end
y = y + rh + S.gap
end
return y
end
return M
+54
View File
@@ -0,0 +1,54 @@
-- 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, "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")
local M = {}
function M.new(parent, label, x, y, opts)
opts = opts or {}
local color = opts.color or "line_hi"
local style = { font = opts.font or "sans_semi", px = opts.px or 11, color = opts.text or color,
tracking = opts.tracking or (opts.font == "mono" and 0 or 0.12), upper = opts.upper }
local h = opts.h or 18
local pad = opts.pad or 6
local probe = ui.text(parent, label, style, 0, 0)
local w = math.max(opts.min_w or 0, math.floor(ui.text_width(probe) + pad * 2))
gui.delete_node(probe)
local left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
-- move the fill out to box's parent, at its 0, 0).
local edge = box
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
gui.set_parent(edge, box, false)
gui.set_position(edge, vmath.vector3(0, 0, 0))
end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w
end
-- Moon grade badges: edge/text color per grade, cheapest to best.
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
M.GRADE_W = 18
function M.grade(parent, grade, x, y, opts)
opts = opts or {}
local color = M.GRADE[grade] or "line_hi"
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
right = opts.right })
end
return M
+354
View File
@@ -0,0 +1,354 @@
-- Dialog (the web's .dialog): a modal panel over the game screen, in the same gui (on its
-- own layer, with its own Druid instance), so a confirmed action runs in the game
-- script and a page rebuild doesn't touch it. One at a time: g.dialog (core/main.lua).
--
-- g.dialog:open({ title, message, w, h,
-- ok = { label, price, danger, act = "<action>", params = {...} } or { label, on_ok = fn },
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
-- backdrop (a click outside still closes it), no title or buttons, its top-right
-- corner `right` from the page's right edge and `top` down; build makes the content.
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
-- In build, d.reply(action, params) closes and runs an action (effects start from the
-- clicked button); d:button(parent, label, x, y, opts) makes a button (components/button
-- opts plus on_click / id). The message wraps (ui.wrap, may hold ₥); the panel grows to
-- fit it. Test ids: "dialog:ok", "dialog:cancel".
--
-- Motion (app.css dialog-in/-out, ui.js): the backdrop fades in (0.28 s); the panel
-- scales up from .94, rises 8 px and unclips from its middle (0.28 s overshoot); a cyan
-- scan line sweeps down it; title, message and buttons rise in, 60 ms apart. Closing:
-- fade, scale .97, clip to the middle (0.18 s). Sounds: open (a confirm) or modal (custom
-- content, after the action's own), confirm on OK, close otherwise. While open the page
-- gets no input and its hovers are dropped (druid:reset_hovers); they come back on close.
local druid = require("druid.druid")
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local sfx = require("modules.sfx")
local button = require("components.button")
local test = require("modules.test")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
M.__index = M
local W0, H0 = 460, 230
local SCRIM = theme.color.scrim
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
-- g: the game screen; layer: a node over the page (top-left = page top-left).
function M.new(g, layer)
return setmetatable({ g = g, layer = layer }, M)
end
function M:is_open()
return self.root ~= nil and not self.closing
end
-- A button inside the dialog (its own Druid instance). opts: components/button draw
-- options plus on_click, id.
function M:button(parent, label, x, y, opts)
opts = opts or {}
local root, nodes, w = ui.button(parent, label, x, y, opts)
local d = self
if not opts.disabled then
button.bind(self.g, root, nodes, { id = opts.id, druid = self.druid, on_click = function()
d.last_button = root
if opts.on_click then
opts.on_click()
end
end })
end
return root, nodes, w
end
-- Close and run an action on the game screen (its effects start at `from`, a node, or
-- at the page button that opened the dialog: ok.from, a rect from fx:rect_of with its
-- test id). quiet: close without the confirm sound (the buy modal's buttons, main.js).
function M:act(name, params, from, quiet)
local g = self.g
local opener = self.opts and self.opts.ok and self.opts.ok.from
if opener then
g.fx.last_origin = { x = opener.x, y = opener.y, w = opener.w, h = opener.h, id = opener.id }
else
g.fx:origin(from)
end
self:close(quiet and "quiet" or "ok")
require("screens.game.core.session").act(g, name, params or {})
end
function M:open(opts)
if self.root then
self:remove()
end
local g = self.g
opts = opts or {}
self.opts = opts
self.closing = false
self.druid = druid.new(g)
test.begin_build("dialog")
test.set_scope("dialog")
g.druid:reset_hovers()
local W, H = g.size[1], g.size[2]
local w = opts.w or W0
local d = self
self.reply = function(name, params)
d:act(name, params, d.last_button, true)
end
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local menu = opts.menu
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
gui.set_position(clip, vec(0, 0))
gui.set_pivot(clip, gui.PIVOT_CENTER)
gui.set_clipping_mode(clip, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(clip, false)
self.clip, self.w, self.h = clip, w, h
local box = ui.panel(clip, -w / 2, -h / 2, w, h)
gui.set_color(box, ui.color("field", 0.96))
self.box = box
-- Input: Druid gives the last created component input first, so: the backdrop (a
-- click on it cancels), then a blocker over the panel, then the panel's buttons.
self.druid:new_button(scrim, function()
d:close("cancel")
end):set_animations_disabled()
self.druid:new_blocker(clip)
-- (Druid's back handler takes key_back only: Esc is a hotkey.)
self.druid:new_back_handler(function()
d:close("cancel")
end)
self.druid:new_hotkey("key_esc", function()
d:close("cancel")
end)
if menu then
opts.build(self, box, w, h)
test.set_scope("page")
sfx.play("toggle")
return
end
-- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0)
local g_body = anim.group(box, 0, 0)
local g_actions = anim.group(box, 0, 0)
-- The panel's layout (geometry only: each group builds into its own part): the title
-- (its middle 34 down), the message from 58, the buttons at the bottom right, OK
-- left of Cancel (Cancel is made first).
local cancel_label = opts.cancel or (opts.ok and "Cancel" or "Close")
local ok = opts.ok
local danger = ok and ok.danger
local ok_opts = ok and { price = ok.price, variant = danger and "danger" or "primary" }
local cancel_box = { style = { width = fg.button_w(root, cancel_label, {}), height = S.button_h, shrink = 0 } }
local ok_box = ok and { style = { width = fg.button_w(root, ok.label or "OK", ok_opts), height = S.button_h, shrink = 0 } }
local tree = flex.box({ direction = "column", width = w, height = h, padding = { 24, 24, 24, opts.title_x or 24 } }, {
{ style = { height = 20 }, id = "title" },
flex.box({ grow = 1 }),
flex.box({ justify = "end", gap = 10 }, ok_box and { ok_box, cancel_box } or { cancel_box }),
})
flex.solve(tree, 0, 0)
local title = tree.children[1]
ui.label(g_title, opts.title or "Notice", "h2", title.x, title.y + title.h / 2)
if opts.build then
opts.build(self, g_body, w, h)
else
ui.wrap(g_body, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 24, title.y + title.h + 14, w - 48)
end
local by = cancel_box.y
self:button(g_actions, cancel_label, cancel_box.x, cancel_box.y, {
id = "dialog:cancel", on_click = function() d:close("cancel") end,
})
if ok then
local ob
ob = self:button(g_actions, ok.label or "OK", ok_box.x, ok_box.y, {
id = "dialog:ok", price = ok.price,
variant = danger and "danger" or "primary",
fill = danger and { "danger", 0.16 } or nil, edge = danger and { "danger", 1 } or nil,
accent = danger and "danger" or nil,
on_click = function()
if ok.on_ok then
d:close("ok")
ok.on_ok()
elseif ok.act then
d:act(ok.act, ok.params, ob)
else
d:close("ok")
end
end,
})
end
self.by = by
test.set_scope("page")
self:animate_in(g_title, g_body, g_actions)
if opts.build then
sfx.play("modal", { delay = 150 })
else
sfx.play("open")
end
end
function M:animate_in(...)
local groups = { ... }
local holder, clip, scrim = self.holder, self.clip, self.scrim
gui.set_color(scrim, vmath.vector4(SCRIM.x, SCRIM.y, SCRIM.z, 0))
gui.animate(scrim, "color.w", SCRIM.w, anim.ease.ease_out, 0.28)
-- Content fades in from under veils in the panel's color (anim.veil): title, message,
-- buttons in turn.
local field = ui.color("field")
local w, h, by = self.w, self.h, self.by
local regions = { { 2, 48 }, { 50, by - 60 }, { by - 10, h - by + 8 } } -- (the edge stays)
for i, r in ipairs(regions) do
anim.veil(self.box, 2, r[1], w - 4, r[2], field, (i - 1) * 0.06, anim.reduced and 0.12 or 0.35)
end
if anim.reduced then
return
end
local p = gui.get_position(holder)
gui.set_scale(holder, vmath.vector3(0.94, 0.94, 1))
gui.animate(holder, "scale", vmath.vector3(1, 1, 1), anim.ease.overshoot, 0.28)
gui.set_position(holder, vec(p.x, p.y - 8))
gui.animate(holder, "position.y", p.y, anim.ease.overshoot, 0.28)
local size = gui.get_size(clip)
gui.set_size(clip, vec(size.x, size.y * 0.04))
gui.animate(clip, "size", size, anim.ease.overshoot, 0.28)
anim.sweep(self.box, self.w, self.h, ui.color("cyan"), 0.7, 0.05)
for i, grp in ipairs(groups) do
anim.rise(grp, (i - 1) * 0.06, 6, 0.35) -- rise-in: 0.35 s, 60 ms apart
end
end
-- value: "ok" (the confirm sound), "quiet" (none: the action makes its own) or "cancel".
function M:close(value)
if not self.root or self.closing then
return
end
self.closing = true
self.closing_at = socket.gettime()
if value ~= "quiet" then
sfx.play(value == "ok" and "confirm" or "close")
end
local d = self
local function done()
d:remove()
end
local dur = anim.reduced and 0.12 or 0.18
gui.animate(self.scrim, "color.w", 0, anim.ease.ease_in, dur, 0, done)
anim.veil(self.box, 2, 2, self.w - 4, self.h - 4, ui.color("field"), 0, dur, true)
if not anim.reduced then
gui.animate(self.holder, "scale", vmath.vector3(0.97, 0.97, 1), anim.ease.ease_in, 0.18)
local size = gui.get_size(self.clip)
gui.animate(self.clip, "size", vec(size.x, size.y * 0.04), anim.ease.ease_in, 0.18)
end
self.g.rehover = true -- the page's hover comes back under the pointer
end
-- Gone at once (a new dialog, the screen going away).
function M:remove()
if self.druid then
-- A dialog gone in the frame it opened (another one replacing it): Druid's late
-- init (a timer.delay(0)) would still run on its deleted nodes.
if self.druid._late_init_timer_id then
timer.cancel(self.druid._late_init_timer_id)
self.druid._late_init_timer_id = nil
end
self.druid:final()
self.druid = nil
end
if self.root then
gui.delete_node(self.root)
end
test.begin_build("dialog")
self.root, self.closing, self.opts, self.closing_at = nil, false, nil, nil
end
-- How long a close may take before its end is forced (the close animation is 0.18 s; its
-- callback is what removes the backdrop, and while the backdrop exists the page gets no
-- input).
local CLOSE_LIMIT = 1
-- The input invariants (checked every frame by core/main.lua): what blocks the page's
-- input exists only while a dialog is open or closing, and a close finishes. Returns a
-- description of what was wrong (and is now repaired), or nil.
function M:check()
if not self.root then
if self.druid then
self.druid:final()
self.druid = nil
return "a dialog's Druid instance outlived it"
end
return nil
end
if not self.druid then
self:remove()
return "a dialog backdrop without its input"
end
if self.closing and socket.gettime() - (self.closing_at or 0) > CLOSE_LIMIT then
self:remove()
return "a dialog close never finished"
end
return nil
end
-- The page was resized: keep the backdrop full and the panel centered.
function M:layout(W, H)
if not self.root then
return
end
gui.set_size(self.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y")
local menu = self.opts and self.opts.menu
if menu then
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
else
gui.set_position(self.holder, vec(W / 2, -H / 2))
end
end
function M:update(dt)
if self.druid then
self.druid:update(dt)
end
end
-- Returns true while open (the page gets no input then).
function M:on_input(action_id, action)
if not self.root then
return false
end
if self.druid and not self.closing then
self.druid:on_input(action_id, action)
end
return true
end
function M:on_message(message_id, message, sender)
if self.druid then
self.druid:on_message(message_id, message, sender)
end
end
return M
+107
View File
@@ -0,0 +1,107 @@
-- A long list (Druid Data List over Scroll + Static Grid, druid-notes.md 2.3): only the
-- rows in view exist, made by create() as they scroll in and deleted as they leave, so
-- hundreds of moons cost a screenful of nodes. Fixed row height; the scroll offset is kept
-- per key on g (g.scroll_pos) so it survives rebuilds; wheel and drag scroll.
--
-- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
-- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus)
-- 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
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
function M.new(g, parent, x, y, w, h, opts)
local l = setmetatable({ g = g, parent = parent, x = x, y = y, w = w, h = h, key = opts.key, create = blocks.bound(opts.create), -- (rows made as it scrolls are its block's)
focus_id = opts.focus_id, row_h = opts.row_h, items = {} }, M)
g.scroll_pos = g.scroll_pos or {}
l.view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(l.view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(l.view, false)
l.content = ui.rect(l.view, 0, 0, w, h, "none")
-- The grid's prefab: a node of the row's slot size (never shown).
l.prefab = ui.rect(l.view, 0, 0, w, opts.row_h, "none")
gui.set_enabled(l.prefab, false)
l.scroll = g.druid:new_scroll(l.view, l.content)
l.scroll:set_horizontal_scroll(false)
l.scroll:set_extra_stretch_size(0)
l.scroll.style.WHEEL_SCROLL_SPEED = 48
l.grid = g.druid:new_grid(l.content, l.prefab, 1)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index)
end)
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)
end
g.scroll_pos[l.key] = pos.y
l:place_thumb(pos.y)
end)
blocks.push(g, "scroll_areas", { view = l.view, content = l.content, key = l.key, max = 0, h = h, list = l, scroll = l.scroll })
return l
end
function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0
self.items = items
-- 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
a.max = max
end
end
-- A thin bar at the right edge (like components/scroll) when it overflows.
if self.track then
gui.delete_node(self.track)
gui.delete_node(self.thumb)
self.track, self.thumb = nil, nil
end
self.max = max
if max > 0 then
local track_h = self.h - 8
self.thumb_h = math.max(28, math.floor(track_h * self.h / (self.h + max)))
self.track = ui.rect(self.parent, self.x + self.w + 4, self.y + 4, 1, track_h, "line")
self.thumb = ui.rect(self.parent, self.x + self.w + 3, self.y + 4, 3, self.thumb_h, "cyan", 0.55)
end
self:place_thumb(self.scroll.position.y)
end
function M:place_thumb(pos_y)
if self.thumb then
local f = math.max(0, math.min(1, pos_y / self.max))
gui.set_position(self.thumb, vec(self.x + self.w + 3, -(self.y + 4 + (self.h - 8 - self.thumb_h) * f)))
end
end
return M
+47
View File
@@ -0,0 +1,47 @@
-- Panel (the web's .frame): a translucent box with a thin edge and cyan corner brackets
-- (gui/templates/panel.gui), and a body group the content goes in. enter() plays the
-- web's entrance (enter.js): the brackets spread from the middle, the content fades in
-- from under a veil and rises 6 px; frames nested in it (cards) follow, staggered.
--
-- local p = panel.new(parent, x, y, w, h) -- p.root (the fill), p.frame, p.body
-- ... build into p.body (top-left based, like any parent) ...
-- p:enter({ delay = 0, nested = { frames } }) -- once it's built (core/main.lua calls
-- -- it when the view changes)
-- panel.frame(node, edge, w, h) -- a nested frame (a card) for `nested`
local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local M = {}
M.__index = M
function M.new(parent, x, y, w, h)
local root, nodes = ui.panel(parent, x, y, w, h)
local p = setmetatable({ root = root, frame = ui.part(nodes, "panel", "frame"), w = w, h = h }, M)
p.body = anim.group(root, 0, 0, w, h)
return p
end
-- A nested frame: a card (fill node holding its content, pivot NW) and its edge (an
-- outline over it). For enter's `nested` list.
function M.frame(node, edge, w, h)
return { node = node, edge = edge, w = w, h = h }
end
-- The entrance. opts: delay, clip (false: the box doesn't unclip; the aside, which holds
-- the 3D viewport), nested (frames inside, staggered after this one).
function M:enter(opts)
opts = opts or {}
local delay = opts.delay or 0
local veil = theme.color.veil
anim.enter_frame({ frame = self.frame, body = self.body, veil = { parent = self.root, color = veil },
x = 1, y = 1, w = self.w - 2, h = self.h - 2 }, { delay = delay, clip = false })
local frames = {}
for _, f in ipairs(opts.nested or {}) do
frames[#frames + 1] = { frame = f.edge, veil = { parent = f.node, color = veil }, x = 0, y = 0, w = f.w, h = f.h }
end
anim.enter(frames, { nested = true, delay = delay })
end
return M
+151
View File
@@ -0,0 +1,151 @@
-- List row (wells table rows, the moon list's rows): a full-width row that lights up
-- under the mouse (cyan wash, eased), shows its selection (wash and a 2 px cyan bar on
-- the left) and runs on_click. Buttons on the row made after it take their clicks first.
--
-- row.bind(g, node, w, h, { selected = true, on_click = fn, id = "well:<id>", label })
--
-- A new row's entrance (app.css fx-new: a moon just bought, a drill just deployed): it
-- slides in from 28 px right and fades in over a cyan wash (.35, .2 at 40%, then the
-- row's own fill), with a 3 px cyan bar on its left until halfway (0.9 s). Rows are
-- rebuilt with every state, so the entrance is kept by id and time on g and picks up
-- where it was on the rebuilt row.
--
-- local new = row.fresh(g, "moons", scope, ids) -- ids not there last build (same scope)
-- row.mark(g, id) -- start an entrance for id now
-- row.enter(g, id, node, w, h, { base = fill, slide = node, fade = fn, veil = color })
local ui = require("modules.ui")
local anim = require("modules.anim")
local button = require("components.button")
local M = {}
M.HOVER, M.SELECTED = 0.05, 0.08
function M.bind(g, node, w, h, opts)
opts = opts or {}
local base = ui.color("cyan", opts.selected and M.SELECTED or 0)
gui.set_color(node, base)
if g.fx then
g.fx:box(node) -- (an action's sweep covers the row its button is on)
end
if opts.selected then
ui.rect(node, 0, 0, 2, h - 1, "cyan")
end
local b = button.bind(g, node, nil, { id = opts.id, label = opts.label, on_click = opts.on_click })
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(node, "color")
local c = on and ui.color("cyan", opts.selected and M.SELECTED or M.HOVER) or base
if button.instant then
gui.set_color(node, c)
else
gui.animate(node, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
return b
end
---------------------------------------------------------------- new rows
M.ENTER = 0.9
local function clock()
return socket.gettime()
end
-- Which of ids weren't there the last time fresh() ran for key (as a set), when scope
-- (e.g. the moon shown) is the same; nothing the first time or after scope changes.
function M.fresh(g, key, scope, ids)
g.row_known = g.row_known or {}
local was = g.row_known[key]
local now_set = {}
for _, id in ipairs(ids) do
now_set[id] = true
end
g.row_known[key] = { scope = scope, ids = now_set }
local new = {}
if was and was.scope == scope then
for _, id in ipairs(ids) do
if not was.ids[id] then
new[id] = true
end
end
end
return new, was and was.scope == scope and was.ids or nil
end
-- Start an entrance for id (at = when; default now).
function M.mark(g, id, at)
g.row_new = g.row_new or {}
g.row_new[id] = at or clock()
end
-- Play (or pick up) id's entrance on node (a row, w x h, pivot NW). opts: base (the
-- row's resting fill color, vector4; default transparent), slide (the node that slides;
-- default node), fade (fade(e): shows the content at e, 0..1, e.g. through the row's own veil), veil (without
-- fade: the background color a veil over the row fades from; default the panel's), bar
-- (false: no left bar).
function M.enter(g, id, node, w, h, opts)
local t0 = g.row_new and g.row_new[id]
if not t0 then
return
end
local age = clock() - t0
if age >= M.ENTER then
g.row_new[id] = nil
return
end
opts = opts or {}
local base = opts.base or ui.color("cyan", 0)
local cyan = ui.color("cyan")
local slide = opts.slide or node
local p0 = gui.get_position(slide)
local veil
if not opts.fade then
veil = ui.rect(node, 0, 0, w, h, opts.veil or "veil")
end
local bar = opts.bar ~= false and ui.rect(node, 0, 0, 3, h, "cyan") or nil
local ease = anim.fn.out
local function step(k)
local t = k * M.ENTER
local a = math.min(1, t / (M.ENTER * 0.4)) -- 0..40%: in
local e = ease(a)
-- The wash: .35 -> .2 (40%) -> the row's fill.
local wash
if a < 1 then
wash = vmath.lerp(e, ui.color("cyan", 0.35), ui.color("cyan", 0.2))
else
wash = vmath.lerp(ease((t - M.ENTER * 0.4) / (M.ENTER * 0.6)), ui.color("cyan", 0.2), base)
end
gui.set_color(node, wash)
if not anim.reduced then
gui.set_position(slide, vmath.vector3(p0.x + 28 * (1 - e), p0.y, p0.z))
end
if opts.fade then
opts.fade(e)
elseif veil then
anim.set(veil, "color.w", 1 - e)
end
if bar then
local b = k < 0.5 and 1 or 1 - ease((k - 0.5) / 0.5)
gui.set_color(bar, vmath.vector4(cyan.x, cyan.y, cyan.z, b))
gui.set_size(bar, vmath.vector3(3 * b, h, 0))
end
end
step(age / M.ENTER)
anim.tween(node, M.ENTER, step, -age, function()
pcall(gui.set_color, node, base)
pcall(gui.set_position, slide, p0)
if veil then
pcall(gui.delete_node, veil)
end
if bar then
pcall(gui.delete_node, bar)
end
if opts.fade then
pcall(opts.fade, 1)
end
end)
end
return M
+94
View File
@@ -0,0 +1,94 @@
-- A vertically scrolling area (Druid scroll in a stencil-clipped view) for content taller
-- than its panel: cards in the Store, Equipment and Advisors, the drill panel.
--
-- local area = scroll.area(self, parent, x, y, w, h, "store")
-- ... build into area.content (top-left based, like any parent) ...
-- area:finish(content_height)
--
-- A section built again makes its scroll area again, so the offset is kept per key in
-- self.scroll_pos and restored on the next build. Buttons inside take the view as their
-- click zone (Druid does that for nodes under a stencil), so hidden parts can't be
-- clicked. Mouse wheel and drag scroll; a thin bar on the right shows where you are; its
-- thumb lights up under the mouse (the web's scrollbar hover). Areas register on
-- g.scroll_areas (the test hook's scroll_to brings an element into view).
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local M = {}
local WHEEL = 48 -- pixels per wheel step
local BAR_W = 3
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Area = {}
Area.__index = Area
function M.area(self, parent, x, y, w, h, key)
local view = ui.rect(parent, x, y, w, h, "text")
gui.set_clipping_mode(view, gui.CLIPPING_MODE_STENCIL)
gui.set_clipping_visible(view, false)
local content = ui.rect(view, 0, 0, w, h, "none")
self.scroll_pos = self.scroll_pos or {}
return setmetatable({ self = self, parent = parent, view = view, content = content,
x = x, y = y, w = w, h = h, key = key }, Area)
end
-- Call once the content is built: content_h is its height. Returns the Druid scroll (nil
-- when everything fits).
function Area:finish(content_h)
local self_, w, h = self.self, self.w, self.h
content_h = math.max(h, math.ceil(content_h))
gui.set_size(self.content, vec(w, content_h))
local max = content_h - h
if max <= 0 then
self_.scroll_pos[self.key] = 0
return nil
end
-- An eased key scroll that a rebuild cut short (core/keys) lands where it was going.
local goal = self_.scroll_goal
if goal and goal.key == self.key and socket.gettime() - goal.t < 1 then
self_.scroll_pos[self.key] = goal.y
self_.scroll_goal = nil
end
local y0 = math.max(0, math.min(self_.scroll_pos[self.key] or 0, max))
gui.set_position(self.content, vec(0, y0))
-- The bar sits outside the clipped view (on the parent), at the view's right edge.
local track_h = h - 8
local thumb_h = math.max(28, math.floor(track_h * h / content_h))
ui.rect(self.parent, self.x + w - BAR_W, self.y + 4, 1, track_h, "line")
local thumb = ui.rect(self.parent, self.x + w - BAR_W - 1, self.y + 4, BAR_W, thumb_h, "cyan", 0.55)
local function place(pos_y)
local f = math.max(0, math.min(1, pos_y / max))
gui.set_position(thumb, vec(self.x + w - BAR_W - 1, -(self.y + 4 + (track_h - thumb_h) * f)))
end
place(y0)
-- Hover: a wider invisible strip over the bar lights the thumb.
local hot = ui.rect(self.parent, self.x + w - 10, self.y + 4, 10, track_h, "none")
local hover = self_.druid:new_hover(hot)
hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(thumb, "color")
gui.animate(thumb, "color", ui.color("cyan", on and 1 or 0.55), gui.EASING_OUTQUAD, 0.12)
gui.animate(thumb, "size.x", on and BAR_W + 2 or BAR_W, gui.EASING_OUTQUAD, 0.12)
end)
local s = self_.druid:new_scroll(self.view, self.content)
blocks.push(self_, "scroll_areas", { view = self.view, content = self.content, key = self.key, max = max, h = h, scroll = s })
s:set_horizontal_scroll(false)
s:set_extra_stretch_size(0)
s.style.WHEEL_SCROLL_SPEED = WHEEL
s.on_scroll:subscribe(function(_, pos)
if self_.tearing_down then
return -- (a rebuild dropping the old scroll: keep the offset)
end
self_.scroll_pos[self.key] = pos.y
place(pos.y)
end)
return s
end
return M
+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
+31
View File
@@ -0,0 +1,31 @@
-- Stat lines: a cap label with its value.
--
-- stat.inline(parent, "Yield", "+3", x, y, color) "YIELD +3" (y the middle); returns
-- its width (cards.stat)
-- stat.row(parent, "Supply left", "75", x, y, w, opts) label left, value right across
-- w (y the top); returns the y under
-- it (H.fact). opts: credits (₥ before
-- the value), suffix, color
local ui = require("modules.ui")
local M = {}
function M.inline(parent, label, value, x, y, color)
local l = ui.text(parent, label, "cap", x, y)
local lw = ui.text_width(l)
local v = ui.text(parent, value, { font = "mono", px = 14, color = color or "text" }, x + lw + 6, y)
return lw + 6 + ui.text_width(v)
end
function M.row(parent, label, value, x, y, w, opts)
ui.text(parent, label, "cap", x, y + 8)
if opts and opts.credits then
ui.credits(parent, value, x + w, y + 8, { pivot = gui.PIVOT_E, suffix = opts.suffix })
else
ui.text(parent, value, { font = "mono", px = 13, color = opts and opts.color or "text" }, x + w, y + 8, gui.PIVOT_E)
end
return y + 20
end
return M
+193
View File
@@ -0,0 +1,193 @@
-- Tabs: the web's nav tabs and their sliding ink (tabs.js, app.css "tab ink"). A strip's
-- ink is a highlight (transparent to cyan .1, top to bottom) with a glowing 2 px
-- underline under the active tab; when the active tab changes it slides from the old
-- one (220 ms, a slight overshoot). Its spot is kept per strip key on g (g.inks), so it
-- slides across the rebuild a navigation causes; a layout resize makes it jump.
--
-- local ink = tabs.ink(g, parent, "nav") -- before the tabs (it draws under them)
-- ... place the tabs ...
-- tabs.tab(g, root, text_node, { active = true, on_click = fn, id = "tab:market" })
-- ink:to(x, y, w, h) -- the active tab's box in parent
-- ink:none() -- no active tab (the ink goes)
--
-- tabs.tab: hover lights the label (nav -> text, eased), a click runs on_click; the label
-- keeps opts.color (default "nav", active "cyan").
--
-- A tab revealed after the page loaded (onboarding) enters once (app.css tab-reveal, 0.7 s
-- overshoot): it fades in, drops 8 px into place and grows from .9 about its middle, out
-- of a soft glow (the web's blur); kept by name and time on g across rebuilds.
-- tabs.revealed(g, "nav", names) -- once per build: which names are new
-- tabs.reveal(g, name, root, text, w, h) -- play / pick up its entrance
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local M = {}
local SLIDE = 0.22
local REVEAL = 0.7
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local Ink = {}
Ink.__index = Ink
function M.ink(g, parent, key)
g.inks = g.inks or {}
local root = anim.group(parent, 0, 0)
-- Highlight: the fade texture flipped (transparent at the top).
local hl = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(hl, root, false)
gui.set_texture(hl, "ui")
gui.play_flipbook(hl, "fade")
gui.set_pivot(hl, gui.PIVOT_CENTER)
gui.set_rotation(hl, vmath.vector3(0, 0, 180))
gui.set_color(hl, ui.color("cyan", 0.1))
local glow = gui.new_box_node(vec(0, 0), vec(1, 1))
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_pivot(glow, gui.PIVOT_CENTER)
gui.set_color(glow, ui.color("cyan", 0.45))
local line = ui.rect(root, 0, 0, 1, 2, "cyan")
gui.set_enabled(root, false)
return setmetatable({ g = g, key = key, root = root, hl = hl, glow = glow, line = line }, Ink)
end
local function place(self, r)
gui.set_position(self.root, vec(r.x, -r.y))
gui.set_size(self.hl, vec(r.w, r.h))
gui.set_position(self.hl, vec(r.w / 2, -r.h / 2))
gui.set_size(self.glow, vec(r.w + 16, 18))
gui.set_position(self.glow, vec(r.w / 2, -(r.h - 1)))
gui.set_size(self.line, vec(r.w, 2))
gui.set_position(self.line, vec(0, -(r.h - 2)))
end
-- Show the ink on the active tab's box (x, y, w, h in the strip's parent).
function Ink:to(x, y, w, h)
local g = self.g
local to = { x = x, y = y, w = w, h = h, layout = g.size and (g.size[1] .. "x" .. g.size[2]) }
local from = g.inks[self.key]
g.inks[self.key] = to
gui.set_enabled(self.root, true)
if not from or from.layout ~= to.layout or anim.reduced
or (from.x == x and from.y == y and from.w == w and from.h == h) then
place(self, to)
return
end
-- Slide: from the old spot to the new one, a frame-driven tween (box sizes and
-- positions together).
place(self, from)
local ease = anim.fn.ink
anim.tween(self.root, SLIDE, function(k)
local e = ease(k)
place(self, { x = from.x + (x - from.x) * e, y = from.y + (y - from.y) * e,
w = from.w + (w - from.w) * e, h = from.h + (h - from.h) * e })
end)
end
-- No active tab: the ink goes (and comes back without a slide).
function Ink:none()
self.g.inks[self.key] = nil
gui.set_enabled(self.root, false)
end
-- Names not shown in the last build of this strip start their entrance now (none the
-- first time: what's there on load doesn't enter).
function M.revealed(g, key, names)
g.tabs_known = g.tabs_known or {}
g.tab_reveal = g.tab_reveal or {}
local known = g.tabs_known[key]
local now = socket.gettime()
local set = {}
for _, n in ipairs(names) do
set[n] = true
if known and not known[n] then
g.tab_reveal[key .. ":" .. n] = now
end
end
g.tabs_known[key] = set
end
-- The entrance of a revealed tab (root: pivot NW, w x h; text: its label), if it is
-- still playing. extra: more nodes that fade in with the label (a button's edge, price).
function M.reveal(g, key, name, root, text, w, h, extra)
local t0 = g.tab_reveal and g.tab_reveal[key .. ":" .. name]
if not t0 then
return
end
local age = socket.gettime() - t0
if age >= REVEAL then
g.tab_reveal[key .. ":" .. name] = nil
return
end
local p, s = gui.get_position(root), gui.get_scale(root)
local fill, tc = gui.get_color(root), gui.get_color(text)
local glow = gui.new_box_node(vec(w / 2, -h / 2), vec(w + 20, h))
local more = {}
for _, n in ipairs(extra or {}) do
more[#more + 1] = { n, gui.get_color(n) }
end
gui.set_parent(glow, root, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.move_below(glow, text)
local function step(k)
local a = anim.fn.out(math.min(1, k / 0.5)) -- (opacity: most of it early)
for _, m in ipairs(more) do
local c = m[2]
gui.set_color(m[1], vmath.vector4(c.x, c.y, c.z, c.w * a))
end
if anim.reduced then
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
return
end
local e = anim.fn.overshoot(k)
anim.scale_about(root, vmath.vector3(p.x, p.y + 8 * (1 - e), p.z), s, w / 2, -h / 2, 0.9 + 0.1 * e)
gui.set_color(text, vmath.vector4(tc.x, tc.y, tc.z, tc.w * a))
gui.set_color(root, vmath.vector4(fill.x, fill.y, fill.z, fill.w * a))
gui.set_color(glow, ui.color("cyan_hi", 0.35 * a * (1 - k)))
end
step(age / REVEAL)
anim.tween(root, REVEAL, step, -age, function()
pcall(gui.set_position, root, p)
pcall(gui.set_scale, root, s)
pcall(gui.set_color, root, fill)
pcall(anim.set, text, "color.w", tc.w)
for _, m in ipairs(more) do
pcall(gui.set_color, m[1], m[2])
end
pcall(gui.delete_node, glow)
end)
end
-- A tab's behavior: hover lights its label, a click runs opts.on_click. opts: active,
-- color (the label's color when not hovered), on_click, id (test id), label.
function M.tab(g, root, text, opts)
opts = opts or {}
local base = ui.color(opts.color or (opts.active and "cyan" or "nav"))
local lit = ui.color(opts.active and "cyan" or "text")
local b = g.druid:new_button(root, function()
if opts.on_click then
opts.on_click()
end
end):set_animations_disabled()
b.hover.on_mouse_hover:subscribe(function(_, on)
gui.cancel_animations(text, "color")
local c = on and lit or base
if anim.reduced or require("components.button").instant then
gui.set_color(text, c)
else
gui.animate(text, "color", c, gui.EASING_OUTQUAD, 0.12)
end
end)
test.register(opts.id, root, "tab", opts.label or gui.get_text(text))
return b
end
return M
+66
View File
@@ -0,0 +1,66 @@
[project]
title = MoonMiners
version = 0.1.0
dependencies#0 = https://github.com/Insality/defold-event/archive/refs/tags/21.zip
dependencies#1 = https://github.com/Insality/druid/archive/refs/tags/1.4.1.zip
dependencies#2 = https://github.com/britzl/monarch/archive/refs/tags/6.0.3.zip
[bootstrap]
main_collection = /main/main.collectionc
render = /render/mm.renderc
[display]
width = 1280
height = 800
high_dpi = 1
update_frequency = 60
vsync = 1
[render]
clear_color_red = 0.0157
clear_color_green = 0.0392
clear_color_blue = 0.0863
clear_color_alpha = 1
[input]
game_binding = /builtins/input/all.input_bindingc
[script]
shared_state = 1
[graphics]
max_font_batches = 1024
default_texture_min_filter = linear
default_texture_mag_filter = linear
max_characters = 32768
[gui]
max_count = 2048
[event]
use_xpcall = 1
[druid]
no_auto_input = 1
[mm]
api_base = http://localhost:3000
quit_after = 0
popup = 0
window =
ui_scale =
moon_id =
moon_spin = 1
player =
view =
[sound]
max_component_count = 64
[html5]
htmlfile = /web/index.html
scale_mode = stretch
show_fullscreen_button = 0
show_made_with_defold = 0
show_console_banner = 0
heap_size = 256
+79
View File
@@ -0,0 +1,79 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.1
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 160.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 80.0000
y: -22.0000
}
size {
x: 150.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Button"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 80.0000
y: 20.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -6.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 14.0000
y: -10.0000
}
size {
x: 70.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "text"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+32
View File
@@ -0,0 +1,32 @@
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Label"
font: "sans"
id: "root"
pivot: PIVOT_W
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+180
View File
@@ -0,0 +1,180 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 242.0000
y: 58.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/outline"
id: "edge"
pivot: PIVOT_NW
parent: "root"
alpha: 0.0
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: 10.0000
y: -11.0000
}
size {
x: 36.0000
y: 36.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/sphere"
id: "planet"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 58.0000
y: -20.0000
}
size {
x: 150.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Moon"
font: "sans_semi"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 58.0000
y: -39.0000
}
size {
x: 150.0000
y: 18.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Idle"
font: "sans"
id: "sub"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 232.0000
y: -29.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_BOX
texture: "ui/outline"
id: "badge"
pivot: PIVOT_E
parent: "root"
slice9 {
x: 2.0000
y: 2.0000
z: 2.0000
w: 2.0000
}
}
nodes {
position {
x: -11.0000
y: 0.0000
}
size {
x: 22.0000
y: 18.0000
}
color {
x: 1.0000
y: 0.8196
z: 0.4000
}
type: TYPE_TEXT
text: "1"
font: "mono"
id: "badge_text"
pivot: PIVOT_CENTER
parent: "badge"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+52
View File
@@ -0,0 +1,52 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.0549
y: 0.1255
z: 0.2275
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.55
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 200.0000
y: 200.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/frame"
id: "frame"
pivot: PIVOT_NW
parent: "root"
slice9 {
x: 14.0000
y: 14.0000
z: 14.0000
w: 14.0000
}
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+25
View File
@@ -0,0 +1,25 @@
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 100.0000
y: 100.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+73
View File
@@ -0,0 +1,73 @@
fonts {
name: "sans_semi"
font: "/assets/fonts/sans_semi.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 110.0000
y: 44.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 55.0000
y: -22.0000
}
size {
x: 110.0000
y: 24.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Tab"
font: "sans_semi"
id: "text"
pivot: PIVOT_CENTER
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -42.0000
}
size {
x: 110.0000
y: 2.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "underline"
pivot: PIVOT_NW
parent: "root"
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+208
View File
@@ -0,0 +1,208 @@
fonts {
name: "mono"
font: "/assets/fonts/mono.font"
}
fonts {
name: "sans"
font: "/assets/fonts/sans.font"
}
textures {
name: "ui"
texture: "/assets/ui.atlas"
}
nodes {
position {
x: 0.0000
y: 0.0000
}
size {
x: 520.0000
y: 52.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "root"
pivot: PIVOT_NW
alpha: 0.0
}
nodes {
position {
x: 0.0000
y: -51.0000
}
size {
x: 520.0000
y: 1.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "line"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 10.0000
y: -22.0000
}
size {
x: 8.0000
y: 8.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "dot"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 26.0000
y: -26.0000
}
size {
x: 120.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: "Well"
font: "sans"
id: "name"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 140.0000
y: 6.0000
}
color {
x: 0.3725
y: 0.8314
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar_bg"
pivot: PIVOT_NW
parent: "root"
alpha: 0.14
}
nodes {
position {
x: 150.0000
y: -23.0000
}
size {
x: 70.0000
y: 6.0000
}
color {
x: 1.0000
y: 1.0000
z: 1.0000
}
type: TYPE_BOX
texture: "ui/white"
id: "bar"
pivot: PIVOT_NW
parent: "root"
}
nodes {
position {
x: 150.0000
y: -38.0000
}
size {
x: 140.0000
y: 16.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "0"
font: "mono"
id: "supply"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 310.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.4980
y: 0.6078
z: 0.7216
}
type: TYPE_TEXT
text: "Empty"
font: "sans"
id: "drill"
pivot: PIVOT_W
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
nodes {
position {
x: 510.0000
y: -26.0000
}
size {
x: 100.0000
y: 20.0000
}
color {
x: 0.8745
y: 0.9647
z: 1.0000
}
type: TYPE_TEXT
text: ""
font: "mono"
id: "status"
pivot: PIVOT_E
parent: "root"
outline_alpha: 0.0
shadow_alpha: 0.0
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
+153
View File
@@ -0,0 +1,153 @@
name: "main"
scale_along_z: 0
embedded_instances {
id: "main"
data: "components {\n"
" id: \"main\"\n"
" component: \"/main/main.script\"\n"
"}\n"
""
}
embedded_instances {
id: "game"
data: "components {\n"
" id: \"screen_proxy\"\n"
" component: \"/monarch/screen_proxy.script\"\n"
" properties {\n"
" id: \"screen_id\"\n"
" value: \"game\"\n"
" type: PROPERTY_TYPE_HASH\n"
" }\n"
"}\n"
"embedded_components {\n"
" id: \"collectionproxy\"\n"
" type: \"collectionproxy\"\n"
" data: \"collection: \\\"/screens/game/game.collection\\\"\\n"
"\"\n"
"}\n"
""
}
embedded_instances {
id: "sfx"
data: "embedded_components {\n"
" id: \"arrive\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/arrive.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"burst\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/burst.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"buy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/buy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"charge\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"rumble\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"reveal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"coin\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/coin.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"collect\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/collect.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"confirm\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/confirm.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"deploy\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/deploy.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"error\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/error.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"harvest\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/harvest.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"modal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/modal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"moon\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/moon.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"nav\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/nav.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"open\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/open.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"powerdown\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/powerdown.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"ready\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/ready.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"refine\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/refine.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"shimmer\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/shimmer.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"tick\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/tick.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"toggle\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/toggle.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"upgrade\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/upgrade.ogg\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
""
}
+55
View File
@@ -0,0 +1,55 @@
-- Bootstrap: shows the game screen once the Monarch screen proxies have registered.
--
-- Debug flags (game.project [mm], or --config=mm.<key>=<value> at launch):
-- mm.quit_after seconds after which the game exits (used by `make shot`; 0: never)
-- mm.popup 1: open a dialog on start (to check it in a shot; core/main.lua)
-- mm.window WxH: resize the window on start (to check scaling; display.width and
-- display.height are the minimum layout size and stay 1280x800)
-- mm.ui_scale the UI scale, forced (not saved; modules/screen.lua)
-- mm.display_scale pretend the display has this scale (HiDPI checks; modules/screen.lua)
--
-- The window listener: sound goes silent while the window is unfocused or minimized
-- (modules/sfx set_hidden, like the web tab going hidden).
local monarch = require("monarch.monarch")
local api = require("modules.api")
local sfx = require("modules.sfx")
local screen = require("modules.screen")
local function on_window(self, event, data)
if event == window.WINDOW_EVENT_FOCUS_LOST or event == window.WINDOW_EVENT_ICONIFIED then
sfx.set_hidden(true)
elseif event == window.WINDOW_EVENT_FOCUS_GAINED or event == window.WINDOW_EVENT_DEICONIFIED then
sfx.set_hidden(false)
end
end
function init(self)
print("mm: api " .. api.base .. ", player " .. (api.player_id() or "none yet"))
-- In a browser the frames come from requestAnimationFrame at the display's rate; with
-- display.update_frequency set the engine skips callbacks to approach it (judder on
-- 120/144 Hz displays, dropped frames on jittery 60 Hz ones). Variable rate there:
-- every callback is a frame, dt is the real time.
if html5 then
sys.set_update_frequency(0)
end
local w, h = sys.get_config_string("mm.window", ""):match("^(%d+)x(%d+)$")
if w then
msg.post("@render:", "resize", { width = tonumber(w), height = tonumber(h) })
end
window.set_listener(on_window)
screen.apply_saved()
msg.post("#", "start")
local quit_after = sys.get_config_number("mm.quit_after", 0)
if quit_after > 0 then
timer.delay(quit_after, false, function()
sys.exit(0)
end)
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("start") then
monarch.show(hash("game"))
end
end
+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
+1
View File
@@ -0,0 +1 @@
name: "MMWindow"
+162
View File
@@ -0,0 +1,162 @@
// mmwindow: a fullscreen toggle at runtime (Defold's window module has none; the engine
// reads display.fullscreen once at start). Desktop only: the engine's GLFW window (the
// current GL context's) goes to its monitor at the monitor's own video mode ("windowed
// fullscreen": no mode change) and back to the size and position it had.
//
// mmwindow.set_fullscreen(true | false)
// mmwindow.is_fullscreen() -> bool
//
// Also sticky mouse buttons and keys: the engine polls button and key states once a frame,
// so a press and release between two polls (a quick click or a touchpad tap at a low frame
// rate) was lost; sticky, a press released before the poll still reads as pressed once.
//
// The GLFW functions are the engine's (statically linked); declared here, no header.
#define EXTENSION_NAME MMWindow
#define LIB_NAME "MMWindow"
#define MODULE_NAME "mmwindow"
#include <dmsdk/sdk.h>
#if defined(DM_PLATFORM_LINUX) || defined(DM_PLATFORM_WINDOWS) || defined(DM_PLATFORM_OSX)
#define MM_GLFW 1
extern "C" {
typedef struct GLFWwindow GLFWwindow;
typedef struct GLFWmonitor GLFWmonitor;
typedef struct GLFWvidmode {
int width, height, redBits, greenBits, blueBits, refreshRate;
} GLFWvidmode;
GLFWwindow* glfwGetCurrentContext(void);
GLFWmonitor* glfwGetPrimaryMonitor(void);
GLFWmonitor** glfwGetMonitors(int* count);
GLFWmonitor* glfwGetWindowMonitor(GLFWwindow* window);
const GLFWvidmode* glfwGetVideoMode(GLFWmonitor* monitor);
void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
void glfwSetInputMode(GLFWwindow* window, int mode, int value);
}
#define MM_GLFW_STICKY_KEYS 0x00033002
#define MM_GLFW_STICKY_MOUSE_BUTTONS 0x00033003
static int g_X, g_Y, g_W, g_H;
static bool g_Saved = false;
// The monitor the window's middle is on (else the primary one).
static GLFWmonitor* MonitorOf(GLFWwindow* win)
{
int wx, wy, ww, wh;
glfwGetWindowPos(win, &wx, &wy);
glfwGetWindowSize(win, &ww, &wh);
int cx = wx + ww / 2, cy = wy + wh / 2;
int count = 0;
GLFWmonitor** list = glfwGetMonitors(&count);
for (int i = 0; i < count; ++i)
{
const GLFWvidmode* mode = glfwGetVideoMode(list[i]);
if (!mode)
continue;
int mx, my;
glfwGetMonitorPos(list[i], &mx, &my);
if (cx >= mx && cx < mx + mode->width && cy >= my && cy < my + mode->height)
return list[i];
}
return glfwGetPrimaryMonitor();
}
#endif
static int IsFullscreen(lua_State* L)
{
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
lua_pushboolean(L, win && glfwGetWindowMonitor(win) != 0);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static int SetFullscreen(lua_State* L)
{
bool on = lua_toboolean(L, 1);
#ifdef MM_GLFW
GLFWwindow* win = glfwGetCurrentContext();
if (!win)
{
lua_pushboolean(L, 0);
return 1;
}
bool is = glfwGetWindowMonitor(win) != 0;
if (on && !is)
{
GLFWmonitor* monitor = MonitorOf(win);
const GLFWvidmode* mode = monitor ? glfwGetVideoMode(monitor) : 0;
if (!mode)
{
lua_pushboolean(L, 0);
return 1;
}
glfwGetWindowPos(win, &g_X, &g_Y);
glfwGetWindowSize(win, &g_W, &g_H);
g_Saved = true;
glfwSetWindowMonitor(win, monitor, 0, 0, mode->width, mode->height, mode->refreshRate);
}
else if (!on && is)
{
if (!g_Saved)
{
// Started fullscreen: a window of 1280x800 in the monitor's corner.
g_X = 80; g_Y = 80; g_W = 1280; g_H = 800;
}
glfwSetWindowMonitor(win, 0, g_X, g_Y, g_W, g_H, 0);
}
lua_pushboolean(L, 1);
#else
lua_pushboolean(L, 0);
#endif
return 1;
}
static const luaL_reg Module_methods[] = {
{"set_fullscreen", SetFullscreen},
{"is_fullscreen", IsFullscreen},
{0, 0}
};
static dmExtension::Result Initialize(dmExtension::Params* params)
{
lua_State* L = params->m_L;
int top = lua_gettop(L);
luaL_register(L, MODULE_NAME, Module_methods);
lua_pop(L, 1);
assert(top == lua_gettop(L));
return dmExtension::RESULT_OK;
}
// Sticky input on the engine's window, once it exists (see the top).
static bool g_Sticky = false;
static dmExtension::Result Update(dmExtension::Params* params)
{
#if defined(MM_GLFW)
if (!g_Sticky) {
GLFWwindow* win = glfwGetCurrentContext();
if (win) {
glfwSetInputMode(win, MM_GLFW_STICKY_MOUSE_BUTTONS, 1);
glfwSetInputMode(win, MM_GLFW_STICKY_KEYS, 1);
g_Sticky = true;
}
}
#endif
return dmExtension::RESULT_OK;
}
static dmExtension::Result Finalize(dmExtension::Params* params)
{
return dmExtension::RESULT_OK;
}
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, Update, 0, Finalize)
+504
View File
@@ -0,0 +1,504 @@
-- Animation helpers: the web build's motion (enter.js, app.css keyframes, fx.js) as GUI
-- tweens. Every helper honors the reduced-motion option (M.reduced: no movement, quick
-- fades only), like the web honors prefers-reduced-motion.
--
-- anim.ease.out / .overshoot / .ink / .back / .linear ... easing curves (gui.animate)
-- anim.to(node, prop, value, dur, ease, delay, done) one tween (instant if dur 0)
-- anim.enter_frame(f, opts) a frame grows: brackets spread from the center, the
-- fill unclips, the content fades and rises (enter.js)
-- anim.enter(frames, opts) several, staggered (30 ms, capped at 60)
-- anim.group(parent, x, y) an invisible container (content that moves together)
-- anim.veil(parent, x, y, w, h, color, delay, dur) content under it fades in
-- anim.rise(node, delay, by) a group rises into place
-- anim.breathe(node, prop, a, b, period) pingpong forever (glows, ready labels)
-- anim.pulse(node, scale, dur) one swell and back
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
-- reveal burst) or anim.noise_shake for a
-- decaying value-noise shake (sell power-down)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
-- anim.update(dt) steps the frame-driven ones (call every frame)
local M = {}
---------------------------------------------------------------- reduced motion
local settings = require("modules.settings")
M.reduced = sys.get_config_int("mm.reduced_motion", -1) == 1
or (sys.get_config_int("mm.reduced_motion", -1) == -1 and settings.load().reduced_motion == true)
-- The option (the header's toggle), saved with the other settings (modules/settings)
-- unless --config=mm.reduced_motion forces it (tests, shots) or save is false (the test
-- hook).
local forced = sys.get_config_int("mm.reduced_motion", -1) ~= -1
function M.set_reduced_motion(on, save)
M.reduced = on and true or false
if not forced and save ~= false then
settings.save({ reduced_motion = M.reduced })
end
end
---------------------------------------------------------------- easing
-- cubic-bezier(x1, y1, x2, y2) sampled for gui.animate (a vector of values over 0..1).
local function bezier(x1, y1, x2, y2, n)
n = n or 64
local function bx(t) return 3 * x1 * t * (1 - t) ^ 2 + 3 * x2 * t ^ 2 * (1 - t) + t ^ 3 end
local function by(t) return 3 * y1 * t * (1 - t) ^ 2 + 3 * y2 * t ^ 2 * (1 - t) + t ^ 3 end
local samples = {}
for i = 0, n - 1 do
local x = i / (n - 1)
local lo, hi = 0, 1
for _ = 1, 30 do -- bisection: bx is monotonic for x1, x2 in 0..1
local mid = (lo + hi) / 2
if bx(mid) < x then lo = mid else hi = mid end
end
samples[#samples + 1] = by((lo + hi) / 2)
end
local v = vmath.vector(samples)
-- The same curve as a Lua function (frame-driven tweens).
local fn = function(k)
k = math.max(0, math.min(1, k))
local f = k * (n - 1)
local i = math.floor(f)
local a, b = samples[i + 1], samples[math.min(n, i + 2)]
return a + (b - a) * (f - i)
end
return v, fn
end
M.ease, M.fn = {}, {}
local function curve(name, ...)
M.ease[name], M.fn[name] = bezier(...)
end
curve("out", 0.2, 0.8, 0.2, 1) -- enter.js EASE (frames, content)
curve("overshoot", 0.2, 0.9, 0.3, 1.2) -- dialog-in (app.css)
curve("ink", 0.3, 1.25, 0.5, 1) -- the sliding tab ink (220 ms)
curve("toast", 0.2, 1.4, 0.4, 1) -- toast-in squash and overshoot
curve("pop", 0.3, 1.6, 0.5, 1) -- ready pop .6 -> 1.12 -> 1
curve("toast_in", 0.2, 0.9, 0.3, 1.15) -- fx-toast-in, per keyframe segment
curve("ready_pop", 0.2, 0.9, 0.3, 1.4) -- fx-ready-pop, per keyframe segment
curve("gain", 0.2, 0.9, 0.3, 1.3) -- fx-gain (wallet), per keyframe segment
curve("ease_in", 0.42, 0, 1, 1)
curve("ease_out", 0, 0, 0.58, 1)
curve("ease", 0.25, 0.1, 0.25, 1)
M.ease.linear, M.fn.linear = gui.EASING_LINEAR, function(k) return k end
M.ease.back, M.fn.back = gui.EASING_OUTBACK, function(k)
local c1, c3 = 1.70158, 2.70158
return 1 + c3 * (k - 1) ^ 3 + c1 * (k - 1) ^ 2
end
M.ease.sine, M.fn.sine = gui.EASING_INOUTSINE, function(k) return -(math.cos(math.pi * k) - 1) / 2 end
---------------------------------------------------------------- basics
local function v3(x, y, z)
return vmath.vector3(x, y, z or 0)
end
-- Set a property, "color" or one component like "color.w" (what gui.animate takes).
local GETTERS = { position = gui.get_position, scale = gui.get_scale, color = gui.get_color, size = gui.get_size,
euler = gui.get_euler, outline = gui.get_outline, shadow = gui.get_shadow }
local SETTERS = { position = gui.set_position, scale = gui.set_scale, color = gui.set_color, size = gui.set_size,
euler = gui.set_euler, outline = gui.set_outline, shadow = gui.set_shadow }
function M.set(node, prop, value)
local base, comp = prop:match("^(%a+)%.(%a)$")
if base and GETTERS[base] then
local v = GETTERS[base](node)
v[comp] = value
SETTERS[base](node, v)
elseif SETTERS[prop] then
SETTERS[prop](node, value)
else
gui.set(node, prop, value)
end
end
-- One tween. dur 0 (or reduced motion for a movement, opts.move) sets the value at once.
function M.to(node, prop, value, dur, ease, delay, done)
if dur <= 0 then
M.set(node, prop, value)
if done then
done()
end
return
end
gui.animate(node, prop, value, ease or M.ease.out, dur, delay or 0, done and function() done() end or nil)
end
-- A CSS-like keyframe track at progress k (0..1): frames = { {t, v}, ... } sorted by t
-- (first at 0, last at 1), each segment eased by ease_fn (a M.fn curve; default linear),
-- like animation-timing-function applies per keyframe interval.
function M.keys(frames, k, ease_fn)
if k <= frames[1][1] then
return frames[1][2]
end
for i = 2, #frames do
local a, b = frames[i - 1], frames[i]
if k <= b[1] then
local u = (k - a[1]) / math.max(1e-6, b[1] - a[1])
u = ease_fn and ease_fn(u) or u
return a[2] + (b[2] - a[2]) * u
end
end
return frames[#frames][2]
end
-- Scale a node about a point: p = its resting position, base = its resting scale, (dx,
-- dy) = from p to the point that stays put (a pivot NW box of w x h: w/2, -h/2 for its
-- middle), sx/sy the factors (sy defaults to sx). Template roots are pivot NW, so scaling
-- them alone grows from the corner; CSS scales about the middle.
function M.scale_about(node, p, base, dx, dy, sx, sy)
sy = sy or sx
gui.set_scale(node, vmath.vector3(base.x * sx, base.y * sy, base.z))
gui.set_position(node, vmath.vector3(p.x + dx * (1 - sx), p.y + dy * (1 - sy), p.z))
end
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now()
return socket.gettime()
end
function M.tween(node, dur, step, delay, done)
local t = { node = node, t0 = now() + (delay or 0), dur = dur, step = step, done = done }
running[#running + 1] = t
return t
end
function M.cancel(t)
if t then
t.dead = true
end
end
function M.update()
if #running == 0 then
return
end
local t1 = now()
local keep = {}
for _, t in ipairs(running) do
if not t.dead then
local k = (t1 - t.t0) / t.dur
if k >= 0 then
local ok = pcall(t.step, math.min(1, k), t.node)
if not ok then
t.dead = true -- node deleted (a rebuild)
elseif k >= 1 then
t.dead = true
if t.done then
pcall(t.done)
end
end
end
if not t.dead then
keep[#keep + 1] = t
end
end
end
running = keep
end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
shaking = {}
end
---------------------------------------------------------------- fading content
-- GUI nodes don't pass their alpha down (inherit_alpha is off, and most containers here
-- are translucent fills: panels, cards, buttons), so content doesn't fade as a subtree.
-- Content fades in from under a veil instead: a box in the background's color over it
-- that fades out (M.veil), while a group (M.group) holding the content rises.
-- An invisible container (not drawn; alpha 1): content built into it moves with it.
function M.group(parent, x, y, w, h)
local n = gui.new_box_node(v3(x or 0, -(y or 0)), v3(w or 1, h or 1))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_color(n, vmath.vector4(1, 1, 1, 1))
gui.set_visible(n, false)
return n
end
-- A veil over (x, y, w, h) of parent (top-left based) in `color` (the background behind
-- the content) that fades out after delay over dur and goes away: the content under it
-- fades in. Make it after the content, so it draws over it. out = true: it fades in
-- instead (the content fading out) and stays. Returns the veil node.
function M.veil(parent, x, y, w, h, color, delay, dur, out)
local n = gui.new_box_node(v3(x, -y), v3(w, h))
gui.set_pivot(n, gui.PIVOT_NW)
gui.set_parent(n, parent, false)
gui.set_texture(n, "ui")
gui.play_flipbook(n, "white")
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, out and 0 or 1))
gui.animate(n, "color.w", out and 1 or 0, M.ease.out, dur or 0.14, delay or 0, (not out) and function()
gui.delete_node(n)
end or nil)
return n
end
---------------------------------------------------------------- entrances (enter.js)
local GROW, FADE, NESTED, STAGGER, MAX_STAGGER = 0.18, 0.14, 0.06, 0.03, 0.06
-- Rise: a group starts `by` px lower and moves up (0.14 s), after delay.
function M.rise(node, delay, by, dur)
if M.reduced then
return
end
local p = gui.get_position(node)
gui.set_position(node, v3(p.x, p.y - (by or 6), p.z))
gui.animate(node, "position.y", p.y, M.ease.out, dur or FADE, delay or 0)
end
-- A frame grows (enter.js grow): brackets spread from the center, the fill unclips, the
-- content fades and rises. f = { frame = the bracket node (slice9 corners, pivot NW),
-- fill = the frame's fill node (pivot NW, or nil), body = a group holding the content
-- (rises), veil = { parent, color } for the content's fade (it covers the box: x, y in
-- veil.parent, w x h). opts: delay, clip (false: the fill doesn't unclip, the aside).
function M.enter_frame(f, opts)
opts = opts or {}
local delay = opts.delay or 0
local w, h = f.w, f.h
local function fade(at)
if f.veil then
local v = M.veil(f.veil.parent, f.x or 0, f.y or 0, w, h, f.veil.color, at, M.reduced and 0.12 or FADE)
if f.frame and gui.get_parent(f.frame) == f.veil.parent then
gui.move_above(f.frame, v) -- the brackets spread over the hidden content
end
end
end
if M.reduced then
fade(delay)
return
end
local x, y = math.max(0, w / 2 - 16), math.max(0, h / 2 - 16)
local function grow(node)
local size = gui.get_size(node)
local pos = gui.get_position(node)
gui.set_size(node, v3(size.x - 2 * x, size.y - 2 * y))
gui.set_position(node, v3(pos.x + x, pos.y - y))
gui.animate(node, "size", size, M.ease.out, GROW, delay)
gui.animate(node, "position", pos, M.ease.out, GROW, delay)
end
if f.frame then
grow(f.frame) -- corner brackets (slice9 corners) start near the center and spread
end
if f.fill and opts.clip ~= false then
grow(f.fill) -- the box unclips from its middle
end
fade(delay + GROW / 2)
if f.body then
M.rise(f.body, delay + GROW / 2, 6)
end
end
-- Several frames, staggered like enter.js (nested ones start NESTED later).
function M.enter(frames, opts)
opts = opts or {}
for i, f in ipairs(frames) do
M.enter_frame(f, { delay = (opts.nested and NESTED or 0) + math.min((i - 1) * STAGGER, MAX_STAGGER) + (opts.delay or 0),
clip = f.clip })
end
end
---------------------------------------------------------------- loops and one-shots
-- Pingpong a property between a and b forever (a glow that breathes). Reduced motion:
-- holds a.
function M.breathe(node, prop, a, b, period)
gui.cancel_animations(node, prop)
M.set(node, prop, a)
if M.reduced then
return
end
gui.animate(node, prop, b, gui.EASING_INOUTSINE, (period or 1.8) / 2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end
-- Swell to `scale` and back once (pivot center nodes scale about their middle).
function M.pulse(node, scale, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.animate(node, "scale", s * (scale or 1.08), gui.EASING_OUTSINE, (dur or 0.3) / 2, 0, nil, gui.PLAYBACK_ONCE_PINGPONG)
end
-- Scale in from `from` with an overshoot (the web's ready pop: .6 -> 1.12 -> 1).
function M.pop(node, from, dur)
if M.reduced then
return
end
local s = gui.get_scale(node)
gui.set_scale(node, s * (from or 0.6))
gui.animate(node, "scale", s, M.ease.pop, dur or 0.6)
end
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
-- 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 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 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.
local function noise(x, seed)
local function h(i)
local s = math.sin((i + seed) * 127.1) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local i = math.floor(x)
local f = x - i
local u = f * f * (3 - 2 * f)
return h(i) * (1 - u) + h(i + 1) * u
end
-- A decaying noise shake in x and y (fx.js sell power-down, the reveal burst). freq:
-- noise steps per second.
function M.noise_shake(node, amp, dur, freq)
if M.reduced then
return
end
amp, dur, freq = amp or 4, dur or 0.34, freq or 30
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + noise(t, seed) * a, p.y + noise(t, seed + 50) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
-- screen shake.
local function grad(i, seed)
local s = math.sin((i + seed) * 12.9898) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local function perlin1(x, seed)
local i = math.floor(x)
local f = x - i
local u = f * f * f * (f * (f * 6 - 15) + 10)
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
return (a + (b - a) * u) * 2
end
function M.perlin(x, seed)
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
end
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
function M.perlin_shake(node, amp, dur, freq)
if M.reduced or not node then
return
end
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur)
local c = gui.get_color(node)
if M.reduced then
return
end
gui.set_color(node, color)
gui.animate(node, "color", c, M.ease.ease_out, dur or 0.5)
end
-- A scan line sweeping down a box (w x h at the parent's top-left): a bright line with a
-- glow that fades as it goes (app.css scan, 0.7-0.9 s). Returns the line node.
function M.sweep(parent, w, h, color, dur, delay)
if M.reduced then
return
end
color = color or require("modules.theme").color.cyan
local line = gui.new_box_node(v3(0, 0), v3(w, 2))
gui.set_pivot(line, gui.PIVOT_NW)
gui.set_parent(line, parent, false)
gui.set_texture(line, "ui")
gui.play_flipbook(line, "white")
gui.set_color(line, vmath.vector4(color.x, color.y, color.z, 0.9))
local glow = gui.new_box_node(v3(0, 8), v3(w, 18))
gui.set_pivot(glow, gui.PIVOT_W)
gui.set_parent(glow, line, false)
gui.set_texture(glow, "ui")
gui.play_flipbook(glow, "glow")
gui.set_color(glow, vmath.vector4(color.x, color.y, color.z, 0.35))
gui.set_inherit_alpha(glow, true)
M.tween(line, dur or 0.8, function(k, n)
local e = M.fn.ease_out(k)
gui.set_position(n, v3(0, -h * e))
gui.set_color(n, vmath.vector4(color.x, color.y, color.z, 0.9 * (1 - k)))
end, delay or 0, function()
pcall(gui.delete_node, line)
end)
return line
end
-- A number counting from `from` to `to` (fmt formats it).
function M.count(node, from, to, dur, fmt, done)
fmt = fmt or tostring
if M.reduced or (dur or 0) <= 0 then
gui.set_text(node, fmt(to))
return
end
return M.tween(node, dur, function(k, n)
local v = from + (to - from) * M.fn.ease_out(k)
if to == math.floor(to) then
v = math.floor(v + 0.5)
end
gui.set_text(n, fmt(v))
end, 0, done)
end
return M
+218
View File
@@ -0,0 +1,218 @@
-- The game server's JSON API. The server is the authority: the client sends actions and
-- shows the state it gets back, it never computes game results itself.
--
-- GET /api/state the player's state (params: the view, e.g. moon, well)
-- POST /api/act/<action> run an action, returns the new state (or an error)
--
-- The player id travels in the X-MM-User header. The server returns one on the first
-- visit; it is kept with sys.save so the same player comes back.
--
-- Base URL: game.project [mm] api_base (default http://localhost:3000), overridable at
-- launch with --config=mm.api_base=http://host:port. In a browser: the page's origin.
local M = {}
M.base = sys.get_config_string("mm.api_base", "http://localhost:3000"):gsub("/+$", "")
-- In a browser (the web build) the page's own origin: the browser only lets the game talk
-- to it (scripts/serve-web.py serves the game there and proxies the server's paths). A
-- page URL with ?api=<url> picks another server (it must allow cross-origin requests).
if html5 then
local ok, origin = pcall(html5.run,
"(new URLSearchParams(location.search).get('api') || location.origin).replace(/\\/+$/, '')")
if ok and type(origin) == "string" and origin:match("^https?://") then
M.base = origin
end
end
M.timeout = 10
-- Debug: mm.fake_latency_ms delays every request by half of it and its response by the
-- other half (prediction checks). mm.fake_reject=<action>[:n] makes the n-th call of that
-- action (every one without :n) fail on the server with a 422: it is sent as a claim of
-- a well that doesn't exist.
M.fake_latency = sys.get_config_int("mm.fake_latency_ms", 0) / 1000
local reject_name, reject_n = sys.get_config_string("mm.fake_reject", ""):match("^([^:]*):?(%d*)$")
reject_n = tonumber(reject_n)
local reject_count = 0
-- One saved player per server, so runs against a local test server don't replace the
-- player of another one.
local SAVE_FILE = sys.get_save_file("moonminers", "player-" .. M.base:gsub("^%a+://", ""):gsub("[^%w]", "_"))
local HEADER = "X-MM-User"
local player_id = nil
local function load_player()
local ok, data = pcall(sys.load, SAVE_FILE)
if ok and type(data) == "table" and type(data.id) == "string" and data.id ~= "" then
return data.id
end
return nil
end
local function store_player(id)
if not id or id == "" or id == player_id then
return
end
player_id = id
if not sys.save(SAVE_FILE, { id = id }) then
print("api: could not save the player id")
end
end
player_id = load_player()
-- mm.new_player=1: start as a new player (screenshots of the first launch).
if sys.get_config_int("mm.new_player", 0) == 1 then
player_id = nil
sys.save(SAVE_FILE, {})
end
-- mm.player=<id>: play as this player (test setups prepared through the admin pages).
local forced = sys.get_config_string("mm.player", "")
if forced ~= "" then
player_id = forced
end
function M.player_id()
return player_id
end
-- Forget the stored player (the next request starts a new one).
function M.reset_player()
player_id = nil
sys.save(SAVE_FILE, {})
end
local function urlencode(s)
return (tostring(s):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
local function query(params)
if not params then
return ""
end
local keys = {}
for k in pairs(params) do
keys[#keys + 1] = k
end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(params[k])
end
return table.concat(parts, "&")
end
-- Header names arrive lowercased in http.request responses; look both up to be safe.
local function header(headers, name)
if not headers then
return nil
end
return headers[name:lower()] or headers[name]
end
-- cb(err, data, status, recv): err is nil on a 2xx with valid JSON; otherwise a string. On
-- an error response with a JSON body (e.g. {"error": "..."}), data is still the decoded
-- body. recv: socket.gettime() when the response arrived (before any fake latency).
local function request(method, path, params, cb)
local url = M.base .. path
local headers = { ["Accept"] = "application/json" }
if player_id then
headers[HEADER] = player_id
end
local body = nil
if method == "GET" then
local q = query(params)
if q ~= "" then
url = url .. "?" .. q
end
else
headers["Content-Type"] = "application/json"
-- An empty Lua table could encode as [] (array); the server wants an object.
body = (params and next(params) ~= nil) and json.encode(params) or "{}"
end
local half = M.fake_latency / 2
local function done(...)
if not cb then
return
end
local args = { ... }
if half > 0 then
timer.delay(half, false, function() cb(unpack(args, 1, 4)) end)
else
cb(...)
end
end
local function send()
http.request(url, method, function(_, _, response)
local recv = socket.gettime()
store_player(header(response.headers, HEADER))
local data = nil
if response.response and response.response ~= "" then
local ok, decoded = pcall(json.decode, response.response)
if ok then
data = decoded
end
end
local status = response.status or 0
local err = nil
if status == 0 then
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil 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)
end, headers, body, { timeout = M.timeout })
end
if half > 0 then
timer.delay(half, false, send)
else
send()
end
end
-- The player's state for a view (params: e.g. { s = "moons", moon = 12, well = 34 }).
function M.get_state(params, cb)
request("GET", "/api/state", params, cb)
end
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
-- itself comes only from the save-code action; restore is the action restore (code).
function M.get_save_code(cb)
request("GET", "/api/save-code", nil, cb)
end
-- 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
reject_count = reject_count + 1
if not reject_n or reject_count == reject_n then
local p = {}
for k, v in pairs(params or {}) do
p[k] = v
end
p["claim-well"] = "00000000-0000-0000-0000-000000000000"
print("api: fake reject of " .. name)
name = "claim"
params = p
end
end
request("POST", "/api/act/" .. urlencode(name), params, cb)
end
return M
+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
Loaded 100 of 454 files, more files were not shown because too many files have changed in this diff. Show more