Compare commits

..
66 Commits
Author SHA1 Message Date
44r0n7andClaude Opus 5.5 a712e51b96 Board: t21 to Review with its report; new tasks t29, t33-t36; board updates
- t21 report and subtasks ticked.
- New Todo tasks: t33 telling solid ground from loose grains, t34 nozzle blow reach, t35
  diagonal screw tubes, t36 diagonal wall blocks with a sloped face; t29 added on the board.
- The owner's board edits since the last board commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:51:27 -04:00
44r0n7andClaude Opus 5.5 7a653d94b4 A loaded world starts around the player first, the rest a few areas a tick (t21)
- Areas within 150 grains of the bot start at once; the others 12 a tick, nearest first, and any
  the bot flies toward start straight away. A Huge world is fully started after 17 s.
- Huge world, first 600 ticks: average 15.2 -> 5.5 ms/tick, worst 79.7 -> 16.2.
- New test/t_load.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:41:57 -04:00
44r0n7andClaude Opus 5.5 80420c3453 Refactor: merge duplicated rules, split the largest files by topic (t21)
- One helper each for wall and mold refunds, tube edges and lanes, pipe text, piece dimensions,
  font lookup, reach circles and surface averaging.
- previews, smooth, notebook, materials, worldgen and selection split into topic files
  (preview-drill, preview-selection, smooth-shaders, notebook-market, notebook-blueprints,
  material-tables, worldgen-helpers, selection-pickers, selection-panels).
- Every suite's output identical apart from timings; seeds make the same worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:35:12 -04:00
44r0n7andClaude Opus 5.5 f57bdd6084 Faster roof scan and heat pass with identical results (t21)
- The roof is rescanned only in area columns that changed since the last scan: a quiet Huge
  world goes from 4.60 to 1.88 ms per scan (every 20 ticks).
- The heat pass skips react() for solids and powders below their first reaction threshold:
  Huge world digging 2.54 -> 2.34 ms/tick, quiet 1.39 -> 1.18.
- Every suite's output is identical apart from timing lines; seeds make the same worlds.
- test/t_sim_optimizations.js checks the dirty roof against a full scan and the heat loop
  against the reference loop byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:18:50 -04:00
44r0n7andClaude Opus 5.5 d3fc1d2cbc Piles near the bot sort, and 20 times faster; burning fuel keeps the slow pace (t21)
- SORTP 0.003 -> 0.06: ore sinks through 8 rows of ash in about 7 s near the bot.
- wakeBotPowder(): every 8 ticks, areas with loose grains within 100 grains of the bot get a
  sorting chance; far away, resting piles still sleep.
- Sinking through burning fuel stays at 0.003, so buried burning piles still char and furnace
  beds behave as tuned. World generation settles at the old rate: seeds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:05:03 -04:00
44r0n7andClaude Opus 5.5 f678165e1b Physics test pass for sand, molten metal and gases; stuck gas finishes, steam is not deleted (t21)
- New test/t_physics.js: heaps, sorting, sinking, world edges; molten copper levelling, setting,
  quenching, slag over copper, cavity filling; smoke, steam, fire, sealed pockets, the top row.
- Gas in a sealed pocket kept its area asleep and never faded or condensed: gas() keeps its area
  awake while it lasts.
- A rain drop or blown grain landing on steam deleted the steam's water: it goes to the clouds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:55:04 -04:00
44r0n7andClaude Opus 5.5 9291040ff0 Water pours off ledges several times faster, raining clouds spread boiled-off water (t21)
- Liquids step up to 8 cells a tick toward a drop (fewer the thicker they are), and a top-layer
  grain looks 4 times as far for one, so pools empty and thin film drains. A 100-wide pool
  empties in about 50 s; the old code left 24-650 grains stranded on wide ledges.
- Only a top-layer grain stepping sideways wakes the row ahead, so settled lakes still sleep.
- World generation settles at the old speed: seeds make the same worlds as before.
- Cooled steam under a roof no longer stays steam for ever (its heat area slept).
- Raining clouds keep drifting at half pace and rain over a band either side, so a waterfall into
  a hot shaft no longer rains straight back on its own lake.
- New test/t_water.js; t_weather rain-location checks; t_conveyors counts steam in flight;
  t_molds seeds the random burning-bed check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:44:06 -04:00
44r0n7andClaude Opus 5.5 eee170e3d1 Bar rack blocks: store 50 bars each, F lets them out oldest first (t1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:53:09 -04:00
44r0n7andClaude Opus 5.5 232f5af6e4 Bars snap to a 2-grain grid and tip into gaps, so piles stack without air (t1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:41:04 -04:00
44r0n7andClaude Opus 5.5 68c021f64b Backlog: merge related tasks into four and rewrite them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:28:00 -04:00
44r0n7andClaude Opus 5.5 8d12b67c08 Minimap with zoom in the bottom right, full map on M; cards move to the bottom centre (t32)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:23:43 -04:00
44r0n7andClaude Opus 5.5 c069abe2d2 New worlds get generated camp names like new claims (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:00:22 -04:00
44r0n7andClaude Opus 5.5 54af0e51ad Progress bar and steps while generating, loading, opening claims and travelling; saving chip (t30)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:54:06 -04:00
44r0n7andClaude Opus 5.5 e3280b7b1e Add a task: choose between several claims and sizes (after t13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:39:01 -04:00
44r0n7andClaude Opus 5.5 37de809216 New claims get camp names from their seed, renameable from the Claims tab; add t30 (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:38:26 -04:00
44r0n7andClaude Opus 5.5 4e3d8e41f6 No deep ore in rock hot enough to light or melt it; reloaded worlds settle again (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:25:13 -04:00
44r0n7andClaude Opus 5.5 0b37e497eb New claims: open another claim once one is paid off, travel between them (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:06:01 -04:00
44r0n7andClaude Opus 5.5 56c93ae135 Huge world size, and saves are gzip-compressed (about 60x smaller) (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:51:42 -04:00
44r0n7andClaude Opus 5.5 4a8825415d Waste rock pays: richer granite and basalt grinding, bulk rock orders (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:30 -04:00
44r0n7andClaude Opus 5.5 26d17c2b1d Deeper is richer: about 4.7x the ore in a world, shallow layers unchanged (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:30:51 -04:00
44r0n7andClaude Opus 5.5 1da33e3703 Brick press: clay lumps into green bricks that fire into Bricks (t28)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:55:54 -04:00
44r0n7andClaude Opus 5.5 08d2d65242 Saw cuts wood chunks into Planks; stone bricks are now Stone blocks (t4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:50:35 -04:00
44r0n7andClaude Opus 5.5 f5e5591e2d Grinders look like a pair of grinding wheels, Saws like a round blade (t4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:43:15 -04:00
44r0n7andClaude Opus 5.5 9739ca0d1e Remove the Refiner and Stone cutter; old saves refund them on load (t4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:39:27 -04:00
44r0n7andClaude Opus 5.5 d1eb659dc6 Grinder and Saw hopper blocks replace the Refiner and Stone cutter in the menu (t4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:06:51 -04:00
44r0n7andClaude Opus 5.5 e2c9bc9c58 Pneumatic nozzle: air streaks while sucking and blowing (t25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:27:18 -04:00
44r0n7andClaude Opus 5.5 638fd3bdb7 The Vacuum is renamed the Pneumatic nozzle, with a title row in its card (t26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:18:52 -04:00
44r0n7andClaude Opus 5.5 efabbfb6a8 Tool messages show as toasts, the card status line is gone (t27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:14:46 -04:00
44r0n7andClaude Opus 5.5 318de3ed76 Bag: hint and Clear selection stay visible under the list (t20)
The hint and Clear button were inside the scrolling bag list, so a long
Ctrl list scrolled them away. They now sit in a footer under the list, and
Clear is a full-width, highlighted "Clear selection" button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:29:02 -04:00
44r0n7andClaude Opus 5.5 248aba439e Vacuum: deselect what is blown out, Ctrl + wheel scrolls the bag, Ctrl+click fix (t20)
Blowing out the last of a selected material removes it from the selection,
so an emptied selection picks up what you click again. Ctrl + wheel never
zooms the page and scrolls the bag list under the mouse. A bag click while
Ctrl is held adds to the selection even if the click lacks the Ctrl flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:16:47 -04:00
44r0n7andClaude Opus 5.5 cd483dd546 Vacuum: one selection for pick-up and blow, full list while Ctrl is held (t20)
With anything selected a drag takes only the selection; with nothing
selected it takes what you click. Right click blows the selected materials
in the bag. Holding Ctrl lists every material found so far under "Not in
bag". Selections stay when materials run out; Clear button, clicking the
only selected row, or gamepad B clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:57:43 -04:00
44r0n7andClaude Opus 5.5 51bdd00a9f Vacuum: pick up what you click, blow out what's in hand (t20)
A drag takes only the material it starts on (Shift: everything). The blow
selection is the hand: click or Q/E for one material, Ctrl+click for a mix.
Empty materials leave the hand, an empty hand blows nothing, and a pickup
fills an empty hand. Removes the pick-up marks and the F vacuum menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:44 -04:00
44r0n7andClaude Opus 5.5 841efd2546 Fog of war: unexplored is black, fades ease into it with no stepped rim (t17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:59:00 -04:00
44r0n7andClaude Opus 5.5 ab61d6b904 Fog of war: wider fades and a grainy edge instead of stepped lines (t17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:39:13 -04:00
44r0n7andClaude Opus 5.5 b4245871ae Fog of war reveals the ground around the bot through rock (t17)
Also: board rule to write tasks from chat in our own words, task
descriptions rewritten that way, and a note on lost web-board descriptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:29:08 -04:00
44r0n7andClaude Opus 5.5 a95f664f7d Vacuum: holding Shift shows the blow-everything mix on the card and in the bag (t19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:59:05 -04:00
44r0n7andClaude Opus 5.5 52a273b656 Vacuum: Shift blows everything, Ctrl+click in the bag, one-line menu rows (t16)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:52:56 -04:00
44r0n7andClaude Opus 5.5 95b5eeb99d Vacuum menu on F replaces the pick-up modes (t16)
Pick-up takes everything unless something is marked; F with the Vacuum
(when nothing in reach can be used) or the card chips open a menu with
pick-up and blow toggles per material. The bag rows lose their buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:28:56 -04:00
44r0n7andClaude Opus 5.5 6d2fe2fdf6 Vacuum controls: pick-up modes and separate pick-up and blow marks in the bag (t6, t15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:54:12 -04:00
44r0n7andClaude Opus 5.5 275d9c4008 Record the owner's decisions for t1, t4 and t6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:42:02 -04:00
44r0n7andClaude Opus 5.5 b045d8378b Fog of war: underground is black until the bot has seen it (t8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:37:34 -04:00
44r0n7andClaude Opus 5.5 da04e4e2a7 Material menu for walls and molds, opened with F (t5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:26:29 -04:00
44r0n7andClaude Opus 5.5 edd9cdf940 Name box in the New world panel (t10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:19:25 -04:00
44r0n7andClaude Opus 5.5 40d9647b73 Setting to stop the camera drifting toward the pointer (t7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:15:36 -04:00
44r0n7andClaude Opus 5.5 1a3a848bad tools/serve.py: build, serve and rebuild on change (t9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:07:08 -04:00
44r0n7andClaude Opus 5.5 259ad89ded Loading a world clears cards left from the last one (t11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:04:25 -04:00
44r0n7andClaude Opus 5.5 e0525c1cfb Move the to-do list from NOTES.md to a kandown board
Each note is a task in tasks/ with the owner's words under ## Context; the
board rules are in .kandown/kandown_work.md and docs/kandown.md explains how
kandown works here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:44:22 -04:00
44r0n7andClaude Opus 5.5 aadcbc6efb Several worlds: a Worlds list with load, rename and delete; old save migrates to World 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:20:18 -04:00
44r0n7andClaude Opus 5.5 c434faefc4 Screw tubes carry bars, one whole bar at a time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:09:33 -04:00
44r0n7andClaude Opus 5.5 92e2d1c19d Screw tubes use all their lanes: grains spread sideways inside a tube
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:00:05 -04:00
44r0n7andClaude Opus 5.5 971bcc2558 Build card describes the build tool in general, not the selected piece
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:42:38 -04:00
44r0n7andClaude Opus 5.5 dd8fc3d866 World sounds are heard in a radius around the bot, not the view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:40:08 -04:00
44r0n7andClaude Opus 5.5 af9759f9df F on an ingot mold does nothing and says nothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:37:49 -04:00
44r0n7andClaude Opus 5.5 2b80a48a35 Docs: the snapping sound confirmed in the owner's world (water boiling on hot deep rock)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:18:49 -04:00
44r0n7andClaude Opus 5.5 995ecaa6fb Silo: pixel-art version for the pixel look
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:16:27 -04:00
44r0n7andClaude Opus 5.5 edcdf2f900 World sounds are local: fade with distance and pan (distant sizzles snapped constantly)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:08:10 -04:00
44r0n7andClaude Opus 5.5 4d4d94cbd8 Fewer, smaller messages: mold percentages, markers for machine heat, short status lines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:03:45 -04:00
44r0n7andClaude Opus 5.5 43eb4e23fd Ingot molds empty themselves (no F); removing a mold returns its set metal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:53:38 -04:00
44r0n7andClaude Opus 5.5 9addc43be1 Build tool: compact top-left card again; status row moved off the build bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:24:46 -04:00
44r0n7andClaude Opus 5.5 da81de1b6c Replace is held G instead of Ctrl (Ctrl+W/Ctrl+D were hit while flying)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:16:59 -04:00
44r0n7andClaude Opus 5.5 0679878e69 Ask before leaving the page while a world is open (Ctrl+W closed the game)
Ctrl (replace) + W (fly) hits the browser's close-tab shortcut, which a page
cannot block. A beforeunload prompt now catches it. Autosave on hiding the
tab no longer depends on the audio context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:47:27 -04:00
44r0n7andClaude Opus 5.5 f9012384fc Build menu: hover popup with full info, top-left card hidden while building
The short description row became a status row for the selected piece
(variant, Replace, ghosts, messages). Gamepad shows the selected tile's popup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:21:23 -04:00
44r0n7andClaude Opus 5.5 3e313e20eb Belts: a backed-up heap stands still instead of the top sliding over the bottom
carryColumn stops at the first blocked grain on a belt, so a heap whose front
is backed up (a screw tube taking less than the belt brings, or a wall) no
longer shears. New t_conveyors checks and a belt-heap screenshot scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:07:28 -04:00
44r0n7andClaude Opus 5.5 f540c5454f Ingot molds drop bars out of the bottom on their own
Bars no longer get thrown up into the furnace, and F is no longer needed to
collect them. Blocked bars wait in the trough; the short stub stays for the
next firing; F is optional upkeep (clean, chip slag, rake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:00:12 -04:00
44r0n7andClaude Opus 5.5 7d8df2ef67 Build menu: Shift+Q/E switch tabs, G no longer picks (middle mouse does)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:12:51 -04:00
44r0n7andClaude Opus 5.5 cab9343b26 Run browser tests locally: Playwright 1.64.1 in .venv, scripts switch to it on their own
The browser tests and screenshot tools now work with plain python3 on a machine whose
system Python refuses pip installs: tools/use_venv.py re-runs them with the repo's .venv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:50:15 -04:00
143 changed files with 7188 additions and 1738 deletions
+4
View File
@@ -4,3 +4,7 @@ __pycache__/
*.pyc *.pyc
node_modules/ node_modules/
.DS_Store .DS_Store
.venv/
# kandown web app: one 6.5 MB file that `kandown init` / `kandown update` regenerate
.kandown/kandown.html
+9
View File
@@ -0,0 +1,9 @@
daemon.json
daemon.lock
.undo/
# Local extension state. Which extensions you enabled and which you trusted is a
# per-machine decision, and a committed copy is ignored at load time anyway
# (see docs/EXTENSIONS.md).
extensions/enabled.json
extensions/trust.json
+56
View File
@@ -0,0 +1,56 @@
# Kandown
File-based kanban for this project. Zero install, zero backend, plain markdown on disk.
## Layout
```
.kandown/ # config, web UI, workflows, skills
├── kandown.html # single-file web app — open this in your browser
├── kandown.json # project preferences, columns, appearance
├── kandown_work.md # optional project agent instructions
├── workflows/ # optional local workflow packages
├── skills/ # optional local Markdown skill packages
└── README.md # this file
tasks/ # source of truth — one .md file per task
├── t1.md
├── t2.md
└── archive/ # archived tasks live here
```
## Usage
1. Open `.kandown/kandown.html` in Chrome, Edge, Brave or Opera (File System Access API required)
2. Click **Select folder** and pick the **project root** (the parent of `.kandown/`), then grant read/write permission
3. That's it
The app remembers the last 10 projects you've opened — no need to re-select the folder each time.
## Settings
Open Settings from the app header to tune this project. Board columns are stored in `.kandown/kandown.json` at `board.columns`. Each task chooses a column with its frontmatter `status`.
## Editing without the app
Everything is plain markdown. Edit files directly in your IDE, Obsidian, or vim. Click **Reload** in the app (or press `R`) to see changes.
The task editor includes a markdown-backed subtask checklist. Add steps with **Add subtask**, press **Enter** to insert another step, and expand a step to edit its description or report. Changes are saved back to the task file automatically.
## For AI agents
Run `kandown work` for the current agent protocol and live board context. Each task file in `tasks/` is its own source of truth. Moving a task means editing its frontmatter `status`.
## Keyboard shortcuts
| Key | Action |
|---|---|
| `⌘K` / `Ctrl+K` | Command palette |
| `⌘1` / `Ctrl+1` | Board view |
| `⌘2` / `Ctrl+2` | List view |
| `N` | New task |
| `R` | Reload |
| `/` | Focus search |
| `Esc` | Close drawer / palette |
| `⌘S` | Save current task |
| `⌘⌫` | Delete task (with confirmation) |
+74
View File
@@ -0,0 +1,74 @@
{
"version": 1,
"preferred": "claude",
"cascade": {
"unassignedBehavior": "skip",
"sameSessionChain": false
},
"agents": [
{
"id": "claude",
"name": "Claude Code",
"bin": "claude",
"interactive": true,
"description": "Anthropic Claude (interactive session)",
"aliases": ["claude", "claudecode", "anthropic", "claudeai"]
},
{
"id": "codex",
"name": "OpenAI Codex",
"bin": "codex",
"interactive": true,
"description": "OpenAI Codex CLI",
"aliases": ["codex", "openaicodex"]
},
{
"id": "gemini",
"name": "Gemini CLI",
"bin": "gemini",
"interactive": true,
"description": "Google Gemini CLI",
"aliases": ["gemini", "geminicli", "googlegemini"]
},
{
"id": "goose",
"name": "Goose",
"bin": "goose",
"interactive": false,
"description": "Block open-source AI agent",
"aliases": ["goose", "blockgoose"]
},
{
"id": "aider",
"name": "Aider",
"bin": "aider",
"interactive": true,
"description": "Git-aware AI pair programmer",
"aliases": ["aider"]
},
{
"id": "opencode",
"name": "OpenCode",
"bin": "opencode",
"interactive": true,
"description": "SST AI coding TUI",
"aliases": ["opencode", "sstopencode"]
},
{
"id": "cursor",
"name": "Cursor",
"bin": "cursor",
"interactive": true,
"description": "Cursor IDE (opens project; paste prompt)",
"aliases": ["cursor"]
},
{
"id": "pi",
"name": "Pi",
"bin": "pi",
"interactive": true,
"description": "Earendil Works pi coding agent",
"aliases": ["pi", "piearendil", "picodingagent"]
}
]
}
+107
View File
@@ -0,0 +1,107 @@
{
"ui": {
"language": "en",
"theme": "dark",
"skin": "kandown",
"font": "inter",
"background": "solid",
"onboardingCompleted": true,
"categoryChips": true
},
"agent": {
"suggestFollowUp": false,
"maxSuggestions": 3,
"workOutput": {
"detailMode": "complete",
"boardDigest": {
"showColumnCounts": true,
"showTasks": true,
"showPriority": true,
"showAssignee": true,
"showBlockedBy": true,
"showNextActionable": true
}
}
},
"workflow": {
"active": "guided-feature",
"skills": [],
"trackingCadence": "balanced"
},
"board": {
"columns": [
"Backlog",
"Todo",
"In Progress",
"Review",
"Done"
],
"defaultPriority": "P3",
"defaultOwnerType": "human",
"columnColors": {
"backlog": "red",
"todo": "blue",
"in progress": "orange",
"review": "violet",
"done": "yellow"
},
"columnMeta": {
"Backlog": {
"role": "backlog",
"instructions": "Capture unscheduled work here. Keep enough context to decide whether it belongs in this workflow."
},
"Todo": {
"role": "ready",
"instructions": "Only move work here when its required inputs, acceptance criteria, dependencies, and approvals are ready."
},
"In Progress": {
"role": "active",
"instructions": "Execute the current workflow phase, update the checklist as work happens, and record blockers immediately."
},
"Review": {
"role": "review",
"instructions": "Finished work waits here for the owner to check in the game. Only the owner moves it to Done; if it isn't fixed, the owner moves it back with feedback."
},
"Done": {
"role": "terminal",
"instructions": "Only accept work whose criteria and required review are satisfied. Preserve the completion report and evidence."
}
},
"stackDefaultState": "collapsed"
},
"tui": {
"defaultView": "list",
"showDetailPane": true,
"listSort": "status",
"listSortDir": "asc",
"columns": {
"age": true,
"status": true,
"priority": true,
"owner": true,
"deps": true,
"tags": false,
"assignee": true
}
},
"fields": {
"priority": true,
"assignee": false,
"tags": true,
"dueDate": false,
"ownerType": false,
"tools": false
},
"notifications": {
"browser": false,
"sound": true,
"soundId": "soft",
"statusChanges": false,
"taskEdits": false,
"subtaskCompletions": true,
"editDebounceMs": 2000
},
"extensions": {
"restricted": true
}
}
+30
View File
@@ -0,0 +1,30 @@
## Deepgrain Foundry: working the board
The board is the owner's to-do list. `docs/kandown.md` explains how kandown works here.
- **Work on tasks only when the owner asks** ("do the next task", "do t8"). Don't pick up work
on your own.
- **Kinds of task.**
- **Todo**: things to build or fix.
- **Backlog, tagged `discussion`**: give options with trade-offs and a recommendation, in
chat, and write nothing in code until the owner decides. Then record the decision.
- **Backlog, tagged `idea`**: ideas for later. Leave them alone unless asked.
- Questions get answers.
- **Keep the owner's words.** Never rewrite or tidy text the owner wrote in a task file, and don't
rename tasks the owner made. Add what you learn as `## Subtasks` with `report:` lines, or in
the `report` field.
- **Writing tasks from chat.** When you write a description, task or subtask from what the owner
said in chat, write it fully in your own words as a clear description of what they want. Don't
label it as the owner's, add dates, or quote them.
- **When a task is finished**, move it to **Review** (that is "done, please check") and set the
`report` field: what changed, and how to check it in the game. Never move a task to Done:
the owner does that after checking it. If the owner moves it back with feedback, keep the
feedback and work from it.
- **Log your work.** Add an entry at the top of `docs/agent-log.md` with the date, the task id
and title, what changed, the files touched, the tests run, and anything left open.
- **Record decisions.** When the owner decides something about the design, add it to the
decisions list in `docs/DESIGN.md` with the date and the reason they gave.
- **Commit task files with the work** they describe. The owner may be editing tasks too: stage
only the task files you changed.
- **New tasks the owner asks for**: `kandown create "title" --to Todo` (add `-t discussion` or
`-t idea` for Backlog), then describe the task under `## Context`.
+22 -23
View File
@@ -11,7 +11,8 @@ The owner (the user) directs the work and is the real playtester. You build, tes
## Start of every session ## Start of every session
1. Read `NOTES.md`. It is the user's to-do inbox (see "Working from NOTES.md" below). 1. Run `kandown work`. It prints the user's task board and the rules for working it (see
"Working from the task board" below).
2. Read `docs/PROJECT_MAP.md` before searching the code. It says where everything lives. Its 2. Read `docs/PROJECT_MAP.md` before searching the code. It says where everything lives. Its
index lists every top-level function and variable by file. index lists every top-level function and variable by file.
3. Before changing how the game plays, read the relevant part of `docs/DESIGN.md`. Many choices 3. Before changing how the game plays, read the relevant part of `docs/DESIGN.md`. Many choices
@@ -39,24 +40,21 @@ These rules matter more than speed.
ask first. If the choice is small or easy to undo, take the sensible reading, say which one you ask first. If the choice is small or easy to undo, take the sensible reading, say which one you
took, and carry on. took, and carry on.
## Working from NOTES.md ## Working from the task board
`NOTES.md` belongs to the user. They add notes at any time and ask you to work on them ("do the The user's to-do list is a kandown board: one Markdown file per task in `tasks/`. **Read
notes", "do the belt note"). Work on notes only when asked. `docs/kandown.md` before using it**: it says how the board works here, which commands are safe
(never run `kandown <command> --help`: it runs the command), and what a task file looks like.
The rules for working the board are in `.kandown/kandown_work.md`, and `kandown work` prints
them. In short:
- **Kinds of note.** - Work on tasks only when asked. Todo tasks are built; Backlog tasks tagged `discussion` get
- Tasks ("add...", "make...", "fix...") are built. options with trade-offs and a recommendation, and nothing is built until the user decides.
- Discussion notes ("let's think about...", "brainstorm...", "what can we do about...", - Never reword the user's words in a task's `## Context`. Add your notes as subtasks with
"let's talk about...") get options with trade-offs and a recommendation. Do not build them `report:` lines, or in the `report` field.
until the user decides. - When a task is finished, move it to **Review** with a `report` saying what changed and how to
- Questions get answers. check it in the game. Only the user moves tasks to Done.
- **Keep the user's wording.** Never rewrite or tidy a note. If you need to add context, add a - **Log your work.** Add an entry at the top of `docs/agent-log.md` with the date, the task,
sub-bullet that starts with `(agent)`.
- **When a task is finished**, move the note, word for word, into the "Done, please check"
section at the bottom. Under it, add an `(agent)` sub-bullet saying what changed and how to
check it in the game. Do not delete it: the user deletes it after checking. If the user says
it is not fixed, move it back up and add their feedback as a sub-bullet.
- **Log your work.** Add an entry at the top of `docs/agent-log.md` with the date, the note,
what changed, the files touched, the tests run, and anything left open. what changed, the files touched, the tests run, and anything left open.
- **Record decisions.** When the user decides something about the design, add it to the - **Record decisions.** When the user decides something about the design, add it to the
decisions list in `docs/DESIGN.md` with the date and the reason they gave. decisions list in `docs/DESIGN.md` with the date and the reason they gave.
@@ -65,7 +63,7 @@ notes", "do the belt note"). Work on notes only when asked.
```sh ```sh
python3 build.py # build dist/index.html from src/ (takes a second) python3 build.py # build dist/index.html from src/ (takes a second)
python3 -m http.server -d dist # play at http://localhost:8000 (or open dist/index.html) python3 tools/serve.py # build, serve at http://localhost:8000, rebuild on src/ changes
python3 test/run_all.py # all headless test suites, in parallel (about a minute) python3 test/run_all.py # all headless test suites, in parallel (about a minute)
python3 test/run_all.py pipes # only suites whose name contains "pipes" python3 test/run_all.py pipes # only suites whose name contains "pipes"
python3 test/run_all.py --browser # also the browser tests (rebuilds first; about 3 more minutes) python3 test/run_all.py --browser # also the browser tests (rebuilds first; about 3 more minutes)
@@ -75,11 +73,10 @@ python3 tools/where.py 7614 # which source line is line 7614 of dist/index.
python3 tools/map.py # regenerate the index in docs/PROJECT_MAP.md python3 tools/map.py # regenerate the index in docs/PROJECT_MAP.md
``` ```
You need Node (tested with 22), Python 3, and Playwright for Python with Chromium You need Node (tested with 22) and Python 3. Playwright for Python and its Chromium live in
(`pip install -r requirements.txt`, then `python3 -m playwright install chromium`). Playwright `.venv/` in the repo (not committed); set up as in README.md → Develop. The browser scripts and
officially supports Ubuntu and Debian. On other Linux distributions, such as Arch, its bundled `tools/shot.py` switch to `.venv` by themselves (`tools/use_venv.py`), so the commands above work
Chromium usually works; if it does not, launch the system Chromium through Playwright's with plain `python3`.
`executable_path`.
## How the code is put together ## How the code is put together
@@ -179,3 +176,5 @@ screenshots and the pitfalls. The essentials:
- Never commit secrets: tokens, passwords, or keys. - Never commit secrets: tokens, passwords, or keys.
- Work happens one agent at a time. If running several agents side by side is set up later, - Work happens one agent at a time. If running several agents side by side is set up later,
this section will say how they coordinate. this section will say how they coordinate.
This project uses Kandown. Before task work, run `kandown work` and follow its output. <!-- kandown:agent-ref -->
+2 -1
View File
@@ -5,5 +5,6 @@
- To look at a screenshot, open the PNG with the Read tool. Do this every time you change - To look at a screenshot, open the PNG with the Read tool. Do this every time you change
anything drawn, and before you call it done. anything drawn, and before you call it done.
- Use WebSearch and WebFetch for anything outside this repository, per the honesty rules above. - Use WebSearch and WebFetch for anything outside this repository, per the honesty rules above.
- Keep `docs/agent-log.md`, `NOTES.md` and `docs/PROJECT_MAP.md` current, as described above. - Keep `docs/agent-log.md`, the task files in `tasks/` and `docs/PROJECT_MAP.md` current, as
described above.
They are how the next session, yours or another agent's, picks up the work. They are how the next session, yours or another agent's, picks up the work.
-28
View File
@@ -1,28 +0,0 @@
# Notes
The owner's to-do list for the game. Add notes anywhere in the lists below. Agents work on
them when asked; AGENTS.md says how. Finished items go to "Done, please check" at the bottom
and stay there until the owner has checked them in the game and deleted them.
## To do
- copper bars go into the furnace. also they shouldnt require the player to press F to collect. instead it should automatically eject ingots below and the player can have a belt there.
- stacking ingots is a little odd, they dont stack neatly. so putting them all in the same place means it will get full but have lots of air space. what can we do about this? WHat in real life might be the solution for storing ingots?
- i have noticed that belts with a big pile on top only move the grains on the top of the stack. the belt should move the whole stack. if this is an issue for performance, maybe a nice solution is to stall the belt when there is too much on it. then the player must build in a way to control the flow to that belt.
- (agent) Written after version 24, which already tried to fix this: carryColumn in src/sim/conveyors.js moves the whole stack, up to 96 grains deep. A one-off headless check of a tall heap moving together passed then (it was not kept as a suite test), so the real game must differ from that check somehow. Reproduce it in the game first (tools/shot.py, or play it), then add a suite test that fails, then fix.
- for the build menu, add shift + q/e to move tabs.
- instead of full description on to left for build menu, mouse hover reviels a new info popup. organize the popup look so its nice and easy to read the information.
- dont need the short description in the build menu if we have a hover info popup.
- in the build menu, having the "g pick" there is confusing. im not sure if it should be there or not. also middle mouse the default for pick is better, g shouldnt even be used at all, its too far from the normal keys people use around wasd. maybe later for something less used.
- lets think about how best to design the factory products. basics are normal smelting ore, refining, etc. but the actual export products could be like real construction materials. materials that a real construction site might need.
- the world still seems quite small. i dont know if thats because the bot moves so fast, the bot can mine too fast, or the grains should be smaller... i would like to make the worl seem huge to the player and give them loads of resources to mine for a long game. as is, you could mine all the resources from the map pretty quickly and end up having to start a new world and new game as a result. or maybe there is some other solution that i havent thought of yet. need to brainstorm this.
## Future features and ideas
- we can add more exotic/sci-fi ish ores and materials for more late game.
- add more world generation options in new world creation and some presetsa Presets like different planet environments and all. Trees, water, dirt, all that could look different too.
- lets talk about upgrades to the res drill. currently we have a bit mold. but that doesnt really make sense for the res drill. lets redesign the whole upgrades functionality anyway.
## Done, please check
(Nothing yet.)
+11 -4
View File
@@ -12,7 +12,7 @@ machines, until the land claim is paid off and beyond.
```sh ```sh
python3 build.py python3 build.py
python3 -m http.server -d dist # then open http://localhost:8000 python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
``` ```
Opening `dist/index.html` directly works too. The game is one self-contained page with no Opening `dist/index.html` directly works too. The game is one self-contained page with no
@@ -34,10 +34,16 @@ The controls are in the in-game notebook (N) under Controls. The basics:
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots: You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots:
```sh ```sh
pip install -r requirements.txt uv venv .venv
python3 -m playwright install chromium uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m playwright install chromium
``` ```
If uv is not installed, `python3 -m venv .venv` and
`.venv/bin/pip install -r requirements.txt` also work; then install Chromium as above.
After that, plain `python3` works for every command below; the browser scripts switch to
`.venv` on their own.
```sh ```sh
python3 build.py # build dist/index.html python3 build.py # build dist/index.html
python3 test/run_all.py # headless test suites python3 test/run_all.py # headless test suites
@@ -55,7 +61,8 @@ The source is small JavaScript files with no dependencies or bundler:
## Documents ## Documents
- `AGENTS.md` / `CLAUDE.md`: how coding agents work on this project. - `AGENTS.md` / `CLAUDE.md`: how coding agents work on this project.
- `NOTES.md`: the to-do list. - `tasks/`: the to-do list, a [kandown](https://kandown.dev) board (`kandown` opens it; see
`docs/kandown.md`).
- `docs/PROJECT_MAP.md`: where everything is. - `docs/PROJECT_MAP.md`: where everything is.
- `docs/DESIGN.md`: what the game is meant to be, and the decisions made. - `docs/DESIGN.md`: what the game is meant to be, and the decisions made.
- `docs/TESTING.md`: tests, screenshots and pitfalls. - `docs/TESTING.md`: tests, screenshots and pitfalls.
+1 -1
View File
@@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a
src/sim/base.js applies to all of it. No bundler, no dependencies. src/sim/base.js applies to all of it. No bundler, no dependencies.
Writes: Writes:
dist/index.html the full page: open it, or serve dist/ with python -m http.server dist/index.html the full page: open it, or serve with python3 tools/serve.py
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
claude.ai artifact) that wrap the page themselves claude.ai artifact) that wrap the page themselves
dist/bundle-lines.json where each source file starts in dist/index.html, so a line number dist/bundle-lines.json where each source file starts in dist/index.html, so a line number
+224 -16
View File
@@ -18,7 +18,7 @@ decides something new, add it under "Decisions" with the date.
- **Copy and paste of builds.** Pasting places ghosts that the bot builds from the bag. - **Copy and paste of builds.** Pasting places ghosts that the bot builds from the bag.
- **Tool upgrades are earned in a satisfying, not tedious way**, not bought with arbitrary - **Tool upgrades are earned in a satisfying, not tedious way**, not bought with arbitrary
credits. So far that means casting them: a drill bit in a Bit mold, a bigger bag in a Tank mold. credits. So far that means casting them: a drill bit in a Bit mold, a bigger bag in a Tank mold.
The owner wants the whole upgrade system redesigned (see NOTES.md). The owner wants the whole upgrade system redesigned (task t14).
- **A hovering bot that flies freely.** A limited bag that can be upgraded. - **A hovering bot that flies freely.** A limited bag that can be upgraded.
- **An economy instead of a rocket or other final goal.** - **An economy instead of a rocket or other final goal.**
- Sell materials at the trade dock; refined goods and alloys pay more. - Sell materials at the trade dock; refined goods and alloys pay more.
@@ -43,10 +43,10 @@ decides something new, add it under "Decisions" with the date.
## The core loop ## The core loop
1. Dig with the resonance drill. Rock shatters into loose grains: rubble, ore, coal, clay, sand. 1. Dig with the resonance drill. Rock shatters into loose grains: rubble, ore, coal, clay, sand.
2. Vacuum up what you want (tick materials to take only those), and blow it out where you need it. 2. Vacuum up what you want (a drag takes only the material it starts on), and blow out what's in hand where you need it.
3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal, 3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal,
light it, and let the molten metal run into the mold. light it, and let the molten metal run into the mold.
4. Knock out bars and sell them at the trade dock, or fill buyers' orders, which pay more. 4. Bars drop out of the bottom of the mold; collect them and sell them at the trade dock, or fill buyers' orders, which pay more.
5. Better bits (copper, then iron, then steel) open harder rock: granite, then basalt. Deeper 5. Better bits (copper, then iron, then steel) open harder rock: granite, then basalt. Deeper
ores give tin, zinc, silver and gold, and alloys (bronze, brass) pay more. ores give tin, zinc, silver and gold, and alloys (bronze, brass) pay more.
6. Automate with belts, screw tubes, filters, fans, pipes and machines. Pay off the claim. 6. Automate with belts, screw tubes, filters, fans, pipes and machines. Pay off the claim.
@@ -77,9 +77,14 @@ Details and numbers live in the code and in `docs/systems/`.
- Blocks: walls of any of several materials, belts, screw tubes, filters, grates, gates, - Blocks: walls of any of several materials, belts, screw tubes, filters, grates, gates,
pipes, pumps, outlets. pipes, pumps, outlets.
- Machines: fan, Refiner, Stone cutter, Silo. - Machines: fan, Refiner, Stone cutter, Silo.
- Molds: ingot, bit, tank. - Molds: ingot, bit, tank. Ingot bars drop out of the bottom onto whatever is under the mold;
- Build actions: drag for lines, Shift-drag for rectangles, Ctrl to replace, hold C / X and leave one open block below and a belt under that carries them off. Ingot molds empty themselves:
drag to copy / cut, V to paste, Ctrl+Z to undo, middle mouse (or G) to pick. junk and mixed scrap drop below, slag is thrown away and the grate above is raked. Short stubs
stay for the next firing. Removing any mold returns its set metal to the bag: whole bars as
bars, the rest as scrap.
- Build actions: drag for lines, Shift-drag for rectangles, hold G to replace, hold C / X and
drag to copy / cut, V to paste, Ctrl+Z to undo, middle mouse to pick,
Shift+Q/E to switch build tabs.
- **Economy:** - **Economy:**
- The trade dock pays for anything dropped through its grate, at the market price. - The trade dock pays for anything dropped through its grate, at the market price.
- Prices dip when you sell a lot of one thing and recover over minutes. - Prices dip when you sell a lot of one thing and recover over minutes.
@@ -119,11 +124,12 @@ above, including:
- **Copy and cut** are held keys, not a tool. Hold C or X and drag a box; it acts on release. - **Copy and cut** are held keys, not a tool. Hold C or X and drag a box; it acts on release.
Copy and cut look different. Cut is destructive, but it can be pasted back or undone. Copy and cut look different. Cut is destructive, but it can be pasted back or undone.
- **Ctrl+Z undoes building** (up to 80 steps). The bot building ghosts is never a step of its own. - **Ctrl+Z undoes building** (up to 80 steps). The bot building ghosts is never a step of its own.
- **Holding Ctrl** while placing replaces what is built there. - **Holding Ctrl** while placing replaces what is built there (moved to G on 2026-10-23).
- **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate. - **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate.
The game warns when a fan gets close to melting. The game warns when a fan gets close to melting.
- **The vacuum takes only ticked materials** when anything is ticked, even if the drag starts on - **The vacuum takes only marked materials** once any are marked, even if the drag starts on
something else. something else. The t6 controls below replace the shared ticks; t16 replaces t6's modes; t20
replaces the marks with pick-what-you-click.
- **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore, - **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore,
roughly halving the waste. Ore and anything else it cannot grind drop straight through. roughly halving the waste. Ore and anything else it cannot grind drop straight through.
- **Pipes, pumps and outlets** move liquids up to 500°C, one liquid per line. A line is stored as - **Pipes, pumps and outlets** move liquids up to 500°C, one liquid per line. A line is stored as
@@ -131,14 +137,20 @@ above, including:
does not replace it. does not replace it.
**2026-10-23:** **2026-10-23:**
- **Ingot molds drop bars below automatically.** The owner reported bars being thrown into the
furnace above and wants belts underneath to carry them off. Blocked bars wait in the trough;
short stubs stay for the next firing. The mold cleans itself and rakes the grate after dropping bars or clearing junk.
- **Screw tubes carry no more than belts:** they move every other tick. For more throughput, - **Screw tubes carry no more than belts:** they move every other tick. For more throughput,
build more lines. build more lines.
- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. The owner - **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. If a grain is blocked,
reports this still misbehaves (see NOTES.md). the load above it stands still and the belt slips underneath; the top no longer slides over
- **Pick** (middle mouse, also G for now) selects the piece under the cursor with its direction, a stuck bottom. Filters still let their chosen materials drop through.
wall material and filter settings. The owner wants G dropped (see NOTES.md). - **Pick** (middle mouse) selects the piece under the cursor with its direction,
wall material and filter settings. G no longer picks, as the owner requested (it is now Replace).
- **The build menu** is a bar of tabs and icon tiles along the bottom: Structure, Moving, - **The build menu** is a bar of tabs and icon tiles along the bottom: Structure, Moving,
Liquids, Machines, Molds. The owner wants more changes (see NOTES.md). Liquids, Machines, Molds. Hover a tile for its full description, cost and placement help;
gamepad players see the selected tile’s popup. While building, the top-left card describes
the build tool and shows the R/F settings, Replace, ghosts and status.
- **No length limit when dragging.** Blocks out of the bot's reach become ghosts that it builds - **No length limit when dragging.** Blocks out of the bot's reach become ghosts that it builds
when it gets there. when it gets there.
- **Pipes are thin:** sand, water and anything else loose passes them. Pumps and outlets are - **Pipes are thin:** sand, water and anything else loose passes them. Pumps and outlets are
@@ -154,11 +166,207 @@ above, including:
refiners until none is left. The owner's reason: it is less profitable than making glass, so refiners until none is left. The owner's reason: it is less profitable than making glass, so
players will do it only when they need more ore rather than more money. players will do it only when they need more ore rather than more money.
- The project moved from a claude.ai artifact to local development with coding agents. It is - The project moved from a claude.ai artifact to local development with coding agents. It is
built from small files, with `NOTES.md` as the owner's to-do inbox. built from small files, with `NOTES.md` as the owner's to-do inbox (since 2026-10-24, a
kandown board in `tasks/`).
- **Replace is held G, not Ctrl.** The owner hit the browser's Ctrl+W (close tab) and Ctrl+D
(bookmark) while replacing and flying with WASD, and the game ignored WASD while Ctrl was held.
- **The build tool's top-left card describes the build tool**, like the other tools' cards:
how to place, the build keys, the R/F settings (material, direction, filter), Replace · G and
ghosts waiting. The selected piece's name, cost and description are in the menu and its hover
popup. The owner asked for a general description rather than the selected building.
- **Ingot molds need no F.** They push junk and mixed metal out of the bottom as loose grains with the bars, throw slag away and rake the grate. Removing any mold returns its set metal: whole bars as bars, the rest as scrap. The owner asked what F was for once bars dropped on their own, and chose this over keeping F for cleaning.
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted tubes and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
- **World sounds are local.** Sounds from the world (water sizzling on hot metal or lava, and
any other event with a place) are heard around the bot, whatever the zoom: full volume within
24 grains, fading to silent at 96, panned left or right. The owner heard a constant snapping
that followed the bot in an older world but not in a new one. It was the sizzle: in the
owner's world, water that had found its way down to hot basalt and granite (480-800°C, about
500 grains below the surface) boiled about 48 times a minute, every one at full volume. The
first fix faded sounds with distance from the view; zoomed out they were still heard, so the
owner asked for a radius around the bot instead.
- **The silo has pixel art in the pixel look**, drawn grain by grain like the rest of the world;
the smooth look keeps the illustrated silo. The owner noticed it looked the same in both.
- **Screw tubes use all their lanes.** Grains in an up or down tube shift sideways toward the
emptier lanes of the block, and grains taken in from the side go to the far lanes first. A
block-wide lift fed by a belt from the side now carries about 90% of what the belt brings
(before, only the edge lane filled: the owner saw one line on either side). Belts keep their
speed.
- **Screw tubes carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
bar into a tube's side, it rides through corners and leaves the mouth. The owner asked how bars
should be moved and chose tubes over a separate bar lift.
- **Several worlds are kept.** Each world has its own save and autosave; the title screen's Worlds
list loads, renames and deletes them, and a new or imported world never overwrites another. An
old single save becomes 'World 1'. The owner asked for multiple saves.
**2026-10-24:**
- **The to-do list is a kandown board.** `NOTES.md` became one task file per note in
`tasks/` (Todo for tasks, Backlog for discussions and ideas, Review for "done, please check").
The owner wanted a task tool built for AI agents.
- **Wall and mold materials have a menu (F).** It lists every wall material with what you have,
the cost and the heat it stands; R still cycles. The owner chose F to match the filter menu.
- **The camera's drift toward the pointer is a setting** ("Camera looks toward the pointer", on
by default). The owner doesn't like it.
- **Fog of war reveals by line of sight.** Unexplored ground is black with a soft edge; the sky
and the surface start seen. The owner chose line of sight over a plain circle around the bot.
- **Bars pile neatly, and a Bar rack stores them (t1).** Bars that come to rest snap to a tidy
brick pattern so piles have no air gaps; a Bar rack machine (the Silo for bars) takes them from
a belt or tube, counts them and lets them out with F. The owner wanted bar storage that doesn't
waste space, like stacked ingots in real life.
As built: a resting bar snaps to a 2-grain grid, and one with fewer than 2 of its 4 bottom cells
supported tips into the gap beside it. Storage became Rack blocks rather than a machine, like
the t4 hoppers: 50 bars of any kind per block, a column is one rack; bars landing on top go in
(into the lowest block with room), F opens the gate at the bottom of the column, and bars come
out oldest first, one every 20 ticks, onto whatever is below. Taking a block back returns its
bars.
- **Hopper blocks replace hopper machines (t4).** 1-block Grinder and Saw blocks each work what
lands on them and drop it below, so a wider row is a wider, faster hopper. The Refiner and Stone
cutter leave the build menu; built ones in old saves keep working. The owner wanted hoppers of
any width.
As built: a block is solid and works the grains resting on its top edge; results drop out of
its bottom, and it stops when that space is full. Same speed and price per width as the old
machines (Grinder 0.25 grains/tick, $60; Saw 0.5, $90). In a vertical stack only the top block
takes input and the result passes down through the rest, so a Saw on a Grinder cuts stone and
grinds everything else. Partial brick counts are not saved.
After the owner played it: the Refiner and Stone cutter code is gone (an old save that still has
one loads without it and refunds its price). Grinders draw as a pair of grinding wheels and
Saws as a round blade, turning while busy. The Saw also cuts wood chunks into Planks (2 build a
wood wall). Its stone output is now called a Stone block, so that only fired clay is a brick.
Items piling up under a block stay a thing to build around (a belt or an open drop), and the
Saw's text says so.
- **A world that lasts: deep ore, rock that pays, Huge worlds, new claims (t3, 2026-10-24).**
The owner found worlds small and quickly mined out, so the only way on was a new game. Keep the
bot and drill at today's pace and add more to find instead:
- Deeper is richer: ore gets more common the deeper you dig, about 4.7x the ore in a Medium
world (coal and copper ~5x, iron, zinc and tin ~4.5-5x, silver and gold ~3.4x, kept lower for
their price). The top quarter of the underground is unchanged; copper also forms in deep stone
and granite, coal in basalt, tin deeper. Lenses grow from lower noise thresholds only:
stretching the noise with depth sheared it into diagonal streaks.
No ore is placed where the natural rock temperature would light it (coal) or melt it (metal
ores, within 200°C): deep coal and gold next to the magma burned and ran forever once a
reload woke those areas, and a Huge world dropped to about 10 fps. So deep coal stays in a
cooler mid band, and coal is about 3.2x rather than 5x; total ore about 4.2x.
- Waste rock pays: grinding granite and basalt finds about twice the ore, and builders order
granite and basalt rubble in bulk.
- A Huge size (6144 x 2048); saves are gzip-compressed (a Medium save went from 29 MB to 0.4 MB).
- New claims: once a claim is paid off, open another for $5,000 down and twice the last debt
($40,000, $80,000, ...). Money, bag, upgrades, discoveries, milestones and stats belong to the
career and come along; buildings stay on their claim and wait (they don't run) until you
travel back from the notebook's Claims page.
- **A minimap and a full map (t32, 2026-10-24).** Bottom right: a minimap around the bot with
zoom (1 to 16 grains per pixel) and a Map button; M opens the full map (wheel zoom, drag to
pan, centre on bot, markers for the bot and the dock). Both draw from one low-resolution colour
image of the world (1 pixel per 2 grains, 4 on worlds wider than 4096) that refreshes a few
32-grain areas a frame, nearest the bot first, and respects fog of war. Onboarding cards moved
to the bottom centre to make room; mute moved to Shift+M. Bigger worlds and several claims
made it easy to get lost.
- **Long waits show what's happening (t30, 2026-10-24).** Generating, loading, opening a claim
and travelling show the loading overlay with a title, a moving bar and the current step
("Laying rock and ore", "Unpacking", "Building the world"...). Generation runs as steps that
give the browser a frame every 30 ms, producing exactly the same world as before. A small
"Saving…" chip shows bottom right only when a save takes longer than 150 ms. The owner found big
worlds froze on a 0% bar with no sign of progress.
- **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every
8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about
900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd
that clay bricks (first planned as a mold) and stone bricks would be made in different ways, and
chose a press over a heated kiln block or a mold. Loose clay still fires into brick rubble.
- **The bag is the vacuum's control panel (t6).** Each bag row has separate pick-up and blow
marks; a chip on the tool card sets pick-up to everything, only marked, or the same as the
first click; R over a grain toggles its pick-up mark. The owner found the shared ticks confusing.
As built: T or the chip cycles What I click → Only marked → Everything; adding a mark switches
to Only marked and removing the last goes back to What I click. A bag name or Q/E blows one
material; ⬆ marks build an even mix (bars are never mixed).
- **The vacuum menu replaces the modes (t16, 2026-10-24).** Pick-up has no mode: nothing marked
takes everything, anything marked takes only those, Shift takes everything for one click. F with
the Vacuum (when nothing in reach can be used) or a click on the card opens a menu with ⬇ Pick up
and ⬆ Blow toggles per material; the bag rows lose their buttons. T no longer does anything for
the vacuum. The owner found t6's version had too many modes.
Later the same day the owner added: Shift + right click blows an even mix of everything loose in
the bag; Ctrl+click a bag name adds it to the blow mix and Ctrl+right-click to pick-up; the menu
is one line per material because the first version was too large.
- **Air streaks for the nozzle (t25, 2026-10-24).** On top of the grains that fly in and out, the
owner chose air streaks: while sucking, short teal streaks swirl in toward the ring; while
blowing, amber streaks run from the bot to the cursor and spread to the brush width. Drawn only
(time-based, no random numbers, so the simulation is unchanged), only in reach, and off with
"Sparks and debris" or in unexplored fog.
- **The Vacuum is the Pneumatic nozzle (t26, 2026-10-24).** The owner's choice of name, since the
tool blows as much as it sucks. It shows as the tool card's title row, with the nozzle width as a
chip, and in the hotbar tooltip and all player text ("the nozzle" after the first mention). Code
ids and names stay `vacuum`.
- **Tool messages are toasts (t27, 2026-10-24).** Every tool's messages ("Nothing selected", "Out
of reach.", "Nothing to use there.") show as toasts at the top of the screen, and the red line in
the tool card is gone. The owner asked for it for the vacuum and chose every tool. Errors have a
red edge, info a patina one. A message that repeats every tick while a button is held keeps one
toast alive instead of stacking more.
- **Pick up what you click, blow out what's in hand (t20, 2026-10-24).** Replaces t16's pick-up
marks and the vacuum menu; the owner wanted something simpler and more intuitive. A left drag
takes only the material it starts on (or the first loose grain it reaches); Shift takes
everything. The hand is the blow selection: click a bag name or Q/E for one material, Ctrl+click
for a mix, Shift + right click for everything loose. When something in hand runs out it leaves
the hand (this settles the old t18 question), and an empty hand blows nothing and says so.
Picking something up fills an empty hand, never a chosen one. One tool, no menu; R and F do
nothing special with the Vacuum. The owner mostly uses it to collect ore and fuel and feed furnaces.
Follow-up the same day: the hand became **one selection for both** picking up and blowing out,
because there was no way to choose several materials to pick up, or ones not in the bag yet. With
anything selected, a drag takes only the selection wherever it starts; with nothing selected it
takes what you click. Holding Ctrl lists every material found so far below a "Not in bag" divider
(no menu). Selected materials stay selected when they run out, and picking up never selects
anything (both reverse the first t20 version). Clear: a button in the bag, clicking the only
selected row, or gamepad B.
Then, after playing: blowing out the last of a material deselects it again (an empty selection
goes back to picking what you click; a selection kept on an empty material got in the way). A
material selected before you have any stays selected. Ctrl + wheel scrolls the bag instead of
zooming the page, and a click while Ctrl is held adds to the selection even when the click
arrives without the Ctrl flag (seen on the owner's Linux setup).
- **Fog of war also reveals the ground around the bot (t17, 2026-10-24).** About 20 grains in every
direction, through rock, fully clear with a soft edge. Line of sight alone hid everything inside
the ground, so the owner couldn't see what was around them or where to dig.
As tuned: clear to 12 grains, fading out by 24, with a grainy edge; the owner found the first
version's narrow fade too sharp.
Unexplored ground is pure black, not dark grey (the owner: "should be black anyway").
- **Water pours faster, and boiled-off rain spreads out (t21, 2026-10-24).** The owner found water
running off a ledge slower than expected. Only the top layer of a pool flows, so pools emptied
at a trickle and the old code left 24-650 grains stranded on a wide ledge for good.
- A liquid now steps up to 8 cells a tick toward a drop instead of 1, fewer the thicker it is
(water 8, molten copper 5, slag 4, magma 2, glass 1). A grain on top may look 4 times as far
for a drop, so thin film left behind drains too. A 100-wide pool empties in about 50 s
(it never fully emptied before); settled lakes still sleep.
- Raining clouds keep drifting downwind at half pace, and rain falls over a cloud's band and the
band either side. Before, a waterfall into a shaft hot enough to boil it could rain straight
back on its own lake.
- World generation settles the land at the old water speed, so every seed keeps making the
same world.
- **Piles near the player sort, and quickly (t21, 2026-10-24).** A grain at least twice as heavy as
the powder under it sinks through it at 20 times the old pace (ore through ash: 4 rows to the
bottom in about 7 s). Before, a resting pile went to sleep and never sorted. Now areas with loose
grains within about 100 grains of the bot get a sorting chance every 8 ticks; far away, piles
still sleep. Through burning fuel sinking stays at the old slow pace: furnace beds are tuned to
it, and dirt heaped on a burning pile must smother it into charcoal rather than sink through.
- **A loaded world starts around the player first (t21, 2026-10-25).** Loading used to start every
area of the world at once, and a Huge world then took 15 ms a tick for its first 10 seconds,
with ticks up to 80 ms. Now the areas within 150 grains of the bot start at once, the rest 12 a
tick, nearest first (a Huge world is fully started after 17 s), and flying toward areas still
waiting starts them. Nothing is skipped: the same settling happens, spread out.
## Ideas not built yet ## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble. - **Concrete**, as a stronger wall material from rubble.
- **Upgrade ideas for the drill**: an amplifier, a tuning fork, harmonics. Also a Grav pick - **Upgrade ideas for the drill**: an amplifier, a tuning fork, harmonics. Also a Grav pick
upgrade tool and mining charges. See `docs/systems/dig-tools.md`. upgrade tool and mining charges. See `docs/systems/dig-tools.md`.
- The owner's ideas for later are in the "Future features and ideas" section of `NOTES.md`. - The owner's ideas for later are tasks tagged `idea` in the Backlog of the task board.
+169 -95
View File
@@ -9,7 +9,7 @@ top part by hand when the structure changes.
``` ```
AGENTS.md, CLAUDE.md instructions for coding agents AGENTS.md, CLAUDE.md instructions for coding agents
NOTES.md the owner's to-do inbox tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md)
build.py joins src/ into dist/index.html (one self-contained page) build.py joins src/ into dist/index.html (one self-contained page)
src/manifest.json the build order: simulation files, then browser files src/manifest.json the build order: simulation files, then browser files
src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers
@@ -20,8 +20,10 @@ src/ui/ the browser shell: input, HUD, build menu, notebook, setti
audio, the frame loop audio, the frame loop
test/ headless suites (t_*.js) and their harness; browser tests (play.py, test/ headless suites (t_*.js) and their harness; browser tests (play.py,
t_smooth.py); run_all.py t_smooth.py); run_all.py
tools/ shot.py (screenshots), where.py, map.py, perf.py, playthrough.py, tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
scenes/ (screenshot setups), experiments/ (measurements and reports) where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/
(measurements and reports)
docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per system), docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per system),
history/ (the owner's requests), agent-log.md history/ (the owner's requests), agent-log.md
dist/, shots/ build output and screenshots (not committed) dist/, shots/ build output and screenshots (not committed)
@@ -73,6 +75,7 @@ dist/, shots/ build output and screenshots (not committed)
only those are simulated. `setCell` wakes what it touches; after writing arrays directly, call only those are simulated. `setCell` wakes what it touches; after writing arrays directly, call
`wakeXY` / `wakeHeatXY`. `wakeXY` / `wakeHeatXY`.
- **Other state:** - **Other state:**
- `fog`: exploration at one cell per 2×2 grains; see [fog](systems/fog.md);
- `machines`: index = machine id; removed ones are `null`. Each is - `machines`: index = machine id; removed ones are `null`. Each is
`{ type, x, y, w, h, dir, on, ... }`. Its footprint is hull cells (solid) and void cells `{ type, x, y, w, h, dir, on, ... }`. Its footprint is hull cells (solid) and void cells
(open inside). (open inside).
@@ -158,7 +161,7 @@ dist/, shots/ build output and screenshots (not committed)
| Material properties and descriptions | `src/sim/materials.js` (`def`) | | Material properties and descriptions | `src/sim/materials.js` (`def`) |
| Prices, orders, the dock | `src/sim/economy.js` | | Prices, orders, the dock | `src/sim/economy.js` |
| Piece costs and descriptions | `src/sim/pieces.js` (`PIECES`) | | Piece costs and descriptions | `src/sim/pieces.js` (`PIECES`) |
| Build menu tabs, tiles and icons | `src/ui/build-menu.js` | | Build menu tabs, tiles, status and hover popup | `src/ui/build-menu.js` |
| Key bindings | `src/ui/input.js` (keydown); what they do: `src/ui/actions.js` | | Key bindings | `src/ui/input.js` (keydown); what they do: `src/ui/actions.js` |
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) | | Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` | | Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
@@ -193,15 +196,19 @@ Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plai
- functions: rnd, mulberry32, hash2 - functions: rnd, mulberry32, hash2
- variables: RS, PH_EMPTY, PH_SOLID, PH_POWDER, PH_LIQUID, PH_GAS, PH_ITEM - variables: RS, PH_EMPTY, PH_SOLID, PH_POWDER, PH_LIQUID, PH_GAS, PH_ITEM
### src/sim/materials.js (159 lines) ### src/sim/materials.js (119 lines)
Material ids, the material table (def), per-material property arrays, cell flags and part ids, and drill/tank tiers. Material definitions: ids, properties and metal families. ids, and drill/tank tiers.
- functions: def, metalFamily - functions: def, metalFamily
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH - variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO
### src/sim/pieces.js (140 lines) ### src/sim/material-tables.js (49 lines)
Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH
### src/sim/pieces.js (143 lines)
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
- functions: isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost - functions: isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
- variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS - variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
### src/sim/economy.js (152 lines) ### src/sim/economy.js (152 lines)
Money, prices, market saturation, the trade dock, buyer and builder orders. Money, prices, market saturation, the trade dock, buyer and builder orders.
@@ -213,57 +220,67 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint - functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES - variables: STARTER_DESIGNS, MILESTONES
### src/sim/world.js (96 lines) ### src/sim/world.js (104 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
### src/sim/cells.js (176 lines) ### src/sim/fog.js (52 lines)
Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
### src/sim/cells.js (207 lines)
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, simMove - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove
- variables: SORTP - variables: SORTP, SORTP_FUEL, LSTEP, LFILM
### src/sim/heat.js (246 lines) ### src/sim/heat.js (265 lines)
Heat: conduction, melting and freezing, reactions, fire and air, steel. Heat: conduction, melting and freezing, reactions, fire and air, steel.
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck - functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
- variables: POROUS, SOIL, HOTLIQ, ALLOY - variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
### src/sim/machines.js (154 lines) ### src/sim/machines.js (148 lines)
Machines: the power network, footprints, placing and removing machines. Machines: the power network, footprints, placing and removing machines.
- functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine - functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine
- variables: nextPowerBuild - variables: nextPowerBuild
### src/sim/processors.js (99 lines) ### src/sim/processors.js (113 lines)
Processing machines: the Refiner, the Stone cutter and the Silo. Processing: Grinder and Saw blocks, and the Silo.
- functions: refinerSlot, refinerTick, cutterTick, siloTick, siloFull - functions: isProcCode, rebuildProcessors, procBottom, procSlot, procFeed, procBlocksTick, siloTick, siloFull
- variables: REFINE, REFINE_SAND, REFINE_RATE, CUT_PER_BRICK, CUT_RATE, SILO_CAP, SILO_RATE - variables: REFINE, REFINE_SAND, CUT_PER_BRICK, SAW_CUTS, procState, procBlocks, procDirty, SILO_CAP, SILO_RATE
### src/sim/racks.js (56 lines)
Each rack block owns its bars; only the bottom of a column releases them.
- functions: newRack, rackTop, rackBottom, rackPush, rackShift, rackIntake, rackRefill, rackTick
- variables: RACK_CAP, rackState
### src/sim/pipes.js (114 lines) ### src/sim/pipes.js (114 lines)
Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains. Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains.
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick - functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty - variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
### src/sim/machine-tick.js (158 lines) ### src/sim/machine-tick.js (164 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick). Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
- functions: engineIntake, fanBlow, machinesTick - functions: engineIntake, fanBlow, machinesTick
### src/sim/conveyors.js (109 lines) ### src/sim/conveyors.js (128 lines)
How belts, filters and screw tubes move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear), the way a real belt carries a heap, not just its bottom layer. A filter's own material drops through it instead, unless the filter below is already full. How belts, filters and screw tubes move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
- functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeIntake - functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty - variables: CARRY_DEPTH, filterR, filterL, filtersDirty
### src/sim/blocks.js (132 lines) ### src/sim/blocks.js (156 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks. Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks - functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
### src/sim/molds.js (284 lines) ### src/sim/molds.js (317 lines)
Molds: cavities that fill with molten metal (or glass) and are knocked out as bars. Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, checkMolds, moldAt, knockNote, tally, collectMold, removeMold - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains - variables: DROSS, grains, barWord
### src/sim/items.js (134 lines) ### src/sim/items.js (232 lines)
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
- functions: entSolidCell, entFreeAt, entMark, spawnBar, removeEnt, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeLane, entTubeEdge, entTubeEnter, entTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast - variables: BAR_W, BAR_H, entCurM, droppedLast
### src/sim/tools.js (52 lines) ### src/sim/tools.js (52 lines)
@@ -280,12 +297,12 @@ Trees (background layer), acorns, saplings, and waking freezing water.
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
- variables: treeBases, saplings, treeDraw - variables: treeBases, saplings, treeDraw
### src/sim/vacuum.js (165 lines) ### src/sim/vacuum.js (172 lines)
The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain. The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
- variables: blowRR, FLY_G, FLY_Q - variables: blowRR, FLY_G, FLY_Q
### src/sim/actions.js (86 lines) ### src/sim/actions.js (92 lines)
Player actions on built things: take back, place a machine or mold, gates, F to interact. Take back whatever piece is at (x,y): machine, mold or block. Returns a message. Player actions on built things: take back, place a machine or mold, gates, F to interact. Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
- functions: removePieceAt, removePieceAt0, placeMachineAt, placeMoldAt, toggleGateAt, interactAt - functions: removePieceAt, removePieceAt0, placeMachineAt, placeMoldAt, toggleGateAt, interactAt
@@ -303,64 +320,72 @@ The bot: movement and collision with the world.
- functions: botBlocked, botOverlapSolid, botSlide, botStep - functions: botBlocked, botOverlapSolid, botSlide, botStep
- variables: SLIDE_MAX, SLIDE_STEP - variables: SLIDE_MAX, SLIDE_STEP
### src/sim/ledger.js (43 lines) ### src/sim/ledger.js (54 lines)
Milestone checks (the ledger) and the sky roof line used for cooling. Milestone checks (the ledger) and the sky roof line used for cooling.
- functions: checkLedger, updateRoof - functions: checkLedger, updateRoof, updateDirtyRoof, scanRoof
### src/sim/weather.js (85 lines) ### src/sim/weather.js (87 lines)
Weather: clouds from steam, rain and snow, and the world water count. Weather: clouds from steam, rain and snow, and the world water count.
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick - functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
- variables: WBAND, WS, weather - variables: WBAND, WS, weather
### src/sim/tick.js (25 lines) ### src/sim/tick.js (28 lines)
simTick: one simulation step, in order. simTick: one simulation step, in order.
- functions: simTick - functions: simTick
### src/sim/worldgen.js (315 lines) ### src/sim/worldgen-helpers.js (61 lines)
World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. World generation helpers: noise and removal of tiny hard rock specks.
- functions: makeNoise, fbm, clearHardSpecks, generateWorld - functions: makeNoise, fbm, clearHardSpecks
- variables: SPECK_MIN - variables: SPECK_MIN
### src/sim/save.js (112 lines) ### src/sim/worldgen.js (286 lines)
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync
### src/sim/save.js (177 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF - variables: SAVE_VERSION, TOFF
### src/render/setup.js (196 lines) ### src/render/setup.js (200 lines)
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera. Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
### src/render/light.js (79 lines) ### src/render/light.js (79 lines)
Propagated coloured light from glowing and burning cells. Propagated coloured light from glowing and burning cells.
- functions: computeLight - functions: computeLight
### src/render/world.js (277 lines) ### src/render/world.js (308 lines)
drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. Scale colour without changing the smooth-look class.
- functions: drawWorld - functions: fogColor, drawWorld
- variables: TUBE_CLASS - variables: TUBE_CLASS
### src/render/machines.js (152 lines) ### src/render/machines.js (242 lines)
Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world. Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawSilo - functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawRackBlocks
### src/render/overlays.js (230 lines) ### src/render/overlays.js (229 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles - functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
### src/render/smooth.js (430 lines) ### src/render/smooth.js (99 lines)
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld fills (colour + class, colour behind, trees, occupancy fields, light, glow) into rounded, outlined, softly lit shapes at screen resolution. The 2D canvas then draws this image as its world layer, and every overlay (machines, bot, previews, labels) goes on top as before. Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, SM_VS, SM_FS, smBytes - variables: SM, smBytes
### src/ui/audio.js (209 lines) ### src/render/smooth-shaders.js (325 lines)
Smooth shaders: vertex and fragment programs for the rounded world look.
- variables: SM_VS, SM_FS
### src/ui/audio.js (230 lines)
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
- variables: AU, SFX, ZONES - variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
### src/ui/state.js (67 lines) ### src/ui/state.js (75 lines)
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and the status line. Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status.
- functions: loadSettings, saveSettings, loadCollect, el, esc, rgbOf, fmtT, toast, setStatus - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
### src/ui/title.js (46 lines) ### src/ui/title.js (46 lines)
@@ -368,49 +393,71 @@ The title screen art.
- functions: buildTitleCells, drawTitle - functions: buildTitleCells, drawTitle
- variables: FONT5, TITLE - variables: FONT5, TITLE
### src/ui/saves.js (106 lines) ### src/ui/saves.js (224 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library. Saves in IndexedDB, world files (export / import) and the blueprint library.
- functions: saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
- variables: DB - variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
### src/ui/session.js (87 lines) ### src/ui/claims.js (145 lines)
Shared player state; the world save remains a complete standalone fallback.
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
- variables: career, claimBusy
### src/ui/session.js (157 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu. Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (116 lines) ### src/ui/settings.js (125 lines)
The settings screen. The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab - variables: SET_TABS, setTab
### src/ui/notebook.js (201 lines) ### src/ui/notebook.js (91 lines)
The notebook (N): milestones, market, wallet, blueprints, controls. Notebook: tab navigation, ledger, materials, pieces and controls.
- functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml - functions: openNotebook, renderNotebook, controlsHtml
- variables: nbTab, MARKET_GROUPS - variables: nbTab
### src/ui/hud.js (40 lines) ### src/ui/notebook-market.js (68 lines)
Notebook market: prices, orders, claim payments and wallet.
- functions: fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet
- variables: MARKET_GROUPS
### src/ui/notebook-blueprints.js (47 lines)
Notebook blueprints: starter designs, saved library and code sharing.
- functions: renderBlueprintTab, copyCode
### src/ui/hud.js (34 lines)
The tool hotbar and what is selected. The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
### src/ui/build-menu.js (72 lines) ### src/ui/build-menu.js (104 lines)
The build menu along the bottom: categories, piece tiles and their icons. The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, renderBuildBar, setupBuildBar - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
### src/ui/selection.js (246 lines) ### src/ui/selection.js (96 lines)
The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. Tool selection: cycling, drill tuning, piece variants and costs.
- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow
### src/ui/selection-pickers.js (59 lines)
Selection pickers: filter rules, filter settings and wall materials. What a filter can hold: loose things and bars you have found, plus anything it already holds
- functions: filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker
### src/ui/selection-panels.js (90 lines)
Selection panels: current tool card and bag materials.
- functions: renderSelCard, renderBag
- variables: lastBagKey - variables: lastBagKey
### src/ui/info.js (167 lines) ### src/ui/info.js (188 lines)
Discovery cards, hover info (I) and the cursor label. Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
### src/ui/input.js (76 lines) ### src/ui/input.js (91 lines)
Keyboard and mouse listeners. Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (244 lines) ### src/ui/actions.js (239 lines)
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
- variables: ovShown - variables: ovShown
@@ -420,16 +467,30 @@ Gamepad input.
- functions: padPressed, pollPad - functions: padPressed, pollPad
- variables: PAD - variables: PAD
### src/ui/previews.js (334 lines) ### src/ui/previews.js (190 lines)
Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste. Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawPreviews, drawTag, drawSelection, drawPastePreview - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews
- variables: CRACK_N, CRACK_LV, SEL_COL
### src/ui/events.js (44 lines) ### src/ui/preview-drill.js (104 lines)
Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern.
- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
- variables: CRACK_N, CRACK_LV
### src/ui/preview-selection.js (80 lines)
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
- functions: drawTag, drawSelection, drawPastePreview
- variables: SEL_COL
### src/ui/events.js (42 lines)
Events from the simulation (emit) turned into sounds, toasts and cards. Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents - functions: drainEvents
### src/ui/loop.js (117 lines) ### src/ui/map.js (93 lines)
Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP
### src/ui/loop.js (134 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
@@ -440,22 +501,31 @@ The frame loop and boot.
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
- `test/run_all.py`: Run the test suites and report. - `test/run_all.py`: Run the test suites and report.
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
- `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters. - `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
- `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks. - `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks.
- `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys.
- `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence.
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
- `test/t_molds.js`: Molds and furnaces stay usable: knock-outs leave the mold clean, plugs over the mold are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Refiner grinds waste rock for trace ore. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the stone cutter, the silo and builders' orders. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing. - `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains. - `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md. - `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
- `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first. - `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots. - `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript. - `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from. - `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel. - `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
- `tools/experiments/factory_cost.js`: What does a running factory cost when the player is somewhere else? Builds K self-running modules in a generated world, far from the bot, and measures simulation time per tick. Each module: a coal + copper-ore furnace burning in a stone-block box, a spring feeding a water wheel, a shaft carrying its power to a 10-block belt that hauls sand off a hopper. - `tools/experiments/factory_cost.js`: What does a running factory cost when the player is somewhere else? Builds K self-running modules in a generated world, far from the bot, and measures simulation time per tick. Each module: a coal + copper-ore furnace burning in a stone-block box, a spring feeding a water wheel, a shaft carrying its power to a 10-block belt that hauls sand off a hopper.
@@ -465,5 +535,9 @@ The frame loop and boot.
- `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...] - `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...]
- `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal] - `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal]
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost. - `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
- `tools/scenes/bar-lift.js`: A mold drops copper bars onto a belt, through a screw lift and onto the upper belt.
- `tools/scenes/belt-heap.js`: A belt feeding an upward screw: sand underneath a deep heap of copper ore. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/belt-heap.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/belt-heap.js)" --ticks 240 --zoom 7
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area). - `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
- `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7 - `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
+1
View File
@@ -60,6 +60,7 @@ diff -r /tmp/before /tmp/after
## Browser tests ## Browser tests
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
- `test/play.py` (about 2.5 minutes) builds a world through the title screen and plays it with real - `test/play.py` (about 2.5 minutes) builds a world through the title screen and plays it with real
input. It checks the tools, building, copy/cut/paste, undo, the build menu, filters, pipes, input. It checks the tools, building, copy/cut/paste, undo, the build menu, filters, pipes,
the notebook and market, saving and continuing, exporting and importing a world file, the the notebook and market, saving and continuing, exporting and importing a world file, the
+784
View File
@@ -4,6 +4,790 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-25: t21: a loaded world starts around the player first (Claude Code, Codex)
- Owner: "lets speed up the first load speed. load in areas around the player first".
- `src/sim/save.js`: `deserializeWorld` no longer wakes every area; `startLoadWake()` keeps the
load-time heat scan's result per area, wakes those within 150 grains of the bot, and queues the
rest by distance; `loadWakeTick()` (from `simTick`) wakes 12 a tick and, every 8 ticks, any still
waiting within 150 grains of the bot. The queue is not saved.
- Huge world, ms/tick before -> after: ticks 0-600 average 15.2 -> 5.5, max 79.7 -> 16.2, 95th
percentile 21.0 -> 11.0; ticks 600-1800 average 7.3 -> 10.0 (the same work, later). Every area
has started by tick 1024 (17 s).
- `test/t_load.js` (19 checks). `run_all.py --browser` ALL PASSED.
## 2026-10-24: t21 phase 4: duplicates merged, largest files split (Claude Code, Codex)
- Merged seven real duplicates: wall refunds (`removeWallCell`, walls and molds), tube edges and
lanes (`entTubeEdge`, `entTubeLane`), pipe text, piece dimensions, font lookup, reach circles,
surface averaging (`meanSurface`). Look-alikes left alone: data tables, sprite rows, GLSL, and
the hot movement and heat code.
- Split by topic: `ui/previews.js` -> `preview-drill.js`, `preview-selection.js`;
`render/smooth.js` -> `smooth-shaders.js`; `ui/notebook.js` -> `notebook-market.js`,
`notebook-blueprints.js`; `sim/materials.js` -> `material-tables.js`; `sim/worldgen.js` ->
`worldgen-helpers.js`; `ui/selection.js` -> `selection-pickers.js`, `selection-panels.js`.
`render/world.js` left whole (one large function).
- Every suite's output identical apart from timings; seeds generate the same worlds.
`run_all.py --browser` ALL PASSED.
## 2026-10-24: t21 phase 3: speed-ups that change nothing (Claude Code, Codex)
- Profiled a Huge world (6144x2048) headless and in the browser. After a load every area is
awake: 8-15 ms/tick for the first 600 ticks, settling to about 0.7 ms/tick after 3 minutes. In
the browser, drawing is cheap; the full roof scan every 20 ticks was the largest single item.
- Kept: `updateDirtyRoof()` (`src/sim/ledger.js`) rescans only area columns marked by
`wakeXY`/`wakeHeatXY`/`simMove`/`heatChunk` (`roofDirty`); `updateRoof()` is still the full scan
for setup code. Quiet Huge world 4.60 -> 1.88 ms per scan, digging 4.67 -> 3.48.
`heatChunk` skips `react()` for solids and powders below their first reaction threshold
(`REACT_MIN`). Huge, ms/tick: digging 2.54 -> 2.34, quiet 1.39 -> 1.18.
- Reverted (no consistent gain): inlining `condPair`, one shared part rule for `enter`/`passable`.
- `test/t_sim_optimizations.js`: the dirty roof matches a full scan; `heatChunk` matches the
reference loop in `test/fixtures/heat-chunk.js` byte for byte, random state included (catches a
reaction added to `react()` but not to `REACT_MIN`).
- Every suite's output identical before and after apart from timings; seeds 777 and 4242
generate the same worlds. `run_all.py --browser` ALL PASSED.
## 2026-10-24: t21: piles near the bot sort quickly (Claude Code, Codex)
- Owner: "a pile should sort when the player is nearby. the sorting shouldnt take long anyway."
- `src/sim/cells.js`: `SORTP` 0.003 -> 0.06, `SORTP_FUEL` 0.003 for sinking through burning fuel
(at 0.06 dirt sank through a buried burning wood pile and `t_world`'s charcoal check failed);
`wakeBotPowder()` every 8 ticks from `simTick` wakes areas with loose grains (`powderArea`
hint, kept by `simMove`) within 100 grains of the bot. `worldSteps` settles at 0.003: seeds
unchanged.
- Cost on a Medium dig site: 0.108 -> 0.134 ms/tick. `t_physics` sorting checks: near the bot ore
sorts in 432 ticks then sleeps; far away it stays; sand on dirt never sorts.
- Tests: `run_all.py --browser` ALL PASSED.
## 2026-10-24: t21 phase 2: physics test pass for sand, molten metal and gases (Claude Code, Codex)
- `test/t_physics.js` (41 checks): heaps, digging under a heap, ore sorting through ash, sand
sinking in a pond, world edges; molten copper levelling (slower than water), setting, sinking in
a pond, slag over copper, filling a cavity; smoke rising and fading (longer under a roof), steam
condensing, fire going out, gas in sealed pockets, gas leaving at the top row. Every scenario
checks that its areas go back to sleep.
- Found and fixed: gas stuck in a pocket never finished fading or condensing because its area
slept (`gas()` keeps its area awake: steady cost 1.34 -> 1.37 ms/tick); a rain drop or blown
grain landing on steam deleted that steam's water (`landFlyer`/`depositNear` in
`src/sim/vacuum.js` now send it to the clouds).
- Found, owner decided: a resting pile never sorts. To follow: piles near the bot keep sorting,
and sorting gets quick.
- Tests: `run_all.py --browser` ALL PASSED; seeds 777 and 77 generate the same worlds.
## 2026-10-24: t21 phase 1: water test pass, faster pouring, rain spreads (Claude Code, Codex)
- `test/t_water.js` (20 checks): conservation, levelling and sleep, pools pouring off ledges,
world edges and corners, pumps, freezing and thawing, boiling and condensing, a pool draining
into a hot shaft.
- `liquid()`/`move()` in `src/sim/cells.js`: multi-cell steps (`LSTEP`, scaled by `FLUID`),
a longer reach for top-layer grains (`LFILM`), and waking the row ahead only when a top-layer
grain steps sideways (waking on every liquid move kept 207 of 2048 areas busy after a reload).
`worldSteps` settles with both at 1: seeds 777, 77 and 4242 hash the same as before.
- Found: cooled steam drifting under a roof could stay steam for ever, because its heat area
slept. `gas()` now keeps the heat pass on it.
- `weatherTick()`: raining clouds drift at half pace; rain falls over the band and one either
side. `t_weather`: 'close to where it gathered' now samples drops in the air (landed water
spreads); a new wind check.
- Test fixes, not weakening: `t_conveyors` pit check counts steam on its way up (the water total
was exact); `t_molds` burning-bed check seeds `RS` (its result hung on earlier tests' draws:
0.28-1.04 depending on the sequence, identical on the old and new code).
- Steady cost after a reload (2048x1024): 1.26 -> 1.24 ms/tick. Tests: `run_all.py --browser`
ALL PASSED. Screenshots `shots/t21-pour-120.png`, `shots/t21-pour-900.png`.
## 2026-10-24: t1: bars stack neatly, Rack blocks store them (Claude Code, Codex)
- `232f5af`: `entTick` snap-and-tip (`entSupportCount`, `src/sim/items.js`);
`test/t_bar_stacking.js` (12 bars fill a pit's bottom six rows; fails on the old code).
- Rack blocks: `src/sim/racks.js` (`rackState` per block, FIFO runs, `rackIntake` from `entTick`,
`rackTick` release, `rackRefill`), `BP_RACK`/`RACKB`, F toggle in `interactAt`, removal returns
bars with `giveOrDrop`, saved in meta `racks` and validated on load, shelves overlay, hover,
Machines tab, notebook. `test/t_racks.js` (29 checks), play.py menu check.
- Tests: `run_all.py --browser` ALL PASSED. Screenshot `shots/t1-racks.png`.
## 2026-10-24: Backlog combined and rewritten (Claude Code)
- On the owner's request: t12 (exotic ores) and t31 (choosing claims) merged into t13, now
"Distinct worlds: generation options, planet presets, exotic ores and a choice of claims";
t24 (water fixes) merged into t21, now "Code health: refactor, speed up and test the physics".
t2 and t14 rewritten with subtasks. t12, t24 and t31 archived (`tasks/archive/`), not deleted.
## 2026-10-24: t32: minimap and full map (Claude Code, Codex)
- `src/ui/map.js`: colour cache (`resetMap`, `mapArea`, `updateMap` from the frame loop),
minimap (`#minimap` in `#hud .br`, `SET.minimap`, `SET.minimapZoom`), full map modal `m-map`
(`openMap`, `zoomMap`, drag pan). `input.js`: M map, Shift+M mute. `#cards` moved to `.bc`.
- Frame time on Medium: 2.18 ms with the minimap off, 2.29 ms on.
- Tests: play.py map checks (buttons, zoom setting, cache updates after digging, fog black, M/Esc,
Shift+M, cards in .bc, setting hides it, frame time). `run_all.py --browser` ALL PASSED.
Screenshots `shots/t32-hud.png`, `shots/t32-map.png`, `shots/t32-map-fog.png`.
## 2026-10-24: New worlds get camp names too (Claude Code, Codex)
- The new-world dialog prefills `claimName(seed, existing names)` instead of "World N", and
follows seed edits until the player types their own name; a blank name falls back to it too
(`src/ui/loop.js`, `session.js`). play.py checks updated. All tests pass.
## 2026-10-24: t30: progress while generating, loading and saving (Claude Code, Codex)
- `worldgen.js`: `worldSteps` generator yields `[fraction, label]`; `generateWorld` (sync, tests)
and `generateWorldAsync` (yields every 30 ms) run it. Small and Medium mat/temp/flags hashes
match the old generator exactly.
- `session.js` `showLoading(on, p, label, title)` with `#loadstep`; `GAME.loading` stops the
frame loop's sim and drawing while worlds swap. `loadSavedWorld` for Continue and the world
list; `worldNewest`/`unpackBuffers`/`startFromSave` report progress; travel and new claims too.
`#saving` chip after 150 ms of saving.
- Tests: t_world progress labels and identical worlds; play.py overlay, responsiveness during
async generation, Continue labels, saving chip. All pass except t_smooth half-resolution
(fails under load, as before). Screenshots `shots/t30-loading.png`, `shots/t30-saving.png`.
## 2026-10-24: t3 follow-up: claim names; t30 added (Claude Code, Codex)
- New claims get a camp name from their seed ("Cinder Gulch"; `claimName` in `src/ui/claims.js`,
skipping names already in the world list) instead of "Claim N". The Claims tab shows "Claim N"
beside the name and has a Rename button on every row (reuses `renameWorld`).
- Owner's request added to Todo as t30: progress meters while generating, loading and saving.
- Tests: play.py claims checks extended; t_smooth half-resolution still fails under load.
## 2026-10-24: t3 fix: deep ore burned and melted after a reload (Claude Code)
- Owner: a Huge world ran at about 10 fps after travelling between claims, even after a refresh.
Cause: reloading wakes every area, and t3's deep coal (ignites at 430°C, natural rock reaches
that at about 70% depth) and gold next to the magma burned and melted forever: about 2,100 of
12,288 areas stayed busy, 65 ms per tick. A fresh world hid it because nothing deep was awake.
- `worldgen.js`: an ore is not placed where `AMB[y]` is within 120°C of its ignition or 200°C of
its melting point; a cooler coal band in stone and granite at 45%+ depth replaces most of the
lost coal. Huge after a reload: 335 areas awake, about 5 ms per tick; Medium 104 (126 before
t3). Medium seed 424242 ore is now about 4.2x the original (coal 3.2x, gold 2.8x).
- `test/t_world.js`: no ore in too-hot rock; a reloaded world settles with no fires and under
10% of areas moving; ore balance ranges updated. Existing saves made with the bug keep it.
## 2026-10-24: t3: a world that lasts (Claude Code, Codex)
- `26d17c2` deeper is richer (`worldgen.js` ore thresholds drop with depth below fy 0.25).
Medium seed 424242 ore grains before -> after: coal 33,493 -> 170,437; copper 26,508 ->
127,298; iron 77,679 -> 361,683; zinc 11,797 -> 53,527; tin 7,723 -> 38,551; silver 3,638 ->
12,166; gold 1,681 -> 5,858; total 162,519 -> 769,520 (4.73x). Shallow ore unchanged. Codex's
first try stretched noise by depth (streaks) and gave gold 26x; redone.
- `4a88254` waste rock: `REFINE` granite/basalt about 2x; granite and basalt bulk orders; prices.
- `56c93ae` Huge 6144x2048 and gzip-packed save buffers (`packBuffers`/`unpackBuffers`, old raw
saves still load). Medium 29.0 MB -> 0.44 MB, Huge 92.8 MB -> 1.36 MB; Huge generation 5.9 s,
save 0.5 s, 0.39 ms per idle tick.
- New claims: `src/ui/claims.js` (career record `career:<id>` in IndexedDB, applied over each
loaded claim; `openNewClaim`, `travel`, Claims tab), `session.js` `setupNewWorld`, `saves.js`.
I switched claims.js to the repo's single quotes. Screenshot `shots/t3-claims.png`.
- Tests: play.py 268 checks and all headless suites pass. t_smooth's half-resolution check
fails under load on this machine; the build before t3 failed it 3 of 3 times under the same
load, so it isn't caused by this work.
## 2026-10-24: t4 follow-ups and t28 Brick press (Claude Code, Codex)
- `9739ca0`: Refiner and Stone cutter code removed; `save.js` skips unknown machine types on load
and refunds a refiner ($120) or cutter ($90). Their old t_pipes/t_stone checks now use blocks.
- `f5e5591`: `drawProcBlocks` (`render/machines.js`) draws Grinder wheels and the Saw blade over a
plain housing texture; turns while busy, no `rnd()`.
- `08d2d65`: `PLANK` item; the Saw cuts `LWOOD` too (`SAW_CUTS`, per-material `cut` counts).
"Stone brick" is "Stone block" in player text. Saw text explains the pile-up under it.
- t28: `BP_PRESS`/`PRESSB`, `GREENB` and `BARBR` items, `FIRETO` (items.js fires a hot green
brick into a Brick). Machines tab, hover, notebook, milestone hint.
- Tests: `run_all.py --browser` passed after each commit (t_smooth's flake rerun alone).
Screenshots `shots/t4-wheels{,-smooth}.png`, `shots/t28-press.png`.
## 2026-10-24: t4: Grinder and Saw hopper blocks (Claude Code, Codex)
- New block pieces `grinder`/`saw` (`BP_GRIND`/`BP_SAW`, materials `GRINDER`/`SAWB`, NMAT 97).
`procBlocksTick` (`src/sim/processors.js`) works the row above each stack top and passes the
result down the stack (`procFeed`), reusing `REFINE` and `CUT_PER_BRICK`; per-block state in
`procState`, rebuilt on place/remove/load. Refiner and Stone cutter are `hidden`; their ticks are
unchanged. Texture in `render/world.js`, hover text in `info.js`, Machines tab, notebook.
- Codex hit its output limit before finishing the tests; I fixed the test setup (a leftover brick
blocked the outlet), kept the old machine icons, stopped "blocked" flickering on idle ticks,
and added the play.py Machines-tab and drag-a-row checks.
- Tests: t_stone all pass, play.py 243 pass; t_smooth's half-resolution check fails inside
run_all at load ~9 and passes alone (known flake). Screenshots `shots/t4-hoppers{,-smooth}.png`.
## 2026-10-24: t25: air streaks for the Pneumatic nozzle (Claude Code, Codex)
- `drawAirStreaks` in `src/ui/previews.js`: 12 teal streaks spiral into the ring while sucking, 10
amber streaks run from the nozzle to the cursor in a cone while blowing. `performance.now()`
only, no `rnd()`; skipped with particles off or in fog. `test/play.py`: drawing both doesn't
change the sim RNG. Also commits the `docs/PROJECT_MAP.md` refresh missed in t27/t26.
- Tests: `run_all.py --browser` passed (play.py 240; t_smooth's half-resolution flake at load 9
passed on rerun). Screenshots `shots/t25-{suck,blow}.png`.
## 2026-10-24: t26: the Vacuum is renamed the Pneumatic nozzle (Claude Code, Codex)
- `TOOLS[1].name`, a title row in the tool card ("Pneumatic nozzle" plus a `width N` chip, moved
from the sub line), and player text in `session.js`, `selection.js`, `notebook.js` and
`progress.js`. Ids, function names and comments still say vacuum.
- Tests: `run_all.py --browser` all passed (play.py 239). Screenshots `shots/t26-card{,-900}.png`.
## 2026-10-24: t27: tool messages as toasts (Claude Code, Codex)
- `setStatus` still records `GAME.status` and now also shows a toast (`err` or `info` class), unless
told to stay quiet (the "New drill bit/bag" F messages, which have their own card). `toast`
restarts the timer of a showing toast with the same text instead of adding another, so per-tick
messages make one toast. The card's status line and its CSS are gone; `doInteract` no longer
toasts twice. Files: `src/ui/{state,selection,actions}.js`, `style.css`, `test/play.py`.
- Tests: `run_all.py --browser` all passed (play.py 237). Screenshot `shots/t27-toasts.png`.
## 2026-10-24: t20 follow-up: bag hint and Clear stay visible (Claude Code)
- The hint and Clear were inside the scrolling `#baglist`, so a long Ctrl list scrolled them away.
They now render into a new `#bagfoot` under the list (`src/shell.html`, `renderBag`); Clear is a
full-width, highlighted "Clear selection" button (`style.css`).
- `test/play.py`: a long Ctrl list scrolls while the hint and a full-width Clear stay visible.
Screenshots `shots/t20c-ctrl{,-900}.png`. Tests: `run_all.py --browser` all passed (play.py 232).
## 2026-10-24: t20 follow-ups: deselect when blown out, Ctrl + wheel, Ctrl+click (Claude Code)
- Blowing the last of a selected material removes it from the selection (`toolTick`); materials
selected before you have any stay. Window `wheel` listener: Ctrl + wheel never zooms the page
and scrolls the scrollable panel under the mouse (the Ctrl bag list). Bag clicks add when
`GAME.ctrl` is set even if the click has no `ctrlKey` (owner had to hold Ctrl+Shift; not
reproduced in headless Chromium, which passes ctrlKey).
- Files: `src/ui/{actions,input,selection,notebook}.js`, `test/play.py` (229 checks).
- Tests: `run_all.py --browser` all passed.
## 2026-10-24: t20 follow-up: one selection, full list while Ctrl is held (Claude Code, Codex)
- `GAME.blow` is now the selection for picking up and blowing out (`vacFilter` -2 = the selection).
`blowList()` returns only selected materials in the bag; no pruning, no auto-select. `GAME.ctrl`
(input.js) makes `renderBag` add a "Not in bag" section of every `seen` material the vacuum can
take; selected but empty materials always show there. Clear button, click-the-only-row, gamepad B.
- Files: `src/ui/{actions,selection,hud,input,state,gamepad,previews,notebook,session}.js`,
`src/sim/{vacuum,progress}.js`, `src/styles/style.css`, `test/play.py`, `test/t_basics.js`.
- Tests: `run_all.py --browser` passed (play.py 226) except `t_smooth` half-resolution at load 8.4,
which passed on rerun. Screenshots `shots/t20b-{noctrl,ctrl,ctrl-900}.png`.
## 2026-10-24: t20: pick up what you click, blow out what's in hand (Claude Code, Codex)
- After discussing options with the owner: one Vacuum, a drag locks onto the material it starts on
(Shift: everything), and `GAME.blow` is "the hand" (click/Q/E, Ctrl+click mix). Empty materials
leave the hand; an empty hand blows nothing; a pickup fills an empty hand. Removed `GAME.collect`,
R marks, Ctrl+right-click, `openVacuumMenu`, `ensureBlow` and the `.vpick` styles.
- Files: `src/ui/{actions,selection,hud,state,previews,build-menu,info,notebook,session}.js`,
`src/sim/{vacuum,progress}.js` (`toolVacuum` returns the first material picked up),
`src/styles/style.css`, `test/play.py`, `test/t_basics.js`.
- Tests: `run_all.py --browser` all passed (play.py 214 checks). Screenshots `shots/t20-*.png`
(empty hand, one item, mix, 900px).
## 2026-10-24: t17 follow-up: no jagged rim, unexplored is black (Claude Code)
- The owner still saw a jagged grey edge. Causes found by sampling pixels: the fade went to 0
(black) while unexplored was drawn (12, 10, 10), leaving a dark stepped rim; the ±40 noise lit
the faintest cells; rays that reached rock already dim dropped to 0 in 2–3 grains.
- `src/render/world.js`: unexplored and off-map are pure black (`OUT`, smooth-look buffers);
partial fog eases into black along a smoothstep, with noise scaled by s·(1−s). `src/sim/fog.js`:
sight into rock fades over 8 grains as a fraction of its brightness.
- `test/play.py`: the three unexplored-colour checks expect black (owner's decision).
`docs/systems/fog.md` updated. Screenshots `shots/fog-dig-black-{pixel,smooth,nolight}.png`.
- Tests: `run_all.py --browser` passed except `t_smooth` once at load 7 (passed on rerun).
## 2026-10-24: t17 follow-up: softer fog edges (Claude Code, Codex)
- The owner found the edges sharp and stepped. `src/sim/fog.js`: sense ring 24 grains with a
12-grain fade (`FOG_SENSE_FADE`), rays fade 8 grains into rock and over the last 16 of range,
surface fade 16, `fogRevealAround` gets a 6-grain soft edge. `src/render/world.js`: partly seen
grains get ±40 of per-grain noise (`noiseB`), so edges look grainy, not stepped.
- `test/t_fog.js`: a point in the fade band is between 0 and 128. `docs/systems/fog.md` updated.
Screenshots `shots/fog-edge-{before,after}{,-nolight}.png` compared.
- Tests: all pass except `t_smooth` half-resolution, which failed 1 of 3 runs with the change and
passed 2 of 2 without, at load 6–12; that check runs with fog off, so the drawing change does
not run there. Fog drawing 0.43 → 0.45 ms.
## 2026-10-24: t17 Fog of war reveals the ground around the bot (Claude Code, Codex)
- `src/sim/fog.js`: `FOG_SENSE = 20`; `fogLook` also reveals every fog cell within 20 grains,
through rock (full to 16, fading to 20). Claude rewrote Codex's per-grain `Math.hypot` loop to
work per fog cell. `fogLook` cost: 0.042 → 0.043 ms (every 4 ticks).
- `test/t_fog.js`: rock 12 grains away is seen, near the edge partly, 25 away still hidden.
- `docs/systems/fog.md`, DESIGN.md decision. Also: `.kandown/kandown_work.md` rule to write
tasks from chat in our own words (no owner labels, dates or quotes); t16/t19 descriptions
rewritten that way; `docs/kandown.md` notes that web-board descriptions were lost.
- Tests: `run_all.py --browser` all pass (220 `play.py` checks). `t_smooth` half-resolution failed
on two runs while the machine was loaded (load 6–10) and passed on the final run; timing
`fogLook` showed the change is not the cause. Screenshot `shots/fog-sense.png` checked.
## 2026-10-24: t19 Vacuum blow all visual (Claude Code, Codex)
- The owner's description was lost in kandown; chosen in chat: bag rows light up and the card
shows it while Shift is held. t18 was deleted at the owner's request (to decide later: what
blow does when the chosen material runs out).
- `hud.js` `blowingAll()` (Shift held and not flying, or blowing everything); `selection.js` card
("Everything · mix of N", no swatches so it stays one line) and bag `.on` rows; `previews.js`
cursor swatches.
- `test/play.py`: Shift preview on and off, flying doesn't preview (the bot is put back after,
or a later gamepad dig check fails). Tests: `run_all.py --browser` all pass (220 `play.py`
checks).
## 2026-10-24: t16 follow-up: Shift blows all, Ctrl+click in the bag, compact menu (Claude Code)
- The owner's subtask "shift to blow all" and two chat requests. Codex was out of credits, so
Claude made the edits.
- `input.js`/`actions.js`: `secondaryPress(shift)` sets `GAME.blowAll`; `hud.js` `blowAllList()`
(loose bag materials, no bars); `previews.js` shows it. `selection.js`: Ctrl+click a bag name
toggles blow, Ctrl+right-click toggles pick-up; the vacuum menu is one `.vrow` per material
(CSS in `style.css`). Text in the card, bag hint and `notebook.js`.
- `test/play.py`: Shift blow-all (replaces the old "Shift still blows only coal" check, per the
owner's change), Ctrl+click and Ctrl+right-click, plain right click does nothing, one line per
menu row at 1280 and 900.
- Tests: `run_all.py --browser` all pass (215 `play.py` checks). Screenshots checked.
## 2026-10-24: t16 Vacuum tool rework, again (Claude Code, Codex)
- The owner: too many modes; chose an F menu with two pick-up states.
- `src/ui/selection.js`: `openVacuumMenu()` (a tile per material with ⬇ Pick up / ⬆ Blow, plus
Pick up everything and Blow one at a time); `GAME.pickMode`, `setPickMode`, `cyclePickMode`,
`GAME.vacLocked` and the 'first' locking removed. Bag rows lose ⬇/⬆ buttons (a ⬇ tag shows
marks). `actions.js`: pick-up is everything unless marked; F with the vacuum opens the menu
when nothing in reach can be used. Card chips open the menu (`build-menu.js`). T and gamepad B
no longer change vacuum modes. Text in `notebook.js`, `progress.js`, `session.js` (Welcome),
`previews.js`; CSS `.vpick`.
- `test/play.py`: mode checks replaced (no marks takes everything, marks, Shift, menu toggles,
F order, card chip, no bag buttons, menu screenshots at 1280 and 900); `test/t_basics.js` name.
- Tests: `run_all.py --browser` all pass (208 `play.py` checks). Codex ran out of credits before
its final test run; Claude fixed one check (it mutated a mold's kind) and the suction line.
## 2026-10-24: t6 Rework the vacuum/blower tool, with t15 (Claude Code, Codex)
- The owner folded t15 (a material menu for the vacuum) into t6's chosen design.
- `src/ui/state.js`, `hud.js`, `actions.js`, `selection.js`: `GAME.pickMode` ('first', 'marked',
'all', saved in localStorage) and `GAME.blow` (a set) replace `dropMat`, `blowMix`,
`matChosen` and the Ticked mix. Pick-up marks stay in `GAME.collect` (same storage key).
- Bag rows with the vacuum: ⬇ pick-up toggle, name (blow only this), ⬆ blow toggle. Tool card:
a pick-up mode chip (T or click cycles) and the blow choice (Q/E). Gamepad button 1 cycles the
mode when nothing else uses it. Text updated in `info.js`, `notebook.js`, `progress.js` (the
copper hint), `src/sim/vacuum.js` (a status line), `docs/DESIGN.md`.
- `test/play.py`: modes, automatic switching, independent marks, mixes, persistence, toggles only
with the vacuum; `test/t_basics.js`: the new status. Screenshots checked at 1280 and 900.
- Tests: `run_all.py --browser` all pass (214 `play.py` checks).
## 2026-10-24: decisions for t1, t4, t6 (Claude Code)
- Options given in chat; the owner chose: t1 neat bar piles plus a Bar rack; t4 Grinder and Saw
blocks replace the Refiner and Stone cutter (old ones keep working); t6 the bag as the vacuum's
control panel with separate pick-up and blow marks. Recorded in `docs/DESIGN.md` and as
subtasks on each task. Nothing built yet.
## 2026-10-24: t8 Fog of war for unexplored areas (Claude Code, Codex)
- Decided with the owner: revealed by line of sight; unseen is black with a soft edge.
- New `src/sim/fog.js` (2×2-grain cells, 0-255, only ever rises): `fogInit` (sky and surface
seen), `fogLook` (256 rays, 48 grains, through air, gas and liquid, 4 grains into rock), called
every 4 ticks from `simTick`. Never read by the simulation.
- `src/sim/save.js`: fog saved; saves without it rebuild it (surface, 12 grains around built
things, the bot's view). `src/sim/world.js`, `worldgen.js`: allocate and initialize.
- `src/render/world.js`: grains scaled by a bilinear fog value; fully unseen grains drawn like
out-of-world grains; no glow from fog; thermal lens fogged too. `overlays.js`: rain and
particles hidden in fog. `src/ui/info.js`: "Unexplored". Setting `fog` (Game tab).
- Browser tests and tools turn fog off (`play.py`, `t_smooth.py`, `shot.py`, `perf.py`,
`playthrough.py`) except the fog checks. New `test/t_fog.js`; `docs/systems/fog.md`.
- Tests: all 12 headless suites, `play.py` (185 checks), `t_smooth` pass. Screenshots checked in
both looks. `tools/perf.py`: drawWorld 0.34 → 0.43 ms with fog on (no change with light off).
## 2026-10-24: t5 Material selection menu (Claude Code, Codex)
- Decided with the owner: F opens it for walls and molds, R keeps cycling.
- `src/ui/selection.js`: `openWallPicker` (a dialog of every wall material: what you have, the
cost for this piece, the heat it stands; owned first, unaffordable dimmed; glass says whether
it is from shards or bars); `pieceCostNow(P, m)` takes a material.
- `src/ui/actions.js`: F with a wall or mold piece opens it. `src/ui/build-menu.js`: the material
chip reads "· R · F" and opens it on click; placement help mentions F. `src/ui/notebook.js`:
Controls and Blueprints text. `src/styles/style.css`: `.wpick`.
- `test/play.py`: F and the chip open it, Brick picks RBRICK, R still cycles, molds have their own
menu and cost, it fits at 1280×800 and 900×700 (screenshots checked).
- Tests: `run_all.py --browser` pass, apart from the `t_smooth` half-resolution flake.
## 2026-10-24: t10 World name box for a new world (Claude Code, Codex)
- `src/shell.html`, `src/styles/style.css`: a Name box across the top of the New world panel.
- `src/ui/loop.js`: opening the panel fills it with the next `World N` and selects it; Enter in
Name or Seed generates. `src/ui/session.js`: `startNewWorld(seed, w, h, name)`.
- `src/ui/saves.js`: `cleanWorldName` (trim, 40 characters, else a fallback), shared with
`renameWorld`.
- `test/play.py`: a typed name reaches the Worlds list and the pause menu; the default and an
all-spaces name both give `World N`. Screenshot `shots/play/new-world.png` checked.
- Tests: `run_all.py --browser` all pass.
## 2026-10-24: t7 Option to turn off smooth pan following the mouse (Claude Code, Codex)
- New setting `cameraLead` (Display: "Camera looks toward the pointer", on by default):
`src/ui/state.js`, `src/ui/settings.js`; `src/ui/loop.js` gives the camera no look-ahead when
it is off (mouse and gamepad).
- `test/play.py`: the toggle saves; with it off the camera stays on the bot with the mouse in a
corner; with it on the camera moves toward the corner.
- Tests: headless suites and `play.py` pass; `t_smooth` half-resolution flake as before.
## 2026-10-24: t9 Script to run the local server for testing (Claude Code, Codex)
- New `tools/serve.py`: builds, serves `dist/` on 127.0.0.1 (port 8000, or the next free one up
to 8010), sends `Cache-Control: no-store`, and rebuilds when `src/` or `build.py` changes; a
failed build is printed and the old page keeps being served.
- `README.md`, `AGENTS.md`, `build.py` docstring, `docs/PROJECT_MAP.md`: point at it.
- Checked by hand: page served with no-store; touching a source file rebuilt it; with the owner's
old server on 8000 it moved to 8001; Ctrl+C stops it. Headless suites and map check pass.
## 2026-10-24: t11 Welcome and first milestone shown again on an older world (Claude Code, Codex)
- Cause: nothing cleared the discovery cards (`#cards`) when a world was entered, so a new
world's Welcome and milestone cards stayed up after loading an older world.
- `src/ui/session.js`: `enterGame` clears the cards, floaters, particles and the status line;
a new world still shows its Welcome card afterwards.
- `test/play.py`: a new world shows Welcome; loading a world from the list shows no cards.
- Tests: `run_all.py --browser` passes (153 `play.py` checks). `t_smooth`'s half-resolution
check flaked (passed and failed on reruns, with and without this change; the machine was busy).
## 2026-10-24: the to-do list moved from NOTES.md to a kandown board (Claude Code)
Request: initialize kandown (https://kandown.dev, 0.52.0) for the project, move all the notes
into it, and document how it works.
- Practised first in a scratch folder: `init --no-open` starts no daemon and makes no sample tasks
when `tasks/` exists; `create` prints only the id and names the file `<id>_<slug>.md`; a
multi-line `report: |` block is read (checked in kandown's parser) and `doctor` accepts it;
`list --json` doesn't include `report`; project instructions in `.kandown/kandown_work.md`
appear in `kandown work` (~1,100 tokens).
- A mistake on the way: `kandown init --help` ran `init` in the vault (it ignores `--help`) and
started a daemon there. Stopped it, and removed the vault's earlier kandown test files as the
owner asked. `docs/kandown.md` warns about it.
- `kandown init --no-open` in the repo. `.kandown/kandown.json`: default columns, Review's
instructions say only the owner moves work to Done, tags and priority shown. `.gitignore`:
`.kandown/kandown.html` (6.5 MB, regenerated).
- 14 notes became tasks t1-t14 (Todo: 6 tasks; Backlog: 5 tagged `discussion`, 3 tagged
`idea`), each with the owner's note word for word under `## Context` (checked by script: 14 of
14). `kandown doctor` is clean.
- `.kandown/kandown_work.md` holds the board rules (from AGENTS.md's old NOTES section);
`docs/kandown.md` explains how kandown works here. `AGENTS.md` (session start, a new "Working
from the task board" section linking both), `CLAUDE.md`, `README.md`, `docs/PROJECT_MAP.md`,
`docs/DESIGN.md` (decision). `NOTES.md` removed.
- `kandown list` and `kandown doctor` checked inside Codex's sandbox: they work.
- Open: the web board (`kandown`) wasn't opened here; the owner uses it.
## 2026-10-24: several worlds, with a Worlds list (Claude Code, with Codex)
Note: "support multiple saves". The owner chose a world list.
- `src/ui/saves.js`: each world has an id and its own `<id>:main` / `<id>:auto` saves, plus an
index record `worlds` (name, seed, size, created, saved, playtime). A save writes the world
and its index entry in one transaction. `GAME.worldId` is the open world.
- Migration: an old single `main` / `auto` save is copied to "World 1", every byte compared,
and only then are the old keys deleted (in the same transaction that writes the index). If
anything fails the old save is kept and a toast says so.
- Title screen: Continue loads the most recently saved world; a Worlds button opens a list
with Load, Rename (trimmed, at most 40 characters, escaped when shown) and Delete (with a
confirmation). New and imported worlds get new ids. Settings' "Delete saves" became "Delete
all worlds" with a confirmation. `shell.html`, `session.js`, `loop.js`, `settings.js`,
`state.js`, `style.css`, `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py` (149 checks): migration of an old save, two worlds listed, load switches,
rename survives a reload, HTML in a name shows as text, long names cut to 40, delete and
cancel, Continue picks the newest, import adds a world. All 11 headless suites pass;
`t_smooth.py` passed in Codex's run and failed its known flaky check in mine.
- Shots: `shots/worlds-{pixel,smooth}.png` and `-900` versions.
- Not committed: a line in `AGENTS.md` about running `kandown work` appeared in the working
tree during this task; it was not part of the task and is left for the owner.
- Open: nothing.
## 2026-10-24: screw tubes carry bars (Claude Code, with Codex)
Note: "how do you transport bars? belts work but tubes do not, should they?" The owner chose
tubes over a separate bar lift.
- `src/sim/items.js`: a bar whose eight cells are all tube cells is in the tube (derived from
its position, so saves need nothing new). On the tube beat it steps along the tube, snaps
into the next block's lane at corners, and leaves a mouth with a launch like grains. A bar
pushed by a belt into a tube's side or back, falling into a down tube or sitting on an up
tube's intake enters when the aligned cells are free. Grains and bars wait for each other.
Gas and liquid don't block a bar (Claude added this after review: smoke near furnaces stalled
bars in Codex's version).
- Text: `pieces.js` tube description, `notebook.js`, `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- Tests: `t_conveyors` (55 checks): belt into a lift and out the top, right tube, up-to-right
and right-to-down corners, blocked mouth waits without overlap, grains and a bar share a
lift with sand conserved, save mid-tube, smoke and steam in the tube, melting in a tube,
bars still ride and stop on belts and rest on top of sideways tubes. All 11 headless suites
and `play.py` (131) pass; `t_smooth.py`'s half-resolution check failed on the slow fallback
(known flaky).
- Shots: `tools/scenes/bar-lift.js`, `shots/bar-lift-{300,900}.png`: mold, belt, lift, corner,
belt; the bars arrive at the top.
- Open: nothing.
## 2026-10-23: screw tubes use all their lanes (Claude Code, with Codex)
Note: "maybe the belts can move faster/carry more than the tubes… the tubes only carry one line of
materials on either side." The owner chose to fill every lane; belt speed is unchanged.
- Measured first (`test/t_conveyors.js`, a belt with a sand heap into the side of a 6-block up
tube, 3,000 ticks): 1,459 grains out, lanes `[2940, 0, 0, 10]`; the edge lane was full (one
lane carries at most ~1,500). Spreading only at intake changed little (1,486): belts push
straight into the edge lane without going through `tubeIntake`.
- `src/sim/machine-tick.js`: on each tube beat a grain in a vertical tube may step sideways
within its block to a lane with clearly fewer grains ahead (a strict gap stops shuffling).
`src/sim/conveyors.js`: `tubeSpread`, shared by side intake (far lanes first) and corners.
- After: 2,071 out (92% of the 2,243 the same belt delivers to an open drop), lanes
`[1937, 1116, 486, 872]`, sand conserved. Checks: all four lanes, conservation, at least 90%
of the belt's delivery, a side-fed sideways tube fills more than one row. All 11 headless
suites and `play.py` pass. `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- Found on the way: Codex's sandbox (`workspace-write`) blocks network sockets, and headless
Chrome crashes at startup without one (SIGTRAP core dumps in pairs). Codex runs now pass
`-c sandbox_workspace_write.network_access=true`.
- Open: nothing.
## 2026-10-23: the build card describes the build tool, not the piece (Claude Code)
Note: "instead of the current info box for the build tool showing the current building selected,
it should just show a description about the build tool in general." The owner kept the R/F chips.
- `src/ui/selection.js`: the build card reads "Build", then the R/F chips, Replace · G and
ghosts, a line on placing, the build keys and the status line. No piece name or cost.
- `test/play.py`: the card shows "Build" and the placing text, not the piece's name, cost or
description; the other card checks are unchanged. `play.py` and all 11 headless suites pass.
- Shots: `shots/build-card-general-{pixel,smooth}.png`. `docs/DESIGN.md`.
- Open: nothing.
## 2026-10-23: world sounds are heard around the bot, not the view (Claude Code)
Note: "the sound effect is still heard when the player's zoom level is farther out… sounds
shouldnt be heard based on the viewport, but rather in a radius from the player."
- `src/ui/audio.js` `sfxPlace`: gain from the distance to the bot's centre (full within 24
grains, silent at 96), pan from the horizontal offset; zoom no longer matters.
- `test/play.py`: the audio checks use points relative to the bot; a new check zooms out to 2
and plays a visible sizzle 200 grains away: silent (it played before). `play.py` and all 11
headless suites pass. `docs/DESIGN.md`.
- Open: nothing.
## 2026-10-23: F on an ingot mold does nothing and says nothing (Claude Code)
Note: "now that ingot molds empty themselves, F still makes a message…"
- `src/sim/actions.js` `interactAt` passes ingot molds by, so F reaches anything else nearby;
`src/ui/actions.js` shows no "Nothing to use there." when the cursor is on an ingot mold.
- Tests: `t_molds` (F on an ingot mold in every state returns nothing, changes nothing, no
sound) and `play.py` (F over an ingot mold leaves no status and no toast). All 11 headless
suites and `play.py` pass.
- Open: nothing.
## 2026-10-23: pixel-art silo for the pixel look (Claude Code, with Codex)
Note: "the silo art looks the same for both pixelated graphiocs and smooth."
- `src/render/machines.js`: `drawSilo` is the smooth (illustrated) version as before; in the
pixel look `drawSiloPixel` draws it grain by grain: a drum shaded by column with rib rows and
ladder rungs, a cone to the slide gate, legs, the roof hatch with its lid, and the sight glass
filling in the contents colour. Codex's first version read poorly (dark side columns made
the drum look narrow); Claude redrew the grain layout.
- `tools/scenes/silo.js` (run after `clear-sky.js`): three silos, empty, half sand, nearly full
copper ore with the gate open. Shots `shots/silo-*.png` at zoom 6 and 12; smooth shots match
the before shots pixel for pixel. `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- Tests: all 11 headless suites and `play.py` (129) pass. `t_smooth.py`'s half-resolution check
failed twice on the slow fallback at load ~5 (known flaky; it passed in the previous task).
- Open: nothing.
## 2026-10-23: world sounds are local (Claude Code, with Codex)
Note: "there is a sound effect that seems to happen randomly but constantly… maybe the sound
effects are not local?" The owner then confirmed a new world is quiet.
- Measured headlessly over 5 game minutes: a fresh world emits no sounds; a lava and water
contact 720+ grains from the bot emitted 2,889 sizzles (about 580 a minute), and `events.js`
played every one at full volume. The sizzle (`heat.js` boiling and quenching) is the only sim
sound with a position.
- `src/ui/audio.js`: `sfxPlace(x, y)` gives gain (1 in view, 0 one view width beyond it) and
pan; `playSfx(name, arg, x, y)` skips silent ones before the rate limit and plays the rest
through a gain and stereo panner (`AU.bus`). `src/ui/events.js` passes positions.
`docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py`: far off-screen is silent, on screen plays, just off-screen is quieter and
panned. All 11 headless suites, `play.py` and `t_smooth.py` pass.
- Checked afterwards with the owner's exported world (seed 622525): over one minute, 48 sizzles
and no other sounds, all from water boiling on naturally hot deep rock (a pool on 580°C basalt
and drips in a shaft and a cave, about 500 grains below the bot). Nothing built there. With
the fix every one of them is silent from the surface.
## 2026-10-23: fewer, smaller messages (Claude Code, with Codex)
Note: "lets change the engot mold and other status messages. they are way too large and just too
many messages…". The owner chose molds, machine warnings and shorter status lines.
- `src/render/overlays.js`: every mold always shows a small percentage (`moldLabel`), gold
`100% · F` for a set bit or tank, red when spoiled or when an ingot mold is blocked
(`mo.blocked`, set in `ejectIngot`, not saved). Details are in the hover panel (`info.js`),
which also carries the furnace-loading advice that F messages used to append.
- `src/ui/events.js`: no cards or toasts for bars dropping, blocked molds or set castings (the
sounds stay). Fan heat is a red ! marker on the fan (`mm.hot`, hover shows °C); melted tubes
and machines destroyed by heat get a short red floating label (`warn` event, coloured
`spawnFloater`) instead of a toast.
- Status lines shortened to one sentence across `src/sim` (molds, actions, blocks, blueprints,
drill, items, machines, tools, undo, vacuum) and `src/ui` (actions, selection).
- Tests: `play.py` records every status line and checks none is over 90 characters (longest
now 55); mold label and no-toast checks. `t_molds` (message lengths, `mo.blocked`), `t_build`
(hot fan: `mm.hot`, no toast), `t_world`. All 11 headless suites and `play.py` pass.
- Shots: `shots/messages-pixel.png`, `shots/messages-smooth.png` (filling, blocked, set bit,
hot fan).
- Open: the first-ever bar drop no longer shows an explaining card; the notebook still explains.
## 2026-10-23: ingot molds need no F; removing a mold returns its metal (Claude Code, with Codex)
Note: "now that the ingot molds drop the ingots, what is the purpose of F…". The owner chose no F
on ingot molds.
- `src/sim/molds.js` `ejectIngot`: when nothing over or in the mold is molten, junk and mixed
metal (as scrap) and plugs over the trough or frozen in the grate are written as loose grains
into the 4 rows under the mold; what doesn't fit waits. Slag and ash are thrown away. After a
drop or a clean-out the grate above is raked. Bars and the short stub work as before.
- `collectMold` on an ingot mold only says "Ingot molds empty themselves." Bit and tank molds
keep F. `removeMold` (every kind) gives the set metal to the bag: whole bars as bars, the rest
as scrap; molten metal is left alone.
- Text: `troughState` flaws, `info.js`, `overlays.js` (no "· F" on ingots), `pieces.js`,
`progress.js`, `notebook.js`, `docs/systems/stone-and-liquids.md`, `docs/DESIGN.md`.
- Tests: `t_molds` 60 checks (F does nothing on ingots; plate, slag and rake cases now
automatic; junk out, mixed metal out as scrap, junk waits when blocked and while molten;
removal returns bars and scrap, leaves molten metal). `furnace_play.js` adapted. All 11
headless suites and `play.py` (122) pass.
- Open: undoing a mold removal brings the mold back empty (its metal stays in the bag).
## 2026-10-23: compact build card top-left (Claude Code, with Codex)
Note: "build tool has no info box like the other tools." The owner chose a compact card.
- `src/ui/selection.js`: the build tool has a card again: Q, piece name, cost (red if short), E;
variant chips, Replace · G and ghosts waiting; one how-to line; the status line. No long
description; that stays in the hover popup.
- `src/ui/build-menu.js`: the `.bb-status` row is gone (its contents are on the card); the
variant chips are shared through `buildVariantChips(P)`. `src/styles/style.css`,
`docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py`: the card is visible while building with name and cost and without the
description; status and Replace · G are read from the card; no `.bb-status` (122 checks pass).
All 11 headless suites pass; `t_smooth.py`'s half-resolution check is still flaky under load.
- Shots: `shots/build-card-*` (belt and wall, both looks, 1280×800 and 900×700) look right.
- Open: at 900px wide the bottom-left hover panel still sits partly under the build bar.
## 2026-10-23: Replace is held G, not Ctrl (Claude Code, with Codex)
Note: "i see the issue was ctrl+w closing the tab… it happens for the bookmark hotkey too. maybe
change the replace key?" The owner chose holding G.
- `src/ui/input.js`: `GAME.ctrl` became `GAME.replace`, set while G is held. Ctrl no longer
does anything on its own, so WASD works while replacing. Ctrl+S, Ctrl+Z, Ctrl+wheel and the
leave-page prompt are unchanged.
- Flag users and text: `previews.js`, `actions.js` ("Hold G to replace it."), `build-menu.js`
(Replace · G chip, key hints), `loop.js`, `state.js`, `notebook.js` controls, `docs/DESIGN.md`.
- `test/play.py`: the replace checks hold G; a new check flies right with D while G is held
(121 checks pass). All 11 headless suites pass. `t_smooth.py`'s half-resolution check failed
on the slow fallback again (known, flaky under load).
- Open: nothing.
## 2026-10-23: Ctrl+W no longer closes the game without asking (Claude Code)
Note: "crash: hoplding crtl and pressing W crashes the whole browser."
- Cause: Ctrl+W is the browser's close-tab shortcut (the whole window if it is the last tab);
holding Ctrl for replace while pressing W to fly hits it. Chrome and Brave reserve it, so the
page never sees the key and cannot cancel it.
- `src/ui/input.js`: a `beforeunload` handler makes the browser ask before leaving while
`GAME.running`. The `visibilitychange` autosave no longer depends on the audio context
existing (it used to return early before any sound had played).
- `test/play.py`: 2 checks (leaving asks while a world is open, not on the title screen); 120
checks pass. All 11 headless suites pass; `t_smooth.py` passed on its rerun (the known flaky
half-resolution check failed once at load ~5).
- Open: the real Ctrl+W prompt can only be seen in a headed browser; the owner checks it.
## 2026-10-23: build menu hover popup, top-left card hidden while building (Claude Code, with Codex)
Notes: "mouse hover reviels a new info popup…" and "dont need the short description in the build
menu…". The owner chose to hide the top-left card entirely while building.
- `src/ui/build-menu.js`: `#bb-pop` above the hovered tile (icon, name, cost, size, power, full
description, how to place, filter help, build keys), clamped to the window; on a gamepad it
shows for the selected tile. The `.bb-tip` row became `.bb-status`: variant (material,
direction, filter) chips, Replace · Ctrl, ghosts waiting, and status messages. The bar now
updates its parts separately so hovering doesn't rebuild the tiles.
- `src/ui/selection.js`: `#selcard` is hidden for the build tool unless copying, cutting or
pasting; the build branch moved to the bar. `src/styles/style.css`: popup and status styles;
on narrow screens the bottom-right panel moves up while building.
- `src/ui/notebook.js`, `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- Tests: `play.py` 118 checks (popup on hover, switching tiles, hidden on leave, gamepad,
no `.bb-tip`, card hidden while building and back for tools 1 and 2, status in the bar; the
Ctrl replace check now reads the bar). All 11 headless suites pass. `t_smooth.py`'s
half-resolution check is still flaky under load (~4.8): 1 pass in 3 runs, as before.
- Screenshots in `shots/` (build-popup-*) at 1280×800 and 900×700 in both looks.
- Open: at 900px wide the bottom-left hover-info panel sits partly under the build bar.
## 2026-10-23: a backed-up heap on a belt no longer shears (Claude Code, with Codex)
Note: "belts with a big pile on top only move the grains on the top of the stack…". The owner's
setup: a belt ending in a screw tube going up; material mixed into the bottom layers never moved.
- Cause: `carryColumn` (`src/sim/conveyors.js`) skipped a blocked grain and carried on up the
column, so with the front backed up the top slid over a stuck bottom. Reproduced headlessly
first: over 4,000 ticks, 8,815 shear steps at the tube (1,415 against a wall) and only 57 of
192 bottom sand grains left the belt.
- Fix: on a belt, the walk stops at the first blocked powder grain, so the load above stands
still. On a filter the walk goes on only so chosen materials can still drop through; nothing
above a blocked grain is carried. After: no shear, 119 of 192 sand grains out through the tube.
- `test/t_conveyors.js`: 3 new checks (no shear into an up tube, none against a wall, bottom
layer leaves through the tube). `tools/scenes/belt-heap.js` sets up the owner's case for
`tools/shot.py`; shots at 30 and 900 ticks look right. `docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- Tests: all 11 headless suites and `play.py` (109) pass. `t_smooth.py` failed its
half-resolution check once at load 6.5 and passed on the rerun.
- Open: `t_smooth.py`'s half-resolution check is flaky on a busy machine (the game drops to
pixel when frames are slow). It is worth making that test immune to the slow fallback.
## 2026-10-23: ingots drop out of the bottom of the mold (Claude Code, with Codex)
Note: "copper bars go into the furnace. also they shouldnt require the player to press F…".
The owner confirmed the first part was a bug: the knock-out threw bars up into the furnace.
- `src/sim/molds.js`: `ejectIngot` runs from `checkMolds` (every 15 ticks). Bars spawn directly
under the mold, only where `entFreeAt` says there is room; the rest wait in the trough. Metal
is taken from the top so the short stub stays on the floor. `ingot_drop` and `ingot_blocked`
events (blocked is said once per spell via `mo.blockedSaid`, not saved). F on a ready ingot
never launches bars upward; F on an empty, short or spoiled mold still cleans and rakes.
- `src/ui/events.js`: first drop gets a card, later drops a toast; blocked gets a toast. Ingots
no longer emit `cast_ready`.
- Text: `pieces.js`, `progress.js`, `notebook.js`, `info.js`, `docs/DESIGN.md` (with the
decision), `docs/systems/stone-and-liquids.md`, `docs/PROJECT_MAP.md`.
- Tests: `t_molds` (38 checks: downward drops, stub kept, top-up, blocked and warned once,
partial drops, full entity pool, slag), `t_basics`, `t_stone`, `furnace_play.js` and
`play.py` section 6 changed to the new behaviour. All 11 headless suites, `play.py` (109) and
`t_smooth.py` (8) pass. A shot of a mold with a belt one block below shows the bars riding off.
- Open: a belt touching the mold's underside counts as blocked; bars need one open block. The
owner may want a belt directly under the mold to work.
## 2026-10-23: Shift+Q/E build tabs, G pick key removed (Claude Code, with Codex)
Notes: "add shift + q/e to move tabs" and "having the "g pick" there is confusing".
- `src/ui/build-menu.js`: `selectBuildCat(c)` (shared with tab clicks) and `cycleBuildCat(d)`;
the menu hint now reads Q/E cycle · Shift+Q/E tab.
- `src/ui/input.js`: Shift+Q/E switch tabs with the build tool; G is unbound.
- Text: build card (`selection.js`), notebook controls (`notebook.js`), `actions.js` comment,
`docs/DESIGN.md`, `docs/PROJECT_MAP.md` (regenerated, `--check` passes).
- `test/play.py`: checks for Shift+Q/E, wrapping, the remembered piece per tab, plain Q/E,
and that G no longer calls pick while middle mouse still does (109 checks).
- Tests: all 11 headless suites and `play.py` pass; a `tools/shot.py` shot of the Moving tab
shows the new hint. `t_smooth.py` passes, but its "half-resolution smooth still draws" check
failed on several runs with the 'slow' pixel fallback while the machine was busy (load ~14).
The previous commit fails the same way under the same load, so this change didn't cause it.
- Open: that check is timing-sensitive on a loaded machine; rerun it when the machine is idle.
## 2026-10-23: browser tests run locally (Claude Code, with Codex)
Request: get the browser tests, Playwright and Chromium working on the owner's Arch machine
(Python 3.14 refuses a system-wide pip install).
- Playwright 1.64.1 and its Chromium 156 (revision 1248) are in an in-repo `.venv/` made with uv.
- Added `tools/use_venv.py` and called it before Playwright imports in `tools/shot.py`,
`tools/perf.py`, `tools/playthrough.py`, `test/play.py` and `test/t_smooth.py`.
The `sys.prefix` guard prevents an exec loop if the venv lacks Playwright.
- Updated `requirements.txt`, `.gitignore`, `README.md`, `AGENTS.md`, `docs/TESTING.md`
and `docs/PROJECT_MAP.md` for Playwright 1.64.1 and venv setup.
- Checks: regenerated the project map and ran `tools/map.py --check`.
- Tests run with plain `python3`: `test/run_all.py --browser` all passed (11 headless suites,
`play.py` 101 checks, `t_smooth.py` 8 checks) on the newer Chromium; `tools/shot.py` shots
of the stone-uses scene look right in both looks. With `.venv` moved away, `play.py` exits
with the setup message instead of a traceback.
- Open: nothing.
## 2026-10-23: export from claude.ai to a local repository (Claude, in claude.ai) ## 2026-10-23: export from claude.ai to a local repository (Claude, in claude.ai)
Request: split the source into small files, deduplicate and tidy a little, test fully, and Request: split the source into small files, deduplicate and tidy a little, test fully, and
+107
View File
@@ -0,0 +1,107 @@
# kandown: the task board
The owner's to-do list is a [kandown](https://kandown.dev) board: one Markdown file per task in
`tasks/`, with settings in `.kandown/`. It replaced `NOTES.md` on 2026-10-24. This page records
how it works here, as checked on kandown 0.52.0. The full upstream documentation is one file at
https://kandown.dev/llms-full.txt (any docs page also works with `.md` added to its URL).
## Start of a session
```sh
kandown work # the rules, our project instructions and the board, as Markdown (~1,900 tokens
# with 14 tasks)
kandown work t8 # the same, focused on one task
```
Our project rules are in `.kandown/kandown_work.md`; `kandown work` includes them under
"Project Instructions". `AGENTS.md` ends with the one-line pointer `kandown init` added; keep it.
## Columns
| Column | Role | What goes here |
|---|---|---|
| Backlog | backlog | Discussion notes (tag `discussion`) and ideas for later (tag `idea`) |
| Todo | ready | The owner's tasks |
| In Progress | active | What an agent is working on now |
| Review | review | Finished work waiting for the owner to check in the game ("done, please check") |
| Done | terminal | Checked by the owner. Only the owner moves tasks here |
## Commands
Task commands work offline and print data only on stdout (messages go to stderr), so they are
safe to capture. Set `KANDOWN_NO_UPDATE=1` in scripts.
```sh
kandown list # the board as a table; --json for data (no report field in it)
kandown show t8 # one task file as it is on disk
ID=$(kandown create "Title" --to Todo -t discussion) # prints only the new id, e.g. t15
kandown move t8 "In Progress" # quote column names with spaces; "archived" archives
kandown doctor # checks kandown.json and every task's frontmatter
kandown # the owner's board: web page plus terminal UI (starts a daemon)
kandown daemon stop # stop that daemon
```
- **`--help` is not safe.** `kandown init --help` ignored the flag and ran `init` in the current
folder, which also started a daemon. Use `kandown help` or the docs, never `<command> --help`.
- `kandown init` starts the web daemon unless given `--no-open`. With an existing empty `tasks/`
it created no sample tasks.
- `kandown create` names the file from the id and the title: `tasks/t8_fog_war_unexplored.md`.
Find it with `tasks/<id>_*.md`; don't rename it.
- `kandown create` has no body option: write the body into the file after creating it.
- `depends_on` blocks a move to Done until the listed tasks are done.
- **Descriptions typed on the web board can be lost.** On 2026-10-24 the owner created t16–t19
on the web board with descriptions; the files were saved with a title and no body. The owner
believes it is a bug in the web daemon (not investigated). A task with only a title may have
had a description: ask the owner, then write their words under `## Context` yourself.
## A task file
```markdown
---
id: t8
title: Fog of war for unexplored areas
status: Review
created: 2026-10-24
updated: 2026-10-24T04:41:46Z
tags: [discussion]
report: |
(agent) What changed, in a few lines.
To check: how the owner sees it in the game.
---
## Context
- The owner's note, word for word.
## Subtasks
- [x] A step
report: What actually happened.
```
- Only `id`, `title` and `status` are required. Unknown keys are kept.
- **`report`** is the completion summary the board shows prominently. A multi-line `report: |`
block works (kandown's parser reads block scalars); `kandown doctor` accepts it.
- `## Context` holds why the task exists: here, the owner's own words. `## Subtasks` is a
checklist whose items can carry a `report:` line.
- Editing files directly is supported; the board notices changes. Run `kandown doctor` after.
## Files
| Path | Committed | What it is |
|---|---|---|
| `tasks/*.md`, `tasks/archive/` | yes | The tasks; the folder is the whole state, no index |
| `.kandown/kandown.json` | yes | Columns, column instructions, workflow, which fields show |
| `.kandown/kandown_work.md` | yes | Our project rules for agents |
| `.kandown/agents.json`, `README.md`, `.gitignore` | yes | Written by `kandown init` |
| `.kandown/kandown.html` | no (`.gitignore`) | The 6.5 MB web app; `kandown init` / `kandown update` rebuild it |
| `.kandown/daemon.json` | no (`.kandown/.gitignore`) | The running daemon's port and access token |
## Codex and kandown
`kandown list` and `kandown doctor` were checked inside Codex's `workspace-write` sandbox and
work; the other task commands also only read and write files in the repo. Not checked under
Codex: the web daemon (`kandown`
with no command), which serves a page on a local port. Codex's `workspace-write` sandbox blocks
network sockets (it is why headless Chrome crashed under Codex until runs passed
`-c sandbox_workspace_write.network_access=true`).
+41
View File
@@ -0,0 +1,41 @@
# Fog of war
Unexplored ground is black with a soft edge. The sky and the first few grains of the
surface start seen. Exploration never changes the simulation.
## Data and revealing
`src/sim/fog.js` keeps a `Uint8Array` at half the grain resolution (`FOG = 2`).
Values run from 0 (unseen) to 255 (seen) and only increase. `alloc` allocates it with
the other world arrays. `fogInit` reveals above `surfY` and fades 16 grains below it.
Every four ticks, 256 rays from the bot's centre reveal up to 48 grains. The last 16
grains fade out. Solid and powder stop sight after an eight-grain fade into their face;
air, gas and liquid pass sight. Bars and machine voids do not block it.
Each look also senses the ground around the bot through rock: every fog cell within
`FOG_SENSE` (24 grains) of the bot is revealed, fully clear out to 12 grains and fading over
the outer 12 grains to nothing at 24, so the player can see ore and caves next to their tunnel
and choose where to dig.
## Saving
The fog buffer is saved and exported with the other typed arrays, without a save version
change. Older saves rebuild the surface, reveal a radius of 12 grains around built blocks,
machine footprints and molds, fully clear inside six grains with a six-grain fade to the
radius edge, then look from the bot. Loading and generating also look
immediately from the bot.
## Drawing and setting
Both looks and the thermal lens sample fog bilinearly. Partly seen grains darken toward black
along a smoothstep curve, with stable per-grain noise (up to about ±38 mid-fade, none at either
end) to break up the half-resolution steps. Fully seen grains use the usual drawing; unseen
grains are pure black (as is everything off the map) in both looks, hiding their shapes.
Partial values dim foreground, background and trees. Bloom sources need at least 128;
bars, flyers, rain and particles need at least 64. Hover info and the cursor label say
only “Unexplored” below 64.
Game settings → Fog of war is on by default. Turning it off shows the whole world while
exploration continues to be recorded and saved. Browser scenes and performance tools turn
it off in their shared setup; dedicated fog checks enable it explicitly.
+4 -1
View File
@@ -70,7 +70,10 @@ through another refiner.
## Uses for stone and sand ## Uses for stone and sand
- **Glass bricks.** - **Glass bricks.**
- Sand melts to molten glass in a fan furnace (peak about 2000°C measured). - Sand melts to molten glass in a fan furnace (peak about 2000°C measured).
- Cast in an Ingot mold, it knocks out as glass bricks, worth $14 each. - Cast in an Ingot mold, it drops out of the bottom as glass bricks, worth $14 each.
- Ingot molds empty themselves: bars, junk and mixed scrap drop below; slag is thrown away
and the grate above is raked. Removing any mold returns its set metal to the bag: whole
bars as bars, the rest as scrap.
- Glass bricks build glass wall blocks. Glass is too brittle for a bit mold. - Glass bricks build glass wall blocks. Glass is too brittle for a bit mold.
- **Stone cutter** (machine, 2×2, $90). - **Stone cutter** (machine, 2×2, $90).
- A saw under a hopper. Every 8 grains of stone rubble become one stone brick, which drops out - A saw under a hopper. Every 8 grains of stone rubble become one stone brick, which drops out
+1 -1
View File
@@ -1,2 +1,2 @@
# browser tests and screenshots (test/play.py, test/t_smooth.py, tools/shot.py) # browser tests and screenshots (test/play.py, test/t_smooth.py, tools/shot.py)
playwright==1.56.0 playwright==1.64.1
+13
View File
@@ -6,14 +6,17 @@
"sim": [ "sim": [
"sim/base.js", "sim/base.js",
"sim/materials.js", "sim/materials.js",
"sim/material-tables.js",
"sim/pieces.js", "sim/pieces.js",
"sim/economy.js", "sim/economy.js",
"sim/progress.js", "sim/progress.js",
"sim/world.js", "sim/world.js",
"sim/fog.js",
"sim/cells.js", "sim/cells.js",
"sim/heat.js", "sim/heat.js",
"sim/machines.js", "sim/machines.js",
"sim/processors.js", "sim/processors.js",
"sim/racks.js",
"sim/pipes.js", "sim/pipes.js",
"sim/machine-tick.js", "sim/machine-tick.js",
"sim/conveyors.js", "sim/conveyors.js",
@@ -31,6 +34,7 @@
"sim/ledger.js", "sim/ledger.js",
"sim/weather.js", "sim/weather.js",
"sim/tick.js", "sim/tick.js",
"sim/worldgen-helpers.js",
"sim/worldgen.js", "sim/worldgen.js",
"sim/save.js" "sim/save.js"
], ],
@@ -41,22 +45,31 @@
"render/machines.js", "render/machines.js",
"render/overlays.js", "render/overlays.js",
"render/smooth.js", "render/smooth.js",
"render/smooth-shaders.js",
"ui/audio.js", "ui/audio.js",
"ui/state.js", "ui/state.js",
"ui/title.js", "ui/title.js",
"ui/saves.js", "ui/saves.js",
"ui/claims.js",
"ui/session.js", "ui/session.js",
"ui/settings.js", "ui/settings.js",
"ui/notebook.js", "ui/notebook.js",
"ui/notebook-market.js",
"ui/notebook-blueprints.js",
"ui/hud.js", "ui/hud.js",
"ui/build-menu.js", "ui/build-menu.js",
"ui/selection.js", "ui/selection.js",
"ui/selection-pickers.js",
"ui/selection-panels.js",
"ui/info.js", "ui/info.js",
"ui/input.js", "ui/input.js",
"ui/actions.js", "ui/actions.js",
"ui/gamepad.js", "ui/gamepad.js",
"ui/previews.js", "ui/previews.js",
"ui/preview-drill.js",
"ui/preview-selection.js",
"ui/events.js", "ui/events.js",
"ui/map.js",
"ui/loop.js" "ui/loop.js"
] ]
} }
+116 -26
View File
@@ -1,4 +1,4 @@
// Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world. // Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
// ---------- Overlays ---------- // ---------- Overlays ----------
function cellRect(x, y, w, h) { const p = worldToScreenPx(x, y); return [p.x, p.y, w * R.zp, h * R.zp]; } function cellRect(x, y, w, h) { const p = worldToScreenPx(x, y); return [p.x, p.y, w * R.zp, h * R.zp]; }
// Yellow and black hazard stripes on both top corners of a hopper // Yellow and black hazard stripes on both top corners of a hopper
@@ -22,7 +22,7 @@ function drawMachines() {
ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp); ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp);
ctx.fillStyle = 'rgba(150,160,170,0.8)'; ctx.fillStyle = 'rgba(150,160,170,0.8)';
for (let k = 0; k < 20; k += 2) ctx.fillRect(sx + (2 + k) * zp, sy + 13 * zp, zp * 0.5, zp); for (let k = 0; k < 20; k += 2) ctx.fillRect(sx + (2 + k) * zp, sy + 13 * zp, zp * 0.5, zp);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; renderMonoFont();
const fs = Math.max(9, Math.round(zp * 2.6)); const fs = Math.max(9, Math.round(zp * 2.6));
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.font = `700 ${fs}px ${R.monoFont}`;
const label = 'TRADE DOCK', tw = ctx.measureText(label).width + fs; const label = 'TRADE DOCK', tw = ctx.measureText(label).width + fs;
@@ -52,35 +52,17 @@ function drawMachines() {
const a = mm.angle + k * Math.PI * 2 / 3; const a = mm.angle + k * Math.PI * 2 / 3;
ctx.beginPath(); ctx.ellipse(cx + Math.cos(a) * sw * 0.18, cy + Math.sin(a) * sw * 0.18, sw * 0.17, sw * 0.07, a, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + Math.cos(a) * sw * 0.18, cy + Math.sin(a) * sw * 0.18, sw * 0.17, sw * 0.07, a, 0, Math.PI * 2); ctx.fill();
} }
if (mm.hot > 760) {
const sz = Math.max(10, zp * 2), x = sx + sw - sz * 0.6, y = sy - sz * 0.4;
ctx.save(); ctx.fillStyle = '#b84332'; ctx.beginPath(); ctx.roundRect(x, y, sz, sz, sz * 0.2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = `700 ${sz * 0.85}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('!', x + sz / 2, y + sz / 2); ctx.restore();
}
// direction arrow // direction arrow
const dx = [1, 0, -1, 0][mm.dir], dy = [0, 1, 0, -1][mm.dir]; const dx = [1, 0, -1, 0][mm.dir], dy = [0, 1, 0, -1][mm.dir];
ctx.strokeStyle = sat > 0.05 && mm.on ? 'rgba(160,220,255,0.7)' : 'rgba(160,170,180,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.35); ctx.strokeStyle = sat > 0.05 && mm.on ? 'rgba(160,220,255,0.7)' : 'rgba(160,170,180,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.35);
ctx.beginPath(); ctx.moveTo(cx + dx * sw * 0.55, cy + dy * sh * 0.55); ctx.lineTo(cx + dx * sw * 0.9, cy + dy * sh * 0.9); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + dx * sw * 0.55, cy + dy * sh * 0.55); ctx.lineTo(cx + dx * sw * 0.9, cy + dy * sh * 0.9); ctx.stroke();
} else if (mm.type === 'silo') { } else if (mm.type === 'silo') {
drawSilo(ctx, mm, sx, sy, zp); drawSilo(ctx, mm, sx, sy, zp);
} else if (mm.type === 'cutter') {
// hazard rims on the hopper, and a toothed saw blade in the floor that spins while it cuts
const busy = tick - (mm.busyT || -99) < 12;
drawHazardRims(ctx, sx, sy, sw, zp);
const cx = sx + 4 * zp, cy = sy + 6.5 * zp, rr = zp * 1.4, a0 = busy ? R.frame * 0.35 : 0;
ctx.fillStyle = '#c9cfd4'; ctx.beginPath();
for (let t = 0; t < 16; t++) { const a = a0 + t * Math.PI / 8, r2 = t & 1 ? rr : rr * 0.78; ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2); }
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#3a4048'; ctx.beginPath(); ctx.arc(cx, cy, rr * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
ctx.fillRect(sx + 2 * zp, sy + 7.6 * zp, 4 * zp, zp * 0.4);
} else if (mm.type === 'refiner') {
// hopper rim, two toothed grinding rollers in the floor (they turn while it works), the chute
const busy = tick - (mm.busyT || -99) < 12;
drawHazardRims(ctx, sx, sy, sw, zp);
for (let k = 0; k < 2; k++) {
const cx = sx + (k ? 7.5 : 4.5) * zp, cy = sy + 11 * zp, rr = zp * 1.15, a0 = (busy ? R.frame * 0.25 : 0) * (k ? -1 : 1);
ctx.fillStyle = '#2c3036'; ctx.beginPath(); ctx.arc(cx, cy, rr, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#9aa4ae'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); for (let t = 0; t < 6; t++) { const a = a0 + t * Math.PI / 3; ctx.moveTo(cx + Math.cos(a) * rr * 0.35, cy + Math.sin(a) * rr * 0.35); ctx.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); } ctx.stroke();
}
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
ctx.beginPath(); ctx.moveTo(sx + 4 * zp, sy + 12 * zp); ctx.lineTo(sx + 8 * zp, sy + 12 * zp); ctx.lineTo(sx + 7 * zp, sy + 12.8 * zp); ctx.lineTo(sx + 5 * zp, sy + 12.8 * zp); ctx.closePath(); ctx.fill();
} else if (mm.type === 'engine') { } else if (mm.type === 'engine') {
ctx.fillStyle = 'rgba(16,18,22,0.4)'; ctx.fillRect(sx + zp, sy + zp, sw - 2 * zp, sh - 3 * zp); ctx.fillStyle = 'rgba(16,18,22,0.4)'; ctx.fillRect(sx + zp, sy + zp, sw - 2 * zp, sh - 3 * zp);
const fx = sx + sw * (mm.dir ? 0.3 : 0.7), fy = sy + sh * 0.42, fr = sh * 0.3; const fx = sx + sw * (mm.dir ? 0.3 : 0.7), fy = sy + sh * 0.42, fr = sh * 0.3;
@@ -93,10 +75,50 @@ function drawMachines() {
} }
ctx.restore(); ctx.restore();
} }
drawProcBlocks();
drawRackBlocks();
}
// Crushing rollers and saw blades sit over the shared steel housings in both looks.
function drawProcBlocks() {
const ctx = R.ctx;
for (const b of procBlocks) {
const bx = b % BW, by = (b / BW) | 0;
const [sx, sy, sw] = cellRect(bx * BS, by * BS, BS, BS);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue;
if (SET.fog && fogSeen(bx * BS + 2, by * BS + 2) < 64) continue;
const saw = blockP[b] === BP_SAW, busy = tick - (procState.get(b)?.busyT ?? -999) < 30;
const angle = busy ? tick * (saw ? 0.5 : 0.25) : b * 0.7;
const radius = sw * (saw ? 0.38 : 0.22), teeth = saw ? 10 : 6;
ctx.save();
ctx.lineWidth = Math.max(1, sw * 0.025);
if (blockP[b] === BP_PRESS) {
const offset = busy ? (1 - Math.cos(tick * 0.3)) * sw * 0.12 : 0;
ctx.fillStyle = '#caa488'; ctx.fillRect(sx + sw * 0.27, sy + sw * 0.76, sw * 0.46, sw * 0.16);
ctx.fillStyle = '#7d8996'; ctx.fillRect(sx + sw * 0.45, sy, sw * 0.1, sw * 0.4 + offset);
ctx.fillStyle = '#b9c0c8'; ctx.fillRect(sx + sw * 0.12, sy + sw * 0.4 + offset, sw * 0.76, sw * 0.12);
} else for (let wheel = 0; wheel < (saw ? 1 : 2); wheel++) {
const cx = sx + sw * (saw ? 0.5 : wheel ? 0.73 : 0.27), cy = sy + sw * 0.5;
const a = wheel ? -angle : angle;
ctx.fillStyle = saw ? '#b9c0c8' : '#8a8f96'; ctx.strokeStyle = '#444b54';
if (sw >= 12) for (let k = 0; k < teeth; k++) {
const t = a + k * Math.PI * 2 / teeth, half = (saw ? 0.18 : 0.24);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(t - half) * radius * 0.9, cy + Math.sin(t - half) * radius * 0.9);
ctx.lineTo(cx + Math.cos(t) * radius * 1.15, cy + Math.sin(t) * radius * 1.15);
ctx.lineTo(cx + Math.cos(t + half) * radius * 0.9, cy + Math.sin(t + half) * radius * 0.9);
ctx.closePath(); ctx.fill(); ctx.stroke();
}
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
if (saw) { ctx.beginPath(); ctx.arc(cx, cy, radius * 0.65, 0, Math.PI * 2); ctx.stroke(); }
ctx.fillStyle = '#292e36'; ctx.beginPath(); ctx.arc(cx, cy, Math.max(1, radius * 0.19), 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
} }
// The silo: legs and braces, a cone to a slide gate, a ribbed steel drum with a ladder and a sight // The silo: legs and braces, a cone to a slide gate, a ribbed steel drum with a ladder and a sight
// glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner. // glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner.
function drawSilo(ctx, mm, sx, sy, zp) { function drawSilo(ctx, mm, sx, sy, zp) {
if (!R.smoothOn) { drawSiloPixel(ctx, mm, sx, sy, zp); return; }
const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP); const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP);
const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; }; const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; };
ctx.save(); ctx.save();
@@ -133,7 +155,7 @@ function drawSilo(ctx, mm, sx, sy, zp) {
for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); } for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); }
// what it holds, stencilled on the drum // what it holds, stencilled on the drum
if (zp >= 3) { if (zp >= 3) {
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; renderMonoFont();
const fs = Math.max(8, Math.round(zp * 1.15)); const fs = Math.max(8, Math.round(zp * 1.15));
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(20,24,28,0.75)'; ctx.fillStyle = 'rgba(20,24,28,0.75)';
@@ -150,3 +172,71 @@ function drawSilo(ctx, mm, sx, sy, zp) {
ctx.beginPath(); ctx.moveTo(X(8.9), Y(0.9)); ctx.lineTo(X(10.2), Y(-1.4)); ctx.stroke(); ctx.beginPath(); ctx.moveTo(X(8.9), Y(0.9)); ctx.lineTo(X(10.2), Y(-1.4)); ctx.stroke();
ctx.restore(); ctx.restore();
} }
function drawSiloPixel(ctx, mm, sx, sy, zp) {
const full = Math.min(1, (mm.sn || 0) / SILO_CAP), col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`;
const grain = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); };
// Steel shaded by column (light on the left, dark at the right edge), darker rib rows, and fixed
// grain noise so it never shimmers. The drum is rows 2-19, the cone 20-22 narrowing to the gate.
const steel = [[78,86,94],[176,186,194],[160,170,179],[148,158,167],[140,150,159],[134,144,153],
[126,136,145],[118,128,137],[108,118,127],[96,105,114],[84,93,101],[62,70,78]];
for (let y = 2; y < 23; y++) for (let x = 0; x < 12; x++) {
if (y >= 20 && (x < y - 19 || x > 30 - y)) continue;
const c = steel[x], h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263);
const n = (((h ^ h >>> 13) >>> 0) % 3 - 1) * 4 - (y === 6 || y === 11 || y === 15 || y === 19 ? 26 : 0) - (y >= 20 ? 10 : 0)
- (x === 2 && y < 19 && !(y & 1) ? 34 : 0); // ladder rungs up the left side
grain(x, y, `rgb(${c[0] + n},${c[1] + n},${c[2] + n})`);
}
// legs with a strut to the cone, the gate opening, and the slide gate (pulled aside when open)
for (let y = 20; y < 24; y++) { grain(0, y, '#2f353b'); grain(11, y, '#2f353b'); }
grain(1, 21, '#2f353b'); grain(10, 21, '#2f353b');
for (let x = 4; x < 8; x++) grain(x, 23, '#1c1f23');
if (mm.open) for (let x = 8; x < 11; x++) grain(x, 23, '#c99a3c'); else for (let x = 3; x < 9; x++) grain(x, 23, '#6b737b');
// the sight glass fills from the bottom in the colour of what is inside
for (let y = 3; y < 19; y++) grain(9, y, mm.sn > 0 && y >= 19 - Math.max(1, Math.round(16 * full)) ? col : '#14181c');
// roof: a rim, shoulders, the dark hatch and its lid standing open
for (let x = 0; x < 12; x++) {
grain(x, 1, x >= 3 && x < 9 ? '#15181b' : '#5b646c');
if (x < 3 || x >= 9) grain(x, 0, '#7d878f');
}
const lid = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae';
grain(8, 0, lid); grain(9, -1, lid);
if (zp >= 3) {
renderMonoFont();
ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)';
ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`;
ctx.fillText('SILO', sx + 5.6 * zp, sy + 5 * zp);
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 5.6 * zp, sy + 16.8 * zp);
if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); }
ctx.restore();
}
}
// Shelves show each block's fill and materials; the bottom gate belongs to the column.
function drawRackBlocks() {
const ctx = R.ctx;
for (const [b, s] of rackState) {
const x = (b % BW) * BS, y = ((b / BW) | 0) * BS;
const [sx, sy, sw] = cellRect(x, y, BS, BS);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue;
if (SET.fog && fogSeen(x + 2, y + 2) < 64) continue;
ctx.save();
ctx.fillStyle = '#202831'; ctx.fillRect(sx + sw * 0.1, sy + sw * 0.1, sw * 0.8, sw * 0.8);
ctx.fillStyle = '#a5adb5';
for (let row = 0; row < 2; row++) ctx.fillRect(sx + sw * 0.1, sy + sw * (0.47 + row * 0.4), sw * 0.8, sw * 0.05);
const slots = Math.ceil(s.n / 5);
let run = 0, count = 0;
for (let k = 0; k < slots; k++) {
while (run + 1 < s.q.length && k * 5 >= count + s.q[run][1]) count += s.q[run++][1];
const m = s.q[run][0];
ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`;
ctx.fillRect(sx + sw * (0.14 + k % 5 * 0.145), sy + sw * (0.71 - Math.floor(k / 5) * 0.4), sw * 0.12, sw * 0.12);
}
if (b + BW >= NB || blockP[b + BW] !== BP_RACK) {
ctx.fillStyle = s.open ? '#e0b43c' : '#c9d1d8';
ctx.fillRect(sx + sw * 0.1, sy + sw * 0.94, sw * (s.open ? 0.2 : 0.8), sw * 0.06);
if (s.open) ctx.fillRect(sx + sw * 0.7, sy + sw * 0.94, sw * 0.2, sw * 0.06);
}
ctx.restore();
}
}
+18 -19
View File
@@ -1,5 +1,5 @@
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. // Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
function spawnFloater(x, y, text) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, life: 70 }); } function spawnFloater(x, y, text, colour) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, colour, life: 70 }); }
// Rain as thin falling streaks, snow as soft flakes // Rain as thin falling streaks, snow as soft flakes
function drawRain() { function drawRain() {
if (!flyers.length) return; if (!flyers.length) return;
@@ -11,7 +11,7 @@ function drawRain() {
ctx.beginPath(); ctx.beginPath();
let snow = false; let snow = false;
for (const f of flyers) { for (const f of flyers) {
if (!f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
if (f.m !== WATER) { snow = true; continue; } if (f.m !== WATER) { snow = true; continue; }
const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2); const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2);
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp); ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp);
@@ -20,7 +20,7 @@ function drawRain() {
if (snow) { if (snow) {
ctx.fillStyle = 'rgba(244,247,252,0.9)'; ctx.fillStyle = 'rgba(244,247,252,0.9)';
for (const f of flyers) { for (const f of flyers) {
if (!f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42); const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42);
ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill();
} }
@@ -73,7 +73,7 @@ function drawFilterTags(showAll, hoverB) {
const v = viewCells(); const v = viewCells();
const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1); const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1);
const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1); const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; renderMonoFont();
ctx.save(); ctx.save();
ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle'; ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u); const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u);
@@ -125,7 +125,7 @@ function drawFilterTags(showAll, hoverB) {
function drawFloaters() { function drawFloaters() {
const fl = R.floaters; if (!fl || !fl.length) return; const fl = R.floaters; if (!fl || !fl.length) return;
const ctx = R.ctx, zp = R.zp; const ctx = R.ctx, zp = R.zp;
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; renderMonoFont();
ctx.save(); ctx.save();
ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (let k = fl.length - 1; k >= 0; k--) { for (let k = fl.length - 1; k >= 0; k--) {
@@ -133,33 +133,31 @@ function drawFloaters() {
if (f.life <= 0) { fl.splice(k, 1); continue; } if (f.life <= 0) { fl.splice(k, 1); continue; }
const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25); const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25);
ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1); ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1);
ctx.fillStyle = `rgba(255,210,122,${a})`; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = a; ctx.fillStyle = f.colour || '#ffd27a'; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = 1;
} }
ctx.restore(); ctx.restore();
} }
function drawMolds(hoverMold) { function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '100% · F' : Math.round(mo.fill * 100) + '%'; }
const ctx = R.ctx, zp = R.zp; function drawMolds() {
const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2));
ctx.save(); ctx.save();
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; renderMonoFont();
ctx.font = `600 ${Math.max(10, Math.round(zp * 3.4))}px ${R.monoFont}`; ctx.font = `600 ${fs}px ${R.monoFont}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (const mo of molds) { for (const mo of molds) {
const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h); const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue; if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
const ready = mo.state === 'ready', flawed = mo.state === 'flawed'; const ready = mo.state === 'ready' && mo.kind !== 'ingot', bad = mo.state === 'flawed' || mo.blocked;
ctx.setLineDash([zp, zp]); ctx.setLineDash([zp, zp]);
ctx.strokeStyle = ready ? 'rgba(255,200,90,0.95)' : flawed ? 'rgba(230,90,70,0.8)' : 'rgba(200,190,170,0.4)'; ctx.strokeStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : 'rgba(200,190,170,0.4)';
ctx.lineWidth = Math.max(1, zp * 0.3); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp); ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp);
ctx.setLineDash([]); ctx.setLineDash([]);
if (ready || flawed || mo === hoverMold || mo.state === 'filling' || mo.state === 'short') { const label = moldLabel(mo), tw = ctx.measureText(label).width + fs * 0.6;
const label = ready ? `${mo.kind === 'ingot' ? (mo.ready.length === 1 ? 'Bar' : `${mo.ready.length} bars`) : mo.kind === 'bit' ? 'Bit' : 'Tank'} set · F` : flawed ? 'Flawed casting' : mo.state === 'short' ? 'Needs more metal' : mo.state === 'filling' ? `Filling ${Math.round(mo.fill * 100)}%` : MOLDS[mo.kind].name;
ctx.fillStyle = 'rgba(14,12,11,0.75)'; ctx.fillStyle = 'rgba(14,12,11,0.75)';
const tw = ctx.measureText(label).width + zp * 2; ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp - fs * 1.3, tw, fs * 1.3);
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp * 5.2, tw, zp * 4.2); ctx.fillStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '#e7dccb';
ctx.fillStyle = ready ? '#ffcf7a' : flawed ? '#ff8a72' : '#e7dccb'; ctx.fillText(label, sx + sw / 2, sy - zp - fs * 0.15);
ctx.fillText(label, sx + sw / 2, sy - zp * 1.4);
}
} }
ctx.restore(); ctx.restore();
} }
@@ -222,6 +220,7 @@ function drawParticles() {
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--; p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
if (p.life <= 0) { ps.splice(k, 1); continue; } if (p.life <= 0) { ps.splice(k, 1); continue; }
} }
if (SET.fog && fogSeen(p.x, p.y) < 64) continue;
const s = worldToScreenPx(p.x, p.y); const s = worldToScreenPx(p.x, p.y);
ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`; ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`;
const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7); const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7);
+5 -1
View File
@@ -129,7 +129,7 @@ const BG = {
let SKY_R, SKY_G, SKY_B; let SKY_R, SKY_G, SKY_B;
function buildSky() { function buildSky() {
SKY_R = new Uint8Array(H); SKY_G = new Uint8Array(H); SKY_B = new Uint8Array(H); SKY_R = new Uint8Array(H); SKY_G = new Uint8Array(H); SKY_B = new Uint8Array(H);
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W; const sm = meanSurface();
for (let y = 0; y < H; y++) { for (let y = 0; y < H; y++) {
const f = Math.min(1, y / (sm + 20)); const f = Math.min(1, y / (sm + 20));
SKY_R[y] = 88 + f * 92; SKY_G[y] = 140 + f * 70; SKY_B[y] = 198 + f * 34; SKY_R[y] = 88 + f * 92; SKY_G[y] = 140 + f * 70; SKY_B[y] = 198 + f * 34;
@@ -194,3 +194,7 @@ function screenToWorld(sx, sy) {
return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp }; return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp };
} }
function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; } function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; }
function renderMonoFont() {
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
}
+325
View File
@@ -0,0 +1,325 @@
// Smooth shaders: vertex and fragment programs for the rounded world look.
const SM_VS = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const SM_FS = `#version 300 es
precision highp float;
precision highp int;
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
uniform vec4 uView, uLightMap;
uniform float uGlowK, uTime, uSurf, uRain;
out vec4 o;
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vn(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
vec4 bsp(sampler2D t, vec2 q) {
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
vec2 g0 = w0 + w1, g1 = w2 + w3;
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
}
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
// the line of belts ends, the end is square with rounded corners. Negative inside.
float beltSD(vec2 lb, bool endL, bool endR) {
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
vec2 q = abs(lb - c) - hs + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
// the line only rounds off where the whole run ends.
float beltLineSD(ivec2 c, vec2 lb) {
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5);
}
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
// back along the bottom.
float bandRidge(vec2 w, vec2 lb, float dir) {
float run = lb.y < 2.0 ? 1.0 : -1.0;
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
}
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
vec2 w = q + uOrigin; // position in the world
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
float shc = uView.w * px; // screen height in grains
ivec2 c = ivec2(floor(q));
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
vec4 own = cellAt(tCol, c); int nc = cls(own);
vec2 s = smoothstep(0.1, 0.9, t);
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
// fields, and everything that needs screen derivatives (worked out before any branching)
vec4 Fb = bsp(tField, q);
vec4 F = mix(lin(tField, q), Fb, 0.62);
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
float M = clamp(F.r + pw, 0.0, 1.0);
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
vec4 back = lin(tBack, q);
vec3 C = back.rgb;
float sky = back.a;
if (sky > 0.01) {
vec3 S = C;
vec2 P0 = scr + cam * 0.08;
// clouds; while it rains they thicken and turn grey
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
float k1 = 0.16; vec2 P1 = scr + cam * k1;
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
float k2 = 0.38; vec2 P2 = scr + cam * k2;
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
S = mix(S, near, m2);
C = mix(C, S, sky);
}
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
float notSky = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
float near = textureLod(tField, q / uSize, 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// ---- trees (behind the ground and everything on it)
if (F.a > 0.2) {
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
float sm = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
float lv = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
float d = (F.a - 0.36) / fwA;
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
}
}
// ---- smoke, steam and flame: soft, see-through puffs
if (Fb.b > 0.02) {
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
}
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
float sm = m.x + m.y + m.z + m.w;
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
float gl = length(gL);
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
if (F.r > 0.3) {
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
float bd = 1e9;
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
if (sm < 1e-4) tc = own.rgb;
tc *= 0.84 + 0.3 * fbm(w * 0.28);
tc *= 0.95 + 0.1 * vn(w * 1.6);
float gl = length(gT);
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
float band = 1.0 - smoothstep(0.5, 0.9, M);
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
// the outline follows where ground and grains meet open space, never where rock meets gravel
float dm = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
for (int k = 0; k < 4; k++) {
ivec2 o4 = ivec2(k & 1, k >> 1);
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
if (nk != 2) continue;
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
vec2 ce = vec2(c0 + o4) + 0.5;
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
vec2 dv = (q - ce) / r;
float dd = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
if (closed && F.r < 0.5) cover = 1.0;
if (cover > 0.0) {
float r2 = min(1.0, dot(gd, gd));
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
C = mix(C, pc, cover);
}
}
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
if (nc == 4) C = own.rgb;
else if (nc == 7 || nc == 8) {
// belt: a rubber band round two copper rollers per block; the top run moves the load
float dir = nc == 8 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
// outline: square ends with rounded corners where the line of belts (and filters) ends
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 bc;
if (edge < 0.8) {
bc = bandColor(lb, bandRidge(w, lb, dir));
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
float rd = length(dv);
bc = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, bc, inside);
}
}
else if (nc >= 9 && nc <= 12) {
// screw tube: a copper pipe; what is inside shows through, and the screw's flights turn
bool vert = nc == 9 || nc == 11;
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
float wall = 0.62;
vec3 tc;
if (a < wall || a > 4.0 - wall) {
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
float cyl = sin(u2 * 1.5708);
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) tc *= 1.08;
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
tc *= 1.0 - 0.3 * ring;
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
} else {
bool full = own.r + own.g + own.b > 0.0;
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
C = tc;
}
else if (nc == 6) {
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
C = tc;
}
else if (nc == 13 || nc == 14) {
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
// band is perforated; its colour tag sits on the top run for what rides along, on the
// bottom run for what drops through.
float dir = nc == 14 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
vec4 tag = cellAt(tTree, c);
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 steel = vec3(0.42, 0.44, 0.5);
// through the holes: whatever is behind (water, sky, rock), or what is passing through
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
vec2 cg = abs(fract(w) - 0.5);
float bw2 = max(px * 0.9, 0.09);
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
vec3 fc;
if (edge < 0.8) {
float rg = bandRidge(w, lb, dir);
vec3 band = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
fc = mix(steel * 0.78, behind, hole);
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
}
C = mix(C, fc, inside);
}
}
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * uGlowK;
}
o = vec4(C, 1.0);
}`;
+2 -333
View File
@@ -1,337 +1,6 @@
/* ===================================================================== // Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld
fills (colour + class, colour behind, trees, occupancy fields, light,
glow) into rounded, outlined, softly lit shapes at screen resolution.
The 2D canvas then draws this image as its world layer, and every
overlay (machines, bot, previews, labels) goes on top as before.
===================================================================== */
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 }; const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
const SM_VS = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const SM_FS = `#version 300 es
precision highp float;
precision highp int;
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
uniform vec4 uView, uLightMap;
uniform float uGlowK, uTime, uSurf, uRain;
out vec4 o;
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vn(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
vec4 bsp(sampler2D t, vec2 q) {
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
vec2 g0 = w0 + w1, g1 = w2 + w3;
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
}
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
// the line of belts ends, the end is square with rounded corners. Negative inside.
float beltSD(vec2 lb, bool endL, bool endR) {
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
vec2 q = abs(lb - c) - hs + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
// the line only rounds off where the whole run ends.
float beltLineSD(ivec2 c, vec2 lb) {
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5);
}
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
// back along the bottom.
float bandRidge(vec2 w, vec2 lb, float dir) {
float run = lb.y < 2.0 ? 1.0 : -1.0;
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
}
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
vec2 w = q + uOrigin; // position in the world
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
float shc = uView.w * px; // screen height in grains
ivec2 c = ivec2(floor(q));
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
vec4 own = cellAt(tCol, c); int nc = cls(own);
vec2 s = smoothstep(0.1, 0.9, t);
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
// fields, and everything that needs screen derivatives (worked out before any branching)
vec4 Fb = bsp(tField, q);
vec4 F = mix(lin(tField, q), Fb, 0.62);
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
float M = clamp(F.r + pw, 0.0, 1.0);
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
vec4 back = lin(tBack, q);
vec3 C = back.rgb;
float sky = back.a;
if (sky > 0.01) {
vec3 S = C;
vec2 P0 = scr + cam * 0.08;
// clouds; while it rains they thicken and turn grey
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
float k1 = 0.16; vec2 P1 = scr + cam * k1;
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
float k2 = 0.38; vec2 P2 = scr + cam * k2;
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
S = mix(S, near, m2);
C = mix(C, S, sky);
}
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
float notSky = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
float near = textureLod(tField, q / uSize, 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// ---- trees (behind the ground and everything on it)
if (F.a > 0.2) {
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
float sm = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
float lv = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
float d = (F.a - 0.36) / fwA;
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
}
}
// ---- smoke, steam and flame: soft, see-through puffs
if (Fb.b > 0.02) {
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
}
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
float sm = m.x + m.y + m.z + m.w;
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
float gl = length(gL);
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
if (F.r > 0.3) {
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
float bd = 1e9;
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
if (sm < 1e-4) tc = own.rgb;
tc *= 0.84 + 0.3 * fbm(w * 0.28);
tc *= 0.95 + 0.1 * vn(w * 1.6);
float gl = length(gT);
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
float band = 1.0 - smoothstep(0.5, 0.9, M);
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
// the outline follows where ground and grains meet open space, never where rock meets gravel
float dm = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
for (int k = 0; k < 4; k++) {
ivec2 o4 = ivec2(k & 1, k >> 1);
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
if (nk != 2) continue;
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
vec2 ce = vec2(c0 + o4) + 0.5;
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
vec2 dv = (q - ce) / r;
float dd = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
if (closed && F.r < 0.5) cover = 1.0;
if (cover > 0.0) {
float r2 = min(1.0, dot(gd, gd));
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
C = mix(C, pc, cover);
}
}
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
if (nc == 4) C = own.rgb;
else if (nc == 7 || nc == 8) {
// belt: a rubber band round two copper rollers per block; the top run moves the load
float dir = nc == 8 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
// outline: square ends with rounded corners where the line of belts (and filters) ends
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 bc;
if (edge < 0.8) {
bc = bandColor(lb, bandRidge(w, lb, dir));
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
float rd = length(dv);
bc = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, bc, inside);
}
}
else if (nc >= 9 && nc <= 12) {
// screw tube: a copper pipe; what is inside shows through, and the screw's flights turn
bool vert = nc == 9 || nc == 11;
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
float wall = 0.62;
vec3 tc;
if (a < wall || a > 4.0 - wall) {
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
float cyl = sin(u2 * 1.5708);
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) tc *= 1.08;
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
tc *= 1.0 - 0.3 * ring;
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
} else {
bool full = own.r + own.g + own.b > 0.0;
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
C = tc;
}
else if (nc == 6) {
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
C = tc;
}
else if (nc == 13 || nc == 14) {
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
// band is perforated; its colour tag sits on the top run for what rides along, on the
// bottom run for what drops through.
float dir = nc == 14 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
vec4 tag = cellAt(tTree, c);
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 steel = vec3(0.42, 0.44, 0.5);
// through the holes: whatever is behind (water, sky, rock), or what is passing through
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
vec2 cg = abs(fract(w) - 0.5);
float bw2 = max(px * 0.9, 0.09);
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
vec3 fc;
if (edge < 0.8) {
float rg = bandRidge(w, lb, dir);
vec3 band = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
fc = mix(steel * 0.78, behind, hole);
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
}
C = mix(C, fc, inside);
}
}
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * uGlowK;
}
o = vec4(C, 1.0);
}`;
function smCompile(gl, type, src) { function smCompile(gl, type, src) {
const sh = gl.createShader(type); const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh); gl.shaderSource(sh, src); gl.compileShader(sh);
@@ -391,7 +60,7 @@ function smoothDraw(lighting, glowOn) {
// the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly // the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly
const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u; const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u;
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; } if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; SM.surf = s / W; SM.surfW = W; SM.surfSeed = worldSeed; } if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
gl.viewport(0, 0, cw, ch); gl.viewport(0, 0, cw, ch);
gl.useProgram(SM.prog); gl.useProgram(SM.prog);
const bw = R.bw, bh = R.bh; const bw = R.bw, bh = R.bh;
+37 -6
View File
@@ -1,3 +1,5 @@
// Scale colour without changing the smooth-look class.
function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 16) | (((c >>> 8 & 255) * k) << 8) | ((c & 255) * k); }
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. // drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
// ---------- World pixels ---------- // ---------- World pixels ----------
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left // smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
@@ -10,7 +12,7 @@ function drawWorld() {
R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02; R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02;
const rk = R.rain; const rk = R.rain;
// Smooth look: the colours below are still worked out per grain, but each grain also gets a // Smooth look: the colours below are still worked out per grain, but each grain also gets a
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth.js) // class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js)
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable(); const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable();
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask; const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
if (sm) { if (sm) {
@@ -20,7 +22,8 @@ function drawWorld() {
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; } for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; }
} }
} }
const OUT = sm ? 0x040a0a0c : 0xff0a0a0c; const useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
const lighting = SET.lighting !== 'off' && !R.lens; const lighting = SET.lighting !== 'off' && !R.lens;
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight(); if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
const grain = SET.grain, incand = SET.incandescence; const grain = SET.grain, incand = SET.incandescence;
@@ -32,13 +35,28 @@ function drawWorld() {
for (let by = 0; by < bh; by++) { for (let by = 0; by < bh; by++) {
const y = vy0 + by; const y = vy0 + by;
const row = by * bw; const row = by * bw;
if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; } if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; }
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
const yw = y * W; const yw = y * W;
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y]; let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; } if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
for (let bx = 0; bx < bw; bx++) { for (let bx = 0; bx < bw; bx++) {
const x = vx0 + bx; const x = vx0 + bx;
if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; } if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
let fv = 255;
if (useFog) {
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
if (fv < 255) {
let s = fv / 255;
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
}
}
const i = yw + x, m = mat[i], nz = noiseB[i] - 128; const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
const T = temp[i]; const T = temp[i];
let r, g, b; let r, g, b;
@@ -48,6 +66,7 @@ function drawWorld() {
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; } else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
const p = part[i]; const p = part[i];
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; } if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
continue; continue;
} }
@@ -96,6 +115,10 @@ function drawWorld() {
} }
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } } else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } } else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
}
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } } else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
@@ -167,7 +190,7 @@ function drawWorld() {
if (T > 450 && m !== EMPTY && incand) { if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi]; const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
if (glowOn && w > 0.3 && ((bx | by) & 1) === 0) { if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4; const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s); gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
} }
@@ -198,11 +221,19 @@ function drawWorld() {
top = cl << 24; top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0); fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0);
} }
if (fv < 255) {
const k = fv / 255; r *= k; g *= k; b *= k;
if (sm) {
const c = bpx[row + bx], t = tpx[row + bx];
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
}
}
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
} }
} }
// bars and other loose objects // bars and other loose objects
if (!lens) for (const e of ents) { if (!lens) for (const e of ents) {
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
const ex = e.ix - vx0, ey = e.iy - vy0; const ex = e.ix - vx0, ey = e.iy - vy0;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue; if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m; const m = e.m;
@@ -219,7 +250,7 @@ function drawWorld() {
} }
// grains in flight from the vacuum nozzle, a tube or a belt (rain and snow are drawn over the top, see drawRain) // grains in flight from the vacuum nozzle, a tube or a belt (rain and snow are drawn over the top, see drawRain)
if (!lens) for (const f of flyers) { if (!lens) for (const f of flyers) {
if (f.rain) continue; if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0; const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8; const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
+23 -2
View File
@@ -25,21 +25,24 @@
</div> </div>
<div class="bar" id="bagbar"><i></i></div> <div class="bar" id="bagbar"><i></i></div>
<div id="baglist"></div> <div id="baglist"></div>
<div id="bagfoot"></div>
</div> </div>
</div> </div>
<div class="corner bl"> <div class="corner bl">
<div id="info" class="plate"></div> <div id="info" class="plate"></div>
</div> </div>
<div class="corner br"> <div class="corner br">
<div id="cards" aria-live="polite"></div> <div id="minimap" class="plate interactive"><canvas id="mini-canvas" width="200" height="130" aria-label="Minimap"></canvas><div class="map-buttons"><button id="mini-minus" class="iconbtn" aria-label="Zoom out minimap">−</button><button id="mini-plus" class="iconbtn" aria-label="Zoom in minimap">+</button><button id="mini-map" class="iconbtn">Map <kbd>M</kbd></button></div></div>
</div> </div>
<div class="corner bc"> <div class="corner bc">
<div id="cards" aria-live="polite"></div>
<div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div> <div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div>
<div id="ovbadges" aria-live="polite"></div> <div id="ovbadges" aria-live="polite"></div>
</div> </div>
</div> </div>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<div id="perf"></div> <div id="perf"></div>
<div id="saving" hidden role="status"><i></i>Saving…</div>
<div id="title"> <div id="title">
<div class="titlewrap"> <div class="titlewrap">
@@ -47,14 +50,17 @@
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p> <p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div class="tmenu" id="tmenu"> <div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button> <button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
<button class="btn" id="btn-new">New world <small>generate</small></button> <button class="btn" id="btn-new">New world <small>generate</small></button>
<div class="plate newworld" id="newworld" hidden> <div class="plate newworld" id="newworld" hidden>
<label>Name <input id="nw-name" type="text" maxlength="40" autocomplete="off" spellcheck="false"></label>
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label> <label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
<label>Size <label>Size
<select id="nw-size"> <select id="nw-size">
<option value="2048x1024">Small · 2048 × 1024</option> <option value="2048x1024">Small · 2048 × 1024</option>
<option value="3072x1280" selected>Medium · 3072 × 1280</option> <option value="3072x1280" selected>Medium · 3072 × 1280</option>
<option value="4096x1536">Large · 4096 × 1536</option> <option value="4096x1536">Large · 4096 × 1536</option>
<option value="6144x2048">Huge · 6144 × 2048</option>
</select> </select>
</label> </label>
<button class="btn primary full" id="btn-generate">Generate and start</button> <button class="btn primary full" id="btn-generate">Generate and start</button>
@@ -67,7 +73,7 @@
<div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div> <div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div>
</div> </div>
<div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div> <div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadstep" role="status"></span><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div>
<div id="m-pause" class="modal small" hidden> <div id="m-pause" class="modal small" hidden>
<div class="plate"> <div class="plate">
@@ -101,6 +107,21 @@
</div> </div>
</div> </div>
<div id="m-worlds" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>Worlds</h2><button class="iconbtn" data-close aria-label="Close worlds">Close <kbd>Esc</kbd></button></div>
<div class="mbody" id="worlds-list"></div>
</div>
</div>
<div id="m-map" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>World map</h2><button class="iconbtn" data-close aria-label="Close map">Close <kbd>M / Esc</kbd></button></div>
<canvas id="map-canvas" aria-label="World map. Drag to pan; wheel to zoom."></canvas>
<div class="mfoot"><button id="map-minus" class="iconbtn" aria-label="Zoom out map">−</button><button id="map-plus" class="iconbtn" aria-label="Zoom in map">+</button><button id="map-centre" class="iconbtn">Centre on bot</button><span class="note">Drag to pan · wheel to zoom</span></div>
</div>
</div>
<div id="m-dialog" class="modal small" hidden> <div id="m-dialog" class="modal small" hidden>
<div class="plate"> <div class="plate">
<div class="mhead"><h2 id="dlg-title"></h2></div> <div class="mhead"><h2 id="dlg-title"></h2></div>
+10 -4
View File
@@ -3,7 +3,7 @@
function removePieceAt(x, y) { function removePieceAt(x, y) {
droppedLast = 0; droppedLast = 0;
const msg = removePieceAt0(x, y); const msg = removePieceAt0(x, y);
return msg && droppedLast ? msg.replace('taken back.', 'taken back. Your bag is full, so the rest dropped where it stood.') : msg; return msg && droppedLast ? msg.replace('taken back.', 'taken back; bag full, excess dropped here.') : msg;
} }
function removePieceAt0(x, y) { function removePieceAt0(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return ''; if (x < 0 || y < 0 || x >= W || y >= H) return '';
@@ -15,7 +15,7 @@ function removePieceAt0(x, y) {
if (mach[i] && machines[mach[i]]) { if (mach[i] && machines[mach[i]]) {
const P = PIECE_BY_ID[machines[mach[i]].type]; const P = PIECE_BY_ID[machines[mach[i]].type];
if (P.fixed) return ''; if (P.fixed) return '';
if (siloFull(machines[mach[i]])) return `Empty the silo first: it holds ${machines[mach[i]].sn.toLocaleString('en-US')} ${MD[machines[mach[i]].sm].name.toLowerCase()}. Press F on it to open its gate.`; if (siloFull(machines[mach[i]])) return 'Empty the silo first; press F to open its gate.';
removeMachine(mach[i], true, false); return `${P.name} taken back.`; removeMachine(mach[i], true, false); return `${P.name} taken back.`;
} }
const mo = moldAt(x, y); const mo = moldAt(x, y);
@@ -73,14 +73,20 @@ function interactAt(x, y) {
const g = toggleGateAt(x, y); const g = toggleGateAt(x, y);
if (g) { emit({ t: 'sfx', s: 'gate' }); return g === 'open' ? 'Gate opened.' : 'Gate closed.'; } if (g) { emit({ t: 'sfx', s: 'gate' }); return g === 'open' ? 'Gate opened.' : 'Gate closed.'; }
const mo = moldAt(x, y); const mo = moldAt(x, y);
if (mo) { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; } // ingot molds empty themselves: F passes them by
if (mo && mo.kind !== 'ingot') { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; }
const pb = ((y / BS) | 0) * BW + ((x / BS) | 0); const pb = ((y / BS) | 0) * BW + ((x / BS) | 0);
if (blockP[pb] === BP_RACK) {
const top = rackTop(pb), open = !rackState.get(top).open;
for (let b = top; b < NB && blockP[b] === BP_RACK; b += BW) { const s = rackState.get(b); s.open = open; s.wait = 0; }
return open ? 'Rack open.' : 'Rack closed.';
}
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) { if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' }); blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.'); return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
} }
const id = mach[y * W + x]; const id = mach[y * W + x];
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open. It is empty.') : 'Silo gate shut.'; } if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open; it is empty.') : 'Silo gate shut.'; }
if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; } if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; }
return ''; return '';
} }
+30 -6
View File
@@ -8,7 +8,7 @@ function canPlaceBlock(bx, by) {
if (blockP[by * BW + bx]) return 'Something is already built there'; if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) { for (const i of blockCellList(bx, by)) {
if (mach[i] || part[i]) return 'Something is already built there'; if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return 'Needs open space. Clear the rock first.'; if (PH[mat[i]] === PH_SOLID) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way'; if (occ[i]) return 'A bar is in the way';
} }
return ''; return '';
@@ -26,6 +26,8 @@ function placeBlock(code, v, bx, by, free, extra) {
if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0); if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0);
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); } else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); } else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0);
else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0);
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0); else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); } else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
else if (code === BP_TUBE) { part[i] = [P_TU, P_TR, P_TD, P_TL][v & 3]; markPow(i, true); wakeXY(x, y); } else if (code === BP_TUBE) { part[i] = [P_TU, P_TR, P_TD, P_TL][v & 3]; markPow(i, true); wakeXY(x, y); }
@@ -36,6 +38,13 @@ function placeBlock(code, v, bx, by, free, extra) {
if (isPipeCode(code)) pipesDirty = true; if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v; blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; } if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true;
if (code === BP_RACK) {
const b = by * BW + bx, s = newRack();
s.open = !!(rackState.get(b - BW)?.open || rackState.get(b + BW)?.open);
rackState.set(b, s);
for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
}
stats.placed++; stats.placed++;
undoNote({ t: 'block+', bx, by, code }); undoNote({ t: 'block+', bx, by, code });
return ''; return '';
@@ -51,6 +60,8 @@ function blockIntegrity(b) {
if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; } if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; } else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
else if (code === BP_SHAFT) { if (m === SHAFT) n++; } else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
else if (code === BP_RACK) { if (m === RACKB) n++; }
else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; }
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; } else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
else if (code === BP_GRATE) { if (p === P_GRATE) n++; } else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; } else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; }
@@ -60,6 +71,15 @@ function blockIntegrity(b) {
void v; void v;
return n; return n;
} }
// One cell of a wall or mold being taken back: metal is counted toward bars (returns 1), other
// walls give their rubble back.
function removeWallCell(i, wall) {
const m = mat[i];
if (BAR_OF[m] && PH[wall] === PH_ITEM) { setCell(i, EMPTY, temp[i], 0); return 1; }
if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place
else setCell(i, EMPTY, temp[i], 0);
return 0;
}
function removeBlock(bx, by) { function removeBlock(bx, by) {
const b = by * BW + bx, code = blockP[b], v = blockV[b]; const b = by * BW + bx, code = blockP[b], v = blockV[b];
if (!code) return 0; if (!code) return 0;
@@ -71,22 +91,26 @@ function removeBlock(bx, by) {
const m = mat[i], p = part[i]; const m = mat[i], p = part[i];
if (code === BP_WALL) { if (code === BP_WALL) {
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
if (BAR_OF[m] && PH[v] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); } metal += removeWallCell(i, v);
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place
else setCell(i, EMPTY, temp[i], 0);
n++; n++;
} }
} else if (isPipeCode(code)) { } else if (isPipeCode(code)) {
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; } if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; } else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE) { } else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; } if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); } if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; } } else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
} }
if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2); if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2);
else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2); else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); } if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
if (isProcCode(code)) { procDirty = true; procState.delete(b); }
if (code === BP_RACK) {
const s = rackState.get(b);
rackState.delete(b);
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
}
blockP[b] = 0; blockV[b] = 0; blockP[b] = 0; blockV[b] = 0;
if (isPipeCode(code)) { if (isPipeCode(code)) {
pipesDirty = true; pipesDirty = true;
+1 -1
View File
@@ -80,7 +80,7 @@ function footprintBlocker(bx, by, bw, bh) {
for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) { for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) {
if (x < 0 || y < 0 || x >= W || y >= H) return 'Out of bounds'; if (x < 0 || y < 0 || x >= W || y >= H) return 'Out of bounds';
const i = y * W + x; const i = y * W + x;
if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space. Clear the rock first.'; if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way'; if (occ[i]) return 'A bar is in the way';
if (mach[i] && machines[mach[i]] && PIECE_BY_ID[machines[mach[i]].type].fixed) return 'That cannot be replaced'; if (mach[i] && machines[mach[i]] && PIECE_BY_ID[machines[mach[i]].type].fixed) return 'That cannot be replaced';
if (mach[i] && siloFull(machines[mach[i]])) return 'Empty the silo first'; if (mach[i] && siloFull(machines[mach[i]])) return 'Empty the silo first';
+40 -9
View File
@@ -21,6 +21,13 @@ function swapCells(i, j) {
function move(i, j, x, y, nx, ny) { function move(i, j, x, y, nx, ny) {
swapCells(i, j); swapCells(i, j);
wakeXY(x, y); wakeXY(nx, ny); wakeXY(x, y); wakeXY(nx, ny);
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) {
// a top-layer grain stepping sideways may clear the path to a drop for surface grains far
// along the row, so wake them; moves inside a body of water do not (settled lakes must sleep)
const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW;
const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5;
for (let cx = first; cx <= last; cx++) awake[row + cx] = 3;
}
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
const d = temp[j] - AMB[ny]; const d = temp[j] - AMB[ny];
if (d > 25 || d < -25) wakeHeatXY(nx, ny); if (d > 25 || d < -25) wakeHeatXY(nx, ny);
@@ -52,9 +59,16 @@ function enter(m, ph, j, dx, dy) {
} }
// ---------- Movement ---------- // ---------- Movement ----------
// Heavy grains settle through a loose pile of much lighter ones, slowly. A sorting // Heavy grains settle through a loose pile of much lighter ones. A sorting
// step keeps its area awake for a while so a pile finishes sorting even at rest. // step keeps its area awake for a while so a pile finishes sorting even at rest.
let SORTP = 0.003; // Through burning fuel it stays slow: a furnace bed is tuned to it, and dirt heaped on a burning
// pile has to smother it into charcoal, not sink through.
let SORTP = 0.06;
const SORTP_FUEL = 0.003;
// Liquids step up to LSTEP cells toward a drop in one tick (fewer the thicker they are), and a top-layer grain looks LFILM
// times as far for one (a two-times range strands the far end of a 200-grain-wide ledge).
// World generation settles with both at 1, so every seed keeps making the same world.
let LSTEP = 8, LFILM = 4;
function powder(i, x, y, m) { function powder(i, x, y, m) {
if (y >= H - 1) return; if (y >= H - 1) return;
const b = i + W; const b = i + W;
@@ -62,7 +76,7 @@ function powder(i, x, y, m) {
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel // a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in. // packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b]; const t = mat[b];
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < SORTP) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; } if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge // a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return; if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
if (rnd() < STICK[m]) { if (rnd() < STICK[m]) {
@@ -97,7 +111,7 @@ function liquid(i, x, y, m) {
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
} }
if (rnd() > FLUID[m]) return; if (rnd() > FLUID[m]) return;
const sp = SPREAD[m]; const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
const d0 = (RS & 4) ? 1 : -1; const d0 = (RS & 4) ? 1 : -1;
for (let pass = 0; pass < 2; pass++) { for (let pass = 0; pass < 2; pass++) {
const d = pass ? -d0 : d0; const d = pass ? -d0 : d0;
@@ -107,14 +121,16 @@ function liquid(i, x, y, m) {
const j = i + d * s; const j = i + d * s;
if (!passable(m, PH_LIQUID, j, d, 0)) break; if (!passable(m, PH_LIQUID, j, d, 0)) break;
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) { if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
// step one cell toward the drop (keeps flow continuous and local) const k = Math.min(s, Math.max(1, (LSTEP * FLUID[m]) | 0)); // thick liquids take shorter steps
move(i, i + d, x, y, x + d, y); move(i, i + d * k, x, y, x + d * k, y);
return; return;
} }
} }
} }
} }
function gas(i, x, y, m) { function gas(i, x, y, m) {
// Even blocked gas must keep aging or condensing until it is gone.
awake[(y >> 5) * CW + (x >> 5)] = 3;
if (m === FIRE) { if (m === FIRE) {
const l = life[i]; const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
@@ -127,7 +143,7 @@ function gas(i, x, y, m) {
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
life[i] = l - 1; life[i] = l - 1;
} }
} } else if (m === STEAM && temp[i] < CONDT[STEAM]) wakeHeatXY(x, y); // cooled steam drifting under a roof needs the heat pass to condense it
if (y === 0 || (m === STEAM && y <= weather.skyY + 4 && y < roofY[x])) { if (y === 0 || (m === STEAM && y <= weather.skyY + 4 && y < roofY[x])) {
// steam that has risen to cloud height in the open sky joins the clouds // steam that has risen to cloud height in the open sky joins the clouds
if (m === STEAM) absorbSteam(x); if (m === STEAM) absorbSteam(x);
@@ -145,11 +161,23 @@ function gas(i, x, y, m) {
if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); } if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); }
} }
} }
function wakeBotPowder() {
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
const x0 = Math.max(0, Math.floor((x - 100) / 32)), x1 = Math.min(CW - 1, Math.floor((x + 100) / 32));
const y0 = Math.max(0, Math.floor((y - 100) / 32)), y1 = Math.min(CH - 1, Math.floor((y + 100) / 32));
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
const c = cy * CW + cx;
if (!powderArea[c] || awake[c]) continue;
const dx = Math.max(cx * 32 - x, 0, x - (cx * 32 + 31));
const dy = Math.max(cy * 32 - y, 0, y - (cy * 32 + 31));
if (dx * dx + dy * dy <= 10000) awake[c] = 1;
}
}
function simMove() { function simMove() {
for (let cy = CH - 1; cy >= 0; cy--) { for (let cy = CH - 1; cy >= 0; cy--) {
const rowBase = cy * CW; const rowBase = cy * CW;
let any = false; let any = false;
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; break; } for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; }
if (!any) continue; if (!any) continue;
const y0 = cy << 5, y1 = Math.min(H, y0 + 32); const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
for (let y = y1 - 1; y >= y0; y--) { for (let y = y1 - 1; y >= y0; y--) {
@@ -158,13 +186,16 @@ function simMove() {
for (let k = 0; k < CW; k++) { for (let k = 0; k < CW; k++) {
const cx = ltr ? k : CW - 1 - k; const cx = ltr ? k : CW - 1 - k;
if (!awake[rowBase + cx]) continue; if (!awake[rowBase + cx]) continue;
roofDirty[cx] = 1;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32); const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
// alternate rows scan left to right and right to left, so nothing drifts one way // alternate rows scan left to right and right to left, so nothing drifts one way
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1; const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) { for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
const i = yw + x, m = mat[i]; const i = yw + x, m = mat[i];
if (m === EMPTY || stamp[i] === tickStamp) continue; if (m === EMPTY) continue;
const ph = PH[m]; const ph = PH[m];
if (ph === PH_POWDER) powderArea[rowBase + cx] = 1;
if (stamp[i] === tickStamp) continue;
if (ph === PH_SOLID) continue; if (ph === PH_SOLID) continue;
const p = part[i]; const p = part[i];
if (p >= P_TU && p <= P_TR && ph === PH_POWDER) continue; // a screw tube moves its own grains if (p >= P_TU && p <= P_TR && ph === PH_POWDER) continue; // a screw tube moves its own grains
+22 -3
View File
@@ -1,10 +1,11 @@
// How belts, filters and screw tubes move what is on or in them. // How belts, filters and screw tubes move what is on or in them.
// What rests on a belt or filter rides along: the whole stack standing on the carrying surface // What rests on a belt or filter rides along: the whole stack standing on the carrying surface
// steps one cell toward d together (belts run front of the line first, so the way ahead is // steps one cell toward d together (belts run front of the line first, so the way ahead is
// clear), the way a real belt carries a heap, not just its bottom layer. A filter's own // clear). If a powder grain is blocked, the load above it stands still and the belt slips
// material drops through it instead, unless the filter below is already full. // underneath. A filter's own material drops through it instead, unless it is already full.
const CARRY_DEPTH = 96; const CARRY_DEPTH = 96;
function carryColumn(a, d, pass) { function carryColumn(a, d, pass) {
let blocked = false;
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) { for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
if (a < 0) return; if (a < 0) return;
const m = mat[a]; const m = mat[a];
@@ -16,12 +17,16 @@ function carryColumn(a, d, pass) {
const u = mat[a + W]; const u = mat[a + W];
if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; } if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; }
} }
if (blocked) continue; // filters still sift above a stopped load
const x = a % W, nx = x + d; const x = a % W, nx = x + d;
if (nx < 0 || nx >= W) return; if (nx < 0 || nx >= W) return;
const j = a + d, tj = mat[j]; const j = a + d, tj = mat[j];
// grains on a wet belt push through the water rather than stopping at it // grains on a wet belt push through the water rather than stopping at it
const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN); const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN);
if (!wet && !enter(m, PH_POWDER, j, d, 0)) continue; if (!wet && !enter(m, PH_POWDER, j, d, 0)) {
if (!pass) return;
blocked = true; continue;
}
const y = (a / W) | 0, bj = j + W; const y = (a / W) | 0, bj = j + W;
// carried off the end with nothing under it: it leaves at belt speed and falls // carried off the end with nothing under it: it leaves at belt speed and falls
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0); if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0);
@@ -86,9 +91,22 @@ function filterRun(b) {
// A cell a grain can be pushed into: empty, or liquid or gas that simply trades places with it // A cell a grain can be pushed into: empty, or liquid or gas that simply trades places with it
function roomFor(j) { const t = mat[j]; return t === EMPTY || PH[t] === PH_GAS || PH[t] === PH_LIQUID; } function roomFor(j) { const t = mat[j]; return t === EMPTY || PH[t] === PH_GAS || PH[t] === PH_LIQUID; }
// Cross one tube block, far lanes first; corners retain their liquid/gas swaps.
function tubeSpread(c, x, y, sx, sy, intake = false) {
const p = part[c], bx = x >> 2, by = y >> 2;
for (;;) {
const nx = x + sx, ny = y + sy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H || (nx >> 2) !== bx || (ny >> 2) !== by) break;
const j = ny * W + nx;
if (part[j] !== p || occ[j] || (intake ? mat[j] !== EMPTY || stamp[j] === tickStamp : !roomFor(j))) break;
move(c, j, x, y, nx, ny); c = j; x = nx; y = ny;
}
return c;
}
// An empty screw cell draws in loose material from behind it or from its sides // An empty screw cell draws in loose material from behind it or from its sides
// (not from the cell it pushes into), so a tube end sitting in a pile feeds itself. // (not from the cell it pushes into), so a tube end sitting in a pile feeds itself.
function tubeIntake(i, p, dx, dy) { function tubeIntake(i, p, dx, dy) {
if (occ[i]) return;
const x = i % W, y = (i / W) | 0; const x = i % W, y = (i / W) | 0;
const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]]; const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]];
const start = (tick + i) % 3; const start = (tick + i) % 3;
@@ -104,6 +122,7 @@ function tubeIntake(i, p, dx, dy) {
if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents
if (pj === P_GRATE || pj === P_FILTER) continue; if (pj === P_GRATE || pj === P_FILTER) continue;
move(j, i, nx, ny, x, y); move(j, i, nx, ny, x, y);
if (ox !== -dx || oy !== -dy) tubeSpread(i, x, y, -ox, -oy, true);
return; return;
} }
} }
+2 -2
View File
@@ -53,11 +53,11 @@ function toolPulse(ax, ay, width, tune) {
if (HARD[m] > tier.hard) { if (HARD[m] > tier.hard) {
const need = BIT_TIERS.find(b => b.hard >= HARD[m]); const need = BIT_TIERS.find(b => b.hard >= HARD[m]);
const nb = need ? need.name.toLowerCase() : 'better bit'; const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`; status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard; needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
} }
break; break;
} }
if (!status && d > reach) status = `Out of reach. A ${Wd.name} pulse from your ${tier.name.toLowerCase()} reaches ${reach} grains from the bot.`; if (!status && d > reach) status = `Out of reach; a ${Wd.name} pulse reaches ${reach} grains.`;
return { fired: true, status }; return { fired: true, status };
} }
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16; const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
+4 -4
View File
@@ -8,9 +8,9 @@ const CLAIM_DEBT = 20000;
// base price per grain (per bar for bars). 0 = not wanted: it drops through for nothing. // base price per grain (per bar for bars). 0 = not wanted: it drops through for nothing.
const PRICE = new Float32Array(NMAT); const PRICE = new Float32Array(NMAT);
for (const [m, v] of [ for (const [m, v] of [
[SAND, 0.1], [LCLAY, 0.2], [LWOOD, 0.3], [ACORN, 0.5], [COAL, 0.5], [RGRAN, 0.2], [RBAS, 0.4], [SAND, 0.1], [LCLAY, 0.2], [LWOOD, 0.3], [ACORN, 0.5], [COAL, 0.5], [RGRAN, 0.3], [RBAS, 0.5],
[CUORE, 1], [FEORE, 1.6], [SNORE, 2], [ZNORE, 2.2], [AGORE, 6], [AUORE, 14], [CUORE, 1], [FEORE, 1.6], [SNORE, 2], [ZNORE, 2.2], [AGORE, 6], [AUORE, 14],
[CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6], [CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6], [PLANK, 2.4], [GREENB, 2], [BARBR, 9],
[SCRCU, 3], [SCRFE, 5], [SCRST, 11], [SCRSN, 3.5], [SCRZN, 3.5], [SCRAG, 18], [SCRAU, 40], [SCRBZ, 8], [SCRBS, 7], [SCRCU, 3], [SCRFE, 5], [SCRST, 11], [SCRSN, 3.5], [SCRZN, 3.5], [SCRAG, 18], [SCRAU, 40], [SCRBZ, 8], [SCRBS, 7],
[BARCU, 40], [BARFE, 75], [BARST, 160], [BARSN, 50], [BARZN, 50], [BARAG, 220], [BARAU, 480], [BARBZ, 110], [BARBS, 95], [BARCU, 40], [BARFE, 75], [BARST, 160], [BARSN, 50], [BARZN, 50], [BARAG, 220], [BARAU, 480], [BARBZ, 110], [BARBS, 95],
]) PRICE[m] = v; ]) PRICE[m] = v;
@@ -71,9 +71,9 @@ let orders = [], ordersDone = 0, nextOrderT = 3600, orderSeq = 1;
const ORDER_SLOTS = 3; const ORDER_SLOTS = 3;
const STANDING = ['Local buyers', 'County buyers', 'Regional buyers', 'National buyers', 'Export houses']; const STANDING = ['Local buyers', 'County buyers', 'Regional buyers', 'National buyers', 'Export houses'];
function standing() { return Math.min(STANDING.length - 1, Math.floor(ordersDone / 4)); } function standing() { return Math.min(STANDING.length - 1, Math.floor(ordersDone / 4)); }
// Builders buy the cheap bulk stuff (rubble, gravel, sand, stone bricks) in big lots at a few // Builders buy the cheap bulk stuff (rubble, gravel, sand, stone blocks, planks) in big lots at a few
// times the market price, so waste rock earns a little. [what, smallest lot, largest lot] // times the market price, so waste rock earns a little. [what, smallest lot, largest lot]
const BUILD_GOODS = [[RSTONE, 400, 1500], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80]]; const BUILD_GOODS = [[RSTONE, 400, 1500], [RGRAN, 300, 1200], [RBAS, 300, 1200], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80], [PLANK, 20, 80], [BARBR, 20, 80]];
function makeBuildersOrder() { function makeBuildersOrder() {
const cands = BUILD_GOODS.filter(([m]) => seen[m] && !orders.some(o => o.m === m)); const cands = BUILD_GOODS.filter(([m]) => seen[m] && !orders.some(o => o.m === m));
if (!cands.length) return null; if (!cands.length) return null;
+52
View File
@@ -0,0 +1,52 @@
// Exploration only: never changes grains or the random stream.
const FOG = 2;
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
const FOG_SENSE_FADE = 12;
let fog, FW, FH;
function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = new Uint8Array(FW * FH); }
function fogMark(x, y, v) {
if (x < 0 || y < 0 || x >= W || y >= H) return;
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
if (v > fog[i]) fog[i] = v;
}
function fogSeen(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
return fog[Math.floor(y / FOG) * FW + Math.floor(x / FOG)];
}
function fogInit() {
for (let x = 0; x < W; x++) for (let y = 0; y < Math.min(H, surfY[x] + 16); y++) fogMark(x, y, y <= surfY[x] ? 255 : (surfY[x] + 16 - y) * 255 / 16);
}
function fogLook(cx, cy) {
for (let a = 0; a < 256; a++) {
const dx = Math.cos(a * Math.PI / 128), dy = Math.sin(a * Math.PI / 128);
let hit = -1;
for (let d = 0; d < 48; d++) {
const x = Math.floor(cx + dx * d), y = Math.floor(cy + dy * d);
if (x < 0 || y < 0 || x >= W || y >= H) break;
const ph = PH[mat[y * W + x]];
if (hit < 0 && (ph === PH_SOLID || ph === PH_POWDER)) hit = d;
// into rock, sight fades out over 8 grains from however bright it arrived
const v = Math.min(255, (48 - d) * 255 / 16) * (hit < 0 ? 1 : (8 - d + hit) / 8);
if (v <= 0) break;
fogMark(x, y, v);
}
}
// one fog cell at a time, measured from its centre; full inside 12 grains, fading over the outer 12 grains
const R2 = FOG_SENSE * FOG_SENSE, in2 = (FOG_SENSE - FOG_SENSE_FADE) ** 2;
const fy0 = Math.max(0, Math.floor((cy - FOG_SENSE) / FOG)), fy1 = Math.min(FH - 1, Math.floor((cy + FOG_SENSE) / FOG));
const fx0 = Math.max(0, Math.floor((cx - FOG_SENSE) / FOG)), fx1 = Math.min(FW - 1, Math.floor((cx + FOG_SENSE) / FOG));
for (let fy = fy0; fy <= fy1; fy++) for (let fx = fx0; fx <= fx1; fx++) {
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
if (d2 >= R2) continue;
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
if (v > fog[i]) fog[i] = v;
}
}
function fogRevealAround(x, y, r) {
for (let yy = Math.max(0, Math.floor(y - r)); yy <= Math.min(H - 1, y + r); yy++) for (let xx = Math.max(0, Math.floor(x - r)); xx <= Math.min(W - 1, x + r); xx++) {
const d2 = (xx - x) ** 2 + (yy - y) ** 2;
if (d2 >= r * r) continue;
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
}
}
function fogRevealAll() { fog.fill(255); }
+20 -1
View File
@@ -7,6 +7,20 @@ function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0
const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1; const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1;
const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1; const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1;
const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1; const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1;
// Cold solids and powders cannot react below their first applicable threshold.
const REACT_MIN = new Float64Array(NMAT).fill(Infinity);
for (let m = 0; m < NMAT; m++) {
if (PH[m] === PH_LIQUID || PH[m] === PH_GAS) REACT_MIN[m] = -Infinity;
else {
if (IGN[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], IGN[m]);
if (MELT[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], MELT[m]);
}
}
REACT_MIN[CLAY] = Math.min(REACT_MIN[CLAY], 880);
REACT_MIN[LCLAY] = Math.min(REACT_MIN[LCLAY], 880);
REACT_MIN[WOOD] = Math.min(REACT_MIN[WOOD], 380);
REACT_MIN[LWOOD] = Math.min(REACT_MIN[LWOOD], 380);
REACT_MIN[GRASS] = Math.min(REACT_MIN[GRASS], 260);
// Alloys: a molten additive grain turns neighbouring molten copper into the alloy, // Alloys: a molten additive grain turns neighbouring molten copper into the alloy,
// and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc). // and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc).
const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } }; const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } };
@@ -51,6 +65,7 @@ function heatPass() {
function heatChunk(c, sc) { function heatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
roofDirty[cx] = 1;
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW]; const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
let maxDev = 0, burning = false; let maxDev = 0, burning = false;
for (let y = y0; y < y1; y++) { for (let y = y0; y < y1; y++) {
@@ -80,7 +95,11 @@ function heatChunk(c, sc) {
} else rel = RELAX[m]; } else rel = RELAX[m];
t += (amb - t) * rel * sc; t += (amb - t) * rel * sc;
temp[i] = t; temp[i] = t;
if (m !== EMPTY) { react(i, x, y, m); m = mat[i]; t = temp[i]; } if (m !== EMPTY) {
// Test the stored Float32 temperature, just as react() reads it.
if ((flags[i] & F_BURN) || !(temp[i] < REACT_MIN[m])) react(i, x, y, m);
m = mat[i]; t = temp[i];
}
const dv = t > amb ? t - amb : amb - t; const dv = t > amb ? t - amb : amb - t;
if (dv > maxDev) maxDev = dv; if (dv > maxDev) maxDev = dv;
if (flags[i] & F_BURN) burning = true; if (flags[i] & F_BURN) burning = true;
+104 -6
View File
@@ -6,6 +6,12 @@
const BAR_W = 4, BAR_H = 2; const BAR_W = 4, BAR_H = 2;
let entCurM = 0; // the bar being moved, so a filter can let its own kind of bar through let entCurM = 0; // the bar being moved, so a filter can let its own kind of bar through
function entSolidCell(j) { const m = mat[j], p = part[j]; return occ[j] || PH[m] === PH_SOLID || PH[m] === PH_POWDER || (p !== 0 && (p <= P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); } function entSolidCell(j) { const m = mat[j], p = part[j]; return occ[j] || PH[m] === PH_SOLID || PH[m] === PH_POWDER || (p !== 0 && (p <= P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); }
function entSupportCount(x, y) {
if (y + BAR_H >= H) return BAR_W;
let n = 0;
for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + BAR_H) * W + x + dx)) n++;
return n;
}
function entFreeAt(x, y) { function entFreeAt(x, y) {
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false; if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + dy) * W + x + dx)) return false; for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + dy) * W + x + dx)) return false;
@@ -32,24 +38,109 @@ function spawnBar(m, x, y, vx, vy, T) {
return e; return e;
} }
function removeEnt(e) { entMark(e, 0); const k = ents.indexOf(e); if (k >= 0) ents.splice(k, 1); } function removeEnt(e) { entMark(e, 0); const k = ents.indexOf(e); if (k >= 0) ents.splice(k, 1); }
// No transient tube state: even a loaded bar derives its ride from its eight cells.
function entTubeAt(x, y) {
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return 0;
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) {
const p = part[yy * W + xx]; if (p < P_TU || p > P_TR) return 0;
}
return part[(y + 1) * W + x + 1];
}
function entTubeFree(x, y, tubeOnly = false) {
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false;
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) {
const j = yy * W + xx, p = part[j], tube = p >= P_TU && p <= P_TR;
// gas and liquid don't stop a bar, in a tube or in the open
if (occ[j] || (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS && PH[mat[j]] !== PH_LIQUID) || (tubeOnly ? !tube : !tube && entSolidCell(j))) return false;
}
return true;
}
// Check the swept rectangle too: a snap must never jump through a grain or another bar.
function entTubeMove(e, x, y, tubeOnly = false) {
if (!entTubeFree(x, y, tubeOnly)) return false;
for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++)
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, yy)) return false;
e.x = e.ix = x; e.y = e.iy = y; e.vx = e.vy = 0; return true;
}
function entTubeLane(y, iy) { return Math.max(y & ~3, Math.min((y & ~3) + 2, iy)); }
function entTubeEdge(e, dx, dy, k) {
const x = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), y = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k);
return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x;
}
function entTubeEnter(e, dx, dy) {
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
const i = entTubeEdge(e, dx, dy, k);
if (i < 0) continue;
const xx = i % W, yy = (i / W) | 0;
const p = part[i]; if (p < P_TU || p > P_TR || (!dx && TDX[p]) || dx * TDX[p] + dy * TDY[p] < 0) continue;
// Vertical screws require the full width; horizontal screws use the nearest two-row lane.
const x = (xx & ~3), y = entTubeLane(yy, e.iy);
if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true)) return true;
}
return false;
}
function entTubeTick(e, p) {
e.vx = e.vy = 0; e.x = e.ix; e.y = e.iy;
const ci = (e.iy + 1) * W + e.ix + 1;
if ((tick & 1) || netSat(ci) <= 0 || rnd() > netSat(ci)) return;
const dx = TDX[p], dy = TDY[p];
let x = e.ix + dx, y = e.iy + dy, next = 0, ax = 0, ay = 0;
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
const i = entTubeEdge(e, dx, dy, k);
if (i < 0) return;
const xx = i % W, yy = (i / W) | 0;
const q = part[i];
if (q >= P_TU && q <= P_TR && q !== p) { next = q; ax = xx; ay = yy; break; }
}
if (next) {
if (TDX[next] === -dx && TDY[next] === -dy) return;
x = ax & ~3; y = entTubeLane(ay, e.iy);
if (entTubeMove(e, x, y, true)) stats.moved++;
return;
}
if (entTubeAt(x, y)) { if (entTubeMove(e, x, y, true)) stats.moved++; return; }
// Clear the mouth in one move so the next tick can derive ordinary falling from position.
x = e.ix + dx * BAR_W; y = e.iy + dy * BAR_H;
if (!entTubeFree(x, y) || entTubeAt(x, y)) return;
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) if (part[yy * W + xx]) return;
if (entTubeMove(e, x, y)) { e.vx = dx ? dx * 0.5 : (rnd() - 0.5) * 0.4; e.vy = dy * 0.6; stats.moved++; }
}
function entTick() { function entTick() {
for (let k = ents.length - 1; k >= 0; k--) { for (let k = ents.length - 1; k >= 0; k--) {
const e = ents[k]; const e = ents[k];
entMark(e, 0); entMark(e, 0);
entCurM = e.m; entCurM = e.m;
const ix = e.ix, iy = e.iy; let ix = e.ix; const iy = e.iy;
const tube = entTubeAt(ix, iy);
if (tube) entTubeTick(e, tube);
else {
// what is underneath? // what is underneath?
let belt = 0, bsat = 0, support = iy + BAR_H >= H; let belt = 0, bsat = 0, transport = false, support = iy + BAR_H >= H;
if (!support) { if (!support) {
const yb = iy + BAR_H; const yb = iy + BAR_H;
for (let dx = 0; dx < BAR_W; dx++) { for (let dx = 0; dx < BAR_W; dx++) {
const x = ix + dx; if (x < 0 || x >= W) continue; const x = ix + dx; if (x < 0 || x >= W) continue;
const j = yb * W + x, m = mat[j]; const j = yb * W + x, m = mat[j];
if (m === BELTL || m === BELTR || part[j] === P_FILTER) transport = true;
if (m === BELTL || m === BELTR) { const s = netSat(j); if (s > bsat) { bsat = s; belt = m === BELTR ? 1 : -1; } } if (m === BELTL || m === BELTR) { const s = netSat(j); if (s > bsat) { bsat = s; belt = m === BELTR ? 1 : -1; } }
else if (part[j] === P_FILTER && !filterPassAt(e.m, j) && (yb & 3) === 0) { bsat = 1; belt = blockV[(yb >> 2) * BW + (x >> 2)] & 128 ? 1 : -1; } else if (part[j] === P_FILTER && !filterPassAt(e.m, j) && (yb & 3) === 0) { bsat = 1; belt = blockV[(yb >> 2) * BW + (x >> 2)] & 128 ? 1 : -1; }
if (entSolidCell(j)) support = true; if (entSolidCell(j)) support = true;
} }
} }
if (support && !transport && e.vy === 0 && Math.abs(e.vx) < 0.05) {
const n = entSupportCount(ix, iy);
if (n < 2) {
let left = 0, right = 0;
for (let dx = 0; dx < BAR_W; dx++) if (!entSolidCell((iy + BAR_H) * W + ix + dx)) {
if (dx < BAR_W / 2) left++; else right++;
}
const dir = left > right ? -1 : right > left ? 1 : ((ix + iy) & 1) ? 1 : -1;
if (entFreeAt(ix + dir, iy)) ix += dir;
} else if (ix & 1) {
for (let dir = -1; dir <= 1; dir += 2) if (entFreeAt(ix + dir, iy) && entSupportCount(ix + dir, iy) >= 2) { ix += dir; break; }
}
if (ix !== e.ix) { e.x = e.ix = ix; support = entSupportCount(ix, iy) > 0; }
}
const ci = Math.min(H - 1, iy + 1) * W + Math.min(W - 1, ix + 1); const ci = Math.min(H - 1, iy + 1) * W + Math.min(W - 1, ix + 1);
const inLiq = PH[mat[ci]] === PH_LIQUID; const inLiq = PH[mat[ci]] === PH_LIQUID;
if (support) { if (e.vy > 0) e.vy = 0; e.vx = belt ? belt * 0.5 * bsat : e.vx * 0.55; } if (support) { if (e.vy > 0) e.vy = 0; e.vx = belt ? belt * 0.5 * bsat : e.vx * 0.55; }
@@ -58,18 +149,25 @@ function entTick() {
e.x += e.vx; e.y += e.vy; e.x += e.vx; e.y += e.vy;
// step toward the new rounded position one cell at a time // step toward the new rounded position one cell at a time
let tx = Math.round(e.x), ty = Math.round(e.y), cx = ix, cy = iy; let tx = Math.round(e.x), ty = Math.round(e.y), cx = ix, cy = iy;
while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else { e.vx = 0; e.x = cx; break; } } while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else if (belt && entTubeEnter(e, s, 0)) { cx = e.ix; cy = e.iy; e.x = cx; e.y = cy; break; } else { e.vx = 0; e.x = cx; break; } }
while (cy !== ty) { const s = Math.sign(ty - cy); if (entFreeAt(cx, cy + s)) cy += s; else { e.vy = 0; e.y = cy; break; } } while (cy !== ty) { const s = Math.sign(ty - cy); if (entTubeAt(cx, cy)) break; if (entFreeAt(cx, cy + s)) cy += s; else if (entTubeEnter(e, 0, s)) { cx = e.ix; cy = e.iy; break; } else { e.vy = 0; e.y = cy; break; } }
if (!entFreeAt(cx, cy)) { if (support && !belt && entTubeEnter(e, 0, 1)) { cx = e.ix; cy = e.iy; }
if (!entTubeAt(cx, cy) && !entFreeAt(cx, cy)) {
// something moved into us (a grain fell in): try to step up out of it // something moved into us (a grain fell in): try to step up out of it
let freed = false; let freed = false;
for (let u = 1; u < 6; u++) if (entFreeAt(cx, cy - u)) { cy -= u; e.y = cy; freed = true; break; } for (let u = 1; u < 6; u++) if (entFreeAt(cx, cy - u)) { cy -= u; e.y = cy; freed = true; break; }
if (!freed) e.y = cy; if (!freed) e.y = cy;
} }
e.ix = cx; e.iy = cy; e.ix = cx; e.iy = cy;
}
if (rackIntake(e)) { ents.splice(k, 1); continue; }
const cx = e.ix, cy = e.iy;
// heat: the bar tends toward the temperature around it // heat: the bar tends toward the temperature around it
const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)]; const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)];
e.T += (T - e.T) * 0.04; e.T += (T - e.T) * 0.04;
if (FIRETO[e.m] && e.T >= 880 && rnd() < 0.004 + (e.T - 880) * 0.00012) {
e.m = FIRETO[e.m]; discover(e.m, e.ix, e.iy, GREENB, 'fire', 880);
}
if (e.T >= MELT[e.m]) { if (e.T >= MELT[e.m]) {
// melts back into 8 grains of molten metal if they fit here // melts back into 8 grains of molten metal if they fit here
let fits = true; let fits = true;
@@ -131,4 +229,4 @@ function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? mo
function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } } function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); } function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); }
function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); } function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); }
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(cost, mult); } function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(c ? [c] : cost, mult) + '.'; }
+13 -2
View File
@@ -5,7 +5,7 @@ function checkLedger() {
const mark = id => { if (!ledger[id]) { ledger[id] = tick || 1; emit({ t: 'milestone', id }); } }; const mark = id => { if (!ledger[id]) { ledger[id] = tick || 1; emit({ t: 'milestone', id }); } };
if (stats.dug >= 15) mark('dig'); if (stats.dug >= 15) mark('dig');
if (seen[FIRE]) { mark('fire'); if (!player.lens) { player.lens = true; } } if (seen[FIRE]) { mark('fire'); if (!player.lens) { player.lens = true; } }
if (seen[BRICK] || seen[RBRICK]) mark('brick'); if (seen[BRICK] || seen[RBRICK] || seen[BARBR]) mark('brick');
if (seen[CHARCOAL]) mark('charcoal'); if (seen[CHARCOAL]) mark('charcoal');
if (stats.grown > 0) mark('grow'); if (stats.grown > 0) mark('grow');
if (seen[MCU]) mark('copper'); if (seen[MCU]) mark('copper');
@@ -35,7 +35,18 @@ function checkLedger() {
// Topmost non-air cell of each column: air above it can shed heat to the sky. // Topmost non-air cell of each column: air above it can shed heat to the sky.
function updateRoof() { function updateRoof() {
for (let x = 0; x < W; x++) { scanRoof(0, W);
roofDirty.fill(0);
}
function updateDirtyRoof() {
for (let cx = 0; cx < CW; cx++) {
if (!roofDirty[cx]) continue;
scanRoof(cx << 5, Math.min(W, (cx + 1) << 5));
roofDirty[cx] = 0;
}
}
function scanRoof(x0, x1) {
for (let x = x0; x < x1; x++) {
let y = 0; let y = 0;
for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; } for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; }
roofY[x] = y; roofY[x] = y;
+23 -17
View File
@@ -45,6 +45,8 @@ function fanBlow(mm, sat) {
} }
} }
function machinesTick() { function machinesTick() {
procBlocksTick();
rackTick();
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; } if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
if (brokenMachines.length) { if (brokenMachines.length) {
const ids = brokenMachines; brokenMachines = []; const ids = brokenMachines; brokenMachines = [];
@@ -56,10 +58,6 @@ function machinesTick() {
const inst = mm.flow * WHEEL_K; const inst = mm.flow * WHEEL_K;
mm.out += (inst - mm.out) * 0.04; mm.flow = 0; mm.out += (inst - mm.out) * 0.04; mm.flow = 0;
mm.angle += Math.min(0.35, mm.out * 0.012); mm.angle += Math.min(0.35, mm.out * 0.012);
} else if (mm.type === 'refiner') {
refinerTick(mm);
} else if (mm.type === 'cutter') {
cutterTick(mm);
} else if (mm.type === 'silo') { } else if (mm.type === 'silo') {
siloTick(mm); siloTick(mm);
} else if (mm.type === 'engine') { } else if (mm.type === 'engine') {
@@ -82,11 +80,11 @@ function machinesTick() {
const sat = POWER_FREE ? 1 : net ? net.sat : 0; const sat = POWER_FREE ? 1 : net ? net.sat : 0;
mm.angle += sat * 0.5 * (mm.on ? 1 : 0); mm.angle += sat * 0.5 * (mm.on ? 1 : 0);
if (mm.on && sat > 0.05) fanBlow(mm, sat); if (mm.on && sat > 0.05) fanBlow(mm, sat);
// a fan getting close to melting says so (once every few seconds) // Keep the measured heat for the marker and hover panel.
if ((tick + mm.id) % 30 === 0) { if ((tick + mm.id) % 30 === 0) {
let hotT = 0; let hotT = 0;
for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; } for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; }
if (hotT > 760 && (!mm.warnT || tick - mm.warnT > 600)) { mm.warnT = tick; emit({ t: 'toast', text: `A fan is at ${Math.round(hotT)}°C and melts at 1085°C. Move it out of the heat: outside the furnace wall, blowing in through a grate.` }); } mm.hot = Math.round(hotT);
} }
} }
// belts and filters move what rests on them every other tick: half a grain per tick // belts and filters move what rests on them every other tick: half a grain per tick
@@ -113,12 +111,12 @@ function machinesTick() {
const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0; const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0;
for (let k = 0; k < list.length; k++) { for (let k = 0; k < list.length; k++) {
const i = list[k]; const i = list[k];
if (part[i] !== p) continue; if (part[i] !== p || occ[i]) continue;
const m = mat[i]; const m = mat[i];
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; } if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; }
if (stamp[i] === tickStamp) continue; if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) { if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i); part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i);
continue; continue;
} }
const s = netSat(i); const s = netSat(i);
@@ -126,6 +124,22 @@ function machinesTick() {
const x = i % W, y = (i / W) | 0; const x = i % W, y = (i / W) | 0;
// a sideways screw carries its load along the bottom: grains settle into empty lanes below // a sideways screw carries its load along the bottom: grains settle into empty lanes below
if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; } if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; }
if (!flat) {
let own = 0, shifted = false;
const end = dy < 0 ? (y & ~3) : (y | 3);
for (let yy = y; dy < 0 ? yy >= end : yy <= end; yy += dy) if (PH[mat[yy * W + x]] === PH_POWDER) own++;
const side = ((tick >> 1) + i) & 1 ? 1 : -1;
for (let c = 0; c < 2; c++) {
const sx = c ? -side : side, xx = x + sx, j = i + sx;
if ((xx >> 2) !== (x >> 2) || part[j] !== p || !roomFor(j) || mat[j] !== EMPTY || occ[j] || stamp[j] === tickStamp) continue;
let load = 0;
for (let yy = y; dy < 0 ? yy >= end : yy <= end; yy += dy) if (PH[mat[yy * W + xx]] === PH_POWDER) load++;
// Include this grain: a strict gap prevents a sideways step being undone next beat.
if (own <= load + 1) continue;
move(i, j, x, y, xx, y); stats.moved++; shifted = true; break;
}
if (shifted) continue;
}
const nx = x + dx, ny = y + dy; const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = i + dj; const j = i + dj;
@@ -143,15 +157,7 @@ function machinesTick() {
move(i, j, x, y, nx, ny); stats.moved++; move(i, j, x, y, nx, ny); stats.moved++;
if (tube && pj !== p) { if (tube && pj !== p) {
// turning a corner: keep going across the new tube's width so every lane fills // turning a corner: keep going across the new tube's width so every lane fills
let c = j, cx = nx, cy = ny; tubeSpread(j, nx, ny, dx, dy);
const bxj = nx >> 2, byj = ny >> 2;
for (;;) {
const ex = cx + dx, ey = cy + dy;
if (ex < 0 || ey < 0 || ex >= W || ey >= H || (ex >> 2) !== bxj || (ey >> 2) !== byj) break;
const e = ey * W + ex;
if (part[e] !== pj || !roomFor(e) || occ[e]) break;
move(c, e, cx, cy, ex, ey); c = e; cx = ex; cy = ey;
}
} }
} }
} }
+2 -8
View File
@@ -76,9 +76,6 @@ function machineFootprint(type, x, y) {
} }
} else if (type === 'fan') { } else if (type === 'fan') {
for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) out.push([x + dx, y + dy, 'hull']); for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) out.push([x + dx, y + dy, 'hull']);
} else if (type === 'cutter') {
// a hopper 4 grains wide over the saw floor; bricks drop out underneath
for (let dy = 0; dy < 8; dy++) for (let dx = 0; dx < 8; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 6 || dy >= 5 ? 'hull' : 'void']);
} else if (type === 'silo') { } else if (type === 'silo') {
// roof with a hatch, the body, a cone narrowing to the gate, and two legs; the gaps beside // roof with a hatch, the body, a cone narrowing to the gate, and two legs; the gaps beside
// the cone are left open // the cone are left open
@@ -92,9 +89,6 @@ function machineFootprint(type, x, y) {
else k = dx >= 4 && dx <= 7 ? 'void' : dx === 3 || dx === 8 ? 'hull' : null; // the gate else k = dx >= 4 && dx <= 7 ? 'void' : dx === 3 || dx === 8 ? 'hull' : null; // the gate
if (k) out.push([x + dx, y + dy, k]); if (k) out.push([x + dx, y + dy, k]);
} }
} else if (type === 'refiner') {
// an open hopper 8 grains wide over the grinding floor; what it lets out drops from under the floor
for (let dy = 0; dy < 12; dy++) for (let dx = 0; dx < 12; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 10 || dy >= 10 ? 'hull' : 'void']);
} else if (type === 'dock') { } else if (type === 'dock') {
// an open-topped hopper: 2-grain walls and floor around a 20x14 bin // an open-topped hopper: 2-grain walls and floor around a 20x14 bin
for (let dy = 0; dy < 16; dy++) for (let dx = 0; dx < 24; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 22 || dy >= 14 ? 'hull' : 'void']); for (let dy = 0; dy < 16; dy++) for (let dx = 0; dx < 24; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 22 || dy >= 14 ? 'hull' : 'void']);
@@ -112,7 +106,7 @@ function canPlaceMachine(type, bx, by) {
for (const [cx, cy] of machineFootprint(type, bx * BS, by * BS)) { for (const [cx, cy] of machineFootprint(type, bx * BS, by * BS)) {
const i = cy * W + cx; const i = cy * W + cx;
if (mach[i] || part[i]) return 'Something is already built there'; if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there'; if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space; clear the rock first.' : 'Something is already built there';
if (occ[i]) return 'A bar is in the way'; if (occ[i]) return 'A bar is in the way';
} }
for (let y = by; y < by + P.bh; y++) for (let x = bx; x < bx + P.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there'; for (let y = by; y < by + P.bh; y++) for (let x = bx; x < bx + P.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
@@ -150,5 +144,5 @@ function removeMachine(id, refund, scrap) {
} }
machines[id] = null; machines[id] = null;
if (refund) { refundAt(PIECE_BY_ID[mm.type].cost, 1, mm.x + mm.w / 2, mm.y + mm.h / 2); undoNote({ t: 'mach-', type: mm.type, bx: mm.x / BS, by: mm.y / BS, dir: mm.dir, on: mm.on }); } if (refund) { refundAt(PIECE_BY_ID[mm.type].cost, 1, mm.x + mm.w / 2, mm.y + mm.h / 2); undoNote({ t: 'mach-', type: mm.type, bx: mm.x / BS, by: mm.y / BS, dir: mm.dir, on: mm.on }); }
if (scrap) emit({ t: 'toast', text: `${PIECE_BY_ID[mm.type].name} was destroyed by heat.` }); if (scrap) emit({ t: 'warn', x: mm.x + mm.w / 2, y: mm.y, text: `${PIECE_BY_ID[mm.type].name} destroyed` });
} }
+49
View File
@@ -0,0 +1,49 @@
// Material tables: typed properties, casting groups, flags, parts and tool tiers.
// Typed property tables
const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT),
HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT),
SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT),
BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT),
FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT),
ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT),
ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT),
RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT),
CVAR = new Uint8Array(NMAT);
for (let i = 0; i < NMAT; i++) {
const d = MD[i];
PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld;
MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo;
BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame;
FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread;
STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0;
COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v;
RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004;
}
// what molds cast (molten metals, and molten glass) and what they set into
const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1;
// tier as a drill bit or tank (0: too soft to be a bit)
const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3;
METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1;
const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1;
// ---------- Flags & parts ----------
const F_NAT = 1, F_CAST = 2, F_BURN = 4;
const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10;
const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0];
// ---------- Tool tiers ----------
// reach: how far from the bot the drill works. Harder rock takes longer, and
// every new bit cuts faster and further.
const BIT_TIERS = [
{ name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 },
{ name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 },
{ name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 },
{ name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 },
];
const TANK_TIERS = [
{ name: 'Canvas sack', cap: 1500 },
{ name: 'Copper tank', cap: 4000 },
{ name: 'Iron tank', cap: 10000 },
{ name: 'Steel tank', cap: 25000 },
];
const REACH = 64;
+13 -53
View File
@@ -1,4 +1,4 @@
// Material ids, the material table (def), per-material property arrays, cell flags and part // Material definitions: ids, properties and metal families.
// ids, and drill/tank tiers. // ids, and drill/tank tiers.
// ---------- Material ids ---------- // ---------- Material ids ----------
const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9, const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9,
@@ -11,8 +11,8 @@ const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6,
ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68, ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68,
AGV = 69, AGORE = 70, SILVER = 71, MAG = 72, SCRAG = 73, BARAG = 74, AGV = 69, AGORE = 70, SILVER = 71, MAG = 72, SCRAG = 73, BARAG = 74,
AUV = 75, AUORE = 76, GOLD = 77, MAU = 78, SCRAU = 79, BARAU = 80, AUV = 75, AUORE = 76, GOLD = 77, MAU = 78, SCRAU = 79, BARAU = 80,
BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94; BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94, GRINDER = 95, SAWB = 96, PLANK = 97, PRESSB = 98, GREENB = 99, BARBR = 100, RACKB = 101;
const NMAT = 95; const NMAT = 102;
const MD = []; const MD = [];
function def(id, o) { function def(id, o) {
@@ -62,6 +62,10 @@ def(BELTL, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085
def(BELTR, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true }); def(BELTR, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
def(SHAFT, { name: 'Shaft', col: [150, 110, 64], v: 6, k: 0.005, hard: 2, ign: 340, flame: 800, flameF: 1000, fuel: 140, burnTo: ASH, ashP: 0.3, partMat: true }); def(SHAFT, { name: 'Shaft', col: [150, 110, 64], v: 6, k: 0.005, hard: 2, ign: 340, flame: 800, flameF: 1000, fuel: 140, burnTo: ASH, ashP: 0.3, partMat: true });
def(GATE, { name: 'Gate', col: [146, 74, 58], v: 6, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, partMat: true }); def(GATE, { name: 'Gate', col: [146, 74, 58], v: 6, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, partMat: true });
def(GRINDER, { name: 'Grinder', col: [64, 70, 80], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(SAWB, { name: 'Saw', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(RACKB, { name: 'Bar rack', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(PRESSB, { name: 'Brick press', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' }); def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' });
def(RSTONE, { name: 'Stone rubble', ph: PH_POWDER, col: [126, 125, 130], v: 30, dens: 1.8, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Broken stone. 16 grains make a stone block.' }); def(RSTONE, { name: 'Stone rubble', ph: PH_POWDER, col: [126, 125, 130], v: 30, dens: 1.8, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Broken stone. 16 grains make a stone block.' });
def(RGRAN, { name: 'Granite rubble', ph: PH_POWDER, col: [164, 128, 124], v: 32, dens: 1.9, k: 0.01, hard: 0, melt: 1260, meltTo: MAGMA, bag: true, desc: 'Broken granite. Makes granite blocks.' }); def(RGRAN, { name: 'Granite rubble', ph: PH_POWDER, col: [164, 128, 124], v: 32, dens: 1.9, k: 0.01, hard: 0, melt: 1260, meltTo: MAGMA, bag: true, desc: 'Broken granite. Makes granite blocks.' });
@@ -103,57 +107,13 @@ metalFamily({ solid: BRONZE, molten: MBZ, scrap: SCRBZ, bar: BARBZ, name: 'Bronz
metalFamily({ solid: BRASS, molten: MBS, scrap: SCRBS, bar: BARBS, name: 'Brass', col: [212, 174, 84], mcol: [255, 196, 104], melt: 930, dens: 8.4, k: 0.035, hardSolid: 4, metalFamily({ solid: BRASS, molten: MBS, scrap: SCRBS, bar: BARBS, name: 'Brass', col: [212, 174, 84], mcol: [255, 196, 104], melt: 930, dens: 8.4, k: 0.035, hardSolid: 4,
desc: 'Copper with zinc melted in. Golden and valuable.' }); desc: 'Copper with zinc melted in. Golden and valuable.' });
def(BARGL, { name: 'Glass brick', ph: PH_ITEM, col: [176, 218, 228], v: 0, dens: 2.5, k: 0, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Cast from molten glass in an Ingot mold: 8 grains of glass. Sells well, and 2 make a glass wall block.' }); def(BARGL, { name: 'Glass brick', ph: PH_ITEM, col: [176, 218, 228], v: 0, dens: 2.5, k: 0, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Cast from molten glass in an Ingot mold: 8 grains of glass. Sells well, and 2 make a glass wall block.' });
def(PLANK, { name: 'Plank', ph: PH_ITEM, col: [204, 158, 104], v: 0, dens: 0.6, k: 0, hard: 0, melt: 9999, bag: true, desc: 'Sawn from 8 wood chunks by a Saw. Builders buy them, and 2 make a wood wall block.' });
def(GREENB, { name: 'Green brick', ph: PH_ITEM, col: [202, 164, 136], v: 0, dens: 1.8, k: 0, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Unfired clay pressed into shape by a Brick press. Fire it above ~900°C to make a Brick.' });
def(BARBR, { name: 'Brick', ph: PH_ITEM, col: [174, 76, 56], v: 0, dens: 1.8, k: 0, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Fired clay brick. Builders buy them, and 2 make a brick wall block.' });
// An item that fires into another item when hot.
const FIRETO = new Uint8Array(NMAT); FIRETO[GREENB] = BARBR;
def(CUTST, { name: 'Cut stone', col: [168, 162, 150], v: 6, k: 0.012, hard: 4, melt: 1450, meltTo: MAGMA, yld: BARSB, desc: 'Dressed stone blocks laid in courses. Neater than rough stone, and it stands 200°C more heat before it slumps.' }); def(CUTST, { name: 'Cut stone', col: [168, 162, 150], v: 6, k: 0.012, hard: 4, melt: 1450, meltTo: MAGMA, yld: BARSB, desc: 'Dressed stone blocks laid in courses. Neater than rough stone, and it stands 200°C more heat before it slumps.' });
def(BARSB, { name: 'Stone brick', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Stone cutter, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' }); def(BARSB, { name: 'Stone block', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Saw, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' });
def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true }); def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
def(DOCKW, { name: 'Trade dock', col: [70, 82, 96], v: 6, k: 0.01, cap: 2, hard: 9, yld: 0, partMat: true, desc: 'The claim office\'s trade dock. Whatever drops through the grate in its floor is sold.' }); def(DOCKW, { name: 'Trade dock', col: [70, 82, 96], v: 6, k: 0.01, cap: 2, hard: 9, yld: 0, partMat: true, desc: 'The claim office\'s trade dock. Whatever drops through the grate in its floor is sold.' });
def(HULL, { name: 'Machine', col: [84, 92, 102], v: 6, k: 0.03, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true }); def(HULL, { name: 'Machine', col: [84, 92, 102], v: 6, k: 0.03, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
// Typed property tables
const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT),
HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT),
SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT),
BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT),
FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT),
ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT),
ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT),
RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT),
CVAR = new Uint8Array(NMAT);
for (let i = 0; i < NMAT; i++) {
const d = MD[i];
PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld;
MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo;
BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame;
FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread;
STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0;
COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v;
RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004;
}
// what molds cast (molten metals, and molten glass) and what they set into
const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1;
// tier as a drill bit or tank (0: too soft to be a bit)
const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3;
METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1;
const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1;
// ---------- Flags & parts ----------
const F_NAT = 1, F_CAST = 2, F_BURN = 4;
const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10;
const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0];
// ---------- Tool tiers ----------
// reach: how far from the bot the drill works. Harder rock takes longer, and
// every new bit cuts faster and further.
const BIT_TIERS = [
{ name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 },
{ name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 },
{ name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 },
{ name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 },
];
const TANK_TIERS = [
{ name: 'Canvas sack', cap: 1500 },
{ name: 'Copper tank', cap: 4000 },
{ name: 'Iron tank', cap: 10000 },
{ name: 'Steel tank', cap: 25000 },
];
const REACH = 64;
+92 -59
View File
@@ -1,4 +1,4 @@
// Molds: cavities that fill with molten metal (or glass) and are knocked out as bars. // Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
// ---------- Molds ---------- // ---------- Molds ----------
function moldCellsAt(kind, x, y) { function moldCellsAt(kind, x, y) {
const md = MOLDS[kind]; const walls = []; const md = MOLDS[kind]; const walls = [];
@@ -11,13 +11,13 @@ function canPlaceMold(kind, bx, by) {
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds'; if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there'; for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS); const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS);
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there'; for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space; clear the rock first.' : 'Something is already built there';
if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there'; if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there';
return null; return null;
} }
function newMoldRecord(kind, x, y, wall) { function newMoldRecord(kind, x, y, wall) {
const md = MOLDS[kind]; const md = MOLDS[kind];
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '' }; return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '', blocked: false };
} }
function addMold(kind, bx, by, wallLoose, free) { function addMold(kind, bx, by, wallLoose, free) {
const md = MOLDS[kind]; const md = MOLDS[kind];
@@ -34,7 +34,7 @@ function addMold(kind, bx, by, wallLoose, free) {
return mo; return mo;
} }
// Slag and ash that reach a mold float on the metal. They are chipped off and thrown // Slag and ash that reach a mold float on the metal. They are chipped off and thrown
// away when the casting is knocked out, so they never spoil it unless metal set over them. // away automatically in ingot troughs, or when other castings are knocked out.
const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1; const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1;
// Two metals in one casting: iron with some steel in it (air got at it) casts as plain iron; // Two metals in one casting: iron with some steel in it (air got at it) casts as plain iron;
// anything else is a mix that spoils it. Returns the metal the casting counts as, or -1. // anything else is a mix that spoils it. Returns the metal the casting counts as, or -1.
@@ -68,7 +68,7 @@ function cavityState(cav) {
return { st, fill: (solid + liq) / n, metal, flaw }; return { st, fill: (solid + liq) / n, metal, flaw };
} }
// The ingot trough: ready as soon as at least one bar's worth has set and nothing is still molten. // The ingot trough: ready as soon as at least one bar's worth has set and nothing is still molten.
// It is forgiving: slag, ash or ore that got in is skimmed off when you knock the ingot out. // Bars, junk and mixed scrap drop automatically; slag is discarded. Short stubs stay on the floor.
function troughState(mo) { function troughState(mo) {
let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false; let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false;
for (const i of mo.cavs[0]) { for (const i of mo.cavs[0]) {
@@ -79,8 +79,8 @@ function troughState(mo) {
} }
const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length; const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length;
const st = liq ? 'filling' : bars ? 'ready' : mixed ? 'flawed' : solid ? 'short' : junk ? 'flawed' : 'empty'; const st = liq ? 'filling' : bars ? 'ready' : mixed ? 'flawed' : solid ? 'short' : junk ? 'flawed' : 'empty';
const flaw = st === 'flawed' ? (mixed ? 'Two different metals mixed in the ingot. Press F to knock it out as scrap.' : 'Only junk in the trough. Press F to knock it out.') const flaw = st === 'flawed' ? (mixed ? 'Two metals mixed. It drops out below as scrap.' : 'Junk in the trough. It drops out below when there is room.')
: st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}. Fire again to top it up, or press F to knock it out as scrap.` : ''; : st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}; the next firing tops it up.` : '';
return { st, bars, fill: (solid + liq) / n, metal, flaw }; return { st, bars, fill: (solid + liq) / n, metal, flaw };
} }
// Empties cells: metal goes to your bag (or beside you if the bag is full) as scrap, other loose // Empties cells: metal goes to your bag (or beside you if the bag is full) as scrap, other loose
@@ -119,7 +119,7 @@ function moldPlugCells(mo) {
} }
return out; return out;
} }
// Knocking a mold out also rakes the furnace over it: slag and ash lying on the grate and in // Ingot ejection or knocking a casting out also rakes the furnace over it: slag and ash lying on the grate and in
// the charge above (up to the furnace roof) are cleared away, so the next charge sits on a // the charge above (up to the furnace roof) are cleared away, so the next charge sits on a
// clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked. // clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked.
function rakeAbove(mo) { function rakeAbove(mo) {
@@ -132,7 +132,7 @@ function rakeAbove(mo) {
if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8); if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8);
return n; return n;
} }
// After a knock-out: if the furnace over the mold is badly loaded, say what to change // Hover advice when the furnace over the mold is badly loaded.
function furnaceJunkHint(mo) { function furnaceJunkHint(mo) {
let ore = 0, fuel = 0, open = 0; let ore = 0, fuel = 0, open = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) { for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
@@ -159,13 +159,68 @@ function unplugMold(mo) {
return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) }; return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) };
} }
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`; const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
const barWord = (m, n) => m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
function ejectIngot(mo) {
mo.blocked = false;
let r = troughState(mo), n = 0;
const metal = r.metal, x = mo.x + (mo.w >> 1), y = mo.y + mo.h;
if (r.st === 'ready') {
let T = 0, solid = 0, free = false;
for (const i of mo.cavs[0]) if (SOLIDMETAL[mat[i]] && castMix(metal, mat[i]) === metal) { T += temp[i]; solid++; }
T /= Math.max(1, solid);
for (let k = 0; k < 3 && n < r.bars; k++) {
const sx = mo.x + 1 + k * 4;
if (!entFreeAt(sx, y)) continue;
free = true;
if (spawnBar(BAR_OF[metal], sx, y, (rnd() - 0.5) * 0.15, 0.2, T)) n++;
}
mo.blocked = !n && !free;
if (n) {
// Take from the top so the short stub stays on the trough floor.
let take = n * GRAINS_PER_BAR;
for (const i of mo.cavs[0].slice().sort((a, b) => a - b)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(metal, mat[i]) === metal) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
const first = stats.cast === 0;
stats.bars += n; stats.cast++; mo.blockedSaid = false;
emit({ t: 'sfx', s: 'cast' });
emit({ t: 'ingot_drop', mold: mo.id, metal, bars: n, x, y, first });
r = troughState(mo);
} else if (!free && !mo.blockedSaid) { mo.blockedSaid = true; emit({ t: 'ingot_blocked', mold: mo.id, x, y }); }
}
let molten = mo.cavs[0].some(i => CASTMETAL[mat[i]]), pushed = 0;
for (let yy = Math.max(0, mo.y - 8); yy < mo.y + mo.h && !molten; yy++) for (let xx = mo.x; xx < mo.x + mo.w; xx++) if (CASTMETAL[mat[yy * W + xx]]) { molten = true; break; }
if (!molten) {
const mixed = r.st === 'flawed' && r.metal >= 0;
const plug = moldPlugCells(mo), cavity = mo.cavs[0];
let slot = 0;
for (const i of cavity.concat(plug)) {
const m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m] || (SOLIDMETAL[m] && !mixed && !plug.includes(i))) continue;
if (!DROSS[m]) {
const loose = SOLIDMETAL[m] ? (YLD[m] || SCRCU) : PH[m] === PH_SOLID ? YLD[m] : m;
if (!loose) continue;
let placed = false;
for (; slot < mo.w * Math.min(4, H - y); slot++) {
const dest = (y + ((slot / mo.w) | 0)) * W + mo.x + slot % mo.w;
if ((mat[dest] === EMPTY || PH[mat[dest]] === PH_GAS) && !occ[dest] && !part[dest]) {
setCell(dest, loose, temp[i], 0); slot++; placed = true; break;
}
}
if (!placed) { mo.blocked = true; continue; }
}
setCell(i, EMPTY, AMB[(i / W) | 0], 0); pushed++;
}
}
if (n || pushed) rakeAbove(mo);
r = troughState(mo);
if (r.st === 'empty') mo.blockedSaid = false;
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
return n;
}
function checkMolds() { function checkMolds() {
for (const mo of molds) { for (const mo of molds) {
if (mo.kind === 'ingot') { if (mo.kind === 'ingot') {
const r = troughState(mo); ejectIngot(mo);
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
if (r.st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: r.metal, bars: r.bars, x: mo.x + (mo.w >> 1), y: mo.y });
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
continue; continue;
} }
const res = mo.cavs.map(cavityState); const res = mo.cavs.map(cavityState);
@@ -188,95 +243,73 @@ function moldAt(x, y) {
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo; for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
return null; return null;
} }
// What a knock-out sent where, as one short sentence after the main message
function knockNote(scrap, other, chipped, raked) {
let s = '';
if (scrap) s += ` The leftover ${grains(scrap)} of scrap went into your bag${other ? ', with the grit that got in' : ''}.`;
else if (other) s += ' The grit that got in went into your bag.';
if (chipped && raked) s += ' The slag was chipped off and the grate raked clean.';
else if (chipped) s += ' The slag was chipped off.';
else if (raked) s += ' Slag and ash were raked off the grate.';
return s;
}
function tally(got) { function tally(got) {
let scrap = 0, other = 0; let scrap = 0, other = 0;
for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; } for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; }
return { scrap, other, dross: got.dross || 0 }; return { scrap, other, dross: got.dross || 0 };
} }
function collectMold(mo) { function collectMold(mo) {
if (mo.kind === 'ingot') return 'Ingot molds empty themselves.';
if (mo.state === 'filling') return 'The metal has not fully set yet.'; if (mo.state === 'filling') return 'The metal has not fully set yet.';
if (mo.state === 'empty') { if (mo.state === 'empty') {
const g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo); const g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
const scrap = g.scrap + u.metal; const scrap = g.scrap + u.metal;
if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.'; if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.';
emit({ t: 'sfx', s: 'ui' }); emit({ t: 'sfx', s: 'ui' });
return 'Knocked the mold clean.' + knockNote(scrap, g.other, g.dross + u.chipped, u.raked); return 'Knocked the mold clean.';
} }
let msg = ''; let msg = '';
if (mo.kind === 'ingot') {
const cav = mo.cavs[0];
let solid = 0, T = 0;
const m = mo.metal;
if (mo.state === 'ready') for (const i of cav) if (SOLIDMETAL[mat[i]]) { solid++; T += temp[i]; }
T /= Math.max(1, solid);
const bars = Math.floor(solid / GRAINS_PER_BAR);
// the ingot snaps into bars that pop up and out of the trough; take the metal for them first
let take = bars * GRAINS_PER_BAR;
for (const i of cav.slice().sort((a, b) => b - a)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(m, mat[i]) === m) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
let n = 0;
for (let k = 0; k < bars; k++) {
const e = spawnBar(BAR_OF[m], mo.x + 1 + k * 4, mo.y - 3, (k - (bars - 1) / 2) * 0.3 + (rnd() - 0.5) * 0.15, -0.55, T);
if (e) n++; else bagAdd(BAR_OF[m], 1, true);
}
// the stub too short for a bar, and anything else that got in, are cleared out: the trough is left clean
const u = unplugMold(mo), g = tally(clearCavity(cav));
stats.bars += n; if (n) stats.cast++;
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: n ? 'cast' : 'ui' });
const word = m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
return (n ? `Knocked out ${n} ${word}. Vacuum them up.` : 'Knocked the trough clean.') + knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
}
if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) { if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) {
// an unfinished casting can still be topped up, so the first press only says so // an unfinished casting can still be topped up, so the first press only says so
mo.knockAsk = tick; mo.knockAsk = tick;
return (mo.flaw || 'The casting is not full yet.').replace(', or press F to knock it out as scrap.', '.') + ' Press F again to knock it out as scrap.'; return 'Not full; press F again to knock it out as scrap.';
} }
if (mo.state === 'flawed' || mo.state === 'short') { if (mo.state === 'flawed' || mo.state === 'short') {
// a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go // a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go
const short = mo.state === 'short', g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo); const short = mo.state === 'short'; clearCavity(mo.cavs.flat()); unplugMold(mo);
mo.state = 'empty'; mo.ready = []; mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: 'ui' }); emit({ t: 'sfx', s: 'ui' });
const what = mo.kind === 'bit' ? 'bit' : 'casting'; const what = mo.kind === 'bit' ? 'bit' : 'casting';
return (short ? `The ${what} was not full, so it was knocked out as scrap.` : `The ${what} was spoiled, so it was knocked out as scrap.`) return (short ? `Unfinished ${what} knocked out as scrap.` : `Spoiled ${what} knocked out as scrap.`);
+ knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
} }
const tier = METAL_TIER[mo.metal]; const tier = METAL_TIER[mo.metal];
const cav = mo.cavs[0]; const cav = mo.cavs[0];
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}. The casting broke into scrap, which went into your bag.`; } if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}; returned as scrap.`; }
else if (mo.kind === 'bit') { else if (mo.kind === 'bit') {
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); } if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; } else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}; returned as scrap.`; }
} else { } else {
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); } if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; } else { msg = 'Your tank is already as good; returned as scrap.'; }
} }
// a casting you could not use breaks into scrap, which goes to your bag // a casting you could not use breaks into scrap, which goes to your bag
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0); if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
else clearCavity(cav); else clearCavity(cav);
const u = unplugMold(mo); unplugMold(mo);
mo.state = 'empty'; stats.cast++; mo.state = 'empty'; stats.cast++;
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo); return msg;
} }
function removeMold(mo) { function removeMold(mo) {
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall }); undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
const cavity = mo.cavs.flat(), set = cavity.filter(i => SOLIDMETAL[mat[i]]);
let cast = -1, mixed = false;
for (const i of set) { if (cast < 0) cast = mat[i]; else { const c = castMix(cast, mat[i]); if (c < 0) mixed = true; else cast = c; } }
const cx = mo.x + mo.w / 2, cy = mo.y + mo.h / 2;
const bars = mixed ? 0 : Math.floor(set.length / GRAINS_PER_BAR);
const scrap = new Map();
for (let k = 0; k < set.length; k++) {
const i = set[k], m = mixed ? mat[i] : cast;
if (k >= bars * GRAINS_PER_BAR) { const loose = YLD[m] || SCRCU; scrap.set(loose, (scrap.get(loose) || 0) + 1); }
setCell(i, EMPTY, AMB[(i / W) | 0], 0);
}
if (bars) giveOrDrop(BAR_OF[cast], bars, cx, cy);
for (const [m, n] of scrap) giveOrDrop(m, n, cx, cy);
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y); const { walls } = moldCellsAt(mo.kind, mo.x, mo.y);
let metal = 0; let metal = 0;
for (const i of walls) { for (const i of walls) {
const m = mat[i]; const m = mat[i];
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) {
if (BAR_OF[m] && PH[mo.wall] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); } metal += removeWallCell(i, mo.wall);
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; }
else setCell(i, EMPTY, temp[i], 0);
} }
} }
if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y); if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y);
+15 -12
View File
@@ -4,7 +4,7 @@
// Everything you build snaps to a grid of 4x4-grain blocks. The world itself // Everything you build snaps to a grid of 4x4-grain blocks. The world itself
// stays grain-level; only built pieces snap. // stays grain-level; only built pieces snap.
const BS = 4; const BS = 4;
const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10; const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14;
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; } function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
// Filters: each filter block has a set of materials and a mode. In drop mode (inv 0) // Filters: each filter block has a set of materials and a mode. In drop mode (inv 0)
// the chosen materials fall through and everything else rides along on top; in carry // the chosen materials fall through and everything else rides along on top; in carry
@@ -25,10 +25,10 @@ function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length
const WALL_OF = new Uint8Array(NMAT); const WALL_OF = new Uint8Array(NMAT);
WALL_OF[LDIRT] = DIRT; WALL_OF[RSTONE] = STONE; WALL_OF[RGRAN] = GRANITE; WALL_OF[RBAS] = BASALT; WALL_OF[LCLAY] = CLAY; WALL_OF[LDIRT] = DIRT; WALL_OF[RSTONE] = STONE; WALL_OF[RGRAN] = GRANITE; WALL_OF[RBAS] = BASALT; WALL_OF[LCLAY] = CLAY;
WALL_OF[LWOOD] = WOOD; WALL_OF[RBRICK] = BRICK; WALL_OF[CULLET] = GLASS; WALL_OF[BARCU] = COPPER; WALL_OF[BARFE] = IRON; WALL_OF[BARST] = STEEL; WALL_OF[LWOOD] = WOOD; WALL_OF[RBRICK] = BRICK; WALL_OF[CULLET] = GLASS; WALL_OF[BARCU] = COPPER; WALL_OF[BARFE] = IRON; WALL_OF[BARST] = STEEL;
WALL_OF[BARSB] = CUTST; WALL_OF[BARGL] = GLASS; WALL_OF[BARSB] = CUTST; WALL_OF[PLANK] = WOOD; WALL_OF[BARBR] = BRICK; WALL_OF[BARGL] = GLASS;
const WALL_LIST = [RSTONE, BARSB, LCLAY, RBRICK, LDIRT, LWOOD, CULLET, BARGL, RGRAN, RBAS, BARCU, BARFE, BARST]; const WALL_LIST = [RSTONE, BARSB, PLANK, BARBR, LCLAY, RBRICK, LDIRT, LWOOD, CULLET, BARGL, RGRAN, RBAS, BARCU, BARFE, BARST];
const BAR_OF = new Uint8Array(NMAT); BAR_OF[COPPER] = BARCU; BAR_OF[IRON] = BARFE; BAR_OF[STEEL] = BARST; const BAR_OF = new Uint8Array(NMAT); BAR_OF[COPPER] = BARCU; BAR_OF[IRON] = BARFE; BAR_OF[STEEL] = BARST;
BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS; BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[WOOD] = PLANK; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS;
const GRAINS_PER_BAR = 8; const GRAINS_PER_BAR = 8;
function wallCost(v, cells) { return [[v, PH[v] === PH_ITEM ? Math.ceil(cells / GRAINS_PER_BAR) : cells]]; } function wallCost(v, cells) { return [[v, PH[v] === PH_ITEM ? Math.ceil(cells / GRAINS_PER_BAR) : cells]]; }
@@ -42,7 +42,7 @@ const PIECES = [
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'], { id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it.' }, desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it.' },
{ id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left'], { id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left'],
desc: 'A turning screw that draws in loose material and pushes it along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Stack them pointing up to lift ore out of a mine, and turn the top one sideways to unload. Copper softens around 1000°C.' }, desc: 'A turning screw that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Stack them pointing up to lift ore out of a mine, and turn the top one sideways to unload. Copper softens around 1000°C.' },
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true, { id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through.' }, desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE, { id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
@@ -57,22 +57,26 @@ const PIECES = [
desc: 'Draws liquid in at the tail of its arrow (keep that side under water) and pushes it into the pipes on its other sides, about 60 grains a second while the line has room. It takes water and anything else cooler than 500°C, one liquid at a time per line. Press F to switch it on or off.' }, desc: 'Draws liquid in at the tail of its arrow (keep that side under water) and pushes it into the pipes on its other sides, about 60 grains a second while the line has room. It takes water and anything else cooler than 500°C, one liquid at a time per line. Press F to switch it on or off.' },
{ id: 'outlet', code: BP_OUTLET, name: 'Outlet', kind: 'block', cost: [[CASH, 15]], dirs: ['down', 'left', 'up', 'right'], { id: 'outlet', code: BP_OUTLET, name: 'Outlet', kind: 'block', cost: [[CASH, 15]], dirs: ['down', 'left', 'up', 'right'],
desc: 'Lets a pipe line\'s liquid out of the side its arrow points to, wherever there is room for it. Press F to open or close it.' }, desc: 'Lets a pipe line\'s liquid out of the side its arrow points to, wherever there is room for it. Press F to open or close it.' },
{ id: 'grinder', code: BP_GRIND, name: 'Grinder', kind: 'block', cost: [[CASH, 60]],
desc: 'Grinds rubble, gravel, sand and dirt resting on top into sand and the odd grain of ore. Results drop out below; keep that space clear. Wider rows grind faster. Stack under a Saw to cut stone and grind the rest.' },
{ id: 'press', code: BP_PRESS, name: 'Brick press', kind: 'block', cost: [[CASH, 60]],
desc: 'Presses clay lumps resting on top into a green brick for every 8. They drop out underneath; put a belt or an open drop under it. Fire green bricks in a fire hotter than about 900°C to make Bricks. Wider rows press faster.' },
{ id: 'rack', name: 'Bar rack', kind: 'block', code: BP_RACK, cost: [[CASH, 40]],
desc: 'Stores bars of any kind, 50 per block. Stack blocks into a column for more room. Bars that land on top go in; F opens the gate at the bottom and lets them out one at a time, oldest first, onto a belt below.' },
{ id: 'saw', code: BP_SAW, name: 'Saw', kind: 'block', cost: [[CASH, 90]],
desc: 'Cuts stone rubble into stone blocks and wood chunks into planks, one from every 8 grains resting on top. They drop out underneath and pile up there: put a belt or an open drop under it to carry them away. Wider rows cut faster. Stack over a Grinder to grind everything else.' },
{ id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE, { id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE,
desc: 'Makes power when liquid falls through it. More flow, more power.' }, desc: 'Makes power when liquid falls through it. More flow, more power.' },
{ id: 'fan', name: 'Fan', kind: 'machine', bw: 1, bh: 1, cost: [[CASH, 60]], dirs: ['right', 'down', 'left', 'up'], { id: 'fan', name: 'Fan', kind: 'machine', bw: 1, bh: 1, cost: [[CASH, 60]], dirs: ['right', 'down', 'left', 'up'],
desc: 'Blows gas and forces air into fires, making them far hotter: its stream reaches 28 grains and passes through grates but not walls. Its copper body melts at 1085°C, so keep it outside the furnace, a block clear of the wall, blowing in through a grate. Press F to switch it on or off.' }, desc: 'Blows gas and forces air into fires, making them far hotter: its stream reaches 28 grains and passes through grates but not walls. Its copper body melts at 1085°C, so keep it outside the furnace, a block clear of the wall, blowing in through a grate. Press F to switch it on or off.' },
{ id: 'cutter', name: 'Stone cutter', kind: 'machine', bw: 2, bh: 2, cost: [[CASH, 90]],
desc: 'A saw under a hopper. Stone rubble dropped in is cut into stone bricks, one from every 8 grains, which drop out underneath. Builders buy stone bricks, and they make neat cut stone walls that stand more heat than rough stone. Anything else falls straight through.' },
{ id: 'silo', name: 'Silo', kind: 'machine', bw: 3, bh: 6, cost: [[CASH, 180]], { id: 'silo', name: 'Silo', kind: 'machine', bw: 3, bh: 6, cost: [[CASH, 180]],
desc: 'A steel bin on legs for one loose material: up to 4,000 grains go in through the hatch in its roof (drop them in from a belt or tube). Its gauge shows how full it is. Press F to open the gate in its cone and let it out underneath, about 60 grains a second; F again shuts it. The first material in decides what it holds until it is empty.' }, desc: 'A steel bin on legs for one loose material: up to 4,000 grains go in through the hatch in its roof (drop them in from a belt or tube). Its gauge shows how full it is. Press F to open the gate in its cone and let it out underneath, about 60 grains a second; F again shuts it. The first material in decides what it holds until it is empty.' },
{ id: 'refiner', name: 'Refiner', kind: 'machine', bw: 3, bh: 3, cost: [[CASH, 120]],
desc: 'A crushing sieve. Drop stone, granite or basalt rubble, gravel, sand or dirt into its hopper: it grinds each grain, keeps the odd grain of ore it finds (clay, from dirt) and lets about half as much fine sand out of the chute underneath. Ore and anything else it cannot grind falls straight through. Keep the space under the chute clear, or a belt running under it.' },
{ id: 'engine', name: 'Steam engine', kind: 'machine', bw: 3, bh: 2, cost: [[BARCU, 10]], dirs: ['exhaust right', 'exhaust left'], hidden: POWER_FREE, { id: 'engine', name: 'Steam engine', kind: 'machine', bw: 3, bh: 2, cost: [[BARCU, 10]], dirs: ['exhaust right', 'exhaust left'], hidden: POWER_FREE,
desc: 'Steam rising into the intake slots on its underside drives it. Condensate drips out the exhaust side.' }, desc: 'Steam rising into the intake slots on its underside drives it. Condensate drips out the exhaust side.' },
{ id: 'dock', name: 'Trade dock', kind: 'machine', bw: 6, bh: 4, cost: [], hidden: true, fixed: true, { id: 'dock', name: 'Trade dock', kind: 'machine', bw: 6, bh: 4, cost: [], hidden: true, fixed: true,
desc: 'Owned by the claim office. Anything that drops through the grate in its floor is sold on the spot. Build walls around it to funnel more in.' }, desc: 'Owned by the claim office. Anything that drops through the grate in its floor is sold on the spot. Build walls around it to funnel more in.' },
{ id: 'ingotmold', name: 'Ingot mold', kind: 'mold', mold: 'ingot', variants: 'wall', { id: 'ingotmold', name: 'Ingot mold', kind: 'mold', mold: 'ingot', variants: 'wall',
desc: 'A long trough. Pour molten metal in, let it set, press F: it snaps into bars of 8 grains, up to 3.' }, desc: 'A long trough. Pour molten metal in: bars of 8 grains drop out of the bottom when set, up to 3. Leave one open block below it; a belt under that carries them off. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.' },
{ id: 'bitmold', name: 'Bit mold', kind: 'mold', mold: 'bit', variants: 'wall', { id: 'bitmold', name: 'Bit mold', kind: 'mold', mold: 'bit', variants: 'wall',
desc: 'A drill-bit-shaped cavity. Cast a harder metal in it to upgrade your drill.' }, desc: 'A drill-bit-shaped cavity. Cast a harder metal in it to upgrade your drill.' },
{ id: 'tankmold', name: 'Tank mold', kind: 'mold', mold: 'tank', variants: 'wall', { id: 'tankmold', name: 'Tank mold', kind: 'mold', mold: 'tank', variants: 'wall',
@@ -86,8 +90,7 @@ const WHEEL_K = 0.32, ENGINE_K = 40;
const MOLDS = { const MOLDS = {
ingot: { ingot: {
// one long trough: metal runs along it and sets level; knocking it out // one long trough: metal sets level and drops bars of 8 grains below
// snaps the ingot into bars of 8 grains each
name: 'Ingot mold', rows: [ name: 'Ingot mold', rows: [
'................', '................',
'#oooooooooooooo#', '#oooooooooooooo#',
+70 -56
View File
@@ -1,77 +1,91 @@
// Processing machines: the Refiner, the Stone cutter and the Silo. // Processing: Grinder and Saw blocks, and the Silo.
// ---------- Refiner ---------- // ---------- Grinding and cutting ----------
// Grinding a grain of rock, gravel, sand or dirt turns up the odd grain of ore: [chance, what], // Grinding a grain of rock, gravel, sand or dirt turns up the odd grain of ore: [chance, what],
// tried in turn. If none turns up, half the time a grain of fine sand comes out, else nothing: // tried in turn. If none turns up, half the time a grain of fine sand comes out, else nothing:
// the rest is ground to dust and washed away, so a refiner halves a pile of waste rock. // the rest is ground to dust and washed away, so a Grinder halves a pile of waste rock.
const REFINE = []; const REFINE = [];
REFINE[RSTONE] = [[0.025, CUORE], [0.02, COAL], [0.015, FEORE], [0.006, SNORE]]; REFINE[RSTONE] = [[0.025, CUORE], [0.02, COAL], [0.015, FEORE], [0.006, SNORE]];
REFINE[RGRAN] = [[0.02, FEORE], [0.012, ZNORE], [0.01, CUORE], [0.002, AGORE]]; REFINE[RGRAN] = [[0.04, FEORE], [0.024, ZNORE], [0.02, CUORE], [0.004, AGORE]];
REFINE[RBAS] = [[0.03, FEORE], [0.01, ZNORE], [0.003, AUORE]]; REFINE[RBAS] = [[0.06, FEORE], [0.02, ZNORE], [0.006, AUORE]];
REFINE[GRAVEL] = [[0.03, FEORE], [0.02, CUORE], [0.01, SNORE], [0.003, ZNORE]]; REFINE[GRAVEL] = [[0.03, FEORE], [0.02, CUORE], [0.01, SNORE], [0.003, ZNORE]];
REFINE[SAND] = [[0.01, SNORE], [0.008, FEORE], [0.0015, AUORE]]; REFINE[SAND] = [[0.01, SNORE], [0.008, FEORE], [0.0015, AUORE]];
REFINE[LDIRT] = [[0.08, LCLAY], [0.01, COAL], [0.008, CUORE], [0.003, ACORN]]; REFINE[LDIRT] = [[0.08, LCLAY], [0.01, COAL], [0.008, CUORE], [0.003, ACORN]];
const REFINE_SAND = 0.5, REFINE_RATE = 0.5; // grains ground per tick const REFINE_SAND = 0.5;
// a free cell under the refiner's chute, or -1 if the way out is blocked const CUT_PER_BRICK = 8;
function refinerSlot(mm) { const SAW_CUTS = []; SAW_CUTS[RSTONE] = BARSB; SAW_CUTS[LWOOD] = PLANK;
const y = mm.y + 12; // Block hoppers share an outlet down a vertical stack; only its top spends work.
let procState = new Map(), procBlocks = [], procDirty = true;
function isProcCode(c) { return c === BP_GRIND || c === BP_SAW || c === BP_PRESS; }
function rebuildProcessors() {
procBlocks = [];
for (let b = 0; b < NB; b++) if (isProcCode(blockP[b])) {
procBlocks.push(b);
if (!procState.has(b)) procState.set(b, { work: 0, press: 0, cut: { [RSTONE]: 0, [LWOOD]: 0 }, busyT: -999, blocked: false });
}
procDirty = false;
}
function procBottom(b) { while (b + BW < NB && isProcCode(blockP[b + BW])) b += BW; return b; }
function procSlot(b) {
const y = (((b / BW) | 0) + 1) * BS, x = (b % BW) * BS;
if (y >= H) return -1; if (y >= H) return -1;
for (const dx of [5, 6, 4, 7]) { const j = y * W + mm.x + dx; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; } for (let k = 0; k < 4; k++) { const j = y * W + x + [1, 2, 0, 3][k]; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; }
return -1; return -1;
} }
function refinerTick(mm) { function procFeed(b, m, T) {
mm.work = (mm.work || 0) + REFINE_RATE; const end = procBottom(b), x = (b % BW) * BS, y = ((b / BW) | 0) * BS - 1;
if (mm.work < 1) return; for (;;) {
let jobs = Math.floor(mm.work); mm.work -= jobs; const s = procState.get(b); s.busyT = tick;
const y = mm.y + 9; if (blockP[b] === BP_GRIND && PH[m] === PH_POWDER && REFINE[m]) {
mm.blocked = false; const from = m; let got = 0;
for (let k = 0; k < 8 && jobs > 0; k++) {
const x = mm.x + 2 + ((k + tick) & 7), i = y * W + x, m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
const out = refinerSlot(mm);
if (out < 0) { mm.blocked = true; break; }
jobs--; mm.busyT = tick;
const T = temp[i];
if (PH[m] === PH_POWDER && REFINE[m]) {
setCell(i, EMPTY, AMB[y], 0);
let got = 0;
for (const [chance, o] of REFINE[m]) if (rnd() < chance) { got = o; break; } for (const [chance, o] of REFINE[m]) if (rnd() < chance) { got = o; break; }
if (got) { stats.refinedOre = (stats.refinedOre || 0) + 1; discover(got, x, y, m, 'refine'); } if (got) { stats.refinedOre = (stats.refinedOre || 0) + 1; discover(got, x, y, from, 'refine'); }
else if (rnd() < REFINE_SAND) got = SAND; else if (rnd() < REFINE_SAND) got = SAND;
if (got) setCell(out, got, T, 0); stats.refined = (stats.refined || 0) + 1; m = got;
stats.refined = (stats.refined || 0) + 1; if (!m) return;
} else { } else if (blockP[b] === BP_SAW && SAW_CUTS[m]) {
// ore, coal, liquids: nothing to grind, so they go straight through if (++s.cut[m] < CUT_PER_BRICK) return;
setCell(out, m, T, 0); setCell(i, EMPTY, AMB[y], 0); s.cut[m] = 0; m = SAW_CUTS[m];
} else if (blockP[b] === BP_PRESS && m === LCLAY) {
if (++s.press < CUT_PER_BRICK) return;
s.press = 0; m = GREENB;
} }
if (b === end) break;
b += BW;
} }
if (m === BARSB || m === PLANK || m === GREENB) {
const e = spawnBar(m, x, (((end / BW) | 0) + 1) * BS, (rnd() - 0.5) * 0.1, 0.05);
if (e) {
e.T = T; discover(m, x, y, m === GREENB ? LCLAY : m === PLANK ? LWOOD : RSTONE, m === GREENB ? 'press' : 'cut');
if (m === GREENB) stats.pressed = (stats.pressed || 0) + 1;
else if (m === PLANK) stats.planks = (stats.planks || 0) + 1;
else stats.bricks = (stats.bricks || 0) + 1;
} }
// ---------- Stone cutter ---------- } else setCell(procSlot(end), m, T, 0);
const CUT_PER_BRICK = 8, CUT_RATE = 0.5; }
function cutterTick(mm) { function procBlocksTick() {
mm.work = (mm.work || 0) + CUT_RATE; if (procDirty) rebuildProcessors();
if (mm.work < 1) return; for (const b of procBlocks) {
let jobs = Math.floor(mm.work); mm.work -= jobs; if (b >= BW && isProcCode(blockP[b - BW])) continue;
const y = mm.y + 4; const s = procState.get(b), end = procBottom(b), y = ((b / BW) | 0) * BS - 1, x = (b % BW) * BS;
mm.blocked = false; s.work += blockP[b] === BP_GRIND ? 0.25 : 0.5;
let jobs = Math.floor(s.work); s.work -= jobs;
if (!jobs || y < 0) continue; // a tick without work keeps the last blocked state, so it doesn't flicker
for (let q = b; q <= end; q += BW) procState.get(q).blocked = false;
for (let k = 0; k < 4 && jobs > 0; k++) { for (let k = 0; k < 4 && jobs > 0; k++) {
const x = mm.x + 2 + ((k + tick) & 3), i = y * W + x, m = mat[i]; const i = y * W + x + ((k + tick) & 3), m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue; if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
if (m === RSTONE) { let brick = false;
if ((mm.cut || 0) + 1 >= CUT_PER_BRICK) { if (SAW_CUTS[m] || m === LCLAY) for (let q = b; q <= end; q += BW) {
// a brick is ready: it needs room under the machine if (blockP[q] === BP_GRIND && REFINE[m]) break;
if (!entFreeAt(mm.x + 2, mm.y + 8)) { mm.blocked = true; break; } if (blockP[q] === BP_PRESS && m === LCLAY) { brick = procState.get(q).press + 1 >= CUT_PER_BRICK; break; }
mm.cut = 0; if (blockP[q] === BP_SAW && SAW_CUTS[m]) { brick = procState.get(q).cut[m] + 1 >= CUT_PER_BRICK; break; }
const e = spawnBar(BARSB, mm.x + 2, mm.y + 8, (rnd() - 0.5) * 0.1, 0.05); }
if (e) { e.T = temp[i]; discover(BARSB, x, y, RSTONE, 'cut'); stats.bricks = (stats.bricks || 0) + 1; } if (procSlot(end) < 0 || brick && !entFreeAt(x, (((end / BW) | 0) + 1) * BS)) {
} else mm.cut = (mm.cut || 0) + 1; for (let q = b; q <= end; q += BW) procState.get(q).blocked = true;
setCell(i, EMPTY, AMB[y], 0); break;
} else { }
// anything else drops through, out of the bottom const T = temp[i]; setCell(i, EMPTY, AMB[y], 0); procFeed(b, m, T); jobs--;
const j = (mm.y + 8) * W + mm.x + 3 + (k & 1);
if (mm.y + 8 >= H || mat[j] !== EMPTY || part[j] || occ[j]) { mm.blocked = true; break; }
setCell(j, m, temp[i], 0); setCell(i, EMPTY, AMB[y], 0);
} }
jobs--; mm.busyT = tick;
} }
} }
// ---------- Silo ---------- // ---------- Silo ----------
+8 -8
View File
@@ -15,23 +15,23 @@ function smelterBlueprint(kind, count, ch, wall) {
} }
const STARTER_DESIGNS = [ const STARTER_DESIGNS = [
{ id: 'smelter1', name: 'Copper smelter', bp: () => smelterBlueprint('ingot', 1, 3, RSTONE), { id: 'smelter1', name: 'Copper smelter', bp: () => smelterBlueprint('ingot', 1, 3, RSTONE),
desc: 'Stone furnace over an ingot mold. Charge it with about 50 copper ore and 50 coal, mixed (or the ore first with the coal on top), and light the coal. Makes 3 bars a firing. Press F on the mold to knock the bars out: that also rakes the slag off the grate, so it is ready to charge again. Leftover copper goes to your bag as scrap; throw it back in with the next charge.' }, desc: 'Stone furnace over an ingot mold. Charge it with about 50 copper ore and 50 coal, mixed (or the ore first with the coal on top), and light the coal. Makes 3 bars a firing. Bars drop out of the bottom of the mold onto whatever is under it; leave one open block under it, and a belt below that carries them off. Leftover copper stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.' },
{ id: 'bitfurnace', name: 'Bit casting furnace', bp: () => smelterBlueprint('bit', 1, 3, RSTONE), { id: 'bitfurnace', name: 'Bit casting furnace', bp: () => smelterBlueprint('bit', 1, 3, RSTONE),
desc: 'Stone furnace over a bit mold. Charge it with about 70 copper ore and 70 coal, mixed (or the coal on top), and light the coal. Press F once the bit has set. For iron, build it from brick and blow air in with a fan.' }, desc: 'Stone furnace over a bit mold. Charge it with about 70 copper ore and 70 coal, mixed (or the coal on top), and light the coal. Press F once the bit has set. For iron, build it from brick and blow air in with a fan.' },
]; ];
// ---------- Milestones ---------- // ---------- Milestones ----------
const MILESTONES = [ const MILESTONES = [
{ id: 'dig', title: 'Break ground', msg: 'Broken material falls loose. The Vacuum (2) picks up only what you click on.', hint: 'Hold the left mouse button with the Drill (1): it sends pulses at the cursor that shake rock until it shatters. It falls as rubble, ore and coal.' }, { id: 'dig', title: 'Break ground', msg: 'Broken material falls loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', hint: 'Hold the left mouse button with the Drill (1): it sends pulses at the cursor that shake rock until it shatters. It falls as rubble, ore and coal.' },
{ id: 'fire', title: 'First fire', hint: 'Vacuum up coal or wood chunks, blow a pile out with right click, then hold the Igniter (4) on it. Fuel only burns where it touches open air.', reward: 'Thermal lens unlocked. Press H to see heat.' }, { id: 'fire', title: 'First fire', hint: 'Pick up coal or wood chunks with the nozzle, blow a pile out with right click, then hold the Igniter (4) on it. Fuel only burns where it touches open air.', reward: 'Thermal lens unlocked. Press H to see heat.' },
{ id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and vacuum up the brick rubble. Brick rubble builds brick blocks.', reward: 'Brick insulates far better than stone and survives 1750°C.' }, { id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and pick up the brick rubble with the Pneumatic nozzle. Brick rubble builds brick blocks. A Brick press makes neat green bricks to fire.', reward: 'Brick insulates far better than stone and survives 1750°C.' },
{ id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' }, { id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' },
{ id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' }, { id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' },
{ id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: tick both in your bag and pick Ticked mix with Q/E, and right click blows an even mix.' }, { id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: Ctrl+click coal and copper ore in the bag to blow a mix. Right click blows an even mix.' },
{ id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, press F on the mold: the bars pop out and the grate is raked clean of slag. Then vacuum up the bars. Copper that sets in the furnace melts again next firing.' }, { id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, they drop out of the bottom of the mold onto whatever is under it; a belt carries them off. Leave space below, or pick up the bars with the Pneumatic nozzle. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap. Copper that sets in the furnace melts again next firing.' },
{ id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' }, { id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' },
{ id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' }, { id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' },
{ id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The Vacuum can suck up and blow out liquids.' }, { id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The nozzle can suck up and blow out liquids.' },
{ id: 'iron', title: 'Molten iron', msg: 'Iron is hard to keep molten. A brick mold right under the hot zone works best.', hint: 'Iron ore melts at 1538°C. That needs forced air, and stone slumps at 1250°C, so build the furnace from brick.' }, { id: 'iron', title: 'Molten iron', msg: 'Iron is hard to keep molten. A brick mold right under the hot zone works best.', hint: 'Iron ore melts at 1538°C. That needs forced air, and stone slumps at 1250°C, so build the furnace from brick.' },
{ id: 'bit2', title: 'Iron drill bit', hint: 'Cast iron in a Bit mold. A stone mold melts at iron temperatures, so build it from brick.', reward: 'Iron bit. Granite is now diggable.' }, { id: 'bit2', title: 'Iron drill bit', hint: 'Cast iron in a Bit mold. A stone mold melts at iron temperatures, so build it from brick.', reward: 'Iron bit. Granite is now diggable.' },
{ id: 'steel', title: 'Steel', msg: 'Cast it in a brick Bit mold for the best drill bit there is.', hint: 'Blow air through molten iron. Aim a Fan at a pool of it and the excess carbon burns off, turning it into steel.' }, { id: 'steel', title: 'Steel', msg: 'Cast it in a brick Bit mold for the best drill bit there is.', hint: 'Blow air through molten iron. Aim a Fan at a pool of it and the excess carbon burns off, turning it into steel.' },
@@ -41,7 +41,7 @@ const MILESTONES = [
{ id: 'order', title: 'First order filled', msg: 'Bigger buyers notice you as you fill more orders.', hint: 'Buyers post orders on the Market page: an amount of one good by a deadline, at well over market price. Goods dropped into the dock fill an open order first.' }, { id: 'order', title: 'First order filled', msg: 'Bigger buyers notice you as you fill more orders.', hint: 'Buyers post orders on the Market page: an amount of one good by a deadline, at well over market price. Goods dropped into the dock fill an open order first.' },
{ id: 'alloy', title: 'An alloy', msg: 'Alloys sell for far more than the metals that went into them.', hint: 'Tin ore lies a little deeper than copper, zinc deeper again. Melt tin into a pool of molten copper and it mixes into bronze; zinc makes brass.' }, { id: 'alloy', title: 'An alloy', msg: 'Alloys sell for far more than the metals that went into them.', hint: 'Tin ore lies a little deeper than copper, zinc deeper again. Melt tin into a pool of molten copper and it mixes into bronze; zinc makes brass.' },
{ id: 'precious', title: 'Precious metal', hint: 'Silver lies deep in the granite, gold deeper still. Their ore sells high even raw, and much higher as bars.' }, { id: 'precious', title: 'Precious metal', hint: 'Silver lies deep in the granite, gold deeper still. Their ore sells high even raw, and much higher as bars.' },
{ id: 'claim', title: 'Pay off the claim', msg: 'The land is yours.', hint: 'You owe the claim office $20,000 for this land. Pay it down from the Market page of your notebook.', reward: 'The land is yours.' }, { id: 'claim', title: 'Pay off the claim', msg: 'The land is yours. Open a new claim from the notebook’s Claims page.', hint: 'Pay down this land from the Market page of your notebook. Once paid off, open a new claim from the Claims page.', reward: 'The land is yours.' },
{ id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' }, { id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' },
{ id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,000 in total.' }, { id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,000 in total.' },
{ id: 'deep', title: 'The magma sea', msg: 'Magma is a heat source that never runs out.', hint: 'Dig down through granite and basalt to the molten layer at the bottom of the world.' }, { id: 'deep', title: 'The magma sea', msg: 'Magma is a heat source that never runs out.', hint: 'Dig down through granite and basalt to the molten layer at the bottom of the world.' },
+56
View File
@@ -0,0 +1,56 @@
// Each rack block owns its bars; only the bottom of a column releases them.
const RACK_CAP = 50;
let rackState = new Map();
function newRack() { return { q: [], n: 0, open: false, wait: 0 }; }
function rackTop(b) { while (b >= BW && blockP[b - BW] === BP_RACK) b -= BW; return b; }
function rackBottom(b) { while (b + BW < NB && blockP[b + BW] === BP_RACK) b += BW; return b; }
function rackPush(s, m, n) {
const last = s.q[s.q.length - 1];
if (last && last[0] === m) last[1] += n; else s.q.push([m, n]);
s.n += n;
}
function rackShift(s, n) {
s.q[0][1] -= n; s.n -= n;
if (!s.q[0][1]) s.q.shift();
}
// Called with the moving bar unmarked, after its ordinary collision handling.
function rackIntake(e) {
const y = e.iy + BAR_H;
if (e.vy !== 0 || Math.abs(e.vx) >= 0.05 || y >= H || y % BS) return false;
for (let x = Math.max(0, e.ix); x < Math.min(W, e.ix + BAR_W); x += BS - x % BS) {
const b = (y / BS) * BW + Math.floor(x / BS), left = (b % BW) * BS;
if (blockP[b] !== BP_RACK || b >= BW && blockP[b - BW] === BP_RACK || Math.min(e.ix + BAR_W, left + BS) - Math.max(e.ix, left) < BAR_W / 2) continue;
for (let q = rackBottom(b); q >= b; q -= BW) {
const s = rackState.get(q);
if (s.n < RACK_CAP) { rackPush(s, e.m, 1); return true; }
}
}
return false;
}
function rackRefill(b) {
const top = rackTop(b);
for (let q = b; q > top; q -= BW) {
const lower = rackState.get(q);
for (let above = q - BW; above >= top && lower.n < RACK_CAP; above -= BW) {
const upper = rackState.get(above);
while (lower.n < RACK_CAP && upper.n) {
const n = Math.min(RACK_CAP - lower.n, upper.q[0][1]);
rackPush(lower, upper.q[0][0], n); rackShift(upper, n);
}
}
}
}
function rackTick() {
for (const [b, s] of rackState) {
if (b + BW < NB && blockP[b + BW] === BP_RACK) continue;
if (!s.open) continue;
if (!s.n) rackRefill(b);
if (!s.n) continue;
if (s.wait < 20) s.wait++;
if (s.wait < 20) continue;
const x = (b % BW) * BS, y = (((b / BW) | 0) + 1) * BS;
if (!entFreeAt(x, y) || !spawnBar(s.q[0][0], x, y, 0, 0)) continue;
rackShift(s, 1); s.wait = 0;
rackRefill(b);
}
}
+75 -10
View File
@@ -12,6 +12,7 @@ function serializeWorld() {
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF, v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]), saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq }, economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
racks: [...rackState].map(([b, s]) => [b, s.q.map(r => r.slice()), s.open]),
filters: [...filterCfg].map(([b, c]) => [b, c.m, c.inv]), filters: [...filterCfg].map(([b, c]) => [b, c.m, c.inv]),
weather: { moist: (() => { const m = Float32Array.from(weather.moist); for (const f of flyers) if (f.rain) m[Math.min(m.length - 1, Math.max(0, (f.x / WBAND) | 0))] += 1; return Array.from(m, v => Math.round(v)); })(), raining: Array.from(weather.raining), shower: weather.shower, next: weather.next, wind: weather.wind, seen: weather.seen, base: weather.base }, weather: { moist: (() => { const m = Float32Array.from(weather.moist); for (const f of flyers) if (f.rain) m[Math.min(m.length - 1, Math.max(0, (f.x / WBAND) | 0))] += 1; return Array.from(m, v => Math.round(v)); })(), raining: Array.from(weather.raining), shower: weather.shower, next: weather.next, wind: weather.wind, seen: weather.seen, base: weather.base },
bot: { x: bot.x, y: bot.y, lamp: bot.lamp }, bot: { x: bot.x, y: bot.y, lamp: bot.lamp },
@@ -25,7 +26,7 @@ function serializeWorld() {
pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(), pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(),
cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) }, cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) },
}; };
return { meta, buffers: { mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } }; return { meta, buffers: { fog: fog.slice(), mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } };
} }
function deserializeWorld(meta, buffers) { function deserializeWorld(meta, buffers) {
if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.'); if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.');
@@ -37,7 +38,21 @@ function deserializeWorld(meta, buffers) {
computeAmbient(); fillNoise(); computeAmbient(); fillNoise();
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags); mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors();
for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack());
for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
if (!Array.isArray(entry)) continue;
const [b, q, open] = entry;
if (!Number.isInteger(b) || !rackState.has(b) || !Array.isArray(q)) continue;
const s = newRack();
for (const r of q) {
if (!Array.isArray(r) || r.length !== 2) continue;
const [m, n] = r;
if (!Number.isInteger(m) || m <= 0 || m >= NMAT || PH[m] !== PH_ITEM || !Number.isInteger(n) || n <= 0 || n > RACK_CAP - s.n) continue;
rackPush(s, m, n);
}
s.open = open === true; rackState.set(b, s);
}
// pipes used to be solid copper blocks; now they are thin and let things past // pipes used to be solid copper blocks; now they are thin and let things past
for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; } for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; }
for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv })); for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
@@ -74,7 +89,13 @@ function deserializeWorld(meta, buffers) {
if (p >= P_TU && p <= P_TR) markPow(i, true); if (p >= P_TU && p <= P_TR) markPow(i, true);
} }
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); } for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); }
for (const m of meta.machines) { const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false; if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; } } // Old machines removed from the game are refunded.
const removedMachines = { refiner: 120, cutter: 90 };
for (const m of meta.machines) {
if (!Object.hasOwn(PIECE_BY_ID, m.type)) { money += Object.hasOwn(removedMachines, m.type) ? removedMachines[m.type] : 0; continue; }
const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false;
if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; }
}
molds = []; molds = [];
for (const m of meta.molds) molds.push(newMoldRecord(m.kind, m.x, m.y, m.wall)); for (const m of meta.molds) molds.push(newMoldRecord(m.kind, m.x, m.y, m.wall));
for (const [m, x, y, T] of meta.ents || []) { const e = { m, x, y, ix: x, iy: y, vx: 0, vy: 0, T, rest: 0 }; ents.push(e); entMark(e, 1); } for (const [m, x, y, T] of meta.ents || []) { const e = { m, x, y, ix: x, iy: y, vx: 0, vy: 0, T, rest: 0 }; ents.push(e); entMark(e, 1); }
@@ -96,17 +117,61 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {}; ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {}); Object.assign(stats, meta.stats || {});
awake.fill(3);
for (let c = 0; c < NCH; c++) {
const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
let dev = 0;
for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; }
if (dev >= 2) { hot[c] = 6; hotLvl[c] = 2; }
}
powerDirty = true; rebuildPower(); updateRoof(); powerDirty = true; rebuildPower(); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
rebuildPipes(); rebuildPipes();
for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } } for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } }
if (buffers.fog && buffers.fog.length === fog.length) fog.set(buffers.fog);
else {
fogInit();
for (let b = 0; b < NB; b++) if (blockP[b]) fogRevealAround((b % BW) * BS + BS / 2, ((b / BW) | 0) * BS + BS / 2, 12);
for (const m of machines) if (m) for (let y = m.y; y < m.y + m.h; y += BS) for (let x = m.x; x < m.x + m.w; x += BS) fogRevealAround(x, y, 12);
for (const mo of molds) for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) fogRevealAround(i % W, (i / W) | 0, 12);
}
fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
startLoadWake();
EV.length = 0; EV.length = 0;
} }
// Keep the load-time heat scan, but start distant areas in order instead of all at once.
function startLoadWake() {
awake.fill(0); hot.fill(0); hotLvl.fill(0);
loadWakePending = new Uint8Array(NCH).fill(1);
for (let c = 0; c < NCH; c++) {
const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
let dev = 0;
for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; }
if (dev >= 2) loadWakePending[c] = 2;
}
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
const distance = c => (c % CW * 32 + 16 - x) ** 2 + (((c / CW) | 0) * 32 + 16 - y) ** 2;
loadWakeOrder = Uint32Array.from({ length: NCH }, (_, c) => c);
loadWakeOrder.sort((a, b) => distance(a) - distance(b));
loadWakeIndex = 0;
wakeLoadNearBot();
}
function wakeLoadArea(c) {
const pending = loadWakePending[c];
if (!pending) return;
loadWakePending[c] = 0;
awake[c] = 3;
if (pending === 2) { hot[c] = 6; hotLvl[c] = 2; }
}
function wakeLoadNearBot() {
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
const x0 = Math.max(0, Math.floor((x - 150) / 32)), x1 = Math.min(CW - 1, Math.floor((x + 150) / 32));
const y0 = Math.max(0, Math.floor((y - 150) / 32)), y1 = Math.min(CH - 1, Math.floor((y + 150) / 32));
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
const dx = Math.max(cx * 32 - x, 0, x - (cx * 32 + 31));
const dy = Math.max(cy * 32 - y, 0, y - (cy * 32 + 31));
if (dx * dx + dy * dy <= 150 * 150) wakeLoadArea(cy * CW + cx);
}
}
function loadWakeTick() {
if (tick % 8 === 0) wakeLoadNearBot();
// Twelve per tick finishes a Huge world in 1024 ticks, and a Small one in 171.
const end = Math.min(loadWakeIndex + 12, loadWakeOrder.length);
while (loadWakeIndex < end) wakeLoadArea(loadWakeOrder[loadWakeIndex++]);
if (loadWakeIndex === loadWakeOrder.length) loadWakeOrder = loadWakePending = null;
}
+4 -1
View File
@@ -3,13 +3,15 @@
function simTick() { function simTick() {
tick++; tick++;
tickStamp = (tickStamp % 255) + 1; tickStamp = (tickStamp % 255) + 1;
if (tick % 20 === 1) updateRoof(); if (loadWakeOrder) loadWakeTick();
if (tick % 20 === 1) updateDirtyRoof();
weatherTick(); weatherTick();
machinesTick(); machinesTick();
pipesTick(); pipesTick();
pulseTick(); pulseTick();
entTick(); entTick();
if (flyers.length) flyTick(); if (flyers.length) flyTick();
if (tick % 8 === 0) wakeBotPowder();
simMove(); simMove();
heatPass(); heatPass();
for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--; for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--;
@@ -22,4 +24,5 @@ function simTick() {
if (tick % 60 === 17) { marketTick(); ordersTick(); } if (tick % 60 === 17) { marketTick(); ordersTick(); }
if (tick % 30 === 11 && saplings.length) saplingTick(); if (tick % 30 === 11 && saplings.length) saplingTick();
ghostTick(); ghostTick();
if (tick % 4 === 0) fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
} }
+2 -2
View File
@@ -42,11 +42,11 @@ function toolDig(cx, cy, r) {
stats.dug += dug; stats.dug += dug;
let status = ''; let status = '';
void 0; void 0;
if (far && !dug && !tooHard) status = `Out of reach. Your ${tier.name.toLowerCase()} reaches ${drillReach()} grains from the bot.`; if (far && !dug && !tooHard) status = `Out of reach; your bit reaches ${drillReach()} grains.`;
if (tooHard && !dug) { if (tooHard && !dug) {
const need = BIT_TIERS.find(b => b.hard >= HARD[tooHard]); const need = BIT_TIERS.find(b => b.hard >= HARD[tooHard]);
const nb = need ? need.name.toLowerCase() : 'better bit'; const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`; status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard; needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
} }
return { dug, status, tooHard }; return { dug, status, tooHard };
} }
+3 -3
View File
@@ -17,8 +17,8 @@ function undoLast() {
const rec = undoStack.pop(); const rec = undoStack.pop();
if (!rec) return 'Nothing to undo.'; if (!rec) return 'Nothing to undo.';
const prev = undoRec; undoRec = null; const prev = undoRec; undoRec = null;
let fails = 0, why = ''; let fails = 0;
const fail = (r) => { fails++; if (r && !why) why = r; }; const fail = () => { fails++; };
for (let k = rec.ops.length - 1; k >= 0; k--) { for (let k = rec.ops.length - 1; k >= 0; k--) {
const o = rec.ops[k]; const o = rec.ops[k];
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); } if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
@@ -38,7 +38,7 @@ function undoLast() {
undoRec = prev; undoRec = prev;
powerDirty = true; powerDirty = true;
const n = rec.ops.length; const n = rec.ops.length;
return fails ? `Undid ${rec.label}, but ${fails === n ? 'none' : fails} of it could not be put back${why ? ': ' + why.replace(/\.$/, '').toLowerCase() : ''}.` : `Undid ${rec.label}.`; return fails ? `Undo incomplete: ${fails} of ${n} changes could not be put back.` : `Undid ${rec.label}.`;
} }
function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); } function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); }
let ghostMissing = 0, ghostIter = null; let ghostMissing = 0, ghostIter = null;
+15 -8
View File
@@ -11,11 +11,11 @@ function vacuumTargetAt(cx, cy, r) {
} }
// Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true. // Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true.
function toolVacuum(cx, cy, r, pick) { function toolVacuum(cx, cy, r, pick) {
let got = 0, full = false, budget = 48; let got = 0, first = 0, full = false, budget = 48;
for (const e of entsInCircle(cx, cy, r)) { for (const e of entsInCircle(cx, cy, r)) {
if (!pick(e.m) || !inReach(e.ix + 2, e.iy + 1)) continue; if (!pick(e.m) || !inReach(e.ix + 2, e.iy + 1)) continue;
if (bagTotal >= bagCap()) { full = true; break; } if (bagTotal >= bagCap()) { full = true; break; }
bagAdd(e.m, 1); removeEnt(e); got++; bagAdd(e.m, 1); removeEnt(e); got++; if (!first) first = e.m;
emit({ t: 'suck', x: e.ix + 2, y: e.iy + 1, m: e.m }); emit({ t: 'suck', x: e.ix + 2, y: e.iy + 1, m: e.m });
} }
if (!full) forBrush(cx, cy, r, (x, y, i) => { if (!full) forBrush(cx, cy, r, (x, y, i) => {
@@ -24,11 +24,12 @@ function toolVacuum(cx, cy, r, pick) {
if (!pick(m)) return; if (!pick(m)) return;
if (part[i] >= P_TU && part[i] <= P_TR) return; if (part[i] >= P_TU && part[i] <= P_TR) return;
if (bagTotal >= bagCap()) { full = true; return false; } if (bagTotal >= bagCap()) { full = true; return false; }
if (!first) first = m;
bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++; bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++;
if (got & 1) emit({ t: 'suck', x, y, m }); if (got & 1) emit({ t: 'suck', x, y, m });
if (--budget <= 0) return false; if (--budget <= 0) return false;
}); });
return { got, full, status: full ? 'Bag full. Blow something out or cast a Tank mold.' : '' }; return { got, m: first, full, status: full ? 'Bag full; blow material out or cast a bigger tank.' : '' };
} }
// Blow: grains leave the bot's nozzle and fly to the cursor, where they drop. // Blow: grains leave the bot's nozzle and fly to the cursor, where they drop.
// Liquids need the Flask. Bars are thrown, one at a time. // Liquids need the Flask. Bars are thrown, one at a time.
@@ -45,12 +46,12 @@ function toolBlow(cx, cy, r, m, mix) {
let pickM = null; let pickM = null;
if (mix && mix.length) { if (mix && mix.length) {
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask)); const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
if (!avail.length) return { placed: 0, status: 'Nothing ticked left to mix. Tick materials in your bag.' }; if (!avail.length) return { placed: 0, status: 'None of the selected materials are in the bag.' };
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; }; pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
m = avail[0]; m = avail[0];
} }
if (!m) return { placed: 0, status: 'Pick something to blow out. Q and E cycle, or click it in your bag.' }; if (!m) return { placed: 0, status: 'Nothing selected: click or Q/E in the bag.' };
if (bag[m] <= 0) return { placed: 0, status: `No ${MD[m].name.toLowerCase()} left.` }; if (bag[m] <= 0) return { placed: 0, status: 'None of the selected materials are in the bag.' };
const ph = PH[m]; const ph = PH[m];
if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' }; if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' };
const nz = nozzle(cx + 0.5, cy + 0.5); const nz = nozzle(cx + 0.5, cy + 0.5);
@@ -107,7 +108,10 @@ function depositNear(m, T, x, y, skip) {
const xx = x + dx, yy = y + dy; const xx = x + dx, yy = y + dy;
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
const j = yy * W + xx; const j = yy * W + xx;
if (j !== skip && flyOpen(m, j)) { setCell(j, m, T, 0); return true; } if (j !== skip && flyOpen(m, j)) {
if (mat[j] === STEAM) absorbSteam(xx); // displaced steam still belongs to the world's water
setCell(j, m, T, 0); return true;
}
} }
} }
return false; return false;
@@ -120,7 +124,10 @@ function landFlyer(f, x, y) {
for (let k = 0; k < 8; k++) { for (let k = 0; k < 8; k++) {
const yy = y - k; if (yy < 0) break; const yy = y - k; if (yy < 0) break;
const j = yy * W + x; const j = yy * W + x;
if (flyOpen(f.m, j)) { setCell(j, f.m, f.T, 0); return; } if (flyOpen(f.m, j)) {
if (mat[j] === STEAM) absorbSteam(x); // landing replaces the gas, so keep its water in the clouds
setCell(j, f.m, f.T, 0); return;
}
} }
if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds) if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds)
} }
+6 -4
View File
@@ -71,15 +71,17 @@ function weatherTick() {
if (sh.left !== undefined) { sh.left -= made; if (sh.left <= 0 && sh.until > tick) sh.until = tick; } // made up the shortfall: it passes if (sh.left !== undefined) { sh.left -= made; if (sh.left <= 0 && sh.until > tick) sh.until = tick; } // made up the shortfall: it passes
} }
} }
// clouds made of your steam build up, then rain it back out where they hang // clouds made of your steam build up, then rain it back out over where they hang and a band
// either side, so water boiled off one spot does not all fall back on it
for (let b = 0; b < n; b++) { for (let b = 0; b < n; b++) {
if (!w.raining[b] && w.moist[b] > 150) w.raining[b] = 1; if (!w.raining[b] && w.moist[b] > 150) w.raining[b] = 1;
if (w.raining[b]) { w.moist[b] -= spawnRain(b * WBAND, (b + 1) * WBAND, Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; } if (w.raining[b]) { w.moist[b] -= spawnRain(Math.max(0, (b - 1) * WBAND), Math.min(W, (b + 2) * WBAND), Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; }
} }
// and drift a little downwind every second; at the edge of the world they pile up instead // and drift a little downwind every second, raining ones at half the pace; at the edge of the
// world they pile up instead
if (tick % 60 === 0) { if (tick % 60 === 0) {
const out = new Float32Array(n), d = w.wind > 0 ? 1 : -1, f = Math.min(0.12, Math.abs(w.wind) * 0.1); const out = new Float32Array(n), d = w.wind > 0 ? 1 : -1, f = Math.min(0.12, Math.abs(w.wind) * 0.1);
for (let b = 0; b < n; b++) { const mv = w.raining[b] ? 0 : w.moist[b] * f; out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; } for (let b = 0; b < n; b++) { const mv = w.moist[b] * f * (w.raining[b] ? 0.5 : 1); out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; }
w.moist = out; w.moist = out;
} }
} }
+11 -3
View File
@@ -13,7 +13,8 @@ let ents = []; // loose objects (bars)
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it. let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
let flyers = []; // grains in flight, blown out of the bot let flyers = []; // grains in flight, blown out of the bot
let ghostBlocks = new Map(), ghostMolds = []; let ghostBlocks = new Map(), ghostMolds = [];
let awake, hot, hotLvl; let awake, hot, hotLvl, powderArea, roofDirty;
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
let tick = 0, tickStamp = 1; let tick = 0, tickStamp = 1;
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true; let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []]; let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
@@ -39,16 +40,20 @@ function discover(m, x, y, from, how, T) {
function alloc(w, h) { function alloc(w, h) {
W = w; H = h; N = w * h; W = w; H = h; N = w * h;
fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH; CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N); mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N); stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N); noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = []; ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36; surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w); surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
AMB = new Float32Array(h); AMB = new Float32Array(h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
roofDirty = new Uint8Array(CW).fill(1);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true; machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = []; beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set(); ghostMachines = []; ghostBlocked = new Set();
@@ -64,8 +69,9 @@ function depthU(y) {
} }
// the y at a fraction u of the underground depth (the inverse of depthU) // the y at a fraction u of the underground depth (the inverse of depthU)
function depthY(u) { return Math.round(surfBase + (!shallowD ? u * underD : u <= 0.3 ? u / 0.3 * shallowD : shallowD + (u - 0.3) / 0.7 * (underD - shallowD))); } function depthY(u) { return Math.round(surfBase + (!shallowD ? u * underD : u <= 0.3 ? u / 0.3 * shallowD : shallowD + (u - 0.3) / 0.7 * (underD - shallowD))); }
function meanSurface() { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; return s / W; }
function computeAmbient() { function computeAmbient() {
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W; const sm = meanSurface();
for (let y = 0; y < H; y++) { for (let y = 0; y < H; y++) {
let a = 18; let a = 18;
if (y > sm) a += Math.min(60, (y - sm) * 0.04); if (y > sm) a += Math.min(60, (y - sm) * 0.04);
@@ -83,6 +89,7 @@ function fillNoise() {
// ---------- Waking ---------- // ---------- Waking ----------
function wakeXY(x, y) { function wakeXY(x, y) {
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx; const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
roofDirty[cx] = 1;
awake[c] = 3; awake[c] = 3;
const lx = x & 31, ly = y & 31; const lx = x & 31, ly = y & 31;
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3; if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
@@ -90,6 +97,7 @@ function wakeXY(x, y) {
} }
function wakeHeatXY(x, y) { function wakeHeatXY(x, y) {
const c = (y >> 5) * CW + (x >> 5); const c = (y >> 5) * CW + (x >> 5);
roofDirty[x >> 5] = 1;
hot[c] = 6; hotLvl[c] = 2; hot[c] = 6; hotLvl[c] = 2;
} }
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); } function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
+61
View File
@@ -0,0 +1,61 @@
// World generation helpers: noise and removal of tiny hard rock specks.
// ---------- Perlin noise ----------
function makeNoise(rng) {
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
const p = []; for (let i = 0; i < 256; i++) p.push(i);
for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
return function (x, y) {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const X = xi & 255, Y = yi & 255;
const p0 = perm[Y], p1 = perm[Y + 1];
const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1];
const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf;
const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1);
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41;
};
}
function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; }
// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a
// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves
// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock
// around it. Bigger veins and pockets stay. live: the world is in play (an older save).
const SPECK_MIN = 30;
function clearHardSpecks(live) {
const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1);
let n = 0;
for (const h0 of [6, 5, 4]) {
seen.fill(0);
const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; };
const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; };
for (let i0 = 0; i0 < N; i0++) {
if (seen[i0] || !mem(i0)) continue;
let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
const hosts = new Map();
st[sp++] = i0; seen[i0] = 1;
while (sp) {
const i = st[--sp], x = i % W, y = (i / W) | 0;
if (sz <= SPECK_MIN) cells[sz] = i;
sz++;
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
if (j < 0 || j >= N || seen[j]) continue;
if (mem(j)) { seen[j] = 1; st[sp++] = j; }
else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1);
}
}
if (sz > SPECK_MIN) continue;
// a speck left floating (in a cave, or dug round) takes the rock nearest round it
if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); }
let host = 0, best = 0;
for (const [m, c] of hosts) if (c > best) { best = c; host = m; }
if (!host) continue;
for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; }
n++;
}
}
return n;
}
+45 -74
View File
@@ -1,66 +1,7 @@
// World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. // World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
// ---------- Perlin noise ----------
function makeNoise(rng) {
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
const p = []; for (let i = 0; i < 256; i++) p.push(i);
for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
return function (x, y) {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const X = xi & 255, Y = yi & 255;
const p0 = perm[Y], p1 = perm[Y + 1];
const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1];
const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf;
const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1);
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41;
};
}
function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; }
// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a
// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves
// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock
// around it. Bigger veins and pockets stay. live: the world is in play (an older save).
const SPECK_MIN = 30;
function clearHardSpecks(live) {
const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1);
let n = 0;
for (const h0 of [6, 5, 4]) {
seen.fill(0);
const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; };
const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; };
for (let i0 = 0; i0 < N; i0++) {
if (seen[i0] || !mem(i0)) continue;
let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
const hosts = new Map();
st[sp++] = i0; seen[i0] = 1;
while (sp) {
const i = st[--sp], x = i % W, y = (i / W) | 0;
if (sz <= SPECK_MIN) cells[sz] = i;
sz++;
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
if (j < 0 || j >= N || seen[j]) continue;
if (mem(j)) { seen[j] = 1; st[sp++] = j; }
else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1);
}
}
if (sz > SPECK_MIN) continue;
// a speck left floating (in a cave, or dug round) takes the rock nearest round it
if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); }
let host = 0, best = 0;
for (const [m, c] of hosts) if (c > best) { best = c; host = m; }
if (!host) continue;
for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; }
n++;
}
}
return n;
}
// ---------- World generation ---------- // ---------- World generation ----------
function generateWorld(seedIn, w, h, onProgress) { function* worldSteps(seedIn, w, h) {
yield [0, 'Shaping the land'];
worldSeed = seedIn >>> 0; worldSeed = seedIn >>> 0;
alloc(w, h); alloc(w, h);
RS = (worldSeed ^ 0x9E3779B9) | 1; RS = (worldSeed ^ 0x9E3779B9) | 1;
@@ -146,6 +87,7 @@ function generateWorld(seedIn, w, h, onProgress) {
for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1; for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1;
} }
computeAmbient(); computeAmbient();
yield [0, 'Laying rock and ore'];
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
const fy = depthU(y); // fraction of the underground depth (negative in the sky) const fy = depthU(y); // fraction of the underground depth (negative in the sky)
for (let x = 0; x < w; x++) { for (let x = 0; x < w; x++) {
@@ -177,24 +119,34 @@ function generateWorld(seedIn, w, h, onProgress) {
} }
// ores // ores
if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) { if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) {
const host = m;
// Leave the shallow quarter unchanged; deeper rock has broader, richer lenses.
const rich = Math.min(1, Math.max(0, (fy - 0.25) / 0.6));
const c = nCoal(x / 13, y / 9); const c = nCoal(x / 13, y / 9);
if (d > 6 && fy < 0.45 && c > 0.5 && m !== CLAY) m = COALV; if ((d > 6 && fy < 0.45 && c > 0.5 - 0.55 * rich && m !== CLAY) ||
(fy >= 0.45 && (m === STONE || m === GRANITE) && c > 0.62 - 0.3 * rich) ||
(m === BASALT && fy > 0.6 && c > 0.58 - 0.4 * rich)) m = COALV;
else { else {
const cu = nCu(x / 9, y / 8); const cu = nCu(x / 9, y / 8);
const cuBand = d > 14 && fy < 0.48; const cuBand = d > 14 && fy < 0.48;
if (cuBand && (m === STONE || m === DIRT) && cu > (d < 140 ? 0.5 : 0.56)) m = CUV; if ((cuBand && (m === STONE || m === DIRT) && cu > (d < 140 ? 0.5 : 0.56) - 0.65 * rich) ||
(fy > 0.48 && fy < 0.8 && (m === STONE || m === GRANITE) && cu > 0.6 - 0.46 * rich)) m = CUV;
else { else {
// iron and zinc are harder than the stone around them, so they come in fewer, chunkier // iron and zinc are harder than the stone around them, so they come in fewer, chunkier
// lenses rather than a scatter of specks that a stone bit would leave in the way // lenses rather than a scatter of specks that a stone bit would leave in the way
const fe = nFe(x / 24, y / 13); const fe = nFe(x / 24, y / 13);
if (fy > 0.28 && (m === STONE || m === GRANITE || m === BASALT) && fe > (m === STONE ? 0.55 : 0.47)) m = FEV; if (fy > 0.28 && (m === STONE || m === GRANITE || m === BASALT) && fe > (m === STONE ? 0.55 : 0.47) - 0.55 * rich) m = FEV;
// rarer ores in deeper bands: tin, then zinc, silver in granite, gold deepest // rarer ores in deeper bands: tin, then zinc, silver in granite, gold deepest
else if (fy > 0.15 && fy < 0.55 && m === STONE && nSn(x / 8, y / 7) > 0.62) m = SNV; else if (((fy > 0.15 && fy < 0.55 && m === STONE) ||
else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6) m = ZNV; (fy >= 0.55 && fy < 0.8 && (m === STONE || m === GRANITE))) &&
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66) m = AGV; nSn(x / 8, y / 7) > 0.62 + Math.max(0, fy - 0.55) * 0.4 - 0.425 * rich) m = SNV;
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7) m = AUV; else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6 - 0.49 * rich) m = ZNV;
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66 - 0.32 * rich) m = AGV;
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7 - 0.18 * rich) m = AUV;
} }
} }
// no ore where the rock is naturally hot enough to light or melt it: it would burn or run forever
if (m !== host && (IGN[m] ? AMB[y] > IGN[m] - 120 : MELT[m] && AMB[y] > MELT[m] - 200)) m = host;
} }
// caves // caves
if (d > 9) { if (d > 9) {
@@ -221,9 +173,11 @@ function generateWorld(seedIn, w, h, onProgress) {
temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y]; temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y];
if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255; if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255;
} }
if (onProgress && (y & 63) === 0) onProgress(y / h * 0.7); if ((y & 63) === 0) yield [y / h * 0.7, 'Laying rock and ore'];
} }
yield [0.7, 'Carving caves'];
clearHardSpecks(false); clearHardSpecks(false);
yield [0.71, 'Snow and trees'];
// (tree bases are collected after the trees below) // (tree bases are collected after the trees below)
// snow lies on the high ground wherever the air is below freezing // snow lies on the high ground wherever the air is below freezing
for (let x = 1; x < w - 1; x++) { for (let x = 1; x < w - 1; x++) {
@@ -234,6 +188,7 @@ function generateWorld(seedIn, w, h, onProgress) {
const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1; const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1;
for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } } for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } }
} }
yield [0.72, 'Snow and trees'];
// trees // trees
for (let x = 12; x < w - 12;) { for (let x = 12; x < w - 12;) {
const sy = surfY[x]; const sy = surfY[x];
@@ -257,6 +212,7 @@ function generateWorld(seedIn, w, h, onProgress) {
x += 16 + Math.floor(rng() * 20); x += 16 + Math.floor(rng() * 20);
} else x += 4; } else x += 4;
} }
yield [0.73, 'Springs and water'];
// springs: in cave side walls just above the cave floor // springs: in cave side walls just above the cave floor
{ {
const cands = []; const cands = [];
@@ -286,16 +242,19 @@ function generateWorld(seedIn, w, h, onProgress) {
void picks; void picks;
} }
fillNoise(); fillNoise();
if (onProgress) onProgress(0.75); yield [0.75, 'Settling loose ground'];
// settle loose material // settle loose material at the water and sorting speeds the seeds were made with
awake.fill(3); awake.fill(3);
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP; LSTEP = LFILM = 1; SORTP = 0.003;
for (let k = 0; k < 260; k++) { for (let k = 0; k < 260; k++) {
tick++; tickStamp = (tickStamp % 255) + 1; tick++; tickStamp = (tickStamp % 255) + 1;
simMove(); simMove();
let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; } let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; }
if (!any) break; if (!any) break;
if (onProgress && (k & 31) === 0) onProgress(0.75 + 0.25 * k / 260); if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
} }
LSTEP = lstep; LFILM = lfilm; SORTP = sortp;
yield [0.99, 'Finishing'];
// re-ambient after settling, then start clean // re-ambient after settling, then start clean
for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; } for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; }
tick = 0; tick = 0;
@@ -311,5 +270,17 @@ function generateWorld(seedIn, w, h, onProgress) {
seen = new Uint8Array(NMAT); ledger = {}; seen = new Uint8Array(NMAT); ledger = {};
stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0; stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0;
EV.length = 0; EV.length = 0;
if (onProgress) onProgress(1); fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
yield [1, 'Finishing'];
}
function generateWorld(seedIn, w, h, onProgress) {
for (const [f, label] of worldSteps(seedIn, w, h)) if (onProgress) onProgress(f, label);
}
async function generateWorldAsync(seedIn, w, h, onProgress) {
let last = Date.now();
for (const [f, label] of worldSteps(seedIn, w, h)) {
if (onProgress) onProgress(f, label);
if (Date.now() - last >= 30) { await new Promise(r => setTimeout(r, 0)); last = Date.now(); }
}
} }
+75 -16
View File
@@ -61,8 +61,15 @@ canvas#game.nocursor { cursor: none; }
.bb-tile:hover { border-color: var(--soot-4); background: #2a2521; } .bb-tile:hover { border-color: var(--soot-4); background: #2a2521; }
.bb-tile.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); } .bb-tile.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); }
.bb-tile.on svg { color: var(--melt-hot); } .bb-tile.on svg { color: var(--melt-hot); }
#buildbar .bb-tip { font-size: 0.82em; line-height: 1.35; color: var(--ash); min-height: 1.35em; } #buildbar { position: relative; }
#buildbar .bb-tip b { color: var(--bone); font-weight: 600; } .bb-menu { display: flex; flex-direction: column; gap: 6px; }
#bb-pop { position: absolute; bottom: calc(100% + 8px); width: min(calc(300px * var(--ui)), calc(100vw - 32px)); box-sizing: border-box; padding: calc(10px * var(--ui)); display: flex; flex-direction: column; gap: 8px; background: var(--plate); border: 1px solid var(--soot-3); border-radius: 5px; box-shadow: 0 4px 18px #0008; color: var(--bone); pointer-events: none; }
.bb-title { display: flex; align-items: center; gap: 8px; }
.bb-title svg { width: calc(26px * var(--ui)); height: calc(26px * var(--ui)); color: var(--melt-hot); flex-shrink: 0; }
.bb-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.bb-desc { font-size: 0.88em; }
.bb-place { color: var(--ash); font-size: 0.82em; }
.bb-place b { display: block; color: var(--bone); margin-bottom: 3px; }
#ovbadges { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } #ovbadges { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.ovbadge { --ov: 92, 186, 166; display: inline-flex; align-items: center; gap: 8px; padding: 5px 8px 5px 11px; border-radius: 999px; background: rgba(12, 13, 16, 0.86); border: 1px solid rgba(var(--ov), 0.75); color: rgb(var(--ov)); font-weight: 650; font-size: 0.8em; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); } .ovbadge { --ov: 92, 186, 166; display: inline-flex; align-items: center; gap: 8px; padding: 5px 8px 5px 11px; border-radius: 999px; background: rgba(12, 13, 16, 0.86); border: 1px solid rgba(var(--ov), 0.75); color: rgb(var(--ov)); font-weight: 650; font-size: 0.8em; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ovbadge.lens { --ov: 255, 160, 90; } .ovbadge.lens { --ov: 255, 160, 90; }
@@ -111,9 +118,6 @@ kbd {
#selcard .row { display: flex; align-items: center; gap: 8px; } #selcard .row { display: flex; align-items: center; gap: 8px; }
#selcard .name { font-weight: 600; font-size: 1.05em; } #selcard .name { font-weight: 600; font-size: 1.05em; }
#selcard .sub { color: var(--ash); font-size: 0.88em; } #selcard .sub { color: var(--ash); font-size: 0.88em; }
#selcard .status { color: var(--danger); font-size: 0.88em; min-height: 0; }
#selcard .status:empty { display: none; }
#selcard .status.info { color: var(--patina); }
.sw { width: 14px; height: 14px; border-radius: 2px; border: 1px solid rgba(0, 0, 0, 0.5); flex: none; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); } .sw { width: 14px; height: 14px; border-radius: 2px; border: 1px solid rgba(0, 0, 0, 0.5); flex: none; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); }
.cycle { display: flex; align-items: center; gap: 6px; } .cycle { display: flex; align-items: center; gap: 6px; }
.cycle .grow { flex: 1; min-width: 0; } .cycle .grow { flex: 1; min-width: 0; }
@@ -170,11 +174,17 @@ table.mt td.dip { color: var(--melt); }
#baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); } #baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
#baglist .bagrow .pick { flex: 1; min-width: 0; } #baglist .bagrow .pick { flex: 1; min-width: 0; }
#baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#baglist .tick { flex: none; width: 20px; height: 20px; padding: 0; justify-content: center; } #baglist .tag { flex: none; padding: 0 3px; color: var(--patina); font-size: 11px; }
#baglist .tick::before { content: ''; width: 10px; height: 10px; border: 1.5px solid var(--ash-dim); border-radius: 2px; box-sizing: border-box; } #selcard .vacrow { flex-wrap: wrap; gap: 4px; }
#baglist .tick:hover::before { border-color: var(--ash); } #selcard .vacrow .sw { width: 9px; height: 9px; }
#baglist .tick.on::before { border-color: var(--patina); background: var(--patina); box-shadow: inset 0 0 0 1.5px var(--soot-1, #15120f); } #bagfoot:empty { display: none; }
#baglist .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; } #bagfoot { border-top: 1px solid var(--soot-3); margin-top: 4px; }
#bagfoot .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; }
.bagsep { margin: 6px 4px 2px; padding-top: 5px; border-top: 1px solid var(--ash-dim); color: var(--ash-dim); font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; }
#baglist .bagrow.none { opacity: 0.55; }
#baglist .bagrow.none:hover { opacity: 1; }
#bagfoot .bagclear { display: block; width: 100%; margin-top: 6px; padding: 4px 8px; background: rgba(92, 186, 166, 0.12); color: var(--bone); border: 1px solid var(--patina-dim); border-radius: 3px; font-weight: 600; cursor: pointer; }
#bagfoot .bagclear:hover { background: rgba(92, 186, 166, 0.24); border-color: var(--patina); }
#bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; } #bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; }
#info { padding: 7px 10px; min-width: calc(220px * var(--ui)); max-width: min(360px, 60vw); display: flex; flex-direction: column; gap: 2px; font-size: 0.92em; } #info { padding: 7px 10px; min-width: calc(220px * var(--ui)); max-width: min(360px, 60vw); display: flex; flex-direction: column; gap: 2px; font-size: 0.92em; }
@@ -182,7 +192,7 @@ table.mt td.dip { color: var(--melt); }
#info .t2 { color: var(--ash); } #info .t2 { color: var(--ash); }
#info .t3 { color: var(--melt-hot); opacity: 0.85; font-size: 0.94em; } #info .t3 { color: var(--melt-hot); opacity: 0.85; font-size: 0.94em; }
#cards { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; } #cards { display: flex; flex-direction: column; gap: 6px; align-items: center; }
.card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; } .card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; }
.card-pop .top { display: flex; align-items: center; gap: 6px; } .card-pop .top { display: flex; align-items: center; gap: 6px; }
.card-pop .x { margin-left: auto; background: none; border: none; color: var(--ash); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 2px; } .card-pop .x { margin-left: auto; background: none; border: none; color: var(--ash); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 2px; }
@@ -202,6 +212,8 @@ table.mt td.dip { color: var(--melt); }
.toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; } .toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; }
.toast.big { border-color: var(--melt-deep); padding: 10px 16px; } .toast.big { border-color: var(--melt-deep); padding: 10px 16px; }
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; } .toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; }
.toast.err { border-color: var(--danger); border-left-width: 3px; }
.toast.info { border-color: var(--patina); border-left-width: 3px; }
.toast.out { opacity: 0; transition: opacity 0.4s; } .toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
@@ -286,6 +298,7 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
.newworld { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 10px; } .newworld { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 10px; }
.newworld label { display: flex; flex-direction: column; gap: 4px; font-size: 0.86em; color: var(--ash); } .newworld label { display: flex; flex-direction: column; gap: 4px; font-size: 0.86em; color: var(--ash); }
.newworld select, .newworld input { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; color: var(--bone); } .newworld select, .newworld input { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; color: var(--bone); }
.newworld label:first-child { grid-column: 1 / -1; }
.newworld .full { grid-column: 1 / -1; } .newworld .full { grid-column: 1 / -1; }
.tfoot { text-align: center; color: var(--ash-dim); font-size: 0.82em; font-family: var(--font-mono); } .tfoot { text-align: center; color: var(--ash-dim); font-size: 0.82em; font-family: var(--font-mono); }
#loading { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(11, 9, 8, 0.86); } #loading { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(11, 9, 8, 0.86); }
@@ -307,6 +320,16 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.toast { animation: none; } .toast { animation: none; }
} }
/* wall material picker */
.wpick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; }
.wpick .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; }
.wpick .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; }
.wpick .wp span span { display: block; color: var(--ash); margin-top: 3px; }
.wpick .wp.short { opacity: 0.55; }
.wpick .wp:hover { background: #2d2724; }
.wpick .wp.on { border-color: #e59a52; background: #33261c; }
.wpick .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
[data-wall-picker] { cursor: pointer; }
/* filter picker */ /* filter picker */
.fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; } .fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; }
.fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; } .fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
@@ -333,8 +356,44 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
.fdir .fd.on { border-color: #e59a52; background: #33261c; } .fdir .fd.on { border-color: #e59a52; background: #33261c; }
.fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; } .fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
/* the ticked-mix choice at the bottom of the bag */
#baglist .mixrow { margin-top: 3px; border-top: 1px solid var(--soot-3); padding-top: 3px; border-radius: 0 0 3px 3px; }
#baglist .mixrow .pick { gap: 6px; } /* saved worlds */
#baglist .mixsw { display: flex; gap: 1px; flex: none; } .world-row { padding: 12px 0; border-bottom: 1px solid var(--soot-3); }
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; } .world-row:last-child { border-bottom: none; }
.world-info { overflow-wrap: anywhere; }
.world-info .note { margin: 4px 0; }
.world-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.world-actions input { flex: 1; min-width: 120px; max-width: 100%; }
#m-dialog { z-index: 21; }
#continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; }
.vacchoice { display: flex; align-items: center; gap: 4px; text-align: left; min-width: 0; background: transparent; border: 0; padding: 0; cursor: pointer; }
#saving { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: 54px; z-index: 10; padding: 5px 9px; border-radius: 12px; background: rgba(11, 9, 8, 0.86); color: var(--ash); font-size: 12px; pointer-events: none; }
#saving i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: saving-pulse 1.5s ease-in-out infinite alternate; }
@keyframes saving-pulse { to { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) { #saving i { animation: none; } }
#loadstep { color: var(--ash); font-size: 13px; }
/* Maps share the same grain colours and exploration. */
#minimap { padding: 5px; }
#mini-canvas { display: block; width: calc(200px * var(--ui)); height: calc(130px * var(--ui)); image-rendering: pixelated; }
.map-buttons { display: flex; justify-content: flex-end; gap: 4px; margin-top: 4px; }
.map-buttons button { padding: 2px 8px; }
#hud:has(#minimap:not([hidden])) ~ #perf { bottom: calc(190px * var(--ui) + 16px); }
#hud:has(#minimap:not([hidden])) ~ #saving { bottom: calc(190px * var(--ui) + 58px); }
#m-map > .plate { width: min(1200px, 96vw); height: 88vh; max-height: none; }
#map-canvas { display: block; width: 100%; flex: 1; min-height: 0; cursor: grab; touch-action: none; }
#m-map .mfoot { align-items: center; gap: 8px; flex-wrap: wrap; }
@media (max-width: 900px) {
#mini-canvas { width: calc(150px * var(--ui)); height: calc(98px * var(--ui)); }
}
@media (max-width: 720px) { #mini-plus, #mini-minus { display: none; } }
@media (max-width: 1100px) {
#hud.building .br { bottom: calc(160px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
}
+36 -41
View File
@@ -30,11 +30,12 @@ function doInteract() {
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => { openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
applyFilterRun(run, f); applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir; GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length === 1 ? 'This filter' : `These ${run.length} filters`} now ${filterSentence(f)}, ${f.dir ? 'right' : 'left'}.`, 'info'); setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${f.dir ? 'right' : 'left'}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter'); }, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run; GAME.editRun = run;
return; return;
} }
if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; }
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; } if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = ''; let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
@@ -42,37 +43,35 @@ function doInteract() {
msg = interactAt(ax + dx, ay + dy); msg = interactAt(ax + dx, ay + dy);
if (msg) break outer; if (msg) break outer;
} }
if (msg) { setStatus(msg, 'info'); if (!/^New (drill bit|bag)/.test(msg)) toast(msg); } if (msg) setStatus(msg, 'info', /^New (drill bit|bag)/.test(msg));
else { setStatus('Nothing to use there. F works on gates, molds, fans and filters.', 'info'); } else if ((moldAt(ax, ay) || {}).kind !== 'ingot') setStatus('Nothing to use there.', 'info'); // an ingot mold needs no F: say nothing
} }
// Pick (G or middle mouse): the piece under the cursor becomes the one you build, with its // Pick (middle mouse): the piece under the cursor becomes the one you build, with its
// direction, wall material and filter settings. On loose material it picks that to blow out. // direction, wall material and filter settings. On loose material it picks that to blow out.
function pickUnderCursor() { function pickUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return; if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
const i = ay * W + ax, m = mat[i], bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0); const i = ay * W + ax, m = mat[i], bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
let pid = null, note = ''; let pid = null;
const ghost = ghostBlocks.get(bi); const ghost = ghostBlocks.get(bi);
if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; } if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; note = MD[WALL_OF[mo.wall]].name.toLowerCase(); } else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; }
else if (blockP[bi] || ghost) { else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1]; const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id; const P = PIECE_BY_CODE[code]; pid = P.id;
if (P.code === BP_WALL) { GAME.wallMat = v; note = MD[WALL_OF[v]].name.toLowerCase(); } if (P.code === BP_WALL) { GAME.wallMat = v; }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; note = filterSentence(GAME.filterCfg); } else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; }
else if (P.dirs) GAME.pieceDir[P.id] = v & 3; else if (P.dirs) GAME.pieceDir[P.id] = v & 3;
if (P.dirs && !P.filter) note = P.dirs[GAME.pieceDir[P.id] || 0];
else if (P.filter) note = `${P.dirs[GAME.pieceDir[P.id] || 0]}, ${note}`;
} }
if (pid) { if (pid) {
const P = PIECE_BY_ID[pid]; const P = PIECE_BY_ID[pid];
if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; } if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; }
GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui'); GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui');
setStatus(`Picked: ${P.name}${note ? ` (${note})` : ''}.`, 'info'); setStatus(`Picked: ${P.name}.`, 'info');
return; return;
} }
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0; const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
if (t && bag[t] > 0) { GAME.dropMat = t; GAME.matChosen = true; GAME.blowMix = false; selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); } if (t) { GAME.blow = new Set([t]); selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
else setStatus('Nothing built here to pick.', 'info'); else setStatus('Nothing built here to pick.', 'info');
} }
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); } function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
@@ -81,7 +80,7 @@ function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx -
// Copy and cut: hold C or X and drag a box with the left button; letting go does it. // Copy and cut: hold C or X and drag a box with the left button; letting go does it.
function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; } function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; }
function startPaste() { function startPaste() {
if (!GAME.clip) { setStatus('Nothing copied yet. Hold C and drag a box over something you built.', 'info'); playSfx('error'); return; } if (!GAME.clip) { setStatus('Hold C and drag over built pieces to copy.', 'info'); playSfx('error'); return; }
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui'); GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
} }
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); } function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
@@ -119,17 +118,13 @@ function primaryPress(shift) {
return; return;
} }
if (t === 'vacuum') { if (t === 'vacuum') {
// with anything ticked, the vacuum takes only the ticked materials, wherever you start; GAME.vacFilter = shift ? -1 : GAME.blow.size ? -2 : grainAt(ax, ay);
// with nothing ticked, it takes what you start on
const g = GAME.collect.size ? 0 : grainAt(ax, ay);
GAME.vacFilter = shift ? -1 : g;
GAME.vacLocked = shift || !!g || GAME.collect.size > 0;
hudDirty = true; hudDirty = true;
} else if (t === 'build') { } else if (t === 'build') {
const P = piece(); const P = piece();
if (P.kind === 'block') { GAME.drag = aimBlock(); } if (P.kind === 'block') { GAME.drag = aimBlock(); }
else { else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh; const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh); const { bx, by } = stampOrigin(bw, bh);
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; } if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by); const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by);
@@ -137,15 +132,15 @@ function primaryPress(shift) {
let err = ''; let err = '';
undoRecord(rec => piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-') ? `replacing with a ${P.name.toLowerCase()}` : `placing a ${P.name.toLowerCase()}`, () => { undoRecord(rec => piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-') ? `replacing with a ${P.name.toLowerCase()}` : `placing a ${P.name.toLowerCase()}`, () => {
err = check() || ''; err = check() || '';
// Ctrl: take down what you built in the way (refunded) and put this in its place // G: take down what you built in the way (refunded) and put this in its place
if (err && GAME.ctrl && /already/.test(err)) { if (err && GAME.replace && /already/.test(err)) {
err = footprintBlocker(bx, by, bw, bh); err = footprintBlocker(bx, by, bw, bh);
if (!err && !canAfford(pieceCostNow(P), 1)) err = needText(pieceCostNow(P), 1); if (!err && !canAfford(pieceCostNow(P), 1)) err = needText(pieceCostNow(P), 1);
if (!err) err = clearFootprint(bx, by, bw, bh); if (!err) err = clearFootprint(bx, by, bw, bh);
} }
if (!err) err = place(); if (!err) err = place();
}); });
if (err) { setStatus(err + (/already/.test(err) ? `${/[.!]$/.test(err) ? '' : '.'} Hold Ctrl to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; } if (err) { setStatus(err + (/already/.test(err) ? `; hold G to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
} }
} }
} }
@@ -156,11 +151,11 @@ function primaryRelease(shift) {
if (sd.mode === 'cut') { if (sd.mode === 'cut') {
let bp = null; let bp = null;
undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); }); undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); });
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks into your bag. Click to paste it somewhere; Ctrl+Z puts it back.`, 'info'); } if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks; click to paste.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); } else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
} else { } else {
const bp = copyRegion(sd.x, sd.y, ax, ay); const bp = copyRegion(sd.x, sd.y, ax, ay);
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks. Click to place it.`, 'info'); } if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks; click to place.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); } else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
} }
return; return;
@@ -175,7 +170,7 @@ function primaryRelease(shift) {
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by)); const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
let ghosts = 0; let ghosts = 0;
const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => { const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => {
const res = placeBlocks(P.code, v, near, ex, GAME.ctrl); const res = placeBlocks(P.code, v, near, ex, GAME.replace);
const g = { t: 'paste', blocks: [], machines: [], molds: [] }; const g = { t: 'paste', blocks: [], machines: [], molds: [] };
for (const [bx, by] of far) { for (const [bx, by] of far) {
const b = by * BW + bx; const b = by * BW + bx;
@@ -185,16 +180,16 @@ function primaryRelease(shift) {
if (ghosts) undoNote(g); if (ghosts) undoNote(g);
return res; return res;
}); });
const hint = /already built/.test(r.status) ? `${/[.!]$/.test(r.status) ? '' : '.'} Hold Ctrl to replace it.` : ''; const hint = /already built/.test(r.status) ? `; hold G to replace it.` : '';
const gtxt = ghosts ? `${r.n ? `Placed ${plural(r.n, P.name.toLowerCase())}; ` : ''}${plural(ghosts, 'more')} out of reach ${ghosts === 1 ? 'is a ghost' : 'are ghosts'} your bot builds when it gets close.` : ''; const gtxt = ghosts ? `${r.n} placed; ${ghosts} ghosts waiting for the bot.` : '';
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); } if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
GAME.drag = null; GAME.dragDir = -1; hudDirty = true; GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
} }
if (t === 'vacuum') { GAME.vacFilter = 0; GAME.vacLocked = false; hudDirty = true; } if (t === 'vacuum') { GAME.vacFilter = 0; hudDirty = true; }
} }
function secondaryPress() { function secondaryPress(shift) {
const t = toolId(); const t = toolId();
GAME.rmbSeen = new Set(); GAME.rmbSeen = new Set(); GAME.blowAll = !!shift;
if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; } if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; }
else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; } else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; }
else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; } else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
@@ -209,26 +204,26 @@ function toolTick() {
const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune); const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune);
if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); } if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); }
} else if (t === 'vacuum') { } else if (t === 'vacuum') {
// started on empty space: the first loose thing you sweep over (that is not ticked) becomes the filter if (GAME.vacFilter === 0) { GAME.vacFilter = grainAt(ax, ay); if (GAME.vacFilter) hudDirty = true; }
// clicked on empty space: the first loose thing that comes under the cursor sets the filter, once const f = GAME.vacFilter;
if (!GAME.vacLocked && !GAME.collect.size) { const g = grainAt(ax, ay); if (g) { GAME.vacFilter = g; GAME.vacLocked = true; hudDirty = true; } } if (f !== 0) {
const f = GAME.vacFilter, list = GAME.collect; const r = toolVacuum(ax, ay, GAME.brush[1], f === -2 ? m => GAME.blow.has(m) : f < 0 ? () => true : m => m === f);
if (f || list.size) {
const r = toolVacuum(ax, ay, GAME.brush[1], f < 0 ? () => true : list.size ? m => list.has(m) : m => m === f);
if (r.got) { playSfx('suck'); hudDirty = true; } if (r.got) { playSfx('suck'); hudDirty = true; }
if (r.status) setStatus(r.status); if (r.status) setStatus(r.status);
} }
} else if (t === 'ignite') { } else if (t === 'ignite') {
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Nothing here burns. Aim at coal or wood that touches air.'); } if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Aim at coal or wood touching air to light it.'); }
else setStatus('Out of reach.'); else setStatus('Out of reach.');
} }
} }
if (GAME.rmb && !GAME.rmbConsumed) { if (GAME.rmb && !GAME.rmbConsumed) {
if (t === 'vacuum') { if (t === 'vacuum') {
ensureDropMat(); if (!GAME.blowAll && !GAME.blow.size) { setStatus('Nothing selected: click or Q/E in the bag.'); return; }
// what right click blows is only ever what is selected (and shown by the cursor): one material, or the ticked mix const list = GAME.blowAll ? blowAllList() : blowList();
const mix = GAME.blowMix && GAME.collect.size ? [...GAME.collect] : null; if (!GAME.blowAll && !list.length) { setStatus('None of the selected materials are in the bag.'); return; }
const r = toolBlow(ax, ay, GAME.brush[1], GAME.dropMat, mix); const r = GAME.blowAll && !list.length ? { placed: 0, status: 'Nothing loose in the bag to blow.' } : toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null);
// blown down to nothing: it leaves the selection, so an empty selection picks up what you click again
for (const m of list) if (bag[m] <= 0 && GAME.blow.delete(m)) { lastBagKey = ''; hudDirty = true; }
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status); if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') { } else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`; const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
+24 -3
View File
@@ -75,7 +75,7 @@ function tone(freq, dur, opts = {}) {
g.gain.exponentialRampToValueAtTime(v, t + a); g.gain.exponentialRampToValueAtTime(v, t + a);
g.gain.setValueAtTime(v, t + Math.max(a, dur - rel)); g.gain.setValueAtTime(v, t + Math.max(a, dur - rel));
g.gain.exponentialRampToValueAtTime(0.0001, t + dur); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(opts.bus || AU.sfx); o.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
o.start(t); o.stop(t + dur + 0.02); o.start(t); o.stop(t + dur + 0.02);
} }
function noiseBurst(dur, opts = {}) { function noiseBurst(dur, opts = {}) {
@@ -87,7 +87,7 @@ function noiseBurst(dur, opts = {}) {
if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur); if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur);
const g = ctx.createGain(); const v = opts.vol || 0.08; const g = ctx.createGain(); const v = opts.vol || 0.08;
g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(opts.bus || AU.sfx); s.connect(f); f.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02); s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02);
} }
const SFX = { const SFX = {
@@ -118,7 +118,28 @@ const SFX = {
sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); }, sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); },
discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); }, discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); },
}; };
function playSfx(name, arg) { if (!AU.ctx || !SFX[name]) return; try { SFX[name](arg); } catch (e) { /* ignore */ } } // World sounds are heard around the bot, whatever the zoom: full within 24 grains, silent at 96
const SFX_NEAR = 24, SFX_FAR = 96;
function sfxPlace(x, y) {
if (typeof bot === 'undefined' || !bot.w) return { gain: 1, pan: 0 };
const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2), d = Math.hypot(dx, dy);
return { gain: Math.max(0, Math.min(1, (SFX_FAR - d) / (SFX_FAR - SFX_NEAR))), pan: Math.max(-1, Math.min(1, dx / SFX_FAR)) * 0.6 };
}
function playSfx(name, arg, x, y) {
if (!AU.ctx || !SFX[name]) return;
const place = typeof x === 'number' && typeof y === 'number' ? sfxPlace(x, y) : null;
if (place && place.gain < 0.02) return;
const prev = AU.bus; let gain, pan;
try {
if (place) {
gain = AU.ctx.createGain(); pan = AU.ctx.createStereoPanner();
gain.gain.value = place.gain; pan.pan.value = place.pan;
gain.connect(pan); pan.connect(AU.sfx); AU.bus = gain;
}
SFX[name](arg);
} catch (e) { /* ignore */ }
finally { AU.bus = prev; if (gain || pan) setTimeout(() => { if (gain) gain.disconnect(); if (pan) pan.disconnect(); }, 1500); }
}
// Ambient layer driven by what is on screen. // Ambient layer driven by what is on screen.
function auAmbient(info) { function auAmbient(info) {
+50 -18
View File
@@ -4,7 +4,7 @@ const BUILD_CATS = [
['Structure', ['wall', 'grate', 'gate']], ['Structure', ['wall', 'grate', 'gate']],
['Moving', ['belt', 'filter', 'tube']], ['Moving', ['belt', 'filter', 'tube']],
['Liquids', ['pipe', 'pump', 'outlet']], ['Liquids', ['pipe', 'pump', 'outlet']],
['Machines', ['fan', 'refiner', 'cutter', 'silo']], ['Machines', ['fan', 'grinder', 'saw', 'press', 'rack', 'silo']],
['Molds', ['ingotmold', 'bitmold', 'tankmold']], ['Molds', ['ingotmold', 'bitmold', 'tankmold']],
]; ];
const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]); const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]);
@@ -19,8 +19,10 @@ const PIECE_ICONS = {
pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>', pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>',
outlet: '<path d="M2 7h12a4 4 0 0 1 4 4v3"/><path d="M2 11h10a2 2 0 0 1 2 2v1"/><path d="M15 18v1.5M18 20v1.5M12 20v1.5"/>', outlet: '<path d="M2 7h12a4 4 0 0 1 4 4v3"/><path d="M2 11h10a2 2 0 0 1 2 2v1"/><path d="M15 18v1.5M18 20v1.5M12 20v1.5"/>',
fan: '<circle cx="12" cy="12" r="1.8"/><path d="M12 10.2c-.4-3.6 1.8-6 4-4.8 2 1.2.4 3.8-4 4.8zM13.8 12c3.6-.4 6 1.8 4.8 4-1.2 2-3.8.4-4.8-4zM12 13.8c.4 3.6-1.8 6-4 4.8-2-1.2-.4-3.8 4-4.8zM10.2 12c-3.6.4-6-1.8-4.8-4 1.2-2 3.8-.4 4.8 4z"/>', fan: '<circle cx="12" cy="12" r="1.8"/><path d="M12 10.2c-.4-3.6 1.8-6 4-4.8 2 1.2.4 3.8-4 4.8zM13.8 12c3.6-.4 6 1.8 4.8 4-1.2 2-3.8.4-4.8-4zM12 13.8c.4 3.6-1.8 6-4 4.8-2-1.2-.4-3.8 4-4.8zM10.2 12c-3.6.4-6-1.8-4.8-4 1.2-2 3.8-.4 4.8 4z"/>',
refiner: '<path d="M3 4h18l-5 7H8z"/><circle cx="9" cy="15.5" r="2.8"/><circle cx="15" cy="15.5" r="2.8"/><path d="M12 20v2"/>', grinder: '<path d="M3 4h18l-5 7H8z"/><circle cx="9" cy="15.5" r="2.8"/><circle cx="15" cy="15.5" r="2.8"/><path d="M12 20v2"/>',
cutter: '<path d="M5 3h14l-3 5H8z"/><circle cx="12" cy="15" r="5"/><circle cx="12" cy="15" r="1.2"/><path d="M12 9.2v1.6M17.8 15h-1.6M12 20.8v-1.6M6.2 15h1.6"/>', press: '<path d="M5 3h14l-3 5H8zM12 8v5M5 13h14v3H5z"/><rect x="8" y="18" width="8" height="3"/>',
saw: '<path d="M5 3h14l-3 5H8z"/><circle cx="12" cy="15" r="5"/><circle cx="12" cy="15" r="1.2"/><path d="M12 9.2v1.6M17.8 15h-1.6M12 20.8v-1.6M6.2 15h1.6"/>',
rack: '<path d="M4 3v18M20 3v18M4 11h16M4 20h16"/><path d="M7 6h10v3H7zM7 15h10v3H7z"/>',
silo: '<path d="M6 5.5h12v11H6z"/><path d="M6 5.5l1-2h10l1 2M6 16.5l4 3h4l4-3M7 16.5v5M17 16.5v5"/><path d="M15.5 8v6"/>', silo: '<path d="M6 5.5h12v11H6z"/><path d="M6 5.5l1-2h10l1 2M6 16.5l4 3h4l4-3M7 16.5v5M17 16.5v5"/><path d="M15.5 8v6"/>',
ingotmold: '<path d="M3 9h18l-2 7H5z"/><path d="M9.2 9l.8 7M14.8 9l-.8 7"/>', ingotmold: '<path d="M3 9h18l-2 7H5z"/><path d="M9.2 9l.8 7M14.8 9l-.8 7"/>',
bitmold: '<path d="M3 9h10l7 3-7 3H3z"/><path d="M6.5 9v6M9.5 9v6"/>', bitmold: '<path d="M3 9h10l7 3-7 3H3z"/><path d="M6.5 9v6M9.5 9v6"/>',
@@ -28,40 +30,70 @@ const PIECE_ICONS = {
}; };
function pieceIcon(id) { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${PIECE_ICONS[id] || ''}</svg>`; } function pieceIcon(id) { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${PIECE_ICONS[id] || ''}</svg>`; }
function catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; } function catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; }
let lastBuildKey = ''; let lastBuildKey = '', lastBuildMenu = '';
function selectBuildCat(c) {
GAME.buildCat = c;
const ids = BUILD_CATS[c][1], keep = GAME.catPick && GAME.catPick[c];
GAME.pieceIdx = PIECE_BY_ID[ids.includes(keep) ? keep : ids[0]].index;
GAME.catPick = GAME.catPick || {}; GAME.catPick[c] = piece().id;
GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui');
}
function cycleBuildCat(d) {
const c = GAME.buildCat != null ? GAME.buildCat : catOf(piece().id);
selectBuildCat((c + d + BUILD_CATS.length) % BUILD_CATS.length);
}
function buildVariantChips(P) {
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
return pieceUsesWall(P) ? `<button class="chip hot" data-wall-picker><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R · F</button>`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
}
function renderBuildBar() { function renderBuildBar() {
const bar = $('buildbar'); if (!bar) return; const bar = $('buildbar'); if (!bar) return;
const show = GAME.running && toolId() === 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld(); const show = GAME.running && toolId() === 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld();
if (bar.hidden === show) bar.hidden = !show; if (bar.hidden === show) { bar.hidden = !show; $('hud').classList.toggle('building', show); }
if (!show) { lastBuildKey = ''; return; } if (!show) { lastBuildKey = ''; return; }
const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover; const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover;
const ids = BUILD_CATS[cat][1]; const ids = BUILD_CATS[cat][1], tip = PIECE_BY_ID[GAME.usingPad ? P.id : hov && ids.includes(hov) ? hov : ''];
const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join(''); const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join('');
const key = [cat, P.id, hov, afford, GAME.wallMat, Math.round(money)].join('|'); const key = [cat, P.id, tip && tip.id, afford, GAME.wallMat, Math.round(money), GAME.pieceDir[P.id], GAME.filterCfg.inv, GAME.filterCfg.m.join('.'), innerWidth, innerHeight, SET.uiScale].join('|');
if (key === lastBuildKey) return; if (key === lastBuildKey) return;
lastBuildKey = key; lastBuildKey = key;
const tip = PIECE_BY_ID[hov && ids.includes(hov) ? hov : P.id];
let h = '<div class="bb-head">' + BUILD_CATS.map(([name], k) => `<button class="bb-tab${k === cat ? ' on' : ''}" data-cat="${k}">${esc(name)}</button>`).join('') let h = '<div class="bb-head">' + BUILD_CATS.map(([name], k) => `<button class="bb-tab${k === cat ? ' on' : ''}" data-cat="${k}">${esc(name)}</button>`).join('')
+ '<span class="bb-keys"><kbd>Q</kbd>/<kbd>E</kbd> cycle · <kbd>G</kbd> pick</span></div><div class="bb-tiles">'; + '<span class="bb-keys"><kbd>Q</kbd>/<kbd>E</kbd> cycle · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab</span></div><div class="bb-tiles">';
for (const id of ids) { for (const id of ids) {
const Q = PIECE_BY_ID[id], cost = pieceCostNow(Q), ok = canAfford(cost, 1); const Q = PIECE_BY_ID[id], cost = pieceCostNow(Q), ok = canAfford(cost, 1);
const sw = Q.variants === 'wall' ? `<span class="sw" style="background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>` : ''; const sw = Q.variants === 'wall' ? `<span class="sw" style="background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>` : '';
h += `<button class="bb-tile${id === P.id ? ' on' : ''}" data-piece="${id}" aria-pressed="${id === P.id}" aria-label="${esc(Q.name)}">${sw}${pieceIcon(id)}<span class="n">${esc(Q.name)}</span><span class="c${ok ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span></button>`; h += `<button class="bb-tile${id === P.id ? ' on' : ''}" data-piece="${id}" aria-pressed="${id === P.id}" aria-label="${esc(Q.name)}">${sw}${pieceIcon(id)}<span class="n">${esc(Q.name)}</span><span class="c${ok ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span></button>`;
} }
// just the first sentence here; the card at the top left has the rest for the chosen piece h += '</div>';
const first = (tip.desc.match(/^.*?[.!?](\s|$)/) || [tip.desc])[0].trim(); if (!bar.querySelector('.bb-menu')) bar.innerHTML = '<div class="bb-menu"></div><div id="bb-pop" hidden></div>';
h += `</div><div class="bb-tip"><b>${esc(tip.name)}.</b> ${esc(first)}</div>`; const menu = bar.querySelector('.bb-menu');
bar.innerHTML = h; if (lastBuildMenu !== h) { menu.innerHTML = h; lastBuildMenu = h; }
const pop = $('bb-pop'); pop.hidden = !tip;
if (!tip) return;
const size = tip.kind === 'machine' ? `${tip.bw}×${tip.bh} blocks` : tip.kind === 'mold' ? `${MOLDS[tip.mold].bw}×${MOLDS[tip.mold].bh} blocks` : '1 block';
pop.innerHTML = `<div class="bb-title">${pieceIcon(tip.id)}<b>${esc(tip.name)}</b></div><div class="bb-chips">${chipCost(pieceCostNow(tip))}<span class="chip">${size}</span>${tip.power ? `<span class="chip">${tip.power} power</span>` : ''}</div>
<div class="bb-desc">${esc(tip.desc)}</div><div class="bb-place"><b>How to place</b> ${tip.kind === 'block' ? 'Drag for a line, Shift-drag for a rectangle; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses <kbd>R</kbd>.' : tip.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(tip) ? ' <kbd>R</kbd> cycles materials; <kbd>F</kbd> opens the material menu.' : ''}</div>
${tip.filter ? `<div class="bb-place">New ones will ${esc(filterSentence(GAME.filterCfg))}. <kbd>F</kbd> sets materials and mode (or click materials in the bag, or <kbd>T</kbd> over one); <kbd>F</kbd> on a placed filter changes its whole row.</div>` : ''}
<div class="bb-place">hold <kbd>G</kbd> replace · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · right click take back · middle mouse pick · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste</div>`;
const tile = bar.querySelector(`[data-piece="${tip.id}"]`); if (!tile) { pop.hidden = true; return; }
const r = tile.getBoundingClientRect(), br = bar.getBoundingClientRect(), w = pop.getBoundingClientRect().width;
pop.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2)) - br.left}px`;
} }
function setupBuildBar() { function setupBuildBar() {
$('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); });
const bar = $('buildbar'); if (!bar) return; const bar = $('buildbar'); if (!bar) return;
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return; const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return;
if (t.dataset.cat != null) { if (t.dataset.cat != null) { selectBuildCat(+t.dataset.cat); return; }
const c = +t.dataset.cat; GAME.buildCat = c; GAME.pieceIdx = PIECE_BY_ID[t.dataset.piece].index; GAME.buildCat = catOf(t.dataset.piece);
const ids = BUILD_CATS[c][1], keep = GAME.catPick && GAME.catPick[c];
GAME.pieceIdx = PIECE_BY_ID[ids.includes(keep) ? keep : ids[0]].index;
} else { GAME.pieceIdx = PIECE_BY_ID[t.dataset.piece].index; GAME.buildCat = catOf(t.dataset.piece); }
GAME.catPick = GAME.catPick || {}; GAME.catPick[catOf(piece().id)] = piece().id; GAME.catPick = GAME.catPick || {}; GAME.catPick[catOf(piece().id)] = piece().id;
GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui'); GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui');
}); });
+145
View File
@@ -0,0 +1,145 @@
// Shared player state; the world save remains a complete standalone fallback.
let career = null, claimBusy = false;
function careerState() {
const carriedBag = Array.from(bag);
for (const f of flyers) if (f.bag) carriedBag[f.m]++;
return { money, earned, bag: carriedBag, player: { ...player }, seen: Array.from(seen), ledger: { ...ledger }, stats: { ...stats } };
}
function validCareer(c, id, worldId) {
const obj = v => v && typeof v === 'object' && !Array.isArray(v);
const count = v => Number.isFinite(v) && v >= 0;
const counts = v => obj(v) && Object.values(v).every(count);
return obj(c) && c.id === id && Array.isArray(c.claims) && c.claims.length > 0 &&
c.claims.every(v => typeof v === 'string' && v.length > 0) && new Set(c.claims).size === c.claims.length && c.claims.includes(worldId) &&
Number.isSafeInteger(c.claimNo) && c.claimNo >= c.claims.length && count(c.money) && count(c.earned) &&
Array.isArray(c.bag) && c.bag.length > 0 && c.bag.every(v => Number.isInteger(v) && v >= 0 && v <= 2147483647) &&
Array.isArray(c.seen) && c.seen.length > 0 && c.seen.every(v => v === 0 || v === 1) &&
obj(c.player) && Number.isInteger(c.player.bitTier) && !!BIT_TIERS[c.player.bitTier] &&
Number.isInteger(c.player.tankTier) && !!TANK_TIERS[c.player.tankTier] &&
typeof c.player.lens === 'boolean' && typeof c.player.flask === 'boolean' &&
counts(c.ledger) && counts(c.stats) && ['dug', 'moved', 'placed', 'ignitions', 'cast', 'bars'].every(k => count(c.stats[k]));
}
function applyCareerState(c) {
money = c.money; earned = c.earned;
bag = new Int32Array(NMAT); bag.set(c.bag.slice(0, NMAT));
bagTotal = bag.reduce((n, v) => n + v, 0);
// These grains are already included in the shared bag, even on an older claim.
flyers = flyers.filter(f => !f.bag);
for (const k of Object.keys(player)) player[k] = c.player[k];
seen = new Uint8Array(NMAT); seen.set(c.seen.slice(0, NMAT));
ledger = { ...c.ledger };
for (const k of Object.keys(stats)) delete stats[k];
Object.assign(stats, c.stats);
}
async function loadCareer() {
career = null; GAME.careerId = ''; GAME.claimNo = 1;
try {
if (!GAME.worldId) GAME.worldId = newWorldId();
const entry = (await listWorlds()).find(w => w.id === GAME.worldId);
const c = entry && entry.career ? await DB.get('career:' + entry.career) : null;
if (entry && validCareer(c, entry.career, GAME.worldId)) {
career = c; applyCareerState(c); GAME.claimNo = Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
} else {
// Preserve surviving claim membership when repairing a missing or damaged record.
const claims = entry && entry.career ? (await listWorlds()).filter(w => w.career === entry.career) : [];
career = { id: entry && entry.career || newWorldId(), claims: claims.length ? claims.map(w => w.id) : [GAME.worldId],
claimNo: Math.max(1, ...claims.map(w => Number.isSafeInteger(w.claimNo) && w.claimNo > 0 ? w.claimNo : 1)), ...careerState() };
GAME.claimNo = entry && Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
await worldWrite(async () => {
const list = await listWorlds();
let w = list.find(w => w.id === GAME.worldId);
if (!w) { w = { id: GAME.worldId, name: GAME.worldName || 'World', seed: worldSeed, w: W, h: H, savedAt: 0 }; list.push(w); }
Object.assign(w, { career: career.id, claimNo: GAME.claimNo, debt });
await DB.op('readwrite', st => { st.put(career, 'career:' + career.id); st.put(list, 'worlds'); });
});
}
GAME.careerId = career.id;
} catch (e) { career = null; toast('Career could not be loaded. Using this world’s own money and bag.'); }
}
// Seeded camp names stay the same unless an existing world already uses one.
function claimName(seed, taken = []) {
const first = ['Copper', 'Iron', 'Red', 'Black', 'Grey', 'Silver', 'Deep', 'Cinder', 'Ash', 'Granite', 'Basalt', 'Slate', 'Rust', 'Ember', 'Flint', 'Cold', 'Hollow', 'Lone', 'Twin', 'Broken'];
const last = ['Hollow', 'Ridge', 'Flats', 'Gulch', 'Mesa', 'Seam', 'Reach', 'Bluff', 'Basin', 'Crag', 'Shelf', 'Draw', 'Cut', 'Rise', 'Pit', 'Vein', 'Wash', 'Fork', 'Run', 'Bench'];
const count = first.length * last.length, start = Math.floor(mulberry32(seed)() * count);
for (let offset = 0; offset < count; offset++) {
const i = (start + offset) % count, a = first[Math.floor(i / last.length)], b = last[i % last.length];
const name = a + ' ' + b;
if (a !== b && !taken.includes(name)) return name;
}
// every camp name is taken: fall back to numbered names
let n = 1;
while (taken.includes('Mining Camp ' + n)) n++;
return 'Mining Camp ' + n;
}
async function openNewClaim() {
if (claimBusy || !career || debt > 0 || money < 5000) return false;
claimBusy = true; const paused = GAME.paused; GAME.paused = true;
let previous;
try {
if (!await saveWorld('main', true)) throw new Error('Save failed');
previous = { save: serializeWorld(), id: GAME.worldId, name: GAME.worldName, time: GAME.playTime, career, claimNo: GAME.claimNo };
const carried = careerState(), n = career.claimNo + 1, w = W, h = H;
carried.money -= 5000;
let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed);
const name = claimName(seed, (await listWorlds()).map(w => w.name));
await setupNewWorld(seed, w, h, name, carried, 'Opening a new claim');
debt = CLAIM_DEBT * 2 ** (n - 1);
career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n };
GAME.claimNo = n;
showLoading(true, 0.9, 'Saving');
if (!await saveWorld('auto', true)) throw new Error('Save failed');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame();
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
return true;
} catch (e) {
if (previous) {
deserializeWorld(previous.save.meta, previous.save.buffers);
GAME.worldId = previous.id; GAME.worldName = previous.name; GAME.playTime = previous.time;
career = previous.career; GAME.claimNo = previous.claimNo;
}
showLoading(false); GAME.paused = paused; toast('Could not open a claim. Your old claim has been kept.'); return false;
} finally { claimBusy = false; }
}
async function travel(worldId) {
if (claimBusy || !career || worldId === GAME.worldId || !career.claims.includes(worldId)) return false;
claimBusy = true; const paused = GAME.paused; let arrived = false; GAME.paused = true;
try {
const entry = (await listWorlds()).find(w => w.id === worldId);
if (!entry || entry.career !== GAME.careerId) return false;
const progress = (f, s) => showLoading(true, f, s, 'Travelling to ' + entry.name);
progress(0, 'Saving');
if (!await saveWorld('main', true)) throw new Error('Save failed');
const sv = await worldNewest(worldId, progress);
if (!sv || !await startFromSave(sv, progress)) return false;
closeAllModals(); arrived = true; return true;
} catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; }
finally { showLoading(false); claimBusy = false; if (!arrived) GAME.paused = paused; }
}
async function renderClaimsTab(body) {
body.innerHTML = '<p class=note>Loading claims…</p>';
try {
const list = (await listWorlds()).filter(w => career && w.career === GAME.careerId && career.claims.includes(w.id));
if (nbTab !== 'Claims') return;
body.innerHTML = list.map(w => `<div class="world-row"><div class="world-info"><strong>${esc(w.name)}${w.id === GAME.worldId ? ' · here' : ''}</strong> <small class="note">Claim ${esc(w.claimNo)}</small><div class="note">${esc(w.w)}×${esc(w.h)} grains · ${(w.id === GAME.worldId ? debt : w.debt) <= 0 ? 'paid off ✓' : fmtMoney(w.id === GAME.worldId ? debt : w.debt || 0) + ' owed'} · Last played: ${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Just opened'}</div></div><button class="btn" data-rename="${esc(w.id)}">Rename</button>${w.id === GAME.worldId ? '' : `<button class="btn" data-travel="${esc(w.id)}">Travel</button>`}</div>`).join('') +
(debt <= 0 ? `<button class="btn primary" id="claim-open" ${money < 5000 || !career ? 'disabled' : ''}>Open a new claim: $5,000 down, then ${fmtMoney(CLAIM_DEBT * 2 ** (career ? career.claimNo : 1))} owed</button>${money < 5000 ? '<p class=note>You need $5,000 for the down payment.</p>' : ''}` : '<p class=note>A new claim opens once this one is paid off.</p>') +
'<p class=note>Machines on other claims wait until you come back.</p>';
body.querySelectorAll('[data-rename]').forEach(b => b.onclick = () => {
const w = list.find(w => w.id === b.dataset.rename);
dialog('Rename claim', `<input class="text" type="text" id="claim-name" aria-label="Claim name" maxlength="40" value="${esc(w.name)}">`, [
{ label: 'Cancel', cls: 'ghost' }, { label: 'Save', cls: 'primary', fn: async () => {
const name = cleanWorldName($('claim-name').value, w.name);
try { await renameWorld(w.id, name); closeModal('m-dialog'); renderClaimsTab(body); }
catch (e) { toast('Could not rename this claim. Try again.'); }
} },
]);
});
const button = $('claim-open');
if (button) button.onclick = () => dialog('Open a new claim?', `<p class="note">Pay $5,000 down and owe ${fmtMoney(CLAIM_DEBT * 2 ** career.claimNo)} on fresh land. Your buildings stay here.</p>`, [
{ label: 'Cancel', cls: 'ghost' }, { label: 'Open claim', cls: 'primary', fn: () => { closeModal('m-dialog'); openNewClaim(); } },
]);
body.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => dialog('Travel to this claim?', '<p class=note>Your current claim will be saved. Money, bag, upgrades and knowledge come with you.</p>', [
{ label: 'Cancel', cls: 'ghost' }, { label: 'Travel', cls: 'primary', fn: () => { closeModal('m-dialog'); travel(b.dataset.travel); } },
]));
} catch (e) { if (nbTab === 'Claims') body.innerHTML = '<p class=note>Could not load claims from browser storage.</p>'; }
}
+3 -5
View File
@@ -11,10 +11,7 @@ function drainEvents() {
const m = MILESTONES.find(x => x.id === e.id); const m = MILESTONES.find(x => x.id === e.id);
if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); } if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); }
} else if (e.t === 'cast_ready') { } else if (e.t === 'cast_ready') {
const metal = MD[e.metal] ? MD[e.metal].name : 'Metal'; playSfx('cast', undefined, e.x, e.y);
const body = e.kind === 'ingot' ? `${e.bars} ${e.bars === 1 ? 'bar' : 'bars'} worth has set. Press F on the mold to knock ${e.bars === 1 ? 'it' : 'them'} out.` : `The ${e.kind} casting has set. Press F on the mold to knock it out.`;
showCard({ kind: 'Casting ready', title: `${metal} ${e.kind === 'ingot' ? 'ingot' : e.kind}`, swatch: e.metal > 0 ? e.metal : 0, body: [body], accent: 'milestone' });
playSfx('cast');
} else if (e.t === 'upgrade') { } else if (e.t === 'upgrade') {
playSfx('upgrade'); if (SET.shake) R.shake = 1.4; playSfx('upgrade'); if (SET.shake) R.shake = 1.4;
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone', showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
@@ -22,6 +19,7 @@ function drainEvents() {
hudDirty = true; hudDirty = true;
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m); } else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m);
else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m); else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m);
else if (e.t === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72');
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v)); else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
else if (e.t === 'order_new') { else if (e.t === 'order_new') {
const o = e.o; const o = e.o;
@@ -38,7 +36,7 @@ function drainEvents() {
} }
else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); } else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); }
else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); } else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); }
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a); } else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a, e.x, e.y); }
else if (e.t === 'toast') { if (e.text) toast(e.text); } else if (e.t === 'toast') { if (e.text) toast(e.text); }
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ function pollPad() {
if (padPressed(gp, 0)) doInteract(); if (padPressed(gp, 0)) doInteract();
if (padPressed(gp, 2)) rotate(); if (padPressed(gp, 2)) rotate();
if (padPressed(gp, 3)) toggleLens(); if (padPressed(gp, 3)) toggleLens();
if (padPressed(gp, 1)) { if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } } if (padPressed(gp, 1)) { if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; } else if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } else if (toolId() === 'vacuum' && GAME.blow.size) { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); } }
if (padPressed(gp, 10)) bot.lamp = !bot.lamp; if (padPressed(gp, 10)) bot.lamp = !bot.lamp;
if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; } if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; }
if (padPressed(gp, 8)) openNotebook(); if (padPressed(gp, 8)) openNotebook();
+5 -11
View File
@@ -11,7 +11,7 @@ function renderHotbar() {
} }
function toolId() { return TOOLS[GAME.tool].id; } function toolId() { return TOOLS[GAME.tool].id; }
// Shift does two jobs. While you are steering the bot it boosts; while you are not, // Shift does two jobs. While you are steering the bot it boosts; while you are not,
// it is the tool modifier (vacuum everything, blow a mix, fill a rectangle). // it is the tool modifier (vacuum everything, fill a rectangle).
function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; } function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; }
function modShift() { return keys.has('Shift') && !steering(); } function modShift() { return keys.has('Shift') && !steering(); }
// The loose material exactly under the cursor, or the nearest one touching it. // The loose material exactly under the cursor, or the nearest one touching it.
@@ -25,16 +25,10 @@ function grainAt(ax, ay) {
function selectTool(k) { function selectTool(k) {
if (k === GAME.tool) return; if (k === GAME.tool) return;
GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui'); GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui');
if (toolId() === 'vacuum') ensureDropMat();
} }
function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); } function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); }
// Only auto-pick a material when the player has not chosen one. A chosen function blowList() { return [...GAME.blow].filter(m => bag[m] > 0); }
// material stays selected even after it runs out. // Shift + right click: an even mix of everything loose in the bag (bars never mix)
function mixList() { return [...GAME.collect].filter(m => bag[m] > 0 && PH[m] !== PH_ITEM); } function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM && (PH[m] !== PH_LIQUID || player.flask)); }
function ensureDropMat() { function blowingAll() { return toolId() === 'vacuum' && (GAME.rmb ? !GAME.rmbConsumed && GAME.blowAll : modShift()); }
if (GAME.blowMix && mixList().length < 1) GAME.blowMix = false;
if (bag[GAME.dropMat] > 0) return;
if (GAME.matChosen && GAME.dropMat) return;
const l = bagMaterials(); GAME.dropMat = l.length ? l[0] : 0;
}
function piece() { return PIECES[GAME.pieceIdx]; } function piece() { return PIECES[GAME.pieceIdx]; }
+31 -10
View File
@@ -1,6 +1,6 @@
// Discovery cards, hover info (I) and the cursor label. // Discovery cards, hover info (I) and the cursor label.
// ---------- Discovery cards ---------- // ---------- Discovery cards ----------
// Short cards in the lower right when you find or make something new. They fade // Short cards at the bottom centre when you find or make something new. They fade
// on their own (hover holds them); everything they say is also in the notebook. // on their own (hover holds them); everything they say is also in the notebook.
function showCard(c) { function showCard(c) {
if (!SET.cardSeconds) return; if (!SET.cardSeconds) return;
@@ -31,8 +31,9 @@ function howText(e) {
case 'blow': return 'Air blown through molten iron burned off its extra carbon.'; case 'blow': return 'Air blown through molten iron burned off its extra carbon.';
case 'alloy': return `${F} melted into molten copper and the two mixed.`; case 'alloy': return `${F} melted into molten copper and the two mixed.`;
case 'char': return `${F} got hot with no air to burn in, and charred.`; case 'char': return `${F} got hot with no air to burn in, and charred.`;
case 'refine': return `Sifted out of ${f} by a Refiner.`; case 'refine': return `Ground out of ${f}.`;
case 'cut': return `Cut from ${f} by a Stone cutter.`; case 'press': return `Pressed from ${f} by a Brick press.`;
case 'cut': return `Cut from ${f} by a saw.`;
default: return ''; default: return '';
} }
} }
@@ -76,6 +77,7 @@ function reactionsOf(m, forNotebook) {
// What is under the cursor: t1 is the name, t2 the details, warn a short problem // What is under the cursor: t1 is the name, t2 the details, warn a short problem
// (too hard, out of reach) for the label next to the cursor. // (too hard, out of reach) for the label next to the cursor.
function hoverInfo(ax, ay) { function hoverInfo(ax, ay) {
if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] };
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true }; if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
const i = ay * W + ax, m = mat[i]; const i = ay * W + ax, m = mat[i];
let warn = ''; let warn = '';
@@ -88,23 +90,37 @@ function hoverInfo(ax, ay) {
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name; const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); } if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`); if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
if (mm.type === 'fan') t2.push(mm.on ? 'on' : 'off · press F'); if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it'); if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
if (mm.type === 'cutter') t2.push(mm.blocked ? 'stopped: clear the space under it' : tick - (mm.busyT || -999) < 30 ? 'cutting' : 'idle: drop stone rubble in', `${(stats.bricks || 0).toLocaleString('en-US')} bricks cut`); } else if (b === BP_RACK) {
if (mm.type === 'refiner') t2.push(mm.blocked ? 'stopped: clear the space under its chute' : tick - (mm.busyT || -999) < 30 ? 'grinding' : 'idle: drop rubble, gravel, sand or dirt in', `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`); const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
let n = 0, capacity = 0;
for (let q = top; q < NB && blockP[q] === BP_RACK; q += BW) {
const s = rackState.get(q); capacity += RACK_CAP; n += s.n;
for (const [m, count] of s.q) counts.set(m, (counts.get(m) || 0) + count);
}
t1 = 'Bar rack'; t2.push(`${n} / ${capacity} bars`);
t2.push(...[...counts].slice(0, 4).map(([m, count]) => `${count} ${MD[m].name.toLowerCase()}`));
if (counts.size > 4) t2.push('+ more');
t2.push(rackState.get(top).open ? 'open: letting bars out' : 'closed: F lets bars out');
if (n === capacity) t2.push('full');
} else if (isProcCode(b)) {
const s = procState.get((ay >> 2) * BW + (ax >> 2)), saw = b === BP_SAW, press = b === BP_PRESS;
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
} else if (p === P_FILTER) { } else if (p === P_FILTER) {
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←'; const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all'); t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
} else if (p === P_PIPE) { } else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb); const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : ''); t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`); t2.push(pipeContentsText(pn));
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : ''); } else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→'); else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
else if (m === PIPE && isPipeCode(b)) { else if (m === PIPE && isPipeCode(b)) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←']; const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe'; t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`); t2.push(pipeContentsText(pn));
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off'); if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it'); if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
} }
@@ -117,13 +133,16 @@ function hoverInfo(ax, ay) {
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`); t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard'; if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
} }
if (!bar && PH[m] === PH_POWDER) t2.push(GAME.collect.has(m) ? 'loose · ticked' : 'loose'); if (!bar && PH[m] === PH_POWDER) t2.push('loose');
if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); } if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
if (flags[i] & F_BURN) t2.push('burning'); if (flags[i] & F_BURN) t2.push('burning');
const mo = moldAt(ax, ay); const mo = moldAt(ax, ay);
if (mo) { if (mo) {
t1 = MOLDS[mo.kind].name; t1 = MOLDS[mo.kind].name;
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set · press F` : `${MD[mo.metal].name} casting set · press F`) : mo.state === 'flawed' || mo.state === 'short' ? mo.flaw : mo.state === 'filling' ? `filling ${Math.round(mo.fill * 100)}%` : 'empty']; t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`];
if (mo.blocked) t2.push('blocked: leave one open block under it');
if (mo.flaw) t2.push(mo.flaw);
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
if (player.lens) t2.push(fmtT(temp[i])); if (player.lens) t2.push(fmtT(temp[i]));
} }
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay); const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
@@ -165,3 +184,5 @@ function drawCursorLabel(px, py, r) {
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore(); ctx.restore();
} }
function pipeContentsText(pn) { return pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`; }
+27 -12
View File
@@ -3,15 +3,17 @@
const canvas = $('game'); const canvas = $('game');
function isTyping(e) { const t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); } function isTyping(e) { const t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); }
window.addEventListener('keydown', e => { window.addEventListener('keydown', e => {
if ((e.key === 'Control' || e.key === 'Meta') && !GAME.ctrl) { GAME.ctrl = true; lastBagKey = ''; hudDirty = true; }
if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; } if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (GAME.running && k === 'm' && e.shiftKey && !e.repeat) { SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); return; }
if (anyModal()) { if (anyModal()) {
if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); } if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); }
else if (k === 'm' && !e.repeat && topModal() === 'm-map') closeModal('m-map');
else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook'); else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook');
return; return;
} }
if (!GAME.running) return; if (!GAME.running) return;
if (e.key === 'Control') { GAME.ctrl = true; return; }
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (k === 's') { e.preventDefault(); saveWorld('main'); } if (k === 's') { e.preventDefault(); saveWorld('main'); }
else if (k === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); } else if (k === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); }
@@ -25,18 +27,18 @@ window.addEventListener('keydown', e => {
case '1': case '2': case '3': case '4': selectTool(+k - 1); break; case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
case 'c': case 'x': hudDirty = true; break; case 'c': case 'x': hudDirty = true; break;
case 'v': startPaste(); break; case 'v': startPaste(); break;
case 'g': pickUnderCursor(); break; case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break;
case 'q': cycleItem(-1); break; case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break;
case 'e': cycleItem(1); break;
case 'r': rotate(); break; case 'r': rotate(); break;
case 't': if (toolId() === 'vacuum') toggleCollectUnderCursor(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break; case 't': if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
case 'f': doInteract(); break; case 'f': doInteract(); break;
case 'g': GAME.replace = true; hudDirty = true; break;
case 'h': toggleLens(); break; case 'h': toggleLens(); break;
case 'i': GAME.infoOverlay = !GAME.infoOverlay; playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, and which way belts and tubes run. I hides it.' : 'Info overlay off.'); break; case 'i': GAME.infoOverlay = !GAME.infoOverlay; playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, and which way belts and tubes run. I hides it.' : 'Info overlay off.'); break;
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break; case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
case 'n': openNotebook(); break; case 'n': openNotebook(); break;
case 'b': openNotebook('Blueprints'); break; case 'b': openNotebook('Blueprints'); break;
case 'm': SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); break; case 'm': openMap(); break;
case '[': setBrush(-1); break; case '[': setBrush(-1); break;
case ']': setBrush(1); break; case ']': setBrush(1); break;
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break; case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
@@ -44,9 +46,9 @@ window.addEventListener('keydown', e => {
case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break; case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break;
} }
}); });
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if (e.key === 'Control') GAME.ctrl = false; if (k === 'c' || k === 'x') hudDirty = true; }); window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if ((k === 'Control' || k === 'Meta') && GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } if (k === 'g') { GAME.replace = false; hudDirty = true; } if (k === 'c' || k === 'x') hudDirty = true; });
window.addEventListener('blur', () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.ctrl = false; GAME.selDrag = null; endRmbRecord(); }); window.addEventListener('blur', () => { keys.clear(); if (GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } GAME.lmb = GAME.rmb = false; GAME.replace = false; GAME.selDrag = null; endRmbRecord(); });
canvas.addEventListener('mousemove', e => { GAME.ctrl = e.ctrlKey; GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } }); canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } });
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; }); canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault()); canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => { canvas.addEventListener('mousedown', e => {
@@ -54,11 +56,11 @@ canvas.addEventListener('mousedown', e => {
auInit(); auInit();
GAME.usingPad = false; GAME.usingPad = false;
if (e.button === 0) { GAME.lmb = true; primaryPress(e.shiftKey && !steering()); } if (e.button === 0) { GAME.lmb = true; primaryPress(e.shiftKey && !steering()); }
else if (e.button === 2) { GAME.rmb = true; secondaryPress(); } else if (e.button === 2) { GAME.rmb = true; secondaryPress(e.shiftKey && !steering()); }
else if (e.button === 1) { e.preventDefault(); pickUnderCursor(); } else if (e.button === 1) { e.preventDefault(); pickUnderCursor(); }
}); });
window.addEventListener('mouseup', e => { window.addEventListener('mouseup', e => {
if (e.button === 0) { GAME.ctrl = e.ctrlKey; if (GAME.lmb) primaryRelease(modShift()); GAME.lmb = false; } if (e.button === 0) { if (GAME.lmb) primaryRelease(modShift()); GAME.lmb = false; }
else if (e.button === 2) { GAME.rmb = false; endRmbRecord(); } else if (e.button === 2) { GAME.rmb = false; endRmbRecord(); }
}); });
canvas.addEventListener('wheel', e => { canvas.addEventListener('wheel', e => {
@@ -68,9 +70,22 @@ canvas.addEventListener('wheel', e => {
if (e.shiftKey || e.ctrlKey) setBrush(d); if (e.shiftKey || e.ctrlKey) setBrush(d);
else { SET.zoom = Math.max(2, Math.min(12, SET.zoom + d)); saveSettings(); resizeRender(); R.snapNext = true; } else { SET.zoom = Math.max(2, Math.min(12, SET.zoom + d)); saveSettings(); resizeRender(); R.snapNext = true; }
}, { passive: false }); }, { passive: false });
// Ctrl + wheel would zoom the browser page (Ctrl shows the full bag list): scroll the list under the mouse instead
window.addEventListener('wheel', e => {
if (!e.ctrlKey || e.target === canvas) return;
e.preventDefault();
for (let n = e.target; n && n !== document.body; n = n.parentElement) {
const oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) { n.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? n.clientHeight : 1); break; }
}
}, { passive: false });
window.addEventListener('resize', () => { if (R.canvas) resizeRender(); }); window.addEventListener('resize', () => { if (R.canvas) resizeRender(); });
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden && GAME.running) saveWorld('auto', true);
if (!AU.ctx) return; if (!AU.ctx) return;
if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); if (GAME.running) saveWorld('auto', true); } if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); }
else AU.ctx.resume(); else AU.ctx.resume();
}); });
// Ctrl+W closes the tab and the page cannot stop it:
// while a world is open, ask the browser to confirm leaving
window.addEventListener('beforeunload', e => { if (GAME.running) { e.preventDefault(); e.returnValue = ''; } });
+29 -12
View File
@@ -16,6 +16,7 @@ function frame(now) {
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now; const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return; if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now; const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
if (GAME.loading) { acc = 0; return; }
if (!GAME.running) { pollPad(); return; } if (!GAME.running) { pollPad(); return; }
const t0 = performance.now(); const t0 = performance.now();
const pad = pollPad(); const pad = pollPad();
@@ -45,8 +46,8 @@ function frame(now) {
} else acc = 0; } else acc = 0;
drainEvents(); drainEvents();
let lookX = 0, lookY = 0; let lookX = 0, lookY = 0;
if (GAME.usingPad) { lookX = GAME.padAim.x * 0.5; lookY = GAME.padAim.y * 0.5; } if (SET.cameraLead && GAME.usingPad) { lookX = GAME.padAim.x * 0.5; lookY = GAME.padAim.y * 0.5; }
else if (GAME.mouse.in && toolId() !== 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld()) { const dpr = R.canvas.width / window.innerWidth; lookX = (GAME.mouse.x - window.innerWidth / 2) * dpr / R.zp; lookY = (GAME.mouse.y - window.innerHeight / 2) * dpr / R.zp; } else if (SET.cameraLead && GAME.mouse.in && toolId() !== 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld()) { const dpr = R.canvas.width / window.innerWidth; lookX = (GAME.mouse.x - window.innerWidth / 2) * dpr / R.zp; lookY = (GAME.mouse.y - window.innerHeight / 2) * dpr / R.zp; }
// hold the camera's look-ahead still while dragging out a line, box or blueprint // hold the camera's look-ahead still while dragging out a line, box or blueprint
// While dragging, the camera stays put unless the bot itself is flying, so the // While dragging, the camera stays put unless the bot itself is flying, so the
// grid under the cursor does not slide around mid-line. // grid under the cursor does not slide around mid-line.
@@ -74,13 +75,14 @@ function frame(now) {
drawRain(); drawRain();
drawBot(); drawBot();
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
if (GAME.ctrl !== GAME.ctrlShown) { GAME.ctrlShown = GAME.ctrl; hudDirty = true; } if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
renderBuildBar(); renderBuildBar();
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; } if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
if (R.frame % 4 === 0) renderInfo(); if (R.frame % 4 === 0) renderInfo();
if (document.body.classList.contains('paused') !== GAME.paused) document.body.classList.toggle('paused', GAME.paused); if (document.body.classList.contains('paused') !== GAME.paused) document.body.classList.toggle('paused', GAME.paused);
updateOverlayUI(); updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); } if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
updateMap(now);
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1; frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05; fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) { if (SET.showPerf && R.frame % 15 === 0) {
@@ -97,20 +99,35 @@ function boot(hotData) {
buildTitleCells(); drawTitle(); buildTitleCells(); drawTitle();
$('nw-seed').value = String(Math.floor(Math.random() * 900000) + 100000); $('nw-seed').value = String(Math.floor(Math.random() * 900000) + 100000);
$('wallet').onclick = () => { playSfx('ui'); openNotebook('Market'); }; $('wallet').onclick = () => { playSfx('ui'); openNotebook('Market'); };
$('btn-new').onclick = () => { auInit(); playSfx('ui'); $('newworld').hidden = !$('newworld').hidden; if (!$('newworld').hidden) $('btn-generate').focus(); }; let autoName = '', worldNames = [];
$('btn-generate').onclick = () => { const newWorldSeed = () => parseInt(String($('nw-seed').value).replace(/\D/g, ''), 10) || Math.floor(Math.random() * 1e6);
const seed = parseInt(String($('nw-seed').value).replace(/\D/g, ''), 10) || Math.floor(Math.random() * 1e6); $('btn-new').onclick = async () => {
const [w, h] = $('nw-size').value.split('x').map(Number); auInit(); playSfx('ui'); $('newworld').hidden = !$('newworld').hidden;
startNewWorld(seed, w, h); if ($('newworld').hidden) return;
try { worldNames = (await listWorlds()).map(w => w.name); }
catch (e) { worldNames = []; }
$('nw-name').value = autoName = claimName(newWorldSeed(), worldNames);
$('nw-name').focus(); $('nw-name').select();
}; };
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); }; $('nw-seed').oninput = () => {
if ($('nw-name').value === autoName) $('nw-name').value = autoName = claimName(newWorldSeed(), worldNames);
};
for (const id of ['nw-name', 'nw-seed']) $(id).onkeydown = e => { if (e.key === 'Enter') $('btn-generate').click(); };
$('btn-generate').onclick = () => {
const seed = newWorldSeed();
const [w, h] = $('nw-size').value.split('x').map(Number);
startNewWorld(seed, w, h, $('nw-name').value);
};
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); await loadSavedWorld(); };
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); }; $('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
$('touchnote').hidden = !touchOnly; $('touchnote').hidden = !touchOnly;
refreshContinue().then(async () => { $('title').hidden = true;
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); } migrateWorlds().then(() => refreshContinue()).then(async () => {
}); $('title').hidden = false; if (!TITLE.raf) drawTitle();
if (hotData && hotData.resume) { await loadSavedWorld(); }
}).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ } try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ }
+93
View File
@@ -0,0 +1,93 @@
// Shared world colours, minimap and full map.
let MAP_S = 2;
const MAP = { canvas: document.createElement('canvas'), image: null, w: 0, h: 0, init: 0, rr: 0, near: 0, miniT: 0, fullT: 0, x: 0, y: 0, zoom: 1, drag: null };
function resetMap() {
MAP_S = W > 4096 ? 4 : 2; MAP.w = W; MAP.h = H; MAP.fog = SET.fog;
MAP.canvas.width = Math.ceil(W / MAP_S); MAP.canvas.height = Math.ceil(H / MAP_S);
MAP.ctx = MAP.canvas.getContext('2d'); MAP.image = MAP.ctx.createImageData(MAP.canvas.width, MAP.canvas.height);
MAP.init = MAP.rr = MAP.near = MAP.miniT = MAP.fullT = 0;
if (![1, 2, 4, 8, 16].includes(SET.minimapZoom)) SET.minimapZoom = 4;
}
function mapArea(cx, cy) {
const s = MAP_S, x0 = cx * 32, y0 = cy * 32, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32), data = MAP.image.data, mw = MAP.canvas.width;
for (let y = y0; y < y1; y += s) for (let x = x0; x < x1; x += s) {
const xx = Math.min(W - 1, x + s / 2), yy = Math.min(H - 1, y + s / 2), seen = SET.fog ? fogSeen(xx, yy) : 255;
let m = mat[yy * W + xx];
if (!m || PH[m] === PH_GAS) for (let c = 0; c < 4; c++) {
const k = mat[Math.min(H - 1, y + (c >> 1) * (s - 1)) * W + Math.min(W - 1, x + (c & 1) * (s - 1))];
if (k && PH[k] !== PH_GAS) { m = k; break; }
}
const sky = yy < surfY[xx], f = seen < 64 ? 0 : seen / 255, i = (y / s * mw + x / s) * 4;
data[i] = (m ? COLR[m] : sky ? 78 : 20) * f;
data[i + 1] = (m ? COLG[m] : sky ? 114 : 18) * f;
data[i + 2] = (m ? COLB[m] : sky ? 142 : 17) * f; data[i + 3] = 255;
}
MAP.ctx.putImageData(MAP.image, 0, 0, x0 / s, y0 / s, (x1 - x0) / s, (y1 - y0) / s);
}
function updateMap(now) {
if (!GAME.running || GAME.loading) return;
$('minimap').hidden = !SET.minimap;
if (!SET.minimap && $('m-map').hidden) return;
if (MAP.w !== W || MAP.h !== H || MAP.fog !== SET.fog) resetMap();
const nx = Math.ceil(W / 32), ny = Math.ceil(H / 32), total = nx * ny;
if (MAP.init < total) {
const end = performance.now() + 8;
do { const n = MAP.init++; mapArea(n % nx, Math.floor(n / nx)); } while (MAP.init < total && performance.now() < end);
if (MAP.init === total) MAP.fullT = MAP.miniT = 0;
} else {
mapArea(Math.max(0, Math.min(nx - 1, Math.floor(bot.x / 32))), Math.max(0, Math.min(ny - 1, Math.floor(bot.y / 32))));
const rx = Math.ceil(R.bw / 32), ry = Math.ceil(R.bh / 32), span = rx * 2 + 1;
for (let n = 0; n < 15; n++) {
const k = MAP.near++ % (span * (ry * 2 + 1)), x = Math.floor(bot.x / 32) - rx + k % span, y = Math.floor(bot.y / 32) - ry + Math.floor(k / span);
if (x >= 0 && y >= 0 && x < nx && y < ny) mapArea(x, y);
}
for (let n = 0; n < 8; n++) { const k = MAP.rr++ % total; mapArea(k % nx, Math.floor(k / nx)); }
}
if (SET.minimap && now >= MAP.miniT) { MAP.miniT = now + 100; drawMap($('mini-canvas'), bot.x, bot.y, 1 / SET.minimapZoom, false); }
if (!$('m-map').hidden && now >= MAP.fullT) { MAP.fullT = now + 1000; drawFullMap(); }
}
function drawMap(c, x, y, z, full) {
const ctx = c.getContext('2d'), w = c.width, h = c.height;
ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#080706'; ctx.fillRect(0, 0, w, h);
ctx.drawImage(MAP.canvas, w / 2 - x * z, h / 2 - y * z, W * z, H * z);
const marker = (mx, my, colour, label) => {
const px = w / 2 + (mx - x) * z, py = h / 2 + (my - y) * z;
if (px < 0 || py < 0 || px > w || py > h) return;
ctx.fillStyle = '#080706'; ctx.fillRect(px - 4, py - 4, 8, 8); ctx.fillStyle = colour; ctx.fillRect(px - 2, py - 2, 4, 4);
if (full) { ctx.font = '13px sans-serif'; ctx.lineWidth = 3; ctx.strokeStyle = '#080706'; const ly = label === 'Trade dock' ? py + 17 : py - 7; ctx.strokeText(label, px + 7, ly); ctx.fillText(label, px + 7, ly); }
};
for (const m of machines) if (m && m.type === 'dock' && (!SET.fog || fogSeen(m.x, m.y) >= 64)) marker(m.x + m.w / 2, m.y + m.h / 2, '#e59a52', 'Trade dock');
marker(bot.x, bot.y, '#c9fff2', 'You are here');
}
function mapFit() { const c = $('map-canvas'); return Math.min(c.clientWidth / W, c.clientHeight / H); }
function drawFullMap() {
if (GAME.loading || !GAME.running || $('m-map').hidden || !MAP.image) return;
const c = $('map-canvas'); c.width = Math.max(1, c.clientWidth); c.height = Math.max(1, c.clientHeight);
MAP.zoom = Math.max(mapFit(), Math.min(4, MAP.zoom));
const hx = c.width / (2 * MAP.zoom), hy = c.height / (2 * MAP.zoom);
MAP.x = hx >= W / 2 ? W / 2 : Math.max(hx, Math.min(W - hx, MAP.x));
MAP.y = hy >= H / 2 ? H / 2 : Math.max(hy, Math.min(H - hy, MAP.y));
drawMap(c, MAP.x, MAP.y, MAP.zoom, true);
}
function openMap() {
if (!GAME.running || GAME.loading) return;
openModal('m-map'); MAP.x = bot.x; MAP.y = bot.y; MAP.zoom = mapFit(); drawFullMap();
}
function zoomMap(factor, px, py) {
const c = $('map-canvas'), old = MAP.zoom, z = Math.max(mapFit(), Math.min(4, old * factor));
MAP.x += ((px === undefined ? c.width / 2 : px) - c.width / 2) * (1 / old - 1 / z);
MAP.y += ((py === undefined ? c.height / 2 : py) - c.height / 2) * (1 / old - 1 / z);
MAP.zoom = z; drawFullMap();
}
function zoomMinimap(d) { SET.minimapZoom = Math.max(1, Math.min(16, SET.minimapZoom * (d > 0 ? 0.5 : 2))); saveSettings(); MAP.miniT = 0; }
$('mini-plus').onclick = () => zoomMinimap(1);
$('mini-minus').onclick = () => zoomMinimap(-1);
$('mini-map').onclick = openMap;
$('map-plus').onclick = () => zoomMap(2);
$('map-minus').onclick = () => zoomMap(0.5);
$('map-centre').onclick = () => { MAP.x = bot.x; MAP.y = bot.y; drawFullMap(); };
$('map-canvas').addEventListener('wheel', e => { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); zoomMap(e.deltaY < 0 ? 1.25 : 0.8, e.clientX - r.left, e.clientY - r.top); }, { passive: false });
$('map-canvas').onpointerdown = e => { if (e.button !== 0) return; MAP.drag = [e.clientX, e.clientY]; e.currentTarget.setPointerCapture(e.pointerId); };
$('map-canvas').onpointermove = e => { if (!MAP.drag) return; MAP.x -= (e.clientX - MAP.drag[0]) / MAP.zoom; MAP.y -= (e.clientY - MAP.drag[1]) / MAP.zoom; MAP.drag = [e.clientX, e.clientY]; drawFullMap(); };
$('map-canvas').onpointerup = $('map-canvas').onpointercancel = () => { MAP.drag = null; };
window.addEventListener('resize', drawFullMap);
+47
View File
@@ -0,0 +1,47 @@
// Notebook blueprints: starter designs, saved library and code sharing.
function renderBlueprintTab(body) {
const lib = libLoad();
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
STARTER_DESIGNS.forEach((d, k) => {
const bp = d.bp();
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
});
h += '</div><p class="label grouphead">Your blueprints</p>';
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
h += `<p class="label grouphead">Library</p>`;
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
else {
h += '<div class="cards">';
lib.forEach((it, k) => {
const ok = bpValid(it.bp);
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
});
h += '</div>';
}
body.innerHTML = h;
const sv = $('bp-save'); if (sv) sv.onclick = () => {
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
]);
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
};
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
$('bp-import').onclick = () => {
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
]);
};
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
}
function copyCode(code) {
const done = () => toast('Blueprint code copied.');
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
}
+68
View File
@@ -0,0 +1,68 @@
// Notebook market: prices, orders, claim payments and wallet.
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
function orderLeft(o) { return Math.max(0, o.due - tick); }
const MARKET_GROUPS = [
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]],
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
];
function renderMarketTab(body) {
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.</p>`;
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
if (orders.length) {
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
let rows = '', unknown = 0;
for (const [g, list] of MARKET_GROUPS) {
const known = list.filter(m => seen[m]);
unknown += list.length - known.length;
if (!known.length) continue;
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
for (const m of known) {
const f = priceFactor(m), now = priceNow(m);
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
}
}
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
h += `<p class="label grouphead">Sales, minute by minute</p>`;
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
body.innerHTML = h;
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
const paid = payClaim(v);
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
renderNotebook(); hudDirty = true;
});
}
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
function itemsText(items) {
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
if (e.length > 3) parts.push(`${e.length - 3} more`);
return parts.join(', ');
}
function renderWallet() {
$('w-money').textContent = fmtMoney(money);
const c = $('w-claim');
c.className = 'line' + (debt <= 0 ? ' paid' : '');
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
const h = orders.length ? orders.map(o => {
const left = orderLeft(o);
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
}).join('') : '<span class="none">No orders yet</span>';
const box = $('w-orders');
if (box.innerHTML !== h) box.innerHTML = h;
}
+20 -130
View File
@@ -1,10 +1,10 @@
// The notebook (N): milestones, market, wallet, blueprints, controls. // Notebook: tab navigation, ledger, materials, pieces and controls.
// ---------- Notebook ---------- // ---------- Notebook ----------
let nbTab = 'Ledger'; let nbTab = 'Ledger';
function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); } function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
function renderNotebook() { function renderNotebook() {
const tabs = $('nb-tabs'); tabs.innerHTML = ''; const tabs = $('nb-tabs'); tabs.innerHTML = '';
for (const name of ['Ledger', 'Market', 'Materials', 'Pieces', 'Blueprints', 'Controls']) { for (const name of ['Ledger', 'Market', 'Claims', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name); const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); }; b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
tabs.appendChild(b); tabs.appendChild(b);
@@ -25,9 +25,11 @@ function renderNotebook() {
body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); }); body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
} else if (nbTab === 'Market') { } else if (nbTab === 'Market') {
renderMarketTab(body); renderMarketTab(body);
} else if (nbTab === 'Claims') {
renderClaimsTab(body);
} else if (nbTab === 'Materials') { } else if (nbTab === 'Materials') {
let rows = '', unknown = 0; let rows = '', unknown = 0;
const list = [DIRT, LDIRT, STONE, RSTONE, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS, const list = [DIRT, LDIRT, STONE, RSTONE, BARSB, PLANK, GREENB, BARBR, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS,
BRICK, RBRICK, GLASS, CULLET, COPPER, SCRCU, BARCU, IRON, SCRFE, BARFE, STEEL, SCRST, BARST, ASH, SLAG, MSLAG, WATER, STEAM, MAGMA, MCU, MFE, MST, MGL, FIRE, SMOKE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS]; BRICK, RBRICK, GLASS, CULLET, COPPER, SCRCU, BARCU, IRON, SCRFE, BARFE, STEEL, SCRST, BARST, ASH, SLAG, MSLAG, WATER, STEAM, MAGMA, MCU, MFE, MST, MGL, FIRE, SMOKE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS];
for (const m of list) { for (const m of list) {
if (!seen[m]) { unknown++; continue; } if (!seen[m]) { unknown++; continue; }
@@ -36,12 +38,12 @@ function renderNotebook() {
const change = reactionsOf(m, true).map((t, k) => k ? t : t[0].toUpperCase() + t.slice(1)).join(' · '); const change = reactionsOf(m, true).map((t, k) => k ? t : t[0].toUpperCase() + t.slice(1)).join(' · ');
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(d.name)}</td><td>${ph}</td><td class="num">${d.hard && d.ph === PH_SOLID ? d.hard : '–'}</td><td>${esc(change || '–')}</td><td class="p" style="color:var(--ash)">${esc(d.desc || '')}</td></tr>`; rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(d.name)}</td><td>${ph}</td><td class="num">${d.hard && d.ph === PH_SOLID ? d.hard : '–'}</td><td>${esc(change || '–')}</td><td class="p" style="color:var(--ash)">${esc(d.desc || '')}</td></tr>`;
} }
body.innerHTML = `<p class="note">What you have found so far. Hardness is what your drill bit has to match. Solid rock breaks into loose rubble that you vacuum up and build with.${unknown ? ` ${unknown} more to discover.` : ''}</p><div class="tablewrap"><table class="mt"><thead><tr><th>Material</th><th>State</th><th>Hard</th><th>What it does</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table></div>`; body.innerHTML = `<p class="note">What you have found so far. Hardness is what your drill bit has to match. Solid rock breaks into loose rubble that you pick up with the Pneumatic nozzle and build with.${unknown ? ` ${unknown} more to discover.` : ''}</p><div class="tablewrap"><table class="mt"><thead><tr><th>Material</th><th>State</th><th>Hard</th><th>What it does</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table></div>`;
} else if (nbTab === 'Pieces') { } else if (nbTab === 'Pieces') {
let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Your bot flies straight through anything you build.</p><div class="cards">'; let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Hover a tile for its cost, full description and placement help. Your bot flies straight through anything you build.</p><div class="cards">';
for (const P of PIECES) { for (const P of PIECES) {
if (P.hidden) continue; if (P.hidden) continue;
const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R picks it)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1); const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1);
const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1); const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1);
const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1'; const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1';
h += `<div class="card"><div class="h"><span>${esc(P.name)}</span><span class="chip ${afford ? 'ok' : ''}">${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}</span></div><div class="p">${esc(P.desc)}</div><div class="p">Cost: ${esc(cost)}</div></div>`; h += `<div class="card"><div class="h"><span>${esc(P.name)}</span><span class="chip ${afford ? 'ok' : ''}">${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}</span></div><div class="p">${esc(P.desc)}</div><div class="p">Cost: ${esc(cost)}</div></div>`;
@@ -53,136 +55,23 @@ function renderNotebook() {
body.innerHTML = controlsHtml(); body.innerHTML = controlsHtml();
} }
} }
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
function orderLeft(o) { return Math.max(0, o.due - tick); }
const MARKET_GROUPS = [
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
['Bulk (builders)', [RSTONE, GRAVEL, BARSB]],
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
];
function renderMarketTab(body) {
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, rubble, ash and slag goes for nothing. Build walls around the dock to funnel more in.</p>`;
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
if (orders.length) {
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
let rows = '', unknown = 0;
for (const [g, list] of MARKET_GROUPS) {
const known = list.filter(m => seen[m]);
unknown += list.length - known.length;
if (!known.length) continue;
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
for (const m of known) {
const f = priceFactor(m), now = priceNow(m);
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
}
}
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
h += `<p class="label grouphead">Sales, minute by minute</p>`;
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
body.innerHTML = h;
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
const paid = payClaim(v);
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
renderNotebook(); hudDirty = true;
});
}
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
function itemsText(items) {
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
if (e.length > 3) parts.push(`${e.length - 3} more`);
return parts.join(', ');
}
function renderWallet() {
$('w-money').textContent = fmtMoney(money);
const c = $('w-claim');
c.className = 'line' + (debt <= 0 ? ' paid' : '');
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
const h = orders.length ? orders.map(o => {
const left = orderLeft(o);
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
}).join('') : '<span class="none">No orders yet</span>';
const box = $('w-orders');
if (box.innerHTML !== h) box.innerHTML = h;
}
function renderBlueprintTab(body) {
const lib = libLoad();
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
STARTER_DESIGNS.forEach((d, k) => {
const bp = d.bp();
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
});
h += '</div><p class="label grouphead">Your blueprints</p>';
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
h += `<p class="label grouphead">Library</p>`;
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
else {
h += '<div class="cards">';
lib.forEach((it, k) => {
const ok = bpValid(it.bp);
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
});
h += '</div>';
}
body.innerHTML = h;
const sv = $('bp-save'); if (sv) sv.onclick = () => {
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
]);
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
};
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
$('bp-import').onclick = () => {
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
]);
};
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
}
function copyCode(code) {
const done = () => toast('Blueprint code copied.');
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
}
function controlsHtml() { function controlsHtml() {
const kb = [ const kb = [
['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Variant: material · direction · flip', 'R'], ['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'],
['Tick/untick what is under the cursor (Vacuum)', 'R or T'], ['Nozzle or brush size', '[ ] or Shift + wheel'], ['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold Ctrl'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'], ['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'],
['Pick the piece under the cursor (with its direction and settings)', 'G or middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'], ['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'Shift + M'], ['Full map', 'M'], ['Minimap zoom', '+ / − on minimap'],
]; ];
const pad = [ const pad = [
['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'], ['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'],
['Cycle what you blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tick material', 'X'], ['Thermal lens', 'Y'], ['Select one to blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tune drill', 'X'], ['Thermal lens', 'Y'],
['Cancel', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'], ['Cancel · clear selection', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'],
]; ];
const tools = [ const tools = [
['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'], ['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'],
['2 Vacuum', 'Left click sucks up what you start on; if anything is ticked in your bag (R over it, or its tick box), it takes only the ticked materials instead. Hold Shift while not flying to take everything. Right click blows the chosen material out of the bot toward the cursor; Q and E choose it, and it stays chosen. Q and E (or clicking in your bag) can also pick Ticked mix, an even mix of everything ticked, which is how you charge a furnace. The swatches next to the cursor always show what right click will blow. R ticks or unticks what is under the cursor.'], ['2 Pneumatic nozzle', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."],
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps and outlets, machines (fans, the Refiner) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'], ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'],
['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'], ['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'],
['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'], ['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'],
]; ];
@@ -191,10 +80,11 @@ function controlsHtml() {
<p class="label grouphead">Keyboard and mouse</p>${tbl(kb)}<p class="label grouphead">Gamepad</p>${tbl(pad)} <p class="label grouphead">Keyboard and mouse</p>${tbl(kb)}<p class="label grouphead">Gamepad</p>${tbl(pad)}
<p class="label grouphead">First steps</p> <p class="label grouphead">First steps</p>
<p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br> <p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br>
2. With the Vacuum, click on coal to suck up only coal, then the copper ore, rubble and clay you want.<br> 2. With the Pneumatic nozzle, left drag to pick up the selection, or what you click if none. Shift takes everything.<br>
3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br> 3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br>
4. Tick coal and copper ore in your bag and pick Ticked mix (Q/E, or click it at the bottom of the bag). Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br> 4. Ctrl+click coal and copper ore in the bag to blow a mix. Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br>
5. When the ingot sets, press F on the mold and vacuum up the bars. That also rakes the slag and ash off the grate, so the furnace is ready for the next charge. Scrap goes to your bag: throw it in with the next charge.<br> 5. When the ingot sets, bars drop out of the bottom of the mold onto whatever is under it. Leave one open block under it: a belt below that carries them off, or pick them up with the nozzle. Leftover metal stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.<br>
Screw tubes carry grains and whole bars: feed bars in from a belt, turn corners and unload at the mouth.<br>
6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br> 6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br>
7. From there: deeper ores (tin, zinc, silver, gold), alloys, fans for hotter fires, iron and steel.</p> 7. From there: deeper ores (tin, zinc, silver, gold), alloys, fans for hotter fires, iron and steel.</p>
`; `;
+104
View File
@@ -0,0 +1,104 @@
// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan.
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
function crackSeg(ctx, x, y, n, sc, zp, oy) {
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
const pts = [];
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
for (let k = 0; k + 3 < pts.length; k += 4) {
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
}
}
function drawCracks() {
const ctx = R.ctx, zp = R.zp;
if (zp < 2.5) return;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
const cells = [];
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
if (!cells.length) return;
ctx.save(); ctx.lineCap = 'round';
for (let l = 0; l < CRACK_LV.length; l++) {
const L = CRACK_LV[l];
// a soft lit lip just below each crack, then the dark crack itself
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
}
}
ctx.restore();
}
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
function pulseRGB(tune) {
if (!tune) return '150,215,255';
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
}
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
function fanPoints(ox, oy, ang, Wd, sd, n) {
const pts = [];
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
return pts;
}
function drawDrillPulses() {
if (!drillPulses.length) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const q of drillPulses) {
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
if (rr < 3) continue;
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
// a faint band behind the front, then the bright front itself
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
}
ctx.restore();
}
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
// out to where it stops, and a crosshair
function drawDrillAim(ax, ay) {
const ctx = R.ctx, zp = R.zp;
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
ctx.save();
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
// the emitter glows across the bot's face while it fires
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
if (GAME.lmb && hot > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
const k = zp * 0.9;
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore();
}
+80
View File
@@ -0,0 +1,80 @@
// Selection previews: labelled tags, copy and cut boxes, and paste ghosts.
// a small labelled tag drawn on the world (screen pixels)
function drawTag(text, col, x, y, sub) {
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
renderMonoFont();
ctx.save();
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const tw = ctx.measureText(text).width;
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
ctx.restore();
}
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
function drawSelection(mode, ax, ay) {
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
ctx.save();
// what is inside: built blocks, and machines and molds that fit wholly inside
let n = 0;
if (sd) {
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
}
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
for (const mo of molds) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
}
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
if (mode === 'cut') {
// red hatching: this will be taken down
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
const step = Math.max(8, zp * 2.5);
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
ctx.restore();
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
}
// Pasting: the clipboard follows the cursor, snapped to the grid
function drawPastePreview() {
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
drawGrid(aimBlock().bx, aimBlock().by, 7);
ctx.save();
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
ctx.restore();
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
}
+45 -189
View File
@@ -1,4 +1,4 @@
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste. // Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
// ---------- Overlay previews ---------- // ---------- Overlay previews ----------
function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; } function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; }
function drawGrid(cbx, cby, radius) { function drawGrid(cbx, cby, radius) {
@@ -18,108 +18,46 @@ function previewColor(code, v) {
if (code === BP_FILTER) return '96,100,110'; if (code === BP_FILTER) return '96,100,110';
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190'; return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
} }
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. // Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines function drawAirStreaks(ax, ay, p, r, blowing) {
// run on unbroken from one cell to the next and look like real fractures, not a pattern. if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return;
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))]; const ctx = R.ctx, zp = R.zp, now = performance.now();
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }]; const cx = p.x + zp / 2, cy = p.y + zp / 2, ring = (r + 0.5) * zp;
function crackSeg(ctx, x, y, n, sc, zp, oy) { ctx.save(); ctx.lineWidth = Math.max(1, zp * 0.2); ctx.lineCap = 'round';
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61); if (blowing) {
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1); const bx = bot.x + bot.w / 2, by = bot.y + bot.h / 2;
const pts = []; const dx = ax + 0.5 - bx, dy = ay + 0.5 - by, d = Math.hypot(dx, dy) || 1;
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y); const o = worldToScreenPx(bx + dx / d * 3, by + dy / d * 3);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c)); const px = cx - o.x, py = cy - o.y, len = Math.hypot(px, py) || 1;
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1); const ux = px / len, uy = py / len;
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d)); for (let i = 0; i < 10; i++) {
for (let k = 0; k + 3 < pts.length; k += 4) { const phase = (now * 0.0016 + i * 0.618) % 1;
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy); const side = ((i * 0.37) % 1 - 0.5) * 2 * ring * phase;
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy); const x = o.x + px * phase - uy * side, y = o.y + py * phase + ux * side;
ctx.strokeStyle = `rgba(255,210,122,${Math.sin(phase * Math.PI) * 0.55})`;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ux * 1.6 * zp, y + uy * 1.6 * zp); ctx.stroke();
} }
} } else {
function drawCracks() { const travel = ring * 0.65 + 3 * zp;
const ctx = R.ctx, zp = R.zp; for (let i = 0; i < 12; i++) {
if (zp < 2.5) return; const phase = (now * 0.0012 + i * 0.618) % 1;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY)); const dist = ring + 3 * zp - travel * phase;
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp)); const angle = i * Math.PI * 2 / 12 + now * 0.0004 + phase * 1.2;
const cells = []; const co = Math.cos(angle), si = Math.sin(angle);
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } } const x = cx + co * dist, y = cy + si * dist;
if (!cells.length) return; const vx = -travel * co - dist * (1.2 + 1 / 3) * si;
ctx.save(); ctx.lineCap = 'round'; const vy = -travel * si + dist * (1.2 + 1 / 3) * co;
for (let l = 0; l < CRACK_LV.length; l++) { const scale = 1.2 * zp / Math.hypot(vx, vy);
const L = CRACK_LV[l]; ctx.strokeStyle = `rgba(92,186,166,${Math.sin(phase * Math.PI) * 0.5})`;
// a soft lit lip just below each crack, then the dark crack itself ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + vx * scale, y + vy * scale); ctx.stroke();
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
} }
} }
ctx.restore(); ctx.restore();
} }
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes function drawReachCircle(radius, alpha) {
function pulseRGB(tune) { const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
if (!tune) return '150,215,255'; ctx.strokeStyle = `rgba(239,106,85,${alpha})`; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`; ctx.beginPath(); ctx.arc(c.x, c.y, radius * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
}
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
function fanPoints(ox, oy, ang, Wd, sd, n) {
const pts = [];
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
return pts;
}
function drawDrillPulses() {
if (!drillPulses.length) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const q of drillPulses) {
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
if (rr < 3) continue;
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
// a faint band behind the front, then the bright front itself
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
}
ctx.restore();
}
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
// out to where it stops, and a crosshair
function drawDrillAim(ax, ay) {
const ctx = R.ctx, zp = R.zp;
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
ctx.save();
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
// the emitter glows across the bot's face while it fires
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
if (GAME.lmb && hot > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
const k = zp * 0.9;
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore();
} }
function drawPreviews() { function drawPreviews() {
const ctx = R.ctx, zp = R.zp; const ctx = R.ctx, zp = R.zp;
@@ -129,14 +67,10 @@ function drawPreviews() {
const reachOk = inReach(ax, ay); const reachOk = inReach(ax, ay);
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip; const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
if (!reachOk && t !== 'drill' && !sel) { if (!reachOk && t !== 'drill' && !sel) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); drawReachCircle(REACH, 0.35);
ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
} }
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) { if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); drawReachCircle(drillReach(GAME.drillW), 0.45);
ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
} }
drawDrillPulses(); drawDrillPulses();
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay); if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
@@ -153,14 +87,15 @@ function drawPreviews() {
if (t === 'vacuum') { if (t === 'vacuum') {
// what the right button would blow, next to the cursor; what the left is sucking, while it sucks // what the right button would blow, next to the cursor; what the left is sucking, while it sucks
const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp; const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp;
const swatches = GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.blowMix ? mixList() : GAME.dropMat && bag[GAME.dropMat] > 0 ? [GAME.dropMat] : []; const swatches = GAME.lmb ? GAME.vacFilter === -2 ? [...GAME.blow] : GAME.vacFilter > 0 ? [GAME.vacFilter] : [] : blowingAll() ? blowAllList() : blowList();
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); }); swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
if (blowing || (GAME.lmb && GAME.vacFilter)) { if (blowing || GAME.lmb) {
// faint line from the nozzle: the path the grains take // faint line from the nozzle: the path the grains take
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6); ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6);
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke();
} }
if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing);
} }
} else if (t === 'build') { } else if (t === 'build') {
const P = piece(), b = aimBlock(); const P = piece(), b = aimBlock();
@@ -176,7 +111,7 @@ function drawPreviews() {
const afford = canAfford(pieceCostNow(P), cells.length); const afford = canAfford(pieceCostNow(P), cells.length);
for (const [bx, by] of cells) { for (const [bx, by] of cells) {
const err = canPlaceBlock(bx, by), rb = by * BW + bx; const err = canPlaceBlock(bx, by), rb = by * BW + bx;
const swap = GAME.ctrl && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P)); const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
const ok = (!err || swap) && !ghost && afford; const ok = (!err || swap) && !ghost && afford;
const [x, y, w, h] = blockRectPx(bx, by, 1, 1); const [x, y, w, h] = blockRectPx(bx, by, 1, 1);
@@ -214,10 +149,10 @@ function drawPreviews() {
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6); ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
} }
} else { } else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh; const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh); const { bx, by } = stampOrigin(bw, bh);
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by); const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
const swap = !!err && GAME.ctrl && /already/.test(err) && !footprintBlocker(bx, by, bw, bh); const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1); const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
const [x, y, w, h] = blockRectPx(bx, by, bw, bh); const [x, y, w, h] = blockRectPx(bx, by, bw, bh);
if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); } if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); }
@@ -250,85 +185,6 @@ function drawPreviews() {
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke();
} }
ctx.restore(); ctx.restore();
if (t === 'build' && GAME.ctrl && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4); if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2); drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
} }
// a small labelled tag drawn on the world (screen pixels)
function drawTag(text, col, x, y, sub) {
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.save();
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const tw = ctx.measureText(text).width;
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
ctx.restore();
}
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
function drawSelection(mode, ax, ay) {
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
ctx.save();
// what is inside: built blocks, and machines and molds that fit wholly inside
let n = 0;
if (sd) {
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
}
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
for (const mo of molds) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
}
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
if (mode === 'cut') {
// red hatching: this will be taken down
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
const step = Math.max(8, zp * 2.5);
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
ctx.restore();
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
}
// Pasting: the clipboard follows the cursor, snapped to the grid
function drawPastePreview() {
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
drawGrid(aimBlock().bx, aimBlock().by, 7);
ctx.save();
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
ctx.restore();
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
}
+130 -12
View File
@@ -15,29 +15,116 @@ const DB = {
}, },
async op(mode, fn) { async op(mode, fn) {
const db = await this.open(); const db = await this.open();
return new Promise((res, rej) => { const tx = db.transaction('saves', mode); const st = tx.objectStore('saves'); const rq = fn(st); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); }); return new Promise((res, rej) => {
const tx = db.transaction('saves', mode); let rq;
tx.oncomplete = () => res(rq && rq.result);
tx.onabort = tx.onerror = () => rej(tx.error || new Error('Storage transaction failed.'));
try { rq = fn(tx.objectStore('saves')); } catch (e) { tx.abort(); rej(e); }
});
}, },
put(k, v) { return this.op('readwrite', s => s.put(v, k)); }, put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
get(k) { return this.op('readonly', s => s.get(k)); }, get(k) { return this.op('readonly', s => s.get(k)); },
del(k) { return this.op('readwrite', s => s.delete(k)); }, del(k) { return this.op('readwrite', s => s.delete(k)); },
}; };
async function saveWorld(slot, quiet) { let worldIdTime = 0, worldIdCounter = 0, worldWrites = Promise.resolve();
function newWorldId() {
const now = Date.now(); worldIdCounter = now === worldIdTime ? worldIdCounter + 1 : 0; worldIdTime = now;
return 'w' + now + (worldIdCounter ? '-' + worldIdCounter : '');
}
function worldWrite(fn) { const p = worldWrites.then(fn); worldWrites = p.catch(() => {}); return p; }
async function listWorlds() { return (await DB.get('worlds') || []).sort((a, b) => b.savedAt - a.savedAt); }
function putWorldEntry(entry) {
return worldWrite(async () => {
const list = await listWorlds(), i = list.findIndex(w => w.id === entry.id);
if (i < 0) list.push(entry); else list[i] = { ...list[i], ...entry };
await DB.put('worlds', list);
});
}
async function worldNewest(id, onProgress) {
if (onProgress) onProgress(0.05, 'Reading the save');
const a = await DB.get(`${id}:main`), b = await DB.get(`${id}:auto`);
const sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return null;
const entry = (await listWorlds()).find(w => w.id === id);
return { ...sv, buffers: await unpackBuffers(sv.buffers, onProgress), worldId: id, worldName: entry ? entry.name : 'World' };
}
function deleteWorld(id) {
return worldWrite(async () => {
const worlds = await listWorlds(), entry = worlds.find(w => w.id === id), list = worlds.filter(w => w.id !== id);
const stored = entry && entry.career ? await DB.get('career:' + entry.career) : null;
const c = stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : null;
const claims = entry && entry.career ? list.filter(w => w.career === entry.career).map(w => w.id) : [];
if (c) c.claims = claims;
await DB.op('readwrite', st => {
st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds');
if (entry && entry.career) {
if (!claims.length) st.delete('career:' + entry.career);
else if (c) st.put(c, 'career:' + entry.career);
}
});
if (career && c && career.id === c.id) career.claims = c.claims;
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
});
}
function cleanWorldName(name, fallback) { return String(name).trim().slice(0, 40) || fallback; }
function renameWorld(id, name) {
return worldWrite(async () => {
const list = await listWorlds(), entry = list.find(w => w.id === id);
if (!entry) return;
entry.name = cleanWorldName(name, entry.name);
await DB.put('worlds', list);
if (GAME.worldId === id) GAME.worldName = entry.name;
});
}
async function migrateWorlds() {
try { try {
const s = serializeWorld(); if (await DB.get('worlds') !== undefined) return;
await DB.put(slot, { meta: s.meta, buffers: s.buffers, savedAt: Date.now(), playTime: GAME.playTime }); const a = await DB.get('main'), b = await DB.get('auto'), sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return;
const id = newWorldId();
await DB.op('readwrite', st => { if (a) st.put(a, `${id}:main`); if (b) st.put(b, `${id}:auto`); });
// Verify every byte before retiring the only old copies.
for (const [slot, old] of [['main', a], ['auto', b]]) if (old) {
const copy = await DB.get(`${id}:${slot}`);
if (!copy || JSON.stringify(copy.meta) !== JSON.stringify(old.meta) || copy.savedAt !== old.savedAt || copy.playTime !== old.playTime) throw new Error('Save copy failed.');
const original = await unpackBuffers(old.buffers), copied = await unpackBuffers(copy.buffers);
for (const k in original) {
const x = original[k], y = copied[k];
if (!y || x.constructor !== y.constructor || x.length !== y.length || !x.every((v, i) => v === y[i])) throw new Error('Save copy failed.');
}
}
await DB.op('readwrite', st => {
st.put([{ id, name: 'World 1', seed: sv.meta.seed, w: sv.meta.W, h: sv.meta.H, createdAt: sv.savedAt, savedAt: sv.savedAt, playTime: sv.playTime || 0 }], 'worlds');
st.delete('main'); st.delete('auto');
});
} catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; }
}
let savingCount = 0;
async function saveWorld(slot, quiet) {
savingCount++;
const indicator = setTimeout(() => { if (!GAME.loading) $('saving').hidden = false; }, 150);
try {
const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
const id = GAME.worldId, name = GAME.worldName, claimNo = GAME.claimNo, c = GAME.careerId && career ? { ...career, ...careerState() } : null;
await worldWrite(async () => {
const buffers = await packBuffers(s.buffers);
const list = await listWorlds();
let entry = list.find(w => w.id === id);
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); }
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, ...(c ? { career: c.id, claimNo } : {}) });
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); if (c) st.put(c, 'career:' + c.id); });
});
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.'); if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true; return true;
} catch (e) { } catch (e) {
if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.'); if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.');
return false; return false;
} finally { clearTimeout(indicator); if (!--savingCount) $('saving').hidden = true; }
} }
} async function newestSave(onProgress) {
async function newestSave() { try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id, onProgress); if (sv) return sv; } return null; }
try { catch (e) { return null; }
const a = await DB.get('main'), b = await DB.get('auto');
if (a && b) return a.savedAt >= b.savedAt ? a : b;
return a || b || null;
} catch (e) { return null; }
} }
function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); } function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); }
function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; } function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; }
@@ -46,6 +133,30 @@ async function gz(u8, inflate) {
const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip')); const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip'));
return new Uint8Array(await new Response(st).arrayBuffer()); return new Uint8Array(await new Response(st).arrayBuffer());
} }
async function packBuffers(buffers) {
const packed = {};
for (const k in buffers) {
const arr = buffers[k], bytes = await gz(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
packed[k] = bytes ? { type: arr.constructor.name, gz: true, bytes } : arr;
}
return packed;
}
async function unpackBuffers(buffers, onProgress) {
let n = 0; const total = Object.keys(buffers).length;
const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array };
for (const k in buffers) {
if (onProgress) onProgress(0.1 + 0.6 * n++ / total, 'Unpacking');
const b = buffers[k];
if (ArrayBuffer.isView(b)) { raw[k] = b; continue; }
if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k);
if (!Object.hasOwn(types, b.type)) throw new Error('Unknown saved buffer type: ' + b.type);
const bytes = await gz(b.bytes, true), C = types[b.type];
if (!bytes) throw new Error('This browser cannot decompress saved worlds.');
raw[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
}
if (onProgress) onProgress(0.7, 'Unpacking');
return raw;
}
async function exportWorld() { async function exportWorld() {
const s = serializeWorld(); const s = serializeWorld();
const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} }; const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} };
@@ -73,20 +184,27 @@ async function exportWorld() {
catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); } catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); }
} }
async function importWorldFile(file) { async function importWorldFile(file) {
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
progress(0.05, 'Reading the save');
try { try {
const obj = JSON.parse(await file.text()); const obj = JSON.parse(await file.text());
if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.'); if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.');
const bufs = {}; const bufs = {};
const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array }; const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array };
let n = 0; const total = Object.keys(obj.buffers).length;
for (const k in obj.buffers) { for (const k in obj.buffers) {
progress(0.1 + 0.6 * n++ / total, 'Unpacking');
const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64); const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64);
if (b.gz) bytes = await gz(bytes, true); if (b.gz) bytes = await gz(bytes, true);
const C = T[b.type] || Uint8Array; const C = T[b.type] || Uint8Array;
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
} }
startFromSave({ meta: obj.meta, buffers: bufs }); const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name }, progress)) return;
if (!await saveWorld('auto')) return;
toast('World imported.'); toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
finally { showLoading(false); }
} }
// ---------- Blueprint library ---------- // ---------- Blueprint library ----------
+90
View File
@@ -0,0 +1,90 @@
// Selection panels: current tool card and bag materials.
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
} else if (sel === 'copy') {
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
} else if (GAME.paste && GAME.clip) {
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
} else if (t === 'drill') {
const b = BIT_TIERS[player.bitTier];
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'build') {
// the build tool in general; the piece itself is described in the menu's hover popup
const P = piece();
const chips = buildVariantChips(P) + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '');
h = '<div class="row"><span class="name grow">Build</span></div>'
+ (chips ? `<div class="row" style="flex-wrap:wrap;gap:4px">${chips}</div>` : '')
+ '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>'
+ '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>';
} else if (t === 'vacuum') {
const all = blowingAll(), list = all ? blowAllList() : blowList();
const sws = ms => ms.slice(0, 4).map(m => `<span class="sw" title="${esc(MD[m].name)}" style="background:${rgbOf(m)}"></span>`).join('') + (ms.length > 4 ? `+${ms.length - 4}` : '');
const count = list.reduce((n, m) => n + bag[m], 0);
h = `<div class="row"><span class="name grow">Pneumatic nozzle</span><span class="chip">width ${GAME.brush[1]}</span></div>
<div class="row vacrow"><b>Pick up</b><span>${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything</span>${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}</div>
<div class="row vacrow"><b>Blow out</b><kbd>Q</kbd><span class="name grow vacchoice">${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}</span><span class="num chip${count ? '' : ' bad'}">${count ? count.toLocaleString('en-US') : 'none'}</span><kbd>E</kbd></div>
<div class="sub">Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.</div>`;
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
function renderBag() {
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap);
const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow];
const missing = [...GAME.blow].filter(m => bag[m] <= 0);
if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m);
const l = bagMaterials().concat(missing);
const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.');
if (key === lastBagKey) return;
lastBagKey = key;
const box = $('baglist'); box.innerHTML = '';
// the hint and Clear sit under the list, outside its scroll, so they never scroll away
const foot = $('bagfoot'); foot.innerHTML = '';
if (t === 'vacuum') {
foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next"));
if (GAME.blow.size) {
const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection');
clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); };
foot.appendChild(clear);
}
}
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; }
for (const m of l) {
if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag'));
const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name });
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}</span>`;
// Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click
// arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen.
b.oncontextmenu = e => e.preventDefault();
b.onclick = e => {
if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; }
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui');
};
row.appendChild(b);
box.appendChild(row);
}
}
+59
View File
@@ -0,0 +1,59 @@
// Selection pickers: filter rules, filter settings and wall materials.
// What a filter can hold: loose things and bars you have found, plus anything it already holds
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
function matList(ms) {
const n = ms.map(m => MD[m].name.toLowerCase());
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
}
// What a filter does, in words: what falls through and what rides along
function filterParts(f) {
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
}
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
const box = $('dlg-body');
function sync() {
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
}
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
function openWallPicker() {
const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material';
const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m]));
const body = `<div class="wpick" role="radiogroup" aria-label="${esc(title)}">${list.map(m => {
const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat;
// two sources build the same wall (glass from shards or from bars): say which
const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name;
const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt";
return `<button class="wp${on ? ' on' : ''}${canAfford(cost, 1) ? '' : ' short'}" role="radio" aria-checked="${on}" data-wall="${m}"><span class="sw" style="background:${rgbOf(WALL_OF[m])}"></span><span><b>${esc(name)}</b><span>you have ${bag[m].toLocaleString('en-US')}${PH[m] === PH_ITEM ? ` ${esc(MD[m].name.toLowerCase())}${bag[m] === 1 ? '' : 's'}` : ''}</span><span>cost: ${esc(costText(cost, 1))}</span><span>${heat}</span></span></button>`;
}).join('')}</div>`;
dialog(title, body, [{ label: 'Cancel' }]);
$('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => {
GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = '';
setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info');
});
}
+16 -166
View File
@@ -1,10 +1,11 @@
// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. // Tool selection: cycling, drill tuning, piece variants and costs.
function pieceSize(P) { return P.kind === 'mold' ? MOLDS[P.mold] : P; }
function pieceUsesWall(P) { return P.variants === 'wall'; } function pieceUsesWall(P) { return P.variants === 'wall'; }
function setDrillWidth(w) { function setDrillWidth(w) {
const n = Math.max(0, Math.min(PULSE_W.length - 1, w)); const n = Math.max(0, Math.min(PULSE_W.length - 1, w));
if (n === GAME.drillW) return; if (n === GAME.drillW) return;
GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui'); GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui');
const what = ['a beam as wide as the bot: bores a tunnel fast and reaches furthest', 'a cone: the all-rounder', 'a broad fan: clears the most rock, but only close to the bot'][n]; const what = ['long beam', 'balanced cone', 'broad fan'][n];
setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info'); setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info');
} }
// R with the drill: tune it to the material under the cursor, or back to everything // R with the drill: tune it to the material under the cursor, or back to everything
@@ -15,19 +16,16 @@ function tuneUnderCursor() {
const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0; const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0;
GAME.drillTune = !to || to === GAME.drillTune ? 0 : to; GAME.drillTune = !to || to === GAME.drillTune ? 0 : to;
hudDirty = true; playSfx('ui'); hudDirty = true; playSfx('ui');
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only that shatters, and the pulses pass through everything else.` : 'Untuned: the drill shatters any rock it can.', 'info'); setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only it shatters.` : 'Untuned: the drill shatters any rock it can.', 'info');
} }
function cycleItem(d) { function cycleItem(d) {
const t = toolId(); const t = toolId();
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; } if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
if (t === 'vacuum') { if (t === 'vacuum') {
// what right click blows: each material in the bag, plus an even mix of the ticked ones const l = bagMaterials();
const l = bagMaterials().map(String);
if (mixList().length >= 2) l.push('mix');
if (!l.length) return; if (!l.length) return;
let k = l.indexOf(GAME.blowMix ? 'mix' : String(GAME.dropMat)); k = (k + d + l.length) % l.length; if (k < 0) k = 0; const first = blowList()[0], k = first ? l.indexOf(first) : d > 0 ? -1 : 0;
if (l[k] === 'mix') GAME.blowMix = true; else { GAME.blowMix = false; GAME.dropMat = +l[k]; } GAME.blow = new Set([l[(k + d + l.length) % l.length]]);
GAME.matChosen = true;
} else if (t === 'build') { } else if (t === 'build') {
let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length; let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length;
GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]); GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]);
@@ -51,15 +49,14 @@ function rotate() {
if (pieceUsesWall(P)) cycleWall(1); if (pieceUsesWall(P)) cycleWall(1);
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length; else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
else return; else return;
} else if (t === 'vacuum') { toggleCollectUnderCursor(); return; } } else if (t === 'drill') { tuneUnderCursor(); return; }
else if (t === 'drill') { tuneUnderCursor(); return; }
else return; else return;
playSfx('ui'); hudDirty = true; playSfx('ui'); hudDirty = true;
} }
function toggleCollect(m) { function toggleBlow(m) {
if (!m) return; if (GAME.blow.has(m)) GAME.blow.delete(m);
if (GAME.collect.has(m)) GAME.collect.delete(m); else GAME.collect.add(m); else if (PH[m] === PH_ITEM) GAME.blow = new Set([m]);
try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ } else { for (const k of GAME.blow) if (PH[k] === PH_ITEM) GAME.blow.delete(k); GAME.blow.add(m); }
lastBagKey = ''; hudDirty = true; playSfx('ui'); lastBagKey = ''; hudDirty = true; playSfx('ui');
} }
function pickFilterUnderCursor() { function pickFilterUnderCursor() {
@@ -74,13 +71,7 @@ function toggleFilterMat(m) {
if (k >= 0) f.m.splice(k, 1); else f.m.push(m); if (k >= 0) f.m.splice(k, 1); else f.m.push(m);
f.m.sort((a, b) => a - b); f.m.sort((a, b) => a - b);
hudDirty = true; lastBagKey = ''; playSfx('ui'); hudDirty = true; lastBagKey = ''; playSfx('ui');
setStatus(`New filters ${filterSentence(f)}.`, 'info'); setStatus(`New filter selection updated.`, 'info');
}
function toggleCollectUnderCursor() {
const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; }
toggleCollect(m);
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked. While anything is ticked, the vacuum takes only ticked materials' : GAME.collect.size ? 'unticked' : 'unticked. Nothing is ticked, so the vacuum takes what you start on'}.`, 'info');
} }
function setBrush(d) { function setBrush(d) {
const t = toolId(); const t = toolId();
@@ -96,151 +87,10 @@ function pieceV(P) {
return GAME.pieceDir[P.id] || 0; return GAME.pieceDir[P.id] || 0;
} }
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
// What a filter can hold: loose things and bars you have found, plus anything it already holds
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
function matList(ms) {
const n = ms.map(m => MD[m].name.toLowerCase());
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
}
// What a filter does, in words: what falls through and what rides along
function filterParts(f) {
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
}
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
const box = $('dlg-body');
function sync() {
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
}
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; } function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; } function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
function pieceCostNow(P) { function pieceCostNow(P, m = GAME.wallMat) {
if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls); if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS); if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS);
return P.cost; return P.cost;
} }
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
} else if (sel === 'copy') {
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
} else if (GAME.paste && GAME.clip) {
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
} else if (t === 'drill') {
const b = BIT_TIERS[player.bitTier];
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'vacuum') {
ensureDropMat();
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0;
const list = [...GAME.collect];
const mixL = mixList();
const blowRow = GAME.blowMix ? `<div class="row cycle"><kbd>Q</kbd>${mixL.map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}<span class="name grow">Ticked mix</span><span class="num chip">${mixL.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span><kbd>E</kbd></div>`
: m ? `<div class="row cycle"><kbd>Q</kbd><span class="sw" style="background:${rgbOf(m)}"></span><span class="name grow">${esc(MD[m].name)}</span><span class="num chip ${bag[m] ? '' : 'bad'}">${bag[m] ? bag[m].toLocaleString('en-US') : 'none left'}</span><kbd>E</kbd></div>`
: `<div class="row"><span class="name">Vacuum</span><span class="sub">bag empty</span></div>`;
const sucking = f < 0 ? 'everything' : list.length ? 'only the ticked materials' : f > 0 ? 'only ' + esc(MD[f].name.toLowerCase()) : 'what you start on';
h = `${blowRow}
<div class="sub"><b>Left</b> sucks up ${sucking}${list.length && f >= 0 ? ' (untick them all to pick by what you click on)' : ''}. Standing still, <kbd>Shift</kbd> takes everything.</div>
<div class="sub"><b>Right</b> blows ${GAME.blowMix ? 'an even mix of the ticked materials' : m ? 'only ' + esc(MD[m].name.toLowerCase()) : 'the chosen material'} out. ${mixL.length > 1 || GAME.blowMix ? '<kbd>Q</kbd>/<kbd>E</kbd> switch between single materials and the ticked mix.' : 'Tick 2+ materials to blow them as a mix.'}</div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">nozzle ${GAME.brush[1]}</span>${list.map(k => `<span class="chip"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(k)}"></span>${esc(MD[k].name)}</span>`).join('')}${list.length ? '' : '<span class="sub">Nothing ticked. <kbd>R</kbd> ticks what is under the cursor.</span>'}</div>`;
} else if (t === 'build') {
const P = piece();
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const cost = pieceCostNow(P);
const size = P.kind === 'machine' ? `${P.bw}×${P.bh} blocks` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh} blocks` : '1 block';
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
const variant = pieceUsesWall(P) ? `<span class="chip hot"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R</span>`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">${esc(P.name)}</span>${variant}<kbd>E</kbd></div>
<div class="row">${chipCost(cost)}<span class="chip">${size}</span>${P.power ? `<span class="chip">${P.power} power</span>` : ''}${GAME.ctrl ? '<span class="chip hot">Replace · Ctrl</span>' : ''}</div>
<div class="sub">${esc(P.desc)}</div>
${P.filter ? `<div class="sub">New ones will ${esc(filterSentence(GAME.filterCfg))}. <kbd>F</kbd> sets materials and mode (or click materials in the bag, or <kbd>T</kbd> over one); <kbd>F</kbd> on a placed filter changes its whole row.</div>` : ''}
<div class="sub">${P.kind === 'block' ? 'Drag for a line (any length: what is out of reach becomes ghosts), Shift-drag for a rectangle.' : 'Click to place.'}${P.dirs && P.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses <kbd>R</kbd>.' : P.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(P) ? ' <kbd>R</kbd> picks the material.' : ''}</div>
<div class="sub keys-mini"><kbd>Ctrl</kbd> replace · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · right click take back · <kbd>G</kbd> pick · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste</div>${ng ? `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>` : ''}`;
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
h += `<div class="status${GAME.statusKind === 'info' ? ' info' : ''}">${esc(status)}</div>`;
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
function renderBag() {
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap);
const l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m));
const t = toolId();
const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '')).join(',') + '|' + GAME.dropMat + '|' + GAME.blowMix + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx;
if (key === lastBagKey) return;
lastBagKey = key;
const box = $('baglist'); box.innerHTML = '';
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; }
for (const m of l) {
const sel = (t === 'vacuum' && (GAME.blowMix ? GAME.collect.has(m) : m === GAME.dropMat)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') });
const tick = el('button', { class: 'tick' + (GAME.collect.has(m) ? ' on' : ''), title: GAME.collect.has(m) ? 'Ticked: the vacuum takes only ticked materials, and they make up the Ticked mix' : 'Tick to vacuum only ticked materials', 'aria-label': `Tick ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m) });
tick.onclick = () => toggleCollect(m);
const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` });
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m].toLocaleString('en-US')}</span>`;
b.onclick = () => {
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.dropMat = m; GAME.matChosen = true; GAME.blowMix = false; if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui');
};
row.appendChild(tick); row.appendChild(b); box.appendChild(row);
}
if (t === 'vacuum' && mixList().length > 1) {
// the ticked mix is a choice of its own, like any material
const mx = mixList(), row = el('div', { class: 'bagrow mixrow' + (GAME.blowMix ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Blow an even mix of the ticked materials' });
b.innerHTML = `<span class="mixsw">${mx.slice(0, 4).map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}</span><span class="nm">Ticked mix</span><span class="n num">${mx.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span>`;
b.onclick = () => { GAME.blowMix = true; GAME.matChosen = true; hudDirty = true; lastBagKey = ''; playSfx('ui'); };
row.appendChild(b); box.appendChild(row);
}
if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click a material to blow only that, or Ticked mix to blow an even mix of the ticked ones. Ticked ones always come along when you vacuum.'));
}
+91 -21
View File
@@ -1,42 +1,83 @@
// Starting, loading and quitting a game; modal dialogs; the pause menu. // Starting, loading and quitting a game; modal dialogs; the pause menu.
// ---------- Start / stop ---------- // ---------- Start / stop ----------
function showLoading(on, p) { function showLoading(on, p, label, title) {
GAME.loading = on;
if (on) {
$('saving').hidden = true;
if (title) $('loading').querySelector('.label').textContent = title;
$('loadstep').textContent = label || '';
}
$('loading').hidden = !on; $('loading').hidden = !on;
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; } if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
} }
function enterGame() { function enterGame() {
$('cards').innerHTML = '';
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
GAME.status = ''; GAME.statusT = 0;
$('title').hidden = true; $('hud').hidden = false; $('title').hidden = true; $('hud').hidden = false;
GAME.running = true; GAME.paused = false; GAME.running = true; GAME.paused = false;
buildSky(); resizeRender(); snapCamera(); buildSky(); resizeRender(); snapCamera(); resetMap();
GAME.lastAutosave = performance.now(); GAME.lastAutosave = performance.now();
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar(); hudDirty = true; renderHotbar();
} }
function startNewWorld(seed, w, h) { async function setupNewWorld(seed, w, h, name, carried, title = 'Generating world') {
auInit(); auInit(); showLoading(true, 0, 'Shaping the land', title);
showLoading(true, 0); await new Promise(resolve => setTimeout(resolve, 30));
setTimeout(() => { for (const k of Object.keys(stats)) stats[k] = 0;
generateWorld(seed, w, h, null); await generateWorldAsync(seed, w, h, (f, s) => showLoading(true, f * 0.9, s, title));
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; GAME.worldId = newWorldId(); GAME.worldName = name;
showLoading(false); GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
enterGame(); if (carried) applyCareerState(carried);
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
saveWorld('auto', true);
}, 30);
} }
function startFromSave(sv) { async function startNewWorld(seed, w, h, name = '') {
try {
const worlds = await listWorlds();
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 };
GAME.careerId = career.id; GAME.claimNo = 1;
showLoading(true, 0.9, 'Saving');
await saveWorld('auto');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
}
async function startFromSave(sv, onProgress) {
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
auInit(); auInit();
try { deserializeWorld(sv.meta, sv.buffers); } GAME.paused = true;
try {
for (const k of Object.keys(stats)) stats[k] = 0;
onProgress(0.75, 'Building the world');
await new Promise(r => setTimeout(r, 30));
deserializeWorld(sv.meta, sv.buffers);
}
catch (e) { catch (e) {
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' }); toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
showLoading(false);
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle(); GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
return false; return false;
} }
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0; GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0; GAME.drag = null; GAME.blow.clear();
onProgress(0.95, 'Your gear and notebook');
await loadCareer();
showLoading(false);
enterGame(); enterGame();
return true; return true;
} }
async function loadSavedWorld(id) {
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
progress(0, 'Reading the save');
try {
const sv = id ? await worldNewest(id, progress) : await newestSave(progress);
if (sv) return await startFromSave(sv, progress);
toast('No saved world found.'); return false;
} catch (e) { toast('Could not reach browser storage.'); return false; }
finally { showLoading(false); }
}
async function quitToTitle() { async function quitToTitle() {
await saveWorld('main', true); await saveWorld('main', true);
GAME.running = false; closeAllModals(); GAME.running = false; closeAllModals();
@@ -46,20 +87,21 @@ async function quitToTitle() {
} }
async function refreshContinue() { async function refreshContinue() {
const sv = await newestSave(); const sv = await newestSave();
$('btn-worlds').hidden = !(await listWorlds()).length;
const b = $('btn-continue'); const b = $('btn-continue');
if (sv && sv.meta && sv.meta.v === SAVE_VERSION) { if (sv && sv.meta && sv.meta.v === SAVE_VERSION) {
b.hidden = false; b.hidden = false;
const d = new Date(sv.savedAt); const d = new Date(sv.savedAt);
$('continue-meta').textContent = `seed ${sv.meta.seed} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; $('continue-meta').textContent = `${sv.worldName} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
} else b.hidden = true; } else b.hidden = true;
} }
// ---------- Modals ---------- // ---------- Modals ----------
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); } function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].some(id => !$(id).hidden); }
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); } function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; } function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; } function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; } function topModal() { for (const id of ['m-dialog', 'm-map', 'm-worlds', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function dialog(title, bodyHtml, buttons) { function dialog(title, bodyHtml, buttons) {
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml; $('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
const foot = $('dlg-foot'); foot.innerHTML = ''; const foot = $('dlg-foot'); foot.innerHTML = '';
@@ -82,6 +124,34 @@ $('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; }; $('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); }; $('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
function openPause() { function openPause() {
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`; $('pause-meta').textContent = `${GAME.worldName} · seed ${worldSeed} · ${W}×${H}`;
openModal('m-pause'); openModal('m-pause');
} }
// ---------- World list ----------
async function renderWorlds(editId) {
const list = await listWorlds(), body = $('worlds-list'); body.innerHTML = '';
if (!list.length) body.innerHTML = '<p class="note">No worlds saved yet. Start a new world.</p>';
for (const w of list) {
const row = el('div', { class: 'world-row', 'data-world': w.id });
const mins = Math.floor((w.playTime || 0) / 60);
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong><div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
const actions = el('div', { class: 'world-actions' });
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
if (editId === w.id) {
const input = el('input', { type: 'text', maxlength: 40, 'aria-label': 'World name' }); input.value = w.name; actions.appendChild(input);
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
button('Cancel', () => renderWorlds());
} else {
button('Load', async () => { const ok = await loadSavedWorld(w.id); if (ok) closeAllModals(); });
button('Rename', () => renderWorlds(w.id));
button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Delete', cls: 'danger', fn: async () => { try { await deleteWorld(w.id); closeModal('m-dialog'); await renderWorlds(); await refreshContinue(); } catch (e) { toast('Could not delete the world.'); } } },
]), 'danger');
}
row.appendChild(actions); body.appendChild(row);
}
if (editId) { const input = body.querySelector('input'); if (input) { input.focus(); input.select(); } }
}
$('btn-worlds').onclick = async () => { try { await renderWorlds(); openModal('m-worlds'); } catch (e) { toast('Could not reach browser storage.'); } };
+12 -3
View File
@@ -9,8 +9,10 @@ const SET_TABS = {
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] }, { k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen }, { k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' }, { k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' },
{ k: 'minimap', label: 'Show minimap', type: 'toggle' },
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' }, { k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'cameraLead', label: 'Camera looks toward the pointer', d: 'The view drifts toward the mouse or right stick. Turn off to keep the bot centred.', type: 'toggle' },
{ k: 'shake', label: 'Screen shake', type: 'toggle' }, { k: 'shake', label: 'Screen shake', type: 'toggle' },
], ],
Graphics: [ Graphics: [
@@ -44,12 +46,13 @@ const SET_TABS = {
{ html: () => controlsHtml() }, { html: () => controlsHtml() },
], ],
Game: [ Game: [
{ k: 'fog', label: 'Fog of war', d: 'Places you have not seen yet are black. Off shows the whole world.', type: 'toggle' },
{ k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] }, { k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] },
{ k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] }, { k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] },
{ k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true }, { k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true },
{ k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true }, { k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true },
{ k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() }, { k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() },
{ k: 'wipe', label: 'Delete saved worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete…', danger: true, fn: confirmWipe }, { k: 'wipe', label: 'Delete all worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete all worlds', danger: true, fn: confirmWipe },
], ],
}; };
let setTab = 'Display'; let setTab = 'Display';
@@ -109,8 +112,14 @@ function toggleFullscreen() {
} catch (e) { toast('Fullscreen is not available in this view.'); } } catch (e) { toast('Fullscreen is not available in this view.'); }
} }
function confirmWipe() { function confirmWipe() {
dialog('Delete saved worlds?', '<p class="note">This removes the main save and the autosave from this browser. Exported world files are not affected.</p>', [ dialog('Delete all worlds?', '<p class="note">This removes every world and its autosave from this browser. Exported world files are not affected.</p>', [
{ label: 'Cancel', cls: 'ghost' }, { label: 'Cancel', cls: 'ghost' },
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } }, { label: 'Delete all worlds', cls: 'danger', fn: async () => {
try {
await worldWrite(() => DB.op('readwrite', st => st.clear()));
GAME.worldId = ''; GAME.worldName = '';
closeModal('m-dialog'); toast('All worlds deleted.'); await refreshContinue();
} catch (e) { toast('Could not reach browser storage.'); }
} },
]); ]);
} }
+24 -16
View File
@@ -1,11 +1,11 @@
/* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, /* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers,
toasts and the status line. */ toasts and tool status. */
// ---------- Settings ---------- // ---------- Settings ----------
const SET_DEFAULT = { const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true,
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true, volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true,
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2,
}; };
@@ -23,7 +23,7 @@ loadSettings();
// ---------- Game state ---------- // ---------- Game state ----------
const TOOLS = [ const TOOLS = [
{ id: 'drill', name: 'Drill', key: '1' }, { id: 'drill', name: 'Drill', key: '1' },
{ id: 'vacuum', name: 'Vacuum', key: '2' }, { id: 'vacuum', name: 'Pneumatic nozzle', key: '2' },
{ id: 'build', name: 'Build', key: '3' }, { id: 'build', name: 'Build', key: '3' },
{ id: 'ignite', name: 'Igniter', key: '4' }, { id: 'ignite', name: 'Igniter', key: '4' },
]; ];
@@ -34,15 +34,15 @@ const ICONS = {
ignite: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 21c-4 0-6-2.6-6-5.6 0-3.4 3-5 3.6-8.4C12.5 9 14 11 14 13c1-.6 1.6-1.8 1.6-3 2 1.6 2.4 3.6 2.4 5.4 0 3-2 5.6-6 5.6z"/></svg>', ignite: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 21c-4 0-6-2.6-6-5.6 0-3.4 3-5 3.6-8.4C12.5 9 14 11 14 13c1-.6 1.6-1.8 1.6-3 2 1.6 2.4 3.6 2.4 5.4 0 3-2 5.6-6 5.6z"/></svg>',
blueprint: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="11" height="11" rx="1" stroke-dasharray="2.5 2"/><rect x="10" y="10" width="11" height="11" rx="1"/></svg>', blueprint: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="11" height="11" rx="1" stroke-dasharray="2.5 2"/><rect x="10" y="10" width="11" height="11" rx="1"/></svg>',
}; };
const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN, BARGL, BARSB, AUORE, AGORE, ZNORE, SNORE, RSTONE, LCLAY, RBRICK, LDIRT, LWOOD, ACORN, COAL, CHARCOAL, CUORE, FEORE, SAND, GRAVEL, RGRAN, RBAS, CULLET, const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN, BARGL, BARSB, PLANK, GREENB, BARBR, AUORE, AGORE, ZNORE, SNORE, RSTONE, LCLAY, RBRICK, LDIRT, LWOOD, ACORN, COAL, CHARCOAL, CUORE, FEORE, SAND, GRAVEL, RGRAN, RBAS, CULLET,
SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS]; SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS];
function loadCollect() { try { const a = JSON.parse(localStorage.getItem('deepgrain.collect') || '[]'); return new Set(a.filter(m => m > 0 && m < NMAT && BAGOK[m])); } catch (e) { return new Set(); } }
const GAME = { const GAME = {
running: false, paused: false, tool: 0, dropMat: 0, matChosen: false, wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {}, ctrl: false,
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 }, running: false, loading: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {},
paste: false, selDrag: null, rmbRec: null, dragDir: -1, ctrlShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false }, brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 }, paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
lastAutosave: 0, playTime: 0, lastHud: 0, worldName: '', lmb: false, rmb: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '', careerId: '', claimNo: 1,
}; };
const keys = new Set(); const keys = new Set();
let hudDirty = true; let hudDirty = true;
@@ -57,11 +57,19 @@ function fmtT(t) { return t >= 999.5 ? `${Math.round(t).toLocaleString('en-US')}
// ---------- Toasts ---------- // ---------- Toasts ----------
function toast(text, opts = {}) { function toast(text, opts = {}) {
const box = $('toasts'); const box = $('toasts');
while (box.children.length > 4) box.firstChild.remove(); let t = [...box.children].find(e => !e.classList.contains('out') && e._text === text && e._title === (opts.title || ''));
const t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') }); if (!t) {
while (box.children.length > 4) {
clearTimeout(box.firstChild._fade); clearTimeout(box.firstChild._remove);
box.firstChild.remove();
}
t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') + (opts.kind ? ' ' + opts.kind : '') });
t._text = text; t._title = opts.title || '';
t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text); t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text);
box.appendChild(t); box.appendChild(t);
const life = opts.big ? 5200 : 2600;
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 450); }, life);
} }
function setStatus(s, kind) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; } } clearTimeout(t._fade);
const life = opts.big ? 5200 : 2600;
t._fade = setTimeout(() => { t.classList.add('out'); t._remove = setTimeout(() => t.remove(), 450); }, life);
}
function setStatus(s, kind, quiet) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; if (!quiet) toast(s, { kind: kind || 'err' }); } }
+14
View File
@@ -0,0 +1,14 @@
---
id: t12
title: Exotic and sci-fi ores for late game
status: Backlog
created: 2026-10-24
updated: 2026-10-24T23:27:52Z
tags: [idea]
order: 0
archived: true
---
## Context
- we can add more exotic/sci-fi ish ores and materials for more late game.
+19
View File
@@ -0,0 +1,19 @@
---
id: t24
status: Backlog
order: 4
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:27:52Z
title: Water Fixes
archived: true
---
# Context
Water never seems to run out in certain conditions when its running off the edge. maybe it actually does but it takes a while and i just didnt notice. Please do a full test suite of water physics and make sure it asks as it should in all conditions.
## Subtasks
- [ ] water physics fixes
- [ ]
@@ -0,0 +1,19 @@
---
id: t31
title: Choose between several claims and sizes when opening a new claim
status: Backlog
created: 2026-10-24
updated: 2026-10-24T23:27:52Z
order: 6
archived: true
---
## Context
- Opening a new claim today always gives a random world the same size as the current one. Instead, offer a few claims to choose from, each with its own size and character: different ores, features and terrain, so every new claim feels distinct and worth picking. This builds on t13 (world generation options and planet presets) and is best done after it, using its options to make the claims differ.
## Subtasks
- [ ] When opening a new claim, offer a short list of claims to pick from (for example three), each showing its size, price and what makes it special
- [ ] Claims differ in world generation: richer or rarer ores, unusual features, different terrain, using the t13 options
- [ ] Each offered claim has its own down payment and debt to match its size and riches
+17
View File
@@ -0,0 +1,17 @@
---
id: t10
title: World name box for a new world
status: Done
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
order: 4
report: |
(agent) The New world panel now has a Name box above Seed. It starts filled with the next
"World N" and selected, so you can type over it; leave it empty to get "World N". Enter in
the Name or Seed box starts the world. The name shows in the Worlds list and the pause menu.
To check: title > New world, type a name, Enter. Pause (Esc) shows the name at the top.
---
## Context
- add world name edit box to new world.
+18
View File
@@ -0,0 +1,18 @@
---
id: t11
title: Welcome and first milestone shown again on an older world
status: Done
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
tags: [bug]
order: 5
report: |
(agent) Fixed: loading a world now clears the cards in the lower right, so cards from the
world you were just in (Welcome, first milestone) no longer carry over.
To check: make a new world, quit to the title, load an older world from Worlds. No Welcome
or milestone card should show. A brand-new world still shows its Welcome card.
---
## Context
- when making a new world then going back to tile and selecting a previously created world. the bottom right welcome and first milestone messages are still shown after loading the older world where you have already completed those.
+21
View File
@@ -0,0 +1,21 @@
---
id: t13
title: Distinct worlds: generation options, planet presets, exotic ores and a choice of claims
status: Backlog
created: 2026-10-24
updated: 2026-10-24T23:55:44Z
tags: [idea]
order: 0
---
## Context
- Every world is generated the same way today, and a new claim is just another random world the same size as the last. Make worlds and claims feel distinct: options when generating a world, planet presets that change how a world looks and what is in it, exotic late-game ores that only some worlds have, and a choice of claims to open, each with its own size, character and price. (Merged from t12 exotic ores and t31 choosing claims.)
## Subtasks
- [ ] Generation options in the new-world dialog: how much ore, caves, water and mountains, and how deep the world goes
- [ ] Planet presets that change the look and the climate: different trees, water, dirt and rock colours, for example a frozen world, a desert world and a volcanic world
- [ ] Exotic, sci-fi ores and materials for the late game, found only on some planets or very deep, each with a use
- [ ] Opening a new claim offers a few claims to choose from (for example three), each showing its size, preset, notable ores, down payment and debt
- [ ] Claim prices match their size and riches
+19
View File
@@ -0,0 +1,19 @@
---
id: t14
title: Redesign drill upgrades
status: Backlog
created: 2026-10-24
updated: 2026-10-24T23:55:44Z
tags: [idea]
order: 1
---
## Context
- The Resonance drill is upgraded today by casting drill bits in a Bit mold, which doesn't make sense for a drill that shatters rock with pulses. Redesign how upgrades work as a whole. Earlier ideas from the design notes: an amplifier, a tuning fork and harmonics for the drill, a Grav pick tool and mining charges.
## Subtasks
- [ ] Decide what the drill's upgrades are (power, reach, width, tuning, harder rock) and how each is earned or built, replacing the Bit mold
- [ ] Decide whether other upgrades (the bag/tank, Flask, thermal lens) follow the same system
- [ ] Old saves keep the upgrades they already have
+14
View File
@@ -0,0 +1,14 @@
---
id: t15
status: Done
order: 7
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: material selection menu for vacuum tool
report: |
(agent) The owner chose to fold this into t6: the bag rows are the vacuum's material
selection (⬇ pick up, ⬆ blow) instead of a separate menu. Check it with t6.
---
+35
View File
@@ -0,0 +1,35 @@
---
id: t16
status: Done
order: 12
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: vacuum tool rework, again
report: |
(agent) Fewer modes. Left click picks up everything, or only what you've marked once you mark
something; Shift takes everything for one click. F with the Vacuum (when nothing in reach can be
used, like a mold or gate) opens the Vacuum menu: one line per material with ⬇ (pick up) and ⬆
(blow), plus Pick up everything and Blow one at a time. Clicking the Pick up / Blow out chips on
the card opens it too. Bag: click a name to blow only it, Ctrl+click to add it to the blow mix,
Ctrl+right-click to add it to pick-up (a small ⬇ shows what's marked). Shift + right click blows
an even mix of everything loose in the bag. Q/E still cycles, R still marks under the cursor.
To check: Ctrl+click coal and copper ore in the bag and blow a mix; Ctrl+right-click copper ore
and vacuum a mixed pile; Shift + right click to blow everything; press F for the compact menu.
---
## Context
- The vacuum still has too many modes. Replace them with a menu on F, like the wall menu, where each material has a Pick up and a Blow toggle. Take the ⬇/⬆ buttons off the bag rows. Pick-up has only two states: everything when nothing is marked, or only the marked materials.
## Subtasks
- [x] Pick-up: everything unless something is marked (no mode variable)
- [x] Vacuum menu on F and on the card chips; bag row buttons removed
- [x] Text, tests and screenshots up
- [x] shift to blow all
report: Shift + right click (while not flying) blows an even mix of everything loose in the bag; bars are left out. What you've chosen to blow doesn't change.
- [x] Ctrl+click bag items to select several for pick-up and blow
report: Ctrl+click a bag name adds it to (or takes it out of) what you blow; Ctrl+right-click does the same for pick-up. Plain click still blows only that one.
- [x] Compact the F menu: one line per item
report: One row per material: swatch, name, count, a ⬇ and a ⬆ button under Pick up / Blow headings.
+28
View File
@@ -0,0 +1,28 @@
---
id: t17
status: Done
order: 13
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: fog of war changes
report: |
(agent) The bot now sees into the ground around it, through rock: fully clear out to 12 grains,
then fading out softly by 24, with a grainy edge instead of a hard or stepped one. It moves with the bot and
stays revealed afterwards, like the rest of the fog. Line of sight down tunnels and into caves
works as before (out to 48 grains).
To check: start or load a world, dig down, and look at the ore and caves showing in the rock
around the bot as you go.
---
## Context
- Fog of war only reveals open areas the bot can see into, so you can't tell what's in the ground around you or where to dig. Also reveal the ground around the bot, through rock, out to about 20 grains with a soft edge. Ground revealed this way looks fully clear, the same as ground seen in the open.
## Subtasks
- [x] Reveal ground within about 20 grains of the bot, through rock, with a soft edge
- [x] Blend the fog edges more: they look sharp and stepped
report: The ring around the bot now fades over 12 grains (clear to 12, gone at 24) instead of 4, rays fade 8 grains into rock and over the last 16 of their range, the surface fades over 16 grains, and partly seen grains get a little per-grain noise so the edge looks grainy instead of stepped.
- [x] Remove the jagged grey edge, and make unexplored ground black
report: Fading now eases into black along a curve, the grain noise is strongest mid-fade and gone at the edges, sight into rock always fades over 8 grains from however bright it arrived, and unexplored ground (and off the map) is pure black instead of dark grey.
+25
View File
@@ -0,0 +1,25 @@
---
id: t19
status: Done
order: 9
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: vacuum blow all visual
report: |
(agent) With the Vacuum, holding Shift (while not flying) now shows what Shift + right click
will blow before you click: the card's Blow out line reads "Everything · mix of N", every bag
row that will be blown lights up, and the swatches by the cursor show the whole mix. Let go of
Shift and it goes back to your chosen material. It also shows while you're blowing everything.
To check: with a few things in the bag and one chosen to blow, hold Shift and watch the card and
bag; then hold Shift with WASD and see it doesn't change (Shift is boost then).
---
## Context
- Show what Shift + right click will blow before you click. While Shift is held with the Vacuum, or while blowing everything, every bag row that will be blown lights up, the card's Blow out line reads "Everything · mix of N", and the swatches by the cursor show the whole mix.
## Subtasks
- [x] Card shows "Everything · mix of N" while Shift is held
- [x] Bag rows that will be blown light up
+26
View File
@@ -0,0 +1,26 @@
---
id: t1
title: Ingot stacking and storage
status: Review
created: 2026-10-24
updated: 2026-10-24T23:53:08Z
tags: [discussion]
order: 0
report: |
(agent) Bars now pile neatly: a bar that lands snaps into line, and one hanging mostly over a gap
tips into it, so piles have no air holes. For storage there's a new Bar rack block ($40) in the
Machines tab. Each block holds 50 bars of any kind; stack blocks in a column for more. Bars that
land on top go in, the hover shows the counts by kind, and F opens the gate at the bottom so
bars come out one at a time, oldest first, onto a belt below. Taking a block back gives its
bars to you. Please try a rack under an ingot mold's belt and see if the stacking feels right.
assignee: claude
---
## Context
- stacking ingots is a little odd, they dont stack neatly. so putting them all in the same place means it will get full but have lots of air space. what can we do about this? WHat in real life might be the solution for storing ingots?
## Subtasks
- [x] Bars that come to rest snap to a 2-grain grid, and a bar with less than half of itself supported tips into the gap beside it, so piles have no air gaps
- [x] Rack blocks: 1-block pieces stacked into a column, 50 bars each; bars dropped on top go in (any kind, counted by kind), F opens a gate at the bottom that lets them out one at a time, oldest first
+42
View File
@@ -0,0 +1,42 @@
---
id: t20
status: Done
order: 14
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: vacuum/blow tool rethink
report: |
(agent) The Vacuum has one selection for picking up and blowing out. With anything selected, a
left drag takes only the selected materials, wherever it starts; with nothing selected it takes
what you click (or the first loose grain it reaches). Right click blows the selected materials
that are in the bag, as a mix. Click a bag name or Q/E selects one, Ctrl+click adds or removes.
Hold Ctrl and the bag also lists everything you've found, under "Not in bag", so you can select
materials you don't have yet. Blowing out the last of a material deselects it (one picked
before you had any stays selected). Ctrl + wheel scrolls the bag list. Clear the
selection with the Clear selection button under the bag list, by clicking the only selected row, or gamepad B.
Shift takes or blows everything.
To check: hold Ctrl, Ctrl+click two materials you don't have, then sweep a mixed pile (only
those come up); blow a coal + ore mix until the coal runs out (ore keeps going, coal is
deselected); Ctrl + wheel over the bag; Clear, then drag on something (only that comes up).
---
# Context
I dont like the current tool. I want to completely rethink how to pick up and drop materials in the game. maybe a vacuum isnt the best tool. maybe it is. I just want to discuss some options for picking up and dropping the materials in the game. it dont need to be super complicated, but i do need to be able to selectively pick up and drop materials. lets brainstorm multiple ideas for tools maybe multiple tools or just one. i want it to be simple and intuitive.
## Subtasks
- [x] discuss with user in claude code when asked about it, to work out exactly what the user wants
- [x] Pick up what you click: a drag that starts on a material sweeps up only that material, and Shift takes everything
- [x] One bag item (or a Ctrl+click mix) is in hand; Q/E or clicking a bag row changes it, and right click blows it out
- [x] When the last of something in hand is blown out, it leaves the hand; an empty hand says so instead of blowing
- [x] Picking something up with an empty hand puts it in hand
- [x] Remove the pick-up marks (R, Ctrl+right-click, the "Only marked" chip) and the F vacuum menu
- [x] One selection for both: a drag picks up only the selected materials (or what you click when nothing is selected), and right click blows out the selected ones in the bag
- [x] Holding Ctrl lists every material found so far in the bag, below a "Not in bag" divider, so several can be selected before any are picked up
- [x] Blowing out the last of a selected material deselects it; materials selected before you have any stay selected
- [x] An easy way to clear the selection (a Clear button, clicking the only selected row, gamepad B)
- [x] Ctrl + wheel over the bag scrolls the list instead of zooming the page
- [x] Ctrl+click adds to the selection on setups where the click arrives without the Ctrl flag
- [x] The bag hint and a wider, more visible Clear selection button stay visible below the list instead of scrolling away
+32
View File
@@ -0,0 +1,32 @@
---
id: t21
status: Review
order: 0
priority: P3
created: 2026-10-24
updated: 2026-10-25T02:42:10Z
title: Code health: refactor, speed up and test the physics
report: |
(agent) Done in six commits; all tests pass. Water: it did run out, just slowly. Pools now pour
off ledges several times faster (a 100-wide pool empties in about 50 s) and leftover film drains;
rain from steam spreads out downwind, so a waterfall into a hot shaft no longer refills its own
lake. Found and fixed: steam or smoke trapped in a pocket never finished, and rain landing on
steam deleted water. Piles near the bot now sort, quickly. Faster with identical results: the
roof scan (4.6 -> 1.9 ms on a quiet Huge world) and the heat pass. A loaded world starts around
the bot first (Huge, first 10 s: 15 -> 5.5 ms a tick, worst 80 -> 16 ms). Seven duplicates merged
and six large files split. Seeds make the same worlds as before. Please check: pour a pond off a
ledge; stand by a pile of ore on ash; load a Huge world and fly around in the first 20 s.
assignee: claude
---
## Context
- The code has grown fast. Clean it up and make it faster without changing how the game plays, and give the physics a full test pass, starting with water: water running off the edge of the world never seems to run out (it may only be slow; find out, and fix it if it really doesn't). (Merged from t24 water fixes.)
## Subtasks
- [x] Remove duplicated code and split the largest modules into smaller files
- [x] Profile the simulation and rendering on a Huge world and speed up the slowest parts
- [x] A full test pass over water physics in every situation (pools, flows, falls off the world edge, pumps and pipes, freezing and boiling); fix what is wrong
- [x] The same kind of pass over sand, molten metal and gases where tests are thin
- [x] Fix issues found along the way, and test every piece of gameplay the changes touch
+21
View File
@@ -0,0 +1,21 @@
---
id: t25
status: Done
order: 11
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: visual effect for vac/blow tool
report: |
(agent) While sucking, short teal streaks of air swirl in toward the nozzle ring; while blowing,
amber streaks run from the bot toward the cursor and widen to the nozzle width. They're on top
of the grains already flying in and out, and they turn off with "Sparks and debris" in
settings. Please check whether they're too faint, too busy or too fast.
---
## Subtasks
- [ ] add visual effects for suck and blow
- [ ]
- [x] While sucking, short teal streaks of air swirl in toward the nozzle ring
- [x] While blowing, amber streaks push out from the bot toward the cursor and spread into a cone
+18
View File
@@ -0,0 +1,18 @@
---
id: t26
status: Done
order: 10
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: rename and make name visible somewhere the vac/blow tool
report: |
(agent) The tool is now the Pneumatic nozzle. Its card starts with a title row (the name and the
nozzle width), and the hotbar tooltip, Welcome, milestones, notebook and empty-bag hint all use
the new name. Please check the wording reads well in the notebook and the Welcome card.
---
## Subtasks
- [x] The tool is called the Pneumatic nozzle in the hotbar tooltip and in all player text
- [x] Its tool card starts with a title row showing the name and the nozzle width
+22
View File
@@ -0,0 +1,22 @@
---
id: t27
status: Done
order: 8
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: add toast for vac/blow tool
report: |
(agent) Every tool's messages now show as toasts at the top of the screen; the red line in the
tool card is gone. Errors have a red edge, info messages a teal one. Holding a button that keeps
repeating a message (blowing with nothing selected, the Igniter out of reach) keeps one toast up
instead of piling them. Please check the toasts are readable and don't get in the way.
---
## Subtasks
- [ ] when blowing without any items selected there is a message in red in the box on the top left, instead move it to a toast.
- [ ] same for any other messages from the tool
- [ ] s
- [x] Every tool's messages show as toasts, and the red line in the tool card goes away
- [x] A message that repeats while a button is held refreshes one toast instead of stacking up
+24
View File
@@ -0,0 +1,24 @@
---
id: t28
title: Brick press for clay bricks
report: |
(agent) The Brick press ($60) is in the Machines tab next to the Saw. Every 8 clay lumps resting
on it become a Green brick that drops out underneath. Drop green bricks into a fire hotter than
about 900°C (blow them in, belt them in, or put the press over a fire) and they turn into Bricks.
2 Bricks build a brick wall block, they sell for about $9, and builders order them. Please try
pressing and firing a batch, and check that the press's moving plate reads well.
status: Done
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
order: 16
---
## Context
- Clay bricks should be made the same way as the Saw's stone blocks: a 1-block hopper piece takes grains on top and drops an item below. A Brick press squeezes clay lumps into green bricks, and like real bricks they then have to be fired to become Bricks.
## Subtasks
- [x] Brick press block: every 8 clay lumps resting on it make a Green brick that drops out underneath
- [x] A Green brick that gets hotter than about 900°C fires into a Brick
- [x] Bricks build brick wall blocks (2 per block) and sell; builders order them
+11
View File
@@ -0,0 +1,11 @@
---
id: t29
status: Done
order: 20
priority: P3
created: 2026-10-24
updated: 2026-10-24T23:25:51Z
title: add minimap
---

Some files were not shown because too many files have changed in this diff Show More