Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc3b07be2 | ||
|
|
7468d00efb | ||
|
|
20963c776e | ||
|
|
452b6aed32 | ||
|
|
65054e691b | ||
|
|
31e3005816 | ||
|
|
fc05146887 | ||
|
|
b2c9ad9533 | ||
|
|
9781aa3ee1 | ||
|
|
0c0edf46ca | ||
|
|
0ad3561e6a | ||
|
|
f193b1c76c | ||
|
|
f4ca1cae57 | ||
|
|
29eee51e35 | ||
|
|
a671dcb59a | ||
|
|
569ec6e5b6 | ||
|
|
ef4ba6d24a | ||
|
|
6bc4fd5928 | ||
|
|
9fc1b92080 | ||
|
|
54e7756ead | ||
|
|
ccca66c0c1 | ||
|
|
99d4889f1e | ||
|
|
72efe85a1b | ||
|
|
c3f9387b24 | ||
|
|
a0f254fdeb | ||
|
|
dd8068dfe6 | ||
|
|
02e338a46f | ||
|
|
45538cc18e | ||
|
|
fd35bf334f | ||
|
|
198bea54da | ||
|
|
30c811ea80 | ||
|
|
65338b2b27 | ||
|
|
787721ad35 | ||
|
|
ff9fd7b1b8 | ||
|
|
828c71487c | ||
|
|
9ff9013c68 | ||
|
|
552b9b6480 | ||
|
|
155708b41d | ||
|
|
18528a7f73 | ||
|
|
664e086969 | ||
|
|
8ce71f478a | ||
|
|
3cb0182ac7 | ||
|
|
be93ad1251 | ||
|
|
2e5ad0f1a1 | ||
|
|
8cdc47fd33 | ||
|
|
3ee85429fd | ||
|
|
578b55316b | ||
|
|
1f8a89db6a | ||
|
|
5e7069d637 | ||
|
|
b74a9173eb | ||
|
|
9bc5a6c3af | ||
|
|
0cf615eb3d | ||
|
|
e19cb784d2 | ||
|
|
24eb9559f8 | ||
|
|
814b51df46 | ||
|
|
f19facbe5c | ||
|
|
7d1be16094 | ||
|
|
f744d786f1 | ||
|
|
8087c28cb5 | ||
|
|
381019b7aa | ||
|
|
1a300bf744 | ||
|
|
20d0627d75 | ||
|
|
5ce44f842e |
@@ -2,10 +2,14 @@
|
||||
|
||||
A falling-sand factory game in the browser, in the style of Sandustry.
|
||||
|
||||
WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU;
|
||||
there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our
|
||||
checks, show a notice on the start screen. Serve the page over HTTP as described below.
|
||||
|
||||
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
|
||||
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
|
||||
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
|
||||
other goods at a trade dock, automating with belts, screw tubes, filters, fans, pipes and
|
||||
other goods at a trade dock, automating with belts, screw conveyors, filters, fans, pipes and
|
||||
machines, until the land claim is paid off and beyond.
|
||||
|
||||
## Play
|
||||
@@ -15,8 +19,32 @@ python3 build.py
|
||||
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
|
||||
```
|
||||
|
||||
Opening `dist/index.html` directly works too. The game is one self-contained page with no
|
||||
downloads; sound and graphics are all generated in code. Saves stay in your browser.
|
||||
The game requires WebGPU and cross-origin isolation for shared-memory CPU workers.
|
||||
Grains and heat run across up to 16 helpers (two CPU cores are reserved); Settings →
|
||||
Simulation → CPU workers caps the pool on the next world load. Small jobs run inline.
|
||||
|
||||
The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
|
||||
server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
|
||||
Use HTTPS when hosting remotely (localhost works over HTTP).
|
||||
|
||||
nginx, in the site's `server` or `location` block:
|
||||
|
||||
```nginx
|
||||
add_header Cross-Origin-Opener-Policy same-origin always;
|
||||
add_header Cross-Origin-Embedder-Policy require-corp always;
|
||||
```
|
||||
|
||||
Caddy, in the site block alongside `root * /path/to/dist` and `file_server`:
|
||||
|
||||
```caddyfile
|
||||
header {
|
||||
Cross-Origin-Opener-Policy same-origin
|
||||
Cross-Origin-Embedder-Policy require-corp
|
||||
}
|
||||
```
|
||||
|
||||
These are plain static files, so a small server is enough. The game is one self-contained page
|
||||
with no downloads; sound and graphics are generated in code. Saves stay in your browser.
|
||||
- To keep or move a world, use Pause → Export world file.
|
||||
- To load one, use Settings → Load a world file on the title screen, or Pause → Import world file
|
||||
during a game. World files exported from the claude.ai version load here too.
|
||||
@@ -31,16 +59,17 @@ The controls are in the in-game notebook (N) under Controls. The basics:
|
||||
|
||||
## Develop
|
||||
|
||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots:
|
||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots.
|
||||
The golden-image tests also use ImageMagick (`magick`):
|
||||
|
||||
```sh
|
||||
uv venv .venv
|
||||
uv pip install --python .venv/bin/python -r requirements.txt
|
||||
.venv/bin/python -m playwright install chromium
|
||||
.venv/bin/python -m playwright install chromium firefox
|
||||
```
|
||||
|
||||
If uv is not installed, `python3 -m venv .venv` and
|
||||
`.venv/bin/pip install -r requirements.txt` also work; then install Chromium as above.
|
||||
`.venv/bin/pip install -r requirements.txt` also work; then install the browsers as above.
|
||||
After that, plain `python3` works for every command below; the browser scripts switch to
|
||||
`.venv` on their own.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a
|
||||
src/sim/base.js applies to all of it. No bundler, no dependencies.
|
||||
|
||||
Writes:
|
||||
dist/index.html the full page: open it, or serve with python3 tools/serve.py
|
||||
dist/index.html the full page: serve with python3 tools/serve.py
|
||||
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
|
||||
claude.ai artifact) that wrap the page themselves
|
||||
dist/bundle-lines.json where each source file starts in dist/index.html, so a line number
|
||||
@@ -35,9 +35,13 @@ def main():
|
||||
shell = (src / man['shell']).read_text()
|
||||
css, _ = join(man['css'])
|
||||
js, starts = join(man['sim'] + man['client'])
|
||||
sim, _ = join(man['sim'])
|
||||
# Keep worker code outside the indexed script so source line mappings stay exact.
|
||||
worker = '<script>const SIM_WORKER_SOURCE=' + json.dumps(sim).replace('</', '<\\/') + ';</script>'
|
||||
head, tail = shell.split('//__JS__')
|
||||
body = head.replace('/*__CSS__*/', css)
|
||||
page = body + js + tail
|
||||
page = worker + page
|
||||
pre = ('<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
'</head><body>')
|
||||
@@ -46,7 +50,7 @@ def main():
|
||||
(dist / 'deepgrain-foundry.html').write_text(page)
|
||||
(dist / 'index.html').write_text(full)
|
||||
# the script starts on this line of index.html (the doctype prefix has no newlines)
|
||||
js_line = body.count('\n') + 1
|
||||
js_line = worker.count('\n') + body.count('\n') + 1
|
||||
(dist / 'bundle-lines.json').write_text(json.dumps(
|
||||
{'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n')
|
||||
print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
|
||||
|
||||
@@ -49,7 +49,7 @@ decides something new, add it under "Decisions" with the date.
|
||||
4. Bars drop out of the bottom of the mold; collect them and sell them at the trade dock, or fill buyers' orders, which pay more.
|
||||
5. Better bits (copper, then iron, then steel) open harder rock: granite, then basalt. Deeper
|
||||
ores give tin, zinc, silver and gold, and alloys (bronze, brass) pay more.
|
||||
6. Automate with belts, screw tubes, filters, fans, pipes and machines. Pay off the claim.
|
||||
6. Automate with belts, screw conveyors, filters, fans, pipes and machines. Pay off the claim.
|
||||
|
||||
## Systems in brief
|
||||
|
||||
@@ -74,7 +74,7 @@ Details and numbers live in the code and in `docs/systems/`.
|
||||
- **Drill:** see `docs/systems/dig-tools.md`. Pulse widths, tuning to one material, hardness by
|
||||
bit tier.
|
||||
- **Building:** a 4×4-grain block grid.
|
||||
- Blocks: walls of any of several materials, belts, screw tubes, filters, grates, gates,
|
||||
- Blocks: walls of any of several materials, belts, screw conveyors, filters, grates, gates,
|
||||
pipes, pumps, outlets.
|
||||
- Machines: fan, Refiner, Stone cutter, Silo.
|
||||
- Molds: ingot, bit, tank. Ingot bars drop out of the bottom onto whatever is under the mold;
|
||||
@@ -140,7 +140,7 @@ above, including:
|
||||
- **Ingot molds drop bars below automatically.** The owner reported bars being thrown into the
|
||||
furnace above and wants belts underneath to carry them off. Blocked bars wait in the trough;
|
||||
short stubs stay for the next firing. The mold cleans itself and rakes the grate after dropping bars or clearing junk.
|
||||
- **Screw tubes carry no more than belts:** they move every other tick. For more throughput,
|
||||
- **Screw conveyors carry no more than belts:** they move every other tick. For more throughput,
|
||||
build more lines.
|
||||
- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. If a grain is blocked,
|
||||
the load above it stands still and the belt slips underneath; the top no longer slides over
|
||||
@@ -177,7 +177,7 @@ above, including:
|
||||
popup. The owner asked for a general description rather than the selected building.
|
||||
- **Ingot molds need no F.** They push junk and mixed metal out of the bottom as loose grains with the bars, throw slag away and rake the grate. Removing any mold returns its set metal: whole bars as bars, the rest as scrap. The owner asked what F was for once bars dropped on their own, and chose this over keeping F for cleaning.
|
||||
|
||||
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted tubes and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
|
||||
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted screw conveyors and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
|
||||
|
||||
- **World sounds are local.** Sounds from the world (water sizzling on hot metal or lava, and
|
||||
any other event with a place) are heard around the bot, whatever the zoom: full volume within
|
||||
@@ -190,14 +190,14 @@ above, including:
|
||||
- **The silo has pixel art in the pixel look**, drawn grain by grain like the rest of the world;
|
||||
the smooth look keeps the illustrated silo. The owner noticed it looked the same in both.
|
||||
|
||||
- **Screw tubes use all their lanes.** Grains in an up or down tube shift sideways toward the
|
||||
- **Screw conveyors use all their lanes.** Grains in an up or down screw conveyor shift sideways toward the
|
||||
emptier lanes of the block, and grains taken in from the side go to the far lanes first. A
|
||||
block-wide lift fed by a belt from the side now carries about 90% of what the belt brings
|
||||
(before, only the edge lane filled: the owner saw one line on either side). Belts keep their
|
||||
speed.
|
||||
- **Screw tubes carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
|
||||
bar into a tube's side, it rides through corners and leaves the mouth. The owner asked how bars
|
||||
should be moved and chose tubes over a separate bar lift.
|
||||
- **Screw conveyors carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
|
||||
bar into a screw conveyor's side, it rides through corners and leaves the mouth. The owner asked how bars
|
||||
should be moved and chose screw conveyors over a separate bar lift.
|
||||
- **Several worlds are kept.** Each world has its own save and autosave; the title screen's Worlds
|
||||
list loads, renames and deletes them, and a new or imported world never overwrites another. An
|
||||
old single save becomes 'World 1'. The owner asked for multiple saves.
|
||||
@@ -214,7 +214,7 @@ above, including:
|
||||
and the surface start seen. The owner chose line of sight over a plain circle around the bot.
|
||||
- **Bars pile neatly, and a Bar rack stores them (t1).** Bars that come to rest snap to a tidy
|
||||
brick pattern so piles have no air gaps; a Bar rack machine (the Silo for bars) takes them from
|
||||
a belt or tube, counts them and lets them out with F. The owner wanted bar storage that doesn't
|
||||
a belt or screw conveyor, counts them and lets them out with F. The owner wanted bar storage that doesn't
|
||||
waste space, like stacked ingots in real life.
|
||||
As built: a resting bar snaps to a 2-grain grid, and one with fewer than 2 of its 4 bottom cells
|
||||
supported tips into the gap beside it. Storage became Rack blocks rather than a machine, like
|
||||
@@ -364,6 +364,178 @@ above, including:
|
||||
tick, nearest first (a Huge world is fully started after 17 s), and flying toward areas still
|
||||
waiting starts them. Nothing is skipped: the same settling happens, spread out.
|
||||
|
||||
- **Sloped walls and diagonal wall lines (t36, 2026-10-25).** The owner wanted funnel walls that
|
||||
material slides down instead of catching on block steps. A Sloped wall is a half-block triangle
|
||||
with a flat 45° face, in four orientations, built of any wall material at half a wall block's
|
||||
cost. Dragging walls or slopes at about 45° lays a diagonal line; for slopes the drag direction
|
||||
picks the orientation (solid side below) and T flips it, or turns a single piece. Each slope
|
||||
also fills one cell past its corner so a diagonal run is sealed (grains move diagonally and
|
||||
would otherwise slip through where two pieces meet). Square walls placed diagonally still
|
||||
touch only at corners and leak there, like any square blocks.
|
||||
Later the same day the owner asked for the Wall piece itself to work diagonally, the way screw conveyors
|
||||
do, so the separate Sloped wall piece left the build menu: a wall drag at about 45° lays slopes
|
||||
(solid side below, T flips it during the drag), straight drags and clicks lay squares, and for a
|
||||
single block T cycles square and the four corners. Slope ids are unchanged, so saves still load.
|
||||
|
||||
- **Diagonal screw conveyors (t35, 2026-10-25).** Screw conveyors run in eight directions. A drag at about 45°
|
||||
lays a diagonal run pointing along the drag, and R cycles all eight for a single screw conveyor. Diagonal
|
||||
runs carry grains at the same rate as straight ones, join straight screw conveyors and belts, and carry
|
||||
bars level, a block every eight ticks. They use new part ids (11-14) because part ids are saved.
|
||||
|
||||
- **Sloped belts and filters (t40, 2026-10-25).** The owner asked for belts, then filters, to work
|
||||
diagonally like screw conveyors and walls. A Belt or Filter drag at about 45° lays slope-shaped blocks
|
||||
whose face is a running belt, carrying grains and bars up or down in the drag's direction at
|
||||
flat-belt speed; the face grips its load, so nothing slides back. A sloped filter drops what it
|
||||
passes out underneath and carries the rest. New block codes (19-22), so saves still load.
|
||||
- **Square corners hold grains (t39, 2026-10-26).** The owner wants square wall staircases to
|
||||
hold material instead of leaking through each touching corner. Powder, liquid and gas cannot
|
||||
step diagonally between two solid neighbours; powder neighbours still allow free piles to
|
||||
slide as before. Free flyers and diagonal screw conveyor intakes obey the same solid-corner boundary.
|
||||
Screw lane transport is unchanged. World generation disables cell corner sealing while
|
||||
settling loose ground, preserving existing seed layouts. This supersedes the square-wall
|
||||
leakage described in the t36 decision below.
|
||||
- **Silo contents are visible without opening the gate (t38, 2026-10-26).** The owner wants
|
||||
contents beside the cursor anywhere over the outline and a material-coloured fill window at
|
||||
every zoom in both looks, while keeping the real steel silo shape.
|
||||
- **Distant drill aim is normal (t41, 2026-10-26).** The owner normally points beyond the
|
||||
drill's reach and expects players to do the same. Keep the pulse colour and light crosshair,
|
||||
mark the pulse's far edge, and omit distance warnings for the drill. Other tools still warn.
|
||||
- **Screw conveyors (t44, 2026-10-26).** The owner chose the real machine name for screw
|
||||
conveyors; code uses `screw` because `conveyor` also covers belts and filters. Transport,
|
||||
numeric ids and save versions stay unchanged.
|
||||
- **No backward compatibility for now (2026-10-26).** The owner will start a new world after
|
||||
changes: no one is playing the game for real yet. Changes may break old saves and blueprints;
|
||||
don't add migration or alias code for them (first used by the t44 rename to screw conveyors).
|
||||
- **No uphill sloped filters (t43, 2026-10-26).** The owner wants sloped belts and filters to be
|
||||
realistic in how they look and behave. Real sorting screens run flat or tilted downhill and
|
||||
shake; none climbs a 45° slope. So filters are flat or downhill
|
||||
only; to sort while lifting, run a sloped belt up, then a filter. Not built yet.
|
||||
|
||||
- **Conveyor drawing and sleep follow-up (t42/t43, 2026-10-26).** The owner wants visible
|
||||
rubber ribs on the actual cleat raster, filled corrugated far walls behind the load, and
|
||||
smooth continuous mesh with rails for inclined screens. Walls stop at the slope ends;
|
||||
only loaded screens shake. Empty moving cleats must let chunks sleep. Keep pocket transport,
|
||||
uphill throughput near 71 grains/s and exact sorting unchanged.
|
||||
|
||||
- **Sidewall belts with moving cleats (t42, 2026-10-26; physics replaced by the entry below).** To replace the old full-column
|
||||
grip, use moving barrier lines rather than material cells: the barrier mask blocks normal
|
||||
powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular
|
||||
to the face, spaced 8 horizontal face cells apart and 6 grains tall (integer normal reach
|
||||
4); named constants keep these tunable. A finite periodic pocket envelope carries only
|
||||
what fits, stepping every other tick. End pockets continue while the cleat rounds the band.
|
||||
Between steps no grains are stamped: bare face slides and excess rolls back. Water drains;
|
||||
bars stay level. Dark corrugated far walls sit behind visible grains; drawing uses the
|
||||
actual barrier mask, including after save/load. The envelope is a discrete transport model,
|
||||
not a calculation of contact forces. A generous flat feed delivers about 71 grains/s uphill
|
||||
versus 90 flat; accepting that reduction remains the owner's playtest decision.
|
||||
- **Inclined vibrating screens built (t43, 2026-10-26).** Diagonal filters always travel
|
||||
downhill, even when dragged uphill. R leaves a picked slope unchanged; flipping a blueprint
|
||||
mirrors its slope and keeps downhill travel. Placement and filter settings enforce the
|
||||
same direction. Sloped filters have neither column carry nor grip: gravity moves grains,
|
||||
with one mesh-contact nudge every 4 ticks (15 downhill steps/s plus gravity). Unselected
|
||||
bars step on that same cadence; selected bars fall through. Flat filters retain their
|
||||
linear transport. Sorting both slope shapes in either mode conserves the complete mix.
|
||||
Both looks use a sturdy mesh frame with a small tick-based shake only while loaded;
|
||||
idle mesh has no running belt animation. No old uphill-save migration was added.
|
||||
|
||||
- **Screw conveyors stay, in every direction (2026-10-26).** The owner asked whether screw
|
||||
conveyors are still needed now that belts climb 45°, whether they're realistic, and what a
|
||||
downward screw is for. Kept as they are: real plants use both (screws for steep, vertical and
|
||||
enclosed runs; belts for long, cheap, high-volume ones), and a downward screw is the same
|
||||
machine turning the other way, useful for letting material down at a steady rate.
|
||||
- **Stalls are the player's to fix, but visible; belts carry a realistic depth (t45,
|
||||
2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner
|
||||
agreed that's realistic and stays the player's problem, but wants a warning on the stalled
|
||||
belt and the animation stopped on everything affected, and belts limited to a realistic
|
||||
load depth instead of carrying 96-grain stacks. Not built yet.
|
||||
|
||||
- **Cleats really push their pockets (t42 feedback, 2026-10-26).** The owner found the first
|
||||
cleats passed through heavy loads while the material climbed as before, and wants cleats that
|
||||
hold the material and drag it up. Now nothing carries the load on a slope but the ribs and a
|
||||
friction drag on the layer touching the belt (which gravity beats on 45°, so loose grains slide
|
||||
back until a rib holds them). Each belt step every rib moves one face cell and pushes the
|
||||
grains in its way ahead of it; whatever is heaped above the rib is left to roll back. A rib
|
||||
never moves into a grain, never crosses a machine, and stops short of anything built. If any
|
||||
rib of a run can't push its way, the whole run stalls that step and every move is undone (the
|
||||
stall t45 will show). At the foot of an uphill run, each rib coming up around the end roller
|
||||
scoops what lies on the feeding belt behind it into its pocket, like a real boot section.
|
||||
Measured: about 80-86 grains/s uphill under a heavy feed (flat 90); downhill about 38, since
|
||||
the top only feeds a layer two grains deep onto the face.
|
||||
- **No cleats on downhill belts (t42 feedback, 2026-10-26).** The owner asked why a downhill
|
||||
belt has cleats: on 45° the load slides anyway. A downhill sloped belt is now a plain belt, with
|
||||
no ribs and no sidewall, that carries at belt speed like a flat one (90 grains/s); only uphill
|
||||
runs are sidewall belts. A whole run also shares one cleat phase, so pieces added later line up
|
||||
with it.
|
||||
- **Build range about the size of the screen (t64, 2026-10-27).** The owner asked whether
|
||||
building needs a range limit at all, since the bot needs no arms to build. Agreed: keep a limit,
|
||||
but make it about what's on screen, so anything the player can see and aim at builds directly
|
||||
and anything beyond stays a blueprint built when the bot comes near. No limit at all would let
|
||||
players build in places they've never been and link far claims instantly. Built: the range is
|
||||
the visible view box, at least 64 grains from its centre and at most 200 when zoomed far out.
|
||||
- **Sticky materials slide freely on built surfaces (t61, 2026-10-27).** Stickiness (STICK) is the
|
||||
chance per tick a grain takes a diagonal step, which made clay lumps, snow, wood chunks, ash and
|
||||
dirt creep down sloped walls (a single clay grain never got down a 20-piece slope). The owner
|
||||
chose to make stickiness grain against grain only: on anything built, every material slides
|
||||
freely, so slopes are useful for every material, while heaps keep their shape. Real chutes clog
|
||||
with sticky material and need cleaning, but that is micromanagement the game doesn't need.
|
||||
- **Belts and other transport pieces never fill a rectangle (2026-10-27).** The owner said Shift
|
||||
doesn't need to fill a rectangle for belts, conveyors and the like. Belts, filters, screw
|
||||
conveyors, shafts, pipes, pumps and outlets (`lineOnly()`) always draw a line, Shift or not, and
|
||||
a diagonal belt drag stays sloped. Walls, grates and gates still Shift-fill.
|
||||
- **The dev console ships in every build (t66, 2026-10-27).** The owner is happy for players to
|
||||
experiment and play their own way, so the console isn't hidden behind a dev build. Turning it
|
||||
on warns first that the save will be marked, and the mark stays with the world; any future
|
||||
achievements won't unlock in a marked world. Besides money, items and upgrades it may have
|
||||
game speed and other things just for fun.
|
||||
- **One join rule for every transport piece: the next piece goes right after the end
|
||||
(2026-10-27, replaces the t62 same-row rule).** The owner wants all pieces to place and join
|
||||
the same way, so players know what to expect when building. Joins follow the direction the
|
||||
material travels: a piece's outlet points at the block right after its end, which is the
|
||||
next block along a straight piece, or the block diagonally past a diagonal's end. The next
|
||||
piece joins when it starts in that block and carries the same general way. So a horizontal
|
||||
then a diagonal (up or down) starts the diagonal in the next block on the same row; a
|
||||
diagonal then a horizontal or vertical starts it diagonally past the diagonal's end; a
|
||||
vertical going up then a diagonal starts the diagonal directly above. Any other placement is
|
||||
two separate ends that pass nothing. Belts carry grains on top, so they bend the rule where
|
||||
grains can't follow it: an uphill belt's top feeds a flat belt on the same row, and a flat
|
||||
belt one block below an uphill belt's low end also feeds it. The owner first approved a rule
|
||||
based only on shape, then found in testing that horizontal → uphill and vertical → diagonal
|
||||
didn't join the way the other pieces did.
|
||||
- **Warehouses replace the trade dock, eventually (t68, 2026-10-27).** The dock only exists to
|
||||
get selling working for testing; the owner finds it unengaging. The plan: a warehouse at each
|
||||
edge of the map, on indestructible ground, served by a road and trucks. Warehouses accept
|
||||
loose grain as well as packaged goods (pallets, barrels or sacks from packaging machines).
|
||||
Trucks matter to play, not just looks: what's waiting is collected when a truck comes, on its
|
||||
schedule. Finding them: the player spawns a short walk from the left warehouse, which takes
|
||||
loose and bulk goods at lower prices with frequent trucks; the right one pays more for
|
||||
packaged goods and is the long-haul goal. Both always show on the minimap and full map with an
|
||||
edge-of-screen arrow, the first-start card names both and suggests starting left, signposts
|
||||
stand on indestructible plinths at spawn, and each warehouse is a landmark (crane or silo,
|
||||
beacon at night) with its road running a little way inland.
|
||||
- **Screws melting under hot loads stays, but must read clearly (t69, t70, 2026-10-27).** Hot
|
||||
material still damages screw conveyors (above 1000°), but the owner wants the damage obvious
|
||||
(a scorched or broken look, hover text, a first-time message), cheap to repair (build over it),
|
||||
and warned about before it happens (a glow near the limit). A heat-resistant screw comes later
|
||||
as an upgrade. Prompted by a long vertical screw that seemed to leak for no reason while it
|
||||
carried hot rubble.
|
||||
- **Melts that cool loose are ordinary rock (2026-10-27).** Magma poured from the bag became an
|
||||
invisible, undrillable basalt the game treated as built. Now any melt except metal and glass
|
||||
cools into natural rock wherever it came from, and any solid no block, mold or machine owns can
|
||||
be drilled, so nothing can get permanently stuck in the world.
|
||||
- **Resonance pylon: metal sets hardness, power sets reach (t47, 2026-10-27).** A pylon is built
|
||||
in copper, iron or steel, which caps the hardest rock it can break (4, 5, 6), the same way
|
||||
bits and tanks step up through metals. Power from the shaft network sets its radius and pulse
|
||||
rate, up to a cap per metal; power never sets hardness, so it can't skip the steel bit. It is
|
||||
tuned like the drill and never harms anything built. It waits until power is switched back on,
|
||||
since today every machine runs without power.
|
||||
- **Performance: WebGPU and all CPU cores, in the browser (2026-10-27).** Zoomed out, drawing
|
||||
is CPU-bound (about 26 ns per visible grain) and the simulation runs on one core. The owner
|
||||
chose: WebGPU only (no WebGL fallback), tested in Chrome and Firefox with a clear message when
|
||||
it's missing; the game must be served with COOP/COEP headers (for SharedArrayBuffer), so
|
||||
opening the file from disk is dropped; determinism is re-baselined once and becomes the same on
|
||||
any number of cores; as fast as possible, measured at every step. Grain movement and heat go
|
||||
across a worker pool first; moving the whole simulation into its own worker is decided after.
|
||||
|
||||
## Ideas not built yet
|
||||
|
||||
- **Concrete**, as a stronger wall material from rubble.
|
||||
|
||||
@@ -13,14 +13,15 @@ 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/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays
|
||||
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
|
||||
audio, the frame loop
|
||||
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
||||
t_smooth.py); run_all.py
|
||||
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
|
||||
t_smooth.py, t_dev_console.py, t_caps.py, firefox_smoke.py); run_all.py
|
||||
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
|
||||
browser.py (shared launch/GPU choice), caps.py (capability report),
|
||||
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
|
||||
scripts to .venv), scenes/ (screenshot setups), experiments/
|
||||
(measurements and reports)
|
||||
@@ -29,6 +30,11 @@ docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per syste
|
||||
dist/, shots/ build output and screenshots (not committed)
|
||||
```
|
||||
|
||||
The dev console pairs `src/sim/devconsole.js` (headless command dispatch, mark, log and
|
||||
runtime controls) with `src/ui/devconsole.js` (confirmation and bottom panel). Save metadata
|
||||
keeps `dev` and the last 200 `devLog` entries; free building, speed and dev pause reset on load.
|
||||
The frame loop applies speed and queued steps while keeping the per-frame tick limit.
|
||||
|
||||
## How it runs
|
||||
|
||||
- **One script.** All files share one global scope; there is no import/export.
|
||||
@@ -44,7 +50,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
4. update the HUD.
|
||||
- **One tick** (`simTick` in `src/sim/tick.js`), in this order:
|
||||
- update the sky roof line (every 20 ticks);
|
||||
- weather, machines (including belts and tubes), pipes, drill pulses;
|
||||
- weather, machines (including belts and screw conveyors), pipes, drill pulses;
|
||||
- bars (`entTick`) and grains in flight (`flyTick`);
|
||||
- falling sand (`simMove`), then heat (`heatPass`);
|
||||
- every so often: molds, milestones, plants, cold, tree support;
|
||||
@@ -62,7 +68,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
- `temp`: °C;
|
||||
- `flags`: `F_NAT` natural rock, `F_CAST`, `F_BURN` burning;
|
||||
- `stamp`: moved this tick;
|
||||
- `part`: which built part a cell belongs to (`P_*`: tube directions, grate, open gate, filter,
|
||||
- `part`: which built part a cell belongs to (`P_*`: screw conveyor directions, grate, open gate, filter,
|
||||
pipe...);
|
||||
- `mach`: machine id;
|
||||
- `digp`: drill damage;
|
||||
@@ -81,7 +87,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
(open inside).
|
||||
- `molds`;
|
||||
- `ents` (bars, 4×2 grains, with their own physics);
|
||||
- `flyers` (grains in the air: blown, off belt ends, out of tube mouths, rain);
|
||||
- `flyers` (grains in the air: blown, off belt ends, out of screw conveyor mouths, rain);
|
||||
- `pipeNets`;
|
||||
- `ghostBlocks`, `ghostMachines`, `ghostMolds`.
|
||||
- **Player:** `bot` (position), `bag` (count per material), `player` (bit and tank tier, lens,
|
||||
@@ -118,6 +124,17 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
- kind `mold`.
|
||||
`hidden` pieces are not in the menu. The menu's tabs and order are `BUILD_CATS` in
|
||||
`src/ui/build-menu.js`.
|
||||
- **Transport geometry:** `joins.js` owns travel-directed ends and mutual joins for belts,
|
||||
filters and screws. `pieceEnds(code, v)` returns precomputed geometric left/right ends
|
||||
(vertical screws: up/down), with travel and inlet/outlet roles. `endJoin(bx, by, endIndex,
|
||||
planned = null)` returns `neighbourBlock * 2 + neighbourEnd`, or -1; `pieceJoins` returns
|
||||
bits 0/1. Plans override world blocks, including the queried block. Outlets target the next
|
||||
block along travel, with the belt surface exceptions. Inlet ties prefer same-shape travel,
|
||||
then top-to-bottom, left-to-right; a join must be reciprocal. Both looks, ghosts, previews,
|
||||
diagonal screw grain/bar transfers and straight screw entry into diagonals share this rule.
|
||||
Flat-belt bar entry uses `entBeltScrewEnter` and the swept-path collision check. Straight
|
||||
screw side-feeding into lifts remains independent. Edge elbows bend inside the
|
||||
diagonal; diagonal outlets bend inside the receiving straight block.
|
||||
- **Placing and removing:**
|
||||
- `placeBlock` / `removeBlock` (`blocks.js`);
|
||||
- `placeMachineAt` / `addMachine` / `removeMachine` (`actions.js`, `machines.js`);
|
||||
@@ -132,17 +149,23 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
|
||||
## Drawing
|
||||
|
||||
- **The pixel look:**
|
||||
- `drawWorld` (`src/render/world.js`) fills one pixel per grain;
|
||||
- `computeLight` (`light.js`) adds coloured light;
|
||||
- overlays (machines, molds, the bot, rain, arrows) are drawn on the 2D canvas afterwards
|
||||
(`machines.js`, `overlays.js`).
|
||||
- **The smooth look** (`src/render/smooth.js`) takes the buffers `drawWorld` fills (colour and
|
||||
a class per cell, the colour behind, trees, fields, light, glow) and shades them with WebGL2.
|
||||
- The cell classes are set in `world.js` and read in the shader: 4 is a crisp built piece,
|
||||
6 a wall tile, 7/8 a belt, 9–12 a screw tube, 13/14 a filter.
|
||||
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test
|
||||
tools set it).
|
||||
Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars,
|
||||
flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas.
|
||||
|
||||
- `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during
|
||||
world loading, and handles one device recreation followed by an autosaved Reload notice.
|
||||
- `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32
|
||||
chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes
|
||||
mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo.
|
||||
- `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no
|
||||
pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading.
|
||||
- `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep.
|
||||
- `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains,
|
||||
field AO and glow; `gpu/surface.js` caches the parallax horizon.
|
||||
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
|
||||
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
|
||||
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
|
||||
smooth tags, ghosts and previews.
|
||||
|
||||
## The browser shell
|
||||
|
||||
@@ -166,12 +189,13 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
|
||||
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
|
||||
| Controls help text | `src/ui/notebook.js` (`controlsHtml`) |
|
||||
| Cached vein membership, ore identification and label timing | `src/ui/cursor.js` |
|
||||
| Hover info and discovery cards | `src/ui/info.js` |
|
||||
| Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` |
|
||||
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
|
||||
| Falling sand, liquids, gases | `src/sim/cells.js` |
|
||||
| Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` |
|
||||
| Belts, filters, screw tubes | `src/sim/conveyors.js`, `src/sim/machine-tick.js` |
|
||||
| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/slope-conveyors.js` (uphill cleat barriers and screen nudges), `src/sim/machine-tick.js` |
|
||||
| Fans | `src/sim/machine-tick.js` (`fanBlow`) |
|
||||
| Refiner, Stone cutter, Silo | `src/sim/processors.js` |
|
||||
| Pipes, pumps, outlets | `src/sim/pipes.js` |
|
||||
@@ -183,7 +207,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
| World generation | `src/sim/worldgen.js` |
|
||||
| Save format and migrations | `src/sim/save.js` |
|
||||
| Machine drawing (silo, refiner...) | `src/render/machines.js` |
|
||||
| Smooth-look shader | `src/render/smooth.js` |
|
||||
| Smooth-look shader | `src/render/gpu/smooth-shader.js` |
|
||||
| Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) |
|
||||
| Sound and music | `src/ui/audio.js` |
|
||||
|
||||
@@ -201,14 +225,14 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti
|
||||
- functions: def, metalFamily
|
||||
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO
|
||||
|
||||
### src/sim/material-tables.js (49 lines)
|
||||
### src/sim/material-tables.js (54 lines)
|
||||
Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables
|
||||
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH
|
||||
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH
|
||||
|
||||
### src/sim/pieces.js (143 lines)
|
||||
### src/sim/pieces.js (179 lines)
|
||||
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
|
||||
- functions: isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
|
||||
- variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
|
||||
- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, filterVariant, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, lineOnly, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
|
||||
- variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
|
||||
|
||||
### src/sim/economy.js (152 lines)
|
||||
Money, prices, market saturation, the trade dock, buyer and builder orders.
|
||||
@@ -220,27 +244,27 @@ Starter furnace designs and the milestone list shown in the notebook.
|
||||
- functions: smelterBlueprint
|
||||
- variables: STARTER_DESIGNS, MILESTONES
|
||||
|
||||
### src/sim/world.js (104 lines)
|
||||
### src/sim/world.js (123 lines)
|
||||
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
||||
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
||||
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
||||
- functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
||||
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
||||
|
||||
### src/sim/fog.js (52 lines)
|
||||
Exploration only: never changes grains or the random stream.
|
||||
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
|
||||
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
|
||||
|
||||
### src/sim/cells.js (207 lines)
|
||||
### src/sim/cells.js (223 lines)
|
||||
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
|
||||
- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove
|
||||
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM
|
||||
- functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
|
||||
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
|
||||
|
||||
### src/sim/heat.js (265 lines)
|
||||
### src/sim/heat.js (266 lines)
|
||||
Heat: conduction, melting and freezing, reactions, fire and air, steel.
|
||||
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
|
||||
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
|
||||
|
||||
### src/sim/machines.js (148 lines)
|
||||
### src/sim/machines.js (157 lines)
|
||||
Machines: the power network, footprints, placing and removing machines.
|
||||
- functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine
|
||||
- variables: nextPowerBuild
|
||||
@@ -260,44 +284,60 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g
|
||||
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
|
||||
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
|
||||
|
||||
### src/sim/machine-tick.js (164 lines)
|
||||
Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
|
||||
### src/sim/machine-tick.js (157 lines)
|
||||
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
|
||||
- functions: engineIntake, fanBlow, machinesTick
|
||||
|
||||
### src/sim/conveyors.js (128 lines)
|
||||
How belts, filters and screw tubes move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
|
||||
- functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake
|
||||
### src/sim/joins.js (125 lines)
|
||||
Travel-directed transport ends. Precomputed probes; world/plan queries allocate nothing. End indices stay geometric: left/right, or up/down for vertical screws.
|
||||
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
|
||||
- variables: JOIN_ENDS, NO_PIECE
|
||||
|
||||
### src/sim/conveyors.js (213 lines)
|
||||
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
|
||||
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
|
||||
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
|
||||
|
||||
### src/sim/blocks.js (156 lines)
|
||||
### src/sim/flow.js (26 lines)
|
||||
Throughput for the info overlay (t49): how many grains each belt, filter and screw conveyor block hands on, per second of game time. Counting runs only while the overlay is on, so normal play pays one flag check per carried grain. Bars are not counted.
|
||||
- functions: setFlowCounting, flowNote, flowCross, flowSlopeUnder, flowTick
|
||||
- variables: flowOn, flowCount, flowRate
|
||||
|
||||
### src/sim/slope-conveyors.js (231 lines)
|
||||
Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells. Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls. Only uphill runs have cleats: downhill, the belt carries its load like a flat one.
|
||||
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
|
||||
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
|
||||
|
||||
### src/sim/blocks.js (237 lines)
|
||||
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
||||
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||
|
||||
### src/sim/molds.js (317 lines)
|
||||
Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
|
||||
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
|
||||
- variables: DROSS, grains, barWord
|
||||
|
||||
### src/sim/items.js (232 lines)
|
||||
### src/sim/items.js (318 lines)
|
||||
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
|
||||
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeLane, entTubeEdge, entTubeEnter, entTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
|
||||
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
|
||||
- variables: BAR_W, BAR_H, entCurM, droppedLast
|
||||
|
||||
### src/sim/tools.js (52 lines)
|
||||
### src/sim/tools.js (65 lines)
|
||||
Tool helpers: reach, brushes, and plain digging.
|
||||
- functions: inReach, forBrush, drillReach, inDrillReach, toolDig
|
||||
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
|
||||
- variables: BUILD_MAX, buildBox
|
||||
|
||||
### src/sim/drill.js (161 lines)
|
||||
### src/sim/drill.js (177 lines)
|
||||
The resonance drill: tuned pulses that shatter one material.
|
||||
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick
|
||||
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF
|
||||
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
|
||||
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
|
||||
|
||||
### src/sim/nature.js (168 lines)
|
||||
### src/sim/nature.js (170 lines)
|
||||
Trees (background layer), acorns, saplings, and waking freezing water.
|
||||
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
|
||||
- variables: treeBases, saplings, treeDraw
|
||||
|
||||
### src/sim/vacuum.js (172 lines)
|
||||
### src/sim/vacuum.js (199 lines)
|
||||
The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
|
||||
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
|
||||
- variables: blowRR, FLY_G, FLY_Q
|
||||
@@ -310,7 +350,7 @@ Player actions on built things: take back, place a machine or mold, gates, F to
|
||||
Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
|
||||
- functions: copyRegion, flipBlueprint, bpCost, pasteGhost, cutRegion, footprintBlocker, clearFootprint
|
||||
|
||||
### src/sim/undo.js (87 lines)
|
||||
### src/sim/undo.js (93 lines)
|
||||
Undo (Ctrl+Z) for building, and the bot building ghosts.
|
||||
- functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0
|
||||
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
|
||||
@@ -329,7 +369,12 @@ Weather: clouds from steam, rain and snow, and the world water count.
|
||||
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
|
||||
- variables: WBAND, WS, weather
|
||||
|
||||
### src/sim/tick.js (28 lines)
|
||||
### src/sim/workers.js (153 lines)
|
||||
The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
|
||||
- functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage
|
||||
- variables: simPool, chunkWork, inlineWork
|
||||
|
||||
### src/sim/tick.js (29 lines)
|
||||
simTick: one simulation step, in order.
|
||||
- functions: simTick
|
||||
|
||||
@@ -342,72 +387,141 @@ World generation helpers: noise and removal of tiny hard rock specks.
|
||||
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
|
||||
- functions: generateWorld, generateWorldAsync
|
||||
|
||||
### src/sim/save.js (177 lines)
|
||||
### src/sim/devconsole.js (114 lines)
|
||||
Dev commands are pure simulation operations; the browser owns the panel and confirmation.
|
||||
- functions: resetDevConsole, enableDevConsole, devMaterial, devCommand
|
||||
- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS
|
||||
|
||||
### src/sim/save.js (185 lines)
|
||||
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
|
||||
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
|
||||
- variables: SAVE_VERSION, TOFF
|
||||
|
||||
### src/render/setup.js (200 lines)
|
||||
### src/render/setup.js (190 lines)
|
||||
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
|
||||
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
|
||||
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
|
||||
|
||||
### src/render/light.js (79 lines)
|
||||
Propagated coloured light from glowing and burning cells.
|
||||
- functions: computeLight
|
||||
### src/render/slope-belts.js (167 lines)
|
||||
Drawing only: the face stays on its simulation cells; the band hangs below it.
|
||||
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
|
||||
- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance, slopeWallOwner
|
||||
|
||||
### src/render/world.js (308 lines)
|
||||
### src/render/diagonal-screws.js (79 lines)
|
||||
Screw casing geometry shared by both looks, ghosts and placement previews. smooth-look class of a screw conveyor cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
|
||||
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
|
||||
|
||||
### src/render/cell.js (276 lines)
|
||||
Rare-cell shading also treats built pieces as transparent surroundings.
|
||||
- functions: builtForShade, cellPixel
|
||||
|
||||
### src/render/objects.js (80 lines)
|
||||
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
||||
- functions: worldObjects
|
||||
|
||||
### src/render/gpu/cracks.js (79 lines)
|
||||
The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||
- functions: gpuCrackTables
|
||||
- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW
|
||||
|
||||
### src/render/gpu/colour.js (101 lines)
|
||||
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
||||
- functions: gpuColourShader
|
||||
- variables: GPU_COLOUR, GPU_COLOUR_SMOOTH
|
||||
|
||||
### src/render/gpu/shaders.js (42 lines)
|
||||
Half-resolution separable Gaussian bloom, then nearest colour + linear light.
|
||||
- variables: GPU_BLUR, GPU_DRAW
|
||||
|
||||
### src/render/gpu/light.js (143 lines)
|
||||
GPU sources retain the CPU roof and transmission rules; directional relaxation follows only left/up, then right/down, so it cannot introduce extra light paths.
|
||||
- functions: gpuLightGrid, gpuLightBindings, gpuLight
|
||||
- variables: GPU_LIGHT, GPU_LIGHT_SPREAD, GPU_LIGHT_PACK
|
||||
|
||||
### src/render/gpu/smooth-slope.js (107 lines)
|
||||
Smooth slope geometry and cleats, ported from slope-belt-shader.js.
|
||||
- variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW
|
||||
|
||||
### src/render/gpu/smooth-shader.js (368 lines)
|
||||
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
||||
- variables: GPU_SMOOTH
|
||||
|
||||
### src/render/gpu/surface.js (5 lines)
|
||||
- functions: smoothSurface
|
||||
- variables: SM
|
||||
|
||||
### src/render/gpu/smooth.js (48 lines)
|
||||
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
||||
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
|
||||
- variables: GPU_FIELD_MIP
|
||||
|
||||
### src/render/gpu/device.js (90 lines)
|
||||
All pipelines compile during startup; rendering never falls back.
|
||||
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
|
||||
- variables: GPU_LIMITS, GPU
|
||||
|
||||
### src/render/gpu/textures.js (98 lines)
|
||||
Whole-world textures retain clean chunks across camera moves.
|
||||
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
|
||||
|
||||
### src/render/gpu/uploads.js (48 lines)
|
||||
Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||
- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads
|
||||
|
||||
### src/render/gpu/draw.js (92 lines)
|
||||
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
||||
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
|
||||
- variables: GPU_PATTERN
|
||||
|
||||
### src/render/world.js (46 lines)
|
||||
Scale colour without changing the smooth-look class.
|
||||
- functions: fogColor, drawWorld
|
||||
- variables: TUBE_CLASS
|
||||
|
||||
### src/render/machines.js (242 lines)
|
||||
### src/render/machines.js (256 lines)
|
||||
Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
|
||||
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawRackBlocks
|
||||
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
|
||||
|
||||
### src/render/overlays.js (229 lines)
|
||||
### src/render/overlays.js (370 lines)
|
||||
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
|
||||
### src/render/smooth.js (99 lines)
|
||||
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
|
||||
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
|
||||
- variables: SM, smBytes
|
||||
|
||||
### src/render/smooth-shaders.js (325 lines)
|
||||
Smooth shaders: vertex and fragment programs for the rounded world look.
|
||||
- variables: SM_VS, SM_FS
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
|
||||
|
||||
### src/ui/audio.js (230 lines)
|
||||
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
|
||||
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
||||
- variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
|
||||
|
||||
### src/ui/state.js (75 lines)
|
||||
### src/ui/state.js (76 lines)
|
||||
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status.
|
||||
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
|
||||
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
|
||||
|
||||
### src/ui/title.js (46 lines)
|
||||
The title screen art.
|
||||
- functions: buildTitleCells, drawTitle
|
||||
- variables: FONT5, TITLE
|
||||
### src/ui/threads.js (39 lines)
|
||||
Browser transport only; the simulation owns the schedule and shared state.
|
||||
- functions: startThreads
|
||||
- variables: threadStarting
|
||||
|
||||
### src/ui/title.js (77 lines)
|
||||
Startup capabilities and title art.
|
||||
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
|
||||
- variables: REQUIRE_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
|
||||
|
||||
### src/ui/saves.js (224 lines)
|
||||
Saves in IndexedDB, world files (export / import) and the blueprint library.
|
||||
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
||||
- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
|
||||
|
||||
### src/ui/claims.js (145 lines)
|
||||
### src/ui/claims.js (146 lines)
|
||||
Shared player state; the world save remains a complete standalone fallback.
|
||||
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
|
||||
- variables: career, claimBusy
|
||||
|
||||
### src/ui/session.js (157 lines)
|
||||
### src/ui/session.js (162 lines)
|
||||
Starting, loading and quitting a game; modal dialogs; the pause menu.
|
||||
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
|
||||
|
||||
### src/ui/settings.js (125 lines)
|
||||
### src/ui/settings.js (124 lines)
|
||||
The settings screen.
|
||||
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
|
||||
- variables: SET_TABS, setTab
|
||||
@@ -426,20 +540,21 @@ Notebook market: prices, orders, claim payments and wallet.
|
||||
Notebook blueprints: starter designs, saved library and code sharing.
|
||||
- functions: renderBlueprintTab, copyCode
|
||||
|
||||
### src/ui/hud.js (34 lines)
|
||||
### src/ui/hud.js (40 lines)
|
||||
The tool hotbar and what is selected.
|
||||
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
|
||||
- variables: IN_BAG_ORDER
|
||||
|
||||
### src/ui/build-menu.js (104 lines)
|
||||
### src/ui/build-menu.js (111 lines)
|
||||
The build menu along the bottom: categories, piece tiles and their icons.
|
||||
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
|
||||
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
|
||||
|
||||
### src/ui/selection.js (96 lines)
|
||||
### src/ui/selection.js (133 lines)
|
||||
Tool selection: cycling, drill tuning, piece variants and costs.
|
||||
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow
|
||||
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, dragPlan, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope
|
||||
|
||||
### src/ui/selection-pickers.js (59 lines)
|
||||
### src/ui/selection-pickers.js (61 lines)
|
||||
Selection pickers: filter rules, filter settings and wall materials. What a filter can hold: loose things and bars you have found, plus anything it already holds
|
||||
- functions: filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker
|
||||
|
||||
@@ -448,35 +563,43 @@ Selection panels: current tool card and bag materials.
|
||||
- functions: renderSelCard, renderBag
|
||||
- variables: lastBagKey
|
||||
|
||||
### src/ui/info.js (188 lines)
|
||||
### src/ui/cursor.js (47 lines)
|
||||
Hover work is shared by the cursor label and the vein outline.
|
||||
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
|
||||
- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince
|
||||
|
||||
### src/ui/info.js (208 lines)
|
||||
Discovery cards, hover info (I) and the cursor label.
|
||||
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
|
||||
|
||||
### src/ui/input.js (91 lines)
|
||||
### src/ui/devconsole.js (42 lines)
|
||||
Bottom panel; command dispatch and state live in sim/devconsole.js.
|
||||
- functions: closeDevConsole, showDevConsole, toggleDevConsole, devPrint, runDevCommand
|
||||
|
||||
### src/ui/input.js (92 lines)
|
||||
Keyboard and mouse listeners.
|
||||
- functions: isTyping
|
||||
- variables: canvas
|
||||
|
||||
### src/ui/actions.js (239 lines)
|
||||
### src/ui/actions.js (256 lines)
|
||||
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
|
||||
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
|
||||
- variables: ovShown
|
||||
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick
|
||||
- variables: ovShown, PING_RECHARGE
|
||||
|
||||
### src/ui/gamepad.js (60 lines)
|
||||
Gamepad input.
|
||||
- functions: padPressed, pollPad
|
||||
- variables: PAD
|
||||
|
||||
### src/ui/previews.js (190 lines)
|
||||
### src/ui/previews.js (241 lines)
|
||||
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
|
||||
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews
|
||||
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
|
||||
|
||||
### src/ui/preview-drill.js (104 lines)
|
||||
Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
||||
- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
|
||||
- variables: CRACK_N, CRACK_LV
|
||||
### src/ui/preview-drill.js (67 lines)
|
||||
Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
- functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
|
||||
|
||||
### src/ui/preview-selection.js (80 lines)
|
||||
### src/ui/preview-selection.js (81 lines)
|
||||
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
|
||||
- functions: drawTag, drawSelection, drawPastePreview
|
||||
- variables: SEL_COL
|
||||
@@ -490,54 +613,99 @@ Shared world colours, minimap and full map.
|
||||
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
|
||||
- variables: MAP_S, MAP
|
||||
|
||||
### src/ui/loop.js (134 lines)
|
||||
### src/ui/perf.js (15 lines)
|
||||
Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
|
||||
|
||||
### src/ui/loop.js (149 lines)
|
||||
The frame loop and boot.
|
||||
- functions: scanVisible, frame, boot
|
||||
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
||||
|
||||
## Tests and tools
|
||||
|
||||
- `test/firefox_smoke.py`: An isolated Firefox title screen and three seconds of a new pixel world.
|
||||
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
|
||||
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
|
||||
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
|
||||
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
|
||||
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
|
||||
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
|
||||
- `test/run_all.py`: Run the test suites and report.
|
||||
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
|
||||
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
|
||||
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
|
||||
- `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
|
||||
- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
|
||||
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
|
||||
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
|
||||
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
|
||||
- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata.
|
||||
- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge.
|
||||
- `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
|
||||
- `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks.
|
||||
- `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys.
|
||||
- `test/t_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does.
|
||||
- `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence.
|
||||
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
|
||||
- `test/t_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
|
||||
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only.
|
||||
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
|
||||
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
|
||||
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
||||
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
||||
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
|
||||
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
|
||||
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
|
||||
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
|
||||
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
|
||||
- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
|
||||
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
|
||||
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
|
||||
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
|
||||
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
|
||||
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
|
||||
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
|
||||
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
|
||||
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
|
||||
- `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
|
||||
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
|
||||
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
||||
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
|
||||
- `tools/browser.py`: Shared isolated HTTP sessions; callers attach hooks before navigating to url.
|
||||
- `tools/caps.py`: Print isolation, WebGPU adapter info and GL renderer for installed browsers.
|
||||
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
|
||||
- `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
|
||||
- `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
|
||||
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
|
||||
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
|
||||
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
|
||||
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
|
||||
- `tools/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
|
||||
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
|
||||
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
|
||||
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
|
||||
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
|
||||
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
|
||||
- `tools/experiments/factory_cost.js`: What does a running factory cost when the player is somewhere else? Builds K self-running modules in a generated world, far from the bot, and measures simulation time per tick. Each module: a coal + copper-ore furnace burning in a stone-block box, a spring feeding a water wheel, a shaft carrying its power to a 10-block belt that hauls sand off a hopper.
|
||||
- `tools/experiments/flow.js`: Throughput of belts and screw tubes: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
|
||||
- `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
|
||||
- `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals.
|
||||
- `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE).
|
||||
- `tools/experiments/slope-cost.js`: Many loaded slopes, with a fresh deterministic world for each timing sample.
|
||||
- `tools/experiments/slope-flow.js`: Reproducible generous-feed throughput and settled pile angles for t42.
|
||||
- `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...]
|
||||
- `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal]
|
||||
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
|
||||
- `tools/scenes/bar-lift.js`: A mold drops copper bars onto a belt, through a screw lift and onto the upper belt.
|
||||
- `tools/scenes/belt-heap.js`: A belt feeding an upward screw: sand underneath a deep heap of copper ore. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/belt-heap.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/belt-heap.js)" --ticks 240 --zoom 7
|
||||
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
|
||||
- `tools/scenes/joins.js`: Travel join grid: UR/DR/DL/UL, both ends, and all three neighbouring placements. Screw H and V, then belt H; each tries the side, above/below, and E. Self-contained, paused and empty. Use --zoom 4 --viewport 2100x1500 --no-light.
|
||||
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
|
||||
- `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
|
||||
- `tools/scenes/slope-conveyors.js`: Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
|
||||
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
|
||||
- `tools/scenes/t-screen-joint.js`: Downhill screen whose outlet belt sits level with its last block (the jam the owner saw).
|
||||
- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket.
|
||||
- `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
|
||||
- `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect.
|
||||
- `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand.
|
||||
- `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below.
|
||||
- `tools/scenes/t88-light.js`: Frozen light comparisons, also used by the WebGPU browser regression suite.
|
||||
- `tools/scenes/t88-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
|
||||
|
||||
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
|
||||
- A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically.
|
||||
|
||||
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the
|
||||
same test give the same output every run, apart from lines with timings in ms. To prove a
|
||||
same test give outputs identical across thread counts and between runs, apart from lines with timings in ms. To prove a
|
||||
refactor changed nothing, save every suite's output before and after, and diff them:
|
||||
|
||||
```sh
|
||||
@@ -58,6 +58,17 @@ for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # afte
|
||||
diff -r /tmp/before /tmp/after
|
||||
```
|
||||
|
||||
`test/t_threads.js` runs the real shared-memory scheduler inline and with 1, 2 and 8
|
||||
Node workers, including repeats, comparing all mutable cell/chunk bytes and JS side effects.
|
||||
The browser creates up to `min(16, hardwareConcurrency - 2, CPU workers setting)` helpers.
|
||||
Small passes run inline; larger passes use batches to amortize dispatch. Wakes and heat flags
|
||||
merge after each pass; long liquid searches are deferred until that merge. Global machine and
|
||||
weather randomness remains separate from the chunk streams. World generation uses the same
|
||||
inline schedule while settling. The t88 re-baseline changes worldgen material hashes and the
|
||||
seeded sand pile bounds in `t_corner_seal.js`; the heat oracle now supports pass outputs.
|
||||
The conveyor fixture uses a wider feed pile, the screen fixture warms longer, and `t_load.js`
|
||||
checks waking within eight ticks separately from the probabilistic consumption of fuel.
|
||||
|
||||
## Browser tests
|
||||
|
||||
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
|
||||
@@ -67,12 +78,47 @@ diff -r /tmp/before /tmp/after
|
||||
gamepad and world sizes. Screenshots go to
|
||||
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
|
||||
look.
|
||||
- `test/t_smooth.py` checks that the WebGL2 smooth look compiles and draws, that it falls back
|
||||
to pixels cleanly, and that the thermal lens works.
|
||||
- Run them one at a time. Two headless browsers at once can starve each other, and
|
||||
`t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other.
|
||||
- Rebuild first (`python3 build.py`). They load `dist/index.html`. `run_all.py --browser`
|
||||
rebuilds for you.
|
||||
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
|
||||
- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under
|
||||
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
|
||||
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
|
||||
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
|
||||
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
|
||||
from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the
|
||||
Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes.
|
||||
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
|
||||
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
|
||||
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
|
||||
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
|
||||
compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
|
||||
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
|
||||
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
|
||||
actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures
|
||||
store float16 (the removed CPU implementation used float32). Behavioural expectations remain.
|
||||
- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests.
|
||||
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
|
||||
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
|
||||
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
|
||||
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
|
||||
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
|
||||
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader.
|
||||
Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend.
|
||||
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
|
||||
changed. The play test holds the slow-save fixture open during its screenshot, waits for
|
||||
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
|
||||
a known reachable target; these checks do not depend on GPU speed or earlier digging.
|
||||
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
|
||||
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
|
||||
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
|
||||
the mandatory WebGPU startup gate. Cross-origin isolation is required for threads. Firefox smoke
|
||||
checks that a browser without WebGPU shows the notice and cannot start a world.
|
||||
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
|
||||
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
|
||||
only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a
|
||||
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
|
||||
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
|
||||
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
|
||||
and [GPUWeb implementation status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status#firefox).
|
||||
|
||||
## Screenshots: tools/shot.py
|
||||
|
||||
@@ -88,6 +134,13 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
`tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set
|
||||
`CAM = [x, y]` to frame the shot.
|
||||
- The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`.
|
||||
- Both looks require WebGPU; there is no renderer switch or legacy fallback.
|
||||
- `test/t_gpu.py` checks GPU uploads, dirty/full equality, contours, resize, timing and device loss.
|
||||
World-pixel tests use `worldPixelContext()` to read both stacked canvases. The helper
|
||||
presents the retained GPU result again before copying, because canvas textures expire after
|
||||
presentation ([WebGPU specification](https://gpuweb.github.io/gpuweb/#abstract-opdef-expire-the-current-texture),
|
||||
[Mozilla implementation notes](https://bugzilla.mozilla.org/show_bug.cgi?id=1965377)). Tests that change
|
||||
static `noiseB` in place set `GPU.world = null` before drawing to upload their new fixture.
|
||||
- `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics.
|
||||
- `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at
|
||||
the same moment. Two shots of the same scene then match pixel for pixel, and a before / after
|
||||
@@ -106,10 +159,9 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader:
|
||||
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set
|
||||
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game
|
||||
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
|
||||
- **GPU access in headless Chromium.** Real mode needs access to GPU devices and driver caches;
|
||||
a filesystem sandbox can silently force software rendering. Use `tools/caps.py` to check.
|
||||
Soft mode is available for machines without a GPU. Both modes load isolated HTTP pages.
|
||||
- **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is
|
||||
`undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name.
|
||||
Functions declared with `function` are on `window`.
|
||||
@@ -128,7 +180,98 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Measuring
|
||||
|
||||
- `tools/perf.py`: renderer and tick times in the browser.
|
||||
- `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
|
||||
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
|
||||
|
||||
```sh
|
||||
python3 tools/perf.py --json tools/perf-baseline.json
|
||||
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
|
||||
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
|
||||
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
|
||||
python3 tools/test_perf.py
|
||||
```
|
||||
|
||||
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
|
||||
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
|
||||
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
|
||||
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
|
||||
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
|
||||
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
|
||||
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
|
||||
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
|
||||
|
||||
**World** is the whole `drawWorld` CPU call. **Prep** sets up the view/masks/joins;
|
||||
**Colour** fills CPU rare-cell overrides (including ghosts and flyers); **Upload** writes
|
||||
dirty world chunks and override textures; **Blit** submits GPU drawing and composition.
|
||||
These are CPU wall times, including driver stalls; they are not GPU timestamp measurements.
|
||||
Bloom is computed on the GPU. GPU light timestamps are recorded separately as `gpuLight`.
|
||||
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
|
||||
overlays absent from the fixture have zero calls, not a measured zero execution cost.
|
||||
|
||||
Five calls warm each render configuration before 30 measured calls. Simulation warms five
|
||||
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
|
||||
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
|
||||
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
|
||||
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
|
||||
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
|
||||
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
|
||||
|
||||
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
|
||||
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
|
||||
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
|
||||
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
|
||||
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
|
||||
JSON records a fixed render scale of 1 for both looks.
|
||||
Comparisons accept subsets of the baseline scenes, looks and zooms, with identical world/viewport dimensions and run duration, print all timing changes, and mark >10% regressions;
|
||||
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
|
||||
|
||||
Performance runs launch Chromium with
|
||||
`--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization --enable-unsafe-webgpu`.
|
||||
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
|
||||
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. Real mode refuses software
|
||||
renderers; both modes require isolation. Chromium needs access to the host GPU devices; a filesystem
|
||||
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
|
||||
tests' SwiftShader numbers (and explicit `--gpu soft` profiler runs) are not a hardware performance baseline.
|
||||
|
||||
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
|
||||
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
|
||||
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
|
||||
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
|
||||
free-port background server with cleanup for measurement tools.
|
||||
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
|
||||
- `tools/experiments/flow.js`: belt and tube throughput in grains a second.
|
||||
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
|
||||
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
|
||||
|
||||
Both looks use WebGPU. Colour measures the CPU rare-cell override fill, including loose
|
||||
objects and ghosts. `frame_ms` is the complete paused-frame CPU call, with overlays and UI.
|
||||
Upload measures dirty world chunks, override buffers and small lighting tables; Blit measures
|
||||
GPU command submission. `override_cells` counts CPU cells. GPU timestamp samples include
|
||||
colour, lighting, bloom and drawing, excluding uploads. CPU lighting is gone. The GPU canvas
|
||||
draws the world; the transparent game canvas holds overlays. Override colours and glow live in
|
||||
plain typed arrays, without offscreen Canvas2D backing stores.
|
||||
|
||||
Use `tools/scenes/t88-renderer.js` with `--freeze --zoom 12` (or 6/2),
|
||||
`--viewport 1920x1080` and `--look pixel|smooth`. It includes terrain, water, fire, magma,
|
||||
fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost. Frozen legacy
|
||||
comparisons are in `test/t_gpu_golden.py`; no legacy renderer remains in the game.
|
||||
|
||||
The smooth shader retains the nine-tap glow filter and samples field AO at mip level 2.2;
|
||||
three render passes generate field levels 1-3, including non-power-of-two view sizes.
|
||||
Only smooth uploads back/tree/field overrides and a separate presence mask. Profiler scale is
|
||||
always 1. The screenshot tool invalidates the parallax horizon after setup changes `surfY`.
|
||||
Mip pass references: [Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
|
||||
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
|
||||
|
||||
`perfSim` reports synchronous tick wall time (also occupied main-thread time), polling in
|
||||
`threadWait`, and main compute excluding polling. The latter includes serial simulation,
|
||||
dispatch/merge and the main thread's own chunk batch; it is not pure scheduling overhead.
|
||||
Final t88 full-matrix results are saved in `tools/perf-after-t88.json`; keep
|
||||
`tools/perf-baseline.json` unchanged as the original comparison.
|
||||
|
||||
The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four
|
||||
storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for
|
||||
2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits)
|
||||
and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp).
|
||||
All game pipelines use async creation during capability checking. World loading submits both
|
||||
looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`;
|
||||
look/zoom switches must not call synchronous pipeline creation.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"shell": "shell.html",
|
||||
"css": [
|
||||
"styles/style.css"
|
||||
"styles/style.css",
|
||||
"styles/devconsole.css"
|
||||
],
|
||||
"sim": [
|
||||
"sim/base.js",
|
||||
@@ -19,7 +20,10 @@
|
||||
"sim/racks.js",
|
||||
"sim/pipes.js",
|
||||
"sim/machine-tick.js",
|
||||
"sim/joins.js",
|
||||
"sim/conveyors.js",
|
||||
"sim/flow.js",
|
||||
"sim/slope-conveyors.js",
|
||||
"sim/blocks.js",
|
||||
"sim/molds.js",
|
||||
"sim/items.js",
|
||||
@@ -33,21 +37,37 @@
|
||||
"sim/bot.js",
|
||||
"sim/ledger.js",
|
||||
"sim/weather.js",
|
||||
"sim/workers.js",
|
||||
"sim/tick.js",
|
||||
"sim/worldgen-helpers.js",
|
||||
"sim/worldgen.js",
|
||||
"sim/devconsole.js",
|
||||
"sim/save.js"
|
||||
],
|
||||
"client": [
|
||||
"render/setup.js",
|
||||
"render/light.js",
|
||||
"render/slope-belts.js",
|
||||
"render/diagonal-screws.js",
|
||||
"render/cell.js",
|
||||
"render/objects.js",
|
||||
"render/gpu/cracks.js",
|
||||
"render/gpu/colour.js",
|
||||
"render/gpu/shaders.js",
|
||||
"render/gpu/light.js",
|
||||
"render/gpu/smooth-slope.js",
|
||||
"render/gpu/smooth-shader.js",
|
||||
"render/gpu/surface.js",
|
||||
"render/gpu/smooth.js",
|
||||
"render/gpu/device.js",
|
||||
"render/gpu/textures.js",
|
||||
"render/gpu/uploads.js",
|
||||
"render/gpu/draw.js",
|
||||
"render/world.js",
|
||||
"render/machines.js",
|
||||
"render/overlays.js",
|
||||
"render/smooth.js",
|
||||
"render/smooth-shaders.js",
|
||||
"ui/audio.js",
|
||||
"ui/state.js",
|
||||
"ui/threads.js",
|
||||
"ui/title.js",
|
||||
"ui/saves.js",
|
||||
"ui/claims.js",
|
||||
@@ -61,7 +81,9 @@
|
||||
"ui/selection.js",
|
||||
"ui/selection-pickers.js",
|
||||
"ui/selection-panels.js",
|
||||
"ui/cursor.js",
|
||||
"ui/info.js",
|
||||
"ui/devconsole.js",
|
||||
"ui/input.js",
|
||||
"ui/actions.js",
|
||||
"ui/gamepad.js",
|
||||
@@ -70,6 +92,7 @@
|
||||
"ui/preview-selection.js",
|
||||
"ui/events.js",
|
||||
"ui/map.js",
|
||||
"ui/perf.js",
|
||||
"ui/loop.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// Rare-cell shading also treats built pieces as transparent surroundings.
|
||||
function builtForShade(i, x, y) {
|
||||
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
|
||||
}
|
||||
// CPU colour rules for the rare-cell WebGPU overrides.
|
||||
function cellPixel(x, y) {
|
||||
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
|
||||
const bx = x - vx0, by = y - vy0, row = by * bw;
|
||||
if (R.gpuOn) { if (!gpuOverrideHas(row + bx)) GPU.overrideCount++; if (sm) GPU.overrideMask[row + bx] = 255; }
|
||||
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
|
||||
const OUT = sm ? 0x04000000 : 0xff000000;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) {
|
||||
px[row + bx] = OUT;
|
||||
if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; }
|
||||
return;
|
||||
}
|
||||
const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence;
|
||||
const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
|
||||
const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowW;
|
||||
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
|
||||
const yw = y * W;
|
||||
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
|
||||
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
|
||||
let fv = 255;
|
||||
if (useFog) {
|
||||
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
|
||||
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
|
||||
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
|
||||
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } return; }
|
||||
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
|
||||
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
|
||||
if (fv < 255) {
|
||||
let s = fv / 255;
|
||||
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
|
||||
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
|
||||
}
|
||||
}
|
||||
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
|
||||
const T = temp[i];
|
||||
// Also used by the thermal lens, which always draws in pixels.
|
||||
let looseShade = 1;
|
||||
if (!sm && m !== EMPTY && !builtForShade(i, x, y)) {
|
||||
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
|
||||
else if (PH[m] === PH_SOLID && (
|
||||
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
|
||||
(x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) ||
|
||||
(y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) ||
|
||||
(y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W])
|
||||
)) looseShade = 0.72;
|
||||
}
|
||||
let r, g, b;
|
||||
if (lens) {
|
||||
const ti = thermIdx(T);
|
||||
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
|
||||
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
|
||||
const p = part[i];
|
||||
r *= looseShade; g *= looseShade; b *= looseShade;
|
||||
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
|
||||
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
|
||||
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
return;
|
||||
}
|
||||
// background
|
||||
let br, bgc, bb, sky = false;
|
||||
const tb = bgm[i];
|
||||
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
|
||||
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
|
||||
else {
|
||||
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
|
||||
const v = sm ? 0 : nz * 0.06;
|
||||
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
|
||||
}
|
||||
if (sm) {
|
||||
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
|
||||
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
|
||||
if (tb) {
|
||||
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
|
||||
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
|
||||
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
|
||||
} else tpx[row + bx] = 0;
|
||||
}
|
||||
if (m === EMPTY) { r = br; g = bgc; b = bb; }
|
||||
else {
|
||||
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
|
||||
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
|
||||
r *= looseShade; g *= looseShade; b *= looseShade;
|
||||
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
|
||||
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
|
||||
else if (m === BELTL || m === BELTR) {
|
||||
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
|
||||
if (ly === 0 || ly === 3) {
|
||||
// top run moves with the load, bottom run returns the other way
|
||||
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
|
||||
const k = ly === 0 ? 1 : 0.72;
|
||||
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
|
||||
} else if (lx === 1 || lx === 2) {
|
||||
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
|
||||
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
|
||||
} else { r = 46; g = 48; b = 54; }
|
||||
} else if (m === PIPE) {
|
||||
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
|
||||
} else if (m === SHAFT) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
|
||||
else { r -= 34; g -= 30; b -= 24; }
|
||||
}
|
||||
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
|
||||
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
|
||||
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
|
||||
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
|
||||
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
|
||||
}
|
||||
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
|
||||
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
|
||||
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
|
||||
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
|
||||
const al = ALPHA[m];
|
||||
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
|
||||
const dp = digp[i];
|
||||
if (dp) {
|
||||
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
|
||||
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
|
||||
r += lift; g += lift; b += lift * 1.08;
|
||||
}
|
||||
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
|
||||
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
const wc = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3);
|
||||
if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
|
||||
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
|
||||
}
|
||||
}
|
||||
// field parts drawn over contents
|
||||
const sb = slopeBeltAt(i, x, y, sm);
|
||||
let p = part[i];
|
||||
if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
|
||||
const db = diagonalScrewBlock(x, y, sm);
|
||||
let diagonal = false;
|
||||
if (db >= 0) {
|
||||
const v = blockV[db], joins = diagonalJoins[db];
|
||||
const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins);
|
||||
p = sm || sd <= 0 ? SCREW_PARTS[v] : 0;
|
||||
diagonal = !!p;
|
||||
if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8);
|
||||
}
|
||||
if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
|
||||
else if (p) {
|
||||
if (IS_SCREW[p]) {
|
||||
const on = netSat(i) > 0;
|
||||
const vert = p === P_SCREW_U || p === P_SCREW_D;
|
||||
const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x;
|
||||
const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3;
|
||||
const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1;
|
||||
if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
|
||||
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
|
||||
if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
|
||||
} else {
|
||||
// screw flights: a diagonal stripe that walks along the tube when powered
|
||||
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
|
||||
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
|
||||
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
|
||||
}
|
||||
} else if (p === P_FILTER) {
|
||||
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
|
||||
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
|
||||
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
|
||||
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) {
|
||||
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
|
||||
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
|
||||
}
|
||||
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
|
||||
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
|
||||
} else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
|
||||
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
|
||||
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
|
||||
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
|
||||
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
|
||||
else { r = 24; g = 25; b = 28; }
|
||||
}
|
||||
} else if (p === P_GRATE) {
|
||||
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
|
||||
} else if (p === P_GOPEN) {
|
||||
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
|
||||
} else if (p === P_INTAKE) {
|
||||
if (m === EMPTY) { r = 30; g = 32; b = 36; }
|
||||
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
|
||||
} else if (p === P_PIPE) {
|
||||
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
|
||||
}
|
||||
}
|
||||
const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
|
||||
if (slopeArt && !sm) {
|
||||
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
|
||||
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
|
||||
}
|
||||
const slopeDetail = slopeDetailAt(i, x, y, sm);
|
||||
if (slopeDetail) {
|
||||
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
|
||||
// ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber
|
||||
const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])];
|
||||
r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56;
|
||||
g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60;
|
||||
b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68;
|
||||
}
|
||||
// incandescence
|
||||
if (T > 450 && m !== EMPTY && incand) {
|
||||
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
|
||||
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
|
||||
if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
|
||||
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
|
||||
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
|
||||
}
|
||||
}
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
let top = 0xff000000;
|
||||
if (sm) {
|
||||
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
|
||||
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
|
||||
let cl = 0;
|
||||
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
|
||||
if (IS_SCREW[pt]) cl = SCREW_CLASS[pt];
|
||||
else if (p === P_FILTER && sb < 0) {
|
||||
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
|
||||
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
|
||||
cl = blockV[fb] & 128 ? 14 : 13;
|
||||
tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
|
||||
}
|
||||
else if (m === BELTL || m === BELTR) {
|
||||
cl = m === BELTL ? 7 : 8;
|
||||
tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
|
||||
}
|
||||
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
|
||||
else if (m !== EMPTY) {
|
||||
const ph = PH[m];
|
||||
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1;
|
||||
else if (ph === PH_POWDER) cl = 2;
|
||||
else if (ph === PH_LIQUID) cl = 3;
|
||||
else cl = 5;
|
||||
}
|
||||
const wallCode = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) {
|
||||
cl = wallCode;
|
||||
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
|
||||
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
|
||||
}
|
||||
if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
|
||||
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
|
||||
const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
|
||||
if (slopeArt) cl = slopeClass;
|
||||
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
|
||||
tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
|
||||
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
|
||||
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
|
||||
}
|
||||
if (slopeDetail && slopeWallOwner >= 0) {
|
||||
// ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation
|
||||
const wb = slopeWallOwner;
|
||||
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
|
||||
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl |
|
||||
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
|
||||
}
|
||||
top = cl << 24;
|
||||
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not
|
||||
}
|
||||
if (fv < 255) {
|
||||
const k = fv / 255; r *= k; g *= k; b *= k;
|
||||
if (sm) {
|
||||
const c = bpx[row + bx], t = tpx[row + bx];
|
||||
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
|
||||
}
|
||||
}
|
||||
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
||||
function gpuColourShader(smooth) { return `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
|
||||
@group(0) @binding(2) var<storage, read> land: array<vec4f>;
|
||||
${['mat','flags','life','part','bgm','digp','noise','fog'].map((n,i)=>`@group(0) @binding(${i+3}) var ${n}T: texture_2d<u32>;`).join('\n')}
|
||||
@group(0) @binding(11) var tempT: texture_2d<f32>;
|
||||
@group(0) @binding(12) var overrideT: texture_2d<f32>;
|
||||
@group(0) @binding(13) var overrideGlow: texture_2d<f32>;
|
||||
@group(0) @binding(14) var outputT: texture_storage_2d<rgba8unorm, write>;
|
||||
${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(17) var treeOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(18) var fieldOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(19) var backOver: texture_2d<f32>;
|
||||
@group(0) @binding(20) var treeOver: texture_2d<f32>;
|
||||
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
|
||||
@group(0) @binding(22) var maskOver: texture_2d<u32>;
|
||||
` : ""}
|
||||
${smooth ? "" : "@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;"}
|
||||
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
||||
fn powder(p: vec2i) -> bool {
|
||||
return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u;
|
||||
}
|
||||
fn quant(c: vec3f) -> vec3f { return floor(clamp(c,vec3f(0),vec3f(255)))/255.; }
|
||||
@compute @workgroup_size(8,8)
|
||||
fn colour(@builtin(global_invocation_id) id: vec3u) {
|
||||
let q=vec2i(id.xy); if (any(q>=vec2i(u.view.zw))) { return; }
|
||||
let p=q+vec2i(u.view.xy); let x=p.x; let y=p.y;
|
||||
var rgb=vec3f(0); var bloom=vec3f(0);
|
||||
${smooth ? "var cl=4.; var backValue=vec4f(0); var treeValue=vec4f(0); var fieldValue=vec4f(1,0,0,0);" : ""}
|
||||
if (all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r; let nz=f32(textureLoad(noiseT,p,0).r)-128.;
|
||||
let T=textureLoad(tempT,p,0).r; let mc=lut[m*3u]; let props=lut[m*3u+1u];
|
||||
var fv=255.;
|
||||
if (u.style.x>0.) {
|
||||
let f=vec2f(p)/${FOG}.; let a=vec2i(f); let b=min(a+1,vec2i(u.world.zw)-1); let t=fract(f);
|
||||
fv=mix(mix(f32(textureLoad(fogT,a,0).r),f32(textureLoad(fogT,vec2i(b.x,a.y),0).r),t.x),mix(f32(textureLoad(fogT,vec2i(a.x,b.y),0).r),f32(textureLoad(fogT,b,0).r),t.x),t.y);
|
||||
if (fv>0. && fv<255.) { var s=fv/255.; s=clamp(s+nz*(0.6/128.)*s*(1.-s),0.,1.); fv=max(1.,255.*s*s*(3.-2.*s)); }
|
||||
}
|
||||
if (fv>0.) {
|
||||
var shade=1.;
|
||||
let built=props.x==${PH_SOLID}. && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u;
|
||||
if (${smooth ? "false" : `m!=${EMPTY}u && !built`}) {
|
||||
if (props.x==${PH_POWDER}.) { shade=1.1+nz*0.00065; }
|
||||
else if (props.x==${PH_SOLID}. && (powder(p+vec2i(-1,0)) || powder(p+vec2i(1,0)) || powder(p+vec2i(0,-1)) || powder(p+vec2i(0,1)))) { shade=0.72; }
|
||||
}
|
||||
if (u.effects.x>0.) {
|
||||
let ti=u32(clamp(i32(T/4.),0,550)); let thermal=lut[${NMAT*3+700}u+u32(u.effects.y)*551u+ti].rgb;
|
||||
rgb=select(thermal*0.78+vec3f((mc.r+mc.g+mc.b)/3.*0.25),thermal*0.55,m==${EMPTY}u)*shade;
|
||||
} else {
|
||||
let tb=textureLoad(bgmT,p,0).r; let dp=f32(textureLoad(digpT,p,0).r);
|
||||
let column=land[x]; let sky=land[i32(u.world.x)+y].rgb;
|
||||
var back: vec3f;
|
||||
if (${smooth ? "false" : "tb!=0u"}) { back=(lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/128.))*(1.+dp/700.); }
|
||||
else if (f32(y)<column.x) {
|
||||
back=sky;
|
||||
if (u.style.y>0.01) { let gy=(sky.r+sky.g+sky.b)/3.*0.82; back+= (vec3f(gy)-sky)*u.style.y*vec3f(0.7,0.7,0.65); }
|
||||
} else {
|
||||
back=vec3f(${BG.basalt.join(',')});
|
||||
if (f32(y)<column.y) { back=vec3f(${BG.dirt.join(',')}); }
|
||||
else if (f32(y)<column.z) { back=vec3f(${BG.stone.join(',')}); }
|
||||
else if (f32(y)<column.w) { back=vec3f(${BG.granite.join(',')}); }
|
||||
${smooth ? "" : "back+=vec3f(nz*0.06);"}
|
||||
}
|
||||
${smooth ? `backValue=vec4f(quant(back),select(0.,1.,f32(y)<column.x));
|
||||
if (tb!=0u) { treeValue=vec4f(quant((lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/256.))*(1.+dp/700.)),select(128./255.,1.,tb==${LEAVES}u)); }
|
||||
cl=0.; if (m!=${EMPTY}u) { cl=5.; if(props.x==${PH_SOLID}.) { cl=1.; } else if(props.x==${PH_POWDER}.) { cl=2.; } else if(props.x==${PH_LIQUID}.) { cl=3.; } }
|
||||
fieldValue=vec4f(select(0.,1.,cl==1.),select(0.,1.,cl==3.),select(0.,1.,cl==5.),select(0.,1.,tb!=0u));
|
||||
` : ""}
|
||||
rgb=back;
|
||||
if (m!=${EMPTY}u) {
|
||||
rgb=(mc.rgb+vec3f(nz*mc.a*${smooth ? `select(u.style.z*0.35,0.,props.x==${PH_SOLID}.)` : "u.style.z"}/128.))*shade;
|
||||
if (m==${FIRE}u) { let f=f32(textureLoad(lifeT,p,0).r & 15u)*6.; rgb=vec3f(255.,120.+f,30.+f*0.5); }
|
||||
else if (${smooth ? "false && " : ""}m==${WATER}u && ((x*3+y*5+(i32(u.style.w)>>2)) & 15)==0) { rgb+=vec3f(14.); }
|
||||
rgb=back+(rgb-back)*props.y;
|
||||
if (dp>0.) { let lift=dp/255.*(30.+sin(u.style.w*1.9+f32(x)*1.3+f32(y)*0.7)*6.); rgb+=lift*vec3f(1,1,1.08); }
|
||||
}
|
||||
if (T>450. && m!=${EMPTY}u && u.effects.z>0.) {
|
||||
var gt=T;
|
||||
if (props.x==${PH_SOLID}.) { gt+=nz*0.9; }
|
||||
else if (m==${MAGMA}u) { gt+=sin(f32(x)*0.19+f32(y)*0.11+u.style.w*0.03+nz*0.04)*55.-30.; }
|
||||
let gi=u32(clamp(i32((gt-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
|
||||
rgb=rgb*(1.-glow.a)+glow.rgb*glow.a;
|
||||
if (u.effects.w>0. && fv>=128. && glow.a>0.3) { bloom=quant(glow.rgb*(glow.a-0.3)*1.4); }
|
||||
}
|
||||
}
|
||||
rgb=quant(rgb*fv/255.);
|
||||
${smooth ? "backValue=vec4f(quant(backValue.rgb*fv),backValue.a); treeValue=vec4f(quant(treeValue.rgb*fv),treeValue.a);" : ""}
|
||||
}
|
||||
}
|
||||
// Flying grains replace colour, while the glow behind them remains.
|
||||
let over=textureLoad(overrideT,q,0);
|
||||
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
|
||||
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
|
||||
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
|
||||
${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"}
|
||||
}`;
|
||||
}
|
||||
const GPU_COLOUR = gpuColourShader(false);
|
||||
const GPU_COLOUR_SMOOTH = gpuColourShader(true);
|
||||
@@ -0,0 +1,79 @@
|
||||
// The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||
function gpuCrackTables() {
|
||||
const out = new Float32Array(3 * 512 * 4);
|
||||
for (let l=0;l<3;l++) {
|
||||
const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i);
|
||||
for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
|
||||
for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255];
|
||||
for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const GPU_CRACKS = `
|
||||
fn crackNoise(p: vec2f, l: u32) -> f32 {
|
||||
let base=${NMAT*3+700+551*3}u+l*512u;
|
||||
let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p);
|
||||
let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x);
|
||||
let a=lut[base+u32(lut[base+v.x+p0].x)].yz;
|
||||
let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz;
|
||||
let c=lut[base+u32(lut[base+v.x+p1].x)].yz;
|
||||
let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz;
|
||||
let t=f*f*f*(f*(f*6.-15.)+10.);
|
||||
return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41;
|
||||
}
|
||||
`;
|
||||
// Screen-resolution colour output preserves the old sub-grain marching-square strokes.
|
||||
const GPU_CRACK_COLOUR = `
|
||||
struct Params { view:vec4f, world:vec4f, style:vec4f, effects:vec4f, camera:vec4f, light:vec4f, screen:vec4f, extra:vec4f }
|
||||
@group(0) @binding(0) var<uniform> u:Params;
|
||||
@group(0) @binding(1) var<storage,read> lut:array<vec4f>;
|
||||
@group(0) @binding(2) var matT:texture_2d<u32>;
|
||||
@group(0) @binding(3) var digpT:texture_2d<u32>;
|
||||
@group(0) @binding(4) var outputT:texture_storage_2d<rgba8unorm,write>;
|
||||
${GPU_CRACKS}
|
||||
fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 {
|
||||
let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.));
|
||||
}
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id:vec3u) {
|
||||
let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;}
|
||||
let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos));
|
||||
var colour=vec4f(0);
|
||||
if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r;let damage=f32(textureLoad(digpT,p,0).r)/255.;
|
||||
if(lut[m*3u+1u].x==${PH_SOLID}.) {
|
||||
for(var l=0u;l<3u;l++) {
|
||||
if(damage<array<f32,3>(0.1,0.42,0.72)[l]){continue;}
|
||||
let scale=array<f32,3>(1./7.,1./3.6,1./2.)[l];
|
||||
let at=vec2f(p)*scale+vec2f(0.37,0.61);
|
||||
let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l);
|
||||
let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l);
|
||||
var pts:array<vec2f,4>;var n=0u;
|
||||
if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;}
|
||||
if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;}
|
||||
if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;}
|
||||
if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;}
|
||||
let width=array<f32,3>(0.2,0.14,0.09)[l];
|
||||
for(var stroke=0u;stroke<2u;stroke++) {
|
||||
let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip);
|
||||
var distance=100.;
|
||||
for(var k=0u;k+1u<n;k+=2u) {
|
||||
distance=min(distance,segmentDistance(pos-vec2f(p)-vec2f(0,offset),pts[k],pts[k+1u]));
|
||||
}
|
||||
let w=max(1./u.camera.z,width*select(1.,1.3,lip))*0.5;
|
||||
let alpha=clamp((w-distance)*u.camera.z+0.5,0.,1.)*select(0.78-f32(l)*0.12,0.13,lip);
|
||||
let rgb=select(vec3f(18,12,10),vec3f(255,240,220),lip)/255.;
|
||||
colour=vec4f(rgb*alpha+colour.rgb*(1.-alpha),alpha+colour.a*(1.-alpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(outputT,q,colour);
|
||||
}`;
|
||||
const GPU_CRACK_DRAW = `
|
||||
@group(0) @binding(0) var cracks:texture_2d<f32>;
|
||||
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
|
||||
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);}
|
||||
`;
|
||||
@@ -0,0 +1,90 @@
|
||||
// All pipelines compile during startup; rendering never falls back.
|
||||
const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 };
|
||||
function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); }
|
||||
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
|
||||
async function gpuInit(adapter) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits.");
|
||||
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
|
||||
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS });
|
||||
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
|
||||
d.lost.then(info => { if (GPU.device === d) gpuRecover(info.message || 'Graphics device lost'); });
|
||||
GPU.context = GPU.canvas.getContext('webgpu');
|
||||
GPU.format = navigator.gpu.getPreferredCanvasFormat();
|
||||
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
|
||||
d.pushErrorScope('validation');
|
||||
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
|
||||
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
|
||||
await gpuSmoothInit();
|
||||
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
|
||||
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
|
||||
}
|
||||
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
|
||||
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
|
||||
GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}});
|
||||
const cracks = d.createShaderModule({code:GPU_CRACK_DRAW});
|
||||
GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});
|
||||
const error = await d.popErrorScope(); if (error) throw error;
|
||||
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
GPU.params = new Float32Array(32);
|
||||
GPU.sampler = d.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
|
||||
if (features.length) {
|
||||
GPU.queries = d.createQuerySet({ type: 'timestamp', count: 4 });
|
||||
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
|
||||
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
}
|
||||
GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started;
|
||||
return true;
|
||||
} catch (e) { GPU.ok = false; GPU.error = e.message; return false; }
|
||||
}
|
||||
function gpuFail(message) {
|
||||
GPU.ok = false; GPU.error = message;
|
||||
console.warn('WebGPU renderer: ' + message);
|
||||
if (GAME.running) gpuRecover(message);
|
||||
}
|
||||
async function gpuRecover(message) {
|
||||
if (GPU.recovering || GPU.stopped) return;
|
||||
GPU.ok = false; GPU.recovering = true;
|
||||
const saved = !GAME.running || await saveWorld('auto', true);
|
||||
const old = GPU.device;
|
||||
// Resources belong to the lost device; force every retained cache to rebuild.
|
||||
GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null;
|
||||
GPU.bindColour = GPU.lightDirections = GPU.queries = null;
|
||||
GPU.timingBusy = false; GPU.lightFrame = undefined;
|
||||
let restored = false;
|
||||
try {
|
||||
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
|
||||
if (adapter) restored = await gpuInit(adapter);
|
||||
} catch (e) { GPU.error = e.message; }
|
||||
if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; }
|
||||
GPU.device = null; if (old) old.destroy();
|
||||
GPU.stopped = true; GPU.recovering = false; GAME.paused = true;
|
||||
dialog('Graphics stopped', '<p>The graphics device could not restart. '+
|
||||
(saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'</p>', [
|
||||
...(!saved ? [{label:'Export world',fn:exportWorld}] : []),
|
||||
{label:'Reload',fn:()=>location.reload()}
|
||||
]);
|
||||
}
|
||||
// Submit both looks behind the loading screen, including their first real bindings.
|
||||
async function gpuWarmWorld() {
|
||||
await startThreads();
|
||||
buildSky(); resizeRender(); snapCamera();
|
||||
const look = SET.look, started = performance.now();
|
||||
try {
|
||||
for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) {
|
||||
SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone();
|
||||
}
|
||||
GPU.warmMs = performance.now() - started;
|
||||
} finally { SET.look = look; }
|
||||
}
|
||||
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
|
||||
function worldPixelContext() {
|
||||
if (!R.gpuOn) return R.ctx;
|
||||
// Canvas textures expire after presentation; redraw the retained result before copying.
|
||||
const encoder = GPU.device.createCommandEncoder(); gpuPresent(encoder);
|
||||
GPU.device.queue.submit([encoder.finish()]);
|
||||
const c = document.createElement('canvas'); c.width = R.canvas.width; c.height = R.canvas.height;
|
||||
const ctx = c.getContext('2d'); ctx.drawImage(GPU.canvas, 0, 0); ctx.drawImage(R.canvas, 0, 0);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
||||
const GPU_PATTERN = new Uint8Array(NMAT);
|
||||
for (const m of [BELTL, BELTR, PIPE, SHAFT, GATE, CUTST, GRINDER, SAWB, PRESSB, RACKB, WOOD, LWOOD, HULL, SPRING, DOCKW]) GPU_PATTERN[m] = 1;
|
||||
function gpuOverrideHas(k) { return R.smoothOn ? GPU.overrideMask[k] : R.px[k]; }
|
||||
function gpuOverrides() {
|
||||
R.px.fill(0);
|
||||
if (R.smoothOn) {
|
||||
if (!GPU.overrideMask || GPU.overrideMask.length !== R.px.length) GPU.overrideMask = new Uint8Array(R.px.length);
|
||||
GPU.overrideMask.fill(0);
|
||||
}
|
||||
if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB);
|
||||
const marked = GPU.blocks; marked.fill(0);
|
||||
const bx0 = Math.max(0, (R.vx0 >> 2) - 3), by0 = Math.max(0, (R.vy0 >> 2) - 3);
|
||||
const bx1 = Math.min(BW - 1, ((R.vx0 + R.bw) >> 2) + 3), by1 = Math.min(BH - 1, ((R.vy0 + R.bh) >> 2) + 3);
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b];
|
||||
let rare = !!code;
|
||||
if (!rare) for (let dy = 0; dy < BS && !rare; dy++) for (let dx = 0; dx < BS; dx++) {
|
||||
const i = (by * BS + dy) * W + bx * BS + dx;
|
||||
if (part[i] || mach[i] || occ[i] || GPU_PATTERN[mat[i]]) { rare = true; break; }
|
||||
}
|
||||
if (!rare) continue;
|
||||
marked[b] = 1;
|
||||
if (code === BP_SCREW || isSlopeBelt(code)) {
|
||||
// Covers joins, the far wall and cleats, including cells outside the owning block.
|
||||
for (let yy = Math.max(0, by - 3); yy <= Math.min(BH - 1, by + 2); yy++)
|
||||
for (let xx = Math.max(0, bx - 2); xx <= Math.min(BW - 1, bx + 2); xx++) marked[yy * BW + xx] = 1;
|
||||
}
|
||||
}
|
||||
if (R.smoothOn) for (const mo of molds) {
|
||||
if (mo.x + 40 < R.vx0 || mo.x - 40 > R.vx0 + R.bw || mo.y + 40 < R.vy0 || mo.y - 40 > R.vy0 + R.bh) continue;
|
||||
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) marked[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] = 1;
|
||||
}
|
||||
GPU.overrideCount = 0;
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
if (!marked[by * BW + bx]) continue;
|
||||
const x0 = Math.max(bx * BS, R.vx0), y0 = Math.max(by * BS, R.vy0);
|
||||
const x1 = Math.min(bx * BS + BS, R.vx0 + R.bw, W), y1 = Math.min(by * BS + BS, R.vy0 + R.bh, H);
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) cellPixel(x, y);
|
||||
}
|
||||
}
|
||||
function gpuDraw(lighting, glowOn, lightUpdate) {
|
||||
const upload = performance.now();
|
||||
gpuUpload(lighting, glowOn);
|
||||
if (GPU.canvas.width !== R.canvas.width || GPU.canvas.height !== R.canvas.height) {
|
||||
GPU.canvas.width = R.canvas.width; GPU.canvas.height = R.canvas.height;
|
||||
}
|
||||
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
|
||||
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
|
||||
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
|
||||
compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour);
|
||||
if (R.smoothOn) {
|
||||
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||
compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth);
|
||||
}
|
||||
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||
compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks);
|
||||
compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end();
|
||||
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
|
||||
const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]);
|
||||
pass.dispatchWorkgroups(Math.ceil(R.glowW / 8), Math.ceil(R.glowH / 8)); pass.end();
|
||||
}
|
||||
const lightTimed = lighting && (lightUpdate || GPU.lightFrame === undefined);
|
||||
if (lightTimed) { gpuLight(encoder, timed); GPU.lightFrame = R.frame; }
|
||||
if (R.smoothOn) gpuSmoothMips(encoder);
|
||||
gpuPresent(encoder, timed);
|
||||
if (timed) { encoder.resolveQuerySet(GPU.queries, 0, lightTimed ? 4 : 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, lightTimed ? 32 : 16); }
|
||||
d.queue.submit([encoder.finish()]);
|
||||
if (timed) {
|
||||
GPU.timingBusy = true;
|
||||
GPU.timingPromise = GPU.readback.mapAsync(GPUMapMode.READ).then(() => {
|
||||
const ns = new BigUint64Array(GPU.readback.getMappedRange()); GPU.gpuMs = Number(ns[1] - ns[0]) / 1e6;
|
||||
GPU.lightMs = lightTimed ? Number(ns[3] - ns[2]) / 1e6 : 0;
|
||||
GPU.readback.unmap(); GPU.timingBusy = false;
|
||||
}).catch(() => { GPU.timingBusy = false; });
|
||||
}
|
||||
if (PERF_MARKS || SET.showPerf) {
|
||||
perfMarks.override = perfMarks.color; perfMarks.upload = blit - upload; perfMarks.blit = performance.now() - blit;
|
||||
perfMarks.gpuLight = GPU.lightMs || 0;
|
||||
if (GPU.gpuMs !== null) perfMarks.gpu = GPU.gpuMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Also used for readback after the browser has expired the presented canvas texture.
|
||||
function gpuPresent(encoder, timed = false) {
|
||||
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
|
||||
});
|
||||
pass.setPipeline(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
|
||||
const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}],
|
||||
...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})});
|
||||
cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// GPU sources retain the CPU roof and transmission rules; directional relaxation
|
||||
// follows only left/up, then right/down, so it cannot introduce extra light paths.
|
||||
const GPU_LIGHT = `
|
||||
struct LightParams { grid: vec4f, world: vec4f, lamp: vec4f, levels: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: LightParams;
|
||||
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
|
||||
@group(0) @binding(2) var<storage, read> columns: array<vec4f>;
|
||||
@group(0) @binding(3) var matT: texture_2d<u32>;
|
||||
@group(0) @binding(4) var flagsT: texture_2d<u32>;
|
||||
@group(0) @binding(5) var partT: texture_2d<u32>;
|
||||
@group(0) @binding(6) var bgmT: texture_2d<u32>;
|
||||
@group(0) @binding(7) var machT: texture_2d<u32>;
|
||||
@group(0) @binding(8) var blockT: texture_2d<u32>;
|
||||
@group(0) @binding(9) var tempT: texture_2d<f32>;
|
||||
@group(0) @binding(10) var<storage, read_write> roofs: array<i32>;
|
||||
@group(0) @binding(11) var dest: texture_storage_2d<rgba16float, write>;
|
||||
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
||||
fn built(p: vec2i) -> bool {
|
||||
return (phase(p)==${PH_SOLID} && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u) ||
|
||||
textureLoad(blockT,p/4,0).r!=0u || textureLoad(machT,p,0).r!=0u || textureLoad(partT,p,0).r!=0u;
|
||||
}
|
||||
fn loose(p: vec2i) -> bool {
|
||||
for(var d=0;d<=12 && p.y+d<i32(u.world.y);d++) {
|
||||
let q=p+vec2i(0,d); let ph=phase(q);
|
||||
if(built(q)) { return true; }
|
||||
if(ph!=${PH_POWDER} && ph!=${PH_LIQUID}) { return ph==${PH_EMPTY} || ph==${PH_GAS}; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@compute @workgroup_size(64)
|
||||
fn roof(@builtin(global_invocation_id) id: vec3u) {
|
||||
let lx=i32(id.x); if(lx>=i32(u.grid.z)) { return; }
|
||||
let x=(i32(u.grid.x)+lx)*i32(u.world.z);
|
||||
if(x<0 || x>=i32(u.world.x)) { roofs[lx]=0; return; }
|
||||
let end=min(i32(u.world.y),i32((u.grid.y+u.grid.w)*u.world.z));
|
||||
var y=i32(columns[x].y);
|
||||
for(;y<end;y++) {
|
||||
let p=vec2i(x,y); let ph=phase(p);
|
||||
if((ph==${PH_SOLID} || ph==${PH_POWDER} || ph==${PH_LIQUID}) && !built(p) && !((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { break; }
|
||||
}
|
||||
roofs[lx]=y;
|
||||
}
|
||||
@compute @workgroup_size(8,8)
|
||||
fn sources(@builtin(global_invocation_id) id: vec3u) {
|
||||
let q=vec2i(id.xy); if(any(q>=vec2i(u.grid.zw))) { return; }
|
||||
let p=(vec2i(u.grid.xy)+q)*i32(u.world.z); let res=i32(u.world.z);
|
||||
var rgb=vec3f(0); var a=0.;
|
||||
if(all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r; let ph=phase(p);
|
||||
var base=0.9;
|
||||
if(built(p) || ((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { base=0.978; }
|
||||
else if(ph==${PH_EMPTY}) { base=select(0.978,0.96,textureLoad(bgmT,p,0).r!=0u); }
|
||||
else if(ph==${PH_GAS}) { base=0.96; }
|
||||
else if(ph==${PH_LIQUID}) { base=0.93; }
|
||||
else if(m==${GLASS}u || m==${ICE}u) { base=0.96; }
|
||||
a=pow(base,u.world.z);
|
||||
if(p.y<roofs[q.x]) { rgb=u.levels.x*vec3f(1,0.98,0.94); }
|
||||
else if(f32(p.y)<columns[p.x].x && ph!=${PH_SOLID}) { rgb=u.levels.x*0.9*vec3f(1,0.98,0.95); }
|
||||
var hottest=0.;
|
||||
for(var dy=0;dy<res && p.y+dy<i32(u.world.y);dy++) {
|
||||
for(var dx=0;dx<res && p.x+dx<i32(u.world.x);dx++) { hottest=max(hottest,textureLoad(tempT,p+vec2i(dx,dy),0).r); }
|
||||
}
|
||||
if(hottest>520.) {
|
||||
let gi=u32(clamp(i32((hottest-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
|
||||
rgb+=glow.rgb/255.*glow.a*1.35;
|
||||
}
|
||||
}
|
||||
if(u.lamp.w>0.) {
|
||||
let d=distance(vec2f(q),u.lamp.xy)/u.lamp.z;
|
||||
if(d<1.) { rgb=max(rgb,(1.-d)*(1.-d)*1.05*vec3f(1,0.93,0.8)); }
|
||||
}
|
||||
textureStore(dest,q,vec4f(rgb,a));
|
||||
}`;
|
||||
const GPU_LIGHT_SPREAD = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(2) var<uniform> direction: vec4i;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if(any(p>=size)) { return; }
|
||||
let own=textureLoad(source,p,0); var rgb=own.rgb;
|
||||
let x=p+vec2i(direction.x,0); let y=p+vec2i(0,direction.x);
|
||||
if(all(x>=vec2i(0)) && all(x<size)) { rgb=max(rgb,textureLoad(source,x,0).rgb*own.a); }
|
||||
if(all(y>=vec2i(0)) && all(y<size)) { rgb=max(rgb,textureLoad(source,y,0).rgb*own.a); }
|
||||
textureStore(dest,p,vec4f(rgb,own.a));
|
||||
}`;
|
||||
const GPU_LIGHT_PACK = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var<uniform> u: array<vec4f,4>;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); if(any(p>=vec2i(textureDimensions(source)))) { return; }
|
||||
let rgb=floor(clamp(max(textureLoad(source,p,0).rgb,vec3f(u[3].y)),vec3f(0),vec3f(1))*255.)/255.;
|
||||
textureStore(dest,p,vec4f(rgb,1));
|
||||
}`;
|
||||
function gpuLightGrid() {
|
||||
const res = SET.lighting === 'fast' ? 4 : 2, M = Math.ceil(28 / res);
|
||||
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
|
||||
const key = `${lw}/${lh}/${res}`;
|
||||
const update = GPU.lightKey !== key || GPU.lightWorld !== mat || GPU.lightFrame === undefined || R.frame % SET.lightEvery === 0;
|
||||
if (update) {
|
||||
GPU.lightKey = key; GPU.lightWorld = mat; GPU.lightFrame = R.frame;
|
||||
GPU.lightGrid = [Math.floor(R.vx0 / res) - M, Math.floor(R.vy0 / res) - M, lw, lh, res];
|
||||
}
|
||||
[R.lx0, R.ly0, R.lw, R.lh, R.lres] = GPU.lightGrid;
|
||||
return update;
|
||||
}
|
||||
function gpuLightBindings(group, view, buffer) {
|
||||
const d = GPU.device;
|
||||
if (GPU.lightUniform) { GPU.lightUniform.destroy(); GPU.lightRoofs.destroy(); }
|
||||
GPU.lightUniform = d.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
GPU.lightRoofs = d.createBuffer({ size: Math.max(1,R.lw) * 4, usage: GPUBufferUsage.STORAGE });
|
||||
const shared = [buffer(GPU.lightUniform), buffer(GPU.lut), buffer(GPU.columns),
|
||||
...[0,1,3,4].map(i => view(GPU.worldTextures[i])), view(GPU.mach), view(GPU.block), view(GPU.temp), buffer(GPU.lightRoofs)];
|
||||
// Auto layouts contain only bindings used by each entry point.
|
||||
GPU.bindRoof = d.createBindGroup({ layout: GPU.lightRoof.getBindGroupLayout(0), entries: shared.map((resource,binding)=>({binding,resource})).filter(e=>![6,9].includes(e.binding)) });
|
||||
GPU.bindSources = group(GPU.lightSources, [...shared, view(GPU.lightWork[0])]);
|
||||
if (!GPU.lightDirections) GPU.lightDirections = [-1,1].map(x => {
|
||||
const b=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||||
d.queue.writeBuffer(b,0,new Int32Array([x,0,0,0])); return b;
|
||||
});
|
||||
GPU.bindSpread = GPU.lightDirections.map(b => [0,1].map(i => group(GPU.lightSpread, [view(GPU.lightWork[i]),view(GPU.lightWork[1-i]),buffer(b)])));
|
||||
GPU.bindPack = group(GPU.lightPack, [view(GPU.lightWork[0]),view(GPU.lightTex),buffer(GPU.lightUniform)]);
|
||||
GPU.lightFrame = undefined;
|
||||
}
|
||||
function gpuLight(encoder, timed) {
|
||||
const q = GPU.device.queue, res = R.lres;
|
||||
q.writeBuffer(GPU.lightUniform,0,new Float32Array([R.lx0,R.ly0,R.lw,R.lh,W,H,res,0,
|
||||
(bot.x+bot.w/2)/res-R.lx0,(bot.y+bot.h/2)/res-R.ly0,34/res,+bot.lamp,
|
||||
SET.daylight*(1-0.32*(R.rain||0)),SET.ambient,0,0]));
|
||||
const pass = encoder.beginComputePass(timed ? {timestampWrites:{querySet:GPU.queries,beginningOfPassWriteIndex:2,endOfPassWriteIndex:3}} : {});
|
||||
pass.setPipeline(GPU.lightRoof); pass.setBindGroup(0,GPU.bindRoof); pass.dispatchWorkgroups(Math.ceil(R.lw/64));
|
||||
pass.setPipeline(GPU.lightSources); pass.setBindGroup(0,GPU.bindSources); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
pass.setPipeline(GPU.lightSpread);
|
||||
// 320 grains per direction: even maximum emission falls below one packed level.
|
||||
const count = 320/res;
|
||||
for(let direction=0;direction<2;direction++) for(let i=0;i<count;i++) {
|
||||
pass.setBindGroup(0,GPU.bindSpread[direction][i&1]); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
}
|
||||
pass.setPipeline(GPU.lightPack); pass.setBindGroup(0,GPU.bindPack); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
pass.end();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Half-resolution separable Gaussian bloom, then nearest colour + linear light.
|
||||
const GPU_BLUR = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var<uniform> blur: vec4f;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if (any(p>=size)) { return; }
|
||||
var sum=vec3f(0); var weight=0.; let sigma=blur.z;
|
||||
let radius=i32(ceil(sigma*3.));
|
||||
for (var j=-radius;j<=radius;j++) {
|
||||
let w=exp(-f32(j*j)/(2.*sigma*sigma)); let q=p+vec2i(blur.xy)*j;
|
||||
if (all(q>=vec2i(0)) && all(q<size)) { sum+=textureLoad(source,q,0).rgb*w; }
|
||||
weight+=w;
|
||||
}
|
||||
textureStore(dest,p,vec4f(sum/weight,1));
|
||||
}`;
|
||||
const GPU_DRAW = `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var colour: texture_2d<f32>;
|
||||
@group(0) @binding(2) var light: texture_2d<f32>;
|
||||
@group(0) @binding(3) var glow: texture_2d<f32>;
|
||||
@group(0) @binding(4) var linear: sampler;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
let cell=(p.xy-u.camera.xy)/u.camera.z;
|
||||
var c=textureLoad(colour,vec2i(floor(cell)),0).rgb;
|
||||
if (u.light.w>0.) {
|
||||
let world=p.xy/u.camera.z+u.extra.xy;
|
||||
let uv=(world/u.light.z-u.light.xy)/vec2f(textureDimensions(light));
|
||||
let l=textureSampleLevel(light,linear,uv,0).rgb;
|
||||
c=round(c*l*255.)/255.;
|
||||
}
|
||||
if (u.effects.w>0.) {
|
||||
let uv=cell/(2.*vec2f(textureDimensions(glow)));
|
||||
c+=textureSampleLevel(glow,linear,uv,0).rgb*u.screen.z;
|
||||
}
|
||||
return vec4f(c,1);
|
||||
}`;
|
||||
@@ -0,0 +1,368 @@
|
||||
// Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
||||
const GPU_SMOOTH = `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var tCol: texture_2d<f32>;
|
||||
@group(0) @binding(2) var tBack: texture_2d<f32>;
|
||||
@group(0) @binding(3) var tTree: texture_2d<f32>;
|
||||
@group(0) @binding(4) var tField: texture_2d<f32>;
|
||||
@group(0) @binding(5) var tLight: texture_2d<f32>;
|
||||
@group(0) @binding(6) var tGlow: texture_2d<f32>;
|
||||
@group(0) @binding(7) var linear: sampler;
|
||||
@group(0) @binding(8) var<uniform> su: vec4f;
|
||||
fn sampleLevel(t: texture_2d<f32>, uv: vec2f, level: f32) -> vec4f { return textureSampleLevel(t,linear,uv,level); }
|
||||
fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); }
|
||||
fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
||||
fn vn(p: vec2f) -> f32 {
|
||||
var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(h21(i), h21(i + vec2f(1, 0)), f.x), mix(h21(i + vec2f(0, 1)), h21(i + vec2f(1, 1)), f.x), f.y);
|
||||
}
|
||||
fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
|
||||
|
||||
fn lin(t: texture_2d<f32>, q: vec2f) -> vec4f { return sampleLevel(t, q / (u.view.zw), 0.0); }
|
||||
|
||||
fn bsp(t: texture_2d<f32>, q: vec2f) -> vec4f {
|
||||
var tc: vec2f = q - 0.5; var i: vec2f = floor(tc); var f: vec2f = tc - i; var f2: vec2f = f * f; var f3: vec2f = f2 * f;
|
||||
var w0: vec2f = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0; var w1: vec2f = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
|
||||
var w2: vec2f = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0; var w3: vec2f = f3 / 6.0;
|
||||
var g0: vec2f = w0 + w1; var g1: vec2f = w2 + w3;
|
||||
var h0: vec2f = (i - 0.5 + w1 / g0) / (u.view.zw); var h1: vec2f = (i + 1.5 + w3 / g1) / (u.view.zw);
|
||||
return g0.y * (g0.x * sampleLevel(t, h0, 0.0) + g1.x * sampleLevel(t, vec2f(h1.x, h0.y), 0.0))
|
||||
+ g1.y * (g0.x * sampleLevel(t, vec2f(h0.x, h1.y), 0.0) + g1.x * sampleLevel(t, h1, 0.0));
|
||||
}
|
||||
fn cellAt(t: texture_2d<f32>, c: vec2i) -> vec4f { return textureLoad(t, clamp(c, vec2i(0), vec2i((u.view.zw)) - 1), 0); }
|
||||
fn cls(c: vec4f) -> i32 { return i32(c.a * 255.0 + 0.5); }
|
||||
fn isT(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ); }
|
||||
fn isM(n: i32) -> f32 { return select( isT(n), 1.0 ,n == 2 ); }
|
||||
fn isF(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 13 || n == 14) ); }
|
||||
fn isBelt(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 7 || n == 8) ); }
|
||||
fn isN(n: i32, k: i32) -> f32 { return select( 0.0, 1.0 ,n == k ); }
|
||||
|
||||
fn beltSD(lb: vec2f, endL: bool, endR: bool) -> f32 {
|
||||
var x0: f32 = select( -2.0, 0.15 , endL ); var x1: f32 = select( 6.0, 3.85 , endR ); var r: f32 = 0.75;
|
||||
var c: vec2f = vec2f((x0 + x1) * 0.5, 2.0); var hs: vec2f = vec2f((x1 - x0) * 0.5, 1.85);
|
||||
var q: vec2f = abs(lb - c) - hs + r;
|
||||
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
|
||||
fn beltLineSD(c: vec2i, lb: vec2f) -> f32 {
|
||||
var caps: i32 = i32(cellAt(tTree, c).a * 255.0 + 0.5);
|
||||
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
|
||||
}
|
||||
|
||||
fn bandRidge(w: vec2f, lb: vec2f, dir: f32) -> f32 {
|
||||
var run: f32 = select( -1.0, 1.0 , lb.y < 2.0 );
|
||||
return step(0.72, fract((w.x - dir * run * (su.x) * 30.0) * 0.5));
|
||||
}
|
||||
fn bandColor(lb: vec2f, rg: f32) -> vec3f { return mix(vec3f(0.2, 0.21, 0.24), vec3f(0.34, 0.35, 0.39), rg) * (select( 0.72, 1.0 ,lb.y < 2.0 )); }
|
||||
|
||||
${GPU_SMOOTH_SLOPE_HELPERS}
|
||||
@fragment fn fs(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
||||
let uView=vec4f(u.extra.xy-u.view.xy,u.camera.z,u.screen.y);
|
||||
let uLightMap=vec4f(u.light.xy*u.light.z-u.view.xy,u.light.z,u.light.w);
|
||||
var zp: f32 = uView.z; var px: f32 = 1.0 / zp;
|
||||
var sp: vec2f = vec2f(position.x, position.y);
|
||||
var q: vec2f = sp * px + uView.xy;
|
||||
var w: vec2f = q + (u.view.xy);
|
||||
var cam: vec2f = (u.view.xy) + uView.xy;
|
||||
var scr: vec2f = q - uView.xy;
|
||||
var shc: f32 = uView.w * px;
|
||||
var c: vec2i = vec2i(floor(q));
|
||||
var qq: vec2f = q - 0.5; var c0: vec2i = vec2i(floor(qq)); var t: vec2f = qq - floor(qq);
|
||||
var k0: vec4f = cellAt(tCol, c0); var k1: vec4f = cellAt(tCol, c0 + vec2i(1, 0)); var k2: vec4f = cellAt(tCol, c0 + vec2i(0, 1)); var k3: vec4f = cellAt(tCol, c0 + vec2i(1, 1));
|
||||
var n0: i32 = cls(k0); var n1: i32 = cls(k1); var n2: i32 = cls(k2); var n3: i32 = cls(k3);
|
||||
var own: vec4f = cellAt(tCol, c); var nc: i32 = cls(own);
|
||||
var s: vec2f = smoothstep(vec2f(0.1), vec2f(0.9), t);
|
||||
var wq: vec4f = vec4f((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
|
||||
|
||||
// Fields and screen derivatives before branching.
|
||||
var Fb: vec4f = bsp(tField, q);
|
||||
var F: vec4f = mix(lin(tField, q), Fb, 0.62);
|
||||
|
||||
var wl: vec4f = vec4f((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
|
||||
var pw: f32 = dot(wl, vec4f(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
|
||||
var M: f32 = clamp(F.r + pw, 0.0, 1.0);
|
||||
var lf: f32 = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
|
||||
var fwT: f32 = max(fwidth(F.r), 1e-4); var fwM: f32 = max(fwidth(M), 1e-4); var fwA: f32 = max(fwidth(F.a), 1e-4); var fwL: f32 = max(fwidth(lf), 1e-4);
|
||||
var gT: vec2f = vec2f(dpdx(mix(Fb.r, M, 0.5)), -dpdy(mix(Fb.r, M, 0.5))); var gL: vec2f = vec2f(dpdx(F.g), -dpdy(F.g));
|
||||
|
||||
// Sky, clouds, parallax hills and back-wall AO.
|
||||
var back: vec4f = lin(tBack, q);
|
||||
var C: vec3f = back.rgb;
|
||||
var sky: f32 = back.a;
|
||||
if (sky > 0.01) {
|
||||
var S: vec3f = C;
|
||||
var P0: vec2f = scr + cam * 0.08;
|
||||
|
||||
var cl: f32 = smoothstep(0.56 - 0.3 * (u.style.y), 0.78 - 0.2 * (u.style.y), fbm(vec2f(P0.x * 0.016 + (su.x) * 0.006, P0.y * 0.05)));
|
||||
var hi: f32 = clamp(((su.y) - 40.0 - w.y) / 50.0, 0.0, 1.0);
|
||||
S = mix(S, mix(vec3f(0.96, 0.95, 0.93), vec3f(0.5, 0.52, 0.56), (u.style.y)), cl * (0.6 + 0.3 * (u.style.y)) * hi);
|
||||
var k1: f32 = 0.16; var P1: vec2f = scr + cam * k1;
|
||||
var base1: f32 = shc * 0.5 + 8.0 + ((su.y) - shc * 0.5) * k1;
|
||||
var top1: f32 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2f(P1.x * 0.009, 1.7)));
|
||||
var m1: f32 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
|
||||
var far: vec3f = mix(vec3f(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
|
||||
S = mix(S, far, m1);
|
||||
var k2: f32 = 0.38; var P2: vec2f = scr + cam * k2;
|
||||
var base2: f32 = shc * 0.5 + 14.0 + ((su.y) - shc * 0.5) * k2;
|
||||
var top2: f32 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2f(P2.x * 0.016, 7.3)));
|
||||
var m2: f32 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
|
||||
var near: vec3f = vec3f(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
|
||||
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
|
||||
S = mix(S, near, m2);
|
||||
C = mix(C, S, sky);
|
||||
}
|
||||
|
||||
var notSky: f32 = 1.0 - sky;
|
||||
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
|
||||
var near: f32 = sampleLevel(tField, q / (u.view.zw), 2.2).r;
|
||||
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
|
||||
|
||||
// Trees behind terrain and built pieces.
|
||||
if (F.a > 0.2) {
|
||||
var a0: vec4f = cellAt(tTree, c0); var a1: vec4f = cellAt(tTree, c0 + vec2i(1, 0)); var a2: vec4f = cellAt(tTree, c0 + vec2i(0, 1)); var a3: vec4f = cellAt(tTree, c0 + vec2i(1, 1));
|
||||
var m: vec4f = vec4f(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
if (sm > 1e-4) {
|
||||
var tc: vec3f = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
|
||||
var leaf: f32 = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
|
||||
var lv: f32 = smoothstep(0.6, 0.9, leaf);
|
||||
tc *= mix(0.86 + 0.22 * vn(vec2f(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
|
||||
var d: f32 = (F.a - 0.36) / fwA;
|
||||
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
|
||||
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
|
||||
}
|
||||
}
|
||||
|
||||
// Smoke, steam and flame.
|
||||
if (Fb.b > 0.02) {
|
||||
var m: vec4f = vec4f(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var gc: vec3f = select( own.rgb, (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm , sm > 1e-4 );
|
||||
if (sm > 1e-4 || nc == 5) { C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); }
|
||||
}
|
||||
|
||||
// Liquids with a glossy top edge.
|
||||
var anyL: f32 = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
|
||||
if (anyL > 0.0 && lf > 0.3) {
|
||||
var m: vec4f = vec4f(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var lc: vec3f = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
|
||||
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2f((su.x) * 0.7, (su.x) * 0.25));
|
||||
var gl: f32 = length(gL);
|
||||
var topSide: f32 = select( 0.0, clamp(gL.y / gl, 0.0, 1.0) , gl > 1e-5 );
|
||||
var band: f32 = 1.0 - smoothstep(0.5, 0.75, F.g);
|
||||
lc += vec3f(0.16, 0.18, 0.2) * topSide * band;
|
||||
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
|
||||
}
|
||||
|
||||
// Rounded ground and the seam against loose grains.
|
||||
if (F.r > 0.3) {
|
||||
var m: vec4f = vec4f(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var blur: vec3f = bsp(tCol, q).rgb; var tc: vec3f = own.rgb;
|
||||
var bd: f32 = 1e9;
|
||||
if (m.x > 0.0) { var e: vec3f = k0.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
|
||||
if (m.y > 0.0) { var e: vec3f = k1.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
|
||||
if (m.z > 0.0) { var e: vec3f = k2.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
|
||||
if (m.w > 0.0) { var e: vec3f = k3.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
|
||||
if (sm < 1e-4) { tc = own.rgb; }
|
||||
tc *= 0.84 + 0.3 * fbm(w * 0.28);
|
||||
tc *= 0.95 + 0.1 * vn(w * 1.6);
|
||||
var gl: f32 = length(gT);
|
||||
var nrm: vec2f = select( vec2f(0.0), -gT / gl , gl > 1e-6 );
|
||||
var band: f32 = 1.0 - smoothstep(0.5, 0.9, M);
|
||||
var lit: f32 = dot(nrm, normalize(vec2f(-0.5, -1.0)));
|
||||
tc *= 1.0 + 0.42 * lit * band;
|
||||
|
||||
var dm: f32 = (M - 0.5) / fwM;
|
||||
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
|
||||
var dt: f32 = (F.r - 0.5) / fwT;
|
||||
if (nc == 1 && pw > 0.0) { tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); }
|
||||
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
|
||||
}
|
||||
|
||||
${GPU_SMOOTH_SLOPE_DRAW}
|
||||
// Round loose grains.
|
||||
var np: i32 = i32(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
|
||||
var closed: bool = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
|
||||
if (np > 0) {
|
||||
var best: f32 = 1e9; var gc: vec3f = vec3f(0.0); var gd: vec2f = vec2f(0.0); var gr: f32 = 0.6;
|
||||
for (var k: i32 = 0; k < 4; k++) {
|
||||
var o4: vec2i = vec2i(k & 1, k >> 1);
|
||||
var nk: i32 = select(select(select( n3, n2 , k == 2 ), n1 , k == 1 ), n0 , k == 0 );
|
||||
if (nk != 2) { continue; }
|
||||
var ck: vec4f = select(select(select( k3, k2 , k == 2 ), k1 , k == 1 ), k0 , k == 0 );
|
||||
var ce: vec2f = vec2f(c0 + o4) + 0.5;
|
||||
var r: f32 = 0.6 + 0.1 * (h21(ce + (u.view.xy)) - 0.5);
|
||||
var dv: vec2f = (q - ce) / r;
|
||||
var dd: f32 = length(dv);
|
||||
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
|
||||
}
|
||||
var cover: f32 = select( 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best), 1.0 , np == 4 );
|
||||
if (closed && F.r < 0.5) { cover = 1.0; }
|
||||
|
||||
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
|
||||
if (cover > 0.0) {
|
||||
var r2: f32 = min(1.0, dot(gd, gd));
|
||||
var n3: vec3f = normalize(vec3f(gd * 0.85, sqrt(1.0 - r2) + 0.25));
|
||||
var sh: f32 = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3f(-0.45, -0.8, 0.6))));
|
||||
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
|
||||
var pc: vec3f = (select( gc, (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) ,np == 4 )) * sh;
|
||||
if (!closed) { pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); }
|
||||
C = mix(C, pc, cover);
|
||||
}
|
||||
}
|
||||
|
||||
// Built pieces: belts, screws, wall tiles and filters.
|
||||
if (nc == 4) { C = own.rgb; }
|
||||
else if (nc == 7 || nc == 8) {
|
||||
|
||||
var dir: f32 = select( -1.0, 1.0 , nc == 8 );
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0;
|
||||
|
||||
var sd: f32 = beltLineSD(c, lb);
|
||||
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
var edge: f32 = -sd;
|
||||
var bc: vec3f;
|
||||
if (edge < 0.8) {
|
||||
bc = bandColor(lb, bandRidge(w, lb, dir));
|
||||
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
var rc: vec2f = vec2f(select( 3.0, 1.0 ,lb.x < 2.0 ), 2.0); var dv: vec2f = lb - rc;
|
||||
var rd: f32 = length(dv);
|
||||
bc = vec3f(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
var ang: f32 = atan2(dv.y, dv.x) - dir * (su.x) * 30.0 / 0.95;
|
||||
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
bc = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, bc, inside);
|
||||
}
|
||||
}
|
||||
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
|
||||
|
||||
var vert: bool = nc == 9 || nc == 11;
|
||||
var sp2: f32 = select(select(select( -1.0, 1.0 , nc == 11 ), 1.0 , nc == 10 ), -1.0 , nc == 9 );
|
||||
var a: f32 = (select( fract(w.y * 0.25), fract(w.x * 0.25) ,vert )) * 4.0; var along: f32 = select( w.x, w.y , vert );
|
||||
var screwSD: f32 = -1.0; var capRing: f32 = 0.0;
|
||||
var screwTag: vec4f = cellAt(tTree, c);
|
||||
var screwJoins: i32 = i32(screwTag.a * 255.0 + 0.5);
|
||||
|
||||
var shaped: bool = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
|
||||
if (shaped) {
|
||||
var d: vec2f = vec2f(1.0,select( 1.0, -1.0 , nc == 19 || nc == 21 ));
|
||||
var tag: vec4f = screwTag;
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
|
||||
var joins: i32 = screwJoins;
|
||||
screwSD = 100.0;
|
||||
for (var end: i32 = 0; end < 2; end++) {
|
||||
var join: i32 = (joins >> u32(end * 3)) & 7;
|
||||
var side: f32 = select( 1.0, -1.0 , end == 0 );
|
||||
var diagonal: bool = select( join != 0, join == 0 || join >= 5 , nc >= 19 );
|
||||
var grade: f32 = select(select( 1.0, -1.0 , join == 1 ), d.y , nc >= 19 );
|
||||
var ray: vec2f = select(select(select(select(select( vec2f(-1, 0), vec2f(0, 1) , join == 3 ), vec2f(1, 0) , join == 2 ), vec2f(0, -1) ,
|
||||
join == 1 ), (select( vec2f(side, 0), vec2f(0, side) ,vert )) ,
|
||||
nc < 19 ), (select( vec2f(side, side * grade), vec2f(side * grade, side) ,vert )) * 0.70710678 , diagonal );
|
||||
var t: f32 = dot(local, ray); var cross2: f32 = dot(local, vec2f(-ray.y, ray.x));
|
||||
var radius: f32 = length(vec2f(cross2, min(0.0, t))); var cap: f32 = t - (select( 2.0, 2.82842712 ,diagonal ));
|
||||
|
||||
var sd: f32 = max(radius - 2.0,select( -10.0, cap , (joins & (64 << u32(end))) != 0 ));
|
||||
if (sd < screwSD) {
|
||||
screwSD = sd;
|
||||
a = 2.0 + (select( radius, -radius ,side * cross2 < 0.0 ));
|
||||
along = (select(select( w.x - local.x, w.y - local.y , vert ), dot(w - local, d) * 0.70710678 ,nc >= 19 )) + side * t;
|
||||
capRing = select( 0.0, smoothstep(-0.55, -0.15, cap) ,(joins & (64 << u32(end))) != 0 );
|
||||
}
|
||||
}
|
||||
if (nc >= 19) { sp2 = select( -1.0, 1.0 ,nc == 19 || nc == 20 ); }
|
||||
}
|
||||
var wall: f32 = 0.62;
|
||||
var tc: vec3f;
|
||||
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
|
||||
var u2: f32 = select( (4.0 - a) / wall, a / wall , a < wall );
|
||||
var cyl: f32 = sin(clamp(u2, 0.0, 1.0) * 1.5708);
|
||||
tc = vec3f(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
|
||||
if (a < wall) { tc *= 1.08; }
|
||||
var ring: f32 = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
|
||||
if (nc < 19) { tc *= 1.0 - 0.3 * ring; }
|
||||
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
|
||||
} else {
|
||||
var full: bool = own.r + own.g + own.b > 0.0;
|
||||
var fl: f32 = fract((along + a * 0.9 - sp2 * (su.x) * 14.0) * 0.5);
|
||||
var flight: f32 = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
|
||||
var bore: vec3f = vec3f(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
|
||||
tc = select( bore, own.rgb * (0.9 + 0.1 * vn(w * 1.3)) ,full );
|
||||
tc = mix(tc, vec3f(0.6, 0.48, 0.38), flight * (select( 0.8, 0.35 ,full )));
|
||||
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
|
||||
}
|
||||
if (shaped) {
|
||||
var full: bool = own.r + own.g + own.b > 0.0;
|
||||
if (full) { C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); }
|
||||
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
|
||||
} else { C = tc; }
|
||||
}
|
||||
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0; var rb: vec2f = 4.0 - lb;
|
||||
var e: f32 = min(min(lb.x, lb.y), min(rb.x, rb.y));
|
||||
var tc: vec3f = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
|
||||
var hl: f32 = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
|
||||
var shd: f32 = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
|
||||
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
|
||||
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
|
||||
if (nc >= 15) {
|
||||
var sl: vec2f = lb;
|
||||
if (nc == 16 || nc == 18) { sl.x = 4.0 - sl.x; }
|
||||
if (nc >= 17) { sl.y = 4.0 - sl.y; }
|
||||
var d: f32 = (sl.y - sl.x) * 0.70710678;
|
||||
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
|
||||
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
|
||||
} else { C = tc; }
|
||||
}
|
||||
|
||||
else if (nc == 13 || nc == 14) {
|
||||
|
||||
var dir: f32 = select( -1.0, 1.0 , nc == 14 );
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0;
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var carry: bool = tag.a > 0.16; var tinted: bool = tag.r + tag.g + tag.b > 0.0;
|
||||
var sd: f32 = beltLineSD(c, lb);
|
||||
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
var edge: f32 = -sd;
|
||||
var steel: vec3f = vec3f(0.42, 0.44, 0.5);
|
||||
|
||||
var behind: vec3f = select( C * 0.8, own.rgb , own.r + own.g + own.b > 0.0 );
|
||||
var cg: vec2f = abs(fract(w) - 0.5);
|
||||
var bw2: f32 = max(px * 0.9, 0.09);
|
||||
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
var fc: vec3f;
|
||||
if (edge < 0.8) {
|
||||
var rg: f32 = bandRidge(w, lb, dir);
|
||||
var band: vec3f = bandColor(lb, rg);
|
||||
if (tinted && (carry == (lb.y < 2.0))) { band = mix(band, tag.rgb * (select( 0.8, 1.0 ,lb.y < 2.0 )), 0.75 * rg); }
|
||||
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
|
||||
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
fc = mix(steel * 0.78, behind, hole);
|
||||
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
|
||||
}
|
||||
C = mix(C, fc, inside);
|
||||
}
|
||||
}
|
||||
|
||||
// Light, then the legacy nine-tap glow filter.
|
||||
if (uLightMap.w > 0.5) { C *= sampleLevel(tLight, (q - uLightMap.xy) / uLightMap.z / (vec2f(textureDimensions(tLight))), 0.0).rgb; }
|
||||
if ((su.z) > 0.0) {
|
||||
var gu: vec2f = q / (2.0 * (vec2f(textureDimensions(tGlow)))); var d: vec2f = 1.0 / (vec2f(textureDimensions(tGlow)));
|
||||
var g: vec3f = sampleLevel(tGlow, gu, 0.0).rgb * 0.25;
|
||||
g += (sampleLevel(tGlow, gu + vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(0, d.y), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(0, d.y), 0.0).rgb) * 0.125;
|
||||
g += (sampleLevel(tGlow, gu + d, 0.0).rgb + sampleLevel(tGlow, gu - d, 0.0).rgb + sampleLevel(tGlow, gu + vec2f(d.x, -d.y), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(-d.x, d.y), 0.0).rgb) * 0.0625;
|
||||
C += g * (su.z);
|
||||
}
|
||||
return vec4f(C, 1.0);
|
||||
}`;
|
||||
@@ -0,0 +1,107 @@
|
||||
// Smooth slope geometry and cleats, ported from slope-belt-shader.js.
|
||||
const GPU_SMOOTH_SLOPE_HELPERS = `fn slopeStroke(p: vec2f, a: vec2f, b: vec2f, origin: f32) -> vec3f {
|
||||
var ray: vec2f = b - a; var len: f32 = length(ray); ray /= len;
|
||||
var along: f32 = dot(p - a, ray); var across: f32 = dot(p - a, vec2f(-ray.y, ray.x));
|
||||
var q: vec2f = abs(vec2f(along - len * 0.5, across)) - vec2f(len * 0.5 - 0.75, 1.1);
|
||||
var sd: f32 = length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - 0.75;
|
||||
return vec3f(sd, origin + along, across + 2.0);
|
||||
}
|
||||
fn slopeCorner(p: vec2f, a: vec2f, origin: f32) -> vec3f {
|
||||
var d: vec2f = p - a; var radius: f32 = length(d);
|
||||
return vec3f(radius - 1.85, origin + d.x, 2.0 + d.y);
|
||||
}
|
||||
fn slopeShape(inputP: vec2f, worldOrigin: vec2f, grade: f32, joins: i32) -> vec3f { var p=inputP;
|
||||
if ((joins & 64) != 0) { p.y -= 0.45; }
|
||||
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
|
||||
var a: vec2f = vec2f(-grade * 1.30814755, sy + 1.30814755); var b: vec2f = vec2f(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
|
||||
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
|
||||
else if ((joins & 4) != 0) { a -= vec2f(4.0, grade * 4.0); }
|
||||
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
|
||||
else if ((joins & 8) != 0) { b += vec2f(4.0, grade * 4.0); }
|
||||
var axis: vec2f = vec2f(1.0, grade) * 0.70710678;
|
||||
var best: vec3f = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
|
||||
if ((joins & 1) != 0) {
|
||||
var h: vec3f = slopeStroke(p, vec2f(-2.0, a.y), a, worldOrigin.x - 2.0);
|
||||
if (h.x < best.x) { best = h; }
|
||||
h = slopeCorner(p, a, worldOrigin.x + a.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
}
|
||||
if ((joins & 2) != 0) {
|
||||
var h: vec3f = slopeStroke(p, b, vec2f(6, b.y), worldOrigin.x + b.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
h = slopeCorner(p, b, worldOrigin.x + b.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
`;
|
||||
const GPU_SMOOTH_SLOPE_DRAW = ` if (nc == 31 || nc == 32) {
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var grade: f32 = select( 1.0, -1.0 , nc == 31 );
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
||||
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
|
||||
var foot: f32 = (local.x + grade * (local.y - sy)) * 0.5;
|
||||
var depth: f32 = (grade * local.x - local.y + sy) * 0.70710678;
|
||||
var wallFlags: i32 = i32(tag.a * 255.0 + 0.5);
|
||||
var ends: f32 = max(select( -100.0, -foot ,(wallFlags & 8) == 0 ),select( -100.0, foot - 4.0 , (wallFlags & 16) == 0 ));
|
||||
var sd: f32 = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
|
||||
var phase: f32 = f32(wallFlags & 7);
|
||||
var wave: f32 = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
|
||||
var rubber: vec3f = mix(vec3f(0.20,0.22,0.25), vec3f(0.25,0.27,0.31), wave);
|
||||
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
|
||||
|
||||
var dirS: f32 = select( -1.0, 1.0 , (wallFlags & 32) != 0 );
|
||||
if (grade * dirS < 0.0) {
|
||||
var rc: f32 = (w.x - local.x) + foot - (select( 0.0, 1.0 ,grade < 0.0 )) - dirS * phase;
|
||||
var off: f32 = (modulo(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
|
||||
var ribTop: f32 = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
|
||||
var rd: f32 = length(vec2f(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
|
||||
var rib: vec3f = mix(vec3f(0.50,0.52,0.56), vec3f(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
|
||||
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
|
||||
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
|
||||
}
|
||||
}
|
||||
|
||||
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
|
||||
(i32(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
|
||||
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
|
||||
var slopeClass: i32 = i32(cellAt(tTree, c).r * 255.0 + 0.5);
|
||||
var shape: i32 = (slopeClass - 23) / 2;
|
||||
var mesh: bool = shape >= 2;
|
||||
var grade: f32 = select( 1.0, -1.0 , (shape == 0 || shape == 2) );
|
||||
var dir: f32 = select( -1.0, 1.0 , ((slopeClass - 23) % 2) == 1 );
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var joins: i32 = i32(tag.a * 255.0 + 0.5);
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
||||
var shapePos: vec3f = slopeShape(local, w - local, grade, joins);
|
||||
var sd: f32 = shapePos.x; var axis: f32 = shapePos.y; var depth: f32 = shapePos.z;
|
||||
var time: f32 = select( 0.0, (su.x) , !mesh && (joins & 16) != 0 );
|
||||
var lb: vec2f = vec2f(axis, depth); var alongWorld: vec2f = vec2f(axis, depth);
|
||||
var rg: f32 = step(0.72, fract((axis - dir * (select( -1.0, 1.0 ,depth < 2.0 )) * time * 30.0) * 0.5));
|
||||
var belt: vec3f;
|
||||
if (-sd < select(0.8,1.1,mesh)) {
|
||||
belt = select( bandColor(lb, rg), mix(vec3f(0.22,0.25,0.29), vec3f(0.48,0.53,0.59),select( 0.35, 0.85 , depth < 2.0 )) ,mesh );
|
||||
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
|
||||
} else if (mesh) {
|
||||
var cg: vec2f = abs(fract(alongWorld) - 0.5);
|
||||
var bw2: f32 = max(px * 0.9, 0.09);
|
||||
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
var steel: vec3f = select( vec3f(0.42, 0.44, 0.5), mix(vec3f(0.42, 0.44, 0.5), own.rgb, 0.4) , dot(own.rgb, vec3f(1)) > 0.01 );
|
||||
belt = mix(steel * 0.78, C * 0.8, hole);
|
||||
} else {
|
||||
var dv: vec2f = vec2f(modulo(axis, 2.0) - 1.0, depth - 2.0);
|
||||
var rd: f32 = length(dv);
|
||||
belt = vec3f(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
var ang: f32 = atan2(dv.y, dv.x) - dir * time * 30.0 / 0.95;
|
||||
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
belt = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
|
||||
}
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
// Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
||||
const GPU_FIELD_MIP = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var linear: sampler;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
let size=max(vec2u(1),textureDimensions(source)/2u);
|
||||
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
|
||||
}`;
|
||||
async function gpuSmoothInit() {
|
||||
const d=GPU.device;
|
||||
const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
|
||||
GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
|
||||
GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
|
||||
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||||
}
|
||||
function gpuSmoothTextures() {
|
||||
const w=R.bw,h=R.bh;
|
||||
GPU.smoothOver=Array.from({length:3},()=>gpuTexture(w,h));
|
||||
GPU.smoothMask=gpuTexture(w,h,'r8uint');
|
||||
GPU.backTex=gpuTexture(w,h,'rgba8unorm',true);
|
||||
GPU.treeTex=gpuTexture(w,h,'rgba8unorm',true);
|
||||
GPU.fieldTex=GPU.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:Math.min(4,1+Math.floor(Math.log2(Math.max(w,h)))),usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.RENDER_ATTACHMENT});
|
||||
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
|
||||
}
|
||||
function gpuSmoothBindings(group,view,buffer) {
|
||||
const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)];
|
||||
GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)});
|
||||
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
|
||||
GPU.fieldMips=[];
|
||||
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
|
||||
bind:group(GPU.fieldMip,[GPU.fieldTex.createView({baseMipLevel:i-1,mipLevelCount:1}),GPU.sampler]),
|
||||
view:GPU.fieldTex.createView({baseMipLevel:i,mipLevelCount:1})
|
||||
});
|
||||
}
|
||||
function gpuSmoothUpload() {
|
||||
for(const [i,a] of [R.backPx,R.treePx,R.fieldPx].entries()) gpuWrite(GPU.smoothOver[i],a,R.bw,0,0,R.bw,R.bh);
|
||||
gpuWrite(GPU.smoothMask,GPU.overrideMask,R.bw,0,0,R.bw,R.bh);
|
||||
GPU.device.queue.writeBuffer(GPU.smoothUniform,0,new Float32Array([(performance.now()-SM.t0)/1000,smoothSurface(),SET.glow==='off'?0:SET.glow==='high'?0.9:0.55,0]));
|
||||
}
|
||||
function gpuSmoothMips(encoder) {
|
||||
for(const m of GPU.fieldMips) {
|
||||
const pass=encoder.beginRenderPass({colorAttachments:[{view:m.view,loadOp:'clear',storeOp:'store'}]});
|
||||
pass.setPipeline(GPU.fieldMip);pass.setBindGroup(0,m.bind);pass.draw(3);pass.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const SM = { t0: performance.now(), surf: -1 };
|
||||
function smoothSurface() {
|
||||
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
|
||||
return SM.surf;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Whole-world textures retain clean chunks across camera moves.
|
||||
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
|
||||
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) });
|
||||
}
|
||||
function gpuWrite(texture, data, stride, x, y, w, h) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const bytes = data.BYTES_PER_ELEMENT;
|
||||
GPU.device.queue.writeTexture({ texture, origin: [x, y] }, data, { offset: (y * stride + x) * bytes, bytesPerRow: stride * bytes }, [w, h]);
|
||||
}
|
||||
function gpuBuffer(data) {
|
||||
const b = GPU.device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||||
GPU.device.queue.writeBuffer(b, 0, data); return b;
|
||||
}
|
||||
function gpuWorldTextures() {
|
||||
if (GPU.world === mat && GPU.sky === SKY_R) return;
|
||||
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
|
||||
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
|
||||
GPU.world = mat; GPU.sky = SKY_R;
|
||||
GPU.resident = new Uint8Array(NCH);
|
||||
GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint'));
|
||||
GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp);
|
||||
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
|
||||
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 4);
|
||||
for (let m = 0; m < NMAT; m++) {
|
||||
tables.set([COLR[m], COLG[m], COLB[m], CVAR[m], PH[m], ALPHA[m], 0, 0, TREE_R[m], TREE_G[m], TREE_B[m], 0], m * 12);
|
||||
}
|
||||
for (let i = 0; i < 700; i++) tables.set([GLOW_R[i], GLOW_G[i], GLOW_B[i], GLOW_W[i]], (NMAT * 3 + i) * 4);
|
||||
for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4);
|
||||
tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4);
|
||||
GPU.lut = gpuBuffer(tables);
|
||||
GPU.landData = new Float32Array((W + H) * 4);
|
||||
GPU.land = gpuBuffer(GPU.landData);
|
||||
if (GPU.columns) GPU.columns.destroy();
|
||||
GPU.columnData = new Float32Array(W * 4); GPU.columns = gpuBuffer(GPU.columnData);
|
||||
GPU.mach = gpuTexture(W, H, 'r16uint'); GPU.block = gpuTexture(BW, BH, 'r8uint');
|
||||
GPU.worldTextures.push(GPU.mach, GPU.block);
|
||||
GPU.bindColour = null;
|
||||
}
|
||||
function gpuViewTextures() {
|
||||
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`;
|
||||
if (GPU.viewKey === key) return;
|
||||
GPU.viewKey = key; GPU.lightFrame = undefined;
|
||||
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
|
||||
const bw = R.bw, bh = R.bh, gw = R.glowW, gh = R.glowH;
|
||||
GPU.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true);
|
||||
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
|
||||
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
|
||||
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
|
||||
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
|
||||
GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
|
||||
if (R.smoothOn) gpuSmoothTextures();
|
||||
GPU.bindColour = null;
|
||||
}
|
||||
function gpuBindings() {
|
||||
if (GPU.bindColour) return;
|
||||
const d = GPU.device, buffer = b => ({ buffer: b }), view = t => t.createView();
|
||||
const group = (pipeline, resources) => d.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: resources.map((resource, binding) => ({ binding, resource })) });
|
||||
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
|
||||
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
|
||||
gpuLightBindings(group, view, buffer);
|
||||
GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]);
|
||||
GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]);
|
||||
GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]);
|
||||
if (GPU.blurUniforms) for (const b of GPU.blurUniforms) b.destroy();
|
||||
GPU.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }));
|
||||
GPU.bindBlur = [group(GPU.blur, [view(GPU.glowTex), view(GPU.blurX), buffer(GPU.blurUniforms[0])]), group(GPU.blur, [view(GPU.blurX), view(GPU.blurY), buffer(GPU.blurUniforms[1])])];
|
||||
}
|
||||
function gpuUpload(lighting, glowOn) {
|
||||
gpuWorldTextures(); gpuViewTextures(); gpuBindings();
|
||||
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
|
||||
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
|
||||
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - y;
|
||||
let top = y;
|
||||
if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]);
|
||||
gpuUploadChunks(x, top, x + w, y + h);
|
||||
// Small per-row/column tables may change in scene tools independently of world allocation.
|
||||
const land = GPU.landData;
|
||||
for (let i = 0; i < W; i++) { const k = i * 4; land[k] = surfY[i]; land[k + 1] = dirtY[i]; land[k + 2] = graniteY[i]; land[k + 3] = basaltY[i]; }
|
||||
for (let i = 0; i < H; i++) { const k = (W + i) * 4; land[k] = SKY_R[i]; land[k + 1] = SKY_G[i]; land[k + 2] = SKY_B[i]; land[k + 3] = AMB[i]; }
|
||||
const q = GPU.device.queue;
|
||||
q.writeBuffer(GPU.land, 0, land);
|
||||
gpuWrite(GPU.override, R.px, R.bw, 0, 0, R.bw, R.bh);
|
||||
gpuWrite(GPU.overrideGlow, R.glowPx, R.glowW, 0, 0, R.glowW, R.glowH);
|
||||
for(let i=0;i<W;i++) { GPU.columnData[i*4]=surfY[i]; GPU.columnData[i*4+1]=roofY[i]; }
|
||||
q.writeBuffer(GPU.columns,0,GPU.columnData);
|
||||
if (R.smoothOn) gpuSmoothUpload();
|
||||
const zp = R.zp, ox = Math.round(-(R.camX - R.vx0) * zp), oy = Math.round(-(R.camY - R.vy0) * zp);
|
||||
GPU.params.set([R.vx0, R.vy0, R.bw, R.bh, W, H, FW, FH,
|
||||
+SET.fog, R.rain, SET.grain, R.frame, +R.lens, Math.max(0, Object.keys(THERM).indexOf(SET.thermalPalette)), +SET.incandescence, +glowOn,
|
||||
ox, oy, zp, 0, R.lx0, R.ly0, R.lres, +lighting,
|
||||
R.canvas.width, R.canvas.height, SET.glow === 'high' ? 0.9 : 0.5, 0, R.camX, R.camY, 0, 0]);
|
||||
q.writeBuffer(GPU.uniform, 0, GPU.params);
|
||||
const sigma = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)) / (2 * zp);
|
||||
q.writeBuffer(GPU.blurUniforms[0], 0, new Float32Array([1, 0, sigma, 0]));
|
||||
q.writeBuffer(GPU.blurUniforms[1], 0, new Float32Array([0, 1, sigma, 0]));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||
function gpuUploadChunks(x0, y0, x1, y1) {
|
||||
const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5);
|
||||
const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32));
|
||||
GPU.uploadStrips = 0; GPU.uploadCells = 0;
|
||||
for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) {
|
||||
const c = cy * CW + cx;
|
||||
if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; }
|
||||
const first = cx++;
|
||||
while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++;
|
||||
const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y);
|
||||
gpuUploadStrip(x, y, w, h);
|
||||
GPU.uploadStrips++; GPU.uploadCells += w * h;
|
||||
for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; }
|
||||
}
|
||||
}
|
||||
function gpuUploadStrip(x, y, w, h) {
|
||||
for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h);
|
||||
gpuWrite(GPU.temp, temp, W, x, y, w, h);
|
||||
gpuWrite(GPU.mach, mach, W, x, y, w, h);
|
||||
gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS));
|
||||
// Fog interpolation reads the next sample, possibly in a neighbouring chunk.
|
||||
gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1));
|
||||
}
|
||||
// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them.
|
||||
async function gpuCheckUploads() {
|
||||
const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]),
|
||||
[GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]];
|
||||
let checked = 0;
|
||||
for (const [texture, data, stride, scale] of sources) {
|
||||
const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256;
|
||||
const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||
const encoder = GPU.device.createCommandEncoder();
|
||||
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]);
|
||||
GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ);
|
||||
const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength);
|
||||
let mismatch = '';
|
||||
for (let cy=0;cy<CH;cy++) for(let cx=0;cx<CW;cx++) {
|
||||
const c=cy*CW+cx; if(!GPU.resident[c] || renderDirty[c]) continue;
|
||||
for(let y=cy*32/scale;y<Math.min(texture.height,(cy+1)*32/scale);y++)
|
||||
for(let x=cx*32/scale;x<Math.min(texture.width,(cx+1)*32/scale);x++) for(let b=0;b<bytes;b++) {
|
||||
checked++; if(actual[y*row+x*bytes+b]!==expected[(y*stride+x)*bytes+b] && !mismatch) mismatch=`${texture.format} at ${x},${y}`;
|
||||
}
|
||||
}
|
||||
buffer.unmap(); buffer.destroy(); if(mismatch) throw Error('Stale GPU upload: '+mismatch);
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Propagated coloured light from glowing and burning cells.
|
||||
// ---------- Lighting ----------
|
||||
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
|
||||
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
|
||||
function computeLight() {
|
||||
const res = SET.lighting === 'fast' ? 4 : 2;
|
||||
const M = Math.ceil(28 / res);
|
||||
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
|
||||
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
|
||||
if (lw !== R.lw || lh !== R.lh || res !== R.lres) {
|
||||
R.lw = lw; R.lh = lh; R.lres = res;
|
||||
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
|
||||
R.lightC.width = lw; R.lightC.height = lh;
|
||||
R.lightImg = R.lightX.createImageData(lw, lh);
|
||||
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
|
||||
}
|
||||
R.lx0 = lx0; R.ly0 = ly0;
|
||||
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
|
||||
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
|
||||
const P = res;
|
||||
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
|
||||
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
|
||||
for (let ly = 0; ly < lh; ly++) {
|
||||
const wy = (ly0 + ly) * res;
|
||||
for (let lx = 0; lx < lw; lx++) {
|
||||
const wx = (lx0 + lx) * res, k = ly * lw + lx;
|
||||
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
|
||||
const i = wy * W + wx, m = mat[i], ph = PH[m];
|
||||
la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
|
||||
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
|
||||
// emission from heat: hottest grain in the block
|
||||
let T = 0;
|
||||
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
|
||||
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
|
||||
if (T > 520) {
|
||||
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
|
||||
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
|
||||
}
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
}
|
||||
// bot lamp
|
||||
if (bot.lamp) {
|
||||
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
|
||||
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
|
||||
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
|
||||
const d = Math.hypot(lx - cx, ly - cy) / rad;
|
||||
if (d >= 1) continue;
|
||||
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
|
||||
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
|
||||
}
|
||||
}
|
||||
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
|
||||
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
|
||||
const k = ly * lw + lx, a = la[k];
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
|
||||
const k = ly * lw + lx, a = la[k];
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
// write the light canvas
|
||||
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
|
||||
for (let k = 0; k < n; k++) {
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
|
||||
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
|
||||
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
||||
function worldObjects() {
|
||||
const { bw, bh, px, vx0, vy0, smoothOn: sm, lens } = R;
|
||||
const useFog = SET.fog, incand = SET.incandescence;
|
||||
// bars and other loose objects
|
||||
if (!lens) for (const e of ents) {
|
||||
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
|
||||
const ex = e.ix - vx0, ey = e.iy - vy0;
|
||||
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
|
||||
const m = e.m;
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
|
||||
const sx = ex + dx, sy = ey + dy;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
let r = COLR[m], g = COLG[m], b = COLB[m];
|
||||
const end = dx === 0 || dx === BAR_W - 1;
|
||||
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
|
||||
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
|
||||
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
|
||||
}
|
||||
}
|
||||
// grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain)
|
||||
if (!lens) for (const f of flyers) {
|
||||
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
|
||||
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
|
||||
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
|
||||
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
|
||||
}
|
||||
// blueprint ghosts: blocks waiting to be built
|
||||
if (ghostBlocks.size) {
|
||||
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
|
||||
const pulse = (R.frame >> 3) & 3;
|
||||
for (const [b, gv] of ghostBlocks) {
|
||||
const gbx = b % BW, gby = (b / BW) | 0;
|
||||
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
|
||||
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
|
||||
let tr = 90, tg = 190, tb = 230;
|
||||
if (blocked) { tr = 230; tg = 70; tb = 60; }
|
||||
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
|
||||
const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks));
|
||||
const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0;
|
||||
const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3;
|
||||
for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) {
|
||||
let sd = 0;
|
||||
if (diagonal) {
|
||||
if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue;
|
||||
const x = gbx * BS + dx, y = gby * BS + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
|
||||
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
|
||||
sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
|
||||
if (sd > 0) continue;
|
||||
}
|
||||
if (slope) {
|
||||
const x = gbx * BS + dx, y = gby * BS + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
|
||||
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
|
||||
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
|
||||
const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code);
|
||||
if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
|
||||
if (rib) sd = -0.1;
|
||||
}
|
||||
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
|
||||
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const k = sy * bw + sx;
|
||||
if (R.gpuOn && !gpuOverrideHas(k)) cellPixel(sx + vx0, sy + vy0);
|
||||
const c = px[k];
|
||||
const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1;
|
||||
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
|
||||
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
|
||||
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function drawRain() {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Info overlay: a small arrow on every belt and screw tube block on screen, the way it moves things
|
||||
// Info overlay: a small arrow on every belt and screw conveyor block on screen, the way it moves things
|
||||
function drawFlowArrows() {
|
||||
const ctx = R.ctx, zp = R.zp, v = viewCells();
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
@@ -37,12 +37,12 @@ function drawFlowArrows() {
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b];
|
||||
if (code !== BP_BELT && code !== BP_TUBE && code !== BP_PUMP && code !== BP_OUTLET) continue;
|
||||
if (code !== BP_BELT && code !== BP_SCREW && code !== BP_PUMP && code !== BP_OUTLET) continue;
|
||||
const vv = blockV[b];
|
||||
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : [[0, -1], [1, 0], [0, 1], [-1, 0]][vv & 3];
|
||||
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : SCREW_DIRS[vv & 7];
|
||||
const c = worldToScreenPx(bx * BS + BS / 2, by * BS + BS / 2);
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.55)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.35, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = code === BP_TUBE ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
|
||||
ctx.strokeStyle = code === BP_SCREW ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
|
||||
// a chevron pointing (dx, dy)
|
||||
const px = -dy, py = dx;
|
||||
ctx.beginPath();
|
||||
@@ -53,6 +53,147 @@ function drawFlowArrows() {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// "Doesn't connect": a red ring with a slash at a transport end that would join one block up or
|
||||
// down (nearMiss), on built pieces, ghosts and the drag preview alike.
|
||||
function drawJoinMark(bx, by, end, code, v) {
|
||||
const e = pieceEnds(code, v)[end], ctx = R.ctx, r = Math.max(3, BS * R.zp * 0.24);
|
||||
const c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS / 2, by * BS + BS / 2 + e.dy * BS / 2);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#ef6a55'; ctx.lineWidth = Math.max(1.2, r * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, r * 0.75, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(c.x - r * 0.53, c.y + r * 0.53); ctx.lineTo(c.x + r * 0.53, c.y - r * 0.53); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawJoinMarks() {
|
||||
const v = viewCells();
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, ghost = ghostBlocks.get(b), code = ghost ? ghost[0] : blockP[b], vv = ghost ? ghost[1] : blockV[b];
|
||||
if (!code || !pieceEnds(code, vv).length) continue;
|
||||
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, ghostBlocks.size ? ghostBlocks : null)) drawJoinMark(bx, by, end, code, vv);
|
||||
}
|
||||
}
|
||||
// Throughput (t49, info overlay): grains per second at the open outlet end of every line, and
|
||||
// an orange glow on transport blocks that are loaded but not moving, where a line backs up.
|
||||
function flowLoaded(b, code) {
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
let n = 0;
|
||||
for (let k = 0; k < BS; k++) {
|
||||
const x = bx * BS + k, i = code === BP_SCREW ? (by * BS + 1) * W + x : (beltFaceY(code, bx, by, x) - 1) * W + x;
|
||||
if (i >= 0 && i < N && PH[mat[i]] === PH_POWDER) n++;
|
||||
}
|
||||
return n >= 3;
|
||||
}
|
||||
function drawFlowRates() {
|
||||
const ctx = R.ctx, v = viewCells(), s = BS * R.zp;
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
||||
ctx.save();
|
||||
ctx.font = `600 ${Math.max(10, Math.min(14, s * 0.45))}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b], ends = pieceEnds(code, blockV[b]);
|
||||
if (!ends.length) continue;
|
||||
const rate = flowRate[b] || 0;
|
||||
if (rate < 0.5 && flowLoaded(b, code)) {
|
||||
const p = worldToScreenPx(bx * BS, by * BS);
|
||||
ctx.fillStyle = 'rgba(255,150,60,0.32)'; ctx.fillRect(p.x, p.y, s, s);
|
||||
}
|
||||
const out = ends[1].outlet ? 1 : 0;
|
||||
if (endJoin(bx, by, out) >= 0) continue;
|
||||
const e = ends[out], c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS * 0.95, by * BS + BS / 2 + e.dy * BS * 0.95 - (e.dy ? 0 : BS * 0.9));
|
||||
const label = rate >= 10 ? `${Math.round(rate)}/s` : `${rate.toFixed(1)}/s`, tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.8)'; ctx.fillRect(c.x - tw / 2 - 4, c.y - 9, tw + 8, 18);
|
||||
ctx.fillStyle = rate > 0 ? '#e7dccb' : '#ef9a6a'; ctx.fillText(label, c.x, c.y + 1);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// The resonance ping's echo: a faint ring spreading from the bot, the veins it found showing in
|
||||
// their own lit colour inside it (with a slightly lighter rim so dark seams read against fog),
|
||||
// a brief sparkle as the ring passes them; each vein fades a fixed time after the ring reached
|
||||
// it, so the fade follows the ring outward. The veins are drawn once into a small image; each
|
||||
// frame draws it in a few distance bands (fading ones at partial alpha) plus a few dozen sparkles.
|
||||
const PING_SPEED = 160, PING_HOLD = 1200, PING_FADE = 800, PING_BANDS = 6, PING_GLINTS = 60, GLINT_MS = 450;
|
||||
function pingImage(p) {
|
||||
const size = p.r * 2 + 1, c = document.createElement('canvas'); c.width = c.height = size;
|
||||
const g = c.getContext('2d'), img = g.createImageData(size, size), d = img.data, ox = Math.floor(p.x - p.r), oy = Math.floor(p.y - p.r);
|
||||
const lit = (i, k, lift) => { const m = mat[i], v = (noiseB[i] - 128) * CVAR[m] / 128 + lift; d[k] = COLR[m] + v; d[k + 1] = COLG[m] + v; d[k + 2] = COLB[m] + v; d[k + 3] = 255; };
|
||||
for (const i of p.cells) { const x = i % W - ox, y = ((i / W) | 0) - oy; if (x >= 0 && y >= 0 && x < size && y < size) lit(i, (y * size + x) * 4, 0); }
|
||||
for (const i of p.cells) {
|
||||
const x = i % W - ox, y = ((i / W) | 0) - oy, k = (y * size + x) * 4;
|
||||
if (x <= 0 || y <= 0 || x >= size - 1 || y >= size - 1) continue;
|
||||
if (!d[k - 1] || !d[k + 7] || !d[k - size * 4 + 3] || !d[k + size * 4 + 3]) lit(i, k, 45); // the rim
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
const step = Math.max(1, Math.ceil(p.cells.length / PING_GLINTS));
|
||||
const glints = p.cells.filter((_, k) => k % step === 0).map(i => ({ x: i % W + 0.5, y: ((i / W) | 0) + 0.5, d: Math.hypot(i % W - p.x, ((i / W) | 0) - p.y), m: mat[i] }));
|
||||
return { c, ox, oy, glints };
|
||||
}
|
||||
function drawPing() {
|
||||
const p = GAME.ping;
|
||||
if (!p) return;
|
||||
const ms = performance.now() - p.t0, front = ms / 1000 * PING_SPEED, done = p.r / PING_SPEED * 1000;
|
||||
if (ms > done + PING_HOLD + PING_FADE) { GAME.ping = null; return; }
|
||||
if (!p.img) p.img = pingImage(p);
|
||||
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(p.x, p.y), o = worldToScreenPx(p.img.ox, p.img.oy);
|
||||
// inside `gone` the veins have faded out; from `full` out to the ring they are fully lit
|
||||
const outer = Math.min(front, p.r + 1), full = (ms - PING_HOLD) / 1000 * PING_SPEED, gone = (ms - PING_HOLD - PING_FADE) / 1000 * PING_SPEED;
|
||||
const band = (r0, r1, a) => {
|
||||
if (r1 <= Math.max(0, r0) || a <= 0) return;
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(c.x, c.y, r1 * zp, 0, Math.PI * 2);
|
||||
if (r0 > 0) ctx.arc(c.x, c.y, r0 * zp, 0, Math.PI * 2, true);
|
||||
ctx.clip(); ctx.globalAlpha = a; ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(p.img.c, o.x, o.y, p.img.c.width * zp, p.img.c.height * zp); ctx.restore();
|
||||
};
|
||||
band(Math.max(full, 0), outer, 1);
|
||||
for (let k = 0; k < PING_BANDS; k++) {
|
||||
const r0 = gone + (full - gone) * k / PING_BANDS, r1 = gone + (full - gone) * (k + 1) / PING_BANDS;
|
||||
band(r0, Math.min(r1, outer), (k + 0.5) / PING_BANDS);
|
||||
}
|
||||
ctx.save();
|
||||
if (front < p.r) {
|
||||
ctx.strokeStyle = `rgba(160,225,255,${0.35 * (1 - front / p.r)})`; ctx.lineWidth = Math.max(1, zp * 0.4);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, front * zp, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// a four-point glint where the ring has just passed, growing then shrinking
|
||||
for (const gl of p.img.glints) {
|
||||
const t = (ms - gl.d / PING_SPEED * 1000) / GLINT_MS;
|
||||
if (t < 0 || t > 1) continue;
|
||||
const s = worldToScreenPx(gl.x, gl.y), k = Math.sin(t * Math.PI) * zp * 2.2, m = gl.m;
|
||||
ctx.strokeStyle = `rgba(${Math.min(255, COLR[m] + 140)},${Math.min(255, COLG[m] + 140)},${Math.min(255, COLB[m] + 140)},${0.9 * Math.sin(t * Math.PI)})`;
|
||||
ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
ctx.beginPath(); ctx.moveTo(s.x - k, s.y); ctx.lineTo(s.x + k, s.y); ctx.moveTo(s.x, s.y - k); ctx.lineTo(s.x, s.y + k); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Edges are built once when the cursor enters a different vein.
|
||||
function drawHoveredVein() {
|
||||
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) return;
|
||||
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!vein) return;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, R.canvas.width, R.canvas.height); ctx.clip();
|
||||
ctx.strokeStyle = 'rgba(235,245,255,0.85)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (const e of vein.edges) {
|
||||
if (e[2] < R.vx0 || e[0] > R.vx0 + R.bw || e[3] < R.vy0 || e[1] > R.vy0 + R.bh) continue;
|
||||
const a = worldToScreenPx(e[0], e[1]);
|
||||
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (e[2] - e[0]) * zp, a.y + (e[3] - e[1]) * zp);
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
}
|
||||
// While the ping recharges, a thin arc around the bot fills up (only with the drill in hand).
|
||||
function drawPingRecharge() {
|
||||
if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return;
|
||||
const k = (tick - GAME.pingTick) / PING_RECHARGE;
|
||||
if (k >= 1) return;
|
||||
const ctx = R.ctx, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2), r = Math.max(bot.w, bot.h) * R.zp * 0.9;
|
||||
ctx.save(); ctx.lineWidth = Math.max(2, R.zp * 0.5); ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(160,225,255,0.18)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(160,225,255,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, -Math.PI / 2, -Math.PI / 2 + k * Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
// Outline a run of blocks: strong while it is being edited, faint when pointed at
|
||||
function drawRunOutline(run, strong) {
|
||||
if (!run || !run.length) return;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
thermal palettes, pipes, trees, sky), canvas setup and the camera. */
|
||||
|
||||
const R = {
|
||||
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
|
||||
glowC: null, glowX: null, glowImg: null, glowPx: null,
|
||||
canvas: null, ctx: null, px: null,
|
||||
glowW: 0, glowH: 0, glowPx: null,
|
||||
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
|
||||
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
||||
lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
||||
frame: 0, lens: false, particles: [],
|
||||
};
|
||||
|
||||
@@ -116,8 +116,8 @@ function thinPipeColor(x, y, anim) {
|
||||
}
|
||||
PC[0] = r; PC[1] = g; PC[2] = bl;
|
||||
}
|
||||
// a block that is a belt or a filter: they join up into one belt
|
||||
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; }
|
||||
// A filter frame opens only where this end has a geometric partner.
|
||||
function beltLike(b, end) { return endJoin(b % BW, (b / BW) | 0, end) >= 0; }
|
||||
// standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops
|
||||
const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT);
|
||||
for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; }
|
||||
@@ -138,13 +138,7 @@ function buildSky() {
|
||||
|
||||
function initRender(canvas) {
|
||||
R.canvas = canvas;
|
||||
R.ctx = canvas.getContext('2d', { alpha: false });
|
||||
R.buf = document.createElement('canvas');
|
||||
R.bctx = R.buf.getContext('2d');
|
||||
R.glowC = document.createElement('canvas');
|
||||
R.glowX = R.glowC.getContext('2d');
|
||||
R.lightC = document.createElement('canvas');
|
||||
R.lightX = R.lightC.getContext('2d');
|
||||
R.ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
function resizeRender() {
|
||||
@@ -157,15 +151,11 @@ function resizeRender() {
|
||||
const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2;
|
||||
if (bw !== R.bw || bh !== R.bh) {
|
||||
R.bw = bw; R.bh = bh;
|
||||
R.buf.width = bw; R.buf.height = bh;
|
||||
R.img = R.bctx.createImageData(bw, bh);
|
||||
R.px = new Uint32Array(R.img.data.buffer);
|
||||
R.px = new Uint32Array(bw * bh);
|
||||
// the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields
|
||||
R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh);
|
||||
const gw = Math.ceil(bw / 2), gh = Math.ceil(bh / 2);
|
||||
R.glowC.width = gw; R.glowC.height = gh;
|
||||
R.glowImg = R.glowX.createImageData(gw, gh);
|
||||
R.glowPx = new Uint32Array(R.glowImg.data.buffer);
|
||||
R.glowW = Math.ceil(bw / 2); R.glowH = Math.ceil(bh / 2);
|
||||
R.glowPx = new Uint32Array(R.glowW * R.glowH);
|
||||
R.lw = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
// Smooth shaders: vertex and fragment programs for the rounded world look.
|
||||
const SM_VS = `#version 300 es
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const SM_FS = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
|
||||
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
|
||||
uniform vec4 uView, uLightMap;
|
||||
uniform float uGlowK, uTime, uSurf, uRain;
|
||||
out vec4 o;
|
||||
|
||||
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
||||
float vn(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
|
||||
|
||||
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
|
||||
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
|
||||
vec4 bsp(sampler2D t, vec2 q) {
|
||||
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
|
||||
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
|
||||
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
|
||||
vec2 g0 = w0 + w1, g1 = w2 + w3;
|
||||
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
|
||||
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
|
||||
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
|
||||
}
|
||||
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
|
||||
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
|
||||
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
|
||||
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
|
||||
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
|
||||
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
|
||||
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
|
||||
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
|
||||
// the line of belts ends, the end is square with rounded corners. Negative inside.
|
||||
float beltSD(vec2 lb, bool endL, bool endR) {
|
||||
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
|
||||
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
|
||||
vec2 q = abs(lb - c) - hs + r;
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
|
||||
// the line only rounds off where the whole run ends.
|
||||
float beltLineSD(ivec2 c, vec2 lb) {
|
||||
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
|
||||
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5);
|
||||
}
|
||||
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
|
||||
// back along the bottom.
|
||||
float bandRidge(vec2 w, vec2 lb, float dir) {
|
||||
float run = lb.y < 2.0 ? 1.0 : -1.0;
|
||||
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
|
||||
}
|
||||
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
|
||||
|
||||
void main() {
|
||||
float zp = uView.z, px = 1.0 / zp;
|
||||
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
|
||||
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
|
||||
vec2 w = q + uOrigin; // position in the world
|
||||
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
|
||||
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
|
||||
float shc = uView.w * px; // screen height in grains
|
||||
ivec2 c = ivec2(floor(q));
|
||||
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
|
||||
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
|
||||
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
|
||||
vec4 own = cellAt(tCol, c); int nc = cls(own);
|
||||
vec2 s = smoothstep(0.1, 0.9, t);
|
||||
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
|
||||
|
||||
// fields, and everything that needs screen derivatives (worked out before any branching)
|
||||
vec4 Fb = bsp(tField, q);
|
||||
vec4 F = mix(lin(tField, q), Fb, 0.62);
|
||||
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
|
||||
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
|
||||
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
|
||||
float M = clamp(F.r + pw, 0.0, 1.0);
|
||||
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
|
||||
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
|
||||
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
|
||||
|
||||
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
|
||||
vec4 back = lin(tBack, q);
|
||||
vec3 C = back.rgb;
|
||||
float sky = back.a;
|
||||
if (sky > 0.01) {
|
||||
vec3 S = C;
|
||||
vec2 P0 = scr + cam * 0.08;
|
||||
// clouds; while it rains they thicken and turn grey
|
||||
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
|
||||
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
|
||||
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
|
||||
float k1 = 0.16; vec2 P1 = scr + cam * k1;
|
||||
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
|
||||
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
|
||||
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
|
||||
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
|
||||
S = mix(S, far, m1);
|
||||
float k2 = 0.38; vec2 P2 = scr + cam * k2;
|
||||
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
|
||||
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
|
||||
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
|
||||
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
|
||||
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
|
||||
S = mix(S, near, m2);
|
||||
C = mix(C, S, sky);
|
||||
}
|
||||
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
|
||||
float notSky = 1.0 - sky;
|
||||
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
|
||||
float near = textureLod(tField, q / uSize, 2.2).r;
|
||||
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
|
||||
|
||||
// ---- trees (behind the ground and everything on it)
|
||||
if (F.a > 0.2) {
|
||||
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
|
||||
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
if (sm > 1e-4) {
|
||||
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
|
||||
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
|
||||
float lv = smoothstep(0.6, 0.9, leaf);
|
||||
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
|
||||
float d = (F.a - 0.36) / fwA;
|
||||
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
|
||||
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- smoke, steam and flame: soft, see-through puffs
|
||||
if (Fb.b > 0.02) {
|
||||
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
|
||||
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
|
||||
}
|
||||
|
||||
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
|
||||
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
|
||||
if (anyL > 0.0 && lf > 0.3) {
|
||||
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
|
||||
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
|
||||
float gl = length(gL);
|
||||
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
|
||||
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
|
||||
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
|
||||
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
|
||||
}
|
||||
|
||||
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
|
||||
if (F.r > 0.3) {
|
||||
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
|
||||
float bd = 1e9;
|
||||
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
|
||||
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
|
||||
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
|
||||
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
|
||||
if (sm < 1e-4) tc = own.rgb;
|
||||
tc *= 0.84 + 0.3 * fbm(w * 0.28);
|
||||
tc *= 0.95 + 0.1 * vn(w * 1.6);
|
||||
float gl = length(gT);
|
||||
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
|
||||
float band = 1.0 - smoothstep(0.5, 0.9, M);
|
||||
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
|
||||
tc *= 1.0 + 0.42 * lit * band;
|
||||
// the outline follows where ground and grains meet open space, never where rock meets gravel
|
||||
float dm = (M - 0.5) / fwM;
|
||||
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
|
||||
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
|
||||
}
|
||||
|
||||
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
|
||||
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
|
||||
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
|
||||
if (np > 0) {
|
||||
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
ivec2 o4 = ivec2(k & 1, k >> 1);
|
||||
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
|
||||
if (nk != 2) continue;
|
||||
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
|
||||
vec2 ce = vec2(c0 + o4) + 0.5;
|
||||
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
|
||||
vec2 dv = (q - ce) / r;
|
||||
float dd = length(dv);
|
||||
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
|
||||
}
|
||||
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
|
||||
if (closed && F.r < 0.5) cover = 1.0;
|
||||
if (cover > 0.0) {
|
||||
float r2 = min(1.0, dot(gd, gd));
|
||||
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
|
||||
float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
|
||||
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
|
||||
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
|
||||
C = mix(C, pc, cover);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
|
||||
if (nc == 4) C = own.rgb;
|
||||
else if (nc == 7 || nc == 8) {
|
||||
// belt: a rubber band round two copper rollers per block; the top run moves the load
|
||||
float dir = nc == 8 ? 1.0 : -1.0;
|
||||
vec2 lb = fract(w * 0.25) * 4.0;
|
||||
// outline: square ends with rounded corners where the line of belts (and filters) ends
|
||||
float sd = beltLineSD(c, lb);
|
||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
float edge = -sd;
|
||||
vec3 bc;
|
||||
if (edge < 0.8) {
|
||||
bc = bandColor(lb, bandRidge(w, lb, dir));
|
||||
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
|
||||
float rd = length(dv);
|
||||
bc = vec3(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
|
||||
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, bc, inside);
|
||||
}
|
||||
}
|
||||
else if (nc >= 9 && nc <= 12) {
|
||||
// screw tube: a copper pipe; what is inside shows through, and the screw's flights turn
|
||||
bool vert = nc == 9 || nc == 11;
|
||||
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
|
||||
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
|
||||
float wall = 0.62;
|
||||
vec3 tc;
|
||||
if (a < wall || a > 4.0 - wall) {
|
||||
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
|
||||
float cyl = sin(u2 * 1.5708);
|
||||
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
|
||||
if (a < wall) tc *= 1.08;
|
||||
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
|
||||
tc *= 1.0 - 0.3 * ring;
|
||||
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
|
||||
} else {
|
||||
bool full = own.r + own.g + own.b > 0.0;
|
||||
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
|
||||
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
|
||||
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
|
||||
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
|
||||
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
|
||||
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
|
||||
}
|
||||
C = tc;
|
||||
}
|
||||
else if (nc == 6) {
|
||||
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
|
||||
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
|
||||
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
|
||||
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
|
||||
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
|
||||
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
|
||||
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
|
||||
C = tc;
|
||||
}
|
||||
|
||||
else if (nc == 13 || nc == 14) {
|
||||
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
|
||||
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
|
||||
// band is perforated; its colour tag sits on the top run for what rides along, on the
|
||||
// bottom run for what drops through.
|
||||
float dir = nc == 14 ? 1.0 : -1.0;
|
||||
vec2 lb = fract(w * 0.25) * 4.0;
|
||||
vec4 tag = cellAt(tTree, c);
|
||||
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
|
||||
float sd = beltLineSD(c, lb);
|
||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
float edge = -sd;
|
||||
vec3 steel = vec3(0.42, 0.44, 0.5);
|
||||
// through the holes: whatever is behind (water, sky, rock), or what is passing through
|
||||
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
|
||||
vec2 cg = abs(fract(w) - 0.5);
|
||||
float bw2 = max(px * 0.9, 0.09);
|
||||
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
vec3 fc;
|
||||
if (edge < 0.8) {
|
||||
float rg = bandRidge(w, lb, dir);
|
||||
vec3 band = bandColor(lb, rg);
|
||||
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
|
||||
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
|
||||
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
fc = mix(steel * 0.78, behind, hole);
|
||||
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
|
||||
}
|
||||
C = mix(C, fc, inside);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- light, then the glow of hot things on top
|
||||
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
|
||||
if (uGlowK > 0.0) {
|
||||
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
|
||||
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
|
||||
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
|
||||
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
|
||||
C += g * uGlowK;
|
||||
}
|
||||
o = vec4(C, 1.0);
|
||||
}`;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
|
||||
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
|
||||
|
||||
function smCompile(gl, type, src) {
|
||||
const sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src); gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
|
||||
return sh;
|
||||
}
|
||||
// Is the smooth look possible here (WebGL2)? Set up once, on first use.
|
||||
function smoothAvailable() {
|
||||
if (SM.tried) return SM.ok;
|
||||
SM.tried = true;
|
||||
try {
|
||||
const cv = document.createElement('canvas');
|
||||
const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
||||
if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); }
|
||||
// a browser drawing without the graphics card would crawl: keep the pixel look there
|
||||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
|
||||
if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); }
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS));
|
||||
gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
||||
gl.useProgram(prog);
|
||||
for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n);
|
||||
// texture unit for each sampler; linear ones are sampled between grains
|
||||
const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]];
|
||||
texs.forEach(([name, linear, mip], unit) => {
|
||||
const t = gl.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
|
||||
SM.tex[name] = { t, unit, mip, w: 0, h: 0 };
|
||||
});
|
||||
gl.bindVertexArray(gl.createVertexArray());
|
||||
SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true;
|
||||
SM.white = new Uint8Array([255, 255, 255, 255]);
|
||||
cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; });
|
||||
} catch (e) {
|
||||
console.warn('Smooth look unavailable, using pixels:', e && e.message);
|
||||
SM.ok = false;
|
||||
}
|
||||
return SM.ok;
|
||||
}
|
||||
function smUpload(name, w, h, data) {
|
||||
const gl = SM.gl, T = SM.tex[name];
|
||||
gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t);
|
||||
if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; }
|
||||
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
if (T.mip) gl.generateMipmap(gl.TEXTURE_2D);
|
||||
}
|
||||
const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
||||
function smoothDraw(lighting, glowOn) {
|
||||
const t0 = performance.now();
|
||||
// the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly
|
||||
const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u;
|
||||
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
|
||||
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
|
||||
gl.viewport(0, 0, cw, ch);
|
||||
gl.useProgram(SM.prog);
|
||||
const bw = R.bw, bh = R.bh;
|
||||
smUpload('tCol', bw, bh, smBytes(R.px));
|
||||
smUpload('tColL', bw, bh, smBytes(R.px));
|
||||
smUpload('tBack', bw, bh, smBytes(R.backPx));
|
||||
smUpload('tTree', bw, bh, smBytes(R.treePx));
|
||||
smUpload('tField', bw, bh, smBytes(R.fieldPx));
|
||||
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
|
||||
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
|
||||
gl.uniform2f(u.uSize, bw, bh);
|
||||
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
|
||||
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
|
||||
gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0);
|
||||
gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1);
|
||||
gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1);
|
||||
gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0);
|
||||
gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000);
|
||||
gl.uniform1f(u.uSurf, SM.surf);
|
||||
gl.uniform1f(u.uRain, R.rain || 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
const ctx = R.ctx;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
|
||||
smoothPace(performance.now() - t0);
|
||||
}
|
||||
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
|
||||
// is room, and if even half resolution is too slow, give up on the smooth look for this session.
|
||||
function smoothPace(ms) {
|
||||
SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms;
|
||||
if (++SM.n < 15) return;
|
||||
SM.n = 1;
|
||||
if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; }
|
||||
if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25));
|
||||
else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25);
|
||||
}
|
||||
@@ -2,19 +2,20 @@
|
||||
function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 16) | (((c >>> 8 & 255) * k) << 8) | ((c & 255) * k); }
|
||||
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
// ---------- World pixels ----------
|
||||
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
const TUBE_CLASS = new Uint8Array(9); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12;
|
||||
function drawWorld() {
|
||||
const bw = R.bw, bh = R.bh, px = R.px;
|
||||
const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||
if (PERF_MARKS || SET.showPerf) { perfMarks.override = 0; delete perfMarks.gpu; }
|
||||
if (!GPU.ok) return;
|
||||
R.gpuOn = true; GPU.canvas.hidden = false;
|
||||
R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height);
|
||||
const bw = R.bw, bh = R.bh;
|
||||
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
|
||||
const vx0 = R.vx0, vy0 = R.vy0;
|
||||
// how hard it is raining around the camera, eased so the sky greys over gently
|
||||
R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02;
|
||||
const rk = R.rain;
|
||||
// Smooth look: the colours below are still worked out per grain, but each grain also gets a
|
||||
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js)
|
||||
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable();
|
||||
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
|
||||
// Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
|
||||
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens;
|
||||
const mmask = R.moldMask;
|
||||
if (sm) {
|
||||
mmask.fill(0);
|
||||
for (const mo of molds) {
|
||||
@@ -22,287 +23,24 @@ function drawWorld() {
|
||||
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; }
|
||||
}
|
||||
}
|
||||
const useFog = SET.fog;
|
||||
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
|
||||
if (diagonalJoins.length !== NB) diagonalJoins = new Uint8Array(NB);
|
||||
for (let by = Math.max(0, (vy0 >> 2) - 1); by <= Math.min(BH - 1, ((vy0 + bh) >> 2) + 1); by++) {
|
||||
for (let bx = Math.max(0, (vx0 >> 2) - 1); bx <= Math.min(BW - 1, ((vx0 + bw) >> 2) + 1); bx++) {
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] === BP_SCREW) diagonalJoins[b] = blockV[b] >= 4 ? diagonalScrewJoins(bx, by, blockV[b]) : straightScrewJoins(bx, by, blockV[b]);
|
||||
}
|
||||
}
|
||||
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
|
||||
const lighting = SET.lighting !== 'off' && !R.lens;
|
||||
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
|
||||
const grain = SET.grain, incand = SET.incandescence;
|
||||
const anim = R.frame;
|
||||
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
|
||||
const glowOn = SET.glow !== 'off' && !lens;
|
||||
const gpx = R.glowPx, gw = R.glowC.width;
|
||||
if (glowOn) gpx.fill(0);
|
||||
for (let by = 0; by < bh; by++) {
|
||||
const y = vy0 + by;
|
||||
const row = by * bw;
|
||||
if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; }
|
||||
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
|
||||
const yw = y * W;
|
||||
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
|
||||
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
|
||||
for (let bx = 0; bx < bw; bx++) {
|
||||
const x = vx0 + bx;
|
||||
if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
|
||||
let fv = 255;
|
||||
if (useFog) {
|
||||
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
|
||||
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
|
||||
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
|
||||
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
|
||||
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
|
||||
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
|
||||
if (fv < 255) {
|
||||
let s = fv / 255;
|
||||
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
|
||||
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
|
||||
}
|
||||
}
|
||||
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
|
||||
const T = temp[i];
|
||||
let r, g, b;
|
||||
if (lens) {
|
||||
const ti = thermIdx(T);
|
||||
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
|
||||
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
|
||||
const p = part[i];
|
||||
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
|
||||
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
|
||||
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
continue;
|
||||
}
|
||||
// background
|
||||
let br, bgc, bb, sky = false;
|
||||
const tb = bgm[i];
|
||||
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
|
||||
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
|
||||
else {
|
||||
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
|
||||
const v = sm ? 0 : nz * 0.06;
|
||||
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
|
||||
}
|
||||
if (sm) {
|
||||
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
|
||||
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
|
||||
if (tb) {
|
||||
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
|
||||
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
|
||||
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
|
||||
} else tpx[row + bx] = 0;
|
||||
}
|
||||
if (m === EMPTY) { r = br; g = bgc; b = bb; }
|
||||
else {
|
||||
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
|
||||
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
|
||||
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
|
||||
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
|
||||
else if (m === BELTL || m === BELTR) {
|
||||
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
|
||||
if (ly === 0 || ly === 3) {
|
||||
// top run moves with the load, bottom run returns the other way
|
||||
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
|
||||
const k = ly === 0 ? 1 : 0.72;
|
||||
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
|
||||
} else if (lx === 1 || lx === 2) {
|
||||
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
|
||||
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
|
||||
} else { r = 46; g = 48; b = 54; }
|
||||
} else if (m === PIPE) {
|
||||
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
|
||||
} else if (m === SHAFT) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
|
||||
else { r -= 34; g -= 30; b -= 24; }
|
||||
}
|
||||
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
|
||||
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
|
||||
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
|
||||
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
|
||||
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
|
||||
}
|
||||
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
|
||||
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
|
||||
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
|
||||
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
|
||||
const al = ALPHA[m];
|
||||
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
|
||||
const dp = digp[i];
|
||||
if (dp) {
|
||||
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
|
||||
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
|
||||
r += lift; g += lift; b += lift * 1.08;
|
||||
}
|
||||
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
|
||||
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
if (lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
|
||||
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
|
||||
}
|
||||
}
|
||||
// field parts drawn over contents
|
||||
const p = part[i];
|
||||
if (p && sm && (p <= P_TR || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
|
||||
else if (p) {
|
||||
if (p <= P_TR) {
|
||||
const on = netSat(i) > 0;
|
||||
const vert = p === P_TU || p === P_TD;
|
||||
const axis = vert ? y : x, across = vert ? x & 3 : y & 3;
|
||||
const dir = (p === P_TU || p === P_TL) ? 1 : -1;
|
||||
if (across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
|
||||
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
|
||||
if ((axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
|
||||
} else {
|
||||
// screw flights: a diagonal stripe that walks along the tube when powered
|
||||
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
|
||||
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
|
||||
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
|
||||
}
|
||||
} else if (p === P_FILTER) {
|
||||
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
|
||||
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
|
||||
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
|
||||
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) {
|
||||
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
|
||||
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
|
||||
}
|
||||
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
|
||||
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
|
||||
} else if ((lx === 0 && !beltLike(fb - 1)) || (lx === 3 && !beltLike(fb + 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
|
||||
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
|
||||
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
|
||||
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
|
||||
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
|
||||
else { r = 24; g = 25; b = 28; }
|
||||
}
|
||||
} else if (p === P_GRATE) {
|
||||
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
|
||||
} else if (p === P_GOPEN) {
|
||||
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
|
||||
} else if (p === P_INTAKE) {
|
||||
if (m === EMPTY) { r = 30; g = 32; b = 36; }
|
||||
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
|
||||
} else if (p === P_PIPE) {
|
||||
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
|
||||
}
|
||||
}
|
||||
// incandescence
|
||||
if (T > 450 && m !== EMPTY && incand) {
|
||||
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
|
||||
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
|
||||
if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
|
||||
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
|
||||
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
|
||||
}
|
||||
}
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
let top = 0xff000000;
|
||||
if (sm) {
|
||||
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
|
||||
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
|
||||
let cl = 0;
|
||||
const pt = p === P_PIPE && !pipeBodyCell(x, y) ? 0 : p; // the open parts of a pipe block show what is in them
|
||||
if (pt >= P_TU && pt <= P_TR) cl = TUBE_CLASS[pt];
|
||||
else if (p === P_FILTER) {
|
||||
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
|
||||
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
|
||||
cl = blockV[fb] & 128 ? 14 : 13;
|
||||
tpx[row + bx] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
|
||||
}
|
||||
else if (m === BELTL || m === BELTR) cl = m === BELTL ? 7 : 8;
|
||||
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
|
||||
else if (m !== EMPTY) {
|
||||
const ph = PH[m];
|
||||
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) ? 6 : 1;
|
||||
else if (ph === PH_POWDER) cl = 2;
|
||||
else if (ph === PH_LIQUID) cl = 3;
|
||||
else cl = 5;
|
||||
}
|
||||
top = cl << 24;
|
||||
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0);
|
||||
}
|
||||
if (fv < 255) {
|
||||
const k = fv / 255; r *= k; g *= k; b *= k;
|
||||
if (sm) {
|
||||
const c = bpx[row + bx], t = tpx[row + bx];
|
||||
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
|
||||
}
|
||||
}
|
||||
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
}
|
||||
}
|
||||
// bars and other loose objects
|
||||
if (!lens) for (const e of ents) {
|
||||
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
|
||||
const ex = e.ix - vx0, ey = e.iy - vy0;
|
||||
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
|
||||
const m = e.m;
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
|
||||
const sx = ex + dx, sy = ey + dy;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
let r = COLR[m], g = COLG[m], b = COLB[m];
|
||||
const end = dx === 0 || dx === BAR_W - 1;
|
||||
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
|
||||
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
|
||||
}
|
||||
}
|
||||
// grains in flight from the vacuum nozzle, a tube or a belt (rain and snow are drawn over the top, see drawRain)
|
||||
if (!lens) for (const f of flyers) {
|
||||
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
|
||||
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
|
||||
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
|
||||
}
|
||||
// blueprint ghosts: blocks waiting to be built
|
||||
if (ghostBlocks.size) {
|
||||
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
|
||||
const pulse = (R.frame >> 3) & 3;
|
||||
for (const [b, gv] of ghostBlocks) {
|
||||
const gbx = b % BW, gby = (b / BW) | 0;
|
||||
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
|
||||
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
|
||||
let tr = 90, tg = 190, tb = 230;
|
||||
if (blocked) { tr = 230; tg = 70; tb = 60; }
|
||||
else if (code === BP_WALL && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
|
||||
for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) {
|
||||
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const k = sy * bw + sx, c = px[k];
|
||||
const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
|
||||
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
|
||||
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
|
||||
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sm) { smoothDraw(lighting, glowOn); return; }
|
||||
R.bctx.putImageData(R.img, 0, 0);
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
|
||||
ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp);
|
||||
if (lighting) {
|
||||
const res = R.lres;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp;
|
||||
ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp);
|
||||
ctx.restore();
|
||||
}
|
||||
if (glowOn) {
|
||||
R.glowX.putImageData(R.glowImg, 0, 0);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2));
|
||||
try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ }
|
||||
ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp);
|
||||
ctx.filter = 'none';
|
||||
ctx.restore();
|
||||
const lightUpdate = lighting ? gpuLightGrid() : false;
|
||||
const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||
const glowOn = SET.glow !== 'off' && !R.lens;
|
||||
if (glowOn) R.glowPx.fill(0);
|
||||
gpuOverrides();
|
||||
worldObjects();
|
||||
if (PERF_MARKS || SET.showPerf) {
|
||||
perfMarks.color = performance.now() - colorStart;
|
||||
perfMarks.prepare = colorStart - perfStart;
|
||||
}
|
||||
gpuDraw(lighting, glowOn, lightUpdate);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
/*__CSS__*/
|
||||
</style>
|
||||
|
||||
<canvas id="gpu" aria-hidden="true" hidden></canvas>
|
||||
<canvas id="game" aria-label="Game world"></canvas>
|
||||
<div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div>
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
<div class="titlewrap">
|
||||
<canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas>
|
||||
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
|
||||
<div id="cap-notice" class="plate note" role="status" hidden></div>
|
||||
<div class="tmenu" id="tmenu">
|
||||
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
|
||||
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
|
||||
@@ -130,6 +132,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="dev-console" class="plate" aria-labelledby="dev-title" hidden>
|
||||
<div class="dev-head"><strong id="dev-title">Dev console</strong><span class="note">World marked · help lists commands</span><button id="dev-close" class="iconbtn" aria-label="Close dev console">Close <kbd>Esc</kbd></button></div>
|
||||
<div id="dev-output" role="log" aria-label="Command output" aria-live="polite"></div>
|
||||
<form id="dev-form"><label for="dev-input">Command</label><input id="dev-input" type="text" autocomplete="off" spellcheck="false" placeholder="help"><button class="btn" type="submit">Run</button></form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
//__JS__
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,7 @@ function toggleGateAt(x, y) {
|
||||
const m = mat[i];
|
||||
if (m !== EMPTY && PH[m] !== PH_GAS) {
|
||||
const u = i - W;
|
||||
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; }
|
||||
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); }
|
||||
}
|
||||
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function interactAt(x, y) {
|
||||
return open ? 'Rack open.' : 'Rack closed.';
|
||||
}
|
||||
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
|
||||
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
|
||||
blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' });
|
||||
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
|
||||
}
|
||||
const id = mach[y * W + x];
|
||||
|
||||
@@ -2,42 +2,90 @@
|
||||
// ---------- Blocks ----------
|
||||
function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; }
|
||||
function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; }
|
||||
function pieceCost(code, v) { return code === BP_WALL ? wallCost(v, BS * BS) : PIECE_BY_CODE[code].cost; }
|
||||
function canPlaceBlock(bx, by) {
|
||||
function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; }
|
||||
function setSlopeSeal(i, code, v) {
|
||||
if (isFilter(code)) { part[i] = P_FILTER; wakeIdx(i); }
|
||||
else {
|
||||
setCell(i, isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v], AMB[(i / W) | 0], 0);
|
||||
if (isBelt(code)) markPow(i, true);
|
||||
}
|
||||
}
|
||||
// One extra cell on the solid side seals diagonal crossings between successive triangles.
|
||||
function slopeSeal(code, bx, by) {
|
||||
code = slopeShape(code);
|
||||
const x = bx * BS + (code === BP_SLOPE_BR || code === BP_SLOPE_TR ? 0 : 3);
|
||||
const y = by * BS + (code >= BP_SLOPE_TL ? -1 : 4);
|
||||
return y < 0 || y >= H ? -1 : y * W + x;
|
||||
}
|
||||
function slopeSealOwner(i) {
|
||||
const x = i % W, y = (i / W) | 0, bx = x >> 2;
|
||||
if (((x & 3) !== 0 && (x & 3) !== 3) || ((y & 3) !== 0 && (y & 3) !== 3)) return -1;
|
||||
for (let by = (y >> 2) - 1; by <= (y >> 2) + 1; by += 2) {
|
||||
if (by < 0 || by >= BH) continue;
|
||||
const b = by * BW + bx, c = blockP[b];
|
||||
if ((isSlope(c) || isSlopeBelt(c)) && slopeSeal(c, bx, by) === i) return b;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// Does block b's own piece fill cell i (with a part, or else with a material)? A slope's seal can
|
||||
// sit in a cell its neighbour leaves open: a slope's open corner, or under a grate, screw or screen.
|
||||
function blockOwnsCell(b, i, asPart) {
|
||||
const c = blockP[b];
|
||||
if (!c || ((isSlope(c) || isSlopeBelt(c)) && !slopeCell(c, (i % W) & 3, ((i / W) | 0) & 3))) return false;
|
||||
return asPart ? isFilter(c) : !(isFilter(c) || c === BP_GRATE || c === BP_SCREW || c === BP_PIPE);
|
||||
}
|
||||
// Belt and screen cells that a removed slope left behind in an empty block (a removal bug in
|
||||
// older worlds): nothing owns them, so nothing could build there or take them away.
|
||||
function clearStraySeals() {
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (mat[i] !== BELTL && mat[i] !== BELTR && part[i] !== P_FILTER) continue;
|
||||
if (blockP[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] || slopeSealOwner(i) >= 0) continue;
|
||||
if (part[i] === P_FILTER) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); markPow(i, false); }
|
||||
}
|
||||
}
|
||||
function wallCells(code, bx, by) {
|
||||
return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3));
|
||||
}
|
||||
function canPlaceBlock(bx, by, code = BP_WALL) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
|
||||
if (blockP[by * BW + bx]) return 'Something is already built there';
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
if (mach[i] || part[i]) return 'Something is already built there';
|
||||
if (PH[mat[i]] === PH_SOLID) return 'Needs open space; clear the rock first.';
|
||||
if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there';
|
||||
if (PH[mat[i]] === PH_SOLID && ((flags[i] & F_NAT) || slopeSealOwner(i) < 0)) return 'Needs open space; clear the rock first.';
|
||||
if (occ[i]) return 'A bar is in the way';
|
||||
}
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0 && PH[mat[i]] !== PH_SOLID && (mach[i] || part[i] || occ[i])) return 'The slope corner is blocked.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// v: wall material (loose id) for walls, direction for belts and tubes
|
||||
// v: wall material (loose id) for walls, direction for belts and screws
|
||||
function placeBlock(code, v, bx, by, free, extra) {
|
||||
const err = canPlaceBlock(bx, by); if (err) return err;
|
||||
if (code === BP_WALL && !WALL_OF[v]) return 'Pick a wall material with R';
|
||||
const err = canPlaceBlock(bx, by, code); if (err) return err;
|
||||
if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R';
|
||||
const cost = pieceCost(code, v);
|
||||
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
|
||||
const cells = blockCellList(bx, by);
|
||||
const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
|
||||
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
|
||||
for (const i of cells) {
|
||||
const y = (i / W) | 0, x = i % W;
|
||||
if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0);
|
||||
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
|
||||
if (isWall(code)) setCell(i, WALL_OF[v], AMB[y], 0);
|
||||
else if (isBelt(code)) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
|
||||
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
|
||||
else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0);
|
||||
else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0);
|
||||
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
|
||||
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
|
||||
else if (code === BP_TUBE) { part[i] = [P_TU, P_TR, P_TD, P_TL][v & 3]; markPow(i, true); wakeXY(x, y); }
|
||||
else if (code === BP_FILTER) { part[i] = P_FILTER; wakeXY(x, y); }
|
||||
else if (code === BP_SCREW) { part[i] = SCREW_PARTS[v & 7]; markPow(i, true); wakeXY(x, y); }
|
||||
else if (isFilter(code)) { part[i] = P_FILTER; wakeXY(x, y); }
|
||||
else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
|
||||
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
|
||||
}
|
||||
if (isPipeCode(code)) pipesDirty = true;
|
||||
renderDirtyXY(bx * BS, by * BS);
|
||||
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
|
||||
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||
if (isProcCode(code)) procDirty = true;
|
||||
if (code === BP_RACK) {
|
||||
const b = by * BW + bx, s = newRack();
|
||||
@@ -45,6 +93,10 @@ function placeBlock(code, v, bx, by, free, extra) {
|
||||
rackState.set(b, s);
|
||||
for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
|
||||
}
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0 && PH[mat[i]] !== PH_SOLID && !part[i]) { evictCells([i], by * BS); setSlopeSeal(i, code, v); }
|
||||
}
|
||||
stats.placed++;
|
||||
undoNote({ t: 'block+', bx, by, code });
|
||||
return '';
|
||||
@@ -57,15 +109,15 @@ function blockIntegrity(b) {
|
||||
let n = 0;
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
const m = mat[i], p = part[i];
|
||||
if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
|
||||
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
|
||||
if (isWall(code) && (!isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3))) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
|
||||
else if (isBelt(code)) { if (m === BELTL || m === BELTR) n++; }
|
||||
else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
|
||||
else if (code === BP_RACK) { if (m === RACKB) n++; }
|
||||
else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; }
|
||||
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
|
||||
else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
|
||||
else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; }
|
||||
else if (code === BP_FILTER) { if (p === P_FILTER) n++; }
|
||||
else if (code === BP_SCREW) { if (IS_SCREW[p]) n++; }
|
||||
else if (isFilter(code)) { if (p === P_FILTER) n++; }
|
||||
else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
|
||||
}
|
||||
void v;
|
||||
@@ -83,44 +135,73 @@ function removeWallCell(i, wall) {
|
||||
function removeBlock(bx, by) {
|
||||
const b = by * BW + bx, code = blockP[b], v = blockV[b];
|
||||
if (!code) return 0;
|
||||
undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined });
|
||||
undoNote({ t: 'block-', bx, by, code, v, extra: isFilter(code) ? filterInfo(b) : undefined });
|
||||
// a pipe taken out spills its share of what the line holds
|
||||
const spill = isPipeCode(code) ? pipeTakeShare(b) : null;
|
||||
let n = 0, metal = 0;
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
const m = mat[i], p = part[i];
|
||||
if (code === BP_WALL) {
|
||||
if (isWall(code)) {
|
||||
if (isSlope(code) && !slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)) continue;
|
||||
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
|
||||
metal += removeWallCell(i, v);
|
||||
if (isSlope(code)) setCell(i, EMPTY, temp[i], 0);
|
||||
else metal += removeWallCell(i, v);
|
||||
n++;
|
||||
}
|
||||
} else if (isPipeCode(code)) {
|
||||
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
|
||||
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
|
||||
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
|
||||
} else if (isBelt(code) || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
|
||||
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
|
||||
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
|
||||
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
|
||||
}
|
||||
if (isSlope(code)) {
|
||||
const cells = Math.floor(n * 8 / 10), refund = PH[v] === PH_ITEM ? Math.floor(cells / GRAINS_PER_BAR) : cells;
|
||||
giveOrDrop(v, refund, bx * BS + 2, by * BS + 2);
|
||||
|
||||
}
|
||||
if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2);
|
||||
else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
|
||||
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
|
||||
else if (!isWall(code) && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
|
||||
if (isFilter(code)) { filtersDirty = true; filterCfg.delete(b); }
|
||||
if (isProcCode(code)) { procDirty = true; procState.delete(b); }
|
||||
if (code === BP_RACK) {
|
||||
const s = rackState.get(b);
|
||||
rackState.delete(b);
|
||||
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
|
||||
}
|
||||
renderDirtyXY(bx * BS, by * BS);
|
||||
blockP[b] = 0; blockV[b] = 0;
|
||||
const cleared = blockCellList(bx, by);
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0) {
|
||||
const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2);
|
||||
if (!blockOwnsCell(nb, i, isFilter(code)) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } }
|
||||
cleared.push(i);
|
||||
}
|
||||
}
|
||||
for (const i of cleared) {
|
||||
const owner = slopeSealOwner(i);
|
||||
if (owner >= 0 && mat[i] === EMPTY && !part[i]) {
|
||||
setSlopeSeal(i, blockP[owner], blockV[owner]);
|
||||
}
|
||||
}
|
||||
if (isPipeCode(code)) {
|
||||
pipesDirty = true;
|
||||
if (spill) { let k = spill.n; for (let y = by * BS + BS - 1; y >= by * BS && k > 0; y--) for (let x = bx * BS; x < bx * BS + BS && k > 0; x++) { const i = y * W + x; if (mat[i] === EMPTY) { setCell(i, spill.m, spill.T, 0); k--; } } }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// Straight line of blocks along the dominant axis
|
||||
function blockLine(bx0, by0, bx1, by1) {
|
||||
// Wall drags near 45° snap to a diagonal; other lines follow the dominant axis.
|
||||
function diagonalDrag(dx, dy) { return dx !== 0 && dy !== 0 && Math.min(Math.abs(dx), Math.abs(dy)) >= Math.max(Math.abs(dx), Math.abs(dy)) * 0.7; }
|
||||
function blockLine(bx0, by0, bx1, by1, diagonal = false) {
|
||||
const out = [];
|
||||
const dx = bx1 - bx0, dy = by1 - by0;
|
||||
if (diagonal && diagonalDrag(dx, dy)) {
|
||||
for (let k = 0; k <= Math.max(Math.abs(dx), Math.abs(dy)); k++) out.push([bx0 + k * Math.sign(dx), by0 + k * Math.sign(dy)]);
|
||||
return out;
|
||||
}
|
||||
if (Math.abs(bx1 - bx0) >= Math.abs(by1 - by0)) { const s = bx1 >= bx0 ? 1 : -1; for (let x = bx0; ; x += s) { out.push([x, by0]); if (x === bx1) break; } }
|
||||
else { const s = by1 >= by0 ? 1 : -1; for (let y = by0; ; y += s) { out.push([bx0, y]); if (y === by1) break; } }
|
||||
return out;
|
||||
@@ -130,11 +211,11 @@ function blockRect(bx0, by0, bx1, by1) {
|
||||
for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]);
|
||||
return out;
|
||||
}
|
||||
function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS / 2); }
|
||||
function blockInReach(bx, by) { return inBuildRange(bx * BS + BS / 2, by * BS + BS / 2); }
|
||||
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
|
||||
function blockIs(b, code, v, extra) {
|
||||
if (blockP[b] !== code) return false;
|
||||
if (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
|
||||
if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
|
||||
return blockV[b] === v;
|
||||
}
|
||||
// replace: a block already built in the way is taken down (and refunded) to make room
|
||||
@@ -145,7 +226,7 @@ function placeBlocks(code, v, list, extra, replace) {
|
||||
const b = by * BW + bx;
|
||||
if (replace && blockP[b]) {
|
||||
if (blockIs(b, code, v, extra)) continue;
|
||||
if (blockP[b] === BP_FILTER && code === BP_FILTER) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
|
||||
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
|
||||
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
|
||||
removeBlock(bx, by);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,9 @@ function setCell(i, m, t, fl) {
|
||||
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
|
||||
}
|
||||
function swapCells(i, j) {
|
||||
renderDirtyIdx(i); renderDirtyIdx(j); swapCellData(i, j);
|
||||
}
|
||||
function swapCellData(i, j) {
|
||||
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
|
||||
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
|
||||
a = life[i]; life[i] = life[j]; life[j] = a;
|
||||
@@ -19,16 +22,16 @@ function swapCells(i, j) {
|
||||
stamp[i] = tickStamp; stamp[j] = tickStamp;
|
||||
}
|
||||
function move(i, j, x, y, nx, ny) {
|
||||
swapCells(i, j);
|
||||
swapCellData(i, j);
|
||||
wakeXY(x, y); wakeXY(nx, ny);
|
||||
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) {
|
||||
// a top-layer grain stepping sideways may clear the path to a drop for surface grains far
|
||||
// along the row, so wake them; moves inside a body of water do not (settled lakes must sleep)
|
||||
const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW;
|
||||
const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5;
|
||||
for (let cx = first; cx <= last; cx++) awake[row + cx] = 3;
|
||||
for (let cx = first; cx <= last; cx++) chunkWake(row + cx);
|
||||
}
|
||||
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
|
||||
if (part[j] === P_WHEEL) { if (chunkWork) chunkEvent('flow', mach[j], PH[mat[j]] === PH_LIQUID ? 1 : 0.5); else { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } }
|
||||
const d = temp[j] - AMB[ny];
|
||||
if (d > 25 || d < -25) wakeHeatXY(nx, ny);
|
||||
}
|
||||
@@ -36,10 +39,15 @@ function move(i, j, x, y, nx, ny) {
|
||||
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
|
||||
// 0 = no, 1 = into empty, 2 = by displacing what is there.
|
||||
function enter(m, ph, j, dx, dy) {
|
||||
if (ph === PH_POWDER && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W]))) return 0;
|
||||
return enterFree(m, ph, j, dx, dy);
|
||||
}
|
||||
// Powder already knows whether its chunk has barriers; other callers use enter.
|
||||
function enterFree(m, ph, j, dx, dy) {
|
||||
if (occ[j]) return 0;
|
||||
const p = part[j];
|
||||
if (p !== 0) {
|
||||
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return 0; } // liquids and gases flow through freely
|
||||
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return 0; } // liquids and gases flow through freely
|
||||
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return 0; } // thick slag does not run through a grate
|
||||
else if (p === P_INTAKE) { if (ph !== PH_GAS) return 0; }
|
||||
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return 0; }
|
||||
@@ -67,24 +75,37 @@ let SORTP = 0.06;
|
||||
const SORTP_FUEL = 0.003;
|
||||
// Liquids step up to LSTEP cells toward a drop in one tick (fewer the thicker they are), and a top-layer grain looks LFILM
|
||||
// times as far for one (a two-times range strands the far end of a 200-grain-wide ledge).
|
||||
// World generation settles with both at 1, so every seed keeps making the same world.
|
||||
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
|
||||
let LSTEP = 8, LFILM = 4;
|
||||
function powder(i, x, y, m) {
|
||||
let CORNER_SEAL = true;
|
||||
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
|
||||
if (y >= H - 1) return;
|
||||
const b = i + W;
|
||||
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
|
||||
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
|
||||
const t = mat[b];
|
||||
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
|
||||
if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); chunkWake((y >> 5) * CW + (x >> 5), 40); return; }
|
||||
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
|
||||
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
|
||||
if (rnd() < STICK[m]) {
|
||||
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
|
||||
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
|
||||
// Feed a receiving screw mouth sideways instead of falling under its bottom casing.
|
||||
if (t === BELTL || t === BELTR) {
|
||||
const b = ((y + 1) >> 2) * BW + (x >> 2);
|
||||
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
|
||||
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
|
||||
if ((!barriers || !cleatMask[i + d]) && enterFree(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// stickiness is grain against grain: on anything built (a wall, a slope, a belt) every material
|
||||
// slides freely, so a chute carries clay as well as sand while clay heaps stay steep
|
||||
if (rnd() < STICK[m] || (PH[t] === PH_SOLID && !(flags[b] & F_NAT))) {
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b + d])) && enterFree(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b - d])) && enterFree(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
}
|
||||
}
|
||||
function passable(m, ph, j, dx, dy) {
|
||||
@@ -92,7 +113,7 @@ function passable(m, ph, j, dx, dy) {
|
||||
if (occ[j]) return false;
|
||||
const p = part[j];
|
||||
if (p !== 0) {
|
||||
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return false; }
|
||||
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return false; }
|
||||
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return false; }
|
||||
else if (p === P_INTAKE) { if (ph !== PH_GAS) return false; }
|
||||
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return false; }
|
||||
@@ -106,18 +127,25 @@ function liquid(i, x, y, m) {
|
||||
if (enter(m, PH_LIQUID, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
}
|
||||
if (rnd() > FLUID[m]) return;
|
||||
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
|
||||
const d0 = (RS & 4) ? 1 : -1;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
liquidSearch(i, x, y, m, sp, d0);
|
||||
}
|
||||
function liquidSearch(i, x, y, m, sp, d0, firstPass = 0, firstStep = 1) {
|
||||
for (let pass = firstPass; pass < 2; pass++) {
|
||||
const d = pass ? -d0 : d0;
|
||||
for (let s = 1; s <= sp; s++) {
|
||||
for (let s = pass === firstPass ? firstStep : 1; s <= sp; s++) {
|
||||
const nx = x + d * s;
|
||||
if (nx < 0 || nx >= W) break;
|
||||
// Same-colour chunks are separated by 32 cells; their maximum write halo is 8.
|
||||
if (chunkWork && (nx < chunkWork.x0 - 24 || nx >= chunkWork.x1 + 24)) {
|
||||
chunkEvent('liquid', i, m, sp, d0, pass, s); return;
|
||||
}
|
||||
const j = i + d * s;
|
||||
if (!passable(m, PH_LIQUID, j, d, 0)) break;
|
||||
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
|
||||
@@ -130,7 +158,7 @@ function liquid(i, x, y, m) {
|
||||
}
|
||||
function gas(i, x, y, m) {
|
||||
// Even blocked gas must keep aging or condensing until it is gone.
|
||||
awake[(y >> 5) * CW + (x >> 5)] = 3;
|
||||
chunkWake((y >> 5) * CW + (x >> 5));
|
||||
if (m === FIRE) {
|
||||
const l = life[i];
|
||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||
@@ -153,9 +181,9 @@ function gas(i, x, y, m) {
|
||||
if (enter(m, PH_GAS, u, 0, -1)) { move(i, u, x, y, x, y - 1); return; }
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
|
||||
if (rnd() < 0.6) {
|
||||
nx = x + d;
|
||||
if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); }
|
||||
@@ -173,35 +201,23 @@ function wakeBotPowder() {
|
||||
if (dx * dx + dy * dy <= 10000) awake[c] = 1;
|
||||
}
|
||||
}
|
||||
function simMove() {
|
||||
for (let cy = CH - 1; cy >= 0; cy--) {
|
||||
const rowBase = cy * CW;
|
||||
let any = false;
|
||||
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; }
|
||||
if (!any) continue;
|
||||
const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
|
||||
function simMove() { chunkPasses(false); }
|
||||
function moveChunk(c) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
|
||||
const barriers = cleatChunks[c];
|
||||
powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
|
||||
for (let y = y1 - 1; y >= y0; y--) {
|
||||
const ltr = ((y + tick) & 1) === 0;
|
||||
const yw = y * W;
|
||||
for (let k = 0; k < CW; k++) {
|
||||
const cx = ltr ? k : CW - 1 - k;
|
||||
if (!awake[rowBase + cx]) continue;
|
||||
roofDirty[cx] = 1;
|
||||
const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
|
||||
// alternate rows scan left to right and right to left, so nothing drifts one way
|
||||
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
|
||||
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
|
||||
const ltr = ((y + tick) & 1) === 0, dx = ltr ? 1 : -1, end = ltr ? x1 : x0 - 1, yw = y * W;
|
||||
for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
|
||||
const i = yw + x, m = mat[i];
|
||||
if (m === EMPTY) continue;
|
||||
const ph = PH[m];
|
||||
if (ph === PH_POWDER) powderArea[rowBase + cx] = 1;
|
||||
if (stamp[i] === tickStamp) continue;
|
||||
if (ph === PH_SOLID) continue;
|
||||
const p = part[i];
|
||||
if (p >= P_TU && p <= P_TR && ph === PH_POWDER) continue; // a screw tube moves its own grains
|
||||
if (ph === PH_POWDER) powder(i, x, y, m); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
|
||||
}
|
||||
}
|
||||
if (ph === PH_POWDER) powderArea[c] = 1;
|
||||
if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
|
||||
if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
|
||||
if (ph === PH_POWDER) powder(i, x, y, m, barriers);
|
||||
else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
// How belts, filters and screw tubes move what is on or in them.
|
||||
// How belts, filters and screw conveyors move what is on or in them.
|
||||
// What rests on a belt or filter rides along: the whole stack standing on the carrying surface
|
||||
// steps one cell toward d together (belts run front of the line first, so the way ahead is
|
||||
// clear). If a powder grain is blocked, the load above it stands still and the belt slips
|
||||
// underneath. A filter's own material drops through it instead, unless it is already full.
|
||||
const CARRY_DEPTH = 96;
|
||||
// Find the next carrying face at the same height or one grain above/below it.
|
||||
function conveyorStep(x, y, d) {
|
||||
const nx = x + d;
|
||||
if (nx < 0 || nx >= W) return 0;
|
||||
if ((x >> 2) === (nx >> 2)) return beltGrade(blockP[(y >> 2) * BW + (x >> 2)]) * d;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const yy = y + dy;
|
||||
if (yy < 0 || yy >= H) continue;
|
||||
const b = (yy >> 2) * BW + (nx >> 2), c = blockP[b], i = yy * W + nx;
|
||||
if ((isBelt(c) || isFilter(c)) && beltFaceY(c, nx >> 2, yy >> 2, nx) === yy &&
|
||||
(mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER)) return dy;
|
||||
}
|
||||
const b = (y >> 2) * BW + (x >> 2);
|
||||
return beltGrade(blockP[b]) * d;
|
||||
}
|
||||
function carryColumn(a, d, pass) {
|
||||
if (a < 0 || mat[a] === EMPTY) return;
|
||||
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d), carrier = (sy >> 2) * BW + ((a % W) >> 2);
|
||||
let blocked = false;
|
||||
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
|
||||
if (a < 0) return;
|
||||
@@ -20,18 +37,21 @@ function carryColumn(a, d, pass) {
|
||||
if (blocked) continue; // filters still sift above a stopped load
|
||||
const x = a % W, nx = x + d;
|
||||
if (nx < 0 || nx >= W) return;
|
||||
const j = a + d, tj = mat[j];
|
||||
const j = a + d + step * W;
|
||||
if (j < 0 || j >= N) return;
|
||||
const tj = mat[j];
|
||||
// grains on a wet belt push through the water rather than stopping at it
|
||||
const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN);
|
||||
if (!wet && !enter(m, PH_POWDER, j, d, 0)) {
|
||||
if (!wet && !enter(m, PH_POWDER, j, d, step)) {
|
||||
if (!pass) return;
|
||||
blocked = true; continue;
|
||||
}
|
||||
const y = (a / W) | 0, bj = j + W;
|
||||
// carried off the end with nothing under it: it leaves at belt speed and falls
|
||||
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0);
|
||||
else move(a, j, x, y, nx, y);
|
||||
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5);
|
||||
else move(a, j, x, y, nx, y + step);
|
||||
stats.moved++;
|
||||
if (flowOn && (x >> 2) !== (nx >> 2)) flowNote(carrier);
|
||||
}
|
||||
}
|
||||
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
|
||||
@@ -43,7 +63,7 @@ function beltDrain(b) {
|
||||
if (!passable(mat[a], PH_LIQUID, d, 0, 1)) return;
|
||||
move(a, d, x, (a / W) | 0, x, (d / W) | 0);
|
||||
}
|
||||
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a tube mouth
|
||||
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a screw mouth
|
||||
function launchGrain(i, x, y, vx, vy) {
|
||||
const m = mat[i], T = temp[i];
|
||||
mat[i] = EMPTY; flags[i] = 0; life[i] = 0; stamp[i] = tickStamp;
|
||||
@@ -53,23 +73,49 @@ function launchGrain(i, x, y, vx, vy) {
|
||||
let filterR = [], filterL = [], filtersDirty = true;
|
||||
function rebuildFilters() {
|
||||
filtersDirty = false; filterR = []; filterL = [];
|
||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b);
|
||||
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) (blockV[b] & 128 ? filterR : filterL).push(b);
|
||||
// front of the line first, so a full run moves as one
|
||||
filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW));
|
||||
}
|
||||
function filterCarry(b, d) {
|
||||
if (blockP[b] !== BP_FILTER) return;
|
||||
const bx = b % BW, y = ((b / BW) | 0) * BS;
|
||||
function filterCarry(b, d, carrying = true) {
|
||||
if (!isFilter(blockP[b])) return;
|
||||
const bx = b % BW, by = (b / BW) | 0, code = blockP[b], y = by * BS;
|
||||
if (y < 1) return;
|
||||
if (!carrying && !isSlopeBelt(code)) return;
|
||||
const c = filterCfg.get(b), pass = c ? c.pass : null;
|
||||
for (let k = 0; k < BS; k++) {
|
||||
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x;
|
||||
if (part[top] === P_FILTER) carryColumn(top - W, d, pass);
|
||||
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
|
||||
if (part[top] === P_FILTER) {
|
||||
if (isSlopeBelt(code)) screenNudge(top, d, pass);
|
||||
else if (carrying) carryColumn(top - W, d, pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Belts and filters share one front-first order, including their flat/slope joins.
|
||||
function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
|
||||
let k = 0, f = 0;
|
||||
while (k < belts.length || f < filters.length) {
|
||||
const fx = f < filters.length ? (filters[f] % BW) * BS + (d > 0 ? BS - 1 : 0) : 0;
|
||||
if (f < filters.length && (k >= belts.length || (fx - belts[k] % W) * d > 0)) { filterCarry(filters[f++], d, carrying); continue; }
|
||||
const b = belts[k++];
|
||||
if (mat[b] !== bm || b < W) continue;
|
||||
const x = b % W, y = (b / W) | 0, code = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
if (!carrying && !isSlopeBelt(code)) continue;
|
||||
if (isSlopeBelt(code) && beltFaceY(code, x >> 2, y >> 2, x) !== y) continue;
|
||||
if (slopeSealOwner(b) >= 0) continue;
|
||||
const above = mat[b - W];
|
||||
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
|
||||
if (carrying) {
|
||||
if (PH[above] === PH_LIQUID) beltDrain(b);
|
||||
// uphill, the cleats do the carrying
|
||||
if (!isSlopeBelt(code) || !slopeUphill(code, blockV[(y >> 2) * BW + (x >> 2)])) carryColumn(b - W, d, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
|
||||
function setFilter(b, cfg) {
|
||||
if (blockP[b] !== BP_FILTER) return false;
|
||||
renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS);
|
||||
if (!isFilter(blockP[b])) return false;
|
||||
const rec = filterRec(cfg);
|
||||
filterCfg.set(b, rec);
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
@@ -79,11 +125,13 @@ function setFilter(b, cfg) {
|
||||
}
|
||||
// The run of filters in a row that touch block b and do the same thing it does
|
||||
function filterRun(b) {
|
||||
if (blockP[b] !== BP_FILTER) return [];
|
||||
if (!isFilter(blockP[b])) return [];
|
||||
const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b];
|
||||
for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) {
|
||||
const c = by * BW + bx;
|
||||
if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
|
||||
const yy = by + beltGrade(blockP[b]) * (bx - (b % BW));
|
||||
if (yy < 0 || yy >= BH) break;
|
||||
const c = yy * BW + bx;
|
||||
if (blockP[c] !== blockP[b] || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
|
||||
out.push(c);
|
||||
}
|
||||
return out.sort((a, c) => a - c);
|
||||
@@ -91,8 +139,8 @@ function filterRun(b) {
|
||||
|
||||
// A cell a grain can be pushed into: empty, or liquid or gas that simply trades places with it
|
||||
function roomFor(j) { const t = mat[j]; return t === EMPTY || PH[t] === PH_GAS || PH[t] === PH_LIQUID; }
|
||||
// Cross one tube block, far lanes first; corners retain their liquid/gas swaps.
|
||||
function tubeSpread(c, x, y, sx, sy, intake = false) {
|
||||
// Cross one screw block, far lanes first; corners retain their liquid/gas swaps.
|
||||
function screwSpread(c, x, y, sx, sy, intake = false) {
|
||||
const p = part[c], bx = x >> 2, by = y >> 2;
|
||||
for (;;) {
|
||||
const nx = x + sx, ny = y + sy;
|
||||
@@ -104,25 +152,62 @@ function tubeSpread(c, x, y, sx, sy, intake = false) {
|
||||
return c;
|
||||
}
|
||||
// An empty screw cell draws in loose material from behind it or from its sides
|
||||
// (not from the cell it pushes into), so a tube end sitting in a pile feeds itself.
|
||||
function tubeIntake(i, p, dx, dy) {
|
||||
// (not from the cell it pushes into), so a screw end sitting in a pile feeds itself.
|
||||
function screwIntake(i, p, dx, dy) {
|
||||
if (occ[i]) return;
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]];
|
||||
const start = (tick + i) % 3;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const [ox, oy] = cands[(start + c) % 3];
|
||||
const k = (start + c) % 3, diagonal = dx && dy;
|
||||
const ox = diagonal ? (k === 1 ? 0 : -dx) : k === 0 ? -dx : k === 1 ? dy : -dy;
|
||||
const oy = diagonal ? (k === 0 ? 0 : -dy) : k === 0 ? -dy : k === 1 ? dx : -dx;
|
||||
const nx = x + ox, ny = y + oy;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
if (ox && oy && PH[mat[y * W + nx]] === PH_SOLID && PH[mat[ny * W + x]] === PH_SOLID) continue;
|
||||
const j = ny * W + nx, m = mat[j];
|
||||
if (m === EMPTY || occ[j] || stamp[j] === tickStamp) continue;
|
||||
if (PH[m] !== PH_POWDER) continue; // the screw only takes loose material; liquids just flow through
|
||||
if (stamp[i] === tickStamp) return;
|
||||
const pj = part[j];
|
||||
if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents
|
||||
if (IS_SCREW[pj]) continue; // other screw cells move their own contents
|
||||
if (pj === P_GRATE || pj === P_FILTER) continue;
|
||||
move(j, i, nx, ny, x, y);
|
||||
if (ox !== -dx || oy !== -dy) tubeSpread(i, x, y, -ox, -oy, true);
|
||||
if (diagonal) screwSpread(i, x, y, 0, (y & 3) < 2 ? 1 : -1, true);
|
||||
else if (ox !== -dx || oy !== -dy) screwSpread(i, x, y, -ox, -oy, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Powder may enter any screw except one pushing exactly against it.
|
||||
function screwAccepts(p, dx, dy) {
|
||||
return SCREW_DX[p] !== -dx || SCREW_DY[p] !== -dy;
|
||||
}
|
||||
// Straight mouths retain side-feeding into lifts; only diagonal transfers need a join.
|
||||
function straightScrewNext(i, p) {
|
||||
const dx = SCREW_DX[p], dy = SCREW_DY[p], x = i % W, y = (i / W) | 0;
|
||||
const xx = x + dx, yy = y + dy, next = yy * W + xx;
|
||||
if ((xx >> 2) === (x >> 2) && (yy >> 2) === (y >> 2)) return next;
|
||||
const q = part[next];
|
||||
if (!(SCREW_DX[q] && SCREW_DY[q])) return next;
|
||||
const join = endJoin(x >> 2, y >> 2, dx + dy > 0 ? 1 : 0);
|
||||
return join >= 0 && (join >> 1) === (yy >> 2) * BW + (xx >> 2) ? next : -1;
|
||||
}
|
||||
// Four lanes cross a block in four beats; only a mutual geometric join can receive them.
|
||||
function diagonalScrewNext(i, p) {
|
||||
const x = i % W, y = (i / W) | 0, dx = SCREW_DX[p], dy = SCREW_DY[p];
|
||||
const xx = x + dx;
|
||||
if (xx < 0 || xx >= W) return -1;
|
||||
if ((xx >> 2) === (x >> 2)) return i + dx;
|
||||
const yy = y + dy * BS, join = endJoin(x >> 2, y >> 2, dx > 0 ? 1 : 0);
|
||||
if (join >= 0) {
|
||||
const nb = join >> 1, nx = nb % BW * BS, ny = ((nb / BW) | 0) * BS;
|
||||
if (blockP[nb] === BP_BELT || blockP[nb] === BP_FILTER) return (ny - 1) * W + xx;
|
||||
if (blockP[nb] === BP_SCREW) {
|
||||
const v = blockV[nb];
|
||||
if (v >= 4) return yy * W + xx;
|
||||
if (v & 1) return (ny + (y & 3)) * W + xx;
|
||||
return (ny + (dy > 0 ? 0 : 3)) * W + nx + (y & 3);
|
||||
}
|
||||
}
|
||||
return yy >= 0 && yy < H && !IS_SCREW[part[yy * W + xx]] ? yy * W + xx : -1;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function pulseRay(ox, oy, ang, Wd, f) {
|
||||
}
|
||||
// Is a solid cell something the player built (or a part of a machine or mold)? Pulses leave those alone.
|
||||
function builtCell(i, x, y) {
|
||||
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
|
||||
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || slopeSealOwner(i) >= 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
|
||||
}
|
||||
// One press-and-hold step: fires a pulse when one is due. Returns { fired, status }.
|
||||
function toolPulse(ax, ay, width, tune) {
|
||||
@@ -43,7 +43,7 @@ function toolPulse(ax, ay, width, tune) {
|
||||
// every cell in the cone is hit, with no untouched stripes between the rays
|
||||
drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 });
|
||||
lastPulseTick = tick;
|
||||
// say why nothing breaks: the first solid on the centre line is too hard, or out of reach
|
||||
// say why nothing breaks: the first solid on the centre line is too hard
|
||||
let status = '';
|
||||
for (let k = 3; k < reach; k++) {
|
||||
const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k);
|
||||
@@ -57,7 +57,6 @@ function toolPulse(ax, ay, width, tune) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!status && d > reach) status = `Out of reach; a ${Wd.name} pulse reaches ${reach} grains.`;
|
||||
return { fired: true, status };
|
||||
}
|
||||
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
|
||||
@@ -87,7 +86,8 @@ function crumbleAround(x, y) {
|
||||
for (const j of got) shatterCell(j, j % W, (j / W) | 0, mat[j]);
|
||||
}
|
||||
}
|
||||
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && (flags[i] & F_NAT) !== 0 && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
|
||||
// Any solid nothing owns can be drilled: natural rock, and rock or metal that set loose in the world.
|
||||
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
|
||||
function shatterCell(i, x, y, m) {
|
||||
const to = YLD[m];
|
||||
setCell(i, to, temp[i], 0);
|
||||
@@ -151,7 +151,7 @@ function pulseTick() {
|
||||
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
|
||||
if (h > tier.hard) { e *= 0.35; continue; }
|
||||
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
|
||||
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q;
|
||||
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
|
||||
e *= p.tune ? 0.96 : p.damp;
|
||||
}
|
||||
p.en[k] = e; p.s[k] = sd;
|
||||
@@ -159,3 +159,19 @@ function pulseTick() {
|
||||
}
|
||||
drillPulses = drillPulses.filter(p => p.r < p.range + 3);
|
||||
}
|
||||
// Resonance ping (t50): an echo from the bot that finds ore veins in the rock around it, under
|
||||
// fog too. Reach grows with the bit. Only reads the world; the UI draws the echo.
|
||||
const PING_VEINS = new Uint8Array(NMAT); for (const m of [COALV, CUV, FEV, SNV, ZNV, AGV, AUV]) PING_VEINS[m] = 1;
|
||||
function pingRadius() { return BIT_TIERS[player.bitTier].reach * 3; }
|
||||
// Natural vein cells within r of (cx, cy), and the materials found.
|
||||
function resonancePing(cx, cy, r = pingRadius()) {
|
||||
const cells = [], found = new Set(), r2 = r * r;
|
||||
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(W - 1, Math.ceil(cx + r));
|
||||
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
|
||||
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx, dy = y - cy, i = y * W + x, m = mat[i];
|
||||
if (dx * dx + dy * dy > r2 || !PING_VEINS[m] || !(flags[i] & F_NAT)) continue;
|
||||
cells.push(i); found.add(m);
|
||||
}
|
||||
return { cells, found: [...found], r, x: cx, y: cy };
|
||||
}
|
||||
|
||||
@@ -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,11 +3,11 @@ const FOG = 2;
|
||||
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
|
||||
const FOG_SENSE_FADE = 12;
|
||||
let fog, FW, FH;
|
||||
function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = new Uint8Array(FW * FH); }
|
||||
function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = sharedArray(Uint8Array, FW * FH); }
|
||||
function fogMark(x, y, v) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return;
|
||||
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
|
||||
if (v > fog[i]) fog[i] = v;
|
||||
if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); }
|
||||
}
|
||||
function fogSeen(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
|
||||
@@ -39,7 +39,7 @@ function fogLook(cx, cy) {
|
||||
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
|
||||
if (d2 >= R2) continue;
|
||||
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
|
||||
if (v > fog[i]) fog[i] = v;
|
||||
if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); }
|
||||
}
|
||||
}
|
||||
function fogRevealAround(x, y, r) {
|
||||
@@ -49,4 +49,4 @@ function fogRevealAround(x, y, r) {
|
||||
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
|
||||
}
|
||||
}
|
||||
function fogRevealAll() { fog.fill(255); }
|
||||
function fogRevealAll() { fog.fill(255); renderDirty.fill(1); }
|
||||
|
||||
@@ -52,19 +52,17 @@ function condPair(a, b, sc, nc, da) {
|
||||
const q = d * k * sc;
|
||||
temp[a] -= q * ICAP[ma];
|
||||
temp[b] += q * ICAP[mb];
|
||||
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; }
|
||||
}
|
||||
function heatPass() {
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
if (!hot[c]) continue;
|
||||
let sc = 1;
|
||||
if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
|
||||
heatChunk(c, sc);
|
||||
}
|
||||
if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
|
||||
}
|
||||
function heatPass() { chunkPasses(true); }
|
||||
function heatChunk(c, sc) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||
renderDirty[c] = 1;
|
||||
if (cx > 0) renderDirty[c - 1] = 1;
|
||||
if (cx + 1 < CW) renderDirty[c + 1] = 1;
|
||||
if (cy > 0) renderDirty[c - CW] = 1;
|
||||
if (cy + 1 < CH) renderDirty[c + CW] = 1;
|
||||
roofDirty[cx] = 1;
|
||||
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
|
||||
let maxDev = 0, burning = false;
|
||||
@@ -88,7 +86,7 @@ function heatChunk(c, sc) {
|
||||
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
|
||||
const a = ex * (sc > 1 ? 0.45 : 0.25);
|
||||
t -= a; temp[u] += a;
|
||||
if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; }
|
||||
if (y === y0 && upCold && a > 0.5) { chunkHeat(c - CW); }
|
||||
}
|
||||
}
|
||||
rel = y < roofY[x] ? 0.02 : 0.002;
|
||||
@@ -105,8 +103,10 @@ function heatChunk(c, sc) {
|
||||
if (flags[i] & F_BURN) burning = true;
|
||||
}
|
||||
}
|
||||
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; }
|
||||
else hot[c]--;
|
||||
const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
|
||||
const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
|
||||
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
|
||||
else { hot[c] = h; hotLvl[c] = level; }
|
||||
}
|
||||
function neighborDir(i, x, y, d) {
|
||||
if (d === 0) return y > 0 ? i - W : -1;
|
||||
@@ -120,7 +120,7 @@ function react(i, x, y, m) {
|
||||
if (fl & F_BURN) { burn(i, x, y, m, t); return; }
|
||||
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
|
||||
flags[i] = fl | F_BURN; life[i] = FUEL[m];
|
||||
stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
|
||||
if (chunkWork) chunkEvent('ignite'); else stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
|
||||
return;
|
||||
}
|
||||
const ph = PH[m];
|
||||
@@ -130,7 +130,7 @@ function react(i, x, y, m) {
|
||||
if (rnd() < 0.02 - t * 0.004) {
|
||||
const below = y < H - 1 ? PH[mat[i + W]] : PH_SOLID;
|
||||
const to = below === PH_EMPTY || below === PH_GAS ? SNOW : ICE;
|
||||
mat[i] = to; life[i] = 0; flags[i] = fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
|
||||
mat[i] = to; life[i] = 0; flags[i] = to === ICE ? F_NAT : fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +143,8 @@ function react(i, x, y, m) {
|
||||
if (L <= 0) {
|
||||
const to = FRZTO[m];
|
||||
mat[i] = to; life[i] = 0; temp[i] = fz - 1;
|
||||
flags[i] = (fl & F_NAT) | (CASTMETAL[m] ? F_CAST : 0); // natural magma freezes into natural rock
|
||||
// metal and glass set into castings; any other melt cools into ordinary rock you can drill, wherever it came from
|
||||
flags[i] = CASTMETAL[m] ? (fl & F_NAT) | F_CAST : PH[to] === PH_SOLID ? F_NAT : fl & F_NAT;
|
||||
wakeXY(x, y); discover(to, x, y, m, 'freeze', fz);
|
||||
} else { life[i] = L; temp[i] = fz; }
|
||||
return;
|
||||
@@ -177,7 +178,7 @@ function react(i, x, y, m) {
|
||||
if (rnd() < 0.03 + (t - mp) * 0.002) {
|
||||
let to = MELTTO[m];
|
||||
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
|
||||
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); }
|
||||
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) { if (chunkWork) chunkEvent('broken', mach[i]); else brokenMachines.push(mach[i]); } }
|
||||
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
|
||||
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
|
||||
}
|
||||
|
||||
@@ -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) + '.'; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ function markPow(i, on) {
|
||||
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
|
||||
powerDirty = true;
|
||||
}
|
||||
function partLost(i) { markPow(i, false); }
|
||||
function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
|
||||
let nextPowerBuild = 0;
|
||||
function rebuildPower() {
|
||||
powerDirty = false;
|
||||
for (const i of pcells) netId[i] = 0;
|
||||
nets = [null]; beltL = []; beltR = []; tubeLists = [[], [], [], [], []];
|
||||
nets = [null]; beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []);
|
||||
const stack = [];
|
||||
for (const s of pcells) {
|
||||
if (netId[s]) continue;
|
||||
@@ -23,8 +23,13 @@ function rebuildPower() {
|
||||
const i = stack.pop();
|
||||
const m = mat[i], p = part[i];
|
||||
if (m === BELTL) { net.nb++; beltL.push(i); } else if (m === BELTR) { net.nb++; beltR.push(i); }
|
||||
if (p >= P_TU && p <= P_TR) { net.nt++; tubeLists[p].push(i); }
|
||||
const x = i % W;
|
||||
if (IS_SCREW[p]) { net.nt++; screwLists[p].push(i); }
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
// Corner-touching screws share power, without connecting unrelated diagonal machinery.
|
||||
if (IS_SCREW[p] && SCREW_DX[p] && SCREW_DY[p]) for (let d = -1; d <= 1; d += 2) {
|
||||
const xx = x + d * SCREW_DX[p], yy = y + d * SCREW_DY[p], j = yy * W + xx;
|
||||
if (xx >= 0 && xx < W && yy >= 0 && yy < H && part[j] === p && isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); }
|
||||
}
|
||||
let j;
|
||||
if (x > 0) { j = i - 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
if (x < W - 1) { j = i + 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
@@ -39,16 +44,20 @@ function rebuildPower() {
|
||||
if (!net) continue;
|
||||
if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm);
|
||||
}
|
||||
beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a);
|
||||
tubeLists[P_TU].sort((a, b) => a - b); tubeLists[P_TD].sort((a, b) => b - a);
|
||||
tubeLists[P_TL].sort((a, b) => a - b); tubeLists[P_TR].sort((a, b) => b - a);
|
||||
beltL.sort((a, b) => (a % W) - (b % W) || a - b); beltR.sort((a, b) => (b % W) - (a % W) || b - a);
|
||||
for (const p of SCREW_PARTS) {
|
||||
screwLists[p].sort((a, b) => {
|
||||
if (SCREW_DX[p] && SCREW_DY[p]) return ((b % W) - (a % W)) * SCREW_DX[p];
|
||||
return (SCREW_DY[p] || SCREW_DX[p]) < 0 ? a - b : b - a;
|
||||
});
|
||||
}
|
||||
}
|
||||
function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return n ? n.sat : 0; }
|
||||
|
||||
// Move loose grains out of the given cells, to the nearest open cell above
|
||||
// the footprint in the same column (they then fall back on top). Gas just vanishes.
|
||||
function evictCells(cells, topY) {
|
||||
for (const i of cells) bgm[i] = 0;
|
||||
for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||
for (const i of cells) {
|
||||
const m = mat[i], ph = PH[m];
|
||||
if (m === EMPTY || ph === PH_SOLID) continue;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@ def(SLAG, { name: 'Slag', ph: PH_POWDER, col: [76, 66, 64], v: 34, dens: 2.4, k:
|
||||
def(MSLAG, { name: 'Molten slag', ph: PH_LIQUID, col: [232, 112, 40], v: 16, dens: 3.6, k: 0.02, cap: 1.2, hard: 0, freeze: 1000, freezeTo: SLAG, fluid: 0.5, spread: 10, liqT: 1150, desc: 'Smelting waste. Trickles down through the charge but is too thick to pass a grate, so it pools there and sets into slag. Knocking out the mold below rakes it away.' });
|
||||
def(BRICK, { name: 'Brick', col: [168, 76, 56], v: 16, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, yld: RBRICK, desc: 'Fired clay. Insulates and survives 1750°C.' });
|
||||
def(GLASS, { name: 'Glass', col: [178, 216, 224], v: 6, k: 0.006, hard: 2, melt: 1300, meltTo: MGL, alpha: 0.45, yld: CULLET, desc: 'Clear. Lets light through.' });
|
||||
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, tubes, fans, engines.' });
|
||||
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, screw conveyors, fans, engines.' });
|
||||
def(IRON, { name: 'Iron', col: [128, 131, 138], v: 8, k: 0.035, cap: 1.5, hard: 5, melt: 1538, meltTo: MFE, yld: SCRFE, desc: 'Strong metal. Melts at 1538°C.' });
|
||||
def(STEEL, { name: 'Steel', col: [172, 180, 192], v: 6, k: 0.035, cap: 1.5, hard: 6, melt: 1450, meltTo: MST, yld: SCRST, desc: 'Iron refined by blowing air through it while molten. The best bit material.' });
|
||||
def(WATER, { name: 'Water', ph: PH_LIQUID, col: [44, 100, 194], v: 6, dens: 1, k: 0.02, cap: 4, hard: 0, boil: 100, boilTo: STEAM, fluid: 1, spread: 48, alpha: 0.78, liqT: 20, bag: true, desc: 'Boils at 100°C. Steam that rises into the open sky comes back down as rain.' });
|
||||
@@ -77,7 +77,7 @@ def(CULLET, { name: 'Glass shards', ph: PH_POWDER, col: [196, 228, 236], v: 22,
|
||||
def(SCRCU, { name: 'Copper scrap', ph: PH_POWDER, col: [192, 118, 74], v: 36, dens: 5, k: 0.035, cap: 1.5, hard: 0, stick: 0.7, melt: 1085, meltTo: MCU, bag: true, desc: 'Copper that set outside a mold. Melt it again and cast it.' });
|
||||
def(SCRFE, { name: 'Iron scrap', ph: PH_POWDER, col: [124, 126, 132], v: 30, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1538, meltTo: MFE, bag: true, desc: 'Iron that set outside a mold. Melt it again and cast it.' });
|
||||
def(SCRST, { name: 'Steel scrap', ph: PH_POWDER, col: [168, 176, 188], v: 28, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1450, meltTo: MST, bag: true, desc: 'Steel that set outside a mold. Melt it again and cast it.' });
|
||||
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw tubes, fans and engines are made from these.' });
|
||||
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw conveyors, fans and engines are made from these.' });
|
||||
def(BARFE, { name: 'Iron bar', ph: PH_ITEM, col: [140, 143, 150], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1538, meltTo: MFE, bag: true, desc: 'A cast bar of iron.' });
|
||||
def(BARST, { name: 'Steel bar', ph: PH_ITEM, col: [184, 192, 204], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1450, meltTo: MST, bag: true, desc: 'A cast bar of steel.' });
|
||||
def(ACORN, { name: 'Acorn', ph: PH_POWDER, col: [150, 104, 56], v: 22, dens: 0.9, k: 0.006, hard: 0, stick: 0.6, ign: 300, flame: 700, flameF: 900, fuel: 30, burnTo: ASH, ashP: 0.3, bag: true, desc: 'A tree seed. Left on dirt or grass under open sky, it sprouts and grows into a tree.' });
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
|
||||
function cutTree(i, x, y, tier, amt = 1) {
|
||||
const b = bgm[i];
|
||||
renderDirtyIdx(i);
|
||||
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
|
||||
if (p < 255) { digp[i] = p; return false; }
|
||||
digp[i] = 0; bgm[i] = 0;
|
||||
@@ -104,6 +105,7 @@ function plantTick() {
|
||||
if (y <= 6 || y >= H - 1) continue;
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
|
||||
renderDirtyIdx(i);
|
||||
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
|
||||
let room = true;
|
||||
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
|
||||
@@ -120,7 +122,7 @@ function setTreeCell(x, y, m) {
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
|
||||
if (m === LEAVES && bgm[i]) return;
|
||||
bgm[i] = m;
|
||||
bgm[i] = m; renderDirtyIdx(i);
|
||||
if (treeDraw) treeDraw.push(i);
|
||||
}
|
||||
function saplingTick() {
|
||||
@@ -134,7 +136,7 @@ function saplingTick() {
|
||||
sp.age++;
|
||||
const s = Math.min(1, sp.age / 180);
|
||||
// wipe the previous stage, then draw this one
|
||||
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0;
|
||||
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||
treeDraw = sp.cells = [];
|
||||
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
|
||||
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// Tool helpers: reach, brushes, and plain digging.
|
||||
// ---------- Tools ----------
|
||||
// Building reaches about as far as the screen shows (t64): the view box the UI sets each frame
|
||||
// (setBuildRange), at least REACH from its centre and at most BUILD_MAX; anything beyond becomes
|
||||
// a blueprint ghost. With no view (tests, headless), a REACH box around the bot.
|
||||
const BUILD_MAX = 200;
|
||||
let buildBox = null;
|
||||
function setBuildRange(cx, cy, w, h) {
|
||||
const hw = Math.max(REACH, Math.min(BUILD_MAX, w / 2)), hh = Math.max(REACH, Math.min(BUILD_MAX, h / 2));
|
||||
if (!buildBox) buildBox = { cx, cy, hw, hh }; else { buildBox.cx = cx; buildBox.cy = cy; buildBox.hw = hw; buildBox.hh = hh; }
|
||||
}
|
||||
function inBuildRange(x, y) {
|
||||
if (!buildBox) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return Math.abs(dx) <= REACH && Math.abs(dy) <= REACH; }
|
||||
return Math.abs(x - buildBox.cx) <= buildBox.hw && Math.abs(y - buildBox.cy) <= buildBox.hh;
|
||||
}
|
||||
function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; }
|
||||
function forBrush(cx, cy, r, fn) {
|
||||
const r2 = (r + 0.5) * (r + 0.5);
|
||||
@@ -34,10 +47,10 @@ function toolDig(cx, cy, r) {
|
||||
const b = blockP[((y / BS) | 0) * BW + ((x / BS) | 0)];
|
||||
setCell(i, to, temp[i], 0);
|
||||
discover(to, x, y, m, 'dig');
|
||||
if (b === BP_WALL && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] = 0;
|
||||
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
|
||||
dug++;
|
||||
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
||||
} else digp[i] = p;
|
||||
} else { digp[i] = p; renderDirtyIdx(i); }
|
||||
});
|
||||
stats.dug += dug;
|
||||
let status = '';
|
||||
|
||||
@@ -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--;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ function worldWater() {
|
||||
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
|
||||
return Math.round(n + bag[WATER] + bag[SNOW]);
|
||||
}
|
||||
function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
|
||||
function absorbSteam(x) { if (chunkWork) { chunkEvent('moist', x); return; } const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
|
||||
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
|
||||
function spawnRain(x0, x1, k) {
|
||||
if (flyers.length > 2500) return 0;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
|
||||
let simPool = null, chunkWork = null, inlineWork = null;
|
||||
function threadWorld() {
|
||||
return { W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
|
||||
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
|
||||
powderArea,roofDirty,renderDirty,AMB,roofY };
|
||||
}
|
||||
function threadBind(s) {
|
||||
({ W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
|
||||
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
|
||||
powderArea,roofDirty,renderDirty,AMB,roofY } = s);
|
||||
}
|
||||
function threadContext() {
|
||||
return { wake: new Uint8Array(NCH), heat: new Uint8Array(NCH), level: new Uint8Array(NCH),
|
||||
ownHot: new Uint8Array(NCH).fill(255), ownLevel: new Uint8Array(NCH), events: [], c: -1,
|
||||
x0: 0, x1: 0 };
|
||||
}
|
||||
function resetChunkWork(work) {
|
||||
work.wake.fill(0); work.heat.fill(0); work.level.fill(0); work.ownHot.fill(255); work.events.length = 0;
|
||||
return work;
|
||||
}
|
||||
function inlineChunkWork() {
|
||||
if (!inlineWork || inlineWork.wake.length !== NCH) inlineWork = threadContext();
|
||||
return resetChunkWork(inlineWork);
|
||||
}
|
||||
function chunkEvent(kind, ...args) { chunkWork.events.push([chunkWork.c, kind, ...args]); }
|
||||
function chunkWake(c, n = 3) {
|
||||
if (chunkWork) { if (chunkWork.wake[c] < n) chunkWork.wake[c] = n; }
|
||||
else awake[c] = n;
|
||||
}
|
||||
function chunkHeat(c, level = 2) {
|
||||
if (chunkWork) { chunkWork.heat[c] = 6; if (chunkWork.level[c] < level) chunkWork.level[c] = level; }
|
||||
else { hot[c] = 6; if (level) hotLvl[c] = level; }
|
||||
}
|
||||
function chunkSeed(c, pass) { return hash2(c, tick, worldSeed ^ Math.imul(pass + 1, 0x9e3779b9)) | 1; }
|
||||
function runChunks(chunks, pass, work) {
|
||||
const savedRS = RS;
|
||||
chunkWork = work;
|
||||
try {
|
||||
for (const c of chunks) {
|
||||
work.c = c; work.x0 = (c % CW) * 32; work.x1 = Math.min(W, work.x0 + 32);
|
||||
RS = chunkSeed(c, pass);
|
||||
if (pass < 4) moveChunk(c); else {
|
||||
const sc = hotLvl[c] === 1 ? 4 : 1;
|
||||
heatChunk(c, sc);
|
||||
}
|
||||
}
|
||||
} finally { chunkWork = null; RS = savedRS; }
|
||||
}
|
||||
function mergeChunkWork(works) {
|
||||
for (const work of works) for (let c = 0; c < NCH; c++) if (work.ownHot[c] !== 255) {
|
||||
hot[c] = work.ownHot[c]; hotLvl[c] = work.ownLevel[c];
|
||||
}
|
||||
for (const work of works) for (let c = 0; c < NCH; c++) {
|
||||
if (awake[c] < work.wake[c]) awake[c] = work.wake[c];
|
||||
if (hot[c] < work.heat[c]) hot[c] = work.heat[c];
|
||||
if (hotLvl[c] < work.level[c]) hotLvl[c] = work.level[c];
|
||||
}
|
||||
const events = works.flatMap(w => w.events).sort((a,b) => a[0] - b[0]);
|
||||
for (const [,kind,...a] of events) {
|
||||
if (kind === 'discover') discover(...a);
|
||||
else if (kind === 'emit') emit(a[0]);
|
||||
else if (kind === 'flow') { const mm = machines[a[0]]; if (mm) mm.flow += a[1]; }
|
||||
else if (kind === 'moist') absorbSteam(a[0]);
|
||||
else if (kind === 'lost') partLost(a[0]);
|
||||
else if (kind === 'broken') brokenMachines.push(a[0]);
|
||||
else if (kind === 'ignite') stats.ignitions++;
|
||||
else if (kind === 'liquid') {
|
||||
const i = a[0];
|
||||
if (stamp[i] !== tickStamp && mat[i] === a[1]) liquidSearch(i, i % W, (i / W) | 0, ...a.slice(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
function chunkPasses(heat) {
|
||||
for (let color = 0; color < 4; color++) {
|
||||
const chunks = [];
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
if (((c % CW & 1) | (((c / CW) & 1) << 1)) !== color) continue;
|
||||
if (heat ? (!hot[c] || (hotLvl[c] === 1 && ((tick + c) & 3) !== 0)) : !awake[c]) continue;
|
||||
chunks.push(c);
|
||||
}
|
||||
if (!chunks.length) continue;
|
||||
const pass = color + (heat ? 4 : 0);
|
||||
if (simPool && simPool.world === mat && chunks.length >= (simPool.force ? 1 : (heat ? 16 : 64))) threadPass(simPool, chunks, pass);
|
||||
else { const work = inlineChunkWork(); runChunks(chunks, pass, work); mergeChunkWork([work]); }
|
||||
}
|
||||
}
|
||||
function threadParams() {
|
||||
return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,
|
||||
seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
|
||||
}
|
||||
function threadSetParams(s) {
|
||||
({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s);
|
||||
seen.set(s.seen); Object.assign(bot, s.bot);
|
||||
weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
|
||||
}
|
||||
// Transport is injected by the browser or Node harness; no browser globals in the simulation.
|
||||
function threadSlot() {
|
||||
// shortcut: 1 MiB of events per helper per pass; grow or stream it if dense reactions fill it.
|
||||
return { control: sharedArray(Int32Array, 2), bytes: sharedArray(Uint8Array, 1024 * 1024),
|
||||
wake: sharedArray(Uint8Array, NCH), heat: sharedArray(Uint8Array, NCH),
|
||||
level: sharedArray(Uint8Array, NCH), ownHot: sharedArray(Uint8Array, NCH),
|
||||
ownLevel: sharedArray(Uint8Array, NCH) };
|
||||
}
|
||||
function threadExecute(slot, chunks, pass) {
|
||||
slot.wake.fill(0); slot.heat.fill(0); slot.level.fill(0); slot.ownHot.fill(255);
|
||||
const work = { ...slot, events: [], c: -1 };
|
||||
runChunks(chunks, pass, work);
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(work.events));
|
||||
if (encoded.length > slot.bytes.length) throw Error('Simulation event buffer full');
|
||||
slot.bytes.set(encoded); Atomics.store(slot.control, 1, encoded.length);
|
||||
}
|
||||
function threadPass(pool, chunks, pass) {
|
||||
const params = threadParams(), n = Math.min(pool.slots.length, (pool.force ? chunks.length : Math.max(1, Math.floor(chunks.length / (pass < 4 ? 32 : 8)))));
|
||||
pool.activeMax = Math.max(pool.activeMax || 0, n);
|
||||
const batches = Array.from({length:n + 1}, () => []);
|
||||
for (let k = 0; k < chunks.length; k++) batches[k % batches.length].push(chunks[k]);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const slot = pool.slots[k]; Atomics.store(slot.control, 0, 0);
|
||||
pool.workers[k].postMessage({ chunks: batches[k], pass, params });
|
||||
}
|
||||
const local = inlineChunkWork(); runChunks(batches[n], pass, local);
|
||||
const works = [local], deadline = Date.now() + 10000;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const slot = pool.slots[k];
|
||||
threadWait(slot, deadline);
|
||||
const events = JSON.parse(new TextDecoder().decode(slot.bytes.slice(0, Atomics.load(slot.control, 1))));
|
||||
if (Atomics.load(slot.control, 0) < 0) throw Error('Simulation worker: ' + events);
|
||||
works.push({ ...slot, events });
|
||||
}
|
||||
mergeChunkWork(works);
|
||||
}
|
||||
function threadWait(slot, deadline) {
|
||||
let spins = 0;
|
||||
// Atomics.wait is forbidden on the browser main thread. Bound polling to fail closed.
|
||||
while (Atomics.load(slot.control, 0) === 0) {
|
||||
if (++spins === 16384) { spins = 0; if (Date.now() > deadline) throw Error('Simulation worker timed out'); }
|
||||
}
|
||||
}
|
||||
function threadFailure(slot, error) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(String(error.stack || error)));
|
||||
slot.bytes.set(bytes.subarray(0, slot.bytes.length));
|
||||
Atomics.store(slot.control, 1, Math.min(bytes.length, slot.bytes.length));
|
||||
Atomics.store(slot.control, 0, -1); Atomics.notify(slot.control, 0);
|
||||
}
|
||||
function threadMessage(data) {
|
||||
if (data.world) { threadBind(data.world); globalThis.threadWorkerSlot = data.slot; Atomics.store(data.slot.control, 0, 1); return; }
|
||||
const slot = globalThis.threadWorkerSlot;
|
||||
try {
|
||||
threadSetParams(data.params); threadExecute(slot, data.chunks, data.pass);
|
||||
Atomics.store(slot.control, 0, 1); Atomics.notify(slot.control, 0);
|
||||
} catch (e) { threadFailure(slot, e); }
|
||||
}
|
||||
@@ -13,11 +13,12 @@ let ents = []; // loose objects (bars)
|
||||
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
|
||||
let flyers = []; // grains in flight, blown out of the bot
|
||||
let ghostBlocks = new Map(), ghostMolds = [];
|
||||
let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk.
|
||||
let awake, hot, hotLvl, powderArea, roofDirty;
|
||||
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
|
||||
let tick = 0, tickStamp = 1;
|
||||
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
|
||||
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
|
||||
let beltL = [], beltR = [], screwLists = Array.from({ length: IS_SCREW.length }, () => []);
|
||||
let brokenMachines = [];
|
||||
let ghostMachines = [], ghostBlocked = new Set();
|
||||
const EV = [];
|
||||
@@ -28,34 +29,43 @@ let seen = new Uint8Array(NMAT);
|
||||
let ledger = {};
|
||||
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
|
||||
|
||||
function emit(e) { if (EV.length < 400) EV.push(e); }
|
||||
function emit(e) { if (chunkWork) { chunkEvent('emit', e); return; } if (EV.length < 400) EV.push(e); }
|
||||
function bagCap() { return TANK_TIERS[player.tankTier].cap; }
|
||||
// Things count as discovered only when they happen near the player (or in the bag).
|
||||
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
|
||||
function discover(m, x, y, from, how, T) {
|
||||
if (seen[m]) return;
|
||||
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
|
||||
if (chunkWork) { chunkEvent('discover', m, x, y, from, how, T); return; }
|
||||
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
|
||||
}
|
||||
|
||||
function sharedArray(Type, length) {
|
||||
const Buffer = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
|
||||
return new Type(new Buffer(length * Type.BYTES_PER_ELEMENT));
|
||||
}
|
||||
function alloc(w, h) {
|
||||
resetDevConsole();
|
||||
W = w; H = h; N = w * h;
|
||||
fogAlloc();
|
||||
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
|
||||
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
|
||||
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
|
||||
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
|
||||
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
|
||||
cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
|
||||
mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
|
||||
stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(Uint8Array, N);
|
||||
noiseB = sharedArray(Uint8Array, N); bgm = sharedArray(Uint8Array, N); airS = sharedArray(Uint16Array, N); netId = sharedArray(Uint16Array, N); isPow = sharedArray(Uint8Array, N); occ = sharedArray(Uint8Array, N);
|
||||
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = sharedArray(Uint8Array, NB); blockV = sharedArray(Uint8Array, NB); slopePhase = sharedArray(Uint8Array, NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
|
||||
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
|
||||
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
|
||||
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
|
||||
AMB = new Float32Array(h);
|
||||
surfY = sharedArray(Int16Array, w); roofY = sharedArray(Int16Array, w); dirtY = sharedArray(Int16Array, w); graniteY = sharedArray(Int16Array, w); basaltY = sharedArray(Int16Array, w); magmaY = sharedArray(Int16Array, w);
|
||||
AMB = sharedArray(Float32Array, h);
|
||||
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
|
||||
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
|
||||
roofDirty = new Uint8Array(CW).fill(1);
|
||||
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
|
||||
renderDirty = sharedArray(Uint8Array, NCH).fill(1);
|
||||
cleatChunks = sharedArray(Uint8Array, NCH);
|
||||
awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
|
||||
roofDirty = sharedArray(Uint8Array, CW).fill(1);
|
||||
powderArea = sharedArray(Uint8Array, NCH).fill(1); // unknown areas get one visit before being ruled out
|
||||
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
|
||||
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
|
||||
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
|
||||
ghostMachines = []; ghostBlocked = new Set();
|
||||
tick = 0; tickStamp = 1; drillPulses = []; lastPulseTick = -99; undoStack = []; undoRec = null;
|
||||
pipeNets = []; pipeNetAt = null; pipesDirty = true;
|
||||
@@ -87,18 +97,27 @@ function fillNoise() {
|
||||
}
|
||||
|
||||
// ---------- Waking ----------
|
||||
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
|
||||
function renderDirtyChunk(c) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
for (let yy = Math.max(0, cy - 1); yy <= Math.min(CH - 1, cy + 1); yy++)
|
||||
for (let xx = Math.max(0, cx - 1); xx <= Math.min(CW - 1, cx + 1); xx++) renderDirty[yy * CW + xx] = 1;
|
||||
}
|
||||
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
|
||||
function wakeXY(x, y) {
|
||||
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
|
||||
if (!chunkWork) renderDirty[c] = 1;
|
||||
roofDirty[cx] = 1;
|
||||
awake[c] = 3;
|
||||
chunkWake(c);
|
||||
const lx = x & 31, ly = y & 31;
|
||||
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
|
||||
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
|
||||
if (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
|
||||
if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
|
||||
}
|
||||
function wakeHeatXY(x, y) {
|
||||
const c = (y >> 5) * CW + (x >> 5);
|
||||
if (!chunkWork) renderDirty[c] = 1;
|
||||
roofDirty[x >> 5] = 1;
|
||||
hot[c] = 6; hotLvl[c] = 2;
|
||||
chunkHeat(c);
|
||||
}
|
||||
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
|
||||
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
@@ -29,8 +29,7 @@ body {
|
||||
font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4;
|
||||
-webkit-user-select: none; user-select: none; touch-action: none;
|
||||
}
|
||||
canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||
canvas#game.nocursor { cursor: none; }
|
||||
canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */
|
||||
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
|
||||
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
|
||||
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
|
||||
@@ -219,6 +218,7 @@ table.mt td.dip { color: var(--melt); }
|
||||
|
||||
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
|
||||
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
|
||||
#perf:not(:empty) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
|
||||
#perf:not(:empty) ~ .br { }
|
||||
|
||||
/* ---------- modals ---------- */
|
||||
@@ -291,6 +291,8 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
|
||||
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
|
||||
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
|
||||
#cap-notice { max-width: 64ch; padding: 8px 14px; color: var(--melt-hot); }
|
||||
#cap-notice p { margin: 6px 0; }
|
||||
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
|
||||
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
|
||||
.tmenu .btn small { color: var(--ash); font-size: 0.82em; }
|
||||
@@ -397,3 +399,6 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
|
||||
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
|
||||
}
|
||||
|
||||
canvas#gpu { pointer-events: none; }
|
||||
canvas#gpu[hidden] { display: none; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -89,7 +89,8 @@ async function openNewClaim() {
|
||||
showLoading(true, 0.9, 'Saving');
|
||||
if (!await saveWorld('auto', true)) throw new Error('Save failed');
|
||||
showLoading(true, 1, 'Saving');
|
||||
showLoading(false); closeAllModals(); enterGame();
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); closeAllModals(); enterGame(true);
|
||||
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Hover work is shared by the cursor label and the vein outline.
|
||||
const CURSOR_ORES = new Uint8Array(NMAT);
|
||||
for (let m = 0; m < NMAT; m++) if (PING_VEINS[m]) { CURSOR_ORES[m] = 1; CURSOR_ORES[YLD[m]] = 1; }
|
||||
function oreLike(m, bar) { return !!(bar || CURSOR_ORES[m] || SOLIDMETAL[m]); }
|
||||
function bitNeeded(m) {
|
||||
if (HARD[m] > 6) return 'cannot be drilled';
|
||||
return 'needs ' + BIT_TIERS.find(b => b.hard >= HARD[m]).name.toLowerCase();
|
||||
}
|
||||
function pingVisible(x, y) {
|
||||
const p = GAME.ping;
|
||||
if (!p || x < 0 || y < 0 || x >= W || y >= H) return false;
|
||||
if (!p.cellSet) p.cellSet = new Set(p.cells);
|
||||
if (!p.cellSet.has(y * W + x)) return false;
|
||||
const start = Math.hypot(x - p.x, y - p.y) / PING_SPEED * 1000, ms = performance.now() - p.t0;
|
||||
return ms >= start && ms < start + PING_HOLD + PING_FADE;
|
||||
}
|
||||
function cursorVein(i) {
|
||||
const m = mat[i], cells = new Set([i]), queue = [i], edges = [];
|
||||
const same = q => q >= 0 && q < N && mat[q] === m && (flags[q] & F_NAT);
|
||||
const add = n => { if (cells.size < 6000 && same(n) && !cells.has(n)) { cells.add(n); queue.push(n); } };
|
||||
for (let k = 0; k < queue.length; k++) {
|
||||
const q = queue[k], x = q % W;
|
||||
if (x) add(q - 1);
|
||||
if (x < W - 1) add(q + 1);
|
||||
add(q - W); add(q + W);
|
||||
}
|
||||
for (const q of cells) {
|
||||
const x = q % W, y = (q / W) | 0;
|
||||
if (!x || !same(q - 1)) edges.push([x, y, x, y + 1]);
|
||||
if (x === W - 1 || !same(q + 1)) edges.push([x + 1, y, x + 1, y + 1]);
|
||||
if (!same(q - W)) edges.push([x, y, x + 1, y]);
|
||||
if (!same(q + W)) edges.push([x, y + 1, x + 1, y + 1]);
|
||||
}
|
||||
return { cells, edges, m, world: mat };
|
||||
}
|
||||
let cursorVeinCache = null, cursorLabelKey = null, cursorLabelSince = 0;
|
||||
function hoveredVein(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return null;
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (!PING_VEINS[m] || !(flags[i] & F_NAT) || (SET.fog && fogSeen(x, y) < 64 && !pingVisible(x, y))) return null;
|
||||
if (!cursorVeinCache || cursorVeinCache.world !== mat || cursorVeinCache.m !== m || !cursorVeinCache.cells.has(i)) cursorVeinCache = cursorVein(i);
|
||||
return cursorVeinCache;
|
||||
}
|
||||
function cursorLabelAlpha(key, now) {
|
||||
if (key !== cursorLabelKey) { cursorLabelKey = key; cursorLabelSince = now; }
|
||||
return Math.max(0, Math.min(1, (2400 - (now - cursorLabelSince)) / 400));
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -10,7 +10,7 @@ function pollPad() {
|
||||
const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz);
|
||||
const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0);
|
||||
const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry;
|
||||
if (pressedAny && !GAME.usingPad) { GAME.usingPad = true; canvas.classList.add('nocursor'); }
|
||||
if (pressedAny) GAME.usingPad = true;
|
||||
const modal = topModal();
|
||||
if (modal || !GAME.running) {
|
||||
const now = performance.now();
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -77,21 +77,28 @@ function reactionsOf(m, forNotebook) {
|
||||
// What is under the cursor: t1 is the name, t2 the details, warn a short problem
|
||||
// (too hard, out of reach) for the label next to the cursor.
|
||||
function hoverInfo(ax, ay) {
|
||||
if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] };
|
||||
const pinged = pingVisible(ax, ay);
|
||||
if (SET.fog && fogSeen(ax, ay) < 64 && !pinged) return { t1: 'Unexplored', t2: [], t3: [] };
|
||||
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
|
||||
const i = ay * W + ax, m = mat[i];
|
||||
let warn = '';
|
||||
let t1 = MD[m].name, t2 = [];
|
||||
const p = part[i], id = mach[i];
|
||||
let t1 = MD[m].name, t2 = [], contents = '';
|
||||
const p = part[i];
|
||||
// Cone gaps have no machine cells; hover still belongs to the silo's whole outline.
|
||||
const silo = machines.find(mm => mm && mm.type === 'silo' && ax >= mm.x && ax < mm.x + mm.w && ay >= mm.y && ay < mm.y + mm.h);
|
||||
const id = silo ? silo.id : mach[i];
|
||||
const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)];
|
||||
const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null;
|
||||
if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
|
||||
if (bar && !silo) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
|
||||
else if (id && machines[id]) {
|
||||
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
|
||||
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
|
||||
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
|
||||
if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
|
||||
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
|
||||
if (mm.type === 'silo') {
|
||||
contents = mm.sn ? `${MD[mm.sm].name.toLowerCase()} ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')}` : 'empty';
|
||||
t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
|
||||
}
|
||||
} else if (b === BP_RACK) {
|
||||
const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
|
||||
let n = 0, capacity = 0;
|
||||
@@ -109,14 +116,15 @@ function hoverInfo(ax, ay) {
|
||||
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
|
||||
t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
|
||||
} else if (p === P_FILTER) {
|
||||
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
|
||||
const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = beltDirection(blockP[fb], blockV[fb]);
|
||||
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
|
||||
} else if (p === P_PIPE) {
|
||||
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
|
||||
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
|
||||
t2.push(pipeContentsText(pn));
|
||||
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
|
||||
} else if (IS_SCREW[p]) t1 = 'Screw conveyor ' + PIECE_BY_ID.screw.dirs[SCREW_PARTS.indexOf(p)] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (p) t1 = ({ 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (m === BELTL || m === BELTR) { const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : (ay >> 2) * BW + (ax >> 2); t1 = 'Belt ' + beltDirection(blockP[fb], blockV[fb]); }
|
||||
else if (m === PIPE && isPipeCode(b)) {
|
||||
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
|
||||
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
|
||||
@@ -124,19 +132,25 @@ function hoverInfo(ax, ay) {
|
||||
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
|
||||
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
|
||||
}
|
||||
else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`;
|
||||
else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`;
|
||||
const jb = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
|
||||
if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`);
|
||||
for (let end = 0; end < 2 && !id && !bar; end++) {
|
||||
const miss = nearMiss(jb % BW, (jb / BW) | 0, end);
|
||||
if (miss) { t2.push(`doesn't connect: place it one block ${miss < 0 ? 'higher' : 'lower'}`); warn = warn || "doesn't connect"; break; }
|
||||
}
|
||||
const n = nets[netId[i]];
|
||||
if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`);
|
||||
if (player.lens && !bar) t2.push(fmtT(temp[i]));
|
||||
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
|
||||
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
|
||||
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
|
||||
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
|
||||
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : bitNeeded(m));
|
||||
if (!ok && toolId() === 'drill') warn = bitNeeded(m);
|
||||
}
|
||||
if (!bar && PH[m] === PH_POWDER) t2.push('loose');
|
||||
if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
|
||||
if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
|
||||
if (flags[i] & F_BURN) t2.push('burning');
|
||||
const mo = moldAt(ax, ay);
|
||||
const mo = silo ? null : moldAt(ax, ay);
|
||||
if (mo) {
|
||||
t1 = MOLDS[mo.kind].name;
|
||||
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`];
|
||||
@@ -145,12 +159,13 @@ function hoverInfo(ax, ay) {
|
||||
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
|
||||
if (player.lens) t2.push(fmtT(temp[i]));
|
||||
}
|
||||
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
|
||||
const reachOk = toolId() === 'drill' || (toolId() === 'build' ? inBuildRange(ax, ay) : inReach(ax, ay));
|
||||
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
|
||||
// what it does, unless the cursor is on a machine or mold, which have their own details
|
||||
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
|
||||
const t3 = what ? reactionsOf(what) : [];
|
||||
return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
|
||||
if (pinged) t2.push('found by the ping');
|
||||
return { t1, t2, t3, warn, contents, ore: oreLike(m, bar), m: bar ? bar.m : m, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
|
||||
}
|
||||
function renderInfo() {
|
||||
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
@@ -160,15 +175,19 @@ function renderInfo() {
|
||||
}
|
||||
// Name of what is under the cursor, drawn right next to it
|
||||
function drawCursorLabel(px, py, r) {
|
||||
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return;
|
||||
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) { cursorLabelKey = null; return; }
|
||||
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!info.t1 || info.air) return;
|
||||
if (!info.t1 || (info.air && !info.warn) || (SET.cursorLabel === 'ores' && !info.ore && !info.warn)) { cursorLabelKey = null; return; }
|
||||
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
const alpha = cursorLabelAlpha(vein || info.m || info.t1, performance.now());
|
||||
if (!alpha && !info.warn) return;
|
||||
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
|
||||
const fs = Math.round(12 * dpr * SET.uiScale);
|
||||
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
|
||||
const main = info.t1 + (player.lens && R.lens ? '' : '');
|
||||
const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
|
||||
const main = alpha ? info.t1 + (info.t2.includes('found by the ping') ? ' · found by the ping' : '') + (info.contents && info.warn ? ` ${info.contents}` : '') : '';
|
||||
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
|
||||
ctx.save();
|
||||
ctx.globalAlpha = info.warn ? 1 : alpha;
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`;
|
||||
const w1 = ctx.measureText(main).width;
|
||||
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
|
||||
@@ -180,8 +199,9 @@ function drawCursorLabel(px, py, r) {
|
||||
ctx.fillStyle = 'rgba(20,17,15,0.82)';
|
||||
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2);
|
||||
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.globalAlpha = alpha; ctx.fillText(main, x + padX, y + h / 2);
|
||||
ctx.globalAlpha = info.warn ? 1 : alpha;
|
||||
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,17 +24,18 @@ window.addEventListener('keydown', e => {
|
||||
if (e.repeat) return;
|
||||
GAME.usingPad = false;
|
||||
switch (k) {
|
||||
case '`': e.preventDefault(); toggleDevConsole(); break;
|
||||
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
|
||||
case 'c': case 'x': hudDirty = true; break;
|
||||
case 'v': startPaste(); break;
|
||||
case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break;
|
||||
case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break;
|
||||
case 'r': rotate(); break;
|
||||
case 't': if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
|
||||
case 't': if (toolId() === 'build' && piece().code === BP_WALL) turnSlope(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
|
||||
case 'f': doInteract(); break;
|
||||
case 'g': GAME.replace = true; hudDirty = true; break;
|
||||
case 'h': toggleLens(); break;
|
||||
case 'i': GAME.infoOverlay = !GAME.infoOverlay; playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, and which way belts and tubes run. I hides it.' : 'Info overlay off.'); break;
|
||||
case 'i': GAME.infoOverlay = !GAME.infoOverlay; setFlowCounting(GAME.infoOverlay); playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, which way belts and screw conveyors run, and how many grains a second each line moves (orange: backing up). I hides it.' : 'Info overlay off.'); break;
|
||||
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
|
||||
case 'n': openNotebook(); break;
|
||||
case 'b': openNotebook('Blueprints'); break;
|
||||
@@ -42,13 +43,13 @@ window.addEventListener('keydown', e => {
|
||||
case '[': setBrush(-1); break;
|
||||
case ']': setBrush(1); break;
|
||||
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
|
||||
case 'Escape': if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
|
||||
case 'Escape': if (!$('dev-console').hidden) closeDevConsole(); else if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
|
||||
case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break;
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if ((k === 'Control' || k === 'Meta') && GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } if (k === 'g') { GAME.replace = false; hudDirty = true; } if (k === 'c' || k === 'x') hudDirty = true; });
|
||||
window.addEventListener('blur', () => { keys.clear(); if (GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } GAME.lmb = GAME.rmb = false; GAME.replace = false; GAME.selDrag = null; endRmbRecord(); });
|
||||
canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } });
|
||||
canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; GAME.usingPad = false; });
|
||||
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
|
||||
@@ -12,21 +12,24 @@ function scanVisible() {
|
||||
}
|
||||
let ambientT = 0;
|
||||
function frame(now) {
|
||||
if (GPU.stopped) return;
|
||||
requestAnimationFrame(frame);
|
||||
if (GPU.recovering) { lastT = now; return; }
|
||||
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
|
||||
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
|
||||
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
|
||||
if (GAME.loading) { acc = 0; return; }
|
||||
if (!GAME.running) { pollPad(); return; }
|
||||
const t0 = performance.now();
|
||||
let frameSim = 0, frameSteps = 0;
|
||||
const pad = pollPad();
|
||||
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
|
||||
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
|
||||
if (!GAME.paused) {
|
||||
acc += dtReal * SET.simSpeed;
|
||||
if (!GAME.paused && (!devPaused || devSteps > 0)) {
|
||||
acc += devPaused ? 0 : dtReal * SET.simSpeed * devSpeed;
|
||||
let steps = 0;
|
||||
const step = 1 / 60;
|
||||
while (acc >= step && steps < SET.maxSteps) {
|
||||
while ((devPaused ? devSteps > 0 : acc >= step) && steps < SET.maxSteps) {
|
||||
let ix = 0, iy = 0;
|
||||
if (keys.has('a') || keys.has('ArrowLeft')) ix -= 1; if (keys.has('d') || keys.has('ArrowRight')) ix += 1;
|
||||
if (keys.has('w') || keys.has('ArrowUp') || keys.has(' ')) iy -= 1; if (keys.has('s') || keys.has('ArrowDown')) iy += 1;
|
||||
@@ -37,8 +40,16 @@ function frame(now) {
|
||||
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
|
||||
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
|
||||
toolTick();
|
||||
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
|
||||
acc -= step; steps++;
|
||||
const s0 = performance.now();
|
||||
try { simTick(); }
|
||||
catch (e) {
|
||||
GAME.paused = true; acc = 0; console.error(e);
|
||||
toast('The world stopped updating. Reload this world to try again.', {big:true}); break;
|
||||
}
|
||||
const elapsed = performance.now() - s0;
|
||||
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
|
||||
if (devPaused) devSteps--; else acc -= step;
|
||||
steps++;
|
||||
GAME.playTime += step;
|
||||
}
|
||||
if (steps >= SET.maxSteps) acc = 0;
|
||||
@@ -53,28 +64,30 @@ function frame(now) {
|
||||
// grid under the cursor does not slide around mid-line.
|
||||
if (GAME.drag) { lookX = GAME.lookHold.x; lookY = GAME.lookHold.y; } else { GAME.lookHold.x = lookX; GAME.lookHold.y = lookY; }
|
||||
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
|
||||
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
|
||||
R.frame++;
|
||||
drawWorld();
|
||||
drawCracks();
|
||||
const overlayStart = SET.showPerf ? performance.now() : 0;
|
||||
if (!weather.seen && (R.rain || 0) > 0.12) {
|
||||
weather.seen = true;
|
||||
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
|
||||
}
|
||||
if (SET.look === 'smooth' && SM.tried && !SM.ok) {
|
||||
SET.look = 'pixel'; saveSettings();
|
||||
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
|
||||
}
|
||||
drawMachines();
|
||||
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
|
||||
drawParticles();
|
||||
const hb = GAME.mouse.in || GAME.usingPad ? filterBlockAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)) : -1;
|
||||
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
|
||||
if (GAME.infoOverlay) drawFlowArrows();
|
||||
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
|
||||
drawJoinMarks();
|
||||
drawPing();
|
||||
drawHoveredVein();
|
||||
drawPingRecharge();
|
||||
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
|
||||
drawFloaters();
|
||||
drawRain();
|
||||
drawBot();
|
||||
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
|
||||
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
|
||||
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
|
||||
renderBuildBar();
|
||||
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
|
||||
@@ -83,11 +96,13 @@ function frame(now) {
|
||||
updateOverlayUI();
|
||||
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
|
||||
updateMap(now);
|
||||
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
|
||||
const frameWork = performance.now() - t0;
|
||||
frameMs = frameMs * 0.9 + frameWork * 0.1;
|
||||
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
|
||||
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
|
||||
if (SET.showPerf && R.frame % 15 === 0) {
|
||||
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).toFixed(1)}) · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
let PERF_MARKS = false, PERF_FRAME = null;
|
||||
const perfMarks = {}, perfTimes = {}, perfLast = {};
|
||||
for (const name of ['drawWorld', 'simMove', 'heatPass']) {
|
||||
const original = window[name];
|
||||
window[name] = function (...args) {
|
||||
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
|
||||
const start = performance.now();
|
||||
const result = original.apply(this, args);
|
||||
const ms = performance.now() - start;
|
||||
perfLast[name] = ms;
|
||||
perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,44 +1,4 @@
|
||||
// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan.
|
||||
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
|
||||
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
|
||||
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
||||
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
|
||||
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
|
||||
function crackSeg(ctx, x, y, n, sc, zp, oy) {
|
||||
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
|
||||
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
|
||||
const pts = [];
|
||||
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
|
||||
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
|
||||
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
|
||||
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
|
||||
for (let k = 0; k + 3 < pts.length; k += 4) {
|
||||
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
|
||||
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
|
||||
}
|
||||
}
|
||||
function drawCracks() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
if (zp < 2.5) return;
|
||||
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
|
||||
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
|
||||
const cells = [];
|
||||
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
|
||||
if (!cells.length) return;
|
||||
ctx.save(); ctx.lineCap = 'round';
|
||||
for (let l = 0; l < CRACK_LV.length; l++) {
|
||||
const L = CRACK_LV[l];
|
||||
// a soft lit lip just below each crack, then the dark crack itself
|
||||
for (const pass of [0, 1]) {
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
|
||||
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
|
||||
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Drill pulse wavefronts and aiming fan.
|
||||
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
function pulseRGB(tune) {
|
||||
if (!tune) return '150,215,255';
|
||||
@@ -74,7 +34,7 @@ function drawDrillAim(ax, ay) {
|
||||
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
|
||||
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
|
||||
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
|
||||
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
|
||||
const col = pulseRGB(GAME.drillTune);
|
||||
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
|
||||
ctx.save();
|
||||
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
|
||||
@@ -84,6 +44,9 @@ function drawDrillAim(ax, ay) {
|
||||
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
|
||||
ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
// A solid far edge marks where the pulse stops, even with a distant cursor.
|
||||
ctx.beginPath(); far.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y));
|
||||
ctx.strokeStyle = `rgba(${col},0.85)`; ctx.lineWidth = Math.max(1, zp * 0.22); ctx.stroke();
|
||||
// the emitter glows across the bot's face while it fires
|
||||
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
|
||||
if (GAME.lmb && hot > 0) {
|
||||
@@ -95,7 +58,7 @@ function drawDrillAim(ax, ay) {
|
||||
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
|
||||
ctx.strokeStyle = 'rgba(239,230,216,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
|
||||
const k = zp * 0.9;
|
||||
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
|
||||
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ function showLoading(on, p, label, title) {
|
||||
$('loading').hidden = !on;
|
||||
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
|
||||
}
|
||||
function enterGame() {
|
||||
function enterGame(renderReady = false) {
|
||||
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
|
||||
$('cards').innerHTML = '';
|
||||
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
|
||||
GAME.status = ''; GAME.statusT = 0;
|
||||
$('title').hidden = true; $('hud').hidden = false;
|
||||
GAME.running = true; GAME.paused = false;
|
||||
buildSky(); resizeRender(); snapCamera(); resetMap();
|
||||
if (!renderReady) { buildSky(); resizeRender(); snapCamera(); }
|
||||
resetMap();
|
||||
GAME.lastAutosave = performance.now();
|
||||
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
|
||||
hudDirty = true; renderHotbar();
|
||||
@@ -31,6 +33,7 @@ async function setupNewWorld(seed, w, h, name, carried, title = 'Generating worl
|
||||
if (carried) applyCareerState(carried);
|
||||
}
|
||||
async function startNewWorld(seed, w, h, name = '') {
|
||||
if (!await canStartGame()) return false;
|
||||
try {
|
||||
const worlds = await listWorlds();
|
||||
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
|
||||
@@ -39,11 +42,13 @@ async function startNewWorld(seed, w, h, name = '') {
|
||||
showLoading(true, 0.9, 'Saving');
|
||||
await saveWorld('auto');
|
||||
showLoading(true, 1, 'Saving');
|
||||
showLoading(false); closeAllModals(); enterGame();
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); closeAllModals(); enterGame(true);
|
||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
||||
}
|
||||
async function startFromSave(sv, onProgress) {
|
||||
if (!await canStartGame()) return false;
|
||||
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
|
||||
auInit();
|
||||
GAME.paused = true;
|
||||
@@ -64,8 +69,8 @@ async function startFromSave(sv, onProgress) {
|
||||
GAME.drag = null; GAME.blow.clear();
|
||||
onProgress(0.95, 'Your gear and notebook');
|
||||
await loadCareer();
|
||||
showLoading(false);
|
||||
enterGame();
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); enterGame(true);
|
||||
return true;
|
||||
}
|
||||
async function loadSavedWorld(id) {
|
||||
@@ -80,7 +85,7 @@ async function loadSavedWorld(id) {
|
||||
}
|
||||
async function quitToTitle() {
|
||||
await saveWorld('main', true);
|
||||
GAME.running = false; closeAllModals();
|
||||
GAME.running = false; closeDevConsole(); closeAllModals();
|
||||
$('hud').hidden = true; $('title').hidden = false;
|
||||
refreshContinue();
|
||||
if (!TITLE.raf) drawTitle();
|
||||
@@ -135,7 +140,7 @@ async function renderWorlds(editId) {
|
||||
for (const w of list) {
|
||||
const row = el('div', { class: 'world-row', 'data-world': w.id });
|
||||
const mins = Math.floor((w.playTime || 0) / 60);
|
||||
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong><div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
|
||||
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong>${w.dev ? ' <span class="dev-badge">Dev console</span>' : ''}<div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
|
||||
const actions = el('div', { class: 'world-actions' });
|
||||
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
|
||||
if (editId === w.id) {
|
||||
|
||||
@@ -8,7 +8,7 @@ const SET_TABS = {
|
||||
{ k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
|
||||
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
|
||||
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' },
|
||||
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] },
|
||||
{ k: 'minimap', label: 'Show minimap', type: 'toggle' },
|
||||
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
|
||||
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
@@ -29,6 +29,7 @@ const SET_TABS = {
|
||||
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
|
||||
],
|
||||
Simulation: [
|
||||
{ k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v },
|
||||
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
|
||||
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
|
||||
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
|
||||
@@ -97,8 +98,6 @@ function renderSettings() {
|
||||
}
|
||||
}
|
||||
function applySettings() {
|
||||
// choosing Smooth again after it was too slow: try once more, at half resolution
|
||||
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
|
||||
saveSettings();
|
||||
document.documentElement.style.setProperty('--ui', SET.uiScale);
|
||||
if (R.canvas) { resizeRender(); R.snapNext = true; }
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
const SET_DEFAULT = {
|
||||
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true,
|
||||
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
|
||||
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
|
||||
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, workerCap: 16,
|
||||
volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true,
|
||||
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2,
|
||||
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2,
|
||||
};
|
||||
const SET = Object.assign({}, SET_DEFAULT);
|
||||
// sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once.
|
||||
function loadSettings() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s);
|
||||
if (typeof SET.cursorLabel === 'boolean') SET.cursorLabel = SET.cursorLabel ? 'ores' : 'off';
|
||||
if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); }
|
||||
} catch (e) { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Browser transport only; the simulation owns the schedule and shared state.
|
||||
let threadStarting = null;
|
||||
async function startThreads() {
|
||||
const count = Math.max(1, Math.min(16, Math.floor(SET.workerCap) || 16, (navigator.hardwareConcurrency || 3) - 2));
|
||||
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
|
||||
if (threadStarting) await threadStarting;
|
||||
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
|
||||
threadStarting = (async () => {
|
||||
if (simPool) for (const worker of simPool.workers) worker.terminate();
|
||||
simPool = null;
|
||||
if (!(mat.buffer instanceof SharedArrayBuffer)) throw Error('Shared memory is unavailable');
|
||||
const url = URL.createObjectURL(new Blob([SIM_WORKER_SOURCE, '\nonmessage=e=>threadMessage(e.data);'], {type:'text/javascript'}));
|
||||
const pool = { world: mat, workers: [], slots: [] };
|
||||
try {
|
||||
const ready = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const worker = new Worker(url), slot = threadSlot();
|
||||
pool.workers.push(worker); pool.slots.push(slot);
|
||||
ready.push(new Promise((resolve,reject) => {
|
||||
const deadline = performance.now() + 10000;
|
||||
worker.onerror = e => {
|
||||
const error = Error(e.message || 'Simulation worker failed');
|
||||
threadFailure(slot, error); reject(error);
|
||||
};
|
||||
const poll = () => {
|
||||
if (Atomics.load(slot.control,0)) resolve();
|
||||
else if (performance.now() > deadline) reject(Error('Simulation worker startup timed out'));
|
||||
else setTimeout(poll, 1);
|
||||
};
|
||||
poll();
|
||||
}));
|
||||
worker.postMessage({world:threadWorld(),slot});
|
||||
}
|
||||
await Promise.all(ready); simPool = pool;
|
||||
} catch (e) { for (const worker of pool.workers) worker.terminate(); throw e; }
|
||||
finally { URL.revokeObjectURL(url); }
|
||||
})();
|
||||
try { await threadStarting; } finally { threadStarting = null; }
|
||||
}
|
||||
@@ -1,4 +1,35 @@
|
||||
// The title screen art.
|
||||
// Startup capabilities and title art.
|
||||
const REQUIRE_WEBGPU = true;
|
||||
const REQUIRE_THREADS = true;
|
||||
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
|
||||
async function checkCapabilities() {
|
||||
try {
|
||||
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
|
||||
CAPS.webgpu = !!adapter && gpuLimitsOK(adapter) && await gpuInit(adapter);
|
||||
CAPS.requiredLimits = GPU_LIMITS;
|
||||
const info = adapter && adapter.info;
|
||||
if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description };
|
||||
} catch (e) { CAPS.webgpu = false; }
|
||||
CAPS.ready = true;
|
||||
renderCapabilityNotice();
|
||||
}
|
||||
function renderCapabilityNotice() {
|
||||
const messages = [];
|
||||
if (!CAPS.isolated) messages.push("Deepgrain needs to be served (for example with python3 tools/serve.py) so it can use all CPU cores; opened from a file it can't.");
|
||||
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
|
||||
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
|
||||
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
|
||||
if (GPU.error) messages.push('WebGPU could not start: ' + GPU.error);
|
||||
const notice = $('cap-notice');
|
||||
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
|
||||
notice.hidden = !messages.length;
|
||||
}
|
||||
async function canStartGame() {
|
||||
await CAPS_CHECK;
|
||||
return (!REQUIRE_WEBGPU || (CAPS.webgpu && GPU.ok)) && (!REQUIRE_THREADS || CAPS.isolated);
|
||||
}
|
||||
const CAPS_CHECK = checkCapabilities();
|
||||
|
||||
// ---------- Title art ----------
|
||||
const FONT5 = {
|
||||
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""An isolated Firefox title screen and three seconds of a new pixel world."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'tools'))
|
||||
from use_venv import ensure_playwright
|
||||
ensure_playwright()
|
||||
from browser import capabilities, consume_gpu_option, installed, launch
|
||||
|
||||
consume_gpu_option()
|
||||
if not installed('firefox'):
|
||||
print('SKIP firefox_smoke.py: Playwright Firefox is not installed')
|
||||
sys.exit(0)
|
||||
|
||||
with launch(browser_name='firefox') as (_, page, url):
|
||||
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(url)
|
||||
caps = capabilities(page)
|
||||
print('Firefox: ' + json.dumps(caps))
|
||||
assert caps['isolated']
|
||||
if not caps['webgpu']:
|
||||
assert page.locator('#cap-notice').is_visible()
|
||||
text = page.inner_text('#cap-notice')
|
||||
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
|
||||
print('PASS Firefox isolation and capability notice')
|
||||
if not caps['webgpu']:
|
||||
assert not page.evaluate('canStartGame()')
|
||||
assert not page.evaluate('startNewWorld(424242,512,384)')
|
||||
assert not page.evaluate('GAME.running')
|
||||
print('PASS Firefox without WebGPU cannot start a world')
|
||||
else:
|
||||
page.evaluate('startNewWorld(424242,512,384)')
|
||||
page.wait_for_function('GAME.running && !GAME.loading',timeout=60000)
|
||||
before=page.evaluate('tick');page.wait_for_timeout(3000)
|
||||
assert page.evaluate('tick')>before
|
||||
assert not errors,errors
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 89 KiB |