Compare commits

..
129 Commits
Author SHA1 Message Date
44r0n7andClaude Opus 5.5 fbc3b07be2 Phase 4: play-through checked, final profile, dead code removed, next-step recommendation (t88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:50:54 -04:00
44r0n7andClaude Opus 5.5 7468d00efb Multi-core simulation: grain movement and heat across a worker pool (t88 phase 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:23:43 -04:00
44r0n7andClaude Opus 5.5 20963c776e WebGPU renderer complete: dirty uploads, default limits, legacy removed, WebGPU required, warm-up (t88 phase 2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:17 -04:00
44r0n7andClaude Opus 5.5 452b6aed32 Smooth look on WebGPU: WGSL port fed by GPU inputs and CPU overrides (t88 phase 2c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:42:12 -04:00
44r0n7andClaude Opus 5.5 65054e691b Log t88 phase 2b: GPU lighting checks, profile, and the gate mistake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:06:39 -04:00
44r0n7andClaude Opus 5.5 31e3005816 Lighting on the GPU in the WebGPU path (t88 phase 2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:01:15 -04:00
44r0n7andClaude Opus 5.5 fc05146887 WebGPU pixel look: GPU colours the world, CPU only rare cells (t88 phase 2a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:49:36 -04:00
44r0n7andClaude Opus 5.5 b2c9ad9533 Served with isolation headers; start-screen capability notices; tests on the real GPU (t88 phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:11:42 -04:00
44r0n7andClaude Opus 5.5 9781aa3ee1 Profiler: real-GPU breakdown by zoom, look and scene, with a baseline (t88 phase 0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:31:43 -04:00
44r0n7andClaude Opus 5.5 0c0edf46ca Decision: WebGPU renderer and multi-core simulation in the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:04:58 -04:00
44r0n7andClaude Opus 5.5 0ad3561e6a Hide the system pointer over the game; the game draws its own aim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:43:49 -04:00
44r0n7andClaude Opus 5.5 f193b1c76c Identify ores with the cursor: pinged veins, ores-only label, vein outline (t77)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:43:49 -04:00
44r0n7andClaude Opus 5.5 f4ca1cae57 Ping: veins fade outward in the order found, faster; recharge arc with the drill (t75)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:23:25 -04:00
44r0n7andClaude Opus 5.5 29eee51e35 Ping: 10 s recharge with a panel chip, natural ore colours with a rim, glints (t75)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:12:09 -04:00
44r0n7andClaude Opus 5.5 a671dcb59a Bag: rock goes in as rubble, older saves convert, list shows everything held
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:53:40 -04:00
44r0n7andClaude Opus 5.5 569ec6e5b6 Resonance ping: right click with the drill lights up nearby veins through fog (t50)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:47:35 -04:00
44r0n7andClaude Opus 5.5 ef4ba6d24a Build range follows the screen; out-of-range replace ghosts replace (t64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:41:32 -04:00
44r0n7andClaude Opus 5.5 6bc4fd5928 Decision: resonance pylon built in metal, reach from power; waits for power (t47)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:31:46 -04:00
44r0n7andClaude Opus 5.5 9fc1b92080 Throughput in the info overlay: grains per second and backing-up glow (t49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:21:48 -04:00
44r0n7andClaude Opus 5.5 54e7756ead Cooled magma is ordinary rock: visible in the smooth look and drillable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:43:20 -04:00
44r0n7andClaude Opus 5.5 ccca66c0c1 "Doesn't connect" marks on transport ends that would join one block off (t67 phase 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:23:08 -04:00
44r0n7andClaude Opus 5.5 99d4889f1e Decision: hot loads stay a hazard but read clearly (t69, t70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:17:42 -04:00
44r0n7andClaude Opus 5.5 72efe85a1b Shared drag placement; drags snap one block to join a diagonal (t67 phases 3-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:08:37 -04:00
44r0n7andClaude Opus 5.5 c3f9387b24 Decision: how the player finds the warehouses (t68)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:59:40 -04:00
44r0n7andClaude Opus 5.5 a0f254fdeb Log t66
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:35:00 -04:00
44r0n7andClaude Opus 5.5 dd8068dfe6 Dev console: money, items, upgrades, free build, speed (t66)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:30:37 -04:00
44r0n7andClaude Opus 5.5 02e338a46f Same-row belt feed into an uphill low end draws as two capped ends (t67)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:30:02 -04:00
44r0n7andClaude Opus 5.5 45538cc18e Joins follow the travel direction: the next piece goes right after the end (t67 phase 2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:09:28 -04:00
44r0n7andClaude Opus 5.5 fd35bf334f Screw conveyors join by the shared rule: low ends and verticals join in the corner block (t67 phase 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:30:44 -04:00
44r0n7andClaude Opus 5.5 198bea54da Log t63
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:05:06 -04:00
44r0n7andClaude Opus 5.5 30c811ea80 Vacuum picks up grains in mid-air (t63)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:52 -04:00
44r0n7andClaude Opus 5.5 65338b2b27 Shared join module for transport pieces; belt drawing uses it (t67 phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:43 -04:00
44r0n7andClaude Opus 5.5 787721ad35 Corner placements of screw conveyors stay separate ends (t62 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:32:23 -04:00
44r0n7andClaude Opus 5.5 ff9fd7b1b8 Screw conveyors join across the corner to diagonals (t62)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:12:44 -04:00
44r0n7andClaude Opus 5.5 828c71487c Decision: dev console ships in every build, marking the save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:35:41 -04:00
44r0n7andClaude Opus 5.5 9ff9013c68 Log t66 dev console task
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:32:40 -04:00
44r0n7andClaude Opus 5.5 552b9b6480 Log: old saves don't need to keep loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:31:35 -04:00
44r0n7andClaude Opus 5.5 155708b41d Log t65 Rust conversion task
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:29:49 -04:00
44r0n7andClaude Opus 5.5 18528a7f73 Shift-drag draws a line for belts and other transport pieces
Belts, filters, screw conveyors, shafts and pipe pieces never fill a rectangle;
a diagonal belt drag stays sloped with Shift held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:27:06 -04:00
44r0n7andClaude Opus 5.5 664e086969 Sticky materials slide freely on built surfaces (t61)
Stickiness gated every diagonal step, so clay lumps, snow, wood chunks,
ash and dirt crawled down sloped walls (a clay grain never got down a
20-piece slope). It now applies only grain against grain: a grain
resting on anything built always tries its diagonal step, so chutes
carry every material while heaps keep their shape. The random draw is
unchanged, so generated worlds stay the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:11:05 -04:00
44r0n7andClaude Opus 5.5 8ce71f478a Removing slopes no longer leaves unowned seal cells; loading clears them
A slope's seal can sit in a cell its neighbour leaves open (a slope's
open corner, or under a grate, screw or screen), but removal only
cleared it when the neighbour was a sloped wall, so cutting a sloped
belt next to a sloped filter or grate could leave a one-cell belt that
nothing owned and nothing could remove. Removal now asks whether the
neighbour fills that cell itself, and loading a world clears belt and
screen cells that no block or seal owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:00:15 -04:00
44r0n7andClaude Opus 5.5 3cb0182ac7 Decision: build range about the size of the screen (t64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:50:43 -04:00
44r0n7andClaude Opus 5.5 be93ad1251 Downhill sloped belts draw as plain belts, ghosts and previews too
Downhill runs have no cleats, so they no longer draw the sidewall or
ribs either: placed, as ghosts, or in the drag preview, which now knows
the belt's direction. Uphill runs keep the wall and ribs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:46:34 -04:00
44r0n7andClaude Opus 5.5 2e5ad0f1a1 Uphill runs share one cleat phase; no cleats downhill; pick slope bands
A piece added to a running sloped belt kept its own cleat phase, so at
the seam a rib could start just behind another and push its pocket into
it, stalling the whole run with only a few grains. Every block of a run
now takes the phase of the run's lower-left block.

Downhill sloped belts no longer have cleats: they carry like a flat belt
(90 grains/s, was 38) and draw only the sidewall.

Middle click on a sloped belt's band, where it is drawn into the empty
block beside it, now picks the belt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:31:01 -04:00
44r0n7andClaude Opus 5.5 8cdc47fd33 Cleats really hold and push their pockets (t42 feedback)
Nothing carries the load on a slope but the ribs: each belt step every
rib moves one face cell and pushes the grains in its way, the layer on
the belt is only dragged by friction that gravity beats on 45 degrees,
and heaped grains roll back. Ribs never pass through grains or into
machines; a run that can't push stalls. The foot of an uphill run
scoops the feed into pockets. Ribs drawn as rubber bars in both looks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:56:51 -04:00
44r0n7andClaude Opus 5.5 3ee85429fd Sidewall belt and screen drawing, cheaper cleat checks (t42, t43)
Far sidewall drawn behind the load with cleat ribs at their physics
positions; sloped screens get a tinted mesh between rails and shake only
when loaded. Powder skips cleat checks outside chunks that have cleats,
and empty slopes let their chunks sleep. Record the screw conveyor and
stall/carry-depth decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:08:21 -04:00
44r0n7andClaude Opus 5.5 578b55316b Sloped belts hold their load with moving cleats (t42); sloped filters are downhill shaking screens (t43)
The magic grip is gone: cleats every 8 face cells, 6 grains tall, carry
pockets uphill (about 71 grains/s against 90 flat) and overflow rolls
back. Sloped filters always run downhill; grains travel by gravity and
a shake, and sorting stays exact. Drawing needs another pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:47:59 -04:00
44r0n7andClaude Opus 5.5 1f8a89db6a Rename screw tubes to screw conveyors (t44)
Player text says Screw conveyor; code uses screw (BP_SCREW, piece id
screw, screwIntake...). No compatibility code for old saves or
blueprints. Suite outputs match before and after apart from names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:59:13 -04:00
44r0n7andClaude Opus 5.5 5e7069d637 Decisions list: newest at the bottom; no backward compatibility, no uphill filters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:40:34 -04:00
44r0n7andClaude Opus 5.5 b74a9173eb Silo contents at a glance (t38); drill aimed past its reach stays calm (t41)
Silos show a wide fill window in the material's colour in both looks,
and the cursor label names the contents anywhere over the outline.
Aiming the drill beyond its reach keeps normal colours, marks where the
pulse stops, and drops the red circle and the out-of-reach toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:40:05 -04:00
44r0n7andClaude Opus 5.5 9bc5a6c3af Square wall corners hold grains, liquids and gas (t39)
Powder, liquid and gas no longer step diagonally between two solid
cells; flyers and diagonal tube intakes obey the same corner. World
generation settles with the rule off, so seeds make the same worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:27:10 -04:00
44r0n7andClaude Opus 5.5 0cf615eb3d Sloped belts and filters drawn as one belt turned to 45° (t40)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:50:45 -04:00
44r0n7andClaude Opus 5.5 e19cb784d2 Sloped belts and filters, laid by a 45° drag (t40)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:17:55 -04:00
44r0n7andClaude Opus 5.5 24eb9559f8 Diagonal tubes as thick as straight ones, with centred elbows where they join (t35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:52 -04:00
44r0n7andClaude Opus 5.5 814b51df46 Wall drags at 45° lay slopes; diagonal tubes drawn as one pipe; no side arrow on drag ghosts (t35, t36)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:20:48 -04:00
44r0n7andClaude Opus 5.5 f19facbe5c Grains resting on anything built cast no shadow (t37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:21:31 -04:00
44r0n7andClaude Opus 5.5 7d1be16094 Falling grains cast no shadow (t37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:11:57 -04:00
44r0n7andClaude Opus 5.5 f744d786f1 Grains riding a belt or filter cast no shadow (t37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:04:44 -04:00
44r0n7andClaude Opus 5.5 8087c28cb5 Diagonal screw tubes, laid by a 45° drag (t35)
- Tubes run in eight directions: a drag at about 45° lays a diagonal run, R cycles all eight.
- Diagonal runs carry grains at the straight rate (120 grains/s), join straight tubes and belts,
  keep grains in at the corners, pass water, and carry bars level.
- New part ids 11-14 (saved ids are never renumbered); tube checks use an IS_TUBE table.
- New test/t_diag_tubes.js (85 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:37:30 -04:00
44r0n7andClaude Opus 5.5 381019b7aa Sloped walls with a flat 45° face, and diagonal wall lines (t36)
- New Sloped wall piece: a half-block triangle in any wall material, at half a wall's cost.
  Dragging walls or slopes at about 45° lays a diagonal line; the drag picks the slope's
  orientation and T flips or turns it.
- Each slope fills one cell past its corner, so a diagonal run is sealed against grains and water.
- New test/t_slopes.js: a slope funnel passes 400/400 sand (square steps 379/400).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:17:51 -04:00
44r0n7andClaude Opus 5.5 1a300bf744 Loose grains and solid ground look different: a seam on the rock, pebbly lighter grains (t33)
- Smooth look: natural ground gets a soft seam where loose grains meet it; packed grains keep a
  pebble texture and are slightly lighter. Pixel look and thermal lens: the same idea per pixel.
- play.py: the F-on-a-mold toast check records toasts the key press makes instead of counting
  the ones on screen, which failed when an older toast expired at the same moment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:59:54 -04:00
44r0n7andClaude Opus 5.5 20d0627d75 Buildings cast no shadows: daylight passes player-built pieces (t37)
The lighting finds its own roof per column, skipping walls, belts, tubes, machines and molds,
and lets light through them like air, in both looks. Natural rock still shades; the
simulation's roof (steam condensing under a built roof) is unchanged. New test/t_light.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:42:07 -04:00
44r0n7andClaude Opus 5.5 5ce44f842e The nozzle blows no further than it sucks (t34)
With the cursor outside the reach circle, blown grains aim at the circle's edge in the cursor's
direction instead of flying all the way to the cursor, and the blow preview stops there too.
New test/t_nozzle.js and a play.py preview check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:35:38 -04:00
44r0n7andClaude Opus 5.5 64ff8310f3 Keep the task board, notes and agent instructions out of the repo
They stay on the owner's machine: .kandown/, tasks/, docs/kandown.md, NOTES.md, CLAUDE.md and
AGENTS.md are ignored, and the README and project map say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:59:24 -04:00
44r0n7andClaude Opus 5.5 0a8847594e 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 7fa1056e09 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 9937683a2c 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 cce81e4eca 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 9be04471ee 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 ea1dd387b1 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 99d04b4972 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 f34c34aa73 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 d2eb388729 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 c8d397e19a 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 945e5205b8 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 66543bbd4b 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 4d5fca6dae 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 c302b0cf42 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 28893c93bc 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 282e9ce9ab 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 06f8f90f19 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 9682cab6d4 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 c7256557bb 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 b76f4516b7 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 94891db349 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 f4eddfb82b 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 8d9e9c546b 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 01403bb95e 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 c95cc48340 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 67b6aba6eb 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 196ce5ce62 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 d643718c48 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 2cf9cb843c 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 c1a085ebb1 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 14194cb4be 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 a18dcda8a4 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 1f56d27ded 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 ec687b8c6c 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 209d065f4e 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 aa46406c92 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 255824ec6d 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 cc6f98a932 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 152a2af6df 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 a730a7d73c 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 f3cedcacc8 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 36f82712ab 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 41663761fb 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 f822db8c50 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 11f6357c09 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 dc873ecb8b 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 9df161046d 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 9340bc132b 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 ddfe78cf01 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 c50444b333 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 daf91891fc 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 92f33b11c9 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 b70bb1f640 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 2b68b5d583 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 10911d4c86 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 a15af6ef9f 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 9bbddc1e20 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 f953a9cc51 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 97c5ca4668 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 fec4f3bfe1 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 caa2463fda 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 de677b49c5 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 6676d99c66 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 ce7df82b01 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
44r0n7andClaude Opus 5.5 b270c4e818 Deepgrain Foundry: move from a claude.ai artifact to a local repository
The game as of artifact version 24, ready for local development with
coding agents.

- Source split from three large files into 52 small ones under src/sim,
  src/render and src/ui, joined in the order in src/manifest.json by
  build.py. The joined code is unchanged apart from comments; every
  headless suite gives the same output, and screenshots in both looks
  are pixel-identical to before.
- Light tidying with no change in behaviour: one movement scan loop,
  shared hazard-stripe and belt shader helpers, unused code removed, a
  shared check helper for the test suites.
- World files can be exported as an ordinary download outside claude.ai.
- Tests: broken old scripts removed, experiments moved to tools/,
  test/run_all.py added. Tools: shot.py (repeatable screenshots),
  where.py, map.py.
- Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox),
  docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the
  owner's requests and an agent log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
2026-10-23 16:05:31 -04:00
224 changed files with 96751 additions and 2929 deletions
+7 -2
View File
@@ -6,5 +6,10 @@ node_modules/
.DS_Store
.venv/
# kandown web app: one 6.5 MB file that `kandown init` / `kandown update` regenerate
.kandown/kandown.html
# kept on this machine only: the kandown task board, the old notes inbox and agent instructions
.kandown/
tasks/
docs/kandown.md
NOTES.md
CLAUDE.md
AGENTS.md
-9
View File
@@ -1,9 +0,0 @@
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
@@ -1,56 +0,0 @@
# 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
@@ -1,74 +0,0 @@
{
"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
@@ -1,107 +0,0 @@
{
"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
@@ -1,30 +0,0 @@
## 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`.
-180
View File
@@ -1,180 +0,0 @@
# Deepgrain Foundry: instructions for coding agents
Deepgrain Foundry is a 2D falling-sand factory game in the style of Sandustry, set in a
Terraria-like side-view world. Every grain of sand, ore, water and fire is simulated. The player
flies a small bot, digs with a resonance drill, vacuums up loose material, builds furnaces,
belts, tubes and machines from blocks, casts metal in molds and sells goods at a trade dock to
pay off a land claim. It runs in the browser as one self-contained HTML page, built from small
JavaScript files with no dependencies.
The owner (the user) directs the work and is the real playtester. You build, test and report.
## Start of every session
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
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
were made deliberately and are recorded there with the reason.
4. Look at the top of `docs/agent-log.md` for what was done recently.
## Honesty and accuracy
These rules matter more than speed.
- **Do not guess or assume.** If you have not checked something, say so. Keep "I tested this and
it works" separate from "this should work".
- **Prove it with the game.** Run the tests. For anything visible, take a screenshot and look at
it before saying it looks right (see "Testing"). A passing test only covers what it checks.
- **Never invent names.** Look a function or variable up in the project map's index, or search
for it (`rg name src`), before you use it or talk about it.
- **When you are not confident, check more than one source.** Use the web for anything outside
this repository: browser APIs, Playwright, WebGL, real-world materials and temperatures,
physics, how other games do something. Confirm important facts with at least two independent
sources, and give the links.
- **Report problems plainly.** Say when a test fails, when something is only partly done, or when
you could not reproduce a problem. Never weaken or delete a test just to make it pass. If a
test's expectation is outdated because the design changed on purpose, update it and say why.
- **Ask when it matters.** If a request is unclear and getting it wrong would waste a lot of work,
ask first. If the choice is small or easy to undo, take the sensible reading, say which one you
took, and carry on.
## Working from the task board
The user's to-do list is a kandown board: one Markdown file per task in `tasks/`. **Read
`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:
- Work on tasks only when asked. Todo tasks are built; Backlog tasks tagged `discussion` get
options with trade-offs and a recommendation, and nothing is built until the user decides.
- Never reword the user's words in a task's `## Context`. Add your notes as subtasks with
`report:` lines, or in the `report` field.
- When a task is finished, move it to **Review** with a `report` saying what changed and how to
check it in the game. Only the user moves tasks to Done.
- **Log your work.** Add an entry at the top of `docs/agent-log.md` with the date, the task,
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
decisions list in `docs/DESIGN.md` with the date and the reason they gave.
## Commands
```sh
python3 build.py # build dist/index.html from src/ (takes a second)
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 pipes # only suites whose name contains "pipes"
python3 test/run_all.py --browser # also the browser tests (rebuilds first; about 3 more minutes)
node test/t_pipes.js # one suite
python3 tools/shot.py shots/x.png --setup tools/scenes/clear-sky.js --js "..." --ticks 60
python3 tools/where.py 7614 # which source line is line 7614 of dist/index.html
python3 tools/map.py # regenerate the index in docs/PROJECT_MAP.md
```
You need Node (tested with 22) and Python 3. Playwright for Python and its Chromium live in
`.venv/` in the repo (not committed); set up as in README.md → Develop. The browser scripts and
`tools/shot.py` switch to `.venv` by themselves (`tools/use_venv.py`), so the commands above work
with plain `python3`.
## How the code is put together
- `build.py` joins the files listed in `src/manifest.json`, in order, into one classic script
inside `src/shell.html`. The output is `dist/index.html`. There are no modules and no
import/export: every file shares one global scope.
- **Order matters only for code that runs when the page loads.** A function can call a function
from any file. But a top-level `const`/`let` must come before top-level code that uses it. New
files go into the manifest in the right place: simulation first, then render, then UI.
- `'use strict'` at the top of `src/sim/base.js` applies to the whole bundle.
- **`src/sim/` must never touch the DOM** (no `document`, `window`, canvas or `localStorage`).
The headless tests load only these files, and keeping the simulation pure keeps a later port to
Rust or WebAssembly possible.
- **Keep files small and on one topic.** Aim for under about 400 lines. Split a file that grows
past that along a clear seam, add the new file to the manifest, and rebuild. Then run
`python3 tools/map.py` and update the hand-written part of the project map.
- **Errors point at real files.** In Node tests, stack traces name the source file and line. In
the browser they point into `dist/index.html`; use `tools/where.py` to find the source line
(`tools/shot.py` does this for you).
## Rules for the simulation
- **Performance.** The simulation runs 60 ticks a second over millions of cells.
- Use the existing typed arrays.
- Do not allocate objects or arrays inside per-cell loops.
- Only chunks that are awake (32×32 grains) are simulated. After changing cells directly,
wake them; `setCell` already does.
- Measure before and after anything in a hot path (`tools/perf.py`,
`tools/experiments/factory_cost.js`).
- **Determinism.** Randomness in `src/sim/` comes only from `rnd()` (seeded) or `mulberry32`.
Never use `Math.random` there. The headless tests rely on this: the same code gives the same
output, line for line.
- **Save compatibility.** The user has saved worlds they care about. Material ids, block codes
(`BP_*`), part ids (`P_*`) and machine type names are stored in saves.
- Never renumber or reuse them. Add new ones at the end (raise `NMAT` for materials).
- New saved fields need a default when an older save lacks them. Migrations go in
`deserializeWorld` (`src/sim/save.js`).
- Only raise `SAVE_VERSION` if old saves truly cannot be read any more, and tell the user first.
- **Undo.** Every building action the player takes must be undoable. Wrap it in
`undoRecord(label, fn)`. The functions that place and remove things record their own steps
with `undoNote`. The bot building ghosts is never an undo step of its own.
## Checklists
**A new material.** It needs:
- an id constant in `src/sim/materials.js` (at the end, before `NMAT`, and raise `NMAT`);
- a `def(...)` entry with a plain description;
- its price in `PRICE` (`src/sim/economy.js`) if it can be sold;
- its place in `BAG_ORDER` (`src/ui/state.js`) if it can go in the bag;
- its place in `MARKET_GROUPS` (`src/ui/notebook.js`) if it is sold;
- any special tables it belongs to: `CASTMETAL`, `SOLIDMETAL`, `METAL_TIER`, `BAR_OF`,
`WALL_OF`/`WALL_LIST`, `REFINE`, `HOTLIQ`, `DROSS`.
Check its colour in both looks.
**A new buildable piece.** It needs:
- an entry in `PIECES` (`src/sim/pieces.js`) with cost and description;
- a block code or machine footprint;
- placing and removing that support undo and Ctrl-replace;
- a tile in `BUILD_CATS` and an icon in `PIECE_ICONS` (`src/ui/build-menu.js`);
- hover info (`hoverInfo` in `src/ui/info.js`);
- drawing in `src/render/` (pixel look, and a smooth-look class if it needs one);
- saving and loading of any state it keeps;
- a line in the controls help (`controlsHtml` in `src/ui/notebook.js`) if it adds controls;
- a headless test.
**Text the player reads** (descriptions, toasts, hover info, help):
- plain words and short sentences;
- what it does and how to use it;
- °C for temperatures, "grains" for cells, "blocks" for the 4×4 build grid;
- no programmer words.
Match the existing descriptions in `PIECES` and the material `def`s.
## Testing
`docs/TESTING.md` has the details: the harness, how to write a suite, the browser tests,
screenshots and the pitfalls. The essentials:
- Every change to the simulation gets a headless check in a `test/t_*.js` suite. Write the
check first when fixing a bug, and see it fail.
- **Test in the pixel look** (the default in the tools). Use the smooth look only when checking
graphics, and then check both looks.
- Look at your screenshots. Open the PNG and check it shows what you meant, especially after
changing anything drawn.
- Before you say you are finished, run `python3 test/run_all.py`. After changing anything in
`src/ui/` or `src/render/`, run `python3 test/run_all.py --browser` too.
- **For a refactor** that should not change behaviour, save the suites' output before and after
and diff them. The headless suites are deterministic, so any difference is a real change (lines
with timings in ms excepted).
## Git
- Small commits, one change each, with a message that says what changed for the player or
developer. Run the tests first.
- `dist/` and `shots/` are build output and are not committed.
- 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,
this section will say how they coordinate.
This project uses Kandown. Before task work, run `kandown work` and follow its output. <!-- kandown:agent-ref -->
-10
View File
@@ -1,10 +0,0 @@
@AGENTS.md
## Claude Code notes
- 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.
- Use WebSearch and WebFetch for anything outside this repository, per the honesty rules 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.
+35 -9
View File
@@ -2,10 +2,14 @@
A falling-sand factory game in the browser, in the style of Sandustry.
WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU;
there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our
checks, show a notice on the start screen. Serve the page over HTTP as described below.
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
other goods at a trade dock, automating with belts, screw tubes, filters, fans, pipes and
other goods at a trade dock, automating with belts, screw conveyors, filters, fans, pipes and
machines, until the land claim is paid off and beyond.
## Play
@@ -15,8 +19,32 @@ python3 build.py
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
downloads; sound and graphics are all generated in code. Saves stay in your browser.
The game requires WebGPU and cross-origin isolation for shared-memory CPU workers.
Grains and heat run across up to 16 helpers (two CPU cores are reserved); Settings →
Simulation → CPU workers caps the pool on the next world load. Small jobs run inline.
The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
Use HTTPS when hosting remotely (localhost works over HTTP).
nginx, in the site's `server` or `location` block:
```nginx
add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;
```
Caddy, in the site block alongside `root * /path/to/dist` and `file_server`:
```caddyfile
header {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
```
These are plain static files, so a small server is enough. The game is one self-contained page
with no downloads; sound and graphics are generated in code. Saves stay in your browser.
- To keep or move a world, use Pause → Export world file.
- To load one, use Settings → Load a world file on the title screen, or Pause → Import world file
during a game. World files exported from the claude.ai version load here too.
@@ -31,16 +59,17 @@ The controls are in the in-game notebook (N) under Controls. The basics:
## Develop
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.
The golden-image tests also use ImageMagick (`magick`):
```sh
uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m playwright install chromium
.venv/bin/python -m playwright install chromium firefox
```
If uv is not installed, `python3 -m venv .venv` and
`.venv/bin/pip install -r requirements.txt` also work; then install Chromium as above.
`.venv/bin/pip install -r requirements.txt` also work; then install the browsers as above.
After that, plain `python3` works for every command below; the browser scripts switch to
`.venv` on their own.
@@ -60,9 +89,6 @@ The source is small JavaScript files with no dependencies or bundler:
## Documents
- `AGENTS.md` / `CLAUDE.md`: how coding agents work on this project.
- `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/DESIGN.md`: what the game is meant to be, and the decisions made.
- `docs/TESTING.md`: tests, screenshots and pitfalls.
+6 -2
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.
Writes:
dist/index.html the full page: open it, or serve with python3 tools/serve.py
dist/index.html the full page: serve with python3 tools/serve.py
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
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
@@ -35,9 +35,13 @@ def main():
shell = (src / man['shell']).read_text()
css, _ = join(man['css'])
js, starts = join(man['sim'] + man['client'])
sim, _ = join(man['sim'])
# Keep worker code outside the indexed script so source line mappings stay exact.
worker = '<script>const SIM_WORKER_SOURCE=' + json.dumps(sim).replace('</', '<\\/') + ';</script>'
head, tail = shell.split('//__JS__')
body = head.replace('/*__CSS__*/', css)
page = body + js + tail
page = worker + page
pre = ('<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
'</head><body>')
@@ -46,7 +50,7 @@ def main():
(dist / 'deepgrain-foundry.html').write_text(page)
(dist / 'index.html').write_text(full)
# the script starts on this line of index.html (the doctype prefix has no newlines)
js_line = body.count('\n') + 1
js_line = worker.count('\n') + body.count('\n') + 1
(dist / 'bundle-lines.json').write_text(json.dumps(
{'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n')
print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
+181 -9
View File
@@ -49,7 +49,7 @@ decides something new, add it under "Decisions" with the date.
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
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 conveyors, filters, fans, pipes and machines. Pay off the claim.
## Systems in brief
@@ -74,7 +74,7 @@ Details and numbers live in the code and in `docs/systems/`.
- **Drill:** see `docs/systems/dig-tools.md`. Pulse widths, tuning to one material, hardness by
bit tier.
- **Building:** a 4×4-grain block grid.
- Blocks: walls of any of several materials, belts, screw tubes, filters, grates, gates,
- Blocks: walls of any of several materials, belts, screw conveyors, filters, grates, gates,
pipes, pumps, outlets.
- Machines: fan, Refiner, Stone cutter, Silo.
- Molds: ingot, bit, tank. Ingot bars drop out of the bottom onto whatever is under the mold;
@@ -140,7 +140,7 @@ above, including:
- **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 conveyors carry no more than belts:** they move every other tick. For more throughput,
build more lines.
- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. If a grain is blocked,
the load above it stands still and the belt slips underneath; the top no longer slides over
@@ -177,7 +177,7 @@ above, including:
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.
- **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 screw conveyors 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
@@ -190,14 +190,14 @@ above, including:
- **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
- **Screw conveyors use all their lanes.** Grains in an up or down screw conveyor 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.
- **Screw conveyors carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
bar into a screw conveyor's side, it rides through corners and leaves the mouth. The owner asked how bars
should be moved and chose screw conveyors 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.
@@ -214,7 +214,7 @@ above, including:
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
a belt or screw conveyor, 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
@@ -364,6 +364,178 @@ above, including:
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.
- **Sloped walls and diagonal wall lines (t36, 2026-10-25).** The owner wanted funnel walls that
material slides down instead of catching on block steps. A Sloped wall is a half-block triangle
with a flat 45° face, in four orientations, built of any wall material at half a wall block's
cost. Dragging walls or slopes at about 45° lays a diagonal line; for slopes the drag direction
picks the orientation (solid side below) and T flips it, or turns a single piece. Each slope
also fills one cell past its corner so a diagonal run is sealed (grains move diagonally and
would otherwise slip through where two pieces meet). Square walls placed diagonally still
touch only at corners and leak there, like any square blocks.
Later the same day the owner asked for the Wall piece itself to work diagonally, the way screw conveyors
do, so the separate Sloped wall piece left the build menu: a wall drag at about 45° lays slopes
(solid side below, T flips it during the drag), straight drags and clicks lay squares, and for a
single block T cycles square and the four corners. Slope ids are unchanged, so saves still load.
- **Diagonal screw conveyors (t35, 2026-10-25).** Screw conveyors run in eight directions. A drag at about 45°
lays a diagonal run pointing along the drag, and R cycles all eight for a single screw conveyor. Diagonal
runs carry grains at the same rate as straight ones, join straight screw conveyors and belts, and carry
bars level, a block every eight ticks. They use new part ids (11-14) because part ids are saved.
- **Sloped belts and filters (t40, 2026-10-25).** The owner asked for belts, then filters, to work
diagonally like screw conveyors and walls. A Belt or Filter drag at about 45° lays slope-shaped blocks
whose face is a running belt, carrying grains and bars up or down in the drag's direction at
flat-belt speed; the face grips its load, so nothing slides back. A sloped filter drops what it
passes out underneath and carries the rest. New block codes (19-22), so saves still load.
- **Square corners hold grains (t39, 2026-10-26).** The owner wants square wall staircases to
hold material instead of leaking through each touching corner. Powder, liquid and gas cannot
step diagonally between two solid neighbours; powder neighbours still allow free piles to
slide as before. Free flyers and diagonal screw conveyor intakes obey the same solid-corner boundary.
Screw lane transport is unchanged. World generation disables cell corner sealing while
settling loose ground, preserving existing seed layouts. This supersedes the square-wall
leakage described in the t36 decision below.
- **Silo contents are visible without opening the gate (t38, 2026-10-26).** The owner wants
contents beside the cursor anywhere over the outline and a material-coloured fill window at
every zoom in both looks, while keeping the real steel silo shape.
- **Distant drill aim is normal (t41, 2026-10-26).** The owner normally points beyond the
drill's reach and expects players to do the same. Keep the pulse colour and light crosshair,
mark the pulse's far edge, and omit distance warnings for the drill. Other tools still warn.
- **Screw conveyors (t44, 2026-10-26).** The owner chose the real machine name for screw
conveyors; code uses `screw` because `conveyor` also covers belts and filters. Transport,
numeric ids and save versions stay unchanged.
- **No backward compatibility for now (2026-10-26).** The owner will start a new world after
changes: no one is playing the game for real yet. Changes may break old saves and blueprints;
don't add migration or alias code for them (first used by the t44 rename to screw conveyors).
- **No uphill sloped filters (t43, 2026-10-26).** The owner wants sloped belts and filters to be
realistic in how they look and behave. Real sorting screens run flat or tilted downhill and
shake; none climbs a 45° slope. So filters are flat or downhill
only; to sort while lifting, run a sloped belt up, then a filter. Not built yet.
- **Conveyor drawing and sleep follow-up (t42/t43, 2026-10-26).** The owner wants visible
rubber ribs on the actual cleat raster, filled corrugated far walls behind the load, and
smooth continuous mesh with rails for inclined screens. Walls stop at the slope ends;
only loaded screens shake. Empty moving cleats must let chunks sleep. Keep pocket transport,
uphill throughput near 71 grains/s and exact sorting unchanged.
- **Sidewall belts with moving cleats (t42, 2026-10-26; physics replaced by the entry below).** To replace the old full-column
grip, use moving barrier lines rather than material cells: the barrier mask blocks normal
powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular
to the face, spaced 8 horizontal face cells apart and 6 grains tall (integer normal reach
4); named constants keep these tunable. A finite periodic pocket envelope carries only
what fits, stepping every other tick. End pockets continue while the cleat rounds the band.
Between steps no grains are stamped: bare face slides and excess rolls back. Water drains;
bars stay level. Dark corrugated far walls sit behind visible grains; drawing uses the
actual barrier mask, including after save/load. The envelope is a discrete transport model,
not a calculation of contact forces. A generous flat feed delivers about 71 grains/s uphill
versus 90 flat; accepting that reduction remains the owner's playtest decision.
- **Inclined vibrating screens built (t43, 2026-10-26).** Diagonal filters always travel
downhill, even when dragged uphill. R leaves a picked slope unchanged; flipping a blueprint
mirrors its slope and keeps downhill travel. Placement and filter settings enforce the
same direction. Sloped filters have neither column carry nor grip: gravity moves grains,
with one mesh-contact nudge every 4 ticks (15 downhill steps/s plus gravity). Unselected
bars step on that same cadence; selected bars fall through. Flat filters retain their
linear transport. Sorting both slope shapes in either mode conserves the complete mix.
Both looks use a sturdy mesh frame with a small tick-based shake only while loaded;
idle mesh has no running belt animation. No old uphill-save migration was added.
- **Screw conveyors stay, in every direction (2026-10-26).** The owner asked whether screw
conveyors are still needed now that belts climb 45°, whether they're realistic, and what a
downward screw is for. Kept as they are: real plants use both (screws for steep, vertical and
enclosed runs; belts for long, cheap, high-volume ones), and a downward screw is the same
machine turning the other way, useful for letting material down at a steady rate.
- **Stalls are the player's to fix, but visible; belts carry a realistic depth (t45,
2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner
agreed that's realistic and stays the player's problem, but wants a warning on the stalled
belt and the animation stopped on everything affected, and belts limited to a realistic
load depth instead of carrying 96-grain stacks. Not built yet.
- **Cleats really push their pockets (t42 feedback, 2026-10-26).** The owner found the first
cleats passed through heavy loads while the material climbed as before, and wants cleats that
hold the material and drag it up. Now nothing carries the load on a slope but the ribs and a
friction drag on the layer touching the belt (which gravity beats on 45°, so loose grains slide
back until a rib holds them). Each belt step every rib moves one face cell and pushes the
grains in its way ahead of it; whatever is heaped above the rib is left to roll back. A rib
never moves into a grain, never crosses a machine, and stops short of anything built. If any
rib of a run can't push its way, the whole run stalls that step and every move is undone (the
stall t45 will show). At the foot of an uphill run, each rib coming up around the end roller
scoops what lies on the feeding belt behind it into its pocket, like a real boot section.
Measured: about 80-86 grains/s uphill under a heavy feed (flat 90); downhill about 38, since
the top only feeds a layer two grains deep onto the face.
- **No cleats on downhill belts (t42 feedback, 2026-10-26).** The owner asked why a downhill
belt has cleats: on 45° the load slides anyway. A downhill sloped belt is now a plain belt, with
no ribs and no sidewall, that carries at belt speed like a flat one (90 grains/s); only uphill
runs are sidewall belts. A whole run also shares one cleat phase, so pieces added later line up
with it.
- **Build range about the size of the screen (t64, 2026-10-27).** The owner asked whether
building needs a range limit at all, since the bot needs no arms to build. Agreed: keep a limit,
but make it about what's on screen, so anything the player can see and aim at builds directly
and anything beyond stays a blueprint built when the bot comes near. No limit at all would let
players build in places they've never been and link far claims instantly. Built: the range is
the visible view box, at least 64 grains from its centre and at most 200 when zoomed far out.
- **Sticky materials slide freely on built surfaces (t61, 2026-10-27).** Stickiness (STICK) is the
chance per tick a grain takes a diagonal step, which made clay lumps, snow, wood chunks, ash and
dirt creep down sloped walls (a single clay grain never got down a 20-piece slope). The owner
chose to make stickiness grain against grain only: on anything built, every material slides
freely, so slopes are useful for every material, while heaps keep their shape. Real chutes clog
with sticky material and need cleaning, but that is micromanagement the game doesn't need.
- **Belts and other transport pieces never fill a rectangle (2026-10-27).** The owner said Shift
doesn't need to fill a rectangle for belts, conveyors and the like. Belts, filters, screw
conveyors, shafts, pipes, pumps and outlets (`lineOnly()`) always draw a line, Shift or not, and
a diagonal belt drag stays sloped. Walls, grates and gates still Shift-fill.
- **The dev console ships in every build (t66, 2026-10-27).** The owner is happy for players to
experiment and play their own way, so the console isn't hidden behind a dev build. Turning it
on warns first that the save will be marked, and the mark stays with the world; any future
achievements won't unlock in a marked world. Besides money, items and upgrades it may have
game speed and other things just for fun.
- **One join rule for every transport piece: the next piece goes right after the end
(2026-10-27, replaces the t62 same-row rule).** The owner wants all pieces to place and join
the same way, so players know what to expect when building. Joins follow the direction the
material travels: a piece's outlet points at the block right after its end, which is the
next block along a straight piece, or the block diagonally past a diagonal's end. The next
piece joins when it starts in that block and carries the same general way. So a horizontal
then a diagonal (up or down) starts the diagonal in the next block on the same row; a
diagonal then a horizontal or vertical starts it diagonally past the diagonal's end; a
vertical going up then a diagonal starts the diagonal directly above. Any other placement is
two separate ends that pass nothing. Belts carry grains on top, so they bend the rule where
grains can't follow it: an uphill belt's top feeds a flat belt on the same row, and a flat
belt one block below an uphill belt's low end also feeds it. The owner first approved a rule
based only on shape, then found in testing that horizontal → uphill and vertical → diagonal
didn't join the way the other pieces did.
- **Warehouses replace the trade dock, eventually (t68, 2026-10-27).** The dock only exists to
get selling working for testing; the owner finds it unengaging. The plan: a warehouse at each
edge of the map, on indestructible ground, served by a road and trucks. Warehouses accept
loose grain as well as packaged goods (pallets, barrels or sacks from packaging machines).
Trucks matter to play, not just looks: what's waiting is collected when a truck comes, on its
schedule. Finding them: the player spawns a short walk from the left warehouse, which takes
loose and bulk goods at lower prices with frequent trucks; the right one pays more for
packaged goods and is the long-haul goal. Both always show on the minimap and full map with an
edge-of-screen arrow, the first-start card names both and suggests starting left, signposts
stand on indestructible plinths at spawn, and each warehouse is a landmark (crane or silo,
beacon at night) with its road running a little way inland.
- **Screws melting under hot loads stays, but must read clearly (t69, t70, 2026-10-27).** Hot
material still damages screw conveyors (above 1000°), but the owner wants the damage obvious
(a scorched or broken look, hover text, a first-time message), cheap to repair (build over it),
and warned about before it happens (a glow near the limit). A heat-resistant screw comes later
as an upgrade. Prompted by a long vertical screw that seemed to leak for no reason while it
carried hot rubble.
- **Melts that cool loose are ordinary rock (2026-10-27).** Magma poured from the bag became an
invisible, undrillable basalt the game treated as built. Now any melt except metal and glass
cools into natural rock wherever it came from, and any solid no block, mold or machine owns can
be drilled, so nothing can get permanently stuck in the world.
- **Resonance pylon: metal sets hardness, power sets reach (t47, 2026-10-27).** A pylon is built
in copper, iron or steel, which caps the hardest rock it can break (4, 5, 6), the same way
bits and tanks step up through metals. Power from the shaft network sets its radius and pulse
rate, up to a cap per metal; power never sets hardness, so it can't skip the steel bit. It is
tuned like the drill and never harms anything built. It waits until power is switched back on,
since today every machine runs without power.
- **Performance: WebGPU and all CPU cores, in the browser (2026-10-27).** Zoomed out, drawing
is CPU-bound (about 26 ns per visible grain) and the simulation runs on one core. The owner
chose: WebGPU only (no WebGL fallback), tested in Chrome and Firefox with a clear message when
it's missing; the game must be served with COOP/COEP headers (for SharedArrayBuffer), so
opening the file from disk is dropped; determinism is re-baselined once and becomes the same on
any number of cores; as fast as possible, measured at every step. Grain movement and heat go
across a worker pool first; moving the whole simulation into its own worker is decided after.
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
+271 -103
View File
@@ -8,19 +8,20 @@ top part by hand when the structure changes.
## Layout
```
AGENTS.md, CLAUDE.md instructions for coding agents
tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md)
AGENTS.md, CLAUDE.md instructions for coding agents (local only, not in git)
tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md; local only, not in git)
build.py joins src/ into dist/index.html (one self-contained page)
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/styles/style.css all styles
src/styles/ base styles and the dev console panel
src/sim/ the simulation. No DOM. The headless tests load only these files.
src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays
src/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
audio, the frame loop
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
t_smooth.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
t_smooth.py, t_dev_console.py, t_caps.py, firefox_smoke.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
browser.py (shared launch/GPU choice), caps.py (capability report),
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/
(measurements and reports)
@@ -29,6 +30,11 @@ docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per syste
dist/, shots/ build output and screenshots (not committed)
```
The dev console pairs `src/sim/devconsole.js` (headless command dispatch, mark, log and
runtime controls) with `src/ui/devconsole.js` (confirmation and bottom panel). Save metadata
keeps `dev` and the last 200 `devLog` entries; free building, speed and dev pause reset on load.
The frame loop applies speed and queued steps while keeping the per-frame tick limit.
## How it runs
- **One script.** All files share one global scope; there is no import/export.
@@ -44,7 +50,7 @@ dist/, shots/ build output and screenshots (not committed)
4. update the HUD.
- **One tick** (`simTick` in `src/sim/tick.js`), in this order:
- update the sky roof line (every 20 ticks);
- weather, machines (including belts and tubes), pipes, drill pulses;
- weather, machines (including belts and screw conveyors), pipes, drill pulses;
- bars (`entTick`) and grains in flight (`flyTick`);
- falling sand (`simMove`), then heat (`heatPass`);
- every so often: molds, milestones, plants, cold, tree support;
@@ -62,7 +68,7 @@ dist/, shots/ build output and screenshots (not committed)
- `temp`: °C;
- `flags`: `F_NAT` natural rock, `F_CAST`, `F_BURN` burning;
- `stamp`: moved this tick;
- `part`: which built part a cell belongs to (`P_*`: tube directions, grate, open gate, filter,
- `part`: which built part a cell belongs to (`P_*`: screw conveyor directions, grate, open gate, filter,
pipe...);
- `mach`: machine id;
- `digp`: drill damage;
@@ -81,7 +87,7 @@ dist/, shots/ build output and screenshots (not committed)
(open inside).
- `molds`;
- `ents` (bars, 4×2 grains, with their own physics);
- `flyers` (grains in the air: blown, off belt ends, out of tube mouths, rain);
- `flyers` (grains in the air: blown, off belt ends, out of screw conveyor mouths, rain);
- `pipeNets`;
- `ghostBlocks`, `ghostMachines`, `ghostMolds`.
- **Player:** `bot` (position), `bag` (count per material), `player` (bit and tank tier, lens,
@@ -118,6 +124,17 @@ dist/, shots/ build output and screenshots (not committed)
- kind `mold`.
`hidden` pieces are not in the menu. The menu's tabs and order are `BUILD_CATS` in
`src/ui/build-menu.js`.
- **Transport geometry:** `joins.js` owns travel-directed ends and mutual joins for belts,
filters and screws. `pieceEnds(code, v)` returns precomputed geometric left/right ends
(vertical screws: up/down), with travel and inlet/outlet roles. `endJoin(bx, by, endIndex,
planned = null)` returns `neighbourBlock * 2 + neighbourEnd`, or -1; `pieceJoins` returns
bits 0/1. Plans override world blocks, including the queried block. Outlets target the next
block along travel, with the belt surface exceptions. Inlet ties prefer same-shape travel,
then top-to-bottom, left-to-right; a join must be reciprocal. Both looks, ghosts, previews,
diagonal screw grain/bar transfers and straight screw entry into diagonals share this rule.
Flat-belt bar entry uses `entBeltScrewEnter` and the swept-path collision check. Straight
screw side-feeding into lifts remains independent. Edge elbows bend inside the
diagonal; diagonal outlets bend inside the receiving straight block.
- **Placing and removing:**
- `placeBlock` / `removeBlock` (`blocks.js`);
- `placeMachineAt` / `addMachine` / `removeMachine` (`actions.js`, `machines.js`);
@@ -132,17 +149,23 @@ dist/, shots/ build output and screenshots (not committed)
## Drawing
- **The pixel look:**
- `drawWorld` (`src/render/world.js`) fills one pixel per grain;
- `computeLight` (`light.js`) adds coloured light;
- overlays (machines, molds, the bot, rain, arrows) are drawn on the 2D canvas afterwards
(`machines.js`, `overlays.js`).
- **The smooth look** (`src/render/smooth.js`) takes the buffers `drawWorld` fills (colour and
a class per cell, the colour behind, trees, fields, light, glow) and shades them with WebGL2.
- The cell classes are set in `world.js` and read in the shader: 4 is a crisp built piece,
6 a wall tile, 7/8 a belt, 9–12 a screw tube, 13/14 a filter.
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test
tools set it).
Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars,
flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas.
- `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during
world loading, and handles one device recreation followed by an autosaved Reload notice.
- `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32
chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes
mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo.
- `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no
pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading.
- `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep.
- `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains,
field AO and glow; `gpu/surface.js` caches the parallax horizon.
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
smooth tags, ghosts and previews.
## The browser shell
@@ -166,12 +189,13 @@ dist/, shots/ build output and screenshots (not committed)
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
| Controls help text | `src/ui/notebook.js` (`controlsHtml`) |
| Cached vein membership, ore identification and label timing | `src/ui/cursor.js` |
| Hover info and discovery cards | `src/ui/info.js` |
| Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` |
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
| Falling sand, liquids, gases | `src/sim/cells.js` |
| Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` |
| Belts, filters, screw tubes | `src/sim/conveyors.js`, `src/sim/machine-tick.js` |
| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/slope-conveyors.js` (uphill cleat barriers and screen nudges), `src/sim/machine-tick.js` |
| Fans | `src/sim/machine-tick.js` (`fanBlow`) |
| Refiner, Stone cutter, Silo | `src/sim/processors.js` |
| Pipes, pumps, outlets | `src/sim/pipes.js` |
@@ -183,7 +207,7 @@ dist/, shots/ build output and screenshots (not committed)
| World generation | `src/sim/worldgen.js` |
| Save format and migrations | `src/sim/save.js` |
| Machine drawing (silo, refiner...) | `src/render/machines.js` |
| Smooth-look shader | `src/render/smooth.js` |
| Smooth-look shader | `src/render/gpu/smooth-shader.js` |
| Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) |
| Sound and music | `src/ui/audio.js` |
@@ -201,14 +225,14 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti
- functions: def, metalFamily
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO
### src/sim/material-tables.js (49 lines)
### src/sim/material-tables.js (54 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
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH
### src/sim/pieces.js (143 lines)
### src/sim/pieces.js (179 lines)
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
- 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
- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, filterVariant, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, lineOnly, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
- variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
### src/sim/economy.js (152 lines)
Money, prices, market saturation, the trade dock, buyer and builder orders.
@@ -220,27 +244,27 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES
### src/sim/world.js (104 lines)
### src/sim/world.js (123 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- 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, 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
- functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, 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, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
### 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)
### src/sim/cells.js (223 lines)
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, wakeBotPowder, simMove
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM
- functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
### src/sim/heat.js (265 lines)
### src/sim/heat.js (266 lines)
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
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
### src/sim/machines.js (148 lines)
### src/sim/machines.js (157 lines)
Machines: the power network, footprints, placing and removing machines.
- functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine
- variables: nextPowerBuild
@@ -260,44 +284,60 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g
- 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
### src/sim/machine-tick.js (164 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
### src/sim/machine-tick.js (157 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
- functions: engineIntake, fanBlow, machinesTick
### 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). 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, tubeSpread, tubeIntake
### src/sim/joins.js (125 lines)
Travel-directed transport ends. Precomputed probes; world/plan queries allocate nothing. End indices stay geometric: left/right, or up/down for vertical screws.
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
- variables: JOIN_ENDS, NO_PIECE
### src/sim/conveyors.js (213 lines)
How belts, filters and screw conveyors 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: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
### src/sim/blocks.js (156 lines)
### src/sim/flow.js (26 lines)
Throughput for the info overlay (t49): how many grains each belt, filter and screw conveyor block hands on, per second of game time. Counting runs only while the overlay is on, so normal play pays one flag check per carried grain. Bars are not counted.
- functions: setFlowCounting, flowNote, flowCross, flowSlopeUnder, flowTick
- variables: flowOn, flowCount, flowRate
### src/sim/slope-conveyors.js (231 lines)
Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells. Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls. Only uphill runs have cleats: downhill, the belt carries its load like a flat one.
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
### src/sim/blocks.js (237 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
### src/sim/molds.js (317 lines)
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, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains, barWord
### src/sim/items.js (232 lines)
### src/sim/items.js (318 lines)
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
- 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
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast
### src/sim/tools.js (52 lines)
### src/sim/tools.js (65 lines)
Tool helpers: reach, brushes, and plain digging.
- functions: inReach, forBrush, drillReach, inDrillReach, toolDig
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
- variables: BUILD_MAX, buildBox
### src/sim/drill.js (161 lines)
### src/sim/drill.js (177 lines)
The resonance drill: tuned pulses that shatter one material.
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
### src/sim/nature.js (168 lines)
### src/sim/nature.js (170 lines)
Trees (background layer), acorns, saplings, and waking freezing water.
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
- variables: treeBases, saplings, treeDraw
### src/sim/vacuum.js (172 lines)
### src/sim/vacuum.js (199 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.
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
- variables: blowRR, FLY_G, FLY_Q
@@ -310,7 +350,7 @@ Player actions on built things: take back, place a machine or mold, gates, F to
Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
- functions: copyRegion, flipBlueprint, bpCost, pasteGhost, cutRegion, footprintBlocker, clearFootprint
### src/sim/undo.js (87 lines)
### src/sim/undo.js (93 lines)
Undo (Ctrl+Z) for building, and the bot building ghosts.
- functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
@@ -329,7 +369,12 @@ Weather: clouds from steam, rain and snow, and the world water count.
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
- variables: WBAND, WS, weather
### src/sim/tick.js (28 lines)
### src/sim/workers.js (153 lines)
The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
- functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage
- variables: simPool, chunkWork, inlineWork
### src/sim/tick.js (29 lines)
simTick: one simulation step, in order.
- functions: simTick
@@ -342,72 +387,141 @@ World generation helpers: noise and removal of tiny hard rock specks.
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync
### src/sim/save.js (177 lines)
### src/sim/devconsole.js (114 lines)
Dev commands are pure simulation operations; the browser owns the panel and confirmation.
- functions: resetDevConsole, enableDevConsole, devMaterial, devCommand
- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS
### src/sim/save.js (185 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF
### src/render/setup.js (200 lines)
### src/render/setup.js (190 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.
- 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
### src/render/light.js (79 lines)
Propagated coloured light from glowing and burning cells.
- functions: computeLight
### src/render/slope-belts.js (167 lines)
Drawing only: the face stays on its simulation cells; the band hangs below it.
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance, slopeWallOwner
### src/render/world.js (308 lines)
### src/render/diagonal-screws.js (79 lines)
Screw casing geometry shared by both looks, ghosts and placement previews. smooth-look class of a screw conveyor cell by direction: 9 up, 10 right, 11 down, 12 left
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
### src/render/cell.js (276 lines)
Rare-cell shading also treats built pieces as transparent surroundings.
- functions: builtForShade, cellPixel
### src/render/objects.js (80 lines)
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
- functions: worldObjects
### src/render/gpu/cracks.js (79 lines)
The drill's three seeded Perlin contour fields, uploaded once with the material tables.
- functions: gpuCrackTables
- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW
### src/render/gpu/colour.js (101 lines)
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
- functions: gpuColourShader
- variables: GPU_COLOUR, GPU_COLOUR_SMOOTH
### src/render/gpu/shaders.js (42 lines)
Half-resolution separable Gaussian bloom, then nearest colour + linear light.
- variables: GPU_BLUR, GPU_DRAW
### src/render/gpu/light.js (143 lines)
GPU sources retain the CPU roof and transmission rules; directional relaxation follows only left/up, then right/down, so it cannot introduce extra light paths.
- functions: gpuLightGrid, gpuLightBindings, gpuLight
- variables: GPU_LIGHT, GPU_LIGHT_SPREAD, GPU_LIGHT_PACK
### src/render/gpu/smooth-slope.js (107 lines)
Smooth slope geometry and cleats, ported from slope-belt-shader.js.
- variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW
### src/render/gpu/smooth-shader.js (368 lines)
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
- variables: GPU_SMOOTH
### src/render/gpu/surface.js (5 lines)
- functions: smoothSurface
- variables: SM
### src/render/gpu/smooth.js (48 lines)
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
- variables: GPU_FIELD_MIP
### src/render/gpu/device.js (90 lines)
All pipelines compile during startup; rendering never falls back.
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
- variables: GPU_LIMITS, GPU
### src/render/gpu/textures.js (98 lines)
Whole-world textures retain clean chunks across camera moves.
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
### src/render/gpu/uploads.js (48 lines)
Upload full chunks so scrolling within a resident chunk needs no extra copy.
- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads
### src/render/gpu/draw.js (92 lines)
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
- variables: GPU_PATTERN
### src/render/world.js (46 lines)
Scale colour without changing the smooth-look class.
- functions: fogColor, drawWorld
- variables: TUBE_CLASS
### src/render/machines.js (242 lines)
### src/render/machines.js (256 lines)
Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawRackBlocks
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
### src/render/overlays.js (229 lines)
### src/render/overlays.js (370 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
### src/render/smooth.js (99 lines)
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes
### src/render/smooth-shaders.js (325 lines)
Smooth shaders: vertex and fragment programs for the rounded world look.
- variables: SM_VS, SM_FS
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
### src/ui/audio.js (230 lines)
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
- variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
### src/ui/state.js (75 lines)
### src/ui/state.js (76 lines)
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status.
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
### src/ui/title.js (46 lines)
The title screen art.
- functions: buildTitleCells, drawTitle
- variables: FONT5, TITLE
### src/ui/threads.js (39 lines)
Browser transport only; the simulation owns the schedule and shared state.
- functions: startThreads
- variables: threadStarting
### src/ui/title.js (77 lines)
Startup capabilities and title art.
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
- variables: REQUIRE_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
### src/ui/saves.js (224 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library.
- 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, worldIdTime, worldIdCounter, worldWrites, savingCount
### src/ui/claims.js (145 lines)
### src/ui/claims.js (146 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)
### src/ui/session.js (162 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (125 lines)
### src/ui/settings.js (124 lines)
The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab
@@ -426,20 +540,21 @@ Notebook market: prices, orders, claim payments and wallet.
Notebook blueprints: starter designs, saved library and code sharing.
- functions: renderBlueprintTab, copyCode
### src/ui/hud.js (34 lines)
### src/ui/hud.js (40 lines)
The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
- variables: IN_BAG_ORDER
### src/ui/build-menu.js (104 lines)
### src/ui/build-menu.js (111 lines)
The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
### src/ui/selection.js (96 lines)
### src/ui/selection.js (133 lines)
Tool selection: cycling, drill tuning, piece variants and costs.
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, dragPlan, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope
### src/ui/selection-pickers.js (59 lines)
### src/ui/selection-pickers.js (61 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
@@ -448,35 +563,43 @@ Selection panels: current tool card and bag materials.
- functions: renderSelCard, renderBag
- variables: lastBagKey
### src/ui/info.js (188 lines)
### src/ui/cursor.js (47 lines)
Hover work is shared by the cursor label and the vein outline.
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince
### src/ui/info.js (208 lines)
Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
### src/ui/input.js (91 lines)
### src/ui/devconsole.js (42 lines)
Bottom panel; command dispatch and state live in sim/devconsole.js.
- functions: closeDevConsole, showDevConsole, toggleDevConsole, devPrint, runDevCommand
### src/ui/input.js (92 lines)
Keyboard and mouse listeners.
- functions: isTyping
- variables: canvas
### src/ui/actions.js (239 lines)
### src/ui/actions.js (256 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
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
- variables: ovShown
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick
- variables: ovShown, PING_RECHARGE
### src/ui/gamepad.js (60 lines)
Gamepad input.
- functions: padPressed, pollPad
- variables: PAD
### src/ui/previews.js (190 lines)
### src/ui/previews.js (241 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
### 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-drill.js (67 lines)
Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
- functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
### src/ui/preview-selection.js (80 lines)
### src/ui/preview-selection.js (81 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
@@ -490,54 +613,99 @@ 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)
### src/ui/perf.js (15 lines)
Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
### src/ui/loop.js (149 lines)
The frame loop and boot.
- functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
## Tests and tools
- `test/firefox_smoke.py`: An isolated Firefox title screen and three seconds of a new pixel world.
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
- `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/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
- `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/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_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_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata.
- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge.
- `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
- `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_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does.
- `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_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only.
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
- `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_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
- `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_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
- `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_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
- `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_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
- `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_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
- `tools/browser.py`: Shared isolated HTTP sessions; callers attach hooks before navigating to url.
- `tools/caps.py`: Print isolation, WebGPU adapter info and GL renderer for installed browsers.
- `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-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
- `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/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
- `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/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/flow.js`: Throughput of belts and screw tubes: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
- `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
- `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals.
- `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE).
- `tools/experiments/slope-cost.js`: Many loaded slopes, with a fresh deterministic world for each timing sample.
- `tools/experiments/slope-flow.js`: Reproducible generous-feed throughput and settled pile angles for t42.
- `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/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/joins.js`: Travel join grid: UR/DR/DL/UL, both ends, and all three neighbouring placements. Screw H and V, then belt H; each tries the side, above/below, and E. Self-contained, paused and empty. Use --zoom 4 --viewport 2100x1500 --no-light.
- `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/slope-conveyors.js`: Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
- `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/t-screen-joint.js`: Downhill screen whose outlet belt sits level with its last block (the jam the owner saw).
- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket.
- `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
- `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect.
- `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand.
- `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below.
- `tools/scenes/t88-light.js`: Frozen light comparisons, also used by the WebGPU browser regression suite.
- `tools/scenes/t88-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
+156 -13
View File
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
- A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically.
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the
same test give the same output every run, apart from lines with timings in ms. To prove a
same test give outputs identical across thread counts and between runs, apart from lines with timings in ms. To prove a
refactor changed nothing, save every suite's output before and after, and diff them:
```sh
@@ -58,6 +58,17 @@ for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # afte
diff -r /tmp/before /tmp/after
```
`test/t_threads.js` runs the real shared-memory scheduler inline and with 1, 2 and 8
Node workers, including repeats, comparing all mutable cell/chunk bytes and JS side effects.
The browser creates up to `min(16, hardwareConcurrency - 2, CPU workers setting)` helpers.
Small passes run inline; larger passes use batches to amortize dispatch. Wakes and heat flags
merge after each pass; long liquid searches are deferred until that merge. Global machine and
weather randomness remains separate from the chunk streams. World generation uses the same
inline schedule while settling. The t88 re-baseline changes worldgen material hashes and the
seeded sand pile bounds in `t_corner_seal.js`; the heat oracle now supports pass outputs.
The conveyor fixture uses a wider feed pile, the screen fixture warms longer, and `t_load.js`
checks waking within eight ticks separately from the probabilistic consumption of fuel.
## Browser tests
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
@@ -67,12 +78,47 @@ diff -r /tmp/before /tmp/after
gamepad and world sizes. Screenshots go to
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
look.
- `test/t_smooth.py` checks that the WebGL2 smooth look compiles and draws, that it falls back
to pixels cleanly, and that the thermal lens works.
- Run them one at a time. Two headless browsers at once can starve each other, and
`t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other.
- Rebuild first (`python3 build.py`). They load `dist/index.html`. `run_all.py --browser`
rebuilds for you.
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the
Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes.
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures
store float16 (the removed CPU implementation used float32). Behavioural expectations remain.
- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests.
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader.
Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend.
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
changed. The play test holds the slow-save fixture open during its screenshot, waits for
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
a known reachable target; these checks do not depend on GPU speed or earlier digging.
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
the mandatory WebGPU startup gate. Cross-origin isolation is required for threads. Firefox smoke
checks that a browser without WebGPU shows the notice and cannot start a world.
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
and [GPUWeb implementation status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status#firefox).
## Screenshots: tools/shot.py
@@ -88,6 +134,13 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
`tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set
`CAM = [x, y]` to frame the shot.
- The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`.
- Both looks require WebGPU; there is no renderer switch or legacy fallback.
- `test/t_gpu.py` checks GPU uploads, dirty/full equality, contours, resize, timing and device loss.
World-pixel tests use `worldPixelContext()` to read both stacked canvases. The helper
presents the retained GPU result again before copying, because canvas textures expire after
presentation ([WebGPU specification](https://gpuweb.github.io/gpuweb/#abstract-opdef-expire-the-current-texture),
[Mozilla implementation notes](https://bugzilla.mozilla.org/show_bug.cgi?id=1965377)). Tests that change
static `noiseB` in place set `GPU.world = null` before drawing to upload their new fixture.
- `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics.
- `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at
the same moment. Two shots of the same scene then match pixel for pixel, and a before / after
@@ -106,10 +159,9 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Pitfalls
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader:
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
- **GPU access in headless Chromium.** Real mode needs access to GPU devices and driver caches;
a filesystem sandbox can silently force software rendering. Use `tools/caps.py` to check.
Soft mode is available for machines without a GPU. Both modes load isolated HTTP pages.
- **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is
`undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name.
Functions declared with `function` are on `window`.
@@ -128,7 +180,98 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Measuring
- `tools/perf.py`: renderer and tick times in the browser.
- `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
```sh
python3 tools/perf.py --json tools/perf-baseline.json
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
python3 tools/test_perf.py
```
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
**World** is the whole `drawWorld` CPU call. **Prep** sets up the view/masks/joins;
**Colour** fills CPU rare-cell overrides (including ghosts and flyers); **Upload** writes
dirty world chunks and override textures; **Blit** submits GPU drawing and composition.
These are CPU wall times, including driver stalls; they are not GPU timestamp measurements.
Bloom is computed on the GPU. GPU light timestamps are recorded separately as `gpuLight`.
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
overlays absent from the fixture have zero calls, not a measured zero execution cost.
Five calls warm each render configuration before 30 measured calls. Simulation warms five
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
JSON records a fixed render scale of 1 for both looks.
Comparisons accept subsets of the baseline scenes, looks and zooms, with identical world/viewport dimensions and run duration, print all timing changes, and mark >10% regressions;
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
Performance runs launch Chromium with
`--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization --enable-unsafe-webgpu`.
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. Real mode refuses software
renderers; both modes require isolation. Chromium needs access to the host GPU devices; a filesystem
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
tests' SwiftShader numbers (and explicit `--gpu soft` profiler runs) are not a hardware performance baseline.
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
free-port background server with cleanup for measurement tools.
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
- `tools/experiments/flow.js`: belt and tube throughput in grains a second.
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
Both looks use WebGPU. Colour measures the CPU rare-cell override fill, including loose
objects and ghosts. `frame_ms` is the complete paused-frame CPU call, with overlays and UI.
Upload measures dirty world chunks, override buffers and small lighting tables; Blit measures
GPU command submission. `override_cells` counts CPU cells. GPU timestamp samples include
colour, lighting, bloom and drawing, excluding uploads. CPU lighting is gone. The GPU canvas
draws the world; the transparent game canvas holds overlays. Override colours and glow live in
plain typed arrays, without offscreen Canvas2D backing stores.
Use `tools/scenes/t88-renderer.js` with `--freeze --zoom 12` (or 6/2),
`--viewport 1920x1080` and `--look pixel|smooth`. It includes terrain, water, fire, magma,
fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost. Frozen legacy
comparisons are in `test/t_gpu_golden.py`; no legacy renderer remains in the game.
The smooth shader retains the nine-tap glow filter and samples field AO at mip level 2.2;
three render passes generate field levels 1-3, including non-power-of-two view sizes.
Only smooth uploads back/tree/field overrides and a separate presence mask. Profiler scale is
always 1. The screenshot tool invalidates the parallax horizon after setup changes `surfY`.
Mip pass references: [Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
`perfSim` reports synchronous tick wall time (also occupied main-thread time), polling in
`threadWait`, and main compute excluding polling. The latter includes serial simulation,
dispatch/merge and the main thread's own chunk batch; it is not pure scheduling overhead.
Final t88 full-matrix results are saved in `tools/perf-after-t88.json`; keep
`tools/perf-baseline.json` unchanged as the original comparison.
The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four
storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for
2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits)
and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp).
All game pipelines use async creation during capability checking. World loading submits both
looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`;
look/zoom switches must not call synchronous pipeline creation.
+1179
View File
File diff suppressed because it is too large Load Diff
-107
View File
@@ -1,107 +0,0 @@
# 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`).
+4 -4
View File
@@ -5,7 +5,7 @@ Built 2026-10-22; the uses for stone and sand were added 2026-10-23.
- Code:
- `src/sim/processors.js` (Refiner, Stone cutter, Silo);
- `src/sim/pipes.js`;
- `src/sim/conveyors.js` and `src/sim/machine-tick.js` (belts and tubes);
- `src/sim/conveyors.js` and `src/sim/machine-tick.js` (belts and screw conveyors);
- `src/sim/economy.js` (prices, builders' orders).
- Tests: `test/t_pipes.js`, `test/t_stone.js`, `test/t_conveyors.js`.
@@ -57,12 +57,12 @@ through another refiner.
- **Water accounting.** Water in pipes counts in the world's water total, so the weather never
sends rain to replace it. Pipe contents are saved.
## Belts and screw tubes
## Belts and screw conveyors
- A heap on a belt moves as one: the whole stack, up to 96 grains deep, steps along together
instead of only its bottom layer. The owner reports that big piles still only move at the top
(see NOTES.md).
- Screw tubes run at belt pace, moving every other tick.
- A horizontal tube fed from a full hopper moves about 100–110 grains a second.
- Screw conveyors run at belt pace, moving every other tick.
- A horizontal screw conveyor fed from a full hopper moves about 100–110 grains a second.
- A belt under its mouth keeps up, carrying a stream 3–5 grains deep at about the same rate.
- A vertical lift brings up about 40–60 grains a second.
- `tools/experiments/flow.js` measures these.
+27 -4
View File
@@ -1,7 +1,8 @@
{
"shell": "shell.html",
"css": [
"styles/style.css"
"styles/style.css",
"styles/devconsole.css"
],
"sim": [
"sim/base.js",
@@ -19,7 +20,10 @@
"sim/racks.js",
"sim/pipes.js",
"sim/machine-tick.js",
"sim/joins.js",
"sim/conveyors.js",
"sim/flow.js",
"sim/slope-conveyors.js",
"sim/blocks.js",
"sim/molds.js",
"sim/items.js",
@@ -33,21 +37,37 @@
"sim/bot.js",
"sim/ledger.js",
"sim/weather.js",
"sim/workers.js",
"sim/tick.js",
"sim/worldgen-helpers.js",
"sim/worldgen.js",
"sim/devconsole.js",
"sim/save.js"
],
"client": [
"render/setup.js",
"render/light.js",
"render/slope-belts.js",
"render/diagonal-screws.js",
"render/cell.js",
"render/objects.js",
"render/gpu/cracks.js",
"render/gpu/colour.js",
"render/gpu/shaders.js",
"render/gpu/light.js",
"render/gpu/smooth-slope.js",
"render/gpu/smooth-shader.js",
"render/gpu/surface.js",
"render/gpu/smooth.js",
"render/gpu/device.js",
"render/gpu/textures.js",
"render/gpu/uploads.js",
"render/gpu/draw.js",
"render/world.js",
"render/machines.js",
"render/overlays.js",
"render/smooth.js",
"render/smooth-shaders.js",
"ui/audio.js",
"ui/state.js",
"ui/threads.js",
"ui/title.js",
"ui/saves.js",
"ui/claims.js",
@@ -61,7 +81,9 @@
"ui/selection.js",
"ui/selection-pickers.js",
"ui/selection-panels.js",
"ui/cursor.js",
"ui/info.js",
"ui/devconsole.js",
"ui/input.js",
"ui/actions.js",
"ui/gamepad.js",
@@ -70,6 +92,7 @@
"ui/preview-selection.js",
"ui/events.js",
"ui/map.js",
"ui/perf.js",
"ui/loop.js"
]
}
+276
View File
@@ -0,0 +1,276 @@
// Rare-cell shading also treats built pieces as transparent surroundings.
function builtForShade(i, x, y) {
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
}
// CPU colour rules for the rare-cell WebGPU overrides.
function cellPixel(x, y) {
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
const bx = x - vx0, by = y - vy0, row = by * bw;
if (R.gpuOn) { if (!gpuOverrideHas(row + bx)) GPU.overrideCount++; if (sm) GPU.overrideMask[row + bx] = 255; }
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
const OUT = sm ? 0x04000000 : 0xff000000;
if (x < 0 || y < 0 || x >= W || y >= H) {
px[row + bx] = OUT;
if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; }
return;
}
const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence;
const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowW;
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;
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; }
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; } return; }
// 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 T = temp[i];
// Also used by the thermal lens, which always draws in pixels.
let looseShade = 1;
if (!sm && m !== EMPTY && !builtForShade(i, x, y)) {
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
else if (PH[m] === PH_SOLID && (
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
(x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) ||
(y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) ||
(y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W])
)) looseShade = 0.72;
}
let r, g, b;
if (lens) {
const ti = thermIdx(T);
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
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];
r *= looseShade; g *= looseShade; b *= looseShade;
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);
return;
}
// background
let br, bgc, bb, sky = false;
const tb = bgm[i];
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
else {
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
const v = sm ? 0 : nz * 0.06;
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
}
if (sm) {
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
if (tb) {
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
} else tpx[row + bx] = 0;
}
if (m === EMPTY) { r = br; g = bgc; b = bb; }
else {
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
r *= looseShade; g *= looseShade; b *= looseShade;
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
else if (m === BELTL || m === BELTR) {
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
if (ly === 0 || ly === 3) {
// top run moves with the load, bottom run returns the other way
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
const k = ly === 0 ? 1 : 0.72;
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
} else if (lx === 1 || lx === 2) {
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
} else { r = 46; g = 48; b = 54; }
} else if (m === PIPE) {
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
} else if (m === SHAFT) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
else { r -= 34; g -= 30; b -= 24; }
}
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 === 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 === 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 === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
const al = ALPHA[m];
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
const dp = digp[i];
if (dp) {
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
r += lift; g += lift; b += lift * 1.08;
}
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
const lx = x & 3, ly = y & 3;
const wc = blockP[(y >> 2) * BW + (x >> 2)];
const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3);
if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
}
}
// field parts drawn over contents
const sb = slopeBeltAt(i, x, y, sm);
let p = part[i];
if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
const db = diagonalScrewBlock(x, y, sm);
let diagonal = false;
if (db >= 0) {
const v = blockV[db], joins = diagonalJoins[db];
const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins);
p = sm || sd <= 0 ? SCREW_PARTS[v] : 0;
diagonal = !!p;
if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8);
}
if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
else if (p) {
if (IS_SCREW[p]) {
const on = netSat(i) > 0;
const vert = p === P_SCREW_U || p === P_SCREW_D;
const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x;
const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3;
const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1;
if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
} else {
// screw flights: a diagonal stripe that walks along the tube when powered
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
}
} else if (p === P_FILTER) {
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) {
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
}
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
} else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
else { r = 24; g = 25; b = 28; }
}
} else if (p === P_GRATE) {
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
} else if (p === P_GOPEN) {
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
} else if (p === P_INTAKE) {
if (m === EMPTY) { r = 30; g = 32; b = 36; }
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
} else if (p === P_PIPE) {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
if (slopeArt && !sm) {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
const slopeDetail = slopeDetailAt(i, x, y, sm);
if (slopeDetail) {
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
// ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber
const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])];
r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56;
g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60;
b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68;
}
// incandescence
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];
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 && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
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);
}
}
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
let top = 0xff000000;
if (sm) {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
if (IS_SCREW[pt]) cl = SCREW_CLASS[pt];
else if (p === P_FILTER && sb < 0) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
else if (m === BELTL || m === BELTR) {
cl = m === BELTL ? 7 : 8;
tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
}
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1;
else if (ph === PH_POWDER) cl = 2;
else if (ph === PH_LIQUID) cl = 3;
else cl = 5;
}
const wallCode = blockP[(y >> 2) * BW + (x >> 2)];
if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) {
cl = wallCode;
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
}
if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
if (slopeArt) cl = slopeClass;
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
if (slopeDetail && slopeWallOwner >= 0) {
// ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation
const wb = slopeWallOwner;
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl |
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not
}
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);
}
+79
View File
@@ -0,0 +1,79 @@
// Screw casing geometry shared by both looks, ghosts and placement previews.
// smooth-look class of a screw conveyor cell by direction: 9 up, 10 right, 11 down, 12 left
const SCREW_CLASS = new Uint8Array(15); SCREW_CLASS[1] = 9; SCREW_CLASS[4] = 10; SCREW_CLASS[2] = 11; SCREW_CLASS[3] = 12;
for (let p = P_SCREW_UR; p <= P_SCREW_UL; p++) SCREW_CLASS[p] = 19 + p - P_SCREW_UR;
// Drawing only: the four transport lanes remain in the square block.
let diagonalJoins = new Uint8Array(0);
function diagonalScrewJoins(bx, by, v, planned = null) {
// Three bits per end: 1..4 edge exits, 5 horizontal in E, 6 vertical in E; bits 6/7 collars.
let joins = 0;
for (let end = 0; end < 2; end++) {
const join = endJoin(bx, by, end, planned);
if (join < 0) { joins |= 64 << end; continue; }
const nb = join >> 1, next = planned && planned.get(nb);
const code = next ? next[0] : blockP[nb], nv = next ? next[1] : blockV[nb];
if (pieceEnds(code, nv)[join & 1].diagonal) continue;
const horizontal = code !== BP_SCREW || (nv & 1);
const ny = (nb / BW) | 0, nx = nb % BW;
const kind = ny === by ? (nx > bx ? 2 : 4) : nx === bx ? (ny < by ? 1 : 3) : horizontal ? 5 : 6;
joins |= kind << (end * 3);
}
return joins;
}
// Only E elbows bend a straight block: 1 rising, 2 falling, three bits per end.
function straightScrewJoins(bx, by, v, planned = null) {
let joins = 0;
for (let end = 0; end < 2; end++) {
const join = endJoin(bx, by, end, planned);
if (join < 0) continue;
const nb = join >> 1, next = planned && planned.get(nb);
const code = next ? next[0] : blockP[nb], nv = next ? next[1] : blockV[nb];
if (code === BP_SCREW && nv >= 4 && ((nb / BW) | 0) !== by && nb % BW !== bx)
joins |= (nv === 4 || nv === 6 ? 1 : 2) << (end * 3);
}
return joins;
}
// Scratch coordinates shared by the pixel shading; no allocations in the grain loop.
let diagonalAcross = 0, diagonalAlong = 0, diagonalCap = 0;
function diagonalScrewShape(x, y, b, v, joins) {
const lx = x - (b % BW) * BS - 2, ly = y - ((b / BW) | 0) * BS - 2;
let best = Infinity;
for (let end = 0; end < 2; end++) {
const join = (joins >> (end * 3)) & 7, side = end ? 1 : -1;
const slope = v === 4 || v === 6 ? -1 : 1, straight = v < 4, vert = straight && !(v & 1);
const bent = straight && join;
const diagonal = straight ? bent : !join || join >= 5;
const grade = straight ? (join === 1 ? -1 : 1) : slope;
const dx = diagonal ? side * (vert ? grade : 1) * Math.SQRT1_2 : straight ? (vert ? 0 : side) : SCREW_DIRS[join - 1][0];
const dy = diagonal ? side * (vert ? 1 : grade) * Math.SQRT1_2 : straight ? (vert ? side : 0) : SCREW_DIRS[join - 1][1];
const along = lx * dx + ly * dy, across = -lx * dy + ly * dx;
const radius = Math.hypot(across, Math.min(0, along));
const cap = along - (diagonal ? 2 * Math.SQRT2 : 2);
const sd = Math.max(radius - 2, !straight && (joins & (64 << end)) ? cap : -10);
if (sd < best) {
best = sd; diagonalAcross = side * across < 0 ? -radius : radius;
const ox = (b % BW) * BS + 2, oy = ((b / BW) | 0) * BS + 2;
diagonalAlong = (straight ? (vert ? oy : ox) : (ox + oy * slope) * Math.SQRT1_2) + side * along;
diagonalCap = joins & (64 << end) ? cap : -10;
}
}
return best;
}
function diagonalScrewBlock(x, y, smooth) {
const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
if (IS_SCREW[part[i]] && (blockV[own] >= 4 || diagonalJoins[own])) return own;
if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[own]) return -1;
let best = smooth ? 0.75 : 0, found = -1;
for (let k = 0; k < 4; k++) {
const bx = (x >> 2) + (k === 0 ? -1 : k === 1 ? 1 : 0);
const by = (y >> 2) + (k === 2 ? -1 : k === 3 ? 1 : 0);
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx, v = blockV[b];
if (blockP[b] !== BP_SCREW || (v < 4 && !diagonalJoins[b])) continue;
const joins = diagonalJoins[b] === undefined ? (v >= 4 ? diagonalScrewJoins(bx, by, v) : straightScrewJoins(bx, by, v)) : diagonalJoins[b];
if (v < 4 && ((v & 1) ? (x >> 2) !== bx : (y >> 2) !== by)) continue;
const sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
if (sd <= best) { best = sd; found = b; }
}
return found;
}
+101
View File
@@ -0,0 +1,101 @@
// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
function gpuColourShader(smooth) { return `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
@group(0) @binding(2) var<storage, read> land: array<vec4f>;
${['mat','flags','life','part','bgm','digp','noise','fog'].map((n,i)=>`@group(0) @binding(${i+3}) var ${n}T: texture_2d<u32>;`).join('\n')}
@group(0) @binding(11) var tempT: texture_2d<f32>;
@group(0) @binding(12) var overrideT: texture_2d<f32>;
@group(0) @binding(13) var overrideGlow: texture_2d<f32>;
@group(0) @binding(14) var outputT: texture_storage_2d<rgba8unorm, write>;
${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(17) var treeOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(18) var fieldOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(19) var backOver: texture_2d<f32>;
@group(0) @binding(20) var treeOver: texture_2d<f32>;
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
@group(0) @binding(22) var maskOver: texture_2d<u32>;
` : ""}
${smooth ? "" : "@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;"}
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
fn powder(p: vec2i) -> bool {
return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u;
}
fn quant(c: vec3f) -> vec3f { return floor(clamp(c,vec3f(0),vec3f(255)))/255.; }
@compute @workgroup_size(8,8)
fn colour(@builtin(global_invocation_id) id: vec3u) {
let q=vec2i(id.xy); if (any(q>=vec2i(u.view.zw))) { return; }
let p=q+vec2i(u.view.xy); let x=p.x; let y=p.y;
var rgb=vec3f(0); var bloom=vec3f(0);
${smooth ? "var cl=4.; var backValue=vec4f(0); var treeValue=vec4f(0); var fieldValue=vec4f(1,0,0,0);" : ""}
if (all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r; let nz=f32(textureLoad(noiseT,p,0).r)-128.;
let T=textureLoad(tempT,p,0).r; let mc=lut[m*3u]; let props=lut[m*3u+1u];
var fv=255.;
if (u.style.x>0.) {
let f=vec2f(p)/${FOG}.; let a=vec2i(f); let b=min(a+1,vec2i(u.world.zw)-1); let t=fract(f);
fv=mix(mix(f32(textureLoad(fogT,a,0).r),f32(textureLoad(fogT,vec2i(b.x,a.y),0).r),t.x),mix(f32(textureLoad(fogT,vec2i(a.x,b.y),0).r),f32(textureLoad(fogT,b,0).r),t.x),t.y);
if (fv>0. && fv<255.) { var s=fv/255.; s=clamp(s+nz*(0.6/128.)*s*(1.-s),0.,1.); fv=max(1.,255.*s*s*(3.-2.*s)); }
}
if (fv>0.) {
var shade=1.;
let built=props.x==${PH_SOLID}. && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u;
if (${smooth ? "false" : `m!=${EMPTY}u && !built`}) {
if (props.x==${PH_POWDER}.) { shade=1.1+nz*0.00065; }
else if (props.x==${PH_SOLID}. && (powder(p+vec2i(-1,0)) || powder(p+vec2i(1,0)) || powder(p+vec2i(0,-1)) || powder(p+vec2i(0,1)))) { shade=0.72; }
}
if (u.effects.x>0.) {
let ti=u32(clamp(i32(T/4.),0,550)); let thermal=lut[${NMAT*3+700}u+u32(u.effects.y)*551u+ti].rgb;
rgb=select(thermal*0.78+vec3f((mc.r+mc.g+mc.b)/3.*0.25),thermal*0.55,m==${EMPTY}u)*shade;
} else {
let tb=textureLoad(bgmT,p,0).r; let dp=f32(textureLoad(digpT,p,0).r);
let column=land[x]; let sky=land[i32(u.world.x)+y].rgb;
var back: vec3f;
if (${smooth ? "false" : "tb!=0u"}) { back=(lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/128.))*(1.+dp/700.); }
else if (f32(y)<column.x) {
back=sky;
if (u.style.y>0.01) { let gy=(sky.r+sky.g+sky.b)/3.*0.82; back+= (vec3f(gy)-sky)*u.style.y*vec3f(0.7,0.7,0.65); }
} else {
back=vec3f(${BG.basalt.join(',')});
if (f32(y)<column.y) { back=vec3f(${BG.dirt.join(',')}); }
else if (f32(y)<column.z) { back=vec3f(${BG.stone.join(',')}); }
else if (f32(y)<column.w) { back=vec3f(${BG.granite.join(',')}); }
${smooth ? "" : "back+=vec3f(nz*0.06);"}
}
${smooth ? `backValue=vec4f(quant(back),select(0.,1.,f32(y)<column.x));
if (tb!=0u) { treeValue=vec4f(quant((lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/256.))*(1.+dp/700.)),select(128./255.,1.,tb==${LEAVES}u)); }
cl=0.; if (m!=${EMPTY}u) { cl=5.; if(props.x==${PH_SOLID}.) { cl=1.; } else if(props.x==${PH_POWDER}.) { cl=2.; } else if(props.x==${PH_LIQUID}.) { cl=3.; } }
fieldValue=vec4f(select(0.,1.,cl==1.),select(0.,1.,cl==3.),select(0.,1.,cl==5.),select(0.,1.,tb!=0u));
` : ""}
rgb=back;
if (m!=${EMPTY}u) {
rgb=(mc.rgb+vec3f(nz*mc.a*${smooth ? `select(u.style.z*0.35,0.,props.x==${PH_SOLID}.)` : "u.style.z"}/128.))*shade;
if (m==${FIRE}u) { let f=f32(textureLoad(lifeT,p,0).r & 15u)*6.; rgb=vec3f(255.,120.+f,30.+f*0.5); }
else if (${smooth ? "false && " : ""}m==${WATER}u && ((x*3+y*5+(i32(u.style.w)>>2)) & 15)==0) { rgb+=vec3f(14.); }
rgb=back+(rgb-back)*props.y;
if (dp>0.) { let lift=dp/255.*(30.+sin(u.style.w*1.9+f32(x)*1.3+f32(y)*0.7)*6.); rgb+=lift*vec3f(1,1,1.08); }
}
if (T>450. && m!=${EMPTY}u && u.effects.z>0.) {
var gt=T;
if (props.x==${PH_SOLID}.) { gt+=nz*0.9; }
else if (m==${MAGMA}u) { gt+=sin(f32(x)*0.19+f32(y)*0.11+u.style.w*0.03+nz*0.04)*55.-30.; }
let gi=u32(clamp(i32((gt-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
rgb=rgb*(1.-glow.a)+glow.rgb*glow.a;
if (u.effects.w>0. && fv>=128. && glow.a>0.3) { bloom=quant(glow.rgb*(glow.a-0.3)*1.4); }
}
}
rgb=quant(rgb*fv/255.);
${smooth ? "backValue=vec4f(quant(backValue.rgb*fv),backValue.a); treeValue=vec4f(quant(treeValue.rgb*fv),treeValue.a);" : ""}
}
}
// Flying grains replace colour, while the glow behind them remains.
let over=textureLoad(overrideT,q,0);
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"}
}`;
}
const GPU_COLOUR = gpuColourShader(false);
const GPU_COLOUR_SMOOTH = gpuColourShader(true);
+79
View File
@@ -0,0 +1,79 @@
// The drill's three seeded Perlin contour fields, uploaded once with the material tables.
function gpuCrackTables() {
const out = new Float32Array(3 * 512 * 4);
for (let l=0;l<3;l++) {
const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i);
for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255];
for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); }
}
return out;
}
const GPU_CRACKS = `
fn crackNoise(p: vec2f, l: u32) -> f32 {
let base=${NMAT*3+700+551*3}u+l*512u;
let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p);
let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x);
let a=lut[base+u32(lut[base+v.x+p0].x)].yz;
let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz;
let c=lut[base+u32(lut[base+v.x+p1].x)].yz;
let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz;
let t=f*f*f*(f*(f*6.-15.)+10.);
return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41;
}
`;
// Screen-resolution colour output preserves the old sub-grain marching-square strokes.
const GPU_CRACK_COLOUR = `
struct Params { view:vec4f, world:vec4f, style:vec4f, effects:vec4f, camera:vec4f, light:vec4f, screen:vec4f, extra:vec4f }
@group(0) @binding(0) var<uniform> u:Params;
@group(0) @binding(1) var<storage,read> lut:array<vec4f>;
@group(0) @binding(2) var matT:texture_2d<u32>;
@group(0) @binding(3) var digpT:texture_2d<u32>;
@group(0) @binding(4) var outputT:texture_storage_2d<rgba8unorm,write>;
${GPU_CRACKS}
fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 {
let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.));
}
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id:vec3u) {
let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;}
let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos));
var colour=vec4f(0);
if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r;let damage=f32(textureLoad(digpT,p,0).r)/255.;
if(lut[m*3u+1u].x==${PH_SOLID}.) {
for(var l=0u;l<3u;l++) {
if(damage<array<f32,3>(0.1,0.42,0.72)[l]){continue;}
let scale=array<f32,3>(1./7.,1./3.6,1./2.)[l];
let at=vec2f(p)*scale+vec2f(0.37,0.61);
let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l);
let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l);
var pts:array<vec2f,4>;var n=0u;
if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;}
if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;}
if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;}
if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;}
let width=array<f32,3>(0.2,0.14,0.09)[l];
for(var stroke=0u;stroke<2u;stroke++) {
let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip);
var distance=100.;
for(var k=0u;k+1u<n;k+=2u) {
distance=min(distance,segmentDistance(pos-vec2f(p)-vec2f(0,offset),pts[k],pts[k+1u]));
}
let w=max(1./u.camera.z,width*select(1.,1.3,lip))*0.5;
let alpha=clamp((w-distance)*u.camera.z+0.5,0.,1.)*select(0.78-f32(l)*0.12,0.13,lip);
let rgb=select(vec3f(18,12,10),vec3f(255,240,220),lip)/255.;
colour=vec4f(rgb*alpha+colour.rgb*(1.-alpha),alpha+colour.a*(1.-alpha));
}
}
}
}
textureStore(outputT,q,colour);
}`;
const GPU_CRACK_DRAW = `
@group(0) @binding(0) var cracks:texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
}
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);}
`;
+90
View File
@@ -0,0 +1,90 @@
// All pipelines compile during startup; rendering never falls back.
const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 };
function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); }
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
async function gpuInit(adapter) {
const started = performance.now();
try {
if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits.");
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS });
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
d.lost.then(info => { if (GPU.device === d) gpuRecover(info.message || 'Graphics device lost'); });
GPU.context = GPU.canvas.getContext('webgpu');
GPU.format = navigator.gpu.getPreferredCanvasFormat();
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
d.pushErrorScope('validation');
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
await gpuSmoothInit();
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
}
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}});
const cracks = d.createShaderModule({code:GPU_CRACK_DRAW});
GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});
const error = await d.popErrorScope(); if (error) throw error;
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
GPU.params = new Float32Array(32);
GPU.sampler = d.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
if (features.length) {
GPU.queries = d.createQuerySet({ type: 'timestamp', count: 4 });
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
}
GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started;
return true;
} catch (e) { GPU.ok = false; GPU.error = e.message; return false; }
}
function gpuFail(message) {
GPU.ok = false; GPU.error = message;
console.warn('WebGPU renderer: ' + message);
if (GAME.running) gpuRecover(message);
}
async function gpuRecover(message) {
if (GPU.recovering || GPU.stopped) return;
GPU.ok = false; GPU.recovering = true;
const saved = !GAME.running || await saveWorld('auto', true);
const old = GPU.device;
// Resources belong to the lost device; force every retained cache to rebuild.
GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null;
GPU.bindColour = GPU.lightDirections = GPU.queries = null;
GPU.timingBusy = false; GPU.lightFrame = undefined;
let restored = false;
try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
if (adapter) restored = await gpuInit(adapter);
} catch (e) { GPU.error = e.message; }
if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; }
GPU.device = null; if (old) old.destroy();
GPU.stopped = true; GPU.recovering = false; GAME.paused = true;
dialog('Graphics stopped', '<p>The graphics device could not restart. '+
(saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'</p>', [
...(!saved ? [{label:'Export world',fn:exportWorld}] : []),
{label:'Reload',fn:()=>location.reload()}
]);
}
// Submit both looks behind the loading screen, including their first real bindings.
async function gpuWarmWorld() {
await startThreads();
buildSky(); resizeRender(); snapCamera();
const look = SET.look, started = performance.now();
try {
for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) {
SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone();
}
GPU.warmMs = performance.now() - started;
} finally { SET.look = look; }
}
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
function worldPixelContext() {
if (!R.gpuOn) return R.ctx;
// Canvas textures expire after presentation; redraw the retained result before copying.
const encoder = GPU.device.createCommandEncoder(); gpuPresent(encoder);
GPU.device.queue.submit([encoder.finish()]);
const c = document.createElement('canvas'); c.width = R.canvas.width; c.height = R.canvas.height;
const ctx = c.getContext('2d'); ctx.drawImage(GPU.canvas, 0, 0); ctx.drawImage(R.canvas, 0, 0);
return ctx;
}
+92
View File
@@ -0,0 +1,92 @@
// Only exceptional blocks and their geometric neighbours run the CPU colour rules.
const GPU_PATTERN = new Uint8Array(NMAT);
for (const m of [BELTL, BELTR, PIPE, SHAFT, GATE, CUTST, GRINDER, SAWB, PRESSB, RACKB, WOOD, LWOOD, HULL, SPRING, DOCKW]) GPU_PATTERN[m] = 1;
function gpuOverrideHas(k) { return R.smoothOn ? GPU.overrideMask[k] : R.px[k]; }
function gpuOverrides() {
R.px.fill(0);
if (R.smoothOn) {
if (!GPU.overrideMask || GPU.overrideMask.length !== R.px.length) GPU.overrideMask = new Uint8Array(R.px.length);
GPU.overrideMask.fill(0);
}
if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB);
const marked = GPU.blocks; marked.fill(0);
const bx0 = Math.max(0, (R.vx0 >> 2) - 3), by0 = Math.max(0, (R.vy0 >> 2) - 3);
const bx1 = Math.min(BW - 1, ((R.vx0 + R.bw) >> 2) + 3), by1 = Math.min(BH - 1, ((R.vy0 + R.bh) >> 2) + 3);
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, code = blockP[b];
let rare = !!code;
if (!rare) for (let dy = 0; dy < BS && !rare; dy++) for (let dx = 0; dx < BS; dx++) {
const i = (by * BS + dy) * W + bx * BS + dx;
if (part[i] || mach[i] || occ[i] || GPU_PATTERN[mat[i]]) { rare = true; break; }
}
if (!rare) continue;
marked[b] = 1;
if (code === BP_SCREW || isSlopeBelt(code)) {
// Covers joins, the far wall and cleats, including cells outside the owning block.
for (let yy = Math.max(0, by - 3); yy <= Math.min(BH - 1, by + 2); yy++)
for (let xx = Math.max(0, bx - 2); xx <= Math.min(BW - 1, bx + 2); xx++) marked[yy * BW + xx] = 1;
}
}
if (R.smoothOn) for (const mo of molds) {
if (mo.x + 40 < R.vx0 || mo.x - 40 > R.vx0 + R.bw || mo.y + 40 < R.vy0 || mo.y - 40 > R.vy0 + R.bh) continue;
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) marked[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] = 1;
}
GPU.overrideCount = 0;
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
if (!marked[by * BW + bx]) continue;
const x0 = Math.max(bx * BS, R.vx0), y0 = Math.max(by * BS, R.vy0);
const x1 = Math.min(bx * BS + BS, R.vx0 + R.bw, W), y1 = Math.min(by * BS + BS, R.vy0 + R.bh, H);
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) cellPixel(x, y);
}
}
function gpuDraw(lighting, glowOn, lightUpdate) {
const upload = performance.now();
gpuUpload(lighting, glowOn);
if (GPU.canvas.width !== R.canvas.width || GPU.canvas.height !== R.canvas.height) {
GPU.canvas.width = R.canvas.width; GPU.canvas.height = R.canvas.height;
}
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour);
if (R.smoothOn) {
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth);
}
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks);
compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end();
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]);
pass.dispatchWorkgroups(Math.ceil(R.glowW / 8), Math.ceil(R.glowH / 8)); pass.end();
}
const lightTimed = lighting && (lightUpdate || GPU.lightFrame === undefined);
if (lightTimed) { gpuLight(encoder, timed); GPU.lightFrame = R.frame; }
if (R.smoothOn) gpuSmoothMips(encoder);
gpuPresent(encoder, timed);
if (timed) { encoder.resolveQuerySet(GPU.queries, 0, lightTimed ? 4 : 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, lightTimed ? 32 : 16); }
d.queue.submit([encoder.finish()]);
if (timed) {
GPU.timingBusy = true;
GPU.timingPromise = GPU.readback.mapAsync(GPUMapMode.READ).then(() => {
const ns = new BigUint64Array(GPU.readback.getMappedRange()); GPU.gpuMs = Number(ns[1] - ns[0]) / 1e6;
GPU.lightMs = lightTimed ? Number(ns[3] - ns[2]) / 1e6 : 0;
GPU.readback.unmap(); GPU.timingBusy = false;
}).catch(() => { GPU.timingBusy = false; });
}
if (PERF_MARKS || SET.showPerf) {
perfMarks.override = perfMarks.color; perfMarks.upload = blit - upload; perfMarks.blit = performance.now() - blit;
perfMarks.gpuLight = GPU.lightMs || 0;
if (GPU.gpuMs !== null) perfMarks.gpu = GPU.gpuMs;
}
}
// Also used for readback after the browser has expired the presented canvas texture.
function gpuPresent(encoder, timed = false) {
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
});
pass.setPipeline(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}],
...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})});
cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end();
}
+143
View File
@@ -0,0 +1,143 @@
// GPU sources retain the CPU roof and transmission rules; directional relaxation
// follows only left/up, then right/down, so it cannot introduce extra light paths.
const GPU_LIGHT = `
struct LightParams { grid: vec4f, world: vec4f, lamp: vec4f, levels: vec4f }
@group(0) @binding(0) var<uniform> u: LightParams;
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
@group(0) @binding(2) var<storage, read> columns: array<vec4f>;
@group(0) @binding(3) var matT: texture_2d<u32>;
@group(0) @binding(4) var flagsT: texture_2d<u32>;
@group(0) @binding(5) var partT: texture_2d<u32>;
@group(0) @binding(6) var bgmT: texture_2d<u32>;
@group(0) @binding(7) var machT: texture_2d<u32>;
@group(0) @binding(8) var blockT: texture_2d<u32>;
@group(0) @binding(9) var tempT: texture_2d<f32>;
@group(0) @binding(10) var<storage, read_write> roofs: array<i32>;
@group(0) @binding(11) var dest: texture_storage_2d<rgba16float, write>;
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
fn built(p: vec2i) -> bool {
return (phase(p)==${PH_SOLID} && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u) ||
textureLoad(blockT,p/4,0).r!=0u || textureLoad(machT,p,0).r!=0u || textureLoad(partT,p,0).r!=0u;
}
fn loose(p: vec2i) -> bool {
for(var d=0;d<=12 && p.y+d<i32(u.world.y);d++) {
let q=p+vec2i(0,d); let ph=phase(q);
if(built(q)) { return true; }
if(ph!=${PH_POWDER} && ph!=${PH_LIQUID}) { return ph==${PH_EMPTY} || ph==${PH_GAS}; }
}
return false;
}
@compute @workgroup_size(64)
fn roof(@builtin(global_invocation_id) id: vec3u) {
let lx=i32(id.x); if(lx>=i32(u.grid.z)) { return; }
let x=(i32(u.grid.x)+lx)*i32(u.world.z);
if(x<0 || x>=i32(u.world.x)) { roofs[lx]=0; return; }
let end=min(i32(u.world.y),i32((u.grid.y+u.grid.w)*u.world.z));
var y=i32(columns[x].y);
for(;y<end;y++) {
let p=vec2i(x,y); let ph=phase(p);
if((ph==${PH_SOLID} || ph==${PH_POWDER} || ph==${PH_LIQUID}) && !built(p) && !((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { break; }
}
roofs[lx]=y;
}
@compute @workgroup_size(8,8)
fn sources(@builtin(global_invocation_id) id: vec3u) {
let q=vec2i(id.xy); if(any(q>=vec2i(u.grid.zw))) { return; }
let p=(vec2i(u.grid.xy)+q)*i32(u.world.z); let res=i32(u.world.z);
var rgb=vec3f(0); var a=0.;
if(all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r; let ph=phase(p);
var base=0.9;
if(built(p) || ((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { base=0.978; }
else if(ph==${PH_EMPTY}) { base=select(0.978,0.96,textureLoad(bgmT,p,0).r!=0u); }
else if(ph==${PH_GAS}) { base=0.96; }
else if(ph==${PH_LIQUID}) { base=0.93; }
else if(m==${GLASS}u || m==${ICE}u) { base=0.96; }
a=pow(base,u.world.z);
if(p.y<roofs[q.x]) { rgb=u.levels.x*vec3f(1,0.98,0.94); }
else if(f32(p.y)<columns[p.x].x && ph!=${PH_SOLID}) { rgb=u.levels.x*0.9*vec3f(1,0.98,0.95); }
var hottest=0.;
for(var dy=0;dy<res && p.y+dy<i32(u.world.y);dy++) {
for(var dx=0;dx<res && p.x+dx<i32(u.world.x);dx++) { hottest=max(hottest,textureLoad(tempT,p+vec2i(dx,dy),0).r); }
}
if(hottest>520.) {
let gi=u32(clamp(i32((hottest-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
rgb+=glow.rgb/255.*glow.a*1.35;
}
}
if(u.lamp.w>0.) {
let d=distance(vec2f(q),u.lamp.xy)/u.lamp.z;
if(d<1.) { rgb=max(rgb,(1.-d)*(1.-d)*1.05*vec3f(1,0.93,0.8)); }
}
textureStore(dest,q,vec4f(rgb,a));
}`;
const GPU_LIGHT_SPREAD = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba16float, write>;
@group(0) @binding(2) var<uniform> direction: vec4i;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if(any(p>=size)) { return; }
let own=textureLoad(source,p,0); var rgb=own.rgb;
let x=p+vec2i(direction.x,0); let y=p+vec2i(0,direction.x);
if(all(x>=vec2i(0)) && all(x<size)) { rgb=max(rgb,textureLoad(source,x,0).rgb*own.a); }
if(all(y>=vec2i(0)) && all(y<size)) { rgb=max(rgb,textureLoad(source,y,0).rgb*own.a); }
textureStore(dest,p,vec4f(rgb,own.a));
}`;
const GPU_LIGHT_PACK = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var<uniform> u: array<vec4f,4>;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); if(any(p>=vec2i(textureDimensions(source)))) { return; }
let rgb=floor(clamp(max(textureLoad(source,p,0).rgb,vec3f(u[3].y)),vec3f(0),vec3f(1))*255.)/255.;
textureStore(dest,p,vec4f(rgb,1));
}`;
function gpuLightGrid() {
const res = SET.lighting === 'fast' ? 4 : 2, M = Math.ceil(28 / res);
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
const key = `${lw}/${lh}/${res}`;
const update = GPU.lightKey !== key || GPU.lightWorld !== mat || GPU.lightFrame === undefined || R.frame % SET.lightEvery === 0;
if (update) {
GPU.lightKey = key; GPU.lightWorld = mat; GPU.lightFrame = R.frame;
GPU.lightGrid = [Math.floor(R.vx0 / res) - M, Math.floor(R.vy0 / res) - M, lw, lh, res];
}
[R.lx0, R.ly0, R.lw, R.lh, R.lres] = GPU.lightGrid;
return update;
}
function gpuLightBindings(group, view, buffer) {
const d = GPU.device;
if (GPU.lightUniform) { GPU.lightUniform.destroy(); GPU.lightRoofs.destroy(); }
GPU.lightUniform = d.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
GPU.lightRoofs = d.createBuffer({ size: Math.max(1,R.lw) * 4, usage: GPUBufferUsage.STORAGE });
const shared = [buffer(GPU.lightUniform), buffer(GPU.lut), buffer(GPU.columns),
...[0,1,3,4].map(i => view(GPU.worldTextures[i])), view(GPU.mach), view(GPU.block), view(GPU.temp), buffer(GPU.lightRoofs)];
// Auto layouts contain only bindings used by each entry point.
GPU.bindRoof = d.createBindGroup({ layout: GPU.lightRoof.getBindGroupLayout(0), entries: shared.map((resource,binding)=>({binding,resource})).filter(e=>![6,9].includes(e.binding)) });
GPU.bindSources = group(GPU.lightSources, [...shared, view(GPU.lightWork[0])]);
if (!GPU.lightDirections) GPU.lightDirections = [-1,1].map(x => {
const b=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
d.queue.writeBuffer(b,0,new Int32Array([x,0,0,0])); return b;
});
GPU.bindSpread = GPU.lightDirections.map(b => [0,1].map(i => group(GPU.lightSpread, [view(GPU.lightWork[i]),view(GPU.lightWork[1-i]),buffer(b)])));
GPU.bindPack = group(GPU.lightPack, [view(GPU.lightWork[0]),view(GPU.lightTex),buffer(GPU.lightUniform)]);
GPU.lightFrame = undefined;
}
function gpuLight(encoder, timed) {
const q = GPU.device.queue, res = R.lres;
q.writeBuffer(GPU.lightUniform,0,new Float32Array([R.lx0,R.ly0,R.lw,R.lh,W,H,res,0,
(bot.x+bot.w/2)/res-R.lx0,(bot.y+bot.h/2)/res-R.ly0,34/res,+bot.lamp,
SET.daylight*(1-0.32*(R.rain||0)),SET.ambient,0,0]));
const pass = encoder.beginComputePass(timed ? {timestampWrites:{querySet:GPU.queries,beginningOfPassWriteIndex:2,endOfPassWriteIndex:3}} : {});
pass.setPipeline(GPU.lightRoof); pass.setBindGroup(0,GPU.bindRoof); pass.dispatchWorkgroups(Math.ceil(R.lw/64));
pass.setPipeline(GPU.lightSources); pass.setBindGroup(0,GPU.bindSources); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
pass.setPipeline(GPU.lightSpread);
// 320 grains per direction: even maximum emission falls below one packed level.
const count = 320/res;
for(let direction=0;direction<2;direction++) for(let i=0;i<count;i++) {
pass.setBindGroup(0,GPU.bindSpread[direction][i&1]); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
}
pass.setPipeline(GPU.lightPack); pass.setBindGroup(0,GPU.bindPack); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
pass.end();
}
+42
View File
@@ -0,0 +1,42 @@
// Half-resolution separable Gaussian bloom, then nearest colour + linear light.
const GPU_BLUR = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var<uniform> blur: vec4f;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if (any(p>=size)) { return; }
var sum=vec3f(0); var weight=0.; let sigma=blur.z;
let radius=i32(ceil(sigma*3.));
for (var j=-radius;j<=radius;j++) {
let w=exp(-f32(j*j)/(2.*sigma*sigma)); let q=p+vec2i(blur.xy)*j;
if (all(q>=vec2i(0)) && all(q<size)) { sum+=textureLoad(source,q,0).rgb*w; }
weight+=w;
}
textureStore(dest,p,vec4f(sum/weight,1));
}`;
const GPU_DRAW = `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var colour: texture_2d<f32>;
@group(0) @binding(2) var light: texture_2d<f32>;
@group(0) @binding(3) var glow: texture_2d<f32>;
@group(0) @binding(4) var linear: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
}
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let cell=(p.xy-u.camera.xy)/u.camera.z;
var c=textureLoad(colour,vec2i(floor(cell)),0).rgb;
if (u.light.w>0.) {
let world=p.xy/u.camera.z+u.extra.xy;
let uv=(world/u.light.z-u.light.xy)/vec2f(textureDimensions(light));
let l=textureSampleLevel(light,linear,uv,0).rgb;
c=round(c*l*255.)/255.;
}
if (u.effects.w>0.) {
let uv=cell/(2.*vec2f(textureDimensions(glow)));
c+=textureSampleLevel(glow,linear,uv,0).rgb*u.screen.z;
}
return vec4f(c,1);
}`;
+368
View File
@@ -0,0 +1,368 @@
// Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
const GPU_SMOOTH = `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var tCol: texture_2d<f32>;
@group(0) @binding(2) var tBack: texture_2d<f32>;
@group(0) @binding(3) var tTree: texture_2d<f32>;
@group(0) @binding(4) var tField: texture_2d<f32>;
@group(0) @binding(5) var tLight: texture_2d<f32>;
@group(0) @binding(6) var tGlow: texture_2d<f32>;
@group(0) @binding(7) var linear: sampler;
@group(0) @binding(8) var<uniform> su: vec4f;
fn sampleLevel(t: texture_2d<f32>, uv: vec2f, level: f32) -> vec4f { return textureSampleLevel(t,linear,uv,level); }
fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); }
fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
fn vn(p: vec2f) -> f32 {
var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2f(1, 0)), f.x), mix(h21(i + vec2f(0, 1)), h21(i + vec2f(1, 1)), f.x), f.y);
}
fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
fn lin(t: texture_2d<f32>, q: vec2f) -> vec4f { return sampleLevel(t, q / (u.view.zw), 0.0); }
fn bsp(t: texture_2d<f32>, q: vec2f) -> vec4f {
var tc: vec2f = q - 0.5; var i: vec2f = floor(tc); var f: vec2f = tc - i; var f2: vec2f = f * f; var f3: vec2f = f2 * f;
var w0: vec2f = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0; var w1: vec2f = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
var w2: vec2f = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0; var w3: vec2f = f3 / 6.0;
var g0: vec2f = w0 + w1; var g1: vec2f = w2 + w3;
var h0: vec2f = (i - 0.5 + w1 / g0) / (u.view.zw); var h1: vec2f = (i + 1.5 + w3 / g1) / (u.view.zw);
return g0.y * (g0.x * sampleLevel(t, h0, 0.0) + g1.x * sampleLevel(t, vec2f(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * sampleLevel(t, vec2f(h0.x, h1.y), 0.0) + g1.x * sampleLevel(t, h1, 0.0));
}
fn cellAt(t: texture_2d<f32>, c: vec2i) -> vec4f { return textureLoad(t, clamp(c, vec2i(0), vec2i((u.view.zw)) - 1), 0); }
fn cls(c: vec4f) -> i32 { return i32(c.a * 255.0 + 0.5); }
fn isT(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ); }
fn isM(n: i32) -> f32 { return select( isT(n), 1.0 ,n == 2 ); }
fn isF(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 13 || n == 14) ); }
fn isBelt(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 7 || n == 8) ); }
fn isN(n: i32, k: i32) -> f32 { return select( 0.0, 1.0 ,n == k ); }
fn beltSD(lb: vec2f, endL: bool, endR: bool) -> f32 {
var x0: f32 = select( -2.0, 0.15 , endL ); var x1: f32 = select( 6.0, 3.85 , endR ); var r: f32 = 0.75;
var c: vec2f = vec2f((x0 + x1) * 0.5, 2.0); var hs: vec2f = vec2f((x1 - x0) * 0.5, 1.85);
var q: vec2f = abs(lb - c) - hs + r;
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
fn beltLineSD(c: vec2i, lb: vec2f) -> f32 {
var caps: i32 = i32(cellAt(tTree, c).a * 255.0 + 0.5);
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
}
fn bandRidge(w: vec2f, lb: vec2f, dir: f32) -> f32 {
var run: f32 = select( -1.0, 1.0 , lb.y < 2.0 );
return step(0.72, fract((w.x - dir * run * (su.x) * 30.0) * 0.5));
}
fn bandColor(lb: vec2f, rg: f32) -> vec3f { return mix(vec3f(0.2, 0.21, 0.24), vec3f(0.34, 0.35, 0.39), rg) * (select( 0.72, 1.0 ,lb.y < 2.0 )); }
${GPU_SMOOTH_SLOPE_HELPERS}
@fragment fn fs(@builtin(position) position: vec4f) -> @location(0) vec4f {
let uView=vec4f(u.extra.xy-u.view.xy,u.camera.z,u.screen.y);
let uLightMap=vec4f(u.light.xy*u.light.z-u.view.xy,u.light.z,u.light.w);
var zp: f32 = uView.z; var px: f32 = 1.0 / zp;
var sp: vec2f = vec2f(position.x, position.y);
var q: vec2f = sp * px + uView.xy;
var w: vec2f = q + (u.view.xy);
var cam: vec2f = (u.view.xy) + uView.xy;
var scr: vec2f = q - uView.xy;
var shc: f32 = uView.w * px;
var c: vec2i = vec2i(floor(q));
var qq: vec2f = q - 0.5; var c0: vec2i = vec2i(floor(qq)); var t: vec2f = qq - floor(qq);
var k0: vec4f = cellAt(tCol, c0); var k1: vec4f = cellAt(tCol, c0 + vec2i(1, 0)); var k2: vec4f = cellAt(tCol, c0 + vec2i(0, 1)); var k3: vec4f = cellAt(tCol, c0 + vec2i(1, 1));
var n0: i32 = cls(k0); var n1: i32 = cls(k1); var n2: i32 = cls(k2); var n3: i32 = cls(k3);
var own: vec4f = cellAt(tCol, c); var nc: i32 = cls(own);
var s: vec2f = smoothstep(vec2f(0.1), vec2f(0.9), t);
var wq: vec4f = vec4f((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 screen derivatives before branching.
var Fb: vec4f = bsp(tField, q);
var F: vec4f = mix(lin(tField, q), Fb, 0.62);
var wl: vec4f = vec4f((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
var pw: f32 = dot(wl, vec4f(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
var M: f32 = clamp(F.r + pw, 0.0, 1.0);
var lf: f32 = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
var fwT: f32 = max(fwidth(F.r), 1e-4); var fwM: f32 = max(fwidth(M), 1e-4); var fwA: f32 = max(fwidth(F.a), 1e-4); var fwL: f32 = max(fwidth(lf), 1e-4);
var gT: vec2f = vec2f(dpdx(mix(Fb.r, M, 0.5)), -dpdy(mix(Fb.r, M, 0.5))); var gL: vec2f = vec2f(dpdx(F.g), -dpdy(F.g));
// Sky, clouds, parallax hills and back-wall AO.
var back: vec4f = lin(tBack, q);
var C: vec3f = back.rgb;
var sky: f32 = back.a;
if (sky > 0.01) {
var S: vec3f = C;
var P0: vec2f = scr + cam * 0.08;
var cl: f32 = smoothstep(0.56 - 0.3 * (u.style.y), 0.78 - 0.2 * (u.style.y), fbm(vec2f(P0.x * 0.016 + (su.x) * 0.006, P0.y * 0.05)));
var hi: f32 = clamp(((su.y) - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3f(0.96, 0.95, 0.93), vec3f(0.5, 0.52, 0.56), (u.style.y)), cl * (0.6 + 0.3 * (u.style.y)) * hi);
var k1: f32 = 0.16; var P1: vec2f = scr + cam * k1;
var base1: f32 = shc * 0.5 + 8.0 + ((su.y) - shc * 0.5) * k1;
var top1: f32 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2f(P1.x * 0.009, 1.7)));
var m1: f32 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
var far: vec3f = mix(vec3f(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
var k2: f32 = 0.38; var P2: vec2f = scr + cam * k2;
var base2: f32 = shc * 0.5 + 14.0 + ((su.y) - shc * 0.5) * k2;
var top2: f32 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2f(P2.x * 0.016, 7.3)));
var m2: f32 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
var near: vec3f = vec3f(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);
}
var notSky: f32 = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
var near: f32 = sampleLevel(tField, q / (u.view.zw), 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// Trees behind terrain and built pieces.
if (F.a > 0.2) {
var a0: vec4f = cellAt(tTree, c0); var a1: vec4f = cellAt(tTree, c0 + vec2i(1, 0)); var a2: vec4f = cellAt(tTree, c0 + vec2i(0, 1)); var a3: vec4f = cellAt(tTree, c0 + vec2i(1, 1));
var m: vec4f = vec4f(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
var tc: vec3f = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
var leaf: f32 = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
var lv: f32 = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2f(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
var d: f32 = (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.
if (Fb.b > 0.02) {
var m: vec4f = vec4f(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
var gc: vec3f = select( own.rgb, (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm , sm > 1e-4 );
if (sm > 1e-4 || nc == 5) { C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); }
}
// Liquids with a glossy top edge.
var anyL: f32 = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
var m: vec4f = vec4f(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
var sm: f32 = m.x + m.y + m.z + m.w;
var lc: vec3f = (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 + vec2f((su.x) * 0.7, (su.x) * 0.25));
var gl: f32 = length(gL);
var topSide: f32 = select( 0.0, clamp(gL.y / gl, 0.0, 1.0) , gl > 1e-5 );
var band: f32 = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3f(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// Rounded ground and the seam against loose grains.
if (F.r > 0.3) {
var m: vec4f = vec4f(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
var blur: vec3f = bsp(tCol, q).rgb; var tc: vec3f = own.rgb;
var bd: f32 = 1e9;
if (m.x > 0.0) { var e: vec3f = k0.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { var e: vec3f = k1.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { var e: vec3f = k2.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { var e: vec3f = k3.rgb - blur; var dd: f32 = 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);
var gl: f32 = length(gT);
var nrm: vec2f = select( vec2f(0.0), -gT / gl , gl > 1e-6 );
var band: f32 = 1.0 - smoothstep(0.5, 0.9, M);
var lit: f32 = dot(nrm, normalize(vec2f(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
var dm: f32 = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
var dt: f32 = (F.r - 0.5) / fwT;
if (nc == 1 && pw > 0.0) { tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); }
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
${GPU_SMOOTH_SLOPE_DRAW}
// Round loose grains.
var np: i32 = i32(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
var closed: bool = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
var best: f32 = 1e9; var gc: vec3f = vec3f(0.0); var gd: vec2f = vec2f(0.0); var gr: f32 = 0.6;
for (var k: i32 = 0; k < 4; k++) {
var o4: vec2i = vec2i(k & 1, k >> 1);
var nk: i32 = select(select(select( n3, n2 , k == 2 ), n1 , k == 1 ), n0 , k == 0 );
if (nk != 2) { continue; }
var ck: vec4f = select(select(select( k3, k2 , k == 2 ), k1 , k == 1 ), k0 , k == 0 );
var ce: vec2f = vec2f(c0 + o4) + 0.5;
var r: f32 = 0.6 + 0.1 * (h21(ce + (u.view.xy)) - 0.5);
var dv: vec2f = (q - ce) / r;
var dd: f32 = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
var cover: f32 = select( 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best), 1.0 , np == 4 );
if (closed && F.r < 0.5) { cover = 1.0; }
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
if (cover > 0.0) {
var r2: f32 = min(1.0, dot(gd, gd));
var n3: vec3f = normalize(vec3f(gd * 0.85, sqrt(1.0 - r2) + 0.25));
var sh: f32 = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3f(-0.45, -0.8, 0.6))));
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
var pc: vec3f = (select( gc, (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) ,np == 4 )) * sh;
if (!closed) { pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); }
C = mix(C, pc, cover);
}
}
// Built pieces: belts, screws, wall tiles and filters.
if (nc == 4) { C = own.rgb; }
else if (nc == 7 || nc == 8) {
var dir: f32 = select( -1.0, 1.0 , nc == 8 );
var lb: vec2f = fract(w * 0.25) * 4.0;
var sd: f32 = beltLineSD(c, lb);
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
var edge: f32 = -sd;
var bc: vec3f;
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 {
var rc: vec2f = vec2f(select( 3.0, 1.0 ,lb.x < 2.0 ), 2.0); var dv: vec2f = lb - rc;
var rd: f32 = length(dv);
bc = vec3f(0.15, 0.16, 0.18);
if (rd < 0.95) {
var ang: f32 = atan2(dv.y, dv.x) - dir * (su.x) * 30.0 / 0.95;
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-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) || (nc >= 19 && nc <= 22)) {
var vert: bool = nc == 9 || nc == 11;
var sp2: f32 = select(select(select( -1.0, 1.0 , nc == 11 ), 1.0 , nc == 10 ), -1.0 , nc == 9 );
var a: f32 = (select( fract(w.y * 0.25), fract(w.x * 0.25) ,vert )) * 4.0; var along: f32 = select( w.x, w.y , vert );
var screwSD: f32 = -1.0; var capRing: f32 = 0.0;
var screwTag: vec4f = cellAt(tTree, c);
var screwJoins: i32 = i32(screwTag.a * 255.0 + 0.5);
var shaped: bool = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
if (shaped) {
var d: vec2f = vec2f(1.0,select( 1.0, -1.0 , nc == 19 || nc == 21 ));
var tag: vec4f = screwTag;
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
var joins: i32 = screwJoins;
screwSD = 100.0;
for (var end: i32 = 0; end < 2; end++) {
var join: i32 = (joins >> u32(end * 3)) & 7;
var side: f32 = select( 1.0, -1.0 , end == 0 );
var diagonal: bool = select( join != 0, join == 0 || join >= 5 , nc >= 19 );
var grade: f32 = select(select( 1.0, -1.0 , join == 1 ), d.y , nc >= 19 );
var ray: vec2f = select(select(select(select(select( vec2f(-1, 0), vec2f(0, 1) , join == 3 ), vec2f(1, 0) , join == 2 ), vec2f(0, -1) ,
join == 1 ), (select( vec2f(side, 0), vec2f(0, side) ,vert )) ,
nc < 19 ), (select( vec2f(side, side * grade), vec2f(side * grade, side) ,vert )) * 0.70710678 , diagonal );
var t: f32 = dot(local, ray); var cross2: f32 = dot(local, vec2f(-ray.y, ray.x));
var radius: f32 = length(vec2f(cross2, min(0.0, t))); var cap: f32 = t - (select( 2.0, 2.82842712 ,diagonal ));
var sd: f32 = max(radius - 2.0,select( -10.0, cap , (joins & (64 << u32(end))) != 0 ));
if (sd < screwSD) {
screwSD = sd;
a = 2.0 + (select( radius, -radius ,side * cross2 < 0.0 ));
along = (select(select( w.x - local.x, w.y - local.y , vert ), dot(w - local, d) * 0.70710678 ,nc >= 19 )) + side * t;
capRing = select( 0.0, smoothstep(-0.55, -0.15, cap) ,(joins & (64 << u32(end))) != 0 );
}
}
if (nc >= 19) { sp2 = select( -1.0, 1.0 ,nc == 19 || nc == 20 ); }
}
var wall: f32 = 0.62;
var tc: vec3f;
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
var u2: f32 = select( (4.0 - a) / wall, a / wall , a < wall );
var cyl: f32 = sin(clamp(u2, 0.0, 1.0) * 1.5708);
tc = vec3f(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) { tc *= 1.08; }
var ring: f32 = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
if (nc < 19) { 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 {
var full: bool = own.r + own.g + own.b > 0.0;
var fl: f32 = fract((along + a * 0.9 - sp2 * (su.x) * 14.0) * 0.5);
var flight: f32 = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
var bore: vec3f = vec3f(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = select( bore, own.rgb * (0.9 + 0.1 * vn(w * 1.3)) ,full );
tc = mix(tc, vec3f(0.6, 0.48, 0.38), flight * (select( 0.8, 0.35 ,full )));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
if (shaped) {
var full: bool = own.r + own.g + own.b > 0.0;
if (full) { C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); }
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
} else { C = tc; }
}
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
var lb: vec2f = fract(w * 0.25) * 4.0; var rb: vec2f = 4.0 - lb;
var e: f32 = min(min(lb.x, lb.y), min(rb.x, rb.y));
var tc: vec3f = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
var hl: f32 = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
var shd: f32 = 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));
if (nc >= 15) {
var sl: vec2f = lb;
if (nc == 16 || nc == 18) { sl.x = 4.0 - sl.x; }
if (nc >= 17) { sl.y = 4.0 - sl.y; }
var d: f32 = (sl.y - sl.x) * 0.70710678;
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
} else { C = tc; }
}
else if (nc == 13 || nc == 14) {
var dir: f32 = select( -1.0, 1.0 , nc == 14 );
var lb: vec2f = fract(w * 0.25) * 4.0;
var tag: vec4f = cellAt(tTree, c);
var carry: bool = tag.a > 0.16; var tinted: bool = tag.r + tag.g + tag.b > 0.0;
var sd: f32 = beltLineSD(c, lb);
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
var edge: f32 = -sd;
var steel: vec3f = vec3f(0.42, 0.44, 0.5);
var behind: vec3f = select( C * 0.8, own.rgb , own.r + own.g + own.b > 0.0 );
var cg: vec2f = abs(fract(w) - 0.5);
var bw2: f32 = max(px * 0.9, 0.09);
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
var fc: vec3f;
if (edge < 0.8) {
var rg: f32 = bandRidge(w, lb, dir);
var band: vec3f = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) { band = mix(band, tag.rgb * (select( 0.8, 1.0 ,lb.y < 2.0 )), 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 legacy nine-tap glow filter.
if (uLightMap.w > 0.5) { C *= sampleLevel(tLight, (q - uLightMap.xy) / uLightMap.z / (vec2f(textureDimensions(tLight))), 0.0).rgb; }
if ((su.z) > 0.0) {
var gu: vec2f = q / (2.0 * (vec2f(textureDimensions(tGlow)))); var d: vec2f = 1.0 / (vec2f(textureDimensions(tGlow)));
var g: vec3f = sampleLevel(tGlow, gu, 0.0).rgb * 0.25;
g += (sampleLevel(tGlow, gu + vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(0, d.y), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(0, d.y), 0.0).rgb) * 0.125;
g += (sampleLevel(tGlow, gu + d, 0.0).rgb + sampleLevel(tGlow, gu - d, 0.0).rgb + sampleLevel(tGlow, gu + vec2f(d.x, -d.y), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * (su.z);
}
return vec4f(C, 1.0);
}`;
+107
View File
@@ -0,0 +1,107 @@
// Smooth slope geometry and cleats, ported from slope-belt-shader.js.
const GPU_SMOOTH_SLOPE_HELPERS = `fn slopeStroke(p: vec2f, a: vec2f, b: vec2f, origin: f32) -> vec3f {
var ray: vec2f = b - a; var len: f32 = length(ray); ray /= len;
var along: f32 = dot(p - a, ray); var across: f32 = dot(p - a, vec2f(-ray.y, ray.x));
var q: vec2f = abs(vec2f(along - len * 0.5, across)) - vec2f(len * 0.5 - 0.75, 1.1);
var sd: f32 = length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - 0.75;
return vec3f(sd, origin + along, across + 2.0);
}
fn slopeCorner(p: vec2f, a: vec2f, origin: f32) -> vec3f {
var d: vec2f = p - a; var radius: f32 = length(d);
return vec3f(radius - 1.85, origin + d.x, 2.0 + d.y);
}
fn slopeShape(inputP: vec2f, worldOrigin: vec2f, grade: f32, joins: i32) -> vec3f { var p=inputP;
if ((joins & 64) != 0) { p.y -= 0.45; }
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
var a: vec2f = vec2f(-grade * 1.30814755, sy + 1.30814755); var b: vec2f = vec2f(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
else if ((joins & 4) != 0) { a -= vec2f(4.0, grade * 4.0); }
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
else if ((joins & 8) != 0) { b += vec2f(4.0, grade * 4.0); }
var axis: vec2f = vec2f(1.0, grade) * 0.70710678;
var best: vec3f = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
if ((joins & 1) != 0) {
var h: vec3f = slopeStroke(p, vec2f(-2.0, a.y), a, worldOrigin.x - 2.0);
if (h.x < best.x) { best = h; }
h = slopeCorner(p, a, worldOrigin.x + a.x);
if (h.x < best.x) { best = h; }
}
if ((joins & 2) != 0) {
var h: vec3f = slopeStroke(p, b, vec2f(6, b.y), worldOrigin.x + b.x);
if (h.x < best.x) { best = h; }
h = slopeCorner(p, b, worldOrigin.x + b.x);
if (h.x < best.x) { best = h; }
}
return best;
}
`;
const GPU_SMOOTH_SLOPE_DRAW = ` if (nc == 31 || nc == 32) {
var tag: vec4f = cellAt(tTree, c);
var grade: f32 = select( 1.0, -1.0 , nc == 31 );
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
var foot: f32 = (local.x + grade * (local.y - sy)) * 0.5;
var depth: f32 = (grade * local.x - local.y + sy) * 0.70710678;
var wallFlags: i32 = i32(tag.a * 255.0 + 0.5);
var ends: f32 = max(select( -100.0, -foot ,(wallFlags & 8) == 0 ),select( -100.0, foot - 4.0 , (wallFlags & 16) == 0 ));
var sd: f32 = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
var phase: f32 = f32(wallFlags & 7);
var wave: f32 = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
var rubber: vec3f = mix(vec3f(0.20,0.22,0.25), vec3f(0.25,0.27,0.31), wave);
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
var dirS: f32 = select( -1.0, 1.0 , (wallFlags & 32) != 0 );
if (grade * dirS < 0.0) {
var rc: f32 = (w.x - local.x) + foot - (select( 0.0, 1.0 ,grade < 0.0 )) - dirS * phase;
var off: f32 = (modulo(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
var ribTop: f32 = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
var rd: f32 = length(vec2f(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
var rib: vec3f = mix(vec3f(0.50,0.52,0.56), vec3f(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
}
}
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(i32(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
var slopeClass: i32 = i32(cellAt(tTree, c).r * 255.0 + 0.5);
var shape: i32 = (slopeClass - 23) / 2;
var mesh: bool = shape >= 2;
var grade: f32 = select( 1.0, -1.0 , (shape == 0 || shape == 2) );
var dir: f32 = select( -1.0, 1.0 , ((slopeClass - 23) % 2) == 1 );
var tag: vec4f = cellAt(tTree, c);
var joins: i32 = i32(tag.a * 255.0 + 0.5);
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
var shapePos: vec3f = slopeShape(local, w - local, grade, joins);
var sd: f32 = shapePos.x; var axis: f32 = shapePos.y; var depth: f32 = shapePos.z;
var time: f32 = select( 0.0, (su.x) , !mesh && (joins & 16) != 0 );
var lb: vec2f = vec2f(axis, depth); var alongWorld: vec2f = vec2f(axis, depth);
var rg: f32 = step(0.72, fract((axis - dir * (select( -1.0, 1.0 ,depth < 2.0 )) * time * 30.0) * 0.5));
var belt: vec3f;
if (-sd < select(0.8,1.1,mesh)) {
belt = select( bandColor(lb, rg), mix(vec3f(0.22,0.25,0.29), vec3f(0.48,0.53,0.59),select( 0.35, 0.85 , depth < 2.0 )) ,mesh );
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
var cg: vec2f = abs(fract(alongWorld) - 0.5);
var bw2: f32 = max(px * 0.9, 0.09);
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
var steel: vec3f = select( vec3f(0.42, 0.44, 0.5), mix(vec3f(0.42, 0.44, 0.5), own.rgb, 0.4) , dot(own.rgb, vec3f(1)) > 0.01 );
belt = mix(steel * 0.78, C * 0.8, hole);
} else {
var dv: vec2f = vec2f(modulo(axis, 2.0) - 1.0, depth - 2.0);
var rd: f32 = length(dv);
belt = vec3f(0.15, 0.16, 0.18);
if (rd < 0.95) {
var ang: f32 = atan2(dv.y, dv.x) - dir * time * 30.0 / 0.95;
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
belt = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
}
`;
+48
View File
@@ -0,0 +1,48 @@
// Field mip levels preserve the legacy trilinear AO sample at level 2.2.
const GPU_FIELD_MIP = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var linear: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
}
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let size=max(vec2u(1),textureDimensions(source)/2u);
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
}`;
async function gpuSmoothInit() {
const d=GPU.device;
const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
}
function gpuSmoothTextures() {
const w=R.bw,h=R.bh;
GPU.smoothOver=Array.from({length:3},()=>gpuTexture(w,h));
GPU.smoothMask=gpuTexture(w,h,'r8uint');
GPU.backTex=gpuTexture(w,h,'rgba8unorm',true);
GPU.treeTex=gpuTexture(w,h,'rgba8unorm',true);
GPU.fieldTex=GPU.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:Math.min(4,1+Math.floor(Math.log2(Math.max(w,h)))),usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.RENDER_ATTACHMENT});
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
}
function gpuSmoothBindings(group,view,buffer) {
const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)];
GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)});
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
GPU.fieldMips=[];
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
bind:group(GPU.fieldMip,[GPU.fieldTex.createView({baseMipLevel:i-1,mipLevelCount:1}),GPU.sampler]),
view:GPU.fieldTex.createView({baseMipLevel:i,mipLevelCount:1})
});
}
function gpuSmoothUpload() {
for(const [i,a] of [R.backPx,R.treePx,R.fieldPx].entries()) gpuWrite(GPU.smoothOver[i],a,R.bw,0,0,R.bw,R.bh);
gpuWrite(GPU.smoothMask,GPU.overrideMask,R.bw,0,0,R.bw,R.bh);
GPU.device.queue.writeBuffer(GPU.smoothUniform,0,new Float32Array([(performance.now()-SM.t0)/1000,smoothSurface(),SET.glow==='off'?0:SET.glow==='high'?0.9:0.55,0]));
}
function gpuSmoothMips(encoder) {
for(const m of GPU.fieldMips) {
const pass=encoder.beginRenderPass({colorAttachments:[{view:m.view,loadOp:'clear',storeOp:'store'}]});
pass.setPipeline(GPU.fieldMip);pass.setBindGroup(0,m.bind);pass.draw(3);pass.end();
}
}
+5
View File
@@ -0,0 +1,5 @@
const SM = { t0: performance.now(), surf: -1 };
function smoothSurface() {
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
return SM.surf;
}
+98
View File
@@ -0,0 +1,98 @@
// Whole-world textures retain clean chunks across camera moves.
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) });
}
function gpuWrite(texture, data, stride, x, y, w, h) {
if (w <= 0 || h <= 0) return;
const bytes = data.BYTES_PER_ELEMENT;
GPU.device.queue.writeTexture({ texture, origin: [x, y] }, data, { offset: (y * stride + x) * bytes, bytesPerRow: stride * bytes }, [w, h]);
}
function gpuBuffer(data) {
const b = GPU.device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
GPU.device.queue.writeBuffer(b, 0, data); return b;
}
function gpuWorldTextures() {
if (GPU.world === mat && GPU.sky === SKY_R) return;
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
GPU.world = mat; GPU.sky = SKY_R;
GPU.resident = new Uint8Array(NCH);
GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint'));
GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp);
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 4);
for (let m = 0; m < NMAT; m++) {
tables.set([COLR[m], COLG[m], COLB[m], CVAR[m], PH[m], ALPHA[m], 0, 0, TREE_R[m], TREE_G[m], TREE_B[m], 0], m * 12);
}
for (let i = 0; i < 700; i++) tables.set([GLOW_R[i], GLOW_G[i], GLOW_B[i], GLOW_W[i]], (NMAT * 3 + i) * 4);
for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4);
tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4);
GPU.lut = gpuBuffer(tables);
GPU.landData = new Float32Array((W + H) * 4);
GPU.land = gpuBuffer(GPU.landData);
if (GPU.columns) GPU.columns.destroy();
GPU.columnData = new Float32Array(W * 4); GPU.columns = gpuBuffer(GPU.columnData);
GPU.mach = gpuTexture(W, H, 'r16uint'); GPU.block = gpuTexture(BW, BH, 'r8uint');
GPU.worldTextures.push(GPU.mach, GPU.block);
GPU.bindColour = null;
}
function gpuViewTextures() {
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`;
if (GPU.viewKey === key) return;
GPU.viewKey = key; GPU.lightFrame = undefined;
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
const bw = R.bw, bh = R.bh, gw = R.glowW, gh = R.glowH;
GPU.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true);
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
if (R.smoothOn) gpuSmoothTextures();
GPU.bindColour = null;
}
function gpuBindings() {
if (GPU.bindColour) return;
const d = GPU.device, buffer = b => ({ buffer: b }), view = t => t.createView();
const group = (pipeline, resources) => d.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: resources.map((resource, binding) => ({ binding, resource })) });
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
gpuLightBindings(group, view, buffer);
GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]);
GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]);
GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]);
if (GPU.blurUniforms) for (const b of GPU.blurUniforms) b.destroy();
GPU.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }));
GPU.bindBlur = [group(GPU.blur, [view(GPU.glowTex), view(GPU.blurX), buffer(GPU.blurUniforms[0])]), group(GPU.blur, [view(GPU.blurX), view(GPU.blurY), buffer(GPU.blurUniforms[1])])];
}
function gpuUpload(lighting, glowOn) {
gpuWorldTextures(); gpuViewTextures(); gpuBindings();
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - y;
let top = y;
if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]);
gpuUploadChunks(x, top, x + w, y + h);
// Small per-row/column tables may change in scene tools independently of world allocation.
const land = GPU.landData;
for (let i = 0; i < W; i++) { const k = i * 4; land[k] = surfY[i]; land[k + 1] = dirtY[i]; land[k + 2] = graniteY[i]; land[k + 3] = basaltY[i]; }
for (let i = 0; i < H; i++) { const k = (W + i) * 4; land[k] = SKY_R[i]; land[k + 1] = SKY_G[i]; land[k + 2] = SKY_B[i]; land[k + 3] = AMB[i]; }
const q = GPU.device.queue;
q.writeBuffer(GPU.land, 0, land);
gpuWrite(GPU.override, R.px, R.bw, 0, 0, R.bw, R.bh);
gpuWrite(GPU.overrideGlow, R.glowPx, R.glowW, 0, 0, R.glowW, R.glowH);
for(let i=0;i<W;i++) { GPU.columnData[i*4]=surfY[i]; GPU.columnData[i*4+1]=roofY[i]; }
q.writeBuffer(GPU.columns,0,GPU.columnData);
if (R.smoothOn) gpuSmoothUpload();
const zp = R.zp, ox = Math.round(-(R.camX - R.vx0) * zp), oy = Math.round(-(R.camY - R.vy0) * zp);
GPU.params.set([R.vx0, R.vy0, R.bw, R.bh, W, H, FW, FH,
+SET.fog, R.rain, SET.grain, R.frame, +R.lens, Math.max(0, Object.keys(THERM).indexOf(SET.thermalPalette)), +SET.incandescence, +glowOn,
ox, oy, zp, 0, R.lx0, R.ly0, R.lres, +lighting,
R.canvas.width, R.canvas.height, SET.glow === 'high' ? 0.9 : 0.5, 0, R.camX, R.camY, 0, 0]);
q.writeBuffer(GPU.uniform, 0, GPU.params);
const sigma = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)) / (2 * zp);
q.writeBuffer(GPU.blurUniforms[0], 0, new Float32Array([1, 0, sigma, 0]));
q.writeBuffer(GPU.blurUniforms[1], 0, new Float32Array([0, 1, sigma, 0]));
}
+48
View File
@@ -0,0 +1,48 @@
// Upload full chunks so scrolling within a resident chunk needs no extra copy.
function gpuUploadChunks(x0, y0, x1, y1) {
const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5);
const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32));
GPU.uploadStrips = 0; GPU.uploadCells = 0;
for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) {
const c = cy * CW + cx;
if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; }
const first = cx++;
while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++;
const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y);
gpuUploadStrip(x, y, w, h);
GPU.uploadStrips++; GPU.uploadCells += w * h;
for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; }
}
}
function gpuUploadStrip(x, y, w, h) {
for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h);
gpuWrite(GPU.temp, temp, W, x, y, w, h);
gpuWrite(GPU.mach, mach, W, x, y, w, h);
gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS));
// Fog interpolation reads the next sample, possibly in a neighbouring chunk.
gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1));
}
// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them.
async function gpuCheckUploads() {
const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]),
[GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]];
let checked = 0;
for (const [texture, data, stride, scale] of sources) {
const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256;
const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
const encoder = GPU.device.createCommandEncoder();
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]);
GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ);
const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength);
let mismatch = '';
for (let cy=0;cy<CH;cy++) for(let cx=0;cx<CW;cx++) {
const c=cy*CW+cx; if(!GPU.resident[c] || renderDirty[c]) continue;
for(let y=cy*32/scale;y<Math.min(texture.height,(cy+1)*32/scale);y++)
for(let x=cx*32/scale;x<Math.min(texture.width,(cx+1)*32/scale);x++) for(let b=0;b<bytes;b++) {
checked++; if(actual[y*row+x*bytes+b]!==expected[(y*stride+x)*bytes+b] && !mismatch) mismatch=`${texture.format} at ${x},${y}`;
}
}
buffer.unmap(); buffer.destroy(); if(mismatch) throw Error('Stale GPU upload: '+mismatch);
}
return checked;
}
-79
View File
@@ -1,79 +0,0 @@
// Propagated coloured light from glowing and burning cells.
// ---------- Lighting ----------
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
function computeLight() {
const res = SET.lighting === 'fast' ? 4 : 2;
const M = Math.ceil(28 / res);
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
if (lw !== R.lw || lh !== R.lh || res !== R.lres) {
R.lw = lw; R.lh = lh; R.lres = res;
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
R.lightC.width = lw; R.lightC.height = lh;
R.lightImg = R.lightX.createImageData(lw, lh);
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
}
R.lx0 = lx0; R.ly0 = ly0;
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
const P = res;
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
for (let ly = 0; ly < lh; ly++) {
const wy = (ly0 + ly) * res;
for (let lx = 0; lx < lw; lx++) {
const wx = (lx0 + lx) * res, k = ly * lw + lx;
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
const i = wy * W + wx, m = mat[i], ph = PH[m];
la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
let r = 0, g = 0, b = 0;
if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
// emission from heat: hottest grain in the block
let T = 0;
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
if (T > 520) {
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
}
lr[k] = r; lg[k] = g; lb[k] = b;
}
}
// bot lamp
if (bot.lamp) {
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
const d = Math.hypot(lx - cx, ly - cy) / rad;
if (d >= 1) continue;
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
}
}
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
// write the light canvas
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
for (let k = 0; k < n; k++) {
let r = lr[k], g = lg[k], b = lb[k];
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
}
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0);
}
+29 -15
View File
@@ -119,7 +119,7 @@ function drawProcBlocks() {
// 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) {
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;
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();
// legs, with short struts to the cone
@@ -146,22 +146,16 @@ function drawSilo(ctx, mm, sx, sy, zp) {
ctx.beginPath(); ctx.moveTo(X(1.3), Y(2)); ctx.lineTo(X(1.3), Y(19.8)); ctx.moveTo(X(2.3), Y(2)); ctx.lineTo(X(2.3), Y(19.8));
for (let yy = 2.6; yy < 19.8; yy += 1.1) { ctx.moveTo(X(1.3), Y(yy)); ctx.lineTo(X(2.3), Y(yy)); }
ctx.stroke();
// the sight glass: fills from the bottom in the colour of what is inside
const gx = 9.1, gy0 = 3, gy1 = 18.8, gh = gy1 - gy0;
ctx.fillStyle = '#14181c'; ctx.fillRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
if (mm.sn > 0) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(gx + 0.15), Y(gy1 - gh * full), zp * 0.8, zp * gh * full); }
ctx.strokeStyle = '#c9d1d8'; ctx.lineWidth = Math.max(1, zp * 0.1);
ctx.strokeRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
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(); }
drawSiloWindow(ctx, mm, sx, sy, zp, false);
// what it holds, stencilled on the drum
if (zp >= 3) {
renderMonoFont();
const fs = Math.max(8, Math.round(zp * 1.15));
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(20,24,28,0.75)';
ctx.fillText('SILO', X(5.6), Y(5));
ctx.fillText('SILO', X(4.5), Y(5), 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', X(5.6), Y(16.8));
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(4.5), Y(16.8), 5 * zp);
if (mm.sn) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(4.4), Y(17.9), zp * 2.4, zp * 0.55); }
}
// roof with a rim, and the loading hatch (its lid stands open)
@@ -173,7 +167,7 @@ function drawSilo(ctx, mm, sx, sy, zp) {
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 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.
@@ -191,8 +185,7 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) {
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');
drawSiloWindow(ctx, mm, sx, sy, zp, true);
// 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');
@@ -204,14 +197,35 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) {
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.fillText('SILO', sx + 4.5 * zp, sy + 5 * zp, 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);
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 4.5 * zp, sy + 16.8 * zp, 5 * zp);
if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); }
ctx.restore();
}
}
// The same framed, three-grain window in both looks; one grain remains visible near empty.
function drawSiloWindow(ctx, mm, sx, sy, zp, pixel) {
const height = mm.sn > 0 ? Math.max(1, Math.round(16 * Math.min(1, mm.sn / SILO_CAP))) : 0;
ctx.fillStyle = '#c9d1d8'; ctx.fillRect(sx + 7 * zp, sy + 2 * zp, 5 * zp, 18 * zp);
ctx.fillStyle = '#14181c'; ctx.fillRect(sx + 8 * zp, sy + 3 * zp, 3 * zp, 16 * zp);
if (!height) return;
// Lift dark fuel slightly so coal stays distinct from the empty glass.
const lift = Math.max(COLR[mm.sm], COLG[mm.sm], COLB[mm.sm]) < 80 ? 32 : 0;
if (pixel) {
for (let y = 19 - height; y < 19; y++) for (let x = 8; x < 11; x++) {
const h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263);
const n = lift + (((h ^ h >>> 13) >>> 0) % 3 - 1) * 8;
ctx.fillStyle = `rgb(${COLR[mm.sm] + n},${COLG[mm.sm] + n},${COLB[mm.sm] + n})`;
ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp);
}
} else {
ctx.fillStyle = `rgb(${COLR[mm.sm] + lift},${COLG[mm.sm] + lift},${COLB[mm.sm] + lift})`;
ctx.fillRect(sx + 8 * zp, sy + (19 - height) * zp, 3 * zp, height * zp);
}
}
// Shelves show each block's fill and materials; the bottom gate belongs to the column.
function drawRackBlocks() {
const ctx = R.ctx;
+80
View File
@@ -0,0 +1,80 @@
// Loose bars, flying grains and queued blueprint blocks in the grain buffer.
function worldObjects() {
const { bw, bh, px, vx0, vy0, smoothOn: sm, lens } = R;
const useFog = SET.fog, incand = SET.incandescence;
// bars and other loose objects
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;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const sx = ex + dx, sy = ey + dy;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
let r = COLR[m], g = COLG[m], b = COLB[m];
const end = dx === 0 || dx === BAR_W - 1;
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
if (e.T > 450 && incand) { const gi = glowIndex(e.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; }
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
}
}
// grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain)
if (!lens) for (const f of flyers) {
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
}
// blueprint ghosts: blocks waiting to be built
if (ghostBlocks.size) {
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
const pulse = (R.frame >> 3) & 3;
for (const [b, gv] of ghostBlocks) {
const gbx = b % BW, gby = (b / BW) | 0;
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks));
const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0;
const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3;
for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) {
let sd = 0;
if (diagonal) {
if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue;
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
if (sd > 0) continue;
}
if (slope) {
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code);
if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
if (rib) sd = -0.1;
}
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx;
if (R.gpuOn && !gpuOverrideHas(k)) cellPixel(sx + vx0, sy + vy0);
const c = px[k];
const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
}
}
}
}
+145 -4
View File
@@ -27,7 +27,7 @@ function drawRain() {
}
ctx.restore();
}
// Info overlay: a small arrow on every belt and screw tube block on screen, the way it moves things
// Info overlay: a small arrow on every belt and screw conveyor block on screen, the way it moves things
function drawFlowArrows() {
const ctx = R.ctx, zp = R.zp, v = viewCells();
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
@@ -37,12 +37,12 @@ function drawFlowArrows() {
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, code = blockP[b];
if (code !== BP_BELT && code !== BP_TUBE && code !== BP_PUMP && code !== BP_OUTLET) continue;
if (code !== BP_BELT && code !== BP_SCREW && code !== BP_PUMP && code !== BP_OUTLET) continue;
const vv = blockV[b];
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : [[0, -1], [1, 0], [0, 1], [-1, 0]][vv & 3];
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : SCREW_DIRS[vv & 7];
const c = worldToScreenPx(bx * BS + BS / 2, by * BS + BS / 2);
ctx.fillStyle = 'rgba(14,12,11,0.55)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.35, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = code === BP_TUBE ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
ctx.strokeStyle = code === BP_SCREW ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
// a chevron pointing (dx, dy)
const px = -dy, py = dx;
ctx.beginPath();
@@ -53,6 +53,147 @@ function drawFlowArrows() {
}
ctx.restore();
}
// "Doesn't connect": a red ring with a slash at a transport end that would join one block up or
// down (nearMiss), on built pieces, ghosts and the drag preview alike.
function drawJoinMark(bx, by, end, code, v) {
const e = pieceEnds(code, v)[end], ctx = R.ctx, r = Math.max(3, BS * R.zp * 0.24);
const c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS / 2, by * BS + BS / 2 + e.dy * BS / 2);
ctx.save();
ctx.fillStyle = 'rgba(14,12,11,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.2, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#ef6a55'; ctx.lineWidth = Math.max(1.2, r * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, r * 0.75, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(c.x - r * 0.53, c.y + r * 0.53); ctx.lineTo(c.x + r * 0.53, c.y - r * 0.53); ctx.stroke();
ctx.restore();
}
function drawJoinMarks() {
const v = viewCells();
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, ghost = ghostBlocks.get(b), code = ghost ? ghost[0] : blockP[b], vv = ghost ? ghost[1] : blockV[b];
if (!code || !pieceEnds(code, vv).length) continue;
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, ghostBlocks.size ? ghostBlocks : null)) drawJoinMark(bx, by, end, code, vv);
}
}
// Throughput (t49, info overlay): grains per second at the open outlet end of every line, and
// an orange glow on transport blocks that are loaded but not moving, where a line backs up.
function flowLoaded(b, code) {
const bx = b % BW, by = (b / BW) | 0;
let n = 0;
for (let k = 0; k < BS; k++) {
const x = bx * BS + k, i = code === BP_SCREW ? (by * BS + 1) * W + x : (beltFaceY(code, bx, by, x) - 1) * W + x;
if (i >= 0 && i < N && PH[mat[i]] === PH_POWDER) n++;
}
return n >= 3;
}
function drawFlowRates() {
const ctx = R.ctx, v = viewCells(), s = BS * R.zp;
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
ctx.save();
ctx.font = `600 ${Math.max(10, Math.min(14, s * 0.45))}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, code = blockP[b], ends = pieceEnds(code, blockV[b]);
if (!ends.length) continue;
const rate = flowRate[b] || 0;
if (rate < 0.5 && flowLoaded(b, code)) {
const p = worldToScreenPx(bx * BS, by * BS);
ctx.fillStyle = 'rgba(255,150,60,0.32)'; ctx.fillRect(p.x, p.y, s, s);
}
const out = ends[1].outlet ? 1 : 0;
if (endJoin(bx, by, out) >= 0) continue;
const e = ends[out], c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS * 0.95, by * BS + BS / 2 + e.dy * BS * 0.95 - (e.dy ? 0 : BS * 0.9));
const label = rate >= 10 ? `${Math.round(rate)}/s` : `${rate.toFixed(1)}/s`, tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(14,12,11,0.8)'; ctx.fillRect(c.x - tw / 2 - 4, c.y - 9, tw + 8, 18);
ctx.fillStyle = rate > 0 ? '#e7dccb' : '#ef9a6a'; ctx.fillText(label, c.x, c.y + 1);
}
ctx.restore();
}
// The resonance ping's echo: a faint ring spreading from the bot, the veins it found showing in
// their own lit colour inside it (with a slightly lighter rim so dark seams read against fog),
// a brief sparkle as the ring passes them; each vein fades a fixed time after the ring reached
// it, so the fade follows the ring outward. The veins are drawn once into a small image; each
// frame draws it in a few distance bands (fading ones at partial alpha) plus a few dozen sparkles.
const PING_SPEED = 160, PING_HOLD = 1200, PING_FADE = 800, PING_BANDS = 6, PING_GLINTS = 60, GLINT_MS = 450;
function pingImage(p) {
const size = p.r * 2 + 1, c = document.createElement('canvas'); c.width = c.height = size;
const g = c.getContext('2d'), img = g.createImageData(size, size), d = img.data, ox = Math.floor(p.x - p.r), oy = Math.floor(p.y - p.r);
const lit = (i, k, lift) => { const m = mat[i], v = (noiseB[i] - 128) * CVAR[m] / 128 + lift; d[k] = COLR[m] + v; d[k + 1] = COLG[m] + v; d[k + 2] = COLB[m] + v; d[k + 3] = 255; };
for (const i of p.cells) { const x = i % W - ox, y = ((i / W) | 0) - oy; if (x >= 0 && y >= 0 && x < size && y < size) lit(i, (y * size + x) * 4, 0); }
for (const i of p.cells) {
const x = i % W - ox, y = ((i / W) | 0) - oy, k = (y * size + x) * 4;
if (x <= 0 || y <= 0 || x >= size - 1 || y >= size - 1) continue;
if (!d[k - 1] || !d[k + 7] || !d[k - size * 4 + 3] || !d[k + size * 4 + 3]) lit(i, k, 45); // the rim
}
g.putImageData(img, 0, 0);
const step = Math.max(1, Math.ceil(p.cells.length / PING_GLINTS));
const glints = p.cells.filter((_, k) => k % step === 0).map(i => ({ x: i % W + 0.5, y: ((i / W) | 0) + 0.5, d: Math.hypot(i % W - p.x, ((i / W) | 0) - p.y), m: mat[i] }));
return { c, ox, oy, glints };
}
function drawPing() {
const p = GAME.ping;
if (!p) return;
const ms = performance.now() - p.t0, front = ms / 1000 * PING_SPEED, done = p.r / PING_SPEED * 1000;
if (ms > done + PING_HOLD + PING_FADE) { GAME.ping = null; return; }
if (!p.img) p.img = pingImage(p);
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(p.x, p.y), o = worldToScreenPx(p.img.ox, p.img.oy);
// inside `gone` the veins have faded out; from `full` out to the ring they are fully lit
const outer = Math.min(front, p.r + 1), full = (ms - PING_HOLD) / 1000 * PING_SPEED, gone = (ms - PING_HOLD - PING_FADE) / 1000 * PING_SPEED;
const band = (r0, r1, a) => {
if (r1 <= Math.max(0, r0) || a <= 0) return;
ctx.save(); ctx.beginPath(); ctx.arc(c.x, c.y, r1 * zp, 0, Math.PI * 2);
if (r0 > 0) ctx.arc(c.x, c.y, r0 * zp, 0, Math.PI * 2, true);
ctx.clip(); ctx.globalAlpha = a; ctx.imageSmoothingEnabled = false;
ctx.drawImage(p.img.c, o.x, o.y, p.img.c.width * zp, p.img.c.height * zp); ctx.restore();
};
band(Math.max(full, 0), outer, 1);
for (let k = 0; k < PING_BANDS; k++) {
const r0 = gone + (full - gone) * k / PING_BANDS, r1 = gone + (full - gone) * (k + 1) / PING_BANDS;
band(r0, Math.min(r1, outer), (k + 0.5) / PING_BANDS);
}
ctx.save();
if (front < p.r) {
ctx.strokeStyle = `rgba(160,225,255,${0.35 * (1 - front / p.r)})`; ctx.lineWidth = Math.max(1, zp * 0.4);
ctx.beginPath(); ctx.arc(c.x, c.y, front * zp, 0, Math.PI * 2); ctx.stroke();
}
// a four-point glint where the ring has just passed, growing then shrinking
for (const gl of p.img.glints) {
const t = (ms - gl.d / PING_SPEED * 1000) / GLINT_MS;
if (t < 0 || t > 1) continue;
const s = worldToScreenPx(gl.x, gl.y), k = Math.sin(t * Math.PI) * zp * 2.2, m = gl.m;
ctx.strokeStyle = `rgba(${Math.min(255, COLR[m] + 140)},${Math.min(255, COLG[m] + 140)},${Math.min(255, COLB[m] + 140)},${0.9 * Math.sin(t * Math.PI)})`;
ctx.lineWidth = Math.max(1, zp * 0.35);
ctx.beginPath(); ctx.moveTo(s.x - k, s.y); ctx.lineTo(s.x + k, s.y); ctx.moveTo(s.x, s.y - k); ctx.lineTo(s.x, s.y + k); ctx.stroke();
}
ctx.restore();
}
// Edges are built once when the cursor enters a different vein.
function drawHoveredVein() {
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) return;
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!vein) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, R.canvas.width, R.canvas.height); ctx.clip();
ctx.strokeStyle = 'rgba(235,245,255,0.85)'; ctx.lineWidth = 1;
ctx.beginPath();
for (const e of vein.edges) {
if (e[2] < R.vx0 || e[0] > R.vx0 + R.bw || e[3] < R.vy0 || e[1] > R.vy0 + R.bh) continue;
const a = worldToScreenPx(e[0], e[1]);
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (e[2] - e[0]) * zp, a.y + (e[3] - e[1]) * zp);
}
ctx.stroke(); ctx.restore();
}
// While the ping recharges, a thin arc around the bot fills up (only with the drill in hand).
function drawPingRecharge() {
if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return;
const k = (tick - GAME.pingTick) / PING_RECHARGE;
if (k >= 1) return;
const ctx = R.ctx, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2), r = Math.max(bot.w, bot.h) * R.zp * 0.9;
ctx.save(); ctx.lineWidth = Math.max(2, R.zp * 0.5); ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(160,225,255,0.18)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = 'rgba(160,225,255,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, -Math.PI / 2, -Math.PI / 2 + k * Math.PI * 2); ctx.stroke();
ctx.restore();
}
// Outline a run of blocks: strong while it is being edited, faint when pointed at
function drawRunOutline(run, strong) {
if (!run || !run.length) return;
+9 -19
View File
@@ -2,10 +2,10 @@
thermal palettes, pipes, trees, sky), canvas setup and the camera. */
const R = {
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
glowC: null, glowX: null, glowImg: null, glowPx: null,
canvas: null, ctx: null, px: null,
glowW: 0, glowH: 0, glowPx: null,
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
frame: 0, lens: false, particles: [],
};
@@ -116,8 +116,8 @@ function thinPipeColor(x, y, anim) {
}
PC[0] = r; PC[1] = g; PC[2] = bl;
}
// a block that is a belt or a filter: they join up into one belt
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; }
// A filter frame opens only where this end has a geometric partner.
function beltLike(b, end) { return endJoin(b % BW, (b / BW) | 0, end) >= 0; }
// standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops
const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT);
for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; }
@@ -138,13 +138,7 @@ function buildSky() {
function initRender(canvas) {
R.canvas = canvas;
R.ctx = canvas.getContext('2d', { alpha: false });
R.buf = document.createElement('canvas');
R.bctx = R.buf.getContext('2d');
R.glowC = document.createElement('canvas');
R.glowX = R.glowC.getContext('2d');
R.lightC = document.createElement('canvas');
R.lightX = R.lightC.getContext('2d');
R.ctx = canvas.getContext('2d');
}
function resizeRender() {
@@ -157,15 +151,11 @@ function resizeRender() {
const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2;
if (bw !== R.bw || bh !== R.bh) {
R.bw = bw; R.bh = bh;
R.buf.width = bw; R.buf.height = bh;
R.img = R.bctx.createImageData(bw, bh);
R.px = new Uint32Array(R.img.data.buffer);
R.px = new Uint32Array(bw * bh);
// the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields
R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh);
const gw = Math.ceil(bw / 2), gh = Math.ceil(bh / 2);
R.glowC.width = gw; R.glowC.height = gh;
R.glowImg = R.glowX.createImageData(gw, gh);
R.glowPx = new Uint32Array(R.glowImg.data.buffer);
R.glowW = Math.ceil(bw / 2); R.glowH = Math.ceil(bh / 2);
R.glowPx = new Uint32Array(R.glowW * R.glowH);
R.lw = 0;
}
}
+167
View File
@@ -0,0 +1,167 @@
// Drawing only: the face stays on its simulation cells; the band hangs below it.
let slopeBeltJoins = new Uint8Array(0), slopeBeltHalo = new Uint8Array(0), beltCaps = new Uint8Array(0);
let slopeAcross = 0, slopeAlong = 0, slopeDistance = 0;
function slopeBeltConnections(bx, by, code, planned = null) {
let joins = 0;
for (let end = 0; end < 2; end++) {
const join = endJoin(bx, by, end, planned);
if (join < 0) continue;
const nb = join >> 1, next = planned && planned.get(nb);
const slope = isSlopeBelt(next ? next[0] : blockP[nb]) &&
((nb / BW) | 0) - by === beltGrade(code) * (nb % BW - bx);
// A same-row feed into a low end drops onto its face: drawn as two capped ends, still joined.
if (!slope && joinIsDrop(bx, by, end, join, planned)) continue;
joins |= (slope ? 4 : 1) << end;
}
return joins;
}
function flatBeltCaps(b, planned = null) {
const bx = b % BW, by = (b / BW) | 0;
let caps = 0;
for (let end = 0; end < 2; end++) {
const join = endJoin(bx, by, end, planned);
if (join < 0 || joinIsDrop(bx, by, end, join, planned)) caps |= 1 << end;
}
return caps;
}
function prepareSlopeBelts(x0, y0, x1, y1) {
if (slopeBeltJoins.length !== NB) { slopeBeltJoins = new Uint8Array(NB); slopeBeltHalo = new Uint8Array(NB); beltCaps = new Uint8Array(NB); }
slopeBeltHalo.fill(0);
for (let by = Math.max(0, (y0 >> 2) - 2); by <= Math.min(BH - 1, (y1 >> 2) + 2); by++) {
for (let bx = Math.max(0, (x0 >> 2) - 2); bx <= Math.min(BW - 1, (x1 >> 2) + 2); bx++) {
const b = by * BW + bx, c = blockP[b];
if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b);
if (!isSlopeBelt(c)) continue;
const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS;
slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0) | (isFilter(c) && netSat(face) > 0 && screenLoaded(b) && (tick & 2) ? 64 : 0);
for (let yy = Math.max(0, by - 2); yy <= Math.min(BH - 1, by + 1); yy++)
for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1;
}
}
}
// Rounded stroke, with coordinates along/across the winning segment for rollers and mesh.
function slopeBeltSegment(x, y, ax, ay, bx, by, origin) {
const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len;
const along = (x - ax) * ux + (y - ay) * uy, across = -(x - ax) * uy + (y - ay) * ux;
const qx = Math.abs(along - len / 2) - len / 2 + 0.75, qy = Math.abs(across) - 1.1;
const sd = Math.hypot(Math.max(0, qx), Math.max(0, qy)) + Math.min(Math.max(qx, qy), 0) - 0.75;
if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = across + 2; slopeAlong = origin + along; }
}
function slopeBeltCorner(x, y, ax, ay, origin) {
const dx = x - ax, dy = y - ay, radius = Math.hypot(dx, dy), sd = radius - 1.85;
if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = 2 + dy; slopeAlong = origin + dx; }
}
function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) {
const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
x -= ox; y -= oy;
if (isFilter(code) && (joins & 64)) y -= 0.45;
const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0;
const normal = 1.85 * Math.SQRT1_2;
let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal;
if (joins & 1) { ay = sy + 2; ax = (ay - sy - offset) / grade; }
else if (joins & 4) { ax -= 4; ay -= grade * 4; }
if (joins & 2) { by = 6 - sy; bx = (by - sy - offset) / grade; }
else if (joins & 8) { bx += 4; by += grade * 4; }
slopeDistance = Infinity;
const origin = (ox + grade * oy) * Math.SQRT1_2;
slopeBeltSegment(x, y, ax, ay, bx, by, origin + (ax + grade * ay) * Math.SQRT1_2);
if (joins & 1) {
slopeBeltSegment(x, y, -2, ay, ax, ay, ox - 2);
slopeBeltCorner(x, y, ax, ay, ox + ax);
}
if (joins & 2) {
slopeBeltSegment(x, y, bx, by, 6, by, ox + bx);
slopeBeltCorner(x, y, bx, by, ox + bx);
}
return slopeDistance;
}
function slopeBeltAt(i, x, y, smooth = false) {
const own = (y >> 2) * BW + (x >> 2), code = blockP[own];
if (!slopeBeltHalo[own]) return -1;
const slope = isSlopeBelt(code);
if (!slope && (mach[i] || occ[i] || (mat[i] !== EMPTY && mat[i] !== BELTL && mat[i] !== BELTR))) return -1;
let seal = !slope && (mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER) ? slopeSealOwner(i) : -1;
if (seal >= 0 && !isSlopeBelt(blockP[seal])) seal = -1;
if (!slope && seal < 0 && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || code)) return -1;
let best = smooth ? 0.8 : 0, found = slope ? own : seal;
for (let yy = Math.max(0, (y >> 2) - 1); yy <= Math.min(BH - 1, (y >> 2) + 1); yy++) {
for (let xx = Math.max(0, (x >> 2) - 1); xx <= Math.min(BW - 1, (x >> 2) + 1); xx++) {
const b = yy * BW + xx;
if (!isSlopeBelt(blockP[b])) continue;
const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
if (sd < best) { best = sd; found = b; }
}
}
return found;
}
function slopeBeltColor(b, x, y, anim, br, bg, bb) {
const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16);
const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c);
const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
const a = slopeAlong, d = slopeAcross, time = !mesh && joins & 16 ? anim : 0;
let r = 38, g = 41, blue = 46;
if (-sd < (mesh ? 1.1 : 0.8)) {
const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72;
r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k;
} else if (mesh) {
const f = filterCfg.get(b), m = f && f.m.length ? f.m[(Math.floor(a / 2) & 65535) % f.m.length] : 0;
if ((Math.floor(a) + Math.floor(d)) & 1) { r = br * 0.8; g = bg * 0.8; blue = bb * 0.8; }
else { r = m ? COLR[m] * 0.75 : 64; g = m ? COLG[m] * 0.75 : 68; blue = m ? COLB[m] * 0.75 : 76; }
} else {
const dx = ((a % 2 + 2) % 2) - 1, dy = d - 2;
if (dx * dx + dy * dy < 0.95 * 0.95) {
const spin = Math.cos((Math.atan2(dy, dx) - dir * time / 1.9) * 3) > 0.6;
r = spin ? 104 : 132; g = spin ? 94 : 120; blue = spin ? 84 : 108;
}
}
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
}
function screenLoaded(b) {
const bx = b % BW, by = (b / BW) | 0, c = blockP[b];
for (let x = bx * BS; x < bx * BS + BS; x++) {
const i = (beltFaceY(c, bx, by, x) - 1) * W + x;
if (i >= 0 && (PH[mat[i]] === PH_POWDER || occ[i])) return true;
}
return false;
}
// Filled far wall, in belt coordinates. Project onto the face so ends stop at the slope ends.
let slopeWallOwner = -1;
function slopeWallShape(x, y, b, code = blockP[b]) {
const g = beltGrade(code), ox = b % BW * BS, oy = ((b / BW) | 0) * BS;
const sy = g < 0 ? BS : 0, lx = x - ox, ly = y - oy - sy;
const foot = (lx + g * ly) / 2, depth = (g * lx - ly) * Math.SQRT1_2;
return Math.max(-foot * Math.SQRT2, (foot - BS) * Math.SQRT2, -depth, depth - CLEAT_HEIGHT);
}
function slopeWallAt(x, y, smooth = false) {
slopeWallOwner = -1;
let best = smooth ? 0.8 : 0;
for (let by = Math.max(0, (y >> 2) - 1); by <= Math.min(BH - 1, (y >> 2) + 3); by++)
for (let bx = Math.max(0, (x >> 2) - 2); bx <= Math.min(BW - 1, (x >> 2) + 2); bx++) {
const b = by * BW + bx;
if (!isBelt(blockP[b]) || !isSlopeBelt(blockP[b]) || !slopeUphill(blockP[b], blockV[b])) continue; // downhill: a plain belt
const sd = slopeWallShape(x + 0.5, y + 0.5, b);
if (sd <= best) { best = sd; slopeWallOwner = b; }
}
return slopeWallOwner;
}
// The load occludes the far wall; ribs use exactly the simulation barrier raster.
function slopeDetailAt(i, x, y, smooth = false) {
slopeWallOwner = -1;
if (!slopeBeltHalo[(y >> 2) * BW + (x >> 2)] && !cleatMask[i]) return 0;
if (occ[i] || mach[i] || part[i]) return 0;
if (cleatMask[i] && PH[mat[i]] !== PH_SOLID) { slopeWallAt(x, y, smooth); return 1; }
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return 0;
return slopeWallAt(x, y, smooth) >= 0 ? 2 : 0;
}
function slopePreviewCleat(x, y, b, code) {
const bx = b % BW, by = (b / BW) | 0, g = beltGrade(code);
for (let h = 0; h <= CLEAT_REACH; h++) {
const foot = x - g * h;
if (foot >= bx * BS && foot < (bx + 1) * BS && foot % CLEAT_SPACING === 0 &&
y + h + 1 === beltFaceY(code, bx, by, foot)) return true;
}
return false;
}
-325
View File
@@ -1,325 +0,0 @@
// 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);
}`;
-99
View File
@@ -1,99 +0,0 @@
// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
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 };
function smCompile(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
return sh;
}
// Is the smooth look possible here (WebGL2)? Set up once, on first use.
function smoothAvailable() {
if (SM.tried) return SM.ok;
SM.tried = true;
try {
const cv = document.createElement('canvas');
const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); }
// a browser drawing without the graphics card would crawl: keep the pixel look there
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); }
const prog = gl.createProgram();
gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS));
gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n);
// texture unit for each sampler; linear ones are sampled between grains
const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]];
texs.forEach(([name, linear, mip], unit) => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
SM.tex[name] = { t, unit, mip, w: 0, h: 0 };
});
gl.bindVertexArray(gl.createVertexArray());
SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true;
SM.white = new Uint8Array([255, 255, 255, 255]);
cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; });
} catch (e) {
console.warn('Smooth look unavailable, using pixels:', e && e.message);
SM.ok = false;
}
return SM.ok;
}
function smUpload(name, w, h, data) {
const gl = SM.gl, T = SM.tex[name];
gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t);
if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; }
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
if (T.mip) gl.generateMipmap(gl.TEXTURE_2D);
}
const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
function smoothDraw(lighting, glowOn) {
const t0 = performance.now();
// 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;
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
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.useProgram(SM.prog);
const bw = R.bw, bh = R.bh;
smUpload('tCol', bw, bh, smBytes(R.px));
smUpload('tColL', bw, bh, smBytes(R.px));
smUpload('tBack', bw, bh, smBytes(R.backPx));
smUpload('tTree', bw, bh, smBytes(R.treePx));
smUpload('tField', bw, bh, smBytes(R.fieldPx));
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
gl.uniform2f(u.uSize, bw, bh);
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0);
gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1);
gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1);
gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0);
gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000);
gl.uniform1f(u.uSurf, SM.surf);
gl.uniform1f(u.uRain, R.rain || 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const ctx = R.ctx;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
smoothPace(performance.now() - t0);
}
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
// is room, and if even half resolution is too slow, give up on the smooth look for this session.
function smoothPace(ms) {
SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms;
if (++SM.n < 15) return;
SM.n = 1;
if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; }
if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25));
else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25);
}
+27 -289
View File
@@ -2,19 +2,20 @@
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.
// ---------- World pixels ----------
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
const TUBE_CLASS = new Uint8Array(9); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12;
function drawWorld() {
const bw = R.bw, bh = R.bh, px = R.px;
const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
if (PERF_MARKS || SET.showPerf) { perfMarks.override = 0; delete perfMarks.gpu; }
if (!GPU.ok) return;
R.gpuOn = true; GPU.canvas.hidden = false;
R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height);
const bw = R.bw, bh = R.bh;
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
const vx0 = R.vx0, vy0 = R.vy0;
// how hard it is raining around the camera, eased so the sky greys over gently
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;
// 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-shaders.js)
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;
// Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens;
const mmask = R.moldMask;
if (sm) {
mmask.fill(0);
for (const mo of molds) {
@@ -22,287 +23,24 @@ 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; }
}
}
const useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
if (diagonalJoins.length !== NB) diagonalJoins = new Uint8Array(NB);
for (let by = Math.max(0, (vy0 >> 2) - 1); by <= Math.min(BH - 1, ((vy0 + bh) >> 2) + 1); by++) {
for (let bx = Math.max(0, (vx0 >> 2) - 1); bx <= Math.min(BW - 1, ((vx0 + bw) >> 2) + 1); bx++) {
const b = by * BW + bx;
if (blockP[b] === BP_SCREW) diagonalJoins[b] = blockV[b] >= 4 ? diagonalScrewJoins(bx, by, blockV[b]) : straightScrewJoins(bx, by, blockV[b]);
}
}
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
const lighting = SET.lighting !== 'off' && !R.lens;
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
const grain = SET.grain, incand = SET.incandescence;
const anim = R.frame;
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
const glowOn = SET.glow !== 'off' && !lens;
const gpx = R.glowPx, gw = R.glowC.width;
if (glowOn) gpx.fill(0);
for (let by = 0; by < bh; by++) {
const y = vy0 + by;
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] = 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;
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; }
for (let bx = 0; bx < bw; bx++) {
const x = vx0 + bx;
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 T = temp[i];
let r, g, b;
if (lens) {
const ti = thermIdx(T);
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
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];
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);
continue;
}
// background
let br, bgc, bb, sky = false;
const tb = bgm[i];
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
else {
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
const v = sm ? 0 : nz * 0.06;
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
}
if (sm) {
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
if (tb) {
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
} else tpx[row + bx] = 0;
}
if (m === EMPTY) { r = br; g = bgc; b = bb; }
else {
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
else if (m === BELTL || m === BELTR) {
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
if (ly === 0 || ly === 3) {
// top run moves with the load, bottom run returns the other way
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
const k = ly === 0 ? 1 : 0.72;
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
} else if (lx === 1 || lx === 2) {
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
} else { r = 46; g = 48; b = 54; }
} else if (m === PIPE) {
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
} else if (m === SHAFT) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
else { r -= 34; g -= 30; b -= 24; }
}
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 === 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 === 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 === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
const al = ALPHA[m];
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
const dp = digp[i];
if (dp) {
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
r += lift; g += lift; b += lift * 1.08;
}
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
}
}
// field parts drawn over contents
const p = part[i];
if (p && sm && (p <= P_TR || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
else if (p) {
if (p <= P_TR) {
const on = netSat(i) > 0;
const vert = p === P_TU || p === P_TD;
const axis = vert ? y : x, across = vert ? x & 3 : y & 3;
const dir = (p === P_TU || p === P_TL) ? 1 : -1;
if (across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
if ((axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
} else {
// screw flights: a diagonal stripe that walks along the tube when powered
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
}
} else if (p === P_FILTER) {
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) {
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
}
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
} else if ((lx === 0 && !beltLike(fb - 1)) || (lx === 3 && !beltLike(fb + 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
else { r = 24; g = 25; b = 28; }
}
} else if (p === P_GRATE) {
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
} else if (p === P_GOPEN) {
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
} else if (p === P_INTAKE) {
if (m === EMPTY) { r = 30; g = 32; b = 36; }
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
} else if (p === P_PIPE) {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
// incandescence
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];
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 && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
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);
}
}
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
let top = 0xff000000;
if (sm) {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = p === P_PIPE && !pipeBodyCell(x, y) ? 0 : p; // the open parts of a pipe block show what is in them
if (pt >= P_TU && pt <= P_TR) cl = TUBE_CLASS[pt];
else if (p === P_FILTER) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
tpx[row + bx] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
else if (m === BELTL || m === BELTR) cl = m === BELTL ? 7 : 8;
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) ? 6 : 1;
else if (ph === PH_POWDER) cl = 2;
else if (ph === PH_LIQUID) cl = 3;
else cl = 5;
}
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);
}
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);
}
}
// bars and other loose objects
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;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const sx = ex + dx, sy = ey + dy;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
let r = COLR[m], g = COLG[m], b = COLB[m];
const end = dx === 0 || dx === BAR_W - 1;
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
if (e.T > 450 && incand) { const gi = glowIndex(e.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; }
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
}
}
// 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 (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
}
// blueprint ghosts: blocks waiting to be built
if (ghostBlocks.size) {
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
const pulse = (R.frame >> 3) & 3;
for (const [b, gv] of ghostBlocks) {
const gbx = b % BW, gby = (b / BW) | 0;
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (code === BP_WALL && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) {
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx, c = px[k];
const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
}
}
}
if (sm) { smoothDraw(lighting, glowOn); return; }
R.bctx.putImageData(R.img, 0, 0);
const ctx = R.ctx, zp = R.zp;
ctx.imageSmoothingEnabled = false;
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp);
if (lighting) {
const res = R.lres;
ctx.save();
ctx.globalCompositeOperation = 'multiply';
ctx.imageSmoothingEnabled = true;
const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp;
ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp);
ctx.restore();
}
if (glowOn) {
R.glowX.putImageData(R.glowImg, 0, 0);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5;
ctx.imageSmoothingEnabled = true;
const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2));
try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ }
ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp);
ctx.filter = 'none';
ctx.restore();
const lightUpdate = lighting ? gpuLightGrid() : false;
const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
const glowOn = SET.glow !== 'off' && !R.lens;
if (glowOn) R.glowPx.fill(0);
gpuOverrides();
worldObjects();
if (PERF_MARKS || SET.showPerf) {
perfMarks.color = performance.now() - colorStart;
perfMarks.prepare = colorStart - perfStart;
}
gpuDraw(lighting, glowOn, lightUpdate);
}
+8
View File
@@ -4,6 +4,7 @@
/*__CSS__*/
</style>
<canvas id="gpu" aria-hidden="true" hidden></canvas>
<canvas id="game" aria-label="Game world"></canvas>
<div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div>
@@ -48,6 +49,7 @@
<div class="titlewrap">
<canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas>
<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 id="cap-notice" class="plate note" role="status" hidden></div>
<div class="tmenu" id="tmenu">
<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>
@@ -130,6 +132,12 @@
</div>
</div>
<section id="dev-console" class="plate" aria-labelledby="dev-title" hidden>
<div class="dev-head"><strong id="dev-title">Dev console</strong><span class="note">World marked · help lists commands</span><button id="dev-close" class="iconbtn" aria-label="Close dev console">Close <kbd>Esc</kbd></button></div>
<div id="dev-output" role="log" aria-label="Command output" aria-live="polite"></div>
<form id="dev-form"><label for="dev-input">Command</label><input id="dev-input" type="text" autocomplete="off" spellcheck="false" placeholder="help"><button class="btn" type="submit">Run</button></form>
</section>
<script>
//__JS__
</script>
+2 -2
View File
@@ -60,7 +60,7 @@ function toggleGateAt(x, y) {
const m = mat[i];
if (m !== EMPTY && PH[m] !== PH_GAS) {
const u = i - W;
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; }
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); }
}
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
}
@@ -82,7 +82,7 @@ function interactAt(x, y) {
return open ? 'Rack open.' : 'Rack closed.';
}
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' });
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];
+109 -28
View File
@@ -2,42 +2,90 @@
// ---------- Blocks ----------
function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; }
function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; }
function pieceCost(code, v) { return code === BP_WALL ? wallCost(v, BS * BS) : PIECE_BY_CODE[code].cost; }
function canPlaceBlock(bx, by) {
function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; }
function setSlopeSeal(i, code, v) {
if (isFilter(code)) { part[i] = P_FILTER; wakeIdx(i); }
else {
setCell(i, isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v], AMB[(i / W) | 0], 0);
if (isBelt(code)) markPow(i, true);
}
}
// One extra cell on the solid side seals diagonal crossings between successive triangles.
function slopeSeal(code, bx, by) {
code = slopeShape(code);
const x = bx * BS + (code === BP_SLOPE_BR || code === BP_SLOPE_TR ? 0 : 3);
const y = by * BS + (code >= BP_SLOPE_TL ? -1 : 4);
return y < 0 || y >= H ? -1 : y * W + x;
}
function slopeSealOwner(i) {
const x = i % W, y = (i / W) | 0, bx = x >> 2;
if (((x & 3) !== 0 && (x & 3) !== 3) || ((y & 3) !== 0 && (y & 3) !== 3)) return -1;
for (let by = (y >> 2) - 1; by <= (y >> 2) + 1; by += 2) {
if (by < 0 || by >= BH) continue;
const b = by * BW + bx, c = blockP[b];
if ((isSlope(c) || isSlopeBelt(c)) && slopeSeal(c, bx, by) === i) return b;
}
return -1;
}
// Does block b's own piece fill cell i (with a part, or else with a material)? A slope's seal can
// sit in a cell its neighbour leaves open: a slope's open corner, or under a grate, screw or screen.
function blockOwnsCell(b, i, asPart) {
const c = blockP[b];
if (!c || ((isSlope(c) || isSlopeBelt(c)) && !slopeCell(c, (i % W) & 3, ((i / W) | 0) & 3))) return false;
return asPart ? isFilter(c) : !(isFilter(c) || c === BP_GRATE || c === BP_SCREW || c === BP_PIPE);
}
// Belt and screen cells that a removed slope left behind in an empty block (a removal bug in
// older worlds): nothing owns them, so nothing could build there or take them away.
function clearStraySeals() {
for (let i = 0; i < N; i++) {
if (mat[i] !== BELTL && mat[i] !== BELTR && part[i] !== P_FILTER) continue;
if (blockP[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] || slopeSealOwner(i) >= 0) continue;
if (part[i] === P_FILTER) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); markPow(i, false); }
}
}
function wallCells(code, bx, by) {
return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3));
}
function canPlaceBlock(bx, by, code = BP_WALL) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) {
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 (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID && ((flags[i] & F_NAT) || slopeSealOwner(i) < 0)) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way';
}
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0 && PH[mat[i]] !== PH_SOLID && (mach[i] || part[i] || occ[i])) return 'The slope corner is blocked.';
}
return '';
}
// v: wall material (loose id) for walls, direction for belts and tubes
// v: wall material (loose id) for walls, direction for belts and screws
function placeBlock(code, v, bx, by, free, extra) {
const err = canPlaceBlock(bx, by); if (err) return err;
if (code === BP_WALL && !WALL_OF[v]) return 'Pick a wall material with R';
const err = canPlaceBlock(bx, by, code); if (err) return err;
if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R';
const cost = pieceCost(code, v);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const cells = blockCellList(bx, by);
const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
for (const i of cells) {
const y = (i / W) | 0, x = i % W;
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); }
if (isWall(code)) setCell(i, WALL_OF[v], AMB[y], 0);
else if (isBelt(code)) { 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_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_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_FILTER) { part[i] = P_FILTER; wakeXY(x, y); }
else if (code === BP_SCREW) { part[i] = SCREW_PARTS[v & 7]; markPow(i, true); wakeXY(x, y); }
else if (isFilter(code)) { part[i] = P_FILTER; wakeXY(x, y); }
else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
}
if (isPipeCode(code)) pipesDirty = true;
renderDirtyXY(bx * BS, by * BS);
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 (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); 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();
@@ -45,6 +93,10 @@ function placeBlock(code, v, bx, by, free, extra) {
rackState.set(b, s);
for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
}
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0 && PH[mat[i]] !== PH_SOLID && !part[i]) { evictCells([i], by * BS); setSlopeSeal(i, code, v); }
}
stats.placed++;
undoNote({ t: 'block+', bx, by, code });
return '';
@@ -57,15 +109,15 @@ function blockIntegrity(b) {
let n = 0;
for (const i of blockCellList(bx, by)) {
const m = mat[i], p = part[i];
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++; }
if (isWall(code) && (!isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3))) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
else if (isBelt(code)) { if (m === BELTL || m === BELTR) 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_GRATE) { if (p === P_GRATE) n++; }
else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; }
else if (code === BP_FILTER) { if (p === P_FILTER) n++; }
else if (code === BP_SCREW) { if (IS_SCREW[p]) n++; }
else if (isFilter(code)) { if (p === P_FILTER) n++; }
else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
}
void v;
@@ -83,44 +135,73 @@ function removeWallCell(i, wall) {
function removeBlock(bx, by) {
const b = by * BW + bx, code = blockP[b], v = blockV[b];
if (!code) return 0;
undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined });
undoNote({ t: 'block-', bx, by, code, v, extra: isFilter(code) ? filterInfo(b) : undefined });
// a pipe taken out spills its share of what the line holds
const spill = isPipeCode(code) ? pipeTakeShare(b) : null;
let n = 0, metal = 0;
for (const i of blockCellList(bx, by)) {
const m = mat[i], p = part[i];
if (code === BP_WALL) {
if (isWall(code)) {
if (isSlope(code) && !slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)) continue;
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
metal += removeWallCell(i, v);
if (isSlope(code)) setCell(i, EMPTY, temp[i], 0);
else metal += removeWallCell(i, v);
n++;
}
} else if (isPipeCode(code)) {
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
} else if (isBelt(code) || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
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); }
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
}
if (isSlope(code)) {
const cells = Math.floor(n * 8 / 10), refund = PH[v] === PH_ITEM ? Math.floor(cells / GRAINS_PER_BAR) : cells;
giveOrDrop(v, refund, 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);
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
else if (!isWall(code) && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
if (isFilter(code)) { 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);
}
renderDirtyXY(bx * BS, by * BS);
blockP[b] = 0; blockV[b] = 0;
const cleared = blockCellList(bx, by);
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0) {
const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2);
if (!blockOwnsCell(nb, i, isFilter(code)) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } }
cleared.push(i);
}
}
for (const i of cleared) {
const owner = slopeSealOwner(i);
if (owner >= 0 && mat[i] === EMPTY && !part[i]) {
setSlopeSeal(i, blockP[owner], blockV[owner]);
}
}
if (isPipeCode(code)) {
pipesDirty = true;
if (spill) { let k = spill.n; for (let y = by * BS + BS - 1; y >= by * BS && k > 0; y--) for (let x = bx * BS; x < bx * BS + BS && k > 0; x++) { const i = y * W + x; if (mat[i] === EMPTY) { setCell(i, spill.m, spill.T, 0); k--; } } }
}
return n;
}
// Straight line of blocks along the dominant axis
function blockLine(bx0, by0, bx1, by1) {
// Wall drags near 45° snap to a diagonal; other lines follow the dominant axis.
function diagonalDrag(dx, dy) { return dx !== 0 && dy !== 0 && Math.min(Math.abs(dx), Math.abs(dy)) >= Math.max(Math.abs(dx), Math.abs(dy)) * 0.7; }
function blockLine(bx0, by0, bx1, by1, diagonal = false) {
const out = [];
const dx = bx1 - bx0, dy = by1 - by0;
if (diagonal && diagonalDrag(dx, dy)) {
for (let k = 0; k <= Math.max(Math.abs(dx), Math.abs(dy)); k++) out.push([bx0 + k * Math.sign(dx), by0 + k * Math.sign(dy)]);
return out;
}
if (Math.abs(bx1 - bx0) >= Math.abs(by1 - by0)) { const s = bx1 >= bx0 ? 1 : -1; for (let x = bx0; ; x += s) { out.push([x, by0]); if (x === bx1) break; } }
else { const s = by1 >= by0 ? 1 : -1; for (let y = by0; ; y += s) { out.push([bx0, y]); if (y === by1) break; } }
return out;
@@ -130,11 +211,11 @@ function blockRect(bx0, by0, bx1, by1) {
for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]);
return out;
}
function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS / 2); }
function blockInReach(bx, by) { return inBuildRange(bx * BS + BS / 2, by * BS + BS / 2); }
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false;
if (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v;
}
// replace: a block already built in the way is taken down (and refunded) to make room
@@ -145,7 +226,7 @@ function placeBlocks(code, v, list, extra, replace) {
const b = by * BW + bx;
if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue;
if (blockP[b] === BP_FILTER && code === BP_FILTER) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
removeBlock(bx, by);
}
+3 -3
View File
@@ -7,7 +7,7 @@ function copyRegion(x0, y0, x1, y1) {
const blocks = [], ms = [], ols = [];
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(blockP[b] === BP_FILTER ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(isFilter(blockP[b]) ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
}
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
@@ -23,11 +23,11 @@ function copyRegion(x0, y0, x1, y1) {
}
function flipBlueprint(bp) {
const bw = bp.bw;
const flipV = (code, v) => code === BP_BELT ? 1 - v : code === BP_FILTER ? v ^ 128 : code === BP_TUBE ? (v === 1 ? 3 : v === 3 ? 1 : v)
const flipV = (code, v) => isBelt(code) ? 1 - v : isFilter(code) ? v ^ 128 : code === BP_SCREW ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7]
: code === BP_PUMP || code === BP_OUTLET ? (((v & 3) === 1 ? 3 : (v & 3) === 3 ? 1 : v & 3) | (v & 4)) : v;
return {
v: 2, bw, bh: bp.bh, name: bp.name,
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, c, flipV(c, v), e] : [bw - 1 - x, y, c, flipV(c, v)]),
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]),
machines: bp.machines.map(([t, x, y, d]) => {
const P = PIECE_BY_ID[t];
let nd = d;
+65 -49
View File
@@ -12,6 +12,9 @@ function setCell(i, m, t, fl) {
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
}
function swapCells(i, j) {
renderDirtyIdx(i); renderDirtyIdx(j); swapCellData(i, j);
}
function swapCellData(i, j) {
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
a = life[i]; life[i] = life[j]; life[j] = a;
@@ -19,16 +22,16 @@ function swapCells(i, j) {
stamp[i] = tickStamp; stamp[j] = tickStamp;
}
function move(i, j, x, y, nx, ny) {
swapCells(i, j);
swapCellData(i, j);
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;
for (let cx = first; cx <= last; cx++) chunkWake(row + cx);
}
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) { if (chunkWork) chunkEvent('flow', mach[j], PH[mat[j]] === PH_LIQUID ? 1 : 0.5); else { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } }
const d = temp[j] - AMB[ny];
if (d > 25 || d < -25) wakeHeatXY(nx, ny);
}
@@ -36,10 +39,15 @@ function move(i, j, x, y, nx, ny) {
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
// 0 = no, 1 = into empty, 2 = by displacing what is there.
function enter(m, ph, j, dx, dy) {
if (ph === PH_POWDER && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W]))) return 0;
return enterFree(m, ph, j, dx, dy);
}
// Powder already knows whether its chunk has barriers; other callers use enter.
function enterFree(m, ph, j, dx, dy) {
if (occ[j]) return 0;
const p = part[j];
if (p !== 0) {
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return 0; } // liquids and gases flow through freely
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return 0; } // liquids and gases flow through freely
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return 0; } // thick slag does not run through a grate
else if (p === P_INTAKE) { if (ph !== PH_GAS) return 0; }
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return 0; }
@@ -67,24 +75,37 @@ 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.
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
let LSTEP = 8, LFILM = 4;
function powder(i, x, y, m) {
let CORNER_SEAL = true;
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
if (y >= H - 1) return;
const b = i + W;
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
// 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.
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() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && 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); chunkWake((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
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
if (rnd() < STICK[m]) {
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
// Feed a receiving screw mouth sideways instead of falling under its bottom casing.
if (t === BELTL || t === BELTR) {
const b = ((y + 1) >> 2) * BW + (x >> 2);
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
if ((!barriers || !cleatMask[i + d]) && enterFree(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
return;
}
}
// stickiness is grain against grain: on anything built (a wall, a slope, a belt) every material
// slides freely, so a chute carries clay as well as sand while clay heaps stay steep
if (rnd() < STICK[m] || (PH[t] === PH_SOLID && !(flags[b] & F_NAT))) {
const d = (RS & 2) ? 1 : -1;
let nx = x + d;
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b + d])) && enterFree(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
nx = x - d;
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b - d])) && enterFree(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
}
function passable(m, ph, j, dx, dy) {
@@ -92,7 +113,7 @@ function passable(m, ph, j, dx, dy) {
if (occ[j]) return false;
const p = part[j];
if (p !== 0) {
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return false; }
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return false; }
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return false; }
else if (p === P_INTAKE) { if (ph !== PH_GAS) return false; }
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return false; }
@@ -106,18 +127,25 @@ function liquid(i, x, y, m) {
if (enter(m, PH_LIQUID, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
const d = (RS & 2) ? 1 : -1;
let nx = x + d;
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 && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
nx = x - d;
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 && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
if (rnd() > FLUID[m]) return;
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
const d0 = (RS & 4) ? 1 : -1;
for (let pass = 0; pass < 2; pass++) {
liquidSearch(i, x, y, m, sp, d0);
}
function liquidSearch(i, x, y, m, sp, d0, firstPass = 0, firstStep = 1) {
for (let pass = firstPass; pass < 2; pass++) {
const d = pass ? -d0 : d0;
for (let s = 1; s <= sp; s++) {
for (let s = pass === firstPass ? firstStep : 1; s <= sp; s++) {
const nx = x + d * s;
if (nx < 0 || nx >= W) break;
// Same-colour chunks are separated by 32 cells; their maximum write halo is 8.
if (chunkWork && (nx < chunkWork.x0 - 24 || nx >= chunkWork.x1 + 24)) {
chunkEvent('liquid', i, m, sp, d0, pass, s); return;
}
const j = i + d * s;
if (!passable(m, PH_LIQUID, j, d, 0)) break;
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
@@ -130,7 +158,7 @@ function liquid(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;
chunkWake((y >> 5) * CW + (x >> 5));
if (m === FIRE) {
const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
@@ -153,9 +181,9 @@ function gas(i, x, y, m) {
if (enter(m, PH_GAS, u, 0, -1)) { move(i, u, x, y, x, y - 1); return; }
const d = (RS & 2) ? 1 : -1;
let nx = x + d;
if (nx >= 0 && nx < W && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
nx = x - d;
if (nx >= 0 && nx < W && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
if (rnd() < 0.6) {
nx = x + d;
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); }
@@ -173,35 +201,23 @@ function wakeBotPowder() {
if (dx * dx + dy * dy <= 10000) awake[c] = 1;
}
}
function simMove() {
for (let cy = CH - 1; cy >= 0; cy--) {
const rowBase = cy * CW;
let any = false;
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; }
if (!any) continue;
const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
for (let y = y1 - 1; y >= y0; y--) {
const ltr = ((y + tick) & 1) === 0;
const yw = y * W;
for (let k = 0; k < CW; k++) {
const cx = ltr ? k : CW - 1 - k;
if (!awake[rowBase + cx]) continue;
roofDirty[cx] = 1;
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
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
const i = yw + x, m = mat[i];
if (m === EMPTY) continue;
const ph = PH[m];
if (ph === PH_POWDER) powderArea[rowBase + cx] = 1;
if (stamp[i] === tickStamp) continue;
if (ph === PH_SOLID) continue;
const p = part[i];
if (p >= P_TU && p <= P_TR && ph === PH_POWDER) continue; // a screw tube moves its own grains
if (ph === PH_POWDER) powder(i, x, y, m); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
function simMove() { chunkPasses(false); }
function moveChunk(c) {
const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
const barriers = cleatChunks[c];
powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
for (let y = y1 - 1; y >= y0; y--) {
const ltr = ((y + tick) & 1) === 0, dx = ltr ? 1 : -1, end = ltr ? x1 : x0 - 1, yw = y * W;
for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
const i = yw + x, m = mat[i];
if (m === EMPTY) continue;
const ph = PH[m];
if (ph === PH_POWDER) powderArea[c] = 1;
if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
if (ph === PH_POWDER) powder(i, x, y, m, barriers);
else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
}
+109 -24
View File
@@ -1,10 +1,27 @@
// How belts, filters and screw tubes move what is on or in them.
// How belts, filters and screw conveyors 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.
const CARRY_DEPTH = 96;
// Find the next carrying face at the same height or one grain above/below it.
function conveyorStep(x, y, d) {
const nx = x + d;
if (nx < 0 || nx >= W) return 0;
if ((x >> 2) === (nx >> 2)) return beltGrade(blockP[(y >> 2) * BW + (x >> 2)]) * d;
for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= H) continue;
const b = (yy >> 2) * BW + (nx >> 2), c = blockP[b], i = yy * W + nx;
if ((isBelt(c) || isFilter(c)) && beltFaceY(c, nx >> 2, yy >> 2, nx) === yy &&
(mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER)) return dy;
}
const b = (y >> 2) * BW + (x >> 2);
return beltGrade(blockP[b]) * d;
}
function carryColumn(a, d, pass) {
if (a < 0 || mat[a] === EMPTY) return;
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d), carrier = (sy >> 2) * BW + ((a % W) >> 2);
let blocked = false;
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
if (a < 0) return;
@@ -20,18 +37,21 @@ function carryColumn(a, d, pass) {
if (blocked) continue; // filters still sift above a stopped load
const x = a % W, nx = x + d;
if (nx < 0 || nx >= W) return;
const j = a + d, tj = mat[j];
const j = a + d + step * W;
if (j < 0 || j >= N) return;
const tj = mat[j];
// 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);
if (!wet && !enter(m, PH_POWDER, j, d, 0)) {
if (!wet && !enter(m, PH_POWDER, j, d, step)) {
if (!pass) return;
blocked = true; continue;
}
const y = (a / W) | 0, bj = j + W;
// 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);
else move(a, j, x, y, nx, y);
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5);
else move(a, j, x, y, nx, y + step);
stats.moved++;
if (flowOn && (x >> 2) !== (nx >> 2)) flowNote(carrier);
}
}
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
@@ -43,7 +63,7 @@ function beltDrain(b) {
if (!passable(mat[a], PH_LIQUID, d, 0, 1)) return;
move(a, d, x, (a / W) | 0, x, (d / W) | 0);
}
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a tube mouth
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a screw mouth
function launchGrain(i, x, y, vx, vy) {
const m = mat[i], T = temp[i];
mat[i] = EMPTY; flags[i] = 0; life[i] = 0; stamp[i] = tickStamp;
@@ -53,23 +73,49 @@ function launchGrain(i, x, y, vx, vy) {
let filterR = [], filterL = [], filtersDirty = true;
function rebuildFilters() {
filtersDirty = false; filterR = []; filterL = [];
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b);
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) (blockV[b] & 128 ? filterR : filterL).push(b);
// front of the line first, so a full run moves as one
filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW));
}
function filterCarry(b, d) {
if (blockP[b] !== BP_FILTER) return;
const bx = b % BW, y = ((b / BW) | 0) * BS;
function filterCarry(b, d, carrying = true) {
if (!isFilter(blockP[b])) return;
const bx = b % BW, by = (b / BW) | 0, code = blockP[b], y = by * BS;
if (y < 1) return;
if (!carrying && !isSlopeBelt(code)) return;
const c = filterCfg.get(b), pass = c ? c.pass : null;
for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x;
if (part[top] === P_FILTER) carryColumn(top - W, d, pass);
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
if (part[top] === P_FILTER) {
if (isSlopeBelt(code)) screenNudge(top, d, pass);
else if (carrying) carryColumn(top - W, d, pass);
}
}
}
// Belts and filters share one front-first order, including their flat/slope joins.
function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
let k = 0, f = 0;
while (k < belts.length || f < filters.length) {
const fx = f < filters.length ? (filters[f] % BW) * BS + (d > 0 ? BS - 1 : 0) : 0;
if (f < filters.length && (k >= belts.length || (fx - belts[k] % W) * d > 0)) { filterCarry(filters[f++], d, carrying); continue; }
const b = belts[k++];
if (mat[b] !== bm || b < W) continue;
const x = b % W, y = (b / W) | 0, code = blockP[(y >> 2) * BW + (x >> 2)];
if (!carrying && !isSlopeBelt(code)) continue;
if (isSlopeBelt(code) && beltFaceY(code, x >> 2, y >> 2, x) !== y) continue;
if (slopeSealOwner(b) >= 0) continue;
const above = mat[b - W];
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
if (carrying) {
if (PH[above] === PH_LIQUID) beltDrain(b);
// uphill, the cleats do the carrying
if (!isSlopeBelt(code) || !slopeUphill(code, blockV[(y >> 2) * BW + (x >> 2)])) carryColumn(b - W, d, null);
}
}
}
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
function setFilter(b, cfg) {
if (blockP[b] !== BP_FILTER) return false;
renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS);
if (!isFilter(blockP[b])) return false;
const rec = filterRec(cfg);
filterCfg.set(b, rec);
const bx = b % BW, by = (b / BW) | 0;
@@ -79,11 +125,13 @@ function setFilter(b, cfg) {
}
// The run of filters in a row that touch block b and do the same thing it does
function filterRun(b) {
if (blockP[b] !== BP_FILTER) return [];
if (!isFilter(blockP[b])) return [];
const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b];
for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) {
const c = by * BW + bx;
if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
const yy = by + beltGrade(blockP[b]) * (bx - (b % BW));
if (yy < 0 || yy >= BH) break;
const c = yy * BW + bx;
if (blockP[c] !== blockP[b] || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
out.push(c);
}
return out.sort((a, c) => a - c);
@@ -91,8 +139,8 @@ function filterRun(b) {
// 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; }
// Cross one tube block, far lanes first; corners retain their liquid/gas swaps.
function tubeSpread(c, x, y, sx, sy, intake = false) {
// Cross one screw block, far lanes first; corners retain their liquid/gas swaps.
function screwSpread(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;
@@ -104,25 +152,62 @@ function tubeSpread(c, x, y, sx, sy, intake = false) {
return c;
}
// 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.
function tubeIntake(i, p, dx, dy) {
// (not from the cell it pushes into), so a screw end sitting in a pile feeds itself.
function screwIntake(i, p, dx, dy) {
if (occ[i]) return;
const x = i % W, y = (i / W) | 0;
const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]];
const start = (tick + i) % 3;
for (let c = 0; c < 3; c++) {
const [ox, oy] = cands[(start + c) % 3];
const k = (start + c) % 3, diagonal = dx && dy;
const ox = diagonal ? (k === 1 ? 0 : -dx) : k === 0 ? -dx : k === 1 ? dy : -dy;
const oy = diagonal ? (k === 0 ? 0 : -dy) : k === 0 ? -dy : k === 1 ? dx : -dx;
const nx = x + ox, ny = y + oy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
if (ox && oy && PH[mat[y * W + nx]] === PH_SOLID && PH[mat[ny * W + x]] === PH_SOLID) continue;
const j = ny * W + nx, m = mat[j];
if (m === EMPTY || occ[j] || stamp[j] === tickStamp) continue;
if (PH[m] !== PH_POWDER) continue; // the screw only takes loose material; liquids just flow through
if (stamp[i] === tickStamp) return;
const pj = part[j];
if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents
if (IS_SCREW[pj]) continue; // other screw cells move their own contents
if (pj === P_GRATE || pj === P_FILTER) continue;
move(j, i, nx, ny, x, y);
if (ox !== -dx || oy !== -dy) tubeSpread(i, x, y, -ox, -oy, true);
if (diagonal) screwSpread(i, x, y, 0, (y & 3) < 2 ? 1 : -1, true);
else if (ox !== -dx || oy !== -dy) screwSpread(i, x, y, -ox, -oy, true);
return;
}
}
// Powder may enter any screw except one pushing exactly against it.
function screwAccepts(p, dx, dy) {
return SCREW_DX[p] !== -dx || SCREW_DY[p] !== -dy;
}
// Straight mouths retain side-feeding into lifts; only diagonal transfers need a join.
function straightScrewNext(i, p) {
const dx = SCREW_DX[p], dy = SCREW_DY[p], x = i % W, y = (i / W) | 0;
const xx = x + dx, yy = y + dy, next = yy * W + xx;
if ((xx >> 2) === (x >> 2) && (yy >> 2) === (y >> 2)) return next;
const q = part[next];
if (!(SCREW_DX[q] && SCREW_DY[q])) return next;
const join = endJoin(x >> 2, y >> 2, dx + dy > 0 ? 1 : 0);
return join >= 0 && (join >> 1) === (yy >> 2) * BW + (xx >> 2) ? next : -1;
}
// Four lanes cross a block in four beats; only a mutual geometric join can receive them.
function diagonalScrewNext(i, p) {
const x = i % W, y = (i / W) | 0, dx = SCREW_DX[p], dy = SCREW_DY[p];
const xx = x + dx;
if (xx < 0 || xx >= W) return -1;
if ((xx >> 2) === (x >> 2)) return i + dx;
const yy = y + dy * BS, join = endJoin(x >> 2, y >> 2, dx > 0 ? 1 : 0);
if (join >= 0) {
const nb = join >> 1, nx = nb % BW * BS, ny = ((nb / BW) | 0) * BS;
if (blockP[nb] === BP_BELT || blockP[nb] === BP_FILTER) return (ny - 1) * W + xx;
if (blockP[nb] === BP_SCREW) {
const v = blockV[nb];
if (v >= 4) return yy * W + xx;
if (v & 1) return (ny + (y & 3)) * W + xx;
return (ny + (dy > 0 ? 0 : 3)) * W + nx + (y & 3);
}
}
return yy >= 0 && yy < H && !IS_SCREW[part[yy * W + xx]] ? yy * W + xx : -1;
}
+114
View File
@@ -0,0 +1,114 @@
// Dev commands are pure simulation operations; the browser owns the panel and confirmation.
let devWorld = false, devLog = [], devFree = false, devSpeed = 1, devPaused = false, devSteps = 0;
function resetDevConsole() {
devWorld = false; devLog = []; devFree = false; devSpeed = 1; devPaused = false; devSteps = 0;
}
function enableDevConsole() { devWorld = true; }
const DEV_HELP = [
'help — list commands',
'money <n> | money set <n> — add or set money; negative amounts allowed',
'debt <n> | debt clear — set or clear claim debt',
'give <material> [n] — add grains or items (default 1000); display or internal name, unique prefix',
'bit [tier] — list or set drill tiers (numbered from 0)',
'bag [tier] — list or set bag tiers (numbered from 0)',
'unlock — learn all materials and unlock progress rewards',
'tp [x y] — teleport in grains; no coordinates uses the cursor',
'free on|off — build without spending money or materials',
'speed <x> — speed multiplier (0.25, 0.5, 1, 2, 4; clamped to 0.1–8)',
'pause — toggle dev pause',
'step [n] — queue 1–10000 ticks while dev paused (default 1)',
'weather clear|rain — stop current rain or start a one-minute shower',
];
// Exact internal names win when a display name names a different material.
const DEV_MATERIALS = { EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, WOOD, LEAVES, COALV, COAL, CUV, CUORE, FEV, FEORE, ASH, SLAG, BRICK, GLASS, COPPER, IRON, STEEL, WATER, MAGMA, MCU, MFE, MST, MGL, STEAM, SMOKE, FIRE, BELTL, BELTR, SHAFT, GATE, HULL, SPRING, RSTONE, RGRAN, RBAS, LCLAY, LWOOD, RBRICK, CULLET, SCRCU, SCRFE, SCRST, BARCU, BARFE, BARST, ACORN, CHARCOAL, SNOW, ICE, 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, DOCKW, MSLAG, PIPE, BARGL, CUTST, BARSB, GRINDER, SAWB, PLANK, PRESSB, GREENB, BARBR, RACKB };
function devMaterial(name) {
name = name.toLowerCase();
const ids = Object.entries(DEV_MATERIALS);
const internal = ids.find(([key]) => key.toLowerCase() === name);
if (internal) return internal[1];
const exact = ids.filter(([, id]) => MD[id].name.toLowerCase() === name);
const matches = [...new Set((exact.length ? exact : ids.filter(([key, id]) => key.toLowerCase().startsWith(name) || MD[id].name.toLowerCase().startsWith(name))).map(([, id]) => id))];
return matches.length === 1 ? matches[0] : matches.length ? `Ambiguous material: ${matches.map(id => MD[id].name + ' (' + ids.find(([, m]) => m === id)[0] + ')').join(', ')}.` : 'Unknown material. Use a display name or internal name, such as copper ore or CUORE.';
}
function devCommand(text, cursor) {
if (typeof text !== 'string' || !text.trim()) return ['Type help to list commands.'];
text = text.trim();
const [cmd, ...args] = text.toLowerCase().split(/\s+/);
const usage = () => ['Usage: ' + DEV_HELP.find(s => s.startsWith(cmd + ' '))];
const number = s => s !== undefined && Number.isFinite(Number(s));
if (cmd === 'help' && !args.length) return DEV_HELP.slice();
if (!DEV_HELP.some(s => s.startsWith(cmd + ' '))) return ['Unknown command. Type help to list commands.'];
if (!devWorld) return ['Open the dev console and accept the world warning first.'];
let result;
switch (cmd) {
case 'money': {
const set = args[0] === 'set', value = Number(args[set ? 1 : 0]);
if (args.length !== (set ? 2 : 1) || !number(args[set ? 1 : 0]) || !number(String(set ? value : money + value))) return usage();
money = set ? value : money + value; result = 'Money: ' + fmtMoney(money); break;
}
case 'debt':
if (args.length !== 1 || (args[0] !== 'clear' && (!number(args[0]) || Number(args[0]) < 0))) return usage();
debt = args[0] === 'clear' ? 0 : Number(args[0]);
result = debt ? 'Claim debt: ' + fmtMoney(debt) : 'Claim paid off.'; break;
case 'give': {
if (!args.length) return usage();
let n = 1000;
if (number(args[args.length - 1])) n = Number(args.pop());
if (!args.length || !Number.isInteger(n) || n <= 0) return usage();
const m = devMaterial(args.join(' '));
if (typeof m === 'string') return [m];
if (m === EMPTY || PH[m] === PH_GAS || (PH[m] === PH_SOLID && !YLD[m])) return [`${MD[m].name} cannot go in the bag.`];
if (n > 2147483647 - bag[m]) return ['Too many: a material count cannot exceed 2147483647.'];
const into = PH[m] === PH_SOLID && YLD[m] ? YLD[m] : m;
bagAdd(m, n, true); result = `Added ${n} ${MD[into].name}${into !== m ? ` (as ${MD[m].name.toLowerCase()} breaks up)` : ''}.`; break;
}
case 'bit': case 'bag': {
const tiers = cmd === 'bit' ? BIT_TIERS : TANK_TIERS;
if (!args.length) return tiers.map((t, i) => `${i}: ${t.name}${t.cap ? ' (' + t.cap + ' grains)' : ''}`);
if (args.length !== 1 || !number(args[0]) || !Number.isInteger(Number(args[0]))) return usage();
const tier = Math.max(0, Math.min(tiers.length - 1, Number(args[0])));
player[cmd === 'bit' ? 'bitTier' : 'tankTier'] = tier;
result = `${cmd}: ${tier} — ${tiers[tier].name}`; break;
}
case 'unlock':
if (args.length) return usage();
seen.fill(1); player.lens = player.flask = true;
for (const m of MILESTONES) ledger[m.id] = ledger[m.id] || tick || 1;
result = 'All materials learned and progress rewards unlocked.'; break;
case 'tp': {
if (args.length !== 0 && (args.length !== 2 || !args.every(number))) return usage();
const x = args.length ? Number(args[0]) : cursor && cursor.x, y = args.length ? Number(args[1]) : cursor && cursor.y;
if (!Number.isFinite(x) || !Number.isFinite(y)) return ['No cursor position. Use tp <x> <y>.'];
bot.x = Math.max(0, Math.min(W - bot.w, x)); bot.y = Math.max(0, Math.min(H - bot.h, y)); bot.vx = bot.vy = 0;
fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
result = `Bot: ${bot.x}, ${bot.y} grains`; break;
}
case 'free':
if (args.length !== 1 || !['on', 'off'].includes(args[0])) return usage();
devFree = args[0] === 'on'; result = 'Free building ' + args[0] + '.'; break;
case 'speed':
if (args.length !== 1 || !number(args[0])) return usage();
devSpeed = Math.max(0.1, Math.min(8, Number(args[0]))); result = 'Speed: ' + devSpeed + '×'; break;
case 'pause':
if (args.length) return usage();
devPaused = !devPaused; devSteps = 0; result = devPaused ? 'Dev paused. Use step or pause to resume.' : 'Dev pause off.'; break;
case 'step': {
const n = args.length ? Number(args[0]) : 1;
if (args.length > 1 || !Number.isInteger(n) || n < 1 || n > 10000 || devSteps + n > 10000) return usage();
if (!devPaused) return ['Use pause before step.'];
devSteps += n; result = `Queued ${n} ticks.`; break;
}
case 'weather':
if (args.length !== 1 || !['clear', 'rain'].includes(args[0])) return usage();
weather.shower = args[0] === 'rain' ? { x0: Math.max(0, bot.x - 160), x1: Math.min(W, bot.x + 160), rate: 2, ramp: 1, until: tick + 3600 } : null;
if (args[0] === 'clear') {
weather.raining.fill(0);
for (const f of flyers) if (f.rain) absorbSteam(f.x);
flyers = flyers.filter(f => !f.rain);
}
weather.next = tick + rainGap(); result = args[0] === 'rain' ? 'A one-minute shower started near the bot.' : 'Current rain cleared. Stored clouds may rain again.'; break;
default: return usage();
}
devLog.push([tick, text]); if (devLog.length > 200) devLog.shift();
return ['> ' + text, result];
}
+21 -5
View File
@@ -28,7 +28,7 @@ function pulseRay(ox, oy, ang, Wd, f) {
}
// Is a solid cell something the player built (or a part of a machine or mold)? Pulses leave those alone.
function builtCell(i, x, y) {
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || slopeSealOwner(i) >= 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
}
// One press-and-hold step: fires a pulse when one is due. Returns { fired, status }.
function toolPulse(ax, ay, width, tune) {
@@ -43,7 +43,7 @@ function toolPulse(ax, ay, width, tune) {
// every cell in the cone is hit, with no untouched stripes between the rays
drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 });
lastPulseTick = tick;
// say why nothing breaks: the first solid on the centre line is too hard, or out of reach
// say why nothing breaks: the first solid on the centre line is too hard
let status = '';
for (let k = 3; k < reach; k++) {
const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k);
@@ -57,7 +57,6 @@ function toolPulse(ax, ay, width, tune) {
}
break;
}
if (!status && d > reach) status = `Out of reach; a ${Wd.name} pulse reaches ${reach} grains.`;
return { fired: true, status };
}
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
@@ -87,7 +86,8 @@ function crumbleAround(x, y) {
for (const j of got) shatterCell(j, j % W, (j / W) | 0, mat[j]);
}
}
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && (flags[i] & F_NAT) !== 0 && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
// Any solid nothing owns can be drilled: natural rock, and rock or metal that set loose in the world.
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
function shatterCell(i, x, y, m) {
const to = YLD[m];
setCell(i, to, temp[i], 0);
@@ -151,7 +151,7 @@ function pulseTick() {
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
if (h > tier.hard) { e *= 0.35; continue; }
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q;
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
e *= p.tune ? 0.96 : p.damp;
}
p.en[k] = e; p.s[k] = sd;
@@ -159,3 +159,19 @@ function pulseTick() {
}
drillPulses = drillPulses.filter(p => p.r < p.range + 3);
}
// Resonance ping (t50): an echo from the bot that finds ore veins in the rock around it, under
// fog too. Reach grows with the bit. Only reads the world; the UI draws the echo.
const PING_VEINS = new Uint8Array(NMAT); for (const m of [COALV, CUV, FEV, SNV, ZNV, AGV, AUV]) PING_VEINS[m] = 1;
function pingRadius() { return BIT_TIERS[player.bitTier].reach * 3; }
// Natural vein cells within r of (cx, cy), and the materials found.
function resonancePing(cx, cy, r = pingRadius()) {
const cells = [], found = new Set(), r2 = r * r;
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(W - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const dx = x - cx, dy = y - cy, i = y * W + x, m = mat[i];
if (dx * dx + dy * dy > r2 || !PING_VEINS[m] || !(flags[i] & F_NAT)) continue;
cells.push(i); found.add(m);
}
return { cells, found: [...found], r, x: cx, y: cy };
}
+26
View File
@@ -0,0 +1,26 @@
// Throughput for the info overlay (t49): how many grains each belt, filter and screw conveyor
// block hands on, per second of game time. Counting runs only while the overlay is on, so
// normal play pays one flag check per carried grain. Bars are not counted.
let flowOn = false, flowCount = new Uint16Array(0), flowRate = new Float32Array(0);
function setFlowCounting(on) {
flowOn = on;
if (flowCount.length !== NB) { flowCount = new Uint16Array(NB); flowRate = new Float32Array(NB); }
else if (!on) { flowCount.fill(0); flowRate.fill(0); }
}
// A grain left carrier block b across one of its edges.
function flowNote(b) { if (b >= 0 && b < flowCount.length && flowCount[b] < 65535) flowCount[b]++; }
// Count a move from cell a to cell j if it crosses into another column of blocks or row of blocks.
function flowCross(b, a, j) { if (((a % W) >> 2) !== ((j % W) >> 2) || ((a / W) >> 2) !== ((j / W) >> 2)) flowNote(b); }
// The sloped belt block carrying a grain at cell a: the slope under it in the same block column.
function flowSlopeUnder(a) {
const bx = (a % W) >> 2;
for (let by = ((a / W) | 0) >> 2, k = 0; k < 3 && by < BH; k++, by++) if (isSlopeBelt(blockP[by * BW + bx])) return by * BW + bx;
return -1;
}
// Once a second, fold the count into a smoothed rate.
function flowTick() {
if (flowCount.length !== NB) setFlowCounting(flowOn);
if (tick % 60) return;
for (let b = 0; b < NB; b++) { if (flowCount[b] || flowRate[b]) { flowRate[b] = flowRate[b] * 0.5 + flowCount[b] * 0.5; if (flowRate[b] < 0.05) flowRate[b] = 0; } }
flowCount.fill(0);
}
+4 -4
View File
@@ -3,11 +3,11 @@ 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 fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = sharedArray(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;
if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); }
}
function fogSeen(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
@@ -39,7 +39,7 @@ function fogLook(cx, cy) {
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;
if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); }
}
}
function fogRevealAround(x, y, r) {
@@ -49,4 +49,4 @@ function fogRevealAround(x, y, r) {
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
}
}
function fogRevealAll() { fog.fill(255); }
function fogRevealAll() { fog.fill(255); renderDirty.fill(1); }
+17 -16
View File
@@ -52,19 +52,17 @@ function condPair(a, b, sc, nc, da) {
const q = d * k * sc;
temp[a] -= q * ICAP[ma];
temp[b] += q * ICAP[mb];
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; }
}
function heatPass() {
for (let c = 0; c < NCH; c++) {
if (!hot[c]) continue;
let sc = 1;
if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
heatChunk(c, sc);
}
if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
}
function heatPass() { chunkPasses(true); }
function heatChunk(c, sc) {
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);
renderDirty[c] = 1;
if (cx > 0) renderDirty[c - 1] = 1;
if (cx + 1 < CW) renderDirty[c + 1] = 1;
if (cy > 0) renderDirty[c - CW] = 1;
if (cy + 1 < CH) renderDirty[c + CW] = 1;
roofDirty[cx] = 1;
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
let maxDev = 0, burning = false;
@@ -88,7 +86,7 @@ function heatChunk(c, sc) {
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
const a = ex * (sc > 1 ? 0.45 : 0.25);
t -= a; temp[u] += a;
if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; }
if (y === y0 && upCold && a > 0.5) { chunkHeat(c - CW); }
}
}
rel = y < roofY[x] ? 0.02 : 0.002;
@@ -105,8 +103,10 @@ function heatChunk(c, sc) {
if (flags[i] & F_BURN) burning = true;
}
}
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; }
else hot[c]--;
const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
else { hot[c] = h; hotLvl[c] = level; }
}
function neighborDir(i, x, y, d) {
if (d === 0) return y > 0 ? i - W : -1;
@@ -120,7 +120,7 @@ function react(i, x, y, m) {
if (fl & F_BURN) { burn(i, x, y, m, t); return; }
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
flags[i] = fl | F_BURN; life[i] = FUEL[m];
stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
if (chunkWork) chunkEvent('ignite'); else stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
return;
}
const ph = PH[m];
@@ -130,7 +130,7 @@ function react(i, x, y, m) {
if (rnd() < 0.02 - t * 0.004) {
const below = y < H - 1 ? PH[mat[i + W]] : PH_SOLID;
const to = below === PH_EMPTY || below === PH_GAS ? SNOW : ICE;
mat[i] = to; life[i] = 0; flags[i] = fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
mat[i] = to; life[i] = 0; flags[i] = to === ICE ? F_NAT : fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
}
return;
}
@@ -143,7 +143,8 @@ function react(i, x, y, m) {
if (L <= 0) {
const to = FRZTO[m];
mat[i] = to; life[i] = 0; temp[i] = fz - 1;
flags[i] = (fl & F_NAT) | (CASTMETAL[m] ? F_CAST : 0); // natural magma freezes into natural rock
// metal and glass set into castings; any other melt cools into ordinary rock you can drill, wherever it came from
flags[i] = CASTMETAL[m] ? (fl & F_NAT) | F_CAST : PH[to] === PH_SOLID ? F_NAT : fl & F_NAT;
wakeXY(x, y); discover(to, x, y, m, 'freeze', fz);
} else { life[i] = L; temp[i] = fz; }
return;
@@ -177,7 +178,7 @@ function react(i, x, y, m) {
if (rnd() < 0.03 + (t - mp) * 0.002) {
let to = MELTTO[m];
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); }
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) { if (chunkWork) chunkEvent('broken', mach[i]); else brokenMachines.push(mach[i]); } }
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
}
+124 -38
View File
@@ -5,7 +5,7 @@
// its cells as solid (occ).
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
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 && (IS_SCREW[p] || p === P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); }
function entSupportCount(x, y) {
if (y + BAR_H >= H) return BAR_W;
let n = 0;
@@ -38,72 +38,157 @@ function spawnBar(m, x, y, vx, vy, T) {
return e;
}
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) {
// No transient screw state: even a loaded bar derives its ride from its eight cells.
function entScrewAt(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;
const p = part[yy * W + xx]; if (!IS_SCREW[p]) return 0;
}
return part[(y + 1) * W + x + 1];
}
function entTubeFree(x, y, tubeOnly = false) {
function entScrewFree(x, y, screwOnly = 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;
const j = yy * W + xx, p = part[j], screw = IS_SCREW[p];
// gas and liquid don't stop a bar, in a screw or in the open
if (occ[j] || (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS && PH[mat[j]] !== PH_LIQUID) || (screwOnly ? !screw : !screw && 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;
function entScrewMove(e, x, y, screwOnly = false, mouth = false) {
if (!entScrewFree(x, y, screwOnly)) return false;
if (mouth) {
// Go around the receiving corner, checking both legs before moving.
const acrossY = y > e.iy ? e.iy : y, downX = y > e.iy ? x : e.ix;
for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entScrewFree(downX, yy)) return false;
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entScrewFree(xx, acrossY)) return false;
} else 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 (!entScrewFree(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) {
function entScrewLane(y, iy) { return Math.max(y & ~3, Math.min((y & ~3) + 2, iy)); }
function entScrewEdge(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) {
function entScrewEnter(e, dx, dy, mouth = false) {
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
const i = entTubeEdge(e, dx, dy, k);
const i = entScrewEdge(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;
const p = part[i]; if (!IS_SCREW[p] || (!dx && SCREW_DX[p]) || dx * SCREW_DX[p] + dy * SCREW_DY[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;
const x = (xx & ~3), y = entScrewLane(yy, e.iy);
if (entScrewMove(e, x, SCREW_DX[p] || dy ? y : e.iy, true, mouth)) return true;
}
return false;
}
function entTubeTick(e, p) {
function entScrewTick(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];
const dx = SCREW_DX[p], dy = SCREW_DY[p];
if (dx && dy) { entDiagonalScrewTick(e, p); return; }
const frontX = e.ix + (dx > 0 ? BAR_W - 1 : 0), frontY = e.iy + (dy > 0 ? BAR_H - 1 : 0);
const front = frontY * W + frontX, dest = straightScrewNext(front, p);
if (dest < 0) return;
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);
const i = entScrewEdge(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 (IS_SCREW[q] && q !== p) {
if (SCREW_DX[q] && SCREW_DY[q]) {
const join = endJoin(frontX >> 2, frontY >> 2, dx + dy > 0 ? 1 : 0);
if (join < 0 || (join >> 1) !== (yy >> 2) * BW + (xx >> 2)) return;
}
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++;
if (!screwAccepts(next, dx, dy)) return;
x = ax & ~3; y = entScrewLane(ay, e.iy);
if (entScrewMove(e, x, y, true)) stats.moved++;
return;
}
if (entTubeAt(x, y)) { if (entTubeMove(e, x, y, true)) stats.moved++; return; }
if (entScrewAt(x, y)) { if (entScrewMove(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;
if (!entScrewFree(x, y) || entScrewAt(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++; }
if (entScrewMove(e, x, y)) { e.vx = dx ? dx * 0.5 : (rnd() - 0.5) * 0.4; e.vy = dy * 0.6; stats.moved++; }
}
// A horizontal bar stays whole: one diagonal block per four screw beats.
function entDiagonalScrewTick(e, p) {
if (tick & 7) return;
const dx = SCREW_DX[p], dy = SCREW_DY[p], bx = e.ix & ~3, by = e.iy & ~3, lane = e.iy & 3;
const edge = (by + lane) * W + bx + (dx > 0 ? BS - 1 : 0);
const dest = diagonalScrewNext(edge, p);
if (dest < 0) return;
const x = (dest % W) & ~3;
let y = (dest / W) | 0;
const q = part[dest];
if (IS_SCREW[q]) {
if (SCREW_DX[q] * dx + SCREW_DY[q] * dy < 0) return;
y = Math.min((y & ~3) + BS - BAR_H, y);
if (entScrewMove(e, x, y, true)) stats.moved++;
return;
}
const join = endJoin(bx >> 2, by >> 2, dx > 0 ? 1 : 0);
if (join >= 0 && (blockP[join >> 1] === BP_BELT || blockP[join >> 1] === BP_FILTER)) y -= BAR_H - 1;
if (!entScrewFree(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 (entScrewMove(e, x, y, false, dy < 0)) { e.vx = dx * 0.5; e.vy = dy * 0.6; stats.moved++; }
}
// A same-row flat belt drops its bar into a joined screw, just as grains fall off its face.
function entBeltScrewEnter(e) {
if (e.ix & 3) return false;
const y = e.iy + BAR_H;
if (y >= H || (y & 3)) return false;
const b = (y >> 2) * BW + (e.ix >> 2), c = blockP[b], v = blockV[b];
if (c !== BP_BELT && c !== BP_FILTER) return false;
if (c === BP_FILTER && filterPassAt(e.m, y * W + e.ix)) return false;
if (netSat(y * W + e.ix) <= 0) return false;
const d = c === BP_FILTER ? (v & 128 ? 1 : -1) : (v & 1 ? 1 : -1);
const join = endJoin(b % BW, (b / BW) | 0, d > 0 ? 1 : 0);
if (join < 0 || blockP[join >> 1] !== BP_SCREW) return false;
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
const nb = join >> 1;
if (!(tick & 1) && entScrewMove(e, nb % BW * BS, ((nb / BW) | 0) * BS, true, true)) stats.moved++;
return true;
}
// Bars stay level; their highest contact follows the face one grain per belt beat.
function entSlopeBeltTick(e) {
const bottom = e.iy + BAR_H;
if (bottom >= H) return false;
let d = 0, slope = false, screen = false;
for (let x = e.ix; x < e.ix + BAR_W; x++) {
const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b];
if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) {
d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
}
}
if (!d) return false;
let face = H;
for (let x = e.ix + d; x < e.ix + d + BAR_W; x++) for (let dy = -1; dy <= 1; dy++) {
const y = bottom + dy;
if (x < 0 || x >= W || y < 0 || y >= H) continue;
const b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue;
if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
face = Math.min(face, y);
}
if (!slope) return false;
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
if (screen ? tick % SCREEN_NUDGE_TICKS : tick & 1) return true;
const y = face === H ? e.iy : face - BAR_H, x = e.ix + d;
if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; }
else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true);
return true;
}
function entTick() {
for (let k = ents.length - 1; k >= 0; k--) {
@@ -111,9 +196,9 @@ function entTick() {
entMark(e, 0);
entCurM = e.m;
let ix = e.ix; const iy = e.iy;
const tube = entTubeAt(ix, iy);
if (tube) entTubeTick(e, tube);
else {
const screw = entScrewAt(ix, iy);
if (screw) entScrewTick(e, screw);
else if (!entBeltScrewEnter(e) && !entSlopeBeltTick(e)) {
// what is underneath?
let belt = 0, bsat = 0, transport = false, support = iy + BAR_H >= H;
if (!support) {
@@ -149,10 +234,10 @@ function entTick() {
e.x += e.vx; e.y += e.vy;
// 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;
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 (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 (support && !belt && entTubeEnter(e, 0, 1)) { cx = e.ix; cy = e.iy; }
if (!entTubeAt(cx, cy) && !entFreeAt(cx, cy)) {
while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else if (belt && entScrewEnter(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 (entScrewAt(cx, cy)) break; if (entFreeAt(cx, cy + s)) cy += s; else if (entScrewEnter(e, 0, s)) { cx = e.ix; cy = e.iy; break; } else { e.vy = 0; e.y = cy; break; } }
if (support && !belt && entScrewEnter(e, 0, 1)) { cx = e.ix; cy = e.iy; }
if (!entScrewAt(cx, cy) && !entFreeAt(cx, cy)) {
// something moved into us (a grain fell in): try to step up out of it
let freed = false;
for (let u = 1; u < 6; u++) if (entFreeAt(cx, cy - u)) { cy -= u; e.y = cy; freed = true; break; }
@@ -194,6 +279,7 @@ function entsInCircle(cx, cy, r) {
// ---------- Bag ----------
function bagAdd(m, n, force) {
if (PH[m] === PH_SOLID && YLD[m]) m = YLD[m]; // rock and metal go in as what drilling them gives
const room = force ? n : Math.max(0, bagCap() - bagTotal);
const k = Math.min(n, room);
bag[m] += k; bagTotal += k;
@@ -225,8 +311,8 @@ function giveOrDrop(m, n, x, y) {
if (rest > 0) bagAdd(m, rest, true);
}
function refundAt(cost, mult, x, y) { for (const [m, n] of cost) giveOrDrop(m, n * mult, x, y); }
function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
function canAfford(cost, mult) { if (devFree) return true; for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
function payCost(cost, mult) { if (devFree) return; 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 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(c ? [c] : cost, mult) + '.'; }
+125
View File
@@ -0,0 +1,125 @@
// Travel-directed transport ends. Precomputed probes; world/plan queries allocate nothing.
// End indices stay geometric: left/right, or up/down for vertical screws.
const JOIN_ENDS = [[]];
for (const [kind, tx, ty] of [
...SCREW_DIRS.map(([x,y]) => [x && y ? 'screw-diagonal' : x ? 'screw-horizontal' : 'screw-vertical',x,y]),
['belt-flat',-1,0], ['belt-flat',1,0],
['belt-sloped',-1,1], ['belt-sloped',1,-1],
['belt-sloped',-1,-1], ['belt-sloped',1,1]
]) {
const outlet = (tx || ty) > 0 ? 1 : 0;
JOIN_ENDS.push([0,1].map(e => ({
dx: (e ? 1 : -1) * Math.abs(tx), dy: (e ? 1 : -1) * (tx ? ty * tx : 1),
diagonal: !!(tx && ty), kind, tx, ty, outlet: e === outlet, targets: []
})));
}
function pieceEnds(code, v) {
if (code === BP_SCREW) return JOIN_ENDS[v >= 0 && v < 8 ? v + 1 : 0];
if (!isBelt(code) && !isFilter(code)) return JOIN_ENDS[0];
const d = isFilter(code) ? (v & 128 ? 1 : 0) : (v & 1);
return JOIN_ENDS[9 + d + (beltGrade(code) < 0 ? 2 : beltGrade(code) > 0 ? 4 : 0)];
}
for (const ends of JOIN_ENDS) for (let e = 0; e < ends.length; e++) {
const a = ends[e];
if (!a.outlet) continue;
for (const others of JOIN_ENDS) for (let n = 0; n < others.length; n++) {
const b = others[n], screw = a.kind.startsWith('screw'), otherScrew = b.kind.startsWith('screw');
if (b.outlet || a.tx * b.tx + a.ty * b.ty <= 0) continue;
if (screw !== otherScrew && (a.kind === 'screw-vertical' || b.kind === 'screw-vertical' ||
(a.diagonal && b.diagonal))) continue;
if (a.diagonal && b.diagonal && (a.tx !== b.tx || a.ty !== b.ty)) continue;
const dy = !screw && a.ty < 0 ? 0 : a.ty;
const offsets = [[a.tx, dy]];
// Grains on a flat belt below an uphill low end already reach its carrying face.
if (!screw && !otherScrew && !a.ty && b.ty < 0) offsets.push([a.tx, -1]);
for (const [dx, yy] of offsets) {
a.targets.push({ dx, dy: yy, ends: others, end: n });
b.targets.push({ dx: -dx, dy: -yy, ends, end: e });
}
}
}
for (const ends of JOIN_ENDS) for (const end of ends) {
const sameShape = t => t.ends[t.end].kind === end.kind &&
t.ends[t.end].tx === end.tx && t.ends[t.end].ty === end.ty;
end.targets.sort((a,b) => end.outlet ? Math.abs(a.dy - end.ty) - Math.abs(b.dy - end.ty) :
Number(sameShape(b)) - Number(sameShape(a)) || a.dy - b.dy || a.dx - b.dx);
}
// Result: neighbour block index * 2 + its end index; -1 means an open end.
function joinCandidate(bx, by, endIndex, planned) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return -1;
const b = by * BW + bx, plan = planned && planned.get(b);
const ends = pieceEnds(plan ? plan[0] : blockP[b], plan ? plan[1] : blockV[b]);
const end = ends[endIndex];
if (!end) return -1;
for (let k = 0; k < end.targets.length; k++) {
const target = end.targets[k];
const x = bx + target.dx, y = by + target.dy;
if (x < 0 || y < 0 || x >= BW || y >= BH) continue;
const nb = y * BW + x, next = planned && planned.get(nb);
if (pieceEnds(next ? next[0] : blockP[nb], next ? next[1] : blockV[nb]) === target.ends)
return nb * 2 + target.end;
}
return -1;
}
function endJoin(bx, by, endIndex, planned = null) {
const join = joinCandidate(bx, by, endIndex, planned);
if (join < 0) return -1;
const nb = join >> 1;
// Competing neighbours cannot claim an end that chose a different partner.
return joinCandidate(nb % BW, (nb / BW) | 0, join & 1, planned) === (by * BW + bx) * 2 + endIndex ? join : -1;
}
function pieceJoins(bx, by, planned = null) {
return (endJoin(bx, by, 0, planned) >= 0 ? 1 : 0) | (endJoin(bx, by, 1, planned) >= 0 ? 2 : 0);
}
function endAt(b, end, planned = null) {
const p = planned && planned.get(b);
return pieceEnds(p ? p[0] : blockP[b], p ? p[1] : blockV[b])[end];
}
// A same-row join into a sloped belt's low end: the flat surface is level with the top of the
// slope's first block, so grains drop about 3 grains onto its face. Joined, but not seamless.
function joinIsDrop(bx, by, end, join, planned = null) {
if (join < 0 || (((join >> 1) / BW) | 0) !== by) return false;
const low = e => !!e && e.kind === 'belt-sloped' && e.dy > 0;
return low(endAt(by * BW + bx, end, planned)) || low(endAt(join >> 1, join & 1, planned));
}
// Rows to move a line so its first block joins: 0, -1 or +1. A line that already joins stays
// put; it moves only when one block up or down gives a seamless join involving a diagonal piece
// and every block there is free, so straight lines beside straight lines never jump.
function joinSnap(code, v, cells) {
if (!cells.length) return 0;
const joins = dy => {
const plan = new Map(cells.map(([x, y]) => [(y + dy) * BW + x, [code, v]]));
const x = cells[0][0], y = cells[0][1] + dy, own = pieceEnds(code, v);
for (let end = 0; end < own.length; end++) {
const j = endJoin(x, y, end, plan);
if (j < 0 || plan.has(j >> 1) || joinIsDrop(x, y, end, j, plan)) continue;
return own[end].diagonal || endAt(j >> 1, j & 1, plan).diagonal ? 2 : 1;
}
return 0;
};
if (!pieceEnds(code, v).length || joins(0)) return 0;
for (const dy of [-1, 1]) {
if (cells.every(([x, y]) => canPlaceBlock(x, y + dy, code) === '') && joins(dy) === 2) return dy;
}
return 0;
}
// "Doesn't connect": an end with no join that would join seamlessly, with a diagonal piece
// involved, if its block sat one higher (-1) or lower (+1) in empty space. 0 when no near miss.
// A drop join counts as connected. The moved block is tried through a small override view of
// `planned`, so ghost maps are never copied.
const NO_PIECE = [0, 0];
function nearMiss(bx, by, end, planned = null) {
const b = by * BW + bx, p = planned && planned.get(b), code = p ? p[0] : blockP[b], v = p ? p[1] : blockV[b];
const own = pieceEnds(code, v);
if (!own[end] || endJoin(bx, by, end, planned) >= 0) return 0;
const entry = [code, v];
for (const dy of [-1, 1]) {
const y = by + dy, nb = y * BW + bx;
if (y < 0 || y >= BH || blockP[nb] || (planned && planned.get(nb))) continue;
const view = { get: k => k === b ? NO_PIECE : k === nb ? entry : planned ? planned.get(k) : undefined };
view.has = k => view.get(k) !== undefined;
const j = endJoin(bx, y, end, view);
if (j >= 0 && !joinIsDrop(bx, y, end, j, view) && (own[end].diagonal || endAt(j >> 1, j & 1, view).diagonal)) return dy;
}
return 0;
}
+28 -35
View File
@@ -1,4 +1,4 @@
// Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
// Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
function engineIntake(mm) {
for (const i of mm.intake) {
if (mat[i] === STEAM) {
@@ -69,7 +69,7 @@ function machinesTick() {
for (let n = 1; n < nets.length; n++) {
const net = nets[n];
let s = 0; for (const p of net.prods) s += p.out;
let d = net.nb * BELT_POWER + net.nt * TUBE_POWER;
let d = net.nb * BELT_POWER + net.nt * SCREW_POWER;
for (const f of net.fans) if (f.on) d += FAN_POWER;
net.supply = s; net.demand = d;
net.sat = POWER_FREE ? 1 : d > 0 ? Math.min(1, s / d) : 0;
@@ -87,33 +87,22 @@ function machinesTick() {
mm.hot = Math.round(hotT);
}
}
// belts and filters move what rests on them every other tick: half a grain per tick
if ((tick & 1) === 0) {
for (let pass = 0; pass < 2; pass++) {
const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL;
for (let k = 0; k < list.length; k++) {
const b = list[k];
if (mat[b] !== bm || b < W) continue;
const above = mat[b - W];
if (above === BELTL || above === BELTR) continue; // only the top surface carries
if (netSat(b) <= 0) continue;
if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null);
}
}
if (filtersDirty) rebuildFilters();
for (const b of filterR) filterCarry(b, 1);
for (const b of filterL) filterCarry(b, -1);
}
// tubes: every loose grain inside steps one cell along, front of the line first, on the same
// Belts step every other tick; sloped belts carry only their finite pockets.
if (filtersDirty) rebuildFilters();
// slopes first, so a flat belt feeding one finds the room the slope has just made
slopeCleatsTick();
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
// screws: every loose grain inside steps one cell along, front of the line first, on the same
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
if ((tick & 1) === 0) for (let p = P_TU; p <= P_TR; p++) {
const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0;
if ((tick & 1) === 0) for (let p = 1; p < IS_SCREW.length; p++) {
if (!IS_SCREW[p]) continue;
const list = screwLists[p], dx = SCREW_DX[p], dy = SCREW_DY[p], flat = dy === 0;
for (let k = 0; k < list.length; k++) {
const i = list[k];
if (part[i] !== p || occ[i]) continue;
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) screwIntake(i, p, dx, dy); continue; }
if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i);
@@ -124,7 +113,7 @@ function machinesTick() {
const x = i % W, y = (i / W) | 0;
// 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) {
if (!flat && !dx) {
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++;
@@ -140,24 +129,28 @@ function machinesTick() {
}
if (shifted) continue;
}
const nx = x + dx, ny = y + dy;
if (x + dx < 0 || x + dx >= W || y + dy < 0 || y + dy >= H) continue;
const dest = dx && dy ? diagonalScrewNext(i, p) : straightScrewNext(i, p);
if (dest < 0) continue;
const nx = dest % W, ny = (dest / W) | 0;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = i + dj;
const j = dest;
if (occ[j]) continue;
const pj = part[j], tube = pj >= P_TU && pj <= P_TR;
if (tube && TDX[pj] === -dx && TDY[pj] === -dy) continue;
const pj = part[j], screw = IS_SCREW[pj];
if (screw && !screwAccepts(pj, dx, dy)) continue;
const t = mat[j];
if (!roomFor(j)) continue;
if (pj === P_GRATE || pj === P_INTAKE || (pj === P_FILTER && !filterPassAt(m, j))) continue;
if (!tube && !pj && t === EMPTY) {
if (!screw && !pj && t === EMPTY) {
// out of the mouth: the screw spits it clear, so the next grain is never blocked by the last
launchGrain(i, nx, ny, dy ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
stats.moved++; continue;
launchGrain(i, nx, ny, dy && !dx ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
stats.moved++; if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j); continue;
}
move(i, j, x, y, nx, ny); stats.moved++;
if (tube && pj !== p) {
// turning a corner: keep going across the new tube's width so every lane fills
tubeSpread(j, nx, ny, dx, dy);
if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j);
if (screw && pj !== p) {
// turning a corner: keep going across the new screw's width so every lane fills
screwSpread(j, nx, ny, dx, dy);
}
}
}
+17 -8
View File
@@ -5,12 +5,12 @@ function markPow(i, on) {
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
powerDirty = true;
}
function partLost(i) { markPow(i, false); }
function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
let nextPowerBuild = 0;
function rebuildPower() {
powerDirty = false;
for (const i of pcells) netId[i] = 0;
nets = [null]; beltL = []; beltR = []; tubeLists = [[], [], [], [], []];
nets = [null]; beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []);
const stack = [];
for (const s of pcells) {
if (netId[s]) continue;
@@ -23,8 +23,13 @@ function rebuildPower() {
const i = stack.pop();
const m = mat[i], p = part[i];
if (m === BELTL) { net.nb++; beltL.push(i); } else if (m === BELTR) { net.nb++; beltR.push(i); }
if (p >= P_TU && p <= P_TR) { net.nt++; tubeLists[p].push(i); }
const x = i % W;
if (IS_SCREW[p]) { net.nt++; screwLists[p].push(i); }
const x = i % W, y = (i / W) | 0;
// Corner-touching screws share power, without connecting unrelated diagonal machinery.
if (IS_SCREW[p] && SCREW_DX[p] && SCREW_DY[p]) for (let d = -1; d <= 1; d += 2) {
const xx = x + d * SCREW_DX[p], yy = y + d * SCREW_DY[p], j = yy * W + xx;
if (xx >= 0 && xx < W && yy >= 0 && yy < H && part[j] === p && isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); }
}
let j;
if (x > 0) { j = i - 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
if (x < W - 1) { j = i + 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
@@ -39,16 +44,20 @@ function rebuildPower() {
if (!net) continue;
if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm);
}
beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a);
tubeLists[P_TU].sort((a, b) => a - b); tubeLists[P_TD].sort((a, b) => b - a);
tubeLists[P_TL].sort((a, b) => a - b); tubeLists[P_TR].sort((a, b) => b - a);
beltL.sort((a, b) => (a % W) - (b % W) || a - b); beltR.sort((a, b) => (b % W) - (a % W) || b - a);
for (const p of SCREW_PARTS) {
screwLists[p].sort((a, b) => {
if (SCREW_DX[p] && SCREW_DY[p]) return ((b % W) - (a % W)) * SCREW_DX[p];
return (SCREW_DY[p] || SCREW_DX[p]) < 0 ? a - b : b - a;
});
}
}
function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return n ? n.sat : 0; }
// Move loose grains out of the given cells, to the nearest open cell above
// the footprint in the same column (they then fall back on top). Gas just vanishes.
function evictCells(cells, topY) {
for (const i of cells) bgm[i] = 0;
for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); }
for (const i of cells) {
const m = mat[i], ph = PH[m];
if (m === EMPTY || ph === PH_SOLID) continue;
+7 -2
View File
@@ -28,8 +28,13 @@ const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, T
// ---------- 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];
const P_NONE = 0, P_SCREW_U = 1, P_SCREW_D = 2, P_SCREW_L = 3, P_SCREW_R = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10, P_SCREW_UR = 11, P_SCREW_DR = 12, P_SCREW_DL = 13, P_SCREW_UL = 14;
const SCREW_PARTS = [P_SCREW_U, P_SCREW_R, P_SCREW_D, P_SCREW_L, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL];
const SCREW_DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [1, 1], [-1, 1], [-1, -1]];
const IS_SCREW = new Uint8Array(15), SCREW_DX = new Int8Array(15), SCREW_DY = new Int8Array(15);
for (let v = 0; v < SCREW_PARTS.length; v++) {
const p = SCREW_PARTS[v]; IS_SCREW[p] = 1; SCREW_DX[p] = SCREW_DIRS[v][0]; SCREW_DY[p] = SCREW_DIRS[v][1];
}
// ---------- Tool tiers ----------
// reach: how far from the bot the drill works. Harder rock takes longer, and
+2 -2
View File
@@ -46,7 +46,7 @@ def(SLAG, { name: 'Slag', ph: PH_POWDER, col: [76, 66, 64], v: 34, dens: 2.4, k:
def(MSLAG, { name: 'Molten slag', ph: PH_LIQUID, col: [232, 112, 40], v: 16, dens: 3.6, k: 0.02, cap: 1.2, hard: 0, freeze: 1000, freezeTo: SLAG, fluid: 0.5, spread: 10, liqT: 1150, desc: 'Smelting waste. Trickles down through the charge but is too thick to pass a grate, so it pools there and sets into slag. Knocking out the mold below rakes it away.' });
def(BRICK, { name: 'Brick', col: [168, 76, 56], v: 16, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, yld: RBRICK, desc: 'Fired clay. Insulates and survives 1750°C.' });
def(GLASS, { name: 'Glass', col: [178, 216, 224], v: 6, k: 0.006, hard: 2, melt: 1300, meltTo: MGL, alpha: 0.45, yld: CULLET, desc: 'Clear. Lets light through.' });
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, tubes, fans, engines.' });
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, screw conveyors, fans, engines.' });
def(IRON, { name: 'Iron', col: [128, 131, 138], v: 8, k: 0.035, cap: 1.5, hard: 5, melt: 1538, meltTo: MFE, yld: SCRFE, desc: 'Strong metal. Melts at 1538°C.' });
def(STEEL, { name: 'Steel', col: [172, 180, 192], v: 6, k: 0.035, cap: 1.5, hard: 6, melt: 1450, meltTo: MST, yld: SCRST, desc: 'Iron refined by blowing air through it while molten. The best bit material.' });
def(WATER, { name: 'Water', ph: PH_LIQUID, col: [44, 100, 194], v: 6, dens: 1, k: 0.02, cap: 4, hard: 0, boil: 100, boilTo: STEAM, fluid: 1, spread: 48, alpha: 0.78, liqT: 20, bag: true, desc: 'Boils at 100°C. Steam that rises into the open sky comes back down as rain.' });
@@ -77,7 +77,7 @@ def(CULLET, { name: 'Glass shards', ph: PH_POWDER, col: [196, 228, 236], v: 22,
def(SCRCU, { name: 'Copper scrap', ph: PH_POWDER, col: [192, 118, 74], v: 36, dens: 5, k: 0.035, cap: 1.5, hard: 0, stick: 0.7, melt: 1085, meltTo: MCU, bag: true, desc: 'Copper that set outside a mold. Melt it again and cast it.' });
def(SCRFE, { name: 'Iron scrap', ph: PH_POWDER, col: [124, 126, 132], v: 30, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1538, meltTo: MFE, bag: true, desc: 'Iron that set outside a mold. Melt it again and cast it.' });
def(SCRST, { name: 'Steel scrap', ph: PH_POWDER, col: [168, 176, 188], v: 28, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1450, meltTo: MST, bag: true, desc: 'Steel that set outside a mold. Melt it again and cast it.' });
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw tubes, fans and engines are made from these.' });
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw conveyors, fans and engines are made from these.' });
def(BARFE, { name: 'Iron bar', ph: PH_ITEM, col: [140, 143, 150], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1538, meltTo: MFE, bag: true, desc: 'A cast bar of iron.' });
def(BARST, { name: 'Steel bar', ph: PH_ITEM, col: [184, 192, 204], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1450, meltTo: MST, bag: true, desc: 'A cast bar of steel.' });
def(ACORN, { name: 'Acorn', ph: PH_POWDER, col: [150, 104, 56], v: 22, dens: 0.9, k: 0.006, hard: 0, stick: 0.6, ign: 300, flame: 700, flameF: 900, fuel: 30, burnTo: ASH, ashP: 0.3, bag: true, desc: 'A tree seed. Left on dirt or grass under open sky, it sprouts and grows into a tree.' });
+4 -2
View File
@@ -4,6 +4,7 @@
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
function cutTree(i, x, y, tier, amt = 1) {
const b = bgm[i];
renderDirtyIdx(i);
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
if (p < 255) { digp[i] = p; return false; }
digp[i] = 0; bgm[i] = 0;
@@ -104,6 +105,7 @@ function plantTick() {
if (y <= 6 || y >= H - 1) continue;
const i = y * W + x;
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
renderDirtyIdx(i);
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
let room = true;
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
@@ -120,7 +122,7 @@ function setTreeCell(x, y, m) {
const i = y * W + x;
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
if (m === LEAVES && bgm[i]) return;
bgm[i] = m;
bgm[i] = m; renderDirtyIdx(i);
if (treeDraw) treeDraw.push(i);
}
function saplingTick() {
@@ -134,7 +136,7 @@ function saplingTick() {
sp.age++;
const s = Math.min(1, sp.age / 180);
// wipe the previous stage, then draw this one
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0;
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); }
treeDraw = sp.cells = [];
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
+46 -10
View File
@@ -4,8 +4,36 @@
// Everything you build snaps to a grid of 4x4-grain blocks. The world itself
// stays grain-level; only built pieces snap.
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, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14;
const BP_WALL = 1, BP_BELT = 2, BP_SCREW = 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;
const BP_SLOPE_BL = 15, BP_SLOPE_BR = 16, BP_SLOPE_TL = 17, BP_SLOPE_TR = 18;
// The shape is independent of the direction: reversing a belt never flips its face.
const BP_BELT_UP = 19, BP_BELT_DOWN = 20, BP_FILTER_UP = 21, BP_FILTER_DOWN = 22;
function isSlopeBelt(c) { return c >= BP_BELT_UP && c <= BP_FILTER_DOWN; }
function isFilter(c) { return c === BP_FILTER || c === BP_FILTER_UP || c === BP_FILTER_DOWN; }
function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_DOWN; }
function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; }
function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; }
function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); }
function filterVariant(c, v) { return beltGrade(c) ? (beltGrade(c) > 0 ? 128 : 0) : v & 128; }
function beltDirection(c, v) {
const d = isFilter(c) ? (filterVariant(c, v) & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left';
}
function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; }
function isWall(c) { return c === BP_WALL || isSlope(c); }
function slopeCell(c, x, y) {
c = slopeShape(c);
if (c === BP_SLOPE_BR || c === BP_SLOPE_TR) x = 3 - x;
if (c === BP_SLOPE_TL || c === BP_SLOPE_TR) y = 3 - y;
return y >= x;
}
function slopeCode(dx, dy, orientation) {
if (!diagonalDrag(dx, dy)) return BP_SLOPE_BL + orientation;
return dx * dy > 0 ? (orientation >= 2 ? BP_SLOPE_TR : BP_SLOPE_BL) : (orientation >= 2 ? BP_SLOPE_TL : BP_SLOPE_BR);
}
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
// pieces that carry something along a run: a Shift-drag still draws a line, never a filled rectangle
function lineOnly(c) { return isBelt(c) || isFilter(c) || c === BP_SCREW || c === BP_SHAFT || isPipeCode(c); }
// 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
// mode (inv 1) the chosen ones ride along and everything else falls through. The block
@@ -18,7 +46,12 @@ function filterRec(c) {
for (let m = 1; m < NMAT; m++) if (filterable(m)) pass[m] = (k.m.includes(m) ? 1 : 0) ^ k.inv;
return { m: k.m, inv: k.inv, pass };
}
function filterPassAt(m, j) { const c = filterCfg.get((((j / W) | 0) >> 2) * BW + ((j % W) >> 2)); return c ? c.pass[m] : 0; }
function filterPassAt(m, j) {
let b = (((j / W) | 0) >> 2) * BW + ((j % W) >> 2);
if (!isFilter(blockP[b])) { const owner = slopeSealOwner(j); if (owner >= 0) b = owner; }
const c = filterCfg.get(b);
return c ? c.pass[m] : 0;
}
function filterInfo(b) { const c = filterCfg.get(b); return c ? { m: c.m.slice(), inv: c.inv } : { m: [], inv: 0 }; }
function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length && a.m.every((m, k) => m === b.m[k]); }
// loose material -> the solid it becomes in a wall block
@@ -38,13 +71,13 @@ const POWER_FREE = true;
const CASH = 255; // stands for money in cost lists
const PIECES = [
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
desc: 'A solid block. R picks what it is made of: 16 grains of rubble, lumps or chunks, or 2 bars of metal.' },
desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' },
{ 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.' },
{ 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 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.' },
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. Drag diagonally for a sidewall belt. Moving cleats lift pockets of grains; extra grains roll back.' },
{ id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
desc: 'A turning screw inside a tube 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. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' },
{ 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 shaking mesh that joins belts. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally for a downhill screen. Grains slide down while it shakes; R keeps a picked slope downhill.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
@@ -70,7 +103,7 @@ const PIECES = [
{ 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.' },
{ 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 screw conveyor). 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: '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.' },
{ id: 'dock', name: 'Trade dock', kind: 'machine', bw: 6, bh: 4, cost: [], hidden: true, fixed: true,
@@ -84,8 +117,11 @@ const PIECES = [
];
const PIECE_BY_ID = {}, PIECE_BY_CODE = [];
PIECES.forEach((p, i) => { p.index = i; PIECE_BY_ID[p.id] = p; if (p.code) PIECE_BY_CODE[p.code] = p; if (p.kind === 'machine') { p.w = p.bw * BS; p.h = p.bh * BS; } });
// power per grain of belt / tube (a block is 16 grains)
const BELT_POWER = 0.03, TUBE_POWER = 0.125, FAN_POWER = 6;
for (let c = BP_SLOPE_BL; c <= BP_SLOPE_TR; c++) PIECE_BY_CODE[c] = PIECE_BY_ID.wall;
for (const c of [BP_BELT_UP, BP_BELT_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.belt;
for (const c of [BP_FILTER_UP, BP_FILTER_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.filter;
// power per grain of belt / screw (a block is 16 grains)
const BELT_POWER = 0.03, SCREW_POWER = 0.125, FAN_POWER = 6;
const WHEEL_K = 0.32, ENGINE_K = 40;
const MOLDS = {
+2 -2
View File
@@ -30,14 +30,14 @@ const MILESTONES = [
{ 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, 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: '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 conveyors 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 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: '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: 'bit3', title: 'Steel drill bit', hint: 'Cast steel in a brick Bit mold.', reward: 'Steel bit. Basalt is now diggable.' },
{ id: 'tank', title: 'Bigger bag', hint: 'Cast any metal in a Tank mold and knock it out to carry more.' },
{ id: 'sale', title: 'First sale', msg: 'Money buys machines: belts, screw tubes and fans. Refined goods pay far more than raw.', hint: 'Blow, belt or lift goods into the trade dock near where you started. They are paid for the moment they drop through the grate in its floor.' },
{ id: 'sale', title: 'First sale', msg: 'Money buys machines: belts, screw conveyors and fans. Refined goods pay far more than raw.', hint: 'Blow, belt or lift goods into the trade dock near where you started. They are paid for the moment they drop through the grate in its floor.' },
{ 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: '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.' },
+13 -5
View File
@@ -3,12 +3,14 @@
const SAVE_VERSION = 2;
const TOFF = 64; // temperatures are saved with an offset so sub-zero snow and ice survive
function serializeWorld() {
// grains in flight from a tube or belt land where they are (rain in the air simply carries on)
// grains in flight from a screw or belt land where they are (rain in the air simply carries on)
for (const f of flyers) if (!f.bag && !f.rain) landFlyer(f, Math.max(0, Math.min(W - 1, Math.floor(f.x))), Math.max(0, Math.min(H - 1, Math.floor(f.y))));
flyers = flyers.filter(f => f.bag || f.rain);
const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = {
dev: devWorld, devLog: devLog.map(e => e.slice()),
slopePhases: Array.from(slopePhase),
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]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
@@ -31,6 +33,8 @@ function serializeWorld() {
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.');
alloc(meta.W, meta.H);
devWorld = meta.dev === true;
devLog = devWorld && Array.isArray(meta.devLog) ? meta.devLog.filter(e => Array.isArray(e) && Number.isSafeInteger(e[0]) && e[0] >= 0 && typeof e[1] === 'string').slice(-200).map(e => [e[0], e[1]]) : [];
worldSeed = meta.seed;
if (meta.surfBase) { surfBase = meta.surfBase; underD = meta.underD; }
shallowD = meta.shallowD || 0; lapse = meta.lapse || 0.36;
@@ -55,8 +59,8 @@ function deserializeWorld(meta, buffers) {
}
// 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 (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) {
for (const [b, m, inv] of meta.filters || []) if (isFilter(blockP[b])) filterCfg.set(b, filterRec({ m, inv }));
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) {
// the first filters kept one material in their block variant
if (!filterCfg.has(b)) filterCfg.set(b, filterRec({ m: [blockV[b] & 127], inv: 0 }));
blockV[b] &= 128;
@@ -86,7 +90,7 @@ function deserializeWorld(meta, buffers) {
const p = savedPart[i];
if (p === P_WHEEL || p === P_INTAKE) continue;
part[i] = p;
if (p >= P_TU && p <= P_TR) markPow(i, true);
if (IS_SCREW[p]) markPow(i, true);
}
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); }
// Old machines removed from the game are refunded.
@@ -102,6 +106,7 @@ function deserializeWorld(meta, buffers) {
if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too
ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || [];
tick = meta.tick || 0;
if (meta.slopePhases) slopePhase.set(meta.slopePhases);
updateRoof(); resetWeather();
if (meta.weather) {
const mw = meta.weather;
@@ -112,12 +117,15 @@ function deserializeWorld(meta, buffers) {
bot.x = meta.bot.x; bot.y = meta.bot.y; bot.lamp = meta.bot.lamp !== false; bot.vx = bot.vy = 0;
bag = new Int32Array(NMAT); meta.bag.forEach((v, k) => { if (k < NMAT) bag[k] = v; });
for (const m of meta.flyers || []) if (m < NMAT) bag[m]++; // grains that were mid-air when saved
// solids saved in the bag (older saves) come back as what drilling them gives
for (let k = 0; k < NMAT; k++) if (bag[k] && PH[k] === PH_SOLID && YLD[k]) { bag[YLD[k]] += bag[k]; bag[k] = 0; }
bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k];
Object.assign(player, meta.player);
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {});
powerDirty = true; rebuildPower(); updateRoof();
clearStraySeals();
powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
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
rebuildPipes();
+231
View File
@@ -0,0 +1,231 @@
// Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells.
// Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it
// until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the
// rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything
// heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls.
// Only uphill runs have cleats: downhill, the belt carries its load like a flat one.
const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in grains
const CLEAT_REACH = Math.floor(CLEAT_HEIGHT * Math.SQRT1_2);
const SCREEN_NUDGE_TICKS = 4; // one downhill contact step per four ticks (15 steps/s), plus gravity
let cleatChunks = new Uint8Array(0);
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
let ribLog = []; // the swaps one run's ribs made this step, undone if the run stalls
function ribMove(a, b) { move(a, b, a % W, (a / W) | 0, b % W, (b / W) | 0); ribLog.push(a, b); }
function cleatFoot(x, b, phase = slopePhase[b]) {
const d = blockV[b] & 1 ? 1 : -1;
return ((x - d * phase) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0;
}
// The rib standing on face cell (x, y) of a slope with grade g, foot first. Where its end would
// reach into something built (a screw conveyor, the next belt, a wall), the rib stops short: -1.
function ribCells(x, y, g) {
const out = [];
let open = true;
for (let h = 0; h <= CLEAT_REACH; h++) {
const xx = x + g * h, yy = y - h - 1, j = yy * W + xx;
if (xx < 0 || xx >= W || yy < 0 || yy >= H || part[j] || mach[j] || PH[mat[j]] === PH_SOLID) open = false;
out.push(open ? j : -1);
}
return out;
}
function slopeUphill(c, v) { return beltGrade(c) * (v & 1 ? 1 : -1) < 0; } // v: the belt's direction
// Is (x, y) a face cell of a sloped belt with grade g?
function slopeFaceAt(x, y, g) {
if (x < 0 || x >= W || y < 0 || y >= H) return false;
const c = blockP[(y >> 2) * BW + (x >> 2)];
return isBelt(c) && isSlopeBelt(c) && beltGrade(c) === g && beltFaceY(c, x >> 2, y >> 2, x) === y;
}
// Push the row of loose grains starting at p one cell along (dx, dy), front first. False if the
// row runs into something it can't enter.
function pushRow(p, dx, dy) {
const row = [];
let q = p;
for (let k = 0; k < CARRY_DEPTH; k++) {
if (PH[mat[q]] !== PH_POWDER || occ[q] || part[q] || (k && cleatMask[q])) break; // a machine moves its own contents
row.push(q);
const x = q % W + dx, y = ((q / W) | 0) + dy;
if (x < 0 || x >= W || y < 0 || y >= H) return false;
if (cleatMask[q + dx] && cleatMask[q + dy * W]) return false; // never between two cells of a rib
q = y * W + x;
}
const front = row[row.length - 1], m = mat[front];
if (cleatMask[q] || !(passable(m, PH_POWDER, q, dx, 0) || (PH[mat[q]] === PH_LIQUID && !occ[q] && !part[q]))) return false;
ribMove(front, q);
for (let k = row.length - 2; k >= 0; k--) ribMove(row[k], row[k] + dx + dy * W);
return true;
}
// At the foot of an uphill run, a rib coming up around the end roller scoops what lies on the
// feeding belt behind it, one pocket long and as high as the rib, into the pocket in front of it.
function scoop(x, y, d, rib) {
for (let k = 1; k < CLEAT_SPACING; k++) {
const xx = x - d * k;
if (xx < 0 || xx >= W) break;
for (let yy = y; yy >= y - Math.min(k, CLEAT_REACH) && yy >= 0; yy--) {
const a = yy * W + xx, m = mat[a];
if (PH[m] !== PH_POWDER || occ[a] || cleatMask[a]) continue;
const to = pocketRoom(rib, d, m);
if (to < 0) return;
ribMove(a, to);
}
}
}
// The lowest open cell in the pocket just in front of a rib, or -1 if it is full.
function pocketRoom(rib, d, m) {
for (let h = 0; h < rib.length; h++) {
if (rib[h] < 0) continue;
for (let t = 1; t < CLEAT_SPACING; t++) {
const j = rib[h] + d * t, jx = rib[h] % W + d * t;
if (jx < 0 || jx >= W) break;
if (PH[mat[j]] === PH_SOLID || cleatMask[j]) break;
if (passable(m, PH_POWDER, j, d, 0)) return j;
}
}
return -1;
}
// Move the rib that now stands on face cell (x - d) to (x, y), pushing what is in its way.
function advanceCleat(x, y, g, d) {
const st = g * d, ox = x - d, oy = y - st;
const fresh = ribCells(x, y, g), old = slopeFaceAt(ox, oy, g) ? ribCells(ox, oy, g) : null;
// the cells the rib sweeps: those between the old and the new rib, and the new rib itself
const sweep = [];
if (old) {
for (const j of old) if (j >= 0) cleatMask[j] = 0;
for (let h = 0; h < old.length; h++) {
const j = old[h];
if (j >= 0) {
sweep.push(j + d);
if (h < old.length - 1) sweep.push(j + st * W);
}
if (fresh[h] >= 0) sweep.push(fresh[h]);
}
} else {
for (const j of fresh) if (j >= 0) sweep.push(j);
if (st < 0) scoop(x, y, d, fresh);
}
let clear = true;
for (const s of sweep) {
if (s < 0 || s >= N || PH[mat[s]] !== PH_POWDER || occ[s] || part[s] || pushRow(s, d, st)) continue;
clear = false;
break;
}
for (const j of fresh) if (j >= 0) { cleatMask[j] = 1; cleatCells.push(j); } // the rebuild clears it if this run stalls
return clear;
}
// A run of sloped belt is one belt: the block at its lower-left end names it.
function slopeRunKey(b, memo) {
if (memo.has(b)) return memo.get(b);
const c = blockP[b], g = beltGrade(c), v = blockV[b] & 1, path = [b];
let bx = b % BW, by = (b / BW) | 0, key = -1;
while (bx > 0 && by - g >= 0 && by - g < BH && blockP[(by - g) * BW + bx - 1] === c && (blockV[(by - g) * BW + bx - 1] & 1) === v) {
bx--; by -= g;
if (memo.has(by * BW + bx)) { key = memo.get(by * BW + bx); break; }
path.push(by * BW + bx);
}
if (key < 0) key = by * BW + bx;
for (const p of path) memo.set(p, key);
return key;
}
function slopeCleatsTick(advance = true) {
const memo = new Map(), stalled = new Set();
// the whole run shares the phase of its lower-left block, so a piece added to a running belt
// takes up its cleat spacing instead of starting a rib of its own at the seam
let resynced = false;
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if ((x & 3) || !isBelt(c) || !isSlopeBelt(c) || beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
const p = slopePhase[slopeRunKey(b, memo)];
if (slopePhase[b] !== p) { slopePhase[b] = p; resynced = true; }
}
if (resynced) rebuildCleatMask();
if (advance && !(tick & 1)) {
// each run moves all its ribs or none: if one can't clear its path, the run stalls and every
// grain its ribs moved goes back. Front of the line first, so a rib pushes into room the rib
// ahead of it has just made.
const runs = new Map();
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || netSat(i) <= 0) continue;
// lift the run's ribs off; each is put back where it moves to, and a rib leaving the top is gone
if (cleatFoot(x, b)) for (const j of ribCells(x, y, beltGrade(c))) if (j >= 0) cleatMask[j] = 0;
if (!cleatFoot(x, b, (slopePhase[b] + 1) % CLEAT_SPACING)) continue;
const key = slopeRunKey(b, memo);
if (!runs.has(key)) runs.set(key, []);
runs.get(key).push(x, y, beltGrade(c), blockV[b] & 1 ? 1 : -1);
}
for (const [key, feet] of runs) {
ribLog.length = 0;
let ok = true;
for (let k = 0; k < feet.length && ok; k += 4) ok = advanceCleat(feet[k], feet[k + 1], feet[k + 2], feet[k + 3]);
if (ok) { if (flowOn) for (let k = 0; k < ribLog.length; k += 2) flowCross(flowSlopeUnder(ribLog[k]), ribLog[k], ribLog[k + 1]); continue; }
stalled.add(key);
for (let k = ribLog.length - 2; k >= 0; k -= 2) { swapCells(ribLog[k], ribLog[k + 1]); wakeIdx(ribLog[k]); wakeIdx(ribLog[k + 1]); }
}
ribLog.length = 0;
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c, x >> 2, y >> 2, x) === y && netSat(i) > 0 && !stalled.has(slopeRunKey(b, memo)))
slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING;
}
}
rebuildCleatMask();
if (advance && !(tick & 1)) {
// the running face drags the grains lying right on it; unless something below holds them,
// they slide back down between steps, which on 45° is faster than the belt runs
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b], a = i - W;
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || netSat(i) <= 0) continue;
if (a < 0 || PH[mat[a]] !== PH_POWDER || occ[a] || cleatMask[a] || stamp[a] === tickStamp) continue;
const d = blockV[b] & 1 ? 1 : -1, st = conveyorStep(x, y, d), j = a + d + st * W;
if (x + d < 0 || x + d >= W || j < 0 || cleatMask[j] || !passable(mat[a], PH_POWDER, j, d, st)) continue;
const bj = j + W;
// dragged off an open 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, x + d, y - 1 + st, d * 0.5, st * 0.5);
else move(a, j, x, y - 1, x + d, y - 1 + st);
if (flowOn) flowCross(b, a, j);
}
}
}
// The barriers, rebuilt from the phases: ribs that left the top of a slope are gone.
function rebuildCleatMask() {
for (const i of cleatCells) { cleatMask[i] = 0; wakeCleatLoad(i); }
cleatChunks.fill(0);
cleatCells.length = 0;
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || !cleatFoot(x, b)) continue;
for (const j of ribCells(x, y, beltGrade(c))) if (j >= 0) cleatCells.push(j);
}
for (const i of cleatCells) {
cleatMask[i] = 1; wakeCleatLoad(i);
if (PH[mat[i]] === PH_POWDER && !occ[i]) expelFromRib(i);
const x = i % W, y = (i / W) | 0;
for (let cy = Math.max(0, (y - 1) >> 5); cy <= Math.min(CH - 1, (y + 1) >> 5); cy++)
for (let cx = Math.max(0, (x - 1) >> 5); cx <= Math.min(CW - 1, (x + 1) >> 5); cx++) cleatChunks[cy * CW + cx] = 1;
}
}
// A rib that appears where grains already lie (a belt built under a heap, a loaded save) lifts
// each one to the nearest open cell above it.
function expelFromRib(i) {
for (let q = i - W, k = 0; q >= 0 && k < 24; q -= W, k++) {
if (cleatMask[q] || part[q] || occ[q]) continue;
if (mat[q] === EMPTY || PH[mat[q]] === PH_GAS) { move(i, q, i % W, (i / W) | 0, q % W, (q / W) | 0); return; }
if (PH[mat[q]] !== PH_POWDER) return;
}
}
// A screen shakes only the grains touching its mesh, never the whole column above it.
function screenNudge(top, d, pass) {
if (tick % SCREEN_NUDGE_TICKS) return;
const i = top - W, m = mat[i];
if (i < 0 || PH[m] !== PH_POWDER || stamp[i] === tickStamp || occ[i]) return;
if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) return;
const j = i + d + W;
if (i % W + d < 0 || i % W + d >= W || j >= N) return;
if (enter(m, PH_POWDER, j, d, 1)) { move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0); if (flowOn) flowCross(((top / W) >> 2) * BW + ((top % W) >> 2), i, j); }
}
// Only loose load can react to a barrier appearing or disappearing.
function wakeCleatLoad(i) {
const x = i % W, y = (i / W) | 0;
for (let yy = Math.max(0, y - 1); yy <= Math.min(H - 1, y + 1); yy++)
for (let xx = Math.max(0, x - 1); xx <= Math.min(W - 1, x + 1); xx++)
if (PH[mat[yy * W + xx]] === PH_POWDER || occ[yy * W + xx]) wakeXY(xx, yy);
}
+1
View File
@@ -14,6 +14,7 @@ function simTick() {
if (tick % 8 === 0) wakeBotPowder();
simMove();
heatPass();
if (flowOn) flowTick();
for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--;
if (tick % 15 === 0) checkMolds();
if (tick % 30 === 0) checkLedger();
+15 -2
View File
@@ -1,5 +1,18 @@
// Tool helpers: reach, brushes, and plain digging.
// ---------- Tools ----------
// Building reaches about as far as the screen shows (t64): the view box the UI sets each frame
// (setBuildRange), at least REACH from its centre and at most BUILD_MAX; anything beyond becomes
// a blueprint ghost. With no view (tests, headless), a REACH box around the bot.
const BUILD_MAX = 200;
let buildBox = null;
function setBuildRange(cx, cy, w, h) {
const hw = Math.max(REACH, Math.min(BUILD_MAX, w / 2)), hh = Math.max(REACH, Math.min(BUILD_MAX, h / 2));
if (!buildBox) buildBox = { cx, cy, hw, hh }; else { buildBox.cx = cx; buildBox.cy = cy; buildBox.hw = hw; buildBox.hh = hh; }
}
function inBuildRange(x, y) {
if (!buildBox) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return Math.abs(dx) <= REACH && Math.abs(dy) <= REACH; }
return Math.abs(x - buildBox.cx) <= buildBox.hw && Math.abs(y - buildBox.cy) <= buildBox.hh;
}
function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; }
function forBrush(cx, cy, r, fn) {
const r2 = (r + 0.5) * (r + 0.5);
@@ -34,10 +47,10 @@ function toolDig(cx, cy, r) {
const b = blockP[((y / BS) | 0) * BW + ((x / BS) | 0)];
setCell(i, to, temp[i], 0);
discover(to, x, y, m, 'dig');
if (b === BP_WALL && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] = 0;
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
dug++;
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
} else digp[i] = p;
} else { digp[i] = p; renderDirtyIdx(i); }
});
stats.dug += dug;
let status = '';
+14 -8
View File
@@ -22,8 +22,8 @@ function undoLast() {
for (let k = rec.ops.length - 1; k >= 0; k--) {
const o = rec.ops[k];
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
else if (o.t === 'block-') { const r = placeBlock(o.code, o.code === BP_FILTER ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (blockP[b] === BP_FILTER) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); }
else if (o.t === 'mach-') { const r = placeMachineAt(o.type, o.dir, o.bx, o.by); if (r) fail(r); else { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm) mm.on = o.on !== false; } }
else if (o.t === 'mold+') { const mo = molds.find(m => m.kind === o.kind && m.x === o.bx * BS && m.y === o.by * BS); if (mo) removeMold(mo); }
@@ -54,14 +54,20 @@ function ghostTick0() {
while (budget > 0 && checks-- > 0 && ghostBlocks.size) {
let nx = ghostIter.next();
if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; }
const [b, [code, v, e]] = nx.value;
const [b, [code, v, e, swap]] = nx.value;
const bx = b % BW, by = (b / BW) | 0;
if (blockP[b] === code && blockV[b] === (code === BP_FILTER ? v & 128 : v)) {
if (code === BP_FILTER && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) {
if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
}
if (!blockInReach(bx, by)) continue;
const err = canPlaceBlock(bx, by);
// a replacing ghost takes down what is there (refunded) and builds itself, like a replace in reach
if (swap && blockP[b]) {
if (blockP[b] === code && isFilter(code)) { blockV[b] = filterVariant(code, v); setFilter(b, e); filtersDirty = true; ghostBlocks.delete(b); ghostBlocked.delete(b); budget--; continue; }
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
removeBlock(bx, by);
}
const err = canPlaceBlock(bx, by, code);
if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; }
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
placeBlock(code, v, bx, by, false, e);
@@ -70,7 +76,7 @@ function ghostTick0() {
}
for (let k = ghostMachines.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMachines[k], P = PIECE_BY_ID[g.type];
if (!inReach(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
if (!inBuildRange(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
if (canPlaceMachine(g.type, g.bx, g.by)) continue;
if (!canAfford(P.cost, 1)) { ghostMissing++; continue; }
placeMachineAt(g.type, g.dir, g.bx, g.by); ghostMachines.splice(k, 1); budget--;
@@ -78,7 +84,7 @@ function ghostTick0() {
}
for (let k = ghostMolds.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMolds[k], md = MOLDS[g.kind];
if (!inReach(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
if (!inBuildRange(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
if (canPlaceMold(g.kind, g.bx, g.by)) continue;
if (!canAfford(wallCost(g.wall, md.walls), 1)) { ghostMissing++; continue; }
addMold(g.kind, g.bx, g.by, g.wall, false); ghostMolds.splice(k, 1); budget--;
+32 -5
View File
@@ -2,11 +2,19 @@
// Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
function vacuumTargetAt(cx, cy, r) {
for (const e of entsInCircle(cx, cy, Math.max(1, r))) return e.m;
r = Math.max(1, r);
let best = 0, bd = 1e9;
forBrush(cx, cy, Math.max(1, r), (x, y, i) => {
const m = mat[i], ph = PH[m];
if (ph === PH_POWDER || (ph === PH_LIQUID && player.flask)) { const d = (x - cx) * (x - cx) + (y - cy) * (y - cy); if (d < bd) { bd = d; best = m; } }
});
for (const f of flyers) {
if (f.rain) continue; // Rain belongs to the clouds; collecting it would create water.
const ph = PH[f.m];
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask))) continue;
const x = Math.floor(f.x), y = Math.floor(f.y), dx = x - cx, dy = y - cy, d = dx * dx + dy * dy;
if (d <= (r + 0.5) * (r + 0.5) && d < bd && inReach(x, y)) { bd = d; best = f.m; }
}
return best;
}
// Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true.
@@ -18,11 +26,23 @@ function toolVacuum(cx, cy, r, pick) {
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 });
}
if (!full) forBrush(cx, cy, r, (x, y, i) => {
if (!full) for (let k = flyers.length - 1; k >= 0; k--) {
const f = flyers[k], m = f.m, ph = PH[m];
if (f.rain) continue; // Rain belongs to the clouds; collecting it would create water.
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask)) || !pick(m)) continue;
const x = Math.floor(f.x), y = Math.floor(f.y), dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy > (r + 0.5) * (r + 0.5) || !inReach(x, y)) continue;
if (bagTotal >= bagCap()) { full = true; break; }
if (!first) first = m;
bagAdd(m, 1); flyers[k] = flyers[flyers.length - 1]; flyers.pop(); got++;
if (got & 1) emit({ t: 'suck', x, y, m });
if (--budget <= 0) break;
}
if (!full && budget > 0) forBrush(cx, cy, r, (x, y, i) => {
const m = mat[i], ph = PH[m];
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask))) return;
if (!pick(m)) return;
if (part[i] >= P_TU && part[i] <= P_TR) return;
if (IS_SCREW[part[i]]) return;
if (bagTotal >= bagCap()) { full = true; return false; }
if (!first) first = m;
bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++;
@@ -31,7 +51,7 @@ function toolVacuum(cx, cy, r, pick) {
});
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 arc toward the cursor, within reach.
// Liquids need the Flask. Bars are thrown, one at a time.
function nozzle(tx, ty) {
const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2;
@@ -65,6 +85,10 @@ function toolBlow(cx, cy, r, m, mix) {
if (!e) return { placed: 0, status: 'No room to throw a bar from here.' };
bagTake(m, 1); return { placed: 1, status: '' };
}
if (!inReach(cx, cy)) {
cx = nz.ox + nz.dx * nz.dist - 0.5;
cy = nz.oy + nz.dy * nz.dist - 0.5;
}
if (flyers.length > 3000) return { placed: 0, status: '' };
// nozzle 0 is a trickle for careful work, the widest a torrent
const rate = 1 + r + ((r * r) >> 1);
@@ -73,7 +97,7 @@ function toolBlow(cx, cy, r, m, mix) {
for (let k = 0; k < n; k++) {
const gm = pickM ? pickM() : m;
if (!gm) break;
// aim each grain at a spot in the brush around the cursor, on an arc that passes
// aim each grain at a spot in the brush around the target, on an arc that passes
// through it; past that point gravity carries it on until it hits something
const a = rnd() * Math.PI * 2, rr = Math.sqrt(rnd()) * (r + 0.5);
const tx = cx + 0.5 + Math.cos(a) * rr, ty = cy + 0.5 + Math.sin(a) * rr;
@@ -142,7 +166,10 @@ function flyTick() {
if (cx < 0 || cx >= W) { f.vx = 0; continue; } // the world's edge stops it; it drops from there
if (cy >= H) { landFlyer(f, px, H - 1); done = true; break; }
if (cy < 0) { f.x = nx; f.y = ny; continue; }
const open = flyOpen(f.m, cy * W + cx);
// A free flyer must not cut across the point where two solid cells meet.
const sealed = cx !== px && cy !== py && px >= 0 && px < W && py >= 0 && py < H &&
PH[mat[py * W + cx]] === PH_SOLID && PH[mat[cy * W + px]] === PH_SOLID;
const open = !sealed && flyOpen(f.m, cy * W + cx);
if (!f.free) {
// still inside whatever the bot is flying through: pass until the first open cell
// a blocked nozzle spills: the grain backs up into the nearest open space
+1 -1
View File
@@ -25,7 +25,7 @@ function worldWater() {
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
return Math.round(n + bag[WATER] + bag[SNOW]);
}
function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
function absorbSteam(x) { if (chunkWork) { chunkEvent('moist', x); return; } const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
function spawnRain(x0, x1, k) {
if (flyers.length > 2500) return 0;
+153
View File
@@ -0,0 +1,153 @@
// The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
let simPool = null, chunkWork = null, inlineWork = null;
function threadWorld() {
return { W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
powderArea,roofDirty,renderDirty,AMB,roofY };
}
function threadBind(s) {
({ W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
powderArea,roofDirty,renderDirty,AMB,roofY } = s);
}
function threadContext() {
return { wake: new Uint8Array(NCH), heat: new Uint8Array(NCH), level: new Uint8Array(NCH),
ownHot: new Uint8Array(NCH).fill(255), ownLevel: new Uint8Array(NCH), events: [], c: -1,
x0: 0, x1: 0 };
}
function resetChunkWork(work) {
work.wake.fill(0); work.heat.fill(0); work.level.fill(0); work.ownHot.fill(255); work.events.length = 0;
return work;
}
function inlineChunkWork() {
if (!inlineWork || inlineWork.wake.length !== NCH) inlineWork = threadContext();
return resetChunkWork(inlineWork);
}
function chunkEvent(kind, ...args) { chunkWork.events.push([chunkWork.c, kind, ...args]); }
function chunkWake(c, n = 3) {
if (chunkWork) { if (chunkWork.wake[c] < n) chunkWork.wake[c] = n; }
else awake[c] = n;
}
function chunkHeat(c, level = 2) {
if (chunkWork) { chunkWork.heat[c] = 6; if (chunkWork.level[c] < level) chunkWork.level[c] = level; }
else { hot[c] = 6; if (level) hotLvl[c] = level; }
}
function chunkSeed(c, pass) { return hash2(c, tick, worldSeed ^ Math.imul(pass + 1, 0x9e3779b9)) | 1; }
function runChunks(chunks, pass, work) {
const savedRS = RS;
chunkWork = work;
try {
for (const c of chunks) {
work.c = c; work.x0 = (c % CW) * 32; work.x1 = Math.min(W, work.x0 + 32);
RS = chunkSeed(c, pass);
if (pass < 4) moveChunk(c); else {
const sc = hotLvl[c] === 1 ? 4 : 1;
heatChunk(c, sc);
}
}
} finally { chunkWork = null; RS = savedRS; }
}
function mergeChunkWork(works) {
for (const work of works) for (let c = 0; c < NCH; c++) if (work.ownHot[c] !== 255) {
hot[c] = work.ownHot[c]; hotLvl[c] = work.ownLevel[c];
}
for (const work of works) for (let c = 0; c < NCH; c++) {
if (awake[c] < work.wake[c]) awake[c] = work.wake[c];
if (hot[c] < work.heat[c]) hot[c] = work.heat[c];
if (hotLvl[c] < work.level[c]) hotLvl[c] = work.level[c];
}
const events = works.flatMap(w => w.events).sort((a,b) => a[0] - b[0]);
for (const [,kind,...a] of events) {
if (kind === 'discover') discover(...a);
else if (kind === 'emit') emit(a[0]);
else if (kind === 'flow') { const mm = machines[a[0]]; if (mm) mm.flow += a[1]; }
else if (kind === 'moist') absorbSteam(a[0]);
else if (kind === 'lost') partLost(a[0]);
else if (kind === 'broken') brokenMachines.push(a[0]);
else if (kind === 'ignite') stats.ignitions++;
else if (kind === 'liquid') {
const i = a[0];
if (stamp[i] !== tickStamp && mat[i] === a[1]) liquidSearch(i, i % W, (i / W) | 0, ...a.slice(1));
}
}
}
function chunkPasses(heat) {
for (let color = 0; color < 4; color++) {
const chunks = [];
for (let c = 0; c < NCH; c++) {
if (((c % CW & 1) | (((c / CW) & 1) << 1)) !== color) continue;
if (heat ? (!hot[c] || (hotLvl[c] === 1 && ((tick + c) & 3) !== 0)) : !awake[c]) continue;
chunks.push(c);
}
if (!chunks.length) continue;
const pass = color + (heat ? 4 : 0);
if (simPool && simPool.world === mat && chunks.length >= (simPool.force ? 1 : (heat ? 16 : 64))) threadPass(simPool, chunks, pass);
else { const work = inlineChunkWork(); runChunks(chunks, pass, work); mergeChunkWork([work]); }
}
}
function threadParams() {
return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,
seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
}
function threadSetParams(s) {
({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s);
seen.set(s.seen); Object.assign(bot, s.bot);
weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
}
// Transport is injected by the browser or Node harness; no browser globals in the simulation.
function threadSlot() {
// shortcut: 1 MiB of events per helper per pass; grow or stream it if dense reactions fill it.
return { control: sharedArray(Int32Array, 2), bytes: sharedArray(Uint8Array, 1024 * 1024),
wake: sharedArray(Uint8Array, NCH), heat: sharedArray(Uint8Array, NCH),
level: sharedArray(Uint8Array, NCH), ownHot: sharedArray(Uint8Array, NCH),
ownLevel: sharedArray(Uint8Array, NCH) };
}
function threadExecute(slot, chunks, pass) {
slot.wake.fill(0); slot.heat.fill(0); slot.level.fill(0); slot.ownHot.fill(255);
const work = { ...slot, events: [], c: -1 };
runChunks(chunks, pass, work);
const encoded = new TextEncoder().encode(JSON.stringify(work.events));
if (encoded.length > slot.bytes.length) throw Error('Simulation event buffer full');
slot.bytes.set(encoded); Atomics.store(slot.control, 1, encoded.length);
}
function threadPass(pool, chunks, pass) {
const params = threadParams(), n = Math.min(pool.slots.length, (pool.force ? chunks.length : Math.max(1, Math.floor(chunks.length / (pass < 4 ? 32 : 8)))));
pool.activeMax = Math.max(pool.activeMax || 0, n);
const batches = Array.from({length:n + 1}, () => []);
for (let k = 0; k < chunks.length; k++) batches[k % batches.length].push(chunks[k]);
for (let k = 0; k < n; k++) {
const slot = pool.slots[k]; Atomics.store(slot.control, 0, 0);
pool.workers[k].postMessage({ chunks: batches[k], pass, params });
}
const local = inlineChunkWork(); runChunks(batches[n], pass, local);
const works = [local], deadline = Date.now() + 10000;
for (let k = 0; k < n; k++) {
const slot = pool.slots[k];
threadWait(slot, deadline);
const events = JSON.parse(new TextDecoder().decode(slot.bytes.slice(0, Atomics.load(slot.control, 1))));
if (Atomics.load(slot.control, 0) < 0) throw Error('Simulation worker: ' + events);
works.push({ ...slot, events });
}
mergeChunkWork(works);
}
function threadWait(slot, deadline) {
let spins = 0;
// Atomics.wait is forbidden on the browser main thread. Bound polling to fail closed.
while (Atomics.load(slot.control, 0) === 0) {
if (++spins === 16384) { spins = 0; if (Date.now() > deadline) throw Error('Simulation worker timed out'); }
}
}
function threadFailure(slot, error) {
const bytes = new TextEncoder().encode(JSON.stringify(String(error.stack || error)));
slot.bytes.set(bytes.subarray(0, slot.bytes.length));
Atomics.store(slot.control, 1, Math.min(bytes.length, slot.bytes.length));
Atomics.store(slot.control, 0, -1); Atomics.notify(slot.control, 0);
}
function threadMessage(data) {
if (data.world) { threadBind(data.world); globalThis.threadWorkerSlot = data.slot; Atomics.store(data.slot.control, 0, 1); return; }
const slot = globalThis.threadWorkerSlot;
try {
threadSetParams(data.params); threadExecute(slot, data.chunks, data.pass);
Atomics.store(slot.control, 0, 1); Atomics.notify(slot.control, 0);
} catch (e) { threadFailure(slot, e); }
}
+35 -16
View File
@@ -13,11 +13,12 @@ let ents = []; // loose objects (bars)
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 ghostBlocks = new Map(), ghostMolds = [];
let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk.
let awake, hot, hotLvl, powderArea, roofDirty;
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
let tick = 0, tickStamp = 1;
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
let beltL = [], beltR = [], screwLists = Array.from({ length: IS_SCREW.length }, () => []);
let brokenMachines = [];
let ghostMachines = [], ghostBlocked = new Set();
const EV = [];
@@ -28,34 +29,43 @@ let seen = new Uint8Array(NMAT);
let ledger = {};
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
function emit(e) { if (EV.length < 400) EV.push(e); }
function emit(e) { if (chunkWork) { chunkEvent('emit', e); return; } if (EV.length < 400) EV.push(e); }
function bagCap() { return TANK_TIERS[player.tankTier].cap; }
// Things count as discovered only when they happen near the player (or in the bag).
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
function discover(m, x, y, from, how, T) {
if (seen[m]) return;
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
if (chunkWork) { chunkEvent('discover', m, x, y, from, how, T); return; }
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
}
function sharedArray(Type, length) {
const Buffer = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
return new Type(new Buffer(length * Type.BYTES_PER_ELEMENT));
}
function alloc(w, h) {
resetDevConsole();
W = w; H = h; N = w * h;
fogAlloc();
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);
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);
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();
cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(Uint8Array, N);
noiseB = sharedArray(Uint8Array, N); bgm = sharedArray(Uint8Array, N); airS = sharedArray(Uint16Array, N); netId = sharedArray(Uint16Array, N); isPow = sharedArray(Uint8Array, N); occ = sharedArray(Uint8Array, N);
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = sharedArray(Uint8Array, NB); blockV = sharedArray(Uint8Array, NB); slopePhase = sharedArray(Uint8Array, NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
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;
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);
surfY = sharedArray(Int16Array, w); roofY = sharedArray(Int16Array, w); dirtY = sharedArray(Int16Array, w); graniteY = sharedArray(Int16Array, w); basaltY = sharedArray(Int16Array, w); magmaY = sharedArray(Int16Array, w);
AMB = sharedArray(Float32Array, h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
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
renderDirty = sharedArray(Uint8Array, NCH).fill(1);
cleatChunks = sharedArray(Uint8Array, NCH);
awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
roofDirty = sharedArray(Uint8Array, CW).fill(1);
powderArea = sharedArray(Uint8Array, NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set();
tick = 0; tickStamp = 1; drillPulses = []; lastPulseTick = -99; undoStack = []; undoRec = null;
pipeNets = []; pipeNetAt = null; pipesDirty = true;
@@ -87,18 +97,27 @@ function fillNoise() {
}
// ---------- Waking ----------
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
function renderDirtyChunk(c) {
const cx = c % CW, cy = (c / CW) | 0;
for (let yy = Math.max(0, cy - 1); yy <= Math.min(CH - 1, cy + 1); yy++)
for (let xx = Math.max(0, cx - 1); xx <= Math.min(CW - 1, cx + 1); xx++) renderDirty[yy * CW + xx] = 1;
}
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
function wakeXY(x, y) {
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
if (!chunkWork) renderDirty[c] = 1;
roofDirty[cx] = 1;
awake[c] = 3;
chunkWake(c);
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 (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
if (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
}
function wakeHeatXY(x, y) {
const c = (y >> 5) * CW + (x >> 5);
if (!chunkWork) renderDirty[c] = 1;
roofDirty[x >> 5] = 1;
hot[c] = 6; hotLvl[c] = 2;
chunkHeat(c);
}
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
+2 -2
View File
@@ -245,7 +245,7 @@ function* worldSteps(seedIn, w, h) {
yield [0.75, 'Settling loose ground'];
// settle loose material at the water and sorting speeds the seeds were made with
awake.fill(3);
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP; LSTEP = LFILM = 1; SORTP = 0.003;
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = false;
for (let k = 0; k < 260; k++) {
tick++; tickStamp = (tickStamp % 255) + 1;
simMove();
@@ -253,7 +253,7 @@ function* worldSteps(seedIn, w, h) {
if (!any) break;
if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
}
LSTEP = lstep; LFILM = lfilm; SORTP = sortp;
LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal;
yield [0.99, 'Finishing'];
// 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; }
+16
View File
@@ -0,0 +1,16 @@
#dev-console {
position: fixed; z-index: 15; bottom: 0; left: 0; right: 0;
padding: 12px 18px; border-radius: 0; border-top: 2px solid var(--patina);
background: rgba(15, 13, 12, 0.97); max-height: 55vh;
}
.dev-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dev-head strong { color: var(--patina); }
.dev-head button { margin-left: auto; }
#dev-output {
height: 160px; max-height: 28vh; overflow-y: auto; margin: 8px 0;
font: 12px/1.6 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere;
user-select: text;
}
#dev-form { display: flex; align-items: center; gap: 10px; }
#dev-input { flex: 1; min-width: 0; font-family: var(--font-mono); }
.dev-badge { display: inline-block; padding: 1px 6px; border: 1px solid var(--patina-dim); border-radius: 4px; color: var(--patina); font-size: 11px; }
+7 -2
View File
@@ -29,8 +29,7 @@ body {
font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4;
-webkit-user-select: none; user-select: none; touch-action: none;
}
canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: crosshair; }
canvas#game.nocursor { cursor: none; }
canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
@@ -219,6 +218,7 @@ table.mt td.dip { color: var(--melt); }
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
#perf:not(:empty) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
#perf:not(:empty) ~ .br { }
/* ---------- modals ---------- */
@@ -291,6 +291,8 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
#cap-notice { max-width: 64ch; padding: 8px 14px; color: var(--melt-hot); }
#cap-notice p { margin: 6px 0; }
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
.tmenu .btn small { color: var(--ash); font-size: 0.82em; }
@@ -397,3 +399,6 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
}
canvas#gpu { pointer-events: none; }
canvas#gpu[hidden] { display: none; }
+37 -20
View File
@@ -27,16 +27,16 @@ function doInteract() {
if (fb >= 0) {
if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; }
const run = filterRun(fb);
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0, code: blockP[fb] }), f => {
applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${f.dir ? 'right' : 'left'}.`, 'info');
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${beltDirection(blockP[fb], f.dir ? 128 : 0)}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run;
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, code: pieceCode(piece()) }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (!inReach(ax + dx, ay + dy)) continue;
@@ -51,17 +51,20 @@ function doInteract() {
function pickUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
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);
let pid = null;
const i = ay * W + ax, m = mat[i];
let bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pid = null;
// a sloped belt's band is drawn across its block's edge, so the belt you see can lie in the next block
if (!blockP[bi] && !ghostBlocks.has(bi)) { const sb = slopeBeltAt(i, ax, ay, R.smoothOn); if (sb >= 0) bi = sb; }
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; }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; }
else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id;
if (P.code === BP_WALL) { GAME.wallMat = v; }
if (isBelt(code) || isFilter(code)) { GAME.pieceShape = GAME.pieceShape || {}; GAME.pieceShape[P.id] = code; }
if (isWall(code)) { GAME.wallMat = v; GAME.pieceDir.wall = isSlope(code) ? code - BP_SLOPE_BL + 1 : 0; }
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 % P.dirs.length;
}
if (pid) {
const P = PIECE_BY_ID[pid];
@@ -94,12 +97,14 @@ function doUndo() {
}
// right-click take-backs while the button is held count as one thing to undo
function endRmbRecord() { if (GAME.rmbRec) { const r = GAME.rmbRec; GAME.rmbRec = null; const n = r.ops.length; r.label = `taking back ${plural(n, 'piece')}`; undoPush(r); } }
// Belts, filters and tubes run the way you drag them; a single click uses the direction R sets
// Belts, filters and screws run the way you drag them; a single click uses the direction R sets
function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || P.filter)) return -1;
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1;
if (P.id === 'tube') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return P.filter ? (dx * dy > 0 ? 1 : 0) : (dx > 0 ? 1 : 0);
if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
}
function blockLabel(P, rec) {
@@ -126,7 +131,7 @@ function primaryPress(shift) {
else {
const { bw, bh } = pieceSize(P);
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 (!inBuildRange(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 check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
let err = '';
@@ -161,21 +166,20 @@ function primaryRelease(shift) {
return;
}
if (t === 'build' && GAME.drag) {
const P = piece(), b = aimBlock();
const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by);
const dd = dragDirFor(P, GAME.drag, b);
if (dd >= 0) GAME.pieceDir[P.id] = dd;
const v = pieceV(P), ex = pieceExtra(P);
const P = piece(), plan = dragPlan(P, GAME.drag, aimBlock(), shift), { code, v, cells: list } = plan;
if (plan.dd >= 0) GAME.pieceDir[P.id] = plan.dd;
const ex = pieceExtra(P);
// blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
let ghosts = 0;
const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => {
const res = placeBlocks(P.code, v, near, ex, GAME.replace);
const res = placeBlocks(code, v, near, ex, GAME.replace);
const g = { t: 'paste', blocks: [], machines: [], molds: [] };
for (const [bx, by] of far) {
const b = by * BW + bx;
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, P.code, v, ex) || ghostBlocks.has(b)) continue;
ghostBlocks.set(b, ex ? [P.code, v, ex] : [P.code, v]); g.blocks.push([bx, by, P.code]); ghosts++;
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue;
// held G over a different piece: the ghost replaces it when the bot comes in range
ghostBlocks.set(b, GAME.replace && blockP[b] ? [code, v, ex, 1] : ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++;
}
if (ghosts) undoNote(g);
return res;
@@ -193,9 +197,22 @@ function secondaryPress(shift) {
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 (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
else if (t === 'drill') { firePing(); GAME.rmbConsumed = true; }
else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
}
// Right click with the drill: a resonance ping. The echo is drawn by drawPing (overlays.js).
const PING_RECHARGE = 600;
// Seconds until the ping is ready again (0: ready).
function pingWait() { return GAME.pingTick !== undefined && tick >= GAME.pingTick ? Math.max(0, Math.ceil((PING_RECHARGE - (tick - GAME.pingTick)) / 60)) : 0; }
function firePing() {
if (pingWait()) { setStatus(`The ping is recharging: ${pingWait()} s.`); playSfx('error'); return; }
const p = resonancePing(bot.x + bot.w / 2, bot.y + bot.h / 2);
GAME.ping = Object.assign(p, { t0: performance.now() }); GAME.pingTick = tick;
playSfx('pulse', 2);
const names = p.found.map(m => MD[m].name.toLowerCase());
setStatus(names.length ? `Ping: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ' and more' : ''} nearby.` : `Ping: no veins within ${Math.round(p.r / BS)} blocks.`, 'info');
}
// continuous actions, once per simulation tick
function toolTick() {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
@@ -227,7 +244,7 @@ function toolTick() {
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
if (!GAME.rmbSeen.has(key) && inReach(ax, ay)) {
if (!GAME.rmbSeen.has(key) && inBuildRange(ax, ay)) {
GAME.rmbSeen.add(key);
const keep = undoRec; undoRec = GAME.rmbRec;
const msg = removePieceAt(ax, ay);
+18 -11
View File
@@ -2,7 +2,7 @@
// ---------- Build menu ----------
const BUILD_CATS = [
['Structure', ['wall', 'grate', 'gate']],
['Moving', ['belt', 'filter', 'tube']],
['Moving', ['belt', 'filter', 'screw']],
['Liquids', ['pipe', 'pump', 'outlet']],
['Machines', ['fan', 'grinder', 'saw', 'press', 'rack', 'silo']],
['Molds', ['ingotmold', 'bitmold', 'tankmold']],
@@ -12,9 +12,9 @@ const PIECE_ICONS = {
wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>',
grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>',
gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>',
belt: '<rect x="2" y="9" width="20" height="8" rx="2"/><circle cx="7" cy="13" r="2"/><circle cx="17" cy="13" r="2"/><path d="M9 5h7M14 3l2 2-2 2"/>',
filter: '<rect x="2" y="10" width="20" height="8" rx="2"/><path d="M7 10v8M12 10v8M17 10v8M2 14h20"/><path d="M12 2v5M10 5l2 2 2-2"/>',
tube: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M3 7V3Q5 1 7 3T11 3T15 3T19 3H21V7M7 7V3M13 7V3M19 7V3"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>',
filter: '<g transform="rotate(45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>',
screw: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>',
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"/>',
@@ -28,7 +28,12 @@ const PIECE_ICONS = {
bitmold: '<path d="M3 9h10l7 3-7 3H3z"/><path d="M6.5 9v6M9.5 9v6"/>',
tankmold: '<rect x="5" y="7" width="14" height="13" rx="3"/><path d="M9 4h6v3M8.5 12h7"/>',
};
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) {
const v = GAME.drag && GAME.dragDir >= 0 ? GAME.dragDir : GAME.pieceDir.screw || 0;
const body = PIECE_ICONS[id] || '';
const icon = id === 'screw' && v >= 4 ? `<g transform="rotate(${v === 4 || v === 6 ? -45 : 45} 12 12)">${body}</g>` : body;
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${icon}</svg>`;
}
function catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; }
let lastBuildKey = '', lastBuildMenu = '';
function selectBuildCat(c) {
@@ -44,13 +49,15 @@ function cycleBuildCat(d) {
}
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
// while dragging a line of belts, filters or screws, 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 glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓', 'up-right': '↗', 'down-right': '↘', 'down-left': '↙', 'up-left': '↖' })[d] || '';
const code = pieceCode(P), direction = dragging ? GAME.dragDir : GAME.pieceDir[P.id] || 0;
const slopeDir = isSlopeBelt(code) ? beltDirection(code, P.filter ? direction << 7 : direction) : '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(slopeDir || P.dirs[GAME.dragDir])} ${esc(slopeDir || P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(slopeDir || dir)} ${esc(slopeDir || 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>`
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.code === BP_WALL ? `<span class="chip hot">${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip</span>` : ''}`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
}
@@ -62,7 +69,7 @@ function renderBuildBar() {
const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover;
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 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('|');
const key = [cat, P.id, tip && tip.id, afford, GAME.wallMat, Math.round(money), GAME.pieceDir[P.id], GAME.dragDir, GAME.previewCode, GAME.filterCfg.inv, GAME.filterCfg.m.join('.'), innerWidth, innerHeight, SET.uiScale].join('|');
if (key === lastBuildKey) return;
lastBuildKey = key;
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('')
@@ -80,7 +87,7 @@ function renderBuildBar() {
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>
<div class="bb-desc">${esc(tip.desc)}</div><div class="bb-place"><b>How to place</b> ${tip.kind === 'block' ? (lineOnly(tip.code) ? 'Drag for a line' : '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; }
+2 -1
View File
@@ -89,7 +89,8 @@ async function openNewClaim() {
showLoading(true, 0.9, 'Saving');
if (!await saveWorld('auto', true)) throw new Error('Save failed');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame();
await gpuWarmWorld();
showLoading(false); closeAllModals(); enterGame(true);
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) {
+47
View File
@@ -0,0 +1,47 @@
// Hover work is shared by the cursor label and the vein outline.
const CURSOR_ORES = new Uint8Array(NMAT);
for (let m = 0; m < NMAT; m++) if (PING_VEINS[m]) { CURSOR_ORES[m] = 1; CURSOR_ORES[YLD[m]] = 1; }
function oreLike(m, bar) { return !!(bar || CURSOR_ORES[m] || SOLIDMETAL[m]); }
function bitNeeded(m) {
if (HARD[m] > 6) return 'cannot be drilled';
return 'needs ' + BIT_TIERS.find(b => b.hard >= HARD[m]).name.toLowerCase();
}
function pingVisible(x, y) {
const p = GAME.ping;
if (!p || x < 0 || y < 0 || x >= W || y >= H) return false;
if (!p.cellSet) p.cellSet = new Set(p.cells);
if (!p.cellSet.has(y * W + x)) return false;
const start = Math.hypot(x - p.x, y - p.y) / PING_SPEED * 1000, ms = performance.now() - p.t0;
return ms >= start && ms < start + PING_HOLD + PING_FADE;
}
function cursorVein(i) {
const m = mat[i], cells = new Set([i]), queue = [i], edges = [];
const same = q => q >= 0 && q < N && mat[q] === m && (flags[q] & F_NAT);
const add = n => { if (cells.size < 6000 && same(n) && !cells.has(n)) { cells.add(n); queue.push(n); } };
for (let k = 0; k < queue.length; k++) {
const q = queue[k], x = q % W;
if (x) add(q - 1);
if (x < W - 1) add(q + 1);
add(q - W); add(q + W);
}
for (const q of cells) {
const x = q % W, y = (q / W) | 0;
if (!x || !same(q - 1)) edges.push([x, y, x, y + 1]);
if (x === W - 1 || !same(q + 1)) edges.push([x + 1, y, x + 1, y + 1]);
if (!same(q - W)) edges.push([x, y, x + 1, y]);
if (!same(q + W)) edges.push([x, y + 1, x + 1, y + 1]);
}
return { cells, edges, m, world: mat };
}
let cursorVeinCache = null, cursorLabelKey = null, cursorLabelSince = 0;
function hoveredVein(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return null;
const i = y * W + x, m = mat[i];
if (!PING_VEINS[m] || !(flags[i] & F_NAT) || (SET.fog && fogSeen(x, y) < 64 && !pingVisible(x, y))) return null;
if (!cursorVeinCache || cursorVeinCache.world !== mat || cursorVeinCache.m !== m || !cursorVeinCache.cells.has(i)) cursorVeinCache = cursorVein(i);
return cursorVeinCache;
}
function cursorLabelAlpha(key, now) {
if (key !== cursorLabelKey) { cursorLabelKey = key; cursorLabelSince = now; }
return Math.max(0, Math.min(1, (2400 - (now - cursorLabelSince)) / 400));
}
+42
View File
@@ -0,0 +1,42 @@
// Bottom panel; command dispatch and state live in sim/devconsole.js.
function closeDevConsole() {
$('dev-console').hidden = true;
$('dev-input').blur();
}
function showDevConsole() {
$('dev-console').hidden = false;
if (!$('dev-output').children.length) devPrint(['Type help to list commands. Backtick or Escape closes this panel.']);
$('dev-input').focus();
}
function toggleDevConsole() {
if (!GAME.running || GAME.loading) return;
if (!$('dev-console').hidden) { closeDevConsole(); return; }
if (devWorld) { showDevConsole(); return; }
dialog('Enable dev console?', '<p>This save will be permanently marked as a dev-console world. If achievements are added later, they will not unlock in this world.</p><p>You can change money, materials, upgrades and game speed. These commands are logged but cannot be undone.</p>', [
{ label: 'Cancel' },
{ label: 'Enable dev console', cls: 'primary', fn: () => {
enableDevConsole(); closeModal('m-dialog'); showDevConsole(); saveWorld('main'); return true;
} },
]);
}
function devPrint(lines) {
const log = $('dev-output');
for (const line of lines) { const row = document.createElement('div'); row.textContent = line; log.appendChild(row); }
while (log.children.length > 400) log.firstChild.remove();
log.scrollTop = log.scrollHeight;
}
function runDevCommand(text) {
devPrint(devCommand(text, GAME.aim));
hudDirty = true; lastBagKey = ''; lastBuildKey = '';
renderWallet();
if (!$('m-notebook').hidden) renderNotebook();
}
$('dev-close').onclick = closeDevConsole;
$('dev-input').onfocus = () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.drag = null; GAME.replace = false; };
$('dev-input').onkeydown = e => {
if (e.key === 'Escape' || e.key === '`') { e.preventDefault(); e.stopPropagation(); closeDevConsole(); }
};
$('dev-form').onsubmit = e => {
e.preventDefault();
const input = $('dev-input'); runDevCommand(input.value); input.value = ''; input.focus();
};
+1 -1
View File
@@ -10,7 +10,7 @@ function pollPad() {
const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz);
const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0);
const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry;
if (pressedAny && !GAME.usingPad) { GAME.usingPad = true; canvas.classList.add('nocursor'); }
if (pressedAny) GAME.usingPad = true;
const modal = topModal();
if (modal || !GAME.running) {
const now = performance.now();
+7 -1
View File
@@ -26,7 +26,13 @@ function selectTool(k) {
if (k === GAME.tool) return;
GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui');
}
function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); }
// The bag's usual order first, then anything else it holds, so nothing in it is ever hidden.
const IN_BAG_ORDER = new Uint8Array(NMAT); for (const m of BAG_ORDER) IN_BAG_ORDER[m] = 1;
function bagMaterials() {
const l = BAG_ORDER.filter(m => bag[m] > 0);
for (let m = 1; m < NMAT; m++) if (bag[m] > 0 && !IN_BAG_ORDER[m]) l.push(m);
return l;
}
function blowList() { return [...GAME.blow].filter(m => bag[m] > 0); }
// Shift + right click: an even mix of everything loose in the bag (bars never mix)
function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM && (PH[m] !== PH_LIQUID || player.flask)); }
+41 -21
View File
@@ -77,21 +77,28 @@ function reactionsOf(m, forNotebook) {
// 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.
function hoverInfo(ax, ay) {
if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] };
const pinged = pingVisible(ax, ay);
if (SET.fog && fogSeen(ax, ay) < 64 && !pinged) return { t1: 'Unexplored', t2: [], t3: [] };
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];
let warn = '';
let t1 = MD[m].name, t2 = [];
const p = part[i], id = mach[i];
let t1 = MD[m].name, t2 = [], contents = '';
const p = part[i];
// Cone gaps have no machine cells; hover still belongs to the silo's whole outline.
const silo = machines.find(mm => mm && mm.type === 'silo' && ax >= mm.x && ax < mm.x + mm.w && ay >= mm.y && ay < mm.y + mm.h);
const id = silo ? silo.id : mach[i];
const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)];
const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null;
if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
if (bar && !silo) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
else if (id && machines[id]) {
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 === '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.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') {
contents = mm.sn ? `${MD[mm.sm].name.toLowerCase()} ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')}` : 'empty';
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');
}
} else if (b === BP_RACK) {
const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
let n = 0, capacity = 0;
@@ -109,14 +116,15 @@ function hoverInfo(ax, ay) {
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) {
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = beltDirection(blockP[fb], blockV[fb]);
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
} else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
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 (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
} else if (IS_SCREW[p]) t1 = 'Screw conveyor ' + PIECE_BY_ID.screw.dirs[SCREW_PARTS.indexOf(p)] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (p) t1 = ({ 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) { const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : (ay >> 2) * BW + (ax >> 2); t1 = 'Belt ' + beltDirection(blockP[fb], blockV[fb]); }
else if (m === PIPE && isPipeCode(b)) {
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';
@@ -124,19 +132,25 @@ function hoverInfo(ax, ay) {
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');
}
else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`;
else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`;
const jb = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`);
for (let end = 0; end < 2 && !id && !bar; end++) {
const miss = nearMiss(jb % BW, (jb / BW) | 0, end);
if (miss) { t2.push(`doesn't connect: place it one block ${miss < 0 ? 'higher' : 'lower'}`); warn = warn || "doesn't connect"; break; }
}
const n = nets[netId[i]];
if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`);
if (player.lens && !bar) t2.push(fmtT(temp[i]));
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
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';
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : bitNeeded(m));
if (!ok && toolId() === 'drill') warn = bitNeeded(m);
}
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 (!id && !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');
const mo = moldAt(ax, ay);
const mo = silo ? null : moldAt(ax, ay);
if (mo) {
t1 = MOLDS[mo.kind].name;
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)}%`];
@@ -145,12 +159,13 @@ function hoverInfo(ax, ay) {
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
if (player.lens) t2.push(fmtT(temp[i]));
}
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
const reachOk = toolId() === 'drill' || (toolId() === 'build' ? inBuildRange(ax, ay) : inReach(ax, ay));
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
// what it does, unless the cursor is on a machine or mold, which have their own details
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
const t3 = what ? reactionsOf(what) : [];
return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
if (pinged) t2.push('found by the ping');
return { t1, t2, t3, warn, contents, ore: oreLike(m, bar), m: bar ? bar.m : m, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
}
function renderInfo() {
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
@@ -160,15 +175,19 @@ function renderInfo() {
}
// Name of what is under the cursor, drawn right next to it
function drawCursorLabel(px, py, r) {
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return;
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) { cursorLabelKey = null; return; }
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!info.t1 || info.air) return;
if (!info.t1 || (info.air && !info.warn) || (SET.cursorLabel === 'ores' && !info.ore && !info.warn)) { cursorLabelKey = null; return; }
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
const alpha = cursorLabelAlpha(vein || info.m || info.t1, performance.now());
if (!alpha && !info.warn) return;
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
const fs = Math.round(12 * dpr * SET.uiScale);
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
const main = info.t1 + (player.lens && R.lens ? '' : '');
const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
const main = alpha ? info.t1 + (info.t2.includes('found by the ping') ? ' · found by the ping' : '') + (info.contents && info.warn ? ` ${info.contents}` : '') : '';
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
ctx.save();
ctx.globalAlpha = info.warn ? 1 : alpha;
ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width;
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
@@ -180,8 +199,9 @@ function drawCursorLabel(px, py, r) {
ctx.fillStyle = 'rgba(20,17,15,0.82)';
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
ctx.textBaseline = 'middle';
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, 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.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.globalAlpha = alpha; ctx.fillText(main, x + padX, y + h / 2);
ctx.globalAlpha = info.warn ? 1 : alpha;
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore();
}
+5 -4
View File
@@ -24,17 +24,18 @@ window.addEventListener('keydown', e => {
if (e.repeat) return;
GAME.usingPad = false;
switch (k) {
case '`': e.preventDefault(); toggleDevConsole(); break;
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
case 'c': case 'x': hudDirty = true; break;
case 'v': startPaste(); break;
case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break;
case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break;
case 'r': rotate(); break;
case 't': if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
case 't': if (toolId() === 'build' && piece().code === BP_WALL) turnSlope(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
case 'f': doInteract(); break;
case 'g': GAME.replace = true; hudDirty = true; 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; setFlowCounting(GAME.infoOverlay); playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, which way belts and screw conveyors run, and how many grains a second each line moves (orange: backing up). I hides it.' : 'Info overlay off.'); break;
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
case 'n': openNotebook(); break;
case 'b': openNotebook('Blueprints'); break;
@@ -42,13 +43,13 @@ window.addEventListener('keydown', e => {
case '[': setBrush(-1); break;
case ']': setBrush(1); break;
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
case 'Escape': if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
case 'Escape': if (!$('dev-console').hidden) closeDevConsole(); else if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); 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 ((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(); 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.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; GAME.usingPad = false; });
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => {
+28 -13
View File
@@ -12,21 +12,24 @@ function scanVisible() {
}
let ambientT = 0;
function frame(now) {
if (GPU.stopped) return;
requestAnimationFrame(frame);
if (GPU.recovering) { lastT = now; return; }
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
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; }
const t0 = performance.now();
let frameSim = 0, frameSteps = 0;
const pad = pollPad();
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
if (!GAME.paused) {
acc += dtReal * SET.simSpeed;
if (!GAME.paused && (!devPaused || devSteps > 0)) {
acc += devPaused ? 0 : dtReal * SET.simSpeed * devSpeed;
let steps = 0;
const step = 1 / 60;
while (acc >= step && steps < SET.maxSteps) {
while ((devPaused ? devSteps > 0 : acc >= step) && steps < SET.maxSteps) {
let ix = 0, iy = 0;
if (keys.has('a') || keys.has('ArrowLeft')) ix -= 1; if (keys.has('d') || keys.has('ArrowRight')) ix += 1;
if (keys.has('w') || keys.has('ArrowUp') || keys.has(' ')) iy -= 1; if (keys.has('s') || keys.has('ArrowDown')) iy += 1;
@@ -37,8 +40,16 @@ function frame(now) {
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick();
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
acc -= step; steps++;
const s0 = performance.now();
try { simTick(); }
catch (e) {
GAME.paused = true; acc = 0; console.error(e);
toast('The world stopped updating. Reload this world to try again.', {big:true}); break;
}
const elapsed = performance.now() - s0;
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
if (devPaused) devSteps--; else acc -= step;
steps++;
GAME.playTime += step;
}
if (steps >= SET.maxSteps) acc = 0;
@@ -53,28 +64,30 @@ function frame(now) {
// grid under the cursor does not slide around mid-line.
if (GAME.drag) { lookX = GAME.lookHold.x; lookY = GAME.lookHold.y; } else { GAME.lookHold.x = lookX; GAME.lookHold.y = lookY; }
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
R.frame++;
drawWorld();
drawCracks();
const overlayStart = SET.showPerf ? performance.now() : 0;
if (!weather.seen && (R.rain || 0) > 0.12) {
weather.seen = true;
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
}
if (SET.look === 'smooth' && SM.tried && !SM.ok) {
SET.look = 'pixel'; saveSettings();
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
}
drawMachines();
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
drawParticles();
const hb = GAME.mouse.in || GAME.usingPad ? filterBlockAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)) : -1;
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
if (GAME.infoOverlay) drawFlowArrows();
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
drawJoinMarks();
drawPing();
drawHoveredVein();
drawPingRecharge();
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
drawFloaters();
drawRain();
drawBot();
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
renderBuildBar();
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
@@ -83,11 +96,13 @@ function frame(now) {
updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
updateMap(now);
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
const frameWork = performance.now() - t0;
frameMs = frameMs * 0.9 + frameWork * 0.1;
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) {
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).toFixed(1)}) · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
}
}
+8 -8
View File
@@ -43,8 +43,8 @@ function renderNotebook() {
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) {
if (P.hidden) continue;
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 cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : isWall(P.code) ? 'Square: 16 grains or 2 bars; slope: 8 grains or 1 bar' : costText(P.cost, 1);
const afford = P.kind === 'mold' || isWall(P.code) ? 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';
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>`;
}
@@ -57,10 +57,10 @@ function renderNotebook() {
}
function controlsHtml() {
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'],
['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'],
['Dev console (marks the world)', 'Backtick (`)'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Resonance ping: light up nearby veins (Drill)', 'Right mouse'],
['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall: cycle shape · flip diagonal drag', 'T'], ['Wall or mold material menu (Build)', 'F'],
['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 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'],
['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 screw conveyor directions, grains per second', 'I'], ['Bot lamp', 'L'],
['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 = [
@@ -71,7 +71,7 @@ function controlsHtml() {
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.'],
['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, 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.'],
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, screw conveyors, 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 (belts, screw conveyors, filters, shafts and pipes always draw a line). A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Uphill belts use cleats to carry pockets up; downhill they carry like a flat belt. Extra grains roll back. Diagonal filters always screen downhill, whichever way you drag. R leaves a picked sloped filter downhill. 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.'],
['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.'],
];
@@ -84,8 +84,8 @@ function controlsHtml() {
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. 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, 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>
Screw conveyors carry grains and whole bars: feed bars in from a belt, turn corners and unload at the mouth. Drag at about 45° for a diagonal run; R cycles eight directions.<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 conveyors 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>
`;
}
+15
View File
@@ -0,0 +1,15 @@
// Browser-only clocks; simulation code stays usable without a browser or a clock.
let PERF_MARKS = false, PERF_FRAME = null;
const perfMarks = {}, perfTimes = {}, perfLast = {};
for (const name of ['drawWorld', 'simMove', 'heatPass']) {
const original = window[name];
window[name] = function (...args) {
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
const start = performance.now();
const result = original.apply(this, args);
const ms = performance.now() - start;
perfLast[name] = ms;
perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
return result;
};
}
+6 -43
View File
@@ -1,44 +1,4 @@
// 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();
}
// Drill pulse wavefronts and aiming fan.
// 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';
@@ -74,7 +34,7 @@ function drawDrillAim(ax, ay) {
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 col = pulseRGB(GAME.drillTune);
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
@@ -84,6 +44,9 @@ function drawDrillAim(ax, ay) {
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;
// A solid far edge marks where the pulse stops, even with a distant cursor.
ctx.beginPath(); far.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y));
ctx.strokeStyle = `rgba(${col},0.85)`; ctx.lineWidth = Math.max(1, zp * 0.22); ctx.stroke();
// 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) {
@@ -95,7 +58,7 @@ function drawDrillAim(ax, ay) {
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);
ctx.strokeStyle = 'rgba(239,230,216,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();
+2 -1
View File
@@ -72,7 +72,8 @@ function drawPastePreview() {
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); }
const planned = new Map(bp.blocks.map(([dx, dy, code, v]) => [(oy + dy) * BW + ox + dx, [code, v]]));
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)`; previewBlock(code, bx2, by2, bw2, bh2, false, code === BP_SCREW || isBelt(code) ? v : -1, ox + dx, oy + dy, planned); }
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();
+85 -34
View File
@@ -14,9 +14,10 @@ function drawGrid(cbx, cby, radius) {
ctx.restore();
}
function previewColor(code, v) {
if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
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';
if (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
if (isFilter(code)) return '96,100,110';
if (isBelt(code)) return '110,114,124';
return { [BP_BELT]: '110,114,124', [BP_SCREW]: '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';
}
// Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
function drawAirStreaks(ax, ay, p, r, blowing) {
@@ -66,12 +67,9 @@ function drawPreviews() {
ctx.save();
const reachOk = inReach(ax, ay);
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
if (!reachOk && t !== 'drill' && !sel) {
if (!reachOk && t !== 'drill' && t !== 'build' && !sel) {
drawReachCircle(REACH, 0.35);
}
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
drawReachCircle(drillReach(GAME.drillW), 0.45);
}
drawDrillPulses();
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
if (sel) drawSelection(sel, ax, ay);
@@ -80,7 +78,9 @@ function drawPreviews() {
const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool];
const reachOk = inReach(ax, ay);
const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed;
ctx.strokeStyle = !reachOk ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
const nz = blowing && !reachOk ? nozzle(ax + 0.5, ay + 0.5) : null;
const p = nz ? worldToScreenPx(nz.ox + nz.dx * nz.dist - 0.5, nz.oy + nz.dy * nz.dist - 0.5) : worldToScreenPx(ax, ay);
ctx.strokeStyle = !reachOk && !nz ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
ctx.lineWidth = Math.max(1, zp * 0.25);
if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]);
ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
@@ -98,50 +98,40 @@ function drawPreviews() {
if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing);
}
} else if (t === 'build') {
const P = piece(), b = aimBlock();
const P = piece(), b = aimBlock(), plan = P.kind === 'block' ? dragPlan(P, GAME.drag, b, modShift()) : null;
const code = plan ? plan.code : pieceCode(P, b);
if (GAME.previewCode !== code) { GAME.previewCode = code; hudDirty = true; }
drawGrid(b.bx, b.by, 9);
let cells = [], col = '120,200,190';
if (P.kind === 'block') {
const v = pieceV(P);
col = previewColor(P.code, v);
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by)) : [[b.bx, b.by]];
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
const { v, dd, dir: dIdx, planned } = plan;
col = previewColor(code, v);
cells = plan.cells;
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
const afford = canAfford(pieceCostNow(P), cells.length);
for (const [bx, by] of cells) {
const err = canPlaceBlock(bx, by), rb = by * BW + bx;
const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
const err = canPlaceBlock(bx, by, code), rb = by * BW + bx;
const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P));
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
const ok = (!err || swap) && !ghost && afford;
const [x, y, w, h] = blockRectPx(bx, by, 1, 1);
ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)';
ctx.fillRect(x, y, w, h);
previewBlock(code, x, y, w, h, false, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned);
ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25);
if (ghost) ctx.setLineDash([zp, zp * 0.7]);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]);
if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
previewBlock(code, x, y, w, h, true, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]);
if (P.dirs && (P.id === 'belt' || P.id === 'screw' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
// a chevron on every block, pointing the way it will carry (or pump, or pour)
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
const [dx, dy] = isSlopeBelt(code) ? [dIdx ? 1 : -1, beltGrade(code) * (dIdx ? 1 : -1)] : P.id === 'screw' ? SCREW_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx;
ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); };
chev(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(1.2, zp * 0.45); chev();
}
}
// dragging a line of belts, filters or tubes: one long arrow over it, the way it will run
if (GAME.drag && dd >= 0 && cells.length > 1 && !modShift()) {
const [x0, y0, w0, h0] = blockRectPx(cells[0][0], cells[0][1], 1, 1), [x1, y1, w1, h1] = blockRectPx(cells[cells.length - 1][0], cells[cells.length - 1][1], 1, 1);
let sx = x0 + w0 / 2, sy = y0 + h0 / 2, ex = x1 + w1 / 2, ey = y1 + h1 / 2;
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dd] : [[-1, 0], [1, 0]][dd];
if ((ex - sx) * dx + (ey - sy) * dy < 0) { const tx = sx, ty = sy; sx = ex; sy = ey; ex = tx; ey = ty; }
const off = BS * zp * 1.0, hx = ex + dx * BS * zp * 0.55, hy = ey + dy * BS * zp * 0.55, ah = Math.max(8, BS * zp * 0.5);
const ox = dy ? off : 0, oy = dx ? off : 0;
const arrow = () => { ctx.beginPath(); ctx.moveTo(sx - dx * BS * zp * 0.3 + ox, sy - dy * BS * zp * 0.3 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.moveTo(hx - dx * ah - dy * ah * 0.6 + ox, hy - dy * ah - dx * ah * 0.6 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.lineTo(hx - dx * ah + dy * ah * 0.6 + ox, hy - dy * ah + dx * ah * 0.6 + oy); ctx.stroke(); };
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(20,16,12,0.6)'; ctx.lineWidth = Math.max(4, zp * 1.1); arrow();
ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(2, zp * 0.55); arrow();
}
// ends of the line that would join one block up or down get the "doesn't connect" mark
for (const [bx, by] of cells.length > 1 ? [cells[0], cells[cells.length - 1]] : cells)
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, planned)) drawJoinMark(bx, by, end, code, v);
if (GAME.drag && cells.length > 1) {
const label = `${cells.length} × ${costText(pieceCostNow(P), 1)}`;
ctx.font = `600 12px ui-monospace, monospace`; ctx.fillStyle = 'rgba(14,12,11,0.85)';
@@ -153,7 +143,7 @@ function drawPreviews() {
const { bx, by } = stampOrigin(bw, bh);
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
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) && inBuildRange(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
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 (P.kind === 'mold') {
@@ -188,3 +178,64 @@ function drawPreviews() {
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);
}
function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) {
const ctx = R.ctx;
if (code === BP_SCREW && (direction >= 4 || (bx >= 0 && straightScrewJoins(bx, by, direction, planned)))) {
const joins = bx >= 0 ? (direction >= 4 ? diagonalScrewJoins(bx, by, direction, planned) : straightScrewJoins(bx, by, direction, planned)) : 0;
const scale = w / BS, step = SET.look === "smooth" ? 0.25 : 1;
const b = bx >= 0 ? by * BW + bx : 0, ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
ctx.beginPath();
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
if (direction < 4 && ((direction & 1) ? xx < 0 || xx >= BS : yy < 0 || yy >= BS)) continue;
const sd = diagonalScrewShape(ox + xx + step / 2, oy + yy + step / 2, b, direction, joins);
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
return;
}
if (isSlopeBelt(code)) {
const b = by * BW + bx, joins = bx >= 0 ? slopeBeltConnections(bx, by, code, planned) : 0;
const scale = w / BS, step = SET.look === 'smooth' ? 0.25 : 1;
const ox = bx * BS, oy = by * BS;
ctx.beginPath();
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
const wx = ox + xx, wy = oy + yy, ix = Math.floor(wx), iy = Math.floor(wy);
if (bx >= 0 && (ix < ox || ix >= ox + BS || iy < oy || iy >= oy + BS)) {
if (ix < 0 || ix >= W || iy < 0 || iy >= H) continue;
const i = iy * W + ix, n = (iy >> 2) * BW + (ix >> 2), next = planned && planned.get(n);
if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || (blockP[n] && !isSlopeBelt(blockP[n])) || (next && !isSlopeBelt(next[0]))) continue;
}
const sd = slopeBeltShape(wx + step / 2, wy + step / 2, b, joins, code);
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
// Use the same filled wall profile as placed slopes; ribs stay on the physics feet.
if (!isFilter(code) && (direction < 0 || slopeUphill(code, direction))) { // downhill: a plain belt
ctx.beginPath();
for (let yy = -9; yy < 8; yy += step) for (let xx = -5; xx < 9; xx += step)
if (slopeWallShape(ox + xx + step / 2, oy + yy + step / 2, b, code) <= 0)
ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
const alpha = ctx.globalAlpha; ctx.globalAlpha *= 0.45; ctx.fill(); ctx.globalAlpha = alpha;
ctx.beginPath();
for (let k = 0; k < BS; k++) if (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0) {
const fy = beltFaceY(code, 0, 0, k);
for (let h = 0; h <= CLEAT_REACH; h++)
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
}
return;
}
code = slopeShape(code);
if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; }
const right = code === BP_SLOPE_BR || code === BP_SLOPE_TR, above = code >= BP_SLOPE_TL;
ctx.beginPath();
ctx.moveTo(x + (right ? w : 0), y + (above ? 0 : h));
ctx.lineTo(x + (right ? w : 0), y + (above ? h : 0));
ctx.lineTo(x + (right ? 0 : w), y + (above ? 0 : h)); ctx.closePath();
if (stroke) ctx.stroke(); else ctx.fill();
}
+1 -1
View File
@@ -112,7 +112,7 @@ async function saveWorld(slot, quiet) {
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 } : {}) });
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, dev: s.meta.dev, ...(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.');
+2 -2
View File
@@ -19,8 +19,8 @@ function renderSelCard() {
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>`;
<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>${pingWait() ? `<span class="chip">ping ${pingWait()}s</span>` : '<span class="chip ok">ping ready</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. Right click pings: veins nearby light up, even unexplored. 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();
+6 -4
View File
@@ -13,18 +13,20 @@ function filterParts(f) {
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); }
undoRecord('filter settings', () => {
for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(blockP[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;
let inv = cur.inv ? 1 : 0, dir = beltGrade(cur.code) ? (beltGrade(cur.code)>0 ? 1 : 0) : 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>`
<div class="fdir" role="radiogroup" aria-label="Screen travel"><span class="label">Screen travel</span>${beltGrade(cur.code) ? `<span>${esc(beltDirection(cur.code, dir << 7))}</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; } }]);
@@ -34,7 +36,7 @@ function openFilterPicker(cur, onDone, title) {
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.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${beltGrade(cur.code) ? beltDirection(cur.code, dir << 7) : 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(); });
+41 -4
View File
@@ -47,6 +47,7 @@ function rotate() {
else if (t === 'build') {
const P = piece();
if (pieceUsesWall(P)) cycleWall(1);
else if (P.filter && beltGrade(pieceCode(P))) return;
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
else return;
} else if (t === 'drill') { tuneUnderCursor(); return; }
@@ -82,15 +83,51 @@ function setBrush(d) {
// The block variant a piece is placed with: wall material, or direction (a filter's
// materials and mode travel separately, as GAME.filterCfg)
function pieceV(P) {
if (P.code === BP_WALL) return GAME.wallMat;
if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7;
if (isWall(P.code)) return GAME.wallMat;
if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7);
return GAME.pieceDir[P.id] || 0;
}
// One drag for the build tool and its preview: code, direction, variant, blocks, and the planned
// blocks the join rule sees. A line that joins one block up or down snaps there (joinSnap).
function dragPlan(P, a, b, shift) {
const code = pieceCode(P, b), dd = a ? dragDirFor(P, a, b) : -1, dir = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
const v = isWall(P.code) ? GAME.wallMat : P.filter ? filterVariant(code, dir << 7) : dir;
let cells = !a ? [[b.bx, b.by]] : shift && !lineOnly(code) ? blockRect(a.bx, a.by, b.bx, b.by)
: blockLine(a.bx, a.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW);
const snap = lineOnly(code) ? joinSnap(code, v, cells) : 0;
if (snap) cells = cells.map(([x, y]) => [x, y + snap]);
return { code, dd, dir, v, cells, snap, planned: new Map(cells.map(([x, y]) => [y * BW + x, [code, v]])) };
}
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
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 isFilter(blockP[b]) ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
function pieceCostNow(P, m = GAME.wallMat) {
if (devFree) return [[CASH, 0]];
if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS);
if (P.kind === 'block' && isWall(P.code)) return pieceCost(pieceCode(P), m);
return P.cost;
}
function pieceCode(P, end = aimBlock()) {
if (P.id === 'belt' || P.filter) {
const a = GAME.drag || end;
// Shift changes nothing here: belts and filters only ever draw a line
if (GAME.drag && diagonalDrag(end.bx - a.bx, end.by - a.by))
return (P.filter ? BP_FILTER_UP : BP_BELT_UP) + ((end.bx - a.bx) * (end.by - a.by) > 0 ? 1 : 0);
if (end.bx === a.bx && end.by === a.by) return GAME.pieceShape && GAME.pieceShape[P.id] || P.code;
return P.code;
}
if (P.code !== BP_WALL) return P.code;
const a = GAME.drag || end, dx = end.bx - a.bx, dy = end.by - a.by;
if (GAME.drag && (dx || dy)) {
return diagonalDrag(dx, dy) && !(typeof modShift === 'function' && modShift()) ? slopeCode(dx, dy, a.flip ? 2 : 0) : BP_WALL;
}
const shape = GAME.pieceDir.wall || 0;
return shape ? BP_SLOPE_BL + shape - 1 : BP_WALL;
}
function turnSlope() {
const a = GAME.drag, b = aimBlock();
if (a && diagonalDrag(b.bx - a.bx, b.by - a.by)) a.flip = !a.flip;
else GAME.pieceDir.wall = ((GAME.pieceDir.wall || 0) + 1) % 5;
hudDirty = true;
}
+12 -7
View File
@@ -10,13 +10,15 @@ function showLoading(on, p, label, title) {
$('loading').hidden = !on;
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
}
function enterGame() {
function enterGame(renderReady = false) {
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
$('cards').innerHTML = '';
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
GAME.status = ''; GAME.statusT = 0;
$('title').hidden = true; $('hud').hidden = false;
GAME.running = true; GAME.paused = false;
buildSky(); resizeRender(); snapCamera(); resetMap();
if (!renderReady) { buildSky(); resizeRender(); snapCamera(); }
resetMap();
GAME.lastAutosave = performance.now();
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar();
@@ -31,6 +33,7 @@ async function setupNewWorld(seed, w, h, name, carried, title = 'Generating worl
if (carried) applyCareerState(carried);
}
async function startNewWorld(seed, w, h, name = '') {
if (!await canStartGame()) return false;
try {
const worlds = await listWorlds();
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
@@ -39,11 +42,13 @@ async function startNewWorld(seed, w, h, name = '') {
showLoading(true, 0.9, 'Saving');
await saveWorld('auto');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame();
await gpuWarmWorld();
showLoading(false); closeAllModals(); enterGame(true);
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) {
if (!await canStartGame()) return false;
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
auInit();
GAME.paused = true;
@@ -64,8 +69,8 @@ async function startFromSave(sv, onProgress) {
GAME.drag = null; GAME.blow.clear();
onProgress(0.95, 'Your gear and notebook');
await loadCareer();
showLoading(false);
enterGame();
await gpuWarmWorld();
showLoading(false); enterGame(true);
return true;
}
async function loadSavedWorld(id) {
@@ -80,7 +85,7 @@ async function loadSavedWorld(id) {
}
async function quitToTitle() {
await saveWorld('main', true);
GAME.running = false; closeAllModals();
GAME.running = false; closeDevConsole(); closeAllModals();
$('hud').hidden = true; $('title').hidden = false;
refreshContinue();
if (!TITLE.raf) drawTitle();
@@ -135,7 +140,7 @@ async function renderWorlds(editId) {
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>`;
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong>${w.dev ? ' <span class="dev-badge">Dev console</span>' : ''}<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) {
+2 -3
View File
@@ -8,7 +8,7 @@ const SET_TABS = {
{ k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ 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: '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: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] },
{ k: 'minimap', label: 'Show minimap', 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) + '%' },
@@ -29,6 +29,7 @@ const SET_TABS = {
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
],
Simulation: [
{ k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v },
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
@@ -97,8 +98,6 @@ function renderSettings() {
}
}
function applySettings() {
// choosing Smooth again after it was too slow: try once more, at half resolution
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
saveSettings();
document.documentElement.style.setProperty('--ui', SET.uiScale);
if (R.canvas) { resizeRender(); R.snapNext = true; }
+3 -2
View File
@@ -5,15 +5,16 @@
const SET_DEFAULT = {
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,
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, workerCap: 16,
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: 'ores', sv: 2,
};
const SET = Object.assign({}, SET_DEFAULT);
// sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once.
function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s);
if (typeof SET.cursorLabel === 'boolean') SET.cursorLabel = SET.cursorLabel ? 'ores' : 'off';
if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); }
} catch (e) { /* storage unavailable */ }
}
+39
View File
@@ -0,0 +1,39 @@
// Browser transport only; the simulation owns the schedule and shared state.
let threadStarting = null;
async function startThreads() {
const count = Math.max(1, Math.min(16, Math.floor(SET.workerCap) || 16, (navigator.hardwareConcurrency || 3) - 2));
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
if (threadStarting) await threadStarting;
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
threadStarting = (async () => {
if (simPool) for (const worker of simPool.workers) worker.terminate();
simPool = null;
if (!(mat.buffer instanceof SharedArrayBuffer)) throw Error('Shared memory is unavailable');
const url = URL.createObjectURL(new Blob([SIM_WORKER_SOURCE, '\nonmessage=e=>threadMessage(e.data);'], {type:'text/javascript'}));
const pool = { world: mat, workers: [], slots: [] };
try {
const ready = [];
for (let k = 0; k < count; k++) {
const worker = new Worker(url), slot = threadSlot();
pool.workers.push(worker); pool.slots.push(slot);
ready.push(new Promise((resolve,reject) => {
const deadline = performance.now() + 10000;
worker.onerror = e => {
const error = Error(e.message || 'Simulation worker failed');
threadFailure(slot, error); reject(error);
};
const poll = () => {
if (Atomics.load(slot.control,0)) resolve();
else if (performance.now() > deadline) reject(Error('Simulation worker startup timed out'));
else setTimeout(poll, 1);
};
poll();
}));
worker.postMessage({world:threadWorld(),slot});
}
await Promise.all(ready); simPool = pool;
} catch (e) { for (const worker of pool.workers) worker.terminate(); throw e; }
finally { URL.revokeObjectURL(url); }
})();
try { await threadStarting; } finally { threadStarting = null; }
}
+32 -1
View File
@@ -1,4 +1,35 @@
// The title screen art.
// Startup capabilities and title art.
const REQUIRE_WEBGPU = true;
const REQUIRE_THREADS = true;
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
async function checkCapabilities() {
try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
CAPS.webgpu = !!adapter && gpuLimitsOK(adapter) && await gpuInit(adapter);
CAPS.requiredLimits = GPU_LIMITS;
const info = adapter && adapter.info;
if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description };
} catch (e) { CAPS.webgpu = false; }
CAPS.ready = true;
renderCapabilityNotice();
}
function renderCapabilityNotice() {
const messages = [];
if (!CAPS.isolated) messages.push("Deepgrain needs to be served (for example with python3 tools/serve.py) so it can use all CPU cores; opened from a file it can't.");
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
if (GPU.error) messages.push('WebGPU could not start: ' + GPU.error);
const notice = $('cap-notice');
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
notice.hidden = !messages.length;
}
async function canStartGame() {
await CAPS_CHECK;
return (!REQUIRE_WEBGPU || (CAPS.webgpu && GPU.ok)) && (!REQUIRE_THREADS || CAPS.isolated);
}
const CAPS_CHECK = checkCapabilities();
// ---------- Title art ----------
const FONT5 = {
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
-14
View File
@@ -1,14 +0,0 @@
---
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
@@ -1,19 +0,0 @@
---
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
- [ ]
@@ -1,19 +0,0 @@
---
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
@@ -1,17 +0,0 @@
---
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.

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