Compare commits

...
51 Commits
Author SHA1 Message Date
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
94 changed files with 5361 additions and 522 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ A falling-sand factory game in the browser, in the style of Sandustry.
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
+174 -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,171 @@ 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.
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
+141 -63
View File
@@ -13,13 +13,13 @@ tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md;
build.py joins src/ into dist/index.html (one self-contained page)
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/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
t_smooth.py, t_dev_console.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/
@@ -29,6 +29,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 +49,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 +67,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 +86,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 +123,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`);
@@ -134,13 +150,14 @@ dist/, shots/ build output and screenshots (not committed)
- **The pixel look:**
- `drawWorld` (`src/render/world.js`) fills one pixel per grain;
- `diagonal-screws.js` derives diagonal and straight end bends from `endJoin`, sharing casing geometry between pixels, smooth shading tags, ghosts and previews;
- `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.
6 a wall tile, 7/8 a belt, 9–12 a screw conveyor, 13/14 a filter.
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test
tools set it).
@@ -171,7 +188,7 @@ dist/, shots/ build output and screenshots (not committed)
| 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` |
@@ -201,14 +218,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 +237,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 (107 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
- 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, 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 (225 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, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove
- 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 +277,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 (212 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 (235 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 (317 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)
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 +343,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 +362,7 @@ 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/tick.js (29 lines)
simTick: one simulation step, in order.
- functions: simTick
@@ -342,7 +375,12 @@ 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 (113 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 (183 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF
@@ -352,29 +390,43 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab
- 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)
### src/render/light.js (103 lines)
Propagated coloured light from glowing and burning cells.
- functions: computeLight
- functions: builtForLight, looseForLight, computeLight
### src/render/world.js (308 lines)
### 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/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/world.js (404 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 (321 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
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_FADE
### 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)
### src/render/slope-belt-shader.js (111 lines)
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
### src/render/smooth-shaders.js (377 lines)
Smooth shaders: vertex and fragment programs for the rounded world look.
- variables: SM_VS, SM_FS
@@ -403,7 +455,7 @@ 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 (158 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
@@ -430,16 +482,16 @@ Notebook blueprints: starter designs, saved library and code sharing.
The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
### 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 +500,39 @@ Selection panels: current tool card and bag materials.
- functions: renderSelCard, renderBag
- variables: lastBagKey
### src/ui/info.js (188 lines)
### src/ui/info.js (201 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 (254 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, 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)
### src/ui/preview-drill.js (107 lines)
Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern.
- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
- variables: CRACK_N, CRACK_LV
### src/ui/preview-selection.js (80 lines)
### 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,7 +546,7 @@ 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/loop.js (138 lines)
The frame loop and boot.
- functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
@@ -504,17 +560,29 @@ The frame loop and boot.
- `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_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_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_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
- `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
- `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_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_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_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 WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
@@ -529,15 +597,25 @@ The frame loop and boot.
- `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.
+1 -1
View File
@@ -130,5 +130,5 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
- `tools/perf.py`: renderer and tick times in the browser.
- `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.
+596
View File
@@ -4,6 +4,602 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
## 2026-10-27: t75 ping recharge, natural ore colour, glints (Claude Code)
- 10 s recharge (`PING_RECHARGE`, `pingWait`) with a drill panel chip. `pingImage` draws veins in
their lit colour (base + per-grain noise) with a lighter rim; the ring is dimmer; up to 60 glints
per ping flash as the ring passes. Follow-up: veins fade by distance (lit 1.2 s, fade 0.8 s,
drawn in 6 alpha bands) and `drawPingRecharge` shows an arc around the bot with the drill. Files: `src/ui/actions.js`, `src/ui/selection-panels.js`,
`src/render/overlays.js`, `src/ui/loop.js`. Tests: full suite with --browser passed. The ring and glints were not
caught in a screenshot (they finish before the frame is taken).
## 2026-10-27: bag counted things its list didn't show (Claude Code)
- Owner's exported save: bag total 1,522 but the list showed nothing for 1,000 basalt (from the
dev console's give), 200 clay (source not traced) and 322 molten slag (vacuumed with the Flask;
not in BAG_ORDER). `bagAdd` now stores solids as their drill yield; `deserializeWorld` converts
solids already in a saved bag; `bagMaterials` appends anything outside BAG_ORDER; dev `give`
reports what it added and refuses gases and solids with no yield.
- Files: `src/sim/items.js`, `src/sim/save.js`, `src/ui/hud.js`, `src/sim/devconsole.js`,
`test/t_basics.js`, `test/t_dev_console.js`. Tests: `python3 test/run_all.py --browser` all passed.
## 2026-10-27: t50 resonance ping (Claude Code)
- Right click with the drill: `firePing` (`src/ui/actions.js`) calls `resonancePing` (`src/sim/drill.js`),
which lists natural vein cells (coal seam and the six ore veins) within 3x the bit's reach.
`drawPing` (`src/render/overlays.js`) draws an echo ring and the veins inside it over fog, from
a one-time offscreen image (one clipped drawImage per frame), fading over 4.5 s. 3 s recharge.
- Files: `src/sim/drill.js`, `src/ui/actions.js`, `src/render/overlays.js`, `src/ui/loop.js`,
`src/ui/notebook.js`, `src/ui/selection-panels.js`, `test/t_drill.js`, `docs/PROJECT_MAP.md`.
- Tests: 4 checks in `test/t_drill.js`; `python3 test/run_all.py --browser` all passed.
## 2026-10-27: t64 build range about the screen; out-of-range replace ghosts (Claude Code)
- `setBuildRange`/`inBuildRange` in `src/sim/tools.js`: the UI sets the view box each frame
(`src/ui/loop.js`); half-sizes clamped to REACH..BUILD_MAX (64..200 grains); with no view (tests)
a REACH box around the bot. Used by `blockInReach`, machine/mold placement and previews,
right-click take-back, hover reach, and ghost building for blocks, machines and molds.
- A replace (G) placed beyond range stores a replace flag on the ghost (`[code, v, extra, 1]`);
`ghostTick0` removes the old piece (refunded) and places the new one (charged), or re-sets a
filter's settings, when the bot comes in range.
- Files: `src/sim/tools.js`, `src/sim/blocks.js`, `src/sim/undo.js`, `src/ui/actions.js`,
`src/ui/previews.js`, `src/ui/info.js`, `src/ui/loop.js`, `test/t_build.js`, `docs/DESIGN.md`,
`docs/PROJECT_MAP.md`. Tests: `python3 test/run_all.py --browser` all passed. Not played by hand.
## 2026-10-27: t49 throughput overlay (Claude Code)
- The I overlay also shows grains per second at each line's open outlet, an orange glow on
transport blocks that are loaded but not moving, and the rate in the hover. `src/sim/flow.js`
counts block-edge crossings per carrier block while `flowOn` (set by the I toggle) and folds
them into a smoothed per-second rate once a second. Hooks: `carryColumn`, `machinesTick` screws,
`screenNudge`, uphill cleat runs (only runs that commit), and the uphill face drag.
- Not counted: bars. Not built: money per minute.
- Files: `src/sim/flow.js`, `src/manifest.json`, `src/sim/conveyors.js`, `src/sim/machine-tick.js`,
`src/sim/slope-conveyors.js`, `src/sim/tick.js`, `src/render/overlays.js`, `src/ui/loop.js`,
`src/ui/input.js`, `src/ui/info.js`, `src/ui/notebook.js`, `test/t_flow.js`, `docs/PROJECT_MAP.md`.
- Tests: `test/t_flow.js` (including a check that counting changes nothing in the simulation);
`python3 test/run_all.py --browser` all passed.
## 2026-10-27: cooled magma visible and drillable (Claude Code)
- Owner report: bag magma in a brick box left invisible basalt that couldn't be picked up,
drilled or built on; melted screws left similar bits. Cause: melts froze into non-natural
rock unless the liquid was natural, the drill only breaks natural rock, and the smooth look's
terrain field skipped non-natural solids.
- `src/sim/heat.js`: non-metal, non-glass melts freeze into natural rock; bag water into natural
ice. `src/sim/drill.js`: `crumbly` no longer needs F_NAT; `builtCell` protects slope corner cells.
`src/render/world.js`: the terrain field covers any terrain-class solid without a block or
machine. Note: the bot still passes through old stuck basalt until it is drilled (it only
collides with natural solids).
- Tests: 7 checks in `test/t_drill.js`; `python3 test/run_all.py --browser` all passed.
## 2026-10-27: t67 phase 4, "doesn't connect" marks (Claude Code)
- `nearMiss(bx, by, end, planned)` in `src/sim/joins.js`: an end with no join that would join
seamlessly, with a diagonal piece involved, one block up or down in empty space. Drop joins
count as connected; straight beside straight is never marked. It checks the moved block
through a small override view of `planned`, so ghost maps are never copied.
- Marks: a red ring with a slash at the end, on built pieces and ghosts (`drawJoinMarks` in
`src/render/overlays.js`, called every frame from `src/ui/loop.js`) and at the ends of a drag
preview (`src/ui/previews.js`). Hover says "doesn't connect: place it one block higher/lower"
(`src/ui/info.js`). This covers t58's mark for belts and the same cases for screws.
- Tests: 7 nearMiss checks in `test/t_joins.js`; `python3 test/run_all.py --browser` all passed.
`docs/PROJECT_MAP.md` regenerated.
## 2026-10-27: t67 phase 3 shared drag placement, and snapping (Claude Code)
- `dragPlan(P, a, b, shift)` in `src/ui/selection.js` gives code, direction, variant, blocks and
a planned map for every block piece. `primaryRelease` and the drag preview both use it (the
duplicated `blockLine(...)` expression and the screw/slope-only planned map are gone).
- Snapping: `joinSnap` in `src/sim/joins.js` moves a line one block up or down when its first
block would not join but would join seamlessly there with a diagonal piece involved, and
every block there is free. Fixes the owner's report that an uphill belt started beside a flat
belt on the same level did not join like screws do: it now snaps up to the seamless join. Also
covers t58's flat belt level with a downhill low end (snaps down) and a diagonal screw started
one block off. Straight beside straight never snaps. `joinIsDrop` (same-row feed into a slope's
low end) is now the one source for the drop, used by snapping and belt drawing.
- Files: `src/sim/joins.js`, `src/ui/selection.js`, `src/ui/actions.js`, `src/ui/previews.js`,
`src/render/slope-belts.js`, `test/t_joins.js`, `test/t_slope_belts.js`.
- Tests: `python3 test/run_all.py --browser` all passed; 9 snap checks plus a real
`primaryRelease` check. Open: phase 4's "doesn't connect" mark is not built yet.
## 2026-10-27: t66 dev console (Claude Code + Codex)
- Backtick opens a dev console in any build. The first time in a world it warns that the save
will be marked; accepting saves `dev: true` and a `devLog` (last 200 commands) in the world,
and the worlds list shows a "Dev console" badge. Commands: help, money, debt, give (display or
internal name, past the bag limit), bit, bag, unlock, tp (cursor or x y), free on|off, speed,
pause, step, weather clear|rain. Command logic is sim-side (`devCommand` in
`src/sim/devconsole.js`); the panel is `src/ui/devconsole.js`.
- Not built: `time` (no clean way to set the time of day) and undo (commands are logged instead).
Free building also skips `payCost`, so ghosts built later are free too; removing a piece built
for free still refunds its normal cost.
- Also fixed a flaky build-card check: the wall-material chip markup (`data-wall-picker=""`)
made the selection card rebuild its button every refresh.
- Files: `src/sim/devconsole.js`, `src/ui/devconsole.js`, `src/styles/devconsole.css`,
`src/shell.html`, `src/manifest.json`, `src/sim/items.js`, `src/sim/save.js`, `src/sim/world.js`,
`src/ui/input.js`, `src/ui/loop.js`, `src/ui/saves.js`, `src/ui/session.js`,
`src/ui/selection.js`, `src/ui/notebook.js`, `src/ui/build-menu.js`, tests
`t_dev_console.js`, `t_dev_console.py`, `test/run_all.py`, `docs/PROJECT_MAP.md`.
- Tests: `python3 test/run_all.py --browser` all passed. Built in a parallel worktree; Codex
hit its usage limit near the end and Claude finished it (the payCost fix).
## 2026-10-27: t67 belt drop drawn as two capped ends (Claude Code)
- A flat belt feeding an uphill belt's low end on the same row (and two uphill slopes side by
side) stays joined, but grains really drop about 3 grains onto the face there, so it now
draws as two capped ends instead of the bridge phase 2b drew (it overlapped and stepped
badly). `beltDrop` in `src/render/slope-belts.js` decides it for `slopeBeltConnections` and
`flatBeltCaps`; the shader and `world.js` packing went back to their pre-2b versions.
- Tests: `t_joins.js` and `t_smooth.py` drop checks updated; the existing sand test confirms
the same-row feed carries sand up. `python3 test/run_all.py --browser` all passed.
## 2026-10-27: t67 phase 2b, joins follow the travel direction (Claude Code + Codex)
- The owner tested phase 2: horizontal → uphill and vertical-up → diagonal didn't join like the
rest. New rule (DESIGN.md): a piece's outlet points at its block plus its travel vector, and
the next piece joins when it sits there travelling the same general way. Belts bend it where
grains can't follow: an uphill belt's top feeds a flat belt on the same row, and a flat belt
one block below an uphill low end still joins.
- `src/sim/joins.js` reworked around inlets and outlets; screw drawing, belt drawing, screw
grain flow and bars follow it. Straight-screw mouths no longer reach a diagonal across a
corner; straight-to-straight transfers (lift side-feeding) unchanged.
- Files: `src/sim/joins.js`, `src/sim/conveyors.js`, `src/sim/items.js`,
`src/render/diagonal-screws.js`, `src/render/slope-belts.js`, `src/render/slope-belt-shader.js`,
`src/render/world.js`, `src/ui/previews.js`, `tools/scenes/joins.js`, tests `t_joins.js`,
`t_diag_screws.js`, `t_slope_belt_art.js`, `t_smooth.py`, `docs/PROJECT_MAP.md`.
- Tests: `python3 test/run_all.py --browser` all passed.
- Open: Codex hit its usage limit before finishing; the belt drop drawing was finished in the
next entry. Not playtested.
## 2026-10-27: t67 phase 2, screw conveyors follow the join rule (Claude Code + Codex)
- Screw drawing, grain flow and bars now all go through `endJoin`. New joins: a horizontal in
E at a diagonal's low end, and a vertical in E at either end; both draw an elbow that bends
inside the straight piece's end block. Same-row high-end elbows are unchanged.
- No longer joined (moves nothing): a same-row horizontal at a low end, a vertical directly
above or below a diagonal's last block, and any other side contact. Joined belts and filters
take discharge on their carrying face at either end. A horizontal screw feeding the side of a
vertical lift still works (`straightScrewNext` keeps straight-to-straight transfers).
- The opposite-direction check is one helper, `screwAccepts`.
- Files: `src/render/diagonal-screws.js`, `src/render/smooth-shaders.js`, `src/render/world.js`,
`src/ui/previews.js`, `src/sim/conveyors.js`, `src/sim/machine-tick.js`, `src/sim/items.js`,
`src/sim/cells.js`, `tools/scenes/joins.js` (replaces `t62-joins.js`), `test/t_diag_screws.js`,
`test/t_smooth.py`, `docs/PROJECT_MAP.md`.
- Tests: `python3 test/run_all.py --browser` all passed; determinism diff changed only screw
checks. Screenshots: `shots/joins-*.png`.
- Open: inside the new elbows the flight stripes of both pieces overlap, which looks a little
busy in the smooth look. Layouts built on the removed joins need moving.
## 2026-10-27: t63 vacuum picks up grains in mid-air (Claude Code + Codex)
- `toolVacuum` now takes flyers (grains a screw launches or that were blown out) inside its
brush and reach before loose cells, with the same pick filter, flask rule, bag-full stop and
48-grain budget. `vacuumTargetAt` also considers them. Rain is skipped: it belongs to the
clouds, and taking it would make water.
- Files: `src/sim/vacuum.js`, `test/t_basics.js`. Built on a separate worktree branch in
parallel with t67 and fast-forwarded.
- Tests: `python3 test/run_all.py --browser` all passed; new checks only in the determinism diff.
## 2026-10-27: t67 phase 1, shared join module (Claude Code + Codex)
- Owner decision (DESIGN.md, replaces the t62 same-row rule): one join rule for every transport
piece. New DOM-free `src/sim/joins.js`: `pieceEnds`, `endJoin`, `pieceJoins`; rule tables are
compiled once and joins must be mutual. Belt drawing now uses it: `slopeBeltConnections`,
`flatBeltCaps` (now also for ghosts), and the pixel filter frame (`beltLike(b, end)`). Sloped
belt drag previews now pass a planned map.
- Deliberate drawing changes where the old belt rules disagreed: flat belt caps no longer
treat any same-row diagonal screw as joined, and filter frames open only at a real join.
- Files: `src/sim/joins.js`, `src/manifest.json`, `src/render/slope-belts.js`,
`src/render/setup.js`, `src/render/world.js`, `src/ui/previews.js`, `test/t_joins.js`,
`test/t_slope_belt_art.js`, `docs/PROJECT_MAP.md`, `docs/DESIGN.md`.
- Tests: `python3 test/run_all.py --browser` all passed; t_joins checks 4,232 placements,
symmetry, planned maps, and that belt sand flow agrees with the rule. Sim output unchanged.
- Open: screws still use their old rules until phase 2.
## 2026-10-27: t62 corner placements stay separate (Claude Code + Codex)
- Owner tried the corner join and found the jog odd; decision (recorded in DESIGN.md): only
same-row joins connect. Removed join kinds 5/6 and `straightScrewNext`; `diagonalScrewNext`
and `entDiagonalScrewTick` now continue across a corner only into a diagonal running the same
way, so a corner straight piece no longer receives grains or bars. Kept the shared geometry
file and the smooth-look elbow seam fix.
- Files: `src/render/diagonal-screws.js`, `src/render/smooth-shaders.js`, `src/sim/conveyors.js`,
`src/sim/items.js`, `src/sim/machine-tick.js`, `test/t_diag_screws.js`, `test/t_smooth.py`,
`tools/scenes/t62-joins.js`, `docs/PROJECT_MAP.md`, `docs/DESIGN.md`.
- Tests: `python3 test/run_all.py --browser` all passed; determinism diff changed only the
replaced corner checks.
## 2026-10-27: t62 screw conveyor joins (Claude Code + Codex)
- Straight screws touching a diagonal's end only at the corner now join, in both looks and in
ghosts/previews, and grains and bars cross the joint. Same-row joins were already elbows.
Also removed the seam across the top of connected elbows in the smooth look.
- Files: new `src/render/diagonal-screws.js` (join and casing geometry moved out of
`src/render/world.js`), `src/render/smooth-shaders.js`, `src/ui/previews.js`,
`src/sim/conveyors.js` (`straightScrewNext`), `src/sim/machine-tick.js`, `src/sim/items.js`,
`src/manifest.json`, `docs/PROJECT_MAP.md`, `tools/scenes/t62-joins.js`, tests
`test/t_diag_screws.js`, `test/t_smooth.py`.
- Tests: `python3 test/run_all.py --browser` all passed; determinism diff showed only timing
lines and the new checks.
- Open: the corner join doesn't check that the straight piece ends there, so a straight run
passing over the diagonal's corner also draws a jog. Not playtested by hand.
## 2026-10-27: t66 dev console in every build (Claude Code)
- Owner decision: the console ships in production too, with a warning when it's turned on that
the save gets marked (and future achievements won't unlock). Recorded in `docs/DESIGN.md`;
t66 gained subtasks for the warning, the save mark, and game speed and fun controls. No code
changed.
## 2026-10-27: t66 dev console added to Todo (Claude Code)
- Owner request: a dev console to add money, items and upgrades quickly for testing. Subtasks
cover how it opens (and hiding it from real players), money and debt, bag items, bit and bag
tiers, other shortcuts, and marking changed worlds. No code changed.
## 2026-10-27: Save compatibility rule dropped from AGENTS.md (Claude Code)
- Owner: old saves don't matter; only they play, for testing, and can start a new game.
`AGENTS.md` still said to keep saves loading (never renumber ids, add migrations), which
contradicted the DESIGN decision "No backward compatibility for now". It now matches. The t65
subtask no longer asks for saves to load. `AGENTS.md` is local (gitignored). No code changed.
## 2026-10-27: t65 Rust conversion added to Todo (Claude Code)
- Owner request: a task to convert the game to Rust for performance, after the base game is more
playable (sooner if performance becomes a problem). Subtasks: settle the scope (whole game or the
hot sim code; WebAssembly or native), profile for a baseline, keep tests and saves working. No
code changed.
## 2026-10-27: Shift-drag draws a line for transport pieces (Claude Code)
- Owner request: Shift shouldn't fill a rectangle for belts, conveyors and similar. New
`lineOnly(code)` in `src/sim/pieces.js` (belts, filters, screw conveyors, shafts, pipe pieces);
`primaryRelease` (`src/ui/actions.js`) and the drag preview (`src/ui/previews.js`) ignore Shift
for them, and `pieceCode` (`src/ui/selection.js`) no longer flattens a diagonal belt drag on
Shift. Build-menu "How to place" and the notebook say so; the notebook's stale "cleats up or
down" line now says uphill only.
- Tests: play.py checks a Shift-drag with a belt lays a 3-piece sloped line. Full
`run_all --browser` passes.
## 2026-10-27: t61 sticky materials slide freely on built surfaces (Claude Code)
- The owner's video showed clay lumps creeping down a sloped wall; removing a piece was
unrelated. Cause: STICK gates every diagonal step, so cohesive materials crawl on 45°. In
`powder()` (`src/sim/cells.js`) a grain resting on a built solid (not F_NAT) now always tries
the diagonal; the random draw is unchanged, so generated worlds are identical.
- Measured: a clay grain down a 20-piece built slope took 77 ticks, same as sand (before: never
arrived in 2000 ticks). Heaps on a built floor and on natural ground are unchanged.
- Test: `t_slopes.js` clay vs sand transit. `python3 test/run_all.py --browser`: ALL PASSED.
## 2026-10-27: Unremovable one-cell belt left after cutting slopes (Claude Code)
- The owner's exported save had a lone belt cell no block owned, so nothing could build there or
remove it. It was a sloped belt's seal: a seal may sit in a cell its neighbour leaves open (a
slope's open corner, or under a grate, screw or screen), but removal only let it go when the
neighbour was a sloped wall. Now `blockOwnsCell` decides (`src/sim/blocks.js`), and loading a
world clears belt and screen cells no block or seal owns (`clearStraySeals`, `save.js`).
- Found with a fuzz of random slope/belt/filter/wall/grate builds removed in random orders and by
cut (0 strays in 4000 runs after the fix). The owner's save loads clean and the spot is
buildable. Tests in `t_slope_belts.js` fail on the old code. `run_all --browser`: ALL PASSED.
## 2026-10-27: t42 feedback: downhill belts drop the sidewall too; new tasks (Claude Code)
- Downhill sloped belts now draw as plain belts: no far wall and no ribs, placed or as ghosts or
drag previews (`slopeWallAt`, ghost drawing in `render/world.js`, `previewBlock` now gets the
belt direction from `previews.js` and `preview-selection.js`). `slopeUphill` takes the
direction bits instead of a block.
- Test: `t_slope_belt_art.js` checks a downhill run draws no wall or ribs. Drag preview checked
headlessly with a counting canvas (downhill draws the band only); placed belts and ghosts
checked in screenshots. `python3 test/run_all.py --browser`: ALL PASSED. Rebuilt dist.
- Filed t59 (world longevity audit), t60 (renewable resources, discussion), t61 (sloped walls
sticky after removing a piece mid-flow: not reproduced headless, see its report), t62 (screw
conveyor horizontal/diagonal joins), t63 (vacuum falling grains).
## 2026-10-26: t42 feedback: uphill stall at a seam, no cleats downhill, slope pick (Claude Code)
- The owner's long uphill run stalled with a few grains, with a flashing seam between pieces 9
and 10. Cause: each block kept its own cleat phase, so a piece added to a running belt was out
of step; at the seam a rib started one or two cells behind another and pushed its pocket into
that rib's back, stalling the run for good. Every block of a run now takes the phase of the
run's lower-left block (`slopeCleatsTick`, memoised `slopeRunKey`).
- Owner decision: downhill belts have no cleats. They carry like a flat belt (`carryColumn`) and
draw only the sidewall; ribs, friction drag and pushes are uphill only (`slopeUphill`).
`pushRow`'s downhill levelling is gone. Downhill now 90 grains/s (was 38).
- Middle click on a sloped belt's band, where it is drawn into the empty block below, now picks
the belt (`pickUnderCursor` falls back to `slopeBeltAt`).
- Tests: `t_slope_belts.js` adds pieces added 3/7/11/13 ticks later (13 stalled before) and the
band pick (fails without the fix); downhill floor back to 60; cleat checks uphill only.
`t_slope_belt_art.js` draws ribs on uphill runs. `python3 test/run_all.py --browser`: ALL
PASSED. Screenshots of an up-and-down run in both looks opened. Rebuilt dist.
## 2026-10-26: t42 feedback: cleats that really hold and push their load (Claude Code)
- The owner saw ribs pass through heavy loads and the load climb as before. Rewrote the slope
physics in `src/sim/slope-conveyors.js`: no column carry on slopes; each step every rib moves
one face cell and pushes the rows of grains in its way (`pushRow`, front first, never between
two rib cells or into a machine); a contact-layer friction drag that gravity beats on 45°;
a boot-section scoop at the foot of uphill runs; a run stalls (all its moves undone) if a rib
can't push; grains under a newly placed rib are lifted out. Ribs stop short of built cells.
Slopes now step before flat belts (`machine-tick.js`), so a feeding belt finds the room.
- Drawing: ribs drawn by the smooth wall shader at the simulation phase (rubber bars with a
light cap); pixel ribs lighter with a cap; far wall a little lighter (`render/world.js`,
`slope-belt-shader.js`, `slope-belts.js`).
- Rates: uphill 78.7 (suite) to 86 grains/s (heavy probe), downhill 38, flat 90. Perf: 576
loaded slopes 1.03 ms/tick; 50 slopes in a generated world 2.19 vs 1.83 ms/tick without.
- Tests: `t_slope_belts.js` adds "no grain ever inside a rib under heavy feed", "a grain on the
bare face slides back to a rib, then rides up with it"; downhill rate floor lowered to 30.
`python3 test/run_all.py --browser`: ALL PASSED. Open: downhill rate; t58 (low-end joins).
## 2026-10-26: t42/t43 drawing and performance follow-up (Codex)
- Filled the far sidewall down to the belt, behind grains, with two-tone pixel corrugation
and a continuous smooth 45-degree band. Rubber ribs use the physics mask in all eight
phases. Fixed smooth wall seams; matched ghosts, cursor previews and menu icons.
Smooth screens retain tinted mesh, now between solid rails without the pixel overlay;
loaded-only shake and transport are unchanged.
- Empty cleats only wake nearby load; per-chunk flags bypass mask reads for distant powder.
Empty eight-block slope: 3 -> 0 moving areas after 120 ticks. Uphill sand/gravel/copper ore
71.3 grains/s, coal 71.2; exact sorting retained. Isolated 1f8a89d -> current, median five
generated falling-world samples: no slopes 2.006 -> 2.063 ms/tick; 50 slopes 1.952 -> 2.076.
Powder probe with cached zero flag: 45.02 -> 50.56 ms (2.14M calls); residual 12 percent
microbenchmark overhead, about 3 percent in the generated no-slope world. Existing loaded
576-block stress: 0.515 -> 1.064 ms/tick. slope-cost.js records the reproducible methodology;
raw results /tmp/t42-perf-{pre,after}-isolated.txt. Pre-t42 loaded slopes use old grip physics.
- Files: sim cells/slope-conveyors/world; render slope-belts/slope-belt-shader/world;
UI previews/build-menu; t_slope_belt_art.js, t_smooth.py, slope-cost.js; PROJECT_MAP,
DESIGN and t42/t43 reports. Reused enterFree for the ordinary entry rules.
- Final python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play,
16 smooth checks; art suite 57). Before/after suite outputs differ only by five new
checks (/tmp/t42-output.diff). Opened shots/t{42,43}-follow-{belt,screen}-{pixel,smooth}-z{4,8}.png;
rechecked smooth belt after fixing seams; opened t42-follow-ghosts-{pixel,smooth}-z8.png
with both updated menu icons visible. Added sleep, border/load wake, wall geometry,
grain colour and smooth grain-class checks. Rebuilt dist, regenerated map; both tasks Review.
Owner playtest and hardware GPU appearance remain unverified. No commit.
## 2026-10-26: t42: sidewall belts with moving cleats (Codex)
- Continued the uncommitted prototype. `sim/slope-conveyors.js` supplies moving barrier
lines (8 face cells apart, 6 grains tall), finite periodic pocket capacity and saved phase.
Removed slope grip; natural powder movement runs between steps and surplus rolls back.
Fixed the shared slope-to-screw mouth boundary so grains cannot fall under its casing.
Pixel/smooth drawing uses the actual mask, continuous dark far walls, matching previews/icons.
- Files: manifest; sim cells/conveyors/slope-conveyors/machine-tick/items/pieces/blocks/world/save;
render slope-belts/slope-belt-shader/world; UI actions/selection/selection-pickers/previews/
build-menu/notebook; slope test suites and play.py; slope-flow/slope-cost experiments,
slope-conveyors scene, PROJECT_MAP, DESIGN and t42/t43. Removed both requested scratch files.
- Reproducible `node tools/experiments/slope-flow.js`: sand, gravel, copper ore 71.3 and
coal 71.2 grains/s uphill, each 90.0 flat; 20-second window after 220 warm-up ticks.
Measured settled pile angles 33.7 degrees for all four. Final HEAD -> current stress measurement: 0.486 -> 1.038 ms/tick for 576 loaded
slope blocks (1024x512); no-slope powder microbenchmark, 2.14M calls: 45.07 -> 65.55 ms.
Median of five samples after warm-up; scripts use DG_HARNESS for the HEAD snapshot in
/tmp/t42-baseline. Barriers skip mask reads when none exist, but still have a CPU cost.
- Opened shots/t42-belt-{pixel,smooth}-z{4,8}.png. Headless checks pass, including pocket
overflow, collision masks, unstamped bare-face sliding, joins, screw mouths, water, bars,
save phase, all eight drawing phases and unchanged seed hashes 777/77/4242.
Full python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play
checks, 15 smooth checks). Rebuilt dist and regenerated PROJECT_MAP. Both tasks moved to
Review. The owner still decides whether the lower rate feels
right. Pocket movement is a discrete envelope, not a calculation of individual forces.
No commit.
## 2026-10-26: t43: inclined vibrating screens (Codex)
- Diagonal filters always run downhill through all placement/settings/undo/paste paths;
R leaves a picked slope unchanged. The picker shows fixed downhill travel rather than two
misleading direction buttons. Flat filters retain their original carry. Slope grains get
gravity plus one mesh-contact nudge every 4 ticks (15 steps/s); unselected bars use the
same cadence. No whole-column carry or between-step grip on slopes.
- Pixel and smooth mesh frames shake a little while loaded, with stable idle drawing.
Updated ghosts, icons, build text, notebook and the relevant sim/UI/render files listed
in t42 above. Added real browser drag/R/picker checks and art shake checks.
- Both shapes and both sorting modes deliver the complete 64 sand + 64 gravel mix exactly.
Generous-feed discharge is 60.0 grains/s down-left and 59.3 down-right; rest passes through.
Opened shots/t43-screen-{pixel,smooth}-z{4,8}.png. Headless and smooth suites pass; the
first browser run found a hover fixture above the mesh and a later timeout. Corrected the
coordinate and reset picked slope state between fixtures. All new browser checks pass.
A subsequent run caught an existing 200 ms silo HUD timing race; wait for the expected
HUD text before the same assertion. Final full browser run: ALL PASSED, 360 play
checks plus 15 smooth checks; all 26 headless suites pass. t43 moved to Review.
No uphill-save migration. No commit; owner playtest remains.
## 2026-10-26: t44: rename screw tubes to screw conveyors (Codex)
- Player text uses Screw conveyor; code uses screw/SCREW, including the piece id, part
constants, direction tables, transport helpers, shader variable, build icons and settings
access. Numeric ids and save/blueprint versions unchanged; no aliases or migrations added.
- Updated affected files across src/sim, src/render, src/ui, test and tools, plus README,
AGENTS and current DESIGN/TESTING/system docs. Regenerated PROJECT_MAP and built dist.
Renamed test/t_diag_tubes.js to test/t_diag_screws.js with git mv; no scene filename needed
renaming. Past log entries, docs/history and task title/Context preserved.
- All 26 suite outputs saved in /tmp/t44-before and /tmp/t44-after. Raw diff:
/tmp/t44-output.diff; name/timing-normalized diff: /tmp/t44-output-normalized.diff (empty).
No numeric output differences except ms timings; numeric literals unchanged in all 39
edited JavaScript/Python files. Only tube matches in scoped source/docs are the physical
casing description in pieces.js and casing/flight rendering comment in world.js.
- python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 352 play checks,
15 smooth checks). Focused menu, tooltip, hover, notebook, screw blueprint-code round-trip
and both starter blueprint-code checks pass in pixel and smooth; no page errors.
Opened shots/t44-{menu,hover,notebook}-{pixel,smooth}.png and the play diagonal screenshot.
Temporary UI fixture initially lacked SX/SY; corrected to match shot.py before these checks.
- Task t44 moved to Review. Owner playtest remains. No commit.
## 2026-10-26: t38: show silo contents at the cursor and in a wide window (Codex)
- `src/ui/info.js`: contents were confined to the bottom info plate (hidden below 720 px),
omitted from cursor labels, and cone gaps lacked mach ownership. Hover now resolves the
full 12x24 outline and shows contents beside the cursor; physical footprints stay unchanged.
- `src/render/machines.js`: shared framed three-grain window in both looks, fixed pixel grain
noise, visible near-empty row, lifted dark fuel colours, and stencils fitted beside the glass.
`tools/scenes/silo.js` adds snow and one grain of coal. All 31 accepted powders checked.
- `test/t_silo_contents.js`: 5 checks confirm snow intake, identity, water accounting, warm
storage and release. `test/play.py` checks hover/label at five outline points empty/filled,
narrow HUD, fill pixels at zooms 2-12 and stable drawing for all powders in both looks.
- Opened and inspected `shots/t38-silos-{pixel,smooth}-z{2,4,8}.png`. Updated t38 and DESIGN;
regenerated PROJECT_MAP. Final `python3 test/run_all.py --browser`: ALL PASSED (26 headless
suites, 352 play checks, 15 smooth checks). Task moved to Review; owner playtest remains.
## 2026-10-26: t41: distant resonance-drill aim is normal (Codex)
- `src/ui/preview-drill.js`, `previews.js`: keep tuned fan and light crosshair, add a solid far
edge in pulse colour, remove the drill-only red reach circle. `info.js` omits drill distance
warnings; other tools retain their warning, including beside silo contents.
- `src/sim/drill.js`: removed only the distance status; too-hard/cannot-be-drilled wording and
`toolDig` are unchanged. Notebook drill text remains accurate; gamepad code untouched.
- `test/t_drill.js`: new distance regression failed before fix, now passes; two former warning
expectations updated intentionally. `test/play.py`: held mouse aim makes no toast/warning,
hard granite still toasts, no red reach circle, gamepad aim stays within reach.
- Opened and inspected `shots/t41-drill-beyond-reach.png`. Focused browser checks pass.
Initial full browser run exposed test timing/paused-input fixture mistakes, corrected without
changing gameplay. Final `python3 test/run_all.py --browser`: ALL PASSED (26 headless
suites, 352 play checks, 15 smooth checks). Updated t41 and DESIGN; moved to Review. No commit.
## 2026-10-26: t39: seal touching square-block corners (Codex)
- `cells.js`: six inline checks reject diagonal moves between two PH_SOLID neighbours, with
no new random calls. `worldgen.js` saves/disables/restores CORNER_SEAL alongside LSTEP/LFILM
while settling. No material ids, save fields or piece geometry changed.
- Reproduced two further leaks: free flyers (`vacuum.js`) and diagonal tube mouths pulling sand
between external walls (`conveyors.js`); blocked those steps only. Horizontal liquid scans
already check each intervening cell; vacuum collection removes grains into the bag. Bars
resolve ordinary movement by axis. Screw lane transport remains explicit and unchanged.
- `test/t_corner_seal.js`: 23 checks, including both staircase orientations for sand/water/steam,
all four diagonal intakes, a flyer, world hashes/flag restoration, and an open pile that remains
57x17 with identical material layout and random state when sealing is disabled.
- All 25 headless suites pass. Before/after outputs: `t_slopes` square-funnel outflow 379 -> 339
(more sand retained on steps); `t_drill` ore outside the wall 56 -> 55 (solid corners hold ore);
`t_world` awake chunks after 900 gameplay ticks following reload 148 -> 149 (different settling
after corner blocking). Disabling the checks restores drill/world baseline output exactly.
Other existing output is identical except timings. Evidence: /tmp/t39-before, /tmp/t39-after.
- Full mat/temp/flags hashes match original code for seeds 777/77/4242 at 512x384 and 2048x1024,
plus 424242 at 1024x640 (/tmp/t39-world-hashes-{before,after}.txt).
- Factory benchmark at 1024x640: idle 0.69 -> 0.68 ms/tick; 1/4/8 modules
2.05/2.36/2.65 -> 2.13/2.44/2.70 ms/tick. Timing is indicative: settling changes, and this
fixture reported zero machine-moved grains, so it does not prove loaded transport performance.
- Rebuilt dist and regenerated PROJECT_MAP. Inspected `shots/t39-corners.png` in pixel look:
sand rests along the upper steps and pours through the central outlet.
- Final `python3 test/run_all.py --browser`: ALL PASSED (25 headless suites, 323 play checks,
15 smooth checks). Updated t39 subtasks/report for Review and recorded the design decision.
No commit made; owner playtest remains.
## 2026-10-25: t40 art pass: sloped belts and filters drawn as one belt (Claude Code, Codex)
- Sloped runs draw as the flat belt (rollers, studs) or filter (tinted mesh) band turned to 45°,
continuous, with elbows into flat belts, filters and tubes and rounded free ends, in both looks
(`src/render/slope-belts.js`, new `slope-belt-shader.js`, `world.js`, `smooth-shaders.js`);
ghosts and icons match. Drawing only.
- `test/t_slope_belt_art.js`; `run_all.py --browser` ALL PASSED.
## 2026-10-25: t40: sloped belts and filters (Claude Code, Codex)
- Block codes 19-22 (`BP_BELT_UP/DOWN`, `BP_FILTER_UP/DOWN`, `src/sim/pieces.js`): slope-shaped
belts and filters whose 45° face runs, laid by a diagonal Belt or Filter drag. `conveyorStep`,
`holdSlopeColumn` and a shared front-first order for belts and filters in `src/sim/conveyors.js`;
bars in `items.js`; saves, blueprints, undo, hover, previews; `src/render/slope-belts.js`.
- 90 grains/s flat, up and down for belts and filters; a sloped filter sorted 64/64 with no loss.
Seeds 777, 77 and 4242 generate identically.
- `test/t_slope_belts.js` (32 checks); `run_all.py --browser` ALL PASSED. Art pass follows.
## 2026-10-25: t35 follow-up: diagonal tube elbows (Claude Code, Codex)
- Diagonal tubes are as thick as straight ones, and an end block joining a straight tube, belt or
filter draws an elbow at its centre, so the pipes meet centred and edge to edge; free ends get a
collar; ghosts match (`src/render/world.js`, `smooth-shaders.js`, `src/ui/previews.js`,
`preview-selection.js`). Scene `tools/scenes/t35-joins.js`.
- `test/t_diag_tubes.js`; `run_all.py --browser` ALL PASSED.
## 2026-10-25: t35/t36 follow-ups: wall drags make slopes, diagonal tube art (Claude Code, Codex)
- Wall piece: a diagonal drag lays slopes (`pieceCode`/`turnSlope` in `src/ui/selection.js`), T flips
a drag or cycles square and four corners for one block; the Sloped wall piece is gone from the
menu, slope codes map to the wall piece (`src/sim/pieces.js`); old slope saves load
(`test/fixtures/slopes-v2.json.gz`).
- Diagonal tubes draw as one continuous 45° pipe in both looks, overhanging empty cells at joints
(`src/render/world.js`, `smooth-shaders.js`). Drag ghosts lost the long side arrow; each ghost
block keeps its own chevron (`src/ui/previews.js`). The owner found the diagonal still meets
straight tubes off-centre; an elbow fix follows.
- `test/t_slopes.js`, `t_diag_tubes.js`, `play.py`; `run_all.py --browser` ALL PASSED.
## 2026-10-25: t37 follow-up: grains on built things cast no shadows (Claude Code, Codex)
- The owner saw a flickering shadow under a grinder fed by a stream: grains resting on its
housing for a moment shaded. `looseForLight` (`src/render/light.js`) now treats a loose stack
that rests on any `builtForLight` cell, or ends in air or gas, as see-through. Stacks on natural
ground or deeper than 12 cells still shade.
- `test/t_light.js` (a grinder stream checked for 180 ticks in both lighting modes);
`run_all.py --browser` ALL PASSED.
## 2026-10-25: t37 follow-up: falling grains cast no shadows (Claude Code, Codex)
- The owner saw a single falling grain darken a column of sky. `onBeltForLight` became
`looseForLight` (`src/render/light.js`): a loose stack that ends in air or gas within 12 cells
(falling) is see-through, as are belt and filter loads; stacks resting on any solid or a tube
part still shade.
- `test/t_light.js`; `run_all.py --browser` ALL PASSED.
## 2026-10-25: t37 follow-up: belt loads cast no shadows (Claude Code, Codex)
- The owner saw grains riding a belt overhead still shading the ground. `onBeltForLight(i)` in
`src/render/light.js` walks down through up to 12 loose cells to a belt or filter; such cells
are skipped by the light roof and pass light like air. `builtForLight` is unchanged, so the t33
loose-grain shading still applies to belt loads. Heaps on rock, silos and wall floors still shade.
- `test/t_light.js` (56 checks); `run_all.py --browser` ALL PASSED.
## 2026-10-25: t35: diagonal screw tubes (Claude Code, Codex)
- Parts `P_TUR`..`P_TUL` (11-14), `TUBE_PARTS`, and an `IS_TUBE` table replacing every
`P_TU..P_TR` range check (`src/sim/material-tables.js` and its users); tube variants 4-7;
diagonal drags for tubes; grains in `src/sim/conveyors.js`/`cells.js`, bars in `items.js`;
both looks, previews, hover, copy/paste, undo, blueprints, saves.
- `test/t_diag_tubes.js` (85 checks): 120 grains/s straight, up and down over ten blocks; joins,
no spills, water passes, bars both ways, controls, persistence. Existing suites unchanged and
passing; seeds unchanged. `run_all.py --browser` ALL PASSED. Screenshots
`shots/t35-diagonal-*.png`, scene `tools/scenes/t35-diagonal.js`.
## 2026-10-25: t36: sloped walls and diagonal wall lines (Claude Code, Codex)
- `BP_SLOPE_BL`..`BP_SLOPE_TR` (15-18) in `src/sim/pieces.js` (`isSlope`, `isWall`, `slopeCell`,
`slopeCode`); `src/sim/blocks.js`: half cost and refund, `slopeSeal` corner cell and
`slopeSealOwner`, `diagonalDrag` and `blockLine(.., diagonal)`. Build menu, T key
(`turnSlope`), previews, copy/paste, undo, blueprints, hover, both looks.
- `test/t_slopes.js`: funnel 400/400 sand (square steps 379/400), water 400/400, no leaks in all
four orientations from both sides, bars slide, every wall material; play.py menu and diagonal
drag. Seeds unchanged. `run_all.py --browser` ALL PASSED. Screenshots
`shots/t36-funnel-*.png`, scene `tools/scenes/t36-funnel.js`.
## 2026-10-25: t33: loose grains and solid ground look different (Claude Code, Codex)
- Smooth look (`src/render/smooth-shaders.js`): natural ground gets a soft seam where it meets
loose grains as well as air; packed grains keep the pebble shading and are slightly lighter.
Pixel look and thermal lens (`drawWorld()` in `src/render/world.js`): loose grains a touch
lighter and speckled, solid cells touching loose grains darker.
- Smooth frame 1.08 -> 1.10 ms. `t_smooth.py` checks the seam and the lighter grains;
`tools/scenes/t33-ground.js` for screenshots (`shots/t33/`).
- play.py: 'F on an automatic ingot mold stays quiet' counted toasts on screen, and failed
now and then when an older toast expired or a new one arrived in the same 150 ms; it now
records the toasts the key press makes (none). `run_all.py --browser` ALL PASSED apart from
that check, which passes in the new form.
## 2026-10-25: t37: buildings cast no shadows (Claude Code, Codex)
- `computeLight()` (`src/render/light.js`): a per-view-column light roof that skips player-built
cells (`builtForLight`: unnatural solids, block, machine or part cells), and built cells pass
light like air. `drawWorld()` stops marking built cells as shading terrain for the smooth look.
The simulation's `roofY` is untouched (built roofs still condense steam).
- `test/t_light.js`; screenshots `shots/t37-before-*.png`, `shots/t37-after-*.png`.
`run_all.py --browser` ALL PASSED.
## 2026-10-25: t34: the nozzle blows no further than it sucks (Claude Code, Codex)
- `toolBlow()` (`src/sim/vacuum.js`) aims at the capped point from `nozzle()` when the cursor is
out of reach (thrown bars already did); `drawPreviews()` draws the blow brush there, in the blow
colour rather than the out-of-reach red.
- `test/t_nozzle.js`: aiming at 3x and 5x the reach lands sand within reach + 16 grains (brush and
settling, measured 15); inside reach it still lands around the cursor. play.py: the preview
sits at the capped point. `run_all.py --browser` ALL PASSED. Screenshot
`shots/t34-blow-out-of-reach.png`.
## 2026-10-25: t21: a loaded world starts around the player first (Claude Code, Codex)
- Owner: "lets speed up the first load speed. load in areas around the player first".
+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.
+10 -1
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",
@@ -36,15 +40,19 @@
"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/world.js",
"render/machines.js",
"render/overlays.js",
"render/smooth.js",
"render/slope-belt-shader.js",
"render/smooth-shaders.js",
"ui/audio.js",
"ui/state.js",
@@ -62,6 +70,7 @@
"ui/selection-pickers.js",
"ui/selection-panels.js",
"ui/info.js",
"ui/devconsole.js",
"ui/input.js",
"ui/actions.js",
"ui/gamepad.js",
+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;
}
+26 -2
View File
@@ -2,6 +2,17 @@
// ---------- 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 builtForLight(i, x, y) {
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
}
function looseForLight(i) {
for (let depth = 0; depth <= 12 && i < N; depth++, i += W) {
const m = mat[i], ph = PH[m];
if (builtForLight(i, i % W, (i / W) | 0)) return true;
if (ph !== PH_POWDER && ph !== PH_LIQUID) return m === EMPTY || ph === PH_GAS;
}
return false;
}
function computeLight() {
const res = SET.lighting === 'fast' ? 4 : 2;
const M = Math.ceil(28 / res);
@@ -13,6 +24,7 @@ function computeLight() {
R.lightC.width = lw; R.lightC.height = lh;
R.lightImg = R.lightX.createImageData(lw, lh);
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
R.lightRoof = new Int32Array(lw);
}
R.lx0 = lx0; R.ly0 = ly0;
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
@@ -20,15 +32,27 @@ function computeLight() {
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
// Only sampled view columns, starting at the simulation roof; nothing below the view matters.
const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof;
for (let lx = 0; lx < lw; lx++) {
const x = (lx0 + lx) * res;
if (x < 0 || x >= W) continue;
let y = roofY[x];
for (; y < roofEnd; y++) {
const i = y * W + x, ph = PH[mat[i]];
if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y) && !((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i))) break;
}
lightRoof[lx] = y;
}
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;
la[k] = builtForLight(i, wx, wy) || ((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i)) ? aAir : 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; }
if (wy < lightRoof[lx]) { 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;
+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;
+129 -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,131 @@ 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();
}
// 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;
+2 -2
View File
@@ -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]; }
+111
View File
@@ -0,0 +1,111 @@
// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
const SM_SLOPE_HELPERS = `
// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band.
vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) {
vec2 ray = b - a; float len = length(ray); ray /= len;
float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x));
vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1);
float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75;
return vec3(sd, origin + along, across + 2.0);
}
vec3 slopeCorner(vec2 p, vec2 a, float origin) {
vec2 d = p - a; float radius = length(d);
return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
}
vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
if ((joins & 64) != 0) p.y -= 0.45;
float sy = grade < 0.0 ? 4.0 : 0.0;
vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(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 -= vec2(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 += vec2(4.0, grade * 4.0);
vec2 axis = vec2(1.0, grade) * 0.70710678;
vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
if ((joins & 1) != 0) {
vec3 h = slopeStroke(p, vec2(-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) {
vec3 h = slopeStroke(p, b, vec2(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 SM_SLOPE_DRAW = `
if (nc == 31 || nc == 32) {
vec4 tag = cellAt(tTree, c);
float grade = nc == 31 ? -1.0 : 1.0;
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
float sy = grade < 0.0 ? 4.0 : 0.0;
float foot = (local.x + grade * (local.y - sy)) * 0.5;
float depth = (grade * local.x - local.y + sy) * 0.70710678;
int wallFlags = int(tag.a * 255.0 + 0.5);
float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0);
float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
float phase = float(wallFlags & 7);
float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
vec3 rubber = mix(vec3(0.20,0.22,0.25), vec3(0.25,0.27,0.31), wave);
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
// the ribs, where the simulation has them: rubber bars standing straight out of an uphill belt,
// one every ${CLEAT_SPACING} face cells, moved along by the belt's phase
float dirS = (wallFlags & 32) != 0 ? 1.0 : -1.0;
if (grade * dirS < 0.0) {
float rc = (w.x - local.x) + foot - (grade < 0.0 ? 1.0 : 0.0) - dirS * phase;
float off = (mod(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
float ribTop = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
float rd = length(vec2(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
vec3 rib = mix(vec3(0.50,0.52,0.56), vec3(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));
}
}
// Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(int(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) {
int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5);
int shape = (slopeClass - 23) / 2;
bool mesh = shape >= 2;
float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0;
vec4 tag = cellAt(tTree, c);
int joins = int(tag.a * 255.0 + 0.5);
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
vec3 shapePos = slopeShape(local, w - local, grade, joins);
float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z;
float time = !mesh && (joins & 16) != 0 ? uTime : 0.0;
vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth);
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
vec3 belt;
if (-sd < (mesh ? 1.1 : 0.8)) {
belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg);
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
vec2 cg = abs(fract(alongWorld) - 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 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5);
belt = mix(steel * 0.78, C * 0.8, hole);
} else {
vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0);
float rd = length(dv);
belt = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
belt = 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));
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));
}
`;
+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;
}
+64 -12
View File
@@ -50,8 +50,8 @@ float beltSD(vec2 lb, bool endL, bool endR) {
// 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);
int caps = int(cellAt(tTree, c).a * 255.0 + 0.5);
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
}
// 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.
@@ -61,6 +61,8 @@ float bandRidge(vec2 w, vec2 lb, float dir) {
}
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); }
${SM_SLOPE_HELPERS}
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
@@ -176,12 +178,16 @@ void main() {
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
// Keep the air outline; a softer, screen-width seam also separates rock from loose grains.
float dm = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
float dt = (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));
}
${SM_SLOPE_DRAW}
// ---- 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;
@@ -200,10 +206,13 @@ void main() {
}
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;
// Pebbles stop at the rock face so they cannot paint over its thin seam.
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
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))));
float sh = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
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);
@@ -240,20 +249,52 @@ void main() {
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
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
// screw conveyor: 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 screwSD = -1.0, capRing = 0.0;
vec4 screwTag = cellAt(tTree, c);
int screwJoins = int(screwTag.a * 255.0 + 0.5);
// Straight bend tags use six bits; background trees use 128/255.
bool shaped = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
if (shaped) {
vec2 d = vec2(1.0, nc == 19 || nc == 21 ? -1.0 : 1.0);
vec4 tag = screwTag;
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
int joins = screwJoins;
screwSD = 100.0;
for (int end = 0; end < 2; end++) {
int join = (joins >> (end * 3)) & 7;
float side = end == 0 ? -1.0 : 1.0;
bool diagonal = nc >= 19 ? join == 0 || join >= 5 : join != 0;
float grade = nc >= 19 ? d.y : join == 1 ? -1.0 : 1.0;
vec2 ray = diagonal ? (vert ? vec2(side * grade, side) : vec2(side, side * grade)) * 0.70710678 :
nc < 19 ? (vert ? vec2(0, side) : vec2(side, 0)) :
join == 1 ? vec2(0, -1) : join == 2 ? vec2(1, 0) : join == 3 ? vec2(0, 1) : vec2(-1, 0);
float t = dot(local, ray), cross2 = dot(local, vec2(-ray.y, ray.x));
float radius = length(vec2(cross2, min(0.0, t))), cap = t - (diagonal ? 2.82842712 : 2.0);
// A connected mouth meets another opaque pipe; fading its edge leaves a seam.
float sd = max(radius - 2.0, (joins & (64 << end)) != 0 ? cap : -10.0);
if (sd < screwSD) {
screwSD = sd;
a = 2.0 + (side * cross2 < 0.0 ? -radius : radius);
along = (nc >= 19 ? dot(w - local, d) * 0.70710678 : vert ? w.y - local.y : w.x - local.x) + side * t;
capRing = (joins & (64 << end)) != 0 ? smoothstep(-0.55, -0.15, cap) : 0.0;
}
}
if (nc >= 19) sp2 = nc == 19 || nc == 20 ? 1.0 : -1.0;
}
float wall = 0.62;
vec3 tc;
if (a < wall || a > 4.0 - wall) {
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
float cyl = sin(u2 * 1.5708);
float cyl = sin(clamp(u2, 0.0, 1.0) * 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;
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 {
bool full = own.r + own.g + own.b > 0.0;
@@ -264,9 +305,13 @@ void main() {
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;
if (shaped) {
bool full = 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) {
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
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));
@@ -274,7 +319,14 @@ void main() {
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;
if (nc >= 15) {
vec2 sl = lb;
if (nc == 16 || nc == 18) sl.x = 4.0 - sl.x;
if (nc >= 17) sl.y = 4.0 - sl.y;
float d = (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) {
+120 -24
View File
@@ -2,8 +2,6 @@
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;
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
@@ -22,6 +20,14 @@ 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; }
}
}
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 useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
const lighting = SET.lighting !== 'off' && !R.lens;
@@ -59,12 +65,24 @@ function drawWorld() {
}
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 && !builtForLight(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);
@@ -93,6 +111,7 @@ function drawWorld() {
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) {
@@ -132,24 +151,38 @@ function drawWorld() {
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) {
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
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 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); } }
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 (p <= P_TR) {
if (IS_SCREW[p]) {
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
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 ((axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
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);
@@ -168,7 +201,7 @@ function drawWorld() {
} 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 ((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
@@ -186,6 +219,20 @@ function drawWorld() {
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];
@@ -201,25 +248,50 @@ function drawWorld() {
// 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) {
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] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
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 (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;
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 || cl === 4 || cl === 6 ? 0xff : 0);
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;
@@ -248,7 +320,7 @@ function drawWorld() {
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)
// 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;
@@ -266,12 +338,36 @@ function drawWorld() {
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++) {
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, c = px[k];
const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
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);
+6
View File
@@ -130,6 +130,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>
+107 -28
View File
@@ -2,42 +2,89 @@
// ---------- 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;
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 +92,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 +108,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,28 +134,35 @@ 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);
@@ -112,15 +170,36 @@ function removeBlock(bx, by) {
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
}
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 +209,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 +224,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;
+35 -17
View File
@@ -36,10 +36,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 +72,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); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
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 +110,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,9 +124,9 @@ 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);
@@ -153,9 +171,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); }
@@ -187,7 +205,7 @@ function simMove() {
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);
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), barriers = cleatChunks[rowBase + cx];
// 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) {
@@ -198,8 +216,8 @@ function simMove() {
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);
if (IS_SCREW[p] && ph === PH_POWDER) continue; // a screw conveyor moves its own grains
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);
}
}
}
+108 -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,48 @@ 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;
if (!isFilter(blockP[b])) return false;
const rec = filterRec(cfg);
filterCfg.set(b, rec);
const bx = b % BW, by = (b / BW) | 0;
@@ -79,11 +124,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 +138,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 +151,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];
}
+20 -4
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);
@@ -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);
}
+3 -2
View File
@@ -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;
+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;
}
+27 -34
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);
}
}
// Belts step every other tick; sloped belts carry only their finite pockets.
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
// 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);
}
}
}
+15 -6
View File
@@ -10,7 +10,7 @@ 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,9 +44,13 @@ 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; }
+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.' });
+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();
+14 -1
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,7 +47,7 @@ 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;
+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
+6 -3
View File
@@ -17,7 +17,7 @@ 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 = [];
@@ -39,23 +39,26 @@ function discover(m, x, y, from, how, T) {
}
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;
cleatMask = new Uint8Array(N); cleatCells = [];
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();
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new 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);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
cleatChunks = new Uint8Array(NCH);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
roofDirty = new Uint8Array(CW).fill(1);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
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;
+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; }
+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; }
+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();
};
+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)); }
+28 -15
View File
@@ -81,17 +81,23 @@ function hoverInfo(ax, ay) {
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 +115,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,7 +131,13 @@ 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]));
@@ -134,9 +147,9 @@ function hoverInfo(ax, ay) {
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
}
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 +158,12 @@ 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 };
return { t1, t2, t3, warn, contents, 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));
@@ -166,8 +179,8 @@ function drawCursorLabel(px, py, r) {
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 = info.t1 + (info.contents && info.warn ? ` ${info.contents}` : '');
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
ctx.save();
ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width;
@@ -181,7 +194,7 @@ function drawCursorLabel(px, py, r) {
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); }
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();
}
+4 -3
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,7 +43,7 @@ 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;
}
});
+10 -5
View File
@@ -22,11 +22,11 @@ function frame(now) {
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;
@@ -38,7 +38,8 @@ function frame(now) {
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++;
if (devPaused) devSteps--; else acc -= step;
steps++;
GAME.playTime += step;
}
if (steps >= SET.maxSteps) acc = 0;
@@ -53,6 +54,7 @@ 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();
@@ -69,7 +71,10 @@ function frame(now) {
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();
drawPingRecharge();
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
drawFloaters();
drawRain();
+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>
`;
}
+5 -2
View File
@@ -74,7 +74,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 +84,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 +98,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;
}
+3 -2
View File
@@ -11,6 +11,7 @@ function showLoading(on, p, label, title) {
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
}
function enterGame() {
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;
@@ -80,7 +81,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 +136,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) {
BIN
View File
Binary file not shown.
+168 -4
View File
@@ -434,7 +434,23 @@ with sync_playwright() as p:
}
})()"""))
# 3. right button blows the selection; exhausted materials stay selected
check('out-of-reach blow preview sits at the capped nozzle target', ev("""(() => {
const aim = GAME.aim, consumed = GAME.rmbConsumed, arc = R.ctx.arc;
const arcs = [];
try {
GAME.aim = { x: bot.x + bot.w / 2 + 3 * REACH, y: bot.y + bot.h / 2 };
GAME.rmb = true; GAME.rmbConsumed = false;
R.ctx.arc = function (x, y, r, ...rest) { arcs.push({ x, y, r }); return arc.call(this, x, y, r, ...rest); };
drawPreviews();
const nz = nozzle(Math.floor(GAME.aim.x) + 0.5, Math.floor(GAME.aim.y) + 0.5);
const target = worldToScreenPx(nz.ox + nz.dx * nz.dist, nz.oy + nz.dy * nz.dist);
return arcs.some(p => Math.abs(p.x - target.x) < 0.01 && Math.abs(p.y - target.y) < 0.01 && Math.abs(p.r - (GAME.brush[1] + 0.5) * R.zp) < 0.01);
} finally {
R.ctx.arc = arc; GAME.aim = aim; GAME.rmb = false; GAME.rmbConsumed = consumed;
}
})()"""))
# 3. right button blows the selection; exhausted materials are deselected
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)')
check('Q/E chooses coal to blow', ok, ev('[...GAME.blow]'))
@@ -647,6 +663,12 @@ with sync_playwright() as p:
drag(lambda: blk(bbx - 10, bby - 8), lambda: blk(bbx - 8, bby - 6), 4, True)
nb2 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
check('Shift-drag fills a 3×3 rectangle', nb2 - nb1 == 9, nb2 - nb1)
ev("GAME.pieceIdx = PIECE_BY_ID.belt.index; for (const [m, n] of PIECE_BY_ID.belt.cost) bag[m] += n * 10; hudDirty = true")
drag(lambda: blk(bbx + 3, bby - 10), lambda: blk(bbx + 5, bby - 8), 4, True)
belts = ev('[blockP.reduce((a, v) => a + isBelt(v), 0), blockP.reduce((a, v) => a + isSlopeBelt(v), 0), ghostBlocks.size]')
check('Shift-drag with a belt still lays a sloped line, not a rectangle', belts == [3, 3, 0], f'{belts}, {ev("GAME.status")}')
ev('doUndo()'); settle(30)
ev("GAME.pieceIdx = PIECE_BY_ID.wall.index; hudDirty = true")
r0 = ev('bag[RSTONE]')
pg.mouse.move(*blk(bbx + 3, bby - 4)); pg.mouse.down(button='right'); settle(120); pg.mouse.up(button='right'); settle(80)
check('right click takes a block back with a refund', ev('bag[RSTONE]') - r0 == 16 and ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)') == nb2 - 1, ev('bag[RSTONE]') - r0)
@@ -660,6 +682,66 @@ with sync_playwright() as p:
check('bot can sit inside a built wall', not ev(f'botOverlapSolid({wx_ - 2}, {wy_ - 2})'))
check('bot cannot sit inside natural rock', ev(f'botOverlapSolid({bx}, {gy + 12})'))
# Slopes use the same mouse drag path as walls, with T for their corner.
pg.click('[data-piece="wall"]'); settle(60)
check('Wall replaces the Sloped wall menu piece', ev("piece().id === 'wall' && !BUILD_ORDER.includes('slope') && !document.querySelector('[data-piece=\"slope\"]')"))
ev('GAME.wallMat = RSTONE; GAME.pieceDir.wall = 0')
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
check('diagonal slope drag places four oriented blocks', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)'))
pg.keyboard.press('Control+z'); settle(60)
pg.keyboard.press('t'); settle(60)
check('T cycles the single slope corner', ev('GAME.pieceDir.wall === 1 && pieceCode(piece()) === BP_SLOPE_BL'))
for shape in [2, 3, 4, 0]:
pg.keyboard.press('t'); settle(40)
check(f'T cycles Wall shape {shape}', ev(f'GAME.pieceDir.wall === {shape} && pieceCode(piece()) === ' + ('BP_WALL' if shape == 0 else f'BP_SLOPE_BL+{shape}-1')))
pg.mouse.move(*blk(bbx - 5, bby - 12)); settle(60); pg.mouse.down()
pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(60)
pg.keyboard.press('t'); settle(60); pg.mouse.up(); settle(60)
check('T flips a diagonal slope above its face', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_TR)'))
pg.keyboard.press('Control+z'); settle(60)
pg.click('[data-piece="wall"]'); settle(60)
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
check('Wall diagonal defaults back to solid below', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)'))
pg.keyboard.press('Control+z'); settle(60)
# Cleated belts and downhill screens use real drags, previews, picking and R.
for piece_id in ['belt', 'filter']:
check('select ' + piece_id + ' for a diagonal drag', select('3', "piece().id === '" + piece_id + "'"))
ev('money=1e6')
drag(lambda: blk(bbx - 5, bby - 9), lambda: blk(bbx - 2, bby - 12))
code = 'BP_BELT_UP' if piece_id == 'belt' else 'BP_FILTER_UP'
check(piece_id + ' uphill drag lays the expected shape', ev(f'Array.from({{length:4}},(_,k)=>blockP[({bby}-9-k)*BW+{bbx}-5+k]).every(c=>c==={code})'))
if piece_id == 'filter':
check('uphill filter drag runs downhill left', ev(f'Array.from({{length:4}},(_,k)=>blockV[({bby}-9-k)*BW+{bbx}-5+k]).every(v=>v===0)'))
pg.mouse.move(*blk(bbx - 3, bby - 11)); settle(60)
check('screen hover says down-left', ev(f"hoverInfo(({bbx}-3)*4+1,({bby}-11)*4+2).t1.includes('down-left')"))
pg.mouse.click(*blk(bbx - 3, bby - 11), button='middle'); settle(60)
pg.keyboard.press('r'); settle(60)
check('R leaves a picked screen downhill', ev('pieceCode(piece())===BP_FILTER_UP && pieceV(piece())===0'))
pg.keyboard.press('f'); settle(100)
check('screen picker offers only downhill travel', pg.locator('#dlg-body [data-dir]').count() == 0 and 'down-left' in pg.locator('#dlg-body').inner_text())
pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
pg.keyboard.press('Control+z'); settle(60)
ev('GAME.pieceShape = {}; GAME.pieceDir.filter = 1; GAME.editRun = null')
# Diagonal screws use real mouse placement, preview arrows, R and picking.
check('select screw conveyor', select('3', "piece().id === 'screw'"))
ev('GAME.pieceDir.screw = 0')
directions = []
for _ in range(8):
directions.append(ev('GAME.pieceDir.screw'))
pg.keyboard.press('r'); settle(40)
check('R cycles eight screw directions', directions == list(range(8)) and ev('GAME.pieceDir.screw') == 0)
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
check('diagonal screw drag places four down-right blocks', ev(f'Array.from({{length:4}}, (_,k)=>[blockP[({bby}-12+k)*BW+{bbx}-5+k],blockV[({bby}-12+k)*BW+{bbx}-5+k]]).every(([c,v])=>c===BP_SCREW && v===5)'))
pg.mouse.move(*blk(bbx - 4, bby - 11)); settle(60)
check('diagonal hover names its direction', ev(f"hoverInfo(({bbx}-4)*4+1,({bby}-11)*4+1).t1.includes('down-right')"))
pg.mouse.click(*blk(bbx - 4, bby - 11), button='middle'); settle(60)
check('picking a diagonal screw keeps its direction', ev("piece().id === 'screw' && GAME.pieceDir.screw === 5"))
pg.screenshot(path=str(out / '04-diagonal-screws.png'))
pg.keyboard.press('Control+z'); settle(60)
# 6. ingot mold: click to place, pour, bars drop below, vacuum them
ok = select('3', "piece().id === 'ingotmold'")
check('E cycles to the ingot mold', ok)
@@ -679,9 +761,18 @@ with sync_playwright() as p:
pg.screenshot(path=str(out / '05-mold-ready.png'))
check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])'))
pg.keyboard.press('2'); settle(60)
ev("GAME.status = ''; GAME.statusT = 0"); n_toasts = ev("$('toasts').children.length")
pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150); pg.keyboard.press('f'); settle(150)
check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts)
ev("GAME.status = ''; GAME.statusT = 0")
# Keep the camera still while aiming at a mold only four grains tall.
lead = ev('SET.cameraLead')
ev('SET.cameraLead = false; snapCamera()'); settle(100)
pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 8, ev('molds[0].y') + 2)); settle(150)
check('mouse aims at the automatic ingot mold', ev('moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)) === molds[0]'))
# count toasts made by the key press itself: counting the ones on screen also caught an older toast expiring
ev("window._toastsMade = []; window._toastFn = toast; toast = function (t, o) { _toastsMade.push(t); return _toastFn(t, o); }; 0")
pg.keyboard.press('f'); settle(150)
made = ev("(() => { toast = _toastFn; return _toastsMade; })()")
check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and made == [], made)
ev(f'SET.cameraLead = {json.dumps(lead)}')
ev("closeModal('m-dialog')")
check('F with vacuum still uses what is in reach', ev("""(() => {
$('toasts').innerHTML = '';
@@ -1070,6 +1161,7 @@ with sync_playwright() as p:
settle(1500)
ev("__pad.buttons[7].pressed = false; __pad.buttons[7].value = 0;")
settle(200)
check('gamepad drill aim stays within its reach', ev('Math.hypot(GAME.padAim.x, GAME.padAim.y) <= drillReach(GAME.drillW)'))
check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}")
ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100)
check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1)
@@ -1121,6 +1213,78 @@ with sync_playwright() as p:
fp.goto(url)
fp.evaluate('startNewWorld(4242, 512, 384)')
fp.wait_for_function('GAME.running')
# Silo feedback uses real pointer positions; drawing checks sample an isolated canvas.
fp.evaluate("""() => {
GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = true;
SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender();
for (let y = 80; y < 140; y++) for (let x = 80; x < 160; x++) { setCell(y * W + x, EMPTY, 20, 0); bgm[y * W + x] = 0; }
window.testSilo = addMachine('silo', 28, 25, 0);
bot.x = 104; bot.y = 106; snapCamera(); $('cards').replaceChildren();
}""")
for amount in [0, 2000]:
fp.evaluate(f'testSilo.sm = SAND; testSilo.sn = {amount}')
for name, dx, dy in [('drum', 5, 8), ('hatch with snow', 5, 0), ('cone gap', 1, 22), ('leg', 0, 23), ('gate', 5, 23)]:
fp.evaluate('setCell((testSilo.y) * W + testSilo.x + 5, SNOW, -10, 0)')
point = fp.evaluate(f'(() => {{ const p = worldToScreenPx(testSilo.x + {dx} + 0.5, testSilo.y + {dy} + 0.5); return [p.x * innerWidth / R.canvas.width, p.y * innerWidth / R.canvas.width]; }})()')
fp.mouse.move(*point); fp.wait_for_timeout(200)
want = 'sand 2,000 / 4,000' if amount else 'empty'
fp.wait_for_function(f"$('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')")
check(f'silo {name} hover shows {want}', fp.evaluate(f"hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t1 === 'Silo' && hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).contents === '{want}' && $('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')"))
check(f'silo {name} cursor label draws {want} in normal colour', fp.evaluate("""() => {
const calls = [], ctx = R.ctx, text = ctx.fillText;
ctx.fillText = function(t) { calls.push([t, this.fillStyle]); };
try { drawCursorLabel(500, 300, 1); } finally { ctx.fillText = text; }
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
return calls.some(([t, c]) => t === info.contents && c === '#efe6d8');
}"""))
check('snow in silo is named snow', fp.evaluate("testSilo.sm = SNOW; hoverInfo(testSilo.x + 5, testSilo.y + 8).contents === 'snow 2,000 / 4,000'"))
for look in ['pixel', 'smooth']:
check(f'{look} silo fill pixels show empty, near empty, half and full at zooms 2 through 12', fp.evaluate("""pixel => {
const canvas = document.createElement('canvas'); canvas.width = 160; canvas.height = 300;
const ctx = canvas.getContext('2d'), mm = { x: 112, y: 100, sm: SAND };
const at = (x, y, z) => Array.from(ctx.getImageData(Math.floor(x * z), Math.floor(y * z), 1, 1).data).slice(0, 3).join(',');
for (let z = 2; z <= 12; z++) for (const [n, h] of [[0, 0], [1, 1], [2000, 8], [4000, 16]]) {
mm.sn = n; drawSiloWindow(ctx, mm, 0, 0, z, pixel);
for (let y = 3; y < 19; y++) if ((at(9.5, y + 0.5, z) !== '20,24,28') !== (y >= 19 - h)) return false;
}
// Every powder siloTick accepts must stand apart from dark glass, including coal.
for (let m = 1; m < NMAT; m++) if (PH[m] === PH_POWDER) {
mm.sm = m; mm.sn = 2000; drawSiloWindow(ctx, mm, 0, 0, 2, pixel);
const filled = at(9.5, 18.5, 2).split(',').map(Number);
if (Math.max(...filled.map((c, k) => Math.abs(c - [20, 24, 28][k]))) < 25) return false;
const image = ctx.getImageData(0, 0, 24, 48).data.slice().join(',');
drawSiloWindow(ctx, mm, 0, 0, 2, pixel);
if (ctx.getImageData(0, 0, 24, 48).data.join(',') !== image) return false;
}
return true;
}""", look == 'pixel'))
fp.set_viewport_size({'width': 700, 'height': 800})
check('silo contents remain available when the narrow HUD hides the info plate', fp.evaluate("getComputedStyle($('info')).display === 'none' && hoverInfo(testSilo.x + 5, testSilo.y + 8).contents === 'snow 2,000 / 4,000'"))
fp.set_viewport_size({'width': 1280, 'height': 800})
check('other tools still warn beyond reach', fp.evaluate("GAME.tool = 1; hoverInfo(testSilo.x + 80, testSilo.y).warn === 'out of reach'"))
# Holding the drill with a distant pointer is ordinary aim, but hard rock still warns.
fp.evaluate('bot.y = 130; snapCamera()')
fp.keyboard.press('1')
fp.mouse.move(1000, 400); fp.wait_for_timeout(100)
fp.evaluate("$('toasts').replaceChildren(); lastPulseTick = -99; player.bitTier = 0; GAME.drillTune = 0")
fp.evaluate('GAME.paused = false')
fp.mouse.down()
fp.evaluate('GAME.paused = true; for (let k = 0; k < 24; k++) { tick++; toolTick(); }')
check('held drill beyond reach makes no toast or distance label', fp.evaluate("GAME.lmb && !inDrillReach(GAME.aim.x, GAME.aim.y, GAME.drillW) && !$('toasts').children.length && !hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).warn && !hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t2.includes('out of reach')"))
check('distant drill fan and crosshair stay normal with no red reach circle', fp.evaluate("""() => {
const ctx = R.ctx, stroke = ctx.stroke, circle = drawReachCircle, colours = []; let circles = 0;
ctx.stroke = function() { colours.push(this.strokeStyle); };
drawReachCircle = () => circles++;
try { drawPreviews(); } finally { ctx.stroke = stroke; drawReachCircle = circle; }
return circles === 0 && !colours.some(c => c.includes('239, 106, 85'));
}"""))
fp.evaluate("""() => {
const o = drillOrigin(), dx = GAME.aim.x + 0.5 - o.x, dy = GAME.aim.y + 0.5 - o.y, d = Math.hypot(dx, dy);
for (let k = 6; k < 10; k++) setCell(Math.floor(o.y + dy / d * k) * W + Math.floor(o.x + dx / d * k), GRANITE, 20, F_NAT);
tick += 20; toolTick();
}""")
check('held drill still toasts too hard with a distant pointer', fp.evaluate("$('toasts').textContent.includes('Granite is too hard; needs an iron bit.')"))
fp.mouse.up()
fp.evaluate("""() => {
GAME.paused = true; SET.fog = true; SET.look = 'pixel'; SET.zoom = 6; SET.lighting = 'off';
SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender();
+2 -2
View File
@@ -3,7 +3,7 @@
python3 test/run_all.py every headless suite (test/t_*.js), in parallel
python3 test/run_all.py pipes stone only suites whose name contains one of these words
python3 test/run_all.py --browser also the browser tests (play.py, then t_smooth.py),
python3 test/run_all.py --browser also browser tests (play.py, t_smooth.py, t_dev_console.py),
after rebuilding dist/; they run one at a time
python3 test/run_all.py --browser-only
@@ -42,7 +42,7 @@ def main():
if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py']: results.append(run_py(name))
for name in ['play.py', 't_smooth.py', 't_dev_console.py']: results.append(run_py(name))
bad = 0
for name, code, out, secs in results:
fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []']
+61
View File
@@ -96,4 +96,65 @@ const before = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: mol
run('globalThis.__sv = serializeWorld(); deserializeWorld(__sv.meta, __sv.buffers);');
const after = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})');
check('save/load keeps bag, bars, molds, machines and blocks', before === after, before + ' vs ' + after);
// 12. vacuum catches grains in flight, before loose cells, with the same limits.
flatWorld(run, 240, 160, 140);
run('bot.x = 98; bot.y = 58; player.flask = false; EV.length = 0;');
run('flyers.push({x: 102.9, y: 62.9, m: SAND});');
const airborne = run('toolVacuum(100, 60, 3, () => true)');
check('vacuum takes airborne sand using its current cell', airborne.got === 1 && airborne.m === run('SAND') && run('bag[SAND] === 1 && flyers.length === 0'));
check('airborne pickup emits a suck at the grain cell', run('EV.some(e => e.t === "suck" && e.x === 102 && e.y === 62 && e.m === SAND)'));
run('flyers.push({x: 103, y: 63, m: SAND}, {x: 180, y: 60, m: SAND});');
check('vacuum leaves flyers outside the circular brush', run('toolVacuum(100, 60, 3, () => true).got === 0 && flyers.length === 2'));
check('vacuum leaves flyers out of reach', run('toolVacuum(180, 60, 3, () => true).got === 0 && flyers.length === 2 && vacuumTargetAt(180, 60, 3) === 0'));
run('flyers = [{x: 100, y: 60, m: WATER}];');
check('liquid flyers need the flask for pickup and targeting', run('toolVacuum(100, 60, 3, () => true).got === 0 && vacuumTargetAt(100, 60, 3) === 0 && flyers.length === 1'));
run('player.flask = true;');
check('flask allows liquid flyer targeting', run('vacuumTargetAt(100, 60, 3) === WATER'));
check('flask allows liquid flyer pickup', run('toolVacuum(100, 60, 3, () => true).got === 1 && bag[WATER] === 1 && flyers.length === 0'));
run('flyers = [{x: 100, y: 60, m: WATER, rain: true}];');
const moisture = run('weather.moist.reduce((a, b) => a + b, 0)');
check('rain is neither targeted nor collected', run('vacuumTargetAt(100, 60, 3) === 0 && toolVacuum(100, 60, 3, () => true).got === 0 && flyers.length === 1 && bag[WATER] === 1') && moisture === run('weather.moist.reduce((a, b) => a + b, 0)'));
run('flyers = [{x: 100, y: 60, m: COAL}, {x: 101, y: 60, m: SAND, bag: true}];');
check('pickup filter also applies to bag-blown flyers', run('toolVacuum(100, 60, 3, m => m === SAND).got === 1 && flyers.length === 1 && flyers[0].m === COAL && bag[COAL] === 0'));
check('targeting finds a material only in flight', run('vacuumTargetAt(100, 60, 3) === COAL'));
run('setCell(60*W+102, SAND, 20, 0);');
check('nearer flyer wins over a loose cell', run('vacuumTargetAt(100, 60, 3) === COAL'));
run('flyers[0].x = 103;');
check('nearer loose cell wins over a flyer', run('vacuumTargetAt(100, 60, 3) === SAND'));
run('spawnBar(BARCU, 100, 60, 0, 0);');
check('bars still take targeting priority', run('vacuumTargetAt(100, 60, 3) === BARCU'));
run('ents = []; bag.fill(0); bag[SAND] = bagTotal = bagCap();');
const fullPickup = run('toolVacuum(100, 60, 3, () => true)');
check('full bag leaves both flyers and cells untouched', fullPickup.full && fullPickup.got === 0 && run('flyers.length === 1 && mat[60*W+102] === SAND && bagTotal === bagCap()'));
run('bagTake(SAND, 1);');
const lastRoom = run('toolVacuum(100, 60, 3, () => true)');
check('last bag space goes to the flyer before the cell', lastRoom.full && lastRoom.got === 1 && lastRoom.m === run('COAL') && run('flyers.length === 0 && mat[60*W+102] === SAND && bagTotal === bagCap()'));
run('bag.fill(0); bagTotal = 0; EV.length = 0; for (let k = 0; k < 50; k++) flyers.push({x: 100, y: 60, m: COAL});');
check('flyers share the 48-grain budget and leave cells for later', run('toolVacuum(100, 60, 3, () => true).got === 48 && flyers.length === 2 && bag[COAL] === 48 && mat[60*W+102] === SAND'));
check('flyers emit suck on every other collected grain', run('EV.filter(e => e.t === "suck").length === 24'));
check('remaining budget collects both flyers and cells', run('toolVacuum(100, 60, 3, () => true).got === 3 && flyers.length === 0 && bag[COAL] === 50 && bag[SAND] === 1'));
// A real screw launches sand; hold the brush below its airborne arc, away from its mouth.
flatWorld(run, 240, 160, 140);
run('RS = 7; nextPowerBuild = 0; bot.x = 106; bot.y = 70; placeBlock(BP_SCREW, 1, 24, 15, true);');
rect(run, 96, 60, 99, 63, run('SAND'));
const screwCatch = run(`(() => {
let airborne = 0, caught = 0;
for (let k = 0; k < 120; k++) {
simTick();
const n = flyers.length;
const result = toolVacuum(106, 70, 3, m => m === SAND);
airborne += n - flyers.length; caught += result.got;
}
return {airborne, caught, bag: bag[SAND]};
})()`);
check('vacuum held under a screw arc catches its launched sand', screwCatch.airborne === 16 && screwCatch.caught === 16 && screwCatch.bag === 16, JSON.stringify(screwCatch));
// Rock in the bag: solids go in as what drilling them gives, and older saves holding solids load
// that way too, so the bag never holds something its list can't show.
flatWorld(run, 120, 80, 60);
check('putting rock in the bag stores its rubble', run('(() => { bag.fill(0); bagTotal = 0; bagAdd(BASALT, 10, true); bagAdd(CLAY, 5, true); return bag[BASALT] === 0 && bag[RBAS] === 10 && bag[CLAY] === 0 && bag[LCLAY] === 5 && bagTotal === 15; })()'));
check('a save holding solid rock loads it as rubble with the same total', run(`(() => {
bag.fill(0); bagTotal = 0; const s = serializeWorld(); s.meta.bag[BASALT] = 1000; s.meta.bag[CLAY] = 200; s.meta.bag[MSLAG] = 7;
deserializeWorld(s.meta, s.buffers);
return bag[BASALT] === 0 && bag[RBAS] === 1000 && bag[CLAY] === 0 && bag[LCLAY] === 200 && bag[MSLAG] === 7 && bagTotal === 1207;
})()`));
done();
+25
View File
@@ -79,4 +79,29 @@ run('for (let y = 120; y < 122; y++) for (let x = 60; x < 62; x++) setCell(y * W
const n = run('clearHardSpecks(true)');
check('a 4-grain speck of iron ore in stone becomes stone; a 100-grain vein stays', n === 1 && count(run, run('FEV')) === 100 && run('mat[120 * W + 60]') === run('STONE'), `${n} specks, ${count(run, run('FEV'))} vein`);
// t64: building reaches about as far as the screen shows, and a replace placed out of range
// becomes a ghost that replaces the old piece when the bot comes in range.
fresh();
check('with no view the build range is a REACH box around the bot', run('buildBox = null; inBuildRange(bot.x + bot.w / 2 + REACH, bot.y) && !inBuildRange(bot.x + bot.w / 2 + REACH + 2, bot.y)'));
check('the build range follows the view box', run('(() => { setBuildRange(200, 100, 300, 160); const r = inBuildRange(340, 100) && !inBuildRange(352, 100) && inBuildRange(200, 180) && !inBuildRange(200, 190); buildBox = null; return r; })()'));
check('a tiny view still reaches REACH and a huge one stops at BUILD_MAX', run('(() => { setBuildRange(500, 500, 20, 20000); const r = buildBox.hw === REACH && buildBox.hh === BUILD_MAX; buildBox = null; return r; })()'));
fresh();
const swap = run(`(() => {
placeBlock(BP_BELT, 1, 50, 20, true); const b = 20 * BW + 50, m0 = money;
ghostBlocks.set(b, [BP_SCREW, 1, undefined, 1]);
bot.x = 20; bot.y = 20; for (let k = 0; k < 30; k++) ghostTick();
const before = blockP[b];
bot.x = 50 * BS - 4; bot.y = 20 * BS - 10; for (let k = 0; k < 30; k++) ghostTick();
return { before, after: blockP[b], v: blockV[b], spent: m0 - money, ghosts: ghostBlocks.size };
})()`);
check('an out-of-range replace ghost leaves the old piece until the bot comes near', swap.before === run('BP_BELT'), JSON.stringify(swap));
check('then replaces it: the new piece is built and the ghost is gone', swap.after === run('BP_SCREW') && swap.v === 1 && swap.ghosts === 0, JSON.stringify(swap));
check('the replace charges the new piece and refunds the old one', swap.spent === 25 - 12, JSON.stringify(swap));
fresh();
check('a plain ghost over a different piece still leaves it alone', run(`(() => {
placeBlock(BP_BELT, 1, 30, 20, true); const b = 20 * BW + 30;
ghostBlocks.set(b, [BP_SCREW, 1]); bot.x = 30 * BS - 4; bot.y = 20 * BS - 10;
for (let k = 0; k < 30; k++) ghostTick();
return blockP[b] === BP_BELT && ghostBlocks.has(b);
})()`));
done();
+48 -48
View File
@@ -1,9 +1,9 @@
// Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
// Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
function fresh() { flatWorld(run, 320, 200, 120); run('bot.x = 20; bot.y = 40; money = 1e6; globalThis.__got = 0;'); }
const tubeLiquid = () => run('(() => { let n = 0; for (let i = 0; i < N; i++) if (part[i] >= P_TU && part[i] <= P_TR && PH[mat[i]] === PH_LIQUID) n++; return n; })()');
const screwLiquid = () => run('(() => { let n = 0; for (let i = 0; i < N; i++) if (part[i] >= P_SCREW_U && part[i] <= P_SCREW_R && PH[mat[i]] === PH_LIQUID) n++; return n; })()');
// grains that land in the sink region are counted and taken away, so outlets never back up
function rate(x0, x1, y0, y1, warm, meas) {
const sink = `for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) { const i=y*W+x; if (mat[i]===SAND) { mat[i]=EMPTY; __got++; } }`;
@@ -13,42 +13,42 @@ function rate(x0, x1, y0, y1, warm, meas) {
return run('__got') / (meas / 60);
}
// --- tubes are sealed: no water in, no water carried
// --- screws are sealed: no water in, no water carried
fresh();
run('for (let y = 120; y < 170; y++) for (let x = 100; x < 124; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
rect(run, 100, 150, 123, 169, run('SAND'));
rect(run, 100, 130, 123, 149, run('WATER'));
run('for (let by = 22; by <= 41; by++) placeBlock(BP_TUBE, 0, 27, by, true); for (let bx = 27; bx <= 34; bx++) placeBlock(BP_TUBE, 1, bx, 21, true);');
run('for (let by = 22; by <= 41; by++) placeBlock(BP_SCREW, 0, 27, by, true); for (let bx = 27; bx <= 34; bx++) placeBlock(BP_SCREW, 1, bx, 21, true);');
const water0 = count(run, run('WATER'));
ticks(run, 900);
const wOut = count(run, run('WATER'), 124, 0, 319, 119), sOut = count(run, run('SAND'), 124, 0, 319, 119);
check('a lift standing in a flooded pit brings up the sand but not the water', sOut > 150 && wOut === 0, `sand out ${sOut}, water out ${wOut}, water standing in the tubes ${tubeLiquid()}`);
check('a lift standing in a flooded pit brings up the sand but not the water', sOut > 150 && wOut === 0, `sand out ${sOut}, water out ${wOut}, water standing in the screws ${screwLiquid()}`);
const pitClouds = run('weather.moist.reduce((a, v) => a + v, 0)');
const pitSteam = count(run, run('STEAM')); // the pit's floor is hot: some is steam on its way up at any moment
check('the pit keeps its water (bar what steams off into the clouds)', water0 - count(run, run('WATER')) - pitSteam - pitClouds <= 6, `${water0} -> ${count(run, run('WATER'))}, ${pitSteam} steam, ${pitClouds.toFixed(0)} in the clouds`);
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_TUBE, 1, bx, 25, true);');
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_SCREW, 1, bx, 25, true);');
rect(run, 84, 60, 95, 79, run('WATER'));
ticks(run, 300);
check('water poured on a tube runs straight through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 200 && tubeLiquid() < 10, `${count(run, run('WATER'), 0, 104, 319, 119)} below, ${tubeLiquid()} still inside`);
check('water poured on a screw runs straight through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 200 && screwLiquid() < 10, `${count(run, run('WATER'), 0, 104, 319, 119)} below, ${screwLiquid()} still inside`);
fresh();
// a vertical tube pointing up, its top open to a pool: the water drains down through it, against the screw
run('for (let by = 20; by <= 26; by++) placeBlock(BP_TUBE, 0, 25, by, true); for (let y = 60; y < 80; y++) { setCell(y*W+99, STONE, AMB[y], 0); setCell(y*W+104, STONE, AMB[y], 0); }');
// a vertical screw pointing up, its top open to a pool: the water drains down through it, against the screw
run('for (let by = 20; by <= 26; by++) placeBlock(BP_SCREW, 0, 25, by, true); for (let y = 60; y < 80; y++) { setCell(y*W+99, STONE, AMB[y], 0); setCell(y*W+104, STONE, AMB[y], 0); }');
rect(run, 100, 60, 103, 79, run('WATER'));
ticks(run, 400);
check('water runs down through an upward screw, which never pushes it back', count(run, run('WATER'), 0, 0, 319, 79) === 0, count(run, run('WATER'), 0, 0, 319, 79));
// Intake itself fills the far lanes first, for either tube axis.
// Intake itself fills the far lanes first, for either screw axis.
for (const horizontal of [false, true]) {
fresh();
run(`placeBlock(BP_TUBE, ${horizontal ? 1 : 0}, 25, 20, true);`);
run(`placeBlock(BP_SCREW, ${horizontal ? 1 : 0}, 25, 20, true);`);
const filled = run(`(() => {
const lanes = [];
for (let k = 0; k < BS; k++) {
tickStamp++;
const x = ${horizontal ? 100 : 99}, y = ${horizontal ? 79 : 80};
setCell(y*W+x, SAND, AMB[y], 0);
tubeIntake(80*W+100, ${horizontal ? 'P_TR, 1, 0' : 'P_TU, 0, -1'});
screwIntake(80*W+100, ${horizontal ? 'P_SCREW_R, 1, 0' : 'P_SCREW_U, 0, -1'});
for (let lane = 0; lane < BS; lane++) if (mat[(80+${horizontal ? 'lane' : '0'})*W+100+${horizontal ? '0' : 'lane'}] === SAND && !lanes.includes(lane)) lanes.push(lane);
}
return lanes;
@@ -59,7 +59,7 @@ for (const horizontal of [false, true]) {
// A finite heap rides into the side of a six-block lift; remove each mouth flyer once.
const sideSeed = run('RS');
fresh();
run('RS = 7; for (let bx = 16; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true); for (let by = 19; by <= 24; by++) placeBlock(BP_TUBE, 0, 32, by, true);');
run('RS = 7; for (let bx = 16; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true); for (let by = 19; by <= 24; by++) placeBlock(BP_SCREW, 0, 32, by, true);');
rect(run, 64, 40, 119, 99, run('SAND'));
const sideLift = run(`(() => {
let out = 0; const lanes = [0, 0, 0, 0];
@@ -79,7 +79,7 @@ check('the side-fed lift conserves its sand, including mouth flyers', sideLift.b
// The same belt and heap deliver 2243 into an open drop, the most the lift can be given.
check('a side-fed lift carries at least 90% of what its belt brings (it was 65%)', sideLift.out >= Math.floor(0.9 * 2243), sideLift.out);
// Same belt and finite heap, with a non-blocking sink at the tube side.
// Same belt and finite heap, with a non-blocking sink at the screw side.
run(`RS = ${sideSeed}`);
fresh();
run('RS = 7; for (let bx = 16; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
@@ -100,24 +100,24 @@ console.log('Belt delivery to a non-blocking sink, 3000 ticks: ' + beltDelivery)
// --- throughput
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_SCREW, 1, bx, 20, true);');
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
rect(run, 80, 0, 95, 79, run('SAND'));
ticks(run, 60);
const lanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
check('a sideways tube fed from above (its side) fills more than one row', lanes > 1, lanes);
check('a sideways tube fed from a hopper runs full, all four grains wide', lanes === 4, lanes);
check('a sideways screw fed from above (its side) fills more than one row', lanes > 1, lanes);
check('a sideways screw fed from a hopper runs full, all four grains wide', lanes === 4, lanes);
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_SCREW, 1, bx, 20, true);');
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
rect(run, 80, 0, 95, 79, run('SAND'));
const tr = rate(120, 319, 84, 119, 90, 300);
// tubes run at belt pace (every other tick), so a belt under a tube's mouth keeps up with it
check('a sideways tube moves around a hundred grains a second', tr > 80 && tr < 150, `${tr.toFixed(0)}/s`);
// screws run at belt pace (every other tick), so a belt under a screw's mouth keeps up with it
check('a sideways screw moves around a hundred grains a second', tr > 80 && tr < 150, `${tr.toFixed(0)}/s`);
fresh();
run('for (let y = 120; y < 196; y++) for (let x = 90; x < 130; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
rect(run, 90, 130, 129, 195, run('SAND'));
run('for (let by = 20; by <= 37; by++) placeBlock(BP_TUBE, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);');
run('for (let by = 20; by <= 37; by++) placeBlock(BP_SCREW, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_SCREW, 1, bx, 19, true);');
const lr = rate(164, 319, 80, 119, 400, 300);
const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y++) for (let x = 120; x < 160; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
@@ -151,7 +151,7 @@ check('the load rides a belt up to a block deep', depth >= 3, depth);
// --- filters
fresh();
// a filter for copper ore under a mixed pile; everything else rides right and off the end
// fed from a walled hopper over its left end, the way a tube or belt would drop a mixed load on it
// fed from a walled hopper over its left end, the way a screw or belt would drop a mixed load on it
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [CUORE] });');
run('for (let y = 30; y < 120; y++) setCell(y*W+79, STONE, AMB[y], 0); for (let y = 30; y < 96; y++) setCell(y*W+88, STONE, AMB[y], 0);');
run('for (let y = 40; y < 96; y++) for (let x = 80; x < 88; x++) setCell(y*W+x, (x + y) % 3 ? SAND : CUORE, AMB[y], 0)');
@@ -236,7 +236,7 @@ run(`globalThis.__carry = carryColumn;
for (const lift of [true, false]) {
fresh();
run('RS = 7; __shear = 0; for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
if (lift) run('for (let by = 15; by <= 24; by++) placeBlock(BP_TUBE, 0, 32, by, true);');
if (lift) run('for (let by = 15; by <= 24; by++) placeBlock(BP_SCREW, 0, 32, by, true);');
else run('for (let by = 10; by <= 25; by++) placeBlock(BP_WALL, RSTONE, 32, by, true);');
rect(run, 88, 94, 119, 99, run('SAND'));
rect(run, 88, 70, 119, 93, run('CUORE'));
@@ -251,60 +251,60 @@ for (const lift of [true, false]) {
}
run('carryColumn = __carry;');
// --- whole bars in screw tubes; position an existing bar inside for mid-ride cases
// --- whole bars in screw conveyors; position an existing bar inside for mid-ride cases
function putBar(x, y) { run(`{ spawnBar(BARCU, 20, 40, 0, 0); const e=ents[0]; entMark(e,0); e.x=e.ix=${x}; e.y=e.iy=${y}; entMark(e,1); }`); }
function barTube(corner = false) {
function barScrew(corner = false) {
fresh();
run(`for (let by = 20; by < 24; by++) placeBlock(BP_TUBE, 0, 25, by, true);
${corner ? 'for (let bx = 25; bx < 29; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);' : ''}`);
run(`for (let by = 20; by < 24; by++) placeBlock(BP_SCREW, 0, 25, by, true);
${corner ? 'for (let bx = 25; bx < 29; bx++) placeBlock(BP_SCREW, 1, bx, 19, true);' : ''}`);
putBar(100,94);
}
function watchBar(n, condition) { return run(`(() => { for (let k=0;k<${n};k++) { simTick(); if (${condition}) return true; } return false; })()`); }
fresh();
run('for (let bx=20;bx<25;bx++) placeBlock(BP_BELT,1,bx,25,true); for(let by=21;by<25;by++) placeBlock(BP_TUBE,0,25,by,true); spawnBar(BARCU,84,98,0,0);');
run('for (let bx=20;bx<25;bx++) placeBlock(BP_BELT,1,bx,25,true); for(let by=21;by<25;by++) placeBlock(BP_SCREW,0,25,by,true); spawnBar(BARCU,84,98,0,0);');
check('a belt feeds a whole copper bar into a lift and out its top', watchBar(200, 'ents.length===1 && ents[0].iy+BAR_H<=84'), run('JSON.stringify(ents)'));
fresh();
run('for(let bx=20;bx<24;bx++) placeBlock(BP_TUBE,1,bx,20,true);'); putBar(80,82);
check('a whole bar leaves a right tube', watchBar(100,'ents.length===1 && ents[0].ix>=96'));
// steam and smoke drift through tubes near furnaces: a bar passes them as it does in the open
barTube();
check('steam and smoke in a tube do not stop a bar', run(`(() => { for (let k = 0; k < 120; k++) {
run('for(let bx=20;bx<24;bx++) placeBlock(BP_SCREW,1,bx,20,true);'); putBar(80,82);
check('a whole bar leaves a right screw', watchBar(100,'ents.length===1 && ents[0].ix>=96'));
// steam and smoke drift through screws near furnaces: a bar passes them as it does in the open
barScrew();
check('steam and smoke in a screw do not stop a bar', run(`(() => { for (let k = 0; k < 120; k++) {
for (let y = 80; y < 94; y++) for (let x = 100; x < 104; x++) if (mat[y*W+x] === EMPTY && !occ[y*W+x]) setCell(y*W+x, (x + y) & 1 ? STEAM : SMOKE, 120, 0);
simTick(); if (ents.length === 1 && ents[0].iy + BAR_H <= 80) return true; } return false; })()`), run('JSON.stringify(ents)'));
barTube(true);
check('a bar turns from an up tube into a right tube and leaves', watchBar(200,'ents.length===1 && ents[0].ix>=116'));
barScrew(true);
check('a bar turns from an up screw into a right screw and leaves', watchBar(200,'ents.length===1 && ents[0].ix>=116'));
fresh();
run('for(let bx=20;bx<24;bx++) placeBlock(BP_TUBE,1,bx,20,true);'); putBar(80,82);
run('for(let bx=20;bx<24;bx++) placeBlock(BP_SCREW,1,bx,20,true);'); putBar(80,82);
rect(run,96,80,99,83,run('STONE'));
ticks(run,100);
check('a blocked mouth holds the whole bar inside without wall overlap',run('ents.length===1 && ents[0].ix===92 && !mat.some((m,i)=>m===STONE && occ[i])'),run('JSON.stringify(ents)'));
rect(run,96,80,99,83,run('EMPTY'));
check('the bar leaves when the mouth wall is removed',watchBar(80,'ents[0].ix>=96'));
barTube();
barScrew();
rect(run,100,88,103,89,run('SAND'));
const shared = run(`(() => { let overlap=false, rose=false; const n=mat.reduce((n,m)=>n+(m===SAND),0); for(let k=0;k<160;k++) { simTick(); if(mat.some((m,i)=>m===SAND && occ[i])) overlap=true; if(ents[0].iy<90) rose=true; } return { overlap, rose, n, after:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length }; })()`);
check('grains and a moving bar share a lift without overlap or lost sand',!shared.overlap && shared.rose && shared.n===shared.after,JSON.stringify(shared));
barTube(); ticks(run,8);
barScrew(); ticks(run,8);
run('globalThis.__barSave=serializeWorld(); deserializeWorld(__barSave.meta,__barSave.buffers);');
check('a bar saved mid-tube continues out of its mouth',watchBar(100,'ents.length===1 && ents[0].iy+BAR_H<=80'));
check('a bar saved mid-screw continues out of its mouth',watchBar(100,'ents.length===1 && ents[0].iy+BAR_H<=80'));
fresh();
run('for(let bx=20;bx<24;bx++) placeBlock(BP_BELT,1,bx,25,true); placeBlock(BP_WALL,RSTONE,24,25,true); placeBlock(BP_WALL,RSTONE,24,24,true); spawnBar(BARCU,80,98,0,0);');
ticks(run,100);
check('a normal belt still carries a bar and stops at its blocked end',run('ents.length===1 && ents[0].ix===92 && ents[0].iy===98'));
fresh(); run('placeBlock(BP_TUBE,1,25,25,true); spawnBar(BARCU,100,98,0,0);'); ticks(run,100);
check('a bar resting on a horizontal tube stays on top',run('ents[0].ix===100 && ents[0].iy===98'));
fresh(); run('placeBlock(BP_SCREW,1,25,25,true); spawnBar(BARCU,100,98,0,0);'); ticks(run,100);
check('a bar resting on a horizontal screw stays on top',run('ents[0].ix===100 && ents[0].iy===98'));
for (const dir of [0, 2]) {
fresh(); run(`placeBlock(BP_TUBE,${dir},25,20,true); spawnBar(BARCU,100,${dir ? 78 : 84},0,${dir ? 0 : -0.4});`);
check(dir ? 'a falling bar enters a down tube from above' : 'an upward moving bar enters the bottom of an up tube',watchBar(20,'entTubeAt(ents[0].ix,ents[0].iy)!==0'));
fresh(); run(`placeBlock(BP_SCREW,${dir},25,20,true); spawnBar(BARCU,100,${dir ? 78 : 84},0,${dir ? 0 : -0.4});`);
check(dir ? 'a falling bar enters a down screw from above' : 'an upward moving bar enters the bottom of an up screw',watchBar(20,'entScrewAt(ents[0].ix,ents[0].iy)!==0'));
}
fresh(); run('for(let bx=20;bx<24;bx++) placeBlock(BP_TUBE,1,bx,20,true); for(let by=21;by<25;by++) placeBlock(BP_TUBE,2,23,by,true);'); putBar(80,82);
check('a bar turns from a right tube down into a vertical lane',watchBar(160,'ents[0].iy>=100'));
barTube(true); rect(run,100,76,103,79,run('STONE')); ticks(run,100);
fresh(); run('for(let bx=20;bx<24;bx++) placeBlock(BP_SCREW,1,bx,20,true); for(let by=21;by<25;by++) placeBlock(BP_SCREW,2,23,by,true);'); putBar(80,82);
check('a bar turns from a right screw down into a vertical lane',watchBar(160,'ents[0].iy>=100'));
barScrew(true); rect(run,100,76,103,79,run('STONE')); ticks(run,100);
check('a blocked corner waits without overlapping stone',run('ents[0].iy===80 && !mat.some((m,i)=>m===STONE && occ[i])'));
rect(run,100,76,103,79,run('EMPTY'));
check('a blocked corner resumes when cleared',watchBar(120,'ents[0].ix>=116'));
barTube(); run('ents[0].T=1200; for(let y=94;y<96;y++) for(let x=100;x<104;x++) temp[y*W+x]=1200; entTick();');
check('a bar melting inside a tube becomes eight molten grains',run('ents.length===0') && count(run,run('MCU'))===8);
barScrew(); run('ents[0].T=1200; for(let y=94;y<96;y++) for(let x=100;x<104;x++) temp[y*W+x]=1200; entTick();');
check('a bar melting inside a screw becomes eight molten grains',run('ents.length===0') && count(run,run('MCU'))===8);
done();
+75
View File
@@ -0,0 +1,75 @@
// Corner-touching square walls seal loose grains without changing free piles or worldgen.
const { load, suite, flatWorld, rect, count, ticks } = require('./harness');
const { createHash } = require('node:crypto');
const { run } = load();
const { check, done } = suite();
for (const [seed, expected] of [
[777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'],
[77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'],
[4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'],
]) {
run(`generateWorld(${seed}, 512, 384)`);
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true);
check(`seed ${seed} keeps its pre-seal material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
}
for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) {
flatWorld(run, 160, 256, 224);
// Span the whole world so escaping an end cannot be confused with crossing a corner.
run(`RS=12345; AMB.fill(20); temp.fill(20); weather.skyY=-1000;
for(let bx=0;bx<40;bx++) placeBlock(BP_WALL,RSTONE,bx,${mirror ? '47-bx' : '8+bx'},true);`);
const gas = material === 'STEAM';
rect(run, 70, gas ? 166 : 20, 89, gas ? 170 : 24, run(material));
if (gas) {
// Isolate gas movement from cooling/condensation, including chunk sleeping.
run(`temp.fill(120); var gasLeaks=0; for(let k=0;k<500;k++){tick++;tickStamp=tickStamp%255+1;simMove();
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===STEAM && y<(${mirror ? '47-(x>>2)' : '8+(x>>2)'})*4) gasLeaks++;}`);
} else ticks(run, 500);
const result = run(`(()=>{
let leaked=0,held=0,x0=W,x1=0,y0=H,y1=0;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===${material}) {
const top=(${mirror ? '47-(x>>2)' : '8+(x>>2)'})*4;
if(${gas ? 'y<top' : 'y>=top+4'}) leaked++; else held++;
x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);
}
return {leaked,held,width:x1-x0+1,height:y1-y0+1};
})()`);
check(`${material} stays ${gas ? 'under' : 'above'} ${mirror ? 'rising' : 'descending'} square stairs`, result.leaked === 0 && result.held === 100 && (!gas || run('gasLeaks')===0), JSON.stringify(result)+(gas ? ` crossings ${run('gasLeaks')}` : ''));
if (!gas) check(`${material} piles and slides along the steps`, result.height > 1 && count(run,run(material),70,0,89,223)<100, JSON.stringify(result));
}
// A screw mouth must not draw sand through two external wall cells either.
for (const direction of [4,5,6,7]) {
flatWorld(run,160,160,140);
run(`placeBlock(BP_SCREW,${direction},20,20,true);
var dx=SCREW_DX[SCREW_PARTS[${direction}]],dy=SCREW_DY[SCREW_PARTS[${direction}]];
var x=dx>0?80:83,y=dy>0?80:83;
setCell((y-dy)*W+x-dx,SAND,20,0);
setCell((y-dy)*W+x,STONE,20,0);setCell(y*W+x-dx,STONE,20,0);
screwIntake(y*W+x,part[y*W+x],dx,dy);`);
check(`diagonal screw ${direction} cannot pull through a solid corner`,run('mat[(y-dy)*W+x-dx]===SAND'));
}
// A free flyer can take the same diagonal shortcut between wall cells.
flatWorld(run, 160, 160, 140);
run(`setCell(79*W+80,STONE,20,0);setCell(80*W+79,STONE,20,0);
flyers=[{x:79.5,y:79.5,vx:1,vy:1,age:0,m:SAND,T:20,free:true,run:0}];flyTick();`);
check('a flying grain cannot cross a sealed corner', run('flyers.length===0 && mat[79*W+79]===SAND'));
flatWorld(run, 240, 160, 120);
run('RS=12345;');
rect(run, 110, 40, 129, 59, run('SAND'));
ticks(run, 600);
const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);}
return [x1-x0+1,y1-y0+1];})()`);
// Before the fix: 57 wide, 17 high. Small bounds allow random-state differences from prior scenes.
check('open-ground sand still forms the same size pile', pile[0]>=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile);
const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS');
flatWorld(run,240,160,120);
run('RS=12345; CORNER_SEAL=false;');
rect(run,110,40,129,59,run('SAND'));ticks(run,600);
check('open pile and random sequence exactly match unsealed movement', createHash('sha256').update(run('mat')).digest('hex')===sealedPile && run('RS')===sealedRS);
run('CORNER_SEAL=true;');
done();
+75
View File
@@ -0,0 +1,75 @@
// Dev command validation, discovery, build costs, runtime controls and save metadata.
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
flatWorld(run, 240, 160, 110);
const command = text => run(`devCommand(${JSON.stringify(text)})`);
check('commands cannot change an unmarked world', command('money 123')[0].includes('warning'));
run('enableDevConsole(); money = 100;');
command('MoNeY -25'); command('money set 1234.5');
check('money adds signed values and sets amounts, ignoring case', run('money === 1234.5'));
command('money -34.5');
check('negative money amount subtracts', run('money === 1200'));
command('debt 500');
check('debt sets claim amount', run('debt === 500'));
command('debt clear');
check('debt clear pays off claim', run('debt === 0'));
run('seen.fill(0); EV.length = 0;');
command('give cUoRe 7'); command('give Copper Bar 3'); command('give charc'); command('give sand 30000');
check('give matches internal names, display names and unique prefixes', run('bag[CUORE] === 7 && bag[BARCU] === 3 && bag[CHARCOAL] === 1000'));
check('give exceeds bag capacity, counts totals and discovers like a pickup', run("bag[SAND] === 30000 && bagTotal === 31010 && seen[SAND] && EV.some(e => e.t === 'discover' && e.m === SAND && e.how === 'pickup')"));
check('ambiguous prefix explains candidates', command('give cop')[0].startsWith('Ambiguous material:'));
check('unknown material is explained', command('give nonexistent')[0].startsWith('Unknown material'));
check('all internal aliases resolve exactly', run('Object.keys(DEV_MATERIALS).length === NMAT && Object.entries(DEV_MATERIALS).every(([k, v]) => devMaterial(k) === v)'));
command('give sand 2147483647');
check('give rejects typed-array overflow', run('bag[SAND] === 30000'));
command('bit 1e100'); command('bag -5');
check('tier setters clamp to valid range', run('player.bitTier === BIT_TIERS.length - 1 && player.tankTier === 0'));
check('tier queries list every tier', command('bit').length === run('BIT_TIERS.length') && command('bag').length === run('TANK_TIERS.length'));
run('player.lens = player.flask = false;'); command('unlock');
check('unlock learns materials, rewards and milestones', run('seen.every(Boolean) && player.lens && player.flask && MILESTONES.every(m => ledger[m.id])'));
command('tp -500 9999');
check('teleport clamps the entire bot inside the world', run('bot.x === 0 && bot.y === H - bot.h && bot.vx === 0 && bot.vy === 0'));
run('devCommand("tp", {x: 70, y: 45})');
check('teleport can use the cursor', run('bot.x === 70 && bot.y === 45'));
command('speed 1e100'); check('speed clamps high', run('devSpeed === 8'));
command('speed -1'); check('speed clamps low', run('devSpeed === 0.1'));
command('step'); check('step requires dev pause', run('devSteps === 0'));
command('pause'); command('step 5');
check('paused steps are queued for the frame loop', run('devPaused && devSteps === 5'));
command('pause'); check('resuming discards queued steps', run('!devPaused && devSteps === 0'));
const beforeBad = run('JSON.stringify([money, debt, bagTotal, player, devLog, devSpeed, devSteps])');
for (const text of ['money', 'money set', 'money Infinity', 'money 1e309', 'money 1 2', 'debt -1', 'debt nope', 'give sand -1', 'give sand 1.5', 'bit 1.5', 'bag nope', 'unlock extra', 'tp 1', 'tp NaN 4', 'free maybe', 'speed NaN', 'step 0', 'step 10001', 'pause extra', 'weather snow', 'nonsense']) {
const output = command(text);
check('bad arguments produce a helpful error: ' + text, output.length > 0 && !output[0].startsWith('>'));
}
check('invalid commands do not mutate or log', beforeBad === run('JSON.stringify([money, debt, bagTotal, player, devLog, devSpeed, devSteps])'));
check('help lists every command', command('help').length === 13);
run('money = 17; bag.fill(0); bagTotal = 0; bagAdd(RSTONE, 32, true); bagAdd(LCLAY, 100, true);');
check('normal build costs are checked', run('!canAfford([[CASH, 100]], 1)'));
command('free on');
const buildBag = run('JSON.stringify(Array.from(bag))');
check('free bypasses affordability', run('canAfford([[CASH, 100000], [BARCU, 99]], 1)'));
check('free places a wall without taking existing rubble', run("placeBlock(BP_WALL, RSTONE, 10, 10, false) === '' && bag[RSTONE] === 32"));
check('free places a machine without charging money', run("placeMachineAt('fan', 0, 24, 10) === '' && money === 17"));
check('free never charges, including blueprint ghosts built later', run('(() => { const m = money; payCost([[CASH, 500]], 3); return money === m; })()'));
check('free places a mold without taking clay', run("typeof addMold('ingot', 35, 10, LCLAY, false) !== 'string' && bag[LCLAY] === 100"));
run("ghostBlocks.set(12 * BW + 17, [BP_BELT, 1]); ghostMachines.push({type: 'fan', dir: 0, bx: 20, by: 17}); ghostMolds.push({kind: 'ingot', bx: 9, by: 16, wall: LCLAY}); ghostTick(); ghostTick();");
check('ghost blocks, machines and molds build free too', run('!ghostBlocks.size && !ghostMachines.length && !ghostMolds.length && money === 17 && ghostMissing === 0'));
check('all free paths preserve bag contents and totals', buildBag === run('JSON.stringify(Array.from(bag))') && run('bagTotal === 132'));
command('free off'); run('payCost([[CASH, 2], [RSTONE, 4]], 1)');
check('normal payment resumes after free off', run('money === 15 && bag[RSTONE] === 28 && bagTotal === 128'));
command('weather rain');
check('weather rain starts a shower near the bot', run('weather.shower && rainAt(bot.x) > 0 && weather.shower.until === tick + 3600'));
command('weather clear');
check('weather clear stops current shower', run('weather.shower === null && weather.raining.every(v => !v)'));
run('for (let i = 0; i < 210; i++) devCommand("money 1");');
check('dev log keeps the last 200 commands', run('devLog.length === 200 && devLog.every(e => e[0] === tick && e[1] === "money 1")'));
run('devCommand("speed 4"); devCommand("pause"); devCommand("free on"); const devSaved = serializeWorld(); const devLogCopy = JSON.stringify(devLog); deserializeWorld(devSaved.meta, devSaved.buffers);');
check('dev mark and command log survive save round trip', run('devWorld && devSaved.meta.dev === true && JSON.stringify(devLog) === devLogCopy'));
check('load resets speed, free building, pause and pending steps', run('devSpeed === 1 && !devFree && !devPaused && devSteps === 0'));
run('alloc(240,160)');
check('a new world starts unmarked without a dev log', run('!devWorld && devLog.length === 0'));
check('give turns rock into its rubble and says so', run(`(() => { enableDevConsole(); const before = bag[RBAS]; const out = devCommand('give basalt 50').join(' '); return bag[RBAS] === before + 50 && bag[BASALT] === 0 && /Basalt rubble/.test(out); })()`));
check('give refuses things that cannot be in a bag', run(`(() => { enableDevConsole(); const out = devCommand('give steam 5').join(' '); return /cannot go in the bag/.test(out) && bag[STEAM] === 0; })()`));
done();
+100
View File
@@ -0,0 +1,100 @@
"""Dev console confirmation, keyboard isolation, HUD, time controls and saved badge."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).resolve().parent.parent
SHOTS = ROOT / 'shots'
SHOTS.mkdir(exist_ok=True)
fails = []
def check(name, ok):
print(('PASS ' if ok else 'FAIL ') + name)
if not ok: fails.append(name)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto((ROOT / 'dist/index.html').as_uri())
page.wait_for_function("!$('title').hidden")
page.evaluate("startNewWorld(424242, 512, 384, 'Console test')")
page.wait_for_function('GAME.running && !GAME.loading')
page.evaluate("document.querySelectorAll('.card, .toast').forEach(e => e.remove())")
page.keyboard.press('`')
check('backtick opens the first-use warning', page.is_visible('#m-dialog') and not page.is_visible('#dev-console'))
page.screenshot(path=str(SHOTS / 't66-warning.png'))
check('warning explains permanent marking and future achievements', 'permanently' in page.inner_text('#dlg-body') and 'achievements' in page.inner_text('#dlg-body'))
page.get_by_role('button', name='Cancel', exact=True).click()
check('cancel leaves the world unmarked and console closed', page.evaluate("!devWorld && $('dev-console').hidden && !GAME.paused"))
page.keyboard.press('`')
page.get_by_role('button', name='Enable dev console', exact=True).click()
page.wait_for_function("!$('dev-console').hidden && document.activeElement === $('dev-input')")
check('accepting marks the world and resumes play', page.evaluate('devWorld && !GAME.paused'))
check('build material chip stays stable across HUD refreshes', page.evaluate("""() => {
selectTool(2); renderSelCard();
const chip = $('selcard').querySelector('[data-wall-picker]');
renderSelCard();
const stable = chip === $('selcard').querySelector('[data-wall-picker]');
selectTool(0); return stable;
}"""))
old_money = page.evaluate('money')
page.fill('#dev-input', 'money 1000'); page.keyboard.press('Enter')
page.wait_for_function(f"money === {old_money + 1000} && $('wallet').textContent.includes(fmtMoney(money))")
check('money command changes the wallet HUD', True)
tool = page.evaluate('GAME.tool')
page.fill('#dev-input', ''); page.keyboard.type('1234wasd')
check('typing game keys does not switch tools or hold movement', page.evaluate(f'GAME.tool === {tool} && keys.size === 0'))
page.keyboard.press('Escape')
check('Escape closes console without opening pause menu', page.evaluate("$('dev-console').hidden && $('m-pause').hidden && !GAME.paused"))
page.keyboard.press('`')
check('marked worlds reopen without the warning', page.evaluate("!$('dev-console').hidden && $('m-dialog').hidden"))
page.keyboard.press('`')
check('backtick closes even with input focused', not page.is_visible('#dev-console'))
page.keyboard.press('`')
def command(text):
page.fill('#dev-input', text); page.keyboard.press('Enter')
command('pause')
tick = page.evaluate('tick')
page.wait_for_timeout(150)
check('dev pause freezes simulation ticks', page.evaluate('tick') == tick)
command('step 5')
page.wait_for_function(f'tick === {tick + 5} && devSteps === 0')
page.wait_for_timeout(150)
check('step advances exactly five ticks then stays paused', page.evaluate('tick') == tick + 5)
# Drive the actual frame accumulator with controlled elapsed time and no scheduled frames.
counts = page.evaluate('''() => {
requestAnimationFrame = () => 0;
GAME.paused = false; devPaused = false; SET.fpsCap = 0; SET.maxSteps = 3;
const counts = [];
for (const speed of [0.5, 1, 2, 8]) {
devSpeed = speed; acc = 0; const before = tick;
const t = performance.now(); lastT = t - 34; lastRender = t - 34; frame(t);
counts.push(tick - before);
}
return counts;
}''')
check('speed scales fixed ticks and respects maxSteps', counts == [1, 2, 3, 3])
page.evaluate('devCommand("debt clear"); runDevCommand("money 1"); openNotebook("Claims")')
page.wait_for_function("$('nb-body').textContent.includes('paid off')")
check('cleared debt is reflected in Claims', True)
page.evaluate('closeAllModals(); runDevCommand("debt 123"); openNotebook("Market")')
check('new debt is reflected in the Market', '$123' in page.inner_text('#nb-body'))
page.evaluate('closeAllModals()')
page.evaluate("async () => { await saveWorld('main', true); await renderWorlds(); }")
page.evaluate("openModal('m-worlds')")
check('world list entry stores and draws the dev badge', page.locator('#worlds-list .dev-badge').is_visible() and page.inner_text('#worlds-list .dev-badge') == 'Dev console')
page.screenshot(path=str(SHOTS / 't66-worlds.png'))
page.evaluate('closeAllModals()')
check('saving and loading preserves mark and log, resets runtime controls', page.evaluate('''async () => {
const sv = await worldNewest(GAME.worldId);
const count = devLog.length;
await startFromSave(sv);
return devWorld && devLog.length === count && devSpeed === 1 && !devPaused && !devFree && $('dev-console').hidden;
}'''))
check('no browser errors', not errors)
browser.close()
sys.exit(1 if fails else 0)
+256
View File
@@ -0,0 +1,256 @@
// Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const fs = require('fs');
const { run } = load();
const { check, done } = suite();
run(fs.readFileSync('src/render/diagonal-screws.js', 'utf8'));
run(fs.readFileSync('src/render/world.js', 'utf8'));
function fresh() { flatWorld(run, 256, 192, 176); run('RS=7; nextPowerBuild=0; money=10000; AMB.fill(20); temp.fill(20); hot.fill(0);'); }
function line(v, chain = false) {
fresh();
const [dx, dy] = run(`SCREW_DIRS[${v}]`), bx = dx > 0 ? 16 : 36, by = dy < 0 ? 32 : 12;
run(`for(let k=0;k<10;k++) placeBlock(BP_SCREW,${v},${bx}+k*${dx},${by}+k*${dy},true);`);
let ty = by, tail = bx, head = bx + 9 * dx;
if (chain) {
run(`for(let k=1;k<=3;k++) { placeBlock(BP_SCREW,${dx > 0 ? 1 : 3},${bx}-k*${dx},${by},true); placeBlock(BP_SCREW,${dx > 0 ? 1 : 3},${head}+k*${dx},${by + 10 * dy},true); }`);
tail -= 3 * dx; head += 3 * dx;
}
return { dx, dy, bx, by, ty, tail, head, hy: by + 9 * dy };
}
function drain(s, steps, feed = false) {
return run(`(() => {
let out=0, leaks=0, added=0;
for(let k=0;k<${steps};k++) {
${feed ? `for(let l=0;l<4;l++) { const i=(${s.ty ?? s.by}*4+l)*W+${s.tail}*4+${s.dx > 0 ? 0 : 3}; if(mat[i]===EMPTY) {setCell(i,SAND,20,0);added++;} }` : ''}
simTick();
for(let i=0;i<N;i++) if(mat[i]===SAND && !IS_SCREW[part[i]]) {
const x=i%W; if(${s.dx}>0 ? x>=${(s.head + 1) * 4} : x<${s.head * 4}) out++; else leaks++;
setCell(i,EMPTY,20,0);
}
for(const f of flyers) if(f.m===SAND) {
if(${s.dx}>0 ? f.x>=${(s.head + 1) * 4} : f.x<${s.head * 4}) out++; else leaks++;
}
flyers=flyers.filter(f=>f.m!==SAND);
}
return {out,leaks,added,inside:mat.reduce((n,m)=>n+(m===SAND),0)};
})()`);
}
for (const v of [4, 5, 6, 7]) {
const s = line(v);
check(`direction ${v} uses appended part ids and marks power`,run(`part[${s.by * 4}*W+${s.bx * 4}]===${v + 7} && blockV[${s.by}*BW+${s.bx}]===${v} && pcells.size===160`));
// Fill every lane, in every block: a corner or side leak is counted before it can fall away.
run('for(let i=0;i<N;i++) if(IS_SCREW[part[i]]) setCell(i,SAND,20,0);');
const r = drain(s, 300);
check(`direction ${v} carries sand through ten blocks and out the head`, r.out === 160, JSON.stringify(r));
check(`direction ${v} has no corner or side spills and conserves grains`, r.leaks === 0 && r.out+r.inside === 160,JSON.stringify(r));
run('rebuildPower();');
check(`direction ${v} joins corner-touching power networks`,run('nets.length===2 && nets[1].nt===160'));
const c = line(v, true);
rect(run,c.tail*4,c.ty*4,c.tail*4+3,c.ty*4+3,run('SAND'));
const joined = drain(c,500);
check(`direction ${v}: straight to diagonal to straight`,joined.out===16 && joined.leaks===0,JSON.stringify(joined));
}
// Every straight direction and neighbour offset, with actual transfers in both source orders.
for(const v of [4,5,6,7])for(const sv of [0,1,2,3])for(const [ox,oy] of [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]]) {
const [dx,dy]=run(`SCREW_DIRS[${v}]`),[sxv,syv]=run(`SCREW_DIRS[${sv}]`),vertical=!sxv;
const out=ox===dx&&oy===dy&&dx*sxv+dy*syv>0;
const incoming=ox===-sxv&&oy===-syv&&dx*sxv+dy*syv>0;
const joined=out||incoming,end=out?(dx>0?1:0):(dx>0?0:1),nx=20+ox,ny=20+oy;
const label=`diagonal ${v} straight ${sv} offset ${ox},${oy}`;
fresh();run(`placeBlock(BP_SCREW,${v},20,20,true);placeBlock(BP_SCREW,${sv},${nx},${ny},true);`);
const kind=joined?(oy===0?(ox>0?2:4):ox===0?(oy<0?1:3):vertical?6:5):0;
check(`${label}: drawing agrees with travel joins`,run(`(()=>{const j=diagonalScrewJoins(20,20,${v});return pieceJoins(20,20)===${joined?1<<end:0}&&${joined?`(j>>${end*3}&7)===${kind}`:'(j&192)===192'};})()`));
check(`${label}: complete ghost plan matches placed joins`,run(`(()=>{
const j=diagonalScrewJoins(20,20,${v}),sj=straightScrewJoins(${nx},${ny},${sv});removeBlock(20,20);removeBlock(${nx},${ny});
const p=new Map([[20*BW+20,[BP_SCREW,${v}]],[${ny}*BW+${nx},[BP_SCREW,${sv}]]]);
return diagonalScrewJoins(20,20,${v},p)===j&&straightScrewJoins(${nx},${ny},${sv},p)===sj;
})()`));
for(const forward of [true,false]) {
fresh();run(`placeBlock(BP_SCREW,${v},20,20,true);placeBlock(BP_SCREW,${sv},${nx},${ny},true);rebuildPower();`);
const sx=forward?20:nx,sy=forward?20:ny,tx=forward?nx:20,ty=forward?ny:20,passes=forward?out:incoming;
rect(run,sx*4,sy*4,sx*4+3,sy*4+3,run('SAND'));
const crossed=run(`(()=>{let n=0;for(let k=0;k<40;k++){tick+=2;tickStamp++;machinesTick();for(let i=0;i<N;i++)if(mat[i]===SAND){const x=i%W>>2,y=(i/W|0)>>2;if(x===${tx}&&y===${ty})n++;if(x!==${sx}||y!==${sy})setCell(i,EMPTY,20,0);}flyers=[];}return n;})()`);
check(`${label}: grains ${forward?'out':'in'} ${passes?'cross':'stay separate'}`,passes?crossed===16:crossed===0,crossed);
run('for(let i=0;i<N;i++)if(mat[i]===SAND)setCell(i,EMPTY,20,0);');putBar(sx*4,sy*4+1);
const bar=run(`(()=>{const e=ents[0];for(let k=0;k<24;k++){tick+=8;entMark(e,0);entScrewTick(e,entScrewAt(e.ix,e.iy));entMark(e,1);if((e.ix>>2)===${tx}&&(e.iy>>2)===${ty})return true;if(!entScrewAt(e.ix,e.iy))break;}return false;})()`);
check(`${label}: bar ${forward?'out':'in'} ${passes?'crosses':'stays separate'}`,bar===passes);
}
}
fresh();
run('placeBlock(BP_SCREW,1,20,20,true);placeBlock(BP_SCREW,0,21,20,true);rebuildPower();');
rect(run,80,80,83,83,run('SAND'));
check('a horizontal screw still feeds the side of a vertical lift',run(`(()=>{for(let k=0;k<24;k++){tick+=2;tickStamp++;machinesTick();for(let y=80;y<84;y++)for(let x=84;x<88;x++)if(mat[y*W+x]===SAND)return true;}return false;})()`));
// The shared opposite-direction check preserves all eight direction combinations.
check('screw entry rejects exactly the opposite direction',run('SCREW_PARTS.every(p=>SCREW_DIRS.every(([dx,dy])=>screwAccepts(p,dx,dy)===(SCREW_DX[p]!==-dx||SCREW_DY[p]!==-dy)))'));
// Flat belts and filters receive diagonal outlets in E, on their carrying face.
for (const v of [4,5,6,7]) for (const side of [-1,1]) for (const code of ['BP_BELT','BP_FILTER']) {
fresh();
const slope=v===4||v===6?-1:1,dy=side*slope,nx=20+side,ny=20+dy;
const dv=(side>0)===(v<6)?v:(v<6?v+2:v-2),end=side>0?1:0,kind=5;
run(`placeBlock(BP_SCREW,${dv},20,20,true);placeBlock(${code},${code==='BP_FILTER'?(side>0?128:0):(side>0?1:0)},${nx},${ny},true);rebuildPower();`);
check(`${code} at diagonal ${v} end ${end} draws the shared join`,run(`(diagonalScrewJoins(20,20,${dv})>>${end*3}&7)===${kind}`));
check(`${code} at diagonal ${v} end ${end} receives every grain lane above its face`,run(`(()=>{for(let l=0;l<4;l++){const i=(80+l)*W+${side>0?83:80};if(diagonalScrewNext(i,SCREW_PARTS[${dv}])!==${ny*4-1}*W+${side>0?84:79})return false;}return true;})()`));
putBar(80,81);
check(`${code} at diagonal ${v} end ${end} receives a bar on its face`,run(`(()=>{const e=ents[0];tick=8;entMark(e,0);entDiagonalScrewTick(e,SCREW_PARTS[${dv}]);entMark(e,1);return e.ix===${nx*4}&&e.iy===${ny*4-2};})()`));
}
// Flat carriers enter every diagonal at its same-row inlet; blocked mouths must wait safely.
for(const v of [4,5,6,7])for(const filter of [false,true]) {
fresh();const dx=run(`SCREW_DIRS[${v}][0]`),nx=20+dx;
run(`placeBlock(${filter?'BP_FILTER':'BP_BELT'},${filter?(dx>0?128:0):(dx>0?1:0)},20,20,true);placeBlock(BP_SCREW,${v},${nx},20,true);rebuildPower();`);
putBar(80,78);
run(`setCell(80*W+${nx*4},SAND,20,0);`);
check(`${filter?'filter':'belt'} → diagonal ${v}: a blocked joined inlet holds the bar`,run(`(()=>{
const e=ents[0];entMark(e,0);tick=8;entCurM=e.m;const held=entBeltScrewEnter(e);entMark(e,1);
return held&&e.ix===80&&e.iy===78;
})()`));
run(`setCell(80*W+${nx*4},EMPTY,20,0);`);
check(`${filter?'filter':'belt'} → diagonal ${v}: clearing the inlet resumes the drop`,run(`(()=>{
const e=ents[0];entMark(e,0);const moved=entBeltScrewEnter(e);entMark(e,1);
return moved&&e.ix===${nx*4}&&e.iy===80&&!!entScrewAt(e.ix,e.iy);
})()`));
fresh();run(`placeBlock(${filter?'BP_FILTER':'BP_BELT'},${filter?(dx>0?128:0):(dx>0?1:0)},20,20,true);placeBlock(BP_SCREW,${v<6?v+2:v-2},${nx},20,true);rebuildPower();`);
putBar(80,78);
check(`${filter?'filter':'belt'} → diagonal ${v}: incompatible travel never snaps a bar`,run('(()=>{const e=ents[0];entMark(e,0);const entered=entBeltScrewEnter(e);entMark(e,1);return !entered&&e.ix===80&&e.iy===78;})()'));
}
// The removed straight-to-diagonal corner path stays open.
fresh();
run('placeBlock(BP_SCREW,1,20,20,true);placeBlock(BP_SCREW,6,21,19,true);');
check('a straight mouth never takes the removed corner shortcut',run('straightScrewNext(82*W+83,P_SCREW_R)===82*W+84'));
// A saturated ten-block line: 80 warmup ticks, then 600 measured ticks at 60 Hz.
const rates = [];
for (const v of [0, 4, 5]) {
let s;
if (v) s=line(v);
else {
fresh();run('for(let by=23;by<=32;by++) placeBlock(BP_SCREW,0,16,by,true);');
s={dx:0,tail:16,by:32,head:16};
}
const result = v ? (drain(s,80,true),drain(s,600,true)) : run(`(() => {
let out=0;
for(let k=0;k<680;k++) {
for(let l=0;l<4;l++) {const i=131*W+64+l;if(mat[i]===EMPTY)setCell(i,SAND,20,0);}
simTick();
if(k>=80) out+=flyers.filter(f=>f.m===SAND).length;
flyers=[];
}return {out};
})()`);
rates.push(result.out/10);
}
console.log('Saturated throughput, ten blocks, grains/s: straight/up-diagonal/down-diagonal '+rates.join('/'));
check('diagonals match straight throughput per block',rates.every(n=>n===120),rates);
// A belt's last carrying cell feeds the tail side of an upward diagonal screw.
const belt=line(4);
run('for(let bx=10;bx<16;bx++)placeBlock(BP_BELT,1,bx,32,true);');
rect(run,39,100,39,127,run('STONE'));
rect(run,40,116,55,127,run('SAND'));
const beltOut=run(`(()=>{let out=0;for(let k=0;k<1000;k++){simTick();for(let f=flyers.length-1;f>=0;f--)if(flyers[f].m===SAND && flyers[f].x>=104){out++;flyers.splice(f,1);}}return out;})()`);
check('a belt feeds sand into a diagonal tail and out its head',beltOut>150,beltOut);
for(const v of [4,5,6,7]) {
const s=line(v);
rect(run,s.bx*4,s.by*4-8,s.bx*4+3,s.by*4-1,run('WATER'));
ticks(run,400);
check(`water flows freely through diagonal ${v}`,count(run,run('WATER'),0,0,255,172)===0 && count(run,run('WATER'))===32,count(run,run('WATER')));
}
// Bars keep their horizontal shape, with no saved ride state.
function putBar(x,y) {
run(`{ const e=spawnBar(BARCU,20,20,0,0); entMark(e,0); e.x=e.ix=${x}; e.y=e.iy=${y}; entMark(e,1); }`);
}
function watchBars(s, n=600) {
return run(`(()=>{let out=0,overlap=false;for(let k=0;k<${n};k++){
simTick();
for(let a=0;a<ents.length;a++)for(let b=0;b<a;b++) {
const e=ents[a],f=ents[b];if(e.ix<f.ix+4 && f.ix<e.ix+4 && e.iy<f.iy+2 && f.iy<e.iy+2)overlap=true;
}
if(mat.some((m,i)=>occ[i] && (PH[m]===PH_SOLID || PH[m]===PH_POWDER)))overlap=true;
for(let a=ents.length-1;a>=0;a--) {const e=ents[a];if(${s.dx}>0 ? e.ix>=${(s.head + 1) * 4} : e.ix+4<=${s.head * 4}) {out++;removeEnt(e);} }
}return {out,overlap,left:ents.length};})()`);
}
for(const v of [4,5,6,7]) for(const chain of [false,true]) {
const s=line(v,chain);
putBar(s.tail*4,s.ty*4);
putBar(s.tail*4,s.ty*4+2); // a queued bar must wait for the first one
const r=watchBars(s);
check(`bars travel diagonal ${v}${chain?' and both straight joins':''} without overlap`,r.out===2 && !r.overlap,JSON.stringify(r));
}
// A lift feeds a diagonal directly ahead; the diagonal feeds the next lift in E.
for (const v of [4,5,6,7]) {
const s=line(v), vv=s.dy<0?0:2, tailX=s.bx, headX=s.head+s.dx;
run(`for(let k=1;k<=3;k++){placeBlock(BP_SCREW,${vv},${tailX},${s.by}-k*${s.dy},true);placeBlock(BP_SCREW,${vv},${headX},${s.hy}+k*${s.dy},true);}`);
putBar(tailX*4,(s.by-3*s.dy)*4+1);
const r=run(`(()=>{let out=false;for(let k=0;k<500;k++){simTick();if(${s.dy}<0?ents[0].iy+2<=${(s.hy-3)*4}:ents[0].iy>=${(s.hy+4)*4})out=true;}return {out,overlap:mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)};})()`);
check(`a bar crosses vertical joins at diagonal ${v}`,r.out && !r.overlap,JSON.stringify(r));
run('for(const e of ents.slice())removeEnt(e);');
rect(run,tailX*4,(s.by-3*s.dy)*4,tailX*4+3,(s.by-3*s.dy)*4+3,run('SAND'));
const grain=run(`(()=>{let out=0,leak=0;for(let k=0;k<500;k++){simTick();for(const f of flyers)if(f.m===SAND){if(${s.dy}<0?f.y<${(s.hy-3)*4}:f.y>=${(s.hy+4)*4})out++;else leak++;}flyers=[];}return {out,leak};})()`);
check(`sand crosses vertical joins at diagonal ${v}`,grain.out===16 && !grain.leak,JSON.stringify(grain));
}
for(const v of [4,5]) {
const s=line(v);
run('for(let bx=10;bx<16;bx++)placeBlock(BP_BELT,1,bx,'+s.by+',true);spawnBar(BARCU,44,'+(s.by*4-2)+',0,0);');
check(`a belt feeds a bar into diagonal ${v}`,watchBars(s).out===1);
// Both diagonal outlet grades discharge onto the joined belt in E.
const b=line(v), beltY=b.hy+b.dy;
run(`for(let k=1;k<=8;k++)placeBlock(BP_BELT,1,${b.head}+k,${beltY},true);`);
rect(run,b.bx*4,b.by*4,b.bx*4+3,b.by*4+3,run('SAND'));
const onBelt=run(`(()=>{let seen=false;for(let k=0;k<300;k++){simTick();for(let x=${(b.head+2)*4};x<${(b.head+8)*4};x++)if(mat[${beltY*4-1}*W+x]===SAND)seen=true;}return seen;})()`);
check(`diagonal ${v} unloads sand onto a belt`,onBelt);
run('for(let i=0;i<N;i++)if(mat[i]===SAND)setCell(i,EMPTY,20,0);flyers=[];');
putBar(b.bx*4,b.by*4+1);
check(`diagonal ${v} unloads a bar onto a belt`,run(`(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=${(b.head+2)*4} && ents[0].iy===${beltY*4-2})return true;}return false;})()`));
}
const savedRide=line(4);
putBar(savedRide.bx*4,savedRide.by*4+1);ticks(run,24);
run('globalThis.rideSave=serializeWorld();deserializeWorld(rideSave.meta,rideSave.buffers);');
check('a bar saved mid-diagonal resumes its ride',watchBars(savedRide).out===1);
// Mouth obstruction must hold the bar, and clearing it must resume transport.
const blocked=line(4);
putBar(blocked.bx*4,blocked.by*4+1);
rect(run,(blocked.head+1)*4,(blocked.hy-1)*4,(blocked.head+2)*4-1,blocked.hy*4-1,run('STONE'));
ticks(run,300);
check('a blocked diagonal mouth holds its bar without wall overlap',run('ents.length===1 && entScrewAt(ents[0].ix,ents[0].iy) && !mat.some((m,i)=>m===STONE && occ[i])'));
rect(run,(blocked.head+1)*4,(blocked.hy-1)*4,(blocked.head+2)*4-1,blocked.hy*4-1,run('EMPTY'));
check('the diagonal bar resumes after the mouth clears',watchBars(blocked).out===1);
for(const v of [4,5,6,7]) {
fresh(); run(`placeBlock(BP_SCREW,${v},20,20,true);`);
run('globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);');
check(`save/load keeps direction ${v} and power`,run(`blockV[20*BW+20]===${v} && part[80*W+80]===${v+7} && isPow[80*W+80]`));
run('globalThis.clip=copyRegion(80,80,83,83);bot.x=100;bot.y=80;undoRecord(\'paste\',()=>pasteGhost(clip,24,20));ghostTick();');
check(`copy/paste keeps direction ${v}`,run(`blockV[20*BW+24]===${v} && blockIntegrity(20*BW+24)===16`));
run('undoLast();');
check(`undo removes pasted diagonal ${v}`,run('blockP[20*BW+24]===0'));
run('globalThis.flipped=flipBlueprint(clip);');
check(`flip maps direction ${v}`,run('flipped.blocks[0][3]')===[7,6,5,4][v-4]);
run('undoRecord(\'cut\',()=>cutRegion(80,80,83,83));undoLast();');
check(`cut and undo restore direction ${v}`,run(`blockV[20*BW+20]===${v} && blockIntegrity(20*BW+20)===16`));
run('money=100;removeBlock(20,20);');
check(`diagonal ${v} refunds the straight screw price`,run('money')===125);
}
// Exercise the actual UI functions without starting a browser or simulating a replacement.
run(fs.readFileSync('src/ui/selection.js','utf8'));
run(fs.readFileSync('src/ui/actions.js','utf8'));
run('globalThis.GAME={pieceDir:{screw:0}};globalThis.hudDirty=false;globalThis.toolId=()=>\'build\';globalThis.piece=()=>PIECE_BY_ID.screw;globalThis.playSfx=()=>{};');
const cycle=run('(()=>{const ds=[];for(let k=0;k<8;k++){ds.push(GAME.pieceDir.screw);rotate();}return ds;})()');
check('R cycles all eight screw directions then wraps',cycle.join(',')==='0,1,2,3,4,5,6,7' && run('GAME.pieceDir.screw')===0,cycle);
for(const [dx,dy,v] of [[5,-4,4],[4,5,5],[-5,4,6],[-4,-5,7]]) {
check(`a diagonal drag picks direction ${v}`,run(`dragDirFor(PIECE_BY_ID.screw,{bx:20,by:20},{bx:${20+dx},by:${20+dy}})`)===v);
check(`direction ${v} lays a corner-touching diagonal run`,run(`blockLine(20,20,${20+dx},${20+dy},true).every(([x,y],k)=>x===20+k*${Math.sign(dx)} && y===20+k*${Math.sign(dy)})`));
}
// The drawing can bridge a joint only in unoccupied air; transport parts stay square.
for (const v of [4,5,6,7]) {
flatWorld(run,240,192,176);
const rising = v===4 || v===6;
run(`placeBlock(BP_SCREW,${v},20,20,true);placeBlock(BP_SCREW,${v},21,${rising ? 19 : 21},true);`);
const x=84, y=rising ? 80 : 83;
check(`diagonal ${v} draws over the empty corner joint`,run(`diagonalScrewBlock(${x},${y},false)>=0 && part[${y}*W+${x}]===0`));
for(const obstacle of ['mat[j]=STONE','mat[j]=SAND','part[j]=P_PIPE','mach[j]=1','occ[j]=1','blockP[(j/W>>2)*BW+(j%W>>2)]=BP_WALL']) {
check(`diagonal ${v} leaves ${obstacle} visible`,run(`(()=>{const j=${y}*W+${x};${obstacle};const hidden=diagonalScrewBlock(${x},${y},true);mat[j]=EMPTY;part[j]=0;mach[j]=0;occ[j]=0;blockP[(${y}>>2)*BW+(${x}>>2)]=0;return hidden<0;})()`));
}
check(`diagonal ${v} keeps its continuation uncapped`,run(`(diagonalScrewJoins(20,20,${v}) & 128)===0 && (diagonalScrewJoins(21,${rising ? 19 : 21},${v}) & 64)===0`));
run(`removeBlock(21,${rising ? 19 : 21});`);
check(`diagonal ${v} has no stray corner after its end`,run(`diagonalScrewBlock(${x+1},${rising ? y-1 : y+1},false)===-1`));
}
fresh();
run('placeBlock(BP_SCREW,5,20,20,true);placeBlock(BP_SCREW,1,21,21,true);');
check('shared rule: downhill low end draws a horizontal elbow in E',run('(diagonalScrewJoins(20,20,5)>>3&7)===5'));
check('shared rule: downhill low end sends its last lane into E',run('diagonalScrewNext(83*W+83,P_SCREW_DR)===87*W+84'));
done();
+56 -2
View File
@@ -16,6 +16,11 @@ const med = { d: depth(), n: opened() };
check('held on a stone wall, medium pulses dig a hole and drop rubble', med.d >= 6 && run('stats.dug') > 30 && count(run, run('RSTONE')) > 30, `${med.d} deep, ${med.n} opened, ${count(run, run('RSTONE'))} rubble`);
wall(); hold(150, 78, 1, 0, 0.3);
check('rock that is shaking but has not broken yet shows it', run('(() => { let n = 0; for (let i = 0; i < N; i++) if (digp[i] > 0 && PH[mat[i]] === PH_SOLID) n++; return n; })()') > 5);
// A distant cursor chooses direction; the pulse still ends at its reach.
flatWorld(run, 240, 160, 100);
run('bot.x = 40; bot.y = 40; player.bitTier = 0; lastPulseTick = -99;');
check('aiming past reach fires without a distance warning', run("toolPulse(220, 42, 1, 0).status") === '');
check('a distant aim still caps the pulse at its reach', run('drillPulses[drillPulses.length - 1].range === drillReach(1)'));
// the widths, with a copper bit so the reach is not what stops them
const wall2 = () => { wall(); run('player.bitTier = 1;'); };
wall2(); hold(150, 78, 1, 0, 2);
@@ -81,10 +86,10 @@ check('and the granite stays whole', count(run, run('GRANITE')) === 116 * 40, co
wall();
run('drillPulses = []; tick += 20;');
const far = JSON.parse(run('JSON.stringify(toolPulse(200, 78, 1, 0))'));
check('aiming past the reach says how far the bit reaches', /Out of reach/.test(far.status), far.status);
check('aiming past the reach fires without a warning', far.fired && !far.status, far.status);
run('drillPulses = []; tick += 20;');
const farW = JSON.parse(run('JSON.stringify(toolPulse(125, 78, 2, 0))')), nearM = JSON.parse(run('tick += 20; JSON.stringify(toolPulse(125, 78, 1, 0))'));
check('a spot a medium pulse reaches is out of reach for a wide one, and it says so', /Out of reach/.test(farW.status) && /wide/.test(farW.status) && !nearM.status, `${farW.status} | ${nearM.status}`);
check('aiming beyond a wide pulse also fires without a warning', farW.fired && !farW.status && nearM.fired && !nearM.status);
// stray bits: a lump of rock left hanging when the rock round it breaks falls apart
const hanging = (m) => {
@@ -134,4 +139,53 @@ run('for (let y = 80; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y * W +
hold(121, 92, 1, 0, 3);
check('a tree trunk in the path is cut into wood chunks', count(run, run('LWOOD')) > 0, count(run, run('LWOOD')));
// Melts that cool loose in the world (magma from the bag, metal spilled outside a mold) are
// ordinary solids the drill can break; built walls, slope corner cells and molds stay protected.
flatWorld(run, 160, 128, 120);
const melt = run(`(() => {
player.bitTier = BIT_TIERS.length - 1; bot.x = 60; bot.y = 60;
for (let y = 80; y < 88; y++) for (let x = 60; x < 72; x++) setCell(y * W + x, MAGMA, 1250, 0);
for (let k = 0; k < 2400; k++) simTick();
for (let x = 90; x < 96; x++) setCell(110 * W + x, COPPER, 20, F_CAST); // metal that set outside a mold
let bas = 0, nat = 0, cu = 0, drillable = 0, cuDrill = 0;
for (let i = 0; i < N; i++) {
if (mat[i] === BASALT) { bas++; if (flags[i] & F_NAT) nat++; if (crumbly(i)) drillable++; }
if (mat[i] === COPPER) { cu++; if (crumbly(i)) cuDrill++; }
}
return { bas, nat, drillable, cu, cuDrill };
})()`);
check('magma from the bag cools into natural basalt', melt.bas > 0 && melt.nat === melt.bas, JSON.stringify(melt));
check('that basalt can be drilled', melt.drillable === melt.bas, JSON.stringify(melt));
check('copper that set outside a mold can be drilled for scrap', melt.cu > 0 && melt.cuDrill === melt.cu, JSON.stringify(melt));
check('basalt already stuck without the natural mark can be drilled too',
run('(() => { setCell(20 * W + 20, BASALT, 20, 0); player.bitTier = BIT_TIERS.length - 1; return crumbly(20 * W + 20); })()'));
check('built walls and slope corner cells stay safe from the drill', run(`(() => {
placeBlock(BP_WALL, RSTONE, 5, 5, true); placeBlock(BP_SLOPE_BL, RSTONE, 8, 5, true);
const wallCell = 5 * BS * W + 5 * BS + 1; let seal = -1;
for (let i = 0; i < N; i++) if (slopeSealOwner(i) >= 0 && PH[mat[i]] === PH_SOLID) { seal = i; break; }
return !crumbly(wallCell) && (seal < 0 || !crumbly(seal));
})()`));
check('a mold and what sets inside it stay safe from the drill', run(`(() => {
bag[RSTONE] = 400; bagTotal = 400; const err = placeMoldAt('ingot', 30, 10, RSTONE);
const mo = molds[molds.length - 1]; if (err || !mo) return false;
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) if (crumbly(i)) return false;
return true;
})()`));
// Resonance ping (t50): finds natural veins within reach of the bit, under fog too, and
// nothing else.
flatWorld(run, 240, 160, 150);
const ping = run(`(() => {
player.bitTier = 0; const cx = 100, cy = 100, r = pingRadius();
setCell(cy * W + cx + 10, CUV, 20, F_NAT); setCell(cy * W + cx - 20, FEV, 20, F_NAT);
setCell(cy * W + cx + r + 5, AUV, 20, F_NAT); // out of reach
setCell((cy + 5) * W + cx, CUV, 20, 0); // not natural (set loose)
setCell((cy - 5) * W + cx, STONE, 20, F_NAT); // not a vein
const p = resonancePing(cx, cy);
player.bitTier = BIT_TIERS.length - 1; const far = resonancePing(cx, cy);
return { n: p.cells.length, found: p.found.map(m => MD[m].name), r, farFound: far.found.map(m => MD[m].name), farR: far.r };
})()`);
check('a ping finds natural veins in reach', ping.n === 2 && ping.found.includes('Copper ore vein') && ping.found.includes('Iron ore vein'), JSON.stringify(ping));
check('it skips rock, loose ore and veins out of reach', !ping.found.includes('Gold ore vein') && ping.n === 2, JSON.stringify(ping));
check('a better bit pings farther', ping.farR > ping.r && ping.farFound.includes('Gold ore vein'), JSON.stringify(ping));
check('a ping never changes the world', run('(() => { const h0 = mat.reduce((a, m, i) => (a * 31 + m + i) | 0, 0); resonancePing(100, 100); return h0 === mat.reduce((a, m, i) => (a * 31 + m + i) | 0, 0); })()'));
done();
+29
View File
@@ -0,0 +1,29 @@
// Throughput overlay (t49): grains per second per transport block, counted only while the
// overlay is on, never changing what the simulation does.
const { load, suite, flatWorld, rect } = require('./harness');
const { run } = load();
const { check, done } = suite();
// A flat belt line fed from a sand heap, a screw line going right, and an uphill belt.
function scene() {
flatWorld(run, 240, 160, 140);
run('RS = 7; nextPowerBuild = 0; bot.x = 120; bot.y = 60;');
run('for (let bx = 10; bx < 20; bx++) placeBlock(BP_BELT, 1, bx, 30, true);');
rect(run, 40, 100, 47, 119, run('SAND'));
run('for (let bx = 30; bx < 38; bx++) placeBlock(BP_SCREW, 1, bx, 30, true);');
rect(run, 120, 110, 123, 119, run('SAND'));
run('for (let k = 0; k < 6; k++) placeBlock(BP_BELT_UP, 1, 44 + k, 30 - k, true); placeBlock(BP_BELT, 1, 43, 31, true);');
rect(run, 172, 112, 175, 123, run('SAND'));
}
const hash = () => run('(() => { let h = 0; for (let i = 0; i < N; i++) h = (h * 31 + mat[i]) | 0; return h; })()');
scene(); run('setFlowCounting(false); for (let k = 0; k < 300; k++) simTick();');
const off = hash();
scene(); run('setFlowCounting(true); for (let k = 0; k < 300; k++) simTick();');
check('counting changes nothing in the simulation', hash() === off);
const r = run('({ belt: flowRate[30 * BW + 15], beltEnd: flowRate[30 * BW + 19], screw: flowRate[30 * BW + 34], uphill: Math.max(...[0,1,2,3,4,5].map(k => flowRate[(30 - k) * BW + 44 + k])) })');
check('a loaded flat belt reports grains per second', r.belt > 0, JSON.stringify(r));
check('a loaded screw conveyor reports grains per second', r.screw > 0, JSON.stringify(r));
check('an uphill belt carrying sand reports grains per second', r.uphill > 0, JSON.stringify(r));
check('the rate is grains per second, not per tick (a flat belt moves well under 600)', r.belt < 600, JSON.stringify(r));
scene(); run('setFlowCounting(false); for (let k = 0; k < 120; k++) simTick();');
check('with the overlay off nothing is counted', run('flowCount.every(n => n === 0) && flowRate.every(n => n === 0)'));
done();
+206
View File
@@ -0,0 +1,206 @@
// The oracle states the geometric rule independently of the precomputed neighbour tables.
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
flatWorld(run, 96, 96, 92);
run(`
var joinCases = [];
for (let v = 0; v < 8; v++) joinCases.push([BP_SCREW, v]);
for (const c of [BP_BELT, BP_FILTER, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN])
for (const v of c === BP_FILTER || isFilter(c) ? [0, 127, 128, 255] : [0, 1]) joinCases.push([c, v]);
`);
const cases = run('joinCases');
const screw = run('BP_SCREW');
const dirs = run('SCREW_DIRS');
function describe([code, v]) {
if(code===screw)return {family:'screw',x:dirs[v][0],y:dirs[v][1],diagonal:!!(dirs[v][0]&&dirs[v][1])};
const x=run(`isFilter(${code})`)?(v&128?1:-1):(v&1?1:-1),y=run(`beltGrade(${code})`)*x;
return {family:'belt',x,y,diagonal:!!y};
}
function directed(a,b,dx,dy) {
if(a.x*b.x+a.y*b.y<=0)return false;
if(a.family!==b.family&&((!a.x||!b.x)||(a.diagonal&&b.diagonal)))return false;
if(a.diagonal&&b.diagonal&&(a.x!==b.x||a.y!==b.y))return false;
return dx===a.x&&(dy===(a.family==='belt'&&a.y<0?0:a.y)||
(a.family==='belt'&&b.family==='belt'&&!a.y&&b.y<0&&dy===-1));
}
function oracle(a,b,dx,dy,ae,be) {
const ao=(a.x||a.y)>0?1:0,bo=(b.x||b.y)>0?1:0;
return ae===ao&&be!==bo?directed(a,b,dx,dy):ae!==ao&&be===bo&&directed(b,a,-dx,-dy);
}
let tableErrors = [], symmetryErrors = [], pairCount = 0;
for (const ac of cases) for (const bc of cases) {
const a = describe(ac), b = describe(bc);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const result = run(`(() => {
blockP.fill(0); blockV.fill(0);
const a = 10 * BW + 10, b = (10 + ${dy}) * BW + 10 + ${dx};
blockP[a] = ${ac[0]}; blockV[a] = ${ac[1]}; blockP[b] = ${bc[0]}; blockV[b] = ${bc[1]};
return [a,b,endJoin(10,10,0),endJoin(10,10,1),endJoin(10+${dx},10+${dy},0),endJoin(10+${dx},10+${dy},1),pieceJoins(10,10)];
})()`);
let mask = 0;
for (let ae = 0; ae < 2; ae++) {
let expected = -1;
for (let be = 0; be < 2; be++) if (oracle(a, b, dx, dy, ae, be)) expected = result[1] * 2 + be;
if (expected >= 0) mask |= 1 << ae;
if (result[2 + ae] !== expected) tableErrors.push([ac,bc,dx,dy,ae,expected,result[2+ae]]);
for (let be = 0; be < 2; be++)
if ((result[2+ae] === result[1]*2+be) !== (result[4+be] === result[0]*2+ae)) symmetryErrors.push([ac,bc,dx,dy,ae,be]);
}
if (mask !== result[6]) tableErrors.push(['mask', ac,bc,dx,dy]);
pairCount++;
}
}
check('every kind and travel direction matches the rule throughout the 3 by 3 neighbourhood', !tableErrors.length, `${pairCount} pairs; ${JSON.stringify(tableErrors.slice(0,3))}`);
check('every joined pair names the reciprocal end', !symmetryErrors.length, JSON.stringify(symmetryErrors.slice(0,3)));
function pair(label, a, b, dx, dy, expected) {
check(label, run(`(() => {
blockP.fill(0); blockV.fill(0);
blockP[10*BW+10]=${a[0]}; blockV[10*BW+10]=${a[1]};
blockP[(10+${dy})*BW+10+${dx}]=${b[0]}; blockV[(10+${dy})*BW+10+${dx}]=${b[1]};
return pieceJoins(10,10)!==0;
})()`) === expected);
}
const ids = run('[BP_SCREW,BP_BELT,BP_FILTER,BP_BELT_UP,BP_BELT_DOWN]');
for(const [label,av,bv,dx,dy,yes] of [
['H(right) → DR next same row',1,5,1,0,true],['DR → H(right) diagonally past',5,1,1,1,true],
['H(right) → UR next same row',1,4,1,0,true],['UR → H(right) diagonally past',4,1,1,-1,true],
['V(up) → UR directly above',0,4,0,-1,true],['V(up) → UL directly above',0,7,0,-1,true],
['UR → V(up) diagonally past',4,0,1,-1,true],['DR → V(down) diagonally past',5,2,1,1,true],
['DR → H same row stays free',5,1,1,0,false],['H → DR one right and lower stays free',1,5,1,1,false],
['V(up) → UR diagonally above-right stays free',0,4,1,-1,false]
])pair(label,[screw,av],[screw,bv],dx,dy,yes);
pair('uphill belt outlet stays on its row',[ids[3],1],[ids[1],1],1,0,true);
pair('uphill slope outlet also targets a slope on the same row',[ids[3],1],[ids[3],1],1,0,true);
pair('uphill slope leaves a diagonally adjacent slope free',[ids[3],1],[ids[3],1],1,-1,false);
pair('flat belt feeds uphill low end on same row',[ids[1],1],[ids[3],1],1,0,true);
pair('flat belt also feeds uphill low end from below',[ids[1],1],[ids[3],1],1,-1,true);
check('planned source, neighbour, direction and empty entries override the world', run(`(() => {
blockP.fill(0); blockV.fill(0);
const a=10*BW+10,b=a+1, p=new Map([[a,[BP_BELT,1]],[b,[BP_SCREW,1,'extra']]]);
blockP[a]=BP_WALL; blockP[b]=BP_SCREW; blockV[b]=0;
if(endJoin(10,10,1,p)!==b*2 || endJoin(11,10,0,p)!==a*2+1) return false;
p.set(b,[0,0]);
return endJoin(10,10,1,p)===-1 && endJoin(10,10,1)===-1;
})()`));
check('non-transport pieces, invalid ends and world edges are open without row wrapping', run(`(() => {
blockP.fill(BP_BELT); blockV.fill(0);
return pieceEnds(BP_WALL,0).length===0 && endJoin(-1,0,0)===-1 && endJoin(BW,0,0)===-1 &&
endJoin(0,-1,0)===-1 && endJoin(0,BH,0)===-1 && endJoin(0,0,0)===-1 &&
endJoin(BW-1,BH-1,1)===-1 && endJoin(1,1,2)===-1;
})()`));
check('same-shape inlet continuation beats an elbow',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
for(const [n,v] of [[b,4],[b+BW-1,4],[b-1,1]]){blockP[n]=BP_SCREW;blockV[n]=v;}
return endJoin(10,10,0)===(b+BW-1)*2+1&&endJoin(9,10,1)===-1;
})()`));
check('equal-shape inlet ties use top to bottom then left to right',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
for(const [n,v] of [[b,1],[b-BW-1,5],[b+BW-1,4]]){blockP[n]=BP_SCREW;blockV[n]=v;}
return endJoin(10,10,0)===(b-BW-1)*2+1&&endJoin(9,11,1)===-1;
})()`));
const fs=require('fs');
run(fs.readFileSync('src/render/slope-belts.js','utf8'));
const setup=fs.readFileSync('src/render/setup.js','utf8');
run(setup.slice(setup.indexOf('function beltLike('),setup.indexOf('\n',setup.indexOf('function beltLike('))));
check('filter frames open at an uphill low-end feed and compatible screws',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
blockP[b]=BP_FILTER;blockV[b]=255;blockP[b+1]=BP_BELT_UP;blockV[b+1]=1;
if(!beltLike(b,1))return false;
blockP[b+1]=BP_SCREW;blockV[b+1]=1;if(!beltLike(b,1))return false;
blockV[b+1]=3;return !beltLike(b,1);
})()`));
check('planned flat caps and slope continuations use the shared ends',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
const p=new Map([[b,[BP_BELT_DOWN,1]],[b-1,[BP_FILTER,255]],[b+BW+1,[BP_FILTER_DOWN,128]]]);
const before=JSON.stringify([Array.from(blockP),Array.from(blockV)]);
if(slopeBeltConnections(10,10,BP_BELT_DOWN,p)!==9||flatBeltCaps(b-1,p)!==1)return false;
p.set(b,[0,0]);return flatBeltCaps(b-1,p)===3&&before===JSON.stringify([Array.from(blockP),Array.from(blockV)]);
})()`));
check('a flat belt picks the same-row uphill inlet before the below-low-end exception',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
for(const n of [b,b+1,b-BW+1]){blockP[n]=n===b?BP_BELT:BP_BELT_UP;blockV[n]=1;}
return endJoin(10,10,1)===(b+1)*2&&endJoin(11,9,0)===-1;
})()`));
check('same-row uphill low end draws as two capped ends in placed and planned geometry',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10;
const p=new Map([[b,[BP_BELT_UP,1]],[b-1,[BP_BELT,1]]]);
const j=slopeBeltConnections(10,10,BP_BELT_UP,p);
return j===0&&endJoin(10,10,0,p)>=0&&flatBeltCaps(b-1,p)===3;
})()`));
check('uphill slopes side by side on one row are joined but drawn as two capped ends',run(`(()=>{
blockP.fill(0);blockV.fill(0);const b=10*BW+10,p=new Map([[b,[BP_BELT_UP,1]],[b+1,[BP_BELT_UP,1]]]);
return endJoin(10,10,1,p)>=0&&slopeBeltConnections(10,10,BP_BELT_UP,p)===0&&slopeBeltConnections(11,10,BP_BELT_UP,p)===0;
})()`));
// Downhill face-to-face transfers (screens always travel downhill when placed).
for(const grade of [-1,1])for(const filter of [false,true])for(const outgoing of [false,true])for(const joined of [false,true]) {
flatWorld(run,96,96,92);
const d=grade,c=filter?(grade<0?'BP_FILTER_UP':'BP_FILTER_DOWN'):(grade<0?'BP_BELT_UP':'BP_BELT_DOWN');
const r=run(`(()=>{
const d=${d},c=${c},out=${outgoing},bx=10,by=10;
placeBlock(c,${filter?(d>0?128:0):(d>0?1:0)},bx,by,true);
const fx=bx+(out?d:-d),fy=by+(out?1:0)+(${joined}?0:out?-1:1);
placeBlock(BP_BELT,d>0?1:0,fx,fy,true);
const sx=bx*BS+(d>0?3:0),ix=bx*BS+(d>0?0:3),x=out?sx:ix-d;
const y=out?beltFaceY(c,bx,by,sx):fy*BS;
const targetY=out?fy*BS:beltFaceY(c,bx,by,ix);
setCell((y-1)*W+x,SAND,20,0);tickStamp++;
carryColumn((y-1)*W+x,d,null);
return [endJoin(bx,by,out?(d>0?1:0):(d>0?0:1))>=0,mat[(targetY-1)*W+x+d]===SAND];
})()`);
check(`grade ${grade} ${filter?'screen':'belt'} ${outgoing?'outlet':'inlet'} ${joined?'joined':'free'} sand handoff agrees`,r[0]===joined&&r[1]===joined,r);
}
// The same-row low-end feed drops onto the face before the uphill cleats carry it.
for(const grade of [-1,1])for(const below of [false,true]) {
flatWorld(run,160,128,120);
const d=-grade,code=grade<0?'BP_BELT_UP':'BP_BELT_DOWN';
const r=run(`(()=>{
const d=${d},c=${code},v=${d>0?1:0},bx=20,by=20,fy=by+${below?1:0};
placeBlock(BP_BELT,d>0?1:0,bx-d,fy,true);
for(let k=0;k<4;k++)placeBlock(c,v,bx+k*d,by-k,true);
rebuildPower();const source=fy*BS-1,x=(bx-d)*BS+(d>0?3:0);
setCell(source*W+x,SAND,20,0);
let seen=false;
for(let k=0;k<320;k++) {simTick();for(let i=0;i<N;i++)if(mat[i]===SAND&&((i/W|0)<by*BS-4))seen=true;}
return [endJoin(bx-d,fy,d>0?1:0)>=0,seen];
})()`);
check(`grade ${grade} belt low end accepts sand from ${below?'below':'same row'} and carries it up`,r[0]&&r[1],r);
}
// Snapping (t67 phase 4): a drag moves one block up or down only to make a seamless join
// involving a diagonal piece, and only when every block there is free.
const snap = (setup, code, v, cells) => { flatWorld(run, 96, 96, 92); return run(`(()=>{${setup};return joinSnap(${code},${v},${JSON.stringify(cells)});})()`); };
check('an uphill belt started beside a flat belt on the same level snaps up one to join seamlessly',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT_UP', 1, [[11,10],[12,9],[13,8]]) === -1);
check('the snapped uphill belt then joins the flat belt',
run('(()=>{placeBlocks(BP_BELT_UP,1,[[11,9],[12,8],[13,7]]);return endJoin(10,10,1)===(9*BW+11)*2+0||endJoin(11,9,0)>=0;})()'));
check('an uphill belt already one block up stays put',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT_UP', 1, [[11,9],[12,8]]) === 0);
check('no snap when a block above is taken; the same-row drop join stays',
snap('placeBlock(BP_BELT,1,10,10,true);placeBlock(BP_WALL,RSTONE,12,8,true)', 'BP_BELT_UP', 1, [[11,10],[12,9]]) === 0);
check('a flat belt beside another flat belt one row off never jumps',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT', 1, [[11,11],[12,11],[13,11]]) === 0);
check('a flat belt started level with a downhill belt low end snaps down one (t58)',
snap('placeBlock(BP_BELT_DOWN,1,10,10,true);placeBlock(BP_BELT_DOWN,1,11,11,true)', 'BP_BELT', 1, [[12,11],[13,11]]) === 1);
check('a downhill screw started one block right and one lower than a screw end snaps up to the same row',
snap('placeBlock(BP_SCREW,1,10,10,true)', 'BP_SCREW', 5, [[11,11],[12,12]]) === -1);
check('a screw that already joins stays put',
snap('placeBlock(BP_SCREW,1,10,10,true)', 'BP_SCREW', 5, [[11,10],[12,11]]) === 0);
check('pieces with no join ends never snap', snap('', 'BP_PIPE', 0, [[11,10]]) === 0);
// "Doesn't connect" marks (t67 phase 4): nearMiss says which way one block would make the join.
const miss = (setup, expr) => { flatWorld(run, 96, 96, 92); return run(`(()=>{${setup};return ${expr};})()`); };
check('a flat belt level with a downhill belt low end is marked: place it one lower (t58)',
miss('placeBlock(BP_BELT_DOWN,1,10,10,true);placeBlock(BP_BELT_DOWN,1,11,11,true);placeBlock(BP_BELT,1,12,11,true)', 'nearMiss(12,11,0)') === 1);
check('a flat belt one lower at that low end is joined and unmarked',
miss('placeBlock(BP_BELT_DOWN,1,10,10,true);placeBlock(BP_BELT_DOWN,1,11,11,true);placeBlock(BP_BELT,1,12,12,true)', 'endJoin(12,12,0)>=0 && nearMiss(12,12,0)===0 && nearMiss(11,11,1)===0'));
check('a screw end diagonally off a downhill screw start is marked on both pieces',
miss('placeBlock(BP_SCREW,1,10,10,true);placeBlock(BP_SCREW,5,11,11,true)', '[nearMiss(10,10,1),nearMiss(11,11,0)].join()') === '1,-1');
check('a drop join (flat feeding an uphill low end on its row) counts as connected',
miss('placeBlock(BP_BELT,1,10,10,true);placeBlock(BP_BELT_UP,1,11,10,true)', 'nearMiss(10,10,1)===0 && nearMiss(11,10,0)===0'));
check('straight belts one row apart are never marked',
miss('placeBlock(BP_BELT,1,10,10,true);placeBlock(BP_BELT,1,11,11,true)', 'nearMiss(10,10,1)===0 && nearMiss(11,11,0)===0'));
check('no mark when the block that would fix it is taken',
miss('placeBlock(BP_BELT_DOWN,1,10,10,true);placeBlock(BP_BELT_DOWN,1,11,11,true);placeBlock(BP_BELT,1,12,11,true);placeBlock(BP_WALL,RSTONE,12,12,true)', 'nearMiss(12,11,0)') === 0);
check('ghosts are checked through their planned map without copying it',
miss('ghostBlocks.set(10*BW+10,[BP_SCREW,1]);ghostBlocks.set(11*BW+11,[BP_SCREW,5])', 'nearMiss(10,10,1,ghostBlocks)===1 && ghostBlocks.size===2'));
done();
+146
View File
@@ -0,0 +1,146 @@
// Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
const fs = require('fs');
const path = require('path');
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8'));
run(`
const SET = { lighting: 'full', daylight: 1, ambient: 0.12 };
R.lightC = {};
R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} };
R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80;
function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; }
`);
flatWorld(run, 320, 200, 160);
run(`
bot.lamp = false;
for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true);
for (let by = 21; by < 39; by++) {
placeBlock(BP_WALL, RSTONE, 8, by, true);
placeBlock(BP_WALL, RSTONE, 29, by, true);
}
placeBlock(BP_BELT, 1, 12, 30, true);
placeBlock(BP_SCREW, 0, 14, 30, true);
addMachine('silo', 18, 30, 0);
addMold('bit', 24, 30, RSTONE, true);
for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
updateRoof();
const simRoof = Array.from(roofY);
`);
for (const mode of ['full', 'fast']) {
run(`SET.lighting = '${mode}'; computeLight();`);
for (const channel of ['lr', 'lg']) {
const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`);
check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`);
check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`);
}
// Directly check transmission as daylight can hide an opaque piece in the open.
for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) {
const a = run(`lightAt(R.latt, ${x}, ${y})`);
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
}
}
check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
for (const material of ['STONE', 'SAND', 'WATER']) {
run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95'));
run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
}
run('updateRoof(); computeLight();');
check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1'));
for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) {
run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6'));
run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
}
run('updateRoof();');
run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2'));
run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2'));
flatWorld(run, 320, 200, 160);
run(`
SET.daylight = 1; bot.lamp = false;
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);
for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
for (let x = 180; x < 280; x++) {
setCell(80 * W + x, STONE, AMB[80], F_NAT);
for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
}
for (let y = 40; y <= 76; y += 2) setCell(y * W + 152, SAND, AMB[y], 0);
updateRoof();
`);
for (const mode of ['full', 'fast']) {
run(`SET.lighting = '${mode}'; computeLight();`);
for (const channel of ['lr', 'lg', 'lb']) {
const [belt, open, stone] = run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-6, `${belt} / ${open}`);
check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`);
const [streamGround, openGround] = run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-6, `${streamGround} / ${openGround}`);
}
run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
const a = run('lightAt(R.latt, 80, 76)');
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
}
for (const [name, support, depth, gap, expected] of [
['left belt', 'mat[i] = BELTL', 12, '', true],
['right belt', 'mat[i] = BELTR', 12, '', true],
['filter', 'part[i] = P_FILTER', 12, '', true],
['deep heap', 'mat[i] = BELTR', 13, '', false],
['air gap', 'mat[i] = BELTR', 3, 'EMPTY', true],
['gas gap', 'mat[i] = BELTR', 3, 'STEAM', true],
['12 falling grains', '', 12, '', true],
['13 falling grains', '', 13, '', false],
['natural ledge', 'mat[i] = STONE; flags[i] = F_NAT', 3, '', false],
['deep natural heap', 'mat[i] = SAND; flags[i] = F_NAT', 13, '', false],
['wall floor', 'mat[i] = STONE', 3, '', true],
['screw', 'part[i] = P_SCREW_R', 3, '', true],
['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true],
]) {
run(`
for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; }
{ const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; }
${gap ? `mat[41 * W + 300] = ${gap};` : ''}
`);
check(`${name}: loose cell detection`, run('looseForLight(40 * W + 300)') === expected);
}
run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
check('a single grain in mid-air is see-through', run('looseForLight(40 * W + 300)'));
run('mat[(H - 1) * W + 300] = SAND;');
check('loose cell detection stops at the world bottom', run('!looseForLight((H - 1) * W + 300)'));
flatWorld(run, 320, 200, 160);
run(`
bot.lamp = false; stats.refined = 0;
placeBlock(BP_GRIND, 0, 20, 20, true);
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true);
setCell(79 * W + 80, SAND, AMB[79], 0);
`);
check('sand resting on grinder housing is see-through', run('looseForLight(79 * W + 80)'));
for (const mode of ['full', 'fast']) {
const result = run(`(() => {
SET.lighting = '${mode}';
let mismatches = 0, first = '';
for (let t = 0; t < 180; t++) {
if (t % 4 === 0) setCell(48 * W + 80, SAND, AMB[48], 0);
simTick();
computeLight();
for (const a of [R.lr, R.lg, R.lb]) for (let x = 76; x < 88; x += 2) {
const below = lightAt(a, x, 160), open = lightAt(a, 152, 160);
if (Math.abs(below - open) > 1e-6) {
mismatches++;
if (!first) first = 'tick ' + tick + ', x ' + x + ': ' + below + ' / ' + open;
}
}
}
return { mismatches, first };
})()`);
check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow');
}
check('the streaming grinder actually processes its input', run('stats.refined > 0'), run('stats.refined'));
done();
+32
View File
@@ -0,0 +1,32 @@
// Blowing aims within the nozzle's reach, with room for the brush and settling.
const { load, suite, flatWorld, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
function blowSand(distance, radius) {
flatWorld(run, 640, 160, 100);
run('RS = 12345; bot.x = 300 - bot.w / 2; bot.y = 80 - bot.h / 2; bag[SAND] = 40; bagTotal = 40;');
run(`for (let k = 0; k < 40 && bag[SAND]; k++) { toolBlow(300 + ${distance}, 99, ${radius}, SAND); simTick(); }`);
ticks(run, 400);
check('all blown sand lands and stays out of the bag', count(run, run('SAND')) === 40 && run('flyers.length === 0 && bag[SAND] === 0'));
return run(`(() => {
let farthest = 0, cursorDistance = 0;
for (let i = 0; i < N; i++) if (mat[i] === SAND) {
const x = i % W + 0.5, y = Math.floor(i / W) + 0.5;
farthest = Math.max(farthest, Math.hypot(x - 300, y - 80));
cursorDistance = Math.max(cursorDistance, Math.hypot(x - (300 + ${distance} + 0.5), y - 99.5));
}
return { farthest, cursorDistance };
})()`);
}
// Widest brush, 40 grains on flat ground: measured 14.95 grains past REACH.
const BRUSH_AND_SETTLING_MARGIN = 16;
for (const distance of [run('3 * REACH'), run('5 * REACH')]) {
const landing = blowSand(distance, 6);
check('aiming far away lands sand within reach plus brush and settling', landing.farthest <= run('REACH') + BRUSH_AND_SETTLING_MARGIN, landing.farthest.toFixed(2));
}
const nearby = blowSand(30, 0);
check('aiming inside reach still lands sand around the cursor', nearby.cursorDistance <= BRUSH_AND_SETTLING_MARGIN, nearby.cursorDistance.toFixed(2));
done();
+2 -2
View File
@@ -64,10 +64,10 @@ check('missing rack metadata defaults to empty and closed', run('rackState.get(2
fresh(3);
run('rackPush(rackState.get(20 * BW + 10), BARFE, 3); rackPush(rackState.get(22 * BW + 10), BARCU, 1); interactAt(40, 84); for (let t = 0; t < 20; t++) rackTick();');
check('refilling crosses an empty middle block without losing FIFO order', run('ents[0].m === BARCU && rackState.get(22 * BW + 10).n === 3 && rackState.get(20 * BW + 10).n === 0'));
for (const delivery of ['belt', 'tube', 'thrown', 'mold']) {
for (const delivery of ['belt', 'screw', 'thrown', 'mold']) {
fresh();
if (delivery === 'belt') run('for (let bx = 6; bx <= 9; bx++) placeBlock(BP_BELT, 1, bx, 20, true); spawnBar(BARCU, 30, 78);');
if (delivery === 'tube') run('placeBlock(BP_TUBE, 2, 10, 17, true); placeBlock(BP_TUBE, 2, 10, 18, true); spawnBar(BARCU, 40, 68);');
if (delivery === 'screw') run('placeBlock(BP_SCREW, 2, 10, 17, true); placeBlock(BP_SCREW, 2, 10, 18, true); spawnBar(BARCU, 40, 68);');
if (delivery === 'thrown') run('spawnBar(BARCU, 34, 66, 0.35, -0.3);');
if (delivery === 'mold') run("addMold('ingot', 10, 17, RSTONE, true); for (const i of molds[0].cavs[0].slice(0, 8)) setCell(i, COPPER, 20, F_CAST); checkMolds();");
run('for (let t = 0; t < 300; t++) simTick();');
+17
View File
@@ -0,0 +1,17 @@
// Stored snow keeps its identity and remains in the world's water budget.
const { load, suite, flatWorld, ticks } = require('./harness');
const { run } = load();
const { check, done } = suite();
flatWorld(run, 240, 160, 120);
run("globalThis.snowSilo = addMachine('silo', 20, 20, 0); setCell(80 * W + 85, SNOW, -10, 0); stamp[80 * W + 85] = 0; tickStamp = 1;");
const before = run('worldWater()');
run('siloTick(snowSilo)');
check('silo accepts snow and keeps its name', run('snowSilo.sm === SNOW && snowSilo.sn === 1'));
check('snow inside still counts as water', run('worldWater()') === before);
run('for (let y = 80; y < 104; y++) for (let x = 80; x < 92; x++) temp[y * W + x] = 100;');
ticks(run, 180);
check('stored snow does not melt in a warm silo', run('snowSilo.sm === SNOW && snowSilo.sn === 1'));
run('snowSilo.open = true; siloTick(snowSilo)');
check('opening the gate releases snow, then the silo is empty', run('snowSilo.sn === 0 && snowSilo.sm === EMPTY && mat[103 * W + 84 + (tick & 3)] === SNOW'));
check('releasing snow preserves the water count', run('worldWater()') === before);
done();
+159
View File
@@ -0,0 +1,159 @@
// Drawing geometry against real parts, without changing transport.
const fs = require('fs');
const path = require('path');
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
run(fs.readFileSync(path.join(__dirname, '../src/render/slope-belts.js'), 'utf8'));
flatWorld(run, 160, 120, 112);
for (const code of ['BP_BELT_UP', 'BP_BELT_DOWN', 'BP_FILTER_UP', 'BP_FILTER_DOWN']) {
run(`
mat.fill(EMPTY); part.fill(0); blockP.fill(0); blockV.fill(0);
var artCode = ${code}, artGrade = beltGrade(artCode), artB = 12 * BW + 12;
for (let k = -2; k <= 2; k++) placeBlock(artCode, artGrade > 0 ? 1 : 0, 12 + k, 12 + artGrade * k, true);
prepareSlopeBelts(0, 0, W, H);
`);
check(`${code}: downhill diagonal neighbours continue the band`, run('(slopeBeltJoins[artB] & 12) === 12'));
check(`${code}: corner seals use the same band`, run(`(() => {
const i = slopeSeal(artCode, 12, 12);
return i < 0 || slopeBeltAt(i, i % W, (i / W) | 0) >= 0;
})()`));
check(`${code}: band is as thick as a flat belt`, run(`(() => {
const g = artGrade, x = 50 - g * 1.85 * Math.SQRT1_2, y = 50 + 1.85 * Math.SQRT1_2;
return slopeBeltShape(x, y, artB) < -1.8 &&
slopeBeltShape(x - g * 1.7 * Math.SQRT1_2, y + 1.7 * Math.SQRT1_2, artB) < 0 &&
slopeBeltShape(x - g * 2.1 * Math.SQRT1_2, y + 2.1 * Math.SQRT1_2, artB) > 0;
})()`));
check(`${code}: air below the triangle gets the continuous band`, run(`(() => {
for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) {
const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
if (!blockP[own] && mat[i] === EMPTY && slopeBeltAt(i, x, y) >= 0) return true;
}
return false;
})()`));
run(`
for (let end = 0; end < 2; end++) {
const side = end ? 1 : -1;
removeBlock(12 + side, 12 + side * artGrade, true);
placeBlock(BP_BELT, artGrade > 0 ? 1 : 0, 12 + side, 12 + (end ? artGrade > 0 : artGrade < 0), true);
}
prepareSlopeBelts(0, 0, W, H);
`);
check(`${code}: both flat joints form elbows`, run('(slopeBeltJoins[artB] & 3) === 3'));
check(`${code}: adjoining flats do not round off at the joint`, run(`(() => {
const l = (12 + (artGrade < 0)) * BW + 11, r = (12 + (artGrade > 0)) * BW + 13;
return !(flatBeltCaps(l) & 2) && !(flatBeltCaps(r) & 1);
})()`));
check(`${code}: overhang never covers terrain, parts, machines or bars`, run(`(() => {
for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) {
const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
if (blockP[own] || mat[i] !== EMPTY || slopeBeltAt(i, x, y) < 0) continue;
mat[i] = STONE; const ground = slopeBeltAt(i, x, y); mat[i] = EMPTY;
part[i] = P_GRATE; const built = slopeBeltAt(i, x, y); part[i] = 0;
mach[i] = 1; const machine = slopeBeltAt(i, x, y); mach[i] = 0;
occ[i] = 1; const bar = slopeBeltAt(i, x, y); occ[i] = 0;
return ground < 0 && built < 0 && machine < 0 && bar < 0;
}
return false;
})()`));
}
check('free end rounds its corners and stops at the end', run(`(() => {
const b = artB, x = 48 - Math.SQRT2, y = 48 + Math.SQRT2;
blockP[b] = BP_BELT_DOWN;
return slopeBeltShape(x + 0.8, y + 0.8, b, 0) < 0 && slopeBeltShape(x - 1, y - 1, b, 0) > 0;
})()`));
check('flat belts still meet diagonal screw elbows without an end cap', run(`(() => {
blockP[artB - 1] = BP_BELT;
blockP[artB] = BP_SCREW; blockV[artB] = 5; // same-row flat at the high end
const joined = !(flatBeltCaps(artB - 1) & 2);
blockP[artB] = BP_BELT_DOWN;
return joined;
})()`));
check('unpowered rollers and studs stand still', run(`(() => {
slopeBeltJoins[artB] &= ~16;
for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++)
if (slopeBeltColor(artB, x, y, 0, 150, 170, 190) !== slopeBeltColor(artB, x, y, 20, 150, 170, 190)) return false;
return true;
})()`));
check('powered rollers and studs reverse with the belt', run(`(() => {
slopeBeltJoins[artB] |= 16;
let moves = false;
for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++) {
blockV[artB] = 1;
const a = slopeBeltColor(artB, x, y, 12, 150, 170, 190), stopped = slopeBeltColor(artB, x, y, 0, 150, 170, 190);
blockV[artB] = 0;
if (a !== slopeBeltColor(artB, x, y, -12, 150, 170, 190)) return false;
if (a !== stopped) moves = true;
}
return moves;
})()`));
check('drawing leaves the seed, cells and saved block data unchanged', run(`(() => {
const snapshot = () => JSON.stringify([RS, Array.from(mat), Array.from(part), Array.from(blockP), Array.from(blockV)]);
const before = snapshot();
prepareSlopeBelts(0, 0, W, H);
for (let y = 40; y < 64; y++) for (let x = 40; x < 72; x++) {
const b = slopeBeltAt(y * W + x, x, y);
if (b >= 0) slopeBeltColor(b, x, y, 12, 150, 170, 190);
}
return snapshot() === before;
})()`));
for (const grade of [-1,1]) {
flatWorld(run,160,120,112);
run(`for(let k=0;k<6;k++)placeBlock(${grade<0?'BP_BELT_UP':'BP_BELT_DOWN'},${grade<0?1:0},12+k,${grade<0?20:12}+k*${grade},true);`); // uphill, the cleated way
for(let phase=0;phase<8;phase++) {
run(`rebuildPower();slopePhase.fill(${phase});slopeCleatsTick(false);prepareSlopeBelts(0,0,W,H);`);
check(`grade ${grade} phase ${phase} draws exactly the physical cleat raster`,run(`(()=>{
let drawn=0;
for(let i=0;i<N;i++){const x=i%W,y=(i/W)|0,detail=slopeDetailAt(i,x,y);if(detail===1)drawn++;
if((detail===1)!==Boolean(cleatMask[i] && !part[i] && !occ[i] && !mach[i] && PH[mat[i]]!==PH_SOLID))return false;
}return drawn>0;
})()`));
}
}
flatWorld(run,160,120,112);
run('placeBlock(BP_FILTER_DOWN,128,12,12,true);tick=0;prepareSlopeBelts(0,0,W,H);var idleJoins=slopeBeltJoins[12*BW+12];tick=2;prepareSlopeBelts(0,0,W,H);');
check('empty screen has a stable frame',run('slopeBeltJoins[12*BW+12]===idleJoins'));
run('setCell(47*W+48,SAND,20,0);prepareSlopeBelts(0,0,W,H);');
check('loaded screen shakes the frame with the simulation tick',run('(slopeBeltJoins[12*BW+12]&64)!==0'));
run('tick=4;prepareSlopeBelts(0,0,W,H);');
check('loaded frame returns on the next shake beat',run('(slopeBeltJoins[12*BW+12]&64)===0'));
flatWorld(run,160,120,112);
run('for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,12+k,20-k,true);rebuildPower();slopeCleatsTick(false);');
check('wall reaches the belt and cleat height, and clips its free ends',run(`(()=>{
const b=20*BW+12,g=-1,sy=84;
for(const depth of [0.5,3,5.5])
if(slopeWallShape(50+g*depth*Math.SQRT1_2,sy-2-depth*Math.SQRT1_2,b)>0)return false;
return slopeWallShape(46,sy+2,b)>0 && slopeWallShape(54,sy-6,b)>0;
})()`));
check('far wall is filled down to the belt and grains cover it',run(`(()=>{
prepareSlopeBelts(0,0,W,H);
let wall=0;
for(let y=8;y<100;y++)for(let x=8;x<140;x++) {
const i=y*W+x;
if(slopeDetailAt(i,x,y)!==2)continue;
wall++;
mat[i]=SAND;const covered=slopeDetailAt(i,x,y)===0;mat[i]=EMPTY;
if(!covered)return false;
}
return wall>20;
})()`));
flatWorld(run,160,120,112);
run('for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,0,12+k,20-k,true);rebuildPower();slopeCleatsTick(false);prepareSlopeBelts(0,0,W,H);');
check('a downhill belt draws no wall and no ribs',run('(()=>{let n=0;for(let i=0;i<N;i++)if(slopeDetailAt(i,i%W,(i/W)|0))n++;return n===0 && cleatCells.length===0;})()'));
flatWorld(run,320,240,232);
run('for(let k=0;k<8;k++)placeBlock(BP_BELT_UP,1,12+k,30-k,true);for(let t=0;t<120;t++)simTick();');
check('idle empty slope lets every moving area sleep',run('awake.every(v=>v===0)'),run('awake.reduce((n,v)=>n+(v>0),0)'));
check('cleat chunk flags include neighbours across chunk borders',run(`(()=>{
for(const i of cleatCells) {
const x=i%W,y=(i/W)|0;
for(let yy=y-1;yy<=y+1;yy++)for(let xx=x-1;xx<=x+1;xx++)
if(xx>=0 && xx<W && yy>=0 && yy<H && !cleatChunks[(yy>>5)*CW+(xx>>5)])return false;
}
return cleatCells.length>0 && cleatChunks.some(v=>v===0);
})()`));
check('a load next to a cleat wakes after the empty line slept',run(`(()=>{
const i=cleatCells[0];setCell(i,SAND,20,0);awake.fill(0);
slopeCleatsTick(false);
return awake.some(v=>v>0) && awake[0]===0;
})()`));
done();
+259
View File
@@ -0,0 +1,259 @@
// Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const fs = require('fs');
const { run } = load(), { check, done } = suite();
function fresh() {
flatWorld(run, 256, 224, 208);
run('RS=7;nextPowerBuild=0;money=10000;AMB.fill(20);temp.fill(20);hot.fill(0);');
}
fresh();
check('slope belts use new codes, without renumbering old slopes', run("typeof BP_BELT_UP !== 'undefined' && BP_BELT_UP > BP_SLOPE_TR"));
function line(grade, filter = false, dir = 1) {
fresh();
if (filter) dir = grade > 0 ? 1 : 0;
const by = grade < 0 ? 32 : 16, inlet = by + (grade < 0 ? 1 : 0), outlet = by + 9 * grade + (grade > 0 ? 1 : 0);
run(`for(let k=0;k<10;k++) placeBlock(${filter ? 'BP_FILTER_UP' : 'BP_BELT_UP'}+${grade>0?1:0},${filter?dir*128:dir},16+k,${by}+k*${grade},true,{m:[SAND],inv:0});
for(let x=10;x<16;x++)placeBlock(BP_BELT,${dir},x,${inlet},true);
for(let x=26;x<32;x++)placeBlock(BP_BELT,${dir},x,${outlet},true);`);
return { by, inlet: filter && grade < 0 ? outlet : inlet, outlet, grade, left: filter && grade < 0, feedX: filter && grade < 0 ? 120 : 44 };
}
function collect(s, steps, feed = false, sink = 128) {
return run(`(()=>{let out=0,added=0;for(let k=0;k<${steps};k++){
${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+${s.feedX || 44};if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''}
simTick();
for(let i=0;i<N;i++)if(mat[i]===SAND && ${s.left ? "i%W<40" : "i%W>="+sink}){out++;setCell(i,EMPTY,20,0);}
for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && ${s.left ? "flyers[j].x<40" : "flyers[j].x>="+sink}){out++;flyers.splice(j,1);}
}return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`);
}
const rates=[];
for(const grade of [0,-1,1]) {
let s;
if(grade) s=line(grade);
else {fresh();run('for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);');s={inlet:24};}
collect(s,220,true);
const r=collect(s,600,true);rates.push(r.out/10);
// uphill pockets carry about as much as a flat belt, and downhill runs like one
const floor = 60;
check(`grade ${grade} carries a generous feed at least ${floor} grains/s`,r.out>0 && r.out/10>=floor,JSON.stringify(r));
const end=collect(s,600);
check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end));
}
console.log('Throughput grains/s flat/up/down: '+rates.join('/'));
for(const grade of [-1,1]) {
const s=line(grade);
rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND'));
rect(run,39,20,39,s.inlet*4-1,run('STONE'));
const r=collect(s,1000);
check(`grade ${grade} conserves and delivers every grain across both flat joins`,r.out===128 && r.left===0,JSON.stringify(r));
line(grade);
run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
ticks(run,500);
check(`grade ${grade} carries a bar through both flat joins`,run('ents.length===1 && ents[0].ix>=128'),run('ents.map(e=>[e.ix,e.iy])'));
}
for(const grade of [-1,1]) {
const s=line(grade);
rect(run,68,s.by*4-12,71,s.by*4-5,run('WATER'));
const before=count(run,run('WATER'));
ticks(run,600);
check(`water drains through grade ${grade} and is conserved`,count(run,run('WATER'))===before && count(run,run('WATER'),0,200,255,207)===before);
// A completely full face held at its head must not unload backwards or through corners.
line(grade);
run(`for(let x=64;x<104;x++){const by=${s.by}+((x>>2)-16)*${grade},c=blockP[by*BW+(x>>2)],y=beltFaceY(c,x>>2,by,x);for(let l=1;l<=4;l++)setCell((y-l)*W+x,SAND,20,0);}`);
rect(run,104,0,104,s.outlet*4-1,run('STONE'));
ticks(run,300);
const held=run('mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length');
check(`blocked grade ${grade} conserves its load and lets overflow roll back`,held===160 && (grade>0 || count(run,run('SAND'),0,0,63,207)>0),held);
rect(run,104,0,104,s.outlet*4-1,run('EMPTY'));
const released=collect(s,800);
check(`grade ${grade} resumes after the head clears`,released.out===160 && released.left===0,JSON.stringify(released));
line(grade);
run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});setCell((${s.by+9*grade}*4+${grade<0?0:3}-1)*W+103,SAND,20,0);tick=2;tickStamp++;machinesTick();`);
check(`grade ${grade} launches off an open end along its face`,run(`flyers.length===1 && flyers[0].vx>0 && Math.sign(flyers[0].vy)===${grade}`));
run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
const barLaunch=run('(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=104)return ents[0].vx>0;}return false;})()');
check(`grade ${grade} keeps a bar moving forward off an open end`,barLaunch);
for(const diagonal of [false,true]) {
line(grade);
run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});for(let k=0;k<3;k++)placeBlock(BP_SCREW,${diagonal?(grade<0?4:5):1},26+k,${s.by+9*grade+(grade<0?-1:0)}+k*${diagonal?grade:0},true);`);
rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND'));
rect(run,39,20,39,s.inlet*4-1,run('STONE'));
const r=collect(s,1000,false,116);
check(`grade ${grade} unloads grains into a ${diagonal?'diagonal':'straight'} screw`,r.out===128 && r.left===0,JSON.stringify(r));
run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);
ticks(run,600);
check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} screw`,run('ents.length===1 && ents[0].ix>=116 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'),run('ents.map(e=>[e.ix,e.iy])'));
}
}
for(const grade of [-1,1]) {
const s=line(grade,true);
run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:1});');
collect(s,220,true);
const r=collect(s,600,true);
console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`);
check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r));
}
for(const grade of [-1,1]) for(const inv of [0,1]) {
const s=line(grade,true);
run(`for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:${inv}});`);
rect(run,s.left?128:39,20,s.left?128:39,s.inlet*4-1,run('STONE'));
run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=${s.left?112:40};x<${s.left?128:56};x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`);
const result=run(`(()=>{const out=[0,0],below=[0,0];for(let k=0;k<1200;k++){
simTick();
for(let i=0;i<N;i++)if(mat[i]===SAND || mat[i]===GRAVEL){const m=mat[i]===SAND?0:1,x=i%W,y=(i/W)|0;
if(${s.left?"x<40":"x>=128"}){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}}
for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && ${s.left?"p.x<40":"p.x>=128"}){out[p.m===SAND?0:1]++;flyers.splice(f,1);}}
}return {out,below,left:mat.reduce((n,m)=>n+(m===SAND || m===GRAVEL),0)+flyers.filter(f=>f.m===SAND || f.m===GRAVEL).length};})()`);
console.log(`Sorting grade ${grade} inverse ${inv}: `+JSON.stringify(result));
check(`grade ${grade} ${inv?'carries selected':'drops selected'} and conserves the full mix`,result.out[inv?0:1]===64 && result.below[inv?1:0]===64 && result.left===0 && result.out[inv?1:0]===0 && result.below[inv?0:1]===0,JSON.stringify(result));
}
for(const c of ['BP_BELT_UP','BP_BELT_DOWN','BP_FILTER_UP','BP_FILTER_DOWN'])for(const d of [0,1]) {
fresh();
run(`placeBlock(${c},${c.includes('FILTER')?d*128:d},20,20,true,{m:[SAND,BARCU],inv:1});globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);`);
check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?'filterVariant('+c+','+d+'*128)':d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`));
if(c.includes('BELT')) {
run(`slopePhase[20*BW+20]=${d?1:7};rebuildPower();slopeCleatsTick(false);globalThis.savedPhase=serializeWorld();deserializeWorld(savedPhase.meta,savedPhase.buffers);`);
// only an uphill run has cleats
const up=(c==='BP_BELT_UP')===(d===1);
check(`${c} saves the moving cleat phase and immediately restores its barriers`,run(`slopePhase[20*BW+20]===${d?1:7} && cleatCells.length${up?'>0':'===0'}`));
}
run(`globalThis.clip=copyRegion(80,80,83,83);bot.x=96;bot.y=80;undoRecord('paste',()=>pasteGhost(clip,24,20));ghostTick();`);
check(`${c} ${d} copy/paste keeps all settings`,run('blockP[20*BW+24]===blockP[20*BW+20] && blockV[20*BW+24]===blockV[20*BW+20] && sameFilter(filterInfo(20*BW+20),filterInfo(20*BW+24))'));
run('undoLast();');
check(`${c} ${d} undo clears the paste including its seal`,run(`blockP[20*BW+24]===0 && blockCellList(24,20).every(i=>mat[i]===EMPTY && !part[i]) && mat[slopeSeal(${c},24,20)]===EMPTY && !part[slopeSeal(${c},24,20)]`));
run("undoRecord('cut',()=>cutRegion(80,80,83,83));undoLast();");
check(`${c} ${d} undo restores a cut`,run(`blockP[20*BW+20]===${c} && blockIntegrity(20*BW+20)===10`));
check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?'filterVariant('+c+'+'+(c.endsWith('UP')?1:-1)+',0)':1-d};})()`));
}
for(const grade of [-1,1]) {
const s=line(grade,true);
run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500);
check(`sloped filter grade ${grade} slides a bar it does not pass downhill`,run(`ents.length===1 && ${s.left?'ents[0].ix<40':'ents[0].ix>=128'}`));
line(grade,true);
run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500);
check(`sloped filter grade ${grade} drops a selected bar through its open frame`,run('ents.length===1 && ents[0].ix<104 && ents[0].iy===206 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'));
}
// Reverse the motor without flipping the upward-facing triangle.
for(const grade of [-1,1]) {
const s=line(grade,false,0);
rect(run,112,s.outlet*4-8,127,s.outlet*4-1,run('SAND'));
rect(run,128,20,128,s.outlet*4-1,run('STONE'));
const r=run(`(()=>{let out=0;for(let k=0;k<1000;k++){simTick();for(let i=0;i<N;i++)if(mat[i]===SAND && i%W<40){out++;setCell(i,EMPTY,20,0);}for(let f=flyers.length-1;f>=0;f--)if(flyers[f].m===SAND && flyers[f].x<40){out++;flyers.splice(f,1);}}return {out,left:mat.reduce((n,m)=>n+(m===SAND),0)};})()`);
check(`reversed grade ${grade} delivers every grain to the left`,r.out===128 && r.left===0,JSON.stringify(r));
line(grade,false,0);run(`spawnBar(BARCU,120,${s.outlet*4-2},0,0);`);ticks(run,500);
check(`reversed grade ${grade} carries bars left across both joins`,run('ents.length===1 && ents[0].ix<40'),run('ents.map(e=>[e.ix,e.iy])'));
}
fresh();
run('placeBlock(BP_BELT_UP,1,20,32,true);');
// The ribs hold the load: under a heavy feed no grain is ever inside a rib, and a grain on the bare
// face slides down until a rib stops it, then rides up with that rib.
{
const s=line(-1);
const inRib=run(`(()=>{let worst=0;for(let k=0;k<600;k++){for(let l=1;l<=12;l++){const i=(${s.inlet}*4-l)*W+44;if(mat[i]===EMPTY)setCell(i,SAND,20,0);}
simTick();let n=0;for(const c of cleatCells)if(PH[mat[c]]===PH_POWDER)n++;if(n>worst)worst=n;}return worst;})()`);
check('under a heavy feed no grain is ever inside a rib',inRib===0,inRib);
line(-1);
// a free face cell high on the run, halfway between two ribs
const g=run(`(()=>{for(let x=90;x<100;x++){const b=((32-((x>>2)-16))*BW)+(x>>2),y=beltFaceY(blockP[b],x>>2,32-((x>>2)-16),x);
let near=99;for(let h=0;h<CLEAT_SPACING;h++){if(cleatFoot(x-h,b)||cleatFoot(x+h,b)){near=h;break;}}
if(near>=3){setCell((y-1)*W+x,SAND,20,0);return x;}}return -1;})()`);
const path=run(`(()=>{const xs=[];for(let k=0;k<40;k++){simTick();for(let i=0;i<N;i++)if(mat[i]===SAND){xs.push(i%W);break;}}return xs;})()`);
check('a grain on the bare face slides back down to a rib',g>0 && Math.min(...path)<g,JSON.stringify([g,path.slice(0,12)]));
check('then rides up with its rib',path[path.length-1]>Math.min(...path),JSON.stringify(path.slice(-8)));
}
// A piece added to a running belt takes up its cleat spacing, so no rib starts at the seam and
// pushes its pocket into the one ahead (that stalled the whole run).
for(const wait of [3,7,11,13]){
fresh();
run(`for(let k=0;k<9;k++)placeBlock(BP_BELT_UP,1,16+k,40-k,true);for(let k=1;k<=6;k++)placeBlock(BP_BELT,1,16-k,41,true);
for(let t=0;t<${wait};t++)simTick();
for(let k=9;k<12;k++)placeBlock(BP_BELT_UP,1,16+k,40-k,true);for(let k=1;k<=6;k++)placeBlock(BP_BELT,1,27+k,29,true);`);
const r=run(`(()=>{let added=0,past=0;for(let t=0;t<1500;t++){if(t<300&&t%10===0){const i=(41*4-1)*W+50;if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}simTick();}
for(let i=0;i<N;i++)if(mat[i]===SAND&&i%W>=112)past++;return {added,past,same:slopePhase[32*BW+24]===slopePhase[31*BW+25]};})()`);
check(`a piece added ${wait} ticks later shares the run's phase and the run keeps carrying`,r.same && r.added>0 && r.past===r.added,JSON.stringify(r));
}
// A stationary cleat blocks downhill crossings; the bare face still lets grains slide.
for (const grade of [-1,1]) {
const s=line(grade);
run('rebuildPower();slopeCleatsTick(false);');
if(grade>0) check('a downhill run has no cleats',run('cleatCells.length===0'));
else check(`grade ${grade} cleats block every downhill crossing`,run(`cleatCells.length>0 && cleatCells.every(i=>!enter(SAND,PH_POWDER,i,${grade},1) && !enter(SAND,PH_POWDER,i+W+${grade},${grade},1))`));
check(`grade ${grade} bare face slides between steps without stamping the load`,run(`(()=>{
for(let x=72;x<96;x++){
const by=${s.by}+((x>>2)-16)*${grade}, y=beltFaceY(blockP[by*BW+(x>>2)],x>>2,by,x)-1,i=y*W+x;
if(cleatMask[i] || cleatMask[i+W+${grade}])continue;
setCell(i,SAND,20,0);tick=3;tickStamp++;machinesTick();
const unstamped=stamp[i]!==tickStamp;
for(let k=0;k<200 && mat[i]===SAND;k++)powder(i,x,y,SAND);
return unstamped && mat[i]===EMPTY;
} return false;
})()`));
}
fresh();
run('placeBlock(BP_FILTER_DOWN,128,20,20,true,{m:[],inv:0});setCell(79*W+80,GRAVEL,20,0);tickStamp++;tick=SCREEN_NUDGE_TICKS-1;screenNudge(80*W+80,1,null);');
check('screen waits between its configured nudges',run('mat[79*W+80]===GRAVEL && stamp[79*W+80]!==tickStamp'));
run('tick++;screenNudge(80*W+80,1,null);');
check('screen nudges just its mesh contact downhill at the configured rate',run('mat[80*W+81]===GRAVEL && mat[79*W+80]===EMPTY'));
fresh();run('placeBlock(BP_BELT_UP,1,20,32,true);');
run(fs.readFileSync('src/render/slope-belts.js','utf8'));
// The renderer prepares joins and power first; stripe pitch is measured along the rotated band.
run('prepareSlopeBelts(0,0,W,H);');
check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(81,130,0)!==c(82,129,0) && c(81,130,0)===c(82,129,4);})()'));
run(fs.readFileSync('src/ui/selection.js','utf8'));
run(fs.readFileSync('src/ui/actions.js','utf8'));
run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wallMat:RSTONE};globalThis.hudDirty=false;globalThis.aimBlock=()=>({bx:25,by:15});globalThis.toolId=()=>'build';globalThis.playSfx=()=>{};");
for(const id of ['belt','filter'])for(const [dx,dy] of [[5,-4],[4,5],[-5,4],[-4,-5]]) {
run('GAME.drag={bx:20,by:20};');
const c=(id==='belt'?19:21)+(dx*dy>0?1:0);
check(`${id} drag ${dx},${dy} picks the slope shape and permitted travel direction`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${id==='filter'?(dx*dy>0?1:0):(dx>0?1:0)}`));
}
run("globalThis.setStatus=()=>{};globalThis.catOf=()=>0;globalThis.selectTool=()=>{};globalThis.aimBlock=()=>blockOf(GAME.aim.x,GAME.aim.y);");
for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,-3]]) {
fresh();
run(`globalThis.piece=()=>PIECE_BY_ID.${id};GAME.drag={bx:20,by:20};GAME.aim={x:${(20+dx)*4},y:${(20+dy)*4}};bot.x=80;bot.y=80;primaryRelease(false);`);
const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=id==='filter'?(dx*dy>0?128:0):(dx>0?1:0);
check(`${id} release really builds the diagonal in direction ${dx},${dy}`,run(`Array.from({length:4},(_,k)=>{const b=(20+k*${Math.sign(dy)})*BW+20+k*${Math.sign(dx)};return blockP[b]===${c} && blockV[b]===${v};}).every(Boolean)`));
run('pickUnderCursor();rotate();');
check(`${id} pick and R preserve the slope and use its permitted direction`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?v:1-v}`));
run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;');
check(`${id} a single replacement keeps the selected direction`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?v:1-v}`));
}
// The owner's drag: a flat belt, then an uphill drag started beside its end on the same level,
// snaps up one block through the real release so the two join seamlessly.
fresh();
run("globalThis.piece=()=>PIECE_BY_ID.belt;placeBlock(BP_BELT,1,19,20,true);GAME.drag={bx:20,by:20};GAME.aim={x:23*4,y:17*4};bot.x=80;bot.y=80;primaryRelease(false);");
check('uphill drag beside a flat belt end snaps up one and joins it', run('blockP[20*BW+20]===0 && blockP[19*BW+20]===BP_BELT_UP && blockP[16*BW+23]===BP_BELT_UP && endJoin(19,20,1)>=0 && !joinIsDrop(19,20,1,endJoin(19,20,1))'));
// Middle click on the belt band you see, even where it is drawn into the empty block next door.
fresh();
const band=run(`(()=>{globalThis.R=globalThis.R||{smoothOn:false};
for(let k=0;k<4;k++)placeBlock(BP_BELT_UP,1,20+k,24-k,true);prepareSlopeBelts(0,0,W,H);
for(let y=60;y<110;y++)for(let x=70;x<100;x++){const i=y*W+x,own=(y>>2)*BW+(x>>2);
if(!blockP[own] && mat[i]===EMPTY && slopeBeltAt(i,x,y)>=0){GAME.pieceIdx=PIECE_BY_ID.wall.index;GAME.aim={x:x+0.5,y:y+0.5};pickUnderCursor();return GAME.pieceIdx===PIECE_BY_ID.belt.index;}}
return 'no band cell outside its block';})()`);
check('middle click on a sloped belt band outside its block picks the belt',band===true,band);
run(fs.readFileSync('src/ui/selection-pickers.js','utf8'));
const filterLine=line(-1,true);
run("applyFilterRun(filterRun(32*BW+16),{m:[GRAVEL],inv:1,dir:0});");
check('the settings UI updates the matching diagonal filter run',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[GRAVEL],inv:1}) && blockV[b]===0)'));
run('undoLast();');
check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===0)'));
// An actual v2 fixture, before either diagonal belt or filter existed.
const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz')));
globalThis.legacyBeltSave=legacy;
run('deserializeWorld(legacyBeltSave.meta,legacyBeltSave.buffers)');
check('old v2 saves keep all wall slope ids and materials',run('Array.from({length:4},(_,k)=>{const b=6*BW+2+k*3;return blockP[b]===15+k && blockV[b]===RSTONE && blockIntegrity(b)===10;}).every(Boolean)'));
// A slope's seal can sit in the open corner of a neighbouring slope or under a grate or screen;
// taking both away must leave nothing behind (it used to leave an unremovable belt cell).
const strayCells="(()=>{let n=0;for(let i=0;i<N;i++){const m=mat[i];if((m===BELTL||m===BELTR||part[i]===P_FILTER||m===RSTONE)&&!blockP[((i/W|0)>>2)*BW+((i%W)>>2)]&&slopeSealOwner(i)<0)n++;}return n;})()";
for(const [first,second] of [['BP_FILTER_DOWN,128,10,12','BP_BELT_DOWN,0,10,11'],['BP_GRATE,0,10,12','BP_BELT_DOWN,0,10,11'],['BP_FILTER,0,10,12','BP_SLOPE_BL,RSTONE,10,11']]) for(const order of [0,1]) for(const built of [0,1]) {
fresh();
const [a,b]=built?[second,first]:[first,second];
run(`placeBlock(${a},true,{m:[],inv:0});placeBlock(${b},true,{m:[],inv:0});`);
run(order?'removeBlock(10,12);removeBlock(10,11);':'removeBlock(10,11);removeBlock(10,12);');
check(`removing ${first.split(',')[0]} and the ${second.split(',')[0]} seal inside it (built ${built}, removed ${order}) leaves no stray cell`,run(strayCells)===0,run(strayCells));
}
fresh();
run("setCell(40*W+43,BELTL,20,0);globalThis.straySave=serializeWorld();deserializeWorld(straySave.meta,straySave.buffers);");
check('loading a world clears a stray belt cell left by the old removal bug',run('mat[40*W+43]===EMPTY'));
done();
+122
View File
@@ -0,0 +1,122 @@
// Sloped walls: flow, sealed joints, building, refunds and persistence.
const { load, suite, flatWorld, rect, count, ticks } = require('./harness');
const { run } = load();
const { check, done } = suite();
// Wall selection drives the same saved slope codes and sealed funnel geometry.
const fs = require('fs');
run(fs.readFileSync('src/ui/selection.js', 'utf8'));
run(`const GAME = { pieceDir: {}, drag: null }; let hudDirty = false;
function aimBlock() { return { bx: 23, by: 23 }; }`);
function fresh() { flatWorld(run, 240, 192, 176); run('AMB.fill(20);temp.fill(20);'); }
function funnel(sloped, material) {
fresh();
run(`for (const [ax,ay,bx,by] of [[12,12,23,23],[36,12,25,23]]) {
GAME.drag={bx:ax,by:ay};
const code=${sloped ? 'pieceCode(PIECE_BY_ID.wall,{bx,by})' : 'BP_WALL'};
for(const [x,y] of blockLine(ax,ay,bx,by,true)) placeBlock(code,RSTONE,x,y,true);
} GAME.drag=null;`);
rect(run, 80, 20, 119, 29, material);
ticks(run, 1800);
const out = count(run, material, 0, 96, 239, 175);
check(`${sloped ? 'slope' : 'square'} funnel conserves 400 grains`, count(run, material) === 400);
return out;
}
const sand = run('SAND'), water = run('WATER');
const slopeOut = funnel(true, sand), squareOut = funnel(false, sand);
check('all sand leaves the sloped funnel', slopeOut === 400, slopeOut);
check('square steps retain sand', squareOut < 400, squareOut);
check('all water leaves the sloped funnel', funnel(true, water) === 400);
for (const material of ['GRAVEL', 'CUORE', 'LDIRT']) check(`${material} slides down`, funnel(true, run(material)) === 400);
fresh();
run('for (let k=0;k<12;k++) placeBlock(BP_SLOPE_BL,RSTONE,12+k,12+k,true); spawnBar(BARCU,52,35);');
ticks(run, 600);
check('bars slide off the slope', run('ents.length === 1 && ents[0].iy > 96'), run('ents.map(e=>[e.ix,e.iy])'));
for (let c = 15; c <= 18; c++) {
fresh();
run(`bagAdd(RSTONE,100,true); bagAdd(BARCU,10,true);`);
const before = run('bag[RSTONE]');
run(`placeBlock(${c},RSTONE,20,20);`);
check(`slope ${c} costs half a wall`, before-run('bag[RSTONE]') === 8);
check(`slope ${c} has its triangle`, run('blockIntegrity(20*BW+20)') === 10);
run(`undoRecord('cut',()=>cutRegion(80,80,83,83)); undoLast();`);
check(`undo restores orientation ${c}`, run(`blockP[20*BW+20] === ${c}`));
check(`copy and flip preserve material and mirror ${c}`, run(`(()=>{ const bp=flipBlueprint(copyRegion(80,80,83,83)); return bp.blocks[0][2] === ${15+((c-15)^1)} && bp.blocks[0][3]===RSTONE; })()`));
run(`const snap${c}=serializeWorld(); deserializeWorld(snap${c}.meta,snap${c}.buffers);`);
check(`save restores orientation ${c}`, run(`blockP[20*BW+20] === ${c} && blockIntegrity(20*BW+20)===10`));
run(`pasteGhost(copyRegion(80,80,83,83),24,20); bot.x=96;bot.y=80;ghostTick();`);
check(`paste builds orientation ${c}`, run(`blockP[20*BW+24]===${c} && blockV[20*BW+24]===RSTONE`));
run('removeBlock(24,20);removeBlock(20,20)');
check(`slope ${c} refunds half a wall`, run('bag[RSTONE]') === before);
run(`placeBlock(${c},BARCU,20,20);removeBlock(20,20);`);
check(`metal slope ${c} refunds one bar`, run('bag[BARCU]') === 10);
}
check('wall diagonal snaps within 30 percent', run('JSON.stringify(blockLine(2,3,7,7,true))') === JSON.stringify([[2,3],[3,4],[4,5],[5,6],[6,7],[7,8]]));
check('belts stay straight', run('blockLine(2,3,7,8).every(p=>p[1]===3)'));
check('drag chooses below or above and ignores drag reversal', run('slopeCode(5,5,0)===15 && slopeCode(-5,-5,0)===15 && slopeCode(5,-5,0)===16 && slopeCode(5,5,2)===18 && slopeCode(5,-5,2)===17'));
// Probe every open cell along both sides, including all eleven joints. A grain may
// leave an end, so measure crossing only within the interior of the diagonal run.
for (let c = 15; c <= 18; c++) for (const m of ['SAND', 'WATER']) for (const side of [-1, 1]) {
fresh();
const descending = c === 15 || c === 18;
run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${descending ? '12+k' : '23-k'},true);`);
const leaked = run(`(()=>{
const sideAt=(x,y)=>${descending ? 'y-x' : 'x+y-143'};
let leaks=0, probes=0;
for(let y=52;y<92;y++) for(let x=52;x<92;x++) {
const i=y*W+x, d=sideAt(x,y);
if(mat[i]!==EMPTY || Math.sign(d)!==${side} || Math.abs(d)>2) continue;
setCell(i,${m},20,0); probes++;
// Exercise the real movement functions repeatedly without letting grains escape an end.
for(let k=0;k<20 && mat[i]===${m};k++) { tickStamp++; ${m === 'WATER' ? 'liquid(i,x,y,WATER)' : 'powder(i,x,y,SAND)'}; }
for(let yy=y;yy<=y+1;yy++) for(let xx=x-32;xx<=x+32;xx++) {
if(xx<0 || xx>=W)continue;
const j=yy*W+xx;
if(mat[j]===${m}) { if(Math.sign(sideAt(xx,yy))===${-side}) leaks++; setCell(j,EMPTY,20,0); }
}
}
return [leaks,probes];
})()`);
check(`slope ${c} seals ${m} from side ${side}`, leaked[0] === 0 && leaked[1] > 0, leaked);
}
for (const c of [15, 16]) {
fresh();
run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${c === 15 ? '12+k' : '23-k'},true);spawnBar(BARCU,${c === 15 ? 52 : 136},35);`);
ticks(run,600);
check(`bars reach the bottom of slope ${c}`,run('ents.length===1 && ents[0].iy>96'));
}
for(const c of [15,16,17,18]) {
fresh();
check(`slope ${c} supports every wall material`,run(`WALL_LIST.every((v,k)=>{ const bx=4+k*3;bagAdd(v,16,true);const before=bag[v];if(placeBlock(${c},v,bx,20))return false;removeBlock(bx,20);return bag[v]===before; })`));
}
fresh();
run('placeBlock(BP_SLOPE_BL,RSTONE,20,20,true);placeBlock(BP_WALL,RSTONE,20,21,true);removeBlock(20,21);');
check('removing an adjoining square wall restores the slope seal',run('mat[slopeSeal(BP_SLOPE_BL,20,20)]===STONE'));
run('removeBlock(20,20)');
check('removal leaves no free seal grains behind',count(run,run('STONE'),80,80,83,87)===0);
check('old saved slope codes select Wall', run("Array.from({length:4},(_,k)=>PIECE_BY_CODE[15+k].id).every(id=>id==='wall')"));
check('single Wall starts square', run('pieceCode(PIECE_BY_ID.wall)===BP_WALL'));
const shapes = [];
for (let k=0;k<5;k++) { run('turnSlope()'); shapes.push(run('pieceCode(PIECE_BY_ID.wall)')); }
check('T cycles square then four corners then square', JSON.stringify(shapes)===JSON.stringify([15,16,17,18,1]), shapes);
run('GAME.drag={bx:20,by:20}');
check('diagonal Wall defaults below', run('pieceCode(PIECE_BY_ID.wall)===BP_SLOPE_BL'));
run('turnSlope()');
check('T flips diagonal Wall above', run('pieceCode(PIECE_BY_ID.wall)===BP_SLOPE_TR'));
run('GAME.drag=null');
check('drag flip preserves single square choice',run('pieceCode(PIECE_BY_ID.wall)===BP_WALL'));
run('GAME.pieceDir.wall=3;GAME.drag={bx:20,by:23}');
check('straight Wall drag stays square even with a corner selected',run('pieceCode(PIECE_BY_ID.wall)===BP_WALL'));
run('GAME.drag=null;GAME.pieceDir.wall=1');
check('single Wall slope shows its half-block price',run('pieceCostNow(PIECE_BY_ID.wall,RSTONE)[0][1]===8'));
// Fixture saved before Wall absorbed the separate Sloped wall piece (save version 2).
const legacy = JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz')));
globalThis.legacySlopeSave = legacy;
run('deserializeWorld(legacySlopeSave.meta,legacySlopeSave.buffers)');
check('old save loads all four slope corners unchanged',run('Array.from({length:4},(_,k)=>{const b=6*BW+2+k*3;return blockP[b]===15+k && blockV[b]===RSTONE && blockIntegrity(b)===10 && PIECE_BY_CODE[blockP[b]].id===\'wall\';}).every(Boolean)'));
delete globalThis.legacySlopeSave;
// Stickiness is grain against grain: on a built slope sticky clay lumps run as fast as sand.
const slide=m=>{flatWorld(run,256,224,208);run('for(let k=0;k<20;k++)placeBlock(BP_SLOPE_BL,RSTONE,10+k,10+k,true);');
return run(`(()=>{setCell(39*W+42,${m},20,0);for(let t=0;t<2000;t++){simTick();for(let i=0;i<N;i++)if(mat[i]===${m}&&i%W>=118)return t;}return -1;})()`);};
const sandT=slide('SAND'),clayT=slide('LCLAY');
check('clay lumps slide down a built slope as fast as sand',clayT>0 && clayT<=sandT+4,JSON.stringify([sandT,clayT]));
done();
+196
View File
@@ -35,6 +35,202 @@ with sync_playwright() as p:
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
ev("SM.scale = 0.5"); pg.wait_for_timeout(1500)
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
# Uniform colours isolate the solid/loose seam from material noise and lighting.
ground = ev('''() => {
GAME.paused = true; requestAnimationFrame = () => 0;
SET.lighting = 'off'; SET.glow = 'off'; SET.grain = 0; SET.zoom = 6;
resizeRender(); bot.x = 138; bot.y = 118; snapCamera();
for (let y = 60; y < 220; y++) for (let x = 40; x < 240; x++) {
const i = y * W + x;
setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128;
}
const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); };
const at = (x, y) => { const c = R.px[(y - R.vy0) * R.bw + x - R.vx0]; return (c & 255) + (c >>> 8 & 255) + (c >>> 16 & 255); };
const out = {};
for (const lens of [false, true]) {
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld();
const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130);
fill(STONE); drawWorld();
out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)];
}
R.lens = false;
const i = 140 * W + 140;
flags[i] = 0; fill(RSTONE); drawWorld(); const built = at(140, 140);
fill(STONE); drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld();
const sample = (x, y) => {
const p = worldToScreenPx(x, y);
const c = R.ctx.getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
return c[0] + c[1] + c[2];
};
out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)];
const packed = [];
for (let y = 120; y < 121; y += 1 / 6) for (let x = 140; x < 141; x += 1 / 6) packed.push(sample(x, y));
out.texture = Math.max(...packed) - Math.min(...packed);
return out;
}''')
for look in ['pixel', 'thermal']:
edge, inside, loose, solid = ground[look]
check(f'{look}: ground has a darker edge against loose grains', edge < inside * 0.85 and inside == solid, ground[look])
check(f'{look}: loose rubble is lighter than solid stone', loose > inside, ground[look])
check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built'])
check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth'])
check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture'])
wall = pg.evaluate('''() => {
GAME.paused = true; R.lens = false; SET.fog = false;
SET.lighting = 'off'; SET.glow = 'off'; SET.grain = false;
for(let y=20;y<150;y++)for(let x=40;x<240;x++)setCell(y*W+x,EMPTY,22,0);
for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,25+k,25-k,true);
rebuildPower();slopeCleatsTick(false);prepareSlopeBelts(40,20,240,150);
let cell=-1;
for(let y=40;y<100 && cell<0;y++)for(let x=100;x<124;x++)
if(slopeDetailAt(y*W+x,x,y)===2){cell=y*W+x;break;}
if(cell<0)return null;
const x=cell%W,y=(cell/W)|0,control=80*W+180;
bot.x=118;bot.y=78;snapCamera();
setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
noiseB[cell]=noiseB[control]=128;
const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0];
SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
return {colors,grainClass};
}''')
check('sidewall stays behind the grain colour in pixels and smooth',
wall and wall['colors'][0] == wall['colors'][1] and wall['grainClass'] == 2, wall)
# Travel joins: edge elbows in diagonals, E elbows in straight receivers, otherwise collars.
pg.set_viewport_size({"width": 2100, "height": 1500})
ev("part.fill(0); blockP.fill(0); occ.fill(0); mach.fill(0); ents=[]; ghostBlocks.clear();")
ev("() => {" + (root / "tools/scenes/joins.js").read_text() + "}")
mouths = ev('''() => {
SET.zoom=4; SET.lighting="off"; SET.glow="off"; resizeRender();
bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();
const now=performance.now.bind(performance),frozen=now();
performance.now=()=>frozen;
try {
const out={};
for(const look of ["pixel","smooth"]) {
SET.look=look;SM.scale=1;drawWorld();
const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1;
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) {
const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1;
const joins=diagonalScrewJoins(j.bx,j.by,j.v);
if(!j.joined) {
if(!(joins & (64<<j.end)) || (joins>>(j.end*3)&7))bad.push(['missing free collar',j]);
// The middle of the free cap, safely inside its diagonal block.
const cap=look==='pixel' ? [[1.5,2.5],[2.5,1.5]] : [[1.8,1.8]];
const colors=cap.map(([x,y])=>sample(j.bx*4+2+side*x,j.by*4+2+side*slope*y));
if(!colors.some(copper))bad.push(['free collar colour',j,colors]);
continue;
}
const ex=(j.kind>=5?j.nx:j.bx)*4+2,ey=(j.kind>=5?j.ny:j.by)*4+2;
const dx=j.vertical?0:side,dy=j.vertical?side*slope:0;
// Both walls on the outgoing axis, beyond the rounded meeting point.
for(const sign of [-1,1]) {
const x=ex+dx*1.5-dy*sign*1.6,y=ey+dy*1.5+dx*sign*1.6,c=sample(x,y);
if(!copper(c))bad.push(['open casing',j,x,y,c]);
}
// Follow both centrelines through the join: the bore must never show sky.
const ax=j.bx*4+2-side*1.5,ay=j.by*4+2-side*slope*1.5;
for(let t=0;t<=1;t+=0.05)for(const leg of [0,1]) {
const x=leg?ex+dx*t*5:ax+(ex-ax)*t,y=leg?ey+dy*t*5:ay+(ey-ay)*t,c=sample(x,y);
if(c[2]>c[0]*1.4 && c[2]>110)bad.push(['background inside tube',j,x,y,c]);
}
if(j.kind>=5) {
// The unused corner of the bent straight block must become background.
const x=j.nx*4+2+(j.vertical?side:-side)*1.8;
const y=j.ny*4+2+(j.vertical?-side*slope:side*slope)*1.8,c=sample(x,y);
if(copper(c) || c[2]<c[0]*1.2)bad.push(['square casing outside elbow',j,x,y,c]);
}
}
out[look]=bad;
}return out;
} finally {performance.now=now;}
}''')
for look, bad in mouths.items():
check(f"{look}: shared screw joins have closed elbows, free collars and no bore holes", not bad, bad[:2])
previews = ev('''() => {
const bad=[], now=performance.now.bind(performance),frozen=now();
performance.now=()=>frozen;
try {
for(const look of ['pixel','smooth'])for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW && j.joined && j.kind>=5 && (j.v===5&&!j.vertical || j.v===4&&j.vertical))) {
SET.look=look;
const side=j.end?1:-1,slope=j.v===4||j.v===6?-1:1;
const ux=2+(j.vertical?side:-side)*1.8,uy=2+(j.vertical?-side*slope:side*slope)*1.8;
const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx;
const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]);
removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny);
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
for(const [n,piece] of plan)ghostBlocks.set(n,piece);
drawWorld();
if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]);
ghostBlocks.clear();
const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192;
try {
R.ctx=canvas.getContext('2d');R.ctx.fillStyle='#ffffff';
previewBlock(BP_SCREW,64,64,64,64,false,j.straight,j.nx,j.ny,plan);
const alpha=(x,y)=>R.ctx.getImageData(Math.floor(64+x*16),Math.floor(64+y*16),1,1).data[3];
if(alpha(ux,uy)!==0 || alpha(2,2)===0)bad.push(['planned preview elbow silhouette',look,j]);
} finally {R.ctx=ctx;}
placeBlock(BP_SCREW,j.v,j.bx,j.by,true);placeBlock(BP_SCREW,j.straight,j.nx,j.ny,true);
}
} finally {performance.now=now;ghostBlocks.clear();}
return bad;
}''')
check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2])
tree_tags = ev('''() => {
SET.look='smooth';SM.scale=1;
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
try {
const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW);
const bx=j.nx+1,by=j.ny;
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
drawWorld();const plain=sample(),bad=[];
for(const m of [WOOD,LEAVES]) {
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m;
drawWorld();if(sample()!==plain)bad.push(m);
}
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=0;
return bad;
} finally {performance.now=now;}
}''')
check('smooth: trees behind a straight screw cannot become elbow join bits', not tree_tags, tree_tags)
drops = ev("""() => {
const detail=slopeDetailAt,now=performance.now.bind(performance),frozen=now(),out={};
const j=JOIN_CASES.find(j=>j.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0);
try {
slopeDetailAt=()=>0;performance.now=()=>frozen;
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const look of ['pixel','smooth']) {
SET.look=look;SM.scale=1;drawWorld();const joined=sample();
removeBlock(j.nx,j.ny);drawWorld();const free=sample();
placeBlock(BP_BELT,j.straight,j.nx,j.ny,true);
out[look]=[joined,free];
}
return out;
} finally {slopeDetailAt=detail;performance.now=now;}
}""")
for look, (joined, free) in drops.items():
check(f'{look}: the same-row uphill inlet is a drop, drawn like a free low end', joined == free, [joined, free])
filter_plans = ev("""() => {
const saved={...GAME},original=slopeBeltConnections,seen=[];
try {
GAME.tool=TOOLS.findIndex(t=>t.id==='build');GAME.pieceIdx=PIECES.findIndex(p=>p.id==='filter');
GAME.selDrag=null;GAME.paste=false;GAME.filterCfg={m:[],inv:0};GAME.pieceDir={filter:1};
slopeBeltConnections=(x,y,c,p)=>{
if(p)seen.push([c,p.get(y*BW+x)?.[1]]);
return original(x,y,c,p);
};
for(const grade of [-1,1]) {
GAME.drag={bx:50,by:30};GAME.aim={x:52*BS,y:(30+2*grade)*BS};
drawPreviews();
}
return seen;
} finally {slopeBeltConnections=original;Object.assign(GAME,saved);}
}""")
check('filter drag previews pass stored travel bits to the join rule',
bool(filter_plans) and all(v == (128 if c == ev('BP_FILTER_DOWN') else 0) for c, v in filter_plans), filter_plans)
# a browser without hardware drawing gets the pixel look and a note
pg2 = b.new_page(viewport={'width': 800, 'height': 500})
pg2.add_init_script('localStorage.clear();')
+3 -3
View File
@@ -216,16 +216,16 @@ check('blown grains never come back into the bag', run('bag[SAND]') === 0 && cou
// --- round 6: lifts, falling trees, machines without power, the bot and walls
fresh();
// a shaft 16 wide, a column of up-tubes from its floor, turned sideways at the top to unload onto the ground
// a shaft 16 wide, a column of up-screws from its floor, turned sideways at the top to unload onto the ground
run('for (let y = 100; y < 132; y++) for (let x = 120; x < 128; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
run('bot.x = 110; bot.y = 90;');
// unload well above the ground: a pile that reaches the outlet would block it
run('for (let by = 19; by <= 32; by++) placeBlock(BP_TUBE, 0, 30, by, true); for (let bx = 30; bx <= 35; bx++) placeBlock(BP_TUBE, 1, bx, 18, true);');
run('for (let by = 19; by <= 32; by++) placeBlock(BP_SCREW, 0, 30, by, true); for (let bx = 30; bx <= 35; bx++) placeBlock(BP_SCREW, 1, bx, 18, true);');
box(120, 116, 127, 131, run('SAND'));
const sand0 = count(run, run('SAND'));
ticks(run, 1800);
const lifted = count(run, run('SAND'), 136, 0, 239, 99);
check('a column of upward screw tubes lifts sand out of a shaft, unpowered', lifted > sand0 * 0.8, `${lifted} of ${sand0} grains brought up and out`);
check('a column of upward screw conveyors lifts sand out of a shaft, unpowered', lifted > sand0 * 0.8, `${lifted} of ${sand0} grains brought up and out`);
fresh();
run('for (let bx = 25; bx <= 30; bx++) placeBlock(BP_BELT, 1, bx, 22, true); rebuildPower();');
box(101, 84, 104, 87, run('SAND'));
+7 -7
View File
@@ -1,4 +1,4 @@
// Throughput of belts and screw tubes: grains delivered per second. Whatever lands
// 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.
const { load, flatWorld, rect, ticks, count } = require('../../test/harness');
const { run } = load();
@@ -11,18 +11,18 @@ function rate(x0, x1, y0, y1) {
for (let k = 0; k < 300; k++) run(`simTick(); ${sink}`);
return run('__got') / 5;
}
// sideways tube fed by a walled hopper sitting on its first two blocks
// sideways screw fed by a walled hopper sitting on its first two blocks
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_SCREW, 1, bx, 20, true);');
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
rect(run, 80, 0, 95, 79, run('SAND'));
res.tubeSideways = rate(120, 319, 84, 119);
res.tubeLanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
// lift: a column of up-tubes in a pit of sand, turned sideways at the top
res.screwSideways = rate(120, 319, 84, 119);
res.screwLanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
// lift: a column of up-screws in a pit of sand, turned sideways at the top
fresh();
run('for (let y = 120; y < 196; y++) for (let x = 90; x < 130; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
rect(run, 90, 130, 129, 195, run('SAND'));
run('for (let by = 20; by <= 37; by++) placeBlock(BP_TUBE, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);');
run('for (let by = 20; by <= 37; by++) placeBlock(BP_SCREW, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_SCREW, 1, bx, 19, true);');
res.lift = rate(164, 319, 80, 119);
res.liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y++) for (let x = 120; x < 160; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
// belt: 12 blocks, a pile dropped on its back end
+51
View File
@@ -0,0 +1,51 @@
// Many loaded slopes, with a fresh deterministic world for each timing sample.
const { performance } = require('perf_hooks');
const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness');
const { run } = load();
const assert = require('node:assert/strict');
const samples=[];
for(let sample=0;sample<6;sample++) {
flatWorld(run,1024,512,496);
run(`RS=7;money=1e9;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let row=0;row<6;row++)for(let col=0;col<8;col++)for(let k=0;k<12;k++){
const bx=col*30+4+k,by=row*18+35-k;
placeBlock(BP_BELT_UP,1,bx,by,true);
for(let x=bx*4;x<bx*4+4;x++){const y=beltFaceY(BP_BELT_UP,bx,by,x);for(let h=1;h<=4;h++)setCell((y-h)*W+x,SAND,20,0);}
}
for(let k=0;k<60;k++)simTick();`);
const start=performance.now();run('for(let k=0;k<240;k++)simTick();');
if(sample) samples.push((performance.now()-start)/240);
}
assert(samples.length===5 && samples.every(n=>Number.isFinite(n) && n>0));
samples.sort((a,b)=>a-b);
console.log(`576 loaded slope blocks, median ${samples[2].toFixed(3)} ms/tick; samples ${samples.map(v=>v.toFixed(3)).join(', ')}`);
// No slopes: use the zero flag supplied once per chunk by simMove. Older powder ignores it.
flatWorld(run,512,384,376);
run('RS=7;for(let y=32;y<350;y+=2)for(let x=32;x<480;x++){mat[y*W+x]=SAND;mat[(y+1)*W+x]=STONE;}');
const powderSamples=[];
for(let sample=0;sample<6;sample++) {
const start=performance.now();
run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND,0);');
if(sample)powderSamples.push(performance.now()-start);
}
assert(powderSamples.length===5 && powderSamples.every(n=>Number.isFinite(n) && n>0));
powderSamples.sort((a,b)=>a-b);
console.log(`2.14M powder calls without cleats, median ${powderSamples[2].toFixed(2)} ms`);
// Generated terrain plus a replenished falling sand curtain, with zero or 50 slope blocks.
for (const slopes of [0, 50]) {
const times = [];
for (let sample = 0; sample < 6; sample++) {
run(`generateWorld(12345, 1024, 640); weather.next=1e15; money=1e9;
for(let y=16;y<160;y++)for(let x=80;x<480;x++)setCell(y*W+x,EMPTY,AMB[y],0);
for(let k=0;k<${slopes};k++) {const bx=25+(k%10),by=32+Math.floor(k/10)*5-(k%10);
placeBlock(BP_BELT_UP,1,bx,by,true);}
for(let t=0;t<60;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`);
const start = performance.now();
run(`for(let t=0;t<180;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`);
if (sample) times.push((performance.now()-start)/180);
}
assert(times.every(n=>Number.isFinite(n) && n>0)); times.sort((a,b)=>a-b);
console.log(`generated falling world, ${slopes} slopes: ${times[2].toFixed(3)} ms/tick; ${times.map(n=>n.toFixed(3)).join(', ')}`);
}
+29
View File
@@ -0,0 +1,29 @@
// Reproducible generous-feed throughput and settled pile angles for t42.
const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness');
const { run } = load();
const assert = require('node:assert/strict');
for (const material of ['SAND','GRAVEL','CUORE','COAL']) {
const rates=[];
for (const grade of [0,-1]) {
flatWorld(run,256,224,208);
run(`RS=7;money=1e6;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let k=0;k<10;k++)placeBlock(${grade?'BP_BELT_UP':'BP_BELT'},1,16+k,${grade?32:24}+k*${grade},true);
for(let x=10;x<16;x++)placeBlock(BP_BELT,1,x,${grade?33:24},true);
for(let x=26;x<32;x++)placeBlock(BP_BELT,1,x,${grade?23:24},true);`);
const result=run(`(()=>{let out=0;for(let k=0;k<1420;k++){
for(let l=1;l<=4;l++){const i=(${grade?132:96}-l)*W+44;if(mat[i]===EMPTY)setCell(i,${material},20,0);}
simTick();
for(let i=0;i<N;i++)if(mat[i]===${material} && i%W>=128){if(k>=220)out++;setCell(i,EMPTY,20,0);}
for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===${material} && flyers[j].x>=128){if(k>=220)out++;flyers.splice(j,1);}
}return out/20;})()`);
rates.push(result);
}
flatWorld(run,256,224,208);
run(`RS=7;AMB.fill(20);temp.fill(20);hot.fill(0);
for(let y=120;y<160;y++)for(let x=118;x<138;x++)setCell(y*W+x,${material},20,0);
for(let k=0;k<1200;k++)simTick();`);
// Apparent settled angle: heap height divided by half its footprint width.
const angle=run(`(()=>{let top=H,left=W,right=0;for(let i=0;i<N;i++)if(mat[i]===${material}){top=Math.min(top,(i/W)|0);left=Math.min(left,i%W);right=Math.max(right,i%W);}return Math.atan2(208-top,(right-left+1)/2)*180/Math.PI;})()`);
assert(rates[1]>=60 && rates[1]<=rates[0]*1.1, `${material} slope rate outside the target`);
console.log(`${material}: flat ${rates[0].toFixed(1)}, uphill ${rates[1].toFixed(1)} grains/s; pile angle ${angle.toFixed(1)} degrees`);
}
+2 -2
View File
@@ -8,8 +8,8 @@ for (let x = (bx - 3) * BS; x < (bx + 27) * BS; x++) surfY[x] = (by + 4) * BS;
const mo = addMold('ingot', bx, by - 4, RSTONE, true);
const beltY = Math.ceil((mo.y + mo.h) / BS) + 1;
for (let x = bx; x < bx + 9; x++) placeBlock(BP_BELT, 1, x, beltY, true);
for (let y = by - 11; y < beltY; y++) placeBlock(BP_TUBE, 0, bx + 9, y, true);
for (let x = bx + 9; x < bx + 16; x++) placeBlock(BP_TUBE, 1, x, by - 12, true);
for (let y = by - 11; y < beltY; y++) placeBlock(BP_SCREW, 0, bx + 9, y, true);
for (let x = bx + 9; x < bx + 16; x++) placeBlock(BP_SCREW, 1, x, by - 12, true);
for (let x = bx + 16; x < bx + 25; x++) placeBlock(BP_BELT, 1, x, by - 11, true);
placeBlock(BP_WALL, RSTONE, bx + 25, by - 12, true);
mo.cavs[0].forEach(i => setCell(i, MCU, 1090, 0));
+1 -1
View File
@@ -3,7 +3,7 @@
// 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
const bx = BX + 6, by = BY;
for (let x = bx; x < bx + 12; x++) placeBlock(BP_BELT, 1, x, by, true);
for (let y = by - 10; y <= by; y++) placeBlock(BP_TUBE, 0, bx + 12, y, true);
for (let y = by - 10; y <= by; y++) placeBlock(BP_SCREW, 0, bx + 12, y, true);
for (let y = by * 4 - 30; y < by * 4; y++) for (let x = (bx + 2) * 4; x < (bx + 10) * 4; x++)
setCell(y * W + x, y >= by * 4 - 6 ? SAND : CUORE, AMB[y], 0);
window.CAM = [(bx + 7) * 4, (by - 5) * 4];
+41
View File
@@ -0,0 +1,41 @@
// 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.
money = 50000;
for (let y = 0; y < 460; y++) for (let x = 0; x < 600; x++) {
const i = y * W + x;
setCell(i, EMPTY, 20, 0); bgm[i] = 0;
}
for (let x = 0; x < 600; x++) surfY[x] = 420;
window.JOIN_CASES = [];
for (let row = 0; row < 8; row++) for (let col = 0; col < 9; col++) {
const v = 4 + (row >> 1), end = row & 1, side = end ? 1 : -1;
const slope = v === 4 || v === 6 ? -1 : 1, dy = side * slope;
const vertical = col >= 3 && col < 6, belt = col >= 6, spot = col % 3;
const bx = 34 + col * 12, by = 24 + row * 10;
const nx = bx + (spot === 1 ? 0 : side), ny = by + (spot === 0 ? 0 : dy);
const code = belt ? BP_BELT : BP_SCREW;
const [tx, ty] = SCREW_DIRS[v];
const straight = belt ? (tx > 0 ? 1 : 0) : vertical ? (ty < 0 ? 0 : 2) : (tx > 0 ? 1 : 3);
const diagonalCode = belt ? (slope < 0 ? BP_BELT_UP : BP_BELT_DOWN) : BP_SCREW;
const diagonalV = belt ? (SCREW_DIRS[v][0] > 0 ? 1 : 0) : v;
for (let k = 0; k < 2; k++) placeBlock(diagonalCode, diagonalV, bx - k * side, by - k * dy, true);
for (let k = 0; k < 2; k++) placeBlock(code, straight, nx + (vertical ? 0 : k * side * (spot === 1 ? -1 : 1)), ny + (vertical ? k * dy * (spot === 0 ? -1 : 1) : 0), true);
const joined = endJoin(bx, by, end) >= 0;
const kind = joined ? (ny === by ? (end ? 2 : 4) : nx === bx ? (ny < by ? 1 : 3) : vertical ? 6 : 5) : 0;
JOIN_CASES.push({v, end, bx, by, nx, ny, vertical, code, straight, joined, kind, spot});
}
updateRoof();
window.CAM = [326, 232];
// Labels are part of the scene so screenshots and browser checks use the same layout.
drawBot = () => {
const ctx = R.ctx;
ctx.save(); ctx.font = '12px monospace'; ctx.textBaseline = 'bottom';
for (const j of JOIN_CASES) {
const p = worldToScreenPx(j.bx * BS - 8, j.by * BS - 14);
ctx.fillStyle = '#17222c';
const name = ['UR', 'DR', 'DL', 'UL'][j.v - 4], axis = j.code === BP_BELT ? 'belt' : j.vertical ? 'V' : 'H';
ctx.fillText(`${name} ${['↗','↘','↙','↖'][j.v-4]} ${j.end ? 'R' : 'L'} ${axis} ${j.vertical ? (SCREW_DIRS[j.v][1]<0?'↑':'↓') : (SCREW_DIRS[j.v][0]>0?'→':'←')} ${['row', 'axis', 'diag'][j.spot]}: ${j.joined ? 'JOIN' : 'free'}`, p.x, p.y);
}
ctx.restore();
};
+5 -3
View File
@@ -1,9 +1,11 @@
// Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
for (let k = 0; k < 3; k++) {
const bx = BX + 10 + k * 7, by = BY - 5;
for (let k = 0; k < 5; k++) {
const bx = BX + 5 + k * 6, by = BY - 5;
placeMachineAt('silo', 0, bx, by);
const mm = machines.find(m => m && m.type === 'silo' && m.x === bx * 4 && m.y === by * 4);
mm.sm = k === 2 ? CUORE : SAND; mm.sn = k === 0 ? 0 : Math.floor(SILO_CAP * (k === 1 ? 0.5 : 0.95));
mm.sm = k === 2 ? CUORE : k === 3 ? SNOW : COAL;
if (k === 1) mm.sm = SAND;
mm.sn = [0, 2000, 3800, 2800, 1][k];
mm.open = k === 2;
}
window.CAM = [(BX + 18.5) * 4, (BY - 2) * 4];
+21
View File
@@ -0,0 +1,21 @@
// Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
globalThis.slopeScene = function(screen = false) {
const bx = Math.floor((SX - 100) / BS), by = Math.floor((SY + 12) / BS);
const left = (bx - 12) * BS, right = (bx + 24) * BS;
for(let y=by*BS-64;y<by*BS+64;y++)for(let x=left;x<right;x++) {
const i=y*W+x;bgm[i]=0;setCell(i,y>=by*BS+56?STONE:EMPTY,20,0);
}
for(let x=left;x<right;x++){surfY[x]=by*BS+56;dirtY[x]=by*BS+64;}
money=1e6;weather.next=1e15;
const startY=screen?by-8:by, grade=screen?1:-1;
for(let k=0;k<8;k++)placeBlock(screen?BP_FILTER_DOWN:BP_BELT_UP,screen?128:1,bx+k,startY+grade*k,true,{m:[SAND],inv:0});
const inlet=startY+(screen?0:1),outlet=startY+grade*7+(screen?1:0);
for(let k=1;k<=5;k++)placeBlock(BP_BELT,1,bx-k,inlet,true);
for(let k=8;k<13;k++)placeBlock(BP_BELT,1,bx+k,outlet,true);
for(let t=0;t<300;t++) {
for(let h=1;h<=4;h++){const i=(inlet*BS-h)*W+(bx-4)*BS;if(mat[i]===EMPTY)setCell(i,screen && (t&2)?GRAVEL:SAND,20,0);}
simTick();
}
// Keep loose rejects and passing grains visible underneath the screen.
CAM=[(bx+3)*BS,(startY+grade*4)*BS+4];updateRoof();
}
+11
View File
@@ -0,0 +1,11 @@
// Downhill screen whose outlet belt sits level with its last block (the jam the owner saw).
globalThis.jointScene = function(level = true) {
const bx = Math.floor((SX - 100) / BS), by = Math.floor((SY + 12) / BS) - 8;
for(let y=by*BS-64;y<by*BS+96;y++)for(let x=(bx-12)*BS;x<(bx+24)*BS;x++){const i=y*W+x;bgm[i]=0;setCell(i,y>=by*BS+88?STONE:EMPTY,20,0);}
money=1e6;weather.next=1e15;
for(let k=0;k<5;k++)placeBlock(BP_FILTER_DOWN,128,bx+k,by+k,true,{m:[GRAVEL],inv:0});
for(let k=1;k<=5;k++)placeBlock(BP_BELT,1,bx-k,by,true);
for(let k=5;k<10;k++)placeBlock(BP_BELT,1,bx+k,by+4+(level?0:1),true);
for(let t=0;t<900;t++){for(let h=1;h<=6;h++){const i=(by*BS-h)*W+(bx-4)*BS+(t%3);if(mat[i]===EMPTY)setCell(i,COAL,20,0);}simTick();}
CAM=[(bx+4)*BS,(by+3)*BS];updateRoof();
};
+24
View File
@@ -0,0 +1,24 @@
// 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.
const cx = 360, cy = 300;
for (let y = 0; y < 480; y++) for (let x = 100; x < 620; x++) {
const dx = x - cx, dy = y - cy;
const floor = 20 + Math.round(3 * Math.sin(dx * 0.11));
const wall = Math.abs(dx) > 68 || dy < -42 || dy >= floor;
const ground = dx < -23 ? STONE : dx < 23 ? GRANITE : DIRT;
let m = wall ? ground : EMPTY;
if (!wall && dy > floor - 11 - Math.round(3 * Math.sin(dx * 0.18))) {
m = dx < -23 ? RSTONE : dx < 23 ? CUORE : LDIRT;
if ((x * 7 + y * 11) % 19 === 0) m = CUORE;
}
const i = y * W + x;
bgm[i] = 0;
setCell(i, m, 22, wall ? F_NAT : 0);
}
for (let x = 100; x < 620; x++) {
surfY[x] = 100; dirtY[x] = 180; graniteY[x] = 400;
}
updateRoof();
window.CAM = [cx, cy];
SET.daylight = 0;
SET.lighting = 'full';
+7
View File
@@ -0,0 +1,7 @@
// Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
const tx = BX + 12, ty = BY - 1;
for (let x = BX + 3; x < tx; x++) placeBlock(BP_BELT, 1, x, BY, true);
for (let k = 0; k < 10; k++) placeBlock(BP_SCREW, 4, tx + k, ty - k, true);
for (let x = tx + 10; x < tx + 18; x++) placeBlock(BP_BELT, 1, x, ty - 9, true);
for (let y = BY * 4 - 22; y < BY * 4; y++) for (let x = (BX + 4) * 4; x < (BX + 11) * 4; x++) setCell(y * W + x, SAND, 20, 0);
window.CAM = [(tx + 4) * 4, (ty - 4) * 4];
+21
View File
@@ -0,0 +1,21 @@
// Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect.
const jx = BX + 5, jy = BY - 3;
for (let k = 1; k <= 4; k++) placeBlock(BP_SCREW, 0, jx, jy + k, true);
for (let k = 0; k < 6; k++) placeBlock(BP_SCREW, 4, jx + k, jy - k, true);
for (let k = 1; k <= 4; k++) placeBlock(BP_SCREW, 0, jx + 5, jy - 5 - k, true);
const hx = BX + 21, hy = BY - 3;
for (let k = 1; k <= 4; k++) placeBlock(BP_BELT, 1, hx - k, hy, true);
for (let k = 0; k < 6; k++) placeBlock(BP_SCREW, 4, hx + k, hy - k, true);
for (let k = 1; k <= 4; k++) placeBlock(BP_BELT, 1, hx + 5 + k, hy - 5, true);
for (let k = 0; k < 4; k++) placeBlock(BP_SCREW, 5, BX + 32 + k, BY - 10 + k, true);
window.JOINTS = {
verticalIn: [jx * 4 + 2, jy * 4 + 4],
verticalOut: [(jx + 5) * 4 + 2, (jy - 5) * 4],
beltIn: [hx * 4, hy * 4 + 2],
beltOut: [(hx + 6) * 4, (hy - 5) * 4 + 2],
free: [(BX + 35) * 4 + 4, (BY - 7) * 4 + 4]
};
window.CAM = [(BX + 20) * 4, (BY - 5) * 4];
// Keep the camera-following bot out of the joint close-ups.
drawBot = () => {};
+20
View File
@@ -0,0 +1,20 @@
// A twelve-block funnel, one block open at the bottom, draining 400 grains of sand.
const fx = Math.floor((SX - 150) / BS), fy = Math.floor((SY - 36) / BS);
const cx = (fx + 12) * BS + 2, floor = (fy + 25) * BS;
for (let y = fy * BS - 45; y < floor + 8; y++) for (let x = fx * BS - 20; x < (fx + 25) * BS + 20; x++) {
const i = y * W + x;
bgm[i] = 0;
setCell(i, y >= floor ? STONE : EMPTY, 20, y >= floor ? F_NAT : 0);
}
for (let x = fx * BS - 20; x < (fx + 25) * BS + 20; x++) surfY[x] = floor;
bot.x = cx; bot.y = fy * BS + 24;
bagAdd(RBRICK, 192, true);
GAME.pieceDir.wall = 0;
for (const [ax, ay, bx, by] of [[fx, fy, fx + 11, fy + 11], [fx + 24, fy, fx + 13, fy + 11]]) {
GAME.drag = { bx: ax, by: ay };
placeBlocks(pieceCode(PIECE_BY_ID.wall, { bx, by }), RBRICK, blockLine(ax, ay, bx, by, true));
}
GAME.drag = null;
for (let y = fy * BS - 28; y < fy * BS - 18; y++) for (let x = cx - 20; x < cx + 20; x++) setCell(y * W + x, SAND, 20, 0);
updateRoof();
window.CAM = [cx, fy * BS + 25];
+31
View File
@@ -0,0 +1,31 @@
// Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below.
const left = Math.max(8, ((SX - 176) >> 2) * 4), base = ((SY - 20) >> 2), bx0 = left >> 2;
const ground = (base + 22) * 4;
for (let y = (base - 16) * 4; y < ground + 8; y++) for (let x = left; x < left + 188; x++) {
const i = y * W + x;
mat[i] = y >= ground ? STONE : EMPTY; part[i] = 0; mach[i] = 0; bgm[i] = 0; flags[i] = y >= ground ? F_NAT : 0; temp[i] = 20; wakeXY(x, y);
}
for (let x = left; x < left + 188; x++) { surfY[x] = ground; dirtY[x] = ground + 8; }
AMB.fill(20); money = 50000; weather.next = 1e15;
for (let k = 4; k < 10; k++) placeBlock(BP_BELT, 1, bx0 + k, base + 1, true);
for (let k = 0; k < 10; k++) placeBlock(BP_BELT_UP, 1, bx0 + 10 + k, base - k, true);
for (let k = 20; k < 26; k++) placeBlock(BP_BELT, 1, bx0 + k, base - 9, true);
for (let k = 0; k < 10; k++) placeBlock(BP_BELT_DOWN, 1, bx0 + 26 + k, base - 9 + k, true);
for (let k = 36; k < 43; k++) placeBlock(BP_BELT, 1, bx0 + k, base + 1, true);
const low = base + 16;
for (let k = 4; k < 10; k++) placeBlock(BP_BELT, 1, bx0 + k, low + 1, true);
for (let k = 0; k < 10; k++) placeBlock(BP_FILTER_UP, 128, bx0 + 10 + k, low - k, true, {m:[SAND],inv:0});
for (let k = 20; k < 30; k++) placeBlock(BP_BELT, 1, bx0 + k, low - 9, true);
for (let k = 0; k < 380; k++) {
for (let l = 1; l <= 4; l++) {
let i = ((base + 1) * 4 - l) * W + left + 20;
if (mat[i] === EMPTY) setCell(i, SAND, 20, 0);
i = ((low + 1) * 4 - l) * W + left + 20;
if (mat[i] === EMPTY) setCell(i, (k + l) & 1 ? SAND : GRAVEL, 20, 0);
}
simTick();
}
spawnBar(BARCU, left + 92, (base - 9) * 4 - BAR_H, 0, 0, 20);
window.CAM = [left + 92, (base + 6) * 4];
window.SLOPE_CAM = [left + 64, (base - 4) * 4];
updateRoof();