Deepgrain Foundry: move from a claude.ai artifact to a local repository
The game as of artifact version 24, ready for local development with coding agents. - Source split from three large files into 52 small ones under src/sim, src/render and src/ui, joined in the order in src/manifest.json by build.py. The joined code is unchanged apart from comments; every headless suite gives the same output, and screenshots in both looks are pixel-identical to before. - Light tidying with no change in behaviour: one movement scan loop, shared hazard-stripe and belt shader helpers, unused code removed, a shared check helper for the test suites. - World files can be exported as an ordinary download outside claude.ai. - Tests: broken old scripts removed, experiments moved to tools/, test/run_all.py added. Tools: shot.py (repeatable screenshots), where.py, map.py. - Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox), docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the owner's requests and an agent log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
shots/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,64 @@
|
||||
# Deepgrain Foundry
|
||||
|
||||
A falling-sand factory game in the browser, in the style of Sandustry.
|
||||
|
||||
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
|
||||
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
|
||||
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
|
||||
other goods at a trade dock, automating with belts, screw tubes, filters, fans, pipes and
|
||||
machines, until the land claim is paid off and beyond.
|
||||
|
||||
## Play
|
||||
|
||||
```sh
|
||||
python3 build.py
|
||||
python3 -m http.server -d dist # then open http://localhost:8000
|
||||
```
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
The controls are in the in-game notebook (N) under Controls. The basics:
|
||||
- WASD to fly;
|
||||
- 1–4 for the drill, vacuum, build and igniter;
|
||||
- left and right mouse to use the tool;
|
||||
- Q/E and R to change what it does;
|
||||
- F to use things;
|
||||
- Esc for the menu.
|
||||
|
||||
## Develop
|
||||
|
||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots:
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
python3 -m playwright install chromium
|
||||
```
|
||||
|
||||
```sh
|
||||
python3 build.py # build dist/index.html
|
||||
python3 test/run_all.py # headless test suites
|
||||
python3 test/run_all.py --browser # plus the browser tests
|
||||
python3 tools/shot.py shots/x.png --setup tools/scenes/clear-sky.js # a screenshot
|
||||
```
|
||||
|
||||
The source is small JavaScript files with no dependencies or bundler:
|
||||
- `src/sim/`: the simulation;
|
||||
- `src/render/`: drawing;
|
||||
- `src/ui/`: the browser shell.
|
||||
|
||||
`build.py` joins them in the order given in `src/manifest.json`.
|
||||
|
||||
## Documents
|
||||
|
||||
- `AGENTS.md` / `CLAUDE.md`: how coding agents work on this project.
|
||||
- `NOTES.md`: the to-do list.
|
||||
- `docs/PROJECT_MAP.md`: where everything is.
|
||||
- `docs/DESIGN.md`: what the game is meant to be, and the decisions made.
|
||||
- `docs/TESTING.md`: tests, screenshots and pitfalls.
|
||||
- `docs/systems/`: notes on individual systems.
|
||||
- `docs/history/requests.md`: the owner's requests while the game was built.
|
||||
- `docs/agent-log.md`: the work log.
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build Deepgrain Foundry into one self-contained HTML file.
|
||||
|
||||
The source files listed in src/manifest.json are joined in order (sim first, then the browser
|
||||
code) into one classic script, so they share one global scope and 'use strict' at the top of
|
||||
src/sim/base.js applies to all of it. No bundler, no dependencies.
|
||||
|
||||
Writes:
|
||||
dist/index.html the full page: open it, or serve dist/ with python -m http.server
|
||||
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
|
||||
from a browser error can be traced back: python3 tools/where.py LINE
|
||||
"""
|
||||
import json, pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent
|
||||
src = root / 'src'
|
||||
dist = root / 'dist'
|
||||
man = json.loads((src / 'manifest.json').read_text())
|
||||
|
||||
|
||||
def join(files):
|
||||
"""Join files with one newline between them; return the text and [first line, file] pairs."""
|
||||
parts, starts, line = [], [], 1
|
||||
for f in files:
|
||||
text = (src / f).read_text().rstrip('\n')
|
||||
starts.append([line, f])
|
||||
parts.append(text)
|
||||
line += text.count('\n') + 1
|
||||
return '\n'.join(parts), starts
|
||||
|
||||
|
||||
def main():
|
||||
shell = (src / man['shell']).read_text()
|
||||
css, _ = join(man['css'])
|
||||
js, starts = join(man['sim'] + man['client'])
|
||||
head, tail = shell.split('//__JS__')
|
||||
body = head.replace('/*__CSS__*/', css)
|
||||
page = body + js + tail
|
||||
pre = ('<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
'</head><body>')
|
||||
full = pre + page + '</body></html>\n'
|
||||
dist.mkdir(exist_ok=True)
|
||||
(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
|
||||
(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)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Design
|
||||
|
||||
What Deepgrain Foundry is meant to be, how its systems fit together, and the decisions made so
|
||||
far with their reasons. Read the relevant part before changing how the game plays. When the owner
|
||||
decides something new, add it under "Decisions" with the date.
|
||||
|
||||
## What the owner wants (stated by the owner)
|
||||
|
||||
- A **Sandustry-style factory and automation game**. Every cell is a fine falling-sand grain
|
||||
(one or two screen pixels at normal zoom), not a Terraria-size tile. The world is a
|
||||
Terraria-like side view with dark caves.
|
||||
- **Strictly a factory game.** No RPG elements, no enemies, and no character items besides the
|
||||
dig and build tools.
|
||||
- **Real physics, and player-built machines** instead of magic converter buildings. Furnaces are
|
||||
built from blocks, metal is cast in molds, and heat, air and gravity do the work. Physics
|
||||
hazards (heat, molten metal) damage builds only.
|
||||
- **Loose material falls; solids hold.**
|
||||
- **Copy and paste of builds.** Pasting places ghosts that the bot builds from the bag.
|
||||
- **Tool upgrades are earned in a satisfying, not tedious way**, not bought with arbitrary
|
||||
credits. So far that means casting them: a drill bit in a Bit mold, a bigger bag in a Tank mold.
|
||||
The owner wants the whole upgrade system redesigned (see NOTES.md).
|
||||
- **A hovering bot that flies freely.** A limited bag that can be upgraded.
|
||||
- **An economy instead of a rocket or other final goal.**
|
||||
- Sell materials at the trade dock; refined goods and alloys pay more.
|
||||
- Spend the money on buildings.
|
||||
- Pay off the $20,000 land claim, then keep growing.
|
||||
- A milestone ledger marks progress.
|
||||
- **Machines run without power**, for simplicity. The power code (water wheel, shaft, steam
|
||||
engine) is still there but switched off (`POWER_FREE` in `src/sim/pieces.js`). Those pieces are
|
||||
hidden from the menu, and old saves keep them.
|
||||
- **Look.** Not pixel art. The default is the "smooth look": a smooth, illustrated style with
|
||||
soft, clay-like lighting, drawn by WebGL2. The pixel look stays as an option and is what tests
|
||||
use.
|
||||
- **A water cycle, not springs.** Steam rises into clouds and comes back as rain, or as snow
|
||||
where it is freezing.
|
||||
- **Sound.** Generated ambient music that changes with depth, and retro chiptune effects.
|
||||
- **No external assets.** Everything is drawn and synthesized in code.
|
||||
- **Controls and settings.** Modern keyboard and mouse, plus gamepad support, and plenty of
|
||||
graphics and display settings.
|
||||
- **Platform.** A web game for now, built and tested by agents locally. A Rust version may come
|
||||
much later, for performance; keeping `src/sim/` free of the DOM keeps that open.
|
||||
|
||||
## The core loop
|
||||
|
||||
1. Dig with the resonance drill. Rock shatters into loose grains: rubble, ore, coal, clay, sand.
|
||||
2. Vacuum up what you want (tick materials to take only those), and blow it out where you need it.
|
||||
3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal,
|
||||
light it, and let the molten metal run into the mold.
|
||||
4. Knock out bars 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.
|
||||
|
||||
## Systems in brief
|
||||
|
||||
Details and numbers live in the code and in `docs/systems/`.
|
||||
|
||||
- **World:**
|
||||
- sizes 2048×1024, 3072×1280 or 4096×1536 grains;
|
||||
- layers: soil, stone, granite and basalt over a magma sea;
|
||||
- ores by depth:
|
||||
- coal, copper and clay near the top;
|
||||
- tin in the upper stone;
|
||||
- iron from about a quarter of the way down;
|
||||
- zinc in the middle (stone and granite);
|
||||
- silver deep in the granite;
|
||||
- gold deepest (granite and basalt);
|
||||
- ambient temperature falls with height and rises with depth.
|
||||
- **Materials:** 95 material ids, counting air and a few used for built parts. Each is in a
|
||||
phase: solid, powder, liquid, gas, or item (bars).
|
||||
- Heat flows between cells. Things melt, freeze, boil, burn and react.
|
||||
- Fire needs air: a buried fire chokes, and a fan blowing in makes it hotter.
|
||||
- Coal burns at about 1350°C in still air and about 2000°C with a fan.
|
||||
- **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,
|
||||
pipes, pumps, outlets.
|
||||
- Machines: fan, Refiner, Stone cutter, Silo.
|
||||
- Molds: ingot, bit, tank.
|
||||
- Build actions: drag for lines, Shift-drag for rectangles, Ctrl to replace, hold C / X and
|
||||
drag to copy / cut, V to paste, Ctrl+Z to undo, middle mouse (or G) to pick.
|
||||
- **Economy:**
|
||||
- The trade dock pays for anything dropped through its grate, at the market price.
|
||||
- Prices dip when you sell a lot of one thing and recover over minutes.
|
||||
- Up to 3 orders at a time pay well over market. Builders' orders want bulk rubble, gravel,
|
||||
sand or stone bricks.
|
||||
- You start with $250 and $20,000 of claim debt.
|
||||
- **Weather:** steam in the open sky becomes clouds. They drift and rain, or snow when cold.
|
||||
The world's water is counted, so rain only returns what evaporated.
|
||||
- **Waste rock and liquids:** see `docs/systems/stone-and-liquids.md`. It covers the Refiner,
|
||||
pipes, the silo, glass bricks, the stone cutter and builders' orders.
|
||||
|
||||
## Decisions
|
||||
|
||||
The newest are at the bottom. The dates are when the decision was made or built.
|
||||
|
||||
**Up to 2026-10-19** (early design, stated by the owner): everything in "What the owner wants"
|
||||
above, including:
|
||||
- the economy replacing a rocket goal;
|
||||
- machines without power;
|
||||
- the smooth illustrated look;
|
||||
- the water cycle instead of springs;
|
||||
- testing in the pixel look unless graphics are being checked.
|
||||
|
||||
**2026-10-19 / 20, dig tools.** The resonance drill replaced the old drill. See
|
||||
`docs/systems/dig-tools.md`.
|
||||
|
||||
**2026-10-22:**
|
||||
- **Rock too hard for your bit never crumbles, however small.** Crumbling it to ore would give ore
|
||||
without the right bit and confuse players. Crumbling it to stone would lose ore. Instead, world
|
||||
generation makes no tiny hard specks: pieces of 30 grains or fewer become the rock around them,
|
||||
and older saves are cleaned once on load. Iron and zinc veins are fewer and chunkier, with the
|
||||
same total ore.
|
||||
- Belts and filters next to each other look like one belt. Belt ends are square with rounded
|
||||
corners.
|
||||
- Dragging belts or filters sets their direction from the drag. R sets it when placing without
|
||||
dragging. An arrow shows the direction while dragging, and the selection card names it.
|
||||
- **Copy and cut** are held keys, not a tool. Hold C or X and drag a box; it acts on release.
|
||||
Copy and cut look different. Cut is destructive, but it can be pasted back or undone.
|
||||
- **Ctrl+Z undoes building** (up to 80 steps). The bot building ghosts is never a step of its own.
|
||||
- **Holding Ctrl** while placing replaces what is built there.
|
||||
- **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate.
|
||||
The game warns when a fan gets close to melting.
|
||||
- **The vacuum takes only ticked materials** when anything is ticked, even if the drag starts on
|
||||
something else.
|
||||
- **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore,
|
||||
roughly halving the waste. Ore and anything else it cannot grind drop straight through.
|
||||
- **Pipes, pumps and outlets** move liquids up to 500°C, one liquid per line. A line is stored as
|
||||
a count, not simulated grains. Water in pipes counts toward the world's water, so the weather
|
||||
does not replace it.
|
||||
|
||||
**2026-10-23:**
|
||||
- **Screw tubes 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. The owner
|
||||
reports this still misbehaves (see NOTES.md).
|
||||
- **Pick** (middle mouse, also G for now) selects the piece under the cursor with its direction,
|
||||
wall material and filter settings. The owner wants G dropped (see NOTES.md).
|
||||
- **The build menu** is a bar of tabs and icon tiles along the bottom: Structure, Moving,
|
||||
Liquids, Machines, Molds. The owner wants more changes (see NOTES.md).
|
||||
- **No length limit when dragging.** Blocks out of the bot's reach become ghosts that it builds
|
||||
when it gets there.
|
||||
- **Pipes are thin:** sand, water and anything else loose passes them. Pumps and outlets are
|
||||
solid.
|
||||
- **Uses for waste rock and sand:**
|
||||
- glass bricks (sand melted with a fan furnace, cast in an Ingot mold);
|
||||
- a Silo for storing one loose material, which should look like a real silo, not a magic
|
||||
black hole;
|
||||
- builders' orders for bulk rubble, gravel, sand and stone bricks;
|
||||
- a Stone cutter that makes stone bricks, which build cut-stone walls.
|
||||
- **Rubble sells for a pittance** ($0.02 a grain, gravel $0.03). Dirt still earns nothing.
|
||||
- **Re-refining sand stays allowed.** Refiner sand can be refined again, even in stacked
|
||||
refiners until none is left. The owner's reason: it is less profitable than making glass, so
|
||||
players will do it only when they need more ore rather than more money.
|
||||
- The project moved from a claude.ai artifact to local development with coding agents. It is
|
||||
built from small files, with `NOTES.md` as the owner's to-do inbox.
|
||||
|
||||
## Ideas not built yet
|
||||
|
||||
- **Concrete**, as a stronger wall material from rubble.
|
||||
- **Upgrade ideas for the drill**: an amplifier, a tuning fork, harmonics. Also a Grav pick
|
||||
upgrade tool and mining charges. See `docs/systems/dig-tools.md`.
|
||||
- The owner's ideas for later are in the "Future features and ideas" section of `NOTES.md`.
|
||||
@@ -0,0 +1,469 @@
|
||||
# Project map
|
||||
|
||||
Where everything is and how the parts connect. The index at the end lists every source file in
|
||||
build order, with its top-level functions and variables. It is generated: run
|
||||
`python3 tools/map.py` after adding, moving or renaming files or top-level functions. Edit this
|
||||
top part by hand when the structure changes.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
AGENTS.md, CLAUDE.md instructions for coding agents
|
||||
NOTES.md the owner's to-do inbox
|
||||
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/sim/ the simulation. No DOM. The headless tests load only these files.
|
||||
src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays
|
||||
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
|
||||
audio, the frame loop
|
||||
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
||||
t_smooth.py); run_all.py
|
||||
tools/ shot.py (screenshots), where.py, map.py, perf.py, playthrough.py,
|
||||
scenes/ (screenshot setups), experiments/ (measurements and reports)
|
||||
docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per system),
|
||||
history/ (the owner's requests), agent-log.md
|
||||
dist/, shots/ build output and screenshots (not committed)
|
||||
```
|
||||
|
||||
## How it runs
|
||||
|
||||
- **One script.** All files share one global scope; there is no import/export.
|
||||
- A function may call functions in any file.
|
||||
- Top-level `const`/`let` must be declared before top-level code that uses it at load time.
|
||||
That is why the manifest order matters: sim, then render, then ui.
|
||||
- `'use strict'` at the top of `src/sim/base.js` covers everything.
|
||||
- **The frame loop** (`frame` in `src/ui/loop.js`):
|
||||
1. read input;
|
||||
2. run up to `SET.maxSteps` simulation ticks, at 60 ticks a second of game time (none while
|
||||
paused);
|
||||
3. draw the world (`drawWorld`) and the overlays;
|
||||
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;
|
||||
- bars (`entTick`) and grains in flight (`flyTick`);
|
||||
- falling sand (`simMove`), then heat (`heatPass`);
|
||||
- every so often: molds, milestones, plants, cold, tree support;
|
||||
- the dock, the market and orders, saplings;
|
||||
- the bot building ghosts.
|
||||
- **Events.** The simulation reports what happened with `emit({ t: ... })`. `drainEvents`
|
||||
(`src/ui/events.js`) turns each event into sounds, toasts and cards.
|
||||
|
||||
## The world (src/sim/world.js)
|
||||
|
||||
- **The grid** is `W × H` grains; cell `i = y * W + x`. Worlds are 2048×1024, 3072×1280 or
|
||||
4096×1536.
|
||||
- **Per-cell typed arrays:**
|
||||
- `mat`: material id;
|
||||
- `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,
|
||||
pipe...);
|
||||
- `mach`: machine id;
|
||||
- `digp`: drill damage;
|
||||
- `occ`: covered by a bar;
|
||||
- `bgm`: the background layer (trees);
|
||||
- also `life`, `netId` and `noiseB`.
|
||||
- **Blocks:** the 4×4-grain build grid (`BS = 4`). `blockP` holds the piece code (`BP_*`) and
|
||||
`blockV` the variant (direction, wall material, flags) of each block, over `BW × BH`.
|
||||
- **Chunks** of 32×32 grains. `awake` marks chunks where things move and `hot` where heat flows;
|
||||
only those are simulated. `setCell` wakes what it touches; after writing arrays directly, call
|
||||
`wakeXY` / `wakeHeatXY`.
|
||||
- **Other state:**
|
||||
- `machines`: index = machine id; removed ones are `null`. Each is
|
||||
`{ type, x, y, w, h, dir, on, ... }`. Its footprint is hull cells (solid) and void cells
|
||||
(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);
|
||||
- `pipeNets`;
|
||||
- `ghostBlocks`, `ghostMachines`, `ghostMolds`.
|
||||
- **Player:** `bot` (position), `bag` (count per material), `player` (bit and tank tier, lens,
|
||||
flask), `ledger` (milestones reached), `stats`, `money`, `debt`, `orders`.
|
||||
|
||||
## Materials (src/sim/materials.js)
|
||||
|
||||
- **Ids** are constants (`EMPTY = 0` to `BARSB = 94`, `NMAT = 95`). They are stored in saves:
|
||||
never renumber them, only add new ones at the end.
|
||||
- **`def(id, {...})`** sets each material's properties:
|
||||
- `ph` (phase), `col`, `dens`;
|
||||
- `k` (heat conduction), `cap`;
|
||||
- `hard` (which bit can break it), `yld` (what it breaks into);
|
||||
- `melt` / `meltTo`, `freeze` / `freezeTo`, `boil` / `boilTo`;
|
||||
- `ign`, `flame`, `fuel`, `burnTo`;
|
||||
- `bag` (can be carried), `desc` (the text players read).
|
||||
These are copied into per-material typed arrays (`PH`, `DENS`, `KC`...).
|
||||
- **Phases:** `PH_SOLID` holds, `PH_POWDER` falls and piles, `PH_LIQUID`, `PH_GAS`, and
|
||||
`PH_ITEM` (bars, which are ents, not cells).
|
||||
- **Naming:**
|
||||
- `...V`: ore vein (solid);
|
||||
- `...ORE`: loose ore;
|
||||
- `M...`: molten;
|
||||
- `SCR...`: scrap;
|
||||
- `BAR...`: bar;
|
||||
- `R...`: rubble (`RSTONE`);
|
||||
- `L...`: loose (`LDIRT`, `LCLAY`, `LWOOD`).
|
||||
|
||||
## Building
|
||||
|
||||
- `PIECES` (`src/sim/pieces.js`) lists everything buildable:
|
||||
- kind `block` (a code `BP_*`);
|
||||
- kind `machine` (`bw × bh` blocks);
|
||||
- kind `mold`.
|
||||
`hidden` pieces are not in the menu. The menu's tabs and order are `BUILD_CATS` in
|
||||
`src/ui/build-menu.js`.
|
||||
- **Placing and removing:**
|
||||
- `placeBlock` / `removeBlock` (`blocks.js`);
|
||||
- `placeMachineAt` / `addMachine` / `removeMachine` (`actions.js`, `machines.js`);
|
||||
- `addMold` / `removeMold` (`molds.js`);
|
||||
- `removePieceAt` (`actions.js`) takes back whatever is at a cell;
|
||||
- `interactAt` (`actions.js`) is the F key.
|
||||
- **Ghosts** are what a paste or an out-of-reach drag leaves. The bot builds them from the bag
|
||||
(`ghostTick` in `undo.js`).
|
||||
- **Undo:** `undoRecord(label, fn)` wraps one player action; the place and remove functions
|
||||
record their own steps with `undoNote(op)`.
|
||||
- **Blueprints** (`blueprints.js`): `copyRegion`, `cutRegion`, `flipBlueprint`, `pasteGhost`.
|
||||
|
||||
## 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).
|
||||
|
||||
## The browser shell
|
||||
|
||||
- `GAME` (`src/ui/state.js`) holds the UI state: tool, selected piece, build tab, drag,
|
||||
clipboard, paste mode, overlays. `SET` holds the settings, kept in `localStorage`.
|
||||
- **Saves:**
|
||||
- worlds are kept in IndexedDB (`src/ui/saves.js`);
|
||||
- world files can be exported and imported;
|
||||
- `serializeWorld` / `deserializeWorld` (`src/sim/save.js`) do the conversion and migrate
|
||||
older saves.
|
||||
|
||||
## Where to find...
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Material properties and descriptions | `src/sim/materials.js` (`def`) |
|
||||
| Prices, orders, the dock | `src/sim/economy.js` |
|
||||
| Piece costs and descriptions | `src/sim/pieces.js` (`PIECES`) |
|
||||
| Build menu tabs, tiles and icons | `src/ui/build-menu.js` |
|
||||
| Key bindings | `src/ui/input.js` (keydown); what they do: `src/ui/actions.js` |
|
||||
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
|
||||
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
|
||||
| Controls help text | `src/ui/notebook.js` (`controlsHtml`) |
|
||||
| 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` |
|
||||
| Fans | `src/sim/machine-tick.js` (`fanBlow`) |
|
||||
| Refiner, Stone cutter, Silo | `src/sim/processors.js` |
|
||||
| Pipes, pumps, outlets | `src/sim/pipes.js` |
|
||||
| Molds and casting | `src/sim/molds.js` |
|
||||
| The drill | `src/sim/drill.js`; reach in `src/sim/tools.js` |
|
||||
| Vacuum and blowing | `src/sim/vacuum.js` |
|
||||
| Trees and saplings | `src/sim/nature.js` |
|
||||
| Weather | `src/sim/weather.js` |
|
||||
| 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` |
|
||||
| Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) |
|
||||
| Sound and music | `src/ui/audio.js` |
|
||||
|
||||
<!-- INDEX: generated by tools/map.py, do not edit below this line -->
|
||||
|
||||
## Index of source files (build order)
|
||||
|
||||
### src/sim/base.js (24 lines)
|
||||
Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plain data and functions, so the headless tests can run it and the layout can be ported to Rust later. This file: the random number generators and the phase ids every material has.
|
||||
- functions: rnd, mulberry32, hash2
|
||||
- variables: RS, PH_EMPTY, PH_SOLID, PH_POWDER, PH_LIQUID, PH_GAS, PH_ITEM
|
||||
|
||||
### src/sim/materials.js (159 lines)
|
||||
Material ids, the material table (def), per-material property arrays, cell flags and part ids, and drill/tank tiers.
|
||||
- functions: def, metalFamily
|
||||
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH
|
||||
|
||||
### src/sim/pieces.js (140 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, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
|
||||
|
||||
### src/sim/economy.js (152 lines)
|
||||
Money, prices, market saturation, the trade dock, buyer and builder orders.
|
||||
- functions: saleAs, priceFactor, priceNow, sellUnit, marketTick, resetEconomy, payClaim, dock, inDock, dockTick, sellToDock, standing, makeBuildersOrder, makeOrder, ordersTick, completeOrder, placeDock
|
||||
- variables: money, debt, earned, CLAIM_DEBT, PRICE, SAT_K, SAT_FLOOR, SAT_HALF, mkRecent, shipLog, saleAcc, floatAcc, orders, ordersDone, nextOrderT, orderSeq, ORDER_SLOTS, STANDING, BUILD_GOODS
|
||||
|
||||
### src/sim/progress.js (48 lines)
|
||||
Starter furnace designs and the milestone list shown in the notebook.
|
||||
- functions: smelterBlueprint
|
||||
- variables: STARTER_DESIGNS, MILESTONES
|
||||
|
||||
### src/sim/world.js (96 lines)
|
||||
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
||||
- functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
||||
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
||||
|
||||
### src/sim/cells.js (176 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, simMove
|
||||
- variables: SORTP
|
||||
|
||||
### src/sim/heat.js (246 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, ALLOY
|
||||
|
||||
### src/sim/machines.js (154 lines)
|
||||
Machines: the power network, footprints, placing and removing machines.
|
||||
- functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine
|
||||
- variables: nextPowerBuild
|
||||
|
||||
### src/sim/processors.js (99 lines)
|
||||
Processing machines: the Refiner, the Stone cutter and the Silo.
|
||||
- functions: refinerSlot, refinerTick, cutterTick, siloTick, siloFull
|
||||
- variables: REFINE, REFINE_SAND, REFINE_RATE, CUT_PER_BRICK, CUT_RATE, SILO_CAP, SILO_RATE
|
||||
|
||||
### src/sim/pipes.js (114 lines)
|
||||
Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains.
|
||||
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
|
||||
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
|
||||
|
||||
### src/sim/machine-tick.js (158 lines)
|
||||
Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
|
||||
- functions: engineIntake, fanBlow, machinesTick
|
||||
|
||||
### src/sim/conveyors.js (109 lines)
|
||||
How belts, filters and screw tubes move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear), the way a real belt carries a heap, not just its bottom layer. A filter's own material drops through it instead, unless the filter below is already full.
|
||||
- functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeIntake
|
||||
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
|
||||
|
||||
### src/sim/blocks.js (132 lines)
|
||||
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
||||
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||
|
||||
### src/sim/molds.js (284 lines)
|
||||
Molds: cavities that fill with molten metal (or glass) and are knocked out as bars.
|
||||
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, checkMolds, moldAt, knockNote, tally, collectMold, removeMold
|
||||
- variables: DROSS, grains
|
||||
|
||||
### src/sim/items.js (134 lines)
|
||||
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
|
||||
- functions: entSolidCell, entFreeAt, entMark, spawnBar, removeEnt, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
|
||||
- variables: BAR_W, BAR_H, entCurM, droppedLast
|
||||
|
||||
### src/sim/tools.js (52 lines)
|
||||
Tool helpers: reach, brushes, and plain digging.
|
||||
- functions: inReach, forBrush, drillReach, inDrillReach, toolDig
|
||||
|
||||
### src/sim/drill.js (161 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
|
||||
|
||||
### src/sim/nature.js (168 lines)
|
||||
Trees (background layer), acorns, saplings, and waking freezing water.
|
||||
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
|
||||
- variables: treeBases, saplings, treeDraw
|
||||
|
||||
### src/sim/vacuum.js (165 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
|
||||
|
||||
### src/sim/actions.js (86 lines)
|
||||
Player actions on built things: take back, place a machine or mold, gates, F to interact. Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
|
||||
- functions: removePieceAt, removePieceAt0, placeMachineAt, placeMoldAt, toggleGateAt, interactAt
|
||||
|
||||
### src/sim/blueprints.js (105 lines)
|
||||
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)
|
||||
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
|
||||
|
||||
### src/sim/bot.js (55 lines)
|
||||
The bot: movement and collision with the world.
|
||||
- functions: botBlocked, botOverlapSolid, botSlide, botStep
|
||||
- variables: SLIDE_MAX, SLIDE_STEP
|
||||
|
||||
### src/sim/ledger.js (43 lines)
|
||||
Milestone checks (the ledger) and the sky roof line used for cooling.
|
||||
- functions: checkLedger, updateRoof
|
||||
|
||||
### src/sim/weather.js (85 lines)
|
||||
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 (25 lines)
|
||||
simTick: one simulation step, in order.
|
||||
- functions: simTick
|
||||
|
||||
### src/sim/worldgen.js (315 lines)
|
||||
World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks.
|
||||
- functions: makeNoise, fbm, clearHardSpecks, generateWorld
|
||||
- variables: SPECK_MIN
|
||||
|
||||
### src/sim/save.js (112 lines)
|
||||
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
|
||||
- functions: serializeWorld, deserializeWorld
|
||||
- variables: SAVE_VERSION, TOFF
|
||||
|
||||
### src/render/setup.js (196 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
|
||||
- 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/world.js (277 lines)
|
||||
drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
- functions: drawWorld
|
||||
- variables: TUBE_CLASS
|
||||
|
||||
### src/render/machines.js (152 lines)
|
||||
Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world.
|
||||
- functions: cellRect, drawHazardRims, drawMachines, drawSilo
|
||||
|
||||
### src/render/overlays.js (230 lines)
|
||||
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
|
||||
### src/render/smooth.js (430 lines)
|
||||
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld fills (colour + class, colour behind, trees, occupancy fields, light, glow) into rounded, outlined, softly lit shapes at screen resolution. The 2D canvas then draws this image as its world layer, and every overlay (machines, bot, previews, labels) goes on top as before.
|
||||
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
|
||||
- variables: SM, SM_VS, SM_FS, smBytes
|
||||
|
||||
### src/ui/audio.js (209 lines)
|
||||
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
|
||||
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
||||
- variables: AU, SFX, ZONES
|
||||
|
||||
### src/ui/state.js (67 lines)
|
||||
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and the status line.
|
||||
- functions: loadSettings, saveSettings, loadCollect, 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/saves.js (106 lines)
|
||||
Saves in IndexedDB, world files (export / import) and the blueprint library.
|
||||
- functions: saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
||||
- variables: DB
|
||||
|
||||
### src/ui/session.js (87 lines)
|
||||
Starting, loading and quitting a game; modal dialogs; the pause menu.
|
||||
- functions: showLoading, enterGame, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause
|
||||
|
||||
### src/ui/settings.js (116 lines)
|
||||
The settings screen.
|
||||
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
|
||||
- variables: SET_TABS, setTab
|
||||
|
||||
### src/ui/notebook.js (201 lines)
|
||||
The notebook (N): milestones, market, wallet, blueprints, controls.
|
||||
- functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml
|
||||
- variables: nbTab, MARKET_GROUPS
|
||||
|
||||
### src/ui/hud.js (40 lines)
|
||||
The tool hotbar and what is selected.
|
||||
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece
|
||||
|
||||
### src/ui/build-menu.js (72 lines)
|
||||
The build menu along the bottom: categories, piece tiles and their icons.
|
||||
- functions: pieceIcon, catOf, renderBuildBar, setupBuildBar
|
||||
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey
|
||||
|
||||
### src/ui/selection.js (246 lines)
|
||||
The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
|
||||
- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
|
||||
- variables: lastBagKey
|
||||
|
||||
### src/ui/info.js (167 lines)
|
||||
Discovery cards, hover info (I) and the cursor label.
|
||||
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel
|
||||
|
||||
### src/ui/input.js (76 lines)
|
||||
Keyboard and mouse listeners.
|
||||
- functions: isTyping
|
||||
- variables: canvas
|
||||
|
||||
### src/ui/actions.js (244 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
|
||||
|
||||
### src/ui/gamepad.js (60 lines)
|
||||
Gamepad input.
|
||||
- functions: padPressed, pollPad
|
||||
- variables: PAD
|
||||
|
||||
### src/ui/previews.js (334 lines)
|
||||
Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
|
||||
- functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawPreviews, drawTag, drawSelection, drawPastePreview
|
||||
- variables: CRACK_N, CRACK_LV, SEL_COL
|
||||
|
||||
### src/ui/events.js (44 lines)
|
||||
Events from the simulation (emit) turned into sounds, toasts and cards.
|
||||
- functions: drainEvents
|
||||
|
||||
### src/ui/loop.js (117 lines)
|
||||
The frame loop and boot.
|
||||
- functions: scanVisible, frame, boot
|
||||
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
||||
|
||||
## Tests and tools
|
||||
|
||||
- `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/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
|
||||
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
|
||||
- `test/run_all.py`: Run the test suites and report.
|
||||
- `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_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_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
|
||||
- `test/t_molds.js`: Molds and furnaces stay usable: knock-outs leave the mold clean, plugs over the mold are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
||||
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Refiner grinds waste rock for trace ore.
|
||||
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
|
||||
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the stone cutter, the silo and builders' orders.
|
||||
- `test/t_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/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/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
|
||||
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
|
||||
- `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/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/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/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/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
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Testing
|
||||
|
||||
There are three layers. Use all three.
|
||||
|
||||
1. **Headless suites** (`test/t_*.js`) run the simulation in Node with no browser. They are fast
|
||||
and deterministic, and they are where most checks live.
|
||||
2. **Browser tests** (`test/play.py`, `test/t_smooth.py`) drive the real page in headless
|
||||
Chromium with real key, mouse and gamepad input. They check the UI, saving, the smooth look,
|
||||
and that the page throws no errors.
|
||||
3. **Screenshots** (`tools/shot.py`) show what something looks like. Open the PNG and look at it.
|
||||
|
||||
Run everything with `python3 test/run_all.py --browser`. It exits non-zero if anything failed and
|
||||
prints the failing checks.
|
||||
|
||||
## Headless suites
|
||||
|
||||
Each suite is a plain Node script:
|
||||
|
||||
```js
|
||||
// What this suite covers, in a sentence or two.
|
||||
const { load, suite, flatWorld, rect, count, ticks } = require('./harness');
|
||||
const { run } = load(); // loads src/sim/ (the files in src/manifest.json)
|
||||
const { check, done } = suite();
|
||||
|
||||
flatWorld(run, 240, 160, 100); // 240x160 grains, sky above row 100, stone below
|
||||
run('money = 5000; bot.x = 60; bot.y = 40;');
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);'); // true: free, no reach check
|
||||
rect(run, 84, 60, 95, 79, run('SAND'));
|
||||
ticks(run, 300);
|
||||
check('sand rides the belt off its end', count(run, run('SAND'), 120, 80, 239, 99) > 100, count(run, run('SAND')));
|
||||
done(); // prints ALL PASS or N FAILED, sets the exit code
|
||||
```
|
||||
|
||||
- `run(code)` evaluates JavaScript inside the simulation and returns the result. Every top-level
|
||||
name in `src/sim/` is available. Return plain values (numbers, strings, `JSON.stringify(...)`).
|
||||
- Harness helpers:
|
||||
- `flatWorld(run, w, h, ground)`;
|
||||
- `put(run, x, y, m)`;
|
||||
- `rect(run, x0, y0, x1, y1, m)`;
|
||||
- `count(run, m, x0, y0, x1, y1)`;
|
||||
- `maxTemp(run, ...)`;
|
||||
- `ticks(run, n)`;
|
||||
- `dump(run, x0, y0, x1, y1, withTemp)` for an ASCII picture of an area.
|
||||
- `check(name, ok, detail)`: name the behaviour the player would notice ("a pump moves water
|
||||
over a wall"). Put the measured numbers in `detail`, so a failure shows how far off it was.
|
||||
- Coordinates are in grains. Block coordinates (for `placeBlock`, `placeMachineAt`) are grains
|
||||
divided by 4.
|
||||
- Errors name the real file and line (`src/sim/pipes.js:42`).
|
||||
- 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
|
||||
refactor changed nothing, save every suite's output before and after, and diff them:
|
||||
|
||||
```sh
|
||||
for t in test/t_*.js; do node $t > /tmp/before/$(basename $t).txt; done # before the change
|
||||
for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # after
|
||||
diff -r /tmp/before /tmp/after
|
||||
```
|
||||
|
||||
## Browser tests
|
||||
|
||||
- `test/play.py` (about 2.5 minutes) builds a world through the title screen and plays it with real
|
||||
input. It checks the tools, building, copy/cut/paste, undo, the build menu, filters, pipes,
|
||||
the notebook and market, saving and continuing, exporting and importing a world file, the
|
||||
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.
|
||||
|
||||
## Screenshots: tools/shot.py
|
||||
|
||||
```sh
|
||||
python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
--js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7 --no-light
|
||||
```
|
||||
|
||||
- It starts a new world, pauses it at once, runs your setup code in the page, runs `--ticks`
|
||||
simulation steps, then saves the picture. Because the world is paused otherwise, the same
|
||||
options always give the same picture.
|
||||
- Setup code can use every game global, plus `SX`, `SY` (the bot's starting position).
|
||||
`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`.
|
||||
- `--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
|
||||
pair differs only where something really changed. A quick comparison with Pillow:
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageChops
|
||||
a, b = (Image.open(p).convert('RGB') for p in ('before.png', 'after.png'))
|
||||
print(ImageChops.difference(a, b).getbbox()) # None: identical
|
||||
```
|
||||
|
||||
To compare against an older version, build it somewhere else (for example from a git
|
||||
worktree) and pass that page with `--page`.
|
||||
- Page errors are printed with their source file and line, and the exit status is 1.
|
||||
- Look at the picture. A shot that "ran without errors" can still show the wrong thing.
|
||||
|
||||
## 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.
|
||||
- **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`.
|
||||
- **Playwright calls a string that evaluates to a function.** `page.evaluate("x = function () {}")`
|
||||
assigns the function and then calls it. Wrap statements in an arrow function:
|
||||
`page.evaluate("() => { ... }")`.
|
||||
- **Starting a world takes a moment.** `startNewWorld(seed, w, h)` generates on a timer. Wait
|
||||
for `GAME.running === true`. Then remove the welcome card if it is in the way:
|
||||
`document.querySelectorAll('.card, .toast').forEach(e => e.remove())`.
|
||||
- **Real-time play is not deterministic.** In the browser the simulation runs as fast as frames
|
||||
arrive. For repeatable results, pause (`GAME.paused = true`) and call `simTick()` yourself, as
|
||||
`tools/shot.py` does.
|
||||
- **World sizes** offered by the game: 2048×1024, 3072×1280, 4096×1536 grains.
|
||||
- **Rebuild after every source change** before any browser test or screenshot. They all load
|
||||
`dist/index.html`.
|
||||
|
||||
## Measuring
|
||||
|
||||
- `tools/perf.py`: renderer and tick times in the browser.
|
||||
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
|
||||
- `tools/experiments/flow.js`: belt and tube throughput in grains a second.
|
||||
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent log
|
||||
|
||||
What agents have done, newest first. Add an entry for every piece of work: the date, which
|
||||
note or request it was for, what changed, the files touched, the tests run, and anything left
|
||||
open or not verified. Keep each entry short; the git history has the details.
|
||||
|
||||
## 2026-10-23: export from claude.ai to a local repository (Claude, in claude.ai)
|
||||
|
||||
Request: split the source into small files, deduplicate and tidy a little, test fully, and
|
||||
write the notes, docs and agent instructions for local development.
|
||||
|
||||
- **Source split.**
|
||||
- `core.js` (3,688 lines), `main.js` (2,012) and `render.js` (938) became 52 files in
|
||||
`src/sim/`, `src/render/` and `src/ui/`, each with a header line. The build order lives in
|
||||
`src/manifest.json`.
|
||||
- Checked that the joined code was identical to before, apart from comments and blank lines.
|
||||
- Every headless suite gave the same output, line for line, before and after.
|
||||
- Screenshots of two scenes, in both looks, were pixel-identical before and after
|
||||
(`tools/shot.py --freeze`).
|
||||
- **Tidying, behaviour unchanged.**
|
||||
- One scan loop in `simMove` instead of two mirrored copies.
|
||||
- A `drawHazardRims` helper replaced 3 copies.
|
||||
- The belt and filter shader code shares `beltLineSD`, `bandRidge` and `bandColor`.
|
||||
- Removed unused `refundCost`, `wakeArea`, `brushFor`, and the `PARTS` / `PART_BY_ID` aliases.
|
||||
- The suites share `suite()` (check / done) from the harness, and failures now set the exit
|
||||
code.
|
||||
- **Tests.**
|
||||
- Six broken old scripts were deleted; they used functions removed long ago.
|
||||
- Experiments and reports moved to `tools/experiments/`.
|
||||
- `t_v2` / `t_v4` / `t_v9` were renamed `t_basics` / `t_world` / `t_conveyors`.
|
||||
- Added `test/run_all.py`.
|
||||
- **Tools.**
|
||||
- `tools/shot.py`: repeatable screenshots of a scene set up in JavaScript.
|
||||
- `tools/where.py`: maps built-page line numbers back to source files.
|
||||
- `tools/map.py`: the project map's index.
|
||||
- `tools/scenes/`: example scenes.
|
||||
- The game skips cursor previews when `GAME.hidePreviews` is set, for clean screenshots.
|
||||
- **World files outside claude.ai.**
|
||||
- Pause → Export world file only worked inside claude.ai, through its downloads hook.
|
||||
- It now falls back to an ordinary browser download (`exportWorld` in `src/ui/saves.js`).
|
||||
- `test/play.py` checks that an exported file downloads and imports back on the title screen
|
||||
as the same world.
|
||||
- **Build.** `build.py` writes `dist/index.html` (a full page), `dist/deepgrain-foundry.html`
|
||||
(the same without the document wrapper, as the claude.ai artifact used) and
|
||||
`dist/bundle-lines.json`.
|
||||
- **Docs.** `AGENTS.md`, `CLAUDE.md`, `README.md`, `NOTES.md`, `docs/PROJECT_MAP.md`,
|
||||
`docs/DESIGN.md`, `docs/TESTING.md`, `docs/systems/*`, `docs/history/requests.md`.
|
||||
- **Tests run.** All 11 headless suites pass, as do `test/play.py` (101 checks) and
|
||||
`test/t_smooth.py` (8 checks).
|
||||
- **Open.**
|
||||
- The owner reports that big piles on belts still only move at the top (NOTES.md); not yet
|
||||
reproduced.
|
||||
- The game's last claude.ai artifact was version 24.
|
||||
@@ -0,0 +1,82 @@
|
||||
# The owner's requests, in their own words
|
||||
|
||||
What the owner asked for while the game was built in claude.ai, kept word for word so the intent
|
||||
behind a feature can be checked. The newest are at the bottom. For what was decided and built,
|
||||
see `docs/DESIGN.md`.
|
||||
|
||||
Messages from before 2026-10-20 are not available word for word. The early design
|
||||
(2026-10-17 to 2026-10-19) is summarised in the "What the owner wants" section of
|
||||
`docs/DESIGN.md`.
|
||||
|
||||
## 2026-10-20 (sent again 2026-10-22)
|
||||
|
||||
> - A lot of times im leaving behind a few bits of materials unmined and thats leaving obstackles in my way. how can we handle that. its a little annoying.
|
||||
> - belts and filter belts should mesh together as one belt.
|
||||
|
||||
## 2026-10-22
|
||||
|
||||
> seems like sometimes when ticking items with R with he vacuum, then sucking items not starting with one that was ticked, will just suck everything. like if you tick coal and copper then click and drag from stone, you also pickup stone and other stuff.
|
||||
|
||||
> dont let the too hard specs crumble at all. world gen shouldnt generate viens that small anyway. leaving them creates friction for flying, crumbling them into what they are leads to player confusion about how they obtained them without the right bit, and crumbling to stone or something means lost ore.
|
||||
|
||||
> when drawing belts and filters, set their direction in the direction of drawing them. allow manual setting with R when they are placed without dragging. maybe show an arrow when dragging to place to indicate it and update the filter box on the top left where it says the direction and R.
|
||||
|
||||
> instead of rounded ends on the belts and filters, lets make them rectangles but somewhat rounded corners.
|
||||
|
||||
> instead of a copy tool. use modifyer keys like X for cut, c for copy. you hold the modifier while dragging a box around. when release it executesd the action. make it clear what function will happen. make them istinct. cut is destructive but it can be pasted back so its not too bad.
|
||||
|
||||
> add ctrl+z undo for building.
|
||||
|
||||
> when placing building objects, hold ctrl to go to replace mode to for it to replace.
|
||||
|
||||
> please explain how to use fans in a furnace. they seem to get destroyed when in contact with the heat from smelting.
|
||||
|
||||
> Lets allow refining of stone to get out trace amounts of common ores. maybe refine sand, dirt and gravel too?
|
||||
|
||||
> what can we do with all the stone in the world? itsa building material but other than that not much else use. the player may want to store it somewhere for use later. but what about the rest? it just sits somewhere clogging up the place. refining can help, refining can reduce it. what else can we do with it?
|
||||
|
||||
> need a way to move water. maybe pumps, pipes, and outlets.
|
||||
|
||||
## 2026-10-23
|
||||
|
||||
> it seems like the screw tubes can carry more materials than the belts can, that makes a buildup on that belt. they should be the same. if the player wants more input they can build additional screw tuibes and another level of belts or something.
|
||||
|
||||
> sand can be re-refined. that is nice but is it intended? im not sure if its good or not. in one hand the pl;ayer ends up with a lot of sand and not much to do with it all. but on the other hand re refining it means they could get more ores while reducing their sand stockpile. This also means its possible to stack refiners (one block clearance) and re-refine the sand multiple times until none is left. lets talk about this.
|
||||
|
||||
> add a dropper/ pick item key so you can select an item already built to build more.
|
||||
|
||||
> we need a better designed for ux, build menu system.
|
||||
|
||||
> there seems to be some sort of length limit on building belts with drag. lets remove that.
|
||||
|
||||
> in real life if a stack of items is on a belt, the belt does not move the items under them while leaving them stationary, thats not how physics works. lets make it more realistic.
|
||||
|
||||
> pipes should not block water.
|
||||
|
||||
> I also like all your ideas worth building next: Glass chain… Storage silo… Builders' orders… Cut stone or concrete… One thing about the silo though, lets make it look less like a magic black hole you can place and more like a real silo.
|
||||
|
||||
The four ideas were:
|
||||
- the glass chain (sand to glass);
|
||||
- a storage silo;
|
||||
- builders' orders for bulk rubble;
|
||||
- cut stone or concrete.
|
||||
|
||||
Cut stone was built; concrete was not.
|
||||
|
||||
### Moving to local development
|
||||
|
||||
> i have more notes for you but ive been thinking about exporting this to code it locally with claude code and codex. I would also need all the notes and other supporting documents and information about it and its development, and probably also an agents/claude.md files with good instructions for building this. Before i do this though i want to discuss how this would work and if the cli coding agents would be able to play test it and take screenshots as you are. i think for a while at least i might leave it as a web game like it is now but i might have the agent split the artifact into multiple modules for better coding practice and host it with python http server i think. i imagine you are playtesting the game with playwrite? i want the agent's to be able to playtest it the same way as you are. much later after the game is at a certain stage i may consider converting to a rust game for performance. but for now its fine as is. lets discuss this before moving forward with any export.
|
||||
|
||||
> I do have more notes, some that i was planning on giving you now and some for the future, but for now you could just not work on any of them and include them in a notes file i can add to locally after the export. the agent could read and modify the notes file removing items that have been completed. the completed items could be recorded in another log the agent uses internally for its self. ill be adding more notes to the notes file which it could read and complete when asked. for now a zip export is fine, ill have the agent add it to a gitea instance i have, here i think claude probably only supports github. make sure the agent instructions include things like to not hallucinate, assume or be too confident. confirm with different sources if not confident enough and use the web when needed. as for splitting it to modules i think keeping the build step is fine for now. but i do want to split the larger source files more. the agents will work better with smaller more concise files. the agents should also create a project map for their use so they can quickly find the information they need when its not in context. before the export, are there any more questions or concerns you have?
|
||||
|
||||
> q1: yes please split for me first that will make the agents get to work faster. maybe also deduplicate and refactor a little too while you are at it? ofcourse full testing first to confirm the game still works properly.
|
||||
> q2: the done please check section is actually a better idea.
|
||||
> q3: i usually run one or the other and if its claude code, i have instructions for it to locally delegate to codex some things to save tokens. so one at a time i guess. but codex and claude working side by side and some way for them to communicate sounds like a good idea, but for later, ive never done it that way and will need to figure out and plan how that will work first.
|
||||
>
|
||||
> concerns:
|
||||
> note 3 was not fixed, all of those notes i wrote after version 24.
|
||||
> i have exported the only save i care about.
|
||||
> claude code and codex both have web access.
|
||||
|
||||
The notes written after version 24 are in `NOTES.md`. The note "keep sand as is, allow
|
||||
re-refining..." was a decision, so it is recorded in `docs/DESIGN.md` instead.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Dig tools: decisions
|
||||
|
||||
Decided 2026-10-19. The resonance drill went in on 2026-10-20, and the same day its widths were
|
||||
reworked so that each one has a use. Handling of stray bits was added on 2026-10-22 and revised
|
||||
the same day: rock too hard for the bit never crumbles, and world generation makes no tiny hard
|
||||
specks instead. The code is in `src/sim/drill.js` and `src/sim/worldgen.js` (`clearHardSpecks`).
|
||||
|
||||
## Main tool: Resonance drill
|
||||
It replaced the old "darken and delete" drill cursor.
|
||||
|
||||
- **How it works.**
|
||||
- Holding the button sends pulses from the bot toward the cursor.
|
||||
- Each pulse is a fan of rays leaving the whole face of the bot, so rock right beside the bot
|
||||
is hit too.
|
||||
- Rock a pulse passes through shakes: it pales, shivers, and shows cracks that grow finer as
|
||||
it nears breaking. Then it shatters into its loose grains.
|
||||
- The pulse loses energy as it goes deeper.
|
||||
- **Pulse width** is changed on the fly with Q/E while the drill is selected. Each width has its
|
||||
own reach.
|
||||
|
||||
| Width | Shape | Reach | Good for |
|
||||
|---|---|---|---|
|
||||
| Narrow | A beam a little wider than the bot | ×1.45 the bit's reach | The fastest and longest tunnels, big enough to fly down |
|
||||
| Medium (default) | A cone | ×1 | General digging |
|
||||
| Wide | A broad fan | ×0.7 | Opening the most rock per second, but only close to the bot |
|
||||
|
||||
Measured with a stone bit, moving with the face: narrow tunnels about 13 grains a second,
|
||||
medium about 9, and wide about 7. Wide opens the most rock per second.
|
||||
- **Tuning** (R over a material, R again to untune): the pulse passes through everything else
|
||||
and shatters only that material.
|
||||
- **Loose grains** a pulse passes are jostled back toward the bot, so a shattered vein pours out
|
||||
of the wall and a bore clears itself.
|
||||
- **Hardness and base reach** still come from the cast bit tiers: stone, copper, iron, steel.
|
||||
- **Built blocks, machines and molds** soak up pulses unharmed and shield what is behind them.
|
||||
- **Trees** in the path come apart into wood chunks (not with a tuned pulse).
|
||||
- **The bot** never moves loose material. Sand, rubble and water pass straight through it.
|
||||
|
||||
### Stray bits
|
||||
Most leftover obstacles were specks of rock too hard for the current bit, such as iron ore or
|
||||
granite inside stone when you have a stone bit.
|
||||
|
||||
The rule: **rock too hard for your bit never breaks, crumbles or comes loose, however small.**
|
||||
Crumbling it into ore would let players get ore without the right bit, and confuse them.
|
||||
Crumbling it into stone would lose ore.
|
||||
|
||||
These keep specks out of the way instead:
|
||||
- **World generation makes no tiny hard specks.**
|
||||
- Iron and zinc come in fewer, chunkier veins, with the same total ore.
|
||||
- Any piece of 30 grains or fewer that is harder than the rock around it becomes that rock
|
||||
(`SPECK_MIN`).
|
||||
- Older saves get this cleanup once, when loaded.
|
||||
- Basalt dikes in the granite were removed, because they walled off parts of it.
|
||||
- **Cut-off pieces fall apart.** When a grain breaks and leaves a piece of natural rock of 16
|
||||
grains or fewer touching nothing else, the piece falls into its loose grains. This only happens
|
||||
if the bit could break every grain of it. A piece with too-hard rock in it stays whole.
|
||||
- **Thin rock breaks faster.** Corners, nubs and slivers with open space on two or more sides
|
||||
shake apart quicker than a flat face.
|
||||
- **The bot slips around lips.** Flying straight into a ledge a grain or two deep, it slides past
|
||||
instead of stopping dead. A real wall still stops it.
|
||||
- **Tuned pulses** do none of this; they only touch the tuned material.
|
||||
|
||||
Upgrade ideas, not built yet:
|
||||
- Amplifier: reaches deeper per pulse.
|
||||
- Tuning fork: tuning could become an upgrade instead of being there from the start.
|
||||
- Harmonics: breaks granite, then basalt (could replace the bit tiers).
|
||||
|
||||
The owner wants the whole upgrade system redesigned. The bit mold does not really fit a
|
||||
resonance drill (see NOTES.md).
|
||||
|
||||
## Later: Grav pick (upgrade tool)
|
||||
A gravity field locks onto a lump of rock, pries it out whole and carries it. It then crushes it
|
||||
into grains or throws it, into a hopper or a furnace.
|
||||
|
||||
It is too slow for normal digging, but useful for selective digging and for moving material.
|
||||
Upgrades:
|
||||
- field coil: bigger lumps;
|
||||
- stabilizer: range;
|
||||
- compressor: crushes faster;
|
||||
- sling: throws.
|
||||
|
||||
## Late game: Mining charges
|
||||
Placed charges that blast a radius and throw debris, for digging out big areas.
|
||||
|
||||
## Concept art
|
||||
Animated concepts for both tools are on the "Dig Tool Concepts" design canvas in claude.ai (not
|
||||
in this repository).
|
||||
@@ -0,0 +1,101 @@
|
||||
# Waste rock and moving liquids
|
||||
|
||||
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/economy.js` (prices, builders' orders).
|
||||
- Tests: `test/t_pipes.js`, `test/t_stone.js`, `test/t_conveyors.js`.
|
||||
|
||||
## Refiner (machine, 3×3 blocks, $120)
|
||||
An open hopper over a grinding floor. Grains that reach the floor are ground at about 30 a
|
||||
second and come out of a chute under the machine. If the space under the chute is blocked, it
|
||||
stops and says so.
|
||||
|
||||
For each grain ground, it tries for one grain of ore, in the order below. If none turns up, half
|
||||
the time a grain of fine sand comes out; otherwise nothing does. So a refiner roughly halves a
|
||||
pile of waste.
|
||||
|
||||
| Input | Chances of ore per grain |
|
||||
|---|---|
|
||||
| Stone rubble | copper ore 2.5%, coal 2%, iron ore 1.5%, tin ore 0.6% |
|
||||
| Granite rubble | iron ore 2%, zinc ore 1.2%, copper ore 1%, silver ore 0.2% |
|
||||
| Basalt rubble | iron ore 3%, zinc ore 1%, gold ore 0.3% |
|
||||
| Gravel | iron ore 3%, copper ore 2%, tin ore 1%, zinc ore 0.3% |
|
||||
| Sand | tin ore 1%, iron ore 0.8%, gold ore 0.15% |
|
||||
| Dirt | clay lumps 8%, coal 1%, copper ore 0.8%, acorns 0.3% |
|
||||
|
||||
Measured: 400 stone rubble gave 28 grains of ore and 190 of sand. Anything it cannot grind,
|
||||
such as ore, coal or liquids, drops straight through.
|
||||
|
||||
**Re-refining sand is allowed** (decided 2026-10-23). The sand a refiner puts out can go
|
||||
through another refiner.
|
||||
- Each pass gives about 2% ore and returns about 49% as sand.
|
||||
- Refining it again and again, as with stacked refiners, eventually turns all the sand into
|
||||
about 3.8% more ore per grain of sand. That is roughly 27% more ore from rubble overall.
|
||||
- The owner kept it because it pays less than making glass. Players will do it when they need
|
||||
ore rather than money.
|
||||
|
||||
## Pipes, pumps and outlets (blocks)
|
||||
- **Pipe ($8).**
|
||||
- Pipes are thin. Sand, water and anything else loose fall past them, so a run of pipe never
|
||||
dams anything.
|
||||
- Pipes, pumps and outlets that touch join into one line, which holds 16 grains per block.
|
||||
Its contents are kept as a count, not simulated grain by grain.
|
||||
- Taking a pipe back spills its share where it stood.
|
||||
- **Pump ($70).**
|
||||
- Draws liquid in at the tail of its arrow, from up to 3 grains away.
|
||||
- Pushes it into the line through its other sides, at about 60 grains a second while there is
|
||||
room.
|
||||
- Takes only liquids cooler than 500°C (water, molten tin, zinc, brass and bronze), and one
|
||||
liquid per line.
|
||||
- F turns it on and off. Pumps and outlets are solid blocks.
|
||||
- **Outlet ($15).** Pours the line's liquid out of the side its arrow points to, up to 2 grains a
|
||||
tick, wherever there is room. F opens and closes it.
|
||||
- **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
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Uses for stone and sand
|
||||
- **Glass bricks.**
|
||||
- Sand melts to molten glass in a fan furnace (peak about 2000°C measured).
|
||||
- Cast in an Ingot mold, it knocks out as glass bricks, worth $14 each.
|
||||
- Glass bricks build glass wall blocks. Glass is too brittle for a bit mold.
|
||||
- **Stone cutter** (machine, 2×2, $90).
|
||||
- A saw under a hopper. Every 8 grains of stone rubble become one stone brick, which drops out
|
||||
underneath; put a belt under it.
|
||||
- Anything else falls straight through.
|
||||
- **Cut stone walls.**
|
||||
- 2 stone bricks per block. They look like coursed masonry and stand more heat than rough
|
||||
stone (they melt at 1450°C).
|
||||
- Taking one back returns the bricks.
|
||||
- **Silo** (machine, 3×6, $180). It was made to look like a real silo, not a magic black hole.
|
||||
- A steel bin on legs, with a roof hatch, a sight-glass gauge, a ladder, and a cone with a
|
||||
slide gate.
|
||||
- Holds up to 4,000 grains of one loose material; drop grains in through the hatch.
|
||||
- F opens the gate, letting about 60 grains a second out underneath; F again shuts it.
|
||||
- It cannot be taken down or cut while it holds anything. Its contents are saved.
|
||||
- **Builders' orders.**
|
||||
- About 30% of new orders come from Builders. They want 400–1,500 rubble, 300–1,000 gravel,
|
||||
300–900 sand or 20–80 stone bricks.
|
||||
- They pay 4–6 times the market price for loose material, and 3–4 times for bricks.
|
||||
- Ordinary buyers never ask for rubble or gravel.
|
||||
- **Market prices.** Rubble sells for $0.02 a grain and gravel for $0.03 at the dock; a stone
|
||||
brick sells for $0.60. Dirt earns nothing.
|
||||
|
||||
## Other ways to deal with stone
|
||||
- The trade dock takes anything that drops through it, so it also works as a dump.
|
||||
- Wall blocks store rubble compactly (16 grains each); take them back to get the rubble.
|
||||
- The vacuum can blow rubble into old shafts as backfill.
|
||||
- Not built yet: concrete.
|
||||
@@ -0,0 +1,2 @@
|
||||
# browser tests and screenshots (test/play.py, test/t_smooth.py, tools/shot.py)
|
||||
playwright==1.56.0
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"shell": "shell.html",
|
||||
"css": [
|
||||
"styles/style.css"
|
||||
],
|
||||
"sim": [
|
||||
"sim/base.js",
|
||||
"sim/materials.js",
|
||||
"sim/pieces.js",
|
||||
"sim/economy.js",
|
||||
"sim/progress.js",
|
||||
"sim/world.js",
|
||||
"sim/cells.js",
|
||||
"sim/heat.js",
|
||||
"sim/machines.js",
|
||||
"sim/processors.js",
|
||||
"sim/pipes.js",
|
||||
"sim/machine-tick.js",
|
||||
"sim/conveyors.js",
|
||||
"sim/blocks.js",
|
||||
"sim/molds.js",
|
||||
"sim/items.js",
|
||||
"sim/tools.js",
|
||||
"sim/drill.js",
|
||||
"sim/nature.js",
|
||||
"sim/vacuum.js",
|
||||
"sim/actions.js",
|
||||
"sim/blueprints.js",
|
||||
"sim/undo.js",
|
||||
"sim/bot.js",
|
||||
"sim/ledger.js",
|
||||
"sim/weather.js",
|
||||
"sim/tick.js",
|
||||
"sim/worldgen.js",
|
||||
"sim/save.js"
|
||||
],
|
||||
"client": [
|
||||
"render/setup.js",
|
||||
"render/light.js",
|
||||
"render/world.js",
|
||||
"render/machines.js",
|
||||
"render/overlays.js",
|
||||
"render/smooth.js",
|
||||
"ui/audio.js",
|
||||
"ui/state.js",
|
||||
"ui/title.js",
|
||||
"ui/saves.js",
|
||||
"ui/session.js",
|
||||
"ui/settings.js",
|
||||
"ui/notebook.js",
|
||||
"ui/hud.js",
|
||||
"ui/build-menu.js",
|
||||
"ui/selection.js",
|
||||
"ui/info.js",
|
||||
"ui/input.js",
|
||||
"ui/actions.js",
|
||||
"ui/gamepad.js",
|
||||
"ui/previews.js",
|
||||
"ui/events.js",
|
||||
"ui/loop.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world.
|
||||
// ---------- Overlays ----------
|
||||
function cellRect(x, y, w, h) { const p = worldToScreenPx(x, y); return [p.x, p.y, w * R.zp, h * R.zp]; }
|
||||
// Yellow and black hazard stripes on both top corners of a hopper
|
||||
function drawHazardRims(ctx, sx, sy, sw, zp) {
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const rx = k ? sx + sw - 2 * zp : sx;
|
||||
for (let j = 0; j < 4; j++) { ctx.fillStyle = j & 1 ? '#1d1b18' : '#e0b43c'; ctx.fillRect(rx, sy + j * zp * 0.5, 2 * zp, zp * 0.5); }
|
||||
}
|
||||
}
|
||||
function drawMachines() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
for (const mm of machines) {
|
||||
if (!mm) continue;
|
||||
const [sx, sy, sw, sh] = cellRect(mm.x, mm.y, mm.w, mm.h);
|
||||
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
|
||||
ctx.save();
|
||||
if (mm.type === 'dock') {
|
||||
// hazard stripes along the hopper rim and a sign on a post
|
||||
drawHazardRims(ctx, sx, sy, sw, zp);
|
||||
// the grate in its floor
|
||||
ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp);
|
||||
ctx.fillStyle = 'rgba(150,160,170,0.8)';
|
||||
for (let k = 0; k < 20; k += 2) ctx.fillRect(sx + (2 + k) * zp, sy + 13 * zp, zp * 0.5, zp);
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
const fs = Math.max(9, Math.round(zp * 2.6));
|
||||
ctx.font = `700 ${fs}px ${R.monoFont}`;
|
||||
const label = 'TRADE DOCK', tw = ctx.measureText(label).width + fs;
|
||||
const px = sx + zp * 0.5, py = sy - zp * 7;
|
||||
ctx.fillStyle = '#4a4036'; ctx.fillRect(px + zp * 0.5, py + fs * 1.4, zp * 0.8, sy - py - fs * 1.4);
|
||||
ctx.fillStyle = '#2b4a3f'; ctx.fillRect(px, py, tw, fs * 1.5);
|
||||
ctx.strokeStyle = '#e7dccb'; ctx.lineWidth = Math.max(1, zp * 0.2); ctx.strokeRect(px + 1, py + 1, tw - 2, fs * 1.5 - 2);
|
||||
ctx.fillStyle = '#efe6d8'; ctx.textBaseline = 'middle'; ctx.fillText(label, px + fs * 0.5, py + fs * 0.78);
|
||||
} else if (mm.type === 'wheel') {
|
||||
const cx = sx + sw / 2, cy = sy + sh / 2, rad = sw / 2 - zp * 0.3;
|
||||
ctx.strokeStyle = '#8a6a44'; ctx.lineWidth = Math.max(1, zp * 0.7);
|
||||
ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.lineWidth = Math.max(1, zp * 0.5);
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const a = mm.angle + k * Math.PI / 4;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad); ctx.stroke();
|
||||
ctx.fillStyle = '#6e5232';
|
||||
ctx.fillRect(cx + Math.cos(a) * rad - zp * 0.6, cy + Math.sin(a) * rad - zp * 0.6, zp * 1.2, zp * 1.2);
|
||||
}
|
||||
ctx.fillStyle = '#4a3a28'; ctx.beginPath(); ctx.arc(cx, cy, zp * 1.1, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (mm.type === 'fan') {
|
||||
const cx = sx + sw / 2, cy = sy + sh / 2;
|
||||
const net = nets[mm.net], sat = net ? net.sat : 0;
|
||||
ctx.fillStyle = 'rgba(20,22,26,0.55)'; ctx.beginPath(); ctx.arc(cx, cy, sw * 0.38, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = mm.on ? (sat > 0.05 ? '#b9c4cc' : '#7d868e') : '#5a5f66';
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const a = mm.angle + k * Math.PI * 2 / 3;
|
||||
ctx.beginPath(); ctx.ellipse(cx + Math.cos(a) * sw * 0.18, cy + Math.sin(a) * sw * 0.18, sw * 0.17, sw * 0.07, a, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// direction arrow
|
||||
const dx = [1, 0, -1, 0][mm.dir], dy = [0, 1, 0, -1][mm.dir];
|
||||
ctx.strokeStyle = sat > 0.05 && mm.on ? 'rgba(160,220,255,0.7)' : 'rgba(160,170,180,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
ctx.beginPath(); ctx.moveTo(cx + dx * sw * 0.55, cy + dy * sh * 0.55); ctx.lineTo(cx + dx * sw * 0.9, cy + dy * sh * 0.9); ctx.stroke();
|
||||
} else if (mm.type === 'silo') {
|
||||
drawSilo(ctx, mm, sx, sy, zp);
|
||||
} else if (mm.type === 'cutter') {
|
||||
// hazard rims on the hopper, and a toothed saw blade in the floor that spins while it cuts
|
||||
const busy = tick - (mm.busyT || -99) < 12;
|
||||
drawHazardRims(ctx, sx, sy, sw, zp);
|
||||
const cx = sx + 4 * zp, cy = sy + 6.5 * zp, rr = zp * 1.4, a0 = busy ? R.frame * 0.35 : 0;
|
||||
ctx.fillStyle = '#c9cfd4'; ctx.beginPath();
|
||||
for (let t = 0; t < 16; t++) { const a = a0 + t * Math.PI / 8, r2 = t & 1 ? rr : rr * 0.78; ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = '#3a4048'; ctx.beginPath(); ctx.arc(cx, cy, rr * 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
|
||||
ctx.fillRect(sx + 2 * zp, sy + 7.6 * zp, 4 * zp, zp * 0.4);
|
||||
} else if (mm.type === 'refiner') {
|
||||
// hopper rim, two toothed grinding rollers in the floor (they turn while it works), the chute
|
||||
const busy = tick - (mm.busyT || -99) < 12;
|
||||
drawHazardRims(ctx, sx, sy, sw, zp);
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const cx = sx + (k ? 7.5 : 4.5) * zp, cy = sy + 11 * zp, rr = zp * 1.15, a0 = (busy ? R.frame * 0.25 : 0) * (k ? -1 : 1);
|
||||
ctx.fillStyle = '#2c3036'; ctx.beginPath(); ctx.arc(cx, cy, rr, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#9aa4ae'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); for (let t = 0; t < 6; t++) { const a = a0 + t * Math.PI / 3; ctx.moveTo(cx + Math.cos(a) * rr * 0.35, cy + Math.sin(a) * rr * 0.35); ctx.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); } ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
|
||||
ctx.beginPath(); ctx.moveTo(sx + 4 * zp, sy + 12 * zp); ctx.lineTo(sx + 8 * zp, sy + 12 * zp); ctx.lineTo(sx + 7 * zp, sy + 12.8 * zp); ctx.lineTo(sx + 5 * zp, sy + 12.8 * zp); ctx.closePath(); ctx.fill();
|
||||
} else if (mm.type === 'engine') {
|
||||
ctx.fillStyle = 'rgba(16,18,22,0.4)'; ctx.fillRect(sx + zp, sy + zp, sw - 2 * zp, sh - 3 * zp);
|
||||
const fx = sx + sw * (mm.dir ? 0.3 : 0.7), fy = sy + sh * 0.42, fr = sh * 0.3;
|
||||
ctx.strokeStyle = '#c3a06a'; ctx.lineWidth = Math.max(1, zp * 0.5);
|
||||
ctx.beginPath(); ctx.arc(fx, fy, fr, 0, Math.PI * 2); ctx.stroke();
|
||||
const ax = fx + Math.cos(mm.angle) * fr * 0.8, ay = fy + Math.sin(mm.angle) * fr * 0.8;
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(sx + sw * (mm.dir ? 0.75 : 0.25), fy); ctx.stroke();
|
||||
ctx.fillStyle = '#d6b27a'; ctx.fillRect(sx + sw * (mm.dir ? 0.75 : 0.25) - zp, fy - zp * 0.8, zp * 2, zp * 1.6);
|
||||
if (mm.out > 0.3) { ctx.fillStyle = 'rgba(230,236,244,0.5)'; ctx.fillRect(mm.dir ? sx - zp * 1.5 : sx + sw + zp * 0.5, sy + sh - zp * (1 + (R.frame >> 3) % 2), zp, zp); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
// The silo: legs and braces, a cone to a slide gate, a ribbed steel drum with a ladder and a sight
|
||||
// glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner.
|
||||
function drawSilo(ctx, mm, sx, sy, zp) {
|
||||
const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP);
|
||||
const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; };
|
||||
ctx.save();
|
||||
// legs, with short struts to the cone
|
||||
ctx.fillStyle = '#2f353b';
|
||||
ctx.fillRect(X(0.15), Y(19.5), zp * 0.75, zp * 4.5); ctx.fillRect(X(11.1), Y(19.5), zp * 0.75, zp * 4.5);
|
||||
ctx.strokeStyle = '#2f353b'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(X(0.8), Y(23)); ctx.lineTo(X(2.6), Y(21)); ctx.moveTo(X(11.2), Y(23)); ctx.lineTo(X(9.4), Y(21)); ctx.stroke();
|
||||
// the cone down to the gate
|
||||
ctx.fillStyle = steel(1, 11); ctx.beginPath(); ctx.moveTo(X(0.8), Y(19.8)); ctx.lineTo(X(11.2), Y(19.8)); ctx.lineTo(X(8.2), Y(23)); ctx.lineTo(X(3.8), Y(23)); ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.12); ctx.stroke();
|
||||
// the slide gate: across the opening when shut, pulled aside when open
|
||||
ctx.fillStyle = '#1c1f23'; ctx.fillRect(X(4), Y(23), zp * 4, zp);
|
||||
ctx.fillStyle = mm.open ? '#c99a3c' : '#6b737b';
|
||||
if (mm.open) ctx.fillRect(X(7.6), Y(23.15), zp * 2.4, zp * 0.6); else ctx.fillRect(X(3.6), Y(23.15), zp * 4.8, zp * 0.6);
|
||||
// the drum
|
||||
ctx.fillStyle = steel(0.5, 11.5); ctx.fillRect(X(0.5), Y(1.8), zp * 11, zp * 18.2);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.28)'; ctx.lineWidth = Math.max(1, zp * 0.14);
|
||||
for (let k = 1; k < 4; k++) { const yy = Y(1.8 + k * 4.55); ctx.beginPath(); ctx.moveTo(X(0.5), yy); ctx.lineTo(X(11.5), yy); ctx.stroke(); }
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
|
||||
for (let k = 1; k < 6; k++) { const xx = X(0.5 + k * 11 / 6); ctx.beginPath(); ctx.moveTo(xx, Y(1.8)); ctx.lineTo(xx, Y(20)); ctx.stroke(); }
|
||||
if (zp >= 4) { ctx.fillStyle = 'rgba(255,255,255,0.35)'; for (let k = 1; k < 4; k++) for (let j = 0; j < 8; j++) { ctx.beginPath(); ctx.arc(X(1 + j * 1.43), Y(1.8 + k * 4.55) - zp * 0.25, Math.max(0.6, zp * 0.09), 0, Math.PI * 2); ctx.fill(); } }
|
||||
// a ladder up the left side
|
||||
ctx.strokeStyle = '#2b3036'; ctx.lineWidth = Math.max(1, zp * 0.14);
|
||||
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(); }
|
||||
// what it holds, stencilled on the drum
|
||||
if (zp >= 3) {
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
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.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));
|
||||
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)
|
||||
ctx.fillStyle = '#5b646c'; ctx.fillRect(X(0.2), Y(1.2), zp * 11.6, zp * 0.8);
|
||||
ctx.fillStyle = '#7d878f'; ctx.fillRect(X(0.6), Y(0.6), zp * 2.4, zp * 0.7); ctx.fillRect(X(9), Y(0.6), zp * 2.4, zp * 0.7);
|
||||
ctx.fillStyle = '#15181b'; ctx.fillRect(X(3), Y(0.9), zp * 6, zp * 1.1);
|
||||
ctx.strokeStyle = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(X(8.9), Y(0.9)); ctx.lineTo(X(10.2), Y(-1.4)); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
||||
function spawnFloater(x, y, text) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, life: 70 }); }
|
||||
// Rain as thin falling streaks, snow as soft flakes
|
||||
function drawRain() {
|
||||
if (!flyers.length) return;
|
||||
const ctx = R.ctx, zp = R.zp, v = viewCells();
|
||||
const x0 = R.camX - 2, y0 = R.camY - 4, x1 = R.camX + v.w + 2, y1 = R.camY + v.h + 2;
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(176,204,236,0.5)'; ctx.lineWidth = Math.max(1, zp * 0.22);
|
||||
ctx.beginPath();
|
||||
let snow = false;
|
||||
for (const f of flyers) {
|
||||
if (!f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
||||
if (f.m !== WATER) { snow = true; continue; }
|
||||
const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2);
|
||||
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp);
|
||||
}
|
||||
ctx.stroke();
|
||||
if (snow) {
|
||||
ctx.fillStyle = 'rgba(244,247,252,0.9)';
|
||||
for (const f of flyers) {
|
||||
if (!f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
||||
const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42);
|
||||
ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Info overlay: a small arrow on every belt and screw tube 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));
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
||||
const s = BS * zp, r = Math.max(3, s * 0.22);
|
||||
ctx.save();
|
||||
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;
|
||||
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 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);
|
||||
// a chevron pointing (dx, dy)
|
||||
const px = -dy, py = dx;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c.x - dx * r * 0.45 + px * r * 0.7, c.y - dy * r * 0.45 + py * r * 0.7);
|
||||
ctx.lineTo(c.x + dx * r * 0.55, c.y + dy * r * 0.55);
|
||||
ctx.lineTo(c.x - dx * r * 0.45 - px * r * 0.7, c.y - dy * r * 0.45 - py * r * 0.7);
|
||||
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;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((run[0] / BW) | 0) * BS;
|
||||
const a = worldToScreenPx(x0, y), b = worldToScreenPx(x1, y + BS);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = strong ? 'rgba(255,190,90,0.95)' : 'rgba(236,226,210,0.45)'; ctx.lineWidth = Math.max(1, zp * (strong ? 0.5 : 0.25));
|
||||
if (!strong) ctx.setLineDash([zp, zp * 0.7]);
|
||||
ctx.strokeRect(a.x - zp * 0.3, a.y - zp * 0.3, b.x - a.x + zp * 0.6, b.y - a.y + zp * 0.6);
|
||||
ctx.restore();
|
||||
}
|
||||
// Tags over filters: what falls through (↓) and what rides along, and which way.
|
||||
// showAll tags every run of matching filters on screen; otherwise only the run under hoverB.
|
||||
function drawFilterTags(showAll, hoverB) {
|
||||
if (!filterCfg.size || (!showAll && hoverB < 0)) return;
|
||||
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
|
||||
const v = viewCells();
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1);
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1);
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
ctx.save();
|
||||
ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
|
||||
const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u);
|
||||
const runs = showAll ? [] : [filterRun(hoverB)];
|
||||
if (showAll) for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] !== BP_FILTER) continue;
|
||||
const run = filterRun(b);
|
||||
if (run[0] === b || bx === bx0) runs.push(run);
|
||||
bx = Math.max(bx, (run[run.length - 1] % BW));
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!run.length) continue;
|
||||
const b = run[0], c = filterCfg.get(b), ms = c ? c.m : [], inv = c ? c.inv : 0, right = blockV[b] & 128;
|
||||
// segment contents: swatches, or a word when it is "the rest"
|
||||
const segs = [
|
||||
{ icon: '↓', col: '#7fd6c2', mats: inv ? null : ms, word: inv ? (ms.length ? 'rest' : 'all') : 'none' },
|
||||
{ icon: right ? '→' : '←', col: '#ffd27a', mats: inv ? ms : null, word: inv ? 'none' : (ms.length ? 'rest' : 'all') },
|
||||
];
|
||||
let w = pad;
|
||||
for (const sg of segs) {
|
||||
sg.iw = ctx.measureText(sg.icon).width;
|
||||
sg.show = sg.mats && sg.mats.length ? sg.mats.slice(0, 5) : null;
|
||||
sg.more = sg.mats && sg.mats.length > 5 ? `+${sg.mats.length - 5}` : '';
|
||||
sg.cw = sg.show ? sg.show.length * (sw + 2) - 2 + (sg.more ? gap + ctx.measureText(sg.more).width : 0) : ctx.measureText(sg.word).width;
|
||||
w += sg.iw + gap + sg.cw + pad;
|
||||
}
|
||||
w += gap;
|
||||
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((b / BW) | 0) * BS;
|
||||
const p = worldToScreenPx((x0 + x1) / 2, y);
|
||||
let tx = Math.round(p.x - w / 2), ty = Math.round(p.y - th - 4 * u);
|
||||
if (tx + w < 0 || tx > R.canvas.width || ty + th < 0 || ty > R.canvas.height) continue;
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.86)'; ctx.fillRect(tx, ty, w, th);
|
||||
ctx.strokeStyle = 'rgba(236,226,210,0.22)'; ctx.lineWidth = u; ctx.strokeRect(tx + u / 2, ty + u / 2, w - u, th - u);
|
||||
let cx = tx + pad;
|
||||
segs.forEach((sg, k) => {
|
||||
if (k) { ctx.fillStyle = 'rgba(236,226,210,0.25)'; ctx.fillRect(cx - pad / 2 - gap / 2, ty + 3 * u, u, th - 6 * u); cx += gap; }
|
||||
ctx.fillStyle = sg.col; ctx.fillText(sg.icon, cx, ty + th / 2 + u * 0.5); cx += sg.iw + gap;
|
||||
if (sg.show) {
|
||||
for (const m of sg.show) { ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; ctx.fillRect(cx, ty + (th - sw) / 2, sw, sw); ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.strokeRect(cx + 0.5, ty + (th - sw) / 2 + 0.5, sw - 1, sw - 1); cx += sw + 2; }
|
||||
cx -= 2;
|
||||
if (sg.more) { cx += gap; ctx.fillStyle = '#e7dccb'; ctx.fillText(sg.more, cx, ty + th / 2 + u * 0.5); cx += ctx.measureText(sg.more).width; }
|
||||
} else { ctx.fillStyle = 'rgba(231,220,203,0.85)'; ctx.fillText(sg.word, cx, ty + th / 2 + u * 0.5); cx += sg.cw; }
|
||||
cx += pad;
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawFloaters() {
|
||||
const fl = R.floaters; if (!fl || !fl.length) return;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
ctx.save();
|
||||
ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
||||
for (let k = fl.length - 1; k >= 0; k--) {
|
||||
const f = fl[k]; f.life--; f.y -= 0.12;
|
||||
if (f.life <= 0) { fl.splice(k, 1); continue; }
|
||||
const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25);
|
||||
ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1);
|
||||
ctx.fillStyle = `rgba(255,210,122,${a})`; ctx.fillText(f.text, s.x, s.y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawMolds(hoverMold) {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.save();
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
ctx.font = `600 ${Math.max(10, Math.round(zp * 3.4))}px ${R.monoFont}`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
||||
for (const mo of molds) {
|
||||
const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h);
|
||||
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
|
||||
const ready = mo.state === 'ready', flawed = mo.state === 'flawed';
|
||||
ctx.setLineDash([zp, zp]);
|
||||
ctx.strokeStyle = ready ? 'rgba(255,200,90,0.95)' : flawed ? 'rgba(230,90,70,0.8)' : 'rgba(200,190,170,0.4)';
|
||||
ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp);
|
||||
ctx.setLineDash([]);
|
||||
if (ready || flawed || mo === hoverMold || mo.state === 'filling' || mo.state === 'short') {
|
||||
const label = ready ? `${mo.kind === 'ingot' ? (mo.ready.length === 1 ? 'Bar' : `${mo.ready.length} bars`) : mo.kind === 'bit' ? 'Bit' : 'Tank'} set · F` : flawed ? 'Flawed casting' : mo.state === 'short' ? 'Needs more metal' : mo.state === 'filling' ? `Filling ${Math.round(mo.fill * 100)}%` : MOLDS[mo.kind].name;
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.75)';
|
||||
const tw = ctx.measureText(label).width + zp * 2;
|
||||
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp * 5.2, tw, zp * 4.2);
|
||||
ctx.fillStyle = ready ? '#ffcf7a' : flawed ? '#ff8a72' : '#e7dccb';
|
||||
ctx.fillText(label, sx + sw / 2, sy - zp * 1.4);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawBot() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const [sx, sy, sw, sh] = cellRect(bot.x, bot.y, bot.w, bot.h);
|
||||
const cx = sx + sw / 2, cy = sy + sh / 2;
|
||||
const bob = Math.sin(R.frame * 0.12) * zp * 0.25;
|
||||
ctx.save();
|
||||
// thruster glow
|
||||
if (bot.thrust > 0.1) {
|
||||
const fl = (0.6 + Math.random() * 0.4) * bot.thrust;
|
||||
const grd = ctx.createRadialGradient(cx, sy + sh + bob, 0, cx, sy + sh + bob, zp * 3.2 * fl);
|
||||
grd.addColorStop(0, 'rgba(255,214,140,0.9)'); grd.addColorStop(1, 'rgba(255,120,40,0)');
|
||||
ctx.fillStyle = grd; ctx.fillRect(cx - zp * 4, sy + sh - zp + bob, zp * 8, zp * 5);
|
||||
}
|
||||
// body
|
||||
ctx.fillStyle = '#c8bfb2';
|
||||
ctx.beginPath(); ctx.roundRect(sx + zp * 0.2, sy + zp * 0.6 + bob, sw - zp * 0.4, sh - zp * 1.4, zp * 1.4); ctx.fill();
|
||||
ctx.fillStyle = '#8c8378';
|
||||
ctx.fillRect(sx + zp * 0.2, sy + sh - zp * 1.6 + bob, sw - zp * 0.4, zp * 0.8);
|
||||
// visor
|
||||
ctx.fillStyle = '#1b1715';
|
||||
const vx = cx + bot.face * zp * 0.5;
|
||||
ctx.beginPath(); ctx.roundRect(vx - zp * 1.5, sy + zp * 1.4 + bob, zp * 3, zp * 1.6, zp * 0.6); ctx.fill();
|
||||
ctx.fillStyle = bot.lamp ? '#ffb45a' : '#6b5a48';
|
||||
ctx.fillRect(vx + bot.face * zp * 0.5 - zp * 0.45, sy + zp * 1.8 + bob, zp * 0.9, zp * 0.8);
|
||||
// antenna
|
||||
ctx.strokeStyle = '#8c8378'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(cx - bot.face * zp, sy + zp * 0.6 + bob); ctx.lineTo(cx - bot.face * zp * 1.6, sy - zp * 0.6 + bob); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function spawnDebris(x, y, m) {
|
||||
if (!SET.particles || R.particles.length > 400) return;
|
||||
for (let k = 0; k < 2; k++) R.particles.push({ x: x + Math.random(), y: y + Math.random(), vx: (Math.random() - 0.5) * 0.5, vy: -Math.random() * 0.5, life: 20 + Math.random() * 20, r: COLR[m], g: COLG[m], b: COLB[m] });
|
||||
}
|
||||
function spawnSpark(x, y) {
|
||||
if (!SET.particles || R.particles.length > 400) return;
|
||||
R.particles.push({ x, y, vx: (Math.random() - 0.5) * 0.9, vy: -Math.random() * 0.8, life: 10 + Math.random() * 12, r: 255, g: 190 + Math.random() * 60, b: 90, spark: true });
|
||||
}
|
||||
// grains flying into the vacuum nozzle
|
||||
function spawnSuck(x, y, m) {
|
||||
if (!SET.particles || R.particles.length > 500) return;
|
||||
const v = (Math.random() - 0.5) * 2 * CVAR[m];
|
||||
R.particles.push({ x: x + 0.5, y: y + 0.5, vx: 0, vy: 0, life: 40, r: COLR[m] + v, g: COLG[m] + v, b: COLB[m] + v, suck: true });
|
||||
}
|
||||
function drawParticles() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const ps = R.particles;
|
||||
const tx = bot.x + bot.w / 2, ty = bot.y + bot.h / 2;
|
||||
for (let k = ps.length - 1; k >= 0; k--) {
|
||||
const p = ps[k];
|
||||
if (p.suck) {
|
||||
const dx = tx - p.x, dy = ty - p.y, d = Math.hypot(dx, dy);
|
||||
if (d < 2.5) { ps.splice(k, 1); continue; }
|
||||
const sp = Math.min(d, 0.6 + (40 - p.life) * 0.28);
|
||||
p.x += dx / d * sp; p.y += dy / d * sp; p.life--;
|
||||
if (p.life <= 0) { ps.splice(k, 1); continue; }
|
||||
} else {
|
||||
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
|
||||
if (p.life <= 0) { ps.splice(k, 1); continue; }
|
||||
}
|
||||
const s = worldToScreenPx(p.x, p.y);
|
||||
ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`;
|
||||
const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7);
|
||||
ctx.fillRect(s.x, s.y, sz, sz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow,
|
||||
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,
|
||||
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,
|
||||
frame: 0, lens: false, particles: [],
|
||||
};
|
||||
|
||||
// ---------- LUTs ----------
|
||||
const GLOW_W = new Float32Array(700), GLOW_R = new Float32Array(700), GLOW_G = new Float32Array(700), GLOW_B = new Float32Array(700);
|
||||
(function buildGlow() {
|
||||
const k = [[450, 0, 90, 10, 0], [560, 0.26, 140, 18, 0], [700, 0.46, 190, 34, 4], [850, 0.6, 224, 56, 8], [1000, 0.72, 244, 86, 16],
|
||||
[1150, 0.82, 255, 118, 30], [1300, 0.89, 255, 150, 52], [1500, 0.94, 255, 192, 96], [1750, 0.97, 255, 226, 158], [2200, 1, 255, 250, 228]];
|
||||
for (let n = 0; n < 700; n++) {
|
||||
const T = 400 + n * 4;
|
||||
let a = k[0], b = k[k.length - 1];
|
||||
for (let j = 0; j < k.length - 1; j++) if (T >= k[j][0] && T <= k[j + 1][0]) { a = k[j]; b = k[j + 1]; break; }
|
||||
let f = b[0] === a[0] ? 0 : (T - a[0]) / (b[0] - a[0]);
|
||||
if (T < k[0][0]) { a = b = k[0]; f = 0; }
|
||||
if (T > k[k.length - 1][0]) { a = b = k[k.length - 1]; f = 0; }
|
||||
GLOW_W[n] = a[1] + (b[1] - a[1]) * f; GLOW_R[n] = a[2] + (b[2] - a[2]) * f; GLOW_G[n] = a[3] + (b[3] - a[3]) * f; GLOW_B[n] = a[4] + (b[4] - a[4]) * f;
|
||||
}
|
||||
})();
|
||||
function glowIndex(T) { const n = ((T - 400) / 4) | 0; return n < 0 ? 0 : n > 699 ? 699 : n; }
|
||||
|
||||
const THERM = { inferno: null, ironbow: null, gray: null };
|
||||
(function buildTherm() {
|
||||
const pal = {
|
||||
inferno: [[0, 8, 6, 28], [20, 24, 14, 64], [60, 64, 18, 110], [150, 130, 30, 108], [350, 196, 54, 78], [700, 238, 104, 32], [1100, 250, 172, 30], [1600, 252, 232, 120], [2200, 255, 255, 236]],
|
||||
ironbow: [[0, 0, 0, 16], [20, 26, 0, 92], [60, 92, 0, 150], [150, 172, 20, 140], [350, 224, 70, 60], [700, 250, 140, 0], [1100, 255, 200, 40], [1600, 255, 240, 150], [2200, 255, 255, 255]],
|
||||
gray: [[0, 10, 10, 10], [20, 40, 40, 40], [60, 70, 70, 70], [150, 105, 105, 105], [350, 145, 145, 145], [700, 185, 185, 185], [1100, 215, 215, 215], [1600, 240, 240, 240], [2200, 255, 255, 255]],
|
||||
};
|
||||
for (const name in pal) {
|
||||
const k = pal[name], lut = new Uint8Array(551 * 3);
|
||||
for (let n = 0; n <= 550; n++) {
|
||||
const T = n * 4;
|
||||
let j = 0; while (j < k.length - 2 && T > k[j + 1][0]) j++;
|
||||
const a = k[j], b = k[j + 1];
|
||||
const f = Math.max(0, Math.min(1, (T - a[0]) / (b[0] - a[0])));
|
||||
lut[n * 3] = a[1] + (b[1] - a[1]) * f; lut[n * 3 + 1] = a[2] + (b[2] - a[2]) * f; lut[n * 3 + 2] = a[3] + (b[3] - a[3]) * f;
|
||||
}
|
||||
THERM[name] = lut;
|
||||
}
|
||||
})();
|
||||
function thermIdx(T) { const n = (T / 4) | 0; return (n < 0 ? 0 : n > 550 ? 550 : n) * 3; }
|
||||
|
||||
// Pipes, pumps and outlets, cell by cell: a copper wall round a bore that shows the line's liquid
|
||||
// (dark when empty, brighter the fuller it is, with a ripple while it flows), open toward the
|
||||
// pipes it joins. Pumps are blue-grey with an impeller and a grille on the intake side;
|
||||
// outlets have a dark mouth on the side they pour from.
|
||||
const PC = [0, 0, 0];
|
||||
function pipeCellColor(x, y, anim) {
|
||||
const lx = x & 3, ly = y & 3, b = (y >> 2) * BW + (x >> 2), code = blockP[b];
|
||||
const net = pipeNetOf(b), full = net && net.n > 0 ? Math.min(1, net.n / net.cap) : 0;
|
||||
const flowing = net && tick - net.flowT < 20;
|
||||
const mid = (lx === 1 || lx === 2), midY = (ly === 1 || ly === 2);
|
||||
let bore = mid && midY;
|
||||
if (!bore && code === BP_PIPE) {
|
||||
if (mid && ly === 0) bore = pipeJoins(b, 0) >= 0;
|
||||
else if (mid && ly === 3) bore = pipeJoins(b, 2) >= 0;
|
||||
else if (midY && lx === 3) bore = pipeJoins(b, 1) >= 0;
|
||||
else if (midY && lx === 0) bore = pipeJoins(b, 3) >= 0;
|
||||
}
|
||||
if (code === BP_PUMP || code === BP_OUTLET) {
|
||||
const v = code === BP_PUMP ? [-PUMP_V[blockV[b] & 3][0], -PUMP_V[blockV[b] & 3][1]] : OUTLET_V[blockV[b] & 3];
|
||||
const s = sideOf(v[0], v[1]), edge = s === 0 ? ly === 0 : s === 2 ? ly === 3 : s === 1 ? lx === 3 : lx === 0;
|
||||
const off = (blockV[b] & 4) !== 0;
|
||||
if (edge) {
|
||||
// intake grille / outlet mouth
|
||||
if (code === BP_PUMP) { const bar = (s === 0 || s === 2 ? lx : ly) & 1; PC[0] = bar ? 40 : 92; PC[1] = bar ? 44 : 100; PC[2] = bar ? 50 : 112; }
|
||||
else if (mid || midY) { const lq = flowing && !off && net.lm; PC[0] = lq ? COLR[net.lm] : 22; PC[1] = lq ? COLG[net.lm] : 20; PC[2] = lq ? COLB[net.lm] : 20; }
|
||||
else { PC[0] = 150; PC[1] = 96; PC[2] = 62; }
|
||||
return;
|
||||
}
|
||||
if (code === BP_PUMP) {
|
||||
if (bore) { const spin = flowing && !off ? ((anim >> 1) + lx + ly * 3) & 3 : (lx + ly) & 1; PC[0] = spin === 0 ? 196 : 70; PC[1] = spin === 0 ? 204 : 78; PC[2] = spin === 0 ? 214 : 92; }
|
||||
else { PC[0] = off ? 84 : 104; PC[1] = off ? 90 : 122; PC[2] = off ? 98 : 142; if (lx === 0 || ly === 0) { PC[0] += 18; PC[1] += 18; PC[2] += 18; } }
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (bore) {
|
||||
// a line that empties as fast as it fills still shows its liquid running through
|
||||
const lm = net ? (net.n > 0 ? net.m : flowing ? net.lm : 0) : 0;
|
||||
if (lm) {
|
||||
const k = 0.45 + 0.55 * full, ripple = flowing && ((x + y + (anim >> 2)) & 3) === 0 ? 22 : 0;
|
||||
PC[0] = COLR[lm] * k + ripple; PC[1] = COLG[lm] * k + ripple; PC[2] = COLB[lm] * k + ripple;
|
||||
} else { PC[0] = 30; PC[1] = 26; PC[2] = 24; }
|
||||
return;
|
||||
}
|
||||
// copper wall, lit on its top and left edges
|
||||
const hi = (lx === 0 || ly === 0) ? 24 : (lx === 3 || ly === 3) ? -22 : 0;
|
||||
PC[0] = 170 + hi; PC[1] = 106 + hi * 0.7; PC[2] = 66 + hi * 0.5;
|
||||
}
|
||||
// A pipe block is thin: only a cross two grains wide (toward the pipes it joins) is pipe; the
|
||||
// rest of the block shows whatever is there, water flowing past included.
|
||||
function pipeBodyCell(x, y) {
|
||||
const lx = x & 3, ly = y & 3, mx = lx === 1 || lx === 2, my = ly === 1 || ly === 2;
|
||||
if (mx && my) return true;
|
||||
const b = (y >> 2) * BW + (x >> 2);
|
||||
if (mx) return ly === 0 ? pipeJoins(b, 0) >= 0 : pipeJoins(b, 2) >= 0;
|
||||
if (my) return lx === 3 ? pipeJoins(b, 1) >= 0 : pipeJoins(b, 3) >= 0;
|
||||
return false;
|
||||
}
|
||||
// a thin pipe's own cells: copper, lit on one side, with the line's liquid showing through the bore
|
||||
function thinPipeColor(x, y, anim) {
|
||||
const lx = x & 3, ly = y & 3, b = (y >> 2) * BW + (x >> 2), net = pipeNetOf(b);
|
||||
const flowing = net && tick - net.flowT < 20, lm = net ? (net.n > 0 ? net.m : flowing ? net.lm : 0) : 0;
|
||||
const vert = (lx === 1 || lx === 2) && (ly === 0 || ly === 3), first = vert ? lx === 1 : ly === 1;
|
||||
let r = first ? 194 : 150, g = first ? 124 : 92, bl = first ? 80 : 56;
|
||||
if (lm) {
|
||||
const k = 0.4 + 0.35 * Math.min(1, net.n / net.cap), rip = flowing && ((x + y + (anim >> 2)) & 3) === 0 ? 20 : 0;
|
||||
r = r * (1 - k) + COLR[lm] * k + rip; g = g * (1 - k) + COLG[lm] * k + rip; bl = bl * (1 - k) + COLB[lm] * k + rip;
|
||||
}
|
||||
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; }
|
||||
// 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]; }
|
||||
TREE_R[WOOD] = 112; TREE_G[WOOD] = 82; TREE_B[WOOD] = 60;
|
||||
// background colors per layer (cave back walls)
|
||||
const BG = {
|
||||
dirt: [46, 33, 24], stone: [36, 36, 40], granite: [48, 38, 38], basalt: [22, 22, 26],
|
||||
};
|
||||
let SKY_R, SKY_G, SKY_B;
|
||||
function buildSky() {
|
||||
SKY_R = new Uint8Array(H); SKY_G = new Uint8Array(H); SKY_B = new Uint8Array(H);
|
||||
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W;
|
||||
for (let y = 0; y < H; y++) {
|
||||
const f = Math.min(1, y / (sm + 20));
|
||||
SKY_R[y] = 88 + f * 92; SKY_G[y] = 140 + f * 70; SKY_B[y] = 198 + f * 34;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function resizeRender() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3) * SET.renderScale;
|
||||
const cw = Math.max(1, Math.floor(window.innerWidth * dpr)), ch = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
if (R.canvas.width !== cw || R.canvas.height !== ch) { R.canvas.width = cw; R.canvas.height = ch; }
|
||||
let zp = SET.zoom * dpr;
|
||||
if (SET.integerScale) zp = Math.max(1, Math.round(zp));
|
||||
R.zp = zp;
|
||||
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);
|
||||
// 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.lw = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function viewCells() { return { w: R.canvas.width / R.zp, h: R.canvas.height / R.zp }; }
|
||||
|
||||
// lookX/lookY: how far the pointer is from screen center, in grains (camera leads toward it)
|
||||
function updateCamera(dt, lookX, lookY) {
|
||||
const v = viewCells();
|
||||
let tx = bot.x + bot.w / 2 - v.w / 2, ty = bot.y + bot.h / 2 - v.h / 2;
|
||||
if (lookX !== undefined) { tx += lookX * 0.22; ty += lookY * 0.22; }
|
||||
let k = SET.cameraSmooth >= 1 ? 1 : 1 - Math.pow(SET.cameraSmooth * 0.92, dt * 60);
|
||||
if (R.snapNext) { k = 1; R.snapNext = false; } // after a zoom, jump straight to the bot
|
||||
R.camX += (tx - R.camX) * k; R.camY += (ty - R.camY) * k;
|
||||
if (v.w >= W) R.camX = (W - v.w) / 2; else R.camX = Math.max(0, Math.min(W - v.w, R.camX));
|
||||
if (v.h >= H) R.camY = (H - v.h) / 2; else R.camY = Math.max(0, Math.min(H - v.h, R.camY));
|
||||
if (R.shake > 0) { R.camX += (Math.random() - 0.5) * R.shake; R.camY += (Math.random() - 0.5) * R.shake; R.shake *= 0.85; if (R.shake < 0.05) R.shake = 0; }
|
||||
}
|
||||
function snapCamera() {
|
||||
const v = viewCells();
|
||||
R.camX = bot.x + bot.w / 2 - v.w / 2; R.camY = bot.y + bot.h / 2 - v.h / 2;
|
||||
R.camX = Math.max(0, Math.min(Math.max(0, W - v.w), R.camX)); R.camY = Math.max(0, Math.min(Math.max(0, H - v.h), R.camY));
|
||||
}
|
||||
function screenToWorld(sx, sy) {
|
||||
const dpr = R.canvas.width / window.innerWidth;
|
||||
return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp };
|
||||
}
|
||||
function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; }
|
||||
@@ -0,0 +1,430 @@
|
||||
/* =====================================================================
|
||||
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld
|
||||
fills (colour + class, colour behind, trees, occupancy fields, light,
|
||||
glow) into rounded, outlined, softly lit shapes at screen resolution.
|
||||
The 2D canvas then draws this image as its world layer, and every
|
||||
overlay (machines, bot, previews, labels) goes on top as before.
|
||||
===================================================================== */
|
||||
|
||||
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
|
||||
|
||||
const SM_VS = `#version 300 es
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const SM_FS = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
|
||||
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
|
||||
uniform vec4 uView, uLightMap;
|
||||
uniform float uGlowK, uTime, uSurf, uRain;
|
||||
out vec4 o;
|
||||
|
||||
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
||||
float vn(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
|
||||
|
||||
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
|
||||
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
|
||||
vec4 bsp(sampler2D t, vec2 q) {
|
||||
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
|
||||
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
|
||||
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
|
||||
vec2 g0 = w0 + w1, g1 = w2 + w3;
|
||||
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
|
||||
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
|
||||
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
|
||||
}
|
||||
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
|
||||
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
|
||||
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
|
||||
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
|
||||
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
|
||||
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
|
||||
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
|
||||
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
|
||||
// the line of belts ends, the end is square with rounded corners. Negative inside.
|
||||
float beltSD(vec2 lb, bool endL, bool endR) {
|
||||
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
|
||||
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
|
||||
vec2 q = abs(lb - c) - hs + r;
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
|
||||
// the line only rounds off where the whole run ends.
|
||||
float beltLineSD(ivec2 c, vec2 lb) {
|
||||
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
|
||||
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5);
|
||||
}
|
||||
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
|
||||
// back along the bottom.
|
||||
float bandRidge(vec2 w, vec2 lb, float dir) {
|
||||
float run = lb.y < 2.0 ? 1.0 : -1.0;
|
||||
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
|
||||
}
|
||||
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
|
||||
|
||||
void main() {
|
||||
float zp = uView.z, px = 1.0 / zp;
|
||||
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
|
||||
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
|
||||
vec2 w = q + uOrigin; // position in the world
|
||||
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
|
||||
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
|
||||
float shc = uView.w * px; // screen height in grains
|
||||
ivec2 c = ivec2(floor(q));
|
||||
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
|
||||
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
|
||||
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
|
||||
vec4 own = cellAt(tCol, c); int nc = cls(own);
|
||||
vec2 s = smoothstep(0.1, 0.9, t);
|
||||
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
|
||||
|
||||
// fields, and everything that needs screen derivatives (worked out before any branching)
|
||||
vec4 Fb = bsp(tField, q);
|
||||
vec4 F = mix(lin(tField, q), Fb, 0.62);
|
||||
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
|
||||
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
|
||||
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
|
||||
float M = clamp(F.r + pw, 0.0, 1.0);
|
||||
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
|
||||
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
|
||||
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
|
||||
|
||||
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
|
||||
vec4 back = lin(tBack, q);
|
||||
vec3 C = back.rgb;
|
||||
float sky = back.a;
|
||||
if (sky > 0.01) {
|
||||
vec3 S = C;
|
||||
vec2 P0 = scr + cam * 0.08;
|
||||
// clouds; while it rains they thicken and turn grey
|
||||
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
|
||||
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
|
||||
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
|
||||
float k1 = 0.16; vec2 P1 = scr + cam * k1;
|
||||
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
|
||||
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
|
||||
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
|
||||
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
|
||||
S = mix(S, far, m1);
|
||||
float k2 = 0.38; vec2 P2 = scr + cam * k2;
|
||||
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
|
||||
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
|
||||
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
|
||||
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
|
||||
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
|
||||
S = mix(S, near, m2);
|
||||
C = mix(C, S, sky);
|
||||
}
|
||||
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
|
||||
float notSky = 1.0 - sky;
|
||||
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
|
||||
float near = textureLod(tField, q / uSize, 2.2).r;
|
||||
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
|
||||
|
||||
// ---- trees (behind the ground and everything on it)
|
||||
if (F.a > 0.2) {
|
||||
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
|
||||
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
if (sm > 1e-4) {
|
||||
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
|
||||
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
|
||||
float lv = smoothstep(0.6, 0.9, leaf);
|
||||
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
|
||||
float d = (F.a - 0.36) / fwA;
|
||||
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
|
||||
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- smoke, steam and flame: soft, see-through puffs
|
||||
if (Fb.b > 0.02) {
|
||||
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
|
||||
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
|
||||
}
|
||||
|
||||
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
|
||||
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
|
||||
if (anyL > 0.0 && lf > 0.3) {
|
||||
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
|
||||
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
|
||||
float gl = length(gL);
|
||||
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
|
||||
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
|
||||
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
|
||||
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
|
||||
}
|
||||
|
||||
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
|
||||
if (F.r > 0.3) {
|
||||
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
|
||||
float sm = m.x + m.y + m.z + m.w;
|
||||
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
|
||||
float bd = 1e9;
|
||||
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
|
||||
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
|
||||
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
|
||||
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
|
||||
if (sm < 1e-4) tc = own.rgb;
|
||||
tc *= 0.84 + 0.3 * fbm(w * 0.28);
|
||||
tc *= 0.95 + 0.1 * vn(w * 1.6);
|
||||
float gl = length(gT);
|
||||
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
|
||||
float band = 1.0 - smoothstep(0.5, 0.9, M);
|
||||
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
|
||||
tc *= 1.0 + 0.42 * lit * band;
|
||||
// the outline follows where ground and grains meet open space, never where rock meets gravel
|
||||
float dm = (M - 0.5) / fwM;
|
||||
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
|
||||
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
|
||||
}
|
||||
|
||||
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
|
||||
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
|
||||
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
|
||||
if (np > 0) {
|
||||
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
ivec2 o4 = ivec2(k & 1, k >> 1);
|
||||
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
|
||||
if (nk != 2) continue;
|
||||
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
|
||||
vec2 ce = vec2(c0 + o4) + 0.5;
|
||||
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
|
||||
vec2 dv = (q - ce) / r;
|
||||
float dd = length(dv);
|
||||
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
|
||||
}
|
||||
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
|
||||
if (closed && F.r < 0.5) cover = 1.0;
|
||||
if (cover > 0.0) {
|
||||
float r2 = min(1.0, dot(gd, gd));
|
||||
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
|
||||
float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
|
||||
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
|
||||
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
|
||||
C = mix(C, pc, cover);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
|
||||
if (nc == 4) C = own.rgb;
|
||||
else if (nc == 7 || nc == 8) {
|
||||
// belt: a rubber band round two copper rollers per block; the top run moves the load
|
||||
float dir = nc == 8 ? 1.0 : -1.0;
|
||||
vec2 lb = fract(w * 0.25) * 4.0;
|
||||
// outline: square ends with rounded corners where the line of belts (and filters) ends
|
||||
float sd = beltLineSD(c, lb);
|
||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
float edge = -sd;
|
||||
vec3 bc;
|
||||
if (edge < 0.8) {
|
||||
bc = bandColor(lb, bandRidge(w, lb, dir));
|
||||
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
|
||||
float rd = length(dv);
|
||||
bc = vec3(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
|
||||
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, bc, inside);
|
||||
}
|
||||
}
|
||||
else if (nc >= 9 && nc <= 12) {
|
||||
// screw tube: a copper pipe; what is inside shows through, and the screw's flights turn
|
||||
bool vert = nc == 9 || nc == 11;
|
||||
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
|
||||
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
|
||||
float wall = 0.62;
|
||||
vec3 tc;
|
||||
if (a < wall || a > 4.0 - wall) {
|
||||
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
|
||||
float cyl = sin(u2 * 1.5708);
|
||||
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
|
||||
if (a < wall) tc *= 1.08;
|
||||
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
|
||||
tc *= 1.0 - 0.3 * ring;
|
||||
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
|
||||
} else {
|
||||
bool full = own.r + own.g + own.b > 0.0;
|
||||
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
|
||||
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
|
||||
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
|
||||
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
|
||||
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
|
||||
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
|
||||
}
|
||||
C = tc;
|
||||
}
|
||||
else if (nc == 6) {
|
||||
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
|
||||
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
|
||||
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
|
||||
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
|
||||
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
|
||||
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
|
||||
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
|
||||
C = tc;
|
||||
}
|
||||
|
||||
else if (nc == 13 || nc == 14) {
|
||||
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
|
||||
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
|
||||
// band is perforated; its colour tag sits on the top run for what rides along, on the
|
||||
// bottom run for what drops through.
|
||||
float dir = nc == 14 ? 1.0 : -1.0;
|
||||
vec2 lb = fract(w * 0.25) * 4.0;
|
||||
vec4 tag = cellAt(tTree, c);
|
||||
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
|
||||
float sd = beltLineSD(c, lb);
|
||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
float edge = -sd;
|
||||
vec3 steel = vec3(0.42, 0.44, 0.5);
|
||||
// through the holes: whatever is behind (water, sky, rock), or what is passing through
|
||||
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
|
||||
vec2 cg = abs(fract(w) - 0.5);
|
||||
float bw2 = max(px * 0.9, 0.09);
|
||||
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
vec3 fc;
|
||||
if (edge < 0.8) {
|
||||
float rg = bandRidge(w, lb, dir);
|
||||
vec3 band = bandColor(lb, rg);
|
||||
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
|
||||
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
|
||||
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
fc = mix(steel * 0.78, behind, hole);
|
||||
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
|
||||
}
|
||||
C = mix(C, fc, inside);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- light, then the glow of hot things on top
|
||||
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
|
||||
if (uGlowK > 0.0) {
|
||||
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
|
||||
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
|
||||
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
|
||||
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
|
||||
C += g * uGlowK;
|
||||
}
|
||||
o = vec4(C, 1.0);
|
||||
}`;
|
||||
|
||||
function smCompile(gl, type, src) {
|
||||
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) { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; SM.surf = s / W; 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);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
// ---------- World pixels ----------
|
||||
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
const TUBE_CLASS = new Uint8Array(9); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12;
|
||||
function drawWorld() {
|
||||
const bw = R.bw, bh = R.bh, px = R.px;
|
||||
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
|
||||
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.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;
|
||||
if (sm) {
|
||||
mmask.fill(0);
|
||||
for (const mo of molds) {
|
||||
if (mo.x + 40 < vx0 || mo.x - 40 > vx0 + bw || mo.y + 40 < vy0 || mo.y - 40 > vy0 + bh) continue;
|
||||
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; }
|
||||
}
|
||||
}
|
||||
const OUT = sm ? 0x040a0a0c : 0xff0a0a0c;
|
||||
const 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] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; }
|
||||
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] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
|
||||
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; }
|
||||
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 === 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 && 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);
|
||||
}
|
||||
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) {
|
||||
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) 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();
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<title>Deepgrain Foundry</title>
|
||||
<meta name="claude:viewport-fit-sides" content="cover">
|
||||
<style>
|
||||
/*__CSS__*/
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="hud" hidden>
|
||||
<div class="corner tl">
|
||||
<div id="hotbar" class="plate interactive" role="toolbar" aria-label="Tools"></div>
|
||||
<div id="selcard" class="plate interactive" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="corner tr">
|
||||
<button id="wallet" class="plate interactive" aria-label="Money and the trade dock. Opens the Market page.">
|
||||
<span class="money num" id="w-money"></span>
|
||||
<span class="line" id="w-claim"></span>
|
||||
<span class="orders" id="w-orders"></span>
|
||||
</button>
|
||||
<div id="bagpanel" class="plate interactive">
|
||||
<div class="head">
|
||||
<span class="label">Bag</span>
|
||||
<span class="num" id="bagcap"></span>
|
||||
</div>
|
||||
<div class="bar" id="bagbar"><i></i></div>
|
||||
<div id="baglist"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="corner bl">
|
||||
<div id="info" class="plate"></div>
|
||||
</div>
|
||||
<div class="corner br">
|
||||
<div id="cards" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="corner bc">
|
||||
<div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div>
|
||||
<div id="ovbadges" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<div id="perf"></div>
|
||||
|
||||
<div id="title">
|
||||
<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 class="tmenu" id="tmenu">
|
||||
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
|
||||
<button class="btn" id="btn-new">New world <small>generate</small></button>
|
||||
<div class="plate newworld" id="newworld" hidden>
|
||||
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
|
||||
<label>Size
|
||||
<select id="nw-size">
|
||||
<option value="2048x1024">Small · 2048 × 1024</option>
|
||||
<option value="3072x1280" selected>Medium · 3072 × 1280</option>
|
||||
<option value="4096x1536">Large · 4096 × 1536</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn primary full" id="btn-generate">Generate and start</button>
|
||||
</div>
|
||||
<button class="btn" id="btn-title-settings">Settings <small>display · audio · controls</small></button>
|
||||
<button class="btn" id="btn-title-help">How to play <small>controls · first steps</small></button>
|
||||
</div>
|
||||
<p class="touchnote" id="touchnote" hidden>This prototype needs a keyboard and mouse, or a gamepad.</p>
|
||||
</div>
|
||||
<div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div>
|
||||
|
||||
<div id="m-pause" class="modal small" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2>Paused</h2><span class="label num" id="pause-meta"></span></div>
|
||||
<div class="mbody" style="display:flex;flex-direction:column;gap:8px">
|
||||
<button class="btn primary" id="pm-resume">Resume</button>
|
||||
<button class="btn" id="pm-notebook">Notebook</button>
|
||||
<button class="btn" id="pm-settings">Settings</button>
|
||||
<button class="btn" id="pm-save">Save now</button>
|
||||
<button class="btn" id="pm-export">Export world file</button>
|
||||
<button class="btn" id="pm-import">Import world file</button>
|
||||
<button class="btn" id="pm-title">Save and quit to title</button>
|
||||
<input type="file" id="importfile" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-settings" class="modal" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2>Settings</h2><button class="iconbtn" data-close aria-label="Close settings">Close <kbd>Esc</kbd></button></div>
|
||||
<div class="tabs" id="set-tabs" role="tablist"></div>
|
||||
<div class="mbody" id="set-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-notebook" class="modal" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2>Field notebook</h2><button class="iconbtn" data-close aria-label="Close notebook">Close <kbd>N</kbd></button></div>
|
||||
<div class="tabs" id="nb-tabs" role="tablist"></div>
|
||||
<div class="mbody" id="nb-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-dialog" class="modal small" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2 id="dlg-title"></h2></div>
|
||||
<div class="mbody" id="dlg-body"></div>
|
||||
<div class="mfoot" id="dlg-foot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
//__JS__
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
// Player actions on built things: take back, place a machine or mold, gates, F to interact.
|
||||
// Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
|
||||
function removePieceAt(x, y) {
|
||||
droppedLast = 0;
|
||||
const msg = removePieceAt0(x, y);
|
||||
return msg && droppedLast ? msg.replace('taken back.', 'taken back. Your bag is full, so the rest dropped where it stood.') : msg;
|
||||
}
|
||||
function removePieceAt0(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return '';
|
||||
const i = y * W + x;
|
||||
if (ghostBlocks.has(((y / BS) | 0) * BW + ((x / BS) | 0))) { ghostBlocks.delete(((y / BS) | 0) * BW + ((x / BS) | 0)); return 'Ghost removed.'; }
|
||||
const gbx = (x / BS) | 0, gby = (y / BS) | 0;
|
||||
for (let k = ghostMachines.length - 1; k >= 0; k--) { const g = ghostMachines[k], P = PIECE_BY_ID[g.type]; if (gbx >= g.bx && gby >= g.by && gbx < g.bx + P.bw && gby < g.by + P.bh) { ghostMachines.splice(k, 1); return 'Ghost removed.'; } }
|
||||
for (let k = ghostMolds.length - 1; k >= 0; k--) { const g = ghostMolds[k], md = MOLDS[g.kind]; if (gbx >= g.bx && gby >= g.by && gbx < g.bx + md.bw && gby < g.by + md.bh) { ghostMolds.splice(k, 1); return 'Ghost removed.'; } }
|
||||
if (mach[i] && machines[mach[i]]) {
|
||||
const P = PIECE_BY_ID[machines[mach[i]].type];
|
||||
if (P.fixed) return '';
|
||||
if (siloFull(machines[mach[i]])) return `Empty the silo first: it holds ${machines[mach[i]].sn.toLocaleString('en-US')} ${MD[machines[mach[i]].sm].name.toLowerCase()}. Press F on it to open its gate.`;
|
||||
removeMachine(mach[i], true, false); return `${P.name} taken back.`;
|
||||
}
|
||||
const mo = moldAt(x, y);
|
||||
if (mo) { removeMold(mo); return `${MOLDS[mo.kind].name} taken back.`; }
|
||||
const { bx, by } = blockOf(x, y), b = by * BW + bx;
|
||||
if (blockP[b]) { const nm = PIECE_BY_CODE[blockP[b]].name; removeBlock(bx, by); return `${nm} taken back.`; }
|
||||
return '';
|
||||
}
|
||||
function placeMachineAt(pid, dir, bx, by) {
|
||||
const P = PIECE_BY_ID[pid];
|
||||
const err = canPlaceMachine(pid, bx, by); if (err) return err;
|
||||
if (!canAfford(P.cost, 1)) return needText(P.cost, 1);
|
||||
payCost(P.cost, 1);
|
||||
addMachine(pid, bx, by, dir);
|
||||
stats.placed++;
|
||||
undoNote({ t: 'mach+', type: pid, bx, by });
|
||||
return '';
|
||||
}
|
||||
function placeMoldAt(kind, bx, by, wallLoose) {
|
||||
const err = canPlaceMold(kind, bx, by); if (err) return err;
|
||||
const r = addMold(kind, bx, by, wallLoose, false);
|
||||
if (typeof r !== 'string') undoNote({ t: 'mold+', kind, bx, by });
|
||||
return typeof r === 'string' ? r : '';
|
||||
}
|
||||
function toggleGateAt(x, y) {
|
||||
const i0 = y * W + x;
|
||||
if (!(mat[i0] === GATE || part[i0] === P_GOPEN)) return false;
|
||||
const open = mat[i0] === GATE; // currently closed -> open
|
||||
const seenS = new Set([i0]), st = [i0], cells = [];
|
||||
while (st.length && cells.length < 1600) {
|
||||
const i = st.pop(); cells.push(i);
|
||||
const cx = i % W;
|
||||
for (const j of [cx > 0 ? i - 1 : -1, cx < W - 1 ? i + 1 : -1, i - W, i + W]) {
|
||||
if (j < 0 || j >= N || seenS.has(j)) continue;
|
||||
if (mat[j] === GATE || part[j] === P_GOPEN) { seenS.add(j); st.push(j); }
|
||||
}
|
||||
}
|
||||
for (const i of cells) {
|
||||
const cx = i % W, cy = (i / W) | 0;
|
||||
if (open) { if (mat[i] === GATE) { mat[i] = EMPTY; flags[i] = 0; part[i] = P_GOPEN; } }
|
||||
else if (part[i] === P_GOPEN) {
|
||||
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]; }
|
||||
}
|
||||
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
|
||||
}
|
||||
wakeXY(cx, cy);
|
||||
}
|
||||
return open ? 'open' : 'closed';
|
||||
}
|
||||
function interactAt(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return '';
|
||||
const g = toggleGateAt(x, y);
|
||||
if (g) { emit({ t: 'sfx', s: 'gate' }); return g === 'open' ? 'Gate opened.' : 'Gate closed.'; }
|
||||
const mo = moldAt(x, y);
|
||||
if (mo) { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; }
|
||||
const pb = ((y / BS) | 0) * BW + ((x / BS) | 0);
|
||||
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
|
||||
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
|
||||
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
|
||||
}
|
||||
const id = mach[y * W + x];
|
||||
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open. It is empty.') : 'Silo gate shut.'; }
|
||||
if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; }
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
/* Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plain data and
|
||||
functions, so the headless tests can run it and the layout can be ported to Rust later.
|
||||
This file: the random number generators and the phase ids every material has. */
|
||||
|
||||
// ---------- RNG ----------
|
||||
let RS = 0x2545F491 | 0;
|
||||
function rnd() { RS ^= RS << 13; RS ^= RS >>> 17; RS ^= RS << 5; return (RS >>> 0) * 2.3283064365386963e-10; }
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; a = a + 0x6D2B79F5 | 0;
|
||||
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function hash2(x, y, s) {
|
||||
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(s, 1442695041);
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
return ((h ^ (h >>> 16)) >>> 0);
|
||||
}
|
||||
|
||||
// ---------- Phases ----------
|
||||
const PH_EMPTY = 0, PH_SOLID = 1, PH_POWDER = 2, PH_LIQUID = 3, PH_GAS = 4, PH_ITEM = 5; // items (bars) never sit in the grid
|
||||
@@ -0,0 +1,132 @@
|
||||
// Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
||||
// ---------- 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) {
|
||||
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 (occ[i]) return 'A bar is in the way';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// v: wall material (loose id) for walls, direction for belts and tubes
|
||||
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 cost = pieceCost(code, v);
|
||||
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
|
||||
const cells = 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); }
|
||||
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
|
||||
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_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
|
||||
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
|
||||
}
|
||||
if (isPipeCode(code)) pipesDirty = true;
|
||||
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
|
||||
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||
stats.placed++;
|
||||
undoNote({ t: 'block+', bx, by, code });
|
||||
return '';
|
||||
}
|
||||
// How many of a block's cells still look like the piece the registry says is there
|
||||
function blockIntegrity(b) {
|
||||
const code = blockP[b], v = blockV[b];
|
||||
if (!code) return 0;
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
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++; }
|
||||
else if (code === BP_SHAFT) { if (m === SHAFT) 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 (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
|
||||
}
|
||||
void v;
|
||||
return n;
|
||||
}
|
||||
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 });
|
||||
// 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 (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
|
||||
if (BAR_OF[m] && PH[v] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); }
|
||||
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place
|
||||
else setCell(i, EMPTY, temp[i], 0);
|
||||
n++;
|
||||
}
|
||||
} 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) {
|
||||
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE) { 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 (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); }
|
||||
blockP[b] = 0; blockV[b] = 0;
|
||||
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) {
|
||||
const 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;
|
||||
}
|
||||
function blockRect(bx0, by0, bx1, by1) {
|
||||
const out = [];
|
||||
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); }
|
||||
// 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));
|
||||
return blockV[b] === v;
|
||||
}
|
||||
// replace: a block already built in the way is taken down (and refunded) to make room
|
||||
function placeBlocks(code, v, list, extra, replace) {
|
||||
let n = 0, last = '';
|
||||
for (const [bx, by] of list) {
|
||||
if (!blockInReach(bx, by)) { last = 'Out of reach'; continue; }
|
||||
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 (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
|
||||
removeBlock(bx, by);
|
||||
}
|
||||
const r = placeBlock(code, v, bx, by, false, extra);
|
||||
if (r) { last = r; if (r.startsWith('Not enough') || r.startsWith('Pick')) break; } else n++;
|
||||
}
|
||||
return { n, status: n ? '' : last };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
|
||||
// ---------- Blueprints ----------
|
||||
// A blueprint is a list of pieces in block coordinates, so it always pastes on the grid.
|
||||
function copyRegion(x0, y0, x1, y1) {
|
||||
const bx0 = Math.max(0, Math.floor(Math.min(x0, x1) / BS)), bx1 = Math.min(BW - 1, Math.floor(Math.max(x0, x1) / BS));
|
||||
const by0 = Math.max(0, Math.floor(Math.min(y0, y1) / BS)), by1 = Math.min(BH - 1, Math.floor(Math.max(y0, y1) / BS));
|
||||
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]]);
|
||||
}
|
||||
for (const mm of machines) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
|
||||
const mbx = mm.x / BS, mby = mm.y / BS, P = PIECE_BY_ID[mm.type];
|
||||
if (mbx >= bx0 && mby >= by0 && mbx + P.bw - 1 <= bx1 && mby + P.bh - 1 <= by1) ms.push([mm.type, mbx - bx0, mby - by0, mm.dir]);
|
||||
}
|
||||
for (const mo of molds) {
|
||||
const mbx = mo.x / BS, mby = mo.y / BS, md = MOLDS[mo.kind];
|
||||
if (mbx >= bx0 && mby >= by0 && mbx + md.bw - 1 <= bx1 && mby + md.bh - 1 <= by1) ols.push([mo.kind, mbx - bx0, mby - by0, mo.wall]);
|
||||
}
|
||||
if (!blocks.length && !ms.length && !ols.length) return null;
|
||||
return { v: 2, bw: bx1 - bx0 + 1, bh: by1 - by0 + 1, blocks, machines: ms, molds: ols };
|
||||
}
|
||||
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)
|
||||
: 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)]),
|
||||
machines: bp.machines.map(([t, x, y, d]) => {
|
||||
const P = PIECE_BY_ID[t];
|
||||
let nd = d;
|
||||
if (t === 'fan') nd = d === 0 ? 2 : d === 2 ? 0 : d;
|
||||
if (t === 'engine') nd = d ? 0 : 1;
|
||||
return [t, bw - P.bw - x, y, nd];
|
||||
}),
|
||||
molds: bp.molds.map(([k, x, y, wl]) => [k, bw - MOLDS[k].bw - x, y, wl]),
|
||||
};
|
||||
}
|
||||
function bpCost(bp) {
|
||||
const c = new Map();
|
||||
const add = (m, n) => c.set(m, (c.get(m) || 0) + n);
|
||||
for (const [, , code, v] of bp.blocks) for (const [m, n] of pieceCost(code, v)) add(m, n);
|
||||
for (const [t] of bp.machines) for (const [m, n] of PIECE_BY_ID[t].cost) add(m, n);
|
||||
for (const [k, , , wl] of bp.molds) for (const [m, n] of wallCost(wl, MOLDS[k].walls)) add(m, n);
|
||||
return [...c.entries()];
|
||||
}
|
||||
function pasteGhost(bp, obx, oby) {
|
||||
let n = 0;
|
||||
const rec = { t: 'paste', blocks: [], machines: [], molds: [] };
|
||||
for (const [dx, dy, code, v, e] of bp.blocks) {
|
||||
const bx = obx + dx, by = oby + dy;
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
|
||||
ghostBlocks.set(by * BW + bx, e ? [code, v, e] : [code, v]); n++;
|
||||
rec.blocks.push([bx, by, code]);
|
||||
}
|
||||
for (const [t, dx, dy, d] of bp.machines) { const g = { type: t, bx: obx + dx, by: oby + dy, dir: d }; ghostMachines.push(g); rec.machines.push(g); }
|
||||
for (const [k, dx, dy, wl] of bp.molds) { const g = { kind: k, bx: obx + dx, by: oby + dy, wall: wl }; ghostMolds.push(g); rec.molds.push(g); }
|
||||
undoNote(rec);
|
||||
return n;
|
||||
}
|
||||
// Cut: copy what you built in the box, then take it all down (refunded to the bag)
|
||||
function cutRegion(x0, y0, x1, y1) {
|
||||
const bp = copyRegion(x0, y0, x1, y1);
|
||||
if (!bp) return null;
|
||||
const bx0 = Math.max(0, Math.floor(Math.min(x0, x1) / BS)), by0 = Math.max(0, Math.floor(Math.min(y0, y1) / BS));
|
||||
droppedLast = 0;
|
||||
for (const mm of machines.slice()) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed || siloFull(mm)) continue; // a full silo stays where it is
|
||||
if (bp.machines.some(([t, dx, dy]) => t === mm.type && bx0 + dx === mm.x / BS && by0 + dy === mm.y / BS)) removeMachine(mm.id, true, false);
|
||||
}
|
||||
for (const mo of molds.slice()) if (bp.molds.some(([k, dx, dy]) => k === mo.kind && bx0 + dx === mo.x / BS && by0 + dy === mo.y / BS)) removeMold(mo);
|
||||
for (const [dx, dy] of bp.blocks) removeBlock(bx0 + dx, by0 + dy);
|
||||
return bp;
|
||||
}
|
||||
// Replace: make room for a machine or mold by taking down what you built in its way. Returns ''
|
||||
// when the space is clear now, or why it cannot be (rock, a bar, something fixed).
|
||||
function footprintBlocker(bx, by, bw, bh) {
|
||||
for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return 'Out of bounds';
|
||||
const i = y * W + x;
|
||||
if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space. Clear the rock first.';
|
||||
if (occ[i]) return 'A bar is in the way';
|
||||
if (mach[i] && machines[mach[i]] && PIECE_BY_ID[machines[mach[i]].type].fixed) return 'That cannot be replaced';
|
||||
if (mach[i] && siloFull(machines[mach[i]])) return 'Empty the silo first';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function clearFootprint(bx, by, bw, bh) {
|
||||
const why = footprintBlocker(bx, by, bw, bh);
|
||||
if (why) return why;
|
||||
droppedLast = 0;
|
||||
for (const mm of machines.slice()) {
|
||||
if (!mm) continue;
|
||||
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
|
||||
if (mbx < bx + bw && mbx + P.bw > bx && mby < by + bh && mby + P.bh > by) removeMachine(mm.id, true, false);
|
||||
}
|
||||
for (const mo of molds.slice()) {
|
||||
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
|
||||
if (mbx < bx + bw && mbx + md.bw > bx && mby < by + bh && mby + md.bh > by) removeMold(mo);
|
||||
}
|
||||
for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) if (blockP[y * BW + x]) removeBlock(x, y);
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// The bot: movement and collision with the world.
|
||||
// ---------- Bot ----------
|
||||
// The bot flies through anything you built (walls, machines, molds, belts); only natural terrain stops it.
|
||||
function botBlocked(i) { return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) !== 0; }
|
||||
function botOverlapSolid(x, y) {
|
||||
const x0 = Math.floor(x), y0 = Math.floor(y), x1 = Math.ceil(x + bot.w) - 1, y1 = Math.ceil(y + bot.h) - 1;
|
||||
if (x0 < 0 || y0 < 0 || x1 >= W || y1 >= H) return true;
|
||||
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) if (botBlocked(cy * W + cx)) return true;
|
||||
return false;
|
||||
}
|
||||
// Flying straight into a lip of rock a grain or two deep at the edge of the bot, it slips
|
||||
// sideways round it instead of stopping dead, so it threads tunnels and holes it nearly fits.
|
||||
const SLIDE_MAX = 2.5, SLIDE_STEP = 0.34;
|
||||
function botSlide(axis, step) {
|
||||
let best = 0;
|
||||
for (let k = 1; k <= SLIDE_MAX * 4; k++) {
|
||||
for (const sg of [-1, 1]) {
|
||||
const o = sg * k * 0.25;
|
||||
const fx = axis ? o : step, fy = axis ? step : o;
|
||||
// the spot just past the lip is clear, and nothing blocks the slide toward it
|
||||
if (botOverlapSolid(bot.x + fx, bot.y + fy)) continue;
|
||||
if (botOverlapSolid(bot.x + (axis ? o : 0), bot.y + (axis ? 0 : o))) continue;
|
||||
best = o; break;
|
||||
}
|
||||
if (best) break;
|
||||
}
|
||||
if (!best) return false;
|
||||
const d = Math.sign(best) * Math.min(SLIDE_STEP, Math.abs(best));
|
||||
if (axis) { if (botOverlapSolid(bot.x + d, bot.y)) return false; bot.x += d; }
|
||||
else { if (botOverlapSolid(bot.x, bot.y + d)) return false; bot.y += d; }
|
||||
return true;
|
||||
}
|
||||
function botStep(ix, iy, boost) {
|
||||
const acc = 0.075 * (boost ? 1.8 : 1);
|
||||
bot.vx += ix * acc; bot.vy += iy * acc;
|
||||
// no extra drag in sand or water: the bot flies through loose material at full speed
|
||||
bot.vx *= 0.885; bot.vy *= 0.885;
|
||||
if (ix) bot.face = ix > 0 ? 1 : -1;
|
||||
bot.thrust = Math.min(1, Math.hypot(ix, iy));
|
||||
if (botOverlapSolid(bot.x, bot.y)) {
|
||||
// unstick: search upward then sideways for a free spot
|
||||
for (let k = 1; k < 40; k++) {
|
||||
if (!botOverlapSolid(bot.x, bot.y - k)) { bot.y -= k; break; }
|
||||
if (!botOverlapSolid(bot.x - k, bot.y)) { bot.x -= k; break; }
|
||||
if (!botOverlapSolid(bot.x + k, bot.y)) { bot.x += k; break; }
|
||||
}
|
||||
}
|
||||
const steps = Math.ceil(Math.max(Math.abs(bot.vx), Math.abs(bot.vy)) / 0.45) || 1;
|
||||
const sx = bot.vx / steps, sy = bot.vy / steps;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
if (sx) { if (!botOverlapSolid(bot.x + sx, bot.y)) bot.x += sx; else if (!(ix && !iy && botSlide(0, sx))) { bot.vx *= -0.15; } }
|
||||
if (sy) { if (!botOverlapSolid(bot.x, bot.y + sy)) bot.y += sy; else if (!(iy && !ix && botSlide(1, sy))) { bot.vy *= -0.15; } }
|
||||
}
|
||||
// loose grains, liquids and gases pass straight through the bot: it never moves them
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids
|
||||
// and gases (simMove).
|
||||
// ---------- Cell helpers ----------
|
||||
function setCell(i, m, t, fl) {
|
||||
const old = mat[i];
|
||||
if (PARTMAT[old] && old !== m) partLost(i);
|
||||
mat[i] = m; temp[i] = t; flags[i] = fl || 0; life[i] = (PH[m] === PH_LIQUID && FRZ[m]) ? 255 : 0; digp[i] = 0;
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
wakeXY(x, y);
|
||||
// far off the local air temperature, or something that cannot last in it (snow in warm air,
|
||||
// water in freezing air): let the heat pass see it
|
||||
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) {
|
||||
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;
|
||||
a = flags[i]; flags[i] = flags[j]; flags[j] = a;
|
||||
stamp[i] = tickStamp; stamp[j] = tickStamp;
|
||||
}
|
||||
function move(i, j, x, y, nx, ny) {
|
||||
swapCells(i, j);
|
||||
wakeXY(x, y); wakeXY(nx, ny);
|
||||
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
|
||||
const d = temp[j] - AMB[ny];
|
||||
if (d > 25 || d < -25) wakeHeatXY(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 (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
|
||||
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; }
|
||||
}
|
||||
const t = mat[j];
|
||||
if (t === EMPTY) return 1;
|
||||
const tp = PH[t];
|
||||
if (tp === PH_SOLID) return 0;
|
||||
if (stamp[j] === tickStamp && tp !== PH_GAS) return 0;
|
||||
if (ph === PH_GAS) {
|
||||
if (dy < 0 && (tp === PH_LIQUID || (tp === PH_GAS && DENS[t] > DENS[m]))) return 2;
|
||||
return 0;
|
||||
}
|
||||
if (tp === PH_GAS) return 2;
|
||||
if (dy > 0 && DENS[m] > DENS[t] && (tp === PH_LIQUID || ph === PH_LIQUID)) return rnd() < (DENS[m] > DENS[t] * 2 ? 0.55 : 0.3) ? 2 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- Movement ----------
|
||||
// Heavy grains settle through a loose pile of much lighter ones, slowly. A sorting
|
||||
// step keeps its area awake for a while so a pile finishes sorting even at rest.
|
||||
let SORTP = 0.003;
|
||||
function powder(i, x, y, m) {
|
||||
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; }
|
||||
// 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() < SORTP) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
|
||||
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
|
||||
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
|
||||
if (rnd() < STICK[m]) {
|
||||
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; }
|
||||
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; }
|
||||
}
|
||||
}
|
||||
function passable(m, ph, j, dx, dy) {
|
||||
// pure check (no randomness): cell is empty or gas and part rules allow
|
||||
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; }
|
||||
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; }
|
||||
}
|
||||
const t = mat[j];
|
||||
return t === EMPTY || PH[t] === PH_GAS;
|
||||
}
|
||||
function liquid(i, x, y, m) {
|
||||
if (y < H - 1) {
|
||||
const b = i + W;
|
||||
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; }
|
||||
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 (rnd() > FLUID[m]) return;
|
||||
const sp = SPREAD[m];
|
||||
const d0 = (RS & 4) ? 1 : -1;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const d = pass ? -d0 : d0;
|
||||
for (let s = 1; s <= sp; s++) {
|
||||
const nx = x + d * s;
|
||||
if (nx < 0 || nx >= W) break;
|
||||
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)) {
|
||||
// step one cell toward the drop (keeps flow continuous and local)
|
||||
move(i, i + d, x, y, x + d, y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function gas(i, x, y, m) {
|
||||
if (m === FIRE) {
|
||||
const l = life[i];
|
||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||
life[i] = l - 1;
|
||||
// flames lick at trees behind them
|
||||
if (rnd() < 0.25) { const j = neighborDir(i, x, y, (rnd() * 4) | 0); if (j >= 0 && bgm[j] && mat[j] === EMPTY && temp[i] > IGN[bgm[j]] && rnd() < 0.3) igniteTree(j); }
|
||||
} else if (m === SMOKE) {
|
||||
if (rnd() < (y < roofY[x] ? 0.5 : 0.05)) {
|
||||
const l = life[i];
|
||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||
life[i] = l - 1;
|
||||
}
|
||||
}
|
||||
if (y === 0 || (m === STEAM && y <= weather.skyY + 4 && y < roofY[x])) {
|
||||
// steam that has risen to cloud height in the open sky joins the clouds
|
||||
if (m === STEAM) absorbSteam(x);
|
||||
mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return;
|
||||
}
|
||||
const u = i - W;
|
||||
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; }
|
||||
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 (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); }
|
||||
}
|
||||
}
|
||||
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; break; }
|
||||
if (!any) continue;
|
||||
const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
|
||||
for (let y = y1 - 1; y >= y0; y--) {
|
||||
const ltr = ((y + tick) & 1) === 0;
|
||||
const yw = y * W;
|
||||
for (let k = 0; k < CW; k++) {
|
||||
const cx = ltr ? k : CW - 1 - k;
|
||||
if (!awake[rowBase + cx]) continue;
|
||||
const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
|
||||
// alternate rows scan left to right and right to left, so nothing drifts one way
|
||||
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
|
||||
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
|
||||
const i = yw + x, m = mat[i];
|
||||
if (m === EMPTY || stamp[i] === tickStamp) continue;
|
||||
const ph = PH[m];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// How belts, filters and screw tubes move what is on or in them.
|
||||
// What rests on a belt or filter rides along: the whole stack standing on the carrying surface
|
||||
// steps one cell toward d together (belts run front of the line first, so the way ahead is
|
||||
// clear), the way a real belt carries a heap, not just its bottom layer. A filter's own
|
||||
// material drops through it instead, unless the filter below is already full.
|
||||
const CARRY_DEPTH = 96;
|
||||
function carryColumn(a, d, pass) {
|
||||
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
|
||||
if (a < 0) return;
|
||||
const m = mat[a];
|
||||
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling
|
||||
if (PH[m] !== PH_POWDER || stamp[a] === tickStamp || occ[a]) continue;
|
||||
if (pass && pass[m]) {
|
||||
if (enter(m, PH_POWDER, a + W, 0, 1)) continue; // falls through on its own
|
||||
// the rollers shake the load: what the filter passes works its way down through the rest
|
||||
const u = mat[a + W];
|
||||
if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; }
|
||||
}
|
||||
const x = a % W, nx = x + d;
|
||||
if (nx < 0 || nx >= W) return;
|
||||
const j = a + d, 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)) 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);
|
||||
stats.moved++;
|
||||
}
|
||||
}
|
||||
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
|
||||
function beltDrain(b) {
|
||||
const a = b - W, x = b % W;
|
||||
let d = b;
|
||||
for (let k = 0; k < 16 && d < N; k++) { const m = mat[d]; if (m !== BELTL && m !== BELTR) break; d += W; }
|
||||
if (d >= N || mat[d] === BELTL || mat[d] === BELTR) return;
|
||||
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
|
||||
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;
|
||||
wakeXY(i % W, (i / W) | 0);
|
||||
flyers.push({ x: x + 0.5, y: y + 0.5, vx, vy, age: 0, m, T, free: true, run: 0 });
|
||||
}
|
||||
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);
|
||||
// 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;
|
||||
if (y < 1) 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);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
const rec = filterRec(cfg);
|
||||
filterCfg.set(b, rec);
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
const cells = blockCellList(bx, by).filter(i => PH[mat[i]] === PH_POWDER && !rec.pass[mat[i]]);
|
||||
for (let y = by * BS + BS - 1; y >= by * BS; y--) for (const i of cells) if (((i / W) | 0) === y) evictCells([i], by * BS);
|
||||
return true;
|
||||
}
|
||||
// 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 [];
|
||||
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;
|
||||
out.push(c);
|
||||
}
|
||||
return out.sort((a, c) => a - c);
|
||||
}
|
||||
|
||||
// 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; }
|
||||
// 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) {
|
||||
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 nx = x + ox, ny = y + oy;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) 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 (pj === P_GRATE || pj === P_FILTER) continue;
|
||||
move(j, i, nx, ny, x, y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// The resonance drill: tuned pulses that shatter one material.
|
||||
// ---------- Resonance drill ----------
|
||||
// Holding the drill sends pulses from the bot toward the cursor. Each pulse is a fan of rays
|
||||
// leaving the whole face of the bot; where a ray passes through rock it shakes it (digp),
|
||||
// losing some of its energy, and rock shaken hard enough shatters into its loose grains.
|
||||
// The width is a real choice:
|
||||
// narrow: a beam as wide as the bot that bores a tunnel you can fly down, fast and far
|
||||
// medium: a cone, the all-rounder
|
||||
// wide: a broad fan that clears a big face close to the bot, but reaches only a short way
|
||||
// Tuned to one material, a pulse passes through everything else and shatters only that, and
|
||||
// loose grains it passes are jostled toward the bot, so a shattered vein pours out of the wall.
|
||||
// Built blocks, machines and molds soak pulses up unharmed.
|
||||
// half: half the spread angle; mouth: half the width of the fan where it leaves the bot;
|
||||
// en: energy per ray; damp: energy kept after each shake (higher bores deeper); reach: x bit reach
|
||||
const PULSE_W = [
|
||||
{ name: 'narrow', half: 0.03, mouth: 3.2, rays: 13, en: 1.75, damp: 0.9, reach: 1.45 },
|
||||
{ name: 'medium', half: 0.24, mouth: 3, rays: 23, en: 1.4, damp: 0.84, reach: 1 },
|
||||
{ name: 'wide', half: 0.62, mouth: 3.4, rays: 41, en: 1.32, damp: 0.78, reach: 0.7 },
|
||||
];
|
||||
const PULSE_EVERY = 8, PULSE_SPEED = 2.2, PULSE_K = 240; // ticks between pulses, cells per tick, shake per unit of energy
|
||||
let drillPulses = [], lastPulseTick = -99, drillWidth = 1;
|
||||
// pulses leave from the middle of the bot (the fan is as wide as the bot there)
|
||||
function drillOrigin() { return { x: bot.x + bot.w / 2, y: bot.y + bot.h / 2 }; }
|
||||
// the start point and direction of ray f (-1 .. 1 across the fan) of a pulse aimed at angle ang
|
||||
function pulseRay(ox, oy, ang, Wd, f) {
|
||||
const off = f * Wd.mouth, a = ang + f * Wd.half;
|
||||
return { x: ox - Math.sin(ang) * off, y: oy + Math.cos(ang) * off, ux: Math.cos(a), uy: Math.sin(a) };
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// One press-and-hold step: fires a pulse when one is due. Returns { fired, status }.
|
||||
function toolPulse(ax, ay, width, tune) {
|
||||
if (PULSE_W[width]) drillWidth = width;
|
||||
if (tick - lastPulseTick < PULSE_EVERY) return { fired: false, status: '' };
|
||||
const tier = BIT_TIERS[player.bitTier], o = drillOrigin();
|
||||
const dx = ax + 0.5 - o.x, dy = ay + 0.5 - o.y, d = Math.hypot(dx, dy);
|
||||
if (d < 0.5) return { fired: false, status: '' };
|
||||
const Wd = PULSE_W[drillWidth], ang = Math.atan2(dy, dx), reach = drillReach();
|
||||
const range = Math.min(reach, d + 3);
|
||||
// each pulse's rays sit at a slightly different place across the fan, so over a few pulses
|
||||
// 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
|
||||
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);
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) break;
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (PH[m] !== PH_SOLID || builtCell(i, x, y) || (tune && YLD[m] !== tune)) continue;
|
||||
if (HARD[m] > tier.hard) {
|
||||
const need = BIT_TIERS.find(b => b.hard >= HARD[m]);
|
||||
const nb = need ? need.name.toLowerCase() : 'better bit';
|
||||
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!status && d > reach) status = `Out of reach. A ${Wd.name} pulse from your ${tier.name.toLowerCase()} reaches ${reach} grains from the bot.`;
|
||||
return { fired: true, status };
|
||||
}
|
||||
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
|
||||
let THIN_BONUS_OFF = 0; // for tests that compare with and without
|
||||
// After a cell breaks: any small piece of natural rock it leaves cut off from the rest (touching
|
||||
// no other rock on any side) has nothing holding it and falls apart into its loose grains, so
|
||||
// the drill does not leave floating bits in the way. Rock too hard for the bit never crumbles
|
||||
// (it holds the piece up), so nothing ever comes loose that the bit could not have broken.
|
||||
function crumbleAround(x, y) {
|
||||
const i0 = y * W + x;
|
||||
for (const j0 of [i0 - 1, i0 + 1, i0 - W, i0 + W]) {
|
||||
if (j0 < 0 || j0 >= N || !crumbly(j0)) continue;
|
||||
const got = [j0], st = [j0];
|
||||
let small = true;
|
||||
while (st.length && small) {
|
||||
const i = st.pop(), cx = i % W;
|
||||
for (const j of [cx > 0 ? i - 1 : -1, cx < W - 1 ? i + 1 : -1, i - W, i + W]) {
|
||||
if (j < 0 || j >= N || got.includes(j)) continue;
|
||||
if (PH[mat[j]] !== PH_SOLID) continue;
|
||||
// touching anything solid that is not loose natural rock (built blocks, the dock, the
|
||||
// edge of the world) holds the piece up
|
||||
if (!crumbly(j) || got.length >= CRUMBLE_MAX) { small = false; break; }
|
||||
got.push(j); st.push(j);
|
||||
}
|
||||
}
|
||||
if (!small) continue;
|
||||
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); }
|
||||
function shatterCell(i, x, y, m) {
|
||||
const to = YLD[m];
|
||||
setCell(i, to, temp[i], 0);
|
||||
discover(to, x, y, m, 'dig');
|
||||
stats.dug++;
|
||||
if ((stats.dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
||||
if ((stats.dug & 7) === 0) emit({ t: 'sfx', s: 'dig', a: HARD[m] });
|
||||
}
|
||||
// Advance every pulse in flight by one tick
|
||||
function pulseTick() {
|
||||
if (!drillPulses.length) return;
|
||||
const tier = BIT_TIERS[player.bitTier];
|
||||
// cells inside the bot's body are not touched (it flies in its own little space)
|
||||
const bx0 = Math.floor(bot.x), by0 = Math.floor(bot.y), bx1 = Math.ceil(bot.x + bot.w) - 1, by1 = Math.ceil(bot.y + bot.h) - 1;
|
||||
for (const p of drillPulses) {
|
||||
p.r += PULSE_SPEED;
|
||||
const lim = Math.min(p.r, p.range), Wd = PULSE_W[p.w];
|
||||
for (let k = 0; k < p.n; k++) {
|
||||
let e = p.en[k];
|
||||
if (e < 0.04) continue;
|
||||
const ray = pulseRay(p.ox, p.oy, p.ang, Wd, -1 + 2 * (k + p.jit) / p.n), ux = ray.ux, uy = ray.uy;
|
||||
let sd = p.s[k];
|
||||
while (sd < lim && e >= 0.04) {
|
||||
sd += 0.5;
|
||||
const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd);
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) { e = 0; break; }
|
||||
if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue;
|
||||
const i = y * W + x, m = mat[i], ph = PH[m];
|
||||
if (ph === PH_EMPTY || ph === PH_GAS) {
|
||||
// standing trees in the way are shaken apart too (not by a tuned pulse)
|
||||
if (bgm[i] && !p.tune && (sd * 2 & 1) === 0) { cutTree(i, x, y, tier, e * 0.5); e *= bgm[i] === WOOD ? 0.85 : 0.97; }
|
||||
continue;
|
||||
}
|
||||
if (ph === PH_LIQUID) { e *= 0.985; continue; }
|
||||
if (ph === PH_POWDER) {
|
||||
// loose grains are jostled back toward the bot, so a shattered pocket drains out of the
|
||||
// wall and a bore clears itself: straight back if there is room, else down or up a step
|
||||
e *= 0.94;
|
||||
if (stamp[i] !== tickStamp && rnd() < 0.3 * e) {
|
||||
const sx = Math.abs(ux) > 0.3 ? -Math.sign(ux) : 0, sy = Math.abs(uy) > 0.3 ? -Math.sign(uy) : 0;
|
||||
for (const [mx, my] of [[sx, sy], [sx, 1], [sx, -1], [0, sy]]) {
|
||||
if (!mx && !my) continue;
|
||||
const nx = x + mx, ny = y + my;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
const j = ny * W + nx;
|
||||
if ((mat[j] === EMPTY || PH[mat[j]] === PH_GAS) && !occ[j] && !part[j]) { move(i, j, x, y, nx, ny); break; }
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (builtCell(i, x, y)) { e *= 0.25; continue; }
|
||||
if (p.tune && YLD[m] !== p.tune) { e *= 0.993; continue; }
|
||||
const h = HARD[m];
|
||||
// thin rock (a corner, a nub, a sliver with open space on two or more sides) has little
|
||||
// to hold it and shakes apart faster than a flat face, so ragged edges get cleaned up
|
||||
let open = 0;
|
||||
if (x > 0 && PH[mat[i - 1]] !== PH_SOLID) open++;
|
||||
if (x < W - 1 && PH[mat[i + 1]] !== PH_SOLID) open++;
|
||||
if (y > 0 && PH[mat[i - W]] !== PH_SOLID) open++;
|
||||
if (y < H - 1 && PH[mat[i + W]] !== PH_SOLID) open++;
|
||||
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;
|
||||
e *= p.tune ? 0.96 : p.damp;
|
||||
}
|
||||
p.en[k] = e; p.s[k] = sd;
|
||||
}
|
||||
}
|
||||
drillPulses = drillPulses.filter(p => p.r < p.range + 3);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Money, prices, market saturation, the trade dock, buyer and builder orders.
|
||||
// ---------- Economy ----------
|
||||
// You sell by putting goods into the trade dock: they are paid for as they drop
|
||||
// through its grate. Money buys machines; wall blocks, molds and bits you still
|
||||
// make from your own materials. The claim on this land starts in debt.
|
||||
let money = 250, debt = 20000, earned = 0;
|
||||
const CLAIM_DEBT = 20000;
|
||||
// base price per grain (per bar for bars). 0 = not wanted: it drops through for nothing.
|
||||
const PRICE = new Float32Array(NMAT);
|
||||
for (const [m, v] of [
|
||||
[SAND, 0.1], [LCLAY, 0.2], [LWOOD, 0.3], [ACORN, 0.5], [COAL, 0.5], [RGRAN, 0.2], [RBAS, 0.4],
|
||||
[CUORE, 1], [FEORE, 1.6], [SNORE, 2], [ZNORE, 2.2], [AGORE, 6], [AUORE, 14],
|
||||
[CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6],
|
||||
[SCRCU, 3], [SCRFE, 5], [SCRST, 11], [SCRSN, 3.5], [SCRZN, 3.5], [SCRAG, 18], [SCRAU, 40], [SCRBZ, 8], [SCRBS, 7],
|
||||
[BARCU, 40], [BARFE, 75], [BARST, 160], [BARSN, 50], [BARZN, 50], [BARAG, 220], [BARAU, 480], [BARBZ, 110], [BARBS, 95],
|
||||
]) PRICE[m] = v;
|
||||
// what a cell is sold as: cast metal sitting in the hopper sells as scrap
|
||||
function saleAs(m) { return SOLIDMETAL[m] ? YLD[m] : CASTMETAL[m] ? YLD[FRZTO[m]] : m; }
|
||||
// The market dips when you sell a lot of one thing, and recovers over time.
|
||||
const SAT_K = 2500, SAT_FLOOR = 0.3, SAT_HALF = 28800; // half-life: 8 minutes of game time
|
||||
let mkRecent = new Float32Array(NMAT);
|
||||
function priceFactor(m) { return Math.max(SAT_FLOOR, 1 / (1 + mkRecent[m] / SAT_K)); }
|
||||
function priceNow(m) { return PRICE[m] * priceFactor(m); }
|
||||
function sellUnit(m) { const p = priceNow(m); mkRecent[m] += PRICE[m]; return p; }
|
||||
function marketTick() { const f = Math.pow(0.5, 60 / SAT_HALF); for (let m = 0; m < NMAT; m++) if (mkRecent[m] > 0.01) mkRecent[m] *= f; else mkRecent[m] = 0; }
|
||||
function resetEconomy() {
|
||||
money = 250; debt = CLAIM_DEBT; earned = 0; mkRecent = new Float32Array(NMAT); shipLog = [];
|
||||
saleAcc = { total: 0, items: {} }; floatAcc = 0;
|
||||
orders = []; ordersDone = 0; nextOrderT = 3600; orderSeq = 1;
|
||||
}
|
||||
function payClaim(amount) {
|
||||
const a = Math.max(0, Math.min(amount, money, debt));
|
||||
money -= a; debt -= a;
|
||||
if (a > 0 && debt <= 0) emit({ t: 'claim_paid' });
|
||||
return a;
|
||||
}
|
||||
|
||||
// The trade dock: an open bin with a grated floor over a chute to the claim office.
|
||||
// Anything that reaches the grate (and is cool enough to handle) is sold on the spot.
|
||||
let shipLog = [], saleAcc = { total: 0, items: {} }, floatAcc = 0;
|
||||
function dock() { for (const mm of machines) if (mm && mm.type === 'dock') return mm; return null; }
|
||||
function inDock(d, x, y) { return x >= d.x + 2 && x < d.x + 22 && y >= d.y && y < d.y + 14; }
|
||||
function dockTick() {
|
||||
const d = dock(); if (!d) return;
|
||||
const fy = d.y + 13;
|
||||
for (let x = d.x + 2; x < d.x + 22; x++) {
|
||||
const i = fy * W + x, m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || temp[i] >= 150 || (flags[i] & F_BURN)) continue;
|
||||
setCell(i, EMPTY, AMB[fy], 0);
|
||||
sellToDock(saleAs(m));
|
||||
}
|
||||
for (const e of ents.slice()) {
|
||||
if (e.iy + BAR_H - 1 >= fy && inDock(d, e.ix + 2, fy) && e.T < 150) { removeEnt(e); sellToDock(e.m); }
|
||||
}
|
||||
if (tick % 45 === 0 && floatAcc >= 0.5) { emit({ t: 'sold', v: floatAcc, x: d.x + 9 + rnd() * 6, y: d.y - 9 }); floatAcc = 0; }
|
||||
if (tick % 3600 === 0 && saleAcc.total > 0) { shipLog.unshift({ tick, total: Math.round(saleAcc.total), items: saleAcc.items }); if (shipLog.length > 12) shipLog.length = 12; saleAcc = { total: 0, items: {} }; }
|
||||
}
|
||||
// One unit reaches the grate: it goes to an open order for it first, else to the market.
|
||||
function sellToDock(m) {
|
||||
stats.sold = (stats.sold || 0) + 1;
|
||||
const o = orders.find(o => o.m === m && o.got < o.qty);
|
||||
if (o) { o.got++; if (o.got >= o.qty) completeOrder(o); return; }
|
||||
const p = sellUnit(m);
|
||||
money += p; earned += p; floatAcc += p;
|
||||
if (p > 0) { saleAcc.total += p; saleAcc.items[m] = (saleAcc.items[m] || 0) + 1; }
|
||||
}
|
||||
|
||||
// Orders: buyers ask for an amount of one good by a deadline and pay well over market.
|
||||
// A missed deadline costs nothing: what you delivered is paid at market price.
|
||||
let orders = [], ordersDone = 0, nextOrderT = 3600, orderSeq = 1;
|
||||
const ORDER_SLOTS = 3;
|
||||
const STANDING = ['Local buyers', 'County buyers', 'Regional buyers', 'National buyers', 'Export houses'];
|
||||
function standing() { return Math.min(STANDING.length - 1, Math.floor(ordersDone / 4)); }
|
||||
// Builders buy the cheap bulk stuff (rubble, gravel, sand, stone bricks) in big lots at a few
|
||||
// times the market price, so waste rock earns a little. [what, smallest lot, largest lot]
|
||||
const BUILD_GOODS = [[RSTONE, 400, 1500], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80]];
|
||||
function makeBuildersOrder() {
|
||||
const cands = BUILD_GOODS.filter(([m]) => seen[m] && !orders.some(o => o.m === m));
|
||||
if (!cands.length) return null;
|
||||
const [m, lo, hi] = cands[(rnd() * cands.length) | 0];
|
||||
const qty = Math.round((lo + rnd() * (hi - lo)) * (1 + Math.min(1, ordersDone * 0.05)) / (PH[m] === PH_ITEM ? 1 : 10)) * (PH[m] === PH_ITEM ? 1 : 10);
|
||||
const mult = Math.round((PH[m] === PH_ITEM ? 2.5 : 4) + rnd() * 2) ;
|
||||
const minutes = Math.max(6, Math.min(20, Math.round(qty / (PH[m] === PH_ITEM ? 6 : 90))));
|
||||
return { id: orderSeq++, m, qty, got: 0, mult, pay: Math.max(5, Math.round(qty * PRICE[m] * mult)), due: tick + minutes * 3600, mins: minutes, who: 'Builders' };
|
||||
}
|
||||
function makeOrder() {
|
||||
if (rnd() < 0.3) { const b = makeBuildersOrder(); if (b) return b; }
|
||||
const cands = [];
|
||||
for (let m = 0; m < NMAT; m++) if (PRICE[m] > 0 && seen[m] && !orders.some(o => o.m === m) && !BUILD_GOODS.some(([g]) => g === m && g !== SAND)) cands.push(m);
|
||||
if (!cands.length) return null;
|
||||
const weight = m => (PH[m] === PH_ITEM ? 4 : 1) * (m === SCRCU || m === SCRFE || m === SCRST || m === SCRSN || m === SCRZN ? 0.4 : 1) * (PRICE[m] >= 5 ? 1.5 : 1);
|
||||
let sum = 0; for (const m of cands) sum += weight(m);
|
||||
let r = rnd() * sum, m = cands[0];
|
||||
for (const c of cands) { r -= weight(c); if (r <= 0) { m = c; break; } }
|
||||
const target = (220 + ordersDone * 110) * (0.7 + rnd() * 0.7); // order size at base price
|
||||
let qty = Math.round(target / PRICE[m]);
|
||||
qty = PH[m] === PH_ITEM ? Math.max(2, Math.min(80, qty)) : Math.max(15, Math.min(900, Math.round(qty / 5) * 5));
|
||||
const mult = Math.round((1.6 + rnd() * 0.7 + Math.min(0.6, ordersDone * 0.03)) * 10) / 10;
|
||||
const minutes = Math.max(5, Math.min(20, Math.round(4 + qty * PRICE[m] / 90)));
|
||||
return { id: orderSeq++, m, qty, got: 0, mult, pay: Math.round(qty * PRICE[m] * mult), due: tick + minutes * 3600, mins: minutes };
|
||||
}
|
||||
function ordersTick() {
|
||||
for (let k = orders.length - 1; k >= 0; k--) {
|
||||
const o = orders[k];
|
||||
if (tick < o.due) continue;
|
||||
orders.splice(k, 1);
|
||||
let paid = 0; for (let n = 0; n < o.got; n++) paid += sellUnit(o.m);
|
||||
money += paid; earned += paid;
|
||||
emit({ t: 'order_expired', m: o.m, got: o.got, qty: o.qty, paid: Math.round(paid) });
|
||||
}
|
||||
if (orders.length < ORDER_SLOTS && tick >= nextOrderT) {
|
||||
const o = makeOrder();
|
||||
nextOrderT = tick + 3600 + ((rnd() * 3600) | 0);
|
||||
if (o) { orders.push(o); emit({ t: 'order_new', o }); }
|
||||
}
|
||||
}
|
||||
function completeOrder(o) {
|
||||
orders = orders.filter(x => x !== o);
|
||||
money += o.pay; earned += o.pay; ordersDone++;
|
||||
emit({ t: 'order_done', m: o.m, qty: o.qty, pay: o.pay });
|
||||
}
|
||||
// Put the dock on open ground near x: the nearest spot with nothing built in it.
|
||||
function placeDock(cx) {
|
||||
const BWD = 6, BHD = 4;
|
||||
for (let off = 0; off < W; off += 4) for (const sg of off ? [1, -1] : [1]) {
|
||||
const bx = Math.floor((cx + sg * off) / BS);
|
||||
if (bx < 2 || bx + BWD > BW - 2) continue;
|
||||
const x0 = bx * BS, x1 = x0 + BWD * BS;
|
||||
// the highest natural ground under it
|
||||
let g = H, ok = true;
|
||||
for (let x = x0; x < x1 && ok; x++) {
|
||||
let y = 0; while (y < H - 1 && !(PH[mat[y * W + x]] === PH_SOLID && (flags[y * W + x] & F_NAT))) y++;
|
||||
if (y < g) g = y;
|
||||
if (y > H - 40) ok = false;
|
||||
}
|
||||
if (!ok) continue;
|
||||
const by = Math.floor((g + 1) / BS) - BHD + 1;
|
||||
if (by < 2) continue;
|
||||
// nothing of yours may be in the way
|
||||
for (let y = by * BS; y < (by + BHD) * BS && ok; y++) for (let x = x0; x < x1; x++) {
|
||||
const i = y * W + x;
|
||||
if (mach[i] || part[i] || occ[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] || (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || PH[mat[i]] === PH_LIQUID) { ok = false; break; }
|
||||
}
|
||||
if (!ok || molds.some(mo => mo.x < x1 && mo.x + mo.w > x0 && mo.y < (by + BHD) * BS && mo.y + mo.h > by * BS)) continue;
|
||||
for (let y = by * BS; y < (by + BHD) * BS; y++) for (let x = x0; x < x1; x++) { const i = y * W + x; bgm[i] = 0; if (mat[i] !== EMPTY) setCell(i, EMPTY, AMB[y], 0); }
|
||||
// fill under it down to the ground so it does not hang in the air
|
||||
for (let x = x0; x < x1; x++) for (let y = (by + BHD) * BS; y < H; y++) { const i = y * W + x; if (PH[mat[i]] === PH_SOLID) break; setCell(i, DIRT, AMB[y], F_NAT); bgm[i] = 0; }
|
||||
addMachine('dock', bx, by, 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Heat: conduction, melting and freezing, reactions, fire and air, steel.
|
||||
// ---------- Heat ----------
|
||||
function curAir() { return (tick & 0x7fff) + 1; }
|
||||
function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0x7fff) < 8; }
|
||||
// Air reaches a fuel grain if an open cell touches it, or sits just behind a
|
||||
// porous neighbor (ash or loose coal), so a fuel bed burns a couple of grains deep.
|
||||
const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1;
|
||||
const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1;
|
||||
const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1;
|
||||
// Alloys: a molten additive grain turns neighbouring molten copper into the alloy,
|
||||
// and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc).
|
||||
const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } };
|
||||
function alloyMix(i, x, y, m) {
|
||||
const a = ALLOY[m];
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const j = neighborDir(i, x, y, (d + tick) & 3);
|
||||
if (j < 0 || mat[j] !== MCU || rnd() > 0.25) continue;
|
||||
mat[j] = a.into; wakeIdx(j); discover(a.into, j % W, (j / W) | 0, m, 'alloy');
|
||||
if (rnd() < 1 / a.per) { mat[i] = a.into; wakeXY(x, y); return; }
|
||||
}
|
||||
}
|
||||
function airy(m) { return m === EMPTY || m === FIRE; }
|
||||
function hasO2(i, x, y) {
|
||||
let m;
|
||||
if (x > 0) { m = mat[i - 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x > 1 && airy(mat[i - 2]))))) return true; }
|
||||
if (x < W - 1) { m = mat[i + 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x < W - 2 && airy(mat[i + 2]))))) return true; }
|
||||
if (y > 0) { m = mat[i - W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y > 1 && airy(mat[i - 2 * W]))))) return true; }
|
||||
if (y < H - 1) { m = mat[i + W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y < H - 2 && airy(mat[i + 2 * W]))))) return true; }
|
||||
return false;
|
||||
}
|
||||
// Conducts heat between a and b. `da` = AMB(a) - AMB(b): only the excess over
|
||||
// the local ambient flows, so the built-in geothermal gradient stays put.
|
||||
function condPair(a, b, sc, nc, da) {
|
||||
const ma = mat[a], mb = mat[b];
|
||||
const d = temp[a] - temp[b] - da;
|
||||
if (d < 0.05 && d > -0.05) return;
|
||||
const k = KC[ma] < KC[mb] ? KC[ma] : KC[mb];
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
|
||||
let maxDev = 0, burning = false;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const amb = AMB[y], yw = y * W;
|
||||
const dDown = y + 1 < H ? amb - AMB[y + 1] : 0, dUp = y > 0 ? AMB[y - 1] - amb : 0;
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = yw + x;
|
||||
if (x + 1 < W) condPair(i, i + 1, sc, x + 1 === x1 ? c + 1 : -1, 0);
|
||||
if (y + 1 < H) condPair(i, i + W, sc, y + 1 === y1 ? c + CW : -1, dDown);
|
||||
if (x === x0 && leftCold) condPair(i - 1, i, sc, c - 1, 0);
|
||||
if (y === y0 && upCold) condPair(i - W, i, sc, c - CW, dUp);
|
||||
let m = mat[i];
|
||||
const ph = PH[m];
|
||||
let t = temp[i];
|
||||
let rel;
|
||||
if (ph === PH_EMPTY || ph === PH_GAS) {
|
||||
if (y > 0) {
|
||||
const u = i - W, pu = PH[mat[u]];
|
||||
const ex = (t - amb) - (temp[u] - AMB[y - 1]);
|
||||
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; }
|
||||
}
|
||||
}
|
||||
rel = y < roofY[x] ? 0.02 : 0.002;
|
||||
} else rel = RELAX[m];
|
||||
t += (amb - t) * rel * sc;
|
||||
temp[i] = t;
|
||||
if (m !== EMPTY) { react(i, x, y, m); m = mat[i]; t = temp[i]; }
|
||||
const dv = t > amb ? t - amb : amb - t;
|
||||
if (dv > maxDev) maxDev = dv;
|
||||
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]--;
|
||||
}
|
||||
function neighborDir(i, x, y, d) {
|
||||
if (d === 0) return y > 0 ? i - W : -1;
|
||||
if (d === 1) return x < W - 1 ? i + 1 : -1;
|
||||
if (d === 2) return y < H - 1 ? i + W : -1;
|
||||
return x > 0 ? i - 1 : -1;
|
||||
}
|
||||
function react(i, x, y, m) {
|
||||
const t = temp[i];
|
||||
const fl = flags[i];
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const ph = PH[m];
|
||||
if (ph === PH_LIQUID) {
|
||||
if (m === WATER && t < -0.5) {
|
||||
// freezes: into ice on something solid, into snowflakes in open air
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (HOTLIQ[m] && t > 600 && rnd() < 0.5) quench(i, x, y, m);
|
||||
if (ALLOY[m]) alloyMix(i, x, y, m);
|
||||
const fz = FRZ[m];
|
||||
if (fz !== 0) {
|
||||
if (t < fz) {
|
||||
const L = life[i] - (fz - t) * 0.28;
|
||||
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
|
||||
wakeXY(x, y); discover(to, x, y, m, 'freeze', fz);
|
||||
} else { life[i] = L; temp[i] = fz; }
|
||||
return;
|
||||
}
|
||||
if (life[i] < 255 && t > fz + 1) {
|
||||
const add = Math.min(255 - life[i], (t - fz) * 0.28);
|
||||
life[i] += add; temp[i] = t - add / 0.28;
|
||||
}
|
||||
if (m === MFE && t >= 1530) steelCheck(i, x, y);
|
||||
return;
|
||||
}
|
||||
const bp = BOIL[m];
|
||||
if (bp !== 0) {
|
||||
if (t > bp) {
|
||||
const L = life[i] + (t - bp) * 2.0;
|
||||
if (L >= 255) {
|
||||
mat[i] = BOILTO[m]; life[i] = 0; temp[i] = bp + 3; flags[i] = 0;
|
||||
wakeXY(x, y); discover(BOILTO[m], x, y, m, 'boil', bp);
|
||||
if (rnd() < 0.02) emit({ t: 'sfx', s: 'sizzle', x, y });
|
||||
} else { life[i] = L; temp[i] = bp; }
|
||||
} else if (life[i] > 0) life[i] = life[i] > 2 ? life[i] - 2 : 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ph === PH_GAS) {
|
||||
if (CONDT[m] !== 0 && t < CONDT[m] && rnd() < 0.05 && !(m === STEAM && y < roofY[x])) { mat[i] = CONDTO[m]; life[i] = 0; wakeXY(x, y); discover(CONDTO[m], x, y, m, 'condense', CONDT[m]); }
|
||||
return;
|
||||
}
|
||||
const mp = MELT[m];
|
||||
if (mp !== 0 && t >= mp) {
|
||||
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]); }
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m === CLAY) {
|
||||
if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = BRICK; discover(BRICK, x, y, CLAY, 'fire', 880); }
|
||||
} else if (m === LCLAY) {
|
||||
if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = RBRICK; discover(RBRICK, x, y, LCLAY, 'fire', 880); }
|
||||
} else if ((m === LWOOD || m === WOOD) && t >= 380) {
|
||||
// baked hot with no air to burn in: wood turns into charcoal and gives off its volatiles
|
||||
if (rnd() < 0.006 + (t - 380) * 0.00002) char(i, x, y, m);
|
||||
} else if (m === GRASS) {
|
||||
if (t >= 260) { mat[i] = DIRT; }
|
||||
}
|
||||
}
|
||||
// Wood with heat but no air chars instead of burning (a kiln, or a smothered pile).
|
||||
function char(i, x, y, m) {
|
||||
const keep = rnd() < 0.6;
|
||||
mat[i] = keep ? CHARCOAL : EMPTY; flags[i] = 0; life[i] = 0; digp[i] = 0;
|
||||
wakeXY(x, y);
|
||||
if (keep) discover(CHARCOAL, x, y, m, 'char');
|
||||
}
|
||||
// A very hot liquid meeting water flashes it to steam and is chilled hard;
|
||||
// snow and ice next to it melt at once.
|
||||
function quench(i, x, y, m) {
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const j = neighborDir(i, x, y, d);
|
||||
if (j < 0) continue;
|
||||
const n = mat[j];
|
||||
if (n === WATER) {
|
||||
mat[j] = STEAM; temp[j] = 110; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j);
|
||||
temp[i] -= 40; discover(STEAM, j % W, (j / W) | 0, WATER, 'boil', 100);
|
||||
if (rnd() < 0.05) emit({ t: 'sfx', s: 'sizzle', x, y });
|
||||
} else if (n === SNOW || n === ICE) {
|
||||
mat[j] = WATER; temp[j] = 20; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j); temp[i] -= 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
function burningNear(i, x, y) {
|
||||
return (x > 0 && (flags[i - 1] & F_BURN)) || (x < W - 1 && (flags[i + 1] & F_BURN)) ||
|
||||
(y > 0 && (flags[i - W] & F_BURN)) || (y < H - 1 && (flags[i + W] & F_BURN));
|
||||
}
|
||||
function burn(i, x, y, m, t) {
|
||||
const forced = airflowAt(i);
|
||||
const open = forced || hasO2(i, x, y);
|
||||
// smothered wood chars into charcoal (a covered pile, like a charcoal clamp)
|
||||
if (!open && (m === LWOOD || m === WOOD) && rnd() < 0.006) { char(i, x, y, m); return; }
|
||||
// buried fuel next to burning fuel smolders: less heat, slower
|
||||
if (!open && !burningNear(i, x, y)) {
|
||||
if (rnd() < 0.004) flags[i] &= ~F_BURN;
|
||||
return;
|
||||
}
|
||||
const target = forced ? FLAMEF[m] : open ? FLAME[m] : FLAME[m] * 0.95;
|
||||
if (t < target) temp[i] = t + (target - t) * (forced ? 0.2 : open ? 0.07 : 0.035);
|
||||
if (rnd() < (forced ? 0.5 : open ? 0.22 : 0.1)) {
|
||||
const l = life[i] - 1;
|
||||
if (l <= 0) {
|
||||
if (PARTMAT[m]) partLost(i);
|
||||
const to = BURNTO[m];
|
||||
mat[i] = (to === ASH && rnd() < ASHP[m]) ? ASH : EMPTY;
|
||||
flags[i] = 0; life[i] = 0; wakeXY(x, y);
|
||||
if (mat[i] === ASH) discover(ASH, x, y, m, 'burn');
|
||||
return;
|
||||
}
|
||||
life[i] = l;
|
||||
}
|
||||
if (open && rnd() < 0.3) {
|
||||
const j = neighborDir(i, x, y, (rnd() * 4) | 0);
|
||||
if (j >= 0 && mat[j] === EMPTY && part[j] !== P_INTAKE) {
|
||||
mat[j] = FIRE; temp[j] = temp[i]; life[j] = 8 + ((rnd() * 18) | 0); flags[j] = 0;
|
||||
wakeIdx(j);
|
||||
}
|
||||
}
|
||||
if (rnd() < 0.012) {
|
||||
const j = neighborDir(i, x, y, (rnd() * 4) | 0);
|
||||
if (j >= 0 && mat[j] === EMPTY) { mat[j] = SMOKE; temp[j] = temp[i] * 0.8; life[j] = 200 + ((rnd() * 55) | 0); flags[j] = 0; wakeIdx(j); }
|
||||
}
|
||||
}
|
||||
// Bessemer: air blown through molten iron burns off excess carbon (and gives off heat).
|
||||
function steelCheck(i, x, y) {
|
||||
if (airflowAt(i) && rnd() < 0.025) {
|
||||
mat[i] = MST; life[i] = 255; temp[i] += 45; discover(MST, x, y, MFE, 'blow');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
|
||||
// ---------- Bars (loose objects) ----------
|
||||
// A bar is 4x2 grains. It falls, rests on things, rides belts, and melts back
|
||||
// into molten metal if it gets hot enough. While it sits somewhere, grains treat
|
||||
// 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 entFreeAt(x, y) {
|
||||
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false;
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + dy) * W + x + dx)) return false;
|
||||
return true;
|
||||
}
|
||||
function entMark(e, on) {
|
||||
const x = e.ix, y = e.iy;
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
|
||||
const xx = x + dx, yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
|
||||
occ[yy * W + xx] = on ? 1 : 0;
|
||||
if (!on) wakeXY(xx, yy);
|
||||
}
|
||||
}
|
||||
function spawnBar(m, x, y, vx, vy, T) {
|
||||
if (ents.length >= 4000 || !m) return null;
|
||||
let ix = Math.round(x), iy = Math.round(y);
|
||||
// find a free spot near the requested one
|
||||
let ok = entFreeAt(ix, iy);
|
||||
for (let k = 1; !ok && k < 12; k++) { if (entFreeAt(ix, iy - k)) { iy -= k; ok = true; } }
|
||||
if (!ok) return null;
|
||||
const e = { m, x: ix, y: iy, ix, iy, vx: vx || 0, vy: vy || 0, T: T === undefined ? AMB[Math.max(0, Math.min(H - 1, iy))] : T, rest: 0 };
|
||||
ents.push(e); entMark(e, 1);
|
||||
return e;
|
||||
}
|
||||
function removeEnt(e) { entMark(e, 0); const k = ents.indexOf(e); if (k >= 0) ents.splice(k, 1); }
|
||||
function entTick() {
|
||||
for (let k = ents.length - 1; k >= 0; k--) {
|
||||
const e = ents[k];
|
||||
entMark(e, 0);
|
||||
entCurM = e.m;
|
||||
const ix = e.ix, iy = e.iy;
|
||||
// what is underneath?
|
||||
let belt = 0, bsat = 0, support = iy + BAR_H >= H;
|
||||
if (!support) {
|
||||
const yb = iy + BAR_H;
|
||||
for (let dx = 0; dx < BAR_W; dx++) {
|
||||
const x = ix + dx; if (x < 0 || x >= W) continue;
|
||||
const j = yb * W + x, m = mat[j];
|
||||
if (m === BELTL || m === BELTR) { const s = netSat(j); if (s > bsat) { bsat = s; belt = m === BELTR ? 1 : -1; } }
|
||||
else if (part[j] === P_FILTER && !filterPassAt(e.m, j) && (yb & 3) === 0) { bsat = 1; belt = blockV[(yb >> 2) * BW + (x >> 2)] & 128 ? 1 : -1; }
|
||||
if (entSolidCell(j)) support = true;
|
||||
}
|
||||
}
|
||||
const ci = Math.min(H - 1, iy + 1) * W + Math.min(W - 1, ix + 1);
|
||||
const inLiq = PH[mat[ci]] === PH_LIQUID;
|
||||
if (support) { if (e.vy > 0) e.vy = 0; e.vx = belt ? belt * 0.5 * bsat : e.vx * 0.55; }
|
||||
else { e.vy = Math.min(inLiq ? 0.35 : 1.3, e.vy + 0.07); e.vx *= inLiq ? 0.8 : 0.985; }
|
||||
if (belt && bsat > 0) stats.moved += 0.05;
|
||||
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 { e.vx = 0; e.x = cx; break; } }
|
||||
while (cy !== ty) { const s = Math.sign(ty - cy); if (entFreeAt(cx, cy + s)) cy += s; else { e.vy = 0; e.y = cy; break; } }
|
||||
if (!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; }
|
||||
if (!freed) e.y = cy;
|
||||
}
|
||||
e.ix = cx; e.iy = cy;
|
||||
// heat: the bar tends toward the temperature around it
|
||||
const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)];
|
||||
e.T += (T - e.T) * 0.04;
|
||||
if (e.T >= MELT[e.m]) {
|
||||
// melts back into 8 grains of molten metal if they fit here
|
||||
let fits = true;
|
||||
for (let dy = 0; dy < BAR_H && fits; dy++) for (let dx = 0; dx < BAR_W; dx++) { const j = (cy + dy) * W + cx + dx; if (PH[mat[j]] === PH_SOLID || PH[mat[j]] === PH_POWDER) { fits = false; break; } }
|
||||
if (fits) {
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) setCell((cy + dy) * W + cx + dx, MELTTO[e.m], e.T, 0);
|
||||
ents.splice(k, 1);
|
||||
discover(MELTTO[e.m], cx, cy);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
entMark(e, 1);
|
||||
}
|
||||
entCurM = 0;
|
||||
}
|
||||
function entsInCircle(cx, cy, r) {
|
||||
const out = [];
|
||||
for (const e of ents) {
|
||||
const ex = e.ix + BAR_W / 2, ey = e.iy + BAR_H / 2;
|
||||
if ((ex - cx) * (ex - cx) + (ey - cy) * (ey - cy) <= (r + 2.5) * (r + 2.5)) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Bag ----------
|
||||
function bagAdd(m, n, force) {
|
||||
const room = force ? n : Math.max(0, bagCap() - bagTotal);
|
||||
const k = Math.min(n, room);
|
||||
bag[m] += k; bagTotal += k;
|
||||
if (k > 0 && m < NMAT) discover(m, undefined, undefined, 0, 'pickup');
|
||||
return k;
|
||||
}
|
||||
function bagTake(m, n) { const k = Math.min(n, bag[m]); bag[m] -= k; bagTotal -= k; return k; }
|
||||
// Give back what fits in the bag; the rest drops into the world near (x, y):
|
||||
// grains as loose material, bars as bar objects. Counts what dropped.
|
||||
let droppedLast = 0;
|
||||
function giveOrDrop(m, n, x, y) {
|
||||
if (n <= 0) return;
|
||||
if (m === CASH) { money += n; return; }
|
||||
const k = bagAdd(m, n, false);
|
||||
let rest = n - k;
|
||||
if (!rest) return;
|
||||
droppedLast += rest;
|
||||
if (PH[m] === PH_ITEM) {
|
||||
for (let j = 0; j < rest; j++) if (!spawnBar(m, x - 2 + (j % 3) * 2, y - 2 - j, (rnd() - 0.5) * 0.4, -0.3)) bagAdd(m, 1, true);
|
||||
return;
|
||||
}
|
||||
for (let r = 0; r <= 12 && rest > 0; r++) for (let dy = -r; dy <= r && rest > 0; dy++) for (let dx = -r; dx <= r && rest > 0; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
const xx = Math.round(x) + dx, yy = Math.round(y) + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
|
||||
const j = yy * W + xx;
|
||||
if (mat[j] === EMPTY && !occ[j] && !part[j] && !mach[j]) { setCell(j, m, AMB[yy], 0); rest--; }
|
||||
}
|
||||
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 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(cost, mult); }
|
||||
@@ -0,0 +1,43 @@
|
||||
// Milestone checks (the ledger) and the sky roof line used for cooling.
|
||||
// ---------- Ledger ----------
|
||||
function checkLedger() {
|
||||
const done = id => ledger[id];
|
||||
const mark = id => { if (!ledger[id]) { ledger[id] = tick || 1; emit({ t: 'milestone', id }); } };
|
||||
if (stats.dug >= 15) mark('dig');
|
||||
if (seen[FIRE]) { mark('fire'); if (!player.lens) { player.lens = true; } }
|
||||
if (seen[BRICK] || seen[RBRICK]) mark('brick');
|
||||
if (seen[CHARCOAL]) mark('charcoal');
|
||||
if (stats.grown > 0) mark('grow');
|
||||
if (seen[MCU]) mark('copper');
|
||||
if (stats.bars > 0) mark('bars');
|
||||
if (player.bitTier >= 1) mark('bit1');
|
||||
let anyPower = false, steamOn = false;
|
||||
for (const mm of machines) if (mm) { if ((mm.type === 'wheel' || mm.type === 'engine') && mm.out > 0.5) anyPower = true; if (mm.type === 'engine' && mm.out > 1) steamOn = true; }
|
||||
if (anyPower && !POWER_FREE) mark('power');
|
||||
if (stats.moved >= 300) mark('auto');
|
||||
if (seen[MGL] || seen[GLASS] || seen[CULLET]) { mark('glass'); player.flask = true; }
|
||||
if (steamOn && !POWER_FREE) mark('steam');
|
||||
if (seen[MFE]) mark('iron');
|
||||
if (player.bitTier >= 2) mark('bit2');
|
||||
if (seen[MST]) mark('steel');
|
||||
if (player.bitTier >= 3) mark('bit3');
|
||||
if (player.tankTier >= 1) mark('tank');
|
||||
if (depthU(bot.y) > 0.85) mark('deep');
|
||||
if (stats.sold > 0) mark('sale');
|
||||
if (ordersDone > 0) mark('order');
|
||||
if (seen[MBZ] || seen[MBS]) mark('alloy');
|
||||
if (seen[AGORE] || seen[AUORE] || seen[SILVER] || seen[GOLD]) mark('precious');
|
||||
if (debt <= 0) mark('claim');
|
||||
if (earned >= 50000) mark('rich1');
|
||||
if (earned >= 200000) mark('rich2');
|
||||
void done;
|
||||
}
|
||||
|
||||
// Topmost non-air cell of each column: air above it can shed heat to the sky.
|
||||
function updateRoof() {
|
||||
for (let x = 0; x < W; x++) {
|
||||
let y = 0;
|
||||
for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; }
|
||||
roofY[x] = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
|
||||
function engineIntake(mm) {
|
||||
for (const i of mm.intake) {
|
||||
if (mat[i] === STEAM) {
|
||||
const T = temp[i];
|
||||
mm.energy += 1 + Math.max(0, T - 100) / 120;
|
||||
mat[i] = EMPTY; life[i] = 0; temp[i] = AMB[(i / W) | 0];
|
||||
wakeIdx(i);
|
||||
const ex = mm.dir === 0 ? mm.x + mm.w : mm.x - 1, ey = mm.y + mm.h - 1;
|
||||
if (ex >= 0 && ex < W) {
|
||||
const j = ey * W + ex;
|
||||
if (mat[j] === EMPTY && !part[j]) { mat[j] = WATER; temp[j] = 92; life[j] = 0; flags[j] = 0; wakeXY(ex, ey); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function fanBlow(mm, sat) {
|
||||
const dir = mm.dir; // 0 right, 1 down, 2 left, 3 up
|
||||
const dx = [1, 0, -1, 0][dir], dy = [0, 1, 0, -1][dir];
|
||||
const cur = curAir();
|
||||
for (let lane = 0; lane < 4; lane++) {
|
||||
let sx, sy;
|
||||
if (dir === 0) { sx = mm.x + 4; sy = mm.y + lane; }
|
||||
else if (dir === 2) { sx = mm.x - 1; sy = mm.y + lane; }
|
||||
else if (dir === 1) { sx = mm.x + lane; sy = mm.y + 4; }
|
||||
else { sx = mm.x + lane; sy = mm.y - 1; }
|
||||
let powderDepth = 0;
|
||||
const range = 28;
|
||||
for (let k = 0; k < range; k++) {
|
||||
const x = sx + dx * k, y = sy + dy * k;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) break;
|
||||
const i = y * W + x, m = mat[i], ph = PH[m];
|
||||
if (ph === PH_SOLID && part[i] !== P_GRATE) break;
|
||||
airS[i] = cur;
|
||||
if (ph === PH_POWDER) { if (++powderDepth > 16) break; }
|
||||
else if (ph === PH_GAS && k < range - 1 && rnd() < sat * 0.7 && stamp[i] !== tickStamp) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (nx >= 0 && ny >= 0 && nx < W && ny < H) {
|
||||
const j = ny * W + nx;
|
||||
if (mat[j] === EMPTY && passable(m, PH_GAS, j, dx, dy)) move(i, j, x, y, nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
wakeHeatXY(Math.min(W - 1, Math.max(0, sx + dx * 4)), Math.min(H - 1, Math.max(0, sy + dy * 4)));
|
||||
}
|
||||
}
|
||||
function machinesTick() {
|
||||
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
|
||||
if (brokenMachines.length) {
|
||||
const ids = brokenMachines; brokenMachines = [];
|
||||
for (const id of ids) if (machines[id]) removeMachine(id, false, true);
|
||||
}
|
||||
for (const mm of machines) {
|
||||
if (!mm) continue;
|
||||
if (mm.type === 'wheel') {
|
||||
const inst = mm.flow * WHEEL_K;
|
||||
mm.out += (inst - mm.out) * 0.04; mm.flow = 0;
|
||||
mm.angle += Math.min(0.35, mm.out * 0.012);
|
||||
} else if (mm.type === 'refiner') {
|
||||
refinerTick(mm);
|
||||
} else if (mm.type === 'cutter') {
|
||||
cutterTick(mm);
|
||||
} else if (mm.type === 'silo') {
|
||||
siloTick(mm);
|
||||
} else if (mm.type === 'engine') {
|
||||
engineIntake(mm);
|
||||
mm.out += (mm.energy * ENGINE_K - mm.out) * 0.03; mm.energy = 0;
|
||||
mm.angle += Math.min(0.4, mm.out * 0.01);
|
||||
}
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
for (const mm of machines) {
|
||||
if (!mm || mm.type !== 'fan') continue;
|
||||
const net = nets[mm.net];
|
||||
const sat = POWER_FREE ? 1 : net ? net.sat : 0;
|
||||
mm.angle += sat * 0.5 * (mm.on ? 1 : 0);
|
||||
if (mm.on && sat > 0.05) fanBlow(mm, sat);
|
||||
// a fan getting close to melting says so (once every few seconds)
|
||||
if ((tick + mm.id) % 30 === 0) {
|
||||
let hotT = 0;
|
||||
for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; }
|
||||
if (hotT > 760 && (!mm.warnT || tick - mm.warnT > 600)) { mm.warnT = tick; emit({ t: 'toast', text: `A fan is at ${Math.round(hotT)}°C and melts at 1085°C. Move it out of the heat: outside the furnace wall, blowing in through a grate.` }); }
|
||||
}
|
||||
}
|
||||
// belts and filters move what rests on them every other tick: half a grain per tick
|
||||
if ((tick & 1) === 0) {
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL;
|
||||
for (let k = 0; k < list.length; k++) {
|
||||
const b = list[k];
|
||||
if (mat[b] !== bm || b < W) continue;
|
||||
const above = mat[b - W];
|
||||
if (above === BELTL || above === BELTR) continue; // only the top surface carries
|
||||
if (netSat(b) <= 0) continue;
|
||||
if (PH[above] === PH_LIQUID) beltDrain(b);
|
||||
carryColumn(b - W, d, null);
|
||||
}
|
||||
}
|
||||
if (filtersDirty) rebuildFilters();
|
||||
for (const b of filterR) filterCarry(b, 1);
|
||||
for (const b of filterL) filterCarry(b, -1);
|
||||
}
|
||||
// tubes: every loose grain inside steps one cell along, front of the line first, on the same
|
||||
// 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;
|
||||
for (let k = 0; k < list.length; k++) {
|
||||
const i = list[k];
|
||||
if (part[i] !== p) 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 (stamp[i] === tickStamp) continue;
|
||||
if (temp[i] > 1000 && rnd() < 0.02) {
|
||||
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i);
|
||||
continue;
|
||||
}
|
||||
const s = netSat(i);
|
||||
if (s <= 0 || rnd() > s) continue;
|
||||
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; }
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
const j = i + dj;
|
||||
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 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) {
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
let c = j, cx = nx, cy = ny;
|
||||
const bxj = nx >> 2, byj = ny >> 2;
|
||||
for (;;) {
|
||||
const ex = cx + dx, ey = cy + dy;
|
||||
if (ex < 0 || ey < 0 || ex >= W || ey >= H || (ex >> 2) !== bxj || (ey >> 2) !== byj) break;
|
||||
const e = ey * W + ex;
|
||||
if (part[e] !== pj || !roomFor(e) || occ[e]) break;
|
||||
move(c, e, cx, cy, ex, ey); c = e; cx = ex; cy = ey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Machines: the power network, footprints, placing and removing machines.
|
||||
// ---------- Power & machines ----------
|
||||
function markPow(i, on) {
|
||||
if (on) { if (!isPow[i]) { isPow[i] = 1; pcells.add(i); } }
|
||||
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
|
||||
powerDirty = true;
|
||||
}
|
||||
function partLost(i) { markPow(i, false); }
|
||||
let nextPowerBuild = 0;
|
||||
function rebuildPower() {
|
||||
powerDirty = false;
|
||||
for (const i of pcells) netId[i] = 0;
|
||||
nets = [null]; beltL = []; beltR = []; tubeLists = [[], [], [], [], []];
|
||||
const stack = [];
|
||||
for (const s of pcells) {
|
||||
if (netId[s]) continue;
|
||||
const id = nets.length;
|
||||
if (id >= 65535) break;
|
||||
const net = { id, nb: 0, nt: 0, fans: [], prods: [], supply: 0, demand: 0, sat: 0 };
|
||||
nets.push(net);
|
||||
netId[s] = id; stack.push(s);
|
||||
while (stack.length) {
|
||||
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;
|
||||
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); } }
|
||||
if (i >= W) { j = i - W; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
if (i < N - W) { j = i + W; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
}
|
||||
}
|
||||
for (const mm of machines) {
|
||||
if (!mm) continue;
|
||||
mm.net = netId[mm.cell0] || 0;
|
||||
const net = nets[mm.net];
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
const m = mat[i], ph = PH[m];
|
||||
if (m === EMPTY || ph === PH_SOLID) continue;
|
||||
if (ph === PH_GAS) { mat[i] = EMPTY; life[i] = 0; continue; }
|
||||
const x = i % W;
|
||||
let moved = false;
|
||||
for (let y = topY - 1; y >= Math.max(0, topY - 80); y--) {
|
||||
const j = y * W + x;
|
||||
if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) { swapCells(i, j); wakeXY(x, y); moved = true; break; }
|
||||
if (PH[mat[j]] === PH_SOLID) break;
|
||||
}
|
||||
if (!moved) { mat[i] = EMPTY; life[i] = 0; flags[i] = 0; }
|
||||
wakeIdx(i);
|
||||
}
|
||||
}
|
||||
function cellBuildable(i) { return PH[mat[i]] !== PH_SOLID && !part[i] && !mach[i] && !occ[i]; }
|
||||
|
||||
function machineFootprint(type, x, y) {
|
||||
// [cx, cy, kind] where kind is 'hull', 'void' (inside the machine's area but open) or a part code
|
||||
const out = [];
|
||||
if (type === 'wheel') {
|
||||
for (let dy = 0; dy < 12; dy++) for (let dx = 0; dx < 12; dx++) {
|
||||
const ex = dx - 5.5, ey = dy - 5.5;
|
||||
out.push([x + dx, y + dy, ex * ex + ey * ey <= 36.5 ? P_WHEEL : 'void']);
|
||||
}
|
||||
} else if (type === 'fan') {
|
||||
for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) out.push([x + dx, y + dy, 'hull']);
|
||||
} else if (type === 'cutter') {
|
||||
// a hopper 4 grains wide over the saw floor; bricks drop out underneath
|
||||
for (let dy = 0; dy < 8; dy++) for (let dx = 0; dx < 8; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 6 || dy >= 5 ? 'hull' : 'void']);
|
||||
} else if (type === 'silo') {
|
||||
// roof with a hatch, the body, a cone narrowing to the gate, and two legs; the gaps beside
|
||||
// the cone are left open
|
||||
for (let dy = 0; dy < 24; dy++) for (let dx = 0; dx < 12; dx++) {
|
||||
let k = null;
|
||||
if (dy < 2) k = dx >= 3 && dx <= 8 ? 'void' : 'hull';
|
||||
else if (dy < 20) k = 'hull';
|
||||
else if (dx === 0 || dx === 11) k = 'hull'; // legs
|
||||
else if (dy === 20) k = dx >= 1 && dx <= 10 ? 'hull' : null;
|
||||
else if (dy === 21) k = dx >= 2 && dx <= 9 ? 'hull' : null;
|
||||
else k = dx >= 4 && dx <= 7 ? 'void' : dx === 3 || dx === 8 ? 'hull' : null; // the gate
|
||||
if (k) out.push([x + dx, y + dy, k]);
|
||||
}
|
||||
} else if (type === 'refiner') {
|
||||
// an open hopper 8 grains wide over the grinding floor; what it lets out drops from under the floor
|
||||
for (let dy = 0; dy < 12; dy++) for (let dx = 0; dx < 12; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 10 || dy >= 10 ? 'hull' : 'void']);
|
||||
} else if (type === 'dock') {
|
||||
// an open-topped hopper: 2-grain walls and floor around a 20x14 bin
|
||||
for (let dy = 0; dy < 16; dy++) for (let dx = 0; dx < 24; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 22 || dy >= 14 ? 'hull' : 'void']);
|
||||
} else if (type === 'engine') {
|
||||
for (let dy = 0; dy < 8; dy++) for (let dx = 0; dx < 12; dx++) {
|
||||
if (dy === 7 && dx >= 4 && dx <= 7) out.push([x + dx, y + dy, P_INTAKE]);
|
||||
else out.push([x + dx, y + dy, 'hull']);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function canPlaceMachine(type, bx, by) {
|
||||
const P = PIECE_BY_ID[type];
|
||||
if (bx < 0 || by < 0 || bx + P.bw > BW || by + P.bh > BH) return 'Out of bounds';
|
||||
for (const [cx, cy] of machineFootprint(type, bx * BS, by * BS)) {
|
||||
const i = cy * W + cx;
|
||||
if (mach[i] || part[i]) return 'Something is already built there';
|
||||
if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
|
||||
if (occ[i]) return 'A bar is in the way';
|
||||
}
|
||||
for (let y = by; y < by + P.bh; y++) for (let x = bx; x < bx + P.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
|
||||
return null;
|
||||
}
|
||||
function addMachine(type, bx, by, dir) {
|
||||
let id = machines.indexOf(null, 1);
|
||||
if (id < 0) id = machines.length;
|
||||
const P = PIECE_BY_ID[type];
|
||||
const x = bx * BS, y = by * BS;
|
||||
const mm = { id, type, x, y, w: P.w, h: P.h, dir: dir | 0, on: true, out: 0, flow: 0, energy: 0, angle: 0, net: 0, cell0: y * W + x, intake: [] };
|
||||
machines[id] = mm;
|
||||
const fp = machineFootprint(type, x, y);
|
||||
evictCells(fp.filter(f => f[2] === 'hull').map(([cx, cy]) => cy * W + cx), y);
|
||||
for (const [cx, cy, k] of fp) {
|
||||
const i = cy * W + cx;
|
||||
if (k === 'hull') { mat[i] = type === 'dock' ? DOCKW : HULL; flags[i] = 0; life[i] = 0; temp[i] = AMB[cy]; if (type !== 'dock') markPow(i, true); }
|
||||
else if (k !== 'void') { part[i] = k; markPow(i, true); if (k === P_INTAKE) mm.intake.push(i); }
|
||||
mach[i] = id;
|
||||
wakeXY(cx, cy);
|
||||
}
|
||||
if (type === 'wheel') mm.cell0 = (y + 6) * W + x + 6;
|
||||
return mm;
|
||||
}
|
||||
function removeMachine(id, refund, scrap) {
|
||||
const mm = machines[id];
|
||||
if (!mm) return;
|
||||
for (const [cx, cy, k] of machineFootprint(mm.type, mm.x, mm.y)) {
|
||||
const i = cy * W + cx;
|
||||
if (mach[i] !== id) continue;
|
||||
mach[i] = 0; markPow(i, false);
|
||||
if (k === 'hull') { if (mat[i] === HULL || mat[i] === DOCKW) { mat[i] = scrap ? SCRCU : EMPTY; flags[i] = 0; } }
|
||||
else if (k !== 'void') part[i] = 0;
|
||||
wakeXY(cx, cy);
|
||||
}
|
||||
machines[id] = null;
|
||||
if (refund) { refundAt(PIECE_BY_ID[mm.type].cost, 1, mm.x + mm.w / 2, mm.y + mm.h / 2); undoNote({ t: 'mach-', type: mm.type, bx: mm.x / BS, by: mm.y / BS, dir: mm.dir, on: mm.on }); }
|
||||
if (scrap) emit({ t: 'toast', text: `${PIECE_BY_ID[mm.type].name} was destroyed by heat.` });
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Material ids, the material table (def), per-material property arrays, cell flags and part
|
||||
// ids, and drill/tank tiers.
|
||||
// ---------- Material ids ----------
|
||||
const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9,
|
||||
WOOD = 10, LEAVES = 11, COALV = 12, COAL = 13, CUV = 14, CUORE = 15, FEV = 16, FEORE = 17, ASH = 18, SLAG = 19,
|
||||
BRICK = 20, GLASS = 21, COPPER = 22, IRON = 23, STEEL = 24, WATER = 25, MAGMA = 26, MCU = 27, MFE = 28, MST = 29,
|
||||
MGL = 30, STEAM = 31, SMOKE = 32, FIRE = 33, BELTL = 34, BELTR = 35, SHAFT = 36, GATE = 37, HULL = 38, SPRING = 39,
|
||||
RSTONE = 40, RGRAN = 41, RBAS = 42, LCLAY = 43, LWOOD = 44, RBRICK = 45, CULLET = 46, SCRCU = 47, SCRFE = 48, SCRST = 49,
|
||||
BARCU = 50, BARFE = 51, BARST = 52, ACORN = 53, CHARCOAL = 54, SNOW = 55, ICE = 56,
|
||||
SNV = 57, SNORE = 58, TIN = 59, MSN = 60, SCRSN = 61, BARSN = 62,
|
||||
ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68,
|
||||
AGV = 69, AGORE = 70, SILVER = 71, MAG = 72, SCRAG = 73, BARAG = 74,
|
||||
AUV = 75, AUORE = 76, GOLD = 77, MAU = 78, SCRAU = 79, BARAU = 80,
|
||||
BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94;
|
||||
const NMAT = 95;
|
||||
|
||||
const MD = [];
|
||||
function def(id, o) {
|
||||
MD[id] = Object.assign({
|
||||
id, name: '?', ph: PH_SOLID, col: [255, 0, 255], v: 10, dens: 2, k: 0.01, cap: 1, hard: 1, yld: id,
|
||||
melt: 0, meltTo: 0, smelt: 0, freeze: 0, freezeTo: 0, boil: 0, boilTo: 0, condT: 0, condTo: 0,
|
||||
ign: 0, flame: 0, flameF: 0, fuel: 0, burnTo: 0, ashP: 0, fluid: 1, spread: 4, stick: 1, alpha: 1,
|
||||
liqT: 0, bag: false, partMat: false, desc: ''
|
||||
}, o);
|
||||
}
|
||||
def(EMPTY, { name: 'Air', ph: PH_EMPTY, k: 0.006, cap: 0.3, hard: 0, yld: 0 });
|
||||
def(GRASS, { name: 'Grass', col: [78, 142, 62], v: 16, k: 0.008, hard: 1, yld: LDIRT });
|
||||
def(DIRT, { name: 'Packed dirt', col: [108, 76, 50], v: 14, k: 0.008, hard: 1, yld: LDIRT });
|
||||
def(LDIRT, { name: 'Dirt', ph: PH_POWDER, col: [124, 88, 58], v: 14, dens: 1.3, k: 0.007, hard: 0, stick: 0.55, bag: true, desc: 'Loose soil. Falls and piles up.' });
|
||||
def(SAND, { name: 'Sand', ph: PH_POWDER, col: [216, 192, 132], v: 12, dens: 1.6, k: 0.007, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Melts into glass near 1300°C.' });
|
||||
def(GRAVEL, { name: 'Gravel', ph: PH_POWDER, col: [124, 121, 116], v: 30, dens: 1.9, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Loose rock. Falls like sand.' });
|
||||
def(CLAY, { name: 'Clay', col: [172, 108, 88], v: 8, dens: 1.8, k: 0.009, hard: 1, yld: LCLAY, desc: 'Holds its shape. Fires into brick above ~900°C.' });
|
||||
def(STONE, { name: 'Stone', col: [106, 106, 112], v: 12, k: 0.012, hard: 3, melt: 1250, meltTo: MAGMA, yld: RSTONE, desc: 'Common rock. Slumps into magma at 1250°C.' });
|
||||
def(GRANITE, { name: 'Granite', col: [152, 118, 116], v: 24, k: 0.012, hard: 5, melt: 1260, meltTo: MAGMA, yld: RGRAN, desc: 'Hard rock. Needs an iron bit.' });
|
||||
def(BASALT, { name: 'Basalt', col: [50, 50, 58], v: 10, k: 0.012, hard: 6, melt: 1350, meltTo: MAGMA, yld: RBAS, desc: 'The hardest rock. Needs a steel bit.' });
|
||||
def(WOOD, { name: 'Wood', col: [184, 136, 82], v: 10, k: 0.005, cap: 1.2, hard: 2, ign: 300, flame: 850, flameF: 1150, fuel: 200, burnTo: ASH, ashP: 0.5, yld: LWOOD, desc: 'Burns around 850°C. Builds water wheels and shafts.' });
|
||||
def(LEAVES, { name: 'Leaves', col: [54, 118, 52], v: 24, k: 0.004, hard: 0, yld: 0, ign: 240, flame: 650, flameF: 800, fuel: 20, burnTo: EMPTY });
|
||||
def(COALV, { name: 'Coal seam', col: [40, 38, 41], v: 12, k: 0.01, hard: 2, yld: COAL, ign: 430, flame: 1350, flameF: 2000, fuel: 250, burnTo: ASH, ashP: 0.15 });
|
||||
def(COAL, { name: 'Coal', ph: PH_POWDER, col: [32, 31, 35], v: 12, dens: 1.1, k: 0.02, hard: 0, ign: 420, flame: 1350, flameF: 2000, fuel: 250, burnTo: ASH, ashP: 0.15, bag: true, desc: 'Fuel. ~1350°C in still air, ~2000°C with forced air.' });
|
||||
def(CUV, { name: 'Copper ore vein', col: [66, 142, 116], v: 34, k: 0.012, hard: 3, yld: CUORE, melt: 1085, meltTo: MCU, smelt: 0.65 });
|
||||
def(CUORE, { name: 'Copper ore', ph: PH_POWDER, col: [76, 152, 126], v: 30, dens: 3.0, k: 0.012, hard: 0, melt: 1085, meltTo: MCU, smelt: 0.65, bag: true, desc: 'Melts at 1085°C into copper and slag. Mix it about half and half with coal: ore blocks air, so a layer of ore smothers the fire under it.' });
|
||||
def(FEV, { name: 'Iron ore vein', col: [142, 78, 60], v: 26, k: 0.012, hard: 4, yld: FEORE, melt: 1538, meltTo: MFE, smelt: 0.6 });
|
||||
def(FEORE, { name: 'Iron ore', ph: PH_POWDER, col: [154, 88, 66], v: 26, dens: 3.5, k: 0.012, hard: 0, melt: 1538, meltTo: MFE, smelt: 0.6, bag: true, desc: 'Melts at 1538°C into iron and slag.' });
|
||||
def(ASH, { name: 'Ash', ph: PH_POWDER, col: [152, 149, 144], v: 18, dens: 0.5, k: 0.003, hard: 0, stick: 0.5, melt: 1250, meltTo: MSLAG, bag: true, desc: 'What is left after a fire. Fuses into slag in a fire hotter than 1250°C.' });
|
||||
def(SLAG, { name: 'Slag', ph: PH_POWDER, col: [76, 66, 64], v: 34, dens: 2.4, k: 0.006, hard: 0, melt: 1150, meltTo: MSLAG, bag: true, desc: 'Smelting waste. Melts again above 1150°C. Knocking out a mold rakes it off the grate above.' });
|
||||
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(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.' });
|
||||
def(MAGMA, { name: 'Magma', ph: PH_LIQUID, col: [255, 96, 26], v: 20, dens: 2.7, k: 0.012, cap: 1.2, hard: 0, freeze: 1100, freezeTo: BASALT, fluid: 0.25, spread: 10, liqT: 1250, bag: true, desc: 'Molten rock. Freezes into basalt below 1100°C.' });
|
||||
def(MCU, { name: 'Molten copper', ph: PH_LIQUID, col: [255, 152, 62], v: 10, dens: 8, k: 0.04, cap: 1.5, hard: 0, freeze: 1080, freezeTo: COPPER, fluid: 0.7, spread: 20, liqT: 1160, bag: true, desc: 'Sets into copper below 1080°C.' });
|
||||
def(MFE, { name: 'Molten iron', ph: PH_LIQUID, col: [255, 192, 92], v: 10, dens: 7, k: 0.035, cap: 1.5, hard: 0, freeze: 1530, freezeTo: IRON, fluid: 0.6, spread: 20, liqT: 1610, bag: true, desc: 'Sets into iron below 1530°C.' });
|
||||
def(MST, { name: 'Molten steel', ph: PH_LIQUID, col: [255, 222, 142], v: 10, dens: 7.2, k: 0.035, cap: 1.5, hard: 0, freeze: 1440, freezeTo: STEEL, fluid: 0.6, spread: 20, liqT: 1520, bag: true, desc: 'Sets into steel below 1440°C.' });
|
||||
def(MGL, { name: 'Molten glass', ph: PH_LIQUID, col: [255, 172, 82], v: 8, dens: 2.3, k: 0.006, cap: 1, hard: 0, freeze: 1200, freezeTo: GLASS, fluid: 0.12, spread: 5, liqT: 1350, bag: true, desc: 'Thick and slow. Sets into glass below 1200°C.' });
|
||||
def(STEAM, { name: 'Steam', ph: PH_GAS, col: [214, 222, 232], v: 10, dens: 0.3, k: 0.006, cap: 0.4, hard: 0, condT: 97, condTo: WATER, alpha: 0.42, desc: 'Rises. Under a roof it turns back into water below 97°C; in the open sky it rises into the clouds and comes back down as rain.' });
|
||||
def(SMOKE, { name: 'Smoke', ph: PH_GAS, col: [62, 60, 62], v: 14, dens: 0.4, k: 0.004, cap: 0.3, hard: 0, alpha: 0.55, desc: 'Burning fuel gives it off. It rises, and a fire that cannot vent it chokes.' });
|
||||
def(FIRE, { name: 'Flame', ph: PH_GAS, col: [255, 150, 40], v: 40, dens: 0.2, k: 0.02, cap: 0.3, hard: 0, alpha: 0.9 });
|
||||
def(BELTL, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
|
||||
def(BELTR, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
|
||||
def(SHAFT, { name: 'Shaft', col: [150, 110, 64], v: 6, k: 0.005, hard: 2, ign: 340, flame: 800, flameF: 1000, fuel: 140, burnTo: ASH, ashP: 0.3, partMat: true });
|
||||
def(GATE, { name: 'Gate', col: [146, 74, 58], v: 6, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, partMat: true });
|
||||
def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' });
|
||||
def(RSTONE, { name: 'Stone rubble', ph: PH_POWDER, col: [126, 125, 130], v: 30, dens: 1.8, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Broken stone. 16 grains make a stone block.' });
|
||||
def(RGRAN, { name: 'Granite rubble', ph: PH_POWDER, col: [164, 128, 124], v: 32, dens: 1.9, k: 0.01, hard: 0, melt: 1260, meltTo: MAGMA, bag: true, desc: 'Broken granite. Makes granite blocks.' });
|
||||
def(RBAS, { name: 'Basalt rubble', ph: PH_POWDER, col: [72, 72, 80], v: 20, dens: 2.0, k: 0.01, hard: 0, melt: 1350, meltTo: MAGMA, bag: true, desc: 'Broken basalt. Basalt blocks survive 1350°C.' });
|
||||
def(LCLAY, { name: 'Clay lumps', ph: PH_POWDER, col: [182, 116, 94], v: 16, dens: 1.6, k: 0.008, hard: 0, stick: 0.25, bag: true, desc: 'Sticky lumps. Clay blocks, grates and gates. Fires into brick above ~900°C.' });
|
||||
def(LWOOD, { name: 'Wood chunks', ph: PH_POWDER, col: [208, 160, 98], v: 20, dens: 0.6, k: 0.005, cap: 1.2, hard: 0, stick: 0.45, ign: 280, flame: 850, flameF: 1150, fuel: 160, burnTo: ASH, ashP: 0.4, bag: true, desc: 'Fuel that floats on water. Wooden blocks, shafts and water wheels.' });
|
||||
def(RBRICK, { name: 'Brick rubble', ph: PH_POWDER, col: [174, 88, 66], v: 26, dens: 1.7, k: 0.004, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Broken or fired brick. 16 grains make a brick block.' });
|
||||
def(CULLET, { name: 'Glass shards', ph: PH_POWDER, col: [196, 228, 236], v: 22, dens: 1.5, k: 0.006, hard: 0, melt: 1300, meltTo: MGL, alpha: 0.75, bag: true, desc: 'Broken glass. Glass blocks, or melt it back down.' });
|
||||
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(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.' });
|
||||
def(CHARCOAL, { name: 'Charcoal', ph: PH_POWDER, col: [58, 50, 46], v: 16, dens: 0.45, k: 0.012, hard: 0, stick: 0.8, ign: 340, flame: 1300, flameF: 1950, fuel: 200, burnTo: ASH, ashP: 0.06, bag: true, desc: 'Wood baked without air. Burns nearly as hot as coal, leaves little ash, and floats.' });
|
||||
def(SNOW, { name: 'Snow', ph: PH_POWDER, col: [232, 238, 246], v: 10, dens: 0.35, k: 0.004, cap: 0.5, hard: 0, stick: 0.4, melt: 0.5, meltTo: WATER, bag: true, desc: 'Melts into water above 0°C. Floats on water.' });
|
||||
def(ICE, { name: 'Ice', col: [178, 214, 238], v: 12, k: 0.03, cap: 0.5, hard: 1, melt: 0.5, meltTo: WATER, alpha: 0.85, yld: SNOW, desc: 'Frozen water. Melts above 0°C; drills into snow.' });
|
||||
// A metal family: vein in the rock, loose ore, cast solid, molten, scrap and bar.
|
||||
function metalFamily(o) {
|
||||
const { vein, ore, solid, molten, scrap, bar, name, col, mcol, melt, dens, k, hardOre, hardVein, smelt, oreCol, desc } = o;
|
||||
if (vein) def(vein, { name: name + ' ore vein', col: oreCol.map(c => c - 10), v: 34, k: 0.012, hard: hardVein, yld: ore, melt: smelt, meltTo: molten, smelt: 0.6 });
|
||||
if (ore) def(ore, { name: name + ' ore', ph: PH_POWDER, col: oreCol, v: 30, dens: 3.2, k: 0.012, hard: 0, melt: smelt, meltTo: molten, smelt: 0.6, bag: true, desc: `Smelts at ${smelt}°C into ${name.toLowerCase()} and slag. ${o.oreNote || ''}` });
|
||||
def(solid, { name, col, v: 8, k, cap: 1.5, hard: o.hardSolid || 3, melt, meltTo: molten, yld: scrap, desc });
|
||||
def(molten, { name: 'Molten ' + name.toLowerCase(), ph: PH_LIQUID, col: mcol, v: 10, dens, k, cap: 1.5, hard: 0, freeze: melt - 5, freezeTo: solid, fluid: 0.7, spread: 20, liqT: melt + 70, bag: true, desc: `Sets into ${name.toLowerCase()} below ${melt - 5}°C.` });
|
||||
def(scrap, { name: name + ' scrap', ph: PH_POWDER, col: col.map(c => c - 8), v: 32, dens: 5, k, cap: 1.5, hard: 0, stick: 0.7, melt, meltTo: molten, bag: true, desc: `${name} that set outside a mold. Melt it again and cast it.` });
|
||||
def(bar, { name: name + ' bar', ph: PH_ITEM, col: col.map(c => Math.min(255, c + 12)), v: 0, dens, k: 0, hard: 0, melt, meltTo: molten, bag: true, desc: `A cast bar of ${name.toLowerCase()}.` });
|
||||
}
|
||||
metalFamily({ vein: SNV, ore: SNORE, solid: TIN, molten: MSN, scrap: SCRSN, bar: BARSN, name: 'Tin', oreCol: [140, 156, 178], col: [196, 200, 206], mcol: [232, 234, 240], melt: 232, smelt: 1000, dens: 7, k: 0.03, hardVein: 3, hardSolid: 2,
|
||||
oreNote: 'Melt tin into molten copper to make bronze.', desc: 'Soft and silvery. Melts at only 232°C. Mixed into molten copper it makes bronze.' });
|
||||
metalFamily({ vein: ZNV, ore: ZNORE, solid: ZINC, molten: MZN, scrap: SCRZN, bar: BARZN, name: 'Zinc', oreCol: [126, 100, 44], col: [166, 178, 190], mcol: [214, 224, 236], melt: 420, smelt: 1000, dens: 6.6, k: 0.03, hardVein: 4,
|
||||
oreNote: 'Melt zinc into molten copper to make brass.', desc: 'Bluish metal that melts at 420°C. Mixed into molten copper it makes brass.' });
|
||||
metalFamily({ vein: AGV, ore: AGORE, solid: SILVER, molten: MAG, scrap: SCRAG, bar: BARAG, name: 'Silver', oreCol: [200, 204, 214], col: [214, 216, 224], mcol: [240, 236, 228], melt: 962, smelt: 962, dens: 9.3, k: 0.045, hardVein: 4,
|
||||
oreNote: 'Found deep in granite. Valuable even raw.', desc: 'A precious metal. Sells high.' });
|
||||
metalFamily({ vein: AUV, ore: AUORE, solid: GOLD, molten: MAU, scrap: SCRAU, bar: BARAU, name: 'Gold', oreCol: [218, 182, 62], col: [232, 190, 72], mcol: [255, 214, 120], melt: 1064, smelt: 1064, dens: 17, k: 0.045, hardVein: 5,
|
||||
oreNote: 'The deepest and rarest ore. Sells for a fortune.', desc: 'The most valuable metal there is.' });
|
||||
metalFamily({ solid: BRONZE, molten: MBZ, scrap: SCRBZ, bar: BARBZ, name: 'Bronze', col: [184, 126, 72], mcol: [255, 170, 90], melt: 950, dens: 8.7, k: 0.035, hardSolid: 4,
|
||||
desc: 'Copper with a little tin melted in. Harder than copper and worth far more.' });
|
||||
metalFamily({ solid: BRASS, molten: MBS, scrap: SCRBS, bar: BARBS, name: 'Brass', col: [212, 174, 84], mcol: [255, 196, 104], melt: 930, dens: 8.4, k: 0.035, hardSolid: 4,
|
||||
desc: 'Copper with zinc melted in. Golden and valuable.' });
|
||||
def(BARGL, { name: 'Glass brick', ph: PH_ITEM, col: [176, 218, 228], v: 0, dens: 2.5, k: 0, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Cast from molten glass in an Ingot mold: 8 grains of glass. Sells well, and 2 make a glass wall block.' });
|
||||
def(CUTST, { name: 'Cut stone', col: [168, 162, 150], v: 6, k: 0.012, hard: 4, melt: 1450, meltTo: MAGMA, yld: BARSB, desc: 'Dressed stone blocks laid in courses. Neater than rough stone, and it stands 200°C more heat before it slumps.' });
|
||||
def(BARSB, { name: 'Stone brick', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Stone cutter, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' });
|
||||
def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
|
||||
def(DOCKW, { name: 'Trade dock', col: [70, 82, 96], v: 6, k: 0.01, cap: 2, hard: 9, yld: 0, partMat: true, desc: 'The claim office\'s trade dock. Whatever drops through the grate in its floor is sold.' });
|
||||
def(HULL, { name: 'Machine', col: [84, 92, 102], v: 6, k: 0.03, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
|
||||
|
||||
// Typed property tables
|
||||
const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT),
|
||||
HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT),
|
||||
SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT),
|
||||
BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT),
|
||||
FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT),
|
||||
ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT),
|
||||
ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT),
|
||||
RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT),
|
||||
CVAR = new Uint8Array(NMAT);
|
||||
for (let i = 0; i < NMAT; i++) {
|
||||
const d = MD[i];
|
||||
PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld;
|
||||
MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo;
|
||||
BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame;
|
||||
FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread;
|
||||
STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0;
|
||||
COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v;
|
||||
RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004;
|
||||
}
|
||||
// what molds cast (molten metals, and molten glass) and what they set into
|
||||
const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1;
|
||||
// tier as a drill bit or tank (0: too soft to be a bit)
|
||||
const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3;
|
||||
METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1;
|
||||
const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1;
|
||||
|
||||
// ---------- Flags & parts ----------
|
||||
const F_NAT = 1, F_CAST = 2, F_BURN = 4;
|
||||
const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10;
|
||||
const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0];
|
||||
|
||||
// ---------- Tool tiers ----------
|
||||
// reach: how far from the bot the drill works. Harder rock takes longer, and
|
||||
// every new bit cuts faster and further.
|
||||
const BIT_TIERS = [
|
||||
{ name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 },
|
||||
{ name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 },
|
||||
{ name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 },
|
||||
{ name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 },
|
||||
];
|
||||
const TANK_TIERS = [
|
||||
{ name: 'Canvas sack', cap: 1500 },
|
||||
{ name: 'Copper tank', cap: 4000 },
|
||||
{ name: 'Iron tank', cap: 10000 },
|
||||
{ name: 'Steel tank', cap: 25000 },
|
||||
];
|
||||
const REACH = 64;
|
||||
@@ -0,0 +1,284 @@
|
||||
// Molds: cavities that fill with molten metal (or glass) and are knocked out as bars.
|
||||
// ---------- Molds ----------
|
||||
function moldCellsAt(kind, x, y) {
|
||||
const md = MOLDS[kind]; const walls = [];
|
||||
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) if (md.rows[r][c] === '#') walls.push((y + r) * W + x + c);
|
||||
const cavs = md.cavs.map(comp => comp.map(([c, r]) => (y + r) * W + x + c));
|
||||
return { walls, cavs };
|
||||
}
|
||||
function canPlaceMold(kind, bx, by) {
|
||||
const md = MOLDS[kind];
|
||||
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
|
||||
for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
|
||||
const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS);
|
||||
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
|
||||
if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there';
|
||||
return null;
|
||||
}
|
||||
function newMoldRecord(kind, x, y, wall) {
|
||||
const md = MOLDS[kind];
|
||||
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '' };
|
||||
}
|
||||
function addMold(kind, bx, by, wallLoose, free) {
|
||||
const md = MOLDS[kind];
|
||||
if (!WALL_OF[wallLoose]) return 'Pick a wall material with R';
|
||||
const cost = wallCost(wallLoose, md.walls);
|
||||
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
|
||||
const x = bx * BS, y = by * BS;
|
||||
const { walls, cavs } = moldCellsAt(kind, x, y);
|
||||
evictCells(walls.concat(...cavs), y);
|
||||
for (const i of walls) setCell(i, WALL_OF[wallLoose], AMB[(i / W) | 0], 0);
|
||||
const mo = newMoldRecord(kind, x, y, wallLoose);
|
||||
molds.push(mo);
|
||||
stats.placed++;
|
||||
return mo;
|
||||
}
|
||||
// Slag and ash that reach a mold float on the metal. They are chipped off and thrown
|
||||
// away when the casting is knocked out, so they never spoil it unless metal set over them.
|
||||
const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1;
|
||||
// Two metals in one casting: iron with some steel in it (air got at it) casts as plain iron;
|
||||
// anything else is a mix that spoils it. Returns the metal the casting counts as, or -1.
|
||||
function castMix(a, b) { return a === b ? a : (a === IRON && b === STEEL) || (a === STEEL && b === IRON) ? IRON : -1; }
|
||||
function cavityState(cav) {
|
||||
let solid = 0, liq = 0, metal = -1, mixed = false, dirty = false, uncast = false, dross = 0;
|
||||
for (const i of cav) {
|
||||
const m = mat[i];
|
||||
if (SOLIDMETAL[m]) {
|
||||
solid++;
|
||||
if (!(flags[i] & F_CAST)) uncast = true;
|
||||
if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; }
|
||||
} else if (CASTMETAL[m]) {
|
||||
liq++;
|
||||
const sm = FRZTO[m];
|
||||
if (metal < 0) metal = sm; else if (metal !== sm) { const c = castMix(metal, sm); if (c < 0) mixed = true; else metal = c; }
|
||||
} else if (DROSS[m]) {
|
||||
dross++;
|
||||
if (i >= W && SOLIDMETAL[mat[i - W]]) dirty = true; // metal set on top of it: it is stuck inside the casting
|
||||
} else if (m !== EMPTY && PH[m] !== PH_GAS) dirty = true;
|
||||
}
|
||||
const n = cav.length;
|
||||
let st;
|
||||
if (liq > 0) st = 'filling';
|
||||
else if (solid === 0) st = 'empty';
|
||||
else if (solid === n && !mixed && !uncast) st = 'ready';
|
||||
else if (dirty || mixed || uncast) st = 'flawed';
|
||||
else st = 'short';
|
||||
const flaw = dirty ? 'Slag or grit got stuck inside the casting. Press F to knock it out as scrap and start again.' : mixed ? 'Two different metals mixed in the casting. Press F to knock it out.' : uncast ? 'That metal was not cast here. Pour molten metal in.'
|
||||
: st === 'short' ? `Not full: ${solid} of ${n} grains${dross ? ', with slag floating on top' : ''}. Fire the furnace again to top it up, or press F to knock it out as scrap.` : '';
|
||||
return { st, fill: (solid + liq) / n, metal, flaw };
|
||||
}
|
||||
// The ingot trough: ready as soon as at least one bar's worth has set and nothing is still molten.
|
||||
// It is forgiving: slag, ash or ore that got in is skimmed off when you knock the ingot out.
|
||||
function troughState(mo) {
|
||||
let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false;
|
||||
for (const i of mo.cavs[0]) {
|
||||
const m = mat[i];
|
||||
if (SOLIDMETAL[m]) { solid++; if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; } }
|
||||
else if (CASTMETAL[m]) { liq++; if (metal < 0) metal = FRZTO[m]; }
|
||||
else if (m !== EMPTY && PH[m] !== PH_GAS && !DROSS[m]) junk++;
|
||||
}
|
||||
const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length;
|
||||
const st = liq ? 'filling' : bars ? 'ready' : mixed ? 'flawed' : solid ? 'short' : junk ? 'flawed' : 'empty';
|
||||
const flaw = st === 'flawed' ? (mixed ? 'Two different metals mixed in the ingot. Press F to knock it out as scrap.' : 'Only junk in the trough. Press F to knock it out.')
|
||||
: st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}. Fire again to top it up, or press F to knock it out as scrap.` : '';
|
||||
return { st, bars, fill: (solid + liq) / n, metal, flaw };
|
||||
}
|
||||
// Empties cells: metal goes to your bag (or beside you if the bag is full) as scrap, other loose
|
||||
// grains (ore, coal) as they are, and slag and ash are thrown away. Molten metal is left alone.
|
||||
// Returns what went to the bag, and how much dross was thrown away as got.dross.
|
||||
function clearCavity(cav) {
|
||||
const got = new Map();
|
||||
let dross = 0;
|
||||
for (const i of cav) {
|
||||
const m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
|
||||
if (DROSS[m]) dross++;
|
||||
else {
|
||||
const loose = SOLIDMETAL[m] ? (YLD[m] || SCRCU) : PH[m] === PH_SOLID ? YLD[m] : m;
|
||||
if (loose) got.set(loose, (got.get(loose) || 0) + 1);
|
||||
}
|
||||
setCell(i, EMPTY, AMB[(i / W) | 0], 0);
|
||||
}
|
||||
for (const [m, n] of got) giveOrDrop(m, n, bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
got.dross = dross;
|
||||
return got;
|
||||
}
|
||||
// Metal that set where it should not have: the open part of the mold above the cavity (overflow
|
||||
// that sets as a plate over it) and the grate cells just above the mold (late drips that froze
|
||||
// on the way down). Left there, it plugs the way down for the next firing. Returns the cells.
|
||||
function moldPlugCells(mo) {
|
||||
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y), wall = new Set(walls), cav = new Set(mo.cavs.flat()), out = [];
|
||||
for (let y = mo.y; y < mo.y + mo.h; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (wall.has(i) || cav.has(i) || m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
|
||||
if (SOLIDMETAL[m] || PH[m] === PH_POWDER || DROSS[m]) out.push(i);
|
||||
}
|
||||
for (let y = Math.max(0, mo.y - 8); y < mo.y; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
|
||||
const i = y * W + x;
|
||||
if (part[i] === P_GRATE && SOLIDMETAL[mat[i]]) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Knocking a mold out also rakes the furnace over it: slag and ash lying on the grate and in
|
||||
// the charge above (up to the furnace roof) are cleared away, so the next charge sits on a
|
||||
// clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked.
|
||||
function rakeAbove(mo) {
|
||||
let n = 0;
|
||||
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (part[i] !== P_GRATE && (occ[i] || (PH[m] === PH_SOLID && !DROSS[m]))) break; // the roof, a wall or a machine
|
||||
if (DROSS[m] && !(flags[i] & F_BURN)) { setCell(i, EMPTY, AMB[y], 0); n++; }
|
||||
}
|
||||
if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8);
|
||||
return n;
|
||||
}
|
||||
// After a knock-out: if the furnace over the mold is badly loaded, say what to change
|
||||
function furnaceJunkHint(mo) {
|
||||
let ore = 0, fuel = 0, open = 0;
|
||||
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (part[i] === P_GRATE) continue;
|
||||
if (occ[i] || PH[m] === PH_SOLID) break;
|
||||
if (SMELTP[m] && PH[m] === PH_POWDER) ore++;
|
||||
else if (FUEL[m] && PH[m] === PH_POWDER) fuel++;
|
||||
else if (m === EMPTY || PH[m] === PH_GAS) open++;
|
||||
}
|
||||
if (ore > 40 && fuel < ore * 0.3) return ' A lot of ore is left and little fuel: add mostly coal next time.';
|
||||
if (ore + fuel > 60 && open < (ore + fuel) * 0.5) return ' The furnace is packed too full to burn well. Leave the top third empty so air gets in.';
|
||||
if (ore > 60) return ' A lot of ore did not melt: mix the next charge evenly with coal.';
|
||||
return '';
|
||||
}
|
||||
// Knocking a mold also clears metal stuck over and above it, and rakes the furnace.
|
||||
// Returns { metal: grains of metal that went to your bag, chipped: slag thrown away from the
|
||||
// mold, raked: slag and ash raked out of the furnace }.
|
||||
function unplugMold(mo) {
|
||||
const plug = moldPlugCells(mo);
|
||||
const got = plug.length ? clearCavity(plug) : new Map();
|
||||
let metal = 0;
|
||||
for (const [k, c] of got) if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) metal += c;
|
||||
return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) };
|
||||
}
|
||||
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
|
||||
function checkMolds() {
|
||||
for (const mo of molds) {
|
||||
if (mo.kind === 'ingot') {
|
||||
const r = troughState(mo);
|
||||
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
|
||||
if (r.st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: r.metal, bars: r.bars, x: mo.x + (mo.w >> 1), y: mo.y });
|
||||
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
|
||||
continue;
|
||||
}
|
||||
const res = mo.cavs.map(cavityState);
|
||||
mo.ready = [];
|
||||
res.forEach((r, k) => { if (r.st === 'ready') mo.ready.push(k); });
|
||||
let st;
|
||||
if (mo.ready.length) st = 'ready';
|
||||
else if (res.some(r => r.st === 'filling')) st = 'filling';
|
||||
else if (res.some(r => r.st === 'flawed')) st = 'flawed';
|
||||
else if (res.some(r => r.st === 'short')) st = 'short';
|
||||
else st = 'empty';
|
||||
mo.fill = res.reduce((a, r) => a + r.fill, 0) / res.length;
|
||||
const ref = mo.ready.length ? res[mo.ready[0]] : res.find(r => r.metal >= 0) || res[0];
|
||||
mo.flaw = (res.find(r => r.flaw) || {}).flaw || '';
|
||||
if (st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: ref.metal, x: mo.x + (mo.w >> 1), y: mo.y });
|
||||
mo.state = st; mo.metal = ref.metal;
|
||||
}
|
||||
}
|
||||
function moldAt(x, y) {
|
||||
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
|
||||
return null;
|
||||
}
|
||||
// What a knock-out sent where, as one short sentence after the main message
|
||||
function knockNote(scrap, other, chipped, raked) {
|
||||
let s = '';
|
||||
if (scrap) s += ` The leftover ${grains(scrap)} of scrap went into your bag${other ? ', with the grit that got in' : ''}.`;
|
||||
else if (other) s += ' The grit that got in went into your bag.';
|
||||
if (chipped && raked) s += ' The slag was chipped off and the grate raked clean.';
|
||||
else if (chipped) s += ' The slag was chipped off.';
|
||||
else if (raked) s += ' Slag and ash were raked off the grate.';
|
||||
return s;
|
||||
}
|
||||
function tally(got) {
|
||||
let scrap = 0, other = 0;
|
||||
for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; }
|
||||
return { scrap, other, dross: got.dross || 0 };
|
||||
}
|
||||
function collectMold(mo) {
|
||||
if (mo.state === 'filling') return 'The metal has not fully set yet.';
|
||||
if (mo.state === 'empty') {
|
||||
const g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
|
||||
const scrap = g.scrap + u.metal;
|
||||
if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.';
|
||||
emit({ t: 'sfx', s: 'ui' });
|
||||
return 'Knocked the mold clean.' + knockNote(scrap, g.other, g.dross + u.chipped, u.raked);
|
||||
}
|
||||
let msg = '';
|
||||
if (mo.kind === 'ingot') {
|
||||
const cav = mo.cavs[0];
|
||||
let solid = 0, T = 0;
|
||||
const m = mo.metal;
|
||||
if (mo.state === 'ready') for (const i of cav) if (SOLIDMETAL[mat[i]]) { solid++; T += temp[i]; }
|
||||
T /= Math.max(1, solid);
|
||||
const bars = Math.floor(solid / GRAINS_PER_BAR);
|
||||
// the ingot snaps into bars that pop up and out of the trough; take the metal for them first
|
||||
let take = bars * GRAINS_PER_BAR;
|
||||
for (const i of cav.slice().sort((a, b) => b - a)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(m, mat[i]) === m) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
|
||||
let n = 0;
|
||||
for (let k = 0; k < bars; k++) {
|
||||
const e = spawnBar(BAR_OF[m], mo.x + 1 + k * 4, mo.y - 3, (k - (bars - 1) / 2) * 0.3 + (rnd() - 0.5) * 0.15, -0.55, T);
|
||||
if (e) n++; else bagAdd(BAR_OF[m], 1, true);
|
||||
}
|
||||
// the stub too short for a bar, and anything else that got in, are cleared out: the trough is left clean
|
||||
const u = unplugMold(mo), g = tally(clearCavity(cav));
|
||||
stats.bars += n; if (n) stats.cast++;
|
||||
mo.state = 'empty'; mo.ready = [];
|
||||
emit({ t: 'sfx', s: n ? 'cast' : 'ui' });
|
||||
const word = m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
|
||||
return (n ? `Knocked out ${n} ${word}. Vacuum them up.` : 'Knocked the trough clean.') + knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
|
||||
}
|
||||
if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) {
|
||||
// an unfinished casting can still be topped up, so the first press only says so
|
||||
mo.knockAsk = tick;
|
||||
return (mo.flaw || 'The casting is not full yet.').replace(', or press F to knock it out as scrap.', '.') + ' Press F again to knock it out as scrap.';
|
||||
}
|
||||
if (mo.state === 'flawed' || mo.state === 'short') {
|
||||
// a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go
|
||||
const short = mo.state === 'short', g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
|
||||
mo.state = 'empty'; mo.ready = [];
|
||||
emit({ t: 'sfx', s: 'ui' });
|
||||
const what = mo.kind === 'bit' ? 'bit' : 'casting';
|
||||
return (short ? `The ${what} was not full, so it was knocked out as scrap.` : `The ${what} was spoiled, so it was knocked out as scrap.`)
|
||||
+ knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
|
||||
}
|
||||
const tier = METAL_TIER[mo.metal];
|
||||
const cav = mo.cavs[0];
|
||||
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}. The casting broke into scrap, which went into your bag.`; }
|
||||
else if (mo.kind === 'bit') {
|
||||
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
|
||||
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; }
|
||||
} else {
|
||||
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
|
||||
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; }
|
||||
}
|
||||
// a casting you could not use breaks into scrap, which goes to your bag
|
||||
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
|
||||
else clearCavity(cav);
|
||||
const u = unplugMold(mo);
|
||||
mo.state = 'empty'; stats.cast++;
|
||||
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo);
|
||||
}
|
||||
function removeMold(mo) {
|
||||
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
|
||||
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y);
|
||||
let metal = 0;
|
||||
for (const i of walls) {
|
||||
const m = mat[i];
|
||||
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) {
|
||||
if (BAR_OF[m] && PH[mo.wall] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); }
|
||||
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; }
|
||||
else setCell(i, EMPTY, temp[i], 0);
|
||||
}
|
||||
}
|
||||
if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y);
|
||||
molds = molds.filter(m => m !== mo);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Trees (background layer), acorns, saplings, and waking freezing water.
|
||||
// ---------- Trees (background layer) ----------
|
||||
// Trunk cells drill into wood chunks; leaves just fall apart. A tree whose trunk
|
||||
// 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];
|
||||
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;
|
||||
if (b === WOOD && woodRoom(i)) { setCell(i, LWOOD, temp[i], 0); discover(LWOOD, x, y, WOOD, 'dig'); }
|
||||
else if (b === LEAVES && mat[i] === EMPTY && rnd() < 0.03) { setCell(i, ACORN, temp[i], 0); discover(ACORN, x, y, LEAVES, 'dig'); }
|
||||
else { wakeXY(x, y); emit({ t: 'debris', x, y, m: b }); }
|
||||
if (b === WOOD) for (const [dx, dy] of [[1, 0], [-1, 0], [0, -1], [0, 1], [1, -1], [-1, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (nx >= 0 && ny >= 0 && nx < W && ny < H && bgm[ny * W + nx]) treeFall(ny * W + nx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// A trunk cell is held up by solid ground or soil directly beneath it.
|
||||
function treeHeld(i) {
|
||||
if (i + W >= N) return true;
|
||||
const b = mat[i + W];
|
||||
return PH[b] === PH_SOLID || SOIL[b] === 1;
|
||||
}
|
||||
// Base cells of every tree (the lowest trunk cells). Checked now and then: a tree
|
||||
// whose ground has been dug or washed away comes down.
|
||||
let treeBases = [];
|
||||
function findTreeBases() {
|
||||
treeBases = [];
|
||||
for (let i = 0; i < N - W; i++) if (bgm[i] === WOOD && bgm[i + W] !== WOOD) treeBases.push(i);
|
||||
}
|
||||
function treeSupportTick() {
|
||||
for (let k = treeBases.length - 1; k >= 0; k--) {
|
||||
const i = treeBases[k];
|
||||
if (bgm[i] !== WOOD) { treeBases.splice(k, 1); continue; }
|
||||
if (!treeHeld(i)) { treeBases.splice(k, 1); treeFall(i); }
|
||||
}
|
||||
}
|
||||
// Make room for a wood chunk where a trunk cell was: air is fine; water there is
|
||||
// pushed to the nearest open cell (the chunk then floats up).
|
||||
function woodRoom(i) {
|
||||
const m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS) return true;
|
||||
if (PH[m] !== PH_LIQUID) return false;
|
||||
const T = temp[i];
|
||||
mat[i] = EMPTY;
|
||||
depositNear(m, T, i % W, (i / W) | 0, i); // if there is nowhere at all, that bit of water is lost
|
||||
return true;
|
||||
}
|
||||
function treeFall(start) {
|
||||
if (bgm[start] !== WOOD) return;
|
||||
// follow the wood (trunk and branches) to see whether any of it still stands on something
|
||||
const seenT = new Set([start]), st = [start], woodCells = [];
|
||||
let grounded = false, bx0 = W, bx1 = 0, by0 = H, by1 = 0;
|
||||
while (st.length && woodCells.length < 2000) {
|
||||
const i = st.pop(); woodCells.push(i);
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
if (treeHeld(i)) { grounded = true; break; }
|
||||
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
const j = ny * W + nx; if (bgm[j] === WOOD && !seenT.has(j)) { seenT.add(j); st.push(j); }
|
||||
}
|
||||
}
|
||||
if (grounded) return;
|
||||
// its crown: leaves connected to it, not straying far from its own wood
|
||||
const cells = woodCells.slice();
|
||||
for (const i of woodCells) st.push(i);
|
||||
bx0 -= 9; bx1 += 9; by0 -= 12; by1 += 4;
|
||||
while (st.length && cells.length < 4000) {
|
||||
const i = st.pop(), x = i % W, y = (i / W) | 0;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = x + dx, ny = y + dy; if (nx < bx0 || ny < by0 || nx > bx1 || ny > by1 || nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
const j = ny * W + nx; if (bgm[j] === LEAVES && !seenT.has(j)) { seenT.add(j); st.push(j); cells.push(j); }
|
||||
}
|
||||
}
|
||||
let wood = 0;
|
||||
// a felled tree drops a few acorns from its crown
|
||||
const crown = cells.filter(i => bgm[i] === LEAVES && mat[i] === EMPTY);
|
||||
const nAcorn = Math.min(crown.length, 1 + (crown.length > 60 ? 1 : 0) + (rnd() < 0.5 ? 1 : 0));
|
||||
for (let k = 0; k < nAcorn; k++) { const i = crown[(rnd() * crown.length) | 0]; if (mat[i] === EMPTY) { setCell(i, ACORN, AMB[(i / W) | 0], 0); bgm[i] = 0; discover(ACORN, i % W, (i / W) | 0, LEAVES, 'dig'); } }
|
||||
for (const i of cells) {
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
if (bgm[i] === WOOD && woodRoom(i)) { setCell(i, LWOOD, temp[i], 0); wood++; }
|
||||
else if (bgm[i] && (x + y) % 3 === 0) emit({ t: 'debris', x, y, m: bgm[i] });
|
||||
bgm[i] = 0; wakeXY(x, y);
|
||||
}
|
||||
if (wood) { discover(LWOOD, cells[0] % W, (cells[0] / W) | 0, WOOD, 'dig'); emit({ t: 'sfx', s: 'decon' }); }
|
||||
}
|
||||
// Acorns resting on soil under open sky sprout after a while; saplings grow
|
||||
// into full trees in the background layer.
|
||||
let saplings = [];
|
||||
// Heat only runs where something is off ambient, so water resting in freezing air
|
||||
// would never be looked at. Wake its area now and then so it can freeze.
|
||||
function coldTick() {
|
||||
for (let y = 0; y < H && AMB[y] < -0.5; y++) {
|
||||
const yw = y * W;
|
||||
for (let x = 0; x < W; x++) if (mat[yw + x] === WATER) { wakeHeatXY(x, y); x |= 31; }
|
||||
}
|
||||
}
|
||||
function plantTick() {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const y = roofY[x];
|
||||
if (y <= 6 || y >= H - 1) continue;
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
|
||||
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; }
|
||||
if (!room) continue;
|
||||
if (++life[i] < 14) continue;
|
||||
setCell(i, EMPTY, AMB[y], 0);
|
||||
saplings.push({ x, y: y + 1, age: 0, th: 8 + ((rnd() * 7) | 0), seed: (rnd() * 1e9) | 0 });
|
||||
if (!stats.sprouted) { stats.sprouted = 1; emit({ t: 'sprout', x, y }); }
|
||||
}
|
||||
}
|
||||
let treeDraw = null; // cells the sapling being drawn has set, so the next stage can redraw it
|
||||
function setTreeCell(x, y, m) {
|
||||
if (x < 0 || y < 1 || x >= W || y >= H) return;
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
|
||||
if (m === LEAVES && bgm[i]) return;
|
||||
bgm[i] = m;
|
||||
if (treeDraw) treeDraw.push(i);
|
||||
}
|
||||
function saplingTick() {
|
||||
for (let k = saplings.length - 1; k >= 0; k--) {
|
||||
const sp = saplings[k], base = sp.y * W + sp.x;
|
||||
// the soil it grows from must still be there
|
||||
if (sp.y >= H || !(SOIL[mat[base]] || PH[mat[base]] === PH_SOLID)) { saplings.splice(k, 1); continue; }
|
||||
if (sp.age > 2 && bgm[base - W] !== WOOD) { saplings.splice(k, 1); continue; } // cut down or burned while young
|
||||
// if any trunk it grew is gone (cut or burned), it stops growing and is left as it is
|
||||
if (sp.wood && sp.wood.some(i => bgm[i] !== WOOD)) { saplings.splice(k, 1); continue; }
|
||||
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;
|
||||
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);
|
||||
if (s > 0.2) {
|
||||
// the same lumpy crown as wild trees, scaled with age
|
||||
const q = s, h0 = sp.seed;
|
||||
if (s >= 1) for (const side of [-1, 1]) { const by = sp.y - th + 3 + (h0 >> (side > 0 ? 3 : 5)) % 3; for (let j = 1; j <= Math.round(3 * q); j++) setTreeCell(sp.x + (side < 0 ? -j : 1 + j), by - (j >> 1), WOOD); }
|
||||
const blobs = [[0.5, -th - 1, (6 + (h0 % 25) / 10) * q], [-3.5 * q, -th + 1, (4 + (h0 >> 4) % 20 / 10) * q], [4.5 * q, -th + 1, (4 + (h0 >> 8) % 20 / 10) * q], [0.5, -th - 5 * q, (4 + (h0 >> 12) % 20 / 10) * q]];
|
||||
for (const [bx, by, r] of blobs) {
|
||||
if (r < 1) continue;
|
||||
const cx = sp.x + bx, cy = sp.y + by;
|
||||
for (let yy = Math.floor(cy - r); yy <= cy + r; yy++) for (let xx = Math.floor(cx - r); xx <= cx + r; xx++) {
|
||||
const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85);
|
||||
if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) setTreeCell(xx, yy, LEAVES);
|
||||
}
|
||||
}
|
||||
}
|
||||
treeDraw = null;
|
||||
sp.wood = sp.cells.filter(i => bgm[i] === WOOD);
|
||||
for (const i of sp.wood) if (bgm[i + W] !== WOOD && !treeBases.includes(i)) treeBases.push(i);
|
||||
if (s >= 1) { saplings.splice(k, 1); stats.grown = (stats.grown || 0) + 1; }
|
||||
}
|
||||
}
|
||||
// A flame next to a tree sets that part of it alight: it moves into the
|
||||
// foreground as burning wood or leaves and burns like any other fuel.
|
||||
function igniteTree(j) {
|
||||
const b = bgm[j]; bgm[j] = 0;
|
||||
mat[j] = b; flags[j] = F_BURN; life[j] = FUEL[b]; temp[j] = Math.max(temp[j], IGN[b] + 40);
|
||||
const x = j % W, y = (j / W) | 0;
|
||||
wakeXY(x, y); wakeHeatXY(x, y);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Everything the player can build (PIECES): blocks, machines and molds, with costs and
|
||||
// descriptions; filter settings; wall materials and their cost.
|
||||
// ---------- Building pieces ----------
|
||||
// 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;
|
||||
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
|
||||
// Filters: each filter block has a set of materials and a mode. In drop mode (inv 0)
|
||||
// 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
|
||||
// variant's top bit is which way it carries (1 right, 0 left).
|
||||
let filterCfg = new Map(); // block index -> { m: [materials], inv: 0|1, pass: Uint8Array(NMAT) }
|
||||
function filterable(m) { return m > 0 && m < NMAT && (PH[m] === PH_POWDER || PH[m] === PH_ITEM); }
|
||||
function cleanFilterCfg(c) { return { m: [...new Set(((c && c.m) || []).filter(filterable))].sort((a, b) => a - b), inv: c && c.inv ? 1 : 0 }; }
|
||||
function filterRec(c) {
|
||||
const k = cleanFilterCfg(c), pass = new Uint8Array(NMAT);
|
||||
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 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
|
||||
const WALL_OF = new Uint8Array(NMAT);
|
||||
WALL_OF[LDIRT] = DIRT; WALL_OF[RSTONE] = STONE; WALL_OF[RGRAN] = GRANITE; WALL_OF[RBAS] = BASALT; WALL_OF[LCLAY] = CLAY;
|
||||
WALL_OF[LWOOD] = WOOD; WALL_OF[RBRICK] = BRICK; WALL_OF[CULLET] = GLASS; WALL_OF[BARCU] = COPPER; WALL_OF[BARFE] = IRON; WALL_OF[BARST] = STEEL;
|
||||
WALL_OF[BARSB] = CUTST; WALL_OF[BARGL] = GLASS;
|
||||
const WALL_LIST = [RSTONE, BARSB, LCLAY, RBRICK, LDIRT, LWOOD, CULLET, BARGL, RGRAN, RBAS, BARCU, BARFE, BARST];
|
||||
const BAR_OF = new Uint8Array(NMAT); BAR_OF[COPPER] = BARCU; BAR_OF[IRON] = BARFE; BAR_OF[STEEL] = BARST;
|
||||
BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS;
|
||||
const GRAINS_PER_BAR = 8;
|
||||
function wallCost(v, cells) { return [[v, PH[v] === PH_ITEM ? Math.ceil(cells / GRAINS_PER_BAR) : cells]]; }
|
||||
|
||||
// For now every machine simply runs: no power to supply. The power pieces (shaft,
|
||||
// water wheel, steam engine) are hidden from the build menu but old saves keep them.
|
||||
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.' },
|
||||
{ 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 material and pushes it along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Stack them pointing up to lift ore out of a mine, and turn the top one sideways to unload. Copper softens around 1000°C.' },
|
||||
{ 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.' },
|
||||
{ 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]],
|
||||
desc: 'Ceramic grille. Liquids and gases pass; powders and bars do not.' },
|
||||
{ id: 'gate', code: BP_GATE, name: 'Gate', kind: 'block', cost: [[LCLAY, 8]],
|
||||
desc: 'Ceramic slide gate. Press F on it to open or close every gate block touching it.' },
|
||||
{ id: 'pipe', code: BP_PIPE, name: 'Pipe', kind: 'block', cost: [[CASH, 8]],
|
||||
desc: 'Thin copper pipe for liquids. Sand, water and anything else loose fall past it, so a run of pipe never dams anything. Every pipe, pump and outlet touching another joins it in one line, and the line holds 16 grains of liquid per block. Drag to lay a run. Taking a pipe back spills what was in it.' },
|
||||
{ id: 'pump', code: BP_PUMP, name: 'Pump', kind: 'block', cost: [[CASH, 70]], dirs: ['up', 'right', 'down', 'left'],
|
||||
desc: 'Draws liquid in at the tail of its arrow (keep that side under water) and pushes it into the pipes on its other sides, about 60 grains a second while the line has room. It takes water and anything else cooler than 500°C, one liquid at a time per line. Press F to switch it on or off.' },
|
||||
{ id: 'outlet', code: BP_OUTLET, name: 'Outlet', kind: 'block', cost: [[CASH, 15]], dirs: ['down', 'left', 'up', 'right'],
|
||||
desc: 'Lets a pipe line\'s liquid out of the side its arrow points to, wherever there is room for it. Press F to open or close it.' },
|
||||
{ id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE,
|
||||
desc: 'Makes power when liquid falls through it. More flow, more power.' },
|
||||
{ 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: 'cutter', name: 'Stone cutter', kind: 'machine', bw: 2, bh: 2, cost: [[CASH, 90]],
|
||||
desc: 'A saw under a hopper. Stone rubble dropped in is cut into stone bricks, one from every 8 grains, which drop out underneath. Builders buy stone bricks, and they make neat cut stone walls that stand more heat than rough stone. Anything else falls straight through.' },
|
||||
{ id: 'silo', name: 'Silo', kind: 'machine', bw: 3, bh: 6, cost: [[CASH, 180]],
|
||||
desc: 'A steel bin on legs for one loose material: up to 4,000 grains go in through the hatch in its roof (drop them in from a belt or tube). Its gauge shows how full it is. Press F to open the gate in its cone and let it out underneath, about 60 grains a second; F again shuts it. The first material in decides what it holds until it is empty.' },
|
||||
{ id: 'refiner', name: 'Refiner', kind: 'machine', bw: 3, bh: 3, cost: [[CASH, 120]],
|
||||
desc: 'A crushing sieve. Drop stone, granite or basalt rubble, gravel, sand or dirt into its hopper: it grinds each grain, keeps the odd grain of ore it finds (clay, from dirt) and lets about half as much fine sand out of the chute underneath. Ore and anything else it cannot grind falls straight through. Keep the space under the chute clear, or a belt running under it.' },
|
||||
{ id: 'engine', name: 'Steam engine', kind: 'machine', bw: 3, bh: 2, cost: [[BARCU, 10]], dirs: ['exhaust right', 'exhaust left'], hidden: POWER_FREE,
|
||||
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,
|
||||
desc: 'Owned by the claim office. Anything that drops through the grate in its floor is sold on the spot. Build walls around it to funnel more in.' },
|
||||
{ id: 'ingotmold', name: 'Ingot mold', kind: 'mold', mold: 'ingot', variants: 'wall',
|
||||
desc: 'A long trough. Pour molten metal in, let it set, press F: it snaps into bars of 8 grains, up to 3.' },
|
||||
{ id: 'bitmold', name: 'Bit mold', kind: 'mold', mold: 'bit', variants: 'wall',
|
||||
desc: 'A drill-bit-shaped cavity. Cast a harder metal in it to upgrade your drill.' },
|
||||
{ id: 'tankmold', name: 'Tank mold', kind: 'mold', mold: 'tank', variants: 'wall',
|
||||
desc: 'A tank-shaped cavity. Cast metal in it to carry more.' },
|
||||
];
|
||||
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;
|
||||
const WHEEL_K = 0.32, ENGINE_K = 40;
|
||||
|
||||
const MOLDS = {
|
||||
ingot: {
|
||||
// one long trough: metal runs along it and sets level; knocking it out
|
||||
// snaps the ingot into bars of 8 grains each
|
||||
name: 'Ingot mold', rows: [
|
||||
'................',
|
||||
'#oooooooooooooo#',
|
||||
'#oooooooooooooo#',
|
||||
'################',
|
||||
]
|
||||
},
|
||||
bit: {
|
||||
name: 'Bit mold', rows: [
|
||||
'#..............#',
|
||||
'##............##',
|
||||
'###oooooooooo###',
|
||||
'####oooooooo####',
|
||||
'#####oooooo#####',
|
||||
'######oooo######',
|
||||
'#######oo#######',
|
||||
'################',
|
||||
]
|
||||
},
|
||||
tank: {
|
||||
name: 'Tank mold', rows: [
|
||||
'#..............#',
|
||||
'#oo##########oo#',
|
||||
'#oo##########oo#',
|
||||
'#oo##########oo#',
|
||||
'#oo##########oo#',
|
||||
'#oooooooooooooo#',
|
||||
'#oooooooooooooo#',
|
||||
'################',
|
||||
]
|
||||
},
|
||||
};
|
||||
for (const k in MOLDS) {
|
||||
const md = MOLDS[k]; md.w = md.rows[0].length; md.h = md.rows.length; md.bw = md.w / BS; md.bh = md.h / BS; md.walls = 0; md.cav = 0;
|
||||
for (const r of md.rows) for (const ch of r) { if (ch === '#') md.walls++; else if (ch === 'o') md.cav++; }
|
||||
// split the cavity into separate pieces (the ingot mold has three)
|
||||
const seenC = new Set(); md.cavs = [];
|
||||
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) {
|
||||
if (md.rows[r][c] !== 'o' || seenC.has(r * 64 + c)) continue;
|
||||
const comp = [], st = [[c, r]]; seenC.add(r * 64 + c);
|
||||
while (st.length) {
|
||||
const [cx, cy] = st.pop(); comp.push([cx, cy]);
|
||||
for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
|
||||
if (nx < 0 || ny < 0 || nx >= md.w || ny >= md.h || md.rows[ny][nx] !== 'o' || seenC.has(ny * 64 + nx)) continue;
|
||||
seenC.add(ny * 64 + nx); st.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
md.cavs.push(comp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains.
|
||||
// ---------- Pipes ----------
|
||||
// Pipes, pumps and outlets that touch join into a line. A line holds one liquid at a time,
|
||||
// PIPE_CAP grains per block, kept as a count (it is not simulated grain by grain inside).
|
||||
// Pumps draw liquid in at their intake side; outlets let it out where there is room.
|
||||
const PIPE_CAP = 16, PUMP_RATE = 1, OUTLET_RATE = 2, PUMP_MAX_T = 500;
|
||||
const SIDE_V = [[0, -1], [1, 0], [0, 1], [-1, 0]]; // up, right, down, left
|
||||
const PUMP_V = SIDE_V; // pump variants: the way it pushes (its intake is the opposite side)
|
||||
const OUTLET_V = [[0, 1], [-1, 0], [0, -1], [1, 0]]; // outlet variants: down, left, up, right
|
||||
let pipeNets = [], pipeNetAt = null, pipesDirty = true;
|
||||
function sideOf(dx, dy) { for (let s = 0; s < 4; s++) if (SIDE_V[s][0] === dx && SIDE_V[s][1] === dy) return s; return -1; }
|
||||
// the one side of a block that does not join the line: a pump's intake, an outlet's mouth
|
||||
function pipeClosedSide(b) {
|
||||
const c = blockP[b], d = blockV[b] & 3;
|
||||
if (c === BP_PUMP) return sideOf(-PUMP_V[d][0], -PUMP_V[d][1]);
|
||||
if (c === BP_OUTLET) return sideOf(OUTLET_V[d][0], OUTLET_V[d][1]);
|
||||
return -1;
|
||||
}
|
||||
function pipeJoins(b, s) {
|
||||
const bx = b % BW, by = (b / BW) | 0, nx = bx + SIDE_V[s][0], ny = by + SIDE_V[s][1];
|
||||
if (nx < 0 || ny < 0 || nx >= BW || ny >= BH || s === pipeClosedSide(b)) return -1;
|
||||
const d = ny * BW + nx;
|
||||
return isPipeCode(blockP[d]) && pipeClosedSide(d) !== (s + 2) % 4 ? d : -1;
|
||||
}
|
||||
function rebuildPipes() {
|
||||
pipesDirty = false;
|
||||
const old = pipeNets, oldAt = pipeNetAt;
|
||||
pipeNetAt = new Int32Array(NB).fill(-1); pipeNets = [];
|
||||
for (let b0 = 0; b0 < NB; b0++) {
|
||||
if (!isPipeCode(blockP[b0]) || pipeNetAt[b0] >= 0) continue;
|
||||
const id = pipeNets.length, net = { blocks: [], pumps: [], outlets: [], m: 0, n: 0, T: 20, cap: 0, flowT: -999 };
|
||||
pipeNets.push(net); pipeNetAt[b0] = id;
|
||||
const st = [b0];
|
||||
while (st.length) {
|
||||
const b = st.pop(); net.blocks.push(b);
|
||||
if (blockP[b] === BP_PUMP) net.pumps.push(b); else if (blockP[b] === BP_OUTLET) net.outlets.push(b);
|
||||
for (let s = 0; s < 4; s++) { const d = pipeJoins(b, s); if (d >= 0 && pipeNetAt[d] < 0) { pipeNetAt[d] = id; st.push(d); } }
|
||||
}
|
||||
net.cap = net.blocks.length * PIPE_CAP;
|
||||
}
|
||||
// what the old lines held goes with their blocks into the new lines
|
||||
if (oldAt) for (const o of old) {
|
||||
if (!o.n) continue;
|
||||
const share = new Map(); let alive = 0;
|
||||
for (const b of o.blocks) { const id = pipeNetAt[b]; if (id >= 0) { share.set(id, (share.get(id) || 0) + 1); alive++; } }
|
||||
let left = o.n;
|
||||
const parts = [...share];
|
||||
parts.forEach(([id, k], idx) => {
|
||||
const amt = idx === parts.length - 1 ? left : Math.min(left, Math.round(o.n * k / alive));
|
||||
left -= amt;
|
||||
const net = pipeNets[id];
|
||||
if (!amt) return;
|
||||
if (net.n && net.m !== o.m) { if (net.n >= amt) return; net.n = 0; } // two liquids met: the smaller lot is lost
|
||||
net.T = (net.T * net.n + o.T * amt) / (net.n + amt); net.m = o.m; net.n = Math.min(net.cap, net.n + amt);
|
||||
});
|
||||
}
|
||||
}
|
||||
function pipeNetOf(b) { if (pipesDirty) rebuildPipes(); const id = pipeNetAt ? pipeNetAt[b] : -1; return id >= 0 ? pipeNets[id] : null; }
|
||||
// take block b's share of its line's liquid out (it is about to be removed)
|
||||
function pipeTakeShare(b) {
|
||||
const net = pipeNetOf(b);
|
||||
if (!net || !net.n) return null;
|
||||
const k = Math.min(net.n, PIPE_CAP, Math.round(net.n / net.blocks.length));
|
||||
net.n -= k;
|
||||
const r = { m: net.m, n: k, T: net.T };
|
||||
if (!net.n) net.m = 0;
|
||||
return r;
|
||||
}
|
||||
// the cells just outside side s of block b, lane by lane; depth grains further out
|
||||
function faceCell(b, s, lane, depth) {
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
let x, y;
|
||||
if (s === 0) { x = bx * BS + lane; y = by * BS - 1 - depth; }
|
||||
else if (s === 2) { x = bx * BS + lane; y = by * BS + BS + depth; }
|
||||
else if (s === 1) { x = bx * BS + BS + depth; y = by * BS + lane; }
|
||||
else { x = bx * BS - 1 - depth; y = by * BS + lane; }
|
||||
return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x;
|
||||
}
|
||||
function pumpDraw(net, b) {
|
||||
const s = sideOf(-PUMP_V[blockV[b] & 3][0], -PUMP_V[blockV[b] & 3][1]);
|
||||
let got = 0;
|
||||
for (let k = 0; k < 4 && got < PUMP_RATE && net.n < net.cap; k++) {
|
||||
const lane = (k + tick) & 3;
|
||||
for (let depth = 0; depth < 3; depth++) {
|
||||
const j = faceCell(b, s, lane, depth);
|
||||
if (j < 0) break;
|
||||
const m = mat[j];
|
||||
if (PH[m] !== PH_LIQUID) { if (PH[m] === PH_SOLID && !part[j]) break; continue; }
|
||||
if (temp[j] > PUMP_MAX_T || occ[j] || (net.n && m !== net.m)) break;
|
||||
net.T = (net.T * net.n + temp[j]) / (net.n + 1); net.m = m; net.lm = m; net.n++;
|
||||
setCell(j, EMPTY, AMB[(j / W) | 0], 0);
|
||||
got++; net.flowT = tick;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
function outletPour(net, b) {
|
||||
const v = OUTLET_V[blockV[b] & 3], s = sideOf(v[0], v[1]);
|
||||
let put = 0;
|
||||
for (let k = 0; k < 4 && put < OUTLET_RATE && net.n > 0; k++) {
|
||||
const j = faceCell(b, s, (k + tick) & 3, 0);
|
||||
if (j < 0 || mat[j] !== EMPTY || occ[j] || (part[j] && part[j] !== P_GRATE && part[j] !== P_GOPEN) || mach[j]) continue;
|
||||
setCell(j, net.m, net.T, 0);
|
||||
net.n--; put++; net.flowT = tick;
|
||||
}
|
||||
if (!net.n) net.m = 0;
|
||||
}
|
||||
function pipesTick() {
|
||||
if (pipesDirty) rebuildPipes();
|
||||
for (const net of pipeNets) {
|
||||
for (const b of net.pumps) if (!(blockV[b] & 4)) pumpDraw(net, b);
|
||||
if (net.n) for (const b of net.outlets) if (!(blockV[b] & 4)) outletPour(net, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Processing machines: the Refiner, the Stone cutter and the Silo.
|
||||
// ---------- Refiner ----------
|
||||
// Grinding a grain of rock, gravel, sand or dirt turns up the odd grain of ore: [chance, what],
|
||||
// tried in turn. If none turns up, half the time a grain of fine sand comes out, else nothing:
|
||||
// the rest is ground to dust and washed away, so a refiner halves a pile of waste rock.
|
||||
const REFINE = [];
|
||||
REFINE[RSTONE] = [[0.025, CUORE], [0.02, COAL], [0.015, FEORE], [0.006, SNORE]];
|
||||
REFINE[RGRAN] = [[0.02, FEORE], [0.012, ZNORE], [0.01, CUORE], [0.002, AGORE]];
|
||||
REFINE[RBAS] = [[0.03, FEORE], [0.01, ZNORE], [0.003, AUORE]];
|
||||
REFINE[GRAVEL] = [[0.03, FEORE], [0.02, CUORE], [0.01, SNORE], [0.003, ZNORE]];
|
||||
REFINE[SAND] = [[0.01, SNORE], [0.008, FEORE], [0.0015, AUORE]];
|
||||
REFINE[LDIRT] = [[0.08, LCLAY], [0.01, COAL], [0.008, CUORE], [0.003, ACORN]];
|
||||
const REFINE_SAND = 0.5, REFINE_RATE = 0.5; // grains ground per tick
|
||||
// a free cell under the refiner's chute, or -1 if the way out is blocked
|
||||
function refinerSlot(mm) {
|
||||
const y = mm.y + 12;
|
||||
if (y >= H) return -1;
|
||||
for (const dx of [5, 6, 4, 7]) { const j = y * W + mm.x + dx; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; }
|
||||
return -1;
|
||||
}
|
||||
function refinerTick(mm) {
|
||||
mm.work = (mm.work || 0) + REFINE_RATE;
|
||||
if (mm.work < 1) return;
|
||||
let jobs = Math.floor(mm.work); mm.work -= jobs;
|
||||
const y = mm.y + 9;
|
||||
mm.blocked = false;
|
||||
for (let k = 0; k < 8 && jobs > 0; k++) {
|
||||
const x = mm.x + 2 + ((k + tick) & 7), i = y * W + x, m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
|
||||
const out = refinerSlot(mm);
|
||||
if (out < 0) { mm.blocked = true; break; }
|
||||
jobs--; mm.busyT = tick;
|
||||
const T = temp[i];
|
||||
if (PH[m] === PH_POWDER && REFINE[m]) {
|
||||
setCell(i, EMPTY, AMB[y], 0);
|
||||
let got = 0;
|
||||
for (const [chance, o] of REFINE[m]) if (rnd() < chance) { got = o; break; }
|
||||
if (got) { stats.refinedOre = (stats.refinedOre || 0) + 1; discover(got, x, y, m, 'refine'); }
|
||||
else if (rnd() < REFINE_SAND) got = SAND;
|
||||
if (got) setCell(out, got, T, 0);
|
||||
stats.refined = (stats.refined || 0) + 1;
|
||||
} else {
|
||||
// ore, coal, liquids: nothing to grind, so they go straight through
|
||||
setCell(out, m, T, 0); setCell(i, EMPTY, AMB[y], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---------- Stone cutter ----------
|
||||
const CUT_PER_BRICK = 8, CUT_RATE = 0.5;
|
||||
function cutterTick(mm) {
|
||||
mm.work = (mm.work || 0) + CUT_RATE;
|
||||
if (mm.work < 1) return;
|
||||
let jobs = Math.floor(mm.work); mm.work -= jobs;
|
||||
const y = mm.y + 4;
|
||||
mm.blocked = false;
|
||||
for (let k = 0; k < 4 && jobs > 0; k++) {
|
||||
const x = mm.x + 2 + ((k + tick) & 3), i = y * W + x, m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
|
||||
if (m === RSTONE) {
|
||||
if ((mm.cut || 0) + 1 >= CUT_PER_BRICK) {
|
||||
// a brick is ready: it needs room under the machine
|
||||
if (!entFreeAt(mm.x + 2, mm.y + 8)) { mm.blocked = true; break; }
|
||||
mm.cut = 0;
|
||||
const e = spawnBar(BARSB, mm.x + 2, mm.y + 8, (rnd() - 0.5) * 0.1, 0.05);
|
||||
if (e) { e.T = temp[i]; discover(BARSB, x, y, RSTONE, 'cut'); stats.bricks = (stats.bricks || 0) + 1; }
|
||||
} else mm.cut = (mm.cut || 0) + 1;
|
||||
setCell(i, EMPTY, AMB[y], 0);
|
||||
} else {
|
||||
// anything else drops through, out of the bottom
|
||||
const j = (mm.y + 8) * W + mm.x + 3 + (k & 1);
|
||||
if (mm.y + 8 >= H || mat[j] !== EMPTY || part[j] || occ[j]) { mm.blocked = true; break; }
|
||||
setCell(j, m, temp[i], 0); setCell(i, EMPTY, AMB[y], 0);
|
||||
}
|
||||
jobs--; mm.busyT = tick;
|
||||
}
|
||||
}
|
||||
// ---------- Silo ----------
|
||||
// One loose material, counted (not simulated grain by grain inside): grains in the roof hatch
|
||||
// go in, and with the gate open they come out of the bottom of the cone.
|
||||
const SILO_CAP = 4000, SILO_RATE = 1;
|
||||
function siloTick(mm) {
|
||||
for (let dy = 0; dy < 2; dy++) for (let dx = 3; dx <= 8; dx++) {
|
||||
const i = (mm.y + dy) * W + mm.x + dx, m = mat[i];
|
||||
if (PH[m] !== PH_POWDER || stamp[i] === tickStamp || (mm.sn > 0 && m !== mm.sm) || (mm.sn || 0) >= SILO_CAP) continue;
|
||||
mm.sm = m; mm.sn = (mm.sn || 0) + 1; mm.fillT = tick;
|
||||
setCell(i, EMPTY, AMB[mm.y + dy], 0);
|
||||
}
|
||||
if (mm.open && mm.sn > 0) {
|
||||
let put = 0;
|
||||
for (let k = 0; k < 4 && put < SILO_RATE && mm.sn > 0; k++) {
|
||||
const x = mm.x + 4 + ((k + tick) & 3), j = (mm.y + 23) * W + x;
|
||||
if (mat[j] !== EMPTY || occ[j]) continue;
|
||||
setCell(j, mm.sm, AMB[mm.y + 23], 0); mm.sn--; put++; mm.pourT = tick;
|
||||
}
|
||||
if (!mm.sn) mm.sm = 0;
|
||||
}
|
||||
}
|
||||
// a silo with something in it is emptied before it can be taken down
|
||||
function siloFull(mm) { return mm && mm.type === 'silo' && mm.sn > 0; }
|
||||
@@ -0,0 +1,48 @@
|
||||
// Starter furnace designs and the milestone list shown in the notebook.
|
||||
// ---------- Starter designs ----------
|
||||
// Optional reference builds the notebook offers as blueprints. A furnace: stone
|
||||
// walls, a one-block vent in the roof, a floor of clay grates (liquids drip
|
||||
// through, the charge stays up) and molds right underneath.
|
||||
function smelterBlueprint(kind, count, ch, wall) {
|
||||
const md = MOLDS[kind], iw = count * md.bw, bw = iw + 2, bh = ch + 2 + md.bh;
|
||||
const blocks = [], molds = [];
|
||||
const vent = iw >> 1;
|
||||
for (let x = 0; x < bw; x++) if (x !== vent) blocks.push([x, 0, BP_WALL, wall]);
|
||||
for (let y = 1; y < bh; y++) { blocks.push([0, y, BP_WALL, wall]); blocks.push([bw - 1, y, BP_WALL, wall]); }
|
||||
for (let x = 1; x <= iw; x++) blocks.push([x, ch + 1, BP_GRATE, 0]);
|
||||
for (let k = 0; k < count; k++) molds.push([kind, 1 + k * md.bw, ch + 2, wall]);
|
||||
return { v: 2, bw, bh, blocks, machines: [], molds };
|
||||
}
|
||||
const STARTER_DESIGNS = [
|
||||
{ id: 'smelter1', name: 'Copper smelter', bp: () => smelterBlueprint('ingot', 1, 3, RSTONE),
|
||||
desc: 'Stone furnace over an ingot mold. Charge it with about 50 copper ore and 50 coal, mixed (or the ore first with the coal on top), and light the coal. Makes 3 bars a firing. Press F on the mold to knock the bars out: that also rakes the slag off the grate, so it is ready to charge again. Leftover copper goes to your bag as scrap; throw it back in with the next charge.' },
|
||||
{ id: 'bitfurnace', name: 'Bit casting furnace', bp: () => smelterBlueprint('bit', 1, 3, RSTONE),
|
||||
desc: 'Stone furnace over a bit mold. Charge it with about 70 copper ore and 70 coal, mixed (or the coal on top), and light the coal. Press F once the bit has set. For iron, build it from brick and blow air in with a fan.' },
|
||||
];
|
||||
|
||||
// ---------- Milestones ----------
|
||||
const MILESTONES = [
|
||||
{ id: 'dig', title: 'Break ground', msg: 'Broken material falls loose. The Vacuum (2) picks up only what you click on.', hint: 'Hold the left mouse button with the Drill (1): it sends pulses at the cursor that shake rock until it shatters. It falls as rubble, ore and coal.' },
|
||||
{ id: 'fire', title: 'First fire', hint: 'Vacuum up coal or wood chunks, blow a pile out with right click, then hold the Igniter (4) on it. Fuel only burns where it touches open air.', reward: 'Thermal lens unlocked. Press H to see heat.' },
|
||||
{ id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and vacuum up the brick rubble. Brick rubble builds brick blocks.', reward: 'Brick insulates far better than stone and survives 1750°C.' },
|
||||
{ id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' },
|
||||
{ id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' },
|
||||
{ id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: tick both in your bag and pick Ticked mix with Q/E, and right click blows an even mix.' },
|
||||
{ id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, press F on the mold: the bars pop out and the grate is raked clean of slag. Then vacuum up the bars. Copper that sets in the furnace melts again next firing.' },
|
||||
{ id: '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: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The Vacuum can suck up and blow out liquids.' },
|
||||
{ id: '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: '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.' },
|
||||
{ id: 'claim', title: 'Pay off the claim', msg: 'The land is yours.', hint: 'You owe the claim office $20,000 for this land. Pay it down from the Market page of your notebook.', reward: 'The land is yours.' },
|
||||
{ id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' },
|
||||
{ id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,000 in total.' },
|
||||
{ id: 'deep', title: 'The magma sea', msg: 'Magma is a heat source that never runs out.', hint: 'Dig down through granite and basalt to the molten layer at the bottom of the world.' },
|
||||
];
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
|
||||
// ---------- Save / load ----------
|
||||
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)
|
||||
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 = {
|
||||
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 },
|
||||
filters: [...filterCfg].map(([b, c]) => [b, c.m, c.inv]),
|
||||
weather: { moist: (() => { const m = Float32Array.from(weather.moist); for (const f of flyers) if (f.rain) m[Math.min(m.length - 1, Math.max(0, (f.x / WBAND) | 0))] += 1; return Array.from(m, v => Math.round(v)); })(), raining: Array.from(weather.raining), shower: weather.shower, next: weather.next, wind: weather.wind, seen: weather.seen, base: weather.base },
|
||||
bot: { x: bot.x, y: bot.y, lamp: bot.lamp },
|
||||
bag: Array.from(bag), player: { ...player }, seen: Array.from(seen), ledger, stats: { ...stats },
|
||||
machines: machines.filter(Boolean).map(m => m.type === 'silo' ? { type: m.type, bx: m.x / BS, by: m.y / BS, dir: m.dir, on: m.on, sm: m.sm || 0, sn: m.sn || 0, open: !!m.open } : { type: m.type, bx: m.x / BS, by: m.y / BS, dir: m.dir, on: m.on }),
|
||||
molds: molds.map(m => ({ kind: m.kind, x: m.x, y: m.y, wall: m.wall })),
|
||||
ents: ents.map(e => [e.m, e.ix, e.iy, Math.round(e.T)]),
|
||||
flyers: flyers.filter(f => f.bag).map(f => f.m), // what you blew out goes back in the bag on load
|
||||
ghostBlocks: [...ghostBlocks.entries()], ghostMachines, ghostMolds,
|
||||
specks: 1,
|
||||
pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(),
|
||||
cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) },
|
||||
};
|
||||
return { meta, buffers: { mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } };
|
||||
}
|
||||
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);
|
||||
worldSeed = meta.seed;
|
||||
if (meta.surfBase) { surfBase = meta.surfBase; underD = meta.underD; }
|
||||
shallowD = meta.shallowD || 0; lapse = meta.lapse || 0.36;
|
||||
surfY.set(meta.cols.surfY); dirtY.set(meta.cols.dirtY); graniteY.set(meta.cols.graniteY); basaltY.set(meta.cols.basaltY); magmaY.set(meta.cols.magmaY);
|
||||
computeAmbient(); fillNoise();
|
||||
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
|
||||
for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
|
||||
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true;
|
||||
// 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) {
|
||||
// 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;
|
||||
}
|
||||
if (buffers.bgm) { bgm.set(buffers.bgm); }
|
||||
else {
|
||||
// older saves kept trees in the foreground: move them behind
|
||||
for (let i = 0; i < N; i++) {
|
||||
const m = mat[i], y = (i / W) | 0;
|
||||
if (m === LEAVES || (m === WOOD && (flags[i] & F_NAT) && y < surfY[i % W])) { bgm[i] = m; mat[i] = EMPTY; flags[i] = 0; }
|
||||
}
|
||||
}
|
||||
const t16 = buffers.temp, off = meta.tOff || 0; for (let i = 0; i < N; i++) temp[i] = t16[i] / 16 - off;
|
||||
saplings = (meta.saplings || []).map(([x, y, age, th, seed]) => ({ x, y, age, th, seed }));
|
||||
findTreeBases();
|
||||
resetEconomy();
|
||||
const ec = meta.economy;
|
||||
if (ec) {
|
||||
money = ec.money; debt = ec.debt; earned = ec.earned || 0; shipLog = ec.log || [];
|
||||
(ec.recent || []).forEach((v, k) => { if (k < NMAT) mkRecent[k] = v; });
|
||||
orders = ec.orders || []; ordersDone = ec.ordersDone || 0; nextOrderT = ec.nextOrderT || tick + 3600; orderSeq = ec.orderSeq || 1;
|
||||
}
|
||||
// machines rebuild their hull cells; clear stale hull first
|
||||
for (let i = 0; i < N; i++) if (mat[i] === HULL || mat[i] === DOCKW) mat[i] = EMPTY;
|
||||
const savedPart = buffers.part;
|
||||
for (let i = 0; i < N; i++) {
|
||||
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);
|
||||
}
|
||||
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); }
|
||||
for (const m of meta.machines) { const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false; if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; } }
|
||||
molds = [];
|
||||
for (const m of meta.molds) molds.push(newMoldRecord(m.kind, m.x, m.y, m.wall));
|
||||
for (const [m, x, y, T] of meta.ents || []) { const e = { m, x, y, ix: x, iy: y, vx: 0, vy: 0, T, rest: 0 }; ents.push(e); entMark(e, 1); }
|
||||
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;
|
||||
updateRoof(); resetWeather();
|
||||
if (meta.weather) {
|
||||
const mw = meta.weather;
|
||||
(mw.moist || []).forEach((v, k) => { if (k < weather.moist.length) weather.moist[k] = v; });
|
||||
(mw.raining || []).forEach((v, k) => { if (k < weather.raining.length) weather.raining[k] = v ? 1 : 0; });
|
||||
weather.shower = mw.shower || null; weather.next = mw.next || weather.next; weather.wind = mw.wind || weather.wind; weather.seen = !!mw.seen; weather.base = mw.base || 0;
|
||||
}
|
||||
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
|
||||
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 || {});
|
||||
awake.fill(3);
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||
let dev = 0;
|
||||
for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; }
|
||||
if (dev >= 2) { hot[c] = 6; hotLvl[c] = 2; }
|
||||
}
|
||||
powerDirty = true; rebuildPower(); updateRoof();
|
||||
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();
|
||||
for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } }
|
||||
EV.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// simTick: one simulation step, in order.
|
||||
// ---------- Main tick ----------
|
||||
function simTick() {
|
||||
tick++;
|
||||
tickStamp = (tickStamp % 255) + 1;
|
||||
if (tick % 20 === 1) updateRoof();
|
||||
weatherTick();
|
||||
machinesTick();
|
||||
pipesTick();
|
||||
pulseTick();
|
||||
entTick();
|
||||
if (flyers.length) flyTick();
|
||||
simMove();
|
||||
heatPass();
|
||||
for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--;
|
||||
if (tick % 15 === 0) checkMolds();
|
||||
if (tick % 30 === 0) checkLedger();
|
||||
if (tick % 60 === 7) plantTick();
|
||||
if (tick % 120 === 13) coldTick();
|
||||
if (tick % 20 === 9 && treeBases.length) treeSupportTick();
|
||||
dockTick();
|
||||
if (tick % 60 === 17) { marketTick(); ordersTick(); }
|
||||
if (tick % 30 === 11 && saplings.length) saplingTick();
|
||||
ghostTick();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Tool helpers: reach, brushes, and plain digging.
|
||||
// ---------- Tools ----------
|
||||
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);
|
||||
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > r2) continue;
|
||||
const x = cx + dx, y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
if (!inReach(x, y)) continue;
|
||||
if (fn(x, y, y * W + x) === false) return;
|
||||
}
|
||||
}
|
||||
// Drill: breaks solids loose. Nothing goes into the bag.
|
||||
// how far the drill reaches: set by the bit, and stretched or shortened by the pulse width
|
||||
function drillReach(w = drillWidth) { return Math.round(BIT_TIERS[player.bitTier].reach * (PULSE_W[w] || PULSE_W[1]).reach); }
|
||||
function inDrillReach(x, y, w) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2), R2 = drillReach(w); return dx * dx + dy * dy <= R2 * R2; }
|
||||
// Plain digging in a circle. The game itself uses the resonance drill (drill.js); the tests use
|
||||
// this to clear rock quickly.
|
||||
function toolDig(cx, cy, r) {
|
||||
const tier = BIT_TIERS[player.bitTier];
|
||||
let dug = 0, tooHard = 0, far = 0;
|
||||
forBrush(cx, cy, r, (x, y, i) => {
|
||||
if (!inDrillReach(x, y)) { far++; return; }
|
||||
const m = mat[i];
|
||||
if (bgm[i] && (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_LIQUID)) { if (cutTree(i, x, y, tier)) dug++; return; }
|
||||
if (PH[m] !== PH_SOLID) return;
|
||||
if (PARTMAT[m] || part[i] || mach[i]) return;
|
||||
const h = HARD[m];
|
||||
if (h > tier.hard) { tooHard = m; return; }
|
||||
const p = digp[i] + tier.speed * 60 / (1 + h * h * 0.6);
|
||||
if (p >= 255) {
|
||||
const to = YLD[m];
|
||||
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;
|
||||
dug++;
|
||||
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
||||
} else digp[i] = p;
|
||||
});
|
||||
stats.dug += dug;
|
||||
let status = '';
|
||||
void 0;
|
||||
if (far && !dug && !tooHard) status = `Out of reach. Your ${tier.name.toLowerCase()} reaches ${drillReach()} grains from the bot.`;
|
||||
if (tooHard && !dug) {
|
||||
const need = BIT_TIERS.find(b => b.hard >= HARD[tooHard]);
|
||||
const nb = need ? need.name.toLowerCase() : 'better bit';
|
||||
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
|
||||
}
|
||||
return { dug, status, tooHard };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Undo (Ctrl+Z) for building, and the bot building ghosts.
|
||||
// ---------- Undo ----------
|
||||
// Building actions are recorded while undoRec is open (the game wraps each click, drag, cut or
|
||||
// paste in one record). Ctrl+Z plays the last record backwards: what was placed is taken back
|
||||
// and refunded, what was taken back is built again (paid for again), ghosts are cleared.
|
||||
const UNDO_MAX = 80;
|
||||
let undoStack = [], undoRec = null;
|
||||
function undoNote(op) { if (undoRec) undoRec.ops.push(op); }
|
||||
function undoPush(rec) { if (rec && rec.ops.length) { undoStack.push(rec); if (undoStack.length > UNDO_MAX) undoStack.shift(); } }
|
||||
// label: text for the message when it is undone, or a function of the record that makes it
|
||||
function undoRecord(label, fn) {
|
||||
const prev = undoRec, rec = { label: '', ops: [] };
|
||||
undoRec = rec;
|
||||
try { return fn(); } finally { undoRec = prev; rec.label = typeof label === 'function' ? label(rec) : label; undoPush(rec); }
|
||||
}
|
||||
function undoLast() {
|
||||
const rec = undoStack.pop();
|
||||
if (!rec) return 'Nothing to undo.';
|
||||
const prev = undoRec; undoRec = null;
|
||||
let fails = 0, why = '';
|
||||
const fail = (r) => { fails++; if (r && !why) why = r; };
|
||||
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 === '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); }
|
||||
else if (o.t === 'mold-') { const r = placeMoldAt(o.kind, o.bx, o.by, o.wall); if (r) fail(r); }
|
||||
else if (o.t === 'paste') {
|
||||
// ghosts still waiting are cleared; anything already built from them is taken back
|
||||
for (const [bx, by, code] of o.blocks) { const b = by * BW + bx; if (ghostBlocks.has(b)) { ghostBlocks.delete(b); ghostBlocked.delete(b); } else if (blockP[b] === code) removeBlock(bx, by); }
|
||||
for (const g of o.machines) { const k = ghostMachines.indexOf(g); if (k >= 0) ghostMachines.splice(k, 1); else { const mm = machines.find(m => m && m.type === g.type && m.x === g.bx * BS && m.y === g.by * BS); if (mm) removeMachine(mm.id, true, false); } }
|
||||
for (const g of o.molds) { const k = ghostMolds.indexOf(g); if (k >= 0) ghostMolds.splice(k, 1); else { const mo = molds.find(m => m.kind === g.kind && m.x === g.bx * BS && m.y === g.by * BS); if (mo) removeMold(mo); } }
|
||||
}
|
||||
}
|
||||
undoRec = prev;
|
||||
powerDirty = true;
|
||||
const n = rec.ops.length;
|
||||
return fails ? `Undid ${rec.label}, but ${fails === n ? 'none' : fails} of it could not be put back${why ? ': ' + why.replace(/\.$/, '').toLowerCase() : ''}.` : `Undid ${rec.label}.`;
|
||||
}
|
||||
function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); }
|
||||
let ghostMissing = 0, ghostIter = null;
|
||||
function ghostTick() {
|
||||
if (!ghostBlocks.size && !ghostMachines.length && !ghostMolds.length) return;
|
||||
const keepRec = undoRec; undoRec = null;
|
||||
try { ghostTick0(); } finally { undoRec = keepRec; }
|
||||
}
|
||||
function ghostTick0() {
|
||||
let budget = 2, checks = 200;
|
||||
ghostMissing = 0;
|
||||
if (!ghostIter) ghostIter = ghostBlocks.entries();
|
||||
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 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
|
||||
ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
|
||||
}
|
||||
if (!blockInReach(bx, by)) continue;
|
||||
const err = canPlaceBlock(bx, by);
|
||||
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);
|
||||
ghostBlocks.delete(b); ghostBlocked.delete(b); budget--;
|
||||
emit({ t: 'sfx', s: 'build' });
|
||||
}
|
||||
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 (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--;
|
||||
emit({ t: 'sfx', s: 'build' });
|
||||
}
|
||||
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 (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--;
|
||||
emit({ t: 'sfx', s: 'build' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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.
|
||||
function vacuumTargetAt(cx, cy, r) {
|
||||
for (const e of entsInCircle(cx, cy, Math.max(1, r))) return e.m;
|
||||
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; } }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
// Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true.
|
||||
function toolVacuum(cx, cy, r, pick) {
|
||||
let got = 0, full = false, budget = 48;
|
||||
for (const e of entsInCircle(cx, cy, r)) {
|
||||
if (!pick(e.m) || !inReach(e.ix + 2, e.iy + 1)) continue;
|
||||
if (bagTotal >= bagCap()) { full = true; break; }
|
||||
bagAdd(e.m, 1); removeEnt(e); got++;
|
||||
emit({ t: 'suck', x: e.ix + 2, y: e.iy + 1, m: e.m });
|
||||
}
|
||||
if (!full) 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 (bagTotal >= bagCap()) { full = true; return false; }
|
||||
bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++;
|
||||
if (got & 1) emit({ t: 'suck', x, y, m });
|
||||
if (--budget <= 0) return false;
|
||||
});
|
||||
return { got, full, status: full ? 'Bag full. Blow something out or cast a Tank mold.' : '' };
|
||||
}
|
||||
// Blow: grains leave the bot's nozzle and fly to the cursor, where they drop.
|
||||
// Liquids need the Flask. Bars are thrown, one at a time.
|
||||
function nozzle(tx, ty) {
|
||||
const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2;
|
||||
let dx = tx - cx, dy = ty - cy; const d = Math.hypot(dx, dy) || 1;
|
||||
const reach = Math.min(d, REACH - 1);
|
||||
dx /= d; dy /= d;
|
||||
return { ox: cx + dx * 3, oy: cy + dy * 3, dx, dy, dist: Math.max(1, reach - 3) };
|
||||
}
|
||||
// mix: optional list of materials blown out in turn, one grain each (an even mix).
|
||||
let blowRR = 0;
|
||||
function toolBlow(cx, cy, r, m, mix) {
|
||||
let pickM = null;
|
||||
if (mix && mix.length) {
|
||||
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
|
||||
if (!avail.length) return { placed: 0, status: 'Nothing ticked left to mix. Tick materials in your bag.' };
|
||||
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
|
||||
m = avail[0];
|
||||
}
|
||||
if (!m) return { placed: 0, status: 'Pick something to blow out. Q and E cycle, or click it in your bag.' };
|
||||
if (bag[m] <= 0) return { placed: 0, status: `No ${MD[m].name.toLowerCase()} left.` };
|
||||
const ph = PH[m];
|
||||
if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' };
|
||||
const nz = nozzle(cx + 0.5, cy + 0.5);
|
||||
if (ph === PH_ITEM) {
|
||||
if (tick % 10 !== 0) return { placed: 0, status: '' };
|
||||
// a short lob: aim so the bar lands near the cursor
|
||||
const T = Math.max(10, Math.min(34, nz.dist / 2.2));
|
||||
const tx = nz.ox + nz.dx * nz.dist, ty = nz.oy + nz.dy * nz.dist;
|
||||
let e = null;
|
||||
for (let k = 0; k < 6 && !e; k++) { const sx = nz.ox + nz.dx * k * 2 - 2, sy = nz.oy + nz.dy * k * 2 - 1; if (entFreeAt(Math.round(sx), Math.round(sy))) e = spawnBar(m, sx, sy, (tx - sx - 2) / T, (ty - sy - 1) / T - 0.5 * 0.07 * T); }
|
||||
if (!e) return { placed: 0, status: 'No room to throw a bar from here.' };
|
||||
bagTake(m, 1); return { placed: 1, status: '' };
|
||||
}
|
||||
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);
|
||||
const n = pickM ? rate : Math.min(bag[m], rate);
|
||||
let placed = 0;
|
||||
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
|
||||
// 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;
|
||||
const ox = nz.ox + (rnd() - 0.5), oy = nz.oy + (rnd() - 0.5);
|
||||
const dx = tx - ox, dy = ty - oy, d = Math.hypot(dx, dy) || 1;
|
||||
// air drag (FLY_Q) and gravity (FLY_G) act every tick; pick the launch speed that
|
||||
// reaches the target after T ticks
|
||||
const T = Math.max(3, Math.min(60, Math.round(d / (2.0 + rnd() * 0.4)))), S = (1 - FLY_Q ** T) / (1 - FLY_Q), G = FLY_G * (T - S) / (1 - FLY_Q);
|
||||
flyers.push({ x: ox, y: oy, vx: dx / S, vy: (dy - G) / S, age: 0, m: gm, T: bagTemp(gm), free: false, run: 0, bag: true });
|
||||
bagTake(gm, 1); placed++;
|
||||
}
|
||||
return { placed, status: '' };
|
||||
}
|
||||
const FLY_G = 0.06, FLY_Q = 0.93;
|
||||
// Can a flying grain of m be in cell j?
|
||||
function flyOpen(m, j) {
|
||||
if (occ[j] || mach[j] && mat[j] === HULL) return false;
|
||||
const p = part[j];
|
||||
if (p === P_GRATE && PH[m] === PH_POWDER) return false;
|
||||
if (p === P_INTAKE) return false;
|
||||
if (p === P_FILTER && PH[m] === PH_POWDER && !filterPassAt(m, j)) return false;
|
||||
const t = mat[j];
|
||||
return t === EMPTY || PH[t] === PH_GAS;
|
||||
}
|
||||
// Put a grain into the nearest open cell around (x, y), at or above where it stopped. It never
|
||||
// jumps down: below could be the far side of a floor, a grate or a filter (into a mold, say).
|
||||
function depositNear(m, T, x, y, skip) {
|
||||
x = Math.max(0, Math.min(W - 1, x)); y = Math.max(0, Math.min(H - 1, y));
|
||||
for (let r = 0; r <= 24; r++) {
|
||||
for (let dy = -r; dy <= 0; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
const xx = x + dx, yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
|
||||
const j = yy * W + xx;
|
||||
if (j !== skip && flyOpen(m, j)) { setCell(j, m, T, 0); return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// What comes out of the bag is at room temperature, except what would melt at it: snow stays frozen,
|
||||
// and molten things come out hot enough to pour.
|
||||
function bagTemp(m) { return PH[m] === PH_LIQUID ? Math.max(LIQT[m], 20) : MELT[m] !== 0 && MELT[m] < 20 ? MELT[m] - 6 : 20; }
|
||||
function landFlyer(f, x, y) {
|
||||
// settle into the nearest open cell at or above where it stopped
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const yy = y - k; if (yy < 0) break;
|
||||
const j = yy * W + x;
|
||||
if (flyOpen(f.m, j)) { setCell(j, f.m, f.T, 0); return; }
|
||||
}
|
||||
if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds)
|
||||
}
|
||||
function flyTick() {
|
||||
for (let k = flyers.length - 1; k >= 0; k--) {
|
||||
const f = flyers[k];
|
||||
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(f.vx), Math.abs(f.vy))));
|
||||
const sx = f.vx / steps, sy = f.vy / steps;
|
||||
let px = Math.floor(f.x), py = Math.floor(f.y), done = false;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const nx = f.x + sx, ny = f.y + sy, cx = Math.floor(nx), cy = Math.floor(ny);
|
||||
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);
|
||||
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
|
||||
if (open) f.free = true; else if (++f.run > 14) { if (!depositNear(f.m, f.T, cx, cy)) bagAdd(f.m, 1, true); done = true; break; }
|
||||
} else if (!open) { landFlyer(f, px, py); done = true; break; }
|
||||
f.x = nx; f.y = ny; px = cx; py = cy;
|
||||
}
|
||||
if (done) { flyers[k] = flyers[flyers.length - 1]; flyers.pop(); continue; }
|
||||
f.vx *= FLY_Q; f.vy = f.vy * FLY_Q + FLY_G;
|
||||
if (++f.age > 900) { landFlyer(f, Math.max(0, Math.min(W - 1, px)), Math.max(0, py)); flyers[k] = flyers[flyers.length - 1]; flyers.pop(); }
|
||||
}
|
||||
}
|
||||
// The flame heats what it touches, and licks a few grains into a loose pile, so fuel
|
||||
// buried just under ore still catches if air reaches it (through a grate, say).
|
||||
// Returns { any: anything got hotter, fuel: there is fuel within the flame's reach }.
|
||||
function toolIgnite(cx, cy) {
|
||||
let any = false, fuel = false;
|
||||
forBrush(cx, cy, 4, (x, y, i) => {
|
||||
const dx = x - cx, dy = y - cy, near = dx * dx + dy * dy <= 2.5;
|
||||
if (IGN[mat[i]]) fuel = true;
|
||||
if (!near && !(IGN[mat[i]] && dy >= 0)) return;
|
||||
const top = near ? 640 : 520;
|
||||
if (temp[i] < top) { temp[i] = Math.min(top, temp[i] + (near ? 26 : 12)); any = true; }
|
||||
wakeHeatXY(x, y);
|
||||
});
|
||||
return { any, fuel };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Weather: clouds from steam, rain and snow, and the world water count.
|
||||
// ---------- Weather ----------
|
||||
// Steam that rises into the open sky is gathered into clouds above where it rose. The clouds
|
||||
// drift slowly with the wind and rain it back out, or snow where the ground is below freezing.
|
||||
// Water is only ever moved around, never made, so the world neither floods nor runs dry. If
|
||||
// some does leave (dropped into the trade dock, say), a shower blows in from beyond the claim
|
||||
// now and then with just enough to make it up.
|
||||
const WBAND = 64;
|
||||
let WS = 0x1b873593 | 0;
|
||||
function wrnd() { WS ^= WS << 13; WS ^= WS >>> 17; WS ^= WS << 5; return (WS >>> 0) * 2.3283064365386963e-10; }
|
||||
let weather = { moist: new Float32Array(1), raining: new Uint8Array(1), shower: null, next: 0, wind: 0.3, seen: false, skyY: 1 };
|
||||
function resetWeather() {
|
||||
let top = H; for (let x = 0; x < W; x++) if (surfY[x] < top) top = surfY[x];
|
||||
weather = { moist: new Float32Array(Math.ceil(W / WBAND)), raining: new Uint8Array(Math.ceil(W / WBAND)), shower: null, next: tick + rainGap(), wind: wrnd() < 0.5 ? -0.3 : 0.3, seen: false, skyY: Math.max(1, top - 40), base: 0 };
|
||||
}
|
||||
function rainGap() { return 3600 * (5 + wrnd() * 7); } // 5 to 12 minutes between checks on the world's water
|
||||
// All the world's water: everything that is water or will be again (ice, snow, steam, clouds,
|
||||
// rain in the air, what is in your bag). weather.base is how much a world started with.
|
||||
function worldWater() {
|
||||
let n = 0;
|
||||
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === WATER || m === ICE || m === SNOW || m === STEAM) n++; }
|
||||
for (let b = 0; b < weather.moist.length; b++) n += weather.moist[b];
|
||||
for (const f of flyers) if (f.m === WATER || f.m === SNOW) n++;
|
||||
for (const pn of pipeNets) if (pn.m === WATER) n += pn.n; // water in pipes still counts
|
||||
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; }
|
||||
// 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;
|
||||
let n = Math.floor(k) + (wrnd() < k - Math.floor(k) ? 1 : 0), made = 0;
|
||||
for (; n > 0; n--) {
|
||||
const x = Math.floor(x0 + wrnd() * (x1 - x0));
|
||||
if (x < 0 || x >= W) continue;
|
||||
const y = Math.min(weather.skyY, Math.max(1, roofY[x] - 30));
|
||||
const ground = Math.min(H - 1, roofY[x]), cold = AMB[ground] < 0;
|
||||
flyers.push({ x: x + wrnd(), y: y + wrnd() * 6, vx: weather.wind * (cold ? 0.35 : 0.12), vy: cold ? 0.15 : 0.5, age: 0, m: cold ? SNOW : WATER, T: cold ? Math.min(AMB[ground], -2) : Math.max(4, Math.min(18, AMB[ground])), free: true, run: 0, rain: true });
|
||||
made++;
|
||||
}
|
||||
return made;
|
||||
}
|
||||
// How hard it is raining over column x, 0 (dry) to 1 (a downpour)
|
||||
function rainAt(x) {
|
||||
let r = 0;
|
||||
const sh = weather.shower;
|
||||
if (sh && x >= sh.x0 - 40 && x <= sh.x1 + 40) r += sh.rate * sh.ramp * (x >= sh.x0 && x <= sh.x1 ? 1 : 0.5);
|
||||
const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)), mo = weather.moist[b];
|
||||
if (weather.raining[b]) r += Math.min(3, 0.5 + mo / 100);
|
||||
return Math.min(1, r / 2);
|
||||
}
|
||||
function weatherTick() {
|
||||
const w = weather, n = w.moist.length;
|
||||
if (!w.shower && tick >= w.next) {
|
||||
// a shower from beyond the claim only when the world is short of the water it started with
|
||||
const short = w.base ? w.base - worldWater() : 0;
|
||||
if (short > Math.max(60, w.base * 0.03)) {
|
||||
const len = Math.min(W, 160 + wrnd() * 380), x0 = wrnd() * Math.max(1, W - len);
|
||||
w.shower = { x0, x1: x0 + len, rate: 0.6 + wrnd() * 1.6, until: tick + 60 * (40 + Math.floor(wrnd() * 80)), ramp: 0, left: short };
|
||||
w.wind = (wrnd() - 0.5) * 0.8;
|
||||
emit({ t: 'weather', kind: 'shower', x0: Math.round(x0), x1: Math.round(x0 + len) });
|
||||
} else w.next = tick + rainGap();
|
||||
}
|
||||
if (w.shower) {
|
||||
const sh = w.shower;
|
||||
sh.ramp = tick < sh.until ? Math.min(1, sh.ramp + 1 / 300) : sh.ramp - 1 / 300; // fades in and out over 5 s
|
||||
sh.x0 += w.wind * 0.03; sh.x1 += w.wind * 0.03;
|
||||
if (sh.ramp <= 0) { w.shower = null; w.next = tick + rainGap(); }
|
||||
else if (!(sh.left <= 0)) {
|
||||
const made = spawnRain(sh.x0, sh.x1, Math.min(sh.left === undefined ? 1e9 : sh.left, sh.rate * sh.ramp * (sh.x1 - sh.x0) / 300));
|
||||
if (sh.left !== undefined) { sh.left -= made; if (sh.left <= 0 && sh.until > tick) sh.until = tick; } // made up the shortfall: it passes
|
||||
}
|
||||
}
|
||||
// clouds made of your steam build up, then rain it back out where they hang
|
||||
for (let b = 0; b < n; b++) {
|
||||
if (!w.raining[b] && w.moist[b] > 150) w.raining[b] = 1;
|
||||
if (w.raining[b]) { w.moist[b] -= spawnRain(b * WBAND, (b + 1) * WBAND, Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; }
|
||||
}
|
||||
// and drift a little downwind every second; at the edge of the world they pile up instead
|
||||
if (tick % 60 === 0) {
|
||||
const out = new Float32Array(n), d = w.wind > 0 ? 1 : -1, f = Math.min(0.12, Math.abs(w.wind) * 0.1);
|
||||
for (let b = 0; b < n; b++) { const mv = w.raining[b] ? 0 : w.moist[b] * f; out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; }
|
||||
w.moist = out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
||||
// ---------- World state ----------
|
||||
let W = 0, H = 0, N = 0, CW = 0, CH = 0, NCH = 0;
|
||||
let mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ;
|
||||
let surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed = 1;
|
||||
let surfBase = 175, underD = 465; // surface height and underground depth: layers are fractions of underD
|
||||
// shallowD: in newer worlds the top 30% of the layering (dirt, coal, copper, tin, the top of the iron) keeps
|
||||
// a fixed depth in cells whatever the world size, and only the deeper layers stretch. 0 = plain fractions.
|
||||
// lapse: how fast the air cools with height above the ground, per cell.
|
||||
let shallowD = 0, lapse = 0.36;
|
||||
let BW = 0, BH = 0, NB = 0, blockP, blockV; // block registry: piece code + variant per 4x4 block
|
||||
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 awake, hot, hotLvl;
|
||||
let tick = 0, tickStamp = 1;
|
||||
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
|
||||
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
|
||||
let brokenMachines = [];
|
||||
let ghostMachines = [], ghostBlocked = new Set();
|
||||
const EV = [];
|
||||
const bot = { x: 0, y: 0, vx: 0, vy: 0, w: 5, h: 5, lamp: true, face: 1, thrust: 0 };
|
||||
let bag = new Int32Array(NMAT), bagTotal = 0;
|
||||
const player = { bitTier: 0, tankTier: 0, lens: false, flask: false };
|
||||
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 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; }
|
||||
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
|
||||
}
|
||||
|
||||
function alloc(w, h) {
|
||||
W = w; H = h; N = w * h;
|
||||
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();
|
||||
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);
|
||||
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
|
||||
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
|
||||
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
|
||||
ghostMachines = []; ghostBlocked = new Set();
|
||||
tick = 0; tickStamp = 1; drillPulses = []; lastPulseTick = -99; undoStack = []; undoRec = null;
|
||||
pipeNets = []; pipeNetAt = null; pipesDirty = true;
|
||||
}
|
||||
|
||||
// 0 at the base surface height, 1 at the bottom of the world
|
||||
function depthU(y) {
|
||||
const d = y - surfBase;
|
||||
if (!shallowD) return d / underD;
|
||||
return d <= shallowD ? d / shallowD * 0.3 : 0.3 + (d - shallowD) / (underD - shallowD) * 0.7;
|
||||
}
|
||||
// the y at a fraction u of the underground depth (the inverse of depthU)
|
||||
function depthY(u) { return Math.round(surfBase + (!shallowD ? u * underD : u <= 0.3 ? u / 0.3 * shallowD : shallowD + (u - 0.3) / 0.7 * (underD - shallowD))); }
|
||||
function computeAmbient() {
|
||||
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W;
|
||||
for (let y = 0; y < H; y++) {
|
||||
let a = 18;
|
||||
if (y > sm) a += Math.min(60, (y - sm) * 0.04);
|
||||
else if (y < sm - 20) a = Math.max(-30, a - (sm - 20 - y) * lapse); // thin cold air high above the ground
|
||||
const u = depthU(y);
|
||||
// geothermal: warms slowly, then steeply toward the magma sea
|
||||
if (u > 0.53) { const k = Math.min(1, (u - 0.53) / (0.905 - 0.53)); a += Math.pow(k, 2.2) * 1200; }
|
||||
AMB[y] = a;
|
||||
}
|
||||
}
|
||||
function fillNoise() {
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) noiseB[y * W + x] = hash2(x, y, worldSeed) & 255;
|
||||
}
|
||||
|
||||
// ---------- Waking ----------
|
||||
function wakeXY(x, y) {
|
||||
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
|
||||
awake[c] = 3;
|
||||
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;
|
||||
}
|
||||
function wakeHeatXY(x, y) {
|
||||
const c = (y >> 5) * CW + (x >> 5);
|
||||
hot[c] = 6; hotLvl[c] = 2;
|
||||
}
|
||||
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
|
||||
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
|
||||
@@ -0,0 +1,315 @@
|
||||
// World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks.
|
||||
// ---------- Perlin noise ----------
|
||||
function makeNoise(rng) {
|
||||
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
|
||||
const p = []; for (let i = 0; i < 256; i++) p.push(i);
|
||||
for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
|
||||
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||||
for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
|
||||
return function (x, y) {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
|
||||
const X = xi & 255, Y = yi & 255;
|
||||
const p0 = perm[Y], p1 = perm[Y + 1];
|
||||
const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1];
|
||||
const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf;
|
||||
const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1);
|
||||
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
|
||||
return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41;
|
||||
};
|
||||
}
|
||||
function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; }
|
||||
|
||||
// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a
|
||||
// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves
|
||||
// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock
|
||||
// around it. Bigger veins and pockets stay. live: the world is in play (an older save).
|
||||
const SPECK_MIN = 30;
|
||||
function clearHardSpecks(live) {
|
||||
const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1);
|
||||
let n = 0;
|
||||
for (const h0 of [6, 5, 4]) {
|
||||
seen.fill(0);
|
||||
const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; };
|
||||
const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; };
|
||||
for (let i0 = 0; i0 < N; i0++) {
|
||||
if (seen[i0] || !mem(i0)) continue;
|
||||
let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
|
||||
const hosts = new Map();
|
||||
st[sp++] = i0; seen[i0] = 1;
|
||||
while (sp) {
|
||||
const i = st[--sp], x = i % W, y = (i / W) | 0;
|
||||
if (sz <= SPECK_MIN) cells[sz] = i;
|
||||
sz++;
|
||||
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
|
||||
for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
|
||||
if (j < 0 || j >= N || seen[j]) continue;
|
||||
if (mem(j)) { seen[j] = 1; st[sp++] = j; }
|
||||
else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1);
|
||||
}
|
||||
}
|
||||
if (sz > SPECK_MIN) continue;
|
||||
// a speck left floating (in a cave, or dug round) takes the rock nearest round it
|
||||
if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); }
|
||||
let host = 0, best = 0;
|
||||
for (const [m, c] of hosts) if (c > best) { best = c; host = m; }
|
||||
if (!host) continue;
|
||||
for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; }
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// ---------- World generation ----------
|
||||
function generateWorld(seedIn, w, h, onProgress) {
|
||||
worldSeed = seedIn >>> 0;
|
||||
alloc(w, h);
|
||||
RS = (worldSeed ^ 0x9E3779B9) | 1;
|
||||
const rng = mulberry32(worldSeed);
|
||||
const nSurf = makeNoise(rng), nDetail = makeNoise(rng), nLayer = makeNoise(rng), nPocket = makeNoise(rng), nPocket2 = makeNoise(rng);
|
||||
const nCoal = makeNoise(rng), nCu = makeNoise(rng), nFe = makeNoise(rng), nCave = makeNoise(rng), nCave2 = makeNoise(rng), nCave3 = makeNoise(rng), nWater = makeNoise(rng);
|
||||
const rng2 = mulberry32(worldSeed ^ 0x5bd1e995); // separate stream so older worlds keep their layout
|
||||
const nSn = makeNoise(rng2), nZn = makeNoise(rng2), nAg = makeNoise(rng2), nAu = makeNoise(rng2);
|
||||
const rng3 = mulberry32(worldSeed ^ 0x27d4eb2d); // the shapes of the deep rock layers
|
||||
const nLay2 = makeNoise(rng3), nWarp = makeNoise(rng3), nIntr = makeNoise(rng3), nDike = makeNoise(rng3), nMagC = makeNoise(rng3);
|
||||
const spawnX = w >> 1;
|
||||
// Layout: a third of the height is sky. The shallow layers keep a fixed depth (see shallowD),
|
||||
// the deep ones stretch to fill the rest, and the air cools gently enough to build tall.
|
||||
surfBase = Math.round(h * 0.3); underD = h - surfBase; shallowD = Math.min(140, Math.round(underD * 0.3)); lapse = 0.22;
|
||||
const base = surfBase;
|
||||
const U = depthY; // y at a fraction of the underground depth
|
||||
for (let x = 0; x < w; x++) {
|
||||
let s = base + fbm(nSurf, x / 230, 0.3, 4) * 46 + nDetail(x / 38, 4.2) * 5;
|
||||
const ds = Math.abs(x - spawnX);
|
||||
if (ds < 60) { const k = 1 - ds / 60; s = s * (1 - k * 0.75) + base * k * 0.75; }
|
||||
surfY[x] = Math.round(s);
|
||||
dirtY[x] = surfY[x] + Math.round(14 + nLayer(x / 60, 1.5) * 7);
|
||||
// the deep layers rise and sink in long swells as well as short wiggles, more in deeper worlds
|
||||
// (a shared fold moves them together, and each also has its own swells)
|
||||
const fold = fbm(nLay2, x / 520, 3.3, 3);
|
||||
const gA = underD * 0.16, bA = underD * 0.12, mA = underD * 0.06;
|
||||
graniteY[x] = U(0.5) + Math.round(fold * gA * 1.8 + fbm(nLay2, x / 170, 9.1, 3) * gA + nLayer(x / 60, 9.1) * 10);
|
||||
basaltY[x] = U(0.72) + Math.round(fold * bA * 1.6 + fbm(nLay2, x / 150, 17.3, 3) * bA + nLayer(x / 50, 17.3) * 8);
|
||||
magmaY[x] = U(0.905) + Math.round(fold * mA * 1.4 + fbm(nLay2, x / 110, 23.7, 3) * mA + nLayer(x / 40, 23.7) * 5);
|
||||
// and stay in order, with room between them
|
||||
graniteY[x] = Math.max(graniteY[x], dirtY[x] + 60);
|
||||
basaltY[x] = Math.max(basaltY[x], graniteY[x] + 40);
|
||||
magmaY[x] = Math.min(h - 12, Math.max(magmaY[x], basaltY[x] + 30));
|
||||
}
|
||||
// Landmarks: a lake on one side of spawn and a desert on the other, then mountains, lakes,
|
||||
// deserts and hills spaced out toward both edges. The first one past the lake is always a
|
||||
// mountain tall enough to reach the freezing air and hold snow.
|
||||
const lakeSide = rng() < 0.5 ? -1 : 1;
|
||||
const feats = [{ k: 'lake', x: Math.round(spawnX + lakeSide * (170 + rng() * 80)) }, { k: 'desert', x: Math.round(spawnX - lakeSide * (250 + rng() * 90)) }];
|
||||
for (const side of [lakeSide, -lakeSide]) {
|
||||
let d = 600 + rng() * 90, prev = side === lakeSide ? '' : 'desert';
|
||||
for (;;) {
|
||||
const x = Math.round(spawnX + side * d);
|
||||
if (x < 140 || x > w - 140) break;
|
||||
// never the same kind twice in a row
|
||||
let k = 'mountain';
|
||||
if (prev) { const ks = ['lake', 'desert', 'mountain', 'hills'].filter(q => q !== prev), wt = { lake: 3, desert: 2.5, mountain: 2.5, hills: 2 }; let r = rng() * ks.reduce((a, q) => a + wt[q], 0); k = ks.find(q => (r -= wt[q]) < 0) || ks[0]; }
|
||||
feats.push({ k, x });
|
||||
prev = k;
|
||||
d += 340 + rng() * 260;
|
||||
}
|
||||
}
|
||||
if (!feats.some(f => f.k === 'mountain')) feats.push({ k: 'mountain', x: lakeSide > 0 ? w - 160 : 160 });
|
||||
const maxMt = Math.max(60, Math.min(surfBase - 80, 200));
|
||||
for (const f of feats) {
|
||||
if (f.k !== 'mountain' && f.k !== 'hills') continue;
|
||||
const mt = f.k === 'mountain', sig = mt ? 55 + rng() * 30 : 45 + rng() * 35, hgt = mt ? maxMt * (0.8 + rng() * 0.2) : 35 + rng() * 35;
|
||||
for (let x = Math.max(0, Math.round(f.x - sig * 3.2)); x < Math.min(w, f.x + sig * 3.2); x++) {
|
||||
const g = Math.exp(-0.5 * ((x - f.x) / sig) ** 2);
|
||||
if (g < 0.01) continue;
|
||||
surfY[x] = Math.round(surfY[x] - hgt * g - nDetail(x / 9, 5.7) * 5 * g);
|
||||
if (mt) dirtY[x] = surfY[x] + Math.round(3 + (1 - g) * 11);
|
||||
else dirtY[x] = Math.min(dirtY[x], surfY[x] + 20);
|
||||
}
|
||||
}
|
||||
// lakes: a basin dug below the lowest point of its rim, filled to just above it
|
||||
const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), desAt = new Uint8Array(w);
|
||||
for (const f of feats) {
|
||||
if (f.k !== 'lake') continue;
|
||||
const lw = Math.round(90 + rng() * 40), deep = 22 + rng() * 8;
|
||||
let lip = 1e9; for (let x = f.x - lw; x <= f.x + lw; x++) if (x >= 0 && x < w) lip = Math.min(lip, surfY[x]);
|
||||
for (let x = f.x - lw; x <= f.x + lw; x++) {
|
||||
if (x < 0 || x >= w) continue;
|
||||
const u = (x - f.x) / lw, depth = Math.max(0, 1 - u * u) * deep;
|
||||
if (depth > 0) { surfY[x] = Math.max(surfY[x], Math.round(lip + depth)); dirtY[x] = Math.max(dirtY[x], surfY[x] + 8); }
|
||||
lakeTop[x] = lip + 2;
|
||||
}
|
||||
for (let x = Math.max(0, f.x - lw - 6); x <= Math.min(w - 1, f.x + lw + 6); x++) noTree[x] = 1;
|
||||
}
|
||||
for (const f of feats) {
|
||||
if (f.k !== 'desert') continue;
|
||||
const dw = Math.round(90 + rng() * 40);
|
||||
for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1;
|
||||
}
|
||||
computeAmbient();
|
||||
for (let y = 0; y < h; y++) {
|
||||
const fy = depthU(y); // fraction of the underground depth (negative in the sky)
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
const sy = surfY[x];
|
||||
let m = EMPTY;
|
||||
if (y < sy) {
|
||||
if (y >= lakeTop[x]) m = WATER;
|
||||
} else {
|
||||
const d = y - sy;
|
||||
const inDes = desAt[x] === 1;
|
||||
if (y < dirtY[x]) m = d === 0 && !inDes ? GRASS : DIRT;
|
||||
else if (y < graniteY[x] + nWarp(x / 70, y / 45) * 26 + nWarp(x / 22 + 40, y / 15) * 8 + nPocket(x / 9, y / 9) * 3) m = STONE;
|
||||
else if (y < basaltY[x] + nWarp(x / 64 + 50, y / 40) * 22 + nWarp(x / 20 + 90, y / 14) * 7 + nPocket(x / 7, y / 7) * 3) m = GRANITE;
|
||||
else m = BASALT;
|
||||
// granite bulges up into the stone in rounded masses, and stone is caught in the granite in lumps
|
||||
if (m === STONE && y > graniteY[x] - 100 && nIntr(x / 46, y / 34) > 0.24 + (graniteY[x] - y) / 100 * 0.3) m = GRANITE;
|
||||
else if (m === GRANITE && y < basaltY[x] - 20 && nIntr(x / 38 + 70, y / 30) < -0.36) m = STONE;
|
||||
void nDike; // (kept so the noises after it stay the same)
|
||||
if (inDes && d < 12 + nDetail(x / 15, 8.1) * 4) m = SAND;
|
||||
// pockets
|
||||
const pk = nPocket(x / 14, y / 11), pk2 = nPocket2(x / 18, y / 13);
|
||||
if (m === DIRT && d > 2 && pk2 > 0.42) m = CLAY;
|
||||
else if (m === STONE) {
|
||||
if (d < 150 && pk > 0.52) m = DIRT;
|
||||
else if (d < 110 && pk2 > 0.5) m = CLAY;
|
||||
else if (pk < -0.56) m = GRAVEL;
|
||||
else if (pk2 < -0.6 && d < 220) m = SAND;
|
||||
}
|
||||
// ores
|
||||
if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) {
|
||||
const c = nCoal(x / 13, y / 9);
|
||||
if (d > 6 && fy < 0.45 && c > 0.5 && m !== CLAY) m = COALV;
|
||||
else {
|
||||
const cu = nCu(x / 9, y / 8);
|
||||
const cuBand = d > 14 && fy < 0.48;
|
||||
if (cuBand && (m === STONE || m === DIRT) && cu > (d < 140 ? 0.5 : 0.56)) m = CUV;
|
||||
else {
|
||||
// iron and zinc are harder than the stone around them, so they come in fewer, chunkier
|
||||
// lenses rather than a scatter of specks that a stone bit would leave in the way
|
||||
const fe = nFe(x / 24, y / 13);
|
||||
if (fy > 0.28 && (m === STONE || m === GRANITE || m === BASALT) && fe > (m === STONE ? 0.55 : 0.47)) m = FEV;
|
||||
// rarer ores in deeper bands: tin, then zinc, silver in granite, gold deepest
|
||||
else if (fy > 0.15 && fy < 0.55 && m === STONE && nSn(x / 8, y / 7) > 0.62) m = SNV;
|
||||
else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6) m = ZNV;
|
||||
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66) m = AGV;
|
||||
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7) m = AUV;
|
||||
}
|
||||
}
|
||||
}
|
||||
// caves
|
||||
if (d > 9) {
|
||||
const t1 = Math.abs(fbm(nCave, x / 130, y / 80, 2));
|
||||
const big = fy > 0.2 ? fbm(nCave2, x / 95, y / 64, 3) : -1;
|
||||
const small = nCave3(x / 26, y / 20);
|
||||
const depthK = Math.min(1, (d - 9) / 50);
|
||||
if (t1 < 0.06 * depthK || big > 0.34 || small > 0.6 - (fy > 0.35 ? 0.05 : 0)) m = EMPTY;
|
||||
}
|
||||
const my = magmaY[x] + nWarp(x / 40 + 120, y / 20) * 12;
|
||||
if (y >= my) m = (nPocket(x / 12, y / 6) > 0.45 && y < my + 6) ? BASALT : MAGMA;
|
||||
// pockets of magma sit in the basalt just above the magma sea
|
||||
else if (m === BASALT && y > my - 50 && nMagC(x / 40, y / 22) > 0.3 + (my - y) / 50 * 0.3) m = MAGMA;
|
||||
if (y >= h - 2) m = BASALT;
|
||||
// aquifers fill the low parts of shallow caves
|
||||
if (m === EMPTY && d > 20 && fy < 0.46) {
|
||||
const wl = nWater(x / 140, 0.5);
|
||||
if (wl > 0.15 && nWater(x / 40, y / 40) > -0.1 && ((y + Math.floor(wl * 40)) % 23) > 13) m = WATER;
|
||||
}
|
||||
}
|
||||
mat[i] = m;
|
||||
if (m !== EMPTY && m !== WATER && m !== MAGMA) flags[i] = F_NAT;
|
||||
else if (m === WATER || m === MAGMA) flags[i] = F_NAT;
|
||||
temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y];
|
||||
if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255;
|
||||
}
|
||||
if (onProgress && (y & 63) === 0) onProgress(y / h * 0.7);
|
||||
}
|
||||
clearHardSpecks(false);
|
||||
// (tree bases are collected after the trees below)
|
||||
// snow lies on the high ground wherever the air is below freezing
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const sy = surfY[x];
|
||||
if (sy < 3 || AMB[sy - 1] > 0) continue;
|
||||
const i0 = sy * w + x;
|
||||
if (mat[i0] === GRASS) mat[i0] = DIRT;
|
||||
const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1;
|
||||
for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } }
|
||||
}
|
||||
// trees
|
||||
for (let x = 12; x < w - 12;) {
|
||||
const sy = surfY[x];
|
||||
const ok = mat[sy * w + x] === GRASS && mat[sy * w + x + 1] === GRASS && !noTree[x] && Math.abs(x - spawnX) > 6 && !(x > spawnX + 14 && x < spawnX + 72); // none where the dock goes
|
||||
if (ok && rng() < 0.7) {
|
||||
const th = 9 + Math.floor(rng() * 8);
|
||||
const setW = (xx, yy) => { if (xx < 0 || yy < 1 || xx >= w) return; const i = yy * w + xx; if (mat[i] === EMPTY) bgm[i] = WOOD; };
|
||||
for (let k = 1; k <= th; k++) for (let dx = 0; dx < 2; dx++) setW(x + dx, sy - k);
|
||||
// two branches
|
||||
for (const side of [-1, 1]) { const by = sy - th + 3 + Math.floor(rng() * 3); for (let k = 1; k <= 3; k++) setW(x + (side < 0 ? -k : 1 + k), by - (k >> 1)); }
|
||||
// lumpy canopy made of a few overlapping blobs
|
||||
const blobs = [[0.5, -th - 1, 6 + rng() * 2.5], [-3.5, -th + 1, 4 + rng() * 2], [4.5, -th + 1, 4 + rng() * 2], [0.5, -th - 5, 4 + rng() * 2]];
|
||||
for (const [bx, by, r] of blobs) {
|
||||
const cx = x + bx, cy = sy + by;
|
||||
for (let yy = Math.floor(cy - r); yy <= cy + r; yy++) for (let xx = Math.floor(cx - r); xx <= cx + r; xx++) {
|
||||
if (xx < 0 || yy < 1 || xx >= w) continue;
|
||||
const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85);
|
||||
if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) { const i = yy * w + xx; if (mat[i] === EMPTY && !bgm[i]) bgm[i] = LEAVES; }
|
||||
}
|
||||
}
|
||||
x += 16 + Math.floor(rng() * 20);
|
||||
} else x += 4;
|
||||
}
|
||||
// springs: in cave side walls just above the cave floor
|
||||
{
|
||||
const cands = [];
|
||||
for (let y = 0; y < h - 4; y++) {
|
||||
if (depthU(y) > 0.45) break;
|
||||
for (let x = 2; x < w - 2; x++) {
|
||||
const i = y * w + x;
|
||||
if (!(flags[i] & F_NAT) || PH[mat[i]] !== PH_SOLID || mat[i] === LEAVES || mat[i] === WOOD) continue;
|
||||
if (y - surfY[x] < 16) continue;
|
||||
for (const dx of [-1, 1]) {
|
||||
const e = i + dx;
|
||||
if (mat[e] !== EMPTY) continue;
|
||||
// floor 2-4 cells below the open cell, and solid above the spring
|
||||
if (PH[mat[e + w]] === PH_EMPTY && PH[mat[e + 2 * w]] !== PH_EMPTY && PH[mat[i - w]] === PH_SOLID && PH[mat[i + w]] === PH_SOLID) cands.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
const picks = [];
|
||||
const near = cands.slice().sort((a, b) => Math.abs(a % w - spawnX) - Math.abs(b % w - spawnX));
|
||||
for (const c of near) { if (picks.length >= 2) break; if (picks.every(p => Math.abs(p % w - c % w) > 30)) picks.push(c); }
|
||||
for (let k = 0; k < 400 && picks.length < 10 && cands.length; k++) {
|
||||
const c = cands[Math.floor(rng() * cands.length)];
|
||||
if (picks.every(p => Math.abs(p % w - c % w) > 50 || Math.abs(((p / w) | 0) - ((c / w) | 0)) > 40)) picks.push(c);
|
||||
}
|
||||
// Springs were taken out of the game (rain refills the world instead). The spots are still
|
||||
// drawn above so every seed keeps making the same world it always did.
|
||||
void picks;
|
||||
}
|
||||
fillNoise();
|
||||
if (onProgress) onProgress(0.75);
|
||||
// settle loose material
|
||||
awake.fill(3);
|
||||
for (let k = 0; k < 260; k++) {
|
||||
tick++; tickStamp = (tickStamp % 255) + 1;
|
||||
simMove();
|
||||
let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; }
|
||||
if (!any) break;
|
||||
if (onProgress && (k & 31) === 0) onProgress(0.75 + 0.25 * k / 260);
|
||||
}
|
||||
// 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; }
|
||||
tick = 0;
|
||||
findTreeBases();
|
||||
placeDock(spawnX + 34);
|
||||
hot.fill(0); awake.fill(0); updateRoof();
|
||||
resetEconomy(); resetWeather();
|
||||
weather.base = worldWater();
|
||||
bot.x = spawnX - 2; bot.y = surfY[spawnX] - 14; bot.vx = 0; bot.vy = 0;
|
||||
while (botOverlapSolid(bot.x, bot.y) && bot.y > 2) bot.y -= 1;
|
||||
bag = new Int32Array(NMAT); bagTotal = 0;
|
||||
player.bitTier = 0; player.tankTier = 0; player.lens = false; player.flask = false;
|
||||
seen = new Uint8Array(NMAT); ledger = {};
|
||||
stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0;
|
||||
EV.length = 0;
|
||||
if (onProgress) onProgress(1);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/* Foundry floor: soot-dark iron plates, molten-metal accent, copper-patina for interactive states.
|
||||
Layout: full-bleed simulation canvas, HUD plates pinned to the four corners, modal plates centered. */
|
||||
:root {
|
||||
--soot-0: #0f0d0c;
|
||||
--soot-1: #191614;
|
||||
--soot-2: #231f1c;
|
||||
--soot-3: #3b342f;
|
||||
--soot-4: #554b44;
|
||||
--ash: #a2978b;
|
||||
--ash-dim: #766c62;
|
||||
--bone: #efe6d8;
|
||||
--melt: #ff9a3c;
|
||||
--melt-hot: #ffd27a;
|
||||
--melt-deep: #c9561d;
|
||||
--patina: #5cbaa6;
|
||||
--patina-dim: #2f6d61;
|
||||
--danger: #ef6a55;
|
||||
--ok: #92d27c;
|
||||
--plate: rgba(24, 21, 19, 0.88);
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", "Noto Sans", "DejaVu Sans", sans-serif;
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
|
||||
--ui: 1;
|
||||
color-scheme: dark;
|
||||
}
|
||||
[hidden] { display: none !important; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; overflow: hidden; background: var(--soot-0); color: var(--bone);
|
||||
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; }
|
||||
/* 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; }
|
||||
#ovframe.on { opacity: 1; }
|
||||
#ovframe.info { background: rgba(60, 150, 175, 0.05); box-shadow: inset 0 0 0 2px rgba(var(--ov), 0.5), inset 0 0 110px rgba(var(--ov), 0.16); }
|
||||
#ovframe.lens { --ov: 255, 140, 64; box-shadow: inset 0 0 0 2px rgba(var(--ov), 0.6), inset 0 0 110px rgba(var(--ov), 0.24); }
|
||||
#ovframe.lens.info { background: rgba(60, 150, 175, 0.05); box-shadow: inset 0 0 0 2px rgba(255, 140, 64, 0.6), inset 0 0 0 5px rgba(92, 186, 166, 0.45), inset 0 0 110px rgba(255, 140, 64, 0.2); }
|
||||
#ovframe i { position: absolute; width: 30px; height: 30px; border: 3px solid rgba(var(--ov), 0.9); }
|
||||
#ovframe .c1 { top: 8px; left: 8px; border-right: 0; border-bottom: 0; }
|
||||
#ovframe .c2 { top: 8px; right: 8px; border-left: 0; border-bottom: 0; }
|
||||
#ovframe .c3 { bottom: 8px; left: 8px; border-right: 0; border-top: 0; }
|
||||
#ovframe .c4 { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
|
||||
#hud .bc { bottom: max(14px, env(safe-area-inset-bottom, 0px)); left: 50%; transform: translateX(-50%); align-items: center; }
|
||||
/* the build menu: category tabs, a row of piece tiles, and what the pointed-at piece does */
|
||||
#buildbar { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 6px; width: min(calc(560px * var(--ui)), calc(100vw - 32px)); }
|
||||
#buildbar .bb-head { display: flex; align-items: center; gap: 2px; }
|
||||
#buildbar .bb-tab { font-family: var(--font-mono); font-size: 0.72em; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ash); padding: 4px 9px; border: 1px solid transparent; border-radius: 3px; background: none; cursor: pointer; }
|
||||
#buildbar .bb-tab:hover { color: var(--bone); }
|
||||
#buildbar .bb-tab.on { color: var(--melt-hot); border-color: var(--soot-3); background: var(--soot-2); }
|
||||
#buildbar .bb-keys { margin-left: auto; font-size: 0.72em; color: var(--ash-dim); white-space: nowrap; }
|
||||
#buildbar .bb-tiles { display: flex; gap: 6px; }
|
||||
.bb-tile { position: relative; flex: 1 1 0; min-width: 0; max-width: calc(110px * var(--ui)); display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 5px 4px 4px; border-radius: 4px; background: var(--soot-2); border: 1px solid var(--soot-3); cursor: pointer; color: inherit; }
|
||||
.bb-tile svg { width: calc(26px * var(--ui)); height: calc(26px * var(--ui)); color: var(--ash); }
|
||||
.bb-tile .n { font-size: 0.8em; line-height: 1.15; text-align: center; color: var(--bone); }
|
||||
.bb-tile .c { font-family: var(--font-mono); font-size: 0.7em; color: var(--ash); white-space: nowrap; }
|
||||
.bb-tile .c.bad { color: var(--danger); }
|
||||
.bb-tile .sw { position: absolute; top: 5px; right: 5px; width: 9px; height: 9px; border-radius: 2px; box-shadow: 0 0 0 1px rgba(0,0,0,0.5); }
|
||||
.bb-tile:hover { border-color: var(--soot-4); background: #2a2521; }
|
||||
.bb-tile.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); }
|
||||
.bb-tile.on svg { color: var(--melt-hot); }
|
||||
#buildbar .bb-tip { font-size: 0.82em; line-height: 1.35; color: var(--ash); min-height: 1.35em; }
|
||||
#buildbar .bb-tip b { color: var(--bone); font-weight: 600; }
|
||||
#ovbadges { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.ovbadge { --ov: 92, 186, 166; display: inline-flex; align-items: center; gap: 8px; padding: 5px 8px 5px 11px; border-radius: 999px; background: rgba(12, 13, 16, 0.86); border: 1px solid rgba(var(--ov), 0.75); color: rgb(var(--ov)); font-weight: 650; font-size: 0.8em; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
|
||||
.ovbadge.lens { --ov: 255, 160, 90; }
|
||||
.ovbadge .dot { width: 8px; height: 8px; border-radius: 50%; background: rgb(var(--ov)); box-shadow: 0 0 8px rgb(var(--ov)); animation: ovpulse 1.6s ease-in-out infinite; }
|
||||
.ovbadge .scale { width: 54px; height: 7px; border-radius: 4px; }
|
||||
.ovbadge kbd { text-transform: none; letter-spacing: 0; }
|
||||
@keyframes ovpulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .ovbadge .dot { animation: none; } }
|
||||
button, select, input { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--patina); outline-offset: 2px; }
|
||||
|
||||
/* ---------- plates ---------- */
|
||||
.plate {
|
||||
background: var(--plate); border: 1px solid var(--soot-3); border-radius: 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 240, 220, 0.04), 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
|
||||
}
|
||||
.label { font-family: var(--font-mono); font-size: 0.74em; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ash); }
|
||||
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
kbd {
|
||||
font-family: var(--font-mono); font-size: 0.78em; padding: 0.05em 0.4em; border-radius: 3px;
|
||||
border: 1px solid var(--soot-4); border-bottom-width: 2px; background: var(--soot-2); color: var(--bone);
|
||||
}
|
||||
|
||||
/* ---------- HUD ---------- */
|
||||
#hud { position: fixed; inset: 0; pointer-events: none; padding: max(12px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px)) max(12px, env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px)); }
|
||||
#hud .corner { position: absolute; display: flex; flex-direction: column; gap: 8px; }
|
||||
#hud .tl { top: max(12px, env(safe-area-inset-top, 0px)); left: max(16px, env(safe-area-inset-left, 0px)); }
|
||||
#hud .tr { top: max(12px, env(safe-area-inset-top, 0px)); right: max(16px, env(safe-area-inset-right, 0px)); align-items: flex-end; }
|
||||
#hud .bl { bottom: max(12px, env(safe-area-inset-bottom, 0px)); left: max(16px, env(safe-area-inset-left, 0px)); }
|
||||
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); right: max(16px, env(safe-area-inset-right, 0px)); align-items: flex-end; }
|
||||
#hud .interactive { pointer-events: auto; }
|
||||
|
||||
#hotbar { display: flex; gap: 4px; padding: 5px; }
|
||||
.slot {
|
||||
position: relative; width: calc(46px * var(--ui)); height: calc(46px * var(--ui)); border-radius: 3px;
|
||||
background: var(--soot-2); border: 1px solid var(--soot-3); display: grid; place-items: center; cursor: pointer; padding: 0;
|
||||
}
|
||||
.slot svg { width: 60%; height: 60%; color: var(--ash); }
|
||||
.slot .k { position: absolute; top: 2px; left: 4px; font-family: var(--font-mono); font-size: 0.7em; color: var(--ash-dim); }
|
||||
.slot.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); }
|
||||
.slot.on svg { color: var(--melt-hot); }
|
||||
.slot.locked { opacity: 0.4; }
|
||||
|
||||
#selcard { padding: 8px 10px; min-width: calc(250px * var(--ui)); max-width: calc(300px * var(--ui)); display: flex; flex-direction: column; gap: 5px; }
|
||||
#selcard .row { display: flex; align-items: center; gap: 8px; }
|
||||
#selcard .name { font-weight: 600; font-size: 1.05em; }
|
||||
#selcard .sub { color: var(--ash); font-size: 0.88em; }
|
||||
#selcard .status { color: var(--danger); font-size: 0.88em; min-height: 0; }
|
||||
#selcard .status:empty { display: none; }
|
||||
#selcard .status.info { color: var(--patina); }
|
||||
.sw { width: 14px; height: 14px; border-radius: 2px; border: 1px solid rgba(0, 0, 0, 0.5); flex: none; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); }
|
||||
.cycle { display: flex; align-items: center; gap: 6px; }
|
||||
.cycle .grow { flex: 1; min-width: 0; }
|
||||
.chip { font-family: var(--font-mono); font-size: 0.78em; padding: 1px 6px; border-radius: 3px; background: var(--soot-2); border: 1px solid var(--soot-3); color: var(--ash); white-space: nowrap; }
|
||||
.chip.ok { color: var(--ok); border-color: #3c5a33; }
|
||||
.chip.bad { color: var(--danger); border-color: #6a2e25; }
|
||||
.chip.hot { color: var(--melt-hot); border-color: #6b4220; }
|
||||
|
||||
#bagpanel { width: calc(232px * var(--ui)); padding: 8px 10px; }
|
||||
#wallet { width: calc(232px * var(--ui)); padding: 7px 10px 8px; display: flex; flex-direction: column; align-items: stretch; gap: 1px; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
#wallet:hover { border-color: var(--soot-4); }
|
||||
#wallet .money { font-size: 1.35em; font-weight: 650; color: var(--melt-hot); letter-spacing: 0.01em; }
|
||||
#wallet .line { font-size: 0.86em; color: var(--ash); display: flex; justify-content: space-between; gap: 8px; }
|
||||
#wallet .line b { font-weight: 600; color: var(--bone); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
#wallet .line.paid b { color: var(--ok); }
|
||||
#wallet .orders { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; }
|
||||
#wallet .ord { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 2px 6px; align-items: center; font-size: 0.84em; }
|
||||
#wallet .ord .sw { width: 10px; height: 10px; }
|
||||
#wallet .ord .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bone); }
|
||||
#wallet .ord .tm { font-family: var(--font-mono); color: var(--ash); }
|
||||
#wallet .ord .tm.late { color: var(--danger); }
|
||||
#wallet .ord .pb { grid-column: 2 / 4; height: 3px; background: var(--soot-3); border-radius: 2px; overflow: hidden; }
|
||||
#wallet .ord .pb i { display: block; height: 100%; background: var(--patina); }
|
||||
#wallet .none { font-size: 0.84em; color: var(--ash-dim); }
|
||||
.orderlist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||
.orderlist .o { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; padding: 8px 10px; background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 4px; }
|
||||
.orderlist .o .t { font-weight: 600; }
|
||||
.orderlist .o .pay { font-family: var(--font-mono); color: var(--melt-hot); font-weight: 650; }
|
||||
.orderlist .o .d { color: var(--ash); font-size: 0.88em; }
|
||||
.orderlist .o .pb { grid-column: 1 / 3; height: 4px; background: var(--soot-3); border-radius: 2px; overflow: hidden; }
|
||||
.orderlist .o .pb i { display: block; height: 100%; background: var(--patina); }
|
||||
.mkt-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.mkt-sum > div { background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 4px; padding: 8px 10px; }
|
||||
.mkt-sum .k { color: var(--ash); font-size: 0.82em; }
|
||||
.mkt-sum .v { font-family: var(--font-mono); font-size: 1.15em; font-weight: 650; }
|
||||
.mkt-pay { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; }
|
||||
table.mt td.dip { color: var(--melt); }
|
||||
.shiplog { display: flex; flex-direction: column; gap: 4px; }
|
||||
.shiplog div { display: flex; justify-content: space-between; gap: 10px; padding: 5px 8px; background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 3px; font-size: 0.9em; }
|
||||
.shiplog .what { color: var(--ash); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#bagpanel .head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.bar { height: 5px; background: var(--soot-2); border-radius: 3px; overflow: hidden; margin: 5px 0 6px; border: 1px solid var(--soot-3); }
|
||||
.bar > i { display: block; height: 100%; background: linear-gradient(90deg, var(--patina-dim), var(--patina)); }
|
||||
.bar.full > i { background: linear-gradient(90deg, var(--melt-deep), var(--danger)); }
|
||||
#baglist { display: grid; grid-template-columns: 1fr; gap: 1px; max-height: 42vh; overflow-y: auto; }
|
||||
#baglist button {
|
||||
display: flex; align-items: center; gap: 7px; padding: 2px 4px; border-radius: 3px; background: transparent; border: 1px solid transparent; cursor: pointer; text-align: left;
|
||||
}
|
||||
#baglist button:hover { background: var(--soot-2); }
|
||||
#baglist button.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
|
||||
#baglist .n { margin-left: auto; color: var(--ash); }
|
||||
#baglist .empty { color: var(--ash-dim); font-size: 0.88em; padding: 2px 4px; }
|
||||
#baglist .bagrow { display: flex; align-items: center; gap: 2px; border-radius: 3px; border: 1px solid transparent; }
|
||||
#baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
|
||||
#baglist .bagrow .pick { flex: 1; min-width: 0; }
|
||||
#baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#baglist .tick { flex: none; width: 20px; height: 20px; padding: 0; justify-content: center; }
|
||||
#baglist .tick::before { content: ''; width: 10px; height: 10px; border: 1.5px solid var(--ash-dim); border-radius: 2px; box-sizing: border-box; }
|
||||
#baglist .tick:hover::before { border-color: var(--ash); }
|
||||
#baglist .tick.on::before { border-color: var(--patina); background: var(--patina); box-shadow: inset 0 0 0 1.5px var(--soot-1, #15120f); }
|
||||
#baglist .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; }
|
||||
#bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; }
|
||||
|
||||
#info { padding: 7px 10px; min-width: calc(220px * var(--ui)); max-width: min(360px, 60vw); display: flex; flex-direction: column; gap: 2px; font-size: 0.92em; }
|
||||
#info .t1 { font-weight: 600; }
|
||||
#info .t2 { color: var(--ash); }
|
||||
#info .t3 { color: var(--melt-hot); opacity: 0.85; font-size: 0.94em; }
|
||||
|
||||
#cards { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
|
||||
.card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; }
|
||||
.card-pop .top { display: flex; align-items: center; gap: 6px; }
|
||||
.card-pop .x { margin-left: auto; background: none; border: none; color: var(--ash); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 2px; }
|
||||
.card-pop .x:hover { color: var(--bone); }
|
||||
.card-pop .title { font-weight: 650; color: var(--melt-hot); text-wrap: balance; }
|
||||
.card-pop .p1 { color: var(--bone); font-size: 0.92em; }
|
||||
.card-pop .p2 { color: var(--ash); font-size: 0.88em; }
|
||||
.card-pop.milestone { border-color: var(--melt-deep); }
|
||||
.card-pop .life { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--patina-dim); transform-origin: left; animation: cardLife var(--dur) linear forwards; }
|
||||
.card-pop:hover .life, body.paused .card-pop .life { animation-play-state: paused; }
|
||||
.card-pop.out { opacity: 0; transform: translateX(10px); transition: opacity 0.35s, transform 0.35s; }
|
||||
@keyframes cardLife { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
.linkbtn { background: none; border: none; color: var(--patina); cursor: pointer; padding: 0; font-size: 0.88em; }
|
||||
.linkbtn:hover { text-decoration: underline; }
|
||||
|
||||
#toasts { position: fixed; left: 50%; top: max(14px, env(safe-area-inset-top, 0px)); transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none; width: min(560px, calc(100vw - 32px)); }
|
||||
.toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; }
|
||||
.toast.big { border-color: var(--melt-deep); padding: 10px 16px; }
|
||||
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; }
|
||||
.toast.out { opacity: 0; transition: opacity 0.4s; }
|
||||
@keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
#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) ~ .br { }
|
||||
|
||||
/* ---------- modals ---------- */
|
||||
.modal { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(8, 7, 6, 0.55); padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px)); padding-block: 16px; z-index: 20; }
|
||||
.modal > .plate { width: min(760px, 100%); max-height: min(86vh, 900px); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.modal.small > .plate { width: min(420px, 100%); }
|
||||
.mhead { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 10px; border-bottom: 1px solid var(--soot-3); }
|
||||
.mhead h2 { margin: 0; font-size: 1.15em; letter-spacing: 0.02em; text-wrap: balance; }
|
||||
.mbody { padding: 12px 16px 16px; overflow-y: auto; min-height: 0; }
|
||||
.mfoot { padding: 10px 16px; border-top: 1px solid var(--soot-3); display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.tabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--soot-3); overflow-x: auto; flex: none; }
|
||||
.tabs button { background: none; border: none; border-bottom: 2px solid transparent; color: var(--ash); padding: 8px 10px; cursor: pointer; white-space: nowrap; }
|
||||
.tabs button.on { color: var(--bone); border-bottom-color: var(--melt); }
|
||||
.btn {
|
||||
background: var(--soot-2); border: 1px solid var(--soot-4); border-radius: 4px; padding: 7px 14px; cursor: pointer; color: var(--bone);
|
||||
}
|
||||
.btn:hover { background: #2d2724; }
|
||||
.btn.primary { background: linear-gradient(180deg, #e2832f, #b85520); border-color: #e59a52; color: #1a0f08; font-weight: 650; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.danger { border-color: #7a3227; color: #ffb4a6; }
|
||||
.btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.iconbtn { background: none; border: 1px solid transparent; color: var(--ash); cursor: pointer; border-radius: 4px; padding: 4px 8px; }
|
||||
.iconbtn:hover { border-color: var(--soot-3); color: var(--bone); }
|
||||
|
||||
/* settings rows */
|
||||
.set { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 220px); gap: 6px 16px; align-items: center; padding: 7px 0; border-bottom: 1px solid rgba(59, 52, 47, 0.5); }
|
||||
.set:last-child { border-bottom: none; }
|
||||
.set .d { color: var(--ash); font-size: 0.86em; }
|
||||
.set .ctl { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
.set input[type=range] { width: 100%; accent-color: var(--melt); }
|
||||
.set select { background: var(--soot-2); border: 1px solid var(--soot-4); border-radius: 4px; padding: 4px 6px; width: 100%; }
|
||||
.set .v { font-family: var(--font-mono); font-size: 0.85em; color: var(--ash); min-width: 3.5em; text-align: right; }
|
||||
.toggle { position: relative; width: 38px; height: 21px; border-radius: 11px; background: var(--soot-3); border: 1px solid var(--soot-4); cursor: pointer; padding: 0; flex: none; }
|
||||
.toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: var(--ash); transition: transform 0.15s; }
|
||||
.toggle.on { background: var(--patina-dim); border-color: var(--patina); }
|
||||
.toggle.on::after { transform: translateX(17px); background: var(--bone); }
|
||||
.note { color: var(--ash); font-size: 0.88em; margin: 4px 0 10px; max-width: 65ch; }
|
||||
.grouphead { margin: 14px 0 2px; }
|
||||
.grouphead:first-child { margin-top: 0; }
|
||||
|
||||
/* notebook */
|
||||
.ledger { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ms { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 2px 10px; padding: 8px 10px; border: 1px solid var(--soot-3); border-radius: 4px; background: var(--soot-1); }
|
||||
.ms .mark { grid-row: span 2; width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--soot-4); margin-top: 1px; }
|
||||
.ms.done .mark { border-color: var(--ok); background: radial-gradient(circle, var(--ok) 0 4px, transparent 5px); }
|
||||
.ms.next { border-color: var(--melt-deep); background: #221812; }
|
||||
.ms.next .mark { border-color: var(--melt); }
|
||||
.ms .h { font-weight: 600; }
|
||||
.ms .p { color: var(--ash); font-size: 0.9em; }
|
||||
.ms .rw { color: var(--patina); font-size: 0.88em; grid-column: 2; }
|
||||
.ms.open .p .linkbtn { font-size: 1em; }
|
||||
table.mt { width: 100%; border-collapse: collapse; font-size: 0.92em; }
|
||||
table.mt th { text-align: left; font-family: var(--font-mono); font-size: 0.74em; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ash); font-weight: 500; padding: 4px 6px; border-bottom: 1px solid var(--soot-3); }
|
||||
table.mt td { padding: 5px 6px; border-bottom: 1px solid rgba(59, 52, 47, 0.45); vertical-align: top; }
|
||||
table.mt td.num { text-align: right; white-space: nowrap; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 8px; }
|
||||
.card { border: 1px solid var(--soot-3); border-radius: 4px; padding: 8px 10px; background: var(--soot-1); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.card .h { font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }
|
||||
.card .p { color: var(--ash); font-size: 0.88em; }
|
||||
.keys { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 4px 18px; }
|
||||
.keys div { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid rgba(59, 52, 47, 0.45); font-size: 0.92em; }
|
||||
.keys div span:last-child { color: var(--ash); text-align: right; }
|
||||
textarea.code { width: 100%; min-height: 90px; resize: vertical; background: var(--soot-0); color: var(--bone); border: 1px solid var(--soot-4); border-radius: 4px; padding: 8px; font-family: var(--font-mono); font-size: 0.82em; box-sizing: border-box; user-select: text; -webkit-user-select: text; }
|
||||
input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; width: 100%; box-sizing: border-box; user-select: text; -webkit-user-select: text; }
|
||||
|
||||
/* ---------- title ---------- */
|
||||
#title { position: fixed; inset: 0; z-index: 10; display: grid; grid-template-rows: 1fr auto; overflow-y: auto; padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px)); padding-block: 0 16px; background: radial-gradient(ellipse at 50% 120%, #3a1d0c 0%, #140e0b 45%, #0b0908 100%); }
|
||||
#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; }
|
||||
.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; }
|
||||
.tmenu .btn.primary small { color: #3a1d0a; }
|
||||
.newworld { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 10px; }
|
||||
.newworld label { display: flex; flex-direction: column; gap: 4px; font-size: 0.86em; color: var(--ash); }
|
||||
.newworld select, .newworld input { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; color: var(--bone); }
|
||||
.newworld .full { grid-column: 1 / -1; }
|
||||
.tfoot { text-align: center; color: var(--ash-dim); font-size: 0.82em; font-family: var(--font-mono); }
|
||||
#loading { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(11, 9, 8, 0.86); }
|
||||
#loading .plate { padding: 16px 20px; min-width: 260px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.touchnote { color: var(--melt-hot); font-size: 0.88em; text-align: center; max-width: 46ch; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.card-pop { width: min(290px, calc(100vw - 32px)); }
|
||||
#bagpanel, #wallet { width: calc(190px * var(--ui)); }
|
||||
#info { display: none; }
|
||||
.set { grid-template-columns: minmax(0, 1fr); }
|
||||
.set .ctl { justify-content: flex-start; }
|
||||
.keys { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
#selcard { min-width: 0; max-width: calc(100vw - 32px - 200px); }
|
||||
.slot { width: 38px; height: 38px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast { animation: none; }
|
||||
}
|
||||
/* filter picker */
|
||||
.fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; }
|
||||
.fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.fpick .fp:hover { background: #2d2724; }
|
||||
.fpick .fp.on { border-color: #e59a52; background: #33261c; }
|
||||
.fpick .fp:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
|
||||
/* filter picker: mode toggle and checkboxes */
|
||||
.fmode { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
|
||||
.fmode .fm { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; cursor: pointer; }
|
||||
.fmode .fm b { font-weight: 650; font-size: 13px; }
|
||||
.fmode .fm span { font-size: 12px; color: var(--ash); }
|
||||
.fmode .fm:hover { background: #2d2724; }
|
||||
.fmode .fm.on { border-color: #e59a52; background: #33261c; }
|
||||
.fmode .fm:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
|
||||
.fpick .fp .ck { width: 13px; height: 13px; border-radius: 3px; border: 1px solid var(--soot-4); background: var(--soot-1, #171412); flex: none; position: relative; }
|
||||
.fpick .fp.on .ck { background: #e59a52; border-color: #e59a52; }
|
||||
.fpick .fp.on .ck::after { content: ''; position: absolute; left: 3.5px; top: 0.5px; width: 3px; height: 7px; border: solid #1a0f08; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
.fp-sum { margin: 10px 0 0; min-height: 1.4em; }
|
||||
@media (max-width: 520px) { .fmode { grid-template-columns: 1fr; } }
|
||||
.fdir { display: flex; align-items: center; gap: 6px; margin: -2px 0 10px; }
|
||||
.fdir .label { margin-right: 4px; }
|
||||
.fdir .fd { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.fdir .fd:hover { background: #2d2724; }
|
||||
.fdir .fd.on { border-color: #e59a52; background: #33261c; }
|
||||
.fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
|
||||
|
||||
/* the ticked-mix choice at the bottom of the bag */
|
||||
#baglist .mixrow { margin-top: 3px; border-top: 1px solid var(--soot-3); padding-top: 3px; border-radius: 0 0 3px 3px; }
|
||||
#baglist .mixrow .pick { gap: 6px; }
|
||||
#baglist .mixsw { display: flex; gap: 1px; flex: none; }
|
||||
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; }
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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
|
||||
let ovShown = '';
|
||||
function updateOverlayUI() {
|
||||
const on = (GAME.running && R.lens ? 'lens ' : '') + (GAME.running && GAME.infoOverlay ? 'info' : '');
|
||||
if (on + SET.thermalPalette === ovShown) return;
|
||||
ovShown = on + SET.thermalPalette;
|
||||
const f = $('ovframe'), b = $('ovbadges');
|
||||
if (!f || !b) return;
|
||||
f.className = on ? 'on ' + on : '';
|
||||
let h = '';
|
||||
if (R.lens) {
|
||||
// a little key of the lens colours, from freezing to white hot
|
||||
const tl = THERM[SET.thermalPalette] || THERM.inferno, stops = [0, 20, 150, 700, 1600].map(T => { const j = thermIdx(T); return `rgb(${tl[j]},${tl[j + 1]},${tl[j + 2]})`; });
|
||||
h += `<span class="ovbadge lens"><span class="dot"></span>Thermal lens<span class="scale" style="background:linear-gradient(90deg,${stops.join(',')})" title="0°C to 1600°C"></span><kbd>H</kbd></span>`;
|
||||
}
|
||||
if (GAME.infoOverlay) h += `<span class="ovbadge info"><span class="dot"></span>Info overlay<kbd>I</kbd></span>`;
|
||||
b.innerHTML = h;
|
||||
}
|
||||
function toggleLens() {
|
||||
if (!player.lens) { setStatus('The thermal lens unlocks at your first fire.'); playSfx('error'); return; }
|
||||
R.lens = !R.lens; playSfx('ui');
|
||||
}
|
||||
function doInteract() {
|
||||
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
const fb = filterBlockAt(ax, ay);
|
||||
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 => {
|
||||
applyFilterRun(run, f);
|
||||
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
|
||||
setStatus(`${run.length === 1 ? 'This filter' : `These ${run.length} filters`} now ${filterSentence(f)}, ${f.dir ? 'right' : 'left'}.`, 'info');
|
||||
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
|
||||
GAME.editRun = run;
|
||||
return;
|
||||
}
|
||||
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
|
||||
let msg = '';
|
||||
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;
|
||||
msg = interactAt(ax + dx, ay + dy);
|
||||
if (msg) break outer;
|
||||
}
|
||||
if (msg) { setStatus(msg, 'info'); if (!/^New (drill bit|bag)/.test(msg)) toast(msg); }
|
||||
else { setStatus('Nothing to use there. F works on gates, molds, fans and filters.', 'info'); }
|
||||
}
|
||||
// Pick (G or middle mouse): the piece under the cursor becomes the one you build, with its
|
||||
// direction, wall material and filter settings. On loose material it picks that to blow out.
|
||||
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, note = '';
|
||||
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; note = MD[WALL_OF[mo.wall]].name.toLowerCase(); }
|
||||
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; note = MD[WALL_OF[v]].name.toLowerCase(); }
|
||||
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; note = filterSentence(GAME.filterCfg); }
|
||||
else if (P.dirs) GAME.pieceDir[P.id] = v & 3;
|
||||
if (P.dirs && !P.filter) note = P.dirs[GAME.pieceDir[P.id] || 0];
|
||||
else if (P.filter) note = `${P.dirs[GAME.pieceDir[P.id] || 0]}, ${note}`;
|
||||
}
|
||||
if (pid) {
|
||||
const P = PIECE_BY_ID[pid];
|
||||
if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; }
|
||||
GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui');
|
||||
setStatus(`Picked: ${P.name}${note ? ` (${note})` : ''}.`, 'info');
|
||||
return;
|
||||
}
|
||||
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
|
||||
if (t && bag[t] > 0) { GAME.dropMat = t; GAME.matChosen = true; GAME.blowMix = false; selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
|
||||
else setStatus('Nothing built here to pick.', 'info');
|
||||
}
|
||||
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
|
||||
function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx - ((bw - 1) >> 1), by: by - ((bh - 1) >> 1) }; }
|
||||
|
||||
// Copy and cut: hold C or X and drag a box with the left button; letting go does it.
|
||||
function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; }
|
||||
function startPaste() {
|
||||
if (!GAME.clip) { setStatus('Nothing copied yet. Hold C and drag a box over something you built.', 'info'); playSfx('error'); return; }
|
||||
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
|
||||
}
|
||||
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
|
||||
function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
|
||||
function piecesIn(rec, tag) { return rec.ops.filter(o => o.t === tag).length; }
|
||||
function doUndo() {
|
||||
GAME.drag = null; GAME.selDrag = null;
|
||||
const msg = undoLast();
|
||||
setStatus(msg, /could not|Nothing/.test(msg) ? 'err' : 'info');
|
||||
playSfx(/Nothing/.test(msg) ? 'error' : 'decon'); hudDirty = true;
|
||||
}
|
||||
// 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
|
||||
function dragDirFor(P, a, b) {
|
||||
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || 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);
|
||||
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
|
||||
}
|
||||
function blockLabel(P, rec) {
|
||||
const put = piecesIn(rec, 'block+'), took = piecesIn(rec, 'block-') + piecesIn(rec, 'filter');
|
||||
return took ? `replacing ${plural(took, 'piece')}` : `placing ${plural(put, P.name.toLowerCase())}`;
|
||||
}
|
||||
|
||||
// press / release actions (one-shot)
|
||||
function primaryPress(shift) {
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
const sel = selModeHeld();
|
||||
if (sel) { GAME.selDrag = { x: ax, y: ay, mode: sel }; GAME.drag = null; hudDirty = true; return; }
|
||||
if (GAME.paste && GAME.clip) {
|
||||
const { bx, by } = stampOrigin(GAME.clip.bw, GAME.clip.bh);
|
||||
undoRecord('pasting a blueprint', () => pasteGhost(GAME.clip, bx, by)); playSfx('place'); hudDirty = true;
|
||||
return;
|
||||
}
|
||||
if (t === 'vacuum') {
|
||||
// with anything ticked, the vacuum takes only the ticked materials, wherever you start;
|
||||
// with nothing ticked, it takes what you start on
|
||||
const g = GAME.collect.size ? 0 : grainAt(ax, ay);
|
||||
GAME.vacFilter = shift ? -1 : g;
|
||||
GAME.vacLocked = shift || !!g || GAME.collect.size > 0;
|
||||
hudDirty = true;
|
||||
} else if (t === 'build') {
|
||||
const P = piece();
|
||||
if (P.kind === 'block') { GAME.drag = aimBlock(); }
|
||||
else {
|
||||
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
|
||||
const { bx, by } = stampOrigin(bw, bh);
|
||||
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
|
||||
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 = '';
|
||||
undoRecord(rec => piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-') ? `replacing with a ${P.name.toLowerCase()}` : `placing a ${P.name.toLowerCase()}`, () => {
|
||||
err = check() || '';
|
||||
// Ctrl: take down what you built in the way (refunded) and put this in its place
|
||||
if (err && GAME.ctrl && /already/.test(err)) {
|
||||
err = footprintBlocker(bx, by, bw, bh);
|
||||
if (!err && !canAfford(pieceCostNow(P), 1)) err = needText(pieceCostNow(P), 1);
|
||||
if (!err) err = clearFootprint(bx, by, bw, bh);
|
||||
}
|
||||
if (!err) err = place();
|
||||
});
|
||||
if (err) { setStatus(err + (/already/.test(err) ? `${/[.!]$/.test(err) ? '' : '.'} Hold Ctrl to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
function primaryRelease(shift) {
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
if (GAME.selDrag) {
|
||||
const sd = GAME.selDrag; GAME.selDrag = null; hudDirty = true;
|
||||
if (sd.mode === 'cut') {
|
||||
let bp = null;
|
||||
undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); });
|
||||
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks into your bag. Click to paste it somewhere; Ctrl+Z puts it back.`, 'info'); }
|
||||
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
|
||||
} else {
|
||||
const bp = copyRegion(sd.x, sd.y, ax, ay);
|
||||
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks. Click to place it.`, 'info'); }
|
||||
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
|
||||
}
|
||||
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);
|
||||
// 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.ctrl);
|
||||
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 (ghosts) undoNote(g);
|
||||
return res;
|
||||
});
|
||||
const hint = /already built/.test(r.status) ? `${/[.!]$/.test(r.status) ? '' : '.'} Hold Ctrl to replace it.` : '';
|
||||
const gtxt = ghosts ? `${r.n ? `Placed ${plural(r.n, P.name.toLowerCase())}; ` : ''}${plural(ghosts, 'more')} out of reach ${ghosts === 1 ? 'is a ghost' : 'are ghosts'} your bot builds when it gets close.` : '';
|
||||
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
|
||||
GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
|
||||
}
|
||||
if (t === 'vacuum') { GAME.vacFilter = 0; GAME.vacLocked = false; hudDirty = true; }
|
||||
}
|
||||
function secondaryPress() {
|
||||
const t = toolId();
|
||||
GAME.rmbSeen = new Set();
|
||||
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 { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
|
||||
}
|
||||
|
||||
// continuous actions, once per simulation tick
|
||||
function toolTick() {
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
if (GAME.lmb && !GAME.selDrag && !GAME.paste && !selModeHeld()) {
|
||||
if (t === 'drill') {
|
||||
const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune);
|
||||
if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); }
|
||||
} else if (t === 'vacuum') {
|
||||
// started on empty space: the first loose thing you sweep over (that is not ticked) becomes the filter
|
||||
// clicked on empty space: the first loose thing that comes under the cursor sets the filter, once
|
||||
if (!GAME.vacLocked && !GAME.collect.size) { const g = grainAt(ax, ay); if (g) { GAME.vacFilter = g; GAME.vacLocked = true; hudDirty = true; } }
|
||||
const f = GAME.vacFilter, list = GAME.collect;
|
||||
if (f || list.size) {
|
||||
const r = toolVacuum(ax, ay, GAME.brush[1], f < 0 ? () => true : list.size ? m => list.has(m) : m => m === f);
|
||||
if (r.got) { playSfx('suck'); hudDirty = true; }
|
||||
if (r.status) setStatus(r.status);
|
||||
}
|
||||
} else if (t === 'ignite') {
|
||||
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Nothing here burns. Aim at coal or wood that touches air.'); }
|
||||
else setStatus('Out of reach.');
|
||||
}
|
||||
}
|
||||
if (GAME.rmb && !GAME.rmbConsumed) {
|
||||
if (t === 'vacuum') {
|
||||
ensureDropMat();
|
||||
// what right click blows is only ever what is selected (and shown by the cursor): one material, or the ticked mix
|
||||
const mix = GAME.blowMix && GAME.collect.size ? [...GAME.collect] : null;
|
||||
const r = toolBlow(ax, ay, GAME.brush[1], GAME.dropMat, mix);
|
||||
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)) {
|
||||
GAME.rmbSeen.add(key);
|
||||
const keep = undoRec; undoRec = GAME.rmbRec;
|
||||
const msg = removePieceAt(ax, ay);
|
||||
undoRec = keep;
|
||||
if (msg) { playSfx('decon'); setStatus(msg, 'info'); hudDirty = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/* =====================================================================
|
||||
Audio: chiptune effects and generative music that changes with depth.
|
||||
Everything is synthesized with WebAudio, no samples.
|
||||
===================================================================== */
|
||||
|
||||
const AU = {
|
||||
ctx: null, master: null, music: null, sfx: null, amb: null, noise: null, pulse12: null, pulse25: null,
|
||||
last: {}, started: false,
|
||||
zone: 0, nextNote: 0, step: 0, bar: 0, chordIdx: 0, timer: 0, melody: null, melPos: 0, lastDeg: 0,
|
||||
hum: null, humGain: null, crackleT: 0, wind: null, windGain: null,
|
||||
};
|
||||
|
||||
function auInit() {
|
||||
if (AU.ctx) { if (AU.ctx.state === 'suspended') AU.ctx.resume(); return; }
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return;
|
||||
const ctx = new AC();
|
||||
AU.ctx = ctx;
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -16; comp.ratio.value = 4;
|
||||
comp.connect(ctx.destination);
|
||||
AU.master = ctx.createGain(); AU.master.connect(comp);
|
||||
AU.music = ctx.createGain(); AU.music.connect(AU.master);
|
||||
AU.sfx = ctx.createGain(); AU.sfx.connect(AU.master);
|
||||
AU.amb = ctx.createGain(); AU.amb.connect(AU.sfx);
|
||||
// white noise buffer
|
||||
const len = ctx.sampleRate * 2, buf = ctx.createBuffer(1, len, ctx.sampleRate), d = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
AU.noise = buf;
|
||||
const pulse = duty => {
|
||||
const n = 64, re = new Float32Array(n), im = new Float32Array(n);
|
||||
for (let k = 1; k < n; k++) { re[k] = 0; im[k] = (2 / (k * Math.PI)) * Math.sin(k * Math.PI * duty); }
|
||||
return ctx.createPeriodicWave(re, im);
|
||||
};
|
||||
AU.pulse12 = pulse(0.125); AU.pulse25 = pulse(0.25);
|
||||
// machine hum
|
||||
AU.hum = ctx.createOscillator(); AU.hum.setPeriodicWave(AU.pulse25); AU.hum.frequency.value = 55;
|
||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320;
|
||||
AU.humGain = ctx.createGain(); AU.humGain.gain.value = 0;
|
||||
AU.hum.connect(lp); lp.connect(AU.humGain); AU.humGain.connect(AU.amb); AU.hum.start();
|
||||
// wind / surface air
|
||||
const ws = ctx.createBufferSource(); ws.buffer = buf; ws.loop = true;
|
||||
const wf = ctx.createBiquadFilter(); wf.type = 'bandpass'; wf.frequency.value = 500; wf.Q.value = 0.7;
|
||||
AU.windGain = ctx.createGain(); AU.windGain.gain.value = 0;
|
||||
ws.connect(wf); wf.connect(AU.windGain); AU.windGain.connect(AU.amb); ws.start();
|
||||
AU.windFilter = wf;
|
||||
auVolumes();
|
||||
AU.nextNote = ctx.currentTime + 0.3;
|
||||
AU.timer = setInterval(musicScheduler, 60);
|
||||
AU.started = true;
|
||||
}
|
||||
function auVolumes() {
|
||||
if (!AU.ctx) return;
|
||||
const m = SET.muted ? 0 : SET.volMaster;
|
||||
AU.master.gain.setTargetAtTime(m, AU.ctx.currentTime, 0.05);
|
||||
AU.music.gain.setTargetAtTime(SET.volMusic * 0.55, AU.ctx.currentTime, 0.1);
|
||||
AU.sfx.gain.setTargetAtTime(SET.volSfx, AU.ctx.currentTime, 0.05);
|
||||
}
|
||||
function rateOk(key, ms) {
|
||||
const now = performance.now();
|
||||
if (AU.last[key] && now - AU.last[key] < ms) return false;
|
||||
AU.last[key] = now; return true;
|
||||
}
|
||||
function tone(freq, dur, opts = {}) {
|
||||
const ctx = AU.ctx; if (!ctx) return;
|
||||
const t = (opts.at || ctx.currentTime) + 0.005;
|
||||
const o = ctx.createOscillator();
|
||||
if (opts.wave === 'p12') o.setPeriodicWave(AU.pulse12); else if (opts.wave === 'p25') o.setPeriodicWave(AU.pulse25); else o.type = opts.wave || 'square';
|
||||
o.frequency.setValueAtTime(freq, t);
|
||||
if (opts.to) o.frequency.exponentialRampToValueAtTime(opts.to, t + dur);
|
||||
if (opts.vib) { const l = ctx.createOscillator(), lg = ctx.createGain(); l.frequency.value = 5.5; lg.gain.value = freq * 0.012; l.connect(lg); lg.connect(o.frequency); l.start(t); l.stop(t + dur + 0.05); }
|
||||
const g = ctx.createGain();
|
||||
const v = opts.vol || 0.1, a = opts.attack || 0.005, rel = opts.release || 0.04;
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(v, t + a);
|
||||
g.gain.setValueAtTime(v, t + Math.max(a, dur - rel));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o.connect(g); g.connect(opts.bus || AU.sfx);
|
||||
o.start(t); o.stop(t + dur + 0.02);
|
||||
}
|
||||
function noiseBurst(dur, opts = {}) {
|
||||
const ctx = AU.ctx; if (!ctx) return;
|
||||
const t = (opts.at || ctx.currentTime) + 0.005;
|
||||
const s = ctx.createBufferSource(); s.buffer = AU.noise;
|
||||
s.playbackRate.value = opts.rate || 1;
|
||||
const f = ctx.createBiquadFilter(); f.type = opts.type || 'bandpass'; f.frequency.value = opts.freq || 1200; f.Q.value = opts.q || 1;
|
||||
if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur);
|
||||
const g = ctx.createGain(); const v = opts.vol || 0.08;
|
||||
g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
s.connect(f); f.connect(g); g.connect(opts.bus || AU.sfx);
|
||||
s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02);
|
||||
}
|
||||
const SFX = {
|
||||
dig(h) { if (!rateOk('dig', 75)) return; noiseBurst(0.06, { freq: 260 + h * 260, q: 1.6, vol: 0.09 }); if (h >= 3) tone(120 + h * 40, 0.04, { wave: 'p12', vol: 0.03 }); },
|
||||
// a resonance pulse: a low thrum, higher and tighter for a narrow pulse
|
||||
pulse(w) { if (!rateOk('pulse', 70)) return; const f = [150, 110, 82][w | 0] || 110; tone(f, 0.16, { wave: 'sine', to: f * 0.72, vol: 0.09, release: 0.12 }); tone(f * 3.02, 0.07, { wave: 'triangle', vol: 0.012, release: 0.06 }); },
|
||||
place() { if (!rateOk('place', 70)) return; tone(520, 0.045, { wave: 'p25', to: 660, vol: 0.05 }); },
|
||||
build() { if (!rateOk('build', 90)) return; tone(440, 0.05, { wave: 'p25', to: 520, vol: 0.035 }); },
|
||||
decon() { if (!rateOk('decon', 80)) return; tone(520, 0.06, { wave: 'p25', to: 300, vol: 0.05 }); },
|
||||
spark() { if (!rateOk('spark', 90)) return; noiseBurst(0.05, { type: 'highpass', freq: 3000, vol: 0.06 }); },
|
||||
ignite() { if (!rateOk('ignite', 400)) return; noiseBurst(0.5, { type: 'lowpass', freq: 400, sweep: 2400, vol: 0.12 }); },
|
||||
sizzle() { if (!rateOk('sizzle', 220)) return; noiseBurst(0.22, { type: 'highpass', freq: 4500, vol: 0.05 }); },
|
||||
ui() { if (!rateOk('ui', 40)) return; tone(1100, 0.025, { wave: 'p25', vol: 0.035 }); },
|
||||
error() { if (!rateOk('err', 300)) return; tone(110, 0.14, { wave: 'p12', vol: 0.07 }); tone(98, 0.14, { wave: 'p12', vol: 0.05, at: AU.ctx && AU.ctx.currentTime + 0.07 }); },
|
||||
gate() { noiseBurst(0.12, { type: 'lowpass', freq: 600, vol: 0.12 }); tone(90, 0.12, { wave: 'triangle', to: 60, vol: 0.12 }); },
|
||||
cast() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [523, 659, 784, 1047].forEach((f, k) => tone(f, 0.12, { wave: 'p25', vol: 0.06, at: t + k * 0.08 })); },
|
||||
upgrade() {
|
||||
if (!AU.ctx) return; const t = AU.ctx.currentTime;
|
||||
[392, 523, 659, 784, 659, 784, 1047].forEach((f, k) => tone(f, k === 6 ? 0.5 : 0.11, { wave: k % 2 ? 'p25' : 'p12', vol: 0.07, at: t + k * 0.09, vib: k === 6 }));
|
||||
[196, 262, 330].forEach((f, k) => tone(f, 0.35, { wave: 'triangle', vol: 0.08, at: t + 0.27 + k * 0.18 }));
|
||||
},
|
||||
milestone() {
|
||||
if (!AU.ctx) return; const t = AU.ctx.currentTime;
|
||||
[659, 784, 988, 1319].forEach((f, k) => tone(f, k === 3 ? 0.35 : 0.1, { wave: 'p25', vol: 0.06, at: t + k * 0.07, vib: k === 3 }));
|
||||
},
|
||||
suck() { if (!rateOk('suck', 110)) return; noiseBurst(0.1, { type: 'bandpass', freq: 900, sweep: 2600, q: 2.2, vol: 0.045 }); },
|
||||
blow() { if (!rateOk('blow', 110)) return; noiseBurst(0.12, { type: 'bandpass', freq: 2200, sweep: 700, q: 1.6, vol: 0.05 }); },
|
||||
sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); },
|
||||
discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); },
|
||||
};
|
||||
function playSfx(name, arg) { if (!AU.ctx || !SFX[name]) return; try { SFX[name](arg); } catch (e) { /* ignore */ } }
|
||||
|
||||
// Ambient layer driven by what is on screen.
|
||||
function auAmbient(info) {
|
||||
if (!AU.ctx) return;
|
||||
const t = AU.ctx.currentTime;
|
||||
AU.humGain.gain.setTargetAtTime(Math.min(0.05, info.power * 0.004), t, 0.4);
|
||||
AU.hum.frequency.setTargetAtTime(48 + Math.min(30, info.power * 0.6), t, 0.6);
|
||||
// wind on the surface; rain turns it into a hiss
|
||||
const rain = info.rain || 0;
|
||||
AU.windGain.gain.setTargetAtTime(info.surface ? 0.018 + 0.01 * Math.sin(t * 0.3) + rain * 0.05 : 0.004 + rain * 0.008, t, 1.2);
|
||||
AU.windFilter.frequency.setTargetAtTime(info.surface ? 420 + 160 * Math.sin(t * 0.21) + rain * 1900 : 260 + rain * 400, t, 1.5);
|
||||
// fire crackle
|
||||
if (info.fire > 0 && performance.now() > AU.crackleT) {
|
||||
const n = Math.min(4, 1 + (info.fire / 60) | 0);
|
||||
for (let k = 0; k < n; k++) noiseBurst(0.02 + Math.random() * 0.03, { type: 'bandpass', freq: 900 + Math.random() * 2200, q: 2, vol: 0.02 + Math.min(0.05, info.fire * 0.0004), at: t + Math.random() * 0.15, bus: AU.amb });
|
||||
AU.crackleT = performance.now() + 120 + Math.random() * Math.max(60, 700 - info.fire * 4);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Generative music ----------
|
||||
const ZONES = [
|
||||
{ name: 'surface', root: 60, scale: [0, 2, 4, 5, 7, 9, 11], prog: [0, 5, 3, 4], tempo: 92, density: 0.62, hat: 0.5, lead: 0.55, oct: 0 },
|
||||
{ name: 'shallows', root: 57, scale: [0, 2, 3, 5, 7, 9, 10], prog: [0, 3, 6, 0], tempo: 84, density: 0.5, hat: 0.3, lead: 0.45, oct: 0 },
|
||||
{ name: 'caverns', root: 62, scale: [0, 2, 3, 5, 7, 8, 10], prog: [0, 5, 6, 4], tempo: 76, density: 0.4, hat: 0.18, lead: 0.35, oct: -12 },
|
||||
{ name: 'deep', root: 52, scale: [0, 1, 3, 5, 7, 8, 10], prog: [0, 1, 0, 6], tempo: 66, density: 0.3, hat: 0.08, lead: 0.25, oct: -12 },
|
||||
{ name: 'magma', root: 49, scale: [0, 1, 4, 5, 7, 8, 10], prog: [0, 1, 0, 1], tempo: 60, density: 0.24, hat: 0.04, lead: 0.2, oct: -12 },
|
||||
];
|
||||
function mtof(m) { return 440 * Math.pow(2, (m - 69) / 12); }
|
||||
function zoneForDepth() {
|
||||
const cx = Math.max(0, Math.min(W - 1, Math.floor(bot.x)));
|
||||
const y = bot.y;
|
||||
if (y < surfY[cx] + 12) return 0;
|
||||
const u = depthU(y);
|
||||
if (u < 0.3) return 1;
|
||||
if (y < graniteY[cx]) return 2;
|
||||
if (u < 0.86) return 3;
|
||||
return 4;
|
||||
}
|
||||
function scaleNote(z, deg, octave) {
|
||||
const n = z.scale.length;
|
||||
const o = Math.floor(deg / n), d = ((deg % n) + n) % n;
|
||||
return z.root + z.scale[d] + 12 * (o + octave) + z.oct;
|
||||
}
|
||||
function musicScheduler() {
|
||||
const ctx = AU.ctx;
|
||||
if (!ctx || !AU.started || SET.volMusic <= 0 || (window.GAME && !GAME.running)) { if (ctx) AU.nextNote = Math.max(AU.nextNote, ctx.currentTime + 0.1); return; }
|
||||
const z = ZONES[AU.zone];
|
||||
const spb = 60 / z.tempo / 4; // seconds per 16th
|
||||
while (AU.nextNote < ctx.currentTime + 0.25) {
|
||||
const s = AU.step % 16, t = AU.nextNote;
|
||||
if (s === 0) {
|
||||
// new bar: maybe change zone, advance chord
|
||||
const nz = W ? zoneForDepth() : 0;
|
||||
if (nz !== AU.zone) { AU.zone = nz; }
|
||||
AU.chordIdx = (AU.chordIdx + 1) % 4;
|
||||
if (Math.random() < ZONES[AU.zone].lead * 0.5 && !AU.melody) {
|
||||
const len = 4 + ((Math.random() * 5) | 0), mel = [];
|
||||
let d = AU.lastDeg;
|
||||
for (let k = 0; k < len; k++) { d += [-2, -1, -1, 1, 1, 2, 0][(Math.random() * 7) | 0]; d = Math.max(-2, Math.min(9, d)); mel.push({ d, at: k * (Math.random() < 0.6 ? 2 : 4) }); }
|
||||
AU.melody = mel; AU.melPos = AU.step; AU.lastDeg = d;
|
||||
}
|
||||
}
|
||||
const zz = ZONES[AU.zone];
|
||||
const chordRoot = zz.prog[AU.chordIdx];
|
||||
// bass
|
||||
if (s === 0 || (s === 8 && Math.random() < 0.6)) {
|
||||
const deg = s === 8 && Math.random() < 0.5 ? chordRoot + 4 : chordRoot;
|
||||
tone(mtof(scaleNote(zz, deg, -1)), spb * (s === 0 ? 7 : 5), { wave: 'p12', vol: 0.07, bus: AU.music, release: 0.15, at: t });
|
||||
}
|
||||
// arpeggio
|
||||
if (s % 2 === 0 && Math.random() < zz.density) {
|
||||
const pat = [0, 2, 4, 2, 0, 4, 2, 4][(s >> 1) & 7] + (Math.random() < 0.15 ? 7 : 0);
|
||||
tone(mtof(scaleNote(zz, chordRoot + pat, 1)), spb * 1.6, { wave: 'triangle', vol: 0.05, bus: AU.music, release: 0.06, at: t });
|
||||
}
|
||||
// melody phrase
|
||||
if (AU.melody) {
|
||||
const rel = AU.step - AU.melPos;
|
||||
for (const n of AU.melody) if (n.at === rel) tone(mtof(scaleNote(zz, n.d, 1)), spb * 2.6, { wave: 'p25', vol: 0.035, bus: AU.music, vib: true, attack: 0.02, release: 0.1, at: t });
|
||||
if (rel > 40) AU.melody = null;
|
||||
}
|
||||
// hats
|
||||
if ((s % 4 === 2) && Math.random() < zz.hat) noiseBurst(0.03, { type: 'highpass', freq: 6000, vol: 0.025, at: t, bus: AU.music });
|
||||
if (s === 0 && AU.zone <= 1 && Math.random() < 0.7) tone(110, 0.12, { wave: 'triangle', to: 50, vol: 0.06, at: t, bus: AU.music });
|
||||
if (AU.zone >= 3 && s === 0 && AU.chordIdx % 2 === 0) tone(mtof(scaleNote(zz, chordRoot, -2)), spb * 30, { wave: 'p12', vol: 0.03, at: t, bus: AU.music, attack: 0.6, release: 1.2 });
|
||||
AU.nextNote += spb;
|
||||
AU.step++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// The build menu along the bottom: categories, piece tiles and their icons.
|
||||
// ---------- Build menu ----------
|
||||
const BUILD_CATS = [
|
||||
['Structure', ['wall', 'grate', 'gate']],
|
||||
['Moving', ['belt', 'filter', 'tube']],
|
||||
['Liquids', ['pipe', 'pump', 'outlet']],
|
||||
['Machines', ['fan', 'refiner', 'cutter', 'silo']],
|
||||
['Molds', ['ingotmold', 'bitmold', 'tankmold']],
|
||||
];
|
||||
const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]);
|
||||
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"/>',
|
||||
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"/>',
|
||||
fan: '<circle cx="12" cy="12" r="1.8"/><path d="M12 10.2c-.4-3.6 1.8-6 4-4.8 2 1.2.4 3.8-4 4.8zM13.8 12c3.6-.4 6 1.8 4.8 4-1.2 2-3.8.4-4.8-4zM12 13.8c.4 3.6-1.8 6-4 4.8-2-1.2-.4-3.8 4-4.8zM10.2 12c-3.6.4-6-1.8-4.8-4 1.2-2 3.8-.4 4.8 4z"/>',
|
||||
refiner: '<path d="M3 4h18l-5 7H8z"/><circle cx="9" cy="15.5" r="2.8"/><circle cx="15" cy="15.5" r="2.8"/><path d="M12 20v2"/>',
|
||||
cutter: '<path d="M5 3h14l-3 5H8z"/><circle cx="12" cy="15" r="5"/><circle cx="12" cy="15" r="1.2"/><path d="M12 9.2v1.6M17.8 15h-1.6M12 20.8v-1.6M6.2 15h1.6"/>',
|
||||
silo: '<path d="M6 5.5h12v11H6z"/><path d="M6 5.5l1-2h10l1 2M6 16.5l4 3h4l4-3M7 16.5v5M17 16.5v5"/><path d="M15.5 8v6"/>',
|
||||
ingotmold: '<path d="M3 9h18l-2 7H5z"/><path d="M9.2 9l.8 7M14.8 9l-.8 7"/>',
|
||||
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 catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; }
|
||||
let lastBuildKey = '';
|
||||
function renderBuildBar() {
|
||||
const bar = $('buildbar'); if (!bar) return;
|
||||
const show = GAME.running && toolId() === 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld();
|
||||
if (bar.hidden === show) bar.hidden = !show;
|
||||
if (!show) { lastBuildKey = ''; return; }
|
||||
const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover;
|
||||
const ids = BUILD_CATS[cat][1];
|
||||
const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join('');
|
||||
const key = [cat, P.id, hov, afford, GAME.wallMat, Math.round(money)].join('|');
|
||||
if (key === lastBuildKey) return;
|
||||
lastBuildKey = key;
|
||||
const tip = PIECE_BY_ID[hov && ids.includes(hov) ? hov : P.id];
|
||||
let h = '<div class="bb-head">' + BUILD_CATS.map(([name], k) => `<button class="bb-tab${k === cat ? ' on' : ''}" data-cat="${k}">${esc(name)}</button>`).join('')
|
||||
+ '<span class="bb-keys"><kbd>Q</kbd>/<kbd>E</kbd> cycle · <kbd>G</kbd> pick</span></div><div class="bb-tiles">';
|
||||
for (const id of ids) {
|
||||
const Q = PIECE_BY_ID[id], cost = pieceCostNow(Q), ok = canAfford(cost, 1);
|
||||
const sw = Q.variants === 'wall' ? `<span class="sw" style="background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>` : '';
|
||||
h += `<button class="bb-tile${id === P.id ? ' on' : ''}" data-piece="${id}" aria-pressed="${id === P.id}" aria-label="${esc(Q.name)}">${sw}${pieceIcon(id)}<span class="n">${esc(Q.name)}</span><span class="c${ok ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span></button>`;
|
||||
}
|
||||
// just the first sentence here; the card at the top left has the rest for the chosen piece
|
||||
const first = (tip.desc.match(/^.*?[.!?](\s|$)/) || [tip.desc])[0].trim();
|
||||
h += `</div><div class="bb-tip"><b>${esc(tip.name)}.</b> ${esc(first)}</div>`;
|
||||
bar.innerHTML = h;
|
||||
}
|
||||
function setupBuildBar() {
|
||||
const bar = $('buildbar'); if (!bar) return;
|
||||
bar.addEventListener('click', e => {
|
||||
const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return;
|
||||
if (t.dataset.cat != null) {
|
||||
const c = +t.dataset.cat; GAME.buildCat = c;
|
||||
const ids = BUILD_CATS[c][1], keep = GAME.catPick && GAME.catPick[c];
|
||||
GAME.pieceIdx = PIECE_BY_ID[ids.includes(keep) ? keep : ids[0]].index;
|
||||
} else { GAME.pieceIdx = PIECE_BY_ID[t.dataset.piece].index; GAME.buildCat = catOf(t.dataset.piece); }
|
||||
GAME.catPick = GAME.catPick || {}; GAME.catPick[catOf(piece().id)] = piece().id;
|
||||
GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui');
|
||||
});
|
||||
bar.addEventListener('mouseover', e => { const t = e.target.closest('[data-piece]'); const id = t ? t.dataset.piece : null; if (id !== GAME.bbHover) { GAME.bbHover = id; } });
|
||||
bar.addEventListener('mouseleave', () => { GAME.bbHover = null; });
|
||||
// clicks on the menu never reach the world
|
||||
bar.addEventListener('mousedown', e => e.stopPropagation());
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Events from the simulation (emit) turned into sounds, toasts and cards.
|
||||
// ---------- Events from the simulation ----------
|
||||
function drainEvents() {
|
||||
let n = 0;
|
||||
while (EV.length && n++ < 300) {
|
||||
const e = EV.shift();
|
||||
if (e.t === 'discover') {
|
||||
discoveryCard(e); playSfx('discover');
|
||||
if (!$('m-notebook').hidden) renderNotebook();
|
||||
} else if (e.t === 'milestone') {
|
||||
const m = MILESTONES.find(x => x.id === e.id);
|
||||
if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); }
|
||||
} else if (e.t === 'cast_ready') {
|
||||
const metal = MD[e.metal] ? MD[e.metal].name : 'Metal';
|
||||
const body = e.kind === 'ingot' ? `${e.bars} ${e.bars === 1 ? 'bar' : 'bars'} worth has set. Press F on the mold to knock ${e.bars === 1 ? 'it' : 'them'} out.` : `The ${e.kind} casting has set. Press F on the mold to knock it out.`;
|
||||
showCard({ kind: 'Casting ready', title: `${metal} ${e.kind === 'ingot' ? 'ingot' : e.kind}`, swatch: e.metal > 0 ? e.metal : 0, body: [body], accent: 'milestone' });
|
||||
playSfx('cast');
|
||||
} else if (e.t === 'upgrade') {
|
||||
playSfx('upgrade'); if (SET.shake) R.shake = 1.4;
|
||||
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
|
||||
body: [e.kind === 'bit' ? `Digs hardness ${BIT_TIERS[e.tier].hard} and below.` : `Holds ${TANK_TIERS[e.tier].cap.toLocaleString('en-US')} grains.`] });
|
||||
hudDirty = true;
|
||||
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m);
|
||||
else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m);
|
||||
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
|
||||
else if (e.t === 'order_new') {
|
||||
const o = e.o;
|
||||
showCard({ kind: 'New order', title: orderName(o), swatch: o.m, body: [`Within ${o.mins} minutes, for ${fmtMoney(o.pay)}: ${o.mult}× the market price.`, 'Goods dropped into the trade dock fill it first.'] });
|
||||
playSfx('discover'); hudDirty = true;
|
||||
}
|
||||
else if (e.t === 'order_done') {
|
||||
showCard({ kind: 'Order filled', title: '+' + fmtMoney(e.pay), swatch: e.m, accent: 'milestone', body: [`${orderName(e)} delivered.`, debt > 0 ? `${fmtMoney(debt)} still owed on the claim.` : ''] });
|
||||
playSfx('sale'); hudDirty = true; if (!$('m-notebook').hidden) renderNotebook();
|
||||
}
|
||||
else if (e.t === 'order_expired') {
|
||||
showCard({ kind: 'Order expired', title: orderName(e), swatch: e.m, body: [e.got ? `You delivered ${e.got} of ${e.qty}. They were paid at market price: ${fmtMoney(e.paid)}.` : 'Nothing was delivered. No harm done.'] });
|
||||
hudDirty = true;
|
||||
}
|
||||
else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); }
|
||||
else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); }
|
||||
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a); }
|
||||
else if (e.t === 'toast') { if (e.text) toast(e.text); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Gamepad input.
|
||||
// ---------- Gamepad ----------
|
||||
const PAD = { prev: [], active: false, menuT: 0 };
|
||||
function padPressed(gp, b) { const v = gp.buttons[b] && gp.buttons[b].pressed; const was = PAD.prev[b]; return v && !was; }
|
||||
function pollPad() {
|
||||
const gps = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
let gp = null; for (const g of gps) if (g && g.connected) { gp = g; break; }
|
||||
if (!gp) return { mx: 0, my: 0 };
|
||||
const dz = SET.deadzone;
|
||||
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'); }
|
||||
const modal = topModal();
|
||||
if (modal || !GAME.running) {
|
||||
const now = performance.now();
|
||||
const up = padPressed(gp, 12) || (ly < -0.6 && now > PAD.menuT), down = padPressed(gp, 13) || (ly > 0.6 && now > PAD.menuT);
|
||||
const left = padPressed(gp, 14) || (lx < -0.6 && now > PAD.menuT), right = padPressed(gp, 15) || (lx > 0.6 && now > PAD.menuT);
|
||||
if (up || down || left || right) PAD.menuT = now + 220;
|
||||
const root = modal ? $(modal) : $('title');
|
||||
const focusables = [...root.querySelectorAll('button:not([disabled]), select, input')].filter(e => e.offsetParent !== null);
|
||||
const cur = document.activeElement;
|
||||
let idx = focusables.indexOf(cur);
|
||||
if (up || down) { idx = idx < 0 ? 0 : (idx + (down ? 1 : -1) + focusables.length) % focusables.length; focusables[idx] && focusables[idx].focus(); }
|
||||
if ((left || right) && cur) {
|
||||
if (cur.type === 'range') { const st = parseFloat(cur.step) || 1; cur.value = Math.max(+cur.min, Math.min(+cur.max, parseFloat(cur.value) + (right ? st : -st))); cur.dispatchEvent(new Event('input')); }
|
||||
else if (cur.tagName === 'SELECT') { cur.selectedIndex = Math.max(0, Math.min(cur.options.length - 1, cur.selectedIndex + (right ? 1 : -1))); cur.dispatchEvent(new Event('change')); }
|
||||
else if (cur.closest('.tabs')) { const tb = [...cur.parentNode.children]; const k = tb.indexOf(cur); const n = tb[(k + (right ? 1 : -1) + tb.length) % tb.length]; n.click(); setTimeout(() => { const t2 = root.querySelector('.tabs .on'); t2 && t2.focus(); }, 0); }
|
||||
}
|
||||
if (padPressed(gp, 0) && cur && focusables.includes(cur)) cur.click();
|
||||
if (padPressed(gp, 1) && modal) closeModal(modal);
|
||||
if (padPressed(gp, 9) && modal === 'm-pause') closeModal(modal);
|
||||
PAD.prev = gp.buttons.map(b => b && b.pressed);
|
||||
return { mx: 0, my: 0 };
|
||||
}
|
||||
const mag = Math.hypot(rx, ry);
|
||||
const reachNow = toolId() === 'drill' ? drillReach(GAME.drillW) : REACH;
|
||||
if (mag > 0.05) { GAME.padAim.x = rx / Math.max(1, mag) * reachNow * SET.aimSpeed * Math.min(1, mag); GAME.padAim.y = ry / Math.max(1, mag) * reachNow * SET.aimSpeed * Math.min(1, mag); }
|
||||
if (padPressed(gp, 4)) selectTool((GAME.tool + TOOLS.length - 1) % TOOLS.length);
|
||||
if (padPressed(gp, 5)) selectTool((GAME.tool + 1) % TOOLS.length);
|
||||
if (padPressed(gp, 14)) cycleItem(-1);
|
||||
if (padPressed(gp, 15)) cycleItem(1);
|
||||
if (padPressed(gp, 12)) setBrush(1);
|
||||
if (padPressed(gp, 13)) setBrush(-1);
|
||||
if (padPressed(gp, 0)) doInteract();
|
||||
if (padPressed(gp, 2)) rotate();
|
||||
if (padPressed(gp, 3)) toggleLens();
|
||||
if (padPressed(gp, 1)) { if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } }
|
||||
if (padPressed(gp, 10)) bot.lamp = !bot.lamp;
|
||||
if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; }
|
||||
if (padPressed(gp, 8)) openNotebook();
|
||||
if (padPressed(gp, 9)) openPause();
|
||||
const rt = gp.buttons[7] && (gp.buttons[7].pressed || gp.buttons[7].value > 0.4), lt = gp.buttons[6] && (gp.buttons[6].pressed || gp.buttons[6].value > 0.4);
|
||||
if (GAME.usingPad) {
|
||||
if (rt && !GAME.lmb) { GAME.lmb = true; primaryPress(false); } else if (!rt && GAME.lmb) { primaryRelease(false); GAME.lmb = false; }
|
||||
if (lt && !GAME.rmb) { GAME.rmb = true; secondaryPress(); } else if (!lt && GAME.rmb) { GAME.rmb = false; endRmbRecord(); }
|
||||
}
|
||||
PAD.prev = gp.buttons.map(b => b && b.pressed);
|
||||
return { mx: lx, my: ly };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The tool hotbar and what is selected.
|
||||
// ---------- HUD ----------
|
||||
function renderHotbar() {
|
||||
const hb = $('hotbar'); hb.innerHTML = '';
|
||||
TOOLS.forEach((t, k) => {
|
||||
const b = el('button', { class: 'slot' + (k === GAME.tool ? ' on' : ''), title: `${t.name} (${t.key})`, 'aria-label': `${t.name} tool, key ${t.key}`, 'aria-pressed': k === GAME.tool });
|
||||
b.innerHTML = `<span class="k">${t.key}</span>${ICONS[t.id]}`;
|
||||
b.onclick = () => { selectTool(k); };
|
||||
hb.appendChild(b);
|
||||
});
|
||||
}
|
||||
function toolId() { return TOOLS[GAME.tool].id; }
|
||||
// Shift does two jobs. While you are steering the bot it boosts; while you are not,
|
||||
// it is the tool modifier (vacuum everything, blow a mix, fill a rectangle).
|
||||
function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; }
|
||||
function modShift() { return keys.has('Shift') && !steering(); }
|
||||
// The loose material exactly under the cursor, or the nearest one touching it.
|
||||
function grainAt(ax, ay) {
|
||||
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return 0;
|
||||
const i = ay * W + ax, m = mat[i];
|
||||
if (occ[i]) { const e = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H); if (e) return e.m; }
|
||||
if (PH[m] === PH_POWDER || (PH[m] === PH_LIQUID && player.flask)) return m;
|
||||
return vacuumTargetAt(ax, ay, 1);
|
||||
}
|
||||
function selectTool(k) {
|
||||
if (k === GAME.tool) return;
|
||||
GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui');
|
||||
if (toolId() === 'vacuum') ensureDropMat();
|
||||
}
|
||||
function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); }
|
||||
// Only auto-pick a material when the player has not chosen one. A chosen
|
||||
// material stays selected even after it runs out.
|
||||
function mixList() { return [...GAME.collect].filter(m => bag[m] > 0 && PH[m] !== PH_ITEM); }
|
||||
function ensureDropMat() {
|
||||
if (GAME.blowMix && mixList().length < 1) GAME.blowMix = false;
|
||||
if (bag[GAME.dropMat] > 0) return;
|
||||
if (GAME.matChosen && GAME.dropMat) return;
|
||||
const l = bagMaterials(); GAME.dropMat = l.length ? l[0] : 0;
|
||||
}
|
||||
function piece() { return PIECES[GAME.pieceIdx]; }
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Discovery cards, hover info (I) and the cursor label.
|
||||
// ---------- Discovery cards ----------
|
||||
// Short cards in the lower right when you find or make something new. They fade
|
||||
// on their own (hover holds them); everything they say is also in the notebook.
|
||||
function showCard(c) {
|
||||
if (!SET.cardSeconds) return;
|
||||
const box = $('cards');
|
||||
while (box.children.length >= 3) box.firstChild.remove();
|
||||
const card = el('div', { class: 'card-pop plate interactive' + (c.accent ? ' ' + c.accent : ''), role: 'status' });
|
||||
const timed = SET.cardSeconds > 0;
|
||||
card.innerHTML = `<div class="top">${c.swatch ? `<span class="sw" style="background:${rgbOf(c.swatch)}"></span>` : ''}<span class="label">${esc(c.kind)}</span><button class="x" aria-label="Dismiss">×</button></div>`
|
||||
+ `<div class="title">${esc(c.title)}</div>${(c.body || []).filter(Boolean).map((t, k) => `<div class="${k ? 'p2' : 'p1'}">${esc(t)}</div>`).join('')}`
|
||||
+ (timed ? `<i class="life" style="--dur:${(SET.cardSeconds * (c.hold || 1)).toFixed(1)}s"></i>` : '');
|
||||
const close = () => { if (card.classList.contains('out')) return; card.classList.add('out'); setTimeout(() => card.remove(), 380); };
|
||||
card.querySelector('.x').onclick = () => { playSfx('ui'); close(); };
|
||||
if (timed) card.querySelector('.life').addEventListener('animationend', close);
|
||||
box.appendChild(card);
|
||||
}
|
||||
function howText(e) {
|
||||
const F = e.from ? MD[e.from].name : '', f = F.toLowerCase();
|
||||
switch (e.how) {
|
||||
case 'dig': return e.from === WOOD ? 'Cut from a tree.' : `Broken loose from ${f}.`;
|
||||
case 'melt': return `${F} melted at ${fmtT(e.T)}.`;
|
||||
case 'smelt': return e.m === SLAG || e.m === MSLAG ? `Left over when ${f} smelted.` : `${F} smelted at ${fmtT(e.T)}.`;
|
||||
case 'freeze': return e.from === WATER ? (e.m === SNOW ? 'Water froze into flakes in the cold air.' : 'Water froze in the cold air.') : `${F} set as it cooled below ${fmtT(e.T)}.`;
|
||||
case 'boil': return `${F} boiled at ${fmtT(e.T)}.`;
|
||||
case 'condense': return `${F} cooled below ${fmtT(e.T)} and condensed.`;
|
||||
case 'ignite': return `${F} caught fire at ${fmtT(e.T)}.`;
|
||||
case 'burn': return `What ${f} leaves behind when it burns.`;
|
||||
case 'fire': return `${F} fired into brick in the heat above ${fmtT(e.T)}.`;
|
||||
case 'blow': return 'Air blown through molten iron burned off its extra carbon.';
|
||||
case 'alloy': return `${F} melted into molten copper and the two mixed.`;
|
||||
case 'char': return `${F} got hot with no air to burn in, and charred.`;
|
||||
case 'refine': return `Sifted out of ${f} by a Refiner.`;
|
||||
case 'cut': return `Cut from ${f} by a Stone cutter.`;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
function discoveryCard(e) {
|
||||
const m = e.m;
|
||||
if (m === FIRE) { showCard({ kind: 'Discovery', title: 'Fire', body: [howText(e), 'Fuel burns where it touches air. More air, more heat.'] }); return; }
|
||||
showCard({ kind: 'New material', title: MD[m].name, swatch: m, body: [howText(e), MD[m].desc] });
|
||||
}
|
||||
// Everything a material does when heated, cooled, wetted or lit, in short phrases. The hover
|
||||
// box and the notebook's Materials page both show this list.
|
||||
function reactionsOf(m, forNotebook) {
|
||||
const d = MD[m], out = [];
|
||||
if (!d || m === EMPTY || d.ph === PH_ITEM && !d.melt) return out;
|
||||
const nm = k => MD[k].name.toLowerCase();
|
||||
if (d.ign) out.push(`burns, catching at ${fmtT(d.ign)}`);
|
||||
if (m === WOOD || m === LWOOD) out.push('turns to charcoal if heated with no air');
|
||||
if (m === CLAY || m === LCLAY) out.push('fires into brick above 880°C');
|
||||
if (m === WATER) out.push('freezes below 0°C', 'boils at 100°C');
|
||||
else if (d.boil) out.push(`boils at ${fmtT(d.boil)}`);
|
||||
if (d.melt) {
|
||||
if (d.smelt) out.push(`smelts at ${fmtT(d.melt)} into ${nm(d.meltTo)} and slag`);
|
||||
else if (d.meltTo === WATER) out.push('melts above 0°C');
|
||||
else if (d.ph === PH_ITEM) out.push(`melts at ${fmtT(d.melt)}`);
|
||||
else out.push(`melts at ${fmtT(d.melt)}${d.meltTo === MAGMA ? ' into magma' : d.meltTo === MGL && m !== GLASS ? ' into glass' : d.meltTo === MSLAG && m !== SLAG ? ' into slag' : ''}`);
|
||||
}
|
||||
if (d.freeze) out.push(`sets into ${nm(d.freezeTo)} below ${fmtT(d.freeze)}`);
|
||||
if (m === STEAM) out.push('turns back to water below 97°C under a roof', 'rises into the clouds in the open');
|
||||
else if (d.condT) out.push(`turns back to ${nm(d.condTo)} below ${fmtT(d.condT)}`);
|
||||
if (HOTLIQ[m]) out.push(m === MAGMA ? 'boils water, and crusts into basalt in it' : 'boils water it touches');
|
||||
if (ALLOY[m]) out.push(`mixes into molten copper as ${nm(FRZTO[ALLOY[m].into])}`);
|
||||
if (m === MFE) out.push('turns to steel with air blown through it');
|
||||
if (m === SNOW || m === ICE) out.push('chills water, and freezes it in cold air');
|
||||
if (POROUS[m] && !d.ign) out.push('lets air through to a fire');
|
||||
if (d.smelt && d.ph === PH_POWDER) out.push('smothers a fire under it');
|
||||
if (m === ACORN) out.push('sprouts on dirt or grass under open sky');
|
||||
if ((d.ph === PH_POWDER || d.ph === PH_ITEM) && m !== SNOW) out.push(DENS[m] < 1 ? 'floats on water' : 'sinks in water');
|
||||
if (m === SNOW) out.push('floats on water');
|
||||
if (forNotebook && d.ph === PH_SOLID && d.yld && d.yld !== m && MD[d.yld]) out.push(`drills into ${nm(d.yld)}`);
|
||||
return out;
|
||||
}
|
||||
// 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 (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];
|
||||
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)); }
|
||||
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.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
|
||||
if (mm.type === 'cutter') t2.push(mm.blocked ? 'stopped: clear the space under it' : tick - (mm.busyT || -999) < 30 ? 'cutting' : 'idle: drop stone rubble in', `${(stats.bricks || 0).toLocaleString('en-US')} bricks cut`);
|
||||
if (mm.type === 'refiner') t2.push(mm.blocked ? 'stopped: clear the space under its chute' : tick - (mm.busyT || -999) < 30 ? 'grinding' : 'idle: drop rubble, gravel, sand or dirt in', `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
|
||||
} else if (p === P_FILTER) {
|
||||
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
|
||||
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
|
||||
} 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(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
|
||||
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
|
||||
else if (m === 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';
|
||||
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
|
||||
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`;
|
||||
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';
|
||||
}
|
||||
if (!bar && PH[m] === PH_POWDER) t2.push(GAME.collect.has(m) ? 'loose · ticked' : '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 (flags[i] & F_BURN) t2.push('burning');
|
||||
const mo = 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 · press F` : `${MD[mo.metal].name} casting set · press F`) : mo.state === 'flawed' || mo.state === 'short' ? mo.flaw : mo.state === 'filling' ? `filling ${Math.round(mo.fill * 100)}%` : 'empty'];
|
||||
if (player.lens) t2.push(fmtT(temp[i]));
|
||||
}
|
||||
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : 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 };
|
||||
}
|
||||
function renderInfo() {
|
||||
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
const box = $('info');
|
||||
const h = `${r.t1 ? `<span class="t1">${esc(r.t1)}</span>` : ''}${r.t2.length ? `<span class="t2">${esc(r.t2.join(' · '))}</span>` : ''}${r.t3 && r.t3.length ? `<span class="t3">${esc(r.t3.join(' · '))}</span>` : ''}`;
|
||||
if (box.innerHTML !== h) box.innerHTML = h;
|
||||
}
|
||||
// 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;
|
||||
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!info.t1 || info.air) 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)) || '' : '');
|
||||
ctx.save();
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`;
|
||||
const w1 = ctx.measureText(main).width;
|
||||
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
|
||||
const w2 = sub ? ctx.measureText(sub).width + fs * 0.6 : 0;
|
||||
const padX = fs * 0.5, h = fs * 1.6;
|
||||
let x = px + (r + 1.6) * R.zp + 6 * dpr, y = py + (r + 1.2) * R.zp + 4 * dpr;
|
||||
if (x + w1 + w2 + padX * 2 > R.canvas.width) x = px - (r + 1.6) * R.zp - 6 * dpr - (w1 + w2 + padX * 2);
|
||||
if (y + h > R.canvas.height) y = py - (r + 1.2) * R.zp - h;
|
||||
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.restore();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Keyboard and mouse listeners.
|
||||
// ---------- Input ----------
|
||||
const canvas = $('game');
|
||||
function isTyping(e) { const t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); }
|
||||
window.addEventListener('keydown', e => {
|
||||
if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; }
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (anyModal()) {
|
||||
if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); }
|
||||
else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook');
|
||||
return;
|
||||
}
|
||||
if (!GAME.running) return;
|
||||
if (e.key === 'Control') { GAME.ctrl = true; return; }
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (k === 's') { e.preventDefault(); saveWorld('main'); }
|
||||
else if (k === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); }
|
||||
return;
|
||||
}
|
||||
keys.add(k);
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Tab'].includes(k)) e.preventDefault();
|
||||
if (e.repeat) return;
|
||||
GAME.usingPad = false;
|
||||
switch (k) {
|
||||
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
|
||||
case 'c': case 'x': hudDirty = true; break;
|
||||
case 'v': startPaste(); break;
|
||||
case 'g': pickUnderCursor(); break;
|
||||
case 'q': cycleItem(-1); break;
|
||||
case 'e': cycleItem(1); break;
|
||||
case 'r': rotate(); break;
|
||||
case 't': if (toolId() === 'vacuum') toggleCollectUnderCursor(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
|
||||
case 'f': doInteract(); 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 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
|
||||
case 'n': openNotebook(); break;
|
||||
case 'b': openNotebook('Blueprints'); break;
|
||||
case 'm': SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); break;
|
||||
case '[': setBrush(-1); break;
|
||||
case ']': setBrush(1); break;
|
||||
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
|
||||
case 'Escape': 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 (e.key === 'Control') GAME.ctrl = false; if (k === 'c' || k === 'x') hudDirty = true; });
|
||||
window.addEventListener('blur', () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.ctrl = false; GAME.selDrag = null; endRmbRecord(); });
|
||||
canvas.addEventListener('mousemove', e => { GAME.ctrl = e.ctrlKey; GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } });
|
||||
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
if (!GAME.running || GAME.paused) return;
|
||||
auInit();
|
||||
GAME.usingPad = false;
|
||||
if (e.button === 0) { GAME.lmb = true; primaryPress(e.shiftKey && !steering()); }
|
||||
else if (e.button === 2) { GAME.rmb = true; secondaryPress(); }
|
||||
else if (e.button === 1) { e.preventDefault(); pickUnderCursor(); }
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (e.button === 0) { GAME.ctrl = e.ctrlKey; if (GAME.lmb) primaryRelease(modShift()); GAME.lmb = false; }
|
||||
else if (e.button === 2) { GAME.rmb = false; endRmbRecord(); }
|
||||
});
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
if (!GAME.running || GAME.paused) return;
|
||||
const d = e.deltaY > 0 ? -1 : 1;
|
||||
if (e.shiftKey || e.ctrlKey) setBrush(d);
|
||||
else { SET.zoom = Math.max(2, Math.min(12, SET.zoom + d)); saveSettings(); resizeRender(); R.snapNext = true; }
|
||||
}, { passive: false });
|
||||
window.addEventListener('resize', () => { if (R.canvas) resizeRender(); });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!AU.ctx) return;
|
||||
if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); if (GAME.running) saveWorld('auto', true); }
|
||||
else AU.ctx.resume();
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// The frame loop and boot.
|
||||
// ---------- Main loop ----------
|
||||
let lastT = performance.now(), acc = 0, lastRender = 0, frameMs = 0, simMs = 0, fpsSm = 60;
|
||||
function scanVisible() {
|
||||
let fire = 0, power = 0;
|
||||
const x0 = Math.max(0, R.vx0), y0 = Math.max(0, R.vy0), x1 = Math.min(W, R.vx0 + R.bw), y1 = Math.min(H, R.vy0 + R.bh);
|
||||
for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { const i = y * W + x; if (mat[i] === FIRE || (flags[i] & F_BURN)) fire++; }
|
||||
for (const mm of machines) if (mm && mm.x + mm.w > x0 && mm.x < x1 && mm.y + mm.h > y0 && mm.y < y1) power += mm.type === 'fan' ? (nets[mm.net] ? nets[mm.net].sat * 6 : 0) : mm.out;
|
||||
for (let n = 1; n < nets.length; n++) power += nets[n].demand * nets[n].sat * 0.2;
|
||||
const cx = Math.max(0, Math.min(W - 1, Math.floor(bot.x)));
|
||||
return { fire: fire * 4, power, surface: bot.y < surfY[cx] + 4, rain: R.rain || 0 };
|
||||
}
|
||||
let ambientT = 0;
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
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.running) { pollPad(); return; }
|
||||
const t0 = performance.now();
|
||||
const pad = pollPad();
|
||||
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
|
||||
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
|
||||
if (!GAME.paused) {
|
||||
acc += dtReal * SET.simSpeed;
|
||||
let steps = 0;
|
||||
const step = 1 / 60;
|
||||
while (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;
|
||||
ix += pad.mx; iy += pad.my;
|
||||
const mag = Math.hypot(ix, iy); if (mag > 1) { ix /= mag; iy /= mag; }
|
||||
// Shift boosts flight with every tool; as a tool modifier it only matters when you click
|
||||
const pm = Math.hypot(pad.mx, pad.my);
|
||||
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++;
|
||||
GAME.playTime += step;
|
||||
}
|
||||
if (steps >= SET.maxSteps) acc = 0;
|
||||
if (SET.autosave && performance.now() - GAME.lastAutosave > SET.autosave * 60000) { GAME.lastAutosave = performance.now(); saveWorld('auto', true); }
|
||||
} else acc = 0;
|
||||
drainEvents();
|
||||
let lookX = 0, lookY = 0;
|
||||
if (GAME.usingPad) { lookX = GAME.padAim.x * 0.5; lookY = GAME.padAim.y * 0.5; }
|
||||
else if (GAME.mouse.in && toolId() !== 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld()) { const dpr = R.canvas.width / window.innerWidth; lookX = (GAME.mouse.x - window.innerWidth / 2) * dpr / R.zp; lookY = (GAME.mouse.y - window.innerHeight / 2) * dpr / R.zp; }
|
||||
// hold the camera's look-ahead still while dragging out a line, box or blueprint
|
||||
// While dragging, the camera stays put unless the bot itself is flying, so the
|
||||
// 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);
|
||||
R.frame++;
|
||||
drawWorld();
|
||||
drawCracks();
|
||||
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();
|
||||
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 (GAME.ctrl !== GAME.ctrlShown) { GAME.ctrlShown = GAME.ctrl; hudDirty = true; }
|
||||
renderBuildBar();
|
||||
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
|
||||
if (R.frame % 4 === 0) renderInfo();
|
||||
if (document.body.classList.contains('paused') !== GAME.paused) document.body.classList.toggle('paused', GAME.paused);
|
||||
updateOverlayUI();
|
||||
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
|
||||
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Boot ----------
|
||||
function boot(hotData) {
|
||||
document.documentElement.style.setProperty('--ui', SET.uiScale);
|
||||
initRender(canvas);
|
||||
setupBuildBar();
|
||||
buildTitleCells(); drawTitle();
|
||||
$('nw-seed').value = String(Math.floor(Math.random() * 900000) + 100000);
|
||||
$('wallet').onclick = () => { playSfx('ui'); openNotebook('Market'); };
|
||||
$('btn-new').onclick = () => { auInit(); playSfx('ui'); $('newworld').hidden = !$('newworld').hidden; if (!$('newworld').hidden) $('btn-generate').focus(); };
|
||||
$('btn-generate').onclick = () => {
|
||||
const seed = parseInt(String($('nw-seed').value).replace(/\D/g, ''), 10) || Math.floor(Math.random() * 1e6);
|
||||
const [w, h] = $('nw-size').value.split('x').map(Number);
|
||||
startNewWorld(seed, w, h);
|
||||
};
|
||||
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); };
|
||||
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
|
||||
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
|
||||
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
|
||||
$('touchnote').hidden = !touchOnly;
|
||||
refreshContinue().then(async () => {
|
||||
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); }
|
||||
});
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ }
|
||||
if (window.claude && window.claude.hot && window.claude.hot.ready) window.claude.hot.ready(boot); else boot((window.claude && window.claude.hot && window.claude.hot.data) || {});
|
||||
@@ -0,0 +1,201 @@
|
||||
// The notebook (N): milestones, market, wallet, blueprints, controls.
|
||||
// ---------- Notebook ----------
|
||||
let nbTab = 'Ledger';
|
||||
function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
|
||||
function renderNotebook() {
|
||||
const tabs = $('nb-tabs'); tabs.innerHTML = '';
|
||||
for (const name of ['Ledger', 'Market', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
|
||||
const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
|
||||
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
const body = $('nb-body');
|
||||
if (nbTab === 'Ledger') {
|
||||
const done = MILESTONES.filter(m => ledger[m.id]).length;
|
||||
let h = `<p class="note">${done} of ${MILESTONES.length} reached. There is no set order. Tips stay hidden until you ask for one. Upgrades come from what you make: your bit is as hard as the metal you cast it from.</p><div class="ledger">`;
|
||||
for (const m of MILESTONES) {
|
||||
const isDone = !!ledger[m.id], tip = isDone || GAME.tipsOpen.has(m.id);
|
||||
h += `<div class="ms ${isDone ? 'done' : 'open'}"><span class="mark" aria-hidden="true"></span><span class="h">${esc(m.title)}</span>`;
|
||||
h += tip ? `<span class="p">${esc(m.hint)}</span>` : `<span class="p"><button class="linkbtn" data-tip="${m.id}">Show tip</button></span>`;
|
||||
if (m.reward) h += `<span class="rw">${isDone ? '✓ ' : ''}${esc(m.reward)}</span>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += `</div><p class="note grouphead">Tools: <b>${esc(BIT_TIERS[player.bitTier].name)}</b> (digs hardness ${BIT_TIERS[player.bitTier].hard} and below) · <b>${esc(TANK_TIERS[player.tankTier].name)}</b> (${bagCap().toLocaleString('en-US')} grains)${player.lens ? ' · <b>Thermal lens</b>' : ''}${player.flask ? ' · <b>Flask</b>' : ''}</p>`;
|
||||
body.innerHTML = h;
|
||||
body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
|
||||
} else if (nbTab === 'Market') {
|
||||
renderMarketTab(body);
|
||||
} else if (nbTab === 'Materials') {
|
||||
let rows = '', unknown = 0;
|
||||
const list = [DIRT, LDIRT, STONE, RSTONE, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS,
|
||||
BRICK, RBRICK, GLASS, CULLET, COPPER, SCRCU, BARCU, IRON, SCRFE, BARFE, STEEL, SCRST, BARST, ASH, SLAG, MSLAG, WATER, STEAM, MAGMA, MCU, MFE, MST, MGL, FIRE, SMOKE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS];
|
||||
for (const m of list) {
|
||||
if (!seen[m]) { unknown++; continue; }
|
||||
const d = MD[m];
|
||||
const ph = ['', 'Solid', 'Loose', 'Liquid', 'Gas', 'Object'][d.ph];
|
||||
const change = reactionsOf(m, true).map((t, k) => k ? t : t[0].toUpperCase() + t.slice(1)).join(' · ');
|
||||
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(d.name)}</td><td>${ph}</td><td class="num">${d.hard && d.ph === PH_SOLID ? d.hard : '–'}</td><td>${esc(change || '–')}</td><td class="p" style="color:var(--ash)">${esc(d.desc || '')}</td></tr>`;
|
||||
}
|
||||
body.innerHTML = `<p class="note">What you have found so far. Hardness is what your drill bit has to match. Solid rock breaks into loose rubble that you vacuum up and build with.${unknown ? ` ${unknown} more to discover.` : ''}</p><div class="tablewrap"><table class="mt"><thead><tr><th>Material</th><th>State</th><th>Hard</th><th>What it does</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
} else if (nbTab === 'Pieces') {
|
||||
let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Your bot flies straight through anything you build.</p><div class="cards">';
|
||||
for (const P of PIECES) {
|
||||
if (P.hidden) continue;
|
||||
const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R picks it)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1);
|
||||
const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1);
|
||||
const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1';
|
||||
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>`;
|
||||
}
|
||||
body.innerHTML = h + '</div>';
|
||||
} else if (nbTab === 'Blueprints') {
|
||||
renderBlueprintTab(body);
|
||||
} else {
|
||||
body.innerHTML = controlsHtml();
|
||||
}
|
||||
}
|
||||
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
|
||||
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
|
||||
function orderLeft(o) { return Math.max(0, o.due - tick); }
|
||||
const MARKET_GROUPS = [
|
||||
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
|
||||
['Bulk (builders)', [RSTONE, GRAVEL, BARSB]],
|
||||
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
|
||||
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
|
||||
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
|
||||
];
|
||||
function renderMarketTab(body) {
|
||||
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
|
||||
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
|
||||
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
|
||||
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
|
||||
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
|
||||
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, rubble, ash and slag goes for nothing. Build walls around the dock to funnel more in.</p>`;
|
||||
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
|
||||
if (orders.length) {
|
||||
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
|
||||
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
|
||||
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
|
||||
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
|
||||
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
|
||||
let rows = '', unknown = 0;
|
||||
for (const [g, list] of MARKET_GROUPS) {
|
||||
const known = list.filter(m => seen[m]);
|
||||
unknown += list.length - known.length;
|
||||
if (!known.length) continue;
|
||||
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
|
||||
for (const m of known) {
|
||||
const f = priceFactor(m), now = priceNow(m);
|
||||
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
|
||||
}
|
||||
}
|
||||
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
|
||||
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
|
||||
h += `<p class="label grouphead">Sales, minute by minute</p>`;
|
||||
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
|
||||
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
|
||||
body.innerHTML = h;
|
||||
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
|
||||
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
|
||||
const paid = payClaim(v);
|
||||
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
|
||||
renderNotebook(); hudDirty = true;
|
||||
});
|
||||
}
|
||||
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
|
||||
function itemsText(items) {
|
||||
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
|
||||
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
|
||||
if (e.length > 3) parts.push(`${e.length - 3} more`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
function renderWallet() {
|
||||
$('w-money').textContent = fmtMoney(money);
|
||||
const c = $('w-claim');
|
||||
c.className = 'line' + (debt <= 0 ? ' paid' : '');
|
||||
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
|
||||
const h = orders.length ? orders.map(o => {
|
||||
const left = orderLeft(o);
|
||||
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
|
||||
}).join('') : '<span class="none">No orders yet</span>';
|
||||
const box = $('w-orders');
|
||||
if (box.innerHTML !== h) box.innerHTML = h;
|
||||
}
|
||||
function renderBlueprintTab(body) {
|
||||
const lib = libLoad();
|
||||
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
|
||||
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
|
||||
STARTER_DESIGNS.forEach((d, k) => {
|
||||
const bp = d.bp();
|
||||
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
|
||||
});
|
||||
h += '</div><p class="label grouphead">Your blueprints</p>';
|
||||
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
|
||||
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
|
||||
h += `<p class="label grouphead">Library</p>`;
|
||||
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
|
||||
else {
|
||||
h += '<div class="cards">';
|
||||
lib.forEach((it, k) => {
|
||||
const ok = bpValid(it.bp);
|
||||
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
body.innerHTML = h;
|
||||
const sv = $('bp-save'); if (sv) sv.onclick = () => {
|
||||
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
|
||||
]);
|
||||
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
|
||||
};
|
||||
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
|
||||
$('bp-import').onclick = () => {
|
||||
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
|
||||
]);
|
||||
};
|
||||
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
|
||||
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
|
||||
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
|
||||
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
|
||||
}
|
||||
function copyCode(code) {
|
||||
const done = () => toast('Blueprint code copied.');
|
||||
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
|
||||
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
|
||||
}
|
||||
function controlsHtml() {
|
||||
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'], ['Cycle what you blow · piece', 'Q / E'], ['Variant: material · direction · flip', 'R'],
|
||||
['Tick/untick what is under the cursor (Vacuum)', 'R or T'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
|
||||
['Replace what is built (Build)', 'Hold Ctrl'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'],
|
||||
['Pick the piece under the cursor (with its direction and settings)', 'G or middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'],
|
||||
];
|
||||
const pad = [
|
||||
['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'],
|
||||
['Cycle what you blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tick material', 'X'], ['Thermal lens', 'Y'],
|
||||
['Cancel', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'],
|
||||
];
|
||||
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 Vacuum', 'Left click sucks up what you start on; if anything is ticked in your bag (R over it, or its tick box), it takes only the ticked materials instead. Hold Shift while not flying to take everything. Right click blows the chosen material out of the bot toward the cursor; Q and E choose it, and it stays chosen. Q and E (or clicking in your bag) can also pick Ticked mix, an even mix of everything ticked, which is how you charge a furnace. The swatches next to the cursor always show what right click will blow. R ticks or unticks what is under the cursor.'],
|
||||
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps and outlets, machines (fans, the Refiner) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'],
|
||||
['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.'],
|
||||
];
|
||||
const tbl = rows => '<div class="keys">' + rows.map(([a, b]) => `<div><span>${esc(a)}</span><span>${esc(b)}</span></div>`).join('') + '</div>';
|
||||
return `<p class="label grouphead">Tools</p><div class="cards">${tools.map(([a, b]) => `<div class="card"><div class="h">${esc(a)}</div><div class="p">${esc(b)}</div></div>`).join('')}</div>
|
||||
<p class="label grouphead">Keyboard and mouse</p>${tbl(kb)}<p class="label grouphead">Gamepad</p>${tbl(pad)}
|
||||
<p class="label grouphead">First steps</p>
|
||||
<p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br>
|
||||
2. With the Vacuum, click on coal to suck up only coal, then the copper ore, rubble and clay you want.<br>
|
||||
3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br>
|
||||
4. Tick coal and copper ore in your bag and pick Ticked mix (Q/E, or click it at the bottom of the bag). Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br>
|
||||
5. When the ingot sets, press F on the mold and vacuum up the bars. That also rakes the slag and ash off the grate, so the furnace is ready for the next charge. Scrap goes to your bag: throw it in with the next charge.<br>
|
||||
6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br>
|
||||
7. From there: deeper ores (tin, zinc, silver, gold), alloys, fans for hotter fires, iron and steel.</p>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
|
||||
// ---------- Overlay previews ----------
|
||||
function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; }
|
||||
function drawGrid(cbx, cby, radius) {
|
||||
const ctx = R.ctx, zp = R.zp, s = BS * zp;
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
|
||||
const d = Math.hypot(dx, dy) / radius; if (d > 1) continue;
|
||||
const [x, y] = blockRectPx(cbx + dx, cby + dy, 1, 1);
|
||||
ctx.strokeStyle = `rgba(236,226,210,${(0.16 * (1 - d)).toFixed(3)})`;
|
||||
ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, s, s);
|
||||
}
|
||||
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';
|
||||
}
|
||||
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
|
||||
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
|
||||
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
||||
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
|
||||
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
|
||||
function crackSeg(ctx, x, y, n, sc, zp, oy) {
|
||||
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
|
||||
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
|
||||
const pts = [];
|
||||
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
|
||||
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
|
||||
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
|
||||
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
|
||||
for (let k = 0; k + 3 < pts.length; k += 4) {
|
||||
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
|
||||
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
|
||||
}
|
||||
}
|
||||
function drawCracks() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
if (zp < 2.5) return;
|
||||
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
|
||||
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
|
||||
const cells = [];
|
||||
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
|
||||
if (!cells.length) return;
|
||||
ctx.save(); ctx.lineCap = 'round';
|
||||
for (let l = 0; l < CRACK_LV.length; l++) {
|
||||
const L = CRACK_LV[l];
|
||||
// a soft lit lip just below each crack, then the dark crack itself
|
||||
for (const pass of [0, 1]) {
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
|
||||
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
|
||||
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
function pulseRGB(tune) {
|
||||
if (!tune) return '150,215,255';
|
||||
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
|
||||
}
|
||||
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
|
||||
function fanPoints(ox, oy, ang, Wd, sd, n) {
|
||||
const pts = [];
|
||||
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
|
||||
return pts;
|
||||
}
|
||||
function drawDrillPulses() {
|
||||
if (!drillPulses.length) return;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
for (const q of drillPulses) {
|
||||
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
|
||||
if (rr < 3) continue;
|
||||
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
|
||||
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
|
||||
// a faint band behind the front, then the bright front itself
|
||||
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
|
||||
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
|
||||
// out to where it stops, and a crosshair
|
||||
function drawDrillAim(ax, ay) {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
|
||||
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
|
||||
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
|
||||
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
|
||||
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
|
||||
ctx.save();
|
||||
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
|
||||
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
|
||||
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
|
||||
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
|
||||
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
|
||||
ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
// the emitter glows across the bot's face while it fires
|
||||
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
|
||||
if (GAME.lmb && hot > 0) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
|
||||
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
|
||||
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
|
||||
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
|
||||
const k = zp * 0.9;
|
||||
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
|
||||
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
|
||||
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
|
||||
ctx.restore();
|
||||
}
|
||||
function drawPreviews() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
const p = worldToScreenPx(ax, ay);
|
||||
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) {
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
drawDrillPulses();
|
||||
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
|
||||
if (sel) drawSelection(sel, ax, ay);
|
||||
else if (pasting) drawPastePreview();
|
||||
else if (t === 'vacuum' || t === 'ignite') {
|
||||
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)';
|
||||
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([]);
|
||||
if (t === 'vacuum') {
|
||||
// what the right button would blow, next to the cursor; what the left is sucking, while it sucks
|
||||
const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp;
|
||||
const swatches = GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.blowMix ? mixList() : GAME.dropMat && bag[GAME.dropMat] > 0 ? [GAME.dropMat] : [];
|
||||
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
|
||||
if (blowing || (GAME.lmb && GAME.vacFilter)) {
|
||||
// faint line from the nozzle: the path the grains take
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6);
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke();
|
||||
}
|
||||
}
|
||||
} else if (t === 'build') {
|
||||
const P = piece(), b = aimBlock();
|
||||
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;
|
||||
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.ctrl && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
|
||||
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
|
||||
const 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);
|
||||
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')) {
|
||||
// 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 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();
|
||||
}
|
||||
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)';
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
|
||||
}
|
||||
} else {
|
||||
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
|
||||
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.ctrl && /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 [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') {
|
||||
const md = MOLDS[P.mold], wm = WALL_OF[GAME.wallMat];
|
||||
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) {
|
||||
const ch = md.rows[r][c]; if (ch === '.') continue;
|
||||
ctx.fillStyle = ch === '#' ? (ok ? `rgba(${COLR[wm]},${COLG[wm]},${COLB[wm]},0.6)` : 'rgba(239,106,85,0.4)') : 'rgba(255,190,90,0.35)';
|
||||
ctx.fillRect(x + c * zp, y + r * zp, zp, zp);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = ok ? 'rgba(92,186,166,0.2)' : 'rgba(239,106,85,0.2)';
|
||||
if (P.id === 'wheel') { ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
else ctx.fillRect(x, y, w, h);
|
||||
if (P.id === 'engine') { ctx.fillStyle = 'rgba(255,210,122,0.6)'; ctx.fillRect(x + 4 * zp, y + 7 * zp, 4 * zp, zp); }
|
||||
if (P.id === 'fan') { const d = GAME.pieceDir.fan || 0, dx = [1, 0, -1, 0][d], dy = [0, 1, 0, -1][d]; ctx.strokeStyle = 'rgba(160,220,255,0.6)'; ctx.lineWidth = Math.max(1, zp * 0.4); ctx.beginPath(); ctx.moveTo(x + w / 2, y + h / 2); ctx.lineTo(x + w / 2 + dx * 28 * zp, y + h / 2 + dy * 28 * zp); ctx.stroke(); }
|
||||
}
|
||||
ctx.strokeStyle = ok ? 'rgba(92,186,166,0.95)' : 'rgba(239,106,85,0.95)'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
}
|
||||
// ghost machines and molds (always visible)
|
||||
ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
for (const g of ghostMachines) { const P = PIECE_BY_ID[g.type]; const [x, y, w, h] = blockRectPx(g.bx, g.by, P.bw, P.bh); ctx.strokeStyle = 'rgba(90,190,230,0.7)'; ctx.strokeRect(x, y, w, h); }
|
||||
for (const g of ghostMolds) { const md = MOLDS[g.kind]; const [x, y, w, h] = blockRectPx(g.bx, g.by, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.7)'; ctx.strokeRect(x, y, w, h); }
|
||||
ctx.setLineDash([]);
|
||||
if (GAME.usingPad) {
|
||||
ctx.strokeStyle = 'rgba(255,230,200,0.9)'; ctx.lineWidth = 2;
|
||||
const cx = p.x + zp / 2, cy = p.y + zp / 2;
|
||||
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
if (t === 'build' && GAME.ctrl && !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);
|
||||
}
|
||||
// a small labelled tag drawn on the world (screen pixels)
|
||||
function drawTag(text, col, x, y, sub) {
|
||||
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
ctx.save();
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
|
||||
const tw = ctx.measureText(text).width;
|
||||
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
|
||||
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
|
||||
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
|
||||
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
|
||||
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
|
||||
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
|
||||
ctx.restore();
|
||||
}
|
||||
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
|
||||
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
|
||||
function drawSelection(mode, ax, ay) {
|
||||
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
|
||||
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
|
||||
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
|
||||
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
|
||||
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
|
||||
ctx.save();
|
||||
// what is inside: built blocks, and machines and molds that fit wholly inside
|
||||
let n = 0;
|
||||
if (sd) {
|
||||
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
|
||||
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
for (const mm of machines) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
|
||||
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
for (const mo of molds) {
|
||||
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
}
|
||||
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
|
||||
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
|
||||
if (mode === 'cut') {
|
||||
// red hatching: this will be taken down
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
|
||||
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
const step = Math.max(8, zp * 2.5);
|
||||
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
|
||||
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
ctx.restore();
|
||||
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
|
||||
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
|
||||
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
|
||||
}
|
||||
// Pasting: the clipboard follows the cursor, snapped to the grid
|
||||
function drawPastePreview() {
|
||||
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
|
||||
drawGrid(aimBlock().bx, aimBlock().by, 7);
|
||||
ctx.save();
|
||||
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
|
||||
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
|
||||
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
|
||||
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
ctx.restore();
|
||||
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Saves in IndexedDB, world files (export / import) and the blueprint library.
|
||||
// ---------- Saves (IndexedDB) ----------
|
||||
const DB = {
|
||||
db: null,
|
||||
open() {
|
||||
if (this.db) return Promise.resolve(this.db);
|
||||
return new Promise((res, rej) => {
|
||||
try {
|
||||
const r = indexedDB.open('deepgrain', 1);
|
||||
r.onupgradeneeded = () => r.result.createObjectStore('saves');
|
||||
r.onsuccess = () => { this.db = r.result; res(this.db); };
|
||||
r.onerror = () => rej(r.error);
|
||||
} catch (e) { rej(e); }
|
||||
});
|
||||
},
|
||||
async op(mode, fn) {
|
||||
const db = await this.open();
|
||||
return new Promise((res, rej) => { const tx = db.transaction('saves', mode); const st = tx.objectStore('saves'); const rq = fn(st); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); });
|
||||
},
|
||||
put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
|
||||
get(k) { return this.op('readonly', s => s.get(k)); },
|
||||
del(k) { return this.op('readwrite', s => s.delete(k)); },
|
||||
};
|
||||
async function saveWorld(slot, quiet) {
|
||||
try {
|
||||
const s = serializeWorld();
|
||||
await DB.put(slot, { meta: s.meta, buffers: s.buffers, savedAt: Date.now(), playTime: GAME.playTime });
|
||||
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function newestSave() {
|
||||
try {
|
||||
const a = await DB.get('main'), b = await DB.get('auto');
|
||||
if (a && b) return a.savedAt >= b.savedAt ? a : b;
|
||||
return a || b || null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); }
|
||||
function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; }
|
||||
async function gz(u8, inflate) {
|
||||
if (typeof CompressionStream === 'undefined') return null;
|
||||
const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip'));
|
||||
return new Uint8Array(await new Response(st).arrayBuffer());
|
||||
}
|
||||
async function exportWorld() {
|
||||
const s = serializeWorld();
|
||||
const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} };
|
||||
for (const k in s.buffers) {
|
||||
const arr = s.buffers[k], bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
||||
const z = await gz(bytes, false);
|
||||
out.buffers[k] = { type: arr.constructor.name, gz: !!z, b64: b64FromBytes(z || bytes) };
|
||||
}
|
||||
const text = JSON.stringify(out);
|
||||
let dl = null;
|
||||
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
|
||||
if (!dl) {
|
||||
// an ordinary browser download (the downloads hook above only exists inside claude.ai)
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `deepgrain-${worldSeed}.json`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
toast('World file saved.');
|
||||
} catch (e) { toast('Saving files is not available in this view.'); }
|
||||
return;
|
||||
}
|
||||
try { await dl.save({ filename: `deepgrain-${worldSeed}.json`, data: new Blob([text], { type: 'application/json' }) }); toast('World file saved.'); }
|
||||
catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); }
|
||||
}
|
||||
async function importWorldFile(file) {
|
||||
try {
|
||||
const obj = JSON.parse(await file.text());
|
||||
if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.');
|
||||
const bufs = {};
|
||||
const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array };
|
||||
for (const k in obj.buffers) {
|
||||
const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64);
|
||||
if (b.gz) bytes = await gz(bytes, true);
|
||||
const C = T[b.type] || Uint8Array;
|
||||
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
|
||||
}
|
||||
startFromSave({ meta: obj.meta, buffers: bufs });
|
||||
toast('World imported.');
|
||||
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
|
||||
}
|
||||
|
||||
// ---------- Blueprint library ----------
|
||||
function libLoad() { try { return JSON.parse(localStorage.getItem('deepgrain.blueprints') || '[]'); } catch (e) { return []; } }
|
||||
function libSave(list) { try { localStorage.setItem('deepgrain.blueprints', JSON.stringify(list)); return true; } catch (e) { toast('Blueprints cannot be stored in this view. Copy the blueprint code instead.'); return false; } }
|
||||
function bpEncode(bp) { return 'DGBP2:' + btoa(unescape(encodeURIComponent(JSON.stringify(bp)))); }
|
||||
function bpValid(bp) { return !!(bp && bp.v === 2 && Array.isArray(bp.blocks) && bp.bw > 0 && bp.bh > 0); }
|
||||
function bpDecode(code) {
|
||||
const s = code.trim();
|
||||
if (s.startsWith('DGBP1:')) throw new Error('That blueprint is from an older version of the game, before the block grid. It cannot be used.');
|
||||
if (!s.startsWith('DGBP2:')) throw new Error('Blueprint codes start with DGBP2:');
|
||||
let bp;
|
||||
try { bp = JSON.parse(decodeURIComponent(escape(atob(s.slice(6))))); } catch (e) { throw new Error('That code is damaged.'); }
|
||||
if (!bpValid(bp)) throw new Error('That code is damaged.');
|
||||
bp.machines = bp.machines || []; bp.molds = bp.molds || [];
|
||||
return bp;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
|
||||
function pieceUsesWall(P) { return P.variants === 'wall'; }
|
||||
function setDrillWidth(w) {
|
||||
const n = Math.max(0, Math.min(PULSE_W.length - 1, w));
|
||||
if (n === GAME.drillW) return;
|
||||
GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui');
|
||||
const what = ['a beam as wide as the bot: bores a tunnel fast and reaches furthest', 'a cone: the all-rounder', 'a broad fan: clears the most rock, but only close to the bot'][n];
|
||||
setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info');
|
||||
}
|
||||
// R with the drill: tune it to the material under the cursor, or back to everything
|
||||
function tuneUnderCursor() {
|
||||
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
|
||||
const m = mat[ay * W + ax];
|
||||
const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0;
|
||||
GAME.drillTune = !to || to === GAME.drillTune ? 0 : to;
|
||||
hudDirty = true; playSfx('ui');
|
||||
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only that shatters, and the pulses pass through everything else.` : 'Untuned: the drill shatters any rock it can.', 'info');
|
||||
}
|
||||
function cycleItem(d) {
|
||||
const t = toolId();
|
||||
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
|
||||
if (t === 'vacuum') {
|
||||
// what right click blows: each material in the bag, plus an even mix of the ticked ones
|
||||
const l = bagMaterials().map(String);
|
||||
if (mixList().length >= 2) l.push('mix');
|
||||
if (!l.length) return;
|
||||
let k = l.indexOf(GAME.blowMix ? 'mix' : String(GAME.dropMat)); k = (k + d + l.length) % l.length; if (k < 0) k = 0;
|
||||
if (l[k] === 'mix') GAME.blowMix = true; else { GAME.blowMix = false; GAME.dropMat = +l[k]; }
|
||||
GAME.matChosen = true;
|
||||
} else if (t === 'build') {
|
||||
let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length;
|
||||
GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]);
|
||||
GAME.catPick = GAME.catPick || {}; GAME.catPick[GAME.buildCat] = BUILD_ORDER[k];
|
||||
GAME.drag = null;
|
||||
} else return;
|
||||
playSfx('ui'); hudDirty = true;
|
||||
}
|
||||
function cycleWall(d) {
|
||||
// prefer materials you actually have, but allow choosing any
|
||||
const have = WALL_LIST.filter(m => bag[m] > 0);
|
||||
const list = have.length ? have.concat(WALL_LIST.filter(m => !have.includes(m))) : WALL_LIST;
|
||||
let k = list.indexOf(GAME.wallMat); k = (k + d + list.length) % list.length;
|
||||
GAME.wallMat = list[k];
|
||||
}
|
||||
function rotate() {
|
||||
const t = toolId();
|
||||
if (GAME.paste && GAME.clip) GAME.clip = flipBlueprint(GAME.clip);
|
||||
else if (t === 'build') {
|
||||
const P = piece();
|
||||
if (pieceUsesWall(P)) cycleWall(1);
|
||||
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
|
||||
else return;
|
||||
} else if (t === 'vacuum') { toggleCollectUnderCursor(); return; }
|
||||
else if (t === 'drill') { tuneUnderCursor(); return; }
|
||||
else return;
|
||||
playSfx('ui'); hudDirty = true;
|
||||
}
|
||||
function toggleCollect(m) {
|
||||
if (!m) return;
|
||||
if (GAME.collect.has(m)) GAME.collect.delete(m); else GAME.collect.add(m);
|
||||
try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ }
|
||||
lastBagKey = ''; hudDirty = true; playSfx('ui');
|
||||
}
|
||||
function pickFilterUnderCursor() {
|
||||
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
const bar = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H);
|
||||
const m = bar ? bar.m : grainAt(ax, ay);
|
||||
if (!m || !filterable(m)) { setStatus('Aim at something loose to add it to the filter.', 'info'); return; }
|
||||
toggleFilterMat(m);
|
||||
}
|
||||
function toggleFilterMat(m) {
|
||||
const f = GAME.filterCfg, k = f.m.indexOf(m);
|
||||
if (k >= 0) f.m.splice(k, 1); else f.m.push(m);
|
||||
f.m.sort((a, b) => a - b);
|
||||
hudDirty = true; lastBagKey = ''; playSfx('ui');
|
||||
setStatus(`New filters ${filterSentence(f)}.`, 'info');
|
||||
}
|
||||
function toggleCollectUnderCursor() {
|
||||
const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; }
|
||||
toggleCollect(m);
|
||||
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked. While anything is ticked, the vacuum takes only ticked materials' : GAME.collect.size ? 'unticked' : 'unticked. Nothing is ticked, so the vacuum takes what you start on'}.`, 'info');
|
||||
}
|
||||
function setBrush(d) {
|
||||
const t = toolId();
|
||||
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
|
||||
if (t === 'build' || t === 'blueprint' || t === 'ignite') return;
|
||||
GAME.brush[GAME.tool] = Math.max(0, Math.min(t === 'vacuum' ? 6 : 8, GAME.brush[GAME.tool] + d)); hudDirty = true;
|
||||
}
|
||||
// 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;
|
||||
return GAME.pieceDir[P.id] || 0;
|
||||
}
|
||||
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
|
||||
// What a filter can hold: loose things and bars you have found, plus anything it already holds
|
||||
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
|
||||
function matList(ms) {
|
||||
const n = ms.map(m => MD[m].name.toLowerCase());
|
||||
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
|
||||
}
|
||||
// What a filter does, in words: what falls through and what rides along
|
||||
function filterParts(f) {
|
||||
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
|
||||
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
|
||||
}
|
||||
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
|
||||
// Change the whole run of matching filters at once
|
||||
function applyFilterRun(run, f) {
|
||||
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
|
||||
filtersDirty = true;
|
||||
}
|
||||
function openFilterPicker(cur, onDone, title) {
|
||||
const sel = new Set(cur.m);
|
||||
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
|
||||
const list = filterChoices(cur.m);
|
||||
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
|
||||
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
|
||||
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
|
||||
</div>
|
||||
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
|
||||
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
|
||||
+ `<p class="note fp-sum" id="fp-sum"></p>`;
|
||||
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
|
||||
const box = $('dlg-body');
|
||||
function sync() {
|
||||
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
const f = { m: [...sel].sort((a, b) => a - b), inv };
|
||||
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
|
||||
}
|
||||
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
|
||||
sync();
|
||||
}
|
||||
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
|
||||
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
|
||||
function pieceCostNow(P) {
|
||||
if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls);
|
||||
if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS);
|
||||
return P.cost;
|
||||
}
|
||||
function renderSelCard() {
|
||||
const t = toolId(), c = $('selcard');
|
||||
let h = '';
|
||||
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
|
||||
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
|
||||
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
|
||||
if (sel === 'cut') {
|
||||
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
|
||||
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
|
||||
} else if (sel === 'copy') {
|
||||
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
|
||||
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
|
||||
} else if (GAME.paste && GAME.clip) {
|
||||
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
|
||||
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
|
||||
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
|
||||
} else if (t === 'drill') {
|
||||
const b = BIT_TIERS[player.bitTier];
|
||||
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
|
||||
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
|
||||
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
|
||||
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
|
||||
} else if (t === 'vacuum') {
|
||||
ensureDropMat();
|
||||
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0;
|
||||
const list = [...GAME.collect];
|
||||
const mixL = mixList();
|
||||
const blowRow = GAME.blowMix ? `<div class="row cycle"><kbd>Q</kbd>${mixL.map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}<span class="name grow">Ticked mix</span><span class="num chip">${mixL.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span><kbd>E</kbd></div>`
|
||||
: m ? `<div class="row cycle"><kbd>Q</kbd><span class="sw" style="background:${rgbOf(m)}"></span><span class="name grow">${esc(MD[m].name)}</span><span class="num chip ${bag[m] ? '' : 'bad'}">${bag[m] ? bag[m].toLocaleString('en-US') : 'none left'}</span><kbd>E</kbd></div>`
|
||||
: `<div class="row"><span class="name">Vacuum</span><span class="sub">bag empty</span></div>`;
|
||||
const sucking = f < 0 ? 'everything' : list.length ? 'only the ticked materials' : f > 0 ? 'only ' + esc(MD[f].name.toLowerCase()) : 'what you start on';
|
||||
h = `${blowRow}
|
||||
<div class="sub"><b>Left</b> sucks up ${sucking}${list.length && f >= 0 ? ' (untick them all to pick by what you click on)' : ''}. Standing still, <kbd>Shift</kbd> takes everything.</div>
|
||||
<div class="sub"><b>Right</b> blows ${GAME.blowMix ? 'an even mix of the ticked materials' : m ? 'only ' + esc(MD[m].name.toLowerCase()) : 'the chosen material'} out. ${mixL.length > 1 || GAME.blowMix ? '<kbd>Q</kbd>/<kbd>E</kbd> switch between single materials and the ticked mix.' : 'Tick 2+ materials to blow them as a mix.'}</div>
|
||||
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">nozzle ${GAME.brush[1]}</span>${list.map(k => `<span class="chip"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(k)}"></span>${esc(MD[k].name)}</span>`).join('')}${list.length ? '' : '<span class="sub">Nothing ticked. <kbd>R</kbd> ticks what is under the cursor.</span>'}</div>`;
|
||||
} else if (t === 'build') {
|
||||
const P = piece();
|
||||
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
|
||||
// while dragging a line of belts, filters or tubes, the direction is the drag's
|
||||
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
|
||||
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
|
||||
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
|
||||
const cost = pieceCostNow(P);
|
||||
const size = P.kind === 'machine' ? `${P.bw}×${P.bh} blocks` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh} blocks` : '1 block';
|
||||
const fc = GAME.filterCfg;
|
||||
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
|
||||
const variant = pieceUsesWall(P) ? `<span class="chip hot"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R</span>`
|
||||
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
|
||||
: dir ? dirChip : '';
|
||||
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">${esc(P.name)}</span>${variant}<kbd>E</kbd></div>
|
||||
<div class="row">${chipCost(cost)}<span class="chip">${size}</span>${P.power ? `<span class="chip">${P.power} power</span>` : ''}${GAME.ctrl ? '<span class="chip hot">Replace · Ctrl</span>' : ''}</div>
|
||||
<div class="sub">${esc(P.desc)}</div>
|
||||
${P.filter ? `<div class="sub">New ones will ${esc(filterSentence(GAME.filterCfg))}. <kbd>F</kbd> sets materials and mode (or click materials in the bag, or <kbd>T</kbd> over one); <kbd>F</kbd> on a placed filter changes its whole row.</div>` : ''}
|
||||
<div class="sub">${P.kind === 'block' ? 'Drag for a line (any length: what is out of reach becomes ghosts), Shift-drag for a rectangle.' : 'Click to place.'}${P.dirs && P.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses <kbd>R</kbd>.' : P.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(P) ? ' <kbd>R</kbd> picks the material.' : ''}</div>
|
||||
<div class="sub keys-mini"><kbd>Ctrl</kbd> replace · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · right click take back · <kbd>G</kbd> pick · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste</div>${ng ? `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>` : ''}`;
|
||||
} else if (t === 'ignite') {
|
||||
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
|
||||
}
|
||||
h += `<div class="status${GAME.statusKind === 'info' ? ' info' : ''}">${esc(status)}</div>`;
|
||||
if (c.innerHTML !== h) c.innerHTML = h;
|
||||
}
|
||||
let lastBagKey = '';
|
||||
function renderBag() {
|
||||
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
|
||||
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
|
||||
$('bagbar').firstChild.style.width = pct + '%';
|
||||
$('bagbar').classList.toggle('full', bagTotal >= cap);
|
||||
const l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m));
|
||||
const t = toolId();
|
||||
const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '')).join(',') + '|' + GAME.dropMat + '|' + GAME.blowMix + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx;
|
||||
if (key === lastBagKey) return;
|
||||
lastBagKey = key;
|
||||
const box = $('baglist'); box.innerHTML = '';
|
||||
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; }
|
||||
for (const m of l) {
|
||||
const sel = (t === 'vacuum' && (GAME.blowMix ? GAME.collect.has(m) : m === GAME.dropMat)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
|
||||
const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') });
|
||||
const tick = el('button', { class: 'tick' + (GAME.collect.has(m) ? ' on' : ''), title: GAME.collect.has(m) ? 'Ticked: the vacuum takes only ticked materials, and they make up the Ticked mix' : 'Tick to vacuum only ticked materials', 'aria-label': `Tick ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m) });
|
||||
tick.onclick = () => toggleCollect(m);
|
||||
const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` });
|
||||
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m].toLocaleString('en-US')}</span>`;
|
||||
b.onclick = () => {
|
||||
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
|
||||
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
|
||||
else { GAME.dropMat = m; GAME.matChosen = true; GAME.blowMix = false; if (t !== 'vacuum') selectTool(1); }
|
||||
hudDirty = true; lastBagKey = ''; playSfx('ui');
|
||||
};
|
||||
row.appendChild(tick); row.appendChild(b); box.appendChild(row);
|
||||
}
|
||||
if (t === 'vacuum' && mixList().length > 1) {
|
||||
// the ticked mix is a choice of its own, like any material
|
||||
const mx = mixList(), row = el('div', { class: 'bagrow mixrow' + (GAME.blowMix ? ' on' : '') });
|
||||
const b = el('button', { class: 'pick', title: 'Blow an even mix of the ticked materials' });
|
||||
b.innerHTML = `<span class="mixsw">${mx.slice(0, 4).map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}</span><span class="nm">Ticked mix</span><span class="n num">${mx.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span>`;
|
||||
b.onclick = () => { GAME.blowMix = true; GAME.matChosen = true; hudDirty = true; lastBagKey = ''; playSfx('ui'); };
|
||||
row.appendChild(b); box.appendChild(row);
|
||||
}
|
||||
if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click a material to blow only that, or Ticked mix to blow an even mix of the ticked ones. Ticked ones always come along when you vacuum.'));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Starting, loading and quitting a game; modal dialogs; the pause menu.
|
||||
// ---------- Start / stop ----------
|
||||
function showLoading(on, p) {
|
||||
$('loading').hidden = !on;
|
||||
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
|
||||
}
|
||||
function enterGame() {
|
||||
$('title').hidden = true; $('hud').hidden = false;
|
||||
GAME.running = true; GAME.paused = false;
|
||||
buildSky(); resizeRender(); snapCamera();
|
||||
GAME.lastAutosave = performance.now();
|
||||
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
|
||||
hudDirty = true; renderHotbar();
|
||||
}
|
||||
function startNewWorld(seed, w, h) {
|
||||
auInit();
|
||||
showLoading(true, 0);
|
||||
setTimeout(() => {
|
||||
generateWorld(seed, w, h, null);
|
||||
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
|
||||
showLoading(false);
|
||||
enterGame();
|
||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||
saveWorld('auto', true);
|
||||
}, 30);
|
||||
}
|
||||
function startFromSave(sv) {
|
||||
auInit();
|
||||
try { deserializeWorld(sv.meta, sv.buffers); }
|
||||
catch (e) {
|
||||
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
|
||||
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
|
||||
return false;
|
||||
}
|
||||
GAME.playTime = sv.playTime || 0;
|
||||
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0;
|
||||
enterGame();
|
||||
return true;
|
||||
}
|
||||
async function quitToTitle() {
|
||||
await saveWorld('main', true);
|
||||
GAME.running = false; closeAllModals();
|
||||
$('hud').hidden = true; $('title').hidden = false;
|
||||
refreshContinue();
|
||||
if (!TITLE.raf) drawTitle();
|
||||
}
|
||||
async function refreshContinue() {
|
||||
const sv = await newestSave();
|
||||
const b = $('btn-continue');
|
||||
if (sv && sv.meta && sv.meta.v === SAVE_VERSION) {
|
||||
b.hidden = false;
|
||||
const d = new Date(sv.savedAt);
|
||||
$('continue-meta').textContent = `seed ${sv.meta.seed} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
|
||||
} else b.hidden = true;
|
||||
}
|
||||
|
||||
// ---------- Modals ----------
|
||||
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); }
|
||||
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
|
||||
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
|
||||
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
|
||||
function topModal() { for (const id of ['m-dialog', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
|
||||
function dialog(title, bodyHtml, buttons) {
|
||||
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
|
||||
const foot = $('dlg-foot'); foot.innerHTML = '';
|
||||
for (const b of buttons) {
|
||||
const btn = el('button', { class: 'btn ' + (b.cls || '') }, esc(b.label));
|
||||
btn.onclick = () => { playSfx('ui'); const keep = b.fn && b.fn(); if (!keep) closeModal('m-dialog'); };
|
||||
foot.appendChild(btn);
|
||||
}
|
||||
openModal('m-dialog');
|
||||
}
|
||||
document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => { playSfx('ui'); closeModal(b.closest('.modal').id); }));
|
||||
|
||||
// pause menu
|
||||
$('pm-resume').onclick = () => { playSfx('ui'); closeModal('m-pause'); };
|
||||
$('pm-notebook').onclick = () => { playSfx('ui'); closeModal('m-pause'); openNotebook(); };
|
||||
$('pm-settings').onclick = () => { playSfx('ui'); openSettings(); };
|
||||
$('pm-save').onclick = () => { playSfx('ui'); saveWorld('main'); };
|
||||
$('pm-export').onclick = () => { playSfx('ui'); exportWorld(); };
|
||||
$('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
|
||||
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
|
||||
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
|
||||
function openPause() {
|
||||
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`;
|
||||
openModal('m-pause');
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// The settings screen.
|
||||
// ---------- Settings UI ----------
|
||||
const SET_TABS = {
|
||||
Display: [
|
||||
{ k: 'zoom', label: 'Zoom', d: 'Screen pixels per grain. Mouse wheel also zooms.', type: 'range', min: 2, max: 12, step: 1, fmt: v => v + ' px' },
|
||||
{ k: 'integerScale', label: 'Pixel-perfect scaling', d: 'Keeps every grain the same size. Turn off for smooth zoom on high-DPI screens.', type: 'toggle' },
|
||||
{ k: 'renderScale', label: 'Render resolution', d: 'Lower is faster on big or high-DPI screens.', type: 'select', opts: [[0.5, '50%'], [0.75, '75%'], [1, '100%']] },
|
||||
{ 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: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
|
||||
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'shake', label: 'Screen shake', type: 'toggle' },
|
||||
],
|
||||
Graphics: [
|
||||
{ k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] },
|
||||
{ k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] },
|
||||
{ k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] },
|
||||
{ k: 'ambient', label: 'Darkness underground', d: 'Lowest brightness in unlit caves.', type: 'range', min: 0, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'daylight', label: 'Daylight', type: 'range', min: 0.5, max: 1.2, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'glow', label: 'Bloom on hot material', type: 'select', opts: [['off', 'Off'], ['low', 'Low'], ['high', 'High']] },
|
||||
{ k: 'incandescence', label: 'Heat glow', d: 'Hot material glows red, orange and white by temperature.', type: 'toggle' },
|
||||
{ k: 'grain', label: 'Grain texture', type: 'range', min: 0, max: 1.5, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'particles', label: 'Sparks and debris', type: 'toggle' },
|
||||
{ k: 'thermalPalette', label: 'Thermal lens colors', type: 'select', opts: [['inferno', 'Inferno'], ['ironbow', 'Ironbow'], ['gray', 'Grayscale']] },
|
||||
{ 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: '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.' },
|
||||
],
|
||||
Audio: [
|
||||
{ k: 'volMaster', label: 'Master volume', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'volMusic', label: 'Music', d: 'Generated chiptune that changes with depth.', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'volSfx', label: 'Effects', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'muted', label: 'Mute everything', type: 'toggle' },
|
||||
{ k: 'muteUnfocused', label: 'Mute when the tab is hidden', type: 'toggle' },
|
||||
],
|
||||
Controls: [
|
||||
{ k: 'deadzone', label: 'Gamepad stick deadzone', type: 'range', min: 0.05, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'aimSpeed', label: 'Gamepad aim reach', d: 'How far the right stick reaches from the bot.', type: 'range', min: 0.4, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ html: () => controlsHtml() },
|
||||
],
|
||||
Game: [
|
||||
{ k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] },
|
||||
{ k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] },
|
||||
{ k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true },
|
||||
{ k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true },
|
||||
{ k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() },
|
||||
{ k: 'wipe', label: 'Delete saved worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete…', danger: true, fn: confirmWipe },
|
||||
],
|
||||
};
|
||||
let setTab = 'Display';
|
||||
function openSettings(tab) { if (tab) setTab = tab; renderSettings(); openModal('m-settings'); }
|
||||
function renderSettings() {
|
||||
const tabs = $('set-tabs'); tabs.innerHTML = '';
|
||||
for (const name of Object.keys(SET_TABS)) {
|
||||
const b = el('button', { role: 'tab', class: name === setTab ? 'on' : '', 'aria-selected': name === setTab }, name);
|
||||
b.onclick = () => { playSfx('ui'); setTab = name; renderSettings(); };
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
const body = $('set-body'); body.innerHTML = '';
|
||||
for (const it of SET_TABS[setTab]) {
|
||||
if (it.note) { body.appendChild(el('p', { class: 'note' }, esc(it.note))); continue; }
|
||||
if (it.html) { const d = el('div'); d.innerHTML = it.html(); body.appendChild(d); continue; }
|
||||
if (it.ingame && !GAME.running) continue;
|
||||
const row = el('div', { class: 'set' });
|
||||
row.appendChild(el('div', null, `<div>${esc(it.label)}</div>${it.d ? `<div class="d">${esc(it.d)}</div>` : ''}`));
|
||||
const ctl = el('div', { class: 'ctl' });
|
||||
const id = 'set-' + it.k;
|
||||
if (it.type === 'range') {
|
||||
const inp = el('input', { type: 'range', id, min: it.min, max: it.max, step: it.step, 'aria-label': it.label });
|
||||
inp.value = SET[it.k];
|
||||
const v = el('span', { class: 'v' }, esc(it.fmt(SET[it.k])));
|
||||
inp.oninput = () => { SET[it.k] = parseFloat(inp.value); v.textContent = it.fmt(SET[it.k]); applySettings(); };
|
||||
ctl.appendChild(inp); ctl.appendChild(v);
|
||||
} else if (it.type === 'toggle') {
|
||||
const t = el('button', { class: 'toggle' + (SET[it.k] ? ' on' : ''), id, role: 'switch', 'aria-checked': !!SET[it.k], 'aria-label': it.label });
|
||||
t.onclick = () => { playSfx('ui'); SET[it.k] = !SET[it.k]; t.classList.toggle('on', SET[it.k]); t.setAttribute('aria-checked', SET[it.k]); applySettings(); };
|
||||
ctl.appendChild(t);
|
||||
} else if (it.type === 'select') {
|
||||
const s = el('select', { id, 'aria-label': it.label });
|
||||
for (const [val, lab] of it.opts) { const o = el('option', { value: String(val) }, esc(lab)); if (String(SET[it.k]) === String(val)) o.selected = true; s.appendChild(o); }
|
||||
s.onchange = () => { const raw = s.value; SET[it.k] = isNaN(parseFloat(raw)) ? raw : parseFloat(raw); applySettings(); };
|
||||
ctl.appendChild(s);
|
||||
} else if (it.type === 'button') {
|
||||
const b = el('button', { class: 'btn' + (it.danger ? ' danger' : ''), id }, esc(it.text));
|
||||
b.onclick = () => { playSfx('ui'); it.fn(); };
|
||||
ctl.appendChild(b);
|
||||
}
|
||||
row.appendChild(ctl); body.appendChild(row);
|
||||
}
|
||||
}
|
||||
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; }
|
||||
auVolumes();
|
||||
if (!SET.showPerf) $('perf').textContent = '';
|
||||
}
|
||||
function toggleFullscreen() {
|
||||
try {
|
||||
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => toast('Fullscreen is not available in this view.'));
|
||||
else document.exitFullscreen();
|
||||
} catch (e) { toast('Fullscreen is not available in this view.'); }
|
||||
}
|
||||
function confirmWipe() {
|
||||
dialog('Delete saved worlds?', '<p class="note">This removes the main save and the autosave from this browser. Exported world files are not affected.</p>', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers,
|
||||
toasts and the status line. */
|
||||
|
||||
// ---------- Settings ----------
|
||||
const SET_DEFAULT = {
|
||||
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7,
|
||||
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
|
||||
thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
|
||||
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,
|
||||
};
|
||||
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 (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); }
|
||||
} catch (e) { /* storage unavailable */ }
|
||||
}
|
||||
function saveSettings() { try { localStorage.setItem('deepgrain.settings', JSON.stringify(SET)); } catch (e) { /* storage unavailable */ } }
|
||||
loadSettings();
|
||||
|
||||
// ---------- Game state ----------
|
||||
const TOOLS = [
|
||||
{ id: 'drill', name: 'Drill', key: '1' },
|
||||
{ id: 'vacuum', name: 'Vacuum', key: '2' },
|
||||
{ id: 'build', name: 'Build', key: '3' },
|
||||
{ id: 'ignite', name: 'Igniter', key: '4' },
|
||||
];
|
||||
const ICONS = {
|
||||
drill: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M4 9h9l3 3-3 3H4z"/><path d="M16 12h5M17 9.5l3 2.5-3 2.5"/><path d="M7 9v6M10 9v6"/></svg>',
|
||||
vacuum: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 15l6-6h4l2 2v4l-6 6z"/><path d="M15 11l5-5M17 4l3 3"/><circle cx="6" cy="19" r="0.8" fill="currentColor"/><circle cx="4" cy="21" r="0.6" fill="currentColor"/></svg>',
|
||||
build: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="6" width="18" height="12" rx="1"/><path d="M3 12h18M9 6v6M15 12v6"/></svg>',
|
||||
ignite: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 21c-4 0-6-2.6-6-5.6 0-3.4 3-5 3.6-8.4C12.5 9 14 11 14 13c1-.6 1.6-1.8 1.6-3 2 1.6 2.4 3.6 2.4 5.4 0 3-2 5.6-6 5.6z"/></svg>',
|
||||
blueprint: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="11" height="11" rx="1" stroke-dasharray="2.5 2"/><rect x="10" y="10" width="11" height="11" rx="1"/></svg>',
|
||||
};
|
||||
const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN, BARGL, BARSB, AUORE, AGORE, ZNORE, SNORE, RSTONE, LCLAY, RBRICK, LDIRT, LWOOD, ACORN, COAL, CHARCOAL, CUORE, FEORE, SAND, GRAVEL, RGRAN, RBAS, CULLET,
|
||||
SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS];
|
||||
function loadCollect() { try { const a = JSON.parse(localStorage.getItem('deepgrain.collect') || '[]'); return new Set(a.filter(m => m > 0 && m < NMAT && BAGOK[m])); } catch (e) { return new Set(); } }
|
||||
const GAME = {
|
||||
running: false, paused: false, tool: 0, dropMat: 0, matChosen: false, wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {},
|
||||
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
|
||||
paste: false, selDrag: null, rmbRec: null, dragDir: -1, ctrlShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
|
||||
lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
|
||||
lastAutosave: 0, playTime: 0, lastHud: 0, worldName: '',
|
||||
};
|
||||
const keys = new Set();
|
||||
let hudDirty = true;
|
||||
|
||||
// ---------- DOM helpers ----------
|
||||
const $ = id => document.getElementById(id);
|
||||
function el(tag, attrs, html) { const e = document.createElement(tag); if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]); if (html !== undefined) e.innerHTML = html; return e; }
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
function rgbOf(m) { return `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; }
|
||||
function fmtT(t) { return t >= 999.5 ? `${Math.round(t).toLocaleString('en-US')}°C` : `${Math.round(t)}°C`; }
|
||||
|
||||
// ---------- Toasts ----------
|
||||
function toast(text, opts = {}) {
|
||||
const box = $('toasts');
|
||||
while (box.children.length > 4) box.firstChild.remove();
|
||||
const t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') });
|
||||
t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text);
|
||||
box.appendChild(t);
|
||||
const life = opts.big ? 5200 : 2600;
|
||||
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 450); }, life);
|
||||
}
|
||||
function setStatus(s, kind) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; } }
|
||||
@@ -0,0 +1,46 @@
|
||||
// The title screen art.
|
||||
// ---------- Title art ----------
|
||||
const FONT5 = {
|
||||
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], G: ['01111', '10000', '10000', '10011', '10001', '10001', '01111'],
|
||||
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
|
||||
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
};
|
||||
const TITLE = { cells: [], grains: [], t: 0, raf: 0 };
|
||||
function buildTitleCells() {
|
||||
const lines = [['DEEPGRAIN', 6, 'hot'], ['FOUNDRY', 44, 'copper']];
|
||||
for (const [word, y0, kind] of lines) {
|
||||
const sc = 3, wpx = word.length * 6 * sc - sc;
|
||||
const x0 = Math.round((320 - wpx) / 2);
|
||||
[...word].forEach((ch, k) => {
|
||||
const g = FONT5[ch];
|
||||
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1')
|
||||
for (let a = 0; a < sc; a++) for (let b = 0; b < sc; b++) TITLE.cells.push({ x: x0 + (k * 6 + c) * sc + a, y: y0 + r * sc + b, kind, n: Math.random() });
|
||||
});
|
||||
}
|
||||
}
|
||||
function drawTitle() {
|
||||
const c = $('titleart'); if (!c || $('title').hidden) { TITLE.raf = 0; return; }
|
||||
const ctx = c.getContext('2d'); const t = (TITLE.t += 1);
|
||||
const img = ctx.createImageData(320, 80), px = new Uint32Array(img.data.buffer);
|
||||
px.fill(0x00000000);
|
||||
for (const g of TITLE.cells) {
|
||||
const w = 0.55 + 0.45 * Math.sin(t * 0.045 + g.x * 0.09 + g.n * 6);
|
||||
let r, gg, b;
|
||||
if (g.kind === 'hot') { const T = 1050 + w * 650 + g.n * 120; const i = glowIndex(T); r = GLOW_R[i]; gg = GLOW_G[i]; b = GLOW_B[i]; }
|
||||
else { const v = (g.n - 0.5) * 30; r = 200 + v + w * 20; gg = 122 + v + w * 12; b = 76 + v; }
|
||||
px[g.y * 320 + g.x] = 0xff000000 | (Math.max(0, Math.min(255, b)) << 16) | (Math.max(0, Math.min(255, gg)) << 8) | Math.max(0, Math.min(255, r));
|
||||
}
|
||||
// falling grains under the words
|
||||
if (Math.random() < 0.6) TITLE.grains.push({ x: 60 + Math.random() * 200, y: 28, v: 0.3 + Math.random() * 0.4, hot: Math.random() < 0.6 });
|
||||
for (let k = TITLE.grains.length - 1; k >= 0; k--) {
|
||||
const g = TITLE.grains[k]; g.y += g.v; g.v += 0.02;
|
||||
if (g.y > 79) { TITLE.grains.splice(k, 1); continue; }
|
||||
if (g.y > 41 && g.y < 66 && px[(g.y | 0) * 320 + (g.x | 0)]) { TITLE.grains.splice(k, 1); continue; }
|
||||
px[(g.y | 0) * 320 + (g.x | 0)] = g.hot ? 0xff3aa0ff : 0xff5a8cc8;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
TITLE.raf = requestAnimationFrame(drawTitle);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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)
|
||||
const { load, flatWorld, ticks } = require('./harness');
|
||||
const { run } = load();
|
||||
const id = process.argv[2] || 'smelter1', ore = +(process.argv[3] || 50), coal = +(process.argv[4] || 50), firings = +(process.argv[5] || 3);
|
||||
const roll = +(process.argv[6] || 0), mode = process.argv[7] || 'mix';
|
||||
flatWorld(run, 240, 160, 100);
|
||||
if (process.env.PATCH) run(process.env.PATCH); // try a rule change without editing the game
|
||||
for (let k = 0; k < roll; k++) run('rnd()');
|
||||
run(`bag[RSTONE] = 900; bag[LCLAY] = 200; bagTotal = 1100;`);
|
||||
const d = run(`JSON.stringify(STARTER_DESIGNS.find(d => d.id === '${id}').bp())`);
|
||||
const bp = JSON.parse(d);
|
||||
const ox = 24, oy = 25 - bp.bh;
|
||||
run(`bot.x = ${ox * 4 + bp.bw * 2 - 2}; bot.y = ${oy * 4 - 14}; pasteGhost(${d}, ${ox}, ${oy});`);
|
||||
for (let k = 0; k < 40 && run('ghostBlocks.size + ghostMolds.length') > 0; k++) ticks(run, 30);
|
||||
const ch = run(`(STARTER_DESIGNS.find(d => d.id === '${id}').chamber || 3)`);
|
||||
const cx0 = (ox + 1) * 4, cx1 = (ox + bp.bw - 1) * 4 - 1, floorY = (oy + 1 + ch) * 4 - 1;
|
||||
const count = (m, inMold) => run(`(()=>{let n=0;const cav=new Set(molds.flatMap(m=>m.cavs.flat()));for(let i=0;i<N;i++)if(mat[i]===${m}&&(${inMold}?cav.has(i):!cav.has(i)))n++;return n})()`);
|
||||
let totalBars = 0, bits = 0;
|
||||
function dump() {
|
||||
const chm = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('SCRCU')]: '%' };
|
||||
for (let y = oy * 4 - 2; y < (oy + bp.bh) * 4 + 2; y++) { let l = ''; for (let x = ox * 4 - 2; x < (ox + bp.bw) * 4 + 2; x++) { const i = y * run('W') + x; const m = run(`mat[${i}]`); l += run(`part[${i}]`) === 5 && m === 0 ? '+' : (chm[m] || '?'); } console.log(l); }
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let f = 0; f < firings; f++) {
|
||||
run(`bag[CUORE] += ${ore}; bag[COAL] += ${coal}; bagTotal += ${ore + coal};`);
|
||||
const blow = seq => {
|
||||
run(`bot.x = ${(cx0 + cx1) / 2 - 2}; bot.y = ${oy * 4};`);
|
||||
for (const s of seq) for (let k = 0; k < 400 && run(s === 'mix' ? 'bag[CUORE] + bag[COAL]' : `bag[${s}]`) > 0; k++) {
|
||||
const tx = cx0 + 1 + (k * 5) % (cx1 - cx0 - 2);
|
||||
run(s === 'mix' ? `toolBlow(${tx}, ${floorY - 1}, 0, CUORE, [CUORE, COAL]); simTick();` : `toolBlow(${tx}, ${floorY - 1}, 0, ${s}, null); simTick();`);
|
||||
}
|
||||
};
|
||||
// like a player: hold the igniter on a few spots of coal that touch air, or on top of the pile if none shows
|
||||
const light = () => {
|
||||
let spots = run(`(()=>{const out=[];for(let y=${oy * 4 + 4};y<=${floorY};y++)for(let x=${cx0};x<=${cx1};x++){const i=y*W+x;if(mat[i]===COAL&&mat[i-W]===EMPTY)out.push([x,y]);}return out})()`);
|
||||
if (!spots.length) spots = run(`(()=>{const out=[];for(let x=${cx0};x<=${cx1};x++)for(let y=${oy * 4 + 4};y<=${floorY};y++){const i=y*W+x;if(PH[mat[i]]===PH_POWDER){out.push([x,y]);break;}}return out})()`);
|
||||
const pick = [spots[0], spots[Math.floor(spots.length / 2)], spots[spots.length - 1]].filter(Boolean);
|
||||
run(`for (let k=0;k<50;k++){ ${pick.map(([x, y]) => `toolIgnite(${x}, ${y});`).join('')} simTick(); }`);
|
||||
};
|
||||
if (mode === 'vent') {
|
||||
run(`bot.x = ${(cx0 + cx1) / 2 - 2}; bot.y = ${oy * 4 - 16};`);
|
||||
for (let k = 0; k < 600 && run('bag[CUORE] + bag[COAL]') > 0; k++) run(`toolBlow(${(cx0 + cx1) >> 1}, ${floorY - 2}, 0, CUORE, [CUORE, COAL]); simTick();`);
|
||||
ticks(run, 60); light();
|
||||
} else if (mode === 'litfirst') {
|
||||
// light the coal, then blow the ore in on top of the fire
|
||||
blow(['COAL']); ticks(run, 30); light(); ticks(run, 180); blow(['CUORE']);
|
||||
} else {
|
||||
blow(mode === 'layers' ? ['CUORE', 'COAL'] : mode === 'coalfirst' ? ['COAL', 'CUORE'] : ['mix']);
|
||||
ticks(run, mode === 'mixwait' ? 3600 : 60);
|
||||
light();
|
||||
}
|
||||
run(`bot.x = ${ox * 4 - 30}; bot.y = ${oy * 4 - 20};`);
|
||||
if (process.env.WATCH) {
|
||||
// a picture every 10 s: material, and temperature in hundreds of degrees
|
||||
const chw = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('SCRCU')]: '%' };
|
||||
for (let s = 0; s < 9; s++) {
|
||||
ticks(run, 600);
|
||||
const rows = JSON.parse(run(`JSON.stringify((()=>{const o=[];for(let y=${oy * 4 + 2};y<=${floorY + 9};y++){const r=[];for(let x=${cx0 - 1};x<=${cx1 + 1};x++){const i=y*W+x;r.push([mat[i],part[i],Math.round(temp[i]),flags[i]&F_BURN?1:0]);}o.push(r);}return o})())`));
|
||||
console.log(`-- ${(s + 1) * 10}s`);
|
||||
for (const r of rows) console.log(r.map(([m, p, t, b]) => (p === 5 && m === 0 ? '+' : b ? '*' : (chw[m] || '?'))).join('') + ' ' + r.map(([m, p, t]) => t < 100 ? ' ' : String(Math.min(9, Math.floor(t / 150)))).join(''));
|
||||
}
|
||||
} else ticks(run, 5400);
|
||||
const st = run(`JSON.stringify(molds.map(m => m.state + (m.flaw ? ':' + m.flaw : '')))`);
|
||||
if (process.env.DUMP === 'each') { console.log('-- before knock-out, firing', f + 1, st); dump(); }
|
||||
const msgs = JSON.parse(run(`JSON.stringify(molds.map(m => collectMold(m)))`));
|
||||
const bars = run('ents.length'); totalBars += bars;
|
||||
if (run('player.bitTier') > bits) bits = run('player.bitTier');
|
||||
run('for (const e of ents.slice()) removeEnt(e);');
|
||||
const left = { ore: count(run('CUORE'), false), coal: count(run('COAL'), false), slag: count(run('SLAG'), false), ash: count(run('ASH'), false), copperOut: count(run('COPPER'), false) + count(run('SCRCU'), false) };
|
||||
results.push({ f: f + 1, st, msg: msgs[0], bars, ...left });
|
||||
console.log(`firing ${f + 1}: ${st} -> ${msgs[0]} | bars ${bars} | left in furnace: ore ${left.ore}, coal ${left.coal}, slag ${left.slag}, ash ${left.ash}, copper outside mold ${left.copperOut}`);
|
||||
if (id === 'bitfurnace') run('player.bitTier = 0;');
|
||||
}
|
||||
console.log(`TOTAL bars ${totalBars} bit ${bits}`);
|
||||
if (process.env.DUMP) dump();
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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.
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, '../src');
|
||||
let starts = []; // [first line in the joined script, file]
|
||||
function where(line) {
|
||||
let f = starts[0];
|
||||
for (const s of starts) if (s[0] <= line) f = s; else break;
|
||||
return f ? `${f[1]}:${line - f[0] + 1}` : `sim:${line}`;
|
||||
}
|
||||
function fixStack(e) {
|
||||
if (e && typeof e.stack === 'string') e.stack = e.stack.replace(/sim-bundle\.js:(\d+)/g, (_, n) => 'src/' + where(+n));
|
||||
return e;
|
||||
}
|
||||
function load() {
|
||||
const man = JSON.parse(fs.readFileSync(path.join(SRC, 'manifest.json'), 'utf8'));
|
||||
const parts = []; let line = 1; starts = [];
|
||||
for (const f of man.sim) {
|
||||
const text = fs.readFileSync(path.join(SRC, f), 'utf8').replace(/\n+$/, '');
|
||||
starts.push([line, f]); parts.push(text); line += text.split('\n').length;
|
||||
}
|
||||
try { vm.runInThisContext(parts.join('\n'), { filename: 'sim-bundle.js' }); } catch (e) { throw fixStack(e); }
|
||||
const run = (src) => { try { return vm.runInThisContext(src); } catch (e) { throw fixStack(e); } };
|
||||
return { run };
|
||||
}
|
||||
|
||||
// A test suite's checks: check(name, ok, detail) prints PASS / FAIL; done() prints the total and
|
||||
// sets a failing exit code, so test/run_all.py can tell.
|
||||
function suite() {
|
||||
let fails = 0;
|
||||
const check = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (extra !== undefined ? ` [${extra}]` : '')); if (!cond) fails++; };
|
||||
const done = () => { console.log(fails ? `${fails} FAILED` : 'ALL PASS'); if (fails) process.exitCode = 1; };
|
||||
return { check, done, get fails() { return fails; } };
|
||||
}
|
||||
|
||||
// Build a flat test world: sky above row `ground`, stone below.
|
||||
function flatWorld(run, w, h, ground, opts = {}) {
|
||||
run(`
|
||||
alloc(${w}, ${h});
|
||||
worldSeed = 7;
|
||||
for (let x = 0; x < W; x++) { surfY[x] = ${ground}; dirtY[x] = ${ground}+10; graniteY[x] = H; basaltY[x] = H; magmaY[x] = H; }
|
||||
computeAmbient(); fillNoise();
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||||
const i = y*W+x; mat[i] = y >= ${ground} ? STONE : EMPTY; flags[i] = y >= ${ground} ? F_NAT : 0; temp[i] = AMB[y];
|
||||
}
|
||||
bot.x = (W>>1) - 8; bot.y = ${ground} - 30;
|
||||
bag = new Int32Array(NMAT); bagTotal = 0;
|
||||
player.bitTier = 0; player.tankTier = 3; player.lens = true; player.flask = true;
|
||||
updateRoof(); resetWeather(); weather.next = 1e15; // no showers unless a test asks for one
|
||||
`);
|
||||
}
|
||||
function put(run, x, y, m, t) { run(`setCell(${y}*W+${x}, ${m}, ${t === undefined ? `AMB[${y}]` : t}, 0)`); }
|
||||
function rect(run, x0, y0, x1, y1, m) { run(`for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) setCell(y*W+x, ${m}, AMB[y], 0)`); }
|
||||
function count(run, m, x0 = 0, y0 = 0, x1 = null, y1 = null) {
|
||||
return run(`(()=>{let n=0; const X1=${x1 === null ? 'W-1' : x1}, Y1=${y1 === null ? 'H-1' : y1}; for (let y=${y0};y<=Y1;y++) for (let x=${x0};x<=X1;x++) if (mat[y*W+x]===${m}) n++; return n;})()`);
|
||||
}
|
||||
function maxTemp(run, x0, y0, x1, y1) {
|
||||
return run(`(()=>{let t=-1e9; for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) t=Math.max(t,temp[y*W+x]); return Math.round(t);})()`);
|
||||
}
|
||||
function ticks(run, n) { run(`for (let k=0;k<${n};k++) simTick();`); }
|
||||
|
||||
module.exports = { load, suite, flatWorld, put, rect, count, maxTemp, ticks };
|
||||
|
||||
// ASCII cross-section: one char per cell
|
||||
const GLYPH = { 4: ':', 34: '<', 35: '>', 36: '-', 37: '|', 38: 'H', 0: ' ', 7: '#', 6: 'c', 20: 'B', 13: '.', 15: 'o', 17: 'f', 18: ',', 19: 's', 22: 'C', 23: 'I', 24: 'S', 27: '~', 28: '=', 29: '%', 25: 'w', 31: '"', 32: ';', 33: '^', 26: 'M', 21: 'g', 30: '*' };
|
||||
function dump(run, x0, y0, x1, y1, withTemp) {
|
||||
const rows = run(`(()=>{const r=[];for(let y=${y0};y<=${y1};y++){const row=[];for(let x=${x0};x<=${x1};x++){const i=y*W+x;row.push([mat[i],Math.round(temp[i]),flags[i]&F_BURN?1:0]);}r.push(row);}return r;})()`);
|
||||
for (const row of rows) {
|
||||
let a = '', b = '';
|
||||
for (const [m, t, burn] of row) {
|
||||
let g = GLYPH[m] !== undefined ? GLYPH[m] : '?';
|
||||
if (burn) g = g === '.' ? '*' : g;
|
||||
a += g;
|
||||
b += t >= 1500 ? '9' : t >= 1300 ? '8' : t >= 1085 ? '7' : t >= 900 ? '6' : t >= 700 ? '5' : t >= 500 ? '4' : t >= 300 ? '3' : t >= 150 ? '2' : t >= 60 ? '1' : '0';
|
||||
}
|
||||
console.log(a + (withTemp ? ' ' + b : ''));
|
||||
}
|
||||
}
|
||||
module.exports.dump = dump;
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
"""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'."""
|
||||
import json, sys, pathlib
|
||||
from playwright.sync_api import sync_playwright
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True)
|
||||
url = (root / 'dist' / 'index.html').as_uri()
|
||||
fails = []
|
||||
def check(name, cond, extra=''):
|
||||
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
|
||||
if not cond: fails.append(name)
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
ctx = b.new_context(viewport={'width': 1280, 'height': 800})
|
||||
pg = ctx.new_page()
|
||||
logs = []
|
||||
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
|
||||
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
|
||||
pg.goto(url); pg.wait_for_timeout(300)
|
||||
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000)
|
||||
pg.wait_for_timeout(500)
|
||||
ev = pg.evaluate
|
||||
look = sys.argv[2] if len(sys.argv) > 2 else 'pixel'
|
||||
check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom'))
|
||||
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
|
||||
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
|
||||
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
|
||||
def cell_to_screen(x, y):
|
||||
return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()')
|
||||
def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2)
|
||||
def hold(x, y, ms, button='left'):
|
||||
pg.mouse.move(x, y); pg.mouse.down(button=button); pg.wait_for_timeout(ms); pg.mouse.up(button=button)
|
||||
def select(key, cond, step='e', n=16):
|
||||
pg.keyboard.press(key); pg.wait_for_timeout(60)
|
||||
for _ in range(n):
|
||||
if ev(cond): return True
|
||||
pg.keyboard.press(step); pg.wait_for_timeout(30)
|
||||
return ev(cond)
|
||||
def settle(ms=300): pg.wait_for_timeout(ms)
|
||||
def drag(fa, fc, steps=6, shift=False, hold_key=None):
|
||||
# move to the start, let the camera settle, then drag using fresh screen positions
|
||||
pg.mouse.move(*fa()); pg.wait_for_timeout(900)
|
||||
a = fa(); pg.mouse.move(*a); pg.wait_for_timeout(50); c = fc()
|
||||
if shift: pg.keyboard.down('Shift')
|
||||
if hold_key: pg.keyboard.down(hold_key); pg.wait_for_timeout(40)
|
||||
pg.mouse.down(); pg.mouse.move(*c, steps=steps); pg.mouse.up()
|
||||
if hold_key: pg.keyboard.up(hold_key)
|
||||
if shift: pg.keyboard.up('Shift')
|
||||
pg.wait_for_timeout(100)
|
||||
def refill(js): ev(js + '; bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k]; hudDirty = true;')
|
||||
ev('player.tankTier = 3')
|
||||
# no trees right around the work area: a test fire that spreads into one would block the belt row
|
||||
ev('(() => { const x0 = Math.floor(bot.x) - 70, x1 = Math.floor(bot.x) + 30; for (let y = 0; y < surfY[Math.floor(bot.x)] + 4; y++) for (let x = x0; x < x1; x++) bgm[y * W + x] = 0; findTreeBases(); })()')
|
||||
bx, by = ev('[Math.floor(bot.x+2), Math.floor(bot.y+2)]')
|
||||
gy = ev(f'surfY[{bx}]')
|
||||
|
||||
# 1. drill: rock breaks loose, nothing enters the bag
|
||||
pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900)
|
||||
sx, sy = cell_to_screen(bx + 6, gy + 3)
|
||||
hold(sx, sy, 3500)
|
||||
settle(700)
|
||||
check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug'))
|
||||
check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal'))
|
||||
# the resonance drill's controls: Q/E pulse width (medium to start), R tunes to what is under the cursor
|
||||
check('the drill starts on a medium pulse', ev('GAME.drillW') == 1, ev('GAME.drillW'))
|
||||
pg.keyboard.press('e'); settle(60)
|
||||
w_wide = ev('GAME.drillW')
|
||||
pg.keyboard.press('q'); pg.keyboard.press('q'); pg.keyboard.press('q'); settle(60)
|
||||
check('E widens the pulse and Q narrows it, stopping at the ends', w_wide == 2 and ev('GAME.drillW') == 0, f"{w_wide} then {ev('GAME.drillW')}")
|
||||
check('the drill card names the pulse width', 'narrow pulse' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:80])
|
||||
pg.keyboard.press('e'); settle(60)
|
||||
pg.mouse.move(*cell_to_screen(bx + 6, gy + 8)); settle(200)
|
||||
pg.keyboard.press('r'); settle(60)
|
||||
tuned = ev('GAME.drillTune')
|
||||
check('R tunes the drill to the rock under the cursor', tuned != 0, ev('GAME.drillTune && MD[GAME.drillTune].name'))
|
||||
pg.keyboard.press('r'); settle(60)
|
||||
check('and R again untunes it', ev('GAME.drillTune') == 0)
|
||||
check('dig milestone', ev('!!ledger.dig'))
|
||||
check('discovery cards appear for new materials', pg.locator('#cards .card-pop').count() > 0, pg.locator('#cards .card-pop').count())
|
||||
check('no fixed tutorial panel', pg.locator('#objective').count() == 0)
|
||||
pg.screenshot(path=str(out / '01-dug.png'))
|
||||
check('trees live in the background layer', ev('bgm.some(v => v === WOOD)') and ev('(() => { for (let i = 0; i < N; i++) if (mat[i] === LEAVES) return false; return true; })()'))
|
||||
|
||||
# 2. vacuum, left button: only the material you start on
|
||||
pg.keyboard.press('2'); settle(100)
|
||||
tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
|
||||
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
|
||||
check('loose material lies around the hole', tgt is not None)
|
||||
pg.mouse.move(*cell_to_screen(tgt[0], tgt[1])); settle(900)
|
||||
vx, vy = cell_to_screen(tgt[0], tgt[1])
|
||||
hold(vx, vy, 1500)
|
||||
settle(200)
|
||||
got = ev(f'bag[{tgt[2]}]')
|
||||
others = ev(f'bagTotal - bag[{tgt[2]}]')
|
||||
check('vacuum (left) picks the clicked material', got > 0, f"{ev(f'MD[{tgt[2]}].name')} x{got}")
|
||||
check('vacuum (left) leaves other materials', others == 0, others)
|
||||
# R ticks what is under the cursor; ticked materials come along with any left-click suck
|
||||
tgt2 = ev(f'''(() => {{ for (let r = 0; r < 40; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
|
||||
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
|
||||
if tgt2:
|
||||
cx, cy = cell_to_screen(tgt2[0], tgt2[1])
|
||||
pg.mouse.move(cx, cy); settle(80); pg.keyboard.press('r'); settle(80)
|
||||
check('R ticks the material under the cursor', ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
|
||||
ev('renderBag()'); settle(50)
|
||||
ticks = pg.locator('#baglist .tick.on')
|
||||
if ticks.count(): ticks.first.click(); settle(80)
|
||||
check('bag tick unticks it', not ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
|
||||
pg.screenshot(path=str(out / '02-vacuum.png'))
|
||||
|
||||
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore ticked
|
||||
def mixed_pile(x0, y0):
|
||||
ev(f"{{ const r = mulberry32(3); for (let y = {y0}; y < {y0} + 6; y++) for (let x = {x0}; x < {x0} + 14; x++) setCell(y*W+x, r() < 0.5 ? CUORE : RSTONE, AMB[y], 0); }}")
|
||||
def suck_at(x, y, ms=900, shift=False, fly=None):
|
||||
pg.mouse.move(*cell_to_screen(x, y)); settle(900)
|
||||
if fly: pg.keyboard.down(fly)
|
||||
if shift: pg.keyboard.down('Shift')
|
||||
settle(40)
|
||||
pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(40)
|
||||
if ev(f'grainAt({x}, {y})') != ev(f'grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y))'): pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(20)
|
||||
pg.mouse.down(); pg.wait_for_timeout(ms); pg.mouse.up()
|
||||
if shift: pg.keyboard.up('Shift')
|
||||
if fly: pg.keyboard.up(fly)
|
||||
settle(100)
|
||||
ev('bot.vx = bot.vy = 0;')
|
||||
px0, py0 = bx - 7, gy - 12
|
||||
def find_ore():
|
||||
return ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === CUORE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
|
||||
mixed_pile(px0, py0); settle(400)
|
||||
ore = find_ore()
|
||||
refill('bag.fill(0)')
|
||||
ev('GAME.collect = new Set([CUORE]); lastBagKey = "";')
|
||||
suck_at(*ore)
|
||||
check('with ore ticked, clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
|
||||
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
|
||||
st = ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === RSTONE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
|
||||
suck_at(*st)
|
||||
check('with ore ticked, starting on stone rubble still takes only the ore', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
|
||||
ev('GAME.collect = new Set(); lastBagKey = "";')
|
||||
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
|
||||
ore = find_ore()
|
||||
suck_at(*ore, ms=500, shift=True, fly='d')
|
||||
check('Shift held while flying boosts instead of taking everything', ev('bag[RSTONE]') == 0 or ev('bag[CUORE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
|
||||
ev(f'bot.x = {bx} - 2; bot.y = {by} - 2; bot.vx = bot.vy = 0;'); settle(300)
|
||||
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
|
||||
ore = find_ore()
|
||||
suck_at(*ore, ms=500, shift=True)
|
||||
check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
|
||||
# zoom jumps straight to the bot
|
||||
pg.mouse.move(640, 400); settle(600)
|
||||
pg.mouse.wheel(0, -100); pg.wait_for_timeout(60)
|
||||
c1 = ev('[R.camX, R.camY]'); settle(900); c2 = ev('[R.camX, R.camY]')
|
||||
check('zooming lands on the bot without a pan afterwards', abs(c1[0] - c2[0]) < 1.5 and abs(c1[1] - c2[1]) < 1.5, f'{c1} -> {c2}')
|
||||
pg.mouse.wheel(0, 100); settle(300)
|
||||
refill('bag.fill(0)')
|
||||
|
||||
# 3. right button blows the chosen material out of the bot; the choice is sticky
|
||||
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
|
||||
ok = select('2', 'GAME.dropMat === COAL && !GAME.blowMix')
|
||||
check('Q/E chooses coal to blow', ok, ev('GAME.dropMat'))
|
||||
dx_, dy_ = cell_to_screen(bx - 14, gy - 6)
|
||||
c0 = ev('bag[COAL]')
|
||||
pg.mouse.move(dx_, dy_); pg.mouse.down(button='right'); settle(150)
|
||||
check('blown grains fly out of the bot', ev('flyers.length') > 0, ev('flyers.length'))
|
||||
pg.screenshot(path=str(out / '03a-blowing.png'))
|
||||
settle(350); pg.mouse.up(button='right'); settle(800)
|
||||
check('right click blows coal out of the bag onto the ground', ev('bag[COAL]') < c0 and ev('flyers.length') == 0, f"{c0}->{ev('bag[COAL]')}")
|
||||
refill('bag[COAL] = 3')
|
||||
hold(dx_, dy_, 400, 'right'); settle(400)
|
||||
check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.dropMat === COAL'), ev('[bag[COAL], GAME.dropMat]'))
|
||||
# tick coal and ore: Shift no longer changes what is blown, the selection does
|
||||
refill('bag[COAL] = 60; bag[CUORE] = 60')
|
||||
ev('GAME.collect = new Set([COAL, CUORE]); GAME.dropMat = COAL; GAME.blowMix = false; lastBagKey = ""; hudDirty = true;')
|
||||
mx_, my_ = cell_to_screen(bx - 26, gy - 6)
|
||||
pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600)
|
||||
check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]'))
|
||||
refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200)
|
||||
pg.click('#baglist .mixrow .pick'); settle(200)
|
||||
check('the bag offers the ticked mix as a choice', ev('GAME.blowMix') and 'Ticked mix' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:120])
|
||||
pg.mouse.move(mx_, my_); pg.mouse.down(button='right'); settle(500); pg.mouse.up(button='right'); settle(600)
|
||||
dc, do_ = 60 - ev('bag[COAL]'), 60 - ev('bag[CUORE]')
|
||||
check('choosing Ticked mix, right click blows an even ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
|
||||
ev('GAME.blowMix = false;')
|
||||
ev('GAME.collect = new Set(); lastBagKey = "";')
|
||||
refill('bag[COAL] = 120')
|
||||
|
||||
# 4. igniter lights it
|
||||
pg.keyboard.press('4')
|
||||
spot = ev(f'(() => {{ for (let r = 0; r < 20; r++) for (const s of [1, -1]) {{ const x = {bx-14} + r * s; for (let y = 1; y < H; y++) {{ const m = mat[y*W+x]; if (m === EMPTY) continue; if (m === COAL && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; break; }} }} return [{bx-14}, 0]; }})()')
|
||||
pg.mouse.move(*cell_to_screen(*spot)); settle(900)
|
||||
ix, iy = cell_to_screen(*spot)
|
||||
hold(ix, iy, 2000)
|
||||
settle(1500)
|
||||
check('igniter lights coal', ev('stats.ignitions') > 0, ev('stats.ignitions'))
|
||||
check('thermal lens unlocked', ev('player.lens'))
|
||||
pg.keyboard.press('h'); settle(200)
|
||||
check('lens toggles', ev('R.lens'))
|
||||
check('the lens is named on screen and framed', 'Thermal lens' in ev("$('ovbadges').textContent") and 'lens' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
|
||||
pg.screenshot(path=str(out / '03-fire-lens.png'))
|
||||
pg.keyboard.press('h'); settle(150)
|
||||
check('and the label and frame go when it is off', pg.inner_text('#ovbadges').strip() == '' and ev("$('ovframe').className") == '', ev("$('ovframe').className"))
|
||||
|
||||
# 5. build: a line of stone wall blocks by dragging, a rectangle with Shift
|
||||
ok = select('3', "piece().id === 'wall'")
|
||||
ev('GAME.wallMat = RSTONE; hudDirty = true')
|
||||
check('build tool starts on wall blocks', ok)
|
||||
bbx, bby = ev('[Math.floor((bot.x + 2) / 4), Math.floor((bot.y + 2) / 4)]')
|
||||
rb = ev('bag[RSTONE]'); nb0 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
|
||||
drag(lambda: blk(bbx + 3, bby - 4), lambda: blk(bbx + 8, bby - 4))
|
||||
nb1 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
|
||||
check('drag places a straight line of 6 wall blocks', nb1 - nb0 == 6, f'{nb1 - nb0} blocks, {ev("GAME.status")}')
|
||||
check('each wall block costs 16 rubble', rb - ev('bag[RSTONE]') == 96, rb - ev('bag[RSTONE]'))
|
||||
drag(lambda: blk(bbx - 10, bby - 8), lambda: blk(bbx - 8, bby - 6), 4, True)
|
||||
nb2 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
|
||||
check('Shift-drag fills a 3×3 rectangle', nb2 - nb1 == 9, nb2 - nb1)
|
||||
r0 = ev('bag[RSTONE]')
|
||||
pg.mouse.move(*blk(bbx + 3, bby - 4)); pg.mouse.down(button='right'); settle(120); pg.mouse.up(button='right'); settle(80)
|
||||
check('right click takes a block back with a refund', ev('bag[RSTONE]') - r0 == 16 and ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)') == nb2 - 1, ev('bag[RSTONE]') - r0)
|
||||
pg.keyboard.press('r'); settle(60)
|
||||
check('R cycles the wall material', ev('GAME.wallMat') != ev('RSTONE'), ev('MD[GAME.wallMat].name'))
|
||||
ev('GAME.wallMat = RSTONE')
|
||||
pg.mouse.move(*blk(bbx + 5, bby - 12)); settle(150)
|
||||
pg.screenshot(path=str(out / '04-blocks.png'))
|
||||
# the bot flies through built blocks but not natural rock
|
||||
wx_, wy_ = ev(f'[{(bbx + 5) * 4 + 1}, {(bby - 4) * 4 + 1}]')
|
||||
check('bot can sit inside a built wall', not ev(f'botOverlapSolid({wx_ - 2}, {wy_ - 2})'))
|
||||
check('bot cannot sit inside natural rock', ev(f'botOverlapSolid({bx}, {gy + 12})'))
|
||||
|
||||
# 6. ingot mold: click to place, pour, knock out bars, vacuum them
|
||||
ok = select('3', "piece().id === 'ingotmold'")
|
||||
check('E cycles to the ingot mold', ok)
|
||||
spot = ev(f'''(() => {{ for (let dx = -2; dx <= 10; dx++) for (let dy = 6; dy < 14; dy++) {{ const ox = {bbx} + dx, oy = {bby} - dy; if (!canPlaceMold('ingot', ox - 1, oy) && inReach(ox * 4 + 8, oy * 4 + 2)) return [ox, oy]; }} return null; }})()''')
|
||||
check('found a spot for the mold', spot is not None)
|
||||
mx, my = blk(spot[0], spot[1])
|
||||
pg.mouse.move(mx, my); settle(100); pg.mouse.down(); pg.mouse.up(); settle(100)
|
||||
check('mold placed by click', ev('molds.length') == 1, ev('GAME.status'))
|
||||
ev('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)')
|
||||
pg.wait_for_function("molds[0].state === 'ready'", timeout=30000)
|
||||
check('bars set in the mold', ev("molds[0].state === 'ready' && molds[0].ready.length === 3"), ev('[molds[0].state, molds[0].ready.length]'))
|
||||
pg.screenshot(path=str(out / '05-mold-ready.png'))
|
||||
mo = ev('[molds[0].x + 8, molds[0].y + 2]')
|
||||
pg.mouse.move(*cell_to_screen(*mo)); settle(60); pg.keyboard.press('f'); settle(300)
|
||||
check('F knocks out three bars', ev('ents.length') == 3, ev('GAME.status'))
|
||||
settle(1500)
|
||||
pg.screenshot(path=str(out / '06-bars.png'))
|
||||
pg.keyboard.press('2'); settle(60)
|
||||
e0 = ev('[ents[0].ix + 2, ents[0].iy + 1]')
|
||||
pg.mouse.move(*cell_to_screen(*e0)); settle(900)
|
||||
hold(*cell_to_screen(*e0), 900)
|
||||
check('vacuum picks up bars', ev('bag[BARCU]') >= 1, ev('bag[BARCU]'))
|
||||
|
||||
# 7. belt line by dragging, copied with a blueprint and pasted
|
||||
refill('bag[BARCU] = 60'); ev('money = 1000')
|
||||
ok = select('3', "piece().id === 'belt'")
|
||||
check('select belt', ok)
|
||||
# keep the belt in open air, on a row clear of the dock and other machines
|
||||
shift_ = ev(f'''(() => {{ for (const dx of [0, -2, -4, -6, -8, 2]) {{ let ok = true; for (let bx = {bbx} + dx - 6; bx <= {bbx} + dx + 2 && ok; bx++) {{ const c = {bby} * 4 * W + bx * 4; if (blockP[{bby} * BW + bx] || mach[c] || mach[c + 3] || mach[c + 3 * W] || !inReach(bx * 4 + 2, {bby} * 4 + 2)) ok = false; for (let y = {bby}*4 - 4; y < {bby}*4 + 8 && ok; y++) for (let x = bx*4; x < bx*4 + 4; x++) if (mach[y*W+x]) ok = false; }} if (ok) return dx; }} return 0; }})()''')
|
||||
bbx += shift_
|
||||
ev(f"for (let y = {bby*4 - 4}; y < {bby*4 + 8}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0); for (const e of ents.slice()) if (e.ix > {(bbx-9)*4} && e.ix < {(bbx+4)*4} && e.iy > {bby*4 - 8} && e.iy < {bby*4 + 10}) removeEnt(e);")
|
||||
drag(lambda: blk(bbx - 6, bby), lambda: blk(bbx + 2, bby))
|
||||
nbelt = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
|
||||
if nbelt != 9:
|
||||
print('DEBUG belt row', ev(f'''JSON.stringify((() => {{ const o = []; for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) {{ const b = {bby} * BW + bx; o.push([bx, blockP[b], blockInReach(bx, {bby}), (() => {{ const r = []; for (let y = {bby}*4; y < {bby}*4+4; y++) for (let x = bx*4; x < bx*4+4; x++) {{ const m = mat[y*W+x]; if (m) r.push(MD[m].name); }} return [...new Set(r)].join('/'); }})()]); }} return o; }})())'''), ev('[bot.x, bot.y]'))
|
||||
check('belt blocks drawn by drag', nbelt == 9, f'{nbelt} {ev("GAME.status")}')
|
||||
check('belts dragged to the right run right', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) if (blockV[{bby} * BW + bx] !== 1) return false; return true; }})()'))
|
||||
# copy: hold C and drag a box over them
|
||||
pg.mouse.move(*blk(bbx - 6, bby)); settle(300); pg.keyboard.down('c'); settle(200)
|
||||
check('holding C shows copy on the card', 'Copy' in ev("$('selcard').textContent") and ev("selModeHeld()") == 'copy', ev("$('selcard').textContent")[:80])
|
||||
pg.keyboard.up('c'); settle(100)
|
||||
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, bby * 4 + 1), lambda: cell_to_screen((bbx + 2) * 4 + 2, bby * 4 + 2), 5, hold_key='c')
|
||||
check('blueprint copied', ev('!!GAME.clip && GAME.paste && GAME.clip.blocks.length === 9'), ev('GAME.status'))
|
||||
pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(150)
|
||||
pg.screenshot(path=str(out / '07-paste-preview.png'))
|
||||
pg.mouse.down(); pg.mouse.up()
|
||||
settle(1500)
|
||||
nbelt2 = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
|
||||
check('ghosts get built from the bag', nbelt2 == 18, f'belts={nbelt2} ghosts={ev("ghostBlocks.size")}')
|
||||
m_before = ev('money')
|
||||
pg.keyboard.press('Control+z'); settle(300)
|
||||
check('Ctrl+Z undoes the paste: what was built from it comes back down, refunded', ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)') == 9 and ev('ghostBlocks.size') == 0 and ev('money') > m_before, ev('[blockP.reduce((a, v) => a + (v === BP_BELT), 0), ghostBlocks.size, GAME.status]'))
|
||||
pg.keyboard.press('Escape'); settle(150)
|
||||
check('Esc stops pasting (and does not open the menu)', not ev('GAME.paste') and ev("$('m-pause').hidden"))
|
||||
# a line dragged right to left runs left
|
||||
ev(f"for (let y = {(bby+1)*4}; y < {(bby+2)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
|
||||
ok = select('3', "piece().id === 'belt'")
|
||||
drag(lambda: blk(bbx - 2, bby + 1), lambda: blk(bbx - 6, bby + 1))
|
||||
check('belts dragged to the left run left, and the card follows', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} - 2; bx++) if (blockP[({bby} + 1) * BW + bx] !== BP_BELT || blockV[({bby} + 1) * BW + bx] !== 0) return false; return true; }})()') and ev('GAME.pieceDir.belt') == 0, ev(f'[blockP[({bby} + 1) * BW + {bbx} - 4], blockV[({bby} + 1) * BW + {bbx} - 4], GAME.status]'))
|
||||
# cut: hold X and drag; it all comes down into the bag, ready to paste; Ctrl+Z puts it back
|
||||
m_before = ev('money')
|
||||
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, (bby + 1) * 4 + 1), lambda: cell_to_screen((bbx - 2) * 4 + 2, (bby + 1) * 4 + 2), 5, hold_key='x')
|
||||
check('X-drag cuts the row: taken down, refunded, ready to paste', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0 and ev('money') > m_before and ev('GAME.paste && GAME.clip.blocks.length === 5'), ev('[money, GAME.status]'))
|
||||
pg.keyboard.press('Escape'); settle(100)
|
||||
pg.keyboard.press('Control+z'); settle(300)
|
||||
check('Ctrl+Z puts the cut row back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and ev('money') == m_before, ev('[money, GAME.status]'))
|
||||
# Ctrl with a machine: it replaces the belt it is placed on
|
||||
ok = select('3', "piece().id === 'fan'")
|
||||
pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(400)
|
||||
pg.mouse.down(); pg.mouse.up(); settle(100)
|
||||
check('a fan on a belt says to hold Ctrl', 'Ctrl' in ev('GAME.status'), ev('GAME.status'))
|
||||
pg.keyboard.down('Control'); settle(100); pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(200)
|
||||
check('holding Ctrl shows replace mode', 'Replace' in ev("$('selcard').textContent"))
|
||||
pg.screenshot(path=str(out / '07g-replace-fan.png'))
|
||||
pg.mouse.down(); pg.mouse.up(); settle(100); pg.keyboard.up('Control'); settle(100)
|
||||
check('Ctrl-click puts the fan in place of the belt', ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4 && m.y === ({bby} + 1) * 4)") and ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0, ev('GAME.status'))
|
||||
# pipes are laid by dragging, like belts
|
||||
ok = select('3', "piece().id === 'pipe'")
|
||||
check('pipes, pumps, outlets and the refiner are in the build menu', ok and all(x in ev("PIECES.filter(p => !p.hidden).map(p => p.id)") for x in ['pipe', 'pump', 'outlet', 'refiner']))
|
||||
ev(f"for (let y = {(bby+2)*4}; y < {(bby+3)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
|
||||
drag(lambda: blk(bbx - 6, bby + 2), lambda: blk(bbx - 3, bby + 2))
|
||||
check('dragging lays a line of pipe that joins into one line', ev(f'(() => {{ let n = 0; for (let bx = {bbx} - 6; bx <= {bbx} - 3; bx++) if (blockP[({bby} + 2) * BW + bx] === BP_PIPE) n++; return n; }})()') == 4 and ev(f'pipeNetOf(({bby} + 2) * BW + {bbx} - 6).blocks.length') == 4, ev('GAME.status'))
|
||||
ok = select('3', "piece().id === 'fan'")
|
||||
pg.keyboard.press('Control+z'); settle(200)
|
||||
pg.keyboard.press('Control+z'); settle(200)
|
||||
check('and Ctrl+Z swaps the belt back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and not ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4)"), ev('GAME.status'))
|
||||
|
||||
# 7b. filter: choose several materials with F, place a line, replace belts with Ctrl, edit the whole line
|
||||
ev('seen[CUORE] = 1; seen[FEORE] = 1; seen[SAND] = 1; money = 1000; GAME.filterCfg = { m: [], inv: 0 };')
|
||||
ok = select('3', "piece().id === 'filter'")
|
||||
check('select filter', ok)
|
||||
pg.keyboard.press('f'); settle(200)
|
||||
check('F opens the filter picker', ev("!$('m-dialog').hidden") and pg.locator('#dlg-body .fp').count() >= 3, pg.locator('#dlg-body .fp').count())
|
||||
pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fd', has_text='Right').click(); settle(100)
|
||||
pg.screenshot(path=str(out / '07b-filter-picker.png'))
|
||||
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
|
||||
check('picking sets several materials and the direction', ev('JSON.stringify(GAME.filterCfg.m) === JSON.stringify([CUORE, FEORE].sort((a, b) => a - b)) && GAME.filterCfg.inv === 0 && GAME.pieceDir.filter === 1') and ev("$('m-dialog').hidden"), ev('JSON.stringify([GAME.filterCfg, GAME.pieceDir.filter])'))
|
||||
fy = bby - 5
|
||||
ev(f"for (let y = {fy*4 - 12}; y < {fy*4 + 12}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+2)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
|
||||
drag(lambda: blk(bbx - 6, fy), lambda: blk(bbx - 3, fy))
|
||||
nf = ev('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)')
|
||||
check('filters placed by drag, dropping both ores and carrying right', nf == 4 and ev(f'blockV[{fy}*BW+{bbx-5}]') == 128 and ev(f'filterInfo({fy}*BW+{bbx-5}).m.length') == 2, f"{nf} {ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-5}))')}")
|
||||
# two belts at the end of the line: plain dragging leaves them, Ctrl-dragging swaps them for filters
|
||||
ev(f"placeBlock(BP_BELT, 1, {bbx-2}, {fy}, true); placeBlock(BP_BELT, 1, {bbx-1}, {fy}, true);")
|
||||
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
|
||||
check('dragging over built belts leaves them and says how to replace', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_BELT') and 'Ctrl' in ev('GAME.status'), ev('GAME.status'))
|
||||
pg.keyboard.down('Control'); settle(50)
|
||||
pg.mouse.move(*blk(bbx - 2, fy)); settle(300)
|
||||
pg.screenshot(path=str(out / '07c-replace-preview.png'))
|
||||
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
|
||||
pg.keyboard.up('Control'); settle(50)
|
||||
check('Ctrl-dragging replaces them with filters', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_FILTER && blockP[{fy}*BW+{bbx-1}] === BP_FILTER') and ev(f'filterRun({fy}*BW+{bbx-5}).length') == 6, ev('GAME.status'))
|
||||
pg.mouse.move(*blk(bbx - 5, fy)); settle(200)
|
||||
check('hovering a filter says what it drops and carries', 'copper ore' in ev('JSON.stringify(hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)))').lower())
|
||||
ev(f"for (let y = {fy*4 - 10}; y < {fy*4 - 1}; y++) for (let x = {(bbx-6)*4}; x < {(bbx-5)*4 + 2}; x++) setCell(y*W+x, [SAND, CUORE, FEORE][(x+y)%3], 20, 0);")
|
||||
settle(500)
|
||||
pg.screenshot(path=str(out / '07d-filter-working.png'))
|
||||
pg.keyboard.press('1'); settle(60)
|
||||
pg.mouse.move(*blk(bbx - 4, fy)); settle(150)
|
||||
pg.keyboard.press('f'); settle(200)
|
||||
check('F on a placed filter opens the picker for its whole line', ev("!$('m-dialog').hidden") and '6 in this row' in pg.locator('#dlg-title').inner_text() and ev('GAME.editRun && GAME.editRun.length') == 6, pg.locator('#dlg-title').inner_text())
|
||||
pg.locator('#dlg-body .fm', has_text='Carry these').click(); pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fp', has_text='Sand').click(); settle(100)
|
||||
pg.screenshot(path=str(out / '07e-filter-edit-line.png'))
|
||||
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
|
||||
check('and changes the whole line to carry sand and drop the rest', ev(f'filterRun({fy}*BW+{bbx-6}).length') == 6 and ev(f'filterInfo({fy}*BW+{bbx-1}).inv') == 1 and ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-6}).m) === JSON.stringify([SAND])') and not ev('GAME.editRun'))
|
||||
pg.mouse.move(*blk(bbx - 4, fy - 12)); settle(150)
|
||||
pg.keyboard.press('i'); settle(300)
|
||||
check('I turns on the info overlay', ev('GAME.infoOverlay === true'))
|
||||
check('the info overlay is named on screen and framed', 'Info overlay' in ev("$('ovbadges').textContent") and 'info' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
|
||||
pg.screenshot(path=str(out / '07f-filter-tags.png'))
|
||||
pg.keyboard.press('i'); settle(100)
|
||||
check('and off again', ev('!GAME.infoOverlay'))
|
||||
|
||||
# 8. zoom range
|
||||
for _ in range(12): pg.mouse.wheel(0, -100); pg.wait_for_timeout(20)
|
||||
check('zoom reaches 12', ev('SET.zoom') == 12, ev('SET.zoom'))
|
||||
pg.screenshot(path=str(out / '08-zoom12.png'))
|
||||
for _ in range(8): pg.mouse.wheel(0, 100); pg.wait_for_timeout(20)
|
||||
|
||||
# 9. notebook + save + quit + continue
|
||||
pg.keyboard.press('n'); settle(200)
|
||||
check('notebook opens', ev("!$('m-notebook').hidden"))
|
||||
ev("nbTab = 'Pieces'; renderNotebook()"); settle(100)
|
||||
pg.screenshot(path=str(out / '09-notebook-pieces.png'))
|
||||
ev("nbTab = 'Ledger'; renderNotebook()"); settle(100)
|
||||
pg.screenshot(path=str(out / '09b-notebook-ledger.png'))
|
||||
ev("nbTab = 'Blueprints'; renderNotebook()"); settle(100)
|
||||
pg.screenshot(path=str(out / '09c-notebook-blueprints.png'))
|
||||
pg.locator('[data-starter]').first.click(); settle(150)
|
||||
check('starter design ready to place', ev("!!GAME.clip && GAME.paste && GAME.clip.molds.length === 1"))
|
||||
pg.mouse.move(*blk(bbx + 2, bby - 16)); settle(200)
|
||||
pg.screenshot(path=str(out / '09d-starter-ghost.png'))
|
||||
pg.keyboard.press('Escape'); settle(100) # stop pasting
|
||||
pg.keyboard.press('n'); settle(150)
|
||||
pg.keyboard.press('Escape'); settle(100)
|
||||
pg.keyboard.press('Escape'); settle(100)
|
||||
check('pause opens', ev("!$('m-pause').hidden"))
|
||||
pg.click('#pm-save'); settle(800)
|
||||
bag_before = ev('bagTotal'); blocks_before = ev('blockP.reduce((a,v)=>a+(v>0),0)')
|
||||
pg.click('#pm-title'); settle(1200)
|
||||
check('back at title with continue', ev("!$('title').hidden && !$('btn-continue').hidden"))
|
||||
pg.click('#btn-continue'); pg.wait_for_function('GAME.running === true', timeout=10000); settle(500)
|
||||
check('continue restores bag and blocks', ev('bagTotal') == bag_before and ev('blockP.reduce((a,v)=>a+(v>0),0)') == blocks_before, f"{bag_before}->{ev('bagTotal')}")
|
||||
|
||||
# 9b. selling: goods sell the moment they drop into the dock; orders pay a lump sum and send a truck
|
||||
ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];")
|
||||
bot_home = ev('[bot.x, bot.y]')
|
||||
ev("{ const dk = dock(); bot.x = dk.x + 12; bot.y = dk.y - 26; R.snapNext = true; }"); settle(400)
|
||||
m0 = ev('money')
|
||||
ev("{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 8; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, (x+y)&1 ? CUORE : COAL, AMB[y], 0); }")
|
||||
pg.wait_for_function(f'money > {m0} + 50', timeout=10000); settle(150)
|
||||
check('goods are paid for as they drop through the grate', ev('money') > m0 + 50, f"{m0} -> {ev('money')}")
|
||||
try: pg.wait_for_function('(R.floaters || []).length > 0', timeout=4000)
|
||||
except Exception: pass
|
||||
check('a "+$" floats up from the dock', ev('(R.floaters || []).length') > 0, ev('JSON.stringify([floatAcc, EV.length, tick, GAME.paused])'))
|
||||
pg.screenshot(path=str(out / '09c-dock-sale.png'))
|
||||
settle(300)
|
||||
check('the wallet shows the new money', pg.locator('#w-money').inner_text().replace(',', '') == '$' + str(round(ev('money'))), pg.locator('#w-money').inner_text())
|
||||
ev("seen[BARCU] = 1; orders.push({ id: 77, m: BARCU, qty: 2, got: 0, mult: 2, pay: 160, due: tick + 36000, mins: 10 }); orders.push({ id: 78, m: CUORE, qty: 300, got: 120, mult: 1.8, pay: 540, due: tick + 9000, mins: 5 }); hudDirty = true;")
|
||||
settle(200)
|
||||
check('the wallet lists open orders', pg.locator('#w-orders .ord').count() == 2, pg.locator('#w-orders').inner_text())
|
||||
pg.screenshot(path=str(out / '09d-wallet-orders.png'), clip={'x': 0, 'y': 0, 'width': 1280, 'height': 260})
|
||||
m1 = ev('money')
|
||||
ev("{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 12, dk.y + 2, 0, 0); }")
|
||||
pg.wait_for_function('ordersDone > 0', timeout=10000); settle(200)
|
||||
check('filling an order pays its lump sum', abs(ev('money') - m1 - 160) < 1, f"{m1} -> {ev('money')}")
|
||||
check('an order-filled card appears, with no truck in it', pg.locator('.card-pop', has_text='Order filled').count() >= 1 and pg.locator('.card-pop', has_text='truck').count() == 0)
|
||||
ev(f"bot.x = {bot_home[0]}; bot.y = {bot_home[1]}; R.snapNext = true;"); settle(400)
|
||||
pg.click('#wallet'); settle(250)
|
||||
check('the wallet opens the Market page', ev("!$('m-notebook').hidden && nbTab === 'Market'"))
|
||||
d0 = ev('debt'); pg.locator('[data-pay="all"]').click(); settle(200)
|
||||
check('paying toward the claim lowers the debt', ev('debt') < d0 and ev('money') == 0, f"debt {d0} -> {ev('debt')}")
|
||||
pg.screenshot(path=str(out / '09f-market.png'))
|
||||
pg.keyboard.press('Escape'); settle(150)
|
||||
|
||||
# 10. mocked gamepad
|
||||
ev('''() => {
|
||||
const mk = () => ({ connected: true, axes: [0,0,0,0], buttons: Array.from({length:17}, () => ({pressed:false, value:0})) });
|
||||
window.__pad = mk();
|
||||
navigator.getGamepads = () => [window.__pad];
|
||||
}''')
|
||||
pg.keyboard.press('1'); settle(100)
|
||||
d0 = ev('stats.dug')
|
||||
ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;")
|
||||
settle(1500)
|
||||
ev("__pad.buttons[7].pressed = false; __pad.buttons[7].value = 0;")
|
||||
settle(200)
|
||||
check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}")
|
||||
ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100)
|
||||
check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1)
|
||||
ev("__pad.buttons[9].pressed = true;"); settle(100); ev("__pad.buttons[9].pressed = false;"); settle(100)
|
||||
check('gamepad Start opens menu', ev("!$('m-pause').hidden"))
|
||||
ev("__pad.buttons[13].pressed = true;"); settle(80); ev("__pad.buttons[13].pressed = false;"); settle(80)
|
||||
check('gamepad d-pad moves menu focus', ev("document.activeElement && document.activeElement.tagName") == 'BUTTON')
|
||||
ev("__pad.buttons[1].pressed = true;"); settle(80); ev("__pad.buttons[1].pressed = false;"); settle(80)
|
||||
check('gamepad B closes menu', ev("$('m-pause').hidden"))
|
||||
pg.screenshot(path=str(out / '10-after.png'))
|
||||
|
||||
# 10b. world files: export from the pause menu as an ordinary download, import on the title screen
|
||||
pg.keyboard.press('Escape'); settle(150)
|
||||
with pg.expect_download() as dl_info:
|
||||
pg.click('#pm-export')
|
||||
dl = dl_info.value
|
||||
wf = json.loads(pathlib.Path(dl.path()).read_text())
|
||||
check('Export world file downloads a world file', wf.get('format') == 'deepgrain-world' and wf['meta']['W'] == ev('W'), dl.suggested_filename)
|
||||
want = ev('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
|
||||
pg3 = ctx.new_page()
|
||||
pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}'))
|
||||
pg3.goto(url); pg3.wait_for_timeout(300)
|
||||
pg3.set_input_files('#importfile', dl.path())
|
||||
pg3.wait_for_function('GAME.running === true', timeout=60000)
|
||||
got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
|
||||
check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}')
|
||||
pg3.close()
|
||||
pg.keyboard.press('Escape'); settle(150)
|
||||
|
||||
# 11. the largest world from the title screen
|
||||
pg2 = ctx.new_page()
|
||||
pg2.on('pageerror', lambda e: logs.append(f'PAGEERROR(large): {e}'))
|
||||
pg2.goto(url); pg2.wait_for_timeout(300)
|
||||
check('the size menu offers small, medium and large, and no deep world', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')"))
|
||||
pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '4096x1536'); pg2.click('#btn-generate')
|
||||
pg2.wait_for_function('GAME.running === true', timeout=90000)
|
||||
check('large world generates', pg2.evaluate('W === 4096 && H === 1536'), pg2.evaluate('[W, H]'))
|
||||
pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800)
|
||||
pg2.screenshot(path=str(out / '11-deep-magma.png'))
|
||||
pg2.close()
|
||||
print('\n'.join(logs) or 'no console errors')
|
||||
print('FAILS:', fails)
|
||||
b.close()
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the test suites and report.
|
||||
|
||||
python3 test/run_all.py every headless suite (test/t_*.js), in parallel
|
||||
python3 test/run_all.py pipes stone only suites whose name contains one of these words
|
||||
python3 test/run_all.py --browser also the browser tests (play.py, then t_smooth.py),
|
||||
after rebuilding dist/; they run one at a time
|
||||
python3 test/run_all.py --browser-only
|
||||
|
||||
Exit status 0 only if everything passed. Failing checks are printed with their suite.
|
||||
The headless suites are deterministic: the same code gives the same output, line for line, so
|
||||
saving the output before and after a refactor and diffing it proves nothing changed.
|
||||
"""
|
||||
import concurrent.futures as cf, pathlib, subprocess, sys, time
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
TEST = ROOT / 'test'
|
||||
|
||||
|
||||
def run_node(path):
|
||||
t0 = time.time()
|
||||
p = subprocess.run(['node', str(path)], cwd=ROOT, capture_output=True, text=True, timeout=1800)
|
||||
return path.stem, p.returncode, p.stdout + p.stderr, time.time() - t0
|
||||
|
||||
|
||||
def run_py(name):
|
||||
t0 = time.time()
|
||||
p = subprocess.run([sys.executable, str(TEST / name)], cwd=ROOT, capture_output=True, text=True, timeout=1800)
|
||||
return name, p.returncode, p.stdout + p.stderr, time.time() - t0
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
browser = '--browser' in sys.argv or '--browser-only' in sys.argv
|
||||
node = '--browser-only' not in sys.argv
|
||||
results = []
|
||||
if node:
|
||||
suites = sorted(TEST.glob('t_*.js'))
|
||||
if args: suites = [s for s in suites if any(a in s.stem for a in args)]
|
||||
with cf.ThreadPoolExecutor(max_workers=8) as ex:
|
||||
for r in ex.map(run_node, suites): results.append(r)
|
||||
if browser:
|
||||
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
|
||||
# one at a time: two headless browsers at once can starve each other and flake
|
||||
for name in ['play.py', 't_smooth.py']: results.append(run_py(name))
|
||||
bad = 0
|
||||
for name, code, out, secs in results:
|
||||
fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []']
|
||||
passes = sum(1 for l in out.splitlines() if l.startswith('PASS'))
|
||||
ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l]
|
||||
bad += not ok
|
||||
print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s")
|
||||
if not ok:
|
||||
for l in (fails or out.splitlines()[-15:]): print(' ' + l)
|
||||
print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED')
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
// Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('player.tankTier = 3; player.flask = false;');
|
||||
// 1. drill breaks stone loose; nothing goes into the bag
|
||||
run('bot.x = 116; bot.y = 92; for (let k=0;k<80;k++) toolDig(120, 104, 3)');
|
||||
check('drill leaves bag empty', run('bagTotal') === 0, run('bagTotal'));
|
||||
const rub = count(run, run('RSTONE'));
|
||||
check('drill turns stone into loose rubble', rub > 20, rub);
|
||||
ticks(run, 60);
|
||||
// 2. vacuum with a filter: only rubble
|
||||
rect(run, 100, 96, 104, 99, run('COAL'));
|
||||
ticks(run, 30);
|
||||
const got = run('toolVacuum(120, 104, 6, m => m === RSTONE).got');
|
||||
check('vacuum picks only the filtered material', run('bag[RSTONE]') > 0 && run('bag[COAL]') === 0, `${run('bag[RSTONE]')} rubble, ${run('bag[COAL]')} coal`);
|
||||
run('toolVacuum(102, 98, 4, m => true)');
|
||||
check('vacuum everything mode picks coal', run('bag[COAL]') > 0, run('bag[COAL]'));
|
||||
// 3. drop: grains fall
|
||||
const c0 = run('bag[COAL]');
|
||||
run('bot.x = 100; bot.y = 60;');
|
||||
run('for (let k=0;k<5;k++) { toolBlow(80, 70, 1, COAL); simTick(); }');
|
||||
check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length'));
|
||||
ticks(run, 120);
|
||||
check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`);
|
||||
// bot flies through built walls but not through natural rock
|
||||
run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))');
|
||||
check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)'));
|
||||
check('bot cannot overlap natural rock', run('botOverlapSolid(120, 98)'));
|
||||
// trees: water passes in front of a background trunk; cutting the base drops the tree
|
||||
run('for (let y = 80; y < 100; y++) for (let x = 140; x < 142; x++) bgm[y*W+x] = WOOD; for (let y = 72; y < 80; y++) for (let x = 136; x < 146; x++) bgm[y*W+x] = LEAVES;');
|
||||
rect(run, 130, 96, 138, 99, run('WATER'));
|
||||
ticks(run, 120);
|
||||
check('water flows past a tree trunk', count(run, run('WATER'), 141, 90, 160, 99) > 0, count(run, run('WATER'), 141, 90, 160, 99));
|
||||
run('bot.x = 134; bot.y = 88; for (let k=0;k<120;k++) toolDig(140, 99, 1)');
|
||||
ticks(run, 5);
|
||||
check('cutting the trunk base brings the tree down as wood chunks', run('bgm[85*W+140]') === 0 && count(run, run('LWOOD')) > 20, count(run, run('LWOOD')));
|
||||
// 4. wall blocks cost rubble and snap to the grid
|
||||
run('bag[RSTONE] = 200; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
|
||||
const r1 = run("JSON.stringify(placeBlocks(BP_WALL, RSTONE, blockLine(25, 20, 29, 20)))");
|
||||
check('stone wall line placed', JSON.parse(r1).n === 5 && run('bag[RSTONE]') === 200 - 80, r1 + ' bag=' + run('bag[RSTONE]'));
|
||||
check('wall cells are solid stone at grid positions', run('mat[80*W+100]') === run('STONE') && run('mat[83*W+119]') === run('STONE'));
|
||||
const rm = run("removePieceAt(101, 81)");
|
||||
check('taking a wall block back refunds its rubble', run('bag[RSTONE]') === 200 - 64, rm + ' ' + run('bag[RSTONE]'));
|
||||
// 5. ingot mold: pour molten copper, knock out bars
|
||||
run('bag[RSTONE] = 200; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
|
||||
const mo = run("placeMoldAt('ingot', 32, 22, RSTONE)");
|
||||
check('ingot mold placed', mo === '' && run('molds.length') === 1, mo);
|
||||
run('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)');
|
||||
ticks(run, 900);
|
||||
check('ingot mold sets', run('molds[0].state') === 'ready' && run('molds[0].ready.length') === 3, run('molds[0].state') + ' ' + run('molds[0].ready.length'));
|
||||
const msg = run('collectMold(molds[0])');
|
||||
check('knock-out spawns 3 bars', run('ents.length') === 3, msg);
|
||||
ticks(run, 200);
|
||||
check('bars fall and come to rest', run('ents.every(e => Math.abs(e.vy) < 0.01)'), run('JSON.stringify(ents.map(e=>[e.ix,e.iy]))'));
|
||||
check('bars count as built-up copper', run('stats.bars') === 3);
|
||||
// 6. vacuum bars
|
||||
const bx = run('ents[0].ix + 2'), by = run('ents[0].iy + 1');
|
||||
run(`toolVacuum(${bx}, ${by}, 4, m => m === BARCU)`);
|
||||
check('vacuum picks up bars', run('bag[BARCU]') >= 1 && run('ents.length') < 3, run('bag[BARCU]'));
|
||||
// 7. belt carries a bar: belt from block (40,24) to (46,24), powered by direct supply
|
||||
run('bot.x = 165; bot.y = 70; bag[BARCU] = 20; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
|
||||
const rb = run("JSON.stringify(placeBlocks(BP_BELT, 1, blockLine(40, 24, 46, 24)))");
|
||||
check('belt blocks placed for $12 each', JSON.parse(rb).n === 7 && run('money') === 250 - 84, rb + ' money ' + run('money'));
|
||||
run('rebuildPower(); nets[netId[96*W+161]].prods.push({out: 50});');
|
||||
run('spawnBar(BARCU, 162, 90, 0, 0)');
|
||||
ticks(run, 20);
|
||||
const x0 = run('ents[ents.length-1].ix');
|
||||
ticks(run, 60);
|
||||
const x1 = run('ents[ents.length-1].ix');
|
||||
check('powered belt moves the bar right', x1 > x0 + 10, `${x0} -> ${x1}`);
|
||||
// 8. bar melts when heated
|
||||
run('const e = ents[ents.length-1]; for (let dy=-2; dy<4; dy++) for (let dx=-2; dx<6; dx++) temp[(e.iy+dy)*W + e.ix+dx] = 1300; e.T = 1100;');
|
||||
ticks(run, 3);
|
||||
check('hot bar melts into molten copper', count(run, run('MCU')) > 0, count(run, run('MCU')));
|
||||
// 9. machines snap to blocks
|
||||
run('bag[LWOOD] = 100; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
|
||||
check('water wheel placed on block grid', run("placeMachineAt('wheel', 0, 10, 10)") === '' && run('machines[1].x') === 40);
|
||||
// 10. blueprint copy + ghost fill
|
||||
run('bot.x = 100; bot.y = 60;');
|
||||
run('bag[RSTONE] = 400; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
|
||||
run("placeBlocks(BP_WALL, RSTONE, blockRect(20, 18, 22, 19))");
|
||||
const bp = run("JSON.stringify(copyRegion(80, 72, 91, 79))");
|
||||
check('blueprint captures 6 wall blocks', JSON.parse(bp).blocks.length === 6, bp.slice(0, 80));
|
||||
run(`pasteGhost(${bp}, 34, 14)`);
|
||||
ticks(run, 20);
|
||||
check('ghosts are built from the bag', run('ghostBlocks.size') === 0 && run('blockP[14*BW+34]') === run('BP_WALL'), run('ghostBlocks.size'));
|
||||
// 11. save / load round trip
|
||||
const before = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})');
|
||||
run('globalThis.__sv = serializeWorld(); deserializeWorld(__sv.meta, __sv.buffers);');
|
||||
const after = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})');
|
||||
check('save/load keeps bag, bars, molds, machines and blocks', before === after, before + ' vs ' + after);
|
||||
done();
|
||||
@@ -0,0 +1,80 @@
|
||||
// Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
|
||||
const { load, suite, flatWorld, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
const fresh = () => { flatWorld(run, 240, 160, 100); run('money = 1000; bag[RSTONE] = 400; bagTotal = 400; bot.x = 100; bot.y = 60; undoStack = []; clearAllGhosts();'); };
|
||||
const belts = () => run('blockP.reduce((a, v) => a + (v === BP_BELT), 0)');
|
||||
const fans = () => run("machines.filter(m => m && m.type === 'fan').length");
|
||||
|
||||
// placing a line of belts, then undoing it: they come down and the money comes back
|
||||
fresh();
|
||||
const m0 = run('money');
|
||||
run("undoRecord('placing belts', () => placeBlocks(BP_BELT, 1, blockLine(22, 20, 27, 20), undefined, false))");
|
||||
check('a line of 6 belts is placed', belts() === 6 && run('money') === m0 - 72, `${belts()} belts, $${run('money')}`);
|
||||
check('Ctrl+Z takes them back and refunds them', run('undoLast()') === 'Undid placing belts.' && belts() === 0 && run('money') === m0, `${belts()} belts, $${run('money')}`);
|
||||
check('with nothing left, it says so', run('undoLast()') === 'Nothing to undo.');
|
||||
|
||||
// taking a piece back, then undoing: it is built again
|
||||
fresh();
|
||||
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); placeBlock(BP_WALL, RSTONE, 26, 20, false);');
|
||||
const st0 = run('bag[RSTONE]');
|
||||
run("undoRecord('taking back', () => { removePieceAt(23 * 4 + 1, 20 * 4 + 1); removePieceAt(26 * 4 + 1, 20 * 4 + 1); })");
|
||||
check('taking back a belt and a wall block', belts() === 3 && run('blockP[20 * BW + 26]') === 0 && run('bag[RSTONE]') === st0 + 16, `${belts()} belts, ${run('bag[RSTONE]')} rubble`);
|
||||
run('undoLast()');
|
||||
check('undo builds them again from the bag', belts() === 4 && run('blockP[20 * BW + 26]') === run('BP_WALL') && run('bag[RSTONE]') === st0, `${belts()} belts, ${run('bag[RSTONE]')} rubble`);
|
||||
|
||||
// replacing belts with filters, then undoing: the belts come back
|
||||
fresh();
|
||||
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false);');
|
||||
run("undoRecord('replacing', () => placeBlocks(BP_FILTER, 128, blockLine(23, 20, 24, 20), { m: [SAND], inv: 0 }, true))");
|
||||
check('Ctrl-dragging filters over belts replaces them', run('blockP[20 * BW + 23]') === run('BP_FILTER') && belts() === 2);
|
||||
run('undoLast()');
|
||||
check('undo puts the belts back', belts() === 4 && run('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)') === 0, belts());
|
||||
|
||||
// Ctrl over built blocks with a machine: they are taken down and the fan goes in their place
|
||||
fresh();
|
||||
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false);');
|
||||
check('a fan will not go on a belt without Ctrl', /already/.test(run("canPlaceMachine('fan', 23, 20) || ''")));
|
||||
run("undoRecord('replacing', () => { clearFootprint(23, 20, 1, 1); placeMachineAt('fan', 0, 23, 20); })");
|
||||
check('with Ctrl the belt under it is taken down and the fan placed', fans() === 1 && belts() === 3);
|
||||
run('undoLast()');
|
||||
check('undo takes the fan back and rebuilds the belt', fans() === 0 && belts() === 4);
|
||||
run('for (let y = 76; y < 80; y++) for (let x = 112; x < 116; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);');
|
||||
check('natural rock still stops a replace', /rock/.test(run('clearFootprint(28, 19, 1, 1)')));
|
||||
|
||||
// cut: everything in the box comes down into the bag and onto the clipboard; undo puts it back
|
||||
fresh();
|
||||
run("placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); placeMachineAt('fan', 0, 22, 18);");
|
||||
const mc = run('money');
|
||||
const bp = JSON.parse(run("JSON.stringify(undoRecord('cutting', () => cutRegion(22 * 4, 18 * 4, 26 * 4 - 1, 21 * 4 - 1)))"));
|
||||
check('cut picks up the belts and the fan', bp && bp.blocks.length === 4 && bp.machines.length === 1, bp && `${bp.blocks.length} blocks, ${bp.machines.length} machines`);
|
||||
check('and takes them down, refunded', belts() === 0 && fans() === 0 && run('money') === mc + 48 + 60, `${belts()} belts, ${fans()} fans, $${run('money')}`);
|
||||
run('undoLast()');
|
||||
check('undo after a cut puts it all back', belts() === 4 && fans() === 1 && run('money') === mc, `${belts()} belts, ${fans()} fans, $${run('money')}`);
|
||||
|
||||
// paste: ghosts appear; undo clears them, and takes back any the bot already built
|
||||
fresh();
|
||||
run("placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); window_bp = copyRegion(22 * 4, 20 * 4, 26 * 4 - 1, 21 * 4 - 1);");
|
||||
run("undoRecord('pasting', () => pasteGhost(window_bp, 22, 17))");
|
||||
check('pasting places ghosts', run('ghostBlocks.size') === 4);
|
||||
run('for (let k = 0; k < 400; k++) simTick();');
|
||||
const built = belts();
|
||||
check('the bot builds some or all of them', built > 4, built);
|
||||
check('building from ghosts is never an undo step of its own', run('undoStack.length') === 1, run('undoStack.length'));
|
||||
run('undoLast()');
|
||||
check('undo clears the ghosts and takes back what was built from them', belts() === 4 && run('ghostBlocks.size') === 0, `${belts()} belts, ${run('ghostBlocks.size')} ghosts`);
|
||||
|
||||
// a fan getting near its melting point warns you
|
||||
fresh();
|
||||
run("placeMachineAt('fan', 0, 22, 20); EV.length = 0;");
|
||||
run('for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; for (let k = 0; k < 40; k++) { for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; simTick(); }');
|
||||
const warn = run("EV.filter(e => e.t === 'toast' && /fan/i.test(e.text)).map(e => e.text).join(' | ')");
|
||||
check('a fan at 900°C warns that it is about to melt', /1085/.test(warn) && fans() === 1, warn);
|
||||
|
||||
// old saves: tiny specks of too-hard rock become the rock around them, once
|
||||
fresh();
|
||||
run('for (let y = 120; y < 122; y++) for (let x = 60; x < 62; x++) setCell(y * W + x, FEV, AMB[y], F_NAT); for (let y = 120; y < 130; y++) for (let x = 140; x < 150; x++) setCell(y * W + x, FEV, AMB[y], F_NAT);');
|
||||
const n = run('clearHardSpecks(true)');
|
||||
check('a 4-grain speck of iron ore in stone becomes stone; a 100-grain vein stays', n === 1 && count(run, run('FEV')) === 100 && run('mat[120 * W + 60]') === run('STONE'), `${n} specks, ${count(run, run('FEV'))} vein`);
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,160 @@
|
||||
// Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
function fresh() { flatWorld(run, 320, 200, 120); run('bot.x = 20; bot.y = 40; money = 1e6; globalThis.__got = 0;'); }
|
||||
const tubeLiquid = () => run('(() => { let n = 0; for (let i = 0; i < N; i++) if (part[i] >= P_TU && part[i] <= P_TR && PH[mat[i]] === PH_LIQUID) n++; return n; })()');
|
||||
// grains that land in the sink region are counted and taken away, so outlets never back up
|
||||
function rate(x0, x1, y0, y1, warm, meas) {
|
||||
const sink = `for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) { const i=y*W+x; if (mat[i]===SAND) { mat[i]=EMPTY; __got++; } }`;
|
||||
for (let k = 0; k < warm; k++) run(`simTick(); ${sink}`);
|
||||
run('__got = 0');
|
||||
for (let k = 0; k < meas; k++) run(`simTick(); ${sink}`);
|
||||
return run('__got') / (meas / 60);
|
||||
}
|
||||
|
||||
// --- tubes are sealed: no water in, no water carried
|
||||
fresh();
|
||||
run('for (let y = 120; y < 170; y++) for (let x = 100; x < 124; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
rect(run, 100, 150, 123, 169, run('SAND'));
|
||||
rect(run, 100, 130, 123, 149, run('WATER'));
|
||||
run('for (let by = 22; by <= 41; by++) placeBlock(BP_TUBE, 0, 27, by, true); for (let bx = 27; bx <= 34; bx++) placeBlock(BP_TUBE, 1, bx, 21, true);');
|
||||
const water0 = count(run, run('WATER'));
|
||||
ticks(run, 900);
|
||||
const wOut = count(run, run('WATER'), 124, 0, 319, 119), sOut = count(run, run('SAND'), 124, 0, 319, 119);
|
||||
check('a lift standing in a flooded pit brings up the sand but not the water', sOut > 150 && wOut === 0, `sand out ${sOut}, water out ${wOut}, water standing in the tubes ${tubeLiquid()}`);
|
||||
const pitClouds = run('weather.moist.reduce((a, v) => a + v, 0)');
|
||||
check('the pit keeps its water (bar what steams off into the clouds)', water0 - count(run, run('WATER')) - pitClouds <= 6, `${water0} -> ${count(run, run('WATER'))}, ${pitClouds.toFixed(0)} in the clouds`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_TUBE, 1, bx, 25, true);');
|
||||
rect(run, 84, 60, 95, 79, run('WATER'));
|
||||
ticks(run, 300);
|
||||
check('water poured on a tube runs straight through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 200 && tubeLiquid() < 10, `${count(run, run('WATER'), 0, 104, 319, 119)} below, ${tubeLiquid()} still inside`);
|
||||
fresh();
|
||||
// a vertical tube pointing up, its top open to a pool: the water drains down through it, against the screw
|
||||
run('for (let by = 20; by <= 26; by++) placeBlock(BP_TUBE, 0, 25, by, true); for (let y = 60; y < 80; y++) { setCell(y*W+99, STONE, AMB[y], 0); setCell(y*W+104, STONE, AMB[y], 0); }');
|
||||
rect(run, 100, 60, 103, 79, run('WATER'));
|
||||
ticks(run, 400);
|
||||
check('water runs down through an upward screw, which never pushes it back', count(run, run('WATER'), 0, 0, 319, 79) === 0, count(run, run('WATER'), 0, 0, 319, 79));
|
||||
|
||||
// --- throughput
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
|
||||
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
|
||||
rect(run, 80, 0, 95, 79, run('SAND'));
|
||||
ticks(run, 60);
|
||||
const lanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
|
||||
check('a sideways tube fed from a hopper runs full, all four grains wide', lanes === 4, lanes);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
|
||||
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
|
||||
rect(run, 80, 0, 95, 79, run('SAND'));
|
||||
const tr = rate(120, 319, 84, 119, 90, 300);
|
||||
// tubes run at belt pace (every other tick), so a belt under a tube's mouth keeps up with it
|
||||
check('a sideways tube moves around a hundred grains a second', tr > 80 && tr < 150, `${tr.toFixed(0)}/s`);
|
||||
fresh();
|
||||
run('for (let y = 120; y < 196; y++) for (let x = 90; x < 130; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
rect(run, 90, 130, 129, 195, run('SAND'));
|
||||
run('for (let by = 20; by <= 37; by++) placeBlock(BP_TUBE, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);');
|
||||
const lr = rate(164, 319, 80, 119, 400, 300);
|
||||
const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y++) for (let x = 120; x < 160; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
|
||||
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
|
||||
rect(run, 80, 20, 95, 99, run('SAND'));
|
||||
const br = rate(128, 319, 104, 119, 90, 300);
|
||||
ticks(run, 0);
|
||||
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
|
||||
rect(run, 96, 80, 111, 99, run('WATER'));
|
||||
ticks(run, 300);
|
||||
check('water on a belt drains through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 280, count(run, run('WATER'), 0, 0, 319, 99));
|
||||
fresh();
|
||||
// a belt under standing water: walls hold a pool over its middle, and sand still rides through it
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true); for (let y = 90; y < 98; y++) { setCell(y*W+100, STONE, AMB[y], 0); setCell(y*W+108, STONE, AMB[y], 0); }');
|
||||
rect(run, 101, 90, 107, 99, run('WATER'));
|
||||
rect(run, 80, 92, 87, 99, run('SAND')); rect(run, 79, 88, 79, 99, run('STONE'));
|
||||
run('globalThis.__past = 0;');
|
||||
for (let k = 0; k < 600; k++) { run('for (let y = 92; y < 100; y++) for (let x = 101; x < 108; x++) if (mat[y*W+x] === EMPTY) setCell(y*W+x, WATER, AMB[y], 0); simTick(); for (let y = 104; y < 120; y++) for (let x = 128; x < 319; x++) if (mat[y*W+x] === SAND) { mat[y*W+x] = EMPTY; __past++; }'); }
|
||||
const wetPast = run('__past') + count(run, run('SAND'), 108, 0, 319, 119);
|
||||
check('sand rides through water standing on a belt and on past it', wetPast === 64, `${wetPast} of 64`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
|
||||
rect(run, 80, 60, 95, 99, run('SAND'));
|
||||
ticks(run, 40);
|
||||
const depth = run('(() => { let d = 0; for (let y = 99; y > 90; y--) if (mat[y*W+110] === SAND) d++; else break; return d; })()');
|
||||
check('the load rides a belt up to a block deep', depth >= 3, depth);
|
||||
|
||||
// --- filters
|
||||
fresh();
|
||||
// a filter for copper ore under a mixed pile; everything else rides right and off the end
|
||||
// fed from a walled hopper over its left end, the way a tube or belt would drop a mixed load on it
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [CUORE] });');
|
||||
run('for (let y = 30; y < 120; y++) setCell(y*W+79, STONE, AMB[y], 0); for (let y = 30; y < 96; y++) setCell(y*W+88, STONE, AMB[y], 0);');
|
||||
run('for (let y = 40; y < 96; y++) for (let x = 80; x < 88; x++) setCell(y*W+x, (x + y) % 3 ? SAND : CUORE, AMB[y], 0)');
|
||||
const ore0 = count(run, run('CUORE')), sand0 = count(run, run('SAND'));
|
||||
// what rides off the far end is taken away and counted, so its pile never spreads back under the filter
|
||||
run('globalThis.__past = [0, 0];');
|
||||
for (let k = 0; k < 1200; k++) run('simTick(); for (let y = 104; y < 120; y++) for (let x = 120; x < 319; x++) { const i = y*W+x; if (mat[i] === SAND) { __past[0]++; mat[i] = EMPTY; } else if (mat[i] === CUORE) { __past[1]++; mat[i] = EMPTY; } }');
|
||||
const oreUnder = count(run, run('CUORE'), 80, 104, 119, 119), sandUnder = count(run, run('SAND'), 80, 104, 119, 119);
|
||||
const sandPast = run('__past[0]');
|
||||
check('a filter lets its material fall through, even from under the rest of the load', oreUnder > ore0 * 0.9, `${oreUnder} of ${ore0} ore below`);
|
||||
check('everything else rides along on top and off the end', sandUnder === 0 && sandPast > sand0 * 0.9, `${sandUnder} sand fell through, ${sandPast} of ${sand0} carried off`);
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 0, bx, 25, true, { m: [CUORE] });');
|
||||
rect(run, 84, 70, 95, 89, run('WATER'));
|
||||
ticks(run, 400);
|
||||
check('liquids drain through a filter', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 220, count(run, run('WATER'), 0, 104, 319, 119));
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND] }); for (let bx = 30; bx < 34; bx++) placeBlock(BP_FILTER, 0, bx, 25, true, { m: [BARCU] });');
|
||||
run('spawnBar(BARCU, 82, 96, 0, 0); spawnBar(BARCU, 124, 96, 0, 0);');
|
||||
ticks(run, 200);
|
||||
const bars = JSON.parse(run('JSON.stringify(ents.map(e => [e.ix, e.iy]))'));
|
||||
check('a bar rides along a filter set for something else', bars.some(([x, y]) => x > 100 || y > 104), JSON.stringify(bars));
|
||||
check('and falls through a filter set for its own kind', bars.some(([x, y]) => x >= 116 && x < 136 && y >= 104), JSON.stringify(bars));
|
||||
// carry mode with several materials: sand and coal ride along, everything else drops
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND, COAL], inv: 1 });');
|
||||
run('for (let y = 30; y < 120; y++) setCell(y*W+79, STONE, AMB[y], 0); for (let y = 30; y < 96; y++) setCell(y*W+88, STONE, AMB[y], 0);');
|
||||
run('for (let y = 40; y < 96; y++) for (let x = 80; x < 88; x++) setCell(y*W+x, [SAND, CUORE, COAL, FEORE][(x + y * 3) & 3], AMB[y], 0)');
|
||||
const k0 = { sand: count(run, run('SAND')), coal: count(run, run('COAL')), cu: count(run, run('CUORE')), fe: count(run, run('FEORE')) };
|
||||
run('globalThis.__past = {};');
|
||||
for (let k = 0; k < 1200; k++) run('simTick(); for (let y = 104; y < 120; y++) for (let x = 120; x < 319; x++) { const i = y*W+x, m = mat[i]; if (m !== EMPTY && m !== STONE) { __past[m] = (__past[m] || 0) + 1; mat[i] = EMPTY; } }');
|
||||
const under = { sand: count(run, run('SAND'), 80, 104, 119, 119), coal: count(run, run('COAL'), 80, 104, 119, 119), cu: count(run, run('CUORE'), 80, 104, 119, 119), fe: count(run, run('FEORE'), 80, 104, 119, 119) };
|
||||
check('carry mode: the chosen materials ride along and off the end', under.sand === 0 && under.coal === 0 && run('(__past[SAND] || 0) + (__past[COAL] || 0)') > 0.9 * (k0.sand + k0.coal), JSON.stringify(under));
|
||||
check('and everything else drops through, both kinds of ore', under.cu > 0.9 * k0.cu && under.fe > 0.9 * k0.fe, `${under.cu}/${k0.cu} copper, ${under.fe}/${k0.fe} iron`);
|
||||
// changing what a filter passes pushes out what no longer fits; blueprints and saves keep the setting
|
||||
fresh();
|
||||
run('placeBlock(BP_FILTER, 0, 25, 25, true, { m: [SAND] }); for (let y = 100; y < 104; y++) for (let x = 100; x < 104; x++) setCell(y*W+x, SAND, AMB[y], 0);');
|
||||
run('setFilter(25 * BW + 25, { m: [CUORE] })');
|
||||
check('switching a filter to copper ore pushes the sand inside it out on top', count(run, run('SAND'), 100, 100, 103, 103) === 0 && count(run, run('SAND'), 96, 80, 108, 99) === 16);
|
||||
run('placeBlock(BP_FILTER, 128, 30, 25, true, { m: [FEORE, CUORE], inv: 1 });');
|
||||
const bp = JSON.parse(run('JSON.stringify(flipBlueprint(copyRegion(120, 100, 123, 103)).blocks)'));
|
||||
check('a flipped blueprint keeps the filter settings and turns its direction', bp[0][3] === 0 && JSON.stringify(bp[0][4]) === JSON.stringify({ m: [run('CUORE'), run('FEORE')].sort((a, b) => a - b), inv: 1 }), JSON.stringify(bp));
|
||||
run('clearAllGhosts(); pasteGhost(flipBlueprint(copyRegion(120, 100, 123, 103)), 34, 25); bot.x = 130; bot.y = 80; for (let k = 0; k < 30; k++) ghostTick();');
|
||||
check('pasting it builds a filter with the same materials and mode', run('blockP[25*BW+34]') === run('BP_FILTER') && run('JSON.stringify(filterInfo(25*BW+34))') === JSON.stringify({ m: [run('CUORE'), run('FEORE')].sort((a, b) => a - b), inv: 1 }), run('JSON.stringify(filterInfo(25*BW+34))'));
|
||||
run('globalThis.__s = serializeWorld(); deserializeWorld(__s.meta, __s.buffers);');
|
||||
check('save and load keep filters, their materials, mode and direction', run('blockP[25*BW+30]') === run('BP_FILTER') && run('blockV[25*BW+30]') === 128 && run('filterInfo(25*BW+30).inv') === 1 && run('filterInfo(25*BW+30).m.length') === 2 && run('part[100*W+120]') === run('P_FILTER'));
|
||||
// a save from before: one material kept in the block variant
|
||||
run('__s.meta.filters = []; __s.buffers.blockV[25*BW+30] = 128 | SAND; deserializeWorld(__s.meta, __s.buffers);');
|
||||
check('older filters keep the one material they had', run('JSON.stringify(filterInfo(25*BW+30))') === JSON.stringify({ m: [run('SAND')], inv: 0 }) && run('blockV[25*BW+30]') === 128);
|
||||
// the run of matching filters in a row
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND] }); placeBlock(BP_FILTER, 128, 26, 25, true, { m: [COAL] }); placeBlock(BP_FILTER, 0, 27, 25, true, { m: [COAL] });');
|
||||
check('a filter line is the touching filters that do the same thing', run('filterRun(25*BW+22).length') === 6 && run('filterRun(25*BW+26).length') === 1 && run('filterRun(25*BW+27).length') === 1);
|
||||
// replacing what is built: hold the modifier and it swaps, refunding the old piece
|
||||
fresh();
|
||||
run('money = 200; for (let bx = 20; bx < 24; bx++) placeBlock(BP_BELT, 1, bx, 25, true); bot.x = 88; bot.y = 80;');
|
||||
let r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 128, blockLine(20, 25, 23, 25), { m: [SAND] }, false))'));
|
||||
check('without the modifier, built blocks are left alone', r.n === 0 && /already built/.test(r.status) && run('money') === 200, JSON.stringify(r));
|
||||
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 128, blockLine(20, 25, 23, 25), { m: [SAND] }, true))'));
|
||||
check('with it, belts are swapped for filters, paying the difference', r.n === 4 && run('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)') === 4 && run('blockP.reduce((a, v) => a + (v === BP_BELT), 0)') === 0 && run('money') === 200 + 4 * 12 - 4 * 40, `${JSON.stringify(r)} money ${run('money')}`);
|
||||
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 0, blockLine(20, 25, 23, 25), { m: [COAL], inv: 1 }, true))'));
|
||||
check('replacing a filter with a filter just re-sets it, at no cost', r.n === 4 && run('money') === 200 + 48 - 160 && run('filterInfo(25*BW+21).inv') === 1 && run('blockV[25*BW+21]') === 0, `${JSON.stringify(r)} money ${run('money')}`);
|
||||
run('bag[RSTONE] = 64; bagTotal = 64;');
|
||||
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_WALL, RSTONE, blockLine(20, 25, 21, 25), undefined, true))'));
|
||||
check('and a wall can replace them too', r.n === 2 && run('blockP[25*BW+20]') === run('BP_WALL') && run('!filterCfg.has(25*BW+20)'), JSON.stringify(r));
|
||||
check('filters are a buildable piece with a price', run('PIECE_BY_ID.filter && !PIECE_BY_ID.filter.hidden && PIECE_BY_ID.filter.cost[0][0] === CASH'));
|
||||
check('no trucks', run('typeof truck') === 'undefined');
|
||||
done();
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// 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.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
// a stone wall to the right of the bot, from x 124 on, rows 60..99
|
||||
function wall(m = 'STONE') { flatWorld(run, 240, 160, 100); run(`for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, ${m}, AMB[y], F_NAT);`); run('bot.x = 110; bot.y = 76; stats.dug = 0; drillPulses = []; player.bitTier = 0;'); }
|
||||
const hold = (ax, ay, w, tune, secs) => run(`for (let k = 0; k < ${Math.round(secs * 60)}; k++) { toolPulse(${ax}, ${ay}, ${w}, ${tune || 0}); simTick(); }`);
|
||||
// how deep the hole goes along the aim line, and how many grains it opened in all
|
||||
const depth = () => run('(() => { let d = 0; for (let x = 124; x < 240; x++) { if (PH[mat[78 * W + x]] === PH_SOLID) break; d = x - 123; } return d; })()');
|
||||
const opened = () => run('(() => { let n = 0; for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) if (PH[mat[y * W + x]] !== PH_SOLID) n++; return n; })()');
|
||||
|
||||
wall(); run('bot.x = 116;'); hold(150, 78, 1, 0, 2);
|
||||
const med = { d: depth(), n: opened() };
|
||||
check('held on a stone wall, medium pulses dig a hole and drop rubble', med.d >= 6 && run('stats.dug') > 30 && count(run, run('RSTONE')) > 30, `${med.d} deep, ${med.n} opened, ${count(run, run('RSTONE'))} rubble`);
|
||||
wall(); hold(150, 78, 1, 0, 0.3);
|
||||
check('rock that is shaking but has not broken yet shows it', run('(() => { let n = 0; for (let i = 0; i < N; i++) if (digp[i] > 0 && PH[mat[i]] === PH_SOLID) n++; return n; })()') > 5);
|
||||
// the widths, with a copper bit so the reach is not what stops them
|
||||
const wall2 = () => { wall(); run('player.bitTier = 1;'); };
|
||||
wall2(); hold(150, 78, 1, 0, 2);
|
||||
const med2 = { d: depth(), n: opened() };
|
||||
wall2(); hold(150, 78, 0, 0, 2);
|
||||
const nar = { d: depth(), n: opened() };
|
||||
wall2(); hold(150, 78, 2, 0, 2);
|
||||
const wid = { d: depth(), n: opened() };
|
||||
check('a narrow pulse bores deeper than a wide one', nar.d > wid.d, `narrow ${nar.d}, medium ${med2.d}, wide ${wid.d} deep`);
|
||||
const rowsOf = () => run('(() => { let a = 99, b = 0; for (let y = 60; y < 100; y++) for (let x = 124; x < 132; x++) if (PH[mat[y * W + x]] !== PH_SOLID) { a = Math.min(a, y); b = Math.max(b, y); } return b - a + 1; })()');
|
||||
wall2(); hold(150, 78, 2, 0, 2); const wideRows = rowsOf();
|
||||
wall2(); hold(150, 78, 0, 0, 2); const narRows = rowsOf();
|
||||
check('the wide cone is taller at the face than the narrow one', wideRows > narRows, `wide ${wideRows} rows, narrow ${narRows}`);
|
||||
|
||||
// the reach depends on the width: a narrow beam reaches furthest, a wide fan only close by
|
||||
run('player.bitTier = 0;');
|
||||
const reaches = [0, 1, 2].map(w => run(`drillReach(${w})`));
|
||||
check('a narrow pulse reaches further than a medium one, and a medium one further than a wide one', reaches[0] > reaches[1] && reaches[1] > reaches[2], reaches.join(' / '));
|
||||
// following the face like a player would, narrow bores the fastest tunnel and wide opens the most rock
|
||||
const follow = (w) => {
|
||||
wall2(); run('for (let y = 50; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); bot.x = 116;');
|
||||
for (let t = 0; t < 12; t++) run(`for (let k = 0; k < 15; k++) { toolPulse(Math.round(bot.x + 42), 78, ${w}, 0); simTick(); } for (let k = 0; k < 40; k++) { if (botOverlapSolid(bot.x + 4, bot.y)) break; bot.x += 1; }`);
|
||||
return { d: depth(), n: opened() };
|
||||
};
|
||||
const fN = follow(0), fM = follow(1), fW = follow(2);
|
||||
check('following the face, the narrow tunnel goes furthest', fN.d > fM.d * 1.3 && fM.d > fW.d, `narrow ${fN.d}, medium ${fM.d}, wide ${fW.d} deep`);
|
||||
check('and the wide fan opens up the most rock', fW.n > fN.n && fW.n > fM.n, `narrow ${fN.n}, medium ${fM.n}, wide ${fW.n} grains`);
|
||||
// the fan leaves the whole face of the bot, so rock right beside it is not missed
|
||||
for (const w of [0, 1, 2]) {
|
||||
wall(); run('for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, EMPTY, AMB[y], 0); for (let y = 77; y < 81; y++) for (let x = 121; x < 124; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); bot.x = 116; bot.y = 76;');
|
||||
const lump = () => count(run, run('STONE'), 121, 60, 123, 81);
|
||||
hold(150, 74, w, 0, 1.5);
|
||||
check(`a ${['narrow', 'medium', 'wide'][w]} pulse aimed a little above a lump touching the bot still breaks it`, lump() <= 2, `${lump()} of 12 left`);
|
||||
}
|
||||
|
||||
// tuning: copper veins in stone; tuned to copper ore only the veins break, and drain out
|
||||
wall();
|
||||
run('for (let s = 0; s <= 1; s += 0.01) { const x = Math.round(124 + 20 * s), y = Math.round(80 - 8 * s); for (let dy = -1; dy <= 1; dy++) for (let dx = 0; dx <= 1; dx++) setCell((y + dy) * W + x + dx, CUV, AMB[y], F_NAT); }');
|
||||
run('player.bitTier = 1; bot.x = 114;');
|
||||
const stone0 = count(run, run('STONE')), vein0 = count(run, run('CUV'));
|
||||
hold(136, 77, 1, run('CUORE'), 3);
|
||||
const stone1 = count(run, run('STONE')), vein1 = count(run, run('CUV'));
|
||||
check('tuned to copper ore, the stone around the vein is untouched', stone1 === stone0, `stone ${stone0} -> ${stone1}`);
|
||||
check('and the vein shatters', vein1 < vein0 * 0.6, `vein ${vein0} -> ${vein1}`);
|
||||
const out = run('(() => { let n = 0; for (let y = 0; y < 100; y++) for (let x = 0; x < 124; x++) if (mat[y * W + x] === CUORE) n++; return n; })()');
|
||||
check('shattered ore pours out of the wall toward the bot', out > 10, `${out} ore grains outside the wall`);
|
||||
|
||||
// built blocks soak pulses up
|
||||
wall();
|
||||
run('bag[RSTONE] = 200; bagTotal = 200; for (let by = 17; by <= 21; by++) placeBlock(BP_WALL, RSTONE, 30, by, true);'); // a built column at x 120..123
|
||||
const built0 = run('(() => { let n = 0; for (let y = 68; y < 88; y++) for (let x = 120; x < 124; x++) if (mat[y * W + x] === STONE) n++; return n; })()');
|
||||
hold(140, 78, 1, 0, 2);
|
||||
const built1 = run('(() => { let n = 0; for (let y = 68; y < 88; y++) for (let x = 120; x < 124; x++) if (mat[y * W + x] === STONE) n++; return n; })()');
|
||||
check('built blocks in the way are never harmed', built0 > 0 && built1 === built0, `${built0} -> ${built1}`);
|
||||
check('and they shield what is behind them', opened() < med.n / 2, `${opened()} opened behind the wall`);
|
||||
|
||||
// it says why nothing breaks
|
||||
wall('GRANITE');
|
||||
const r = JSON.parse(run('JSON.stringify(toolPulse(140, 78, 1, 0))'));
|
||||
check('granite with a stone bit says which bit it needs', /too hard/.test(r.status) && /iron bit/i.test(r.status), r.status);
|
||||
hold(140, 78, 1, 0, 1);
|
||||
check('and the granite stays whole', count(run, run('GRANITE')) === 116 * 40, count(run, run('GRANITE')));
|
||||
wall();
|
||||
run('drillPulses = []; tick += 20;');
|
||||
const far = JSON.parse(run('JSON.stringify(toolPulse(200, 78, 1, 0))'));
|
||||
check('aiming past the reach says how far the bit reaches', /Out of reach/.test(far.status), far.status);
|
||||
run('drillPulses = []; tick += 20;');
|
||||
const farW = JSON.parse(run('JSON.stringify(toolPulse(125, 78, 2, 0))')), nearM = JSON.parse(run('tick += 20; JSON.stringify(toolPulse(125, 78, 1, 0))'));
|
||||
check('a spot a medium pulse reaches is out of reach for a wide one, and it says so', /Out of reach/.test(farW.status) && /wide/.test(farW.status) && !nearM.status, `${farW.status} | ${nearM.status}`);
|
||||
|
||||
// stray bits: a lump of rock left hanging when the rock round it breaks falls apart
|
||||
const hanging = (m) => {
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('for (let y = 60; y < 100; y++) for (let x = 140; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);');
|
||||
// a 3 x 3 lump joined to the wall by a line of stone grains
|
||||
run(`player.bitTier = 0; for (let y = 70; y < 73; y++) for (let x = 130; x < 133; x++) setCell(y * W + x, ${m}, AMB[y], F_NAT); for (let x = 133; x < 140; x++) setCell(71 * W + x, STONE, AMB[71], F_NAT);`);
|
||||
run('shatterCell(71 * W + 136, 136, 71, STONE); crumbleAround(136, 71);');
|
||||
};
|
||||
hanging('STONE');
|
||||
check('a bit of stone cut off from the wall falls apart', count(run, run('STONE'), 128, 60, 135, 80) === 0 && count(run, run('RSTONE')) === 13, `${count(run, run('STONE'), 128, 60, 135, 80)} left, ${count(run, run('RSTONE'))} rubble`);
|
||||
check('and the wall it came off stays whole', count(run, run('STONE'), 137, 60, 239, 99) === 100 * 40 + 3, count(run, run('STONE'), 137, 60, 239, 99));
|
||||
hanging('FEV');
|
||||
check('a cut-off lump with ore too hard for the bit in it stays whole', count(run, run('FEV')) === 9 && count(run, run('FEORE')) === 0, `${count(run, run('FEV'))} vein, ${count(run, run('FEORE'))} ore`);
|
||||
// too-hard rock is never knocked loose, however small
|
||||
const speckWall = (n) => {
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); player.bitTier = 0; drillPulses = [];');
|
||||
// a lump of iron ore at the face, n grains across, three rows tall, sticking out into the open
|
||||
run(`for (let y = 77; y < 80; y++) for (let x = 122; x < ${122 + n}; x++) setCell(y * W + x, FEV, AMB[y], F_NAT); bot.x = 112; bot.y = 76;`);
|
||||
};
|
||||
speckWall(2); hold(150, 78, 1, 0, 3);
|
||||
check('a speck of iron ore is never broken or knocked loose by a stone bit', count(run, run('FEV')) === 6 && count(run, run('FEORE')) === 0, `${count(run, run('FEV'))} left, ${count(run, run('FEORE'))} ore`);
|
||||
// world generation leaves no tiny specks of rock harder than what is round them
|
||||
run('generateWorld(424242, 1024, 640)');
|
||||
// (a speck only counts if it is harder than the rock round it; another sweep finds none)
|
||||
const tiny = run('clearHardSpecks(false)');
|
||||
check('a new world has no specks of iron, zinc or granite small enough to leave in the way', tiny === 0, `${tiny} specks`);
|
||||
const fe = count(run, run('FEV'));
|
||||
check('and still has plenty of iron ore', fe > 4000, fe);
|
||||
// the bot slips round a lip of rock a grain or two deep instead of stopping dead
|
||||
const corridor = (lip) => {
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run(`for (let y = 50; y < 100; y++) for (let x = 100; x < 240; x++) if (y < 70 || y >= 78) setCell(y * W + x, STONE, AMB[y], F_NAT);
|
||||
for (let y = 70; y < 70 + ${lip}; y++) for (let x = 140; x < 143; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
|
||||
bot.x = 110; bot.y = 70; bot.vx = bot.vy = 0;`);
|
||||
for (let k = 0; k < 150; k++) run('botStep(1, 0, false)');
|
||||
return run('bot.x');
|
||||
};
|
||||
const past2 = corridor(2), past8 = corridor(8);
|
||||
check('flying along a tunnel, the bot slips past a two-grain lip', past2 > 150, past2.toFixed(1));
|
||||
check('but a wall across the tunnel still stops it', past8 < 140, past8.toFixed(1));
|
||||
|
||||
// trees in the way come apart into wood chunks
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('for (let y = 80; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y * W + x] = WOOD; findTreeBases(); bot.x = 108; bot.y = 88; player.bitTier = 0;');
|
||||
hold(121, 92, 1, 0, 3);
|
||||
check('a tree trunk in the path is cut into wood chunks', count(run, run('LWOOD')) > 0, count(run, run('LWOOD')));
|
||||
|
||||
done();
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
|
||||
// a generated world comes with a dock near spawn
|
||||
run('generateWorld(777, 2048, 1024, null)');
|
||||
const d = run('JSON.stringify(dock() && { x: dock().x, y: dock().y })');
|
||||
check('new worlds have a trade dock near spawn', d !== 'null' && Math.abs(JSON.parse(d).x - 1024) < 80, d);
|
||||
check('starting money and claim debt', run('money') === 250 && run('debt') === 20000);
|
||||
// goods dropped into the dock sell the moment they reach its grate
|
||||
run('mkRecent.fill(0); orders = []; nextOrderT = 1e12; EV.length = 0;');
|
||||
run('{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 8; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, (x+y) % 2 ? CUORE : COAL, AMB[y], 0); }');
|
||||
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 12, dk.y + 2, 0, 0); }');
|
||||
ticks(run, 90);
|
||||
const interior = () => run('(() => { const dk = dock(); let n = 0; for (let y = dk.y; y < dk.y + 14; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) if (mat[y*W+x] !== EMPTY) n++; return n; })()');
|
||||
check('ore, coal and bars are paid for as soon as they drop in', run('money') > 250 + 150 && interior() === 0 && run('ents.length') === 0, `money ${run('money').toFixed(1)}`);
|
||||
check('a "+$" floater rises from the dock', run('EV.some(e => e.t === "sold" && e.v > 1)'));
|
||||
// no cap: a whole column of ore poured in over time all sells
|
||||
run('money = 0; EV.length = 0;');
|
||||
for (let k = 0; k < 20; k++) {
|
||||
run('{ const dk = dock(); for (let y = dk.y - 30; y < dk.y - 10; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) if (mat[y*W+x] === EMPTY) setCell(y*W+x, CUORE, AMB[y], 0); }');
|
||||
ticks(run, 30);
|
||||
}
|
||||
ticks(run, 240);
|
||||
const sold = run('money');
|
||||
check('8,000 grains poured in over ten seconds all sell, nothing piles up', sold > 2500 && interior() === 0, `$${sold.toFixed(0)}, price now ${(run('priceFactor(CUORE)') * 100).toFixed(0)}%`);
|
||||
// hot goods wait until they cool
|
||||
run('{ const dk = dock(); for (let x = dk.x + 2; x < dk.x + 22; x++) setCell((dk.y + 13)*W + x, CUORE, 600, 0); }');
|
||||
ticks(run, 3);
|
||||
check('hot goods are not sold until they cool', interior() >= 15, interior());
|
||||
run('{ const dk = dock(); for (let y = dk.y; y < dk.y + 14; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, EMPTY, AMB[y], 0); }');
|
||||
// orders: goods fill a matching order first and the order pays a lump sum
|
||||
run('mkRecent.fill(0); money = 0; earned = 0; orders = []; ordersDone = 0; EV.length = 0;');
|
||||
run('orders.push({ id: 1, m: BARCU, qty: 3, got: 0, mult: 2, pay: 240, due: tick + 36000, mins: 10 })');
|
||||
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 10, dk.y + 2, 0, 0); }');
|
||||
ticks(run, 90);
|
||||
check('two bars count toward the order and are not paid yet', run('orders[0].got') === 2 && run('money') === 0, `got ${run('orders[0].got')}, money ${run('money')}`);
|
||||
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 14, dk.y + 2, 0, 0); }');
|
||||
ticks(run, 90);
|
||||
check('the third bar fills it: the lump sum is paid, and the fourth sells at market', run('orders.length') === 0 && run('ordersDone') === 1 && Math.abs(run('money') - 280) < 1, `money ${run('money').toFixed(1)}`);
|
||||
check('the order-filled event carries its pay, and no truck comes', run('EV.some(e => e.t === "order_done" && e.pay === 240)') && run('typeof truck') === 'undefined');
|
||||
// an expired order costs nothing: what was delivered is paid at market
|
||||
run('money = 0; mkRecent.fill(0); orders.push({ id: 2, m: CUORE, qty: 500, got: 0, mult: 2, pay: 1000, due: tick + 400, mins: 1 })');
|
||||
run('{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 4; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, CUORE, AMB[y], 0); }');
|
||||
ticks(run, 90);
|
||||
check('ore counts toward its order', run('orders[0].got') === 40 && run('money') === 0, run('orders[0].got'));
|
||||
ticks(run, 400);
|
||||
check('when the order runs out, the delivered ore is paid at market', run('orders.length') === 0 && Math.abs(run('money') - 40) < 1 && run('EV.some(e => e.t === "order_expired" && e.got === 40)'), `money ${run('money').toFixed(2)}`);
|
||||
// buyers post orders for goods you have seen, and they pay well over market
|
||||
run('orders = []; ordersDone = 0; seen.fill(0); seen[CUORE] = 1; seen[BARCU] = 1; seen[COAL] = 1; nextOrderT = 0;');
|
||||
ticks(run, 120);
|
||||
const o1 = JSON.parse(run('JSON.stringify(orders[0] || null)'));
|
||||
check('a buyer posts an order for something you have found', o1 && [run('CUORE'), run('BARCU'), run('COAL')].includes(o1.m) && o1.pay >= o1.qty * run(`PRICE[${o1.m}]`) * 1.5, JSON.stringify(o1));
|
||||
let sizes = [];
|
||||
for (const done of [0, 20]) { run(`ordersDone = ${done}; orders = [];`); let t = 0; for (let k = 0; k < 40; k++) t += run('(() => { const o = makeOrder(); return o ? o.qty * PRICE[o.m] : 0; })()'); sizes.push(t / 40); }
|
||||
check('orders grow as your standing grows', sizes[1] > sizes[0] * 3 && run('STANDING[standing()]') === 'Export houses', sizes.map(v => v.toFixed(0)).join(' -> '));
|
||||
run('ordersDone = 0; orders = []; nextOrderT = 0;');
|
||||
run('for (let k = 0; k < 300; k++) { tick += 1800; ordersTick(); }');
|
||||
check('there are never more than three open orders', run('orders.length') <= 3 && run('orders.length') >= 1, run('orders.length'));
|
||||
// the market dips when one thing is sold in bulk, then recovers
|
||||
run('mkRecent.fill(0)');
|
||||
const p0 = run('priceNow(BARCU)');
|
||||
run('for (let k = 0; k < 100; k++) sellUnit(BARCU)');
|
||||
const p1 = run('priceNow(BARCU)');
|
||||
run('for (let k = 0; k < 4 * 480; k++) marketTick()'); // four half-lives: 32 minutes
|
||||
const p2 = run('priceNow(BARCU)');
|
||||
check('selling 100 copper bars lowers their price, which recovers within half an hour', p1 < p0 * 0.5 && p2 > p0 * 0.9, `${p0} -> ${p1.toFixed(1)} -> ${p2.toFixed(1)}`);
|
||||
check('dirt earns nothing, and rubble only a pittance (builders pay a few times that)', run('PRICE[LDIRT]') === 0 && run('PRICE[RSTONE]') > 0 && run('PRICE[RSTONE]') < 0.05);
|
||||
check('refined goods pay more than the raw ore in them', run('PRICE[BARCU] > 13 * PRICE[CUORE] + 13 * PRICE[COAL]') && run('PRICE[BARBZ] > 2 * PRICE[BARCU]'));
|
||||
// machines cost money, walls do not
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('money = 30; bag[RSTONE] = 100; bagTotal = 100; bot.x = 110; bot.y = 70;');
|
||||
const r1 = run('JSON.stringify(placeBlocks(BP_BELT, 1, blockLine(28, 20, 31, 20)))');
|
||||
check('belts cost money: $30 buys two', JSON.parse(r1).n === 2 && run('money') === 6, r1 + ' money ' + run('money'));
|
||||
check('wall blocks still cost material, not money', run('placeBlock(BP_WALL, RSTONE, 26, 22, false)') === '' && run('money') === 6);
|
||||
run('removePieceAt(113, 81)');
|
||||
check('taking a belt back refunds its price', run('money') === 18, run('money'));
|
||||
// paying the claim
|
||||
run('money = 25000; debt = 20000;');
|
||||
run('payClaim(5000)');
|
||||
check('paying part of the claim', run('debt') === 15000 && run('money') === 20000);
|
||||
run('payClaim(1e9); checkLedger();');
|
||||
check('paying it off marks the milestone', run('debt') === 0 && run('!!ledger.claim') && run('money') === 5000);
|
||||
// alloys: tin into molten copper becomes bronze; zinc makes brass
|
||||
flatWorld(run, 240, 160, 100);
|
||||
rect(run, 100, 80, 101, 99, run('BRICK')); rect(run, 140, 80, 141, 99, run('BRICK'));
|
||||
run('for (let y = 92; y <= 99; y++) for (let x = 102; x <= 139; x++) setCell(y*W+x, MCU, 1200, 0)');
|
||||
run('for (let y = 86; y <= 87; y++) for (let x = 110; x <= 131; x++) setCell(y*W+x, MSN, 1100, 0)');
|
||||
run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) wakeHeatXY(x,y)');
|
||||
for (let k = 0; k < 20; k++) { run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) { const i=y*W+x; if (CASTMETAL[mat[i]] && temp[i] < 1150) temp[i] = 1150; }'); ticks(run, 15); }
|
||||
const bz = count(run, run('MBZ')), sn = count(run, run('MSN')), cu = count(run, run('MCU'));
|
||||
check('tin melted into copper mixes into bronze, roughly 1 part tin to 7 copper', bz > 200 && sn <= 12, `bronze ${bz}, tin left ${sn}, copper left ${cu}`);
|
||||
run('for (let y = 92; y <= 99; y++) for (let x = 102; x <= 139; x++) setCell(y*W+x, MCU, 1200, 0)');
|
||||
run('for (let y = 86; y <= 87; y++) for (let x = 110; x <= 131; x++) setCell(y*W+x, MZN, 1100, 0)');
|
||||
for (let k = 0; k < 20; k++) { run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) { const i=y*W+x; if (CASTMETAL[mat[i]] && temp[i] < 1150) temp[i] = 1150; }'); ticks(run, 15); }
|
||||
check('zinc melted into copper becomes brass', count(run, run('MBS')) > 60, count(run, run('MBS')));
|
||||
// a bronze ingot casts into bronze bars
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE)');
|
||||
run('for (const i of molds[0].cavs[0]) setCell(i, MBZ, 1000, 0)');
|
||||
ticks(run, 900);
|
||||
run('collectMold(molds[0])');
|
||||
check('bronze casts into bronze bars', run('ents.length') === 3 && run('ents.every(e => e.m === BARBZ)'), run('JSON.stringify(ents.map(e => MD[e.m].name))'));
|
||||
// soft metals are no good for a drill bit
|
||||
run('placeMoldAt("bit", 20, 22, RSTONE)');
|
||||
run('for (const i of molds[1].cavs[0]) setCell(i, MAU, 1100, 0)');
|
||||
ticks(run, 1200);
|
||||
check('a gold casting is too soft for a drill bit', /too soft/.test(run('collectMold(molds[1])')) && run('player.bitTier') === 0);
|
||||
// save and load keeps the economy
|
||||
run('generateWorld(778, 768, 512, null); money = 1234; debt = 5000; earned = 9999; mkRecent[BARCU] = 777; ordersDone = 6;');
|
||||
run('orders = [{ id: 9, m: BARCU, qty: 10, got: 4, mult: 2.1, pay: 840, due: tick + 9000, mins: 3 }];');
|
||||
run('globalThis.__s = serializeWorld(); orders = []; ordersDone = 0; deserializeWorld(__s.meta, __s.buffers);');
|
||||
check('save and load keep money, debt, earnings, market, orders and the dock', run('money') === 1234 && run('debt') === 5000 && run('earned') === 9999 && run('mkRecent[BARCU]') === 777 && run('!!dock()') && run('ordersDone') === 6 && run('orders.length === 1 && orders[0].got === 4 && orders[0].pay === 840'));
|
||||
done();
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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.
|
||||
const { execFileSync } = require('child_process');
|
||||
const { suite } = require('./harness');
|
||||
const { check, done } = suite();
|
||||
function play(design, ore, coal, firings, roll, mode) {
|
||||
const out = execFileSync('node', [__dirname + '/furnace_play.js', design, ore, coal, firings, roll, mode].map(String), { encoding: 'utf8', timeout: 600000 });
|
||||
const per = [...out.matchAll(/firing \d+: (.*)\| bars (\d+) \| left in furnace: ore (\d+), coal (\d+), slag (\d+), ash (\d+)/g)]
|
||||
.map(r => ({ bit: /New drill bit/.test(r[1]), bars: +r[2], ore: +r[3], coal: +r[4], slag: +r[5], ash: +r[6] }));
|
||||
return per;
|
||||
}
|
||||
for (const mode of ['mix', 'layers', 'vent']) {
|
||||
const runs = [0, 1].map(k => play('smelter1', 50, 50, 6, k, mode));
|
||||
const bars = runs.map(r => r.reduce((s, f) => s + f.bars, 0));
|
||||
const last = runs.map(r => r[r.length - 1]);
|
||||
check(`copper smelter, ${mode}: about 3 bars every firing for 6 firings`, bars.every(b => b >= 16), bars.join(', ') + ' of 18');
|
||||
check(` and the furnace stays clean`, last.every(f => f.ore < 30 && f.slag + f.ash < 10), last.map(f => `ore ${f.ore} slag ${f.slag} ash ${f.ash}`).join('; '));
|
||||
}
|
||||
for (const mode of ['mix', 'layers', 'vent']) {
|
||||
const got = [0, 1, 2].map(k => play('bitfurnace', 70, 70, 2, k, mode).filter(f => f.bit).length);
|
||||
check(`bit furnace, ${mode}: makes a bit every firing`, got.reduce((s, n) => s + n, 0) >= 5, got.join(', ') + ' of 2 each');
|
||||
}
|
||||
done();
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Molds and furnaces stay usable: knock-outs leave the mold clean, plugs over the mold are
|
||||
// cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting,
|
||||
// and slag lets the fire breathe.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
function fresh() { flatWorld(run, 240, 160, 100); run('bot.x = 110; bot.y = 70; bag.fill(0); bagTotal = 0; for (const e of ents.slice()) removeEnt(e);'); }
|
||||
const cavCount = (k, m) => run(`molds[${k}].cavs[0].filter(i => mat[i] === ${m}).length`);
|
||||
|
||||
// an ingot with 27 grains of copper: 3 bars, and the 3 left over go to the bag, not back in the trough
|
||||
fresh();
|
||||
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); for (let k = 0; k < 27; k++) { setCell(c[k], COPPER, 30, F_CAST); } } checkMolds();');
|
||||
let msg = run('collectMold(molds[0])');
|
||||
check('knocking out an ingot gives the bars', run('ents.length') === 3 && run('ents.every(e => e.m === BARCU)'), msg);
|
||||
check('and leaves the trough empty, the scrap in your bag', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 3, `scrap in bag ${run('bag[SCRCU]')}`);
|
||||
// a plate of copper set over the trough, and drips frozen in the grate above: knocked loose too
|
||||
fresh();
|
||||
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('for (let bx = 30; bx < 34; bx++) placeBlock(BP_GRATE, 0, bx, 21, true);');
|
||||
run('{ const mo = molds[0]; for (let x = mo.x + 1; x < mo.x + 15; x++) setCell(mo.y * W + x, COPPER, 30, F_CAST); for (let x = mo.x + 2; x < mo.x + 10; x++) setCell((mo.y - 1) * W + x, COPPER, 30, F_CAST); } checkMolds();');
|
||||
check('a mold with only a plate over it reads as empty', run('molds[0].state') === 'empty');
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('pressing F knocks the plate and the frozen drips loose', run(`(() => { const mo = molds[0]; for (let y = mo.y - 4; y <= mo.y; y++) for (let x = mo.x; x < mo.x + 16; x++) if (mat[y*W+x] === COPPER) return false; return true; })()`) && run('bag[SCRCU]') === 22, `${msg} | scrap ${run('bag[SCRCU]')}`);
|
||||
check('the grate itself is still there', run('part[(molds[0].y - 1) * W + molds[0].x + 3]') === run('P_GRATE'));
|
||||
// slag in an ingot is skimmed off; the bars still come out
|
||||
fresh();
|
||||
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); for (let k = 0; k < 16; k++) setCell(c[k], COPPER, 30, F_CAST); for (let k = 16; k < 20; k++) setCell(c[k], SLAG, 30, 0); } checkMolds();');
|
||||
check('an ingot with slag on top is still ready', run('molds[0].state') === 'ready', run('molds[0].state'));
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('the slag is chipped off and thrown away, and two bars come out', run('ents.length') === 2 && run('bag[SLAG]') === 0 && /chipped off/.test(msg) && run('molds[0].cavs[0].every(i => mat[i] === EMPTY)'), msg);
|
||||
// a bit casting with slag in it is spoiled, but F knocks it out instead of asking you to drill
|
||||
fresh();
|
||||
run('bag[RSTONE] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k === 3 ? SLAG : COPPER, 30, F_CAST)); } checkMolds();');
|
||||
check('a bit with slag in it is spoiled', run('molds[0].state') === 'flawed', run('molds[0].flaw'));
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('F knocks the spoiled bit out as scrap and the mold is ready again', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 29 && run('bag[SLAG]') === 0 && run('player.bitTier') === 0, msg);
|
||||
// a blown grain with nowhere to land never ends up below a grate
|
||||
fresh();
|
||||
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 22, true);'); // grate at y 88..91
|
||||
rect(run, 70, 66, 149, 87, run('SAND')); // the chamber above the grate is packed full up to y 66
|
||||
const placed = run('depositNear(CUORE, 20, 108, 86)');
|
||||
check('a grain that cannot land nearby goes up, never through the grate', placed && count(run, run('CUORE'), 0, 66, 239, 159) === 0 && count(run, run('CUORE'), 0, 0, 239, 65) === 1, `below the pile ${count(run, run('CUORE'), 0, 66, 239, 159)}, above it ${count(run, run('CUORE'), 0, 0, 239, 65)}`);
|
||||
// slag lets the fire breathe: coal buried under slag still finds air
|
||||
fresh();
|
||||
rect(run, 100, 95, 110, 99, run('COAL')); rect(run, 100, 92, 110, 94, run('SLAG'));
|
||||
check('coal under a layer of slag still gets air', run('hasO2(94 * 0 + 95 * W + 105, 105, 95)'));
|
||||
rect(run, 100, 92, 110, 94, run('RSTONE'));
|
||||
check('but not under rubble', !run('hasO2(95 * W + 105, 105, 95)'));
|
||||
|
||||
// molten slag trickles down through a charge but is too thick for a grate; molten copper runs through
|
||||
fresh();
|
||||
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 23, true);'); // grate at y 92..95
|
||||
rect(run, 98, 70, 99, 99, run('STONE')); rect(run, 120, 70, 121, 99, run('STONE'));
|
||||
run('for (let x = 104; x < 112; x++) { setCell(80 * W + x, MSLAG, 1300, 0); life[80 * W + x] = 255; setCell(81 * W + x, MCU, 1300, 0); life[81 * W + x] = 255; } for (let y = 84; y < 92; y++) for (let x = 100; x < 116; x++) setCell(y * W + x, CUORE, 1300, 0);');
|
||||
run('for (let y = 76; y < 100; y++) for (let x = 100; x < 116; x++) temp[y * W + x] = 1300;');
|
||||
ticks(run, 240);
|
||||
const below = m => run(`(()=>{let n=0;for(let y=96;y<100;y++)for(let x=96;x<120;x++)if(mat[y*W+x]===${m})n++;return n})()`);
|
||||
const sunk = run('(()=>{let n=0;for(let y=84;y<96;y++)for(let x=96;x<120;x++)if(mat[y*W+x]===MSLAG||mat[y*W+x]===SLAG)n++;return n})()');
|
||||
check('molten slag sinks down through the ore and stops on the grate', sunk >= 6 && below(run('MSLAG')) + below(run('SLAG')) === 0, `slag in the charge ${sunk}, under the grate ${below(run('MSLAG')) + below(run('SLAG'))}`);
|
||||
check('molten copper runs through the grate', below(run('MCU')) + below(run('COPPER')) >= 6, below(run('MCU')) + below(run('COPPER')));
|
||||
// ash fuses into molten slag in a hot fire, but not in a wood fire's heat
|
||||
fresh();
|
||||
run('for (let x = 100; x < 104; x++) setCell(98 * W + x, ASH, 1320, 0); for (let x = 110; x < 114; x++) setCell(98 * W + x, ASH, 900, 0);');
|
||||
run('for (let k = 0; k < 120; k++) { for (let y = 90; y < 100; y++) for (let x = 96; x < 108; x++) if (mat[y * W + x] === ASH) temp[y * W + x] = Math.max(temp[y * W + x], 1320); simTick(); }');
|
||||
check('ash fuses into slag above 1250°C', count(run, run('ASH'), 96, 90, 107, 99) === 0 && count(run, run('MSLAG')) + count(run, run('SLAG')) === 4, `ash ${count(run, run('ASH'), 96, 90, 107, 99)}, slag ${count(run, run('MSLAG')) + count(run, run('SLAG'))}`);
|
||||
check('ash in a cooler fire stays ash', count(run, run('ASH'), 108, 90, 116, 99) === 4);
|
||||
// a mixed charge stays mixed until it burns; ore then works its way down through the burning bed
|
||||
fresh();
|
||||
rect(run, 100, 90, 101, 99, run('STONE')); rect(run, 114, 90, 115, 99, run('STONE'));
|
||||
run('{ const r = mulberry32(3); for (let y = 90; y <= 99; y++) for (let x = 102; x <= 113; x++) setCell(y * W + x, r() < 0.5 ? CUORE : COAL, AMB[y], 0); }');
|
||||
const oreY = () => run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
|
||||
const y0 = oreY(); ticks(run, 3600); const y1 = oreY();
|
||||
check('ore does not sift down through unlit coal', Math.abs(y1 - y0) < 0.3, `${y0.toFixed(2)} -> ${y1.toFixed(2)}`);
|
||||
run('for (let y = 90; y <= 99; y++) for (let x = 102; x <= 113; x++) { const i = y * W + x; if (mat[i] === COAL) { flags[i] |= F_BURN; life[i] = 250; temp[i] = 900; } wakeXY(x, y); }');
|
||||
run('for (let k = 0; k < 900; k++) { if (k % 8 === 0) wakeXY(107, 95); simTick(); }'); const y2 = oreY();
|
||||
check('but sinks into a burning bed', y2 > y1 + 0.3, `${y1.toFixed(2)} -> ${y2.toFixed(2)}`);
|
||||
// knocking out the mold rakes slag and ash off the grate above it, and leaves the charge
|
||||
fresh();
|
||||
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('for (let bx = 30; bx < 34; bx++) placeBlock(BP_GRATE, 0, bx, 21, true); for (let bx = 29; bx < 35; bx++) placeBlock(BP_WALL, RSTONE, bx, 17, true);');
|
||||
run('{ const mo = molds[0]; for (let x = mo.x; x < mo.x + 16; x++) { setCell((mo.y - 5) * W + x, SLAG, 30, 0); setCell((mo.y - 6) * W + x, x & 1 ? ASH : SLAG, 30, 0); setCell((mo.y - 7) * W + x, x & 1 ? CUORE : COAL, 30, 0); setCell(70 * W + x, SLAG, 30, 0); } const c = molds[0].cavs[0]; for (let k = 0; k < 16; k++) setCell(c[k], COPPER, 30, F_CAST); } checkMolds();');
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('F rakes the slag and ash off the grate', /raked/.test(msg) && count(run, run('SLAG'), 120, 72, 136, 88) + count(run, run('ASH'), 120, 72, 136, 88) === 0 && run('bag[SLAG] + bag[ASH]') === 0, msg);
|
||||
check('but leaves the unburnt charge, and what is outside the furnace roof', count(run, run('CUORE'), 120, 72, 136, 88) + count(run, run('COAL'), 120, 72, 136, 88) === 16 && count(run, run('SLAG'), 120, 70, 136, 70) === 16);
|
||||
// slag floating on top of a part-filled bit is not a flaw: fire again, or knock it out
|
||||
fresh();
|
||||
run('bag[RSTONE] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
|
||||
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); c.forEach((i, k) => setCell(i, k < 24 ? COPPER : SLAG, 30, k < 24 ? F_CAST : 0)); } checkMolds();');
|
||||
check('a bit with slag floating on top reads as not full, not spoiled', run('molds[0].state') === 'short' && /floating/.test(run('molds[0].flaw')), run('molds[0].flaw'));
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('the first F press on an unfinished bit only asks', run('molds[0].cavs[0].filter(i => mat[i] === COPPER).length') === 24 && /again/.test(msg), msg);
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('a second press knocks it out as scrap', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 24 && run('player.bitTier') === 0, msg);
|
||||
// the igniter's flame reaches coal just under a thin layer of ore, when air gets to it from below
|
||||
fresh();
|
||||
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 23, true);');
|
||||
rect(run, 100, 90, 119, 91, run('COAL')); rect(run, 100, 88, 119, 89, run('CUORE'));
|
||||
run('for (let k = 0; k < 60; k++) { toolIgnite(110, 88); simTick(); }');
|
||||
ticks(run, 120);
|
||||
check('the igniter lights coal under two rows of ore over a grate', run('(()=>{for(let i=0;i<N;i++) if(flags[i]&F_BURN) return true; return false})()'));
|
||||
check('and says when there is nothing to burn', run('toolIgnite(60, 60).fuel') === false);
|
||||
// iron with some steel in it casts as iron; copper mixed with iron spoils the casting
|
||||
fresh();
|
||||
run('bag[RBRICK] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RBRICK); bag[RBRICK] = 0; bagTotal = 0; player.bitTier = 1;');
|
||||
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k % 5 === 0 ? STEEL : IRON, 30, F_CAST)); } checkMolds();');
|
||||
check('a bit of iron with a little steel in it is a good iron bit', run('molds[0].state') === 'ready' && run('molds[0].metal') === run('IRON'), run('molds[0].state + " " + molds[0].flaw'));
|
||||
msg = run('collectMold(molds[0])');
|
||||
check('and knocks out as an iron bit', run('player.bitTier') === 2, msg);
|
||||
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k % 5 === 0 ? COPPER : IRON, 30, F_CAST)); } checkMolds();');
|
||||
check('copper mixed with iron still spoils it', run('molds[0].state') === 'flawed', run('molds[0].flaw'));
|
||||
done();
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// Pipes, pumps and outlets move liquids; the Refiner grinds waste rock for trace ore.
|
||||
const { load, suite, flatWorld, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
const box = (x0, y0, x1, y1, m, fl = 'F_NAT') => run(`for (let y = ${y0}; y <= ${y1}; y++) for (let x = ${x0}; x <= ${x1}; x++) setCell(y * W + x, ${m}, AMB[y], ${fl})`);
|
||||
const lineWater = () => run('pipeNets.reduce((a, n) => a + (n.m === WATER ? n.n : 0), 0)');
|
||||
const totalWater = () => count(run, run('WATER')) + lineWater();
|
||||
|
||||
// two stone basins; a pump in the bottom corner of the left one pushes water up a pipe, over the
|
||||
// wall and out of an outlet into the right one
|
||||
function rig() {
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('money = 5000; bot.x = 110; bot.y = 50;');
|
||||
box(36, 80, 39, 99, 'STONE'); box(72, 80, 75, 99, 'STONE'); // left basin walls
|
||||
box(108, 80, 111, 99, 'STONE'); box(144, 80, 147, 99, 'STONE'); // right basin walls
|
||||
box(40, 84, 71, 99, 'WATER', 0);
|
||||
run(`placeBlock(BP_PUMP, 1, 17, 24, false); // pushes right, draws in from the left
|
||||
for (let by = 19; by <= 23; by++) placeBlock(BP_PIPE, 0, 17, by, false);
|
||||
for (let bx = 18; bx <= 28; bx++) placeBlock(BP_PIPE, 0, bx, 19, false);
|
||||
placeBlock(BP_OUTLET, 0, 29, 19, false); // pours down`);
|
||||
}
|
||||
rig();
|
||||
const w0 = totalWater(), ww0 = run('worldWater()');
|
||||
run('for (let k = 0; k < 900; k++) simTick();');
|
||||
const right = count(run, run('WATER'), 112, 60, 143, 99);
|
||||
check('a pump, a pipe over the wall and an outlet move water from one basin to the other', right > 250, `${right} grains in the right basin, ${lineWater()} in the pipes`);
|
||||
check('no water is lost or made on the way', totalWater() === w0, `${w0} -> ${totalWater()}`);
|
||||
check('the weather counts water in pipes, so it sends no rain to make up for it', run('worldWater()') === ww0, `${ww0} -> ${run('worldWater()')}`);
|
||||
// the line is one network: pump, 16 pipes, outlet
|
||||
check('touching pipes, pump and outlet make one line', run('(rebuildPipes(), pipeNets.length)') === 1 && run('pipeNets[0].blocks.length') === 18, `${run('pipeNets.length')} lines, ${run('pipeNets[0].blocks.length')} blocks`);
|
||||
|
||||
// an outlet closed with F holds the water in the line; the pump stops when the line is full
|
||||
rig();
|
||||
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 900; k++) simTick();');
|
||||
check('with the outlet closed the line fills up and nothing comes out', count(run, run('WATER'), 112, 60, 143, 99) === 0 && lineWater() === 18 * 16, `${lineWater()} in the line`);
|
||||
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 120; k++) simTick();');
|
||||
check('opened again, it pours', count(run, run('WATER'), 112, 60, 143, 99) > 50);
|
||||
|
||||
// taking a pipe back spills its share where it was
|
||||
rig();
|
||||
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 900; k++) simTick();');
|
||||
const before = totalWater();
|
||||
run('removeBlock(23, 19); rebuildPipes();');
|
||||
check('taking a full pipe back spills its 16 grains of water there', totalWater() === before && count(run, run('WATER'), 88, 70, 99, 99) > 0, `${before} -> ${totalWater()}`);
|
||||
check('and the line is cut in two', run('pipeNets.length') === 2, run('pipeNets.length'));
|
||||
|
||||
// one liquid per line, and nothing hotter than 500°C
|
||||
rig();
|
||||
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 60; k++) simTick();'); // outlet closed: some water stays in the line
|
||||
box(40, 84, 71, 99, 'EMPTY', 0); box(40, 90, 71, 99, 'MSN', 0); run('for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) temp[y * W + x] = 300;');
|
||||
run('for (let k = 0; k < 120; k++) simTick();');
|
||||
check('a line holding water will not take molten tin', count(run, run('MSN')) + count(run, run('TIN')) === 32 * 10 && run('pipeNets[0].m') === run('WATER'), `${count(run, run('MSN')) + count(run, run('TIN'))} tin left, line holds ${run('MD[pipeNets[0].m].name')}`);
|
||||
rig();
|
||||
box(40, 84, 71, 99, 'EMPTY', 0); box(40, 90, 71, 99, 'MCU', 0); run('for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) temp[y * W + x] = 1200;');
|
||||
run('for (let k = 0; k < 60; k++) { for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) if (mat[y * W + x] === MCU) temp[y * W + x] = 1200; simTick(); }');
|
||||
check('a pump will not take molten copper at 1200°C', run('pipeNets[0].n') === 0, run('pipeNets[0].n'));
|
||||
|
||||
// what is in the pipes is saved
|
||||
rig();
|
||||
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 400; k++) simTick();');
|
||||
const held = lineWater();
|
||||
run('(() => { const s = serializeWorld(); deserializeWorld(JSON.parse(JSON.stringify(s.meta)), s.buffers); })()');
|
||||
check('the water in a line survives saving and loading', held > 100 && lineWater() === held, `${held} -> ${lineWater()}`);
|
||||
|
||||
// pipes are thin: water and sand pass through a pipe run instead of being held back by it
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('money = 5000; bot.x = 60; bot.y = 50;');
|
||||
box(36, 76, 39, 99, 'STONE'); box(88, 76, 91, 99, 'STONE');
|
||||
run('for (let by = 19; by <= 24; by++) placeBlock(BP_PIPE, 0, 15, by, false);'); // a pipe wall at x 60..63
|
||||
box(40, 80, 59, 99, 'WATER', 0);
|
||||
run('for (let k = 0; k < 400; k++) simTick();');
|
||||
check('water flows through a run of pipe', count(run, run('WATER'), 64, 70, 87, 99) > 60, `${count(run, run('WATER'), 64, 70, 87, 99)} grains got past`);
|
||||
box(60, 64, 63, 70, 'SAND', 0); run('for (let k = 0; k < 300; k++) simTick();');
|
||||
check('and sand dropped on a pipe falls down through it', count(run, run('SAND'), 40, 80, 87, 99) > 20, `${count(run, run('SAND'), 40, 80, 87, 99)} of 28 at the bottom`);
|
||||
|
||||
// ---- the Refiner
|
||||
function refinerRig(feed, n) {
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run(`money = 5000; bot.x = 110; bot.y = 40; stats.refined = 0; stats.refinedOre = 0; placeMachineAt('refiner', 0, 26, 15);`); // x 104..115, y 60..71; chute out at y 72
|
||||
run(`{ let k = 0; for (let y = 60; y < 70 && k < ${n}; y++) for (let x = 106; x < 114 && k < ${n}; x++, k++) setCell(y * W + x, ${feed}, AMB[y], 0); }`);
|
||||
}
|
||||
refinerRig('RSTONE', 80);
|
||||
const ore = () => count(run, run('CUORE')) + count(run, run('COAL')) + count(run, run('FEORE')) + count(run, run('SNORE'));
|
||||
// five hopper-loads, each ground before the next goes in
|
||||
for (let k = 0; k < 5; k++) { run('for (let k = 0; k < 300; k++) simTick();'); if (k < 4) run('for (let y = 60; y < 70; y++) for (let x = 106; x < 114; x++) setCell(y * W + x, RSTONE, AMB[y], 0);'); }
|
||||
const fed = 400, sand = count(run, run('SAND')), o = ore();
|
||||
check('the Refiner grinds all the stone rubble it is fed', count(run, run('RSTONE')) === 0 && run('stats.refined') === fed, `${count(run, run('RSTONE'))} left, ${run('stats.refined')} of ${fed} ground`);
|
||||
check('about half as much sand comes out', sand > fed * 0.35 && sand < fed * 0.6, `${sand} sand from ${fed} rubble`);
|
||||
check('with the odd grain of common ore', o > 0 && o < fed * 0.15, `${o} ore grains`);
|
||||
// gravel, sand and dirt are refined too; ore drops straight through
|
||||
refinerRig('LDIRT', 60); run('for (let k = 0; k < 600; k++) simTick();');
|
||||
check('dirt goes through the refiner too', count(run, run('LDIRT')) === 0 && run('stats.refined') === 60);
|
||||
refinerRig('CUORE', 30); run('for (let k = 0; k < 400; k++) simTick();');
|
||||
check('ore it cannot grind drops straight through, all of it', count(run, run('CUORE')) === 30 && count(run, run('CUORE'), 104, 60, 115, 71) === 0, count(run, run('CUORE'), 104, 60, 115, 71));
|
||||
// a blocked chute stops it
|
||||
refinerRig('RSTONE', 40);
|
||||
box(100, 72, 120, 73, 'STONE');
|
||||
run('for (let k = 0; k < 200; k++) simTick();');
|
||||
check('with the space under its chute blocked it stops, and says so', count(run, run('RSTONE')) === 40 && run("machines.find(m => m && m.type === 'refiner').blocked") === true);
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works."""
|
||||
import sys, pathlib
|
||||
from playwright.sync_api import sync_playwright
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'smooth'); out.mkdir(parents=True, exist_ok=True)
|
||||
fails = []
|
||||
def check(name, cond, extra=''):
|
||||
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
|
||||
if not cond: fails.append(name)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'])
|
||||
pg = b.new_page(viewport={'width': 1024, 'height': 640})
|
||||
logs = []
|
||||
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
|
||||
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None)
|
||||
pg.add_init_script('window.__allowSoftwareGL = true; localStorage.clear();')
|
||||
pg.goto((root / 'dist' / 'index.html').as_uri()); pg.wait_for_timeout(300)
|
||||
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
|
||||
ev = pg.evaluate
|
||||
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]'))
|
||||
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
|
||||
px = ev("""(() => { const c = R.ctx.getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = R.ctx.getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
|
||||
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
|
||||
pg.screenshot(path=str(out / 'smooth.png'))
|
||||
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
||||
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
|
||||
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
||||
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, SM.ok, SM.why, SM.ms, SM.scale]'))
|
||||
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
|
||||
check('the pixel look can be chosen', ev('!R.smoothOn'))
|
||||
pg.screenshot(path=str(out / 'pixel.png'))
|
||||
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
|
||||
ev("SM.scale = 0.5"); pg.wait_for_timeout(1500)
|
||||
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
|
||||
# a browser without hardware drawing gets the pixel look and a note
|
||||
pg2 = b.new_page(viewport={'width': 800, 'height': 500})
|
||||
pg2.add_init_script('localStorage.clear();')
|
||||
pg2.goto((root / 'dist' / 'index.html').as_uri()); pg2.wait_for_timeout(300)
|
||||
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
|
||||
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
|
||||
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
|
||||
errs = [l for l in logs if 'PAGEERROR' in l or l.startswith('error')]
|
||||
check('no errors', not errs, errs[:3])
|
||||
b.close()
|
||||
print('FAILS:', fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,77 @@
|
||||
// Uses for waste rock and sand: glass bricks from a fan furnace, the stone cutter, the silo and
|
||||
// builders' orders.
|
||||
const { load, suite, flatWorld, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
const bars = (m) => run(`ents.filter(e => e.m === ${m}).length`);
|
||||
|
||||
// ---- glass: a brick furnace over an ingot mold, a fan blowing in through a side grate,
|
||||
// charged with sand and coal
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('money = 9999; bot.x = 60; bot.y = 40;');
|
||||
const bp = JSON.parse(run("JSON.stringify(smelterBlueprint('ingot', 1, 3, RBRICK))"));
|
||||
const ox = 24, oy = 25 - bp.bh;
|
||||
run(`(() => { const bp = ${JSON.stringify(bp)};
|
||||
for (const [dx, dy, code, v] of bp.blocks) placeBlock(code, v, ${ox} + dx, ${oy} + dy, true);
|
||||
for (const [k, dx, dy, wl] of bp.molds) addMold(k, ${ox} + dx, ${oy} + dy, wl, true);
|
||||
// a grate low in the left wall, and a fan a block out from it blowing in
|
||||
removeBlock(${ox}, ${oy} + 3); placeBlock(BP_GRATE, 0, ${ox}, ${oy} + 3, true);
|
||||
addMachine('fan', ${ox} - 2, ${oy} + 3, 0);
|
||||
})()`);
|
||||
const cx0 = (ox + 1) * 4, cx1 = (ox + bp.bw - 1) * 4 - 1, cy0 = (oy + 1) * 4, cy1 = (oy + 4) * 4 - 1;
|
||||
run(`{ const r = mulberry32(9); for (let y = ${cy0 + 5}; y <= ${cy1}; y++) for (let x = ${cx0}; x <= ${cx1}; x++) if (mat[y * W + x] === EMPTY) setCell(y * W + x, r() < 0.5 ? SAND : COAL, AMB[y], 0); }`);
|
||||
const sand0 = count(run, run('SAND'));
|
||||
run(`for (let k = 0; k < 60; k++) { toolIgnite(${cx0 + 3}, ${cy0 + 5}); toolIgnite(${cx1 - 3}, ${cy0 + 5}); simTick(); }`);
|
||||
let peak = 0;
|
||||
for (let t = 0; t < 6; t++) { run('for (let k = 0; k < 600; k++) simTick();'); peak = Math.max(peak, run(`(() => { let p = 0; for (let y = ${cy0}; y <= ${cy1}; y++) for (let x = ${cx0}; x <= ${cx1}; x++) p = Math.max(p, temp[y * W + x]); return Math.round(p); })()`)); }
|
||||
const st = run('molds[0].state'), glassIn = run('molds[0].cavs.flat().filter(i => mat[i] === GLASS).length');
|
||||
check('a fan furnace melts the sand and the molten glass runs into the ingot mold', glassIn >= 8, `peak ${peak}°C, ${sand0} sand -> ${count(run, run('SAND'))}, ${glassIn} glass in the mold, state ${st}`);
|
||||
const msg = run('collectMold(molds[0])');
|
||||
check('knocking it out gives glass bricks', bars(run('BARGL')) >= 2, `${bars(run('BARGL'))} bricks: ${msg}`);
|
||||
check('glass bricks sell for much more than the sand in them', run('PRICE[BARGL]') >= 8 * run('PRICE[SAND]') * 10, run('PRICE[BARGL]'));
|
||||
check('glass in a bit mold is too brittle to make a bit', run("METAL_TIER[GLASS]") === 0);
|
||||
|
||||
// ---- the stone cutter: 8 rubble to a stone brick; other grains drop through
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run("money = 9999; bot.x = 60; bot.y = 40; stats.bricks = 0; placeMachineAt('cutter', 0, 26, 18); for (let bx = 24; bx <= 34; bx++) placeBlock(BP_BELT, 1, bx, 21, true);"); // x 104..111, y 72..79; out at y 80, onto a belt
|
||||
run('for (let y = 72; y < 77; y++) for (let x = 106; x < 110; x++) setCell(y * W + x, RSTONE, AMB[y], 0);'); // 20 grains
|
||||
for (let k = 0; k < 4; k++) { run('for (let k = 0; k < 150; k++) simTick();'); run('for (let y = 72; y < 77; y++) for (let x = 106; x < 110; x++) if (mat[y * W + x] === EMPTY) setCell(y * W + x, RSTONE, AMB[y], 0);'); }
|
||||
run('for (let k = 0; k < 300; k++) simTick();');
|
||||
const fed = run('stats.bricks') * 8 + run("machines.find(m => m && m.type === 'cutter').cut");
|
||||
check('the stone cutter turns every 8 rubble into a stone brick', bars(run('BARSB')) === run('stats.bricks') && run('stats.bricks') >= 8 && count(run, run('RSTONE'), 104, 72, 111, 79) === 0, `${bars(run('BARSB'))} bricks from ${fed} rubble`);
|
||||
check('and they ride a belt away from under it', run('ents.filter(e => e.m === BARSB && e.ix > 120).length') >= 3, run('JSON.stringify(ents.filter(e => e.m === BARSB).map(e => e.ix))'));
|
||||
check('stone bricks build cut stone walls, 2 a block', run('WALL_OF[BARSB]') === run('CUTST') && JSON.stringify(run('wallCost(BARSB, 16)')) === JSON.stringify([[run('BARSB'), 2]]));
|
||||
check('cut stone stands more heat than rough stone', run('MELT[CUTST]') > run('MELT[STONE]'));
|
||||
run('bag[BARSB] = 4; bagTotal = 4; placeBlock(BP_WALL, BARSB, 10, 20, false);');
|
||||
const wallOk = count(run, run('CUTST')) === 16 && run('bag[BARSB]') === 2;
|
||||
run('removeBlock(10, 20)');
|
||||
check('a cut stone wall is laid from 2 bricks and gives them back', wallOk && run('bag[BARSB] + ents.filter(e => e.m === BARSB).length') >= 4 - 0, `${run('bag[BARSB]')} in the bag`);
|
||||
|
||||
// ---- the silo: one material in through the hatch, out of the gate when it is open
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run("money = 9999; bot.x = 60; bot.y = 30; placeMachineAt('silo', 0, 26, 1);"); // x 104..115, y 4..27; gate at y 27
|
||||
const silo = 'machines.find(m => m && m.type === "silo")';
|
||||
run('for (let k = 0; k < 30; k++) { for (let x = 107; x < 113; x++) setCell(2 * W + x, RSTONE, AMB[2], 0); simTick(); }');
|
||||
check('grains dropped in its hatch go into the silo', run(`${silo}.sn`) >= 150 && run(`${silo}.sm`) === run('RSTONE'), run(`${silo}.sn`));
|
||||
run('for (let x = 107; x < 113; x++) setCell(2 * W + x, SAND, AMB[2], 0); for (let k = 0; k < 20; k++) simTick();');
|
||||
check('a different material does not go in on top', run(`${silo}.sm`) === run('RSTONE') && count(run, run('SAND'), 104, 0, 115, 6) > 0);
|
||||
check('it will not be taken down while it holds something', /Empty the silo/.test(run('removePieceAt(110, 12)')) && run(`!!${silo}`));
|
||||
const held = run(`${silo}.sn`);
|
||||
run('interactAt(110, 12); for (let k = 0; k < 60; k++) simTick();');
|
||||
const out1 = count(run, run('RSTONE'), 100, 27, 120, 99);
|
||||
check('F opens the gate and it pours out underneath', out1 > 30 && run(`${silo}.sn`) === held - out1, `${out1} out, ${run(`${silo}.sn`)} left of ${held}`);
|
||||
run('interactAt(110, 12); for (let k = 0; k < 60; k++) simTick();');
|
||||
check('F shuts it again', count(run, run('RSTONE'), 100, 27, 120, 99) === out1);
|
||||
run('(() => { const s = serializeWorld(); deserializeWorld(JSON.parse(JSON.stringify(s.meta)), s.buffers); })()');
|
||||
check('what is in a silo is saved', run(`${silo}.sn`) === held - out1 && run(`${silo}.sm`) === run('RSTONE'));
|
||||
|
||||
// ---- builders' orders: bulk rubble, gravel and stone bricks at a few times a tiny price
|
||||
flatWorld(run, 240, 160, 100);
|
||||
run('seen[RSTONE] = 1; seen[GRAVEL] = 1; seen[BARSB] = 1; orders = []; ordersDone = 0;');
|
||||
const os = JSON.parse(run('JSON.stringify(Array.from({ length: 40 }, () => makeBuildersOrder()))'));
|
||||
const okOrders = os.every(o => o && o.who === 'Builders' && [run('RSTONE'), run('GRAVEL'), run('SAND'), run('BARSB')].includes(o.m) && o.pay > 0 && o.pay < 400);
|
||||
check("builders post orders for rubble, gravel and stone bricks that pay a little", okOrders, os.slice(0, 4).map(o => `${o.qty} of ${o.m} for $${o.pay}`).join(', '));
|
||||
const regular = JSON.parse(run('JSON.stringify(Array.from({ length: 60 }, () => { const o = makeOrder(); return o && !o.who ? o.m : 0; }))'));
|
||||
check('ordinary buyers never ask for thousands of grains of rubble', !regular.includes(run('RSTONE')) && !regular.includes(run('GRAVEL')));
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,104 @@
|
||||
// Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
const moist = () => run('weather.moist.reduce((a, v) => a + v, 0)');
|
||||
const rainInAir = (m) => run(`flyers.filter(f => f.rain && f.m === ${m}).length`);
|
||||
|
||||
run('generateWorld(777, 1024, 640, null)');
|
||||
check('new worlds have no springs', count(run, run('SPRING')) === 0);
|
||||
check('and the same seed still makes the same land', run('surfY[512]') > 0 && run('!!dock()'));
|
||||
// an older save with a spring in it: the spring becomes plain rock
|
||||
run('globalThis.__s = serializeWorld(); __s.buffers.mat[300 * W + 500] = SPRING; deserializeWorld(__s.meta, __s.buffers);');
|
||||
check('springs in older worlds turn into plain rock', run('mat[300 * W + 500]') === run('STONE') && count(run, run('SPRING')) === 0);
|
||||
|
||||
// steam in the open sky rises into the clouds instead of condensing
|
||||
flatWorld(run, 320, 200, 120);
|
||||
rect(run, 150, 100, 169, 104, run('STEAM'));
|
||||
run('for (let y = 100; y < 105; y++) for (let x = 150; x < 170; x++) temp[y*W+x] = 60;'); // already cooler than 97°C
|
||||
const steam0 = count(run, run('STEAM'));
|
||||
ticks(run, 240);
|
||||
check('cool steam under open sky rises to cloud height and joins the clouds', count(run, run('STEAM')) === 0 && count(run, run('WATER')) + rainInAir(run('WATER')) === 0 && moist() > steam0 * 0.9, `water ${count(run, run('WATER'))}, clouds ${moist().toFixed(0)} of ${steam0}`);
|
||||
check('a small cloud just hangs there', run('weather.raining.every(v => !v)'));
|
||||
// under a roof it condenses as before
|
||||
flatWorld(run, 320, 200, 120);
|
||||
rect(run, 140, 80, 179, 81, run('STONE')); rect(run, 140, 80, 141, 119, run('STONE')); rect(run, 178, 80, 179, 119, run('STONE'));
|
||||
rect(run, 150, 100, 169, 109, run('STEAM'));
|
||||
run('updateRoof(); for (let y = 100; y < 110; y++) for (let x = 150; x < 170; x++) temp[y*W+x] = 60;');
|
||||
ticks(run, 600);
|
||||
check('under a roof steam still turns back into water', count(run, run('WATER')) > 120 && moist() === 0, `water ${count(run, run('WATER'))}, clouds ${moist()}`);
|
||||
// clouds rain back out over the band where they gathered
|
||||
flatWorld(run, 512, 200, 120);
|
||||
run('weather.moist[3] = 400; weather.wind = 0;');
|
||||
ticks(run, 2);
|
||||
check('a big cloud starts to rain', run('weather.raining[3]') === 1);
|
||||
ticks(run, 900);
|
||||
const landed = count(run, run('WATER'));
|
||||
const near = count(run, run('WATER'), 3 * 64 - 64, 0, 4 * 64 + 64, 199);
|
||||
check('a cloud rains its water back down', landed + rainInAir(run('WATER')) + moist() > 390 && landed > 250, `landed ${landed}, in the air ${rainInAir(run('WATER'))}, left in the cloud ${moist().toFixed(0)}`);
|
||||
check('close to where it gathered', near > landed * 0.85, `${near} of ${landed} within a band`);
|
||||
// boiling water outdoors comes back as rain
|
||||
flatWorld(run, 320, 200, 120);
|
||||
rect(run, 140, 116, 179, 119, run('WATER'));
|
||||
run('for (let y = 116; y < 120; y++) for (let x = 140; x < 180; x++) { temp[y*W+x] = 400; wakeHeatXY(x, y); }');
|
||||
const w0 = count(run, run('WATER'));
|
||||
for (let k = 0; k < 40; k++) { run('for (let y = 116; y < 120; y++) for (let x = 140; x < 180; x++) if (mat[y*W+x] === WATER && temp[y*W+x] < 150) { temp[y*W+x] = 400; wakeHeatXY(x, y); }'); ticks(run, 30); }
|
||||
check('water boiled off outdoors goes up into the clouds', moist() > 30 || rainInAir(run('WATER')) > 0, `clouds ${moist().toFixed(0)}, ${count(run, run('WATER'))} of ${w0} still water`);
|
||||
run('weather.moist.fill(0); weather.moist[2] = 200;'); // more steam piles in: enough for a downpour
|
||||
ticks(run, 2400);
|
||||
check('and once the cloud is big enough it falls back down as rain', run('weather.raining.some(v => v)') || count(run, run('WATER')) > 100,
|
||||
`water ${count(run, run('WATER'))}, clouds ${moist().toFixed(0)}`);
|
||||
check('nothing is lost on the way', count(run, run('WATER')) + count(run, run('STEAM')) + moist() + rainInAir(run('WATER')) > w0 * 0.85, `water ${count(run, run('WATER'))}, steam ${count(run, run('STEAM'))}, clouds ${moist().toFixed(0)} of ${w0}`);
|
||||
// water is never made: with all of its water still there, no shower blows in
|
||||
flatWorld(run, 640, 200, 120);
|
||||
rect(run, 100, 110, 400, 119, run('WATER'));
|
||||
run('weather.base = worldWater(); weather.next = tick; EV.length = 0;');
|
||||
ticks(run, 2);
|
||||
check('a world with all its water gets no showers from outside', !run('weather.shower') && run('weather.next - tick') > 3600 * 4, run('weather.next - tick'));
|
||||
// some water left the world (sold at the dock, say): a shower makes up just that much
|
||||
const wBefore = run('worldWater()');
|
||||
run('for (let y = 110; y < 120; y++) for (let x = 100; x < 200; x++) setCell(y * W + x, EMPTY, AMB[y], 0);');
|
||||
run('weather.next = tick; EV.length = 0;');
|
||||
ticks(run, 2);
|
||||
const sh = JSON.parse(run('JSON.stringify(weather.shower)'));
|
||||
check('a world that has lost water gets a shower, and says so', sh && run('EV.some(e => e.t === "weather")'), JSON.stringify(sh));
|
||||
run('weather.shower.rate = 2; weather.wind = 0;');
|
||||
ticks(run, 3600);
|
||||
const wAfter = run('worldWater()');
|
||||
check('the shower brings back what was lost, and no more', wAfter >= wBefore * 0.97 && wAfter <= wBefore * 1.03, `${wBefore} -> ${wAfter}`);
|
||||
check('mostly under the shower', count(run, run('WATER'), Math.floor(sh.x0) - 60, 0, Math.ceil(sh.x1) + 60, 199) > (count(run, run('WATER')) - 2000) * 0.9);
|
||||
ticks(run, 400);
|
||||
check('the shower passes and the next check is a few minutes off', !run('weather.shower') && run('weather.next - tick') > 3600 * 4, run('weather.next - tick'));
|
||||
// clouds that drift to the edge of the world stay there
|
||||
flatWorld(run, 640, 200, 120);
|
||||
run('weather.moist[0] = 100; weather.moist[9] = 100; weather.wind = 1;');
|
||||
ticks(run, 600);
|
||||
check('clouds blown to the edge of the world are not lost', Math.round(moist()) === 200, moist());
|
||||
check('the sky reads as raining while it rains, dry after', run('rainAt(5)') === 0);
|
||||
// where the ground is frozen it snows
|
||||
flatWorld(run, 320, 200, 120);
|
||||
run('for (let y = 60; y <= 120; y++) AMB[y] = -6; for (let i = 0; i < N; i++) if (mat[i] === EMPTY) temp[i] = AMB[(i / W) | 0]; weather.moist[2] = 200; weather.wind = 0;');
|
||||
ticks(run, 900);
|
||||
check('on freezing ground it snows instead', count(run, run('SNOW')) > 100 && count(run, run('WATER')) < 5, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
|
||||
// saving: weather is kept; rain in the air never ends up in your bag
|
||||
flatWorld(run, 320, 200, 120);
|
||||
run('weather.moist[1] = 123; weather.next = tick + 5000; bag.fill(0); bagTotal = 0;');
|
||||
ticks(run, 2);
|
||||
run('spawnRain(10, 300, 50);');
|
||||
run('globalThis.__s = serializeWorld(); deserializeWorld(__s.meta, __s.buffers);');
|
||||
check('save and load keep the clouds and the forecast', Math.round(run('weather.moist[1]')) === 123 - 0 || run('weather.moist[1]') > 100, run('weather.moist[1]'));
|
||||
check('rain in the air never ends up in your bag', run('bagTotal') === 0, run('bagTotal'));
|
||||
check('rain in the air at the save goes back into the clouds', run('weather.moist.reduce((a, v) => a + v, 0)') >= 123 + 40, run('weather.moist.reduce((a, v) => a + v, 0)'));
|
||||
|
||||
// snow and water
|
||||
flatWorld(run, 240, 160, 100);
|
||||
rect(run, 100, 80, 101, 99, run('STONE')); rect(run, 140, 80, 141, 99, run('STONE')); rect(run, 102, 90, 139, 99, run('WATER'));
|
||||
check('snow blown out of the bag comes out frozen', run('bagTemp(SNOW)') < 0, run('bagTemp(SNOW)'));
|
||||
run('bag[SNOW] = 200; bagTotal = 200; bot.x = 116; bot.y = 70; for (let k = 0; k < 200 && bag[SNOW] > 0; k++) { toolBlow(110 + (k * 7) % 24, 86, 1, SNOW, null); simTick(); }');
|
||||
ticks(run, 900);
|
||||
check('snow that lands on a warm pond melts into it', count(run, run('SNOW')) < 10 && count(run, run('WATER')) > 38 * 10 + 100, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
|
||||
run('for (let y = 60; y < 120; y++) AMB[y] = -6; for (let y = 90; y < 100; y++) for (let x = 102; x < 140; x++) if (mat[y*W+x] === WATER) temp[y*W+x] = 0.2;');
|
||||
run('for (let x = 102; x < 140; x++) for (let y = 84; y < 88; y++) setCell(y * W + x, SNOW, -6, 0);');
|
||||
ticks(run, 2400);
|
||||
check('on freezing ground, a pond with snow on it ices over', count(run, run('ICE')) > 30, `ice ${count(run, run('ICE'))}, water ${count(run, run('WATER'))}`);
|
||||
done();
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
// Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag
|
||||
// refunds, drill reach, ballistic blowing, snowy mountains.
|
||||
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); };
|
||||
const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m);
|
||||
|
||||
// 1. acorns sprout on soil under open sky and grow into a tree
|
||||
fresh();
|
||||
box(100, 100, 140, 103, run('DIRT'));
|
||||
run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();');
|
||||
ticks(run, 1200);
|
||||
check('acorn sprouts into a sapling', run('saplings.length') === 1 && run('mat[99*W+120]') !== run('ACORN'), run('saplings.length'));
|
||||
ticks(run, 5600);
|
||||
const wood = run('bgm.reduce((a, v) => a + (v === WOOD), 0)'), leaves = run('bgm.reduce((a, v) => a + (v === LEAVES), 0)');
|
||||
check('sapling grows into a full tree', run('stats.grown') === 1 && wood >= 14 && leaves > 40, `wood ${wood}, leaves ${leaves}`);
|
||||
// acorn on bare stone does not sprout
|
||||
run('setCell(99*W+60, ACORN, AMB[99], 0); updateRoof();');
|
||||
box(55, 100, 65, 100, run('STONE'));
|
||||
ticks(run, 1500);
|
||||
check('acorn on stone stays an acorn', run('mat[99*W+60]') === run('ACORN') && run('saplings.length') === 0);
|
||||
// cutting the grown tree drops acorns
|
||||
run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(121, 99, 1)');
|
||||
ticks(run, 20);
|
||||
check('felling a tree drops acorns and wood', count(run, run('ACORN')) >= 2 && count(run, run('LWOOD')) > 10, `${count(run, run('ACORN'))} acorns, ${count(run, run('LWOOD'))} wood`);
|
||||
|
||||
// 2. charcoal: wood chunks heated in a sealed stone box (a kiln) char instead of burning
|
||||
fresh();
|
||||
box(110, 80, 130, 99, run('STONE')); box(112, 82, 128, 97, run('LWOOD'));
|
||||
run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; temp[i] = 520; wakeHeatXY(x,y); }');
|
||||
for (let k = 0; k < 30; k++) { run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; if (mat[i]===STONE) temp[i] = 560; }'); ticks(run, 20); }
|
||||
const ch = count(run, run('CHARCOAL'), 110, 80, 130, 99), wl = count(run, run('LWOOD'), 110, 80, 130, 99);
|
||||
check('sealed kiln turns wood into charcoal', ch > 100, `charcoal ${ch}, wood left ${wl}`);
|
||||
// smothered: a burning wood pile buried under dirt chars inside
|
||||
fresh();
|
||||
box(110, 92, 130, 99, run('LWOOD'));
|
||||
run('for (let k=0;k<60;k++){ for (let x=112;x<130;x+=4) toolIgnite(x, 92); simTick(); }');
|
||||
ticks(run, 60);
|
||||
box(106, 86, 134, 91, run('LDIRT'));
|
||||
ticks(run, 1800);
|
||||
check('a buried burning pile leaves charcoal', count(run, run('CHARCOAL')) > 20, count(run, run('CHARCOAL')));
|
||||
// charcoal burns hot
|
||||
fresh();
|
||||
box(110, 94, 130, 99, run('CHARCOAL'));
|
||||
run('for (let k=0;k<60;k++){ toolIgnite(120, 94); simTick(); }');
|
||||
ticks(run, 600);
|
||||
const tc = run('(()=>{let t=0;for(let y=88;y<100;y++)for(let x=105;x<135;x++)t=Math.max(t,temp[y*W+x]);return Math.round(t)})()');
|
||||
check('charcoal burns over 1100°C', tc > 1100, tc);
|
||||
|
||||
// 3. brick: clay lumps mixed into a walled coal fire turn into brick rubble
|
||||
fresh();
|
||||
box(104, 76, 107, 99, run('STONE')); box(133, 76, 136, 99, run('STONE')); box(104, 76, 136, 79, run('STONE'));
|
||||
run('for (let y=76;y<80;y++) for (let x=118;x<122;x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
run('{ const r = mulberry32(5); for (let y=90;y<=99;y++) for (let x=108;x<=132;x++) setCell(y*W+x, r() < 0.4 ? LCLAY : COAL, AMB[y], 0); }');
|
||||
run('bot.x = 115; bot.y = 82; for (let k=0;k<60;k++){ for (const x of [110,120,130]) { for (let y=90;y<99;y++) if (mat[y*W+x]===COAL && mat[(y-1)*W+x]===EMPTY) { toolIgnite(x,y); break; } } simTick(); }');
|
||||
ticks(run, 3600);
|
||||
check('clay lumps in a walled coal fire become brick rubble', count(run, run('RBRICK')) > 15, `${count(run, run('RBRICK'))} brick, ${count(run, run('LCLAY'))} clay left`);
|
||||
|
||||
// 4. density: a pile sorts heavy under light; wood floats, ore sinks
|
||||
fresh();
|
||||
box(110, 84, 130, 99, run('COAL')); box(110, 80, 130, 83, run('CUORE'));
|
||||
const oreY0 = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
|
||||
ticks(run, 3600);
|
||||
const oreY1 = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
|
||||
check('ore settles down through a coal pile', oreY1 > oreY0 + 3, `${oreY0.toFixed(1)} -> ${oreY1.toFixed(1)}`);
|
||||
fresh();
|
||||
box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE'));
|
||||
box(102, 86, 139, 99, run('WATER')); box(110, 70, 115, 73, run('LWOOD')); box(125, 70, 130, 73, run('RSTONE'));
|
||||
ticks(run, 900);
|
||||
const wy = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===LWOOD){s+=(i/W)|0;n++;} return s/n})()');
|
||||
const ry = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===RSTONE){s+=(i/W)|0;n++;} return s/n})()');
|
||||
check('wood chunks float, rubble sinks', wy < 89 && ry > 95, `wood y ${wy.toFixed(1)}, rubble y ${ry.toFixed(1)}`);
|
||||
|
||||
// 5. lava meets water: steam and a basalt crust
|
||||
fresh();
|
||||
box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE'));
|
||||
run('for (let y=90;y<=99;y++) for (let x=102;x<=139;x++) setCell(y*W+x, MAGMA, 1250, 0)');
|
||||
box(102, 84, 139, 87, run('WATER'));
|
||||
ticks(run, 240);
|
||||
check('water on lava flashes to steam and crusts it to basalt', count(run, run('BASALT')) > 20 && (count(run, run('STEAM')) + count(run, run('WATER'))) < 150, `basalt ${count(run, run('BASALT'))}, steam ${count(run, run('STEAM'))}, water ${count(run, run('WATER'))}`);
|
||||
|
||||
// 6. snow melts by a fire; water freezes in cold air high up
|
||||
fresh();
|
||||
box(110, 90, 116, 99, run('COAL')); box(117, 88, 126, 99, run('SNOW'));
|
||||
run('for (let y=88;y<=99;y++) for (let x=117;x<=126;x++) temp[y*W+x] = -5;');
|
||||
run('bot.x = 108; bot.y = 80; for (let k=0;k<60;k++){ toolIgnite(112, 90); simTick(); }');
|
||||
ticks(run, 1800);
|
||||
check('fire melts snow into water', count(run, run('SNOW')) < 100 && count(run, run('WATER')) + count(run, run('STEAM')) > 0, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
|
||||
check('the air is below freezing high up', run('AMB[5]') < -1, run('AMB[5].toFixed(1)'));
|
||||
fresh();
|
||||
box(100, 8, 140, 9, run('STONE')); box(100, 4, 101, 7, run('STONE')); box(139, 4, 140, 7, run('STONE'));
|
||||
run('for (let y=5;y<=7;y++) for (let x=102;x<=138;x++) setCell(y*W+x, WATER, AMB[y], 0)');
|
||||
ticks(run, 2400);
|
||||
check('water up in the cold air freezes into ice', count(run, run('ICE')) > 20, `ice ${count(run, run('ICE'))}, water ${count(run, run('WATER'))}`);
|
||||
const sv = run('(()=>{ const s = serializeWorld(); deserializeWorld(s.meta, s.buffers); let t = 99; for (let i=0;i<N;i++) if (mat[i]===ICE) t = Math.min(t, temp[i]); return t; })()');
|
||||
check('sub-zero temperatures survive save and load', sv < 0, sv);
|
||||
|
||||
// 7. full bag: what does not fit drops where the block stood
|
||||
fresh();
|
||||
run('player.tankTier = 0; bag[RSTONE] = 16; bag[LDIRT] = 1500 - 16; bagTotal = 1500; bot.x = 110; bot.y = 60;');
|
||||
run('placeBlocks(BP_WALL, RSTONE, [[30, 18]])');
|
||||
run('bag[LDIRT] = 1500; bagTotal = 1500;');
|
||||
const msg = run('removePieceAt(121, 73)');
|
||||
check('removing a block with a full bag drops its rubble in place', count(run, run('RSTONE'), 120, 72, 123, 75) === 16 && /bag is full/.test(msg), msg);
|
||||
run('bag[BARCU] = 20; bag[LDIRT] = 1480; bagTotal = 1500; placeBlocks(BP_BELT, 1, [[32, 18]]); bag[LDIRT] += 1; bagTotal = 1500;');
|
||||
const m0 = run('money'); run('removePieceAt(129, 73)'); ticks(run, 60);
|
||||
check('a belt removed with a full bag still refunds its price as money', run('money') === m0 + 12, `${m0} -> ${run('money')}`);
|
||||
|
||||
// 8. drill reach grows with the bit
|
||||
fresh();
|
||||
run('bot.x = 100; bot.y = 70;');
|
||||
const far = run('JSON.stringify(toolDig(102, 102, 2))');
|
||||
check('stone bit cannot reach 30 grains away', JSON.parse(far).dug === 0 && /Out of reach/.test(far), far);
|
||||
run('player.bitTier = 3;'); run('for (let k=0;k<30;k++) toolDig(102, 102, 2)');
|
||||
check('steel bit reaches that far', count(run, run('RSTONE')) > 5, count(run, run('RSTONE')));
|
||||
|
||||
// 9. blown grains fly past the cursor until gravity brings them down
|
||||
fresh();
|
||||
run('bot.x = 100; bot.y = 60; bag[SAND] = 200; bagTotal = 200;');
|
||||
for (let k = 0; k < 20; k++) run('toolBlow(120, 55, 0, SAND); simTick();');
|
||||
ticks(run, 200);
|
||||
const sx = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===SAND){s+=i%W;n++;} return n ? s/n : 0})()');
|
||||
check('blown sand carries on past the cursor and lands beyond it', sx > 121 && run('flyers.length') === 0, `landed around x ${sx.toFixed(1)} (cursor at 120)`);
|
||||
|
||||
// 10. a generated world has a snowy mountain
|
||||
run('generateWorld(4242, 2048, 1024, null)');
|
||||
const snow = run('(()=>{let n=0;for(let i=0;i<N;i++) if(mat[i]===SNOW) n++; return n})()');
|
||||
const peak = run('Math.min(...surfY)');
|
||||
check('worldgen raises a snow-capped mountain', snow > 80 && run(`AMB[${peak}]`) < 0, `snow ${snow}, peak y ${peak}, ${run(`AMB[${peak}].toFixed(1)`)}°C`);
|
||||
// the layout: plenty of sky, the first ores as shallow as ever, the deep layers stretched out below
|
||||
check('a third of a new world is sky', Math.abs(run('surfBase / H') - 0.3) < 0.01, run('surfBase'));
|
||||
const shallowOre = run('(()=>{let cu=0,fe=0;for(let x=0;x<W;x++)for(let d=0;d<150;d++){const m=mat[(surfY[x]+d)*W+x];if(m===CUV)cu++;else if(m===FEV)fe++;}return [cu,fe]})()');
|
||||
check('copper and iron ore are within reach of the surface', shallowOre[0] > 2000 && shallowOre[1] > 200, shallowOre);
|
||||
check('the deep layers fill the extra depth', run('depthY(0.905) - surfBase') > 600 && run('Math.abs(depthY(0.28) - surfBase - 131) <= 1'), run('[depthY(0.28), depthY(0.5), depthY(0.905)].map(y => y - surfBase)'));
|
||||
check('it is still warm a hundred cells above the ground', run('AMB[surfBase - 100]') > 0, run('AMB[surfBase - 100].toFixed(1)'));
|
||||
run('generateWorld(99, 4096, 1536, null)');
|
||||
const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x<W-1;x++){const l=mat[(surfY[x]-1)*W+x]===WATER;if(l&&!inLake)lakes++;inLake=l;if(surfY[x]<surfBase-100&&surfY[x]<=surfY[x-1]&&surfY[x]<surfY[x+1])peaks++;}return [lakes,peaks]})()');
|
||||
check('a large world has several lakes and mountains across it', marks[0] >= 2 && marks[1] >= 2, marks);
|
||||
|
||||
// --- round 5: trees under water, cut saplings, blown grains stay out of the bag
|
||||
fresh();
|
||||
box(100, 100, 140, 103, run('DIRT'));
|
||||
run('for (let y = 82; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 74; y < 82; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES;');
|
||||
box(100, 84, 101, 99, run('STONE')); box(140, 84, 141, 99, run('STONE')); box(102, 84, 139, 99, run('WATER'));
|
||||
run('bot.x = 114; bot.y = 80; for (let k=0;k<200;k++) toolDig(120, 98, 1)');
|
||||
ticks(run, 200);
|
||||
check('a tree under water can be cut down', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, `${count(run, run('LWOOD'))} wood chunks`);
|
||||
fresh();
|
||||
box(100, 100, 140, 103, run('DIRT'));
|
||||
run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();');
|
||||
ticks(run, 1200 + 3000);
|
||||
run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(120, 98, 1)');
|
||||
ticks(run, 30);
|
||||
run('for (let i = 0; i < N; i++) if (mat[i] === ACORN) setCell(i, EMPTY, 20, 0)'); // keep its acorns from sprouting a new one
|
||||
ticks(run, 2400);
|
||||
const left = run('bgm.reduce((a, v) => a + (v === WOOD), 0)');
|
||||
check('a half-grown tree that is cut down stays down', run('saplings.length') === 0 && left < 4, `trunk cells left ${left}, saplings ${run('saplings.length')}`);
|
||||
fresh();
|
||||
run('bot.x = 110; bot.y = 85; bag[SAND] = 300; bagTotal = 300;');
|
||||
for (let k = 0; k < 400 && run('bag[SAND]') > 0; k++) run('toolBlow(125, 99, 5, SAND); simTick();');
|
||||
ticks(run, 400);
|
||||
check('blown grains never come back into the bag', run('bag[SAND]') === 0 && count(run, run('SAND')) === 300, `bag ${run('bag[SAND]')}, world ${count(run, run('SAND'))}`);
|
||||
|
||||
// --- round 6: lifts, falling trees, machines without power, the bot and walls
|
||||
fresh();
|
||||
// a shaft 16 wide, a column of up-tubes from its floor, turned sideways at the top to unload onto the ground
|
||||
run('for (let y = 100; y < 132; y++) for (let x = 120; x < 128; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
run('bot.x = 110; bot.y = 90;');
|
||||
// unload well above the ground: a pile that reaches the outlet would block it
|
||||
run('for (let by = 19; by <= 32; by++) placeBlock(BP_TUBE, 0, 30, by, true); for (let bx = 30; bx <= 35; bx++) placeBlock(BP_TUBE, 1, bx, 18, true);');
|
||||
box(120, 116, 127, 131, run('SAND'));
|
||||
const sand0 = count(run, run('SAND'));
|
||||
ticks(run, 1800);
|
||||
const lifted = count(run, run('SAND'), 136, 0, 239, 99);
|
||||
check('a column of upward screw tubes lifts sand out of a shaft, unpowered', lifted > sand0 * 0.8, `${lifted} of ${sand0} grains brought up and out`);
|
||||
fresh();
|
||||
run('for (let bx = 25; bx <= 30; bx++) placeBlock(BP_BELT, 1, bx, 22, true); rebuildPower();');
|
||||
box(101, 84, 104, 87, run('SAND'));
|
||||
ticks(run, 600);
|
||||
check('belts run without any power', count(run, run('SAND'), 110, 0, 239, 99) > 8, count(run, run('SAND'), 110, 0, 239, 99));
|
||||
fresh();
|
||||
box(100, 100, 140, 103, run('DIRT'));
|
||||
run('for (let y = 86; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 76; y < 86; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES; findTreeBases();');
|
||||
run('for (let y = 100; y < 104; y++) for (let x = 116; x < 126; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
ticks(run, 60);
|
||||
check('a tree with its ground dug out from under it falls', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, count(run, run('LWOOD')));
|
||||
fresh();
|
||||
// a stone box of sand; the bot flies in through the wall: no sand may end up outside
|
||||
box(110, 80, 113, 99, run('STONE')); box(130, 80, 133, 99, run('STONE')); box(110, 96, 133, 99, run('STONE'));
|
||||
run('for (let y=80;y<100;y++) for (let x=110;x<134;x++) flags[y*W+x] = 0;');
|
||||
box(114, 84, 129, 95, run('SAND'));
|
||||
const inside0 = count(run, run('SAND'), 114, 0, 129, 99);
|
||||
run('bot.x = 100; bot.y = 88;');
|
||||
for (let k = 0; k < 120; k++) run('botStep(1, 0, false); simTick();');
|
||||
for (let k = 0; k < 160; k++) run('botStep(-1, 0, false); simTick();');
|
||||
ticks(run, 120);
|
||||
check('the bot flying through a wall into sand never pushes sand out', count(run, run('SAND'), 114, 0, 129, 99) === inside0, `${inside0} -> ${count(run, run('SAND'), 114, 0, 129, 99)} inside`);
|
||||
// a settled sand pile with the bot sitting in it: not one grain moves
|
||||
fresh();
|
||||
box(110, 92, 133, 99, run('STONE'));
|
||||
box(114, 84, 129, 91, run('SAND'));
|
||||
ticks(run, 60);
|
||||
const pile = () => run('(() => { let s = ""; for (let y = 70; y < 92; y++) for (let x = 110; x < 134; x++) s += mat[y * W + x] === SAND ? 1 : 0; return s; })()');
|
||||
const pile0 = pile();
|
||||
run('bot.x = 119; bot.y = 84; bot.vx = bot.vy = 0;');
|
||||
for (let k = 0; k < 90; k++) run('botStep(0, 0, false); simTick();');
|
||||
check('the bot resting inside a sand pile leaves every grain where it was', pile() === pile0);
|
||||
// sand poured onto the bot falls through it and piles up just as it would with the bot elsewhere
|
||||
const pour = (bx) => {
|
||||
fresh(); box(110, 96, 133, 99, run('STONE'));
|
||||
run(`bot.x = ${bx}; bot.y = 86; bot.vx = bot.vy = 0;`);
|
||||
for (let k = 0; k < 140; k++) run('if (k_ % 2 === 0) setCell(60 * W + 120, SAND, AMB[60], 0); botStep(0, 0, false); simTick();'.replace('k_', String(k)));
|
||||
ticks(run, 60);
|
||||
return [count(run, run('SAND'), 110, 60, 133, 95), count(run, run('SAND'), 110, 60, 133, 84)];
|
||||
};
|
||||
const [withBot, aboveBot] = pour(118), [without] = pour(200);
|
||||
check('sand poured onto the bot falls through it to the ground', withBot === without && aboveBot === 0, `${withBot} grains landed (${without} with the bot away), ${aboveBot} held up above it`);
|
||||
done();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
|
||||
const { load, flatWorld, rect, ticks, dump } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const C = n => run(n);
|
||||
const kind = process.argv[2] || 'bit', wallName = process.argv[3] || 'STONE';
|
||||
flatWorld(run, 220, 160, 100);
|
||||
run('bot.x = 90; bot.y = 60;');
|
||||
// pit for the mold: x 80..103, y 100..111
|
||||
run(`for (let y=100;y<=111;y++) for (let x=80;x<=103;x++) setCell(y*W+x, EMPTY, AMB[y], 0)`);
|
||||
run(`bag[R${wallName === 'STONE' ? 'STONE' : 'BRICK'}] = 500; bagTotal = 500;`);
|
||||
console.log('mold', JSON.stringify(run(`placeMoldAt('${kind}', 21, 25, ${wallName === 'STONE' ? 'RSTONE' : 'RBRICK'})`)));
|
||||
const W_ = C(wallName);
|
||||
rect(run, 80, 100, 83, 111, W_); rect(run, 100, 100, 103, 111, W_); rect(run, 80, 108, 103, 111, W_);
|
||||
if (process.env.GRATE) { run('bag[LCLAY] = 100; bagTotal += 100;'); console.log('grate', JSON.stringify(run('placeBlocks(BP_GRATE, 0, blockLine(21, 24, 24, 24))'))); }
|
||||
// furnace walls above, floor at ground level except over the mold
|
||||
rect(run, 74, 74, 75, 99, W_); rect(run, 108, 74, 109, 99, W_); rect(run, 74, 74, 109, 75, W_);
|
||||
run(`for (let y=74;y<76;y++) for (let x=91;x<93;x++) setCell(y*W+x, EMPTY, AMB[y], 0)`);
|
||||
run(`{ let r=mulberry32(9); for (let y=80;y<=99;y++) for (let x=76;x<=107;x++) { const i=y*W+x; if (mat[i]===EMPTY && !part[i]) setCell(i, r()<0.32 ? CUORE : COAL, AMB[y], 0); } }`);
|
||||
run(`for (let k=0;k<40;k++){ toolIgnite(80,79); toolIgnite(103,79); simTick(); }`);
|
||||
for (const t of [1200, 1200, 1200, 1800]) {
|
||||
ticks(run, t);
|
||||
console.log('--- +' + t, run(`JSON.stringify(molds.map(m=>({state:m.state, fill:+m.fill.toFixed(2), ready: m.ready.length, flaw:m.flaw})))`));
|
||||
}
|
||||
if (process.argv[4]) dump(run, 72, 70, 112, 112, true);
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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.
|
||||
const { load } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const seed = 424242, w = +(process.argv[2] || 1024), h = +(process.argv[3] || 640);
|
||||
run(`generateWorld(${seed}, ${w}, ${h}, null); player.tankTier = 3;`);
|
||||
const awake = () => run('(()=>{let a=0,h=0;for(let c=0;c<NCH;c++){if(awake[c])a++;if(hot[c])h++;}return [a,h,NCH]})()');
|
||||
function measure(n) {
|
||||
const t0 = performance.now();
|
||||
run(`for (let k=0;k<${n};k++) simTick();`);
|
||||
return (performance.now() - t0) / n;
|
||||
}
|
||||
run('for (let k=0;k<600;k++) simTick();');
|
||||
const idle = measure(600);
|
||||
const [ia, ih, nch] = awake();
|
||||
console.log(`world ${w}x${h}: ${nch} areas of 32x32`);
|
||||
console.log(`idle: ${idle.toFixed(2)} ms/tick, ${ia} moving areas, ${ih} warm areas`);
|
||||
|
||||
function buildModule(mx, my) {
|
||||
run(`(() => {
|
||||
const mx = ${mx}, my = ${my};
|
||||
// clear the sky box we build in
|
||||
for (let y = my*4 - 4; y < (my+9)*4; y++) for (let x = mx*4 - 4; x < (mx+20)*4; x++) { const i = y*W+x; if (PH[mat[i]] !== PH_SOLID) setCell(i, EMPTY, AMB[y], 0); }
|
||||
// furnace: 4x4 blocks of stone with a 2x2 hollow and a vent on top
|
||||
for (let by = my; by < my+4; by++) for (let bx = mx; bx < mx+4; bx++) {
|
||||
const inner = bx > mx && bx < mx+3 && by > my && by < my+3;
|
||||
if (inner || (by === my && bx === mx+2)) continue;
|
||||
placeBlock(BP_WALL, RSTONE, bx, by, true);
|
||||
}
|
||||
const ix = (mx+1)*4, iy = (my+1)*4;
|
||||
for (let y = iy+3; y < iy+8; y++) for (let x = ix; x < ix+8; x++) setCell(y*W+x, ((x+y)&3) ? COAL : CUORE, AMB[y], 0);
|
||||
for (let x = ix; x < ix+8; x++) { temp[(iy+3)*W+x] = 700; wakeHeatXY(x, iy+3); }
|
||||
// spring above a water wheel
|
||||
for (let y = my*4; y < my*4+2; y++) for (let x = (mx+6)*4; x < (mx+7)*4; x++) { mat[y*W+x] = SPRING; flags[y*W+x] = F_NAT; }
|
||||
addMachine('wheel', mx+5, my+1, 0);
|
||||
// shaft from the wheel's side down to a belt line
|
||||
placeBlock(BP_SHAFT, 0, mx+8, my+2, true); placeBlock(BP_SHAFT, 0, mx+9, my+2, true);
|
||||
placeBlock(BP_SHAFT, 0, mx+9, my+3, true); placeBlock(BP_SHAFT, 0, mx+9, my+4, true);
|
||||
for (let bx = mx+9; bx < mx+20; bx++) placeBlock(BP_BELT, 1, bx, my+5, true);
|
||||
// sand hopper on the first belt blocks
|
||||
for (let y = (my+5)*4 - 12; y < (my+5)*4; y++) for (let x = (mx+10)*4; x < (mx+13)*4; x++) setCell(y*W+x, SAND, AMB[y], 0);
|
||||
})()`);
|
||||
run('powerDirty = true;');
|
||||
}
|
||||
const report = [];
|
||||
const botBx = run('Math.floor(bot.x / 4)');
|
||||
const slots = [];
|
||||
for (let mx = 6; mx + 22 < w / 4 && slots.length < 8; mx += 28) if (Math.abs(mx + 10 - botBx) > 24) slots.push(mx);
|
||||
for (const K of [1, 4, 8]) {
|
||||
if (K > slots.length) break;
|
||||
// fresh world for each count, so every module starts fully fuelled
|
||||
run(`generateWorld(${seed}, ${w}, ${h}, null); for (let k=0;k<600;k++) simTick();`);
|
||||
const modules = slots.slice(0, K);
|
||||
for (const mx of modules) buildModule(mx, 8);
|
||||
run('for (let k=0;k<300;k++) simTick();');
|
||||
const moved0 = run('stats.moved');
|
||||
const ms = measure(900);
|
||||
const [a, ht] = awake();
|
||||
const wheels = run('machines.filter(m => m && m.type === "wheel").map(m => m.out.toFixed(1)).join(" ")');
|
||||
const sat = run('nets.slice(1).map(n => n.sat.toFixed(2)).join(" ")');
|
||||
const furn = run(`[${modules.map(mx => `(() => { let t = 0; for (let y = 36; y < 44; y++) for (let x = ${(mx + 1) * 4}; x < ${(mx + 3) * 4}; x++) t = Math.max(t, temp[y*W+x]); return Math.round(t); })()`).join(',')}].join(' ')`);
|
||||
const moved = run('stats.moved') - moved0;
|
||||
const dist = Math.min(...modules.map(mx => Math.abs(mx * 4 + 40 - run('bot.x'))));
|
||||
console.log(`\n${K} module${K > 1 ? 's' : ''}: ${ms.toFixed(2)} ms/tick (+${(ms - idle).toFixed(2)} over idle), ${a} moving areas, ${ht} warm areas`);
|
||||
console.log(` nearest module ${Math.round(dist)} grains from the bot; wheel output ${wheels}; belt power satisfaction ${sat}`);
|
||||
console.log(` furnace peak temps ${furn} °C; grains moved by machines in 15 s: ${moved}`);
|
||||
report.push({ K, ms, extra: ms - idle, a, ht });
|
||||
}
|
||||
const per = report.map(r => (r.extra / r.K).toFixed(2));
|
||||
console.log(`\nextra cost per module: ${per.join(' / ')} ms/tick`);
|
||||
const budget = 9; // ms per tick left for simulation at 60 ticks/s after rendering
|
||||
console.log(`at ~${budget} ms/tick of simulation budget, roughly ${Math.floor((budget - idle) / Math.max(0.01, report[report.length - 1].extra / report[report.length - 1].K))} such modules run at full speed`);
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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.
|
||||
const { load, flatWorld, rect, ticks, count } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const res = {};
|
||||
function fresh() { flatWorld(run, 320, 200, 120); run('bot.x = 20; bot.y = 40; money = 1e6; globalThis.__got = 0;'); }
|
||||
function rate(x0, x1, y0, y1) {
|
||||
const sink = `for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) { const i=y*W+x; if (mat[i]===SAND) { mat[i]=EMPTY; __got++; } }`;
|
||||
for (let k = 0; k < 90; k++) run(`simTick(); ${sink}`);
|
||||
run('__got = 0');
|
||||
for (let k = 0; k < 300; k++) run(`simTick(); ${sink}`);
|
||||
return run('__got') / 5;
|
||||
}
|
||||
// sideways tube fed by a walled hopper sitting on its first two blocks
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
|
||||
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
|
||||
rect(run, 80, 0, 95, 79, run('SAND'));
|
||||
res.tubeSideways = rate(120, 319, 84, 119);
|
||||
res.tubeLanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
|
||||
// lift: a column of up-tubes in a pit of sand, turned sideways at the top
|
||||
fresh();
|
||||
run('for (let y = 120; y < 196; y++) for (let x = 90; x < 130; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
|
||||
rect(run, 90, 130, 129, 195, run('SAND'));
|
||||
run('for (let by = 20; by <= 37; by++) placeBlock(BP_TUBE, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);');
|
||||
res.lift = rate(164, 319, 80, 119);
|
||||
res.liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y++) for (let x = 120; x < 160; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
|
||||
// belt: 12 blocks, a pile dropped on its back end
|
||||
fresh();
|
||||
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
|
||||
rect(run, 80, 20, 95, 99, run('SAND'));
|
||||
res.belt = rate(128, 319, 104, 119);
|
||||
console.log(JSON.stringify(res));
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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.
|
||||
const { load, flatWorld, rect, count, maxTemp, ticks } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const C = n => run(n);
|
||||
|
||||
function report(t, region) {
|
||||
const [x0, y0, x1, y1] = region;
|
||||
const burning = run(`(()=>{let n=0;for(let y=${y0};y<=${y1};y++)for(let x=${x0};x<=${x1};x++)if(flags[y*W+x]&F_BURN)n++;return n})()`);
|
||||
const oreT = run(`(()=>{let s=0,n=0;for(let y=${y0};y<=${y1};y++)for(let x=${x0};x<=${x1};x++)if(mat[y*W+x]===CUORE||mat[y*W+x]===FEORE){s+=temp[y*W+x];n++;}return n?Math.round(s/n):0})()`);
|
||||
const airT = run(`(()=>{let s=0,n=0;for(let y=${y0};y<=${y1};y++)for(let x=${x0};x<=${x1};x++)if(mat[y*W+x]===EMPTY){s+=temp[y*W+x];n++;}return n?Math.round(s/n):0})()`);
|
||||
console.log(`t=${String(t).padStart(5)} maxT=${String(maxTemp(run, x0, y0, x1, y1)).padStart(5)} oreT=${String(oreT).padStart(5)} airT=${String(airT).padStart(4)} burn=${String(burning).padStart(3)} coal=${count(run, C('COAL'), x0, y0, x1, y1)} cuOre=${count(run, C('CUORE'), x0, y0, x1, y1)} feOre=${count(run, C('FEORE'), x0, y0, x1, y1)} mCu=${count(run, C('MCU'), x0, y0, x1, y1)} Cu=${count(run, C('COPPER'), x0, y0, x1, y1)} mFe=${count(run, C('MFE'), x0, y0, x1, y1)} Fe=${count(run, C('IRON'), x0, y0, x1, y1)} slag=${count(run, C('SLAG'), x0, y0, x1, y1)} ash=${count(run, C('ASH'), x0, y0, x1, y1)} magma=${count(run, C('MAGMA'), x0, y0, x1, y1)} brick=${count(run, C('BRICK'), x0, y0, x1, y1)} smoke=${count(run, C('SMOKE'), x0, 0, x1, y1)}`);
|
||||
}
|
||||
function scenario(name, build, total, every, region) {
|
||||
flatWorld(run, 200, 140, 90);
|
||||
build();
|
||||
console.log(`\n=== ${name} ===`);
|
||||
for (let t = 0; t <= total; t += every) { if (t) ticks(run, every); report(t, region); }
|
||||
}
|
||||
function ignite(x, y, n = 40) { run(`for (let k=0;k<${n};k++){ toolIgnite(${x},${y}); simTick(); }`); }
|
||||
// mixed charge: coal with ore grains sprinkled through it
|
||||
function charge(x0, y0, x1, y1, ore, frac) {
|
||||
run(`{ let r=mulberry32(5); for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) setCell(y*W+x, r()<${frac} ? ${ore} : COAL, AMB[y], 0); }`);
|
||||
}
|
||||
function furnace(x0, y0, x1, y1, wall, thick, vent) {
|
||||
rect(run, x0, y0, x0 + thick - 1, y1, wall); rect(run, x1 - thick + 1, y0, x1, y1, wall);
|
||||
rect(run, x0, y0, x1, y0 + thick - 1, wall);
|
||||
const cx = (x0 + x1) >> 1;
|
||||
run(`for (let y=${y0};y<${y0 + thick};y++) for (let x=${cx - (vent >> 1)};x<${cx - (vent >> 1) + vent};x++) setCell(y*W+x, EMPTY, AMB[y], 0)`);
|
||||
}
|
||||
const which = process.argv[2] || 'all';
|
||||
|
||||
if (which === 'all' || which === 'open') scenario('Open mixed pile (coal + 30% copper ore)', () => {
|
||||
charge(80, 80, 103, 89, C('CUORE'), 0.3);
|
||||
ignite(85, 79); ignite(98, 79);
|
||||
}, 3600, 600, [70, 60, 113, 89]);
|
||||
|
||||
if (which === 'all' || which === 'stone') scenario('Stone furnace, mixed charge, 2-wide vent', () => {
|
||||
furnace(77, 66, 106, 89, C('STONE'), 2, 2);
|
||||
charge(79, 76, 104, 89, C('CUORE'), 0.3);
|
||||
ignite(84, 75); ignite(99, 75);
|
||||
}, 4800, 600, [77, 64, 106, 89]);
|
||||
|
||||
if (which === 'all' || which === 'clay') scenario('Clay furnace, mixed charge (walls fire to brick)', () => {
|
||||
furnace(77, 66, 106, 89, C('CLAY'), 2, 2);
|
||||
charge(79, 76, 104, 89, C('CUORE'), 0.3);
|
||||
ignite(84, 75); ignite(99, 75);
|
||||
}, 4800, 600, [77, 64, 106, 89]);
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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).
|
||||
const { load, flatWorld, rect, ticks, dump } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const C = n => run(n);
|
||||
flatWorld(run, 200, 140, 90);
|
||||
const wall = C(process.argv[2] || 'STONE');
|
||||
rect(run, 77, 66, 78, 95, wall); rect(run, 105, 66, 106, 95, wall); rect(run, 77, 66, 106, 67, wall);
|
||||
run(`for (let y=66;y<68;y++) for (let x=91;x<93;x++) setCell(y*W+x, EMPTY, AMB[y], 0)`);
|
||||
// dig a basin into the ground so the furnace has a floor of the wall material
|
||||
run(`for (let y=90;y<=95;y++) for (let x=79;x<=104;x++) setCell(y*W+x, y>=94? ${wall} : EMPTY, AMB[y], 0)`);
|
||||
run(`{ let r=mulberry32(5); for (let y=76;y<=93;y++) for (let x=79;x<=104;x++) setCell(y*W+x, r()<0.3 ? CUORE : COAL, AMB[y], 0); }`);
|
||||
run(`for (let k=0;k<40;k++){ toolIgnite(84,75); toolIgnite(99,75); simTick(); }`);
|
||||
for (const t of [600, 1200, 1200, 1200]) { ticks(run, t); console.log('--- +' + t); dump(run, 75, 64, 108, 97, true); }
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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 ...]
|
||||
const { load, flatWorld, ticks } = require('../../test/harness');
|
||||
const opt = Object.assign({ wall: 'RSTONE', ratio: 0.33, ch: 4, vent: 1, grate: 1, fill: 0.65, mix: 'mix', ign: 'top', mold: 'ingot', T: 7200, dump: 0, lid: 0, base: 'RSTONE', cw: 4 },
|
||||
Object.fromEntries(process.argv.slice(2).map(a => a.split('=')).map(([k, v]) => [k, isNaN(+v) ? v : +v])));
|
||||
const { run } = load();
|
||||
flatWorld(run, 200, 160, 100);
|
||||
run('bot.x = 90; bot.y = 50;');
|
||||
const bx = 23, g = 23, CH = opt.ch, top = g - CH - 1, CWB = opt.cw;
|
||||
const W_ = opt.wall;
|
||||
const B = (code, v, x, y) => run(`placeBlock(${code}, ${v}, ${x}, ${y}, true)`);
|
||||
// mold on the ground
|
||||
for (let k = 0; k < CWB / 4; k++) {
|
||||
const moldErr = run(`addMold('${opt.mold}', ${bx + k * 4}, 24, ${opt.base}, true)`);
|
||||
if (typeof moldErr === 'string') { console.log('mold error', moldErr); process.exit(1); }
|
||||
}
|
||||
// floor: grate (or plain walls with nothing) over the mold
|
||||
for (let x = bx; x < bx + CWB; x++) if (opt.grate) B('BP_GRATE', 0, x, g);
|
||||
// side walls from roof down to the mold row, roof with a vent in the middle
|
||||
if (W_ !== 'none') {
|
||||
for (let y = top; y <= 24; y++) { B('BP_WALL', W_, bx - 1, y); B('BP_WALL', W_, bx + CWB, y); }
|
||||
const mid = bx + (CWB >> 1);
|
||||
const ventX = opt.vent === 2 ? [mid - 1, mid] : opt.vent === 1 ? [mid] : [];
|
||||
for (let x = bx; x < bx + CWB; x++) if (!ventX.includes(x)) B('BP_WALL', W_, x, top);
|
||||
}
|
||||
// charge
|
||||
const x0 = bx * 4, x1 = (bx + CWB) * 4 - 1, yBot = g * 4 - 1, yTop = Math.round(yBot - CH * 4 * opt.fill) + 1;
|
||||
run(`{ const r = mulberry32(11); for (let y = ${yTop}; y <= ${yBot}; y++) for (let x = ${x0}; x <= ${x1}; x++) {
|
||||
const i = y*W+x; if (mat[i] !== EMPTY || part[i]) continue;
|
||||
let ore;
|
||||
if ('${opt.mix}' === 'layers') ore = ((${yBot} - y) >> 1) % 3 === 1;
|
||||
else if ('${opt.mix}' === 'coalbottom') ore = y < ${yTop} + (${yBot} - ${yTop}) * ${opt.ratio};
|
||||
else ore = r() < ${opt.ratio};
|
||||
setCell(i, ore ? CUORE : COAL, AMB[y], 0);
|
||||
} }`);
|
||||
const ore0 = run(`(()=>{let n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE) n++; return n})()`);
|
||||
const coal0 = run(`(()=>{let n=0;for(let i=0;i<N;i++) if(mat[i]===COAL) n++; return n})()`);
|
||||
// light it like a player would: igniter on the top surface (or bottom corner)
|
||||
const iy = opt.ign === 'top' ? yTop : yBot - 1;
|
||||
run(`for (let k=0;k<45;k++){ ${[x0 + 2, x0 + 8, x1 - 2].map(x => `toolIgnite(${x}, ${iy});`).join('')} simTick(); }`);
|
||||
const stat = () => run(`(() => {
|
||||
let mcu = 0, cu = 0, scr = 0, ore = 0, coal = 0, inCav = 0, peak = 0, slag = 0;
|
||||
const mo = molds[0], cav = new Set(molds.flatMap(m => m.cavs.flat()));
|
||||
for (let i = 0; i < N; i++) { const m = mat[i];
|
||||
if (m === MCU) mcu++; else if (m === COPPER) cu++; else if (m === SCRCU) scr++; else if (m === CUORE) ore++; else if (m === COAL) coal++; else if (m === SLAG) slag++;
|
||||
if ((m === MCU || m === COPPER) && cav.has(i)) inCav++; }
|
||||
for (let y = ${top * 4}; y < ${g * 4}; y++) for (let x = ${x0}; x <= ${x1}; x++) peak = Math.max(peak, temp[y*W+x]);
|
||||
return { mcu, cu, scr, ore, coal, slag, inCav, cav: cav.size, peak: Math.round(peak), state: molds.map(m => m.state).join('+'), ready: molds.reduce((a, m) => a + m.ready.length, 0), fill: +mo.fill.toFixed(2) };
|
||||
})()`);
|
||||
console.log(JSON.stringify(opt), `ore ${ore0} coal ${coal0}`);
|
||||
let el = 0;
|
||||
for (const dt of [900, 900, 1800, 1800, 1800, 1800, 1800, 1800].filter((_, k, a) => a.slice(0, k + 1).reduce((p, q) => p + q, 0) <= opt.T)) {
|
||||
ticks(run, dt); el += dt;
|
||||
const s = stat();
|
||||
console.log(`${String(el / 60).padStart(4)}s peak ${String(s.peak).padStart(4)}°C ore ${String(s.ore).padStart(3)} coal ${String(s.coal).padStart(3)} molten ${String(s.mcu).padStart(3)} set ${String(s.cu).padStart(3)} in mold ${s.inCav}/${s.cav} mold ${s.state}${s.ready ? ' ' + s.ready + ' bars' : ''}`);
|
||||
}
|
||||
if (opt.dump) {
|
||||
const ch = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('BRICK')]: 'B', [run('CLAY')]: 'C' };
|
||||
for (let y = top * 4 - 2; y < 104; y++) { let l = ''; for (let x = x0 - 6; x <= x1 + 6; x++) { const i = run(`${y}*W+${x}`); const m = run(`mat[${i}]`); l += run(`part[${i}]`) === 5 && m === 0 ? '+' : (ch[m] || '?'); } console.log(l); }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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]
|
||||
const { load, flatWorld, ticks } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const id = process.argv[2] || 'smelter1', ore = +(process.argv[3] || 40), coal = +(process.argv[4] || 40);
|
||||
flatWorld(run, 240, 160, 100);
|
||||
for (let k = 0; k < +(process.argv[5] || 0); k++) run('rnd()'); // shake the dice: how robust is the design?
|
||||
run(`bag[RSTONE] = 900; bag[LCLAY] = 200; bag[COAL] = ${coal}; bag[CUORE] = ${ore}; bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k];`);
|
||||
const d = run(`JSON.stringify(STARTER_DESIGNS.find(d => d.id === '${id}').bp())`);
|
||||
const bp = JSON.parse(d);
|
||||
// sit it on the ground: bottom row at block 24 (ground starts at block 25)
|
||||
const ox = 24, oy = 25 - bp.bh;
|
||||
run(`bot.x = ${ox * 4 + bp.bw * 2 - 2}; bot.y = ${oy * 4 - 14}; pasteGhost(${d}, ${ox}, ${oy});`);
|
||||
for (let k = 0; k < 40 && run('ghostBlocks.size + ghostMolds.length') > 0; k++) ticks(run, 30);
|
||||
console.log(id, `${bp.bw}x${bp.bh} blocks;`, 'ghosts left', run('ghostBlocks.size + ghostMolds.length'), 'molds', run('molds.length'), 'stone used', 900 - run('bag[RSTONE]'), 'clay used', 200 - run('bag[LCLAY]'));
|
||||
// fly inside (the bot passes through walls) and blow the mix across the chamber floor
|
||||
const cx0 = (ox + 1) * 4, cx1 = (ox + bp.bw - 1) * 4 - 1, floorY = (oy + 4) * 4 - 1;
|
||||
run(`bot.x = ${(cx0 + cx1) / 2 - 2}; bot.y = ${oy * 4};`);
|
||||
run(`GCOL = [CUORE, COAL];`);
|
||||
for (let k = 0; k < 400 && run('bag[CUORE] + bag[COAL]') > 0; k++) {
|
||||
const tx = cx0 + 1 + (k * 5) % (cx1 - cx0 - 2);
|
||||
run(`toolBlow(${tx}, ${floorY - 1}, 0, CUORE, GCOL); simTick();`);
|
||||
}
|
||||
ticks(run, 60);
|
||||
const inside = run(`(()=>{let o=0,c=0;for(let y=${oy * 4};y<=${floorY};y++)for(let x=${cx0};x<=${cx1};x++){const m=mat[y*W+x];if(m===CUORE)o++;else if(m===COAL)c++;}return [o,c]})()`);
|
||||
console.log('charge inside', inside, 'left in bag', run('[bag[CUORE], bag[COAL]]'));
|
||||
// light the top of the pile from inside, then leave
|
||||
// like a player: hold the igniter on a few coal grains that touch air
|
||||
const spots = run(`(()=>{const out=[];for(let y=${oy * 4 + 4};y<=${floorY};y++)for(let x=${cx0};x<=${cx1};x++){const i=y*W+x;if(mat[i]===COAL&&mat[i-W]===EMPTY)out.push([x,y]);}return out})()`);
|
||||
const pick = [spots[0], spots[Math.floor(spots.length / 2)], spots[spots.length - 1]].filter(Boolean);
|
||||
run(`for (let k=0;k<50;k++){ ${pick.map(([x, y]) => `toolIgnite(${x}, ${y});`).join('')} simTick(); }`);
|
||||
run(`bot.x = ${ox * 4 - 30}; bot.y = ${oy * 4 - 20};`);
|
||||
let t = 0;
|
||||
for (const dt of [900, 900, 1800, 1800]) {
|
||||
ticks(run, dt); t += dt;
|
||||
console.log(`${t / 60}s`, run(`JSON.stringify(molds.map(m => m.state + (m.ready.length ? ':' + m.ready.length : '') + (m.flaw ? ':' + m.flaw : '')))`),
|
||||
'ore left', run(`(()=>{let n=0;for(let i=0;i<N;i++)if(mat[i]===CUORE)n++;return n})()`), 'copper set outside molds', run(`(()=>{let n=0;const cav=new Set(molds.flatMap(m=>m.cavs.flat()));for(let i=0;i<N;i++)if(mat[i]===COPPER&&!cav.has(i))n++;return n})()`),
|
||||
'in molds', run(`(()=>{let n=0;for(const i of molds.flatMap(m=>m.cavs.flat()))if(mat[i]===COPPER||mat[i]===MCU)n++;return n})()`), 'slag', run(`(()=>{let n=0;for(let i=0;i<N;i++)if(mat[i]===SLAG)n++;return n})()`), 'molten', run(`(()=>{let n=0;for(let i=0;i<N;i++)if(mat[i]===MCU)n++;return n})()`));
|
||||
}
|
||||
function dump(title) {
|
||||
console.log('--', title);
|
||||
const ch = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('SCRCU')]: '%' };
|
||||
const rows = run(`(()=>{const out=[];for (let y = ${oy * 4 - 2}; y < ${(oy + bp.bh) * 4 + 2}; y++) { const r = []; for (let x = ${ox * 4 - 2}; x < ${(ox + bp.bw) * 4 + 2}; x++) { const i = y * W + x; r.push([mat[i], part[i], Math.round(temp[i])]); } out.push(r); } return out; })()`);
|
||||
for (const r of rows) console.log(r.map(([m, p]) => p === 5 && m === 0 ? '+' : p === 5 ? (ch[m] || '?').toUpperCase() : (ch[m] || '?')).join(''));
|
||||
}
|
||||
if (process.env.DUMP) dump('before knocking out');
|
||||
const msgs = run(`JSON.stringify(molds.map(m => collectMold(m)))`);
|
||||
console.log('knock out:', msgs, '| bars on the ground', run('ents.length'), '| bit tier', run('player.bitTier'));
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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.
|
||||
const { load, ticks } = require('../../test/harness');
|
||||
const { run } = load();
|
||||
const t0 = Date.now();
|
||||
run(`generateWorld(12345, 1024, 640)`);
|
||||
console.log('worldgen ms', Date.now() - t0);
|
||||
const names = run(`MD.map(d=>d.name)`);
|
||||
const counts = run(`(()=>{const c=new Array(NMAT).fill(0); for(let i=0;i<N;i++) c[mat[i]]++; return c;})()`);
|
||||
counts.forEach((c, i) => { if (c) console.log(String(i).padStart(2), names[i].padEnd(16), c); });
|
||||
console.log('bot', run(`JSON.stringify(bot)`), 'surf at spawn', run(`surfY[W>>1]`));
|
||||
// settle cost: run 600 ticks idle
|
||||
let t1 = Date.now();
|
||||
ticks(run, 600);
|
||||
console.log('600 idle ticks ms', Date.now() - t1, 'awake chunks', run(`awake.reduce((a,b)=>a+(b>0),0)`), 'hot', run(`hot.reduce((a,b)=>a+(b>0),0)`));
|
||||
// dig a big hole near spawn and measure active cost
|
||||
run(`player.bitTier=3; for (let k=0;k<60;k++) { toolDig(W>>1, surfY[W>>1]+20+k, 6); }`);
|
||||
t1 = Date.now();
|
||||
ticks(run, 300);
|
||||
console.log('300 ticks after digging ms', Date.now() - t1);
|
||||
// worst case: wake everything
|
||||
run(`awake.fill(3); hot.fill(6); hotLvl.fill(2)`);
|
||||
t1 = Date.now();
|
||||
ticks(run, 60);
|
||||
console.log('60 ticks all-awake ms', Date.now() - t1);
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
|
||||
|
||||
python3 tools/map.py rewrite the index (everything after the INDEX marker)
|
||||
python3 tools/map.py --check exit 1 if the index is out of date (nothing is written)
|
||||
|
||||
The index lists every source file in build order with its line count, its header comment, and
|
||||
the top-level functions and variables it declares, so a name can be found without searching.
|
||||
Run it after adding, moving or renaming files or top-level functions. The hand-written part of
|
||||
the map above the marker is kept as it is.
|
||||
"""
|
||||
import json, pathlib, re, sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
MAP = ROOT / 'docs' / 'PROJECT_MAP.md'
|
||||
MARK = '<!-- INDEX: generated by tools/map.py, do not edit below this line -->'
|
||||
|
||||
|
||||
def header(text):
|
||||
"""The file's leading comment (// lines or one /* block */), as one line."""
|
||||
out, block = [], False
|
||||
for line in text.split('\n'):
|
||||
s = line.strip()
|
||||
if block:
|
||||
done = s.endswith('*/')
|
||||
s = s.rstrip('/').rstrip('*').strip(' =')
|
||||
if s: out.append(s)
|
||||
if done: break
|
||||
continue
|
||||
if s in ("'use strict';", '') and not out: continue
|
||||
if s.startswith('/*'):
|
||||
block = not s.endswith('*/')
|
||||
s = s.lstrip('/').lstrip('*').rstrip('/').rstrip('*').strip(' =')
|
||||
if s: out.append(s)
|
||||
if not block: break
|
||||
elif s.startswith('//') and not s.startswith('// ----'):
|
||||
out.append(s[2:].strip())
|
||||
else:
|
||||
break
|
||||
return re.sub(r'\s+', ' ', ' '.join(out)).strip()
|
||||
|
||||
|
||||
def symbols(text):
|
||||
funcs = re.findall(r'^(?:async\s+)?function\s+([A-Za-z0-9_$]+)', text, re.M)
|
||||
names = []
|
||||
for m in re.finditer(r'^(?:const|let|var)\s+(.+)$', text, re.M):
|
||||
decl = m.group(1)
|
||||
# names at the top level of the declaration list (skip what is inside brackets)
|
||||
depth, cur, parts = 0, '', []
|
||||
for ch in decl:
|
||||
if ch in '([{': depth += 1
|
||||
elif ch in ')]}': depth -= 1
|
||||
if ch == ',' and depth == 0: parts.append(cur); cur = ''
|
||||
else: cur += ch
|
||||
if ch == ';' and depth == 0: break
|
||||
parts.append(cur)
|
||||
for p in parts:
|
||||
n = re.match(r'\s*([A-Za-z_$][A-Za-z0-9_$]*)', p)
|
||||
if n and n.group(1) not in names: names.append(n.group(1))
|
||||
return funcs, names
|
||||
|
||||
|
||||
def index():
|
||||
man = json.loads((ROOT / 'src' / 'manifest.json').read_text())
|
||||
lines = [MARK, '', '## Index of source files (build order)', '']
|
||||
for f in man['sim'] + man['client']:
|
||||
text = (ROOT / 'src' / f).read_text()
|
||||
funcs, names = symbols(text)
|
||||
lines.append(f'### src/{f} ({text.count(chr(10))} lines)')
|
||||
h = header(text)
|
||||
if h: lines.append(h)
|
||||
if funcs: lines.append('- functions: ' + ', '.join(funcs))
|
||||
if names: lines.append('- variables: ' + ', '.join(names))
|
||||
lines.append('')
|
||||
lines += ['## Tests and tools', '']
|
||||
for d in ['test', 'tools', 'tools/experiments', 'tools/scenes']:
|
||||
for p in sorted((ROOT / d).glob('*')):
|
||||
if p.suffix not in ('.js', '.py') : continue
|
||||
text = p.read_text()
|
||||
m = re.search(r'"""(.+?)(?:\n\n|""")', text, re.S) if p.suffix == '.py' else None
|
||||
h = re.sub(r'\s+', ' ', m.group(1)).strip() if m else header(text)
|
||||
lines.append(f'- `{d}/{p.name}`: {h}')
|
||||
return '\n'.join(lines).rstrip() + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
cur = MAP.read_text() if MAP.exists() else '# Project map\n\n'
|
||||
head = cur.split(MARK)[0].rstrip() + '\n\n'
|
||||
new = head + index()
|
||||
if '--check' in sys.argv:
|
||||
ok = new == cur
|
||||
print('index is up to date' if ok else 'index is out of date: run python3 tools/map.py')
|
||||
sys.exit(0 if ok else 1)
|
||||
MAP.parent.mkdir(exist_ok=True)
|
||||
MAP.write_text(new)
|
||||
print(f'wrote {MAP.relative_to(ROOT)}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""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.
|
||||
|
||||
python3 tools/perf.py
|
||||
"""
|
||||
import sys, pathlib
|
||||
from playwright.sync_api import sync_playwright
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
url = (root / 'dist' / 'index.html').as_uri()
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(args=['--enable-gpu-rasterization','--ignore-gpu-blocklist'])
|
||||
pg = b.new_page(viewport={'width': 1440, 'height': 860})
|
||||
pg.goto(url); pg.wait_for_timeout(300)
|
||||
pg.evaluate("startNewWorld(424242, 1024, 640)")
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000)
|
||||
pg.wait_for_timeout(500)
|
||||
res = pg.evaluate('''() => {
|
||||
const t = (f, n) => { const a = performance.now(); for (let k=0;k<n;k++) f(); return ((performance.now()-a)/n).toFixed(2); };
|
||||
const out = {};
|
||||
out.light = t(() => computeLight(), 20);
|
||||
out.drawWorld = t(() => drawWorld(), 20);
|
||||
SET.lighting = 'off'; out.drawWorldNoLight = t(() => drawWorld(), 20); SET.lighting='full';
|
||||
SET.glow = 'off'; out.drawWorldNoGlow = t(() => drawWorld(), 20); SET.glow='low';
|
||||
out.put = t(() => { R.bctx.putImageData(R.img,0,0); }, 20);
|
||||
out.blit = t(() => { R.ctx.drawImage(R.buf, 0, 0, R.bw, R.bh, 0, 0, R.bw*R.zp, R.bh*R.zp); }, 20);
|
||||
out.sim = t(() => simTick(), 60);
|
||||
out.view = [R.bw, R.bh, R.zp, R.lw, R.lh];
|
||||
return out;
|
||||
}''')
|
||||
print(res)
|
||||
b.close()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Headless browser run: load the page, start a world, play a little, take screenshots."""
|
||||
import sys, time, pathlib
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'playthrough')
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
url = (root / 'dist' / 'index.html').as_uri()
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(args=['--autoplay-policy=no-user-gesture-required'])
|
||||
pg = b.new_page(viewport={'width': 1440, 'height': 860})
|
||||
logs = []
|
||||
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}'))
|
||||
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
|
||||
pg.goto(url)
|
||||
pg.wait_for_timeout(800)
|
||||
pg.screenshot(path=str(out / '01-title.png'))
|
||||
pg.click('#btn-new')
|
||||
pg.fill('#nw-seed', '424242')
|
||||
pg.click('#btn-generate')
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000)
|
||||
pg.wait_for_timeout(1500)
|
||||
pg.screenshot(path=str(out / '02-spawn.png'))
|
||||
# dig straight down for a while
|
||||
pg.mouse.move(720, 560)
|
||||
pg.mouse.down()
|
||||
pg.keyboard.down('s')
|
||||
pg.wait_for_timeout(2500)
|
||||
pg.keyboard.up('s')
|
||||
pg.mouse.up()
|
||||
pg.wait_for_timeout(400)
|
||||
pg.screenshot(path=str(out / '03-digging.png'))
|
||||
print('bag', pg.evaluate('bagTotal'), pg.evaluate('JSON.stringify(BAG_ORDER.filter(m=>bag[m]>0).map(m=>[MD[m].name,bag[m]]))'))
|
||||
# build a coal fire scenario directly through the core API near the bot, then ignite with the tool
|
||||
pg.evaluate('''() => {
|
||||
const bx = Math.floor(bot.x)+12, by = Math.floor(bot.y)+3;
|
||||
bag[COAL] += 300; bag[CUORE] += 120; bag[STONE] += 400; bagTotal += 820;
|
||||
for (let y=by-14; y<=by+6; y++) for (let x=bx-2; x<=bx+26; x++) setCell(y*W+x, EMPTY, AMB[y], 0);
|
||||
for (let y=by-12; y<=by+6; y++) { setCell(y*W+bx-2, STONE, AMB[y], 0); setCell(y*W+bx-1, STONE, AMB[y], 0); setCell(y*W+bx+25, STONE, AMB[y], 0); setCell(y*W+bx+26, STONE, AMB[y], 0); }
|
||||
for (let x=bx-2; x<=bx+26; x++) { if (x < bx+11 || x > bx+13) { setCell((by-12)*W+x, STONE, AMB[by-12], 0); setCell((by-13)*W+x, STONE, AMB[by-13], 0);} setCell((by+6)*W+x, STONE, AMB[by+6], 0); }
|
||||
const r = mulberry32(4);
|
||||
for (let y=by-4; y<=by+5; y++) for (let x=bx; x<=bx+24; x++) setCell(y*W+x, r()<0.3 ? CUORE : COAL, AMB[y], 0);
|
||||
for (let k=0;k<50;k++){ toolIgnite(bx+3, by-5); toolIgnite(bx+20, by-5); simTick(); }
|
||||
window.__fx = {bx, by};
|
||||
}''')
|
||||
pg.wait_for_timeout(9000)
|
||||
pg.screenshot(path=str(out / '04-furnace.png'))
|
||||
pg.keyboard.press('h')
|
||||
pg.wait_for_timeout(300)
|
||||
pg.screenshot(path=str(out / '05-lens.png'))
|
||||
pg.keyboard.press('h')
|
||||
pg.keyboard.press('3')
|
||||
pg.wait_for_timeout(200)
|
||||
pg.screenshot(path=str(out / '06-parts.png'))
|
||||
pg.keyboard.press('n')
|
||||
pg.wait_for_timeout(300)
|
||||
pg.screenshot(path=str(out / '07-notebook.png'))
|
||||
pg.keyboard.press('Escape')
|
||||
pg.keyboard.press('Escape')
|
||||
pg.wait_for_timeout(200)
|
||||
pg.click('#pm-settings')
|
||||
pg.wait_for_timeout(200)
|
||||
pg.screenshot(path=str(out / '08-settings.png'))
|
||||
perf = pg.evaluate('JSON.stringify({frameMs, simMs, fps: fpsSm, view: [R.bw, R.bh]})')
|
||||
print('perf', perf)
|
||||
print('molten copper', pg.evaluate('(()=>{let n=0,c=0;for(let i=0;i<N;i++){if(mat[i]===MCU)n++; if(mat[i]===COPPER)c++;} return [n,c]})()'))
|
||||
print('\n'.join(l for l in logs if 'error' in l.lower() or 'PAGEERROR' in l) or 'no errors')
|
||||
b.close()
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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).
|
||||
money = 50000;
|
||||
const x0 = Math.max(4, SX - 170), x1 = SX - 10, y0 = SY - 40;
|
||||
window.FLOOR = SY + 40;
|
||||
for (let y = y0; y < FLOOR + 8; y++) for (let x = x0; x < x1; x++) {
|
||||
const i = y * W + x;
|
||||
bgm[i] = 0;
|
||||
setCell(i, y >= FLOOR ? STONE : EMPTY, AMB[y], y >= FLOOR ? F_NAT : 0);
|
||||
}
|
||||
// open sky down to the floor (the renderer draws cave wall behind empty cells below surfY)
|
||||
for (let x = x0; x < x1; x++) { surfY[x] = FLOOR; dirtY[x] = Math.max(dirtY[x], FLOOR + 8); }
|
||||
updateRoof();
|
||||
window.BX = Math.ceil(x0 / 4) + 1; window.BY = Math.floor(FLOOR / 4) - 1;
|
||||
bag[RSTONE] = 400; bag[RBRICK] = 200; bagTotal = 600;
|
||||
window.CAM = [(x0 + x1) / 2, SY + 20];
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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
|
||||
const bx = BX + 1, by = BY - 7; // the silo is 6 blocks tall; its gate pours onto a belt a block below
|
||||
placeMachineAt('silo', 0, bx, by);
|
||||
const silo = machines.find(m => m && m.type === 'silo'); silo.sm = RSTONE; silo.sn = 2300; silo.open = true;
|
||||
for (let k = bx - 2; k <= bx + 10; k++) placeBlock(BP_BELT, 1, k, BY, true);
|
||||
placeMachineAt('cutter', 0, bx + 14, BY - 3);
|
||||
for (let k = bx + 12; k <= bx + 22; k++) placeBlock(BP_BELT, 1, k, BY - 1, true);
|
||||
for (let y = (BY - 3) * 4; y < (BY - 3) * 4 + 5; y++) for (let x = (bx + 14) * 4 + 2; x < (bx + 14) * 4 + 6; x++) setCell(y * W + x, RSTONE, AMB[y], 0);
|
||||
bag[BARSB] = 40; bag[BARGL] = 20; bagTotal += 60;
|
||||
for (let k = 0; k < 4; k++) for (let j = 0; j < 3; j++) placeBlock(BP_WALL, BARSB, bx + 26 + k, BY - j, false);
|
||||
for (let k = 0; k < 2; k++) placeBlock(BP_WALL, BARGL, bx + 31 + k, BY, false);
|
||||
window.CAM = [(bx + 16) * 4, (BY - 4) * 4];
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Screenshot the game in a headless browser, after setting up a scene with JavaScript.
|
||||
|
||||
python3 tools/shot.py OUT.png [options]
|
||||
|
||||
Starts a new world in dist/index.html (run python3 build.py first), runs your setup code in the
|
||||
page (every game global is available: setCell, placeBlock, placeMachineAt, bot, SET, GAME ...),
|
||||
runs the simulation for --ticks steps (it is paused otherwise, so the same options give the same
|
||||
picture), then saves a PNG. Open the PNG to look at it.
|
||||
|
||||
The camera follows the bot. To frame something else, set CAM = [x, y] (in grains) in your setup
|
||||
code and the bot is moved there before the shot. SX, SY are the bot's starting position.
|
||||
|
||||
Options:
|
||||
--look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics)
|
||||
--seed N world seed (default 424242)
|
||||
--size WxH world size in grains (default 2048x1024)
|
||||
--setup FILE.js code to run once the world is ready (see tools/scenes/ for examples)
|
||||
--js CODE code to run after --setup (both may be given)
|
||||
--ticks N simulation ticks to run after the setup (default 0)
|
||||
--zoom Z screen pixels per grain (default 6)
|
||||
--hud keep the HUD and build menu (hidden by default)
|
||||
--previews keep the cursor previews (drill aim, build ghost); hidden by default
|
||||
--viewport WxH browser window size (default 1280x760)
|
||||
--clip X,Y,W,H save only this part of the window
|
||||
--no-light switch off lighting (shows true colours, as in daylight)
|
||||
--freeze draw the last frame at a fixed time and frame count, so animation (belts,
|
||||
saws, the bot's bob) is caught at the same moment every time: two shots
|
||||
of the same scene then match pixel for pixel, and a before / after pair
|
||||
shows only what really changed
|
||||
--page PATH another built page to load (default dist/index.html)
|
||||
|
||||
Exit status 1 if the page threw an error (they are printed).
|
||||
|
||||
Example: a belt carrying a heap of sand, in the smooth look:
|
||||
python3 tools/shot.py /tmp/belt.png --look smooth --setup tools/scenes/clear-sky.js \\
|
||||
--js "placeBlocks(BP_BELT, 1, blockLine(SX/4+2, SY/4+6, SX/4+12, SY/4+6), undefined, false)" --ticks 30
|
||||
"""
|
||||
import argparse, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
from where import translate
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
# Headless Chromium has no GPU. These flags give it WebGL2 through SwiftShader (software), and
|
||||
# window.__allowSoftwareGL tells the game to use the smooth look anyway (players on software
|
||||
# rendering get the pixel look instead).
|
||||
GL_ARGS = ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
||||
FREEZE = """(() => { const real = performance.now.bind(performance); window.__frozenNow = null;
|
||||
performance.now = () => window.__frozenNow === null ? real() : window.__frozenNow; })();"""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('out')
|
||||
ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel')
|
||||
ap.add_argument('--seed', type=int, default=424242)
|
||||
ap.add_argument('--size', default='2048x1024')
|
||||
ap.add_argument('--setup')
|
||||
ap.add_argument('--js')
|
||||
ap.add_argument('--ticks', type=int, default=0)
|
||||
ap.add_argument('--zoom', type=float, default=6)
|
||||
ap.add_argument('--hud', action='store_true')
|
||||
ap.add_argument('--previews', action='store_true')
|
||||
ap.add_argument('--viewport', default='1280x760')
|
||||
ap.add_argument('--clip')
|
||||
ap.add_argument('--freeze', action='store_true')
|
||||
ap.add_argument('--no-light', action='store_true')
|
||||
ap.add_argument('--page', default=str(ROOT / 'dist' / 'index.html'))
|
||||
a = ap.parse_args()
|
||||
ww, wh = (int(v) for v in a.size.split('x'))
|
||||
vw, vh = (int(v) for v in a.viewport.split('x'))
|
||||
errors = []
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(args=GL_ARGS)
|
||||
pg = b.new_page(viewport={'width': vw, 'height': vh})
|
||||
pg.on('pageerror', lambda e: errors.append(translate(e.stack or str(e))))
|
||||
pg.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' else None)
|
||||
pg.add_init_script('window.__allowSoftwareGL = true;' + (FREEZE if a.freeze else ''))
|
||||
pg.goto(pathlib.Path(a.page).resolve().as_uri())
|
||||
pg.wait_for_timeout(300)
|
||||
# pause the moment the world exists, so the shot depends only on the seed, the setup
|
||||
# code and --ticks (simTick is deterministic), not on how fast the browser ran
|
||||
# (wrapped in a function: Playwright calls a string that evaluates to a function)
|
||||
pg.evaluate("() => { const enter = enterGame; enterGame = function () { enter(); GAME.paused = true; }; }")
|
||||
pg.evaluate(f"SET.look = '{a.look}'; {"SET.lighting = 'off';" if a.no_light else ''} startNewWorld({a.seed}, {ww}, {wh})")
|
||||
pg.wait_for_function('GAME.running === true', timeout=60000)
|
||||
pg.evaluate("document.querySelectorAll('.card, .toast').forEach(e => e.remove())")
|
||||
# SX, SY: the bot's position when the world starts, handy for placing things near it
|
||||
pg.evaluate(f"window.SX = Math.floor(bot.x); window.SY = Math.floor(bot.y); SET.zoom = {a.zoom}; resizeRender();")
|
||||
for code in [pathlib.Path(a.setup).read_text() if a.setup else None, a.js]:
|
||||
if code:
|
||||
pg.evaluate(f'() => {{ {code}\n}}')
|
||||
if a.ticks:
|
||||
pg.evaluate(f'for (let k = 0; k < {a.ticks}; k++) simTick();')
|
||||
pg.evaluate(f"""GAME.paused = true; $('hud').hidden = {'false' if a.hud else 'true'}; GAME.hidePreviews = {'false' if a.previews else 'true'};
|
||||
if (window.CAM) {{ bot.x = CAM[0] - bot.w / 2; bot.y = CAM[1] - bot.h / 2; }}
|
||||
snapCamera(); R.snapNext = true;""")
|
||||
pg.wait_for_timeout(600)
|
||||
if a.freeze:
|
||||
# stop the frame loop, then draw one last frame at a fixed time and frame count
|
||||
pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }")
|
||||
pg.wait_for_timeout(100)
|
||||
pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }")
|
||||
kw = {}
|
||||
if a.clip:
|
||||
x, y, w, h = (float(v) for v in a.clip.split(','))
|
||||
kw['clip'] = {'x': x, 'y': y, 'width': w, 'height': h}
|
||||
pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
pg.screenshot(path=a.out, **kw)
|
||||
note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'")
|
||||
b.close()
|
||||
print(f'saved {a.out} ({note})')
|
||||
for e in errors:
|
||||
print('ERROR', e)
|
||||
sys.exit(1 if errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn a line number in dist/index.html into the source file and line it came from.
|
||||
|
||||
python3 tools/where.py 4211 -> src/sim/pipes.js:57
|
||||
python3 tools/where.py < error.txt -> the text with every index.html:LINE rewritten
|
||||
|
||||
Browser errors point into the built page. build.py writes dist/bundle-lines.json, which says where
|
||||
each source file starts in it. Rebuild first if the sources changed since the error.
|
||||
"""
|
||||
import json, pathlib, re, sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load():
|
||||
return json.loads((ROOT / 'dist' / 'bundle-lines.json').read_text())['files']
|
||||
|
||||
|
||||
def where(line, files=None):
|
||||
files = files or load()
|
||||
best = None
|
||||
for start, f in files:
|
||||
if start <= line: best = (start, f)
|
||||
else: break
|
||||
return f'src/{best[1]}:{line - best[0] + 1}' if best else f'index.html:{line} (before the script)'
|
||||
|
||||
|
||||
def translate(text, files=None):
|
||||
files = files or load()
|
||||
return re.sub(r'(?:file://)?[^\s()]*index\.html:(\d+)(:\d+)?', lambda m: where(int(m.group(1)), files) + (m.group(2) or ''), text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1:
|
||||
for a in sys.argv[1:]: print(where(int(a)))
|
||||
else:
|
||||
print(translate(sys.stdin.read()), end='')
|
||||
Reference in New Issue
Block a user