Compare commits
129
Commits
3f971a94c2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc3b07be2 | ||
|
|
7468d00efb | ||
|
|
20963c776e | ||
|
|
452b6aed32 | ||
|
|
65054e691b | ||
|
|
31e3005816 | ||
|
|
fc05146887 | ||
|
|
b2c9ad9533 | ||
|
|
9781aa3ee1 | ||
|
|
0c0edf46ca | ||
|
|
0ad3561e6a | ||
|
|
f193b1c76c | ||
|
|
f4ca1cae57 | ||
|
|
29eee51e35 | ||
|
|
a671dcb59a | ||
|
|
569ec6e5b6 | ||
|
|
ef4ba6d24a | ||
|
|
6bc4fd5928 | ||
|
|
9fc1b92080 | ||
|
|
54e7756ead | ||
|
|
ccca66c0c1 | ||
|
|
99d4889f1e | ||
|
|
72efe85a1b | ||
|
|
c3f9387b24 | ||
|
|
a0f254fdeb | ||
|
|
dd8068dfe6 | ||
|
|
02e338a46f | ||
|
|
45538cc18e | ||
|
|
fd35bf334f | ||
|
|
198bea54da | ||
|
|
30c811ea80 | ||
|
|
65338b2b27 | ||
|
|
787721ad35 | ||
|
|
ff9fd7b1b8 | ||
|
|
828c71487c | ||
|
|
9ff9013c68 | ||
|
|
552b9b6480 | ||
|
|
155708b41d | ||
|
|
18528a7f73 | ||
|
|
664e086969 | ||
|
|
8ce71f478a | ||
|
|
3cb0182ac7 | ||
|
|
be93ad1251 | ||
|
|
2e5ad0f1a1 | ||
|
|
8cdc47fd33 | ||
|
|
3ee85429fd | ||
|
|
578b55316b | ||
|
|
1f8a89db6a | ||
|
|
5e7069d637 | ||
|
|
b74a9173eb | ||
|
|
9bc5a6c3af | ||
|
|
0cf615eb3d | ||
|
|
e19cb784d2 | ||
|
|
24eb9559f8 | ||
|
|
814b51df46 | ||
|
|
f19facbe5c | ||
|
|
7d1be16094 | ||
|
|
f744d786f1 | ||
|
|
8087c28cb5 | ||
|
|
381019b7aa | ||
|
|
1a300bf744 | ||
|
|
20d0627d75 | ||
|
|
5ce44f842e | ||
|
|
64ff8310f3 | ||
|
|
0a8847594e | ||
|
|
7fa1056e09 | ||
|
|
9937683a2c | ||
|
|
cce81e4eca | ||
|
|
9be04471ee | ||
|
|
ea1dd387b1 | ||
|
|
99d04b4972 | ||
|
|
f34c34aa73 | ||
|
|
d2eb388729 | ||
|
|
c8d397e19a | ||
|
|
945e5205b8 | ||
|
|
66543bbd4b | ||
|
|
4d5fca6dae | ||
|
|
c302b0cf42 | ||
|
|
28893c93bc | ||
|
|
282e9ce9ab | ||
|
|
06f8f90f19 | ||
|
|
9682cab6d4 | ||
|
|
c7256557bb | ||
|
|
b76f4516b7 | ||
|
|
94891db349 | ||
|
|
f4eddfb82b | ||
|
|
8d9e9c546b | ||
|
|
01403bb95e | ||
|
|
c95cc48340 | ||
|
|
67b6aba6eb | ||
|
|
196ce5ce62 | ||
|
|
d643718c48 | ||
|
|
2cf9cb843c | ||
|
|
c1a085ebb1 | ||
|
|
14194cb4be | ||
|
|
a18dcda8a4 | ||
|
|
1f56d27ded | ||
|
|
ec687b8c6c | ||
|
|
209d065f4e | ||
|
|
aa46406c92 | ||
|
|
255824ec6d | ||
|
|
cc6f98a932 | ||
|
|
152a2af6df | ||
|
|
a730a7d73c | ||
|
|
f3cedcacc8 | ||
|
|
36f82712ab | ||
|
|
41663761fb | ||
|
|
f822db8c50 | ||
|
|
11f6357c09 | ||
|
|
dc873ecb8b | ||
|
|
9df161046d | ||
|
|
9340bc132b | ||
|
|
ddfe78cf01 | ||
|
|
c50444b333 | ||
|
|
daf91891fc | ||
|
|
92f33b11c9 | ||
|
|
b70bb1f640 | ||
|
|
2b68b5d583 | ||
|
|
10911d4c86 | ||
|
|
a15af6ef9f | ||
|
|
9bbddc1e20 | ||
|
|
f953a9cc51 | ||
|
|
97c5ca4668 | ||
|
|
fec4f3bfe1 | ||
|
|
caa2463fda | ||
|
|
de677b49c5 | ||
|
|
6676d99c66 | ||
|
|
ce7df82b01 | ||
|
|
b270c4e818 |
@@ -4,3 +4,12 @@ __pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.venv/
|
||||
|
||||
# kept on this machine only: the kandown task board, the old notes inbox and agent instructions
|
||||
.kandown/
|
||||
tasks/
|
||||
docs/kandown.md
|
||||
NOTES.md
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
# Deepgrain Foundry: instructions for coding agents
|
||||
|
||||
Deepgrain Foundry is a 2D falling-sand factory game in the style of Sandustry, set in a
|
||||
Terraria-like side-view world. Every grain of sand, ore, water and fire is simulated. The player
|
||||
flies a small bot, digs with a resonance drill, vacuums up loose material, builds furnaces,
|
||||
belts, tubes and machines from blocks, casts metal in molds and sells goods at a trade dock to
|
||||
pay off a land claim. It runs in the browser as one self-contained HTML page, built from small
|
||||
JavaScript files with no dependencies.
|
||||
|
||||
The owner (the user) directs the work and is the real playtester. You build, test and report.
|
||||
|
||||
## Start of every session
|
||||
|
||||
1. Read `NOTES.md`. It is the user's to-do inbox (see "Working from NOTES.md" below).
|
||||
2. Read `docs/PROJECT_MAP.md` before searching the code. It says where everything lives. Its
|
||||
index lists every top-level function and variable by file.
|
||||
3. Before changing how the game plays, read the relevant part of `docs/DESIGN.md`. Many choices
|
||||
were made deliberately and are recorded there with the reason.
|
||||
4. Look at the top of `docs/agent-log.md` for what was done recently.
|
||||
|
||||
## Honesty and accuracy
|
||||
|
||||
These rules matter more than speed.
|
||||
|
||||
- **Do not guess or assume.** If you have not checked something, say so. Keep "I tested this and
|
||||
it works" separate from "this should work".
|
||||
- **Prove it with the game.** Run the tests. For anything visible, take a screenshot and look at
|
||||
it before saying it looks right (see "Testing"). A passing test only covers what it checks.
|
||||
- **Never invent names.** Look a function or variable up in the project map's index, or search
|
||||
for it (`rg name src`), before you use it or talk about it.
|
||||
- **When you are not confident, check more than one source.** Use the web for anything outside
|
||||
this repository: browser APIs, Playwright, WebGL, real-world materials and temperatures,
|
||||
physics, how other games do something. Confirm important facts with at least two independent
|
||||
sources, and give the links.
|
||||
- **Report problems plainly.** Say when a test fails, when something is only partly done, or when
|
||||
you could not reproduce a problem. Never weaken or delete a test just to make it pass. If a
|
||||
test's expectation is outdated because the design changed on purpose, update it and say why.
|
||||
- **Ask when it matters.** If a request is unclear and getting it wrong would waste a lot of work,
|
||||
ask first. If the choice is small or easy to undo, take the sensible reading, say which one you
|
||||
took, and carry on.
|
||||
|
||||
## Working from NOTES.md
|
||||
|
||||
`NOTES.md` belongs to the user. They add notes at any time and ask you to work on them ("do the
|
||||
notes", "do the belt note"). Work on notes only when asked.
|
||||
|
||||
- **Kinds of note.**
|
||||
- Tasks ("add...", "make...", "fix...") are built.
|
||||
- Discussion notes ("let's think about...", "brainstorm...", "what can we do about...",
|
||||
"let's talk about...") get options with trade-offs and a recommendation. Do not build them
|
||||
until the user decides.
|
||||
- Questions get answers.
|
||||
- **Keep the user's wording.** Never rewrite or tidy a note. If you need to add context, add a
|
||||
sub-bullet that starts with `(agent)`.
|
||||
- **When a task is finished**, move the note, word for word, into the "Done, please check"
|
||||
section at the bottom. Under it, add an `(agent)` sub-bullet saying what changed and how to
|
||||
check it in the game. Do not delete it: the user deletes it after checking. If the user says
|
||||
it is not fixed, move it back up and add their feedback as a sub-bullet.
|
||||
- **Log your work.** Add an entry at the top of `docs/agent-log.md` with the date, the note,
|
||||
what changed, the files touched, the tests run, and anything left open.
|
||||
- **Record decisions.** When the user decides something about the design, add it to the
|
||||
decisions list in `docs/DESIGN.md` with the date and the reason they gave.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
python3 build.py # build dist/index.html from src/ (takes a second)
|
||||
python3 -m http.server -d dist # play at http://localhost:8000 (or open dist/index.html)
|
||||
python3 test/run_all.py # all headless test suites, in parallel (about a minute)
|
||||
python3 test/run_all.py pipes # only suites whose name contains "pipes"
|
||||
python3 test/run_all.py --browser # also the browser tests (rebuilds first; about 3 more minutes)
|
||||
node test/t_pipes.js # one suite
|
||||
python3 tools/shot.py shots/x.png --setup tools/scenes/clear-sky.js --js "..." --ticks 60
|
||||
python3 tools/where.py 7614 # which source line is line 7614 of dist/index.html
|
||||
python3 tools/map.py # regenerate the index in docs/PROJECT_MAP.md
|
||||
```
|
||||
|
||||
You need Node (tested with 22), Python 3, and Playwright for Python with Chromium
|
||||
(`pip install -r requirements.txt`, then `python3 -m playwright install chromium`). Playwright
|
||||
officially supports Ubuntu and Debian. On other Linux distributions, such as Arch, its bundled
|
||||
Chromium usually works; if it does not, launch the system Chromium through Playwright's
|
||||
`executable_path`.
|
||||
|
||||
## How the code is put together
|
||||
|
||||
- `build.py` joins the files listed in `src/manifest.json`, in order, into one classic script
|
||||
inside `src/shell.html`. The output is `dist/index.html`. There are no modules and no
|
||||
import/export: every file shares one global scope.
|
||||
- **Order matters only for code that runs when the page loads.** A function can call a function
|
||||
from any file. But a top-level `const`/`let` must come before top-level code that uses it. New
|
||||
files go into the manifest in the right place: simulation first, then render, then UI.
|
||||
- `'use strict'` at the top of `src/sim/base.js` applies to the whole bundle.
|
||||
- **`src/sim/` must never touch the DOM** (no `document`, `window`, canvas or `localStorage`).
|
||||
The headless tests load only these files, and keeping the simulation pure keeps a later port to
|
||||
Rust or WebAssembly possible.
|
||||
- **Keep files small and on one topic.** Aim for under about 400 lines. Split a file that grows
|
||||
past that along a clear seam, add the new file to the manifest, and rebuild. Then run
|
||||
`python3 tools/map.py` and update the hand-written part of the project map.
|
||||
- **Errors point at real files.** In Node tests, stack traces name the source file and line. In
|
||||
the browser they point into `dist/index.html`; use `tools/where.py` to find the source line
|
||||
(`tools/shot.py` does this for you).
|
||||
|
||||
## Rules for the simulation
|
||||
|
||||
- **Performance.** The simulation runs 60 ticks a second over millions of cells.
|
||||
- Use the existing typed arrays.
|
||||
- Do not allocate objects or arrays inside per-cell loops.
|
||||
- Only chunks that are awake (32×32 grains) are simulated. After changing cells directly,
|
||||
wake them; `setCell` already does.
|
||||
- Measure before and after anything in a hot path (`tools/perf.py`,
|
||||
`tools/experiments/factory_cost.js`).
|
||||
- **Determinism.** Randomness in `src/sim/` comes only from `rnd()` (seeded) or `mulberry32`.
|
||||
Never use `Math.random` there. The headless tests rely on this: the same code gives the same
|
||||
output, line for line.
|
||||
- **Save compatibility.** The user has saved worlds they care about. Material ids, block codes
|
||||
(`BP_*`), part ids (`P_*`) and machine type names are stored in saves.
|
||||
- Never renumber or reuse them. Add new ones at the end (raise `NMAT` for materials).
|
||||
- New saved fields need a default when an older save lacks them. Migrations go in
|
||||
`deserializeWorld` (`src/sim/save.js`).
|
||||
- Only raise `SAVE_VERSION` if old saves truly cannot be read any more, and tell the user first.
|
||||
- **Undo.** Every building action the player takes must be undoable. Wrap it in
|
||||
`undoRecord(label, fn)`. The functions that place and remove things record their own steps
|
||||
with `undoNote`. The bot building ghosts is never an undo step of its own.
|
||||
|
||||
## Checklists
|
||||
|
||||
**A new material.** It needs:
|
||||
- an id constant in `src/sim/materials.js` (at the end, before `NMAT`, and raise `NMAT`);
|
||||
- a `def(...)` entry with a plain description;
|
||||
- its price in `PRICE` (`src/sim/economy.js`) if it can be sold;
|
||||
- its place in `BAG_ORDER` (`src/ui/state.js`) if it can go in the bag;
|
||||
- its place in `MARKET_GROUPS` (`src/ui/notebook.js`) if it is sold;
|
||||
- any special tables it belongs to: `CASTMETAL`, `SOLIDMETAL`, `METAL_TIER`, `BAR_OF`,
|
||||
`WALL_OF`/`WALL_LIST`, `REFINE`, `HOTLIQ`, `DROSS`.
|
||||
|
||||
Check its colour in both looks.
|
||||
|
||||
**A new buildable piece.** It needs:
|
||||
- an entry in `PIECES` (`src/sim/pieces.js`) with cost and description;
|
||||
- a block code or machine footprint;
|
||||
- placing and removing that support undo and Ctrl-replace;
|
||||
- a tile in `BUILD_CATS` and an icon in `PIECE_ICONS` (`src/ui/build-menu.js`);
|
||||
- hover info (`hoverInfo` in `src/ui/info.js`);
|
||||
- drawing in `src/render/` (pixel look, and a smooth-look class if it needs one);
|
||||
- saving and loading of any state it keeps;
|
||||
- a line in the controls help (`controlsHtml` in `src/ui/notebook.js`) if it adds controls;
|
||||
- a headless test.
|
||||
|
||||
**Text the player reads** (descriptions, toasts, hover info, help):
|
||||
- plain words and short sentences;
|
||||
- what it does and how to use it;
|
||||
- °C for temperatures, "grains" for cells, "blocks" for the 4×4 build grid;
|
||||
- no programmer words.
|
||||
|
||||
Match the existing descriptions in `PIECES` and the material `def`s.
|
||||
|
||||
## Testing
|
||||
|
||||
`docs/TESTING.md` has the details: the harness, how to write a suite, the browser tests,
|
||||
screenshots and the pitfalls. The essentials:
|
||||
|
||||
- Every change to the simulation gets a headless check in a `test/t_*.js` suite. Write the
|
||||
check first when fixing a bug, and see it fail.
|
||||
- **Test in the pixel look** (the default in the tools). Use the smooth look only when checking
|
||||
graphics, and then check both looks.
|
||||
- Look at your screenshots. Open the PNG and check it shows what you meant, especially after
|
||||
changing anything drawn.
|
||||
- Before you say you are finished, run `python3 test/run_all.py`. After changing anything in
|
||||
`src/ui/` or `src/render/`, run `python3 test/run_all.py --browser` too.
|
||||
- **For a refactor** that should not change behaviour, save the suites' output before and after
|
||||
and diff them. The headless suites are deterministic, so any difference is a real change (lines
|
||||
with timings in ms excepted).
|
||||
|
||||
## Git
|
||||
|
||||
- Small commits, one change each, with a message that says what changed for the player or
|
||||
developer. Run the tests first.
|
||||
- `dist/` and `shots/` are build output and are not committed.
|
||||
- Never commit secrets: tokens, passwords, or keys.
|
||||
- Work happens one agent at a time. If running several agents side by side is set up later,
|
||||
this section will say how they coordinate.
|
||||
@@ -1,9 +0,0 @@
|
||||
@AGENTS.md
|
||||
|
||||
## Claude Code notes
|
||||
|
||||
- To look at a screenshot, open the PNG with the Read tool. Do this every time you change
|
||||
anything drawn, and before you call it done.
|
||||
- Use WebSearch and WebFetch for anything outside this repository, per the honesty rules above.
|
||||
- Keep `docs/agent-log.md`, `NOTES.md` and `docs/PROJECT_MAP.md` current, as described above.
|
||||
They are how the next session, yours or another agent's, picks up the work.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Notes
|
||||
|
||||
The owner's to-do list for the game. Add notes anywhere in the lists below. Agents work on
|
||||
them when asked; AGENTS.md says how. Finished items go to "Done, please check" at the bottom
|
||||
and stay there until the owner has checked them in the game and deleted them.
|
||||
|
||||
## To do
|
||||
|
||||
- copper bars go into the furnace. also they shouldnt require the player to press F to collect. instead it should automatically eject ingots below and the player can have a belt there.
|
||||
- stacking ingots is a little odd, they dont stack neatly. so putting them all in the same place means it will get full but have lots of air space. what can we do about this? WHat in real life might be the solution for storing ingots?
|
||||
- i have noticed that belts with a big pile on top only move the grains on the top of the stack. the belt should move the whole stack. if this is an issue for performance, maybe a nice solution is to stall the belt when there is too much on it. then the player must build in a way to control the flow to that belt.
|
||||
- (agent) Written after version 24, which already tried to fix this: carryColumn in src/sim/conveyors.js moves the whole stack, up to 96 grains deep. A one-off headless check of a tall heap moving together passed then (it was not kept as a suite test), so the real game must differ from that check somehow. Reproduce it in the game first (tools/shot.py, or play it), then add a suite test that fails, then fix.
|
||||
- for the build menu, add shift + q/e to move tabs.
|
||||
- instead of full description on to left for build menu, mouse hover reviels a new info popup. organize the popup look so its nice and easy to read the information.
|
||||
- dont need the short description in the build menu if we have a hover info popup.
|
||||
- in the build menu, having the "g pick" there is confusing. im not sure if it should be there or not. also middle mouse the default for pick is better, g shouldnt even be used at all, its too far from the normal keys people use around wasd. maybe later for something less used.
|
||||
- lets think about how best to design the factory products. basics are normal smelting ore, refining, etc. but the actual export products could be like real construction materials. materials that a real construction site might need.
|
||||
- the world still seems quite small. i dont know if thats because the bot moves so fast, the bot can mine too fast, or the grains should be smaller... i would like to make the worl seem huge to the player and give them loads of resources to mine for a long game. as is, you could mine all the resources from the map pretty quickly and end up having to start a new world and new game as a result. or maybe there is some other solution that i havent thought of yet. need to brainstorm this.
|
||||
|
||||
## Future features and ideas
|
||||
|
||||
- we can add more exotic/sci-fi ish ores and materials for more late game.
|
||||
- add more world generation options in new world creation and some presetsa Presets like different planet environments and all. Trees, water, dirt, all that could look different too.
|
||||
- lets talk about upgrades to the res drill. currently we have a bit mold. but that doesnt really make sense for the res drill. lets redesign the whole upgrades functionality anyway.
|
||||
|
||||
## Done, please check
|
||||
|
||||
(Nothing yet.)
|
||||
@@ -2,21 +2,49 @@
|
||||
|
||||
A falling-sand factory game in the browser, in the style of Sandustry.
|
||||
|
||||
WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU;
|
||||
there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our
|
||||
checks, show a notice on the start screen. Serve the page over HTTP as described below.
|
||||
|
||||
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
|
||||
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
|
||||
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
|
||||
other goods at a trade dock, automating with belts, screw tubes, filters, fans, pipes and
|
||||
other goods at a trade dock, automating with belts, screw conveyors, filters, fans, pipes and
|
||||
machines, until the land claim is paid off and beyond.
|
||||
|
||||
## Play
|
||||
|
||||
```sh
|
||||
python3 build.py
|
||||
python3 -m http.server -d dist # then open http://localhost:8000
|
||||
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
|
||||
```
|
||||
|
||||
Opening `dist/index.html` directly works too. The game is one self-contained page with no
|
||||
downloads; sound and graphics are all generated in code. Saves stay in your browser.
|
||||
The game requires WebGPU and cross-origin isolation for shared-memory CPU workers.
|
||||
Grains and heat run across up to 16 helpers (two CPU cores are reserved); Settings →
|
||||
Simulation → CPU workers caps the pool on the next world load. Small jobs run inline.
|
||||
|
||||
The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
|
||||
server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
|
||||
Use HTTPS when hosting remotely (localhost works over HTTP).
|
||||
|
||||
nginx, in the site's `server` or `location` block:
|
||||
|
||||
```nginx
|
||||
add_header Cross-Origin-Opener-Policy same-origin always;
|
||||
add_header Cross-Origin-Embedder-Policy require-corp always;
|
||||
```
|
||||
|
||||
Caddy, in the site block alongside `root * /path/to/dist` and `file_server`:
|
||||
|
||||
```caddyfile
|
||||
header {
|
||||
Cross-Origin-Opener-Policy same-origin
|
||||
Cross-Origin-Embedder-Policy require-corp
|
||||
}
|
||||
```
|
||||
|
||||
These are plain static files, so a small server is enough. The game is one self-contained page
|
||||
with no downloads; sound and graphics are generated in code. Saves stay in your browser.
|
||||
- To keep or move a world, use Pause → Export world file.
|
||||
- To load one, use Settings → Load a world file on the title screen, or Pause → Import world file
|
||||
during a game. World files exported from the claude.ai version load here too.
|
||||
@@ -31,13 +59,20 @@ The controls are in the in-game notebook (N) under Controls. The basics:
|
||||
|
||||
## Develop
|
||||
|
||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots:
|
||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots.
|
||||
The golden-image tests also use ImageMagick (`magick`):
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
python3 -m playwright install chromium
|
||||
uv venv .venv
|
||||
uv pip install --python .venv/bin/python -r requirements.txt
|
||||
.venv/bin/python -m playwright install chromium firefox
|
||||
```
|
||||
|
||||
If uv is not installed, `python3 -m venv .venv` and
|
||||
`.venv/bin/pip install -r requirements.txt` also work; then install the browsers as above.
|
||||
After that, plain `python3` works for every command below; the browser scripts switch to
|
||||
`.venv` on their own.
|
||||
|
||||
```sh
|
||||
python3 build.py # build dist/index.html
|
||||
python3 test/run_all.py # headless test suites
|
||||
@@ -54,8 +89,6 @@ The source is small JavaScript files with no dependencies or bundler:
|
||||
|
||||
## Documents
|
||||
|
||||
- `AGENTS.md` / `CLAUDE.md`: how coding agents work on this project.
|
||||
- `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.
|
||||
|
||||
@@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a
|
||||
src/sim/base.js applies to all of it. No bundler, no dependencies.
|
||||
|
||||
Writes:
|
||||
dist/index.html the full page: open it, or serve dist/ with python -m http.server
|
||||
dist/index.html the full page: serve with python3 tools/serve.py
|
||||
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
|
||||
claude.ai artifact) that wrap the page themselves
|
||||
dist/bundle-lines.json where each source file starts in dist/index.html, so a line number
|
||||
@@ -35,9 +35,13 @@ def main():
|
||||
shell = (src / man['shell']).read_text()
|
||||
css, _ = join(man['css'])
|
||||
js, starts = join(man['sim'] + man['client'])
|
||||
sim, _ = join(man['sim'])
|
||||
# Keep worker code outside the indexed script so source line mappings stay exact.
|
||||
worker = '<script>const SIM_WORKER_SOURCE=' + json.dumps(sim).replace('</', '<\\/') + ';</script>'
|
||||
head, tail = shell.split('//__JS__')
|
||||
body = head.replace('/*__CSS__*/', css)
|
||||
page = body + js + tail
|
||||
page = worker + page
|
||||
pre = ('<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
'</head><body>')
|
||||
@@ -46,7 +50,7 @@ def main():
|
||||
(dist / 'deepgrain-foundry.html').write_text(page)
|
||||
(dist / 'index.html').write_text(full)
|
||||
# the script starts on this line of index.html (the doctype prefix has no newlines)
|
||||
js_line = body.count('\n') + 1
|
||||
js_line = worker.count('\n') + body.count('\n') + 1
|
||||
(dist / 'bundle-lines.json').write_text(json.dumps(
|
||||
{'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n')
|
||||
print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
|
||||
|
||||
+399
-19
@@ -18,7 +18,7 @@ decides something new, add it under "Decisions" with the date.
|
||||
- **Copy and paste of builds.** Pasting places ghosts that the bot builds from the bag.
|
||||
- **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).
|
||||
The owner wants the whole upgrade system redesigned (task t14).
|
||||
- **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.
|
||||
@@ -43,13 +43,13 @@ decides something new, add it under "Decisions" with the date.
|
||||
## 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.
|
||||
2. Vacuum up what you want (a drag takes only the material it starts on), and blow out what's in hand where you need it.
|
||||
3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal,
|
||||
light it, and let the molten metal run into the mold.
|
||||
4. Knock out bars and sell them at the trade dock, or fill buyers' orders, which pay more.
|
||||
4. Bars drop out of the bottom of the mold; collect them and sell them at the trade dock, or fill buyers' orders, which pay more.
|
||||
5. Better bits (copper, then iron, then steel) open harder rock: granite, then basalt. Deeper
|
||||
ores give tin, zinc, silver and gold, and alloys (bronze, brass) pay more.
|
||||
6. Automate with belts, screw tubes, filters, fans, pipes and machines. Pay off the claim.
|
||||
6. Automate with belts, screw conveyors, filters, fans, pipes and machines. Pay off the claim.
|
||||
|
||||
## Systems in brief
|
||||
|
||||
@@ -74,12 +74,17 @@ Details and numbers live in the code and in `docs/systems/`.
|
||||
- **Drill:** see `docs/systems/dig-tools.md`. Pulse widths, tuning to one material, hardness by
|
||||
bit tier.
|
||||
- **Building:** a 4×4-grain block grid.
|
||||
- Blocks: walls of any of several materials, belts, screw tubes, filters, grates, gates,
|
||||
- Blocks: walls of any of several materials, belts, screw conveyors, filters, grates, gates,
|
||||
pipes, pumps, outlets.
|
||||
- Machines: fan, Refiner, Stone cutter, Silo.
|
||||
- Molds: ingot, bit, tank.
|
||||
- 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.
|
||||
- Molds: ingot, bit, tank. Ingot bars drop out of the bottom onto whatever is under the mold;
|
||||
leave one open block below and a belt under that carries them off. Ingot molds empty themselves:
|
||||
junk and mixed scrap drop below, slag is thrown away and the grate above is raked. Short stubs
|
||||
stay for the next firing. Removing any mold returns its set metal to the bag: whole bars as
|
||||
bars, the rest as scrap.
|
||||
- Build actions: drag for lines, Shift-drag for rectangles, hold G to replace, hold C / X and
|
||||
drag to copy / cut, V to paste, Ctrl+Z to undo, middle mouse to pick,
|
||||
Shift+Q/E to switch build tabs.
|
||||
- **Economy:**
|
||||
- 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.
|
||||
@@ -119,11 +124,12 @@ above, including:
|
||||
- **Copy and cut** are held keys, not a tool. Hold C or X and drag a box; it acts on release.
|
||||
Copy and cut 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.
|
||||
- **Holding Ctrl** while placing replaces what is built there (moved to G on 2026-10-23).
|
||||
- **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate.
|
||||
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 vacuum takes only marked materials** once any are marked, even if the drag starts on
|
||||
something else. The t6 controls below replace the shared ticks; t16 replaces t6's modes; t20
|
||||
replaces the marks with pick-what-you-click.
|
||||
- **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore,
|
||||
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
|
||||
@@ -131,14 +137,20 @@ above, including:
|
||||
does not replace it.
|
||||
|
||||
**2026-10-23:**
|
||||
- **Screw tubes carry no more than belts:** they move every other tick. For more throughput,
|
||||
- **Ingot molds drop bars below automatically.** The owner reported bars being thrown into the
|
||||
furnace above and wants belts underneath to carry them off. Blocked bars wait in the trough;
|
||||
short stubs stay for the next firing. The mold cleans itself and rakes the grate after dropping bars or clearing junk.
|
||||
- **Screw conveyors carry no more than belts:** they move every other tick. For more throughput,
|
||||
build more lines.
|
||||
- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. 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).
|
||||
- **A heap on a belt moves as one**, up to 96 grains deep, not just its bottom layer. If a grain is blocked,
|
||||
the load above it stands still and the belt slips underneath; the top no longer slides over
|
||||
a stuck bottom. Filters still let their chosen materials drop through.
|
||||
- **Pick** (middle mouse) selects the piece under the cursor with its direction,
|
||||
wall material and filter settings. G no longer picks, as the owner requested (it is now Replace).
|
||||
- **The build menu** is a bar of tabs and icon tiles along the bottom: Structure, Moving,
|
||||
Liquids, Machines, Molds. The owner wants more changes (see NOTES.md).
|
||||
Liquids, Machines, Molds. Hover a tile for its full description, cost and placement help;
|
||||
gamepad players see the selected tile’s popup. While building, the top-left card describes
|
||||
the build tool and shows the R/F settings, Replace, ghosts and status.
|
||||
- **No length limit when dragging.** Blocks out of the bot's reach become ghosts that it builds
|
||||
when it gets there.
|
||||
- **Pipes are thin:** sand, water and anything else loose passes them. Pumps and outlets are
|
||||
@@ -154,11 +166,379 @@ above, including:
|
||||
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.
|
||||
built from small files, with `NOTES.md` as the owner's to-do inbox (since 2026-10-24, a
|
||||
kandown board in `tasks/`).
|
||||
- **Replace is held G, not Ctrl.** The owner hit the browser's Ctrl+W (close tab) and Ctrl+D
|
||||
(bookmark) while replacing and flying with WASD, and the game ignored WASD while Ctrl was held.
|
||||
|
||||
- **The build tool's top-left card describes the build tool**, like the other tools' cards:
|
||||
how to place, the build keys, the R/F settings (material, direction, filter), Replace · G and
|
||||
ghosts waiting. The selected piece's name, cost and description are in the menu and its hover
|
||||
popup. The owner asked for a general description rather than the selected building.
|
||||
- **Ingot molds need no F.** They push junk and mixed metal out of the bottom as loose grains with the bars, throw slag away and rake the grate. Removing any mold returns its set metal: whole bars as bars, the rest as scrap. The owner asked what F was for once bars dropped on their own, and chose this over keeping F for cleaning.
|
||||
|
||||
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted screw conveyors and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
|
||||
|
||||
- **World sounds are local.** Sounds from the world (water sizzling on hot metal or lava, and
|
||||
any other event with a place) are heard around the bot, whatever the zoom: full volume within
|
||||
24 grains, fading to silent at 96, panned left or right. The owner heard a constant snapping
|
||||
that followed the bot in an older world but not in a new one. It was the sizzle: in the
|
||||
owner's world, water that had found its way down to hot basalt and granite (480-800°C, about
|
||||
500 grains below the surface) boiled about 48 times a minute, every one at full volume. The
|
||||
first fix faded sounds with distance from the view; zoomed out they were still heard, so the
|
||||
owner asked for a radius around the bot instead.
|
||||
- **The silo has pixel art in the pixel look**, drawn grain by grain like the rest of the world;
|
||||
the smooth look keeps the illustrated silo. The owner noticed it looked the same in both.
|
||||
|
||||
- **Screw conveyors use all their lanes.** Grains in an up or down screw conveyor shift sideways toward the
|
||||
emptier lanes of the block, and grains taken in from the side go to the far lanes first. A
|
||||
block-wide lift fed by a belt from the side now carries about 90% of what the belt brings
|
||||
(before, only the edge lane filled: the owner saw one line on either side). Belts keep their
|
||||
speed.
|
||||
- **Screw conveyors carry bars**, one whole bar at a time, in line with the grains: a belt pushes a
|
||||
bar into a screw conveyor's side, it rides through corners and leaves the mouth. The owner asked how bars
|
||||
should be moved and chose screw conveyors over a separate bar lift.
|
||||
- **Several worlds are kept.** Each world has its own save and autosave; the title screen's Worlds
|
||||
list loads, renames and deletes them, and a new or imported world never overwrites another. An
|
||||
old single save becomes 'World 1'. The owner asked for multiple saves.
|
||||
|
||||
**2026-10-24:**
|
||||
- **The to-do list is a kandown board.** `NOTES.md` became one task file per note in
|
||||
`tasks/` (Todo for tasks, Backlog for discussions and ideas, Review for "done, please check").
|
||||
The owner wanted a task tool built for AI agents.
|
||||
- **Wall and mold materials have a menu (F).** It lists every wall material with what you have,
|
||||
the cost and the heat it stands; R still cycles. The owner chose F to match the filter menu.
|
||||
- **The camera's drift toward the pointer is a setting** ("Camera looks toward the pointer", on
|
||||
by default). The owner doesn't like it.
|
||||
- **Fog of war reveals by line of sight.** Unexplored ground is black with a soft edge; the sky
|
||||
and the surface start seen. The owner chose line of sight over a plain circle around the bot.
|
||||
- **Bars pile neatly, and a Bar rack stores them (t1).** Bars that come to rest snap to a tidy
|
||||
brick pattern so piles have no air gaps; a Bar rack machine (the Silo for bars) takes them from
|
||||
a belt or screw conveyor, counts them and lets them out with F. The owner wanted bar storage that doesn't
|
||||
waste space, like stacked ingots in real life.
|
||||
As built: a resting bar snaps to a 2-grain grid, and one with fewer than 2 of its 4 bottom cells
|
||||
supported tips into the gap beside it. Storage became Rack blocks rather than a machine, like
|
||||
the t4 hoppers: 50 bars of any kind per block, a column is one rack; bars landing on top go in
|
||||
(into the lowest block with room), F opens the gate at the bottom of the column, and bars come
|
||||
out oldest first, one every 20 ticks, onto whatever is below. Taking a block back returns its
|
||||
bars.
|
||||
- **Hopper blocks replace hopper machines (t4).** 1-block Grinder and Saw blocks each work what
|
||||
lands on them and drop it below, so a wider row is a wider, faster hopper. The Refiner and Stone
|
||||
cutter leave the build menu; built ones in old saves keep working. The owner wanted hoppers of
|
||||
any width.
|
||||
As built: a block is solid and works the grains resting on its top edge; results drop out of
|
||||
its bottom, and it stops when that space is full. Same speed and price per width as the old
|
||||
machines (Grinder 0.25 grains/tick, $60; Saw 0.5, $90). In a vertical stack only the top block
|
||||
takes input and the result passes down through the rest, so a Saw on a Grinder cuts stone and
|
||||
grinds everything else. Partial brick counts are not saved.
|
||||
After the owner played it: the Refiner and Stone cutter code is gone (an old save that still has
|
||||
one loads without it and refunds its price). Grinders draw as a pair of grinding wheels and
|
||||
Saws as a round blade, turning while busy. The Saw also cuts wood chunks into Planks (2 build a
|
||||
wood wall). Its stone output is now called a Stone block, so that only fired clay is a brick.
|
||||
Items piling up under a block stay a thing to build around (a belt or an open drop), and the
|
||||
Saw's text says so.
|
||||
|
||||
- **A world that lasts: deep ore, rock that pays, Huge worlds, new claims (t3, 2026-10-24).**
|
||||
The owner found worlds small and quickly mined out, so the only way on was a new game. Keep the
|
||||
bot and drill at today's pace and add more to find instead:
|
||||
- Deeper is richer: ore gets more common the deeper you dig, about 4.7x the ore in a Medium
|
||||
world (coal and copper ~5x, iron, zinc and tin ~4.5-5x, silver and gold ~3.4x, kept lower for
|
||||
their price). The top quarter of the underground is unchanged; copper also forms in deep stone
|
||||
and granite, coal in basalt, tin deeper. Lenses grow from lower noise thresholds only:
|
||||
stretching the noise with depth sheared it into diagonal streaks.
|
||||
No ore is placed where the natural rock temperature would light it (coal) or melt it (metal
|
||||
ores, within 200°C): deep coal and gold next to the magma burned and ran forever once a
|
||||
reload woke those areas, and a Huge world dropped to about 10 fps. So deep coal stays in a
|
||||
cooler mid band, and coal is about 3.2x rather than 5x; total ore about 4.2x.
|
||||
- Waste rock pays: grinding granite and basalt finds about twice the ore, and builders order
|
||||
granite and basalt rubble in bulk.
|
||||
- A Huge size (6144 x 2048); saves are gzip-compressed (a Medium save went from 29 MB to 0.4 MB).
|
||||
- New claims: once a claim is paid off, open another for $5,000 down and twice the last debt
|
||||
($40,000, $80,000, ...). Money, bag, upgrades, discoveries, milestones and stats belong to the
|
||||
career and come along; buildings stay on their claim and wait (they don't run) until you
|
||||
travel back from the notebook's Claims page.
|
||||
|
||||
- **A minimap and a full map (t32, 2026-10-24).** Bottom right: a minimap around the bot with
|
||||
zoom (1 to 16 grains per pixel) and a Map button; M opens the full map (wheel zoom, drag to
|
||||
pan, centre on bot, markers for the bot and the dock). Both draw from one low-resolution colour
|
||||
image of the world (1 pixel per 2 grains, 4 on worlds wider than 4096) that refreshes a few
|
||||
32-grain areas a frame, nearest the bot first, and respects fog of war. Onboarding cards moved
|
||||
to the bottom centre to make room; mute moved to Shift+M. Bigger worlds and several claims
|
||||
made it easy to get lost.
|
||||
|
||||
- **Long waits show what's happening (t30, 2026-10-24).** Generating, loading, opening a claim
|
||||
and travelling show the loading overlay with a title, a moving bar and the current step
|
||||
("Laying rock and ore", "Unpacking", "Building the world"...). Generation runs as steps that
|
||||
give the browser a frame every 30 ms, producing exactly the same world as before. A small
|
||||
"Saving…" chip shows bottom right only when a save takes longer than 150 ms. The owner found big
|
||||
worlds froze on a 0% bar with no sign of progress.
|
||||
|
||||
- **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every
|
||||
8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about
|
||||
900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd
|
||||
that clay bricks (first planned as a mold) and stone bricks would be made in different ways, and
|
||||
chose a press over a heated kiln block or a mold. Loose clay still fires into brick rubble.
|
||||
- **The bag is the vacuum's control panel (t6).** Each bag row has separate pick-up and blow
|
||||
marks; a chip on the tool card sets pick-up to everything, only marked, or the same as the
|
||||
first click; R over a grain toggles its pick-up mark. The owner found the shared ticks confusing.
|
||||
As built: T or the chip cycles What I click → Only marked → Everything; adding a mark switches
|
||||
to Only marked and removing the last goes back to What I click. A bag name or Q/E blows one
|
||||
material; ⬆ marks build an even mix (bars are never mixed).
|
||||
- **The vacuum menu replaces the modes (t16, 2026-10-24).** Pick-up has no mode: nothing marked
|
||||
takes everything, anything marked takes only those, Shift takes everything for one click. F with
|
||||
the Vacuum (when nothing in reach can be used) or a click on the card opens a menu with ⬇ Pick up
|
||||
and ⬆ Blow toggles per material; the bag rows lose their buttons. T no longer does anything for
|
||||
the vacuum. The owner found t6's version had too many modes.
|
||||
Later the same day the owner added: Shift + right click blows an even mix of everything loose in
|
||||
the bag; Ctrl+click a bag name adds it to the blow mix and Ctrl+right-click to pick-up; the menu
|
||||
is one line per material because the first version was too large.
|
||||
|
||||
- **Air streaks for the nozzle (t25, 2026-10-24).** On top of the grains that fly in and out, the
|
||||
owner chose air streaks: while sucking, short teal streaks swirl in toward the ring; while
|
||||
blowing, amber streaks run from the bot to the cursor and spread to the brush width. Drawn only
|
||||
(time-based, no random numbers, so the simulation is unchanged), only in reach, and off with
|
||||
"Sparks and debris" or in unexplored fog.
|
||||
|
||||
- **The Vacuum is the Pneumatic nozzle (t26, 2026-10-24).** The owner's choice of name, since the
|
||||
tool blows as much as it sucks. It shows as the tool card's title row, with the nozzle width as a
|
||||
chip, and in the hotbar tooltip and all player text ("the nozzle" after the first mention). Code
|
||||
ids and names stay `vacuum`.
|
||||
|
||||
- **Tool messages are toasts (t27, 2026-10-24).** Every tool's messages ("Nothing selected", "Out
|
||||
of reach.", "Nothing to use there.") show as toasts at the top of the screen, and the red line in
|
||||
the tool card is gone. The owner asked for it for the vacuum and chose every tool. Errors have a
|
||||
red edge, info a patina one. A message that repeats every tick while a button is held keeps one
|
||||
toast alive instead of stacking more.
|
||||
|
||||
- **Pick up what you click, blow out what's in hand (t20, 2026-10-24).** Replaces t16's pick-up
|
||||
marks and the vacuum menu; the owner wanted something simpler and more intuitive. A left drag
|
||||
takes only the material it starts on (or the first loose grain it reaches); Shift takes
|
||||
everything. The hand is the blow selection: click a bag name or Q/E for one material, Ctrl+click
|
||||
for a mix, Shift + right click for everything loose. When something in hand runs out it leaves
|
||||
the hand (this settles the old t18 question), and an empty hand blows nothing and says so.
|
||||
Picking something up fills an empty hand, never a chosen one. One tool, no menu; R and F do
|
||||
nothing special with the Vacuum. The owner mostly uses it to collect ore and fuel and feed furnaces.
|
||||
Follow-up the same day: the hand became **one selection for both** picking up and blowing out,
|
||||
because there was no way to choose several materials to pick up, or ones not in the bag yet. With
|
||||
anything selected, a drag takes only the selection wherever it starts; with nothing selected it
|
||||
takes what you click. Holding Ctrl lists every material found so far below a "Not in bag" divider
|
||||
(no menu). Selected materials stay selected when they run out, and picking up never selects
|
||||
anything (both reverse the first t20 version). Clear: a button in the bag, clicking the only
|
||||
selected row, or gamepad B.
|
||||
Then, after playing: blowing out the last of a material deselects it again (an empty selection
|
||||
goes back to picking what you click; a selection kept on an empty material got in the way). A
|
||||
material selected before you have any stays selected. Ctrl + wheel scrolls the bag instead of
|
||||
zooming the page, and a click while Ctrl is held adds to the selection even when the click
|
||||
arrives without the Ctrl flag (seen on the owner's Linux setup).
|
||||
|
||||
- **Fog of war also reveals the ground around the bot (t17, 2026-10-24).** About 20 grains in every
|
||||
direction, through rock, fully clear with a soft edge. Line of sight alone hid everything inside
|
||||
the ground, so the owner couldn't see what was around them or where to dig.
|
||||
As tuned: clear to 12 grains, fading out by 24, with a grainy edge; the owner found the first
|
||||
version's narrow fade too sharp.
|
||||
Unexplored ground is pure black, not dark grey (the owner: "should be black anyway").
|
||||
|
||||
- **Water pours faster, and boiled-off rain spreads out (t21, 2026-10-24).** The owner found water
|
||||
running off a ledge slower than expected. Only the top layer of a pool flows, so pools emptied
|
||||
at a trickle and the old code left 24-650 grains stranded on a wide ledge for good.
|
||||
- A liquid now steps up to 8 cells a tick toward a drop instead of 1, fewer the thicker it is
|
||||
(water 8, molten copper 5, slag 4, magma 2, glass 1). A grain on top may look 4 times as far
|
||||
for a drop, so thin film left behind drains too. A 100-wide pool empties in about 50 s
|
||||
(it never fully emptied before); settled lakes still sleep.
|
||||
- Raining clouds keep drifting downwind at half pace, and rain falls over a cloud's band and the
|
||||
band either side. Before, a waterfall into a shaft hot enough to boil it could rain straight
|
||||
back on its own lake.
|
||||
- World generation settles the land at the old water speed, so every seed keeps making the
|
||||
same world.
|
||||
|
||||
- **Piles near the player sort, and quickly (t21, 2026-10-24).** A grain at least twice as heavy as
|
||||
the powder under it sinks through it at 20 times the old pace (ore through ash: 4 rows to the
|
||||
bottom in about 7 s). Before, a resting pile went to sleep and never sorted. Now areas with loose
|
||||
grains within about 100 grains of the bot get a sorting chance every 8 ticks; far away, piles
|
||||
still sleep. Through burning fuel sinking stays at the old slow pace: furnace beds are tuned to
|
||||
it, and dirt heaped on a burning pile must smother it into charcoal rather than sink through.
|
||||
|
||||
- **A loaded world starts around the player first (t21, 2026-10-25).** Loading used to start every
|
||||
area of the world at once, and a Huge world then took 15 ms a tick for its first 10 seconds,
|
||||
with ticks up to 80 ms. Now the areas within 150 grains of the bot start at once, the rest 12 a
|
||||
tick, nearest first (a Huge world is fully started after 17 s), and flying toward areas still
|
||||
waiting starts them. Nothing is skipped: the same settling happens, spread out.
|
||||
|
||||
- **Sloped walls and diagonal wall lines (t36, 2026-10-25).** The owner wanted funnel walls that
|
||||
material slides down instead of catching on block steps. A Sloped wall is a half-block triangle
|
||||
with a flat 45° face, in four orientations, built of any wall material at half a wall block's
|
||||
cost. Dragging walls or slopes at about 45° lays a diagonal line; for slopes the drag direction
|
||||
picks the orientation (solid side below) and T flips it, or turns a single piece. Each slope
|
||||
also fills one cell past its corner so a diagonal run is sealed (grains move diagonally and
|
||||
would otherwise slip through where two pieces meet). Square walls placed diagonally still
|
||||
touch only at corners and leak there, like any square blocks.
|
||||
Later the same day the owner asked for the Wall piece itself to work diagonally, the way screw conveyors
|
||||
do, so the separate Sloped wall piece left the build menu: a wall drag at about 45° lays slopes
|
||||
(solid side below, T flips it during the drag), straight drags and clicks lay squares, and for a
|
||||
single block T cycles square and the four corners. Slope ids are unchanged, so saves still load.
|
||||
|
||||
- **Diagonal screw conveyors (t35, 2026-10-25).** Screw conveyors run in eight directions. A drag at about 45°
|
||||
lays a diagonal run pointing along the drag, and R cycles all eight for a single screw conveyor. Diagonal
|
||||
runs carry grains at the same rate as straight ones, join straight screw conveyors and belts, and carry
|
||||
bars level, a block every eight ticks. They use new part ids (11-14) because part ids are saved.
|
||||
|
||||
- **Sloped belts and filters (t40, 2026-10-25).** The owner asked for belts, then filters, to work
|
||||
diagonally like screw conveyors and walls. A Belt or Filter drag at about 45° lays slope-shaped blocks
|
||||
whose face is a running belt, carrying grains and bars up or down in the drag's direction at
|
||||
flat-belt speed; the face grips its load, so nothing slides back. A sloped filter drops what it
|
||||
passes out underneath and carries the rest. New block codes (19-22), so saves still load.
|
||||
- **Square corners hold grains (t39, 2026-10-26).** The owner wants square wall staircases to
|
||||
hold material instead of leaking through each touching corner. Powder, liquid and gas cannot
|
||||
step diagonally between two solid neighbours; powder neighbours still allow free piles to
|
||||
slide as before. Free flyers and diagonal screw conveyor intakes obey the same solid-corner boundary.
|
||||
Screw lane transport is unchanged. World generation disables cell corner sealing while
|
||||
settling loose ground, preserving existing seed layouts. This supersedes the square-wall
|
||||
leakage described in the t36 decision below.
|
||||
- **Silo contents are visible without opening the gate (t38, 2026-10-26).** The owner wants
|
||||
contents beside the cursor anywhere over the outline and a material-coloured fill window at
|
||||
every zoom in both looks, while keeping the real steel silo shape.
|
||||
- **Distant drill aim is normal (t41, 2026-10-26).** The owner normally points beyond the
|
||||
drill's reach and expects players to do the same. Keep the pulse colour and light crosshair,
|
||||
mark the pulse's far edge, and omit distance warnings for the drill. Other tools still warn.
|
||||
- **Screw conveyors (t44, 2026-10-26).** The owner chose the real machine name for screw
|
||||
conveyors; code uses `screw` because `conveyor` also covers belts and filters. Transport,
|
||||
numeric ids and save versions stay unchanged.
|
||||
- **No backward compatibility for now (2026-10-26).** The owner will start a new world after
|
||||
changes: no one is playing the game for real yet. Changes may break old saves and blueprints;
|
||||
don't add migration or alias code for them (first used by the t44 rename to screw conveyors).
|
||||
- **No uphill sloped filters (t43, 2026-10-26).** The owner wants sloped belts and filters to be
|
||||
realistic in how they look and behave. Real sorting screens run flat or tilted downhill and
|
||||
shake; none climbs a 45° slope. So filters are flat or downhill
|
||||
only; to sort while lifting, run a sloped belt up, then a filter. Not built yet.
|
||||
|
||||
- **Conveyor drawing and sleep follow-up (t42/t43, 2026-10-26).** The owner wants visible
|
||||
rubber ribs on the actual cleat raster, filled corrugated far walls behind the load, and
|
||||
smooth continuous mesh with rails for inclined screens. Walls stop at the slope ends;
|
||||
only loaded screens shake. Empty moving cleats must let chunks sleep. Keep pocket transport,
|
||||
uphill throughput near 71 grains/s and exact sorting unchanged.
|
||||
|
||||
- **Sidewall belts with moving cleats (t42, 2026-10-26; physics replaced by the entry below).** To replace the old full-column
|
||||
grip, use moving barrier lines rather than material cells: the barrier mask blocks normal
|
||||
powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular
|
||||
to the face, spaced 8 horizontal face cells apart and 6 grains tall (integer normal reach
|
||||
4); named constants keep these tunable. A finite periodic pocket envelope carries only
|
||||
what fits, stepping every other tick. End pockets continue while the cleat rounds the band.
|
||||
Between steps no grains are stamped: bare face slides and excess rolls back. Water drains;
|
||||
bars stay level. Dark corrugated far walls sit behind visible grains; drawing uses the
|
||||
actual barrier mask, including after save/load. The envelope is a discrete transport model,
|
||||
not a calculation of contact forces. A generous flat feed delivers about 71 grains/s uphill
|
||||
versus 90 flat; accepting that reduction remains the owner's playtest decision.
|
||||
- **Inclined vibrating screens built (t43, 2026-10-26).** Diagonal filters always travel
|
||||
downhill, even when dragged uphill. R leaves a picked slope unchanged; flipping a blueprint
|
||||
mirrors its slope and keeps downhill travel. Placement and filter settings enforce the
|
||||
same direction. Sloped filters have neither column carry nor grip: gravity moves grains,
|
||||
with one mesh-contact nudge every 4 ticks (15 downhill steps/s plus gravity). Unselected
|
||||
bars step on that same cadence; selected bars fall through. Flat filters retain their
|
||||
linear transport. Sorting both slope shapes in either mode conserves the complete mix.
|
||||
Both looks use a sturdy mesh frame with a small tick-based shake only while loaded;
|
||||
idle mesh has no running belt animation. No old uphill-save migration was added.
|
||||
|
||||
- **Screw conveyors stay, in every direction (2026-10-26).** The owner asked whether screw
|
||||
conveyors are still needed now that belts climb 45°, whether they're realistic, and what a
|
||||
downward screw is for. Kept as they are: real plants use both (screws for steep, vertical and
|
||||
enclosed runs; belts for long, cheap, high-volume ones), and a downward screw is the same
|
||||
machine turning the other way, useful for letting material down at a steady rate.
|
||||
- **Stalls are the player's to fix, but visible; belts carry a realistic depth (t45,
|
||||
2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner
|
||||
agreed that's realistic and stays the player's problem, but wants a warning on the stalled
|
||||
belt and the animation stopped on everything affected, and belts limited to a realistic
|
||||
load depth instead of carrying 96-grain stacks. Not built yet.
|
||||
|
||||
- **Cleats really push their pockets (t42 feedback, 2026-10-26).** The owner found the first
|
||||
cleats passed through heavy loads while the material climbed as before, and wants cleats that
|
||||
hold the material and drag it up. Now nothing carries the load on a slope but the ribs and a
|
||||
friction drag on the layer touching the belt (which gravity beats on 45°, so loose grains slide
|
||||
back until a rib holds them). Each belt step every rib moves one face cell and pushes the
|
||||
grains in its way ahead of it; whatever is heaped above the rib is left to roll back. A rib
|
||||
never moves into a grain, never crosses a machine, and stops short of anything built. If any
|
||||
rib of a run can't push its way, the whole run stalls that step and every move is undone (the
|
||||
stall t45 will show). At the foot of an uphill run, each rib coming up around the end roller
|
||||
scoops what lies on the feeding belt behind it into its pocket, like a real boot section.
|
||||
Measured: about 80-86 grains/s uphill under a heavy feed (flat 90); downhill about 38, since
|
||||
the top only feeds a layer two grains deep onto the face.
|
||||
- **No cleats on downhill belts (t42 feedback, 2026-10-26).** The owner asked why a downhill
|
||||
belt has cleats: on 45° the load slides anyway. A downhill sloped belt is now a plain belt, with
|
||||
no ribs and no sidewall, that carries at belt speed like a flat one (90 grains/s); only uphill
|
||||
runs are sidewall belts. A whole run also shares one cleat phase, so pieces added later line up
|
||||
with it.
|
||||
- **Build range about the size of the screen (t64, 2026-10-27).** The owner asked whether
|
||||
building needs a range limit at all, since the bot needs no arms to build. Agreed: keep a limit,
|
||||
but make it about what's on screen, so anything the player can see and aim at builds directly
|
||||
and anything beyond stays a blueprint built when the bot comes near. No limit at all would let
|
||||
players build in places they've never been and link far claims instantly. Built: the range is
|
||||
the visible view box, at least 64 grains from its centre and at most 200 when zoomed far out.
|
||||
- **Sticky materials slide freely on built surfaces (t61, 2026-10-27).** Stickiness (STICK) is the
|
||||
chance per tick a grain takes a diagonal step, which made clay lumps, snow, wood chunks, ash and
|
||||
dirt creep down sloped walls (a single clay grain never got down a 20-piece slope). The owner
|
||||
chose to make stickiness grain against grain only: on anything built, every material slides
|
||||
freely, so slopes are useful for every material, while heaps keep their shape. Real chutes clog
|
||||
with sticky material and need cleaning, but that is micromanagement the game doesn't need.
|
||||
- **Belts and other transport pieces never fill a rectangle (2026-10-27).** The owner said Shift
|
||||
doesn't need to fill a rectangle for belts, conveyors and the like. Belts, filters, screw
|
||||
conveyors, shafts, pipes, pumps and outlets (`lineOnly()`) always draw a line, Shift or not, and
|
||||
a diagonal belt drag stays sloped. Walls, grates and gates still Shift-fill.
|
||||
- **The dev console ships in every build (t66, 2026-10-27).** The owner is happy for players to
|
||||
experiment and play their own way, so the console isn't hidden behind a dev build. Turning it
|
||||
on warns first that the save will be marked, and the mark stays with the world; any future
|
||||
achievements won't unlock in a marked world. Besides money, items and upgrades it may have
|
||||
game speed and other things just for fun.
|
||||
- **One join rule for every transport piece: the next piece goes right after the end
|
||||
(2026-10-27, replaces the t62 same-row rule).** The owner wants all pieces to place and join
|
||||
the same way, so players know what to expect when building. Joins follow the direction the
|
||||
material travels: a piece's outlet points at the block right after its end, which is the
|
||||
next block along a straight piece, or the block diagonally past a diagonal's end. The next
|
||||
piece joins when it starts in that block and carries the same general way. So a horizontal
|
||||
then a diagonal (up or down) starts the diagonal in the next block on the same row; a
|
||||
diagonal then a horizontal or vertical starts it diagonally past the diagonal's end; a
|
||||
vertical going up then a diagonal starts the diagonal directly above. Any other placement is
|
||||
two separate ends that pass nothing. Belts carry grains on top, so they bend the rule where
|
||||
grains can't follow it: an uphill belt's top feeds a flat belt on the same row, and a flat
|
||||
belt one block below an uphill belt's low end also feeds it. The owner first approved a rule
|
||||
based only on shape, then found in testing that horizontal → uphill and vertical → diagonal
|
||||
didn't join the way the other pieces did.
|
||||
- **Warehouses replace the trade dock, eventually (t68, 2026-10-27).** The dock only exists to
|
||||
get selling working for testing; the owner finds it unengaging. The plan: a warehouse at each
|
||||
edge of the map, on indestructible ground, served by a road and trucks. Warehouses accept
|
||||
loose grain as well as packaged goods (pallets, barrels or sacks from packaging machines).
|
||||
Trucks matter to play, not just looks: what's waiting is collected when a truck comes, on its
|
||||
schedule. Finding them: the player spawns a short walk from the left warehouse, which takes
|
||||
loose and bulk goods at lower prices with frequent trucks; the right one pays more for
|
||||
packaged goods and is the long-haul goal. Both always show on the minimap and full map with an
|
||||
edge-of-screen arrow, the first-start card names both and suggests starting left, signposts
|
||||
stand on indestructible plinths at spawn, and each warehouse is a landmark (crane or silo,
|
||||
beacon at night) with its road running a little way inland.
|
||||
- **Screws melting under hot loads stays, but must read clearly (t69, t70, 2026-10-27).** Hot
|
||||
material still damages screw conveyors (above 1000°), but the owner wants the damage obvious
|
||||
(a scorched or broken look, hover text, a first-time message), cheap to repair (build over it),
|
||||
and warned about before it happens (a glow near the limit). A heat-resistant screw comes later
|
||||
as an upgrade. Prompted by a long vertical screw that seemed to leak for no reason while it
|
||||
carried hot rubble.
|
||||
- **Melts that cool loose are ordinary rock (2026-10-27).** Magma poured from the bag became an
|
||||
invisible, undrillable basalt the game treated as built. Now any melt except metal and glass
|
||||
cools into natural rock wherever it came from, and any solid no block, mold or machine owns can
|
||||
be drilled, so nothing can get permanently stuck in the world.
|
||||
- **Resonance pylon: metal sets hardness, power sets reach (t47, 2026-10-27).** A pylon is built
|
||||
in copper, iron or steel, which caps the hardest rock it can break (4, 5, 6), the same way
|
||||
bits and tanks step up through metals. Power from the shaft network sets its radius and pulse
|
||||
rate, up to a cap per metal; power never sets hardness, so it can't skip the steel bit. It is
|
||||
tuned like the drill and never harms anything built. It waits until power is switched back on,
|
||||
since today every machine runs without power.
|
||||
- **Performance: WebGPU and all CPU cores, in the browser (2026-10-27).** Zoomed out, drawing
|
||||
is CPU-bound (about 26 ns per visible grain) and the simulation runs on one core. The owner
|
||||
chose: WebGPU only (no WebGL fallback), tested in Chrome and Firefox with a clear message when
|
||||
it's missing; the game must be served with COOP/COEP headers (for SharedArrayBuffer), so
|
||||
opening the file from disk is dropped; determinism is re-baselined once and becomes the same on
|
||||
any number of cores; as fast as possible, measured at every step. Grain movement and heat go
|
||||
across a worker pool first; moving the whole simulation into its own worker is decided after.
|
||||
|
||||
## Ideas not built yet
|
||||
|
||||
- **Concrete**, as a stronger wall material from rubble.
|
||||
- **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`.
|
||||
- The owner's ideas for later are tasks tagged `idea` in the Backlog of the task board.
|
||||
|
||||
+382
-140
@@ -8,25 +8,33 @@ 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
|
||||
AGENTS.md, CLAUDE.md instructions for coding agents (local only, not in git)
|
||||
tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md; local only, not in git)
|
||||
build.py joins src/ into dist/index.html (one self-contained page)
|
||||
src/manifest.json the build order: simulation files, then browser files
|
||||
src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers
|
||||
src/styles/style.css all styles
|
||||
src/styles/ base styles and the dev console panel
|
||||
src/sim/ the simulation. No DOM. The headless tests load only these files.
|
||||
src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays
|
||||
src/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays
|
||||
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
|
||||
audio, the frame loop
|
||||
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
||||
t_smooth.py); run_all.py
|
||||
tools/ shot.py (screenshots), where.py, map.py, perf.py, playthrough.py,
|
||||
scenes/ (screenshot setups), experiments/ (measurements and reports)
|
||||
t_smooth.py, t_dev_console.py, t_caps.py, firefox_smoke.py); run_all.py
|
||||
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
|
||||
browser.py (shared launch/GPU choice), caps.py (capability report),
|
||||
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
|
||||
scripts to .venv), scenes/ (screenshot setups), experiments/
|
||||
(measurements and reports)
|
||||
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)
|
||||
```
|
||||
|
||||
The dev console pairs `src/sim/devconsole.js` (headless command dispatch, mark, log and
|
||||
runtime controls) with `src/ui/devconsole.js` (confirmation and bottom panel). Save metadata
|
||||
keeps `dev` and the last 200 `devLog` entries; free building, speed and dev pause reset on load.
|
||||
The frame loop applies speed and queued steps while keeping the per-frame tick limit.
|
||||
|
||||
## How it runs
|
||||
|
||||
- **One script.** All files share one global scope; there is no import/export.
|
||||
@@ -42,7 +50,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
4. update the HUD.
|
||||
- **One tick** (`simTick` in `src/sim/tick.js`), in this order:
|
||||
- update the sky roof line (every 20 ticks);
|
||||
- weather, machines (including belts and tubes), pipes, drill pulses;
|
||||
- weather, machines (including belts and screw conveyors), pipes, drill pulses;
|
||||
- bars (`entTick`) and grains in flight (`flyTick`);
|
||||
- falling sand (`simMove`), then heat (`heatPass`);
|
||||
- every so often: molds, milestones, plants, cold, tree support;
|
||||
@@ -60,7 +68,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
- `temp`: °C;
|
||||
- `flags`: `F_NAT` natural rock, `F_CAST`, `F_BURN` burning;
|
||||
- `stamp`: moved this tick;
|
||||
- `part`: which built part a cell belongs to (`P_*`: tube directions, grate, open gate, filter,
|
||||
- `part`: which built part a cell belongs to (`P_*`: screw conveyor directions, grate, open gate, filter,
|
||||
pipe...);
|
||||
- `mach`: machine id;
|
||||
- `digp`: drill damage;
|
||||
@@ -73,12 +81,13 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
only those are simulated. `setCell` wakes what it touches; after writing arrays directly, call
|
||||
`wakeXY` / `wakeHeatXY`.
|
||||
- **Other state:**
|
||||
- `fog`: exploration at one cell per 2×2 grains; see [fog](systems/fog.md);
|
||||
- `machines`: index = machine id; removed ones are `null`. Each is
|
||||
`{ 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);
|
||||
- `flyers` (grains in the air: blown, off belt ends, out of screw conveyor mouths, rain);
|
||||
- `pipeNets`;
|
||||
- `ghostBlocks`, `ghostMachines`, `ghostMolds`.
|
||||
- **Player:** `bot` (position), `bag` (count per material), `player` (bit and tank tier, lens,
|
||||
@@ -115,6 +124,17 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
- kind `mold`.
|
||||
`hidden` pieces are not in the menu. The menu's tabs and order are `BUILD_CATS` in
|
||||
`src/ui/build-menu.js`.
|
||||
- **Transport geometry:** `joins.js` owns travel-directed ends and mutual joins for belts,
|
||||
filters and screws. `pieceEnds(code, v)` returns precomputed geometric left/right ends
|
||||
(vertical screws: up/down), with travel and inlet/outlet roles. `endJoin(bx, by, endIndex,
|
||||
planned = null)` returns `neighbourBlock * 2 + neighbourEnd`, or -1; `pieceJoins` returns
|
||||
bits 0/1. Plans override world blocks, including the queried block. Outlets target the next
|
||||
block along travel, with the belt surface exceptions. Inlet ties prefer same-shape travel,
|
||||
then top-to-bottom, left-to-right; a join must be reciprocal. Both looks, ghosts, previews,
|
||||
diagonal screw grain/bar transfers and straight screw entry into diagonals share this rule.
|
||||
Flat-belt bar entry uses `entBeltScrewEnter` and the swept-path collision check. Straight
|
||||
screw side-feeding into lifts remains independent. Edge elbows bend inside the
|
||||
diagonal; diagonal outlets bend inside the receiving straight block.
|
||||
- **Placing and removing:**
|
||||
- `placeBlock` / `removeBlock` (`blocks.js`);
|
||||
- `placeMachineAt` / `addMachine` / `removeMachine` (`actions.js`, `machines.js`);
|
||||
@@ -129,17 +149,23 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
|
||||
## Drawing
|
||||
|
||||
- **The pixel look:**
|
||||
- `drawWorld` (`src/render/world.js`) fills one pixel per grain;
|
||||
- `computeLight` (`light.js`) adds coloured light;
|
||||
- overlays (machines, molds, the bot, rain, arrows) are drawn on the 2D canvas afterwards
|
||||
(`machines.js`, `overlays.js`).
|
||||
- **The smooth look** (`src/render/smooth.js`) takes the buffers `drawWorld` fills (colour and
|
||||
a class per cell, the colour behind, trees, fields, light, glow) and shades them with WebGL2.
|
||||
- The cell classes are set in `world.js` and read in the shader: 4 is a crisp built piece,
|
||||
6 a wall tile, 7/8 a belt, 9–12 a screw tube, 13/14 a filter.
|
||||
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test
|
||||
tools set it).
|
||||
Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars,
|
||||
flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas.
|
||||
|
||||
- `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during
|
||||
world loading, and handles one device recreation followed by an autosaved Reload notice.
|
||||
- `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32
|
||||
chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes
|
||||
mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo.
|
||||
- `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no
|
||||
pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading.
|
||||
- `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep.
|
||||
- `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains,
|
||||
field AO and glow; `gpu/surface.js` caches the parallax horizon.
|
||||
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
|
||||
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
|
||||
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
|
||||
smooth tags, ghosts and previews.
|
||||
|
||||
## The browser shell
|
||||
|
||||
@@ -158,17 +184,18 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
| 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` |
|
||||
| Build menu tabs, tiles, status and hover popup | `src/ui/build-menu.js` |
|
||||
| Key bindings | `src/ui/input.js` (keydown); what they do: `src/ui/actions.js` |
|
||||
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
|
||||
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
|
||||
| Controls help text | `src/ui/notebook.js` (`controlsHtml`) |
|
||||
| Cached vein membership, ore identification and label timing | `src/ui/cursor.js` |
|
||||
| Hover info and discovery cards | `src/ui/info.js` |
|
||||
| Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` |
|
||||
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
|
||||
| Falling sand, liquids, gases | `src/sim/cells.js` |
|
||||
| Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` |
|
||||
| Belts, filters, screw tubes | `src/sim/conveyors.js`, `src/sim/machine-tick.js` |
|
||||
| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/slope-conveyors.js` (uphill cleat barriers and screen nudges), `src/sim/machine-tick.js` |
|
||||
| Fans | `src/sim/machine-tick.js` (`fanBlow`) |
|
||||
| Refiner, Stone cutter, Silo | `src/sim/processors.js` |
|
||||
| Pipes, pumps, outlets | `src/sim/pipes.js` |
|
||||
@@ -180,7 +207,7 @@ dist/, shots/ build output and screenshots (not committed)
|
||||
| World generation | `src/sim/worldgen.js` |
|
||||
| Save format and migrations | `src/sim/save.js` |
|
||||
| Machine drawing (silo, refiner...) | `src/render/machines.js` |
|
||||
| Smooth-look shader | `src/render/smooth.js` |
|
||||
| Smooth-look shader | `src/render/gpu/smooth-shader.js` |
|
||||
| Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) |
|
||||
| Sound and music | `src/ui/audio.js` |
|
||||
|
||||
@@ -193,15 +220,19 @@ Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plai
|
||||
- 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.
|
||||
### src/sim/materials.js (119 lines)
|
||||
Material definitions: ids, properties and metal families. 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
|
||||
- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO
|
||||
|
||||
### src/sim/pieces.js (140 lines)
|
||||
### src/sim/material-tables.js (54 lines)
|
||||
Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables
|
||||
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH
|
||||
|
||||
### src/sim/pieces.js (179 lines)
|
||||
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
|
||||
- functions: isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
|
||||
- variables: BS, BP_WALL, BP_BELT, BP_TUBE, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
|
||||
- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, filterVariant, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, lineOnly, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
|
||||
- variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
|
||||
|
||||
### src/sim/economy.js (152 lines)
|
||||
Money, prices, market saturation, the trade dock, buyer and builder orders.
|
||||
@@ -213,79 +244,105 @@ Starter furnace designs and the milestone list shown in the notebook.
|
||||
- functions: smelterBlueprint
|
||||
- variables: STARTER_DESIGNS, MILESTONES
|
||||
|
||||
### src/sim/world.js (96 lines)
|
||||
### src/sim/world.js (123 lines)
|
||||
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
||||
- functions: emit, bagCap, discover, alloc, depthU, depthY, 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
|
||||
- functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
||||
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
||||
|
||||
### src/sim/cells.js (176 lines)
|
||||
### src/sim/fog.js (52 lines)
|
||||
Exploration only: never changes grains or the random stream.
|
||||
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
|
||||
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
|
||||
|
||||
### src/sim/cells.js (223 lines)
|
||||
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
|
||||
- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, simMove
|
||||
- variables: SORTP
|
||||
- functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
|
||||
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
|
||||
|
||||
### src/sim/heat.js (246 lines)
|
||||
### src/sim/heat.js (266 lines)
|
||||
Heat: conduction, melting and freezing, reactions, fire and air, steel.
|
||||
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
|
||||
- variables: POROUS, SOIL, HOTLIQ, ALLOY
|
||||
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
|
||||
|
||||
### src/sim/machines.js (154 lines)
|
||||
### src/sim/machines.js (157 lines)
|
||||
Machines: the power network, footprints, placing and removing machines.
|
||||
- functions: markPow, partLost, rebuildPower, netSat, evictCells, cellBuildable, machineFootprint, canPlaceMachine, addMachine, removeMachine
|
||||
- variables: nextPowerBuild
|
||||
|
||||
### 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/processors.js (113 lines)
|
||||
Processing: Grinder and Saw blocks, and the Silo.
|
||||
- functions: isProcCode, rebuildProcessors, procBottom, procSlot, procFeed, procBlocksTick, siloTick, siloFull
|
||||
- variables: REFINE, REFINE_SAND, CUT_PER_BRICK, SAW_CUTS, procState, procBlocks, procDirty, SILO_CAP, SILO_RATE
|
||||
|
||||
### src/sim/racks.js (56 lines)
|
||||
Each rack block owns its bars; only the bottom of a column releases them.
|
||||
- functions: newRack, rackTop, rackBottom, rackPush, rackShift, rackIntake, rackRefill, rackTick
|
||||
- variables: RACK_CAP, rackState
|
||||
|
||||
### src/sim/pipes.js (114 lines)
|
||||
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).
|
||||
### src/sim/machine-tick.js (157 lines)
|
||||
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
|
||||
- functions: engineIntake, fanBlow, machinesTick
|
||||
|
||||
### src/sim/conveyors.js (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
|
||||
### src/sim/joins.js (125 lines)
|
||||
Travel-directed transport ends. Precomputed probes; world/plan queries allocate nothing. End indices stay geometric: left/right, or up/down for vertical screws.
|
||||
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
|
||||
- variables: JOIN_ENDS, NO_PIECE
|
||||
|
||||
### src/sim/conveyors.js (213 lines)
|
||||
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
|
||||
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
|
||||
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
|
||||
|
||||
### src/sim/blocks.js (132 lines)
|
||||
### src/sim/flow.js (26 lines)
|
||||
Throughput for the info overlay (t49): how many grains each belt, filter and screw conveyor block hands on, per second of game time. Counting runs only while the overlay is on, so normal play pays one flag check per carried grain. Bars are not counted.
|
||||
- functions: setFlowCounting, flowNote, flowCross, flowSlopeUnder, flowTick
|
||||
- variables: flowOn, flowCount, flowRate
|
||||
|
||||
### src/sim/slope-conveyors.js (231 lines)
|
||||
Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells. Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls. Only uphill runs have cleats: downhill, the belt carries its load like a flat one.
|
||||
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
|
||||
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
|
||||
|
||||
### src/sim/blocks.js (237 lines)
|
||||
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
||||
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||
|
||||
### src/sim/molds.js (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/molds.js (317 lines)
|
||||
Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
|
||||
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
|
||||
- variables: DROSS, grains, barWord
|
||||
|
||||
### src/sim/items.js (134 lines)
|
||||
### src/sim/items.js (318 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
|
||||
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
|
||||
- variables: BAR_W, BAR_H, entCurM, droppedLast
|
||||
|
||||
### src/sim/tools.js (52 lines)
|
||||
### src/sim/tools.js (65 lines)
|
||||
Tool helpers: reach, brushes, and plain digging.
|
||||
- functions: inReach, forBrush, drillReach, inDrillReach, toolDig
|
||||
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
|
||||
- variables: BUILD_MAX, buildBox
|
||||
|
||||
### src/sim/drill.js (161 lines)
|
||||
### src/sim/drill.js (177 lines)
|
||||
The resonance drill: tuned pulses that shatter one material.
|
||||
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick
|
||||
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF
|
||||
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
|
||||
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
|
||||
|
||||
### src/sim/nature.js (168 lines)
|
||||
### src/sim/nature.js (170 lines)
|
||||
Trees (background layer), acorns, saplings, and waking freezing water.
|
||||
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
|
||||
- variables: treeBases, saplings, treeDraw
|
||||
|
||||
### src/sim/vacuum.js (165 lines)
|
||||
### src/sim/vacuum.js (199 lines)
|
||||
The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
|
||||
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
|
||||
- variables: blowRR, FLY_G, FLY_Q
|
||||
|
||||
### src/sim/actions.js (86 lines)
|
||||
### src/sim/actions.js (92 lines)
|
||||
Player actions on built things: take back, place a machine or mold, gates, F to interact. Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
|
||||
- functions: removePieceAt, removePieceAt0, placeMachineAt, placeMoldAt, toggleGateAt, interactAt
|
||||
|
||||
@@ -293,7 +350,7 @@ Player actions on built things: take back, place a machine or mold, gates, F to
|
||||
Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
|
||||
- functions: copyRegion, flipBlueprint, bpCost, pasteGhost, cutRegion, footprintBlocker, clearFootprint
|
||||
|
||||
### src/sim/undo.js (87 lines)
|
||||
### src/sim/undo.js (93 lines)
|
||||
Undo (Ctrl+Z) for building, and the bot building ghosts.
|
||||
- functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0
|
||||
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
|
||||
@@ -303,167 +360,352 @@ The bot: movement and collision with the world.
|
||||
- functions: botBlocked, botOverlapSolid, botSlide, botStep
|
||||
- variables: SLIDE_MAX, SLIDE_STEP
|
||||
|
||||
### src/sim/ledger.js (43 lines)
|
||||
### src/sim/ledger.js (54 lines)
|
||||
Milestone checks (the ledger) and the sky roof line used for cooling.
|
||||
- functions: checkLedger, updateRoof
|
||||
- functions: checkLedger, updateRoof, updateDirtyRoof, scanRoof
|
||||
|
||||
### src/sim/weather.js (85 lines)
|
||||
### src/sim/weather.js (87 lines)
|
||||
Weather: clouds from steam, rain and snow, and the world water count.
|
||||
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
|
||||
- variables: WBAND, WS, weather
|
||||
|
||||
### src/sim/tick.js (25 lines)
|
||||
### src/sim/workers.js (153 lines)
|
||||
The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
|
||||
- functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage
|
||||
- variables: simPool, chunkWork, inlineWork
|
||||
|
||||
### src/sim/tick.js (29 lines)
|
||||
simTick: one simulation step, in order.
|
||||
- functions: simTick
|
||||
|
||||
### src/sim/worldgen.js (315 lines)
|
||||
World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks.
|
||||
- functions: makeNoise, fbm, clearHardSpecks, generateWorld
|
||||
### src/sim/worldgen-helpers.js (61 lines)
|
||||
World generation helpers: noise and removal of tiny hard rock specks.
|
||||
- functions: makeNoise, fbm, clearHardSpecks
|
||||
- variables: SPECK_MIN
|
||||
|
||||
### src/sim/save.js (112 lines)
|
||||
### src/sim/worldgen.js (286 lines)
|
||||
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
|
||||
- functions: generateWorld, generateWorldAsync
|
||||
|
||||
### src/sim/devconsole.js (114 lines)
|
||||
Dev commands are pure simulation operations; the browser owns the panel and confirmation.
|
||||
- functions: resetDevConsole, enableDevConsole, devMaterial, devCommand
|
||||
- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS
|
||||
|
||||
### src/sim/save.js (185 lines)
|
||||
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
|
||||
- functions: serializeWorld, deserializeWorld
|
||||
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
|
||||
- variables: SAVE_VERSION, TOFF
|
||||
|
||||
### src/render/setup.js (196 lines)
|
||||
### src/render/setup.js (190 lines)
|
||||
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
|
||||
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx
|
||||
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
|
||||
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
|
||||
|
||||
### src/render/light.js (79 lines)
|
||||
Propagated coloured light from glowing and burning cells.
|
||||
- functions: computeLight
|
||||
### src/render/slope-belts.js (167 lines)
|
||||
Drawing only: the face stays on its simulation cells; the band hangs below it.
|
||||
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
|
||||
- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance, slopeWallOwner
|
||||
|
||||
### src/render/world.js (277 lines)
|
||||
drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
- functions: drawWorld
|
||||
- variables: TUBE_CLASS
|
||||
### src/render/diagonal-screws.js (79 lines)
|
||||
Screw casing geometry shared by both looks, ghosts and placement previews. smooth-look class of a screw conveyor cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
|
||||
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
|
||||
|
||||
### src/render/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/cell.js (276 lines)
|
||||
Rare-cell shading also treats built pieces as transparent surroundings.
|
||||
- functions: builtForShade, cellPixel
|
||||
|
||||
### src/render/overlays.js (230 lines)
|
||||
### src/render/objects.js (80 lines)
|
||||
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
||||
- functions: worldObjects
|
||||
|
||||
### src/render/gpu/cracks.js (79 lines)
|
||||
The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||
- functions: gpuCrackTables
|
||||
- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW
|
||||
|
||||
### src/render/gpu/colour.js (101 lines)
|
||||
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
||||
- functions: gpuColourShader
|
||||
- variables: GPU_COLOUR, GPU_COLOUR_SMOOTH
|
||||
|
||||
### src/render/gpu/shaders.js (42 lines)
|
||||
Half-resolution separable Gaussian bloom, then nearest colour + linear light.
|
||||
- variables: GPU_BLUR, GPU_DRAW
|
||||
|
||||
### src/render/gpu/light.js (143 lines)
|
||||
GPU sources retain the CPU roof and transmission rules; directional relaxation follows only left/up, then right/down, so it cannot introduce extra light paths.
|
||||
- functions: gpuLightGrid, gpuLightBindings, gpuLight
|
||||
- variables: GPU_LIGHT, GPU_LIGHT_SPREAD, GPU_LIGHT_PACK
|
||||
|
||||
### src/render/gpu/smooth-slope.js (107 lines)
|
||||
Smooth slope geometry and cleats, ported from slope-belt-shader.js.
|
||||
- variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW
|
||||
|
||||
### src/render/gpu/smooth-shader.js (368 lines)
|
||||
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
||||
- variables: GPU_SMOOTH
|
||||
|
||||
### src/render/gpu/surface.js (5 lines)
|
||||
- functions: smoothSurface
|
||||
- variables: SM
|
||||
|
||||
### src/render/gpu/smooth.js (48 lines)
|
||||
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
||||
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
|
||||
- variables: GPU_FIELD_MIP
|
||||
|
||||
### src/render/gpu/device.js (90 lines)
|
||||
All pipelines compile during startup; rendering never falls back.
|
||||
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
|
||||
- variables: GPU_LIMITS, GPU
|
||||
|
||||
### src/render/gpu/textures.js (98 lines)
|
||||
Whole-world textures retain clean chunks across camera moves.
|
||||
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
|
||||
|
||||
### src/render/gpu/uploads.js (48 lines)
|
||||
Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||
- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads
|
||||
|
||||
### src/render/gpu/draw.js (92 lines)
|
||||
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
||||
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
|
||||
- variables: GPU_PATTERN
|
||||
|
||||
### src/render/world.js (46 lines)
|
||||
Scale colour without changing the smooth-look class.
|
||||
- functions: fogColor, drawWorld
|
||||
|
||||
### src/render/machines.js (256 lines)
|
||||
Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
|
||||
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
|
||||
|
||||
### src/render/overlays.js (370 lines)
|
||||
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
|
||||
|
||||
### src/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)
|
||||
### src/ui/audio.js (230 lines)
|
||||
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
|
||||
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
||||
- variables: AU, SFX, ZONES
|
||||
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
||||
- variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
|
||||
|
||||
### src/ui/state.js (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
|
||||
### src/ui/state.js (76 lines)
|
||||
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status.
|
||||
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
|
||||
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
|
||||
|
||||
### src/ui/title.js (46 lines)
|
||||
The title screen art.
|
||||
- functions: buildTitleCells, drawTitle
|
||||
- variables: FONT5, TITLE
|
||||
### src/ui/threads.js (39 lines)
|
||||
Browser transport only; the simulation owns the schedule and shared state.
|
||||
- functions: startThreads
|
||||
- variables: threadStarting
|
||||
|
||||
### src/ui/saves.js (106 lines)
|
||||
### src/ui/title.js (77 lines)
|
||||
Startup capabilities and title art.
|
||||
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
|
||||
- variables: REQUIRE_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
|
||||
|
||||
### src/ui/saves.js (224 lines)
|
||||
Saves in IndexedDB, world files (export / import) and the blueprint library.
|
||||
- functions: saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
||||
- variables: DB
|
||||
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
||||
- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
|
||||
|
||||
### src/ui/session.js (87 lines)
|
||||
### src/ui/claims.js (146 lines)
|
||||
Shared player state; the world save remains a complete standalone fallback.
|
||||
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
|
||||
- variables: career, claimBusy
|
||||
|
||||
### src/ui/session.js (162 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
|
||||
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
|
||||
|
||||
### src/ui/settings.js (116 lines)
|
||||
### src/ui/settings.js (124 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/notebook.js (91 lines)
|
||||
Notebook: tab navigation, ledger, materials, pieces and controls.
|
||||
- functions: openNotebook, renderNotebook, controlsHtml
|
||||
- variables: nbTab
|
||||
|
||||
### src/ui/notebook-market.js (68 lines)
|
||||
Notebook market: prices, orders, claim payments and wallet.
|
||||
- functions: fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet
|
||||
- variables: MARKET_GROUPS
|
||||
|
||||
### src/ui/notebook-blueprints.js (47 lines)
|
||||
Notebook blueprints: starter designs, saved library and code sharing.
|
||||
- functions: renderBlueprintTab, copyCode
|
||||
|
||||
### src/ui/hud.js (40 lines)
|
||||
The tool hotbar and what is selected.
|
||||
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece
|
||||
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
|
||||
- variables: IN_BAG_ORDER
|
||||
|
||||
### src/ui/build-menu.js (72 lines)
|
||||
### src/ui/build-menu.js (111 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
|
||||
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
|
||||
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
|
||||
|
||||
### 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
|
||||
### src/ui/selection.js (133 lines)
|
||||
Tool selection: cycling, drill tuning, piece variants and costs.
|
||||
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, dragPlan, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope
|
||||
|
||||
### src/ui/selection-pickers.js (61 lines)
|
||||
Selection pickers: filter rules, filter settings and wall materials. What a filter can hold: loose things and bars you have found, plus anything it already holds
|
||||
- functions: filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker
|
||||
|
||||
### src/ui/selection-panels.js (90 lines)
|
||||
Selection panels: current tool card and bag materials.
|
||||
- functions: 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/cursor.js (47 lines)
|
||||
Hover work is shared by the cursor label and the vein outline.
|
||||
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
|
||||
- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince
|
||||
|
||||
### src/ui/input.js (76 lines)
|
||||
### src/ui/info.js (208 lines)
|
||||
Discovery cards, hover info (I) and the cursor label.
|
||||
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
|
||||
|
||||
### src/ui/devconsole.js (42 lines)
|
||||
Bottom panel; command dispatch and state live in sim/devconsole.js.
|
||||
- functions: closeDevConsole, showDevConsole, toggleDevConsole, devPrint, runDevCommand
|
||||
|
||||
### src/ui/input.js (92 lines)
|
||||
Keyboard and mouse listeners.
|
||||
- functions: isTyping
|
||||
- variables: canvas
|
||||
|
||||
### src/ui/actions.js (244 lines)
|
||||
### src/ui/actions.js (256 lines)
|
||||
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
|
||||
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
|
||||
- variables: ovShown
|
||||
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick
|
||||
- variables: ovShown, PING_RECHARGE
|
||||
|
||||
### src/ui/gamepad.js (60 lines)
|
||||
Gamepad input.
|
||||
- functions: padPressed, pollPad
|
||||
- variables: PAD
|
||||
|
||||
### src/ui/previews.js (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/previews.js (241 lines)
|
||||
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
|
||||
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
|
||||
|
||||
### src/ui/events.js (44 lines)
|
||||
### src/ui/preview-drill.js (67 lines)
|
||||
Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
- functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
|
||||
|
||||
### src/ui/preview-selection.js (81 lines)
|
||||
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
|
||||
- functions: drawTag, drawSelection, drawPastePreview
|
||||
- variables: SEL_COL
|
||||
|
||||
### src/ui/events.js (42 lines)
|
||||
Events from the simulation (emit) turned into sounds, toasts and cards.
|
||||
- functions: drainEvents
|
||||
|
||||
### src/ui/loop.js (117 lines)
|
||||
### src/ui/map.js (93 lines)
|
||||
Shared world colours, minimap and full map.
|
||||
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
|
||||
- variables: MAP_S, MAP
|
||||
|
||||
### src/ui/perf.js (15 lines)
|
||||
Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
|
||||
|
||||
### src/ui/loop.js (149 lines)
|
||||
The frame loop and boot.
|
||||
- functions: scanVisible, frame, boot
|
||||
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
||||
|
||||
## Tests and tools
|
||||
|
||||
- `test/firefox_smoke.py`: An isolated Firefox title screen and three seconds of a new pixel world.
|
||||
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
|
||||
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
|
||||
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
|
||||
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
|
||||
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
|
||||
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
|
||||
- `test/run_all.py`: Run the test suites and report.
|
||||
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
|
||||
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
|
||||
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
|
||||
- `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
|
||||
- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
|
||||
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
|
||||
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
|
||||
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
|
||||
- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata.
|
||||
- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge.
|
||||
- `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
|
||||
- `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks.
|
||||
- `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys.
|
||||
- `test/t_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does.
|
||||
- `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence.
|
||||
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
|
||||
- `test/t_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_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
|
||||
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only.
|
||||
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
|
||||
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
|
||||
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
||||
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
||||
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
|
||||
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
|
||||
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
|
||||
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
|
||||
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
|
||||
- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
|
||||
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
|
||||
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
|
||||
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
|
||||
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
|
||||
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
|
||||
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
|
||||
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
|
||||
- `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
|
||||
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
|
||||
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
||||
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
|
||||
- `tools/browser.py`: Shared isolated HTTP sessions; callers attach hooks before navigating to url.
|
||||
- `tools/caps.py`: Print isolation, WebGPU adapter info and GL renderer for installed browsers.
|
||||
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
|
||||
- `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
|
||||
- `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
|
||||
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
|
||||
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
|
||||
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
|
||||
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
|
||||
- `tools/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
|
||||
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
|
||||
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
|
||||
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
|
||||
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
|
||||
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
|
||||
- `tools/experiments/factory_cost.js`: What does a running factory cost when the player is somewhere else? Builds K self-running modules in a generated world, far from the bot, and measures simulation time per tick. Each module: a coal + copper-ore furnace burning in a stone-block box, a spring feeding a water wheel, a shaft carrying its power to a 10-block belt that hauls sand off a hopper.
|
||||
- `tools/experiments/flow.js`: Throughput of belts and screw tubes: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
|
||||
- `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
|
||||
- `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals.
|
||||
- `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE).
|
||||
- `tools/experiments/slope-cost.js`: Many loaded slopes, with a fresh deterministic world for each timing sample.
|
||||
- `tools/experiments/slope-flow.js`: Reproducible generous-feed throughput and settled pile angles for t42.
|
||||
- `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...]
|
||||
- `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal]
|
||||
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
|
||||
- `tools/scenes/bar-lift.js`: A mold drops copper bars onto a belt, through a screw lift and onto the upper belt.
|
||||
- `tools/scenes/belt-heap.js`: A belt feeding an upward screw: sand underneath a deep heap of copper ore. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/belt-heap.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/belt-heap.js)" --ticks 240 --zoom 7
|
||||
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
|
||||
- `tools/scenes/joins.js`: Travel join grid: UR/DR/DL/UL, both ends, and all three neighbouring placements. Screw H and V, then belt H; each tries the side, above/below, and E. Self-contained, paused and empty. Use --zoom 4 --viewport 2100x1500 --no-light.
|
||||
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
|
||||
- `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
|
||||
- `tools/scenes/slope-conveyors.js`: Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
|
||||
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
|
||||
- `tools/scenes/t-screen-joint.js`: Downhill screen whose outlet belt sits level with its last block (the jam the owner saw).
|
||||
- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket.
|
||||
- `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
|
||||
- `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect.
|
||||
- `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand.
|
||||
- `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below.
|
||||
- `tools/scenes/t88-light.js`: Frozen light comparisons, also used by the WebGPU browser regression suite.
|
||||
- `tools/scenes/t88-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
|
||||
|
||||
+157
-13
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
|
||||
- A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically.
|
||||
|
||||
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the
|
||||
same test give the same output every run, apart from lines with timings in ms. To prove a
|
||||
same test give outputs identical across thread counts and between runs, apart from lines with timings in ms. To prove a
|
||||
refactor changed nothing, save every suite's output before and after, and diff them:
|
||||
|
||||
```sh
|
||||
@@ -58,20 +58,67 @@ for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # afte
|
||||
diff -r /tmp/before /tmp/after
|
||||
```
|
||||
|
||||
`test/t_threads.js` runs the real shared-memory scheduler inline and with 1, 2 and 8
|
||||
Node workers, including repeats, comparing all mutable cell/chunk bytes and JS side effects.
|
||||
The browser creates up to `min(16, hardwareConcurrency - 2, CPU workers setting)` helpers.
|
||||
Small passes run inline; larger passes use batches to amortize dispatch. Wakes and heat flags
|
||||
merge after each pass; long liquid searches are deferred until that merge. Global machine and
|
||||
weather randomness remains separate from the chunk streams. World generation uses the same
|
||||
inline schedule while settling. The t88 re-baseline changes worldgen material hashes and the
|
||||
seeded sand pile bounds in `t_corner_seal.js`; the heat oracle now supports pass outputs.
|
||||
The conveyor fixture uses a wider feed pile, the screen fixture warms longer, and `t_load.js`
|
||||
checks waking within eight ticks separately from the probabilistic consumption of fuel.
|
||||
|
||||
## Browser tests
|
||||
|
||||
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
|
||||
- `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.
|
||||
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
|
||||
- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under
|
||||
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
|
||||
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
|
||||
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
|
||||
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
|
||||
from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the
|
||||
Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes.
|
||||
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
|
||||
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
|
||||
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
|
||||
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
|
||||
compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
|
||||
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
|
||||
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
|
||||
actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures
|
||||
store float16 (the removed CPU implementation used float32). Behavioural expectations remain.
|
||||
- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests.
|
||||
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
|
||||
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
|
||||
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
|
||||
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
|
||||
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
|
||||
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader.
|
||||
Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend.
|
||||
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
|
||||
changed. The play test holds the slow-save fixture open during its screenshot, waits for
|
||||
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
|
||||
a known reachable target; these checks do not depend on GPU speed or earlier digging.
|
||||
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
|
||||
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
|
||||
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
|
||||
the mandatory WebGPU startup gate. Cross-origin isolation is required for threads. Firefox smoke
|
||||
checks that a browser without WebGPU shows the notice and cannot start a world.
|
||||
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
|
||||
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
|
||||
only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a
|
||||
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
|
||||
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
|
||||
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
|
||||
and [GPUWeb implementation status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status#firefox).
|
||||
|
||||
## Screenshots: tools/shot.py
|
||||
|
||||
@@ -87,6 +134,13 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
`tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set
|
||||
`CAM = [x, y]` to frame the shot.
|
||||
- The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`.
|
||||
- Both looks require WebGPU; there is no renderer switch or legacy fallback.
|
||||
- `test/t_gpu.py` checks GPU uploads, dirty/full equality, contours, resize, timing and device loss.
|
||||
World-pixel tests use `worldPixelContext()` to read both stacked canvases. The helper
|
||||
presents the retained GPU result again before copying, because canvas textures expire after
|
||||
presentation ([WebGPU specification](https://gpuweb.github.io/gpuweb/#abstract-opdef-expire-the-current-texture),
|
||||
[Mozilla implementation notes](https://bugzilla.mozilla.org/show_bug.cgi?id=1965377)). Tests that change
|
||||
static `noiseB` in place set `GPU.world = null` before drawing to upload their new fixture.
|
||||
- `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics.
|
||||
- `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at
|
||||
the same moment. Two shots of the same scene then match pixel for pixel, and a before / after
|
||||
@@ -105,10 +159,9 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader:
|
||||
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set
|
||||
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game
|
||||
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
|
||||
- **GPU access in headless Chromium.** Real mode needs access to GPU devices and driver caches;
|
||||
a filesystem sandbox can silently force software rendering. Use `tools/caps.py` to check.
|
||||
Soft mode is available for machines without a GPU. Both modes load isolated HTTP pages.
|
||||
- **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is
|
||||
`undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name.
|
||||
Functions declared with `function` are on `window`.
|
||||
@@ -127,7 +180,98 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
|
||||
|
||||
## Measuring
|
||||
|
||||
- `tools/perf.py`: renderer and tick times in the browser.
|
||||
- `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
|
||||
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
|
||||
|
||||
```sh
|
||||
python3 tools/perf.py --json tools/perf-baseline.json
|
||||
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
|
||||
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
|
||||
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
|
||||
python3 tools/test_perf.py
|
||||
```
|
||||
|
||||
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
|
||||
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
|
||||
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
|
||||
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
|
||||
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
|
||||
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
|
||||
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
|
||||
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
|
||||
|
||||
**World** is the whole `drawWorld` CPU call. **Prep** sets up the view/masks/joins;
|
||||
**Colour** fills CPU rare-cell overrides (including ghosts and flyers); **Upload** writes
|
||||
dirty world chunks and override textures; **Blit** submits GPU drawing and composition.
|
||||
These are CPU wall times, including driver stalls; they are not GPU timestamp measurements.
|
||||
Bloom is computed on the GPU. GPU light timestamps are recorded separately as `gpuLight`.
|
||||
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
|
||||
overlays absent from the fixture have zero calls, not a measured zero execution cost.
|
||||
|
||||
Five calls warm each render configuration before 30 measured calls. Simulation warms five
|
||||
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
|
||||
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
|
||||
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
|
||||
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
|
||||
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
|
||||
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
|
||||
|
||||
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
|
||||
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
|
||||
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
|
||||
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
|
||||
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
|
||||
JSON records a fixed render scale of 1 for both looks.
|
||||
Comparisons accept subsets of the baseline scenes, looks and zooms, with identical world/viewport dimensions and run duration, print all timing changes, and mark >10% regressions;
|
||||
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
|
||||
|
||||
Performance runs launch Chromium with
|
||||
`--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization --enable-unsafe-webgpu`.
|
||||
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
|
||||
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. Real mode refuses software
|
||||
renderers; both modes require isolation. Chromium needs access to the host GPU devices; a filesystem
|
||||
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
|
||||
tests' SwiftShader numbers (and explicit `--gpu soft` profiler runs) are not a hardware performance baseline.
|
||||
|
||||
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
|
||||
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
|
||||
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
|
||||
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
|
||||
free-port background server with cleanup for measurement tools.
|
||||
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
|
||||
- `tools/experiments/flow.js`: belt and tube throughput in grains a second.
|
||||
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
|
||||
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
|
||||
|
||||
Both looks use WebGPU. Colour measures the CPU rare-cell override fill, including loose
|
||||
objects and ghosts. `frame_ms` is the complete paused-frame CPU call, with overlays and UI.
|
||||
Upload measures dirty world chunks, override buffers and small lighting tables; Blit measures
|
||||
GPU command submission. `override_cells` counts CPU cells. GPU timestamp samples include
|
||||
colour, lighting, bloom and drawing, excluding uploads. CPU lighting is gone. The GPU canvas
|
||||
draws the world; the transparent game canvas holds overlays. Override colours and glow live in
|
||||
plain typed arrays, without offscreen Canvas2D backing stores.
|
||||
|
||||
Use `tools/scenes/t88-renderer.js` with `--freeze --zoom 12` (or 6/2),
|
||||
`--viewport 1920x1080` and `--look pixel|smooth`. It includes terrain, water, fire, magma,
|
||||
fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost. Frozen legacy
|
||||
comparisons are in `test/t_gpu_golden.py`; no legacy renderer remains in the game.
|
||||
|
||||
The smooth shader retains the nine-tap glow filter and samples field AO at mip level 2.2;
|
||||
three render passes generate field levels 1-3, including non-power-of-two view sizes.
|
||||
Only smooth uploads back/tree/field overrides and a separate presence mask. Profiler scale is
|
||||
always 1. The screenshot tool invalidates the parallax horizon after setup changes `surfY`.
|
||||
Mip pass references: [Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
|
||||
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
|
||||
|
||||
`perfSim` reports synchronous tick wall time (also occupied main-thread time), polling in
|
||||
`threadWait`, and main compute excluding polling. The latter includes serial simulation,
|
||||
dispatch/merge and the main thread's own chunk batch; it is not pure scheduling overhead.
|
||||
Final t88 full-matrix results are saved in `tools/perf-after-t88.json`; keep
|
||||
`tools/perf-baseline.json` unchanged as the original comparison.
|
||||
|
||||
The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four
|
||||
storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for
|
||||
2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits)
|
||||
and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp).
|
||||
All game pipelines use async creation during capability checking. World loading submits both
|
||||
looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`;
|
||||
look/zoom switches must not call synchronous pipeline creation.
|
||||
|
||||
+1963
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
# Fog of war
|
||||
|
||||
Unexplored ground is black with a soft edge. The sky and the first few grains of the
|
||||
surface start seen. Exploration never changes the simulation.
|
||||
|
||||
## Data and revealing
|
||||
|
||||
`src/sim/fog.js` keeps a `Uint8Array` at half the grain resolution (`FOG = 2`).
|
||||
Values run from 0 (unseen) to 255 (seen) and only increase. `alloc` allocates it with
|
||||
the other world arrays. `fogInit` reveals above `surfY` and fades 16 grains below it.
|
||||
|
||||
Every four ticks, 256 rays from the bot's centre reveal up to 48 grains. The last 16
|
||||
grains fade out. Solid and powder stop sight after an eight-grain fade into their face;
|
||||
air, gas and liquid pass sight. Bars and machine voids do not block it.
|
||||
|
||||
Each look also senses the ground around the bot through rock: every fog cell within
|
||||
`FOG_SENSE` (24 grains) of the bot is revealed, fully clear out to 12 grains and fading over
|
||||
the outer 12 grains to nothing at 24, so the player can see ore and caves next to their tunnel
|
||||
and choose where to dig.
|
||||
|
||||
## Saving
|
||||
|
||||
The fog buffer is saved and exported with the other typed arrays, without a save version
|
||||
change. Older saves rebuild the surface, reveal a radius of 12 grains around built blocks,
|
||||
machine footprints and molds, fully clear inside six grains with a six-grain fade to the
|
||||
radius edge, then look from the bot. Loading and generating also look
|
||||
immediately from the bot.
|
||||
|
||||
## Drawing and setting
|
||||
|
||||
Both looks and the thermal lens sample fog bilinearly. Partly seen grains darken toward black
|
||||
along a smoothstep curve, with stable per-grain noise (up to about ±38 mid-fade, none at either
|
||||
end) to break up the half-resolution steps. Fully seen grains use the usual drawing; unseen
|
||||
grains are pure black (as is everything off the map) in both looks, hiding their shapes.
|
||||
Partial values dim foreground, background and trees. Bloom sources need at least 128;
|
||||
bars, flyers, rain and particles need at least 64. Hover info and the cursor label say
|
||||
only “Unexplored” below 64.
|
||||
|
||||
Game settings → Fog of war is on by default. Turning it off shows the whole world while
|
||||
exploration continues to be recorded and saved. Browser scenes and performance tools turn
|
||||
it off in their shared setup; dedicated fog checks enable it explicitly.
|
||||
@@ -5,7 +5,7 @@ Built 2026-10-22; the uses for stone and sand were added 2026-10-23.
|
||||
- Code:
|
||||
- `src/sim/processors.js` (Refiner, Stone cutter, Silo);
|
||||
- `src/sim/pipes.js`;
|
||||
- `src/sim/conveyors.js` and `src/sim/machine-tick.js` (belts and tubes);
|
||||
- `src/sim/conveyors.js` and `src/sim/machine-tick.js` (belts and screw conveyors);
|
||||
- `src/sim/economy.js` (prices, builders' orders).
|
||||
- Tests: `test/t_pipes.js`, `test/t_stone.js`, `test/t_conveyors.js`.
|
||||
|
||||
@@ -57,12 +57,12 @@ through another refiner.
|
||||
- **Water accounting.** Water in pipes counts in the world's water total, so the weather never
|
||||
sends rain to replace it. Pipe contents are saved.
|
||||
|
||||
## Belts and screw tubes
|
||||
## Belts and screw conveyors
|
||||
- A heap on a belt moves as one: the whole stack, up to 96 grains deep, steps along together
|
||||
instead of only its bottom layer. The owner reports that big piles still only move at the top
|
||||
(see NOTES.md).
|
||||
- Screw tubes run at belt pace, moving every other tick.
|
||||
- A horizontal tube fed from a full hopper moves about 100–110 grains a second.
|
||||
- Screw conveyors run at belt pace, moving every other tick.
|
||||
- A horizontal screw conveyor fed from a full hopper moves about 100–110 grains a second.
|
||||
- A belt under its mouth keeps up, carrying a stream 3–5 grains deep at about the same rate.
|
||||
- A vertical lift brings up about 40–60 grains a second.
|
||||
- `tools/experiments/flow.js` measures these.
|
||||
@@ -70,7 +70,10 @@ through another refiner.
|
||||
## 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.
|
||||
- Cast in an Ingot mold, it drops out of the bottom as glass bricks, worth $14 each.
|
||||
- Ingot molds empty themselves: bars, junk and mixed scrap drop below; slag is thrown away
|
||||
and the grate above is raked. Removing any mold returns its set metal to the bag: whole
|
||||
bars as bars, the rest as scrap.
|
||||
- Glass bricks build glass wall blocks. Glass is too brittle for a bit mold.
|
||||
- **Stone cutter** (machine, 2×2, $90).
|
||||
- A saw under a hopper. Every 8 grains of stone rubble become one stone brick, which drops out
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
# browser tests and screenshots (test/play.py, test/t_smooth.py, tools/shot.py)
|
||||
playwright==1.56.0
|
||||
playwright==1.64.1
|
||||
|
||||
+39
-3
@@ -1,22 +1,29 @@
|
||||
{
|
||||
"shell": "shell.html",
|
||||
"css": [
|
||||
"styles/style.css"
|
||||
"styles/style.css",
|
||||
"styles/devconsole.css"
|
||||
],
|
||||
"sim": [
|
||||
"sim/base.js",
|
||||
"sim/materials.js",
|
||||
"sim/material-tables.js",
|
||||
"sim/pieces.js",
|
||||
"sim/economy.js",
|
||||
"sim/progress.js",
|
||||
"sim/world.js",
|
||||
"sim/fog.js",
|
||||
"sim/cells.js",
|
||||
"sim/heat.js",
|
||||
"sim/machines.js",
|
||||
"sim/processors.js",
|
||||
"sim/racks.js",
|
||||
"sim/pipes.js",
|
||||
"sim/machine-tick.js",
|
||||
"sim/joins.js",
|
||||
"sim/conveyors.js",
|
||||
"sim/flow.js",
|
||||
"sim/slope-conveyors.js",
|
||||
"sim/blocks.js",
|
||||
"sim/molds.js",
|
||||
"sim/items.js",
|
||||
@@ -30,33 +37,62 @@
|
||||
"sim/bot.js",
|
||||
"sim/ledger.js",
|
||||
"sim/weather.js",
|
||||
"sim/workers.js",
|
||||
"sim/tick.js",
|
||||
"sim/worldgen-helpers.js",
|
||||
"sim/worldgen.js",
|
||||
"sim/devconsole.js",
|
||||
"sim/save.js"
|
||||
],
|
||||
"client": [
|
||||
"render/setup.js",
|
||||
"render/light.js",
|
||||
"render/slope-belts.js",
|
||||
"render/diagonal-screws.js",
|
||||
"render/cell.js",
|
||||
"render/objects.js",
|
||||
"render/gpu/cracks.js",
|
||||
"render/gpu/colour.js",
|
||||
"render/gpu/shaders.js",
|
||||
"render/gpu/light.js",
|
||||
"render/gpu/smooth-slope.js",
|
||||
"render/gpu/smooth-shader.js",
|
||||
"render/gpu/surface.js",
|
||||
"render/gpu/smooth.js",
|
||||
"render/gpu/device.js",
|
||||
"render/gpu/textures.js",
|
||||
"render/gpu/uploads.js",
|
||||
"render/gpu/draw.js",
|
||||
"render/world.js",
|
||||
"render/machines.js",
|
||||
"render/overlays.js",
|
||||
"render/smooth.js",
|
||||
"ui/audio.js",
|
||||
"ui/state.js",
|
||||
"ui/threads.js",
|
||||
"ui/title.js",
|
||||
"ui/saves.js",
|
||||
"ui/claims.js",
|
||||
"ui/session.js",
|
||||
"ui/settings.js",
|
||||
"ui/notebook.js",
|
||||
"ui/notebook-market.js",
|
||||
"ui/notebook-blueprints.js",
|
||||
"ui/hud.js",
|
||||
"ui/build-menu.js",
|
||||
"ui/selection.js",
|
||||
"ui/selection-pickers.js",
|
||||
"ui/selection-panels.js",
|
||||
"ui/cursor.js",
|
||||
"ui/info.js",
|
||||
"ui/devconsole.js",
|
||||
"ui/input.js",
|
||||
"ui/actions.js",
|
||||
"ui/gamepad.js",
|
||||
"ui/previews.js",
|
||||
"ui/preview-drill.js",
|
||||
"ui/preview-selection.js",
|
||||
"ui/events.js",
|
||||
"ui/map.js",
|
||||
"ui/perf.js",
|
||||
"ui/loop.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// Rare-cell shading also treats built pieces as transparent surroundings.
|
||||
function builtForShade(i, x, y) {
|
||||
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
|
||||
}
|
||||
// CPU colour rules for the rare-cell WebGPU overrides.
|
||||
function cellPixel(x, y) {
|
||||
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
|
||||
const bx = x - vx0, by = y - vy0, row = by * bw;
|
||||
if (R.gpuOn) { if (!gpuOverrideHas(row + bx)) GPU.overrideCount++; if (sm) GPU.overrideMask[row + bx] = 255; }
|
||||
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
|
||||
const OUT = sm ? 0x04000000 : 0xff000000;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) {
|
||||
px[row + bx] = OUT;
|
||||
if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; }
|
||||
return;
|
||||
}
|
||||
const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence;
|
||||
const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
|
||||
const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowW;
|
||||
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
|
||||
const yw = y * W;
|
||||
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
|
||||
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
|
||||
let fv = 255;
|
||||
if (useFog) {
|
||||
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
|
||||
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
|
||||
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
|
||||
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } return; }
|
||||
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
|
||||
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
|
||||
if (fv < 255) {
|
||||
let s = fv / 255;
|
||||
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
|
||||
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
|
||||
}
|
||||
}
|
||||
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
|
||||
const T = temp[i];
|
||||
// Also used by the thermal lens, which always draws in pixels.
|
||||
let looseShade = 1;
|
||||
if (!sm && m !== EMPTY && !builtForShade(i, x, y)) {
|
||||
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
|
||||
else if (PH[m] === PH_SOLID && (
|
||||
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
|
||||
(x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) ||
|
||||
(y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) ||
|
||||
(y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W])
|
||||
)) looseShade = 0.72;
|
||||
}
|
||||
let r, g, b;
|
||||
if (lens) {
|
||||
const ti = thermIdx(T);
|
||||
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
|
||||
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
|
||||
const p = part[i];
|
||||
r *= looseShade; g *= looseShade; b *= looseShade;
|
||||
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
|
||||
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
|
||||
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
return;
|
||||
}
|
||||
// background
|
||||
let br, bgc, bb, sky = false;
|
||||
const tb = bgm[i];
|
||||
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
|
||||
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
|
||||
else {
|
||||
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
|
||||
const v = sm ? 0 : nz * 0.06;
|
||||
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
|
||||
}
|
||||
if (sm) {
|
||||
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
|
||||
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
|
||||
if (tb) {
|
||||
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
|
||||
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
|
||||
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
|
||||
} else tpx[row + bx] = 0;
|
||||
}
|
||||
if (m === EMPTY) { r = br; g = bgc; b = bb; }
|
||||
else {
|
||||
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
|
||||
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
|
||||
r *= looseShade; g *= looseShade; b *= looseShade;
|
||||
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
|
||||
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
|
||||
else if (m === BELTL || m === BELTR) {
|
||||
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
|
||||
if (ly === 0 || ly === 3) {
|
||||
// top run moves with the load, bottom run returns the other way
|
||||
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
|
||||
const k = ly === 0 ? 1 : 0.72;
|
||||
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
|
||||
} else if (lx === 1 || lx === 2) {
|
||||
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
|
||||
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
|
||||
} else { r = 46; g = 48; b = 54; }
|
||||
} else if (m === PIPE) {
|
||||
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
|
||||
} else if (m === SHAFT) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
|
||||
else { r -= 34; g -= 30; b -= 24; }
|
||||
}
|
||||
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
|
||||
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
|
||||
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
|
||||
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
|
||||
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
|
||||
}
|
||||
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
|
||||
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
|
||||
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
|
||||
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
|
||||
const al = ALPHA[m];
|
||||
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
|
||||
const dp = digp[i];
|
||||
if (dp) {
|
||||
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
|
||||
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
|
||||
r += lift; g += lift; b += lift * 1.08;
|
||||
}
|
||||
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
|
||||
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
|
||||
const lx = x & 3, ly = y & 3;
|
||||
const wc = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3);
|
||||
if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
|
||||
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
|
||||
}
|
||||
}
|
||||
// field parts drawn over contents
|
||||
const sb = slopeBeltAt(i, x, y, sm);
|
||||
let p = part[i];
|
||||
if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
|
||||
const db = diagonalScrewBlock(x, y, sm);
|
||||
let diagonal = false;
|
||||
if (db >= 0) {
|
||||
const v = blockV[db], joins = diagonalJoins[db];
|
||||
const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins);
|
||||
p = sm || sd <= 0 ? SCREW_PARTS[v] : 0;
|
||||
diagonal = !!p;
|
||||
if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8);
|
||||
}
|
||||
if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
|
||||
else if (p) {
|
||||
if (IS_SCREW[p]) {
|
||||
const on = netSat(i) > 0;
|
||||
const vert = p === P_SCREW_U || p === P_SCREW_D;
|
||||
const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x;
|
||||
const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3;
|
||||
const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1;
|
||||
if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
|
||||
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
|
||||
if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
|
||||
} else {
|
||||
// screw flights: a diagonal stripe that walks along the tube when powered
|
||||
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
|
||||
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
|
||||
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
|
||||
}
|
||||
} else if (p === P_FILTER) {
|
||||
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
|
||||
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
|
||||
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
|
||||
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) {
|
||||
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
|
||||
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
|
||||
}
|
||||
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
|
||||
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
|
||||
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
|
||||
} else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
|
||||
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
|
||||
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
|
||||
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
|
||||
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
|
||||
else { r = 24; g = 25; b = 28; }
|
||||
}
|
||||
} else if (p === P_GRATE) {
|
||||
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
|
||||
} else if (p === P_GOPEN) {
|
||||
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
|
||||
} else if (p === P_INTAKE) {
|
||||
if (m === EMPTY) { r = 30; g = 32; b = 36; }
|
||||
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
|
||||
} else if (p === P_PIPE) {
|
||||
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
|
||||
}
|
||||
}
|
||||
const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
|
||||
if (slopeArt && !sm) {
|
||||
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
|
||||
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
|
||||
}
|
||||
const slopeDetail = slopeDetailAt(i, x, y, sm);
|
||||
if (slopeDetail) {
|
||||
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
|
||||
// ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber
|
||||
const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])];
|
||||
r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56;
|
||||
g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60;
|
||||
b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68;
|
||||
}
|
||||
// incandescence
|
||||
if (T > 450 && m !== EMPTY && incand) {
|
||||
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
|
||||
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
|
||||
if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
|
||||
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
|
||||
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
|
||||
}
|
||||
}
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
let top = 0xff000000;
|
||||
if (sm) {
|
||||
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
|
||||
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
|
||||
let cl = 0;
|
||||
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
|
||||
if (IS_SCREW[pt]) cl = SCREW_CLASS[pt];
|
||||
else if (p === P_FILTER && sb < 0) {
|
||||
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
|
||||
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
|
||||
cl = blockV[fb] & 128 ? 14 : 13;
|
||||
tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
|
||||
}
|
||||
else if (m === BELTL || m === BELTR) {
|
||||
cl = m === BELTL ? 7 : 8;
|
||||
tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
|
||||
}
|
||||
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
|
||||
else if (m !== EMPTY) {
|
||||
const ph = PH[m];
|
||||
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1;
|
||||
else if (ph === PH_POWDER) cl = 2;
|
||||
else if (ph === PH_LIQUID) cl = 3;
|
||||
else cl = 5;
|
||||
}
|
||||
const wallCode = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) {
|
||||
cl = wallCode;
|
||||
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
|
||||
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
|
||||
}
|
||||
if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
|
||||
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
|
||||
const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
|
||||
if (slopeArt) cl = slopeClass;
|
||||
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
|
||||
tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
|
||||
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
|
||||
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
|
||||
}
|
||||
if (slopeDetail && slopeWallOwner >= 0) {
|
||||
// ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation
|
||||
const wb = slopeWallOwner;
|
||||
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
|
||||
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl |
|
||||
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
|
||||
}
|
||||
top = cl << 24;
|
||||
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not
|
||||
}
|
||||
if (fv < 255) {
|
||||
const k = fv / 255; r *= k; g *= k; b *= k;
|
||||
if (sm) {
|
||||
const c = bpx[row + bx], t = tpx[row + bx];
|
||||
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
|
||||
}
|
||||
}
|
||||
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Screw casing geometry shared by both looks, ghosts and placement previews.
|
||||
// smooth-look class of a screw conveyor cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
const SCREW_CLASS = new Uint8Array(15); SCREW_CLASS[1] = 9; SCREW_CLASS[4] = 10; SCREW_CLASS[2] = 11; SCREW_CLASS[3] = 12;
|
||||
for (let p = P_SCREW_UR; p <= P_SCREW_UL; p++) SCREW_CLASS[p] = 19 + p - P_SCREW_UR;
|
||||
// Drawing only: the four transport lanes remain in the square block.
|
||||
let diagonalJoins = new Uint8Array(0);
|
||||
function diagonalScrewJoins(bx, by, v, planned = null) {
|
||||
// Three bits per end: 1..4 edge exits, 5 horizontal in E, 6 vertical in E; bits 6/7 collars.
|
||||
let joins = 0;
|
||||
for (let end = 0; end < 2; end++) {
|
||||
const join = endJoin(bx, by, end, planned);
|
||||
if (join < 0) { joins |= 64 << end; continue; }
|
||||
const nb = join >> 1, next = planned && planned.get(nb);
|
||||
const code = next ? next[0] : blockP[nb], nv = next ? next[1] : blockV[nb];
|
||||
if (pieceEnds(code, nv)[join & 1].diagonal) continue;
|
||||
const horizontal = code !== BP_SCREW || (nv & 1);
|
||||
const ny = (nb / BW) | 0, nx = nb % BW;
|
||||
const kind = ny === by ? (nx > bx ? 2 : 4) : nx === bx ? (ny < by ? 1 : 3) : horizontal ? 5 : 6;
|
||||
joins |= kind << (end * 3);
|
||||
}
|
||||
return joins;
|
||||
}
|
||||
// Only E elbows bend a straight block: 1 rising, 2 falling, three bits per end.
|
||||
function straightScrewJoins(bx, by, v, planned = null) {
|
||||
let joins = 0;
|
||||
for (let end = 0; end < 2; end++) {
|
||||
const join = endJoin(bx, by, end, planned);
|
||||
if (join < 0) continue;
|
||||
const nb = join >> 1, next = planned && planned.get(nb);
|
||||
const code = next ? next[0] : blockP[nb], nv = next ? next[1] : blockV[nb];
|
||||
if (code === BP_SCREW && nv >= 4 && ((nb / BW) | 0) !== by && nb % BW !== bx)
|
||||
joins |= (nv === 4 || nv === 6 ? 1 : 2) << (end * 3);
|
||||
}
|
||||
return joins;
|
||||
}
|
||||
// Scratch coordinates shared by the pixel shading; no allocations in the grain loop.
|
||||
let diagonalAcross = 0, diagonalAlong = 0, diagonalCap = 0;
|
||||
function diagonalScrewShape(x, y, b, v, joins) {
|
||||
const lx = x - (b % BW) * BS - 2, ly = y - ((b / BW) | 0) * BS - 2;
|
||||
let best = Infinity;
|
||||
for (let end = 0; end < 2; end++) {
|
||||
const join = (joins >> (end * 3)) & 7, side = end ? 1 : -1;
|
||||
const slope = v === 4 || v === 6 ? -1 : 1, straight = v < 4, vert = straight && !(v & 1);
|
||||
const bent = straight && join;
|
||||
const diagonal = straight ? bent : !join || join >= 5;
|
||||
const grade = straight ? (join === 1 ? -1 : 1) : slope;
|
||||
const dx = diagonal ? side * (vert ? grade : 1) * Math.SQRT1_2 : straight ? (vert ? 0 : side) : SCREW_DIRS[join - 1][0];
|
||||
const dy = diagonal ? side * (vert ? 1 : grade) * Math.SQRT1_2 : straight ? (vert ? side : 0) : SCREW_DIRS[join - 1][1];
|
||||
const along = lx * dx + ly * dy, across = -lx * dy + ly * dx;
|
||||
const radius = Math.hypot(across, Math.min(0, along));
|
||||
const cap = along - (diagonal ? 2 * Math.SQRT2 : 2);
|
||||
const sd = Math.max(radius - 2, !straight && (joins & (64 << end)) ? cap : -10);
|
||||
if (sd < best) {
|
||||
best = sd; diagonalAcross = side * across < 0 ? -radius : radius;
|
||||
const ox = (b % BW) * BS + 2, oy = ((b / BW) | 0) * BS + 2;
|
||||
diagonalAlong = (straight ? (vert ? oy : ox) : (ox + oy * slope) * Math.SQRT1_2) + side * along;
|
||||
diagonalCap = joins & (64 << end) ? cap : -10;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function diagonalScrewBlock(x, y, smooth) {
|
||||
const i = y * W + x, own = (y >> 2) * BW + (x >> 2);
|
||||
if (IS_SCREW[part[i]] && (blockV[own] >= 4 || diagonalJoins[own])) return own;
|
||||
if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[own]) return -1;
|
||||
let best = smooth ? 0.75 : 0, found = -1;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const bx = (x >> 2) + (k === 0 ? -1 : k === 1 ? 1 : 0);
|
||||
const by = (y >> 2) + (k === 2 ? -1 : k === 3 ? 1 : 0);
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
|
||||
const b = by * BW + bx, v = blockV[b];
|
||||
if (blockP[b] !== BP_SCREW || (v < 4 && !diagonalJoins[b])) continue;
|
||||
const joins = diagonalJoins[b] === undefined ? (v >= 4 ? diagonalScrewJoins(bx, by, v) : straightScrewJoins(bx, by, v)) : diagonalJoins[b];
|
||||
if (v < 4 && ((v & 1) ? (x >> 2) !== bx : (y >> 2) !== by)) continue;
|
||||
const sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
|
||||
if (sd <= best) { best = sd; found = b; }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
||||
function gpuColourShader(smooth) { return `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
|
||||
@group(0) @binding(2) var<storage, read> land: array<vec4f>;
|
||||
${['mat','flags','life','part','bgm','digp','noise','fog'].map((n,i)=>`@group(0) @binding(${i+3}) var ${n}T: texture_2d<u32>;`).join('\n')}
|
||||
@group(0) @binding(11) var tempT: texture_2d<f32>;
|
||||
@group(0) @binding(12) var overrideT: texture_2d<f32>;
|
||||
@group(0) @binding(13) var overrideGlow: texture_2d<f32>;
|
||||
@group(0) @binding(14) var outputT: texture_storage_2d<rgba8unorm, write>;
|
||||
${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(17) var treeOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(18) var fieldOut: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(19) var backOver: texture_2d<f32>;
|
||||
@group(0) @binding(20) var treeOver: texture_2d<f32>;
|
||||
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
|
||||
@group(0) @binding(22) var maskOver: texture_2d<u32>;
|
||||
` : ""}
|
||||
${smooth ? "" : "@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;"}
|
||||
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
||||
fn powder(p: vec2i) -> bool {
|
||||
return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u;
|
||||
}
|
||||
fn quant(c: vec3f) -> vec3f { return floor(clamp(c,vec3f(0),vec3f(255)))/255.; }
|
||||
@compute @workgroup_size(8,8)
|
||||
fn colour(@builtin(global_invocation_id) id: vec3u) {
|
||||
let q=vec2i(id.xy); if (any(q>=vec2i(u.view.zw))) { return; }
|
||||
let p=q+vec2i(u.view.xy); let x=p.x; let y=p.y;
|
||||
var rgb=vec3f(0); var bloom=vec3f(0);
|
||||
${smooth ? "var cl=4.; var backValue=vec4f(0); var treeValue=vec4f(0); var fieldValue=vec4f(1,0,0,0);" : ""}
|
||||
if (all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r; let nz=f32(textureLoad(noiseT,p,0).r)-128.;
|
||||
let T=textureLoad(tempT,p,0).r; let mc=lut[m*3u]; let props=lut[m*3u+1u];
|
||||
var fv=255.;
|
||||
if (u.style.x>0.) {
|
||||
let f=vec2f(p)/${FOG}.; let a=vec2i(f); let b=min(a+1,vec2i(u.world.zw)-1); let t=fract(f);
|
||||
fv=mix(mix(f32(textureLoad(fogT,a,0).r),f32(textureLoad(fogT,vec2i(b.x,a.y),0).r),t.x),mix(f32(textureLoad(fogT,vec2i(a.x,b.y),0).r),f32(textureLoad(fogT,b,0).r),t.x),t.y);
|
||||
if (fv>0. && fv<255.) { var s=fv/255.; s=clamp(s+nz*(0.6/128.)*s*(1.-s),0.,1.); fv=max(1.,255.*s*s*(3.-2.*s)); }
|
||||
}
|
||||
if (fv>0.) {
|
||||
var shade=1.;
|
||||
let built=props.x==${PH_SOLID}. && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u;
|
||||
if (${smooth ? "false" : `m!=${EMPTY}u && !built`}) {
|
||||
if (props.x==${PH_POWDER}.) { shade=1.1+nz*0.00065; }
|
||||
else if (props.x==${PH_SOLID}. && (powder(p+vec2i(-1,0)) || powder(p+vec2i(1,0)) || powder(p+vec2i(0,-1)) || powder(p+vec2i(0,1)))) { shade=0.72; }
|
||||
}
|
||||
if (u.effects.x>0.) {
|
||||
let ti=u32(clamp(i32(T/4.),0,550)); let thermal=lut[${NMAT*3+700}u+u32(u.effects.y)*551u+ti].rgb;
|
||||
rgb=select(thermal*0.78+vec3f((mc.r+mc.g+mc.b)/3.*0.25),thermal*0.55,m==${EMPTY}u)*shade;
|
||||
} else {
|
||||
let tb=textureLoad(bgmT,p,0).r; let dp=f32(textureLoad(digpT,p,0).r);
|
||||
let column=land[x]; let sky=land[i32(u.world.x)+y].rgb;
|
||||
var back: vec3f;
|
||||
if (${smooth ? "false" : "tb!=0u"}) { back=(lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/128.))*(1.+dp/700.); }
|
||||
else if (f32(y)<column.x) {
|
||||
back=sky;
|
||||
if (u.style.y>0.01) { let gy=(sky.r+sky.g+sky.b)/3.*0.82; back+= (vec3f(gy)-sky)*u.style.y*vec3f(0.7,0.7,0.65); }
|
||||
} else {
|
||||
back=vec3f(${BG.basalt.join(',')});
|
||||
if (f32(y)<column.y) { back=vec3f(${BG.dirt.join(',')}); }
|
||||
else if (f32(y)<column.z) { back=vec3f(${BG.stone.join(',')}); }
|
||||
else if (f32(y)<column.w) { back=vec3f(${BG.granite.join(',')}); }
|
||||
${smooth ? "" : "back+=vec3f(nz*0.06);"}
|
||||
}
|
||||
${smooth ? `backValue=vec4f(quant(back),select(0.,1.,f32(y)<column.x));
|
||||
if (tb!=0u) { treeValue=vec4f(quant((lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/256.))*(1.+dp/700.)),select(128./255.,1.,tb==${LEAVES}u)); }
|
||||
cl=0.; if (m!=${EMPTY}u) { cl=5.; if(props.x==${PH_SOLID}.) { cl=1.; } else if(props.x==${PH_POWDER}.) { cl=2.; } else if(props.x==${PH_LIQUID}.) { cl=3.; } }
|
||||
fieldValue=vec4f(select(0.,1.,cl==1.),select(0.,1.,cl==3.),select(0.,1.,cl==5.),select(0.,1.,tb!=0u));
|
||||
` : ""}
|
||||
rgb=back;
|
||||
if (m!=${EMPTY}u) {
|
||||
rgb=(mc.rgb+vec3f(nz*mc.a*${smooth ? `select(u.style.z*0.35,0.,props.x==${PH_SOLID}.)` : "u.style.z"}/128.))*shade;
|
||||
if (m==${FIRE}u) { let f=f32(textureLoad(lifeT,p,0).r & 15u)*6.; rgb=vec3f(255.,120.+f,30.+f*0.5); }
|
||||
else if (${smooth ? "false && " : ""}m==${WATER}u && ((x*3+y*5+(i32(u.style.w)>>2)) & 15)==0) { rgb+=vec3f(14.); }
|
||||
rgb=back+(rgb-back)*props.y;
|
||||
if (dp>0.) { let lift=dp/255.*(30.+sin(u.style.w*1.9+f32(x)*1.3+f32(y)*0.7)*6.); rgb+=lift*vec3f(1,1,1.08); }
|
||||
}
|
||||
if (T>450. && m!=${EMPTY}u && u.effects.z>0.) {
|
||||
var gt=T;
|
||||
if (props.x==${PH_SOLID}.) { gt+=nz*0.9; }
|
||||
else if (m==${MAGMA}u) { gt+=sin(f32(x)*0.19+f32(y)*0.11+u.style.w*0.03+nz*0.04)*55.-30.; }
|
||||
let gi=u32(clamp(i32((gt-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
|
||||
rgb=rgb*(1.-glow.a)+glow.rgb*glow.a;
|
||||
if (u.effects.w>0. && fv>=128. && glow.a>0.3) { bloom=quant(glow.rgb*(glow.a-0.3)*1.4); }
|
||||
}
|
||||
}
|
||||
rgb=quant(rgb*fv/255.);
|
||||
${smooth ? "backValue=vec4f(quant(backValue.rgb*fv),backValue.a); treeValue=vec4f(quant(treeValue.rgb*fv),treeValue.a);" : ""}
|
||||
}
|
||||
}
|
||||
// Flying grains replace colour, while the glow behind them remains.
|
||||
let over=textureLoad(overrideT,q,0);
|
||||
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
|
||||
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
|
||||
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
|
||||
${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"}
|
||||
}`;
|
||||
}
|
||||
const GPU_COLOUR = gpuColourShader(false);
|
||||
const GPU_COLOUR_SMOOTH = gpuColourShader(true);
|
||||
@@ -0,0 +1,79 @@
|
||||
// The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||
function gpuCrackTables() {
|
||||
const out = new Float32Array(3 * 512 * 4);
|
||||
for (let l=0;l<3;l++) {
|
||||
const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i);
|
||||
for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
|
||||
for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255];
|
||||
for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const GPU_CRACKS = `
|
||||
fn crackNoise(p: vec2f, l: u32) -> f32 {
|
||||
let base=${NMAT*3+700+551*3}u+l*512u;
|
||||
let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p);
|
||||
let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x);
|
||||
let a=lut[base+u32(lut[base+v.x+p0].x)].yz;
|
||||
let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz;
|
||||
let c=lut[base+u32(lut[base+v.x+p1].x)].yz;
|
||||
let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz;
|
||||
let t=f*f*f*(f*(f*6.-15.)+10.);
|
||||
return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41;
|
||||
}
|
||||
`;
|
||||
// Screen-resolution colour output preserves the old sub-grain marching-square strokes.
|
||||
const GPU_CRACK_COLOUR = `
|
||||
struct Params { view:vec4f, world:vec4f, style:vec4f, effects:vec4f, camera:vec4f, light:vec4f, screen:vec4f, extra:vec4f }
|
||||
@group(0) @binding(0) var<uniform> u:Params;
|
||||
@group(0) @binding(1) var<storage,read> lut:array<vec4f>;
|
||||
@group(0) @binding(2) var matT:texture_2d<u32>;
|
||||
@group(0) @binding(3) var digpT:texture_2d<u32>;
|
||||
@group(0) @binding(4) var outputT:texture_storage_2d<rgba8unorm,write>;
|
||||
${GPU_CRACKS}
|
||||
fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 {
|
||||
let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.));
|
||||
}
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id:vec3u) {
|
||||
let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;}
|
||||
let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos));
|
||||
var colour=vec4f(0);
|
||||
if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r;let damage=f32(textureLoad(digpT,p,0).r)/255.;
|
||||
if(lut[m*3u+1u].x==${PH_SOLID}.) {
|
||||
for(var l=0u;l<3u;l++) {
|
||||
if(damage<array<f32,3>(0.1,0.42,0.72)[l]){continue;}
|
||||
let scale=array<f32,3>(1./7.,1./3.6,1./2.)[l];
|
||||
let at=vec2f(p)*scale+vec2f(0.37,0.61);
|
||||
let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l);
|
||||
let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l);
|
||||
var pts:array<vec2f,4>;var n=0u;
|
||||
if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;}
|
||||
if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;}
|
||||
if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;}
|
||||
if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;}
|
||||
let width=array<f32,3>(0.2,0.14,0.09)[l];
|
||||
for(var stroke=0u;stroke<2u;stroke++) {
|
||||
let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip);
|
||||
var distance=100.;
|
||||
for(var k=0u;k+1u<n;k+=2u) {
|
||||
distance=min(distance,segmentDistance(pos-vec2f(p)-vec2f(0,offset),pts[k],pts[k+1u]));
|
||||
}
|
||||
let w=max(1./u.camera.z,width*select(1.,1.3,lip))*0.5;
|
||||
let alpha=clamp((w-distance)*u.camera.z+0.5,0.,1.)*select(0.78-f32(l)*0.12,0.13,lip);
|
||||
let rgb=select(vec3f(18,12,10),vec3f(255,240,220),lip)/255.;
|
||||
colour=vec4f(rgb*alpha+colour.rgb*(1.-alpha),alpha+colour.a*(1.-alpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(outputT,q,colour);
|
||||
}`;
|
||||
const GPU_CRACK_DRAW = `
|
||||
@group(0) @binding(0) var cracks:texture_2d<f32>;
|
||||
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
|
||||
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);}
|
||||
`;
|
||||
@@ -0,0 +1,90 @@
|
||||
// All pipelines compile during startup; rendering never falls back.
|
||||
const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 };
|
||||
function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); }
|
||||
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
|
||||
async function gpuInit(adapter) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits.");
|
||||
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
|
||||
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS });
|
||||
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
|
||||
d.lost.then(info => { if (GPU.device === d) gpuRecover(info.message || 'Graphics device lost'); });
|
||||
GPU.context = GPU.canvas.getContext('webgpu');
|
||||
GPU.format = navigator.gpu.getPreferredCanvasFormat();
|
||||
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
|
||||
d.pushErrorScope('validation');
|
||||
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
|
||||
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
|
||||
await gpuSmoothInit();
|
||||
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
|
||||
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
|
||||
}
|
||||
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
|
||||
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
|
||||
GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}});
|
||||
const cracks = d.createShaderModule({code:GPU_CRACK_DRAW});
|
||||
GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});
|
||||
const error = await d.popErrorScope(); if (error) throw error;
|
||||
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
GPU.params = new Float32Array(32);
|
||||
GPU.sampler = d.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
|
||||
if (features.length) {
|
||||
GPU.queries = d.createQuerySet({ type: 'timestamp', count: 4 });
|
||||
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
|
||||
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
}
|
||||
GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started;
|
||||
return true;
|
||||
} catch (e) { GPU.ok = false; GPU.error = e.message; return false; }
|
||||
}
|
||||
function gpuFail(message) {
|
||||
GPU.ok = false; GPU.error = message;
|
||||
console.warn('WebGPU renderer: ' + message);
|
||||
if (GAME.running) gpuRecover(message);
|
||||
}
|
||||
async function gpuRecover(message) {
|
||||
if (GPU.recovering || GPU.stopped) return;
|
||||
GPU.ok = false; GPU.recovering = true;
|
||||
const saved = !GAME.running || await saveWorld('auto', true);
|
||||
const old = GPU.device;
|
||||
// Resources belong to the lost device; force every retained cache to rebuild.
|
||||
GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null;
|
||||
GPU.bindColour = GPU.lightDirections = GPU.queries = null;
|
||||
GPU.timingBusy = false; GPU.lightFrame = undefined;
|
||||
let restored = false;
|
||||
try {
|
||||
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
|
||||
if (adapter) restored = await gpuInit(adapter);
|
||||
} catch (e) { GPU.error = e.message; }
|
||||
if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; }
|
||||
GPU.device = null; if (old) old.destroy();
|
||||
GPU.stopped = true; GPU.recovering = false; GAME.paused = true;
|
||||
dialog('Graphics stopped', '<p>The graphics device could not restart. '+
|
||||
(saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'</p>', [
|
||||
...(!saved ? [{label:'Export world',fn:exportWorld}] : []),
|
||||
{label:'Reload',fn:()=>location.reload()}
|
||||
]);
|
||||
}
|
||||
// Submit both looks behind the loading screen, including their first real bindings.
|
||||
async function gpuWarmWorld() {
|
||||
await startThreads();
|
||||
buildSky(); resizeRender(); snapCamera();
|
||||
const look = SET.look, started = performance.now();
|
||||
try {
|
||||
for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) {
|
||||
SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone();
|
||||
}
|
||||
GPU.warmMs = performance.now() - started;
|
||||
} finally { SET.look = look; }
|
||||
}
|
||||
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
|
||||
function worldPixelContext() {
|
||||
if (!R.gpuOn) return R.ctx;
|
||||
// Canvas textures expire after presentation; redraw the retained result before copying.
|
||||
const encoder = GPU.device.createCommandEncoder(); gpuPresent(encoder);
|
||||
GPU.device.queue.submit([encoder.finish()]);
|
||||
const c = document.createElement('canvas'); c.width = R.canvas.width; c.height = R.canvas.height;
|
||||
const ctx = c.getContext('2d'); ctx.drawImage(GPU.canvas, 0, 0); ctx.drawImage(R.canvas, 0, 0);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
||||
const GPU_PATTERN = new Uint8Array(NMAT);
|
||||
for (const m of [BELTL, BELTR, PIPE, SHAFT, GATE, CUTST, GRINDER, SAWB, PRESSB, RACKB, WOOD, LWOOD, HULL, SPRING, DOCKW]) GPU_PATTERN[m] = 1;
|
||||
function gpuOverrideHas(k) { return R.smoothOn ? GPU.overrideMask[k] : R.px[k]; }
|
||||
function gpuOverrides() {
|
||||
R.px.fill(0);
|
||||
if (R.smoothOn) {
|
||||
if (!GPU.overrideMask || GPU.overrideMask.length !== R.px.length) GPU.overrideMask = new Uint8Array(R.px.length);
|
||||
GPU.overrideMask.fill(0);
|
||||
}
|
||||
if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB);
|
||||
const marked = GPU.blocks; marked.fill(0);
|
||||
const bx0 = Math.max(0, (R.vx0 >> 2) - 3), by0 = Math.max(0, (R.vy0 >> 2) - 3);
|
||||
const bx1 = Math.min(BW - 1, ((R.vx0 + R.bw) >> 2) + 3), by1 = Math.min(BH - 1, ((R.vy0 + R.bh) >> 2) + 3);
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b];
|
||||
let rare = !!code;
|
||||
if (!rare) for (let dy = 0; dy < BS && !rare; dy++) for (let dx = 0; dx < BS; dx++) {
|
||||
const i = (by * BS + dy) * W + bx * BS + dx;
|
||||
if (part[i] || mach[i] || occ[i] || GPU_PATTERN[mat[i]]) { rare = true; break; }
|
||||
}
|
||||
if (!rare) continue;
|
||||
marked[b] = 1;
|
||||
if (code === BP_SCREW || isSlopeBelt(code)) {
|
||||
// Covers joins, the far wall and cleats, including cells outside the owning block.
|
||||
for (let yy = Math.max(0, by - 3); yy <= Math.min(BH - 1, by + 2); yy++)
|
||||
for (let xx = Math.max(0, bx - 2); xx <= Math.min(BW - 1, bx + 2); xx++) marked[yy * BW + xx] = 1;
|
||||
}
|
||||
}
|
||||
if (R.smoothOn) for (const mo of molds) {
|
||||
if (mo.x + 40 < R.vx0 || mo.x - 40 > R.vx0 + R.bw || mo.y + 40 < R.vy0 || mo.y - 40 > R.vy0 + R.bh) continue;
|
||||
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) marked[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] = 1;
|
||||
}
|
||||
GPU.overrideCount = 0;
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
if (!marked[by * BW + bx]) continue;
|
||||
const x0 = Math.max(bx * BS, R.vx0), y0 = Math.max(by * BS, R.vy0);
|
||||
const x1 = Math.min(bx * BS + BS, R.vx0 + R.bw, W), y1 = Math.min(by * BS + BS, R.vy0 + R.bh, H);
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) cellPixel(x, y);
|
||||
}
|
||||
}
|
||||
function gpuDraw(lighting, glowOn, lightUpdate) {
|
||||
const upload = performance.now();
|
||||
gpuUpload(lighting, glowOn);
|
||||
if (GPU.canvas.width !== R.canvas.width || GPU.canvas.height !== R.canvas.height) {
|
||||
GPU.canvas.width = R.canvas.width; GPU.canvas.height = R.canvas.height;
|
||||
}
|
||||
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
|
||||
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
|
||||
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
|
||||
compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour);
|
||||
if (R.smoothOn) {
|
||||
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||
compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth);
|
||||
}
|
||||
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||
compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks);
|
||||
compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end();
|
||||
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
|
||||
const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]);
|
||||
pass.dispatchWorkgroups(Math.ceil(R.glowW / 8), Math.ceil(R.glowH / 8)); pass.end();
|
||||
}
|
||||
const lightTimed = lighting && (lightUpdate || GPU.lightFrame === undefined);
|
||||
if (lightTimed) { gpuLight(encoder, timed); GPU.lightFrame = R.frame; }
|
||||
if (R.smoothOn) gpuSmoothMips(encoder);
|
||||
gpuPresent(encoder, timed);
|
||||
if (timed) { encoder.resolveQuerySet(GPU.queries, 0, lightTimed ? 4 : 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, lightTimed ? 32 : 16); }
|
||||
d.queue.submit([encoder.finish()]);
|
||||
if (timed) {
|
||||
GPU.timingBusy = true;
|
||||
GPU.timingPromise = GPU.readback.mapAsync(GPUMapMode.READ).then(() => {
|
||||
const ns = new BigUint64Array(GPU.readback.getMappedRange()); GPU.gpuMs = Number(ns[1] - ns[0]) / 1e6;
|
||||
GPU.lightMs = lightTimed ? Number(ns[3] - ns[2]) / 1e6 : 0;
|
||||
GPU.readback.unmap(); GPU.timingBusy = false;
|
||||
}).catch(() => { GPU.timingBusy = false; });
|
||||
}
|
||||
if (PERF_MARKS || SET.showPerf) {
|
||||
perfMarks.override = perfMarks.color; perfMarks.upload = blit - upload; perfMarks.blit = performance.now() - blit;
|
||||
perfMarks.gpuLight = GPU.lightMs || 0;
|
||||
if (GPU.gpuMs !== null) perfMarks.gpu = GPU.gpuMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Also used for readback after the browser has expired the presented canvas texture.
|
||||
function gpuPresent(encoder, timed = false) {
|
||||
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
|
||||
});
|
||||
pass.setPipeline(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
|
||||
const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}],
|
||||
...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})});
|
||||
cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// GPU sources retain the CPU roof and transmission rules; directional relaxation
|
||||
// follows only left/up, then right/down, so it cannot introduce extra light paths.
|
||||
const GPU_LIGHT = `
|
||||
struct LightParams { grid: vec4f, world: vec4f, lamp: vec4f, levels: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: LightParams;
|
||||
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
|
||||
@group(0) @binding(2) var<storage, read> columns: array<vec4f>;
|
||||
@group(0) @binding(3) var matT: texture_2d<u32>;
|
||||
@group(0) @binding(4) var flagsT: texture_2d<u32>;
|
||||
@group(0) @binding(5) var partT: texture_2d<u32>;
|
||||
@group(0) @binding(6) var bgmT: texture_2d<u32>;
|
||||
@group(0) @binding(7) var machT: texture_2d<u32>;
|
||||
@group(0) @binding(8) var blockT: texture_2d<u32>;
|
||||
@group(0) @binding(9) var tempT: texture_2d<f32>;
|
||||
@group(0) @binding(10) var<storage, read_write> roofs: array<i32>;
|
||||
@group(0) @binding(11) var dest: texture_storage_2d<rgba16float, write>;
|
||||
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
||||
fn built(p: vec2i) -> bool {
|
||||
return (phase(p)==${PH_SOLID} && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u) ||
|
||||
textureLoad(blockT,p/4,0).r!=0u || textureLoad(machT,p,0).r!=0u || textureLoad(partT,p,0).r!=0u;
|
||||
}
|
||||
fn loose(p: vec2i) -> bool {
|
||||
for(var d=0;d<=12 && p.y+d<i32(u.world.y);d++) {
|
||||
let q=p+vec2i(0,d); let ph=phase(q);
|
||||
if(built(q)) { return true; }
|
||||
if(ph!=${PH_POWDER} && ph!=${PH_LIQUID}) { return ph==${PH_EMPTY} || ph==${PH_GAS}; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@compute @workgroup_size(64)
|
||||
fn roof(@builtin(global_invocation_id) id: vec3u) {
|
||||
let lx=i32(id.x); if(lx>=i32(u.grid.z)) { return; }
|
||||
let x=(i32(u.grid.x)+lx)*i32(u.world.z);
|
||||
if(x<0 || x>=i32(u.world.x)) { roofs[lx]=0; return; }
|
||||
let end=min(i32(u.world.y),i32((u.grid.y+u.grid.w)*u.world.z));
|
||||
var y=i32(columns[x].y);
|
||||
for(;y<end;y++) {
|
||||
let p=vec2i(x,y); let ph=phase(p);
|
||||
if((ph==${PH_SOLID} || ph==${PH_POWDER} || ph==${PH_LIQUID}) && !built(p) && !((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { break; }
|
||||
}
|
||||
roofs[lx]=y;
|
||||
}
|
||||
@compute @workgroup_size(8,8)
|
||||
fn sources(@builtin(global_invocation_id) id: vec3u) {
|
||||
let q=vec2i(id.xy); if(any(q>=vec2i(u.grid.zw))) { return; }
|
||||
let p=(vec2i(u.grid.xy)+q)*i32(u.world.z); let res=i32(u.world.z);
|
||||
var rgb=vec3f(0); var a=0.;
|
||||
if(all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||
let m=textureLoad(matT,p,0).r; let ph=phase(p);
|
||||
var base=0.9;
|
||||
if(built(p) || ((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { base=0.978; }
|
||||
else if(ph==${PH_EMPTY}) { base=select(0.978,0.96,textureLoad(bgmT,p,0).r!=0u); }
|
||||
else if(ph==${PH_GAS}) { base=0.96; }
|
||||
else if(ph==${PH_LIQUID}) { base=0.93; }
|
||||
else if(m==${GLASS}u || m==${ICE}u) { base=0.96; }
|
||||
a=pow(base,u.world.z);
|
||||
if(p.y<roofs[q.x]) { rgb=u.levels.x*vec3f(1,0.98,0.94); }
|
||||
else if(f32(p.y)<columns[p.x].x && ph!=${PH_SOLID}) { rgb=u.levels.x*0.9*vec3f(1,0.98,0.95); }
|
||||
var hottest=0.;
|
||||
for(var dy=0;dy<res && p.y+dy<i32(u.world.y);dy++) {
|
||||
for(var dx=0;dx<res && p.x+dx<i32(u.world.x);dx++) { hottest=max(hottest,textureLoad(tempT,p+vec2i(dx,dy),0).r); }
|
||||
}
|
||||
if(hottest>520.) {
|
||||
let gi=u32(clamp(i32((hottest-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
|
||||
rgb+=glow.rgb/255.*glow.a*1.35;
|
||||
}
|
||||
}
|
||||
if(u.lamp.w>0.) {
|
||||
let d=distance(vec2f(q),u.lamp.xy)/u.lamp.z;
|
||||
if(d<1.) { rgb=max(rgb,(1.-d)*(1.-d)*1.05*vec3f(1,0.93,0.8)); }
|
||||
}
|
||||
textureStore(dest,q,vec4f(rgb,a));
|
||||
}`;
|
||||
const GPU_LIGHT_SPREAD = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(2) var<uniform> direction: vec4i;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if(any(p>=size)) { return; }
|
||||
let own=textureLoad(source,p,0); var rgb=own.rgb;
|
||||
let x=p+vec2i(direction.x,0); let y=p+vec2i(0,direction.x);
|
||||
if(all(x>=vec2i(0)) && all(x<size)) { rgb=max(rgb,textureLoad(source,x,0).rgb*own.a); }
|
||||
if(all(y>=vec2i(0)) && all(y<size)) { rgb=max(rgb,textureLoad(source,y,0).rgb*own.a); }
|
||||
textureStore(dest,p,vec4f(rgb,own.a));
|
||||
}`;
|
||||
const GPU_LIGHT_PACK = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var<uniform> u: array<vec4f,4>;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); if(any(p>=vec2i(textureDimensions(source)))) { return; }
|
||||
let rgb=floor(clamp(max(textureLoad(source,p,0).rgb,vec3f(u[3].y)),vec3f(0),vec3f(1))*255.)/255.;
|
||||
textureStore(dest,p,vec4f(rgb,1));
|
||||
}`;
|
||||
function gpuLightGrid() {
|
||||
const res = SET.lighting === 'fast' ? 4 : 2, M = Math.ceil(28 / res);
|
||||
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
|
||||
const key = `${lw}/${lh}/${res}`;
|
||||
const update = GPU.lightKey !== key || GPU.lightWorld !== mat || GPU.lightFrame === undefined || R.frame % SET.lightEvery === 0;
|
||||
if (update) {
|
||||
GPU.lightKey = key; GPU.lightWorld = mat; GPU.lightFrame = R.frame;
|
||||
GPU.lightGrid = [Math.floor(R.vx0 / res) - M, Math.floor(R.vy0 / res) - M, lw, lh, res];
|
||||
}
|
||||
[R.lx0, R.ly0, R.lw, R.lh, R.lres] = GPU.lightGrid;
|
||||
return update;
|
||||
}
|
||||
function gpuLightBindings(group, view, buffer) {
|
||||
const d = GPU.device;
|
||||
if (GPU.lightUniform) { GPU.lightUniform.destroy(); GPU.lightRoofs.destroy(); }
|
||||
GPU.lightUniform = d.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
GPU.lightRoofs = d.createBuffer({ size: Math.max(1,R.lw) * 4, usage: GPUBufferUsage.STORAGE });
|
||||
const shared = [buffer(GPU.lightUniform), buffer(GPU.lut), buffer(GPU.columns),
|
||||
...[0,1,3,4].map(i => view(GPU.worldTextures[i])), view(GPU.mach), view(GPU.block), view(GPU.temp), buffer(GPU.lightRoofs)];
|
||||
// Auto layouts contain only bindings used by each entry point.
|
||||
GPU.bindRoof = d.createBindGroup({ layout: GPU.lightRoof.getBindGroupLayout(0), entries: shared.map((resource,binding)=>({binding,resource})).filter(e=>![6,9].includes(e.binding)) });
|
||||
GPU.bindSources = group(GPU.lightSources, [...shared, view(GPU.lightWork[0])]);
|
||||
if (!GPU.lightDirections) GPU.lightDirections = [-1,1].map(x => {
|
||||
const b=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||||
d.queue.writeBuffer(b,0,new Int32Array([x,0,0,0])); return b;
|
||||
});
|
||||
GPU.bindSpread = GPU.lightDirections.map(b => [0,1].map(i => group(GPU.lightSpread, [view(GPU.lightWork[i]),view(GPU.lightWork[1-i]),buffer(b)])));
|
||||
GPU.bindPack = group(GPU.lightPack, [view(GPU.lightWork[0]),view(GPU.lightTex),buffer(GPU.lightUniform)]);
|
||||
GPU.lightFrame = undefined;
|
||||
}
|
||||
function gpuLight(encoder, timed) {
|
||||
const q = GPU.device.queue, res = R.lres;
|
||||
q.writeBuffer(GPU.lightUniform,0,new Float32Array([R.lx0,R.ly0,R.lw,R.lh,W,H,res,0,
|
||||
(bot.x+bot.w/2)/res-R.lx0,(bot.y+bot.h/2)/res-R.ly0,34/res,+bot.lamp,
|
||||
SET.daylight*(1-0.32*(R.rain||0)),SET.ambient,0,0]));
|
||||
const pass = encoder.beginComputePass(timed ? {timestampWrites:{querySet:GPU.queries,beginningOfPassWriteIndex:2,endOfPassWriteIndex:3}} : {});
|
||||
pass.setPipeline(GPU.lightRoof); pass.setBindGroup(0,GPU.bindRoof); pass.dispatchWorkgroups(Math.ceil(R.lw/64));
|
||||
pass.setPipeline(GPU.lightSources); pass.setBindGroup(0,GPU.bindSources); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
pass.setPipeline(GPU.lightSpread);
|
||||
// 320 grains per direction: even maximum emission falls below one packed level.
|
||||
const count = 320/res;
|
||||
for(let direction=0;direction<2;direction++) for(let i=0;i<count;i++) {
|
||||
pass.setBindGroup(0,GPU.bindSpread[direction][i&1]); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
}
|
||||
pass.setPipeline(GPU.lightPack); pass.setBindGroup(0,GPU.bindPack); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
|
||||
pass.end();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Half-resolution separable Gaussian bloom, then nearest colour + linear light.
|
||||
const GPU_BLUR = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var<uniform> blur: vec4f;
|
||||
@compute @workgroup_size(8,8)
|
||||
fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if (any(p>=size)) { return; }
|
||||
var sum=vec3f(0); var weight=0.; let sigma=blur.z;
|
||||
let radius=i32(ceil(sigma*3.));
|
||||
for (var j=-radius;j<=radius;j++) {
|
||||
let w=exp(-f32(j*j)/(2.*sigma*sigma)); let q=p+vec2i(blur.xy)*j;
|
||||
if (all(q>=vec2i(0)) && all(q<size)) { sum+=textureLoad(source,q,0).rgb*w; }
|
||||
weight+=w;
|
||||
}
|
||||
textureStore(dest,p,vec4f(sum/weight,1));
|
||||
}`;
|
||||
const GPU_DRAW = `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var colour: texture_2d<f32>;
|
||||
@group(0) @binding(2) var light: texture_2d<f32>;
|
||||
@group(0) @binding(3) var glow: texture_2d<f32>;
|
||||
@group(0) @binding(4) var linear: sampler;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
let cell=(p.xy-u.camera.xy)/u.camera.z;
|
||||
var c=textureLoad(colour,vec2i(floor(cell)),0).rgb;
|
||||
if (u.light.w>0.) {
|
||||
let world=p.xy/u.camera.z+u.extra.xy;
|
||||
let uv=(world/u.light.z-u.light.xy)/vec2f(textureDimensions(light));
|
||||
let l=textureSampleLevel(light,linear,uv,0).rgb;
|
||||
c=round(c*l*255.)/255.;
|
||||
}
|
||||
if (u.effects.w>0.) {
|
||||
let uv=cell/(2.*vec2f(textureDimensions(glow)));
|
||||
c+=textureSampleLevel(glow,linear,uv,0).rgb*u.screen.z;
|
||||
}
|
||||
return vec4f(c,1);
|
||||
}`;
|
||||
@@ -0,0 +1,368 @@
|
||||
// Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
||||
const GPU_SMOOTH = `
|
||||
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@group(0) @binding(1) var tCol: texture_2d<f32>;
|
||||
@group(0) @binding(2) var tBack: texture_2d<f32>;
|
||||
@group(0) @binding(3) var tTree: texture_2d<f32>;
|
||||
@group(0) @binding(4) var tField: texture_2d<f32>;
|
||||
@group(0) @binding(5) var tLight: texture_2d<f32>;
|
||||
@group(0) @binding(6) var tGlow: texture_2d<f32>;
|
||||
@group(0) @binding(7) var linear: sampler;
|
||||
@group(0) @binding(8) var<uniform> su: vec4f;
|
||||
fn sampleLevel(t: texture_2d<f32>, uv: vec2f, level: f32) -> vec4f { return textureSampleLevel(t,linear,uv,level); }
|
||||
fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); }
|
||||
fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
||||
fn vn(p: vec2f) -> f32 {
|
||||
var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(h21(i), h21(i + vec2f(1, 0)), f.x), mix(h21(i + vec2f(0, 1)), h21(i + vec2f(1, 1)), f.x), f.y);
|
||||
}
|
||||
fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
|
||||
|
||||
fn lin(t: texture_2d<f32>, q: vec2f) -> vec4f { return sampleLevel(t, q / (u.view.zw), 0.0); }
|
||||
|
||||
fn bsp(t: texture_2d<f32>, q: vec2f) -> vec4f {
|
||||
var tc: vec2f = q - 0.5; var i: vec2f = floor(tc); var f: vec2f = tc - i; var f2: vec2f = f * f; var f3: vec2f = f2 * f;
|
||||
var w0: vec2f = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0; var w1: vec2f = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
|
||||
var w2: vec2f = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0; var w3: vec2f = f3 / 6.0;
|
||||
var g0: vec2f = w0 + w1; var g1: vec2f = w2 + w3;
|
||||
var h0: vec2f = (i - 0.5 + w1 / g0) / (u.view.zw); var h1: vec2f = (i + 1.5 + w3 / g1) / (u.view.zw);
|
||||
return g0.y * (g0.x * sampleLevel(t, h0, 0.0) + g1.x * sampleLevel(t, vec2f(h1.x, h0.y), 0.0))
|
||||
+ g1.y * (g0.x * sampleLevel(t, vec2f(h0.x, h1.y), 0.0) + g1.x * sampleLevel(t, h1, 0.0));
|
||||
}
|
||||
fn cellAt(t: texture_2d<f32>, c: vec2i) -> vec4f { return textureLoad(t, clamp(c, vec2i(0), vec2i((u.view.zw)) - 1), 0); }
|
||||
fn cls(c: vec4f) -> i32 { return i32(c.a * 255.0 + 0.5); }
|
||||
fn isT(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ); }
|
||||
fn isM(n: i32) -> f32 { return select( isT(n), 1.0 ,n == 2 ); }
|
||||
fn isF(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 13 || n == 14) ); }
|
||||
fn isBelt(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 7 || n == 8) ); }
|
||||
fn isN(n: i32, k: i32) -> f32 { return select( 0.0, 1.0 ,n == k ); }
|
||||
|
||||
fn beltSD(lb: vec2f, endL: bool, endR: bool) -> f32 {
|
||||
var x0: f32 = select( -2.0, 0.15 , endL ); var x1: f32 = select( 6.0, 3.85 , endR ); var r: f32 = 0.75;
|
||||
var c: vec2f = vec2f((x0 + x1) * 0.5, 2.0); var hs: vec2f = vec2f((x1 - x0) * 0.5, 1.85);
|
||||
var q: vec2f = abs(lb - c) - hs + r;
|
||||
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
|
||||
fn beltLineSD(c: vec2i, lb: vec2f) -> f32 {
|
||||
var caps: i32 = i32(cellAt(tTree, c).a * 255.0 + 0.5);
|
||||
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
|
||||
}
|
||||
|
||||
fn bandRidge(w: vec2f, lb: vec2f, dir: f32) -> f32 {
|
||||
var run: f32 = select( -1.0, 1.0 , lb.y < 2.0 );
|
||||
return step(0.72, fract((w.x - dir * run * (su.x) * 30.0) * 0.5));
|
||||
}
|
||||
fn bandColor(lb: vec2f, rg: f32) -> vec3f { return mix(vec3f(0.2, 0.21, 0.24), vec3f(0.34, 0.35, 0.39), rg) * (select( 0.72, 1.0 ,lb.y < 2.0 )); }
|
||||
|
||||
${GPU_SMOOTH_SLOPE_HELPERS}
|
||||
@fragment fn fs(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
||||
let uView=vec4f(u.extra.xy-u.view.xy,u.camera.z,u.screen.y);
|
||||
let uLightMap=vec4f(u.light.xy*u.light.z-u.view.xy,u.light.z,u.light.w);
|
||||
var zp: f32 = uView.z; var px: f32 = 1.0 / zp;
|
||||
var sp: vec2f = vec2f(position.x, position.y);
|
||||
var q: vec2f = sp * px + uView.xy;
|
||||
var w: vec2f = q + (u.view.xy);
|
||||
var cam: vec2f = (u.view.xy) + uView.xy;
|
||||
var scr: vec2f = q - uView.xy;
|
||||
var shc: f32 = uView.w * px;
|
||||
var c: vec2i = vec2i(floor(q));
|
||||
var qq: vec2f = q - 0.5; var c0: vec2i = vec2i(floor(qq)); var t: vec2f = qq - floor(qq);
|
||||
var k0: vec4f = cellAt(tCol, c0); var k1: vec4f = cellAt(tCol, c0 + vec2i(1, 0)); var k2: vec4f = cellAt(tCol, c0 + vec2i(0, 1)); var k3: vec4f = cellAt(tCol, c0 + vec2i(1, 1));
|
||||
var n0: i32 = cls(k0); var n1: i32 = cls(k1); var n2: i32 = cls(k2); var n3: i32 = cls(k3);
|
||||
var own: vec4f = cellAt(tCol, c); var nc: i32 = cls(own);
|
||||
var s: vec2f = smoothstep(vec2f(0.1), vec2f(0.9), t);
|
||||
var wq: vec4f = vec4f((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
|
||||
|
||||
// Fields and screen derivatives before branching.
|
||||
var Fb: vec4f = bsp(tField, q);
|
||||
var F: vec4f = mix(lin(tField, q), Fb, 0.62);
|
||||
|
||||
var wl: vec4f = vec4f((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
|
||||
var pw: f32 = dot(wl, vec4f(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
|
||||
var M: f32 = clamp(F.r + pw, 0.0, 1.0);
|
||||
var lf: f32 = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
|
||||
var fwT: f32 = max(fwidth(F.r), 1e-4); var fwM: f32 = max(fwidth(M), 1e-4); var fwA: f32 = max(fwidth(F.a), 1e-4); var fwL: f32 = max(fwidth(lf), 1e-4);
|
||||
var gT: vec2f = vec2f(dpdx(mix(Fb.r, M, 0.5)), -dpdy(mix(Fb.r, M, 0.5))); var gL: vec2f = vec2f(dpdx(F.g), -dpdy(F.g));
|
||||
|
||||
// Sky, clouds, parallax hills and back-wall AO.
|
||||
var back: vec4f = lin(tBack, q);
|
||||
var C: vec3f = back.rgb;
|
||||
var sky: f32 = back.a;
|
||||
if (sky > 0.01) {
|
||||
var S: vec3f = C;
|
||||
var P0: vec2f = scr + cam * 0.08;
|
||||
|
||||
var cl: f32 = smoothstep(0.56 - 0.3 * (u.style.y), 0.78 - 0.2 * (u.style.y), fbm(vec2f(P0.x * 0.016 + (su.x) * 0.006, P0.y * 0.05)));
|
||||
var hi: f32 = clamp(((su.y) - 40.0 - w.y) / 50.0, 0.0, 1.0);
|
||||
S = mix(S, mix(vec3f(0.96, 0.95, 0.93), vec3f(0.5, 0.52, 0.56), (u.style.y)), cl * (0.6 + 0.3 * (u.style.y)) * hi);
|
||||
var k1: f32 = 0.16; var P1: vec2f = scr + cam * k1;
|
||||
var base1: f32 = shc * 0.5 + 8.0 + ((su.y) - shc * 0.5) * k1;
|
||||
var top1: f32 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2f(P1.x * 0.009, 1.7)));
|
||||
var m1: f32 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
|
||||
var far: vec3f = mix(vec3f(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
|
||||
S = mix(S, far, m1);
|
||||
var k2: f32 = 0.38; var P2: vec2f = scr + cam * k2;
|
||||
var base2: f32 = shc * 0.5 + 14.0 + ((su.y) - shc * 0.5) * k2;
|
||||
var top2: f32 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2f(P2.x * 0.016, 7.3)));
|
||||
var m2: f32 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
|
||||
var near: vec3f = vec3f(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
|
||||
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
|
||||
S = mix(S, near, m2);
|
||||
C = mix(C, S, sky);
|
||||
}
|
||||
|
||||
var notSky: f32 = 1.0 - sky;
|
||||
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
|
||||
var near: f32 = sampleLevel(tField, q / (u.view.zw), 2.2).r;
|
||||
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
|
||||
|
||||
// Trees behind terrain and built pieces.
|
||||
if (F.a > 0.2) {
|
||||
var a0: vec4f = cellAt(tTree, c0); var a1: vec4f = cellAt(tTree, c0 + vec2i(1, 0)); var a2: vec4f = cellAt(tTree, c0 + vec2i(0, 1)); var a3: vec4f = cellAt(tTree, c0 + vec2i(1, 1));
|
||||
var m: vec4f = vec4f(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
if (sm > 1e-4) {
|
||||
var tc: vec3f = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
|
||||
var leaf: f32 = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
|
||||
var lv: f32 = smoothstep(0.6, 0.9, leaf);
|
||||
tc *= mix(0.86 + 0.22 * vn(vec2f(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
|
||||
var d: f32 = (F.a - 0.36) / fwA;
|
||||
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
|
||||
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
|
||||
}
|
||||
}
|
||||
|
||||
// Smoke, steam and flame.
|
||||
if (Fb.b > 0.02) {
|
||||
var m: vec4f = vec4f(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var gc: vec3f = select( own.rgb, (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm , sm > 1e-4 );
|
||||
if (sm > 1e-4 || nc == 5) { C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); }
|
||||
}
|
||||
|
||||
// Liquids with a glossy top edge.
|
||||
var anyL: f32 = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
|
||||
if (anyL > 0.0 && lf > 0.3) {
|
||||
var m: vec4f = vec4f(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var lc: vec3f = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
|
||||
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2f((su.x) * 0.7, (su.x) * 0.25));
|
||||
var gl: f32 = length(gL);
|
||||
var topSide: f32 = select( 0.0, clamp(gL.y / gl, 0.0, 1.0) , gl > 1e-5 );
|
||||
var band: f32 = 1.0 - smoothstep(0.5, 0.75, F.g);
|
||||
lc += vec3f(0.16, 0.18, 0.2) * topSide * band;
|
||||
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
|
||||
}
|
||||
|
||||
// Rounded ground and the seam against loose grains.
|
||||
if (F.r > 0.3) {
|
||||
var m: vec4f = vec4f(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
|
||||
var sm: f32 = m.x + m.y + m.z + m.w;
|
||||
var blur: vec3f = bsp(tCol, q).rgb; var tc: vec3f = own.rgb;
|
||||
var bd: f32 = 1e9;
|
||||
if (m.x > 0.0) { var e: vec3f = k0.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
|
||||
if (m.y > 0.0) { var e: vec3f = k1.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
|
||||
if (m.z > 0.0) { var e: vec3f = k2.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
|
||||
if (m.w > 0.0) { var e: vec3f = k3.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
|
||||
if (sm < 1e-4) { tc = own.rgb; }
|
||||
tc *= 0.84 + 0.3 * fbm(w * 0.28);
|
||||
tc *= 0.95 + 0.1 * vn(w * 1.6);
|
||||
var gl: f32 = length(gT);
|
||||
var nrm: vec2f = select( vec2f(0.0), -gT / gl , gl > 1e-6 );
|
||||
var band: f32 = 1.0 - smoothstep(0.5, 0.9, M);
|
||||
var lit: f32 = dot(nrm, normalize(vec2f(-0.5, -1.0)));
|
||||
tc *= 1.0 + 0.42 * lit * band;
|
||||
|
||||
var dm: f32 = (M - 0.5) / fwM;
|
||||
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
|
||||
var dt: f32 = (F.r - 0.5) / fwT;
|
||||
if (nc == 1 && pw > 0.0) { tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); }
|
||||
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
|
||||
}
|
||||
|
||||
${GPU_SMOOTH_SLOPE_DRAW}
|
||||
// Round loose grains.
|
||||
var np: i32 = i32(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
|
||||
var closed: bool = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
|
||||
if (np > 0) {
|
||||
var best: f32 = 1e9; var gc: vec3f = vec3f(0.0); var gd: vec2f = vec2f(0.0); var gr: f32 = 0.6;
|
||||
for (var k: i32 = 0; k < 4; k++) {
|
||||
var o4: vec2i = vec2i(k & 1, k >> 1);
|
||||
var nk: i32 = select(select(select( n3, n2 , k == 2 ), n1 , k == 1 ), n0 , k == 0 );
|
||||
if (nk != 2) { continue; }
|
||||
var ck: vec4f = select(select(select( k3, k2 , k == 2 ), k1 , k == 1 ), k0 , k == 0 );
|
||||
var ce: vec2f = vec2f(c0 + o4) + 0.5;
|
||||
var r: f32 = 0.6 + 0.1 * (h21(ce + (u.view.xy)) - 0.5);
|
||||
var dv: vec2f = (q - ce) / r;
|
||||
var dd: f32 = length(dv);
|
||||
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
|
||||
}
|
||||
var cover: f32 = select( 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best), 1.0 , np == 4 );
|
||||
if (closed && F.r < 0.5) { cover = 1.0; }
|
||||
|
||||
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
|
||||
if (cover > 0.0) {
|
||||
var r2: f32 = min(1.0, dot(gd, gd));
|
||||
var n3: vec3f = normalize(vec3f(gd * 0.85, sqrt(1.0 - r2) + 0.25));
|
||||
var sh: f32 = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3f(-0.45, -0.8, 0.6))));
|
||||
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
|
||||
var pc: vec3f = (select( gc, (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) ,np == 4 )) * sh;
|
||||
if (!closed) { pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); }
|
||||
C = mix(C, pc, cover);
|
||||
}
|
||||
}
|
||||
|
||||
// Built pieces: belts, screws, wall tiles and filters.
|
||||
if (nc == 4) { C = own.rgb; }
|
||||
else if (nc == 7 || nc == 8) {
|
||||
|
||||
var dir: f32 = select( -1.0, 1.0 , nc == 8 );
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0;
|
||||
|
||||
var sd: f32 = beltLineSD(c, lb);
|
||||
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
var edge: f32 = -sd;
|
||||
var bc: vec3f;
|
||||
if (edge < 0.8) {
|
||||
bc = bandColor(lb, bandRidge(w, lb, dir));
|
||||
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
var rc: vec2f = vec2f(select( 3.0, 1.0 ,lb.x < 2.0 ), 2.0); var dv: vec2f = lb - rc;
|
||||
var rd: f32 = length(dv);
|
||||
bc = vec3f(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
var ang: f32 = atan2(dv.y, dv.x) - dir * (su.x) * 30.0 / 0.95;
|
||||
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
bc = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, bc, inside);
|
||||
}
|
||||
}
|
||||
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
|
||||
|
||||
var vert: bool = nc == 9 || nc == 11;
|
||||
var sp2: f32 = select(select(select( -1.0, 1.0 , nc == 11 ), 1.0 , nc == 10 ), -1.0 , nc == 9 );
|
||||
var a: f32 = (select( fract(w.y * 0.25), fract(w.x * 0.25) ,vert )) * 4.0; var along: f32 = select( w.x, w.y , vert );
|
||||
var screwSD: f32 = -1.0; var capRing: f32 = 0.0;
|
||||
var screwTag: vec4f = cellAt(tTree, c);
|
||||
var screwJoins: i32 = i32(screwTag.a * 255.0 + 0.5);
|
||||
|
||||
var shaped: bool = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
|
||||
if (shaped) {
|
||||
var d: vec2f = vec2f(1.0,select( 1.0, -1.0 , nc == 19 || nc == 21 ));
|
||||
var tag: vec4f = screwTag;
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
|
||||
var joins: i32 = screwJoins;
|
||||
screwSD = 100.0;
|
||||
for (var end: i32 = 0; end < 2; end++) {
|
||||
var join: i32 = (joins >> u32(end * 3)) & 7;
|
||||
var side: f32 = select( 1.0, -1.0 , end == 0 );
|
||||
var diagonal: bool = select( join != 0, join == 0 || join >= 5 , nc >= 19 );
|
||||
var grade: f32 = select(select( 1.0, -1.0 , join == 1 ), d.y , nc >= 19 );
|
||||
var ray: vec2f = select(select(select(select(select( vec2f(-1, 0), vec2f(0, 1) , join == 3 ), vec2f(1, 0) , join == 2 ), vec2f(0, -1) ,
|
||||
join == 1 ), (select( vec2f(side, 0), vec2f(0, side) ,vert )) ,
|
||||
nc < 19 ), (select( vec2f(side, side * grade), vec2f(side * grade, side) ,vert )) * 0.70710678 , diagonal );
|
||||
var t: f32 = dot(local, ray); var cross2: f32 = dot(local, vec2f(-ray.y, ray.x));
|
||||
var radius: f32 = length(vec2f(cross2, min(0.0, t))); var cap: f32 = t - (select( 2.0, 2.82842712 ,diagonal ));
|
||||
|
||||
var sd: f32 = max(radius - 2.0,select( -10.0, cap , (joins & (64 << u32(end))) != 0 ));
|
||||
if (sd < screwSD) {
|
||||
screwSD = sd;
|
||||
a = 2.0 + (select( radius, -radius ,side * cross2 < 0.0 ));
|
||||
along = (select(select( w.x - local.x, w.y - local.y , vert ), dot(w - local, d) * 0.70710678 ,nc >= 19 )) + side * t;
|
||||
capRing = select( 0.0, smoothstep(-0.55, -0.15, cap) ,(joins & (64 << u32(end))) != 0 );
|
||||
}
|
||||
}
|
||||
if (nc >= 19) { sp2 = select( -1.0, 1.0 ,nc == 19 || nc == 20 ); }
|
||||
}
|
||||
var wall: f32 = 0.62;
|
||||
var tc: vec3f;
|
||||
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
|
||||
var u2: f32 = select( (4.0 - a) / wall, a / wall , a < wall );
|
||||
var cyl: f32 = sin(clamp(u2, 0.0, 1.0) * 1.5708);
|
||||
tc = vec3f(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
|
||||
if (a < wall) { tc *= 1.08; }
|
||||
var ring: f32 = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
|
||||
if (nc < 19) { tc *= 1.0 - 0.3 * ring; }
|
||||
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
|
||||
} else {
|
||||
var full: bool = own.r + own.g + own.b > 0.0;
|
||||
var fl: f32 = fract((along + a * 0.9 - sp2 * (su.x) * 14.0) * 0.5);
|
||||
var flight: f32 = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
|
||||
var bore: vec3f = vec3f(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
|
||||
tc = select( bore, own.rgb * (0.9 + 0.1 * vn(w * 1.3)) ,full );
|
||||
tc = mix(tc, vec3f(0.6, 0.48, 0.38), flight * (select( 0.8, 0.35 ,full )));
|
||||
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
|
||||
}
|
||||
if (shaped) {
|
||||
var full: bool = own.r + own.g + own.b > 0.0;
|
||||
if (full) { C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); }
|
||||
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
|
||||
} else { C = tc; }
|
||||
}
|
||||
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0; var rb: vec2f = 4.0 - lb;
|
||||
var e: f32 = min(min(lb.x, lb.y), min(rb.x, rb.y));
|
||||
var tc: vec3f = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
|
||||
var hl: f32 = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
|
||||
var shd: f32 = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
|
||||
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
|
||||
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
|
||||
if (nc >= 15) {
|
||||
var sl: vec2f = lb;
|
||||
if (nc == 16 || nc == 18) { sl.x = 4.0 - sl.x; }
|
||||
if (nc >= 17) { sl.y = 4.0 - sl.y; }
|
||||
var d: f32 = (sl.y - sl.x) * 0.70710678;
|
||||
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
|
||||
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
|
||||
} else { C = tc; }
|
||||
}
|
||||
|
||||
else if (nc == 13 || nc == 14) {
|
||||
|
||||
var dir: f32 = select( -1.0, 1.0 , nc == 14 );
|
||||
var lb: vec2f = fract(w * 0.25) * 4.0;
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var carry: bool = tag.a > 0.16; var tinted: bool = tag.r + tag.g + tag.b > 0.0;
|
||||
var sd: f32 = beltLineSD(c, lb);
|
||||
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
|
||||
if (inside > 0.0) {
|
||||
var edge: f32 = -sd;
|
||||
var steel: vec3f = vec3f(0.42, 0.44, 0.5);
|
||||
|
||||
var behind: vec3f = select( C * 0.8, own.rgb , own.r + own.g + own.b > 0.0 );
|
||||
var cg: vec2f = abs(fract(w) - 0.5);
|
||||
var bw2: f32 = max(px * 0.9, 0.09);
|
||||
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
var fc: vec3f;
|
||||
if (edge < 0.8) {
|
||||
var rg: f32 = bandRidge(w, lb, dir);
|
||||
var band: vec3f = bandColor(lb, rg);
|
||||
if (tinted && (carry == (lb.y < 2.0))) { band = mix(band, tag.rgb * (select( 0.8, 1.0 ,lb.y < 2.0 )), 0.75 * rg); }
|
||||
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
|
||||
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
||||
} else {
|
||||
fc = mix(steel * 0.78, behind, hole);
|
||||
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
|
||||
}
|
||||
C = mix(C, fc, inside);
|
||||
}
|
||||
}
|
||||
|
||||
// Light, then the legacy nine-tap glow filter.
|
||||
if (uLightMap.w > 0.5) { C *= sampleLevel(tLight, (q - uLightMap.xy) / uLightMap.z / (vec2f(textureDimensions(tLight))), 0.0).rgb; }
|
||||
if ((su.z) > 0.0) {
|
||||
var gu: vec2f = q / (2.0 * (vec2f(textureDimensions(tGlow)))); var d: vec2f = 1.0 / (vec2f(textureDimensions(tGlow)));
|
||||
var g: vec3f = sampleLevel(tGlow, gu, 0.0).rgb * 0.25;
|
||||
g += (sampleLevel(tGlow, gu + vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(0, d.y), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(0, d.y), 0.0).rgb) * 0.125;
|
||||
g += (sampleLevel(tGlow, gu + d, 0.0).rgb + sampleLevel(tGlow, gu - d, 0.0).rgb + sampleLevel(tGlow, gu + vec2f(d.x, -d.y), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(-d.x, d.y), 0.0).rgb) * 0.0625;
|
||||
C += g * (su.z);
|
||||
}
|
||||
return vec4f(C, 1.0);
|
||||
}`;
|
||||
@@ -0,0 +1,107 @@
|
||||
// Smooth slope geometry and cleats, ported from slope-belt-shader.js.
|
||||
const GPU_SMOOTH_SLOPE_HELPERS = `fn slopeStroke(p: vec2f, a: vec2f, b: vec2f, origin: f32) -> vec3f {
|
||||
var ray: vec2f = b - a; var len: f32 = length(ray); ray /= len;
|
||||
var along: f32 = dot(p - a, ray); var across: f32 = dot(p - a, vec2f(-ray.y, ray.x));
|
||||
var q: vec2f = abs(vec2f(along - len * 0.5, across)) - vec2f(len * 0.5 - 0.75, 1.1);
|
||||
var sd: f32 = length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - 0.75;
|
||||
return vec3f(sd, origin + along, across + 2.0);
|
||||
}
|
||||
fn slopeCorner(p: vec2f, a: vec2f, origin: f32) -> vec3f {
|
||||
var d: vec2f = p - a; var radius: f32 = length(d);
|
||||
return vec3f(radius - 1.85, origin + d.x, 2.0 + d.y);
|
||||
}
|
||||
fn slopeShape(inputP: vec2f, worldOrigin: vec2f, grade: f32, joins: i32) -> vec3f { var p=inputP;
|
||||
if ((joins & 64) != 0) { p.y -= 0.45; }
|
||||
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
|
||||
var a: vec2f = vec2f(-grade * 1.30814755, sy + 1.30814755); var b: vec2f = vec2f(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
|
||||
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
|
||||
else if ((joins & 4) != 0) { a -= vec2f(4.0, grade * 4.0); }
|
||||
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
|
||||
else if ((joins & 8) != 0) { b += vec2f(4.0, grade * 4.0); }
|
||||
var axis: vec2f = vec2f(1.0, grade) * 0.70710678;
|
||||
var best: vec3f = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
|
||||
if ((joins & 1) != 0) {
|
||||
var h: vec3f = slopeStroke(p, vec2f(-2.0, a.y), a, worldOrigin.x - 2.0);
|
||||
if (h.x < best.x) { best = h; }
|
||||
h = slopeCorner(p, a, worldOrigin.x + a.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
}
|
||||
if ((joins & 2) != 0) {
|
||||
var h: vec3f = slopeStroke(p, b, vec2f(6, b.y), worldOrigin.x + b.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
h = slopeCorner(p, b, worldOrigin.x + b.x);
|
||||
if (h.x < best.x) { best = h; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
`;
|
||||
const GPU_SMOOTH_SLOPE_DRAW = ` if (nc == 31 || nc == 32) {
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var grade: f32 = select( 1.0, -1.0 , nc == 31 );
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
||||
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
|
||||
var foot: f32 = (local.x + grade * (local.y - sy)) * 0.5;
|
||||
var depth: f32 = (grade * local.x - local.y + sy) * 0.70710678;
|
||||
var wallFlags: i32 = i32(tag.a * 255.0 + 0.5);
|
||||
var ends: f32 = max(select( -100.0, -foot ,(wallFlags & 8) == 0 ),select( -100.0, foot - 4.0 , (wallFlags & 16) == 0 ));
|
||||
var sd: f32 = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
|
||||
var phase: f32 = f32(wallFlags & 7);
|
||||
var wave: f32 = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
|
||||
var rubber: vec3f = mix(vec3f(0.20,0.22,0.25), vec3f(0.25,0.27,0.31), wave);
|
||||
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
|
||||
|
||||
var dirS: f32 = select( -1.0, 1.0 , (wallFlags & 32) != 0 );
|
||||
if (grade * dirS < 0.0) {
|
||||
var rc: f32 = (w.x - local.x) + foot - (select( 0.0, 1.0 ,grade < 0.0 )) - dirS * phase;
|
||||
var off: f32 = (modulo(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
|
||||
var ribTop: f32 = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
|
||||
var rd: f32 = length(vec2f(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
|
||||
var rib: vec3f = mix(vec3f(0.50,0.52,0.56), vec3f(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
|
||||
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
|
||||
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
|
||||
}
|
||||
}
|
||||
|
||||
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
|
||||
(i32(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
|
||||
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
|
||||
var slopeClass: i32 = i32(cellAt(tTree, c).r * 255.0 + 0.5);
|
||||
var shape: i32 = (slopeClass - 23) / 2;
|
||||
var mesh: bool = shape >= 2;
|
||||
var grade: f32 = select( 1.0, -1.0 , (shape == 0 || shape == 2) );
|
||||
var dir: f32 = select( -1.0, 1.0 , ((slopeClass - 23) % 2) == 1 );
|
||||
var tag: vec4f = cellAt(tTree, c);
|
||||
var joins: i32 = i32(tag.a * 255.0 + 0.5);
|
||||
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
||||
var shapePos: vec3f = slopeShape(local, w - local, grade, joins);
|
||||
var sd: f32 = shapePos.x; var axis: f32 = shapePos.y; var depth: f32 = shapePos.z;
|
||||
var time: f32 = select( 0.0, (su.x) , !mesh && (joins & 16) != 0 );
|
||||
var lb: vec2f = vec2f(axis, depth); var alongWorld: vec2f = vec2f(axis, depth);
|
||||
var rg: f32 = step(0.72, fract((axis - dir * (select( -1.0, 1.0 ,depth < 2.0 )) * time * 30.0) * 0.5));
|
||||
var belt: vec3f;
|
||||
if (-sd < select(0.8,1.1,mesh)) {
|
||||
belt = select( bandColor(lb, rg), mix(vec3f(0.22,0.25,0.29), vec3f(0.48,0.53,0.59),select( 0.35, 0.85 , depth < 2.0 )) ,mesh );
|
||||
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
|
||||
} else if (mesh) {
|
||||
var cg: vec2f = abs(fract(alongWorld) - 0.5);
|
||||
var bw2: f32 = max(px * 0.9, 0.09);
|
||||
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
||||
var steel: vec3f = select( vec3f(0.42, 0.44, 0.5), mix(vec3f(0.42, 0.44, 0.5), own.rgb, 0.4) , dot(own.rgb, vec3f(1)) > 0.01 );
|
||||
belt = mix(steel * 0.78, C * 0.8, hole);
|
||||
} else {
|
||||
var dv: vec2f = vec2f(modulo(axis, 2.0) - 1.0, depth - 2.0);
|
||||
var rd: f32 = length(dv);
|
||||
belt = vec3f(0.15, 0.16, 0.18);
|
||||
if (rd < 0.95) {
|
||||
var ang: f32 = atan2(dv.y, dv.x) - dir * time * 30.0 / 0.95;
|
||||
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
||||
belt = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
||||
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
||||
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
||||
}
|
||||
}
|
||||
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
|
||||
}
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
// Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
||||
const GPU_FIELD_MIP = `
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var linear: sampler;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
let size=max(vec2u(1),textureDimensions(source)/2u);
|
||||
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
|
||||
}`;
|
||||
async function gpuSmoothInit() {
|
||||
const d=GPU.device;
|
||||
const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
|
||||
GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
|
||||
GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
|
||||
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||||
}
|
||||
function gpuSmoothTextures() {
|
||||
const w=R.bw,h=R.bh;
|
||||
GPU.smoothOver=Array.from({length:3},()=>gpuTexture(w,h));
|
||||
GPU.smoothMask=gpuTexture(w,h,'r8uint');
|
||||
GPU.backTex=gpuTexture(w,h,'rgba8unorm',true);
|
||||
GPU.treeTex=gpuTexture(w,h,'rgba8unorm',true);
|
||||
GPU.fieldTex=GPU.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:Math.min(4,1+Math.floor(Math.log2(Math.max(w,h)))),usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.RENDER_ATTACHMENT});
|
||||
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
|
||||
}
|
||||
function gpuSmoothBindings(group,view,buffer) {
|
||||
const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)];
|
||||
GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)});
|
||||
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
|
||||
GPU.fieldMips=[];
|
||||
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
|
||||
bind:group(GPU.fieldMip,[GPU.fieldTex.createView({baseMipLevel:i-1,mipLevelCount:1}),GPU.sampler]),
|
||||
view:GPU.fieldTex.createView({baseMipLevel:i,mipLevelCount:1})
|
||||
});
|
||||
}
|
||||
function gpuSmoothUpload() {
|
||||
for(const [i,a] of [R.backPx,R.treePx,R.fieldPx].entries()) gpuWrite(GPU.smoothOver[i],a,R.bw,0,0,R.bw,R.bh);
|
||||
gpuWrite(GPU.smoothMask,GPU.overrideMask,R.bw,0,0,R.bw,R.bh);
|
||||
GPU.device.queue.writeBuffer(GPU.smoothUniform,0,new Float32Array([(performance.now()-SM.t0)/1000,smoothSurface(),SET.glow==='off'?0:SET.glow==='high'?0.9:0.55,0]));
|
||||
}
|
||||
function gpuSmoothMips(encoder) {
|
||||
for(const m of GPU.fieldMips) {
|
||||
const pass=encoder.beginRenderPass({colorAttachments:[{view:m.view,loadOp:'clear',storeOp:'store'}]});
|
||||
pass.setPipeline(GPU.fieldMip);pass.setBindGroup(0,m.bind);pass.draw(3);pass.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const SM = { t0: performance.now(), surf: -1 };
|
||||
function smoothSurface() {
|
||||
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
|
||||
return SM.surf;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Whole-world textures retain clean chunks across camera moves.
|
||||
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
|
||||
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) });
|
||||
}
|
||||
function gpuWrite(texture, data, stride, x, y, w, h) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const bytes = data.BYTES_PER_ELEMENT;
|
||||
GPU.device.queue.writeTexture({ texture, origin: [x, y] }, data, { offset: (y * stride + x) * bytes, bytesPerRow: stride * bytes }, [w, h]);
|
||||
}
|
||||
function gpuBuffer(data) {
|
||||
const b = GPU.device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||||
GPU.device.queue.writeBuffer(b, 0, data); return b;
|
||||
}
|
||||
function gpuWorldTextures() {
|
||||
if (GPU.world === mat && GPU.sky === SKY_R) return;
|
||||
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
|
||||
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
|
||||
GPU.world = mat; GPU.sky = SKY_R;
|
||||
GPU.resident = new Uint8Array(NCH);
|
||||
GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint'));
|
||||
GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp);
|
||||
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
|
||||
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 4);
|
||||
for (let m = 0; m < NMAT; m++) {
|
||||
tables.set([COLR[m], COLG[m], COLB[m], CVAR[m], PH[m], ALPHA[m], 0, 0, TREE_R[m], TREE_G[m], TREE_B[m], 0], m * 12);
|
||||
}
|
||||
for (let i = 0; i < 700; i++) tables.set([GLOW_R[i], GLOW_G[i], GLOW_B[i], GLOW_W[i]], (NMAT * 3 + i) * 4);
|
||||
for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4);
|
||||
tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4);
|
||||
GPU.lut = gpuBuffer(tables);
|
||||
GPU.landData = new Float32Array((W + H) * 4);
|
||||
GPU.land = gpuBuffer(GPU.landData);
|
||||
if (GPU.columns) GPU.columns.destroy();
|
||||
GPU.columnData = new Float32Array(W * 4); GPU.columns = gpuBuffer(GPU.columnData);
|
||||
GPU.mach = gpuTexture(W, H, 'r16uint'); GPU.block = gpuTexture(BW, BH, 'r8uint');
|
||||
GPU.worldTextures.push(GPU.mach, GPU.block);
|
||||
GPU.bindColour = null;
|
||||
}
|
||||
function gpuViewTextures() {
|
||||
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`;
|
||||
if (GPU.viewKey === key) return;
|
||||
GPU.viewKey = key; GPU.lightFrame = undefined;
|
||||
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
|
||||
const bw = R.bw, bh = R.bh, gw = R.glowW, gh = R.glowH;
|
||||
GPU.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true);
|
||||
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
|
||||
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
|
||||
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
|
||||
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
|
||||
GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
|
||||
if (R.smoothOn) gpuSmoothTextures();
|
||||
GPU.bindColour = null;
|
||||
}
|
||||
function gpuBindings() {
|
||||
if (GPU.bindColour) return;
|
||||
const d = GPU.device, buffer = b => ({ buffer: b }), view = t => t.createView();
|
||||
const group = (pipeline, resources) => d.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: resources.map((resource, binding) => ({ binding, resource })) });
|
||||
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
|
||||
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
|
||||
gpuLightBindings(group, view, buffer);
|
||||
GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]);
|
||||
GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]);
|
||||
GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]);
|
||||
if (GPU.blurUniforms) for (const b of GPU.blurUniforms) b.destroy();
|
||||
GPU.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }));
|
||||
GPU.bindBlur = [group(GPU.blur, [view(GPU.glowTex), view(GPU.blurX), buffer(GPU.blurUniforms[0])]), group(GPU.blur, [view(GPU.blurX), view(GPU.blurY), buffer(GPU.blurUniforms[1])])];
|
||||
}
|
||||
function gpuUpload(lighting, glowOn) {
|
||||
gpuWorldTextures(); gpuViewTextures(); gpuBindings();
|
||||
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
|
||||
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
|
||||
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - y;
|
||||
let top = y;
|
||||
if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]);
|
||||
gpuUploadChunks(x, top, x + w, y + h);
|
||||
// Small per-row/column tables may change in scene tools independently of world allocation.
|
||||
const land = GPU.landData;
|
||||
for (let i = 0; i < W; i++) { const k = i * 4; land[k] = surfY[i]; land[k + 1] = dirtY[i]; land[k + 2] = graniteY[i]; land[k + 3] = basaltY[i]; }
|
||||
for (let i = 0; i < H; i++) { const k = (W + i) * 4; land[k] = SKY_R[i]; land[k + 1] = SKY_G[i]; land[k + 2] = SKY_B[i]; land[k + 3] = AMB[i]; }
|
||||
const q = GPU.device.queue;
|
||||
q.writeBuffer(GPU.land, 0, land);
|
||||
gpuWrite(GPU.override, R.px, R.bw, 0, 0, R.bw, R.bh);
|
||||
gpuWrite(GPU.overrideGlow, R.glowPx, R.glowW, 0, 0, R.glowW, R.glowH);
|
||||
for(let i=0;i<W;i++) { GPU.columnData[i*4]=surfY[i]; GPU.columnData[i*4+1]=roofY[i]; }
|
||||
q.writeBuffer(GPU.columns,0,GPU.columnData);
|
||||
if (R.smoothOn) gpuSmoothUpload();
|
||||
const zp = R.zp, ox = Math.round(-(R.camX - R.vx0) * zp), oy = Math.round(-(R.camY - R.vy0) * zp);
|
||||
GPU.params.set([R.vx0, R.vy0, R.bw, R.bh, W, H, FW, FH,
|
||||
+SET.fog, R.rain, SET.grain, R.frame, +R.lens, Math.max(0, Object.keys(THERM).indexOf(SET.thermalPalette)), +SET.incandescence, +glowOn,
|
||||
ox, oy, zp, 0, R.lx0, R.ly0, R.lres, +lighting,
|
||||
R.canvas.width, R.canvas.height, SET.glow === 'high' ? 0.9 : 0.5, 0, R.camX, R.camY, 0, 0]);
|
||||
q.writeBuffer(GPU.uniform, 0, GPU.params);
|
||||
const sigma = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)) / (2 * zp);
|
||||
q.writeBuffer(GPU.blurUniforms[0], 0, new Float32Array([1, 0, sigma, 0]));
|
||||
q.writeBuffer(GPU.blurUniforms[1], 0, new Float32Array([0, 1, sigma, 0]));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||
function gpuUploadChunks(x0, y0, x1, y1) {
|
||||
const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5);
|
||||
const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32));
|
||||
GPU.uploadStrips = 0; GPU.uploadCells = 0;
|
||||
for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) {
|
||||
const c = cy * CW + cx;
|
||||
if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; }
|
||||
const first = cx++;
|
||||
while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++;
|
||||
const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y);
|
||||
gpuUploadStrip(x, y, w, h);
|
||||
GPU.uploadStrips++; GPU.uploadCells += w * h;
|
||||
for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; }
|
||||
}
|
||||
}
|
||||
function gpuUploadStrip(x, y, w, h) {
|
||||
for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h);
|
||||
gpuWrite(GPU.temp, temp, W, x, y, w, h);
|
||||
gpuWrite(GPU.mach, mach, W, x, y, w, h);
|
||||
gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS));
|
||||
// Fog interpolation reads the next sample, possibly in a neighbouring chunk.
|
||||
gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1));
|
||||
}
|
||||
// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them.
|
||||
async function gpuCheckUploads() {
|
||||
const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]),
|
||||
[GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]];
|
||||
let checked = 0;
|
||||
for (const [texture, data, stride, scale] of sources) {
|
||||
const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256;
|
||||
const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||
const encoder = GPU.device.createCommandEncoder();
|
||||
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]);
|
||||
GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ);
|
||||
const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength);
|
||||
let mismatch = '';
|
||||
for (let cy=0;cy<CH;cy++) for(let cx=0;cx<CW;cx++) {
|
||||
const c=cy*CW+cx; if(!GPU.resident[c] || renderDirty[c]) continue;
|
||||
for(let y=cy*32/scale;y<Math.min(texture.height,(cy+1)*32/scale);y++)
|
||||
for(let x=cx*32/scale;x<Math.min(texture.width,(cx+1)*32/scale);x++) for(let b=0;b<bytes;b++) {
|
||||
checked++; if(actual[y*row+x*bytes+b]!==expected[(y*stride+x)*bytes+b] && !mismatch) mismatch=`${texture.format} at ${x},${y}`;
|
||||
}
|
||||
}
|
||||
buffer.unmap(); buffer.destroy(); if(mismatch) throw Error('Stale GPU upload: '+mismatch);
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Propagated coloured light from glowing and burning cells.
|
||||
// ---------- Lighting ----------
|
||||
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
|
||||
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
|
||||
function computeLight() {
|
||||
const res = SET.lighting === 'fast' ? 4 : 2;
|
||||
const M = Math.ceil(28 / res);
|
||||
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
|
||||
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
|
||||
if (lw !== R.lw || lh !== R.lh || res !== R.lres) {
|
||||
R.lw = lw; R.lh = lh; R.lres = res;
|
||||
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
|
||||
R.lightC.width = lw; R.lightC.height = lh;
|
||||
R.lightImg = R.lightX.createImageData(lw, lh);
|
||||
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
|
||||
}
|
||||
R.lx0 = lx0; R.ly0 = ly0;
|
||||
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
|
||||
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
|
||||
const P = res;
|
||||
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
|
||||
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
|
||||
for (let ly = 0; ly < lh; ly++) {
|
||||
const wy = (ly0 + ly) * res;
|
||||
for (let lx = 0; lx < lw; lx++) {
|
||||
const wx = (lx0 + lx) * res, k = ly * lw + lx;
|
||||
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
|
||||
const i = wy * W + wx, m = mat[i], ph = PH[m];
|
||||
la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
|
||||
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
|
||||
// emission from heat: hottest grain in the block
|
||||
let T = 0;
|
||||
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
|
||||
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
|
||||
if (T > 520) {
|
||||
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
|
||||
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
|
||||
}
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
}
|
||||
// bot lamp
|
||||
if (bot.lamp) {
|
||||
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
|
||||
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
|
||||
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
|
||||
const d = Math.hypot(lx - cx, ly - cy) / rad;
|
||||
if (d >= 1) continue;
|
||||
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
|
||||
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
|
||||
}
|
||||
}
|
||||
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
|
||||
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
|
||||
const k = ly * lw + lx, a = la[k];
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
|
||||
const k = ly * lw + lx, a = la[k];
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
||||
}
|
||||
// write the light canvas
|
||||
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
|
||||
for (let k = 0; k < n; k++) {
|
||||
let r = lr[k], g = lg[k], b = lb[k];
|
||||
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
|
||||
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
|
||||
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
|
||||
}
|
||||
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0);
|
||||
}
|
||||
+140
-36
@@ -1,4 +1,4 @@
|
||||
// Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world.
|
||||
// Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
|
||||
// ---------- Overlays ----------
|
||||
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
|
||||
@@ -22,7 +22,7 @@ function drawMachines() {
|
||||
ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp);
|
||||
ctx.fillStyle = 'rgba(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';
|
||||
renderMonoFont();
|
||||
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;
|
||||
@@ -52,35 +52,17 @@ function drawMachines() {
|
||||
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();
|
||||
}
|
||||
if (mm.hot > 760) {
|
||||
const sz = Math.max(10, zp * 2), x = sx + sw - sz * 0.6, y = sy - sz * 0.4;
|
||||
ctx.save(); ctx.fillStyle = '#b84332'; ctx.beginPath(); ctx.roundRect(x, y, sz, sz, sz * 0.2); ctx.fill();
|
||||
ctx.fillStyle = '#fff'; ctx.font = `700 ${sz * 0.85}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('!', x + sz / 2, y + sz / 2); ctx.restore();
|
||||
}
|
||||
// direction arrow
|
||||
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;
|
||||
@@ -93,11 +75,51 @@ function drawMachines() {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
drawProcBlocks();
|
||||
drawRackBlocks();
|
||||
}
|
||||
// Crushing rollers and saw blades sit over the shared steel housings in both looks.
|
||||
function drawProcBlocks() {
|
||||
const ctx = R.ctx;
|
||||
for (const b of procBlocks) {
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
const [sx, sy, sw] = cellRect(bx * BS, by * BS, BS, BS);
|
||||
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue;
|
||||
if (SET.fog && fogSeen(bx * BS + 2, by * BS + 2) < 64) continue;
|
||||
const saw = blockP[b] === BP_SAW, busy = tick - (procState.get(b)?.busyT ?? -999) < 30;
|
||||
const angle = busy ? tick * (saw ? 0.5 : 0.25) : b * 0.7;
|
||||
const radius = sw * (saw ? 0.38 : 0.22), teeth = saw ? 10 : 6;
|
||||
ctx.save();
|
||||
ctx.lineWidth = Math.max(1, sw * 0.025);
|
||||
if (blockP[b] === BP_PRESS) {
|
||||
const offset = busy ? (1 - Math.cos(tick * 0.3)) * sw * 0.12 : 0;
|
||||
ctx.fillStyle = '#caa488'; ctx.fillRect(sx + sw * 0.27, sy + sw * 0.76, sw * 0.46, sw * 0.16);
|
||||
ctx.fillStyle = '#7d8996'; ctx.fillRect(sx + sw * 0.45, sy, sw * 0.1, sw * 0.4 + offset);
|
||||
ctx.fillStyle = '#b9c0c8'; ctx.fillRect(sx + sw * 0.12, sy + sw * 0.4 + offset, sw * 0.76, sw * 0.12);
|
||||
} else for (let wheel = 0; wheel < (saw ? 1 : 2); wheel++) {
|
||||
const cx = sx + sw * (saw ? 0.5 : wheel ? 0.73 : 0.27), cy = sy + sw * 0.5;
|
||||
const a = wheel ? -angle : angle;
|
||||
ctx.fillStyle = saw ? '#b9c0c8' : '#8a8f96'; ctx.strokeStyle = '#444b54';
|
||||
if (sw >= 12) for (let k = 0; k < teeth; k++) {
|
||||
const t = a + k * Math.PI * 2 / teeth, half = (saw ? 0.18 : 0.24);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(t - half) * radius * 0.9, cy + Math.sin(t - half) * radius * 0.9);
|
||||
ctx.lineTo(cx + Math.cos(t) * radius * 1.15, cy + Math.sin(t) * radius * 1.15);
|
||||
ctx.lineTo(cx + Math.cos(t + half) * radius * 0.9, cy + Math.sin(t + half) * radius * 0.9);
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
if (saw) { ctx.beginPath(); ctx.arc(cx, cy, radius * 0.65, 0, Math.PI * 2); ctx.stroke(); }
|
||||
ctx.fillStyle = '#292e36'; ctx.beginPath(); ctx.arc(cx, cy, Math.max(1, radius * 0.19), 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
// The silo: legs and braces, a cone to a slide gate, a ribbed steel drum with a ladder and a sight
|
||||
// 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);
|
||||
if (!R.smoothOn) { drawSiloPixel(ctx, mm, sx, sy, zp); return; }
|
||||
const X = u => sx + u * zp, Y = u => sy + u * zp;
|
||||
const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; };
|
||||
ctx.save();
|
||||
// legs, with short struts to the cone
|
||||
@@ -124,22 +146,16 @@ function drawSilo(ctx, mm, sx, sy, zp) {
|
||||
ctx.beginPath(); ctx.moveTo(X(1.3), Y(2)); ctx.lineTo(X(1.3), Y(19.8)); ctx.moveTo(X(2.3), Y(2)); ctx.lineTo(X(2.3), Y(19.8));
|
||||
for (let yy = 2.6; yy < 19.8; yy += 1.1) { ctx.moveTo(X(1.3), Y(yy)); ctx.lineTo(X(2.3), Y(yy)); }
|
||||
ctx.stroke();
|
||||
// the sight glass: fills from the bottom in the colour of what is inside
|
||||
const gx = 9.1, gy0 = 3, gy1 = 18.8, gh = gy1 - gy0;
|
||||
ctx.fillStyle = '#14181c'; ctx.fillRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
|
||||
if (mm.sn > 0) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(gx + 0.15), Y(gy1 - gh * full), zp * 0.8, zp * gh * full); }
|
||||
ctx.strokeStyle = '#c9d1d8'; ctx.lineWidth = Math.max(1, zp * 0.1);
|
||||
ctx.strokeRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
|
||||
for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); }
|
||||
drawSiloWindow(ctx, mm, sx, sy, zp, false);
|
||||
// what it holds, stencilled on the drum
|
||||
if (zp >= 3) {
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
renderMonoFont();
|
||||
const fs = Math.max(8, Math.round(zp * 1.15));
|
||||
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'rgba(20,24,28,0.75)';
|
||||
ctx.fillText('SILO', X(5.6), Y(5));
|
||||
ctx.fillText('SILO', X(4.5), Y(5), 5 * zp);
|
||||
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
|
||||
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(5.6), Y(16.8));
|
||||
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(4.5), Y(16.8), 5 * zp);
|
||||
if (mm.sn) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(4.4), Y(17.9), zp * 2.4, zp * 0.55); }
|
||||
}
|
||||
// roof with a rim, and the loading hatch (its lid stands open)
|
||||
@@ -150,3 +166,91 @@ function drawSilo(ctx, mm, sx, sy, zp) {
|
||||
ctx.beginPath(); ctx.moveTo(X(8.9), Y(0.9)); ctx.lineTo(X(10.2), Y(-1.4)); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawSiloPixel(ctx, mm, sx, sy, zp) {
|
||||
const col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`;
|
||||
const grain = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); };
|
||||
// Steel shaded by column (light on the left, dark at the right edge), darker rib rows, and fixed
|
||||
// grain noise so it never shimmers. The drum is rows 2-19, the cone 20-22 narrowing to the gate.
|
||||
const steel = [[78,86,94],[176,186,194],[160,170,179],[148,158,167],[140,150,159],[134,144,153],
|
||||
[126,136,145],[118,128,137],[108,118,127],[96,105,114],[84,93,101],[62,70,78]];
|
||||
for (let y = 2; y < 23; y++) for (let x = 0; x < 12; x++) {
|
||||
if (y >= 20 && (x < y - 19 || x > 30 - y)) continue;
|
||||
const c = steel[x], h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263);
|
||||
const n = (((h ^ h >>> 13) >>> 0) % 3 - 1) * 4 - (y === 6 || y === 11 || y === 15 || y === 19 ? 26 : 0) - (y >= 20 ? 10 : 0)
|
||||
- (x === 2 && y < 19 && !(y & 1) ? 34 : 0); // ladder rungs up the left side
|
||||
grain(x, y, `rgb(${c[0] + n},${c[1] + n},${c[2] + n})`);
|
||||
}
|
||||
// legs with a strut to the cone, the gate opening, and the slide gate (pulled aside when open)
|
||||
for (let y = 20; y < 24; y++) { grain(0, y, '#2f353b'); grain(11, y, '#2f353b'); }
|
||||
grain(1, 21, '#2f353b'); grain(10, 21, '#2f353b');
|
||||
for (let x = 4; x < 8; x++) grain(x, 23, '#1c1f23');
|
||||
if (mm.open) for (let x = 8; x < 11; x++) grain(x, 23, '#c99a3c'); else for (let x = 3; x < 9; x++) grain(x, 23, '#6b737b');
|
||||
drawSiloWindow(ctx, mm, sx, sy, zp, true);
|
||||
// roof: a rim, shoulders, the dark hatch and its lid standing open
|
||||
for (let x = 0; x < 12; x++) {
|
||||
grain(x, 1, x >= 3 && x < 9 ? '#15181b' : '#5b646c');
|
||||
if (x < 3 || x >= 9) grain(x, 0, '#7d878f');
|
||||
}
|
||||
const lid = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae';
|
||||
grain(8, 0, lid); grain(9, -1, lid);
|
||||
if (zp >= 3) {
|
||||
renderMonoFont();
|
||||
ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)';
|
||||
ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`;
|
||||
ctx.fillText('SILO', sx + 4.5 * zp, sy + 5 * zp, 5 * zp);
|
||||
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
|
||||
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 4.5 * zp, sy + 16.8 * zp, 5 * zp);
|
||||
if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); }
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// The same framed, three-grain window in both looks; one grain remains visible near empty.
|
||||
function drawSiloWindow(ctx, mm, sx, sy, zp, pixel) {
|
||||
const height = mm.sn > 0 ? Math.max(1, Math.round(16 * Math.min(1, mm.sn / SILO_CAP))) : 0;
|
||||
ctx.fillStyle = '#c9d1d8'; ctx.fillRect(sx + 7 * zp, sy + 2 * zp, 5 * zp, 18 * zp);
|
||||
ctx.fillStyle = '#14181c'; ctx.fillRect(sx + 8 * zp, sy + 3 * zp, 3 * zp, 16 * zp);
|
||||
if (!height) return;
|
||||
// Lift dark fuel slightly so coal stays distinct from the empty glass.
|
||||
const lift = Math.max(COLR[mm.sm], COLG[mm.sm], COLB[mm.sm]) < 80 ? 32 : 0;
|
||||
if (pixel) {
|
||||
for (let y = 19 - height; y < 19; y++) for (let x = 8; x < 11; x++) {
|
||||
const h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263);
|
||||
const n = lift + (((h ^ h >>> 13) >>> 0) % 3 - 1) * 8;
|
||||
ctx.fillStyle = `rgb(${COLR[mm.sm] + n},${COLG[mm.sm] + n},${COLB[mm.sm] + n})`;
|
||||
ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = `rgb(${COLR[mm.sm] + lift},${COLG[mm.sm] + lift},${COLB[mm.sm] + lift})`;
|
||||
ctx.fillRect(sx + 8 * zp, sy + (19 - height) * zp, 3 * zp, height * zp);
|
||||
}
|
||||
}
|
||||
|
||||
// Shelves show each block's fill and materials; the bottom gate belongs to the column.
|
||||
function drawRackBlocks() {
|
||||
const ctx = R.ctx;
|
||||
for (const [b, s] of rackState) {
|
||||
const x = (b % BW) * BS, y = ((b / BW) | 0) * BS;
|
||||
const [sx, sy, sw] = cellRect(x, y, BS, BS);
|
||||
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue;
|
||||
if (SET.fog && fogSeen(x + 2, y + 2) < 64) continue;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#202831'; ctx.fillRect(sx + sw * 0.1, sy + sw * 0.1, sw * 0.8, sw * 0.8);
|
||||
ctx.fillStyle = '#a5adb5';
|
||||
for (let row = 0; row < 2; row++) ctx.fillRect(sx + sw * 0.1, sy + sw * (0.47 + row * 0.4), sw * 0.8, sw * 0.05);
|
||||
const slots = Math.ceil(s.n / 5);
|
||||
let run = 0, count = 0;
|
||||
for (let k = 0; k < slots; k++) {
|
||||
while (run + 1 < s.q.length && k * 5 >= count + s.q[run][1]) count += s.q[run++][1];
|
||||
const m = s.q[run][0];
|
||||
ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`;
|
||||
ctx.fillRect(sx + sw * (0.14 + k % 5 * 0.145), sy + sw * (0.71 - Math.floor(k / 5) * 0.4), sw * 0.12, sw * 0.12);
|
||||
}
|
||||
if (b + BW >= NB || blockP[b + BW] !== BP_RACK) {
|
||||
ctx.fillStyle = s.open ? '#e0b43c' : '#c9d1d8';
|
||||
ctx.fillRect(sx + sw * 0.1, sy + sw * 0.94, sw * (s.open ? 0.2 : 0.8), sw * 0.06);
|
||||
if (s.open) ctx.fillRect(sx + sw * 0.7, sy + sw * 0.94, sw * 0.2, sw * 0.06);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
||||
function worldObjects() {
|
||||
const { bw, bh, px, vx0, vy0, smoothOn: sm, lens } = R;
|
||||
const useFog = SET.fog, incand = SET.incandescence;
|
||||
// bars and other loose objects
|
||||
if (!lens) for (const e of ents) {
|
||||
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
|
||||
const ex = e.ix - vx0, ey = e.iy - vy0;
|
||||
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
|
||||
const m = e.m;
|
||||
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
|
||||
const sx = ex + dx, sy = ey + dy;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
let r = COLR[m], g = COLG[m], b = COLB[m];
|
||||
const end = dx === 0 || dx === BAR_W - 1;
|
||||
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
|
||||
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
|
||||
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
|
||||
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
|
||||
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
|
||||
}
|
||||
}
|
||||
// grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain)
|
||||
if (!lens) for (const f of flyers) {
|
||||
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
|
||||
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
|
||||
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
|
||||
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
|
||||
}
|
||||
// blueprint ghosts: blocks waiting to be built
|
||||
if (ghostBlocks.size) {
|
||||
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
|
||||
const pulse = (R.frame >> 3) & 3;
|
||||
for (const [b, gv] of ghostBlocks) {
|
||||
const gbx = b % BW, gby = (b / BW) | 0;
|
||||
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
|
||||
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
|
||||
let tr = 90, tg = 190, tb = 230;
|
||||
if (blocked) { tr = 230; tg = 70; tb = 60; }
|
||||
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
|
||||
const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks));
|
||||
const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0;
|
||||
const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3;
|
||||
for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) {
|
||||
let sd = 0;
|
||||
if (diagonal) {
|
||||
if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue;
|
||||
const x = gbx * BS + dx, y = gby * BS + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
|
||||
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
|
||||
sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
|
||||
if (sd > 0) continue;
|
||||
}
|
||||
if (slope) {
|
||||
const x = gbx * BS + dx, y = gby * BS + dy;
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
|
||||
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
|
||||
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
|
||||
const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code);
|
||||
if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
|
||||
if (rib) sd = -0.1;
|
||||
}
|
||||
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
|
||||
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
|
||||
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
|
||||
const k = sy * bw + sx;
|
||||
if (R.gpuOn && !gpuOverrideHas(k)) cellPixel(sx + vx0, sy + vy0);
|
||||
const c = px[k];
|
||||
const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1;
|
||||
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
|
||||
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
|
||||
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-23
@@ -1,5 +1,5 @@
|
||||
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
||||
function spawnFloater(x, y, text) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, life: 70 }); }
|
||||
function spawnFloater(x, y, text, colour) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, colour, life: 70 }); }
|
||||
// Rain as thin falling streaks, snow as soft flakes
|
||||
function drawRain() {
|
||||
if (!flyers.length) return;
|
||||
@@ -11,7 +11,7 @@ function drawRain() {
|
||||
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 ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
||||
if (f.m !== WATER) { snow = true; continue; }
|
||||
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);
|
||||
@@ -20,14 +20,14 @@ function drawRain() {
|
||||
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;
|
||||
if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
||||
const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42);
|
||||
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
|
||||
// Info overlay: a small arrow on every belt and screw conveyor block on screen, the way it moves things
|
||||
function drawFlowArrows() {
|
||||
const ctx = R.ctx, zp = R.zp, v = viewCells();
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
@@ -37,12 +37,12 @@ function drawFlowArrows() {
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b];
|
||||
if (code !== BP_BELT && code !== BP_TUBE && code !== BP_PUMP && code !== BP_OUTLET) continue;
|
||||
if (code !== BP_BELT && code !== BP_SCREW && code !== BP_PUMP && code !== BP_OUTLET) continue;
|
||||
const vv = blockV[b];
|
||||
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : [[0, -1], [1, 0], [0, 1], [-1, 0]][vv & 3];
|
||||
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : SCREW_DIRS[vv & 7];
|
||||
const c = worldToScreenPx(bx * BS + BS / 2, by * BS + BS / 2);
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.55)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.35, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = code === BP_TUBE ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
|
||||
ctx.strokeStyle = code === BP_SCREW ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
|
||||
// a chevron pointing (dx, dy)
|
||||
const px = -dy, py = dx;
|
||||
ctx.beginPath();
|
||||
@@ -53,6 +53,147 @@ function drawFlowArrows() {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// "Doesn't connect": a red ring with a slash at a transport end that would join one block up or
|
||||
// down (nearMiss), on built pieces, ghosts and the drag preview alike.
|
||||
function drawJoinMark(bx, by, end, code, v) {
|
||||
const e = pieceEnds(code, v)[end], ctx = R.ctx, r = Math.max(3, BS * R.zp * 0.24);
|
||||
const c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS / 2, by * BS + BS / 2 + e.dy * BS / 2);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#ef6a55'; ctx.lineWidth = Math.max(1.2, r * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, r * 0.75, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(c.x - r * 0.53, c.y + r * 0.53); ctx.lineTo(c.x + r * 0.53, c.y - r * 0.53); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawJoinMarks() {
|
||||
const v = viewCells();
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, ghost = ghostBlocks.get(b), code = ghost ? ghost[0] : blockP[b], vv = ghost ? ghost[1] : blockV[b];
|
||||
if (!code || !pieceEnds(code, vv).length) continue;
|
||||
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, ghostBlocks.size ? ghostBlocks : null)) drawJoinMark(bx, by, end, code, vv);
|
||||
}
|
||||
}
|
||||
// Throughput (t49, info overlay): grains per second at the open outlet end of every line, and
|
||||
// an orange glow on transport blocks that are loaded but not moving, where a line backs up.
|
||||
function flowLoaded(b, code) {
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
let n = 0;
|
||||
for (let k = 0; k < BS; k++) {
|
||||
const x = bx * BS + k, i = code === BP_SCREW ? (by * BS + 1) * W + x : (beltFaceY(code, bx, by, x) - 1) * W + x;
|
||||
if (i >= 0 && i < N && PH[mat[i]] === PH_POWDER) n++;
|
||||
}
|
||||
return n >= 3;
|
||||
}
|
||||
function drawFlowRates() {
|
||||
const ctx = R.ctx, v = viewCells(), s = BS * R.zp;
|
||||
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
||||
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
||||
ctx.save();
|
||||
ctx.font = `600 ${Math.max(10, Math.min(14, s * 0.45))}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx, code = blockP[b], ends = pieceEnds(code, blockV[b]);
|
||||
if (!ends.length) continue;
|
||||
const rate = flowRate[b] || 0;
|
||||
if (rate < 0.5 && flowLoaded(b, code)) {
|
||||
const p = worldToScreenPx(bx * BS, by * BS);
|
||||
ctx.fillStyle = 'rgba(255,150,60,0.32)'; ctx.fillRect(p.x, p.y, s, s);
|
||||
}
|
||||
const out = ends[1].outlet ? 1 : 0;
|
||||
if (endJoin(bx, by, out) >= 0) continue;
|
||||
const e = ends[out], c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS * 0.95, by * BS + BS / 2 + e.dy * BS * 0.95 - (e.dy ? 0 : BS * 0.9));
|
||||
const label = rate >= 10 ? `${Math.round(rate)}/s` : `${rate.toFixed(1)}/s`, tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.8)'; ctx.fillRect(c.x - tw / 2 - 4, c.y - 9, tw + 8, 18);
|
||||
ctx.fillStyle = rate > 0 ? '#e7dccb' : '#ef9a6a'; ctx.fillText(label, c.x, c.y + 1);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// The resonance ping's echo: a faint ring spreading from the bot, the veins it found showing in
|
||||
// their own lit colour inside it (with a slightly lighter rim so dark seams read against fog),
|
||||
// a brief sparkle as the ring passes them; each vein fades a fixed time after the ring reached
|
||||
// it, so the fade follows the ring outward. The veins are drawn once into a small image; each
|
||||
// frame draws it in a few distance bands (fading ones at partial alpha) plus a few dozen sparkles.
|
||||
const PING_SPEED = 160, PING_HOLD = 1200, PING_FADE = 800, PING_BANDS = 6, PING_GLINTS = 60, GLINT_MS = 450;
|
||||
function pingImage(p) {
|
||||
const size = p.r * 2 + 1, c = document.createElement('canvas'); c.width = c.height = size;
|
||||
const g = c.getContext('2d'), img = g.createImageData(size, size), d = img.data, ox = Math.floor(p.x - p.r), oy = Math.floor(p.y - p.r);
|
||||
const lit = (i, k, lift) => { const m = mat[i], v = (noiseB[i] - 128) * CVAR[m] / 128 + lift; d[k] = COLR[m] + v; d[k + 1] = COLG[m] + v; d[k + 2] = COLB[m] + v; d[k + 3] = 255; };
|
||||
for (const i of p.cells) { const x = i % W - ox, y = ((i / W) | 0) - oy; if (x >= 0 && y >= 0 && x < size && y < size) lit(i, (y * size + x) * 4, 0); }
|
||||
for (const i of p.cells) {
|
||||
const x = i % W - ox, y = ((i / W) | 0) - oy, k = (y * size + x) * 4;
|
||||
if (x <= 0 || y <= 0 || x >= size - 1 || y >= size - 1) continue;
|
||||
if (!d[k - 1] || !d[k + 7] || !d[k - size * 4 + 3] || !d[k + size * 4 + 3]) lit(i, k, 45); // the rim
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
const step = Math.max(1, Math.ceil(p.cells.length / PING_GLINTS));
|
||||
const glints = p.cells.filter((_, k) => k % step === 0).map(i => ({ x: i % W + 0.5, y: ((i / W) | 0) + 0.5, d: Math.hypot(i % W - p.x, ((i / W) | 0) - p.y), m: mat[i] }));
|
||||
return { c, ox, oy, glints };
|
||||
}
|
||||
function drawPing() {
|
||||
const p = GAME.ping;
|
||||
if (!p) return;
|
||||
const ms = performance.now() - p.t0, front = ms / 1000 * PING_SPEED, done = p.r / PING_SPEED * 1000;
|
||||
if (ms > done + PING_HOLD + PING_FADE) { GAME.ping = null; return; }
|
||||
if (!p.img) p.img = pingImage(p);
|
||||
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(p.x, p.y), o = worldToScreenPx(p.img.ox, p.img.oy);
|
||||
// inside `gone` the veins have faded out; from `full` out to the ring they are fully lit
|
||||
const outer = Math.min(front, p.r + 1), full = (ms - PING_HOLD) / 1000 * PING_SPEED, gone = (ms - PING_HOLD - PING_FADE) / 1000 * PING_SPEED;
|
||||
const band = (r0, r1, a) => {
|
||||
if (r1 <= Math.max(0, r0) || a <= 0) return;
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(c.x, c.y, r1 * zp, 0, Math.PI * 2);
|
||||
if (r0 > 0) ctx.arc(c.x, c.y, r0 * zp, 0, Math.PI * 2, true);
|
||||
ctx.clip(); ctx.globalAlpha = a; ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(p.img.c, o.x, o.y, p.img.c.width * zp, p.img.c.height * zp); ctx.restore();
|
||||
};
|
||||
band(Math.max(full, 0), outer, 1);
|
||||
for (let k = 0; k < PING_BANDS; k++) {
|
||||
const r0 = gone + (full - gone) * k / PING_BANDS, r1 = gone + (full - gone) * (k + 1) / PING_BANDS;
|
||||
band(r0, Math.min(r1, outer), (k + 0.5) / PING_BANDS);
|
||||
}
|
||||
ctx.save();
|
||||
if (front < p.r) {
|
||||
ctx.strokeStyle = `rgba(160,225,255,${0.35 * (1 - front / p.r)})`; ctx.lineWidth = Math.max(1, zp * 0.4);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, front * zp, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// a four-point glint where the ring has just passed, growing then shrinking
|
||||
for (const gl of p.img.glints) {
|
||||
const t = (ms - gl.d / PING_SPEED * 1000) / GLINT_MS;
|
||||
if (t < 0 || t > 1) continue;
|
||||
const s = worldToScreenPx(gl.x, gl.y), k = Math.sin(t * Math.PI) * zp * 2.2, m = gl.m;
|
||||
ctx.strokeStyle = `rgba(${Math.min(255, COLR[m] + 140)},${Math.min(255, COLG[m] + 140)},${Math.min(255, COLB[m] + 140)},${0.9 * Math.sin(t * Math.PI)})`;
|
||||
ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
ctx.beginPath(); ctx.moveTo(s.x - k, s.y); ctx.lineTo(s.x + k, s.y); ctx.moveTo(s.x, s.y - k); ctx.lineTo(s.x, s.y + k); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Edges are built once when the cursor enters a different vein.
|
||||
function drawHoveredVein() {
|
||||
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) return;
|
||||
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!vein) return;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, R.canvas.width, R.canvas.height); ctx.clip();
|
||||
ctx.strokeStyle = 'rgba(235,245,255,0.85)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (const e of vein.edges) {
|
||||
if (e[2] < R.vx0 || e[0] > R.vx0 + R.bw || e[3] < R.vy0 || e[1] > R.vy0 + R.bh) continue;
|
||||
const a = worldToScreenPx(e[0], e[1]);
|
||||
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (e[2] - e[0]) * zp, a.y + (e[3] - e[1]) * zp);
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
}
|
||||
// While the ping recharges, a thin arc around the bot fills up (only with the drill in hand).
|
||||
function drawPingRecharge() {
|
||||
if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return;
|
||||
const k = (tick - GAME.pingTick) / PING_RECHARGE;
|
||||
if (k >= 1) return;
|
||||
const ctx = R.ctx, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2), r = Math.max(bot.w, bot.h) * R.zp * 0.9;
|
||||
ctx.save(); ctx.lineWidth = Math.max(2, R.zp * 0.5); ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(160,225,255,0.18)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(160,225,255,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r, -Math.PI / 2, -Math.PI / 2 + k * Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
// Outline a run of blocks: strong while it is being edited, faint when pointed at
|
||||
function drawRunOutline(run, strong) {
|
||||
if (!run || !run.length) return;
|
||||
@@ -73,7 +214,7 @@ function drawFilterTags(showAll, hoverB) {
|
||||
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';
|
||||
renderMonoFont();
|
||||
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);
|
||||
@@ -125,7 +266,7 @@ function drawFilterTags(showAll, hoverB) {
|
||||
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';
|
||||
renderMonoFont();
|
||||
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--) {
|
||||
@@ -133,33 +274,31 @@ function drawFloaters() {
|
||||
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.globalAlpha = a; ctx.fillStyle = f.colour || '#ffd27a'; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawMolds(hoverMold) {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '100% · F' : Math.round(mo.fill * 100) + '%'; }
|
||||
function drawMolds() {
|
||||
const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2));
|
||||
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}`;
|
||||
renderMonoFont();
|
||||
ctx.font = `600 ${fs}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';
|
||||
const ready = mo.state === 'ready' && mo.kind !== 'ingot', bad = mo.state === 'flawed' || mo.blocked;
|
||||
ctx.setLineDash([zp, zp]);
|
||||
ctx.strokeStyle = ready ? 'rgba(255,200,90,0.95)' : flawed ? 'rgba(230,90,70,0.8)' : 'rgba(200,190,170,0.4)';
|
||||
ctx.strokeStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : 'rgba(200,190,170,0.4)';
|
||||
ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.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;
|
||||
const label = moldLabel(mo), tw = ctx.measureText(label).width + fs * 0.6;
|
||||
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.fillRect(sx + sw / 2 - tw / 2, sy - zp - fs * 1.3, tw, fs * 1.3);
|
||||
ctx.fillStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '#e7dccb';
|
||||
ctx.fillText(label, sx + sw / 2, sy - zp - fs * 0.15);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -222,6 +361,7 @@ function drawParticles() {
|
||||
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
|
||||
if (p.life <= 0) { ps.splice(k, 1); continue; }
|
||||
}
|
||||
if (SET.fog && fogSeen(p.x, p.y) < 64) continue;
|
||||
const s = worldToScreenPx(p.x, p.y);
|
||||
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);
|
||||
|
||||
+14
-20
@@ -2,10 +2,10 @@
|
||||
thermal palettes, pipes, trees, sky), canvas setup and the camera. */
|
||||
|
||||
const R = {
|
||||
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
|
||||
glowC: null, glowX: null, glowImg: null, glowPx: null,
|
||||
canvas: null, ctx: null, px: null,
|
||||
glowW: 0, glowH: 0, glowPx: null,
|
||||
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
|
||||
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
||||
lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
||||
frame: 0, lens: false, particles: [],
|
||||
};
|
||||
|
||||
@@ -116,8 +116,8 @@ function thinPipeColor(x, y, anim) {
|
||||
}
|
||||
PC[0] = r; PC[1] = g; PC[2] = bl;
|
||||
}
|
||||
// a block that is a belt or a filter: they join up into one belt
|
||||
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; }
|
||||
// A filter frame opens only where this end has a geometric partner.
|
||||
function beltLike(b, end) { return endJoin(b % BW, (b / BW) | 0, end) >= 0; }
|
||||
// standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops
|
||||
const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT);
|
||||
for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; }
|
||||
@@ -129,7 +129,7 @@ const BG = {
|
||||
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;
|
||||
const sm = meanSurface();
|
||||
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;
|
||||
@@ -138,13 +138,7 @@ function buildSky() {
|
||||
|
||||
function initRender(canvas) {
|
||||
R.canvas = canvas;
|
||||
R.ctx = canvas.getContext('2d', { alpha: false });
|
||||
R.buf = document.createElement('canvas');
|
||||
R.bctx = R.buf.getContext('2d');
|
||||
R.glowC = document.createElement('canvas');
|
||||
R.glowX = R.glowC.getContext('2d');
|
||||
R.lightC = document.createElement('canvas');
|
||||
R.lightX = R.lightC.getContext('2d');
|
||||
R.ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
function resizeRender() {
|
||||
@@ -157,15 +151,11 @@ function resizeRender() {
|
||||
const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2;
|
||||
if (bw !== R.bw || bh !== R.bh) {
|
||||
R.bw = bw; R.bh = bh;
|
||||
R.buf.width = bw; R.buf.height = bh;
|
||||
R.img = R.bctx.createImageData(bw, bh);
|
||||
R.px = new Uint32Array(R.img.data.buffer);
|
||||
R.px = new Uint32Array(bw * bh);
|
||||
// the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields
|
||||
R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh);
|
||||
const gw = Math.ceil(bw / 2), gh = Math.ceil(bh / 2);
|
||||
R.glowC.width = gw; R.glowC.height = gh;
|
||||
R.glowImg = R.glowX.createImageData(gw, gh);
|
||||
R.glowPx = new Uint32Array(R.glowImg.data.buffer);
|
||||
R.glowW = Math.ceil(bw / 2); R.glowH = Math.ceil(bh / 2);
|
||||
R.glowPx = new Uint32Array(R.glowW * R.glowH);
|
||||
R.lw = 0;
|
||||
}
|
||||
}
|
||||
@@ -194,3 +184,7 @@ function screenToWorld(sx, sy) {
|
||||
return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp };
|
||||
}
|
||||
function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; }
|
||||
|
||||
function renderMonoFont() {
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Drawing only: the face stays on its simulation cells; the band hangs below it.
|
||||
let slopeBeltJoins = new Uint8Array(0), slopeBeltHalo = new Uint8Array(0), beltCaps = new Uint8Array(0);
|
||||
let slopeAcross = 0, slopeAlong = 0, slopeDistance = 0;
|
||||
function slopeBeltConnections(bx, by, code, planned = null) {
|
||||
let joins = 0;
|
||||
for (let end = 0; end < 2; end++) {
|
||||
const join = endJoin(bx, by, end, planned);
|
||||
if (join < 0) continue;
|
||||
const nb = join >> 1, next = planned && planned.get(nb);
|
||||
const slope = isSlopeBelt(next ? next[0] : blockP[nb]) &&
|
||||
((nb / BW) | 0) - by === beltGrade(code) * (nb % BW - bx);
|
||||
// A same-row feed into a low end drops onto its face: drawn as two capped ends, still joined.
|
||||
if (!slope && joinIsDrop(bx, by, end, join, planned)) continue;
|
||||
joins |= (slope ? 4 : 1) << end;
|
||||
}
|
||||
return joins;
|
||||
}
|
||||
function flatBeltCaps(b, planned = null) {
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
let caps = 0;
|
||||
for (let end = 0; end < 2; end++) {
|
||||
const join = endJoin(bx, by, end, planned);
|
||||
if (join < 0 || joinIsDrop(bx, by, end, join, planned)) caps |= 1 << end;
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
function prepareSlopeBelts(x0, y0, x1, y1) {
|
||||
if (slopeBeltJoins.length !== NB) { slopeBeltJoins = new Uint8Array(NB); slopeBeltHalo = new Uint8Array(NB); beltCaps = new Uint8Array(NB); }
|
||||
slopeBeltHalo.fill(0);
|
||||
for (let by = Math.max(0, (y0 >> 2) - 2); by <= Math.min(BH - 1, (y1 >> 2) + 2); by++) {
|
||||
for (let bx = Math.max(0, (x0 >> 2) - 2); bx <= Math.min(BW - 1, (x1 >> 2) + 2); bx++) {
|
||||
const b = by * BW + bx, c = blockP[b];
|
||||
if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b);
|
||||
if (!isSlopeBelt(c)) continue;
|
||||
const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS;
|
||||
slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0) | (isFilter(c) && netSat(face) > 0 && screenLoaded(b) && (tick & 2) ? 64 : 0);
|
||||
for (let yy = Math.max(0, by - 2); yy <= Math.min(BH - 1, by + 1); yy++)
|
||||
for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rounded stroke, with coordinates along/across the winning segment for rollers and mesh.
|
||||
function slopeBeltSegment(x, y, ax, ay, bx, by, origin) {
|
||||
const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len;
|
||||
const along = (x - ax) * ux + (y - ay) * uy, across = -(x - ax) * uy + (y - ay) * ux;
|
||||
const qx = Math.abs(along - len / 2) - len / 2 + 0.75, qy = Math.abs(across) - 1.1;
|
||||
const sd = Math.hypot(Math.max(0, qx), Math.max(0, qy)) + Math.min(Math.max(qx, qy), 0) - 0.75;
|
||||
if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = across + 2; slopeAlong = origin + along; }
|
||||
}
|
||||
function slopeBeltCorner(x, y, ax, ay, origin) {
|
||||
const dx = x - ax, dy = y - ay, radius = Math.hypot(dx, dy), sd = radius - 1.85;
|
||||
if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = 2 + dy; slopeAlong = origin + dx; }
|
||||
}
|
||||
function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) {
|
||||
const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
|
||||
x -= ox; y -= oy;
|
||||
if (isFilter(code) && (joins & 64)) y -= 0.45;
|
||||
const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0;
|
||||
const normal = 1.85 * Math.SQRT1_2;
|
||||
let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal;
|
||||
if (joins & 1) { ay = sy + 2; ax = (ay - sy - offset) / grade; }
|
||||
else if (joins & 4) { ax -= 4; ay -= grade * 4; }
|
||||
if (joins & 2) { by = 6 - sy; bx = (by - sy - offset) / grade; }
|
||||
else if (joins & 8) { bx += 4; by += grade * 4; }
|
||||
slopeDistance = Infinity;
|
||||
const origin = (ox + grade * oy) * Math.SQRT1_2;
|
||||
slopeBeltSegment(x, y, ax, ay, bx, by, origin + (ax + grade * ay) * Math.SQRT1_2);
|
||||
if (joins & 1) {
|
||||
slopeBeltSegment(x, y, -2, ay, ax, ay, ox - 2);
|
||||
slopeBeltCorner(x, y, ax, ay, ox + ax);
|
||||
}
|
||||
if (joins & 2) {
|
||||
slopeBeltSegment(x, y, bx, by, 6, by, ox + bx);
|
||||
slopeBeltCorner(x, y, bx, by, ox + bx);
|
||||
}
|
||||
return slopeDistance;
|
||||
}
|
||||
function slopeBeltAt(i, x, y, smooth = false) {
|
||||
const own = (y >> 2) * BW + (x >> 2), code = blockP[own];
|
||||
if (!slopeBeltHalo[own]) return -1;
|
||||
const slope = isSlopeBelt(code);
|
||||
if (!slope && (mach[i] || occ[i] || (mat[i] !== EMPTY && mat[i] !== BELTL && mat[i] !== BELTR))) return -1;
|
||||
let seal = !slope && (mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER) ? slopeSealOwner(i) : -1;
|
||||
if (seal >= 0 && !isSlopeBelt(blockP[seal])) seal = -1;
|
||||
if (!slope && seal < 0 && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || code)) return -1;
|
||||
let best = smooth ? 0.8 : 0, found = slope ? own : seal;
|
||||
for (let yy = Math.max(0, (y >> 2) - 1); yy <= Math.min(BH - 1, (y >> 2) + 1); yy++) {
|
||||
for (let xx = Math.max(0, (x >> 2) - 1); xx <= Math.min(BW - 1, (x >> 2) + 1); xx++) {
|
||||
const b = yy * BW + xx;
|
||||
if (!isSlopeBelt(blockP[b])) continue;
|
||||
const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
|
||||
if (sd < best) { best = sd; found = b; }
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function slopeBeltColor(b, x, y, anim, br, bg, bb) {
|
||||
const sd = slopeBeltShape(x + 0.5, y + 0.5, b);
|
||||
if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16);
|
||||
const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c);
|
||||
const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
|
||||
const a = slopeAlong, d = slopeAcross, time = !mesh && joins & 16 ? anim : 0;
|
||||
let r = 38, g = 41, blue = 46;
|
||||
if (-sd < (mesh ? 1.1 : 0.8)) {
|
||||
const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72;
|
||||
r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k;
|
||||
} else if (mesh) {
|
||||
const f = filterCfg.get(b), m = f && f.m.length ? f.m[(Math.floor(a / 2) & 65535) % f.m.length] : 0;
|
||||
if ((Math.floor(a) + Math.floor(d)) & 1) { r = br * 0.8; g = bg * 0.8; blue = bb * 0.8; }
|
||||
else { r = m ? COLR[m] * 0.75 : 64; g = m ? COLG[m] * 0.75 : 68; blue = m ? COLB[m] * 0.75 : 76; }
|
||||
} else {
|
||||
const dx = ((a % 2 + 2) % 2) - 1, dy = d - 2;
|
||||
if (dx * dx + dy * dy < 0.95 * 0.95) {
|
||||
const spin = Math.cos((Math.atan2(dy, dx) - dir * time / 1.9) * 3) > 0.6;
|
||||
r = spin ? 104 : 132; g = spin ? 94 : 120; blue = spin ? 84 : 108;
|
||||
}
|
||||
}
|
||||
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
|
||||
}
|
||||
|
||||
function screenLoaded(b) {
|
||||
const bx = b % BW, by = (b / BW) | 0, c = blockP[b];
|
||||
for (let x = bx * BS; x < bx * BS + BS; x++) {
|
||||
const i = (beltFaceY(c, bx, by, x) - 1) * W + x;
|
||||
if (i >= 0 && (PH[mat[i]] === PH_POWDER || occ[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Filled far wall, in belt coordinates. Project onto the face so ends stop at the slope ends.
|
||||
let slopeWallOwner = -1;
|
||||
function slopeWallShape(x, y, b, code = blockP[b]) {
|
||||
const g = beltGrade(code), ox = b % BW * BS, oy = ((b / BW) | 0) * BS;
|
||||
const sy = g < 0 ? BS : 0, lx = x - ox, ly = y - oy - sy;
|
||||
const foot = (lx + g * ly) / 2, depth = (g * lx - ly) * Math.SQRT1_2;
|
||||
return Math.max(-foot * Math.SQRT2, (foot - BS) * Math.SQRT2, -depth, depth - CLEAT_HEIGHT);
|
||||
}
|
||||
function slopeWallAt(x, y, smooth = false) {
|
||||
slopeWallOwner = -1;
|
||||
let best = smooth ? 0.8 : 0;
|
||||
for (let by = Math.max(0, (y >> 2) - 1); by <= Math.min(BH - 1, (y >> 2) + 3); by++)
|
||||
for (let bx = Math.max(0, (x >> 2) - 2); bx <= Math.min(BW - 1, (x >> 2) + 2); bx++) {
|
||||
const b = by * BW + bx;
|
||||
if (!isBelt(blockP[b]) || !isSlopeBelt(blockP[b]) || !slopeUphill(blockP[b], blockV[b])) continue; // downhill: a plain belt
|
||||
const sd = slopeWallShape(x + 0.5, y + 0.5, b);
|
||||
if (sd <= best) { best = sd; slopeWallOwner = b; }
|
||||
}
|
||||
return slopeWallOwner;
|
||||
}
|
||||
// The load occludes the far wall; ribs use exactly the simulation barrier raster.
|
||||
function slopeDetailAt(i, x, y, smooth = false) {
|
||||
slopeWallOwner = -1;
|
||||
if (!slopeBeltHalo[(y >> 2) * BW + (x >> 2)] && !cleatMask[i]) return 0;
|
||||
if (occ[i] || mach[i] || part[i]) return 0;
|
||||
if (cleatMask[i] && PH[mat[i]] !== PH_SOLID) { slopeWallAt(x, y, smooth); return 1; }
|
||||
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return 0;
|
||||
return slopeWallAt(x, y, smooth) >= 0 ? 2 : 0;
|
||||
}
|
||||
|
||||
function slopePreviewCleat(x, y, b, code) {
|
||||
const bx = b % BW, by = (b / BW) | 0, g = beltGrade(code);
|
||||
for (let h = 0; h <= CLEAT_REACH; h++) {
|
||||
const foot = x - g * h;
|
||||
if (foot >= bx * BS && foot < (bx + 1) * BS && foot % CLEAT_SPACING === 0 &&
|
||||
y + h + 1 === beltFaceY(code, bx, by, foot)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
/* =====================================================================
|
||||
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);
|
||||
}
|
||||
+29
-260
@@ -1,18 +1,21 @@
|
||||
// Scale colour without changing the smooth-look class.
|
||||
function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 16) | (((c >>> 8 & 255) * k) << 8) | ((c & 255) * k); }
|
||||
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
|
||||
// ---------- World pixels ----------
|
||||
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
|
||||
const TUBE_CLASS = new Uint8Array(9); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12;
|
||||
function drawWorld() {
|
||||
const bw = R.bw, bh = R.bh, px = R.px;
|
||||
const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||
if (PERF_MARKS || SET.showPerf) { perfMarks.override = 0; delete perfMarks.gpu; }
|
||||
if (!GPU.ok) return;
|
||||
R.gpuOn = true; GPU.canvas.hidden = false;
|
||||
R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height);
|
||||
const bw = R.bw, bh = R.bh;
|
||||
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
|
||||
const vx0 = R.vx0, vy0 = R.vy0;
|
||||
// how hard it is raining around the camera, eased so the sky greys over gently
|
||||
R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02;
|
||||
const rk = R.rain;
|
||||
// Smooth look: the colours below are still worked out per grain, but each grain also gets a
|
||||
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth.js)
|
||||
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable();
|
||||
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
|
||||
// Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
|
||||
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens;
|
||||
const mmask = R.moldMask;
|
||||
if (sm) {
|
||||
mmask.fill(0);
|
||||
for (const mo of molds) {
|
||||
@@ -20,258 +23,24 @@ function drawWorld() {
|
||||
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; }
|
||||
}
|
||||
}
|
||||
const OUT = sm ? 0x040a0a0c : 0xff0a0a0c;
|
||||
if (diagonalJoins.length !== NB) diagonalJoins = new Uint8Array(NB);
|
||||
for (let by = Math.max(0, (vy0 >> 2) - 1); by <= Math.min(BH - 1, ((vy0 + bh) >> 2) + 1); by++) {
|
||||
for (let bx = Math.max(0, (vx0 >> 2) - 1); bx <= Math.min(BW - 1, ((vx0 + bw) >> 2) + 1); bx++) {
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] === BP_SCREW) diagonalJoins[b] = blockV[b] >= 4 ? diagonalScrewJoins(bx, by, blockV[b]) : straightScrewJoins(bx, by, blockV[b]);
|
||||
}
|
||||
}
|
||||
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
|
||||
const lighting = SET.lighting !== 'off' && !R.lens;
|
||||
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
|
||||
const grain = SET.grain, incand = SET.incandescence;
|
||||
const anim = R.frame;
|
||||
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
|
||||
const glowOn = SET.glow !== 'off' && !lens;
|
||||
const gpx = R.glowPx, gw = R.glowC.width;
|
||||
if (glowOn) gpx.fill(0);
|
||||
for (let by = 0; by < bh; by++) {
|
||||
const y = vy0 + by;
|
||||
const row = by * bw;
|
||||
if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 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();
|
||||
const lightUpdate = lighting ? gpuLightGrid() : false;
|
||||
const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||
const glowOn = SET.glow !== 'off' && !R.lens;
|
||||
if (glowOn) R.glowPx.fill(0);
|
||||
gpuOverrides();
|
||||
worldObjects();
|
||||
if (PERF_MARKS || SET.showPerf) {
|
||||
perfMarks.color = performance.now() - colorStart;
|
||||
perfMarks.prepare = colorStart - perfStart;
|
||||
}
|
||||
gpuDraw(lighting, glowOn, lightUpdate);
|
||||
}
|
||||
|
||||
+31
-2
@@ -4,6 +4,7 @@
|
||||
/*__CSS__*/
|
||||
</style>
|
||||
|
||||
<canvas id="gpu" aria-hidden="true" hidden></canvas>
|
||||
<canvas id="game" aria-label="Game world"></canvas>
|
||||
<div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div>
|
||||
|
||||
@@ -25,36 +26,43 @@
|
||||
</div>
|
||||
<div class="bar" id="bagbar"><i></i></div>
|
||||
<div id="baglist"></div>
|
||||
<div id="bagfoot"></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 id="minimap" class="plate interactive"><canvas id="mini-canvas" width="200" height="130" aria-label="Minimap"></canvas><div class="map-buttons"><button id="mini-minus" class="iconbtn" aria-label="Zoom out minimap">−</button><button id="mini-plus" class="iconbtn" aria-label="Zoom in minimap">+</button><button id="mini-map" class="iconbtn">Map <kbd>M</kbd></button></div></div>
|
||||
</div>
|
||||
<div class="corner bc">
|
||||
<div id="cards" aria-live="polite"></div>
|
||||
<div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div>
|
||||
<div id="ovbadges" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<div id="perf"></div>
|
||||
<div id="saving" hidden role="status"><i></i>Saving…</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 id="cap-notice" class="plate note" role="status" hidden></div>
|
||||
<div class="tmenu" id="tmenu">
|
||||
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
|
||||
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
|
||||
<button class="btn" id="btn-new">New world <small>generate</small></button>
|
||||
<div class="plate newworld" id="newworld" hidden>
|
||||
<label>Name <input id="nw-name" type="text" maxlength="40" autocomplete="off" spellcheck="false"></label>
|
||||
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
|
||||
<label>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>
|
||||
<option value="6144x2048">Huge · 6144 × 2048</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn primary full" id="btn-generate">Generate and start</button>
|
||||
@@ -67,7 +75,7 @@
|
||||
<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="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadstep" role="status"></span><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div>
|
||||
|
||||
<div id="m-pause" class="modal small" hidden>
|
||||
<div class="plate">
|
||||
@@ -101,6 +109,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-worlds" class="modal" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2>Worlds</h2><button class="iconbtn" data-close aria-label="Close worlds">Close <kbd>Esc</kbd></button></div>
|
||||
<div class="mbody" id="worlds-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-map" class="modal" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2>World map</h2><button class="iconbtn" data-close aria-label="Close map">Close <kbd>M / Esc</kbd></button></div>
|
||||
<canvas id="map-canvas" aria-label="World map. Drag to pan; wheel to zoom."></canvas>
|
||||
<div class="mfoot"><button id="map-minus" class="iconbtn" aria-label="Zoom out map">−</button><button id="map-plus" class="iconbtn" aria-label="Zoom in map">+</button><button id="map-centre" class="iconbtn">Centre on bot</button><span class="note">Drag to pan · wheel to zoom</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-dialog" class="modal small" hidden>
|
||||
<div class="plate">
|
||||
<div class="mhead"><h2 id="dlg-title"></h2></div>
|
||||
@@ -109,6 +132,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="dev-console" class="plate" aria-labelledby="dev-title" hidden>
|
||||
<div class="dev-head"><strong id="dev-title">Dev console</strong><span class="note">World marked · help lists commands</span><button id="dev-close" class="iconbtn" aria-label="Close dev console">Close <kbd>Esc</kbd></button></div>
|
||||
<div id="dev-output" role="log" aria-label="Command output" aria-live="polite"></div>
|
||||
<form id="dev-form"><label for="dev-input">Command</label><input id="dev-input" type="text" autocomplete="off" spellcheck="false" placeholder="help"><button class="btn" type="submit">Run</button></form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
//__JS__
|
||||
</script>
|
||||
|
||||
+12
-6
@@ -3,7 +3,7 @@
|
||||
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;
|
||||
return msg && droppedLast ? msg.replace('taken back.', 'taken back; bag full, excess dropped here.') : msg;
|
||||
}
|
||||
function removePieceAt0(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return '';
|
||||
@@ -15,7 +15,7 @@ function removePieceAt0(x, y) {
|
||||
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.`;
|
||||
if (siloFull(machines[mach[i]])) return 'Empty the silo first; press F to open its gate.';
|
||||
removeMachine(mach[i], true, false); return `${P.name} taken back.`;
|
||||
}
|
||||
const mo = moldAt(x, y);
|
||||
@@ -60,7 +60,7 @@ function toggleGateAt(x, y) {
|
||||
const m = mat[i];
|
||||
if (m !== EMPTY && PH[m] !== PH_GAS) {
|
||||
const u = i - W;
|
||||
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; }
|
||||
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); }
|
||||
}
|
||||
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
|
||||
}
|
||||
@@ -73,14 +73,20 @@ function interactAt(x, y) {
|
||||
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; }
|
||||
// ingot molds empty themselves: F passes them by
|
||||
if (mo && mo.kind !== 'ingot') { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; }
|
||||
const pb = ((y / BS) | 0) * BW + ((x / BS) | 0);
|
||||
if (blockP[pb] === BP_RACK) {
|
||||
const top = rackTop(pb), open = !rackState.get(top).open;
|
||||
for (let b = top; b < NB && blockP[b] === BP_RACK; b += BW) { const s = rackState.get(b); s.open = open; s.wait = 0; }
|
||||
return open ? 'Rack open.' : 'Rack closed.';
|
||||
}
|
||||
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
|
||||
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
|
||||
blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' });
|
||||
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
|
||||
}
|
||||
const id = mach[y * W + x];
|
||||
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open. It is empty.') : 'Silo gate shut.'; }
|
||||
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open; it is empty.') : 'Silo gate shut.'; }
|
||||
if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; }
|
||||
return '';
|
||||
}
|
||||
|
||||
+136
-31
@@ -2,40 +2,101 @@
|
||||
// ---------- Blocks ----------
|
||||
function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; }
|
||||
function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; }
|
||||
function pieceCost(code, v) { return code === BP_WALL ? wallCost(v, BS * BS) : PIECE_BY_CODE[code].cost; }
|
||||
function canPlaceBlock(bx, by) {
|
||||
function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; }
|
||||
function setSlopeSeal(i, code, v) {
|
||||
if (isFilter(code)) { part[i] = P_FILTER; wakeIdx(i); }
|
||||
else {
|
||||
setCell(i, isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v], AMB[(i / W) | 0], 0);
|
||||
if (isBelt(code)) markPow(i, true);
|
||||
}
|
||||
}
|
||||
// One extra cell on the solid side seals diagonal crossings between successive triangles.
|
||||
function slopeSeal(code, bx, by) {
|
||||
code = slopeShape(code);
|
||||
const x = bx * BS + (code === BP_SLOPE_BR || code === BP_SLOPE_TR ? 0 : 3);
|
||||
const y = by * BS + (code >= BP_SLOPE_TL ? -1 : 4);
|
||||
return y < 0 || y >= H ? -1 : y * W + x;
|
||||
}
|
||||
function slopeSealOwner(i) {
|
||||
const x = i % W, y = (i / W) | 0, bx = x >> 2;
|
||||
if (((x & 3) !== 0 && (x & 3) !== 3) || ((y & 3) !== 0 && (y & 3) !== 3)) return -1;
|
||||
for (let by = (y >> 2) - 1; by <= (y >> 2) + 1; by += 2) {
|
||||
if (by < 0 || by >= BH) continue;
|
||||
const b = by * BW + bx, c = blockP[b];
|
||||
if ((isSlope(c) || isSlopeBelt(c)) && slopeSeal(c, bx, by) === i) return b;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// Does block b's own piece fill cell i (with a part, or else with a material)? A slope's seal can
|
||||
// sit in a cell its neighbour leaves open: a slope's open corner, or under a grate, screw or screen.
|
||||
function blockOwnsCell(b, i, asPart) {
|
||||
const c = blockP[b];
|
||||
if (!c || ((isSlope(c) || isSlopeBelt(c)) && !slopeCell(c, (i % W) & 3, ((i / W) | 0) & 3))) return false;
|
||||
return asPart ? isFilter(c) : !(isFilter(c) || c === BP_GRATE || c === BP_SCREW || c === BP_PIPE);
|
||||
}
|
||||
// Belt and screen cells that a removed slope left behind in an empty block (a removal bug in
|
||||
// older worlds): nothing owns them, so nothing could build there or take them away.
|
||||
function clearStraySeals() {
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (mat[i] !== BELTL && mat[i] !== BELTR && part[i] !== P_FILTER) continue;
|
||||
if (blockP[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] || slopeSealOwner(i) >= 0) continue;
|
||||
if (part[i] === P_FILTER) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); markPow(i, false); }
|
||||
}
|
||||
}
|
||||
function wallCells(code, bx, by) {
|
||||
return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3));
|
||||
}
|
||||
function canPlaceBlock(bx, by, code = BP_WALL) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
|
||||
if (blockP[by * BW + bx]) return 'Something is already built there';
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
if (mach[i] || part[i]) return 'Something is already built there';
|
||||
if (PH[mat[i]] === PH_SOLID) return 'Needs open space. Clear the rock first.';
|
||||
if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there';
|
||||
if (PH[mat[i]] === PH_SOLID && ((flags[i] & F_NAT) || slopeSealOwner(i) < 0)) return 'Needs open space; clear the rock first.';
|
||||
if (occ[i]) return 'A bar is in the way';
|
||||
}
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0 && PH[mat[i]] !== PH_SOLID && (mach[i] || part[i] || occ[i])) return 'The slope corner is blocked.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// v: wall material (loose id) for walls, direction for belts and tubes
|
||||
// v: wall material (loose id) for walls, direction for belts and screws
|
||||
function placeBlock(code, v, bx, by, free, extra) {
|
||||
const err = canPlaceBlock(bx, by); if (err) return err;
|
||||
if (code === BP_WALL && !WALL_OF[v]) return 'Pick a wall material with R';
|
||||
const err = canPlaceBlock(bx, by, code); if (err) return err;
|
||||
if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R';
|
||||
const cost = pieceCost(code, v);
|
||||
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
|
||||
const cells = blockCellList(bx, by);
|
||||
const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
|
||||
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
|
||||
for (const i of cells) {
|
||||
const y = (i / W) | 0, x = i % W;
|
||||
if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0);
|
||||
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
|
||||
if (isWall(code)) setCell(i, WALL_OF[v], AMB[y], 0);
|
||||
else if (isBelt(code)) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
|
||||
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
|
||||
else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0);
|
||||
else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0);
|
||||
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
|
||||
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
|
||||
else if (code === BP_TUBE) { part[i] = [P_TU, P_TR, P_TD, P_TL][v & 3]; markPow(i, true); wakeXY(x, y); }
|
||||
else if (code === BP_FILTER) { part[i] = P_FILTER; wakeXY(x, y); }
|
||||
else if (code === BP_SCREW) { part[i] = SCREW_PARTS[v & 7]; markPow(i, true); wakeXY(x, y); }
|
||||
else if (isFilter(code)) { part[i] = P_FILTER; wakeXY(x, y); }
|
||||
else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
|
||||
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
|
||||
}
|
||||
if (isPipeCode(code)) pipesDirty = true;
|
||||
renderDirtyXY(bx * BS, by * BS);
|
||||
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
|
||||
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||
if (isProcCode(code)) procDirty = true;
|
||||
if (code === BP_RACK) {
|
||||
const b = by * BW + bx, s = newRack();
|
||||
s.open = !!(rackState.get(b - BW)?.open || rackState.get(b + BW)?.open);
|
||||
rackState.set(b, s);
|
||||
for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
|
||||
}
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0 && PH[mat[i]] !== PH_SOLID && !part[i]) { evictCells([i], by * BS); setSlopeSeal(i, code, v); }
|
||||
}
|
||||
stats.placed++;
|
||||
undoNote({ t: 'block+', bx, by, code });
|
||||
return '';
|
||||
@@ -48,55 +109,99 @@ function blockIntegrity(b) {
|
||||
let n = 0;
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
const m = mat[i], p = part[i];
|
||||
if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
|
||||
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
|
||||
if (isWall(code) && (!isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3))) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
|
||||
else if (isBelt(code)) { if (m === BELTL || m === BELTR) n++; }
|
||||
else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
|
||||
else if (code === BP_RACK) { if (m === RACKB) n++; }
|
||||
else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; }
|
||||
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
|
||||
else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
|
||||
else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; }
|
||||
else if (code === BP_FILTER) { if (p === P_FILTER) n++; }
|
||||
else if (code === BP_SCREW) { if (IS_SCREW[p]) n++; }
|
||||
else if (isFilter(code)) { if (p === P_FILTER) n++; }
|
||||
else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
|
||||
}
|
||||
void v;
|
||||
return n;
|
||||
}
|
||||
// One cell of a wall or mold being taken back: metal is counted toward bars (returns 1), other
|
||||
// walls give their rubble back.
|
||||
function removeWallCell(i, wall) {
|
||||
const m = mat[i];
|
||||
if (BAR_OF[m] && PH[wall] === PH_ITEM) { setCell(i, EMPTY, temp[i], 0); return 1; }
|
||||
if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place
|
||||
else setCell(i, EMPTY, temp[i], 0);
|
||||
return 0;
|
||||
}
|
||||
function removeBlock(bx, by) {
|
||||
const b = by * BW + bx, code = blockP[b], v = blockV[b];
|
||||
if (!code) return 0;
|
||||
undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined });
|
||||
undoNote({ t: 'block-', bx, by, code, v, extra: isFilter(code) ? filterInfo(b) : undefined });
|
||||
// a pipe taken out spills its share of what the line holds
|
||||
const spill = isPipeCode(code) ? pipeTakeShare(b) : null;
|
||||
let n = 0, metal = 0;
|
||||
for (const i of blockCellList(bx, by)) {
|
||||
const m = mat[i], p = part[i];
|
||||
if (code === BP_WALL) {
|
||||
if (isWall(code)) {
|
||||
if (isSlope(code) && !slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)) continue;
|
||||
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
|
||||
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);
|
||||
if (isSlope(code)) setCell(i, EMPTY, temp[i], 0);
|
||||
else metal += removeWallCell(i, v);
|
||||
n++;
|
||||
}
|
||||
} else if (isPipeCode(code)) {
|
||||
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
|
||||
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
|
||||
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE) {
|
||||
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
|
||||
} else if (isBelt(code) || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
|
||||
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
|
||||
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
|
||||
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
|
||||
}
|
||||
if (isSlope(code)) {
|
||||
const cells = Math.floor(n * 8 / 10), refund = PH[v] === PH_ITEM ? Math.floor(cells / GRAINS_PER_BAR) : cells;
|
||||
giveOrDrop(v, refund, bx * BS + 2, by * BS + 2);
|
||||
|
||||
}
|
||||
if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2);
|
||||
else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
|
||||
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
|
||||
else if (!isWall(code) && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
|
||||
if (isFilter(code)) { filtersDirty = true; filterCfg.delete(b); }
|
||||
if (isProcCode(code)) { procDirty = true; procState.delete(b); }
|
||||
if (code === BP_RACK) {
|
||||
const s = rackState.get(b);
|
||||
rackState.delete(b);
|
||||
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
|
||||
}
|
||||
renderDirtyXY(bx * BS, by * BS);
|
||||
blockP[b] = 0; blockV[b] = 0;
|
||||
const cleared = blockCellList(bx, by);
|
||||
if (isSlope(code) || isSlopeBelt(code)) {
|
||||
const i = slopeSeal(code, bx, by);
|
||||
if (i >= 0) {
|
||||
const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2);
|
||||
if (!blockOwnsCell(nb, i, isFilter(code)) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } }
|
||||
cleared.push(i);
|
||||
}
|
||||
}
|
||||
for (const i of cleared) {
|
||||
const owner = slopeSealOwner(i);
|
||||
if (owner >= 0 && mat[i] === EMPTY && !part[i]) {
|
||||
setSlopeSeal(i, blockP[owner], blockV[owner]);
|
||||
}
|
||||
}
|
||||
if (isPipeCode(code)) {
|
||||
pipesDirty = true;
|
||||
if (spill) { let k = spill.n; for (let y = by * BS + BS - 1; y >= by * BS && k > 0; y--) for (let x = bx * BS; x < bx * BS + BS && k > 0; x++) { const i = y * W + x; if (mat[i] === EMPTY) { setCell(i, spill.m, spill.T, 0); k--; } } }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// Straight line of blocks along the dominant axis
|
||||
function blockLine(bx0, by0, bx1, by1) {
|
||||
// Wall drags near 45° snap to a diagonal; other lines follow the dominant axis.
|
||||
function diagonalDrag(dx, dy) { return dx !== 0 && dy !== 0 && Math.min(Math.abs(dx), Math.abs(dy)) >= Math.max(Math.abs(dx), Math.abs(dy)) * 0.7; }
|
||||
function blockLine(bx0, by0, bx1, by1, diagonal = false) {
|
||||
const out = [];
|
||||
const dx = bx1 - bx0, dy = by1 - by0;
|
||||
if (diagonal && diagonalDrag(dx, dy)) {
|
||||
for (let k = 0; k <= Math.max(Math.abs(dx), Math.abs(dy)); k++) out.push([bx0 + k * Math.sign(dx), by0 + k * Math.sign(dy)]);
|
||||
return out;
|
||||
}
|
||||
if (Math.abs(bx1 - bx0) >= Math.abs(by1 - by0)) { const s = bx1 >= bx0 ? 1 : -1; for (let x = bx0; ; x += s) { out.push([x, by0]); if (x === bx1) break; } }
|
||||
else { const s = by1 >= by0 ? 1 : -1; for (let y = by0; ; y += s) { out.push([bx0, y]); if (y === by1) break; } }
|
||||
return out;
|
||||
@@ -106,11 +211,11 @@ function blockRect(bx0, by0, bx1, by1) {
|
||||
for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]);
|
||||
return out;
|
||||
}
|
||||
function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS / 2); }
|
||||
function blockInReach(bx, by) { return inBuildRange(bx * BS + BS / 2, by * BS + BS / 2); }
|
||||
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
|
||||
function blockIs(b, code, v, extra) {
|
||||
if (blockP[b] !== code) return false;
|
||||
if (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
|
||||
if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
|
||||
return blockV[b] === v;
|
||||
}
|
||||
// replace: a block already built in the way is taken down (and refunded) to make room
|
||||
@@ -121,7 +226,7 @@ function placeBlocks(code, v, list, extra, replace) {
|
||||
const b = by * BW + bx;
|
||||
if (replace && blockP[b]) {
|
||||
if (blockIs(b, code, v, extra)) continue;
|
||||
if (blockP[b] === BP_FILTER && code === BP_FILTER) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
|
||||
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
|
||||
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
|
||||
removeBlock(bx, by);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ function copyRegion(x0, y0, x1, y1) {
|
||||
const blocks = [], ms = [], ols = [];
|
||||
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(blockP[b] === BP_FILTER ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
|
||||
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(isFilter(blockP[b]) ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
|
||||
}
|
||||
for (const mm of machines) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
|
||||
@@ -23,11 +23,11 @@ function copyRegion(x0, y0, x1, y1) {
|
||||
}
|
||||
function flipBlueprint(bp) {
|
||||
const bw = bp.bw;
|
||||
const flipV = (code, v) => code === BP_BELT ? 1 - v : code === BP_FILTER ? v ^ 128 : code === BP_TUBE ? (v === 1 ? 3 : v === 3 ? 1 : v)
|
||||
const flipV = (code, v) => isBelt(code) ? 1 - v : isFilter(code) ? v ^ 128 : code === BP_SCREW ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7]
|
||||
: code === BP_PUMP || code === BP_OUTLET ? (((v & 3) === 1 ? 3 : (v & 3) === 3 ? 1 : v & 3) | (v & 4)) : v;
|
||||
return {
|
||||
v: 2, bw, bh: bp.bh, name: bp.name,
|
||||
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, c, flipV(c, v), e] : [bw - 1 - x, y, c, flipV(c, v)]),
|
||||
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]),
|
||||
machines: bp.machines.map(([t, x, y, d]) => {
|
||||
const P = PIECE_BY_ID[t];
|
||||
let nd = d;
|
||||
@@ -80,7 +80,7 @@ function footprintBlocker(bx, by, bw, bh) {
|
||||
for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) {
|
||||
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 (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';
|
||||
|
||||
+93
-46
@@ -12,6 +12,9 @@ function setCell(i, m, t, fl) {
|
||||
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
|
||||
}
|
||||
function swapCells(i, j) {
|
||||
renderDirtyIdx(i); renderDirtyIdx(j); swapCellData(i, j);
|
||||
}
|
||||
function swapCellData(i, j) {
|
||||
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
|
||||
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
|
||||
a = life[i]; life[i] = life[j]; life[j] = a;
|
||||
@@ -19,9 +22,16 @@ function swapCells(i, j) {
|
||||
stamp[i] = tickStamp; stamp[j] = tickStamp;
|
||||
}
|
||||
function move(i, j, x, y, nx, ny) {
|
||||
swapCells(i, j);
|
||||
swapCellData(i, j);
|
||||
wakeXY(x, y); wakeXY(nx, ny);
|
||||
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
|
||||
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) {
|
||||
// a top-layer grain stepping sideways may clear the path to a drop for surface grains far
|
||||
// along the row, so wake them; moves inside a body of water do not (settled lakes must sleep)
|
||||
const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW;
|
||||
const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5;
|
||||
for (let cx = first; cx <= last; cx++) chunkWake(row + cx);
|
||||
}
|
||||
if (part[j] === P_WHEEL) { if (chunkWork) chunkEvent('flow', mach[j], PH[mat[j]] === PH_LIQUID ? 1 : 0.5); else { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } }
|
||||
const d = temp[j] - AMB[ny];
|
||||
if (d > 25 || d < -25) wakeHeatXY(nx, ny);
|
||||
}
|
||||
@@ -29,10 +39,15 @@ function move(i, j, x, y, nx, ny) {
|
||||
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
|
||||
// 0 = no, 1 = into empty, 2 = by displacing what is there.
|
||||
function enter(m, ph, j, dx, dy) {
|
||||
if (ph === PH_POWDER && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W]))) return 0;
|
||||
return enterFree(m, ph, j, dx, dy);
|
||||
}
|
||||
// Powder already knows whether its chunk has barriers; other callers use enter.
|
||||
function enterFree(m, ph, j, dx, dy) {
|
||||
if (occ[j]) return 0;
|
||||
const p = part[j];
|
||||
if (p !== 0) {
|
||||
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return 0; } // liquids and gases flow through freely
|
||||
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return 0; } // liquids and gases flow through freely
|
||||
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return 0; } // thick slag does not run through a grate
|
||||
else if (p === P_INTAKE) { if (ph !== PH_GAS) return 0; }
|
||||
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return 0; }
|
||||
@@ -52,25 +67,45 @@ function enter(m, ph, j, dx, dy) {
|
||||
}
|
||||
|
||||
// ---------- Movement ----------
|
||||
// Heavy grains settle through a loose pile of much lighter ones, slowly. A sorting
|
||||
// Heavy grains settle through a loose pile of much lighter ones. A sorting
|
||||
// step keeps its area awake for a while so a pile finishes sorting even at rest.
|
||||
let SORTP = 0.003;
|
||||
function powder(i, x, y, m) {
|
||||
// Through burning fuel it stays slow: a furnace bed is tuned to it, and dirt heaped on a burning
|
||||
// pile has to smother it into charcoal, not sink through.
|
||||
let SORTP = 0.06;
|
||||
const SORTP_FUEL = 0.003;
|
||||
// Liquids step up to LSTEP cells toward a drop in one tick (fewer the thicker they are), and a top-layer grain looks LFILM
|
||||
// times as far for one (a two-times range strands the far end of a 200-grain-wide ledge).
|
||||
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
|
||||
let LSTEP = 8, LFILM = 4;
|
||||
let CORNER_SEAL = true;
|
||||
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
|
||||
if (y >= H - 1) return;
|
||||
const b = i + W;
|
||||
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
|
||||
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
|
||||
const t = mat[b];
|
||||
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < SORTP) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
|
||||
if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); chunkWake((y >> 5) * CW + (x >> 5), 40); return; }
|
||||
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
|
||||
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
|
||||
if (rnd() < STICK[m]) {
|
||||
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
|
||||
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
|
||||
// Feed a receiving screw mouth sideways instead of falling under its bottom casing.
|
||||
if (t === BELTL || t === BELTR) {
|
||||
const b = ((y + 1) >> 2) * BW + (x >> 2);
|
||||
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
|
||||
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
|
||||
if ((!barriers || !cleatMask[i + d]) && enterFree(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// stickiness is grain against grain: on anything built (a wall, a slope, a belt) every material
|
||||
// slides freely, so a chute carries clay as well as sand while clay heaps stay steep
|
||||
if (rnd() < STICK[m] || (PH[t] === PH_SOLID && !(flags[b] & F_NAT))) {
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b + d])) && enterFree(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && (!barriers || (!cleatMask[i] && !cleatMask[b - d])) && enterFree(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
}
|
||||
}
|
||||
function passable(m, ph, j, dx, dy) {
|
||||
@@ -78,7 +113,7 @@ function passable(m, ph, j, dx, dy) {
|
||||
if (occ[j]) return false;
|
||||
const p = part[j];
|
||||
if (p !== 0) {
|
||||
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return false; }
|
||||
if (IS_SCREW[p]) { if (ph === PH_POWDER && !screwAccepts(p, dx, dy)) return false; }
|
||||
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return false; }
|
||||
else if (p === P_INTAKE) { if (ph !== PH_GAS) return false; }
|
||||
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return false; }
|
||||
@@ -92,29 +127,38 @@ function liquid(i, x, y, m) {
|
||||
if (enter(m, PH_LIQUID, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
||||
}
|
||||
if (rnd() > FLUID[m]) return;
|
||||
const sp = SPREAD[m];
|
||||
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
|
||||
const d0 = (RS & 4) ? 1 : -1;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
liquidSearch(i, x, y, m, sp, d0);
|
||||
}
|
||||
function liquidSearch(i, x, y, m, sp, d0, firstPass = 0, firstStep = 1) {
|
||||
for (let pass = firstPass; pass < 2; pass++) {
|
||||
const d = pass ? -d0 : d0;
|
||||
for (let s = 1; s <= sp; s++) {
|
||||
for (let s = pass === firstPass ? firstStep : 1; s <= sp; s++) {
|
||||
const nx = x + d * s;
|
||||
if (nx < 0 || nx >= W) break;
|
||||
// Same-colour chunks are separated by 32 cells; their maximum write halo is 8.
|
||||
if (chunkWork && (nx < chunkWork.x0 - 24 || nx >= chunkWork.x1 + 24)) {
|
||||
chunkEvent('liquid', i, m, sp, d0, pass, s); return;
|
||||
}
|
||||
const j = i + d * s;
|
||||
if (!passable(m, PH_LIQUID, j, d, 0)) break;
|
||||
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
|
||||
// step one cell toward the drop (keeps flow continuous and local)
|
||||
move(i, i + d, x, y, x + d, y);
|
||||
const k = Math.min(s, Math.max(1, (LSTEP * FLUID[m]) | 0)); // thick liquids take shorter steps
|
||||
move(i, i + d * k, x, y, x + d * k, y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function gas(i, x, y, m) {
|
||||
// Even blocked gas must keep aging or condensing until it is gone.
|
||||
chunkWake((y >> 5) * CW + (x >> 5));
|
||||
if (m === FIRE) {
|
||||
const l = life[i];
|
||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||
@@ -127,7 +171,7 @@ function gas(i, x, y, m) {
|
||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||
life[i] = l - 1;
|
||||
}
|
||||
}
|
||||
} else if (m === STEAM && temp[i] < CONDT[STEAM]) wakeHeatXY(x, y); // cooled steam drifting under a roof needs the heat pass to condense it
|
||||
if (y === 0 || (m === STEAM && y <= weather.skyY + 4 && y < roofY[x])) {
|
||||
// steam that has risen to cloud height in the open sky joins the clouds
|
||||
if (m === STEAM) absorbSteam(x);
|
||||
@@ -137,40 +181,43 @@ function gas(i, x, y, m) {
|
||||
if (enter(m, PH_GAS, u, 0, -1)) { move(i, u, x, y, x, y - 1); return; }
|
||||
const d = (RS & 2) ? 1 : -1;
|
||||
let nx = x + d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
|
||||
nx = x - d;
|
||||
if (nx >= 0 && nx < W && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
|
||||
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
|
||||
if (rnd() < 0.6) {
|
||||
nx = x + d;
|
||||
if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); }
|
||||
}
|
||||
}
|
||||
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);
|
||||
function wakeBotPowder() {
|
||||
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
|
||||
const x0 = Math.max(0, Math.floor((x - 100) / 32)), x1 = Math.min(CW - 1, Math.floor((x + 100) / 32));
|
||||
const y0 = Math.max(0, Math.floor((y - 100) / 32)), y1 = Math.min(CH - 1, Math.floor((y + 100) / 32));
|
||||
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
|
||||
const c = cy * CW + cx;
|
||||
if (!powderArea[c] || awake[c]) continue;
|
||||
const dx = Math.max(cx * 32 - x, 0, x - (cx * 32 + 31));
|
||||
const dy = Math.max(cy * 32 - y, 0, y - (cy * 32 + 31));
|
||||
if (dx * dx + dy * dy <= 10000) awake[c] = 1;
|
||||
}
|
||||
}
|
||||
function simMove() { chunkPasses(false); }
|
||||
function moveChunk(c) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
|
||||
const barriers = cleatChunks[c];
|
||||
powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
|
||||
for (let y = y1 - 1; y >= y0; y--) {
|
||||
const ltr = ((y + tick) & 1) === 0;
|
||||
const yw = y * W;
|
||||
for (let k = 0; k < CW; k++) {
|
||||
const cx = ltr ? k : CW - 1 - k;
|
||||
if (!awake[rowBase + cx]) continue;
|
||||
const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
|
||||
// alternate rows scan left to right and right to left, so nothing drifts one way
|
||||
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
|
||||
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
|
||||
const ltr = ((y + tick) & 1) === 0, dx = ltr ? 1 : -1, end = ltr ? x1 : x0 - 1, yw = y * W;
|
||||
for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
|
||||
const i = yw + x, m = mat[i];
|
||||
if (m === EMPTY || stamp[i] === tickStamp) continue;
|
||||
if (m === EMPTY) 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);
|
||||
}
|
||||
}
|
||||
if (ph === PH_POWDER) powderArea[c] = 1;
|
||||
if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
|
||||
if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
|
||||
if (ph === PH_POWDER) powder(i, x, y, m, barriers);
|
||||
else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
-23
@@ -1,10 +1,28 @@
|
||||
// How belts, filters and screw tubes move what is on or in them.
|
||||
// How belts, filters and screw conveyors move what is on or in them.
|
||||
// What rests on a belt or filter rides along: the whole stack standing on the carrying surface
|
||||
// steps one cell toward d together (belts run front of the line first, so the way ahead is
|
||||
// clear), 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.
|
||||
// clear). If a powder grain is blocked, the load above it stands still and the belt slips
|
||||
// underneath. A filter's own material drops through it instead, unless it is already full.
|
||||
const CARRY_DEPTH = 96;
|
||||
// Find the next carrying face at the same height or one grain above/below it.
|
||||
function conveyorStep(x, y, d) {
|
||||
const nx = x + d;
|
||||
if (nx < 0 || nx >= W) return 0;
|
||||
if ((x >> 2) === (nx >> 2)) return beltGrade(blockP[(y >> 2) * BW + (x >> 2)]) * d;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const yy = y + dy;
|
||||
if (yy < 0 || yy >= H) continue;
|
||||
const b = (yy >> 2) * BW + (nx >> 2), c = blockP[b], i = yy * W + nx;
|
||||
if ((isBelt(c) || isFilter(c)) && beltFaceY(c, nx >> 2, yy >> 2, nx) === yy &&
|
||||
(mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER)) return dy;
|
||||
}
|
||||
const b = (y >> 2) * BW + (x >> 2);
|
||||
return beltGrade(blockP[b]) * d;
|
||||
}
|
||||
function carryColumn(a, d, pass) {
|
||||
if (a < 0 || mat[a] === EMPTY) return;
|
||||
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d), carrier = (sy >> 2) * BW + ((a % W) >> 2);
|
||||
let blocked = false;
|
||||
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
|
||||
if (a < 0) return;
|
||||
const m = mat[a];
|
||||
@@ -16,17 +34,24 @@ function carryColumn(a, d, pass) {
|
||||
const u = mat[a + W];
|
||||
if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; }
|
||||
}
|
||||
if (blocked) continue; // filters still sift above a stopped load
|
||||
const x = a % W, nx = x + d;
|
||||
if (nx < 0 || nx >= W) return;
|
||||
const j = a + d, tj = mat[j];
|
||||
const j = a + d + step * W;
|
||||
if (j < 0 || j >= N) return;
|
||||
const tj = mat[j];
|
||||
// grains on a wet belt push through the water rather than stopping at it
|
||||
const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN);
|
||||
if (!wet && !enter(m, PH_POWDER, j, d, 0)) continue;
|
||||
if (!wet && !enter(m, PH_POWDER, j, d, step)) {
|
||||
if (!pass) return;
|
||||
blocked = true; continue;
|
||||
}
|
||||
const y = (a / W) | 0, bj = j + W;
|
||||
// carried off the end with nothing under it: it leaves at belt speed and falls
|
||||
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0);
|
||||
else move(a, j, x, y, nx, y);
|
||||
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5);
|
||||
else move(a, j, x, y, nx, y + step);
|
||||
stats.moved++;
|
||||
if (flowOn && (x >> 2) !== (nx >> 2)) flowNote(carrier);
|
||||
}
|
||||
}
|
||||
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
|
||||
@@ -38,7 +63,7 @@ function beltDrain(b) {
|
||||
if (!passable(mat[a], PH_LIQUID, d, 0, 1)) return;
|
||||
move(a, d, x, (a / W) | 0, x, (d / W) | 0);
|
||||
}
|
||||
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a tube mouth
|
||||
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a screw mouth
|
||||
function launchGrain(i, x, y, vx, vy) {
|
||||
const m = mat[i], T = temp[i];
|
||||
mat[i] = EMPTY; flags[i] = 0; life[i] = 0; stamp[i] = tickStamp;
|
||||
@@ -48,23 +73,49 @@ function launchGrain(i, x, y, vx, vy) {
|
||||
let filterR = [], filterL = [], filtersDirty = true;
|
||||
function rebuildFilters() {
|
||||
filtersDirty = false; filterR = []; filterL = [];
|
||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b);
|
||||
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) (blockV[b] & 128 ? filterR : filterL).push(b);
|
||||
// front of the line first, so a full run moves as one
|
||||
filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW));
|
||||
}
|
||||
function filterCarry(b, d) {
|
||||
if (blockP[b] !== BP_FILTER) return;
|
||||
const bx = b % BW, y = ((b / BW) | 0) * BS;
|
||||
function filterCarry(b, d, carrying = true) {
|
||||
if (!isFilter(blockP[b])) return;
|
||||
const bx = b % BW, by = (b / BW) | 0, code = blockP[b], y = by * BS;
|
||||
if (y < 1) return;
|
||||
if (!carrying && !isSlopeBelt(code)) return;
|
||||
const c = filterCfg.get(b), pass = c ? c.pass : null;
|
||||
for (let k = 0; k < BS; k++) {
|
||||
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x;
|
||||
if (part[top] === P_FILTER) carryColumn(top - W, d, pass);
|
||||
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
|
||||
if (part[top] === P_FILTER) {
|
||||
if (isSlopeBelt(code)) screenNudge(top, d, pass);
|
||||
else if (carrying) carryColumn(top - W, d, pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Belts and filters share one front-first order, including their flat/slope joins.
|
||||
function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
|
||||
let k = 0, f = 0;
|
||||
while (k < belts.length || f < filters.length) {
|
||||
const fx = f < filters.length ? (filters[f] % BW) * BS + (d > 0 ? BS - 1 : 0) : 0;
|
||||
if (f < filters.length && (k >= belts.length || (fx - belts[k] % W) * d > 0)) { filterCarry(filters[f++], d, carrying); continue; }
|
||||
const b = belts[k++];
|
||||
if (mat[b] !== bm || b < W) continue;
|
||||
const x = b % W, y = (b / W) | 0, code = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
if (!carrying && !isSlopeBelt(code)) continue;
|
||||
if (isSlopeBelt(code) && beltFaceY(code, x >> 2, y >> 2, x) !== y) continue;
|
||||
if (slopeSealOwner(b) >= 0) continue;
|
||||
const above = mat[b - W];
|
||||
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
|
||||
if (carrying) {
|
||||
if (PH[above] === PH_LIQUID) beltDrain(b);
|
||||
// uphill, the cleats do the carrying
|
||||
if (!isSlopeBelt(code) || !slopeUphill(code, blockV[(y >> 2) * BW + (x >> 2)])) carryColumn(b - W, d, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
|
||||
function setFilter(b, cfg) {
|
||||
if (blockP[b] !== BP_FILTER) return false;
|
||||
renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS);
|
||||
if (!isFilter(blockP[b])) return false;
|
||||
const rec = filterRec(cfg);
|
||||
filterCfg.set(b, rec);
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
@@ -74,11 +125,13 @@ function setFilter(b, cfg) {
|
||||
}
|
||||
// The run of filters in a row that touch block b and do the same thing it does
|
||||
function filterRun(b) {
|
||||
if (blockP[b] !== BP_FILTER) return [];
|
||||
if (!isFilter(blockP[b])) return [];
|
||||
const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b];
|
||||
for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) {
|
||||
const c = by * BW + bx;
|
||||
if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
|
||||
const yy = by + beltGrade(blockP[b]) * (bx - (b % BW));
|
||||
if (yy < 0 || yy >= BH) break;
|
||||
const c = yy * BW + bx;
|
||||
if (blockP[c] !== blockP[b] || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
|
||||
out.push(c);
|
||||
}
|
||||
return out.sort((a, c) => a - c);
|
||||
@@ -86,24 +139,75 @@ function filterRun(b) {
|
||||
|
||||
// A cell a grain can be pushed into: empty, or liquid or gas that simply trades places with it
|
||||
function roomFor(j) { const t = mat[j]; return t === EMPTY || PH[t] === PH_GAS || PH[t] === PH_LIQUID; }
|
||||
// Cross one screw block, far lanes first; corners retain their liquid/gas swaps.
|
||||
function screwSpread(c, x, y, sx, sy, intake = false) {
|
||||
const p = part[c], bx = x >> 2, by = y >> 2;
|
||||
for (;;) {
|
||||
const nx = x + sx, ny = y + sy;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H || (nx >> 2) !== bx || (ny >> 2) !== by) break;
|
||||
const j = ny * W + nx;
|
||||
if (part[j] !== p || occ[j] || (intake ? mat[j] !== EMPTY || stamp[j] === tickStamp : !roomFor(j))) break;
|
||||
move(c, j, x, y, nx, ny); c = j; x = nx; y = ny;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
// An empty screw cell draws in loose material from behind it or from its sides
|
||||
// (not from the cell it pushes into), so a tube end sitting in a pile feeds itself.
|
||||
function tubeIntake(i, p, dx, dy) {
|
||||
// (not from the cell it pushes into), so a screw end sitting in a pile feeds itself.
|
||||
function screwIntake(i, p, dx, dy) {
|
||||
if (occ[i]) return;
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]];
|
||||
const start = (tick + i) % 3;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const [ox, oy] = cands[(start + c) % 3];
|
||||
const k = (start + c) % 3, diagonal = dx && dy;
|
||||
const ox = diagonal ? (k === 1 ? 0 : -dx) : k === 0 ? -dx : k === 1 ? dy : -dy;
|
||||
const oy = diagonal ? (k === 0 ? 0 : -dy) : k === 0 ? -dy : k === 1 ? dx : -dx;
|
||||
const nx = x + ox, ny = y + oy;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
if (ox && oy && PH[mat[y * W + nx]] === PH_SOLID && PH[mat[ny * W + x]] === PH_SOLID) continue;
|
||||
const j = ny * W + nx, m = mat[j];
|
||||
if (m === EMPTY || occ[j] || stamp[j] === tickStamp) continue;
|
||||
if (PH[m] !== PH_POWDER) continue; // the screw only takes loose material; liquids just flow through
|
||||
if (stamp[i] === tickStamp) return;
|
||||
const pj = part[j];
|
||||
if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents
|
||||
if (IS_SCREW[pj]) continue; // other screw cells move their own contents
|
||||
if (pj === P_GRATE || pj === P_FILTER) continue;
|
||||
move(j, i, nx, ny, x, y);
|
||||
if (diagonal) screwSpread(i, x, y, 0, (y & 3) < 2 ? 1 : -1, true);
|
||||
else if (ox !== -dx || oy !== -dy) screwSpread(i, x, y, -ox, -oy, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Powder may enter any screw except one pushing exactly against it.
|
||||
function screwAccepts(p, dx, dy) {
|
||||
return SCREW_DX[p] !== -dx || SCREW_DY[p] !== -dy;
|
||||
}
|
||||
// Straight mouths retain side-feeding into lifts; only diagonal transfers need a join.
|
||||
function straightScrewNext(i, p) {
|
||||
const dx = SCREW_DX[p], dy = SCREW_DY[p], x = i % W, y = (i / W) | 0;
|
||||
const xx = x + dx, yy = y + dy, next = yy * W + xx;
|
||||
if ((xx >> 2) === (x >> 2) && (yy >> 2) === (y >> 2)) return next;
|
||||
const q = part[next];
|
||||
if (!(SCREW_DX[q] && SCREW_DY[q])) return next;
|
||||
const join = endJoin(x >> 2, y >> 2, dx + dy > 0 ? 1 : 0);
|
||||
return join >= 0 && (join >> 1) === (yy >> 2) * BW + (xx >> 2) ? next : -1;
|
||||
}
|
||||
// Four lanes cross a block in four beats; only a mutual geometric join can receive them.
|
||||
function diagonalScrewNext(i, p) {
|
||||
const x = i % W, y = (i / W) | 0, dx = SCREW_DX[p], dy = SCREW_DY[p];
|
||||
const xx = x + dx;
|
||||
if (xx < 0 || xx >= W) return -1;
|
||||
if ((xx >> 2) === (x >> 2)) return i + dx;
|
||||
const yy = y + dy * BS, join = endJoin(x >> 2, y >> 2, dx > 0 ? 1 : 0);
|
||||
if (join >= 0) {
|
||||
const nb = join >> 1, nx = nb % BW * BS, ny = ((nb / BW) | 0) * BS;
|
||||
if (blockP[nb] === BP_BELT || blockP[nb] === BP_FILTER) return (ny - 1) * W + xx;
|
||||
if (blockP[nb] === BP_SCREW) {
|
||||
const v = blockV[nb];
|
||||
if (v >= 4) return yy * W + xx;
|
||||
if (v & 1) return (ny + (y & 3)) * W + xx;
|
||||
return (ny + (dy > 0 ? 0 : 3)) * W + nx + (y & 3);
|
||||
}
|
||||
}
|
||||
return yy >= 0 && yy < H && !IS_SCREW[part[yy * W + xx]] ? yy * W + xx : -1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Dev commands are pure simulation operations; the browser owns the panel and confirmation.
|
||||
let devWorld = false, devLog = [], devFree = false, devSpeed = 1, devPaused = false, devSteps = 0;
|
||||
function resetDevConsole() {
|
||||
devWorld = false; devLog = []; devFree = false; devSpeed = 1; devPaused = false; devSteps = 0;
|
||||
}
|
||||
function enableDevConsole() { devWorld = true; }
|
||||
const DEV_HELP = [
|
||||
'help — list commands',
|
||||
'money <n> | money set <n> — add or set money; negative amounts allowed',
|
||||
'debt <n> | debt clear — set or clear claim debt',
|
||||
'give <material> [n] — add grains or items (default 1000); display or internal name, unique prefix',
|
||||
'bit [tier] — list or set drill tiers (numbered from 0)',
|
||||
'bag [tier] — list or set bag tiers (numbered from 0)',
|
||||
'unlock — learn all materials and unlock progress rewards',
|
||||
'tp [x y] — teleport in grains; no coordinates uses the cursor',
|
||||
'free on|off — build without spending money or materials',
|
||||
'speed <x> — speed multiplier (0.25, 0.5, 1, 2, 4; clamped to 0.1–8)',
|
||||
'pause — toggle dev pause',
|
||||
'step [n] — queue 1–10000 ticks while dev paused (default 1)',
|
||||
'weather clear|rain — stop current rain or start a one-minute shower',
|
||||
];
|
||||
// Exact internal names win when a display name names a different material.
|
||||
const DEV_MATERIALS = { EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, WOOD, LEAVES, COALV, COAL, CUV, CUORE, FEV, FEORE, ASH, SLAG, BRICK, GLASS, COPPER, IRON, STEEL, WATER, MAGMA, MCU, MFE, MST, MGL, STEAM, SMOKE, FIRE, BELTL, BELTR, SHAFT, GATE, HULL, SPRING, RSTONE, RGRAN, RBAS, LCLAY, LWOOD, RBRICK, CULLET, SCRCU, SCRFE, SCRST, BARCU, BARFE, BARST, ACORN, CHARCOAL, SNOW, ICE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS, DOCKW, MSLAG, PIPE, BARGL, CUTST, BARSB, GRINDER, SAWB, PLANK, PRESSB, GREENB, BARBR, RACKB };
|
||||
function devMaterial(name) {
|
||||
name = name.toLowerCase();
|
||||
const ids = Object.entries(DEV_MATERIALS);
|
||||
const internal = ids.find(([key]) => key.toLowerCase() === name);
|
||||
if (internal) return internal[1];
|
||||
const exact = ids.filter(([, id]) => MD[id].name.toLowerCase() === name);
|
||||
const matches = [...new Set((exact.length ? exact : ids.filter(([key, id]) => key.toLowerCase().startsWith(name) || MD[id].name.toLowerCase().startsWith(name))).map(([, id]) => id))];
|
||||
return matches.length === 1 ? matches[0] : matches.length ? `Ambiguous material: ${matches.map(id => MD[id].name + ' (' + ids.find(([, m]) => m === id)[0] + ')').join(', ')}.` : 'Unknown material. Use a display name or internal name, such as copper ore or CUORE.';
|
||||
}
|
||||
function devCommand(text, cursor) {
|
||||
if (typeof text !== 'string' || !text.trim()) return ['Type help to list commands.'];
|
||||
text = text.trim();
|
||||
const [cmd, ...args] = text.toLowerCase().split(/\s+/);
|
||||
const usage = () => ['Usage: ' + DEV_HELP.find(s => s.startsWith(cmd + ' '))];
|
||||
const number = s => s !== undefined && Number.isFinite(Number(s));
|
||||
if (cmd === 'help' && !args.length) return DEV_HELP.slice();
|
||||
if (!DEV_HELP.some(s => s.startsWith(cmd + ' '))) return ['Unknown command. Type help to list commands.'];
|
||||
if (!devWorld) return ['Open the dev console and accept the world warning first.'];
|
||||
let result;
|
||||
switch (cmd) {
|
||||
case 'money': {
|
||||
const set = args[0] === 'set', value = Number(args[set ? 1 : 0]);
|
||||
if (args.length !== (set ? 2 : 1) || !number(args[set ? 1 : 0]) || !number(String(set ? value : money + value))) return usage();
|
||||
money = set ? value : money + value; result = 'Money: ' + fmtMoney(money); break;
|
||||
}
|
||||
case 'debt':
|
||||
if (args.length !== 1 || (args[0] !== 'clear' && (!number(args[0]) || Number(args[0]) < 0))) return usage();
|
||||
debt = args[0] === 'clear' ? 0 : Number(args[0]);
|
||||
result = debt ? 'Claim debt: ' + fmtMoney(debt) : 'Claim paid off.'; break;
|
||||
case 'give': {
|
||||
if (!args.length) return usage();
|
||||
let n = 1000;
|
||||
if (number(args[args.length - 1])) n = Number(args.pop());
|
||||
if (!args.length || !Number.isInteger(n) || n <= 0) return usage();
|
||||
const m = devMaterial(args.join(' '));
|
||||
if (typeof m === 'string') return [m];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || (PH[m] === PH_SOLID && !YLD[m])) return [`${MD[m].name} cannot go in the bag.`];
|
||||
if (n > 2147483647 - bag[m]) return ['Too many: a material count cannot exceed 2147483647.'];
|
||||
const into = PH[m] === PH_SOLID && YLD[m] ? YLD[m] : m;
|
||||
bagAdd(m, n, true); result = `Added ${n} ${MD[into].name}${into !== m ? ` (as ${MD[m].name.toLowerCase()} breaks up)` : ''}.`; break;
|
||||
}
|
||||
case 'bit': case 'bag': {
|
||||
const tiers = cmd === 'bit' ? BIT_TIERS : TANK_TIERS;
|
||||
if (!args.length) return tiers.map((t, i) => `${i}: ${t.name}${t.cap ? ' (' + t.cap + ' grains)' : ''}`);
|
||||
if (args.length !== 1 || !number(args[0]) || !Number.isInteger(Number(args[0]))) return usage();
|
||||
const tier = Math.max(0, Math.min(tiers.length - 1, Number(args[0])));
|
||||
player[cmd === 'bit' ? 'bitTier' : 'tankTier'] = tier;
|
||||
result = `${cmd}: ${tier} — ${tiers[tier].name}`; break;
|
||||
}
|
||||
case 'unlock':
|
||||
if (args.length) return usage();
|
||||
seen.fill(1); player.lens = player.flask = true;
|
||||
for (const m of MILESTONES) ledger[m.id] = ledger[m.id] || tick || 1;
|
||||
result = 'All materials learned and progress rewards unlocked.'; break;
|
||||
case 'tp': {
|
||||
if (args.length !== 0 && (args.length !== 2 || !args.every(number))) return usage();
|
||||
const x = args.length ? Number(args[0]) : cursor && cursor.x, y = args.length ? Number(args[1]) : cursor && cursor.y;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return ['No cursor position. Use tp <x> <y>.'];
|
||||
bot.x = Math.max(0, Math.min(W - bot.w, x)); bot.y = Math.max(0, Math.min(H - bot.h, y)); bot.vx = bot.vy = 0;
|
||||
fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
result = `Bot: ${bot.x}, ${bot.y} grains`; break;
|
||||
}
|
||||
case 'free':
|
||||
if (args.length !== 1 || !['on', 'off'].includes(args[0])) return usage();
|
||||
devFree = args[0] === 'on'; result = 'Free building ' + args[0] + '.'; break;
|
||||
case 'speed':
|
||||
if (args.length !== 1 || !number(args[0])) return usage();
|
||||
devSpeed = Math.max(0.1, Math.min(8, Number(args[0]))); result = 'Speed: ' + devSpeed + '×'; break;
|
||||
case 'pause':
|
||||
if (args.length) return usage();
|
||||
devPaused = !devPaused; devSteps = 0; result = devPaused ? 'Dev paused. Use step or pause to resume.' : 'Dev pause off.'; break;
|
||||
case 'step': {
|
||||
const n = args.length ? Number(args[0]) : 1;
|
||||
if (args.length > 1 || !Number.isInteger(n) || n < 1 || n > 10000 || devSteps + n > 10000) return usage();
|
||||
if (!devPaused) return ['Use pause before step.'];
|
||||
devSteps += n; result = `Queued ${n} ticks.`; break;
|
||||
}
|
||||
case 'weather':
|
||||
if (args.length !== 1 || !['clear', 'rain'].includes(args[0])) return usage();
|
||||
weather.shower = args[0] === 'rain' ? { x0: Math.max(0, bot.x - 160), x1: Math.min(W, bot.x + 160), rate: 2, ramp: 1, until: tick + 3600 } : null;
|
||||
if (args[0] === 'clear') {
|
||||
weather.raining.fill(0);
|
||||
for (const f of flyers) if (f.rain) absorbSteam(f.x);
|
||||
flyers = flyers.filter(f => !f.rain);
|
||||
}
|
||||
weather.next = tick + rainGap(); result = args[0] === 'rain' ? 'A one-minute shower started near the bot.' : 'Current rain cleared. Stored clouds may rain again.'; break;
|
||||
default: return usage();
|
||||
}
|
||||
devLog.push([tick, text]); if (devLog.length > 200) devLog.shift();
|
||||
return ['> ' + text, result];
|
||||
}
|
||||
+22
-6
@@ -28,7 +28,7 @@ function pulseRay(ox, oy, ang, Wd, f) {
|
||||
}
|
||||
// Is a solid cell something the player built (or a part of a machine or mold)? Pulses leave those alone.
|
||||
function builtCell(i, x, y) {
|
||||
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
|
||||
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || slopeSealOwner(i) >= 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
|
||||
}
|
||||
// One press-and-hold step: fires a pulse when one is due. Returns { fired, status }.
|
||||
function toolPulse(ax, ay, width, tune) {
|
||||
@@ -43,7 +43,7 @@ function toolPulse(ax, ay, width, tune) {
|
||||
// every cell in the cone is hit, with no untouched stripes between the rays
|
||||
drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 });
|
||||
lastPulseTick = tick;
|
||||
// say why nothing breaks: the first solid on the centre line is too hard, or out of reach
|
||||
// say why nothing breaks: the first solid on the centre line is too hard
|
||||
let status = '';
|
||||
for (let k = 3; k < reach; k++) {
|
||||
const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k);
|
||||
@@ -53,11 +53,10 @@ function toolPulse(ax, ay, width, tune) {
|
||||
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}.`;
|
||||
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard; 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;
|
||||
@@ -87,7 +86,8 @@ function crumbleAround(x, y) {
|
||||
for (const j of got) shatterCell(j, j % W, (j / W) | 0, mat[j]);
|
||||
}
|
||||
}
|
||||
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && (flags[i] & F_NAT) !== 0 && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
|
||||
// Any solid nothing owns can be drilled: natural rock, and rock or metal that set loose in the world.
|
||||
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
|
||||
function shatterCell(i, x, y, m) {
|
||||
const to = YLD[m];
|
||||
setCell(i, to, temp[i], 0);
|
||||
@@ -151,7 +151,7 @@ function pulseTick() {
|
||||
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
|
||||
if (h > tier.hard) { e *= 0.35; continue; }
|
||||
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
|
||||
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q;
|
||||
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
|
||||
e *= p.tune ? 0.96 : p.damp;
|
||||
}
|
||||
p.en[k] = e; p.s[k] = sd;
|
||||
@@ -159,3 +159,19 @@ function pulseTick() {
|
||||
}
|
||||
drillPulses = drillPulses.filter(p => p.r < p.range + 3);
|
||||
}
|
||||
// Resonance ping (t50): an echo from the bot that finds ore veins in the rock around it, under
|
||||
// fog too. Reach grows with the bit. Only reads the world; the UI draws the echo.
|
||||
const PING_VEINS = new Uint8Array(NMAT); for (const m of [COALV, CUV, FEV, SNV, ZNV, AGV, AUV]) PING_VEINS[m] = 1;
|
||||
function pingRadius() { return BIT_TIERS[player.bitTier].reach * 3; }
|
||||
// Natural vein cells within r of (cx, cy), and the materials found.
|
||||
function resonancePing(cx, cy, r = pingRadius()) {
|
||||
const cells = [], found = new Set(), r2 = r * r;
|
||||
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(W - 1, Math.ceil(cx + r));
|
||||
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
|
||||
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx, dy = y - cy, i = y * W + x, m = mat[i];
|
||||
if (dx * dx + dy * dy > r2 || !PING_VEINS[m] || !(flags[i] & F_NAT)) continue;
|
||||
cells.push(i); found.add(m);
|
||||
}
|
||||
return { cells, found: [...found], r, x: cx, y: cy };
|
||||
}
|
||||
|
||||
+4
-4
@@ -8,9 +8,9 @@ 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],
|
||||
[SAND, 0.1], [LCLAY, 0.2], [LWOOD, 0.3], [ACORN, 0.5], [COAL, 0.5], [RGRAN, 0.3], [RBAS, 0.5],
|
||||
[CUORE, 1], [FEORE, 1.6], [SNORE, 2], [ZNORE, 2.2], [AGORE, 6], [AUORE, 14],
|
||||
[CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6],
|
||||
[CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6], [PLANK, 2.4], [GREENB, 2], [BARBR, 9],
|
||||
[SCRCU, 3], [SCRFE, 5], [SCRST, 11], [SCRSN, 3.5], [SCRZN, 3.5], [SCRAG, 18], [SCRAU, 40], [SCRBZ, 8], [SCRBS, 7],
|
||||
[BARCU, 40], [BARFE, 75], [BARST, 160], [BARSN, 50], [BARZN, 50], [BARAG, 220], [BARAU, 480], [BARBZ, 110], [BARBS, 95],
|
||||
]) PRICE[m] = v;
|
||||
@@ -71,9 +71,9 @@ 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
|
||||
// Builders buy the cheap bulk stuff (rubble, gravel, sand, stone blocks, planks) in big lots at a few
|
||||
// times the market price, so waste rock earns a little. [what, smallest lot, largest lot]
|
||||
const BUILD_GOODS = [[RSTONE, 400, 1500], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80]];
|
||||
const BUILD_GOODS = [[RSTONE, 400, 1500], [RGRAN, 300, 1200], [RBAS, 300, 1200], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80], [PLANK, 20, 80], [BARBR, 20, 80]];
|
||||
function makeBuildersOrder() {
|
||||
const cands = BUILD_GOODS.filter(([m]) => seen[m] && !orders.some(o => o.m === m));
|
||||
if (!cands.length) return null;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Throughput for the info overlay (t49): how many grains each belt, filter and screw conveyor
|
||||
// block hands on, per second of game time. Counting runs only while the overlay is on, so
|
||||
// normal play pays one flag check per carried grain. Bars are not counted.
|
||||
let flowOn = false, flowCount = new Uint16Array(0), flowRate = new Float32Array(0);
|
||||
function setFlowCounting(on) {
|
||||
flowOn = on;
|
||||
if (flowCount.length !== NB) { flowCount = new Uint16Array(NB); flowRate = new Float32Array(NB); }
|
||||
else if (!on) { flowCount.fill(0); flowRate.fill(0); }
|
||||
}
|
||||
// A grain left carrier block b across one of its edges.
|
||||
function flowNote(b) { if (b >= 0 && b < flowCount.length && flowCount[b] < 65535) flowCount[b]++; }
|
||||
// Count a move from cell a to cell j if it crosses into another column of blocks or row of blocks.
|
||||
function flowCross(b, a, j) { if (((a % W) >> 2) !== ((j % W) >> 2) || ((a / W) >> 2) !== ((j / W) >> 2)) flowNote(b); }
|
||||
// The sloped belt block carrying a grain at cell a: the slope under it in the same block column.
|
||||
function flowSlopeUnder(a) {
|
||||
const bx = (a % W) >> 2;
|
||||
for (let by = ((a / W) | 0) >> 2, k = 0; k < 3 && by < BH; k++, by++) if (isSlopeBelt(blockP[by * BW + bx])) return by * BW + bx;
|
||||
return -1;
|
||||
}
|
||||
// Once a second, fold the count into a smoothed rate.
|
||||
function flowTick() {
|
||||
if (flowCount.length !== NB) setFlowCounting(flowOn);
|
||||
if (tick % 60) return;
|
||||
for (let b = 0; b < NB; b++) { if (flowCount[b] || flowRate[b]) { flowRate[b] = flowRate[b] * 0.5 + flowCount[b] * 0.5; if (flowRate[b] < 0.05) flowRate[b] = 0; } }
|
||||
flowCount.fill(0);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Exploration only: never changes grains or the random stream.
|
||||
const FOG = 2;
|
||||
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
|
||||
const FOG_SENSE_FADE = 12;
|
||||
let fog, FW, FH;
|
||||
function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = sharedArray(Uint8Array, FW * FH); }
|
||||
function fogMark(x, y, v) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return;
|
||||
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
|
||||
if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); }
|
||||
}
|
||||
function fogSeen(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
|
||||
return fog[Math.floor(y / FOG) * FW + Math.floor(x / FOG)];
|
||||
}
|
||||
function fogInit() {
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < Math.min(H, surfY[x] + 16); y++) fogMark(x, y, y <= surfY[x] ? 255 : (surfY[x] + 16 - y) * 255 / 16);
|
||||
}
|
||||
function fogLook(cx, cy) {
|
||||
for (let a = 0; a < 256; a++) {
|
||||
const dx = Math.cos(a * Math.PI / 128), dy = Math.sin(a * Math.PI / 128);
|
||||
let hit = -1;
|
||||
for (let d = 0; d < 48; d++) {
|
||||
const x = Math.floor(cx + dx * d), y = Math.floor(cy + dy * d);
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) break;
|
||||
const ph = PH[mat[y * W + x]];
|
||||
if (hit < 0 && (ph === PH_SOLID || ph === PH_POWDER)) hit = d;
|
||||
// into rock, sight fades out over 8 grains from however bright it arrived
|
||||
const v = Math.min(255, (48 - d) * 255 / 16) * (hit < 0 ? 1 : (8 - d + hit) / 8);
|
||||
if (v <= 0) break;
|
||||
fogMark(x, y, v);
|
||||
}
|
||||
}
|
||||
// one fog cell at a time, measured from its centre; full inside 12 grains, fading over the outer 12 grains
|
||||
const R2 = FOG_SENSE * FOG_SENSE, in2 = (FOG_SENSE - FOG_SENSE_FADE) ** 2;
|
||||
const fy0 = Math.max(0, Math.floor((cy - FOG_SENSE) / FOG)), fy1 = Math.min(FH - 1, Math.floor((cy + FOG_SENSE) / FOG));
|
||||
const fx0 = Math.max(0, Math.floor((cx - FOG_SENSE) / FOG)), fx1 = Math.min(FW - 1, Math.floor((cx + FOG_SENSE) / FOG));
|
||||
for (let fy = fy0; fy <= fy1; fy++) for (let fx = fx0; fx <= fx1; fx++) {
|
||||
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
|
||||
if (d2 >= R2) continue;
|
||||
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
|
||||
if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); }
|
||||
}
|
||||
}
|
||||
function fogRevealAround(x, y, r) {
|
||||
for (let yy = Math.max(0, Math.floor(y - r)); yy <= Math.min(H - 1, y + r); yy++) for (let xx = Math.max(0, Math.floor(x - r)); xx <= Math.min(W - 1, x + r); xx++) {
|
||||
const d2 = (xx - x) ** 2 + (yy - y) ** 2;
|
||||
if (d2 >= r * r) continue;
|
||||
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
|
||||
}
|
||||
}
|
||||
function fogRevealAll() { fog.fill(255); renderDirty.fill(1); }
|
||||
+37
-17
@@ -7,6 +7,20 @@ function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0
|
||||
const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1;
|
||||
const 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;
|
||||
// Cold solids and powders cannot react below their first applicable threshold.
|
||||
const REACT_MIN = new Float64Array(NMAT).fill(Infinity);
|
||||
for (let m = 0; m < NMAT; m++) {
|
||||
if (PH[m] === PH_LIQUID || PH[m] === PH_GAS) REACT_MIN[m] = -Infinity;
|
||||
else {
|
||||
if (IGN[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], IGN[m]);
|
||||
if (MELT[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], MELT[m]);
|
||||
}
|
||||
}
|
||||
REACT_MIN[CLAY] = Math.min(REACT_MIN[CLAY], 880);
|
||||
REACT_MIN[LCLAY] = Math.min(REACT_MIN[LCLAY], 880);
|
||||
REACT_MIN[WOOD] = Math.min(REACT_MIN[WOOD], 380);
|
||||
REACT_MIN[LWOOD] = Math.min(REACT_MIN[LWOOD], 380);
|
||||
REACT_MIN[GRASS] = Math.min(REACT_MIN[GRASS], 260);
|
||||
// Alloys: a molten additive grain turns neighbouring molten copper into the alloy,
|
||||
// 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 } };
|
||||
@@ -38,19 +52,18 @@ function condPair(a, b, sc, nc, da) {
|
||||
const q = d * k * sc;
|
||||
temp[a] -= q * ICAP[ma];
|
||||
temp[b] += q * ICAP[mb];
|
||||
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; }
|
||||
}
|
||||
function heatPass() {
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
if (!hot[c]) continue;
|
||||
let sc = 1;
|
||||
if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
|
||||
heatChunk(c, sc);
|
||||
}
|
||||
if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
|
||||
}
|
||||
function heatPass() { chunkPasses(true); }
|
||||
function heatChunk(c, sc) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||
renderDirty[c] = 1;
|
||||
if (cx > 0) renderDirty[c - 1] = 1;
|
||||
if (cx + 1 < CW) renderDirty[c + 1] = 1;
|
||||
if (cy > 0) renderDirty[c - CW] = 1;
|
||||
if (cy + 1 < CH) renderDirty[c + CW] = 1;
|
||||
roofDirty[cx] = 1;
|
||||
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
|
||||
let maxDev = 0, burning = false;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
@@ -73,21 +86,27 @@ function heatChunk(c, sc) {
|
||||
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
|
||||
const a = ex * (sc > 1 ? 0.45 : 0.25);
|
||||
t -= a; temp[u] += a;
|
||||
if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; }
|
||||
if (y === y0 && upCold && a > 0.5) { chunkHeat(c - CW); }
|
||||
}
|
||||
}
|
||||
rel = y < roofY[x] ? 0.02 : 0.002;
|
||||
} 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]; }
|
||||
if (m !== EMPTY) {
|
||||
// Test the stored Float32 temperature, just as react() reads it.
|
||||
if ((flags[i] & F_BURN) || !(temp[i] < REACT_MIN[m])) react(i, x, y, m);
|
||||
m = mat[i]; t = temp[i];
|
||||
}
|
||||
const dv = t > amb ? t - amb : amb - t;
|
||||
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]--;
|
||||
const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
|
||||
const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
|
||||
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
|
||||
else { hot[c] = h; hotLvl[c] = level; }
|
||||
}
|
||||
function neighborDir(i, x, y, d) {
|
||||
if (d === 0) return y > 0 ? i - W : -1;
|
||||
@@ -101,7 +120,7 @@ function react(i, x, y, m) {
|
||||
if (fl & F_BURN) { burn(i, x, y, m, t); return; }
|
||||
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
|
||||
flags[i] = fl | F_BURN; life[i] = FUEL[m];
|
||||
stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
|
||||
if (chunkWork) chunkEvent('ignite'); else stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
|
||||
return;
|
||||
}
|
||||
const ph = PH[m];
|
||||
@@ -111,7 +130,7 @@ function react(i, x, y, m) {
|
||||
if (rnd() < 0.02 - t * 0.004) {
|
||||
const below = y < H - 1 ? PH[mat[i + W]] : PH_SOLID;
|
||||
const to = below === PH_EMPTY || below === PH_GAS ? SNOW : ICE;
|
||||
mat[i] = to; life[i] = 0; flags[i] = fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
|
||||
mat[i] = to; life[i] = 0; flags[i] = to === ICE ? F_NAT : fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +143,8 @@ function react(i, x, y, m) {
|
||||
if (L <= 0) {
|
||||
const to = FRZTO[m];
|
||||
mat[i] = to; life[i] = 0; temp[i] = fz - 1;
|
||||
flags[i] = (fl & F_NAT) | (CASTMETAL[m] ? F_CAST : 0); // natural magma freezes into natural rock
|
||||
// metal and glass set into castings; any other melt cools into ordinary rock you can drill, wherever it came from
|
||||
flags[i] = CASTMETAL[m] ? (fl & F_NAT) | F_CAST : PH[to] === PH_SOLID ? F_NAT : fl & F_NAT;
|
||||
wakeXY(x, y); discover(to, x, y, m, 'freeze', fz);
|
||||
} else { life[i] = L; temp[i] = fz; }
|
||||
return;
|
||||
@@ -158,7 +178,7 @@ function react(i, x, y, m) {
|
||||
if (rnd() < 0.03 + (t - mp) * 0.002) {
|
||||
let to = MELTTO[m];
|
||||
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
|
||||
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); }
|
||||
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) { if (chunkWork) chunkEvent('broken', mach[i]); else brokenMachines.push(mach[i]); } }
|
||||
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
|
||||
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
|
||||
}
|
||||
|
||||
+193
-9
@@ -5,7 +5,13 @@
|
||||
// its cells as solid (occ).
|
||||
const BAR_W = 4, BAR_H = 2;
|
||||
let entCurM = 0; // the bar being moved, so a filter can let its own kind of bar through
|
||||
function entSolidCell(j) { const m = mat[j], p = part[j]; return occ[j] || PH[m] === PH_SOLID || PH[m] === PH_POWDER || (p !== 0 && (p <= P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); }
|
||||
function entSolidCell(j) { const m = mat[j], p = part[j]; return occ[j] || PH[m] === PH_SOLID || PH[m] === PH_POWDER || (p !== 0 && (IS_SCREW[p] || p === P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); }
|
||||
function entSupportCount(x, y) {
|
||||
if (y + BAR_H >= H) return BAR_W;
|
||||
let n = 0;
|
||||
for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + BAR_H) * W + x + dx)) n++;
|
||||
return n;
|
||||
}
|
||||
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;
|
||||
@@ -32,24 +38,194 @@ function spawnBar(m, x, y, vx, vy, T) {
|
||||
return e;
|
||||
}
|
||||
function removeEnt(e) { entMark(e, 0); const k = ents.indexOf(e); if (k >= 0) ents.splice(k, 1); }
|
||||
// No transient screw state: even a loaded bar derives its ride from its eight cells.
|
||||
function entScrewAt(x, y) {
|
||||
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return 0;
|
||||
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) {
|
||||
const p = part[yy * W + xx]; if (!IS_SCREW[p]) return 0;
|
||||
}
|
||||
return part[(y + 1) * W + x + 1];
|
||||
}
|
||||
function entScrewFree(x, y, screwOnly = false) {
|
||||
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false;
|
||||
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) {
|
||||
const j = yy * W + xx, p = part[j], screw = IS_SCREW[p];
|
||||
// gas and liquid don't stop a bar, in a screw or in the open
|
||||
if (occ[j] || (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS && PH[mat[j]] !== PH_LIQUID) || (screwOnly ? !screw : !screw && entSolidCell(j))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Check the swept rectangle too: a snap must never jump through a grain or another bar.
|
||||
function entScrewMove(e, x, y, screwOnly = false, mouth = false) {
|
||||
if (!entScrewFree(x, y, screwOnly)) return false;
|
||||
if (mouth) {
|
||||
// Go around the receiving corner, checking both legs before moving.
|
||||
const acrossY = y > e.iy ? e.iy : y, downX = y > e.iy ? x : e.ix;
|
||||
for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entScrewFree(downX, yy)) return false;
|
||||
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entScrewFree(xx, acrossY)) return false;
|
||||
} else for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++)
|
||||
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entScrewFree(xx, yy)) return false;
|
||||
e.x = e.ix = x; e.y = e.iy = y; e.vx = e.vy = 0; return true;
|
||||
}
|
||||
function entScrewLane(y, iy) { return Math.max(y & ~3, Math.min((y & ~3) + 2, iy)); }
|
||||
function entScrewEdge(e, dx, dy, k) {
|
||||
const x = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), y = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k);
|
||||
return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x;
|
||||
}
|
||||
function entScrewEnter(e, dx, dy, mouth = false) {
|
||||
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
|
||||
const i = entScrewEdge(e, dx, dy, k);
|
||||
if (i < 0) continue;
|
||||
const xx = i % W, yy = (i / W) | 0;
|
||||
const p = part[i]; if (!IS_SCREW[p] || (!dx && SCREW_DX[p]) || dx * SCREW_DX[p] + dy * SCREW_DY[p] < 0) continue;
|
||||
// Vertical screws require the full width; horizontal screws use the nearest two-row lane.
|
||||
const x = (xx & ~3), y = entScrewLane(yy, e.iy);
|
||||
if (entScrewMove(e, x, SCREW_DX[p] || dy ? y : e.iy, true, mouth)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function entScrewTick(e, p) {
|
||||
e.vx = e.vy = 0; e.x = e.ix; e.y = e.iy;
|
||||
const ci = (e.iy + 1) * W + e.ix + 1;
|
||||
if ((tick & 1) || netSat(ci) <= 0 || rnd() > netSat(ci)) return;
|
||||
const dx = SCREW_DX[p], dy = SCREW_DY[p];
|
||||
if (dx && dy) { entDiagonalScrewTick(e, p); return; }
|
||||
const frontX = e.ix + (dx > 0 ? BAR_W - 1 : 0), frontY = e.iy + (dy > 0 ? BAR_H - 1 : 0);
|
||||
const front = frontY * W + frontX, dest = straightScrewNext(front, p);
|
||||
if (dest < 0) return;
|
||||
let x = e.ix + dx, y = e.iy + dy, next = 0, ax = 0, ay = 0;
|
||||
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
|
||||
const i = entScrewEdge(e, dx, dy, k);
|
||||
if (i < 0) return;
|
||||
const xx = i % W, yy = (i / W) | 0;
|
||||
const q = part[i];
|
||||
if (IS_SCREW[q] && q !== p) {
|
||||
if (SCREW_DX[q] && SCREW_DY[q]) {
|
||||
const join = endJoin(frontX >> 2, frontY >> 2, dx + dy > 0 ? 1 : 0);
|
||||
if (join < 0 || (join >> 1) !== (yy >> 2) * BW + (xx >> 2)) return;
|
||||
}
|
||||
next = q; ax = xx; ay = yy; break;
|
||||
}
|
||||
}
|
||||
if (next) {
|
||||
if (!screwAccepts(next, dx, dy)) return;
|
||||
x = ax & ~3; y = entScrewLane(ay, e.iy);
|
||||
if (entScrewMove(e, x, y, true)) stats.moved++;
|
||||
return;
|
||||
}
|
||||
if (entScrewAt(x, y)) { if (entScrewMove(e, x, y, true)) stats.moved++; return; }
|
||||
// Clear the mouth in one move so the next tick can derive ordinary falling from position.
|
||||
x = e.ix + dx * BAR_W; y = e.iy + dy * BAR_H;
|
||||
if (!entScrewFree(x, y) || entScrewAt(x, y)) return;
|
||||
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) if (part[yy * W + xx]) return;
|
||||
if (entScrewMove(e, x, y)) { e.vx = dx ? dx * 0.5 : (rnd() - 0.5) * 0.4; e.vy = dy * 0.6; stats.moved++; }
|
||||
}
|
||||
// A horizontal bar stays whole: one diagonal block per four screw beats.
|
||||
function entDiagonalScrewTick(e, p) {
|
||||
if (tick & 7) return;
|
||||
const dx = SCREW_DX[p], dy = SCREW_DY[p], bx = e.ix & ~3, by = e.iy & ~3, lane = e.iy & 3;
|
||||
const edge = (by + lane) * W + bx + (dx > 0 ? BS - 1 : 0);
|
||||
const dest = diagonalScrewNext(edge, p);
|
||||
if (dest < 0) return;
|
||||
const x = (dest % W) & ~3;
|
||||
let y = (dest / W) | 0;
|
||||
const q = part[dest];
|
||||
if (IS_SCREW[q]) {
|
||||
if (SCREW_DX[q] * dx + SCREW_DY[q] * dy < 0) return;
|
||||
y = Math.min((y & ~3) + BS - BAR_H, y);
|
||||
if (entScrewMove(e, x, y, true)) stats.moved++;
|
||||
return;
|
||||
}
|
||||
const join = endJoin(bx >> 2, by >> 2, dx > 0 ? 1 : 0);
|
||||
if (join >= 0 && (blockP[join >> 1] === BP_BELT || blockP[join >> 1] === BP_FILTER)) y -= BAR_H - 1;
|
||||
if (!entScrewFree(x, y)) return;
|
||||
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) if (part[yy * W + xx]) return;
|
||||
if (entScrewMove(e, x, y, false, dy < 0)) { e.vx = dx * 0.5; e.vy = dy * 0.6; stats.moved++; }
|
||||
}
|
||||
// A same-row flat belt drops its bar into a joined screw, just as grains fall off its face.
|
||||
function entBeltScrewEnter(e) {
|
||||
if (e.ix & 3) return false;
|
||||
const y = e.iy + BAR_H;
|
||||
if (y >= H || (y & 3)) return false;
|
||||
const b = (y >> 2) * BW + (e.ix >> 2), c = blockP[b], v = blockV[b];
|
||||
if (c !== BP_BELT && c !== BP_FILTER) return false;
|
||||
if (c === BP_FILTER && filterPassAt(e.m, y * W + e.ix)) return false;
|
||||
if (netSat(y * W + e.ix) <= 0) return false;
|
||||
const d = c === BP_FILTER ? (v & 128 ? 1 : -1) : (v & 1 ? 1 : -1);
|
||||
const join = endJoin(b % BW, (b / BW) | 0, d > 0 ? 1 : 0);
|
||||
if (join < 0 || blockP[join >> 1] !== BP_SCREW) return false;
|
||||
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
|
||||
const nb = join >> 1;
|
||||
if (!(tick & 1) && entScrewMove(e, nb % BW * BS, ((nb / BW) | 0) * BS, true, true)) stats.moved++;
|
||||
return true;
|
||||
}
|
||||
// Bars stay level; their highest contact follows the face one grain per belt beat.
|
||||
function entSlopeBeltTick(e) {
|
||||
const bottom = e.iy + BAR_H;
|
||||
if (bottom >= H) return false;
|
||||
let d = 0, slope = false, screen = false;
|
||||
for (let x = e.ix; x < e.ix + BAR_W; x++) {
|
||||
const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) {
|
||||
d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
|
||||
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
|
||||
}
|
||||
}
|
||||
if (!d) return false;
|
||||
let face = H;
|
||||
for (let x = e.ix + d; x < e.ix + d + BAR_W; x++) for (let dy = -1; dy <= 1; dy++) {
|
||||
const y = bottom + dy;
|
||||
if (x < 0 || x >= W || y < 0 || y >= H) continue;
|
||||
const b = (y >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue;
|
||||
if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
|
||||
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
|
||||
face = Math.min(face, y);
|
||||
}
|
||||
if (!slope) return false;
|
||||
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
|
||||
if (screen ? tick % SCREEN_NUDGE_TICKS : tick & 1) return true;
|
||||
const y = face === H ? e.iy : face - BAR_H, x = e.ix + d;
|
||||
if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; }
|
||||
else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true);
|
||||
return true;
|
||||
}
|
||||
function entTick() {
|
||||
for (let k = ents.length - 1; k >= 0; k--) {
|
||||
const e = ents[k];
|
||||
entMark(e, 0);
|
||||
entCurM = e.m;
|
||||
const ix = e.ix, iy = e.iy;
|
||||
let ix = e.ix; const iy = e.iy;
|
||||
const screw = entScrewAt(ix, iy);
|
||||
if (screw) entScrewTick(e, screw);
|
||||
else if (!entBeltScrewEnter(e) && !entSlopeBeltTick(e)) {
|
||||
// what is underneath?
|
||||
let belt = 0, bsat = 0, support = iy + BAR_H >= H;
|
||||
let belt = 0, bsat = 0, transport = false, support = iy + BAR_H >= H;
|
||||
if (!support) {
|
||||
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 || part[j] === P_FILTER) transport = true;
|
||||
if (m === BELTL || m === BELTR) { const s = netSat(j); if (s > bsat) { bsat = s; belt = m === BELTR ? 1 : -1; } }
|
||||
else if (part[j] === P_FILTER && !filterPassAt(e.m, j) && (yb & 3) === 0) { bsat = 1; belt = blockV[(yb >> 2) * BW + (x >> 2)] & 128 ? 1 : -1; }
|
||||
if (entSolidCell(j)) support = true;
|
||||
}
|
||||
}
|
||||
if (support && !transport && e.vy === 0 && Math.abs(e.vx) < 0.05) {
|
||||
const n = entSupportCount(ix, iy);
|
||||
if (n < 2) {
|
||||
let left = 0, right = 0;
|
||||
for (let dx = 0; dx < BAR_W; dx++) if (!entSolidCell((iy + BAR_H) * W + ix + dx)) {
|
||||
if (dx < BAR_W / 2) left++; else right++;
|
||||
}
|
||||
const dir = left > right ? -1 : right > left ? 1 : ((ix + iy) & 1) ? 1 : -1;
|
||||
if (entFreeAt(ix + dir, iy)) ix += dir;
|
||||
} else if (ix & 1) {
|
||||
for (let dir = -1; dir <= 1; dir += 2) if (entFreeAt(ix + dir, iy) && entSupportCount(ix + dir, iy) >= 2) { ix += dir; break; }
|
||||
}
|
||||
if (ix !== e.ix) { e.x = e.ix = ix; support = entSupportCount(ix, iy) > 0; }
|
||||
}
|
||||
const ci = Math.min(H - 1, iy + 1) * W + Math.min(W - 1, ix + 1);
|
||||
const 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; }
|
||||
@@ -58,18 +234,25 @@ function entTick() {
|
||||
e.x += e.vx; e.y += e.vy;
|
||||
// step toward the new rounded position one cell at a time
|
||||
let tx = Math.round(e.x), ty = Math.round(e.y), cx = ix, cy = iy;
|
||||
while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else { 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)) {
|
||||
while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else if (belt && entScrewEnter(e, s, 0)) { cx = e.ix; cy = e.iy; e.x = cx; e.y = cy; break; } else { e.vx = 0; e.x = cx; break; } }
|
||||
while (cy !== ty) { const s = Math.sign(ty - cy); if (entScrewAt(cx, cy)) break; if (entFreeAt(cx, cy + s)) cy += s; else if (entScrewEnter(e, 0, s)) { cx = e.ix; cy = e.iy; break; } else { e.vy = 0; e.y = cy; break; } }
|
||||
if (support && !belt && entScrewEnter(e, 0, 1)) { cx = e.ix; cy = e.iy; }
|
||||
if (!entScrewAt(cx, cy) && !entFreeAt(cx, cy)) {
|
||||
// something moved into us (a grain fell in): try to step up out of it
|
||||
let freed = false;
|
||||
for (let u = 1; u < 6; u++) if (entFreeAt(cx, cy - u)) { cy -= u; e.y = cy; freed = true; break; }
|
||||
if (!freed) e.y = cy;
|
||||
}
|
||||
e.ix = cx; e.iy = cy;
|
||||
}
|
||||
if (rackIntake(e)) { ents.splice(k, 1); continue; }
|
||||
const cx = e.ix, cy = e.iy;
|
||||
// heat: the bar tends toward the temperature around it
|
||||
const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)];
|
||||
e.T += (T - e.T) * 0.04;
|
||||
if (FIRETO[e.m] && e.T >= 880 && rnd() < 0.004 + (e.T - 880) * 0.00012) {
|
||||
e.m = FIRETO[e.m]; discover(e.m, e.ix, e.iy, GREENB, 'fire', 880);
|
||||
}
|
||||
if (e.T >= MELT[e.m]) {
|
||||
// melts back into 8 grains of molten metal if they fit here
|
||||
let fits = true;
|
||||
@@ -96,6 +279,7 @@ function entsInCircle(cx, cy, r) {
|
||||
|
||||
// ---------- Bag ----------
|
||||
function bagAdd(m, n, force) {
|
||||
if (PH[m] === PH_SOLID && YLD[m]) m = YLD[m]; // rock and metal go in as what drilling them gives
|
||||
const room = force ? n : Math.max(0, bagCap() - bagTotal);
|
||||
const k = Math.min(n, room);
|
||||
bag[m] += k; bagTotal += k;
|
||||
@@ -127,8 +311,8 @@ function giveOrDrop(m, n, x, y) {
|
||||
if (rest > 0) bagAdd(m, rest, true);
|
||||
}
|
||||
function refundAt(cost, mult, x, y) { for (const [m, n] of cost) giveOrDrop(m, n * mult, x, y); }
|
||||
function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
|
||||
function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
|
||||
function canAfford(cost, mult) { if (devFree) return true; for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
|
||||
function payCost(cost, mult) { if (devFree) return; for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
|
||||
function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); }
|
||||
function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); }
|
||||
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(cost, mult); }
|
||||
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(c ? [c] : cost, mult) + '.'; }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Travel-directed transport ends. Precomputed probes; world/plan queries allocate nothing.
|
||||
// End indices stay geometric: left/right, or up/down for vertical screws.
|
||||
const JOIN_ENDS = [[]];
|
||||
for (const [kind, tx, ty] of [
|
||||
...SCREW_DIRS.map(([x,y]) => [x && y ? 'screw-diagonal' : x ? 'screw-horizontal' : 'screw-vertical',x,y]),
|
||||
['belt-flat',-1,0], ['belt-flat',1,0],
|
||||
['belt-sloped',-1,1], ['belt-sloped',1,-1],
|
||||
['belt-sloped',-1,-1], ['belt-sloped',1,1]
|
||||
]) {
|
||||
const outlet = (tx || ty) > 0 ? 1 : 0;
|
||||
JOIN_ENDS.push([0,1].map(e => ({
|
||||
dx: (e ? 1 : -1) * Math.abs(tx), dy: (e ? 1 : -1) * (tx ? ty * tx : 1),
|
||||
diagonal: !!(tx && ty), kind, tx, ty, outlet: e === outlet, targets: []
|
||||
})));
|
||||
}
|
||||
function pieceEnds(code, v) {
|
||||
if (code === BP_SCREW) return JOIN_ENDS[v >= 0 && v < 8 ? v + 1 : 0];
|
||||
if (!isBelt(code) && !isFilter(code)) return JOIN_ENDS[0];
|
||||
const d = isFilter(code) ? (v & 128 ? 1 : 0) : (v & 1);
|
||||
return JOIN_ENDS[9 + d + (beltGrade(code) < 0 ? 2 : beltGrade(code) > 0 ? 4 : 0)];
|
||||
}
|
||||
for (const ends of JOIN_ENDS) for (let e = 0; e < ends.length; e++) {
|
||||
const a = ends[e];
|
||||
if (!a.outlet) continue;
|
||||
for (const others of JOIN_ENDS) for (let n = 0; n < others.length; n++) {
|
||||
const b = others[n], screw = a.kind.startsWith('screw'), otherScrew = b.kind.startsWith('screw');
|
||||
if (b.outlet || a.tx * b.tx + a.ty * b.ty <= 0) continue;
|
||||
if (screw !== otherScrew && (a.kind === 'screw-vertical' || b.kind === 'screw-vertical' ||
|
||||
(a.diagonal && b.diagonal))) continue;
|
||||
if (a.diagonal && b.diagonal && (a.tx !== b.tx || a.ty !== b.ty)) continue;
|
||||
const dy = !screw && a.ty < 0 ? 0 : a.ty;
|
||||
const offsets = [[a.tx, dy]];
|
||||
// Grains on a flat belt below an uphill low end already reach its carrying face.
|
||||
if (!screw && !otherScrew && !a.ty && b.ty < 0) offsets.push([a.tx, -1]);
|
||||
for (const [dx, yy] of offsets) {
|
||||
a.targets.push({ dx, dy: yy, ends: others, end: n });
|
||||
b.targets.push({ dx: -dx, dy: -yy, ends, end: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ends of JOIN_ENDS) for (const end of ends) {
|
||||
const sameShape = t => t.ends[t.end].kind === end.kind &&
|
||||
t.ends[t.end].tx === end.tx && t.ends[t.end].ty === end.ty;
|
||||
end.targets.sort((a,b) => end.outlet ? Math.abs(a.dy - end.ty) - Math.abs(b.dy - end.ty) :
|
||||
Number(sameShape(b)) - Number(sameShape(a)) || a.dy - b.dy || a.dx - b.dx);
|
||||
}
|
||||
// Result: neighbour block index * 2 + its end index; -1 means an open end.
|
||||
function joinCandidate(bx, by, endIndex, planned) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return -1;
|
||||
const b = by * BW + bx, plan = planned && planned.get(b);
|
||||
const ends = pieceEnds(plan ? plan[0] : blockP[b], plan ? plan[1] : blockV[b]);
|
||||
const end = ends[endIndex];
|
||||
if (!end) return -1;
|
||||
for (let k = 0; k < end.targets.length; k++) {
|
||||
const target = end.targets[k];
|
||||
const x = bx + target.dx, y = by + target.dy;
|
||||
if (x < 0 || y < 0 || x >= BW || y >= BH) continue;
|
||||
const nb = y * BW + x, next = planned && planned.get(nb);
|
||||
if (pieceEnds(next ? next[0] : blockP[nb], next ? next[1] : blockV[nb]) === target.ends)
|
||||
return nb * 2 + target.end;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function endJoin(bx, by, endIndex, planned = null) {
|
||||
const join = joinCandidate(bx, by, endIndex, planned);
|
||||
if (join < 0) return -1;
|
||||
const nb = join >> 1;
|
||||
// Competing neighbours cannot claim an end that chose a different partner.
|
||||
return joinCandidate(nb % BW, (nb / BW) | 0, join & 1, planned) === (by * BW + bx) * 2 + endIndex ? join : -1;
|
||||
}
|
||||
function pieceJoins(bx, by, planned = null) {
|
||||
return (endJoin(bx, by, 0, planned) >= 0 ? 1 : 0) | (endJoin(bx, by, 1, planned) >= 0 ? 2 : 0);
|
||||
}
|
||||
function endAt(b, end, planned = null) {
|
||||
const p = planned && planned.get(b);
|
||||
return pieceEnds(p ? p[0] : blockP[b], p ? p[1] : blockV[b])[end];
|
||||
}
|
||||
// A same-row join into a sloped belt's low end: the flat surface is level with the top of the
|
||||
// slope's first block, so grains drop about 3 grains onto its face. Joined, but not seamless.
|
||||
function joinIsDrop(bx, by, end, join, planned = null) {
|
||||
if (join < 0 || (((join >> 1) / BW) | 0) !== by) return false;
|
||||
const low = e => !!e && e.kind === 'belt-sloped' && e.dy > 0;
|
||||
return low(endAt(by * BW + bx, end, planned)) || low(endAt(join >> 1, join & 1, planned));
|
||||
}
|
||||
// Rows to move a line so its first block joins: 0, -1 or +1. A line that already joins stays
|
||||
// put; it moves only when one block up or down gives a seamless join involving a diagonal piece
|
||||
// and every block there is free, so straight lines beside straight lines never jump.
|
||||
function joinSnap(code, v, cells) {
|
||||
if (!cells.length) return 0;
|
||||
const joins = dy => {
|
||||
const plan = new Map(cells.map(([x, y]) => [(y + dy) * BW + x, [code, v]]));
|
||||
const x = cells[0][0], y = cells[0][1] + dy, own = pieceEnds(code, v);
|
||||
for (let end = 0; end < own.length; end++) {
|
||||
const j = endJoin(x, y, end, plan);
|
||||
if (j < 0 || plan.has(j >> 1) || joinIsDrop(x, y, end, j, plan)) continue;
|
||||
return own[end].diagonal || endAt(j >> 1, j & 1, plan).diagonal ? 2 : 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
if (!pieceEnds(code, v).length || joins(0)) return 0;
|
||||
for (const dy of [-1, 1]) {
|
||||
if (cells.every(([x, y]) => canPlaceBlock(x, y + dy, code) === '') && joins(dy) === 2) return dy;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// "Doesn't connect": an end with no join that would join seamlessly, with a diagonal piece
|
||||
// involved, if its block sat one higher (-1) or lower (+1) in empty space. 0 when no near miss.
|
||||
// A drop join counts as connected. The moved block is tried through a small override view of
|
||||
// `planned`, so ghost maps are never copied.
|
||||
const NO_PIECE = [0, 0];
|
||||
function nearMiss(bx, by, end, planned = null) {
|
||||
const b = by * BW + bx, p = planned && planned.get(b), code = p ? p[0] : blockP[b], v = p ? p[1] : blockV[b];
|
||||
const own = pieceEnds(code, v);
|
||||
if (!own[end] || endJoin(bx, by, end, planned) >= 0) return 0;
|
||||
const entry = [code, v];
|
||||
for (const dy of [-1, 1]) {
|
||||
const y = by + dy, nb = y * BW + bx;
|
||||
if (y < 0 || y >= BH || blockP[nb] || (planned && planned.get(nb))) continue;
|
||||
const view = { get: k => k === b ? NO_PIECE : k === nb ? entry : planned ? planned.get(k) : undefined };
|
||||
view.has = k => view.get(k) !== undefined;
|
||||
const j = endJoin(bx, y, end, view);
|
||||
if (j >= 0 && !joinIsDrop(bx, y, end, j, view) && (own[end].diagonal || endAt(j >> 1, j & 1, view).diagonal)) return dy;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
+13
-2
@@ -5,7 +5,7 @@ function checkLedger() {
|
||||
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[BRICK] || seen[RBRICK] || seen[BARBR]) mark('brick');
|
||||
if (seen[CHARCOAL]) mark('charcoal');
|
||||
if (stats.grown > 0) mark('grow');
|
||||
if (seen[MCU]) mark('copper');
|
||||
@@ -35,7 +35,18 @@ function checkLedger() {
|
||||
|
||||
// Topmost non-air cell of each column: air above it can shed heat to the sky.
|
||||
function updateRoof() {
|
||||
for (let x = 0; x < W; x++) {
|
||||
scanRoof(0, W);
|
||||
roofDirty.fill(0);
|
||||
}
|
||||
function updateDirtyRoof() {
|
||||
for (let cx = 0; cx < CW; cx++) {
|
||||
if (!roofDirty[cx]) continue;
|
||||
scanRoof(cx << 5, Math.min(W, (cx + 1) << 5));
|
||||
roofDirty[cx] = 0;
|
||||
}
|
||||
}
|
||||
function scanRoof(x0, x1) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
let y = 0;
|
||||
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;
|
||||
|
||||
+48
-49
@@ -1,4 +1,4 @@
|
||||
// Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
|
||||
// Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
|
||||
function engineIntake(mm) {
|
||||
for (const i of mm.intake) {
|
||||
if (mat[i] === STEAM) {
|
||||
@@ -45,6 +45,8 @@ function fanBlow(mm, sat) {
|
||||
}
|
||||
}
|
||||
function machinesTick() {
|
||||
procBlocksTick();
|
||||
rackTick();
|
||||
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
|
||||
if (brokenMachines.length) {
|
||||
const ids = brokenMachines; brokenMachines = [];
|
||||
@@ -56,10 +58,6 @@ function machinesTick() {
|
||||
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') {
|
||||
@@ -71,7 +69,7 @@ function machinesTick() {
|
||||
for (let n = 1; n < nets.length; n++) {
|
||||
const net = nets[n];
|
||||
let s = 0; for (const p of net.prods) s += p.out;
|
||||
let d = net.nb * BELT_POWER + net.nt * TUBE_POWER;
|
||||
let d = net.nb * BELT_POWER + net.nt * SCREW_POWER;
|
||||
for (const f of net.fans) if (f.on) d += FAN_POWER;
|
||||
net.supply = s; net.demand = d;
|
||||
net.sat = POWER_FREE ? 1 : d > 0 ? Math.min(1, s / d) : 0;
|
||||
@@ -82,43 +80,32 @@ function machinesTick() {
|
||||
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)
|
||||
// Keep the measured heat for the marker and hover panel.
|
||||
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);
|
||||
mm.hot = Math.round(hotT);
|
||||
}
|
||||
}
|
||||
// Belts step every other tick; sloped belts carry only their finite pockets.
|
||||
if (filtersDirty) rebuildFilters();
|
||||
for (const b of filterR) filterCarry(b, 1);
|
||||
for (const b of filterL) filterCarry(b, -1);
|
||||
}
|
||||
// tubes: every loose grain inside steps one cell along, front of the line first, on the same
|
||||
// slopes first, so a flat belt feeding one finds the room the slope has just made
|
||||
slopeCleatsTick();
|
||||
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
|
||||
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
|
||||
// screws: every loose grain inside steps one cell along, front of the line first, on the same
|
||||
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
|
||||
if ((tick & 1) === 0) for (let p = P_TU; p <= P_TR; p++) {
|
||||
const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0;
|
||||
if ((tick & 1) === 0) for (let p = 1; p < IS_SCREW.length; p++) {
|
||||
if (!IS_SCREW[p]) continue;
|
||||
const list = screwLists[p], dx = SCREW_DX[p], dy = SCREW_DY[p], flat = dy === 0;
|
||||
for (let k = 0; k < list.length; k++) {
|
||||
const i = list[k];
|
||||
if (part[i] !== p) continue;
|
||||
if (part[i] !== p || occ[i]) continue;
|
||||
const m = mat[i];
|
||||
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; }
|
||||
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) screwIntake(i, p, dx, dy); continue; }
|
||||
if (stamp[i] === tickStamp) continue;
|
||||
if (temp[i] > 1000 && rnd() < 0.02) {
|
||||
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i);
|
||||
part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i);
|
||||
continue;
|
||||
}
|
||||
const s = netSat(i);
|
||||
@@ -126,32 +113,44 @@ function machinesTick() {
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
// a sideways screw carries its load along the bottom: grains settle into empty lanes below
|
||||
if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; }
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!flat && !dx) {
|
||||
let own = 0, shifted = false;
|
||||
const end = dy < 0 ? (y & ~3) : (y | 3);
|
||||
for (let yy = y; dy < 0 ? yy >= end : yy <= end; yy += dy) if (PH[mat[yy * W + x]] === PH_POWDER) own++;
|
||||
const side = ((tick >> 1) + i) & 1 ? 1 : -1;
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const sx = c ? -side : side, xx = x + sx, j = i + sx;
|
||||
if ((xx >> 2) !== (x >> 2) || part[j] !== p || !roomFor(j) || mat[j] !== EMPTY || occ[j] || stamp[j] === tickStamp) continue;
|
||||
let load = 0;
|
||||
for (let yy = y; dy < 0 ? yy >= end : yy <= end; yy += dy) if (PH[mat[yy * W + xx]] === PH_POWDER) load++;
|
||||
// Include this grain: a strict gap prevents a sideways step being undone next beat.
|
||||
if (own <= load + 1) continue;
|
||||
move(i, j, x, y, xx, y); stats.moved++; shifted = true; break;
|
||||
}
|
||||
if (shifted) continue;
|
||||
}
|
||||
if (x + dx < 0 || x + dx >= W || y + dy < 0 || y + dy >= H) continue;
|
||||
const dest = dx && dy ? diagonalScrewNext(i, p) : straightScrewNext(i, p);
|
||||
if (dest < 0) continue;
|
||||
const nx = dest % W, ny = (dest / W) | 0;
|
||||
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
||||
const j = i + dj;
|
||||
const j = dest;
|
||||
if (occ[j]) continue;
|
||||
const pj = part[j], tube = pj >= P_TU && pj <= P_TR;
|
||||
if (tube && TDX[pj] === -dx && TDY[pj] === -dy) continue;
|
||||
const pj = part[j], screw = IS_SCREW[pj];
|
||||
if (screw && !screwAccepts(pj, dx, dy)) continue;
|
||||
const t = mat[j];
|
||||
if (!roomFor(j)) continue;
|
||||
if (pj === P_GRATE || pj === P_INTAKE || (pj === P_FILTER && !filterPassAt(m, j))) continue;
|
||||
if (!tube && !pj && t === EMPTY) {
|
||||
if (!screw && !pj && t === EMPTY) {
|
||||
// out of the mouth: the screw spits it clear, so the next grain is never blocked by the last
|
||||
launchGrain(i, nx, ny, dy ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
|
||||
stats.moved++; continue;
|
||||
launchGrain(i, nx, ny, dy && !dx ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
|
||||
stats.moved++; if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j); continue;
|
||||
}
|
||||
move(i, j, x, y, nx, ny); stats.moved++;
|
||||
if (tube && pj !== p) {
|
||||
// turning a corner: keep going across the new tube's width so every lane fills
|
||||
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;
|
||||
}
|
||||
if (flowOn) flowCross((y >> 2) * BW + (x >> 2), i, j);
|
||||
if (screw && pj !== p) {
|
||||
// turning a corner: keep going across the new screw's width so every lane fills
|
||||
screwSpread(j, nx, ny, dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-16
@@ -5,12 +5,12 @@ function markPow(i, on) {
|
||||
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
|
||||
powerDirty = true;
|
||||
}
|
||||
function partLost(i) { markPow(i, false); }
|
||||
function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
|
||||
let nextPowerBuild = 0;
|
||||
function rebuildPower() {
|
||||
powerDirty = false;
|
||||
for (const i of pcells) netId[i] = 0;
|
||||
nets = [null]; beltL = []; beltR = []; tubeLists = [[], [], [], [], []];
|
||||
nets = [null]; beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []);
|
||||
const stack = [];
|
||||
for (const s of pcells) {
|
||||
if (netId[s]) continue;
|
||||
@@ -23,8 +23,13 @@ function rebuildPower() {
|
||||
const i = stack.pop();
|
||||
const m = mat[i], p = part[i];
|
||||
if (m === BELTL) { net.nb++; beltL.push(i); } else if (m === BELTR) { net.nb++; beltR.push(i); }
|
||||
if (p >= P_TU && p <= P_TR) { net.nt++; tubeLists[p].push(i); }
|
||||
const x = i % W;
|
||||
if (IS_SCREW[p]) { net.nt++; screwLists[p].push(i); }
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
// Corner-touching screws share power, without connecting unrelated diagonal machinery.
|
||||
if (IS_SCREW[p] && SCREW_DX[p] && SCREW_DY[p]) for (let d = -1; d <= 1; d += 2) {
|
||||
const xx = x + d * SCREW_DX[p], yy = y + d * SCREW_DY[p], j = yy * W + xx;
|
||||
if (xx >= 0 && xx < W && yy >= 0 && yy < H && part[j] === p && isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); }
|
||||
}
|
||||
let j;
|
||||
if (x > 0) { j = i - 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
if (x < W - 1) { j = i + 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
|
||||
@@ -39,16 +44,20 @@ function rebuildPower() {
|
||||
if (!net) continue;
|
||||
if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm);
|
||||
}
|
||||
beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a);
|
||||
tubeLists[P_TU].sort((a, b) => a - b); tubeLists[P_TD].sort((a, b) => b - a);
|
||||
tubeLists[P_TL].sort((a, b) => a - b); tubeLists[P_TR].sort((a, b) => b - a);
|
||||
beltL.sort((a, b) => (a % W) - (b % W) || a - b); beltR.sort((a, b) => (b % W) - (a % W) || b - a);
|
||||
for (const p of SCREW_PARTS) {
|
||||
screwLists[p].sort((a, b) => {
|
||||
if (SCREW_DX[p] && SCREW_DY[p]) return ((b % W) - (a % W)) * SCREW_DX[p];
|
||||
return (SCREW_DY[p] || SCREW_DX[p]) < 0 ? a - b : b - a;
|
||||
});
|
||||
}
|
||||
}
|
||||
function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return n ? n.sat : 0; }
|
||||
|
||||
// Move loose grains out of the given cells, to the nearest open cell above
|
||||
// the footprint in the same column (they then fall back on top). Gas just vanishes.
|
||||
function evictCells(cells, topY) {
|
||||
for (const i of cells) bgm[i] = 0;
|
||||
for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||
for (const i of cells) {
|
||||
const m = mat[i], ph = PH[m];
|
||||
if (m === EMPTY || ph === PH_SOLID) continue;
|
||||
@@ -76,9 +85,6 @@ function machineFootprint(type, x, y) {
|
||||
}
|
||||
} 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
|
||||
@@ -92,9 +98,6 @@ function machineFootprint(type, x, y) {
|
||||
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']);
|
||||
@@ -112,7 +115,7 @@ function canPlaceMachine(type, bx, by) {
|
||||
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 (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';
|
||||
@@ -150,5 +153,5 @@ function removeMachine(id, refund, scrap) {
|
||||
}
|
||||
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.` });
|
||||
if (scrap) emit({ t: 'warn', x: mm.x + mm.w / 2, y: mm.y, text: `${PIECE_BY_ID[mm.type].name} destroyed` });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Material tables: typed properties, casting groups, flags, parts and tool tiers.
|
||||
// Typed property tables
|
||||
const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT),
|
||||
HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT),
|
||||
SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT),
|
||||
BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT),
|
||||
FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT),
|
||||
ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT),
|
||||
ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT),
|
||||
RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT),
|
||||
CVAR = new Uint8Array(NMAT);
|
||||
for (let i = 0; i < NMAT; i++) {
|
||||
const d = MD[i];
|
||||
PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld;
|
||||
MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo;
|
||||
BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame;
|
||||
FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread;
|
||||
STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0;
|
||||
COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v;
|
||||
RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004;
|
||||
}
|
||||
// what molds cast (molten metals, and molten glass) and what they set into
|
||||
const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1;
|
||||
// tier as a drill bit or tank (0: too soft to be a bit)
|
||||
const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3;
|
||||
METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1;
|
||||
const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1;
|
||||
|
||||
// ---------- Flags & parts ----------
|
||||
const F_NAT = 1, F_CAST = 2, F_BURN = 4;
|
||||
const P_NONE = 0, P_SCREW_U = 1, P_SCREW_D = 2, P_SCREW_L = 3, P_SCREW_R = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10, P_SCREW_UR = 11, P_SCREW_DR = 12, P_SCREW_DL = 13, P_SCREW_UL = 14;
|
||||
const SCREW_PARTS = [P_SCREW_U, P_SCREW_R, P_SCREW_D, P_SCREW_L, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL];
|
||||
const SCREW_DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [1, 1], [-1, 1], [-1, -1]];
|
||||
const IS_SCREW = new Uint8Array(15), SCREW_DX = new Int8Array(15), SCREW_DY = new Int8Array(15);
|
||||
for (let v = 0; v < SCREW_PARTS.length; v++) {
|
||||
const p = SCREW_PARTS[v]; IS_SCREW[p] = 1; SCREW_DX[p] = SCREW_DIRS[v][0]; SCREW_DY[p] = SCREW_DIRS[v][1];
|
||||
}
|
||||
|
||||
// ---------- Tool tiers ----------
|
||||
// reach: how far from the bot the drill works. Harder rock takes longer, and
|
||||
// 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;
|
||||
+15
-55
@@ -1,4 +1,4 @@
|
||||
// Material ids, the material table (def), per-material property arrays, cell flags and part
|
||||
// Material definitions: ids, properties and metal families.
|
||||
// ids, and drill/tank tiers.
|
||||
// ---------- Material ids ----------
|
||||
const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9,
|
||||
@@ -11,8 +11,8 @@ const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6,
|
||||
ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68,
|
||||
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;
|
||||
BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94, GRINDER = 95, SAWB = 96, PLANK = 97, PRESSB = 98, GREENB = 99, BARBR = 100, RACKB = 101;
|
||||
const NMAT = 102;
|
||||
|
||||
const MD = [];
|
||||
function def(id, o) {
|
||||
@@ -46,7 +46,7 @@ def(SLAG, { name: 'Slag', ph: PH_POWDER, col: [76, 66, 64], v: 34, dens: 2.4, k:
|
||||
def(MSLAG, { name: 'Molten slag', ph: PH_LIQUID, col: [232, 112, 40], v: 16, dens: 3.6, k: 0.02, cap: 1.2, hard: 0, freeze: 1000, freezeTo: SLAG, fluid: 0.5, spread: 10, liqT: 1150, desc: 'Smelting waste. Trickles down through the charge but is too thick to pass a grate, so it pools there and sets into slag. Knocking out the mold below rakes it away.' });
|
||||
def(BRICK, { name: 'Brick', col: [168, 76, 56], v: 16, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, yld: RBRICK, desc: 'Fired clay. Insulates and survives 1750°C.' });
|
||||
def(GLASS, { name: 'Glass', col: [178, 216, 224], v: 6, k: 0.006, hard: 2, melt: 1300, meltTo: MGL, alpha: 0.45, yld: CULLET, desc: 'Clear. Lets light through.' });
|
||||
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, tubes, fans, engines.' });
|
||||
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, screw conveyors, fans, engines.' });
|
||||
def(IRON, { name: 'Iron', col: [128, 131, 138], v: 8, k: 0.035, cap: 1.5, hard: 5, melt: 1538, meltTo: MFE, yld: SCRFE, desc: 'Strong metal. Melts at 1538°C.' });
|
||||
def(STEEL, { name: 'Steel', col: [172, 180, 192], v: 6, k: 0.035, cap: 1.5, hard: 6, melt: 1450, meltTo: MST, yld: SCRST, desc: 'Iron refined by blowing air through it while molten. The best bit material.' });
|
||||
def(WATER, { name: 'Water', ph: PH_LIQUID, col: [44, 100, 194], v: 6, dens: 1, k: 0.02, cap: 4, hard: 0, boil: 100, boilTo: STEAM, fluid: 1, spread: 48, alpha: 0.78, liqT: 20, bag: true, desc: 'Boils at 100°C. Steam that rises into the open sky comes back down as rain.' });
|
||||
@@ -62,6 +62,10 @@ def(BELTL, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085
|
||||
def(BELTR, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
|
||||
def(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(GRINDER, { name: 'Grinder', col: [64, 70, 80], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
|
||||
def(SAWB, { name: 'Saw', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
|
||||
def(RACKB, { name: 'Bar rack', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
|
||||
def(PRESSB, { name: 'Brick press', col: [74, 80, 90], v: 4, k: 0.035, hard: 6, melt: 1450, meltTo: MST, partMat: true });
|
||||
def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' });
|
||||
def(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.' });
|
||||
@@ -73,7 +77,7 @@ def(CULLET, { name: 'Glass shards', ph: PH_POWDER, col: [196, 228, 236], v: 22,
|
||||
def(SCRCU, { name: 'Copper scrap', ph: PH_POWDER, col: [192, 118, 74], v: 36, dens: 5, k: 0.035, cap: 1.5, hard: 0, stick: 0.7, melt: 1085, meltTo: MCU, bag: true, desc: 'Copper that set outside a mold. Melt it again and cast it.' });
|
||||
def(SCRFE, { name: 'Iron scrap', ph: PH_POWDER, col: [124, 126, 132], v: 30, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1538, meltTo: MFE, bag: true, desc: 'Iron that set outside a mold. Melt it again and cast it.' });
|
||||
def(SCRST, { name: 'Steel scrap', ph: PH_POWDER, col: [168, 176, 188], v: 28, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1450, meltTo: MST, bag: true, desc: 'Steel that set outside a mold. Melt it again and cast it.' });
|
||||
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw tubes, fans and engines are made from these.' });
|
||||
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw conveyors, fans and engines are made from these.' });
|
||||
def(BARFE, { name: 'Iron bar', ph: PH_ITEM, col: [140, 143, 150], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1538, meltTo: MFE, bag: true, desc: 'A cast bar of iron.' });
|
||||
def(BARST, { name: 'Steel bar', ph: PH_ITEM, col: [184, 192, 204], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1450, meltTo: MST, bag: true, desc: 'A cast bar of steel.' });
|
||||
def(ACORN, { name: 'Acorn', ph: PH_POWDER, col: [150, 104, 56], v: 22, dens: 0.9, k: 0.006, hard: 0, stick: 0.6, ign: 300, flame: 700, flameF: 900, fuel: 30, burnTo: ASH, ashP: 0.3, bag: true, desc: 'A tree seed. Left on dirt or grass under open sky, it sprouts and grows into a tree.' });
|
||||
@@ -103,57 +107,13 @@ metalFamily({ solid: BRONZE, molten: MBZ, scrap: SCRBZ, bar: BARBZ, name: 'Bronz
|
||||
metalFamily({ solid: BRASS, molten: MBS, scrap: SCRBS, bar: BARBS, name: 'Brass', col: [212, 174, 84], mcol: [255, 196, 104], melt: 930, dens: 8.4, k: 0.035, hardSolid: 4,
|
||||
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(PLANK, { name: 'Plank', ph: PH_ITEM, col: [204, 158, 104], v: 0, dens: 0.6, k: 0, hard: 0, melt: 9999, bag: true, desc: 'Sawn from 8 wood chunks by a Saw. Builders buy them, and 2 make a wood wall block.' });
|
||||
def(GREENB, { name: 'Green brick', ph: PH_ITEM, col: [202, 164, 136], v: 0, dens: 1.8, k: 0, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Unfired clay pressed into shape by a Brick press. Fire it above ~900°C to make a Brick.' });
|
||||
def(BARBR, { name: 'Brick', ph: PH_ITEM, col: [174, 76, 56], v: 0, dens: 1.8, k: 0, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Fired clay brick. Builders buy them, and 2 make a brick wall block.' });
|
||||
// An item that fires into another item when hot.
|
||||
const FIRETO = new Uint8Array(NMAT); FIRETO[GREENB] = BARBR;
|
||||
def(CUTST, { name: 'Cut stone', col: [168, 162, 150], v: 6, k: 0.012, hard: 4, melt: 1450, meltTo: MAGMA, yld: BARSB, desc: 'Dressed stone blocks laid in courses. Neater than rough stone, and it stands 200°C more heat before it slumps.' });
|
||||
def(BARSB, { name: 'Stone brick', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Stone cutter, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' });
|
||||
def(BARSB, { name: 'Stone block', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Saw, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' });
|
||||
def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
|
||||
def(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;
|
||||
|
||||
+92
-59
@@ -1,4 +1,4 @@
|
||||
// Molds: cavities that fill with molten metal (or glass) and are knocked out as bars.
|
||||
// Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
|
||||
// ---------- Molds ----------
|
||||
function moldCellsAt(kind, x, y) {
|
||||
const md = MOLDS[kind]; const walls = [];
|
||||
@@ -11,13 +11,13 @@ function canPlaceMold(kind, bx, by) {
|
||||
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
|
||||
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';
|
||||
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: '' };
|
||||
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '', blocked: false };
|
||||
}
|
||||
function addMold(kind, bx, by, wallLoose, free) {
|
||||
const md = MOLDS[kind];
|
||||
@@ -34,7 +34,7 @@ function addMold(kind, bx, by, wallLoose, free) {
|
||||
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.
|
||||
// away automatically in ingot troughs, or when other castings are knocked out.
|
||||
const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1;
|
||||
// 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.
|
||||
@@ -68,7 +68,7 @@ function cavityState(cav) {
|
||||
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.
|
||||
// Bars, junk and mixed scrap drop automatically; slag is discarded. Short stubs stay on the floor.
|
||||
function troughState(mo) {
|
||||
let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false;
|
||||
for (const i of mo.cavs[0]) {
|
||||
@@ -79,8 +79,8 @@ function troughState(mo) {
|
||||
}
|
||||
const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length;
|
||||
const 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.` : '';
|
||||
const flaw = st === 'flawed' ? (mixed ? 'Two metals mixed. It drops out below as scrap.' : 'Junk in the trough. It drops out below when there is room.')
|
||||
: st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}; the next firing tops it up.` : '';
|
||||
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
|
||||
@@ -119,7 +119,7 @@ function moldPlugCells(mo) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Knocking a mold out also rakes the furnace over it: slag and ash lying on the grate and in
|
||||
// Ingot ejection or knocking a casting out also rakes the furnace over it: slag and ash lying on the grate and in
|
||||
// the charge above (up to the furnace roof) are cleared away, so the next charge sits on a
|
||||
// clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked.
|
||||
function rakeAbove(mo) {
|
||||
@@ -132,7 +132,7 @@ function rakeAbove(mo) {
|
||||
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
|
||||
// Hover advice when the furnace over the mold is badly loaded.
|
||||
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++) {
|
||||
@@ -159,13 +159,68 @@ function unplugMold(mo) {
|
||||
return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) };
|
||||
}
|
||||
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
|
||||
const barWord = (m, n) => m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
|
||||
function ejectIngot(mo) {
|
||||
mo.blocked = false;
|
||||
let r = troughState(mo), n = 0;
|
||||
const metal = r.metal, x = mo.x + (mo.w >> 1), y = mo.y + mo.h;
|
||||
if (r.st === 'ready') {
|
||||
let T = 0, solid = 0, free = false;
|
||||
for (const i of mo.cavs[0]) if (SOLIDMETAL[mat[i]] && castMix(metal, mat[i]) === metal) { T += temp[i]; solid++; }
|
||||
T /= Math.max(1, solid);
|
||||
for (let k = 0; k < 3 && n < r.bars; k++) {
|
||||
const sx = mo.x + 1 + k * 4;
|
||||
if (!entFreeAt(sx, y)) continue;
|
||||
free = true;
|
||||
if (spawnBar(BAR_OF[metal], sx, y, (rnd() - 0.5) * 0.15, 0.2, T)) n++;
|
||||
}
|
||||
mo.blocked = !n && !free;
|
||||
if (n) {
|
||||
// Take from the top so the short stub stays on the trough floor.
|
||||
let take = n * GRAINS_PER_BAR;
|
||||
for (const i of mo.cavs[0].slice().sort((a, b) => a - b)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(metal, mat[i]) === metal) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
|
||||
const first = stats.cast === 0;
|
||||
stats.bars += n; stats.cast++; mo.blockedSaid = false;
|
||||
emit({ t: 'sfx', s: 'cast' });
|
||||
emit({ t: 'ingot_drop', mold: mo.id, metal, bars: n, x, y, first });
|
||||
r = troughState(mo);
|
||||
} else if (!free && !mo.blockedSaid) { mo.blockedSaid = true; emit({ t: 'ingot_blocked', mold: mo.id, x, y }); }
|
||||
}
|
||||
let molten = mo.cavs[0].some(i => CASTMETAL[mat[i]]), pushed = 0;
|
||||
for (let yy = Math.max(0, mo.y - 8); yy < mo.y + mo.h && !molten; yy++) for (let xx = mo.x; xx < mo.x + mo.w; xx++) if (CASTMETAL[mat[yy * W + xx]]) { molten = true; break; }
|
||||
if (!molten) {
|
||||
const mixed = r.st === 'flawed' && r.metal >= 0;
|
||||
const plug = moldPlugCells(mo), cavity = mo.cavs[0];
|
||||
let slot = 0;
|
||||
for (const i of cavity.concat(plug)) {
|
||||
const m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m] || (SOLIDMETAL[m] && !mixed && !plug.includes(i))) continue;
|
||||
if (!DROSS[m]) {
|
||||
const loose = SOLIDMETAL[m] ? (YLD[m] || SCRCU) : PH[m] === PH_SOLID ? YLD[m] : m;
|
||||
if (!loose) continue;
|
||||
let placed = false;
|
||||
for (; slot < mo.w * Math.min(4, H - y); slot++) {
|
||||
const dest = (y + ((slot / mo.w) | 0)) * W + mo.x + slot % mo.w;
|
||||
if ((mat[dest] === EMPTY || PH[mat[dest]] === PH_GAS) && !occ[dest] && !part[dest]) {
|
||||
setCell(dest, loose, temp[i], 0); slot++; placed = true; break;
|
||||
}
|
||||
}
|
||||
if (!placed) { mo.blocked = true; continue; }
|
||||
}
|
||||
setCell(i, EMPTY, AMB[(i / W) | 0], 0); pushed++;
|
||||
}
|
||||
}
|
||||
if (n || pushed) rakeAbove(mo);
|
||||
r = troughState(mo);
|
||||
if (r.st === 'empty') mo.blockedSaid = false;
|
||||
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
|
||||
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
|
||||
return n;
|
||||
}
|
||||
function checkMolds() {
|
||||
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;
|
||||
ejectIngot(mo);
|
||||
continue;
|
||||
}
|
||||
const res = mo.cavs.map(cavityState);
|
||||
@@ -188,95 +243,73 @@ function moldAt(x, y) {
|
||||
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
|
||||
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.kind === 'ingot') return 'Ingot molds empty themselves.';
|
||||
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);
|
||||
return 'Knocked the mold clean.';
|
||||
}
|
||||
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.';
|
||||
return 'Not full; 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);
|
||||
const short = mo.state === 'short'; clearCavity(mo.cavs.flat()); 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);
|
||||
return (short ? `Unfinished ${what} knocked out as scrap.` : `Spoiled ${what} knocked out as scrap.`);
|
||||
}
|
||||
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.`; }
|
||||
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}; returned as scrap.`; }
|
||||
else if (mo.kind === 'bit') {
|
||||
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
|
||||
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; }
|
||||
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}; returned as scrap.`; }
|
||||
} else {
|
||||
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
|
||||
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; }
|
||||
else { msg = 'Your tank is already as good; returned as scrap.'; }
|
||||
}
|
||||
// a casting you could not use breaks into scrap, which goes to your bag
|
||||
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
|
||||
else clearCavity(cav);
|
||||
const u = unplugMold(mo);
|
||||
unplugMold(mo);
|
||||
mo.state = 'empty'; stats.cast++;
|
||||
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo);
|
||||
return msg;
|
||||
}
|
||||
function removeMold(mo) {
|
||||
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
|
||||
const cavity = mo.cavs.flat(), set = cavity.filter(i => SOLIDMETAL[mat[i]]);
|
||||
let cast = -1, mixed = false;
|
||||
for (const i of set) { if (cast < 0) cast = mat[i]; else { const c = castMix(cast, mat[i]); if (c < 0) mixed = true; else cast = c; } }
|
||||
const cx = mo.x + mo.w / 2, cy = mo.y + mo.h / 2;
|
||||
const bars = mixed ? 0 : Math.floor(set.length / GRAINS_PER_BAR);
|
||||
const scrap = new Map();
|
||||
for (let k = 0; k < set.length; k++) {
|
||||
const i = set[k], m = mixed ? mat[i] : cast;
|
||||
if (k >= bars * GRAINS_PER_BAR) { const loose = YLD[m] || SCRCU; scrap.set(loose, (scrap.get(loose) || 0) + 1); }
|
||||
setCell(i, EMPTY, AMB[(i / W) | 0], 0);
|
||||
}
|
||||
if (bars) giveOrDrop(BAR_OF[cast], bars, cx, cy);
|
||||
for (const [m, n] of scrap) giveOrDrop(m, n, cx, cy);
|
||||
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y);
|
||||
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);
|
||||
metal += removeWallCell(i, mo.wall);
|
||||
}
|
||||
}
|
||||
if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y);
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@
|
||||
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
|
||||
function cutTree(i, x, y, tier, amt = 1) {
|
||||
const b = bgm[i];
|
||||
renderDirtyIdx(i);
|
||||
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
|
||||
if (p < 255) { digp[i] = p; return false; }
|
||||
digp[i] = 0; bgm[i] = 0;
|
||||
@@ -104,6 +105,7 @@ function plantTick() {
|
||||
if (y <= 6 || y >= H - 1) continue;
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
|
||||
renderDirtyIdx(i);
|
||||
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
|
||||
let room = true;
|
||||
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
|
||||
@@ -120,7 +122,7 @@ function setTreeCell(x, y, m) {
|
||||
const i = y * W + x;
|
||||
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
|
||||
if (m === LEAVES && bgm[i]) return;
|
||||
bgm[i] = m;
|
||||
bgm[i] = m; renderDirtyIdx(i);
|
||||
if (treeDraw) treeDraw.push(i);
|
||||
}
|
||||
function saplingTick() {
|
||||
@@ -134,7 +136,7 @@ function saplingTick() {
|
||||
sp.age++;
|
||||
const s = Math.min(1, sp.age / 180);
|
||||
// wipe the previous stage, then draw this one
|
||||
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0;
|
||||
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||
treeDraw = sp.cells = [];
|
||||
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
|
||||
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
|
||||
|
||||
+59
-20
@@ -4,8 +4,36 @@
|
||||
// Everything you build snaps to a grid of 4x4-grain blocks. The world itself
|
||||
// stays grain-level; only built pieces snap.
|
||||
const BS = 4;
|
||||
const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10;
|
||||
const BP_WALL = 1, BP_BELT = 2, BP_SCREW = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14;
|
||||
const BP_SLOPE_BL = 15, BP_SLOPE_BR = 16, BP_SLOPE_TL = 17, BP_SLOPE_TR = 18;
|
||||
// The shape is independent of the direction: reversing a belt never flips its face.
|
||||
const BP_BELT_UP = 19, BP_BELT_DOWN = 20, BP_FILTER_UP = 21, BP_FILTER_DOWN = 22;
|
||||
function isSlopeBelt(c) { return c >= BP_BELT_UP && c <= BP_FILTER_DOWN; }
|
||||
function isFilter(c) { return c === BP_FILTER || c === BP_FILTER_UP || c === BP_FILTER_DOWN; }
|
||||
function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_DOWN; }
|
||||
function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; }
|
||||
function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; }
|
||||
function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); }
|
||||
function filterVariant(c, v) { return beltGrade(c) ? (beltGrade(c) > 0 ? 128 : 0) : v & 128; }
|
||||
function beltDirection(c, v) {
|
||||
const d = isFilter(c) ? (filterVariant(c, v) & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
|
||||
return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left';
|
||||
}
|
||||
function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; }
|
||||
function isWall(c) { return c === BP_WALL || isSlope(c); }
|
||||
function slopeCell(c, x, y) {
|
||||
c = slopeShape(c);
|
||||
if (c === BP_SLOPE_BR || c === BP_SLOPE_TR) x = 3 - x;
|
||||
if (c === BP_SLOPE_TL || c === BP_SLOPE_TR) y = 3 - y;
|
||||
return y >= x;
|
||||
}
|
||||
function slopeCode(dx, dy, orientation) {
|
||||
if (!diagonalDrag(dx, dy)) return BP_SLOPE_BL + orientation;
|
||||
return dx * dy > 0 ? (orientation >= 2 ? BP_SLOPE_TR : BP_SLOPE_BL) : (orientation >= 2 ? BP_SLOPE_TL : BP_SLOPE_BR);
|
||||
}
|
||||
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
|
||||
// pieces that carry something along a run: a Shift-drag still draws a line, never a filled rectangle
|
||||
function lineOnly(c) { return isBelt(c) || isFilter(c) || c === BP_SCREW || c === BP_SHAFT || isPipeCode(c); }
|
||||
// Filters: each filter block has a set of materials and a mode. In drop mode (inv 0)
|
||||
// the chosen materials fall through and everything else rides along on top; in carry
|
||||
// mode (inv 1) the chosen ones ride along and everything else falls through. The block
|
||||
@@ -18,17 +46,22 @@ function filterRec(c) {
|
||||
for (let m = 1; m < NMAT; m++) if (filterable(m)) pass[m] = (k.m.includes(m) ? 1 : 0) ^ k.inv;
|
||||
return { m: k.m, inv: k.inv, pass };
|
||||
}
|
||||
function filterPassAt(m, j) { const c = filterCfg.get((((j / W) | 0) >> 2) * BW + ((j % W) >> 2)); return c ? c.pass[m] : 0; }
|
||||
function filterPassAt(m, j) {
|
||||
let b = (((j / W) | 0) >> 2) * BW + ((j % W) >> 2);
|
||||
if (!isFilter(blockP[b])) { const owner = slopeSealOwner(j); if (owner >= 0) b = owner; }
|
||||
const c = filterCfg.get(b);
|
||||
return c ? c.pass[m] : 0;
|
||||
}
|
||||
function filterInfo(b) { const c = filterCfg.get(b); return c ? { m: c.m.slice(), inv: c.inv } : { m: [], inv: 0 }; }
|
||||
function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length && a.m.every((m, k) => m === b.m[k]); }
|
||||
// loose material -> the solid it becomes in a wall block
|
||||
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];
|
||||
WALL_OF[BARSB] = CUTST; WALL_OF[PLANK] = WOOD; WALL_OF[BARBR] = BRICK; WALL_OF[BARGL] = GLASS;
|
||||
const WALL_LIST = [RSTONE, BARSB, PLANK, BARBR, LCLAY, RBRICK, LDIRT, LWOOD, CULLET, BARGL, RGRAN, RBAS, BARCU, BARFE, BARST];
|
||||
const BAR_OF = new Uint8Array(NMAT); BAR_OF[COPPER] = BARCU; BAR_OF[IRON] = BARFE; BAR_OF[STEEL] = BARST;
|
||||
BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS;
|
||||
BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[WOOD] = PLANK; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS;
|
||||
const GRAINS_PER_BAR = 8;
|
||||
function wallCost(v, cells) { return [[v, PH[v] === PH_ITEM ? Math.ceil(cells / GRAINS_PER_BAR) : cells]]; }
|
||||
|
||||
@@ -38,13 +71,13 @@ const POWER_FREE = true;
|
||||
const CASH = 255; // stands for money in cost lists
|
||||
const PIECES = [
|
||||
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
|
||||
desc: 'A solid block. R picks what it is made of: 16 grains of rubble, lumps or chunks, or 2 bars of metal.' },
|
||||
desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' },
|
||||
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
|
||||
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it.' },
|
||||
{ id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left'],
|
||||
desc: 'A turning screw that draws in loose material and pushes it along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Stack them pointing up to lift ore out of a mine, and turn the top one sideways to unload. Copper softens around 1000°C.' },
|
||||
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally for a sidewall belt. Moving cleats lift pockets of grains; extra grains roll back.' },
|
||||
{ id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
|
||||
desc: 'A turning screw inside a tube that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' },
|
||||
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
|
||||
desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through.' },
|
||||
desc: 'A shaking mesh that joins belts. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally for a downhill screen. Grains slide down while it shakes; R keeps a picked slope downhill.' },
|
||||
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
|
||||
desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
|
||||
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
|
||||
@@ -57,22 +90,26 @@ const PIECES = [
|
||||
desc: 'Draws liquid in at the tail of its arrow (keep that side under water) and pushes it into the pipes on its other sides, about 60 grains a second while the line has room. It takes water and anything else cooler than 500°C, one liquid at a time per line. Press F to switch it on or off.' },
|
||||
{ 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: 'grinder', code: BP_GRIND, name: 'Grinder', kind: 'block', cost: [[CASH, 60]],
|
||||
desc: 'Grinds rubble, gravel, sand and dirt resting on top into sand and the odd grain of ore. Results drop out below; keep that space clear. Wider rows grind faster. Stack under a Saw to cut stone and grind the rest.' },
|
||||
{ id: 'press', code: BP_PRESS, name: 'Brick press', kind: 'block', cost: [[CASH, 60]],
|
||||
desc: 'Presses clay lumps resting on top into a green brick for every 8. They drop out underneath; put a belt or an open drop under it. Fire green bricks in a fire hotter than about 900°C to make Bricks. Wider rows press faster.' },
|
||||
{ id: 'rack', name: 'Bar rack', kind: 'block', code: BP_RACK, cost: [[CASH, 40]],
|
||||
desc: 'Stores bars of any kind, 50 per block. Stack blocks into a column for more room. Bars that land on top go in; F opens the gate at the bottom and lets them out one at a time, oldest first, onto a belt below.' },
|
||||
{ id: 'saw', code: BP_SAW, name: 'Saw', kind: 'block', cost: [[CASH, 90]],
|
||||
desc: 'Cuts stone rubble into stone blocks and wood chunks into planks, one from every 8 grains resting on top. They drop out underneath and pile up there: put a belt or an open drop under it to carry them away. Wider rows cut faster. Stack over a Grinder to grind everything else.' },
|
||||
{ id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE,
|
||||
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.' },
|
||||
desc: 'A steel bin on legs for one loose material: up to 4,000 grains go in through the hatch in its roof (drop them in from a belt or screw conveyor). Its gauge shows how full it is. Press F to open the gate in its cone and let it out underneath, about 60 grains a second; F again shuts it. The first material in decides what it holds until it is empty.' },
|
||||
{ id: 'engine', name: 'Steam engine', kind: 'machine', bw: 3, bh: 2, cost: [[BARCU, 10]], dirs: ['exhaust right', 'exhaust left'], hidden: POWER_FREE,
|
||||
desc: 'Steam rising into the intake slots on its underside drives it. Condensate drips out the exhaust side.' },
|
||||
{ id: 'dock', name: 'Trade dock', kind: 'machine', bw: 6, bh: 4, cost: [], hidden: true, fixed: true,
|
||||
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.' },
|
||||
desc: 'A long trough. Pour molten metal in: bars of 8 grains drop out of the bottom when set, up to 3. Leave one open block below it; a belt under that carries them off. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.' },
|
||||
{ id: 'bitmold', name: 'Bit mold', kind: 'mold', mold: 'bit', variants: 'wall',
|
||||
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',
|
||||
@@ -80,14 +117,16 @@ const PIECES = [
|
||||
];
|
||||
const PIECE_BY_ID = {}, PIECE_BY_CODE = [];
|
||||
PIECES.forEach((p, i) => { p.index = i; PIECE_BY_ID[p.id] = p; if (p.code) PIECE_BY_CODE[p.code] = p; if (p.kind === 'machine') { p.w = p.bw * BS; p.h = p.bh * BS; } });
|
||||
// power per grain of belt / tube (a block is 16 grains)
|
||||
const BELT_POWER = 0.03, TUBE_POWER = 0.125, FAN_POWER = 6;
|
||||
for (let c = BP_SLOPE_BL; c <= BP_SLOPE_TR; c++) PIECE_BY_CODE[c] = PIECE_BY_ID.wall;
|
||||
for (const c of [BP_BELT_UP, BP_BELT_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.belt;
|
||||
for (const c of [BP_FILTER_UP, BP_FILTER_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.filter;
|
||||
// power per grain of belt / screw (a block is 16 grains)
|
||||
const BELT_POWER = 0.03, SCREW_POWER = 0.125, FAN_POWER = 6;
|
||||
const WHEEL_K = 0.32, ENGINE_K = 40;
|
||||
|
||||
const MOLDS = {
|
||||
ingot: {
|
||||
// one long trough: metal runs along it and sets level; knocking it out
|
||||
// snaps the ingot into bars of 8 grains each
|
||||
// one long trough: metal sets level and drops bars of 8 grains below
|
||||
name: 'Ingot mold', rows: [
|
||||
'................',
|
||||
'#oooooooooooooo#',
|
||||
|
||||
+70
-56
@@ -1,77 +1,91 @@
|
||||
// Processing machines: the Refiner, the Stone cutter and the Silo.
|
||||
// ---------- Refiner ----------
|
||||
// Processing: Grinder and Saw blocks, and the Silo.
|
||||
// ---------- Grinding and cutting ----------
|
||||
// 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.
|
||||
// the rest is ground to dust and washed away, so a Grinder 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[RGRAN] = [[0.04, FEORE], [0.024, ZNORE], [0.02, CUORE], [0.004, AGORE]];
|
||||
REFINE[RBAS] = [[0.06, FEORE], [0.02, ZNORE], [0.006, AUORE]];
|
||||
REFINE[GRAVEL] = [[0.03, FEORE], [0.02, CUORE], [0.01, SNORE], [0.003, ZNORE]];
|
||||
REFINE[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;
|
||||
const REFINE_SAND = 0.5;
|
||||
const CUT_PER_BRICK = 8;
|
||||
const SAW_CUTS = []; SAW_CUTS[RSTONE] = BARSB; SAW_CUTS[LWOOD] = PLANK;
|
||||
// Block hoppers share an outlet down a vertical stack; only its top spends work.
|
||||
let procState = new Map(), procBlocks = [], procDirty = true;
|
||||
function isProcCode(c) { return c === BP_GRIND || c === BP_SAW || c === BP_PRESS; }
|
||||
function rebuildProcessors() {
|
||||
procBlocks = [];
|
||||
for (let b = 0; b < NB; b++) if (isProcCode(blockP[b])) {
|
||||
procBlocks.push(b);
|
||||
if (!procState.has(b)) procState.set(b, { work: 0, press: 0, cut: { [RSTONE]: 0, [LWOOD]: 0 }, busyT: -999, blocked: false });
|
||||
}
|
||||
procDirty = false;
|
||||
}
|
||||
function procBottom(b) { while (b + BW < NB && isProcCode(blockP[b + BW])) b += BW; return b; }
|
||||
function procSlot(b) {
|
||||
const y = (((b / BW) | 0) + 1) * BS, x = (b % BW) * BS;
|
||||
if (y >= H) return -1;
|
||||
for (const dx of [5, 6, 4, 7]) { const j = y * W + mm.x + dx; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; }
|
||||
for (let k = 0; k < 4; k++) { const j = y * W + x + [1, 2, 0, 3][k]; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; }
|
||||
return -1;
|
||||
}
|
||||
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;
|
||||
function procFeed(b, m, T) {
|
||||
const end = procBottom(b), x = (b % BW) * BS, y = ((b / BW) | 0) * BS - 1;
|
||||
for (;;) {
|
||||
const s = procState.get(b); s.busyT = tick;
|
||||
if (blockP[b] === BP_GRIND && PH[m] === PH_POWDER && REFINE[m]) {
|
||||
const from = m; 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'); }
|
||||
if (got) { stats.refinedOre = (stats.refinedOre || 0) + 1; discover(got, x, y, from, '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);
|
||||
stats.refined = (stats.refined || 0) + 1; m = got;
|
||||
if (!m) return;
|
||||
} else if (blockP[b] === BP_SAW && SAW_CUTS[m]) {
|
||||
if (++s.cut[m] < CUT_PER_BRICK) return;
|
||||
s.cut[m] = 0; m = SAW_CUTS[m];
|
||||
} else if (blockP[b] === BP_PRESS && m === LCLAY) {
|
||||
if (++s.press < CUT_PER_BRICK) return;
|
||||
s.press = 0; m = GREENB;
|
||||
}
|
||||
if (b === end) break;
|
||||
b += BW;
|
||||
}
|
||||
if (m === BARSB || m === PLANK || m === GREENB) {
|
||||
const e = spawnBar(m, x, (((end / BW) | 0) + 1) * BS, (rnd() - 0.5) * 0.1, 0.05);
|
||||
if (e) {
|
||||
e.T = T; discover(m, x, y, m === GREENB ? LCLAY : m === PLANK ? LWOOD : RSTONE, m === GREENB ? 'press' : 'cut');
|
||||
if (m === GREENB) stats.pressed = (stats.pressed || 0) + 1;
|
||||
else if (m === PLANK) stats.planks = (stats.planks || 0) + 1;
|
||||
else stats.bricks = (stats.bricks || 0) + 1;
|
||||
}
|
||||
// ---------- Stone cutter ----------
|
||||
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;
|
||||
} else setCell(procSlot(end), m, T, 0);
|
||||
}
|
||||
function procBlocksTick() {
|
||||
if (procDirty) rebuildProcessors();
|
||||
for (const b of procBlocks) {
|
||||
if (b >= BW && isProcCode(blockP[b - BW])) continue;
|
||||
const s = procState.get(b), end = procBottom(b), y = ((b / BW) | 0) * BS - 1, x = (b % BW) * BS;
|
||||
s.work += blockP[b] === BP_GRIND ? 0.25 : 0.5;
|
||||
let jobs = Math.floor(s.work); s.work -= jobs;
|
||||
if (!jobs || y < 0) continue; // a tick without work keeps the last blocked state, so it doesn't flicker
|
||||
for (let q = b; q <= end; q += BW) procState.get(q).blocked = false;
|
||||
for (let k = 0; k < 4 && jobs > 0; k++) {
|
||||
const x = mm.x + 2 + ((k + tick) & 3), i = y * W + x, m = mat[i];
|
||||
const i = y * W + x + ((k + tick) & 3), m = mat[i];
|
||||
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
|
||||
if (m === 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);
|
||||
let brick = false;
|
||||
if (SAW_CUTS[m] || m === LCLAY) for (let q = b; q <= end; q += BW) {
|
||||
if (blockP[q] === BP_GRIND && REFINE[m]) break;
|
||||
if (blockP[q] === BP_PRESS && m === LCLAY) { brick = procState.get(q).press + 1 >= CUT_PER_BRICK; break; }
|
||||
if (blockP[q] === BP_SAW && SAW_CUTS[m]) { brick = procState.get(q).cut[m] + 1 >= CUT_PER_BRICK; break; }
|
||||
}
|
||||
if (procSlot(end) < 0 || brick && !entFreeAt(x, (((end / BW) | 0) + 1) * BS)) {
|
||||
for (let q = b; q <= end; q += BW) procState.get(q).blocked = true;
|
||||
break;
|
||||
}
|
||||
const T = temp[i]; setCell(i, EMPTY, AMB[y], 0); procFeed(b, m, T); jobs--;
|
||||
}
|
||||
jobs--; mm.busyT = tick;
|
||||
}
|
||||
}
|
||||
// ---------- Silo ----------
|
||||
|
||||
+10
-10
@@ -15,33 +15,33 @@ function smelterBlueprint(kind, count, ch, wall) {
|
||||
}
|
||||
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.' },
|
||||
desc: 'Stone furnace over an ingot mold. Charge it with about 50 copper ore and 50 coal, mixed (or the ore first with the coal on top), and light the coal. Makes 3 bars a firing. Bars drop out of the bottom of the mold onto whatever is under it; leave one open block under it, and a belt below that carries them off. Leftover copper stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.' },
|
||||
{ id: 'bitfurnace', name: 'Bit casting furnace', bp: () => smelterBlueprint('bit', 1, 3, RSTONE),
|
||||
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: 'dig', title: 'Break ground', msg: 'Broken material falls loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', hint: 'Hold the left mouse button with the Drill (1): it sends pulses at the cursor that shake rock until it shatters. It falls as rubble, ore and coal.' },
|
||||
{ id: 'fire', title: 'First fire', hint: 'Pick up coal or wood chunks with the nozzle, blow a pile out with right click, then hold the Igniter (4) on it. Fuel only burns where it touches open air.', reward: 'Thermal lens unlocked. Press H to see heat.' },
|
||||
{ id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and pick up the brick rubble with the Pneumatic nozzle. Brick rubble builds brick blocks. A Brick press makes neat green bricks to fire.', reward: 'Brick insulates far better than stone and survives 1750°C.' },
|
||||
{ id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' },
|
||||
{ id: '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: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: Ctrl+click coal and copper ore in the bag to blow a mix. Right click blows an even mix.' },
|
||||
{ id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, they drop out of the bottom of the mold onto whatever is under it; a belt carries them off. Leave space below, or pick up the bars with the Pneumatic nozzle. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap. Copper that sets in the furnace melts again next firing.' },
|
||||
{ id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' },
|
||||
{ id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' },
|
||||
{ id: '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: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw conveyors push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' },
|
||||
{ id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The nozzle can suck up and blow out liquids.' },
|
||||
{ id: 'iron', title: 'Molten iron', msg: 'Iron is hard to keep molten. A brick mold right under the hot zone works best.', hint: 'Iron ore melts at 1538°C. That needs forced air, and stone slumps at 1250°C, so build the furnace from brick.' },
|
||||
{ id: 'bit2', title: 'Iron drill bit', hint: 'Cast iron in a Bit mold. A stone mold melts at iron temperatures, so build it from brick.', reward: 'Iron bit. Granite is now diggable.' },
|
||||
{ id: 'steel', title: 'Steel', msg: 'Cast it in a brick Bit mold for the best drill bit there is.', hint: 'Blow air through molten iron. Aim a Fan at a pool of it and the excess carbon burns off, turning it into steel.' },
|
||||
{ id: 'bit3', title: 'Steel drill bit', hint: 'Cast steel in a brick Bit mold.', reward: 'Steel bit. Basalt is now diggable.' },
|
||||
{ id: 'tank', title: 'Bigger bag', hint: 'Cast any metal in a Tank mold and knock it out to carry more.' },
|
||||
{ id: 'sale', title: 'First sale', msg: 'Money buys machines: belts, screw tubes and fans. Refined goods pay far more than raw.', hint: 'Blow, belt or lift goods into the trade dock near where you started. They are paid for the moment they drop through the grate in its floor.' },
|
||||
{ id: 'sale', title: 'First sale', msg: 'Money buys machines: belts, screw conveyors and fans. Refined goods pay far more than raw.', hint: 'Blow, belt or lift goods into the trade dock near where you started. They are paid for the moment they drop through the grate in its floor.' },
|
||||
{ id: 'order', title: 'First order filled', msg: 'Bigger buyers notice you as you fill more orders.', hint: 'Buyers post orders on the Market page: an amount of one good by a deadline, at well over market price. Goods dropped into the dock fill an open order first.' },
|
||||
{ id: 'alloy', title: 'An alloy', msg: 'Alloys sell for far more than the metals that went into them.', hint: 'Tin ore lies a little deeper than copper, zinc deeper again. Melt tin into a pool of molten copper and it mixes into bronze; zinc makes brass.' },
|
||||
{ id: 'precious', title: 'Precious metal', hint: 'Silver lies deep in the granite, gold deeper still. Their ore sells high even raw, and much higher as bars.' },
|
||||
{ id: 'claim', title: 'Pay off the claim', msg: 'The land is yours.', hint: 'You owe the claim office $20,000 for this land. Pay it down from the Market page of your notebook.', reward: 'The land is yours.' },
|
||||
{ id: 'claim', title: 'Pay off the claim', msg: 'The land is yours. Open a new claim from the notebook’s Claims page.', hint: 'Pay down this land from the Market page of your notebook. Once paid off, open a new claim from the Claims page.', reward: 'The land is yours.' },
|
||||
{ id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' },
|
||||
{ id: '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.' },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Each rack block owns its bars; only the bottom of a column releases them.
|
||||
const RACK_CAP = 50;
|
||||
let rackState = new Map();
|
||||
function newRack() { return { q: [], n: 0, open: false, wait: 0 }; }
|
||||
function rackTop(b) { while (b >= BW && blockP[b - BW] === BP_RACK) b -= BW; return b; }
|
||||
function rackBottom(b) { while (b + BW < NB && blockP[b + BW] === BP_RACK) b += BW; return b; }
|
||||
function rackPush(s, m, n) {
|
||||
const last = s.q[s.q.length - 1];
|
||||
if (last && last[0] === m) last[1] += n; else s.q.push([m, n]);
|
||||
s.n += n;
|
||||
}
|
||||
function rackShift(s, n) {
|
||||
s.q[0][1] -= n; s.n -= n;
|
||||
if (!s.q[0][1]) s.q.shift();
|
||||
}
|
||||
// Called with the moving bar unmarked, after its ordinary collision handling.
|
||||
function rackIntake(e) {
|
||||
const y = e.iy + BAR_H;
|
||||
if (e.vy !== 0 || Math.abs(e.vx) >= 0.05 || y >= H || y % BS) return false;
|
||||
for (let x = Math.max(0, e.ix); x < Math.min(W, e.ix + BAR_W); x += BS - x % BS) {
|
||||
const b = (y / BS) * BW + Math.floor(x / BS), left = (b % BW) * BS;
|
||||
if (blockP[b] !== BP_RACK || b >= BW && blockP[b - BW] === BP_RACK || Math.min(e.ix + BAR_W, left + BS) - Math.max(e.ix, left) < BAR_W / 2) continue;
|
||||
for (let q = rackBottom(b); q >= b; q -= BW) {
|
||||
const s = rackState.get(q);
|
||||
if (s.n < RACK_CAP) { rackPush(s, e.m, 1); return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function rackRefill(b) {
|
||||
const top = rackTop(b);
|
||||
for (let q = b; q > top; q -= BW) {
|
||||
const lower = rackState.get(q);
|
||||
for (let above = q - BW; above >= top && lower.n < RACK_CAP; above -= BW) {
|
||||
const upper = rackState.get(above);
|
||||
while (lower.n < RACK_CAP && upper.n) {
|
||||
const n = Math.min(RACK_CAP - lower.n, upper.q[0][1]);
|
||||
rackPush(lower, upper.q[0][0], n); rackShift(upper, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function rackTick() {
|
||||
for (const [b, s] of rackState) {
|
||||
if (b + BW < NB && blockP[b + BW] === BP_RACK) continue;
|
||||
if (!s.open) continue;
|
||||
if (!s.n) rackRefill(b);
|
||||
if (!s.n) continue;
|
||||
if (s.wait < 20) s.wait++;
|
||||
if (s.wait < 20) continue;
|
||||
const x = (b % BW) * BS, y = (((b / BW) | 0) + 1) * BS;
|
||||
if (!entFreeAt(x, y) || !spawnBar(s.q[0][0], x, y, 0, 0)) continue;
|
||||
rackShift(s, 1); s.wait = 0;
|
||||
rackRefill(b);
|
||||
}
|
||||
}
|
||||
+88
-15
@@ -3,15 +3,18 @@
|
||||
const SAVE_VERSION = 2;
|
||||
const TOFF = 64; // temperatures are saved with an offset so sub-zero snow and ice survive
|
||||
function serializeWorld() {
|
||||
// grains in flight from a tube or belt land where they are (rain in the air simply carries on)
|
||||
// grains in flight from a screw or belt land where they are (rain in the air simply carries on)
|
||||
for (const f of flyers) if (!f.bag && !f.rain) landFlyer(f, Math.max(0, Math.min(W - 1, Math.floor(f.x))), Math.max(0, Math.min(H - 1, Math.floor(f.y))));
|
||||
flyers = flyers.filter(f => f.bag || f.rain);
|
||||
const t16 = new Uint16Array(N);
|
||||
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
|
||||
const meta = {
|
||||
dev: devWorld, devLog: devLog.map(e => e.slice()),
|
||||
slopePhases: Array.from(slopePhase),
|
||||
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
|
||||
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
|
||||
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
|
||||
racks: [...rackState].map(([b, s]) => [b, s.q.map(r => r.slice()), s.open]),
|
||||
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 },
|
||||
@@ -25,11 +28,13 @@ function serializeWorld() {
|
||||
pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(),
|
||||
cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) },
|
||||
};
|
||||
return { meta, buffers: { mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } };
|
||||
return { meta, buffers: { fog: fog.slice(), mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } };
|
||||
}
|
||||
function deserializeWorld(meta, buffers) {
|
||||
if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.');
|
||||
alloc(meta.W, meta.H);
|
||||
devWorld = meta.dev === true;
|
||||
devLog = devWorld && Array.isArray(meta.devLog) ? meta.devLog.filter(e => Array.isArray(e) && Number.isSafeInteger(e[0]) && e[0] >= 0 && typeof e[1] === 'string').slice(-200).map(e => [e[0], e[1]]) : [];
|
||||
worldSeed = meta.seed;
|
||||
if (meta.surfBase) { surfBase = meta.surfBase; underD = meta.underD; }
|
||||
shallowD = meta.shallowD || 0; lapse = meta.lapse || 0.36;
|
||||
@@ -37,11 +42,25 @@ function deserializeWorld(meta, buffers) {
|
||||
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;
|
||||
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true; rebuildProcessors();
|
||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_RACK) rackState.set(b, newRack());
|
||||
for (const entry of Array.isArray(meta.racks) ? meta.racks : []) {
|
||||
if (!Array.isArray(entry)) continue;
|
||||
const [b, q, open] = entry;
|
||||
if (!Number.isInteger(b) || !rackState.has(b) || !Array.isArray(q)) continue;
|
||||
const s = newRack();
|
||||
for (const r of q) {
|
||||
if (!Array.isArray(r) || r.length !== 2) continue;
|
||||
const [m, n] = r;
|
||||
if (!Number.isInteger(m) || m <= 0 || m >= NMAT || PH[m] !== PH_ITEM || !Number.isInteger(n) || n <= 0 || n > RACK_CAP - s.n) continue;
|
||||
rackPush(s, m, n);
|
||||
}
|
||||
s.open = open === true; rackState.set(b, s);
|
||||
}
|
||||
// pipes used to be solid copper blocks; now they are thin and let things past
|
||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; }
|
||||
for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
|
||||
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) {
|
||||
for (const [b, m, inv] of meta.filters || []) if (isFilter(blockP[b])) filterCfg.set(b, filterRec({ m, inv }));
|
||||
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) {
|
||||
// the first filters kept one material in their block variant
|
||||
if (!filterCfg.has(b)) filterCfg.set(b, filterRec({ m: [blockV[b] & 127], inv: 0 }));
|
||||
blockV[b] &= 128;
|
||||
@@ -71,16 +90,23 @@ function deserializeWorld(meta, buffers) {
|
||||
const p = savedPart[i];
|
||||
if (p === P_WHEEL || p === P_INTAKE) continue;
|
||||
part[i] = p;
|
||||
if (p >= P_TU && p <= P_TR) markPow(i, true);
|
||||
if (IS_SCREW[p]) markPow(i, true);
|
||||
}
|
||||
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); }
|
||||
for (const m of meta.machines) { const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false; if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; } }
|
||||
// Old machines removed from the game are refunded.
|
||||
const removedMachines = { refiner: 120, cutter: 90 };
|
||||
for (const m of meta.machines) {
|
||||
if (!Object.hasOwn(PIECE_BY_ID, m.type)) { money += Object.hasOwn(removedMachines, m.type) ? removedMachines[m.type] : 0; continue; }
|
||||
const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false;
|
||||
if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; }
|
||||
}
|
||||
molds = [];
|
||||
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;
|
||||
if (meta.slopePhases) slopePhase.set(meta.slopePhases);
|
||||
updateRoof(); resetWeather();
|
||||
if (meta.weather) {
|
||||
const mw = meta.weather;
|
||||
@@ -91,22 +117,69 @@ function deserializeWorld(meta, buffers) {
|
||||
bot.x = meta.bot.x; bot.y = meta.bot.y; bot.lamp = meta.bot.lamp !== false; bot.vx = bot.vy = 0;
|
||||
bag = new Int32Array(NMAT); meta.bag.forEach((v, k) => { if (k < NMAT) bag[k] = v; });
|
||||
for (const m of meta.flyers || []) if (m < NMAT) bag[m]++; // grains that were mid-air when saved
|
||||
// solids saved in the bag (older saves) come back as what drilling them gives
|
||||
for (let k = 0; k < NMAT; k++) if (bag[k] && PH[k] === PH_SOLID && YLD[k]) { bag[YLD[k]] += bag[k]; bag[k] = 0; }
|
||||
bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k];
|
||||
Object.assign(player, meta.player);
|
||||
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
|
||||
ledger = meta.ledger || {};
|
||||
Object.assign(stats, meta.stats || {});
|
||||
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();
|
||||
clearStraySeals();
|
||||
powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
|
||||
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
|
||||
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
|
||||
rebuildPipes();
|
||||
for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } }
|
||||
if (buffers.fog && buffers.fog.length === fog.length) fog.set(buffers.fog);
|
||||
else {
|
||||
fogInit();
|
||||
for (let b = 0; b < NB; b++) if (blockP[b]) fogRevealAround((b % BW) * BS + BS / 2, ((b / BW) | 0) * BS + BS / 2, 12);
|
||||
for (const m of machines) if (m) for (let y = m.y; y < m.y + m.h; y += BS) for (let x = m.x; x < m.x + m.w; x += BS) fogRevealAround(x, y, 12);
|
||||
for (const mo of molds) for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) fogRevealAround(i % W, (i / W) | 0, 12);
|
||||
}
|
||||
fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
startLoadWake();
|
||||
EV.length = 0;
|
||||
}
|
||||
|
||||
// Keep the load-time heat scan, but start distant areas in order instead of all at once.
|
||||
function startLoadWake() {
|
||||
awake.fill(0); hot.fill(0); hotLvl.fill(0);
|
||||
loadWakePending = new Uint8Array(NCH).fill(1);
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||
let dev = 0;
|
||||
for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; }
|
||||
if (dev >= 2) loadWakePending[c] = 2;
|
||||
}
|
||||
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
|
||||
const distance = c => (c % CW * 32 + 16 - x) ** 2 + (((c / CW) | 0) * 32 + 16 - y) ** 2;
|
||||
loadWakeOrder = Uint32Array.from({ length: NCH }, (_, c) => c);
|
||||
loadWakeOrder.sort((a, b) => distance(a) - distance(b));
|
||||
loadWakeIndex = 0;
|
||||
wakeLoadNearBot();
|
||||
}
|
||||
function wakeLoadArea(c) {
|
||||
const pending = loadWakePending[c];
|
||||
if (!pending) return;
|
||||
loadWakePending[c] = 0;
|
||||
awake[c] = 3;
|
||||
if (pending === 2) { hot[c] = 6; hotLvl[c] = 2; }
|
||||
}
|
||||
function wakeLoadNearBot() {
|
||||
const x = bot.x + bot.w / 2, y = bot.y + bot.h / 2;
|
||||
const x0 = Math.max(0, Math.floor((x - 150) / 32)), x1 = Math.min(CW - 1, Math.floor((x + 150) / 32));
|
||||
const y0 = Math.max(0, Math.floor((y - 150) / 32)), y1 = Math.min(CH - 1, Math.floor((y + 150) / 32));
|
||||
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
|
||||
const dx = Math.max(cx * 32 - x, 0, x - (cx * 32 + 31));
|
||||
const dy = Math.max(cy * 32 - y, 0, y - (cy * 32 + 31));
|
||||
if (dx * dx + dy * dy <= 150 * 150) wakeLoadArea(cy * CW + cx);
|
||||
}
|
||||
}
|
||||
function loadWakeTick() {
|
||||
if (tick % 8 === 0) wakeLoadNearBot();
|
||||
// Twelve per tick finishes a Huge world in 1024 ticks, and a Small one in 171.
|
||||
const end = Math.min(loadWakeIndex + 12, loadWakeOrder.length);
|
||||
while (loadWakeIndex < end) wakeLoadArea(loadWakeOrder[loadWakeIndex++]);
|
||||
if (loadWakeIndex === loadWakeOrder.length) loadWakeOrder = loadWakePending = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SPACING face cells.
|
||||
// Loose grains treat them as solid (cleatMask). Nothing grips the bare face: grains slide down it
|
||||
// until a cleat stops them. Each belt step moves every cleat one face cell along the belt, and the
|
||||
// rib pushes whatever is in its way ahead of it, so the pocket in front rides along while anything
|
||||
// heaped above the rib is left to roll back. If a rib can't push its way, its whole run stalls.
|
||||
// Only uphill runs have cleats: downhill, the belt carries its load like a flat one.
|
||||
const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in grains
|
||||
const CLEAT_REACH = Math.floor(CLEAT_HEIGHT * Math.SQRT1_2);
|
||||
const SCREEN_NUDGE_TICKS = 4; // one downhill contact step per four ticks (15 steps/s), plus gravity
|
||||
let cleatChunks = new Uint8Array(0);
|
||||
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
|
||||
let ribLog = []; // the swaps one run's ribs made this step, undone if the run stalls
|
||||
function ribMove(a, b) { move(a, b, a % W, (a / W) | 0, b % W, (b / W) | 0); ribLog.push(a, b); }
|
||||
function cleatFoot(x, b, phase = slopePhase[b]) {
|
||||
const d = blockV[b] & 1 ? 1 : -1;
|
||||
return ((x - d * phase) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0;
|
||||
}
|
||||
// The rib standing on face cell (x, y) of a slope with grade g, foot first. Where its end would
|
||||
// reach into something built (a screw conveyor, the next belt, a wall), the rib stops short: -1.
|
||||
function ribCells(x, y, g) {
|
||||
const out = [];
|
||||
let open = true;
|
||||
for (let h = 0; h <= CLEAT_REACH; h++) {
|
||||
const xx = x + g * h, yy = y - h - 1, j = yy * W + xx;
|
||||
if (xx < 0 || xx >= W || yy < 0 || yy >= H || part[j] || mach[j] || PH[mat[j]] === PH_SOLID) open = false;
|
||||
out.push(open ? j : -1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function slopeUphill(c, v) { return beltGrade(c) * (v & 1 ? 1 : -1) < 0; } // v: the belt's direction
|
||||
// Is (x, y) a face cell of a sloped belt with grade g?
|
||||
function slopeFaceAt(x, y, g) {
|
||||
if (x < 0 || x >= W || y < 0 || y >= H) return false;
|
||||
const c = blockP[(y >> 2) * BW + (x >> 2)];
|
||||
return isBelt(c) && isSlopeBelt(c) && beltGrade(c) === g && beltFaceY(c, x >> 2, y >> 2, x) === y;
|
||||
}
|
||||
// Push the row of loose grains starting at p one cell along (dx, dy), front first. False if the
|
||||
// row runs into something it can't enter.
|
||||
function pushRow(p, dx, dy) {
|
||||
const row = [];
|
||||
let q = p;
|
||||
for (let k = 0; k < CARRY_DEPTH; k++) {
|
||||
if (PH[mat[q]] !== PH_POWDER || occ[q] || part[q] || (k && cleatMask[q])) break; // a machine moves its own contents
|
||||
row.push(q);
|
||||
const x = q % W + dx, y = ((q / W) | 0) + dy;
|
||||
if (x < 0 || x >= W || y < 0 || y >= H) return false;
|
||||
if (cleatMask[q + dx] && cleatMask[q + dy * W]) return false; // never between two cells of a rib
|
||||
q = y * W + x;
|
||||
}
|
||||
const front = row[row.length - 1], m = mat[front];
|
||||
if (cleatMask[q] || !(passable(m, PH_POWDER, q, dx, 0) || (PH[mat[q]] === PH_LIQUID && !occ[q] && !part[q]))) return false;
|
||||
ribMove(front, q);
|
||||
for (let k = row.length - 2; k >= 0; k--) ribMove(row[k], row[k] + dx + dy * W);
|
||||
return true;
|
||||
}
|
||||
// At the foot of an uphill run, a rib coming up around the end roller scoops what lies on the
|
||||
// feeding belt behind it, one pocket long and as high as the rib, into the pocket in front of it.
|
||||
function scoop(x, y, d, rib) {
|
||||
for (let k = 1; k < CLEAT_SPACING; k++) {
|
||||
const xx = x - d * k;
|
||||
if (xx < 0 || xx >= W) break;
|
||||
for (let yy = y; yy >= y - Math.min(k, CLEAT_REACH) && yy >= 0; yy--) {
|
||||
const a = yy * W + xx, m = mat[a];
|
||||
if (PH[m] !== PH_POWDER || occ[a] || cleatMask[a]) continue;
|
||||
const to = pocketRoom(rib, d, m);
|
||||
if (to < 0) return;
|
||||
ribMove(a, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The lowest open cell in the pocket just in front of a rib, or -1 if it is full.
|
||||
function pocketRoom(rib, d, m) {
|
||||
for (let h = 0; h < rib.length; h++) {
|
||||
if (rib[h] < 0) continue;
|
||||
for (let t = 1; t < CLEAT_SPACING; t++) {
|
||||
const j = rib[h] + d * t, jx = rib[h] % W + d * t;
|
||||
if (jx < 0 || jx >= W) break;
|
||||
if (PH[mat[j]] === PH_SOLID || cleatMask[j]) break;
|
||||
if (passable(m, PH_POWDER, j, d, 0)) return j;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// Move the rib that now stands on face cell (x - d) to (x, y), pushing what is in its way.
|
||||
function advanceCleat(x, y, g, d) {
|
||||
const st = g * d, ox = x - d, oy = y - st;
|
||||
const fresh = ribCells(x, y, g), old = slopeFaceAt(ox, oy, g) ? ribCells(ox, oy, g) : null;
|
||||
// the cells the rib sweeps: those between the old and the new rib, and the new rib itself
|
||||
const sweep = [];
|
||||
if (old) {
|
||||
for (const j of old) if (j >= 0) cleatMask[j] = 0;
|
||||
for (let h = 0; h < old.length; h++) {
|
||||
const j = old[h];
|
||||
if (j >= 0) {
|
||||
sweep.push(j + d);
|
||||
if (h < old.length - 1) sweep.push(j + st * W);
|
||||
}
|
||||
if (fresh[h] >= 0) sweep.push(fresh[h]);
|
||||
}
|
||||
} else {
|
||||
for (const j of fresh) if (j >= 0) sweep.push(j);
|
||||
if (st < 0) scoop(x, y, d, fresh);
|
||||
}
|
||||
let clear = true;
|
||||
for (const s of sweep) {
|
||||
if (s < 0 || s >= N || PH[mat[s]] !== PH_POWDER || occ[s] || part[s] || pushRow(s, d, st)) continue;
|
||||
clear = false;
|
||||
break;
|
||||
}
|
||||
for (const j of fresh) if (j >= 0) { cleatMask[j] = 1; cleatCells.push(j); } // the rebuild clears it if this run stalls
|
||||
return clear;
|
||||
}
|
||||
// A run of sloped belt is one belt: the block at its lower-left end names it.
|
||||
function slopeRunKey(b, memo) {
|
||||
if (memo.has(b)) return memo.get(b);
|
||||
const c = blockP[b], g = beltGrade(c), v = blockV[b] & 1, path = [b];
|
||||
let bx = b % BW, by = (b / BW) | 0, key = -1;
|
||||
while (bx > 0 && by - g >= 0 && by - g < BH && blockP[(by - g) * BW + bx - 1] === c && (blockV[(by - g) * BW + bx - 1] & 1) === v) {
|
||||
bx--; by -= g;
|
||||
if (memo.has(by * BW + bx)) { key = memo.get(by * BW + bx); break; }
|
||||
path.push(by * BW + bx);
|
||||
}
|
||||
if (key < 0) key = by * BW + bx;
|
||||
for (const p of path) memo.set(p, key);
|
||||
return key;
|
||||
}
|
||||
function slopeCleatsTick(advance = true) {
|
||||
const memo = new Map(), stalled = new Set();
|
||||
// the whole run shares the phase of its lower-left block, so a piece added to a running belt
|
||||
// takes up its cleat spacing instead of starting a rib of its own at the seam
|
||||
let resynced = false;
|
||||
for (const list of [beltR, beltL]) for (const i of list) {
|
||||
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if ((x & 3) || !isBelt(c) || !isSlopeBelt(c) || beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
|
||||
const p = slopePhase[slopeRunKey(b, memo)];
|
||||
if (slopePhase[b] !== p) { slopePhase[b] = p; resynced = true; }
|
||||
}
|
||||
if (resynced) rebuildCleatMask();
|
||||
if (advance && !(tick & 1)) {
|
||||
// each run moves all its ribs or none: if one can't clear its path, the run stalls and every
|
||||
// grain its ribs moved goes back. Front of the line first, so a rib pushes into room the rib
|
||||
// ahead of it has just made.
|
||||
const runs = new Map();
|
||||
for (const list of [beltR, beltL]) for (const i of list) {
|
||||
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || netSat(i) <= 0) continue;
|
||||
// lift the run's ribs off; each is put back where it moves to, and a rib leaving the top is gone
|
||||
if (cleatFoot(x, b)) for (const j of ribCells(x, y, beltGrade(c))) if (j >= 0) cleatMask[j] = 0;
|
||||
if (!cleatFoot(x, b, (slopePhase[b] + 1) % CLEAT_SPACING)) continue;
|
||||
const key = slopeRunKey(b, memo);
|
||||
if (!runs.has(key)) runs.set(key, []);
|
||||
runs.get(key).push(x, y, beltGrade(c), blockV[b] & 1 ? 1 : -1);
|
||||
}
|
||||
for (const [key, feet] of runs) {
|
||||
ribLog.length = 0;
|
||||
let ok = true;
|
||||
for (let k = 0; k < feet.length && ok; k += 4) ok = advanceCleat(feet[k], feet[k + 1], feet[k + 2], feet[k + 3]);
|
||||
if (ok) { if (flowOn) for (let k = 0; k < ribLog.length; k += 2) flowCross(flowSlopeUnder(ribLog[k]), ribLog[k], ribLog[k + 1]); continue; }
|
||||
stalled.add(key);
|
||||
for (let k = ribLog.length - 2; k >= 0; k -= 2) { swapCells(ribLog[k], ribLog[k + 1]); wakeIdx(ribLog[k]); wakeIdx(ribLog[k + 1]); }
|
||||
}
|
||||
ribLog.length = 0;
|
||||
for (const list of [beltR, beltL]) for (const i of list) {
|
||||
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c, x >> 2, y >> 2, x) === y && netSat(i) > 0 && !stalled.has(slopeRunKey(b, memo)))
|
||||
slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING;
|
||||
}
|
||||
}
|
||||
rebuildCleatMask();
|
||||
if (advance && !(tick & 1)) {
|
||||
// the running face drags the grains lying right on it; unless something below holds them,
|
||||
// they slide back down between steps, which on 45° is faster than the belt runs
|
||||
for (const list of [beltR, beltL]) for (const i of list) {
|
||||
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b], a = i - W;
|
||||
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || netSat(i) <= 0) continue;
|
||||
if (a < 0 || PH[mat[a]] !== PH_POWDER || occ[a] || cleatMask[a] || stamp[a] === tickStamp) continue;
|
||||
const d = blockV[b] & 1 ? 1 : -1, st = conveyorStep(x, y, d), j = a + d + st * W;
|
||||
if (x + d < 0 || x + d >= W || j < 0 || cleatMask[j] || !passable(mat[a], PH_POWDER, j, d, st)) continue;
|
||||
const bj = j + W;
|
||||
// dragged off an open end with nothing under it: it leaves at belt speed and falls
|
||||
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, x + d, y - 1 + st, d * 0.5, st * 0.5);
|
||||
else move(a, j, x, y - 1, x + d, y - 1 + st);
|
||||
if (flowOn) flowCross(b, a, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The barriers, rebuilt from the phases: ribs that left the top of a slope are gone.
|
||||
function rebuildCleatMask() {
|
||||
for (const i of cleatCells) { cleatMask[i] = 0; wakeCleatLoad(i); }
|
||||
cleatChunks.fill(0);
|
||||
cleatCells.length = 0;
|
||||
for (const list of [beltR, beltL]) for (const i of list) {
|
||||
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
|
||||
if (!isBelt(c) || !isSlopeBelt(c) || !slopeUphill(c, blockV[b]) || beltFaceY(c, x >> 2, y >> 2, x) !== y || !cleatFoot(x, b)) continue;
|
||||
for (const j of ribCells(x, y, beltGrade(c))) if (j >= 0) cleatCells.push(j);
|
||||
}
|
||||
for (const i of cleatCells) {
|
||||
cleatMask[i] = 1; wakeCleatLoad(i);
|
||||
if (PH[mat[i]] === PH_POWDER && !occ[i]) expelFromRib(i);
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
for (let cy = Math.max(0, (y - 1) >> 5); cy <= Math.min(CH - 1, (y + 1) >> 5); cy++)
|
||||
for (let cx = Math.max(0, (x - 1) >> 5); cx <= Math.min(CW - 1, (x + 1) >> 5); cx++) cleatChunks[cy * CW + cx] = 1;
|
||||
}
|
||||
}
|
||||
// A rib that appears where grains already lie (a belt built under a heap, a loaded save) lifts
|
||||
// each one to the nearest open cell above it.
|
||||
function expelFromRib(i) {
|
||||
for (let q = i - W, k = 0; q >= 0 && k < 24; q -= W, k++) {
|
||||
if (cleatMask[q] || part[q] || occ[q]) continue;
|
||||
if (mat[q] === EMPTY || PH[mat[q]] === PH_GAS) { move(i, q, i % W, (i / W) | 0, q % W, (q / W) | 0); return; }
|
||||
if (PH[mat[q]] !== PH_POWDER) return;
|
||||
}
|
||||
}
|
||||
// A screen shakes only the grains touching its mesh, never the whole column above it.
|
||||
function screenNudge(top, d, pass) {
|
||||
if (tick % SCREEN_NUDGE_TICKS) return;
|
||||
const i = top - W, m = mat[i];
|
||||
if (i < 0 || PH[m] !== PH_POWDER || stamp[i] === tickStamp || occ[i]) return;
|
||||
if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) return;
|
||||
const j = i + d + W;
|
||||
if (i % W + d < 0 || i % W + d >= W || j >= N) return;
|
||||
if (enter(m, PH_POWDER, j, d, 1)) { move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0); if (flowOn) flowCross(((top / W) >> 2) * BW + ((top % W) >> 2), i, j); }
|
||||
}
|
||||
|
||||
// Only loose load can react to a barrier appearing or disappearing.
|
||||
function wakeCleatLoad(i) {
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
for (let yy = Math.max(0, y - 1); yy <= Math.min(H - 1, y + 1); yy++)
|
||||
for (let xx = Math.max(0, x - 1); xx <= Math.min(W - 1, x + 1); xx++)
|
||||
if (PH[mat[yy * W + xx]] === PH_POWDER || occ[yy * W + xx]) wakeXY(xx, yy);
|
||||
}
|
||||
+5
-1
@@ -3,15 +3,18 @@
|
||||
function simTick() {
|
||||
tick++;
|
||||
tickStamp = (tickStamp % 255) + 1;
|
||||
if (tick % 20 === 1) updateRoof();
|
||||
if (loadWakeOrder) loadWakeTick();
|
||||
if (tick % 20 === 1) updateDirtyRoof();
|
||||
weatherTick();
|
||||
machinesTick();
|
||||
pipesTick();
|
||||
pulseTick();
|
||||
entTick();
|
||||
if (flyers.length) flyTick();
|
||||
if (tick % 8 === 0) wakeBotPowder();
|
||||
simMove();
|
||||
heatPass();
|
||||
if (flowOn) flowTick();
|
||||
for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--;
|
||||
if (tick % 15 === 0) checkMolds();
|
||||
if (tick % 30 === 0) checkLedger();
|
||||
@@ -22,4 +25,5 @@ function simTick() {
|
||||
if (tick % 60 === 17) { marketTick(); ordersTick(); }
|
||||
if (tick % 30 === 11 && saplings.length) saplingTick();
|
||||
ghostTick();
|
||||
if (tick % 4 === 0) fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,5 +1,18 @@
|
||||
// Tool helpers: reach, brushes, and plain digging.
|
||||
// ---------- Tools ----------
|
||||
// Building reaches about as far as the screen shows (t64): the view box the UI sets each frame
|
||||
// (setBuildRange), at least REACH from its centre and at most BUILD_MAX; anything beyond becomes
|
||||
// a blueprint ghost. With no view (tests, headless), a REACH box around the bot.
|
||||
const BUILD_MAX = 200;
|
||||
let buildBox = null;
|
||||
function setBuildRange(cx, cy, w, h) {
|
||||
const hw = Math.max(REACH, Math.min(BUILD_MAX, w / 2)), hh = Math.max(REACH, Math.min(BUILD_MAX, h / 2));
|
||||
if (!buildBox) buildBox = { cx, cy, hw, hh }; else { buildBox.cx = cx; buildBox.cy = cy; buildBox.hw = hw; buildBox.hh = hh; }
|
||||
}
|
||||
function inBuildRange(x, y) {
|
||||
if (!buildBox) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return Math.abs(dx) <= REACH && Math.abs(dy) <= REACH; }
|
||||
return Math.abs(x - buildBox.cx) <= buildBox.hw && Math.abs(y - buildBox.cy) <= buildBox.hh;
|
||||
}
|
||||
function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; }
|
||||
function forBrush(cx, cy, r, fn) {
|
||||
const r2 = (r + 0.5) * (r + 0.5);
|
||||
@@ -34,19 +47,19 @@ function toolDig(cx, cy, r) {
|
||||
const b = blockP[((y / BS) | 0) * BW + ((x / BS) | 0)];
|
||||
setCell(i, to, temp[i], 0);
|
||||
discover(to, x, y, m, 'dig');
|
||||
if (b === BP_WALL && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] = 0;
|
||||
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
|
||||
dug++;
|
||||
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
||||
} else digp[i] = p;
|
||||
} else { digp[i] = p; renderDirtyIdx(i); }
|
||||
});
|
||||
stats.dug += dug;
|
||||
let status = '';
|
||||
void 0;
|
||||
if (far && !dug && !tooHard) status = `Out of reach. Your ${tier.name.toLowerCase()} reaches ${drillReach()} grains from the bot.`;
|
||||
if (far && !dug && !tooHard) status = `Out of reach; your bit reaches ${drillReach()} grains.`;
|
||||
if (tooHard && !dug) {
|
||||
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}.`;
|
||||
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard; needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
|
||||
}
|
||||
return { dug, status, tooHard };
|
||||
}
|
||||
|
||||
+17
-11
@@ -17,13 +17,13 @@ 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; };
|
||||
let fails = 0;
|
||||
const fail = () => { fails++; };
|
||||
for (let k = rec.ops.length - 1; k >= 0; k--) {
|
||||
const o = rec.ops[k];
|
||||
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
|
||||
else if (o.t === 'block-') { const r = placeBlock(o.code, o.code === BP_FILTER ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
|
||||
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (blockP[b] === BP_FILTER) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
|
||||
else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
|
||||
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
|
||||
else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); }
|
||||
else if (o.t === 'mach-') { const r = placeMachineAt(o.type, o.dir, o.bx, o.by); if (r) fail(r); else { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm) mm.on = o.on !== false; } }
|
||||
else if (o.t === 'mold+') { const mo = molds.find(m => m.kind === o.kind && m.x === o.bx * BS && m.y === o.by * BS); if (mo) removeMold(mo); }
|
||||
@@ -38,7 +38,7 @@ function undoLast() {
|
||||
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}.`;
|
||||
return fails ? `Undo incomplete: ${fails} of ${n} changes could not be put back.` : `Undid ${rec.label}.`;
|
||||
}
|
||||
function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); }
|
||||
let ghostMissing = 0, ghostIter = null;
|
||||
@@ -54,14 +54,20 @@ function ghostTick0() {
|
||||
while (budget > 0 && checks-- > 0 && ghostBlocks.size) {
|
||||
let nx = ghostIter.next();
|
||||
if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; }
|
||||
const [b, [code, v, e]] = nx.value;
|
||||
const [b, [code, v, e, swap]] = nx.value;
|
||||
const bx = b % BW, by = (b / BW) | 0;
|
||||
if (blockP[b] === code && blockV[b] === (code === BP_FILTER ? v & 128 : v)) {
|
||||
if (code === BP_FILTER && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
|
||||
if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) {
|
||||
if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
|
||||
ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
|
||||
}
|
||||
if (!blockInReach(bx, by)) continue;
|
||||
const err = canPlaceBlock(bx, by);
|
||||
// a replacing ghost takes down what is there (refunded) and builds itself, like a replace in reach
|
||||
if (swap && blockP[b]) {
|
||||
if (blockP[b] === code && isFilter(code)) { blockV[b] = filterVariant(code, v); setFilter(b, e); filtersDirty = true; ghostBlocks.delete(b); ghostBlocked.delete(b); budget--; continue; }
|
||||
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
|
||||
removeBlock(bx, by);
|
||||
}
|
||||
const err = canPlaceBlock(bx, by, code);
|
||||
if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; }
|
||||
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
|
||||
placeBlock(code, v, bx, by, false, e);
|
||||
@@ -70,7 +76,7 @@ function ghostTick0() {
|
||||
}
|
||||
for (let k = ghostMachines.length - 1; k >= 0 && budget > 0; k--) {
|
||||
const g = ghostMachines[k], P = PIECE_BY_ID[g.type];
|
||||
if (!inReach(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
|
||||
if (!inBuildRange(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
|
||||
if (canPlaceMachine(g.type, g.bx, g.by)) continue;
|
||||
if (!canAfford(P.cost, 1)) { ghostMissing++; continue; }
|
||||
placeMachineAt(g.type, g.dir, g.bx, g.by); ghostMachines.splice(k, 1); budget--;
|
||||
@@ -78,7 +84,7 @@ function ghostTick0() {
|
||||
}
|
||||
for (let k = ghostMolds.length - 1; k >= 0 && budget > 0; k--) {
|
||||
const g = ghostMolds[k], md = MOLDS[g.kind];
|
||||
if (!inReach(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
|
||||
if (!inBuildRange(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
|
||||
if (canPlaceMold(g.kind, g.bx, g.by)) continue;
|
||||
if (!canAfford(wallCost(g.wall, md.walls), 1)) { ghostMissing++; continue; }
|
||||
addMold(g.kind, g.bx, g.by, g.wall, false); ghostMolds.splice(k, 1); budget--;
|
||||
|
||||
+47
-13
@@ -2,35 +2,56 @@
|
||||
// Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
|
||||
function vacuumTargetAt(cx, cy, r) {
|
||||
for (const e of entsInCircle(cx, cy, Math.max(1, r))) return e.m;
|
||||
r = Math.max(1, r);
|
||||
let best = 0, bd = 1e9;
|
||||
forBrush(cx, cy, Math.max(1, r), (x, y, i) => {
|
||||
const m = mat[i], ph = PH[m];
|
||||
if (ph === PH_POWDER || (ph === PH_LIQUID && player.flask)) { const d = (x - cx) * (x - cx) + (y - cy) * (y - cy); if (d < bd) { bd = d; best = m; } }
|
||||
});
|
||||
for (const f of flyers) {
|
||||
if (f.rain) continue; // Rain belongs to the clouds; collecting it would create water.
|
||||
const ph = PH[f.m];
|
||||
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask))) continue;
|
||||
const x = Math.floor(f.x), y = Math.floor(f.y), dx = x - cx, dy = y - cy, d = dx * dx + dy * dy;
|
||||
if (d <= (r + 0.5) * (r + 0.5) && d < bd && inReach(x, y)) { bd = d; best = f.m; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true.
|
||||
function toolVacuum(cx, cy, r, pick) {
|
||||
let got = 0, full = false, budget = 48;
|
||||
let got = 0, first = 0, full = false, budget = 48;
|
||||
for (const e of entsInCircle(cx, cy, r)) {
|
||||
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++;
|
||||
bagAdd(e.m, 1); removeEnt(e); got++; if (!first) first = e.m;
|
||||
emit({ t: 'suck', x: e.ix + 2, y: e.iy + 1, m: e.m });
|
||||
}
|
||||
if (!full) forBrush(cx, cy, r, (x, y, i) => {
|
||||
if (!full) for (let k = flyers.length - 1; k >= 0; k--) {
|
||||
const f = flyers[k], m = f.m, ph = PH[m];
|
||||
if (f.rain) continue; // Rain belongs to the clouds; collecting it would create water.
|
||||
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask)) || !pick(m)) continue;
|
||||
const x = Math.floor(f.x), y = Math.floor(f.y), dx = x - cx, dy = y - cy;
|
||||
if (dx * dx + dy * dy > (r + 0.5) * (r + 0.5) || !inReach(x, y)) continue;
|
||||
if (bagTotal >= bagCap()) { full = true; break; }
|
||||
if (!first) first = m;
|
||||
bagAdd(m, 1); flyers[k] = flyers[flyers.length - 1]; flyers.pop(); got++;
|
||||
if (got & 1) emit({ t: 'suck', x, y, m });
|
||||
if (--budget <= 0) break;
|
||||
}
|
||||
if (!full && budget > 0) forBrush(cx, cy, r, (x, y, i) => {
|
||||
const m = mat[i], ph = PH[m];
|
||||
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask))) return;
|
||||
if (!pick(m)) return;
|
||||
if (part[i] >= P_TU && part[i] <= P_TR) return;
|
||||
if (IS_SCREW[part[i]]) return;
|
||||
if (bagTotal >= bagCap()) { full = true; return false; }
|
||||
if (!first) first = m;
|
||||
bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++;
|
||||
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.' : '' };
|
||||
return { got, m: first, full, status: full ? 'Bag full; blow material out or cast a bigger tank.' : '' };
|
||||
}
|
||||
// Blow: grains leave the bot's nozzle and fly to the cursor, where they drop.
|
||||
// Blow: grains leave the bot's nozzle and arc toward the cursor, within reach.
|
||||
// Liquids need the Flask. Bars are thrown, one at a time.
|
||||
function nozzle(tx, ty) {
|
||||
const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2;
|
||||
@@ -45,12 +66,12 @@ 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.' };
|
||||
if (!avail.length) return { placed: 0, status: 'None of the selected materials are in the bag.' };
|
||||
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
|
||||
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.` };
|
||||
if (!m) return { placed: 0, status: 'Nothing selected: click or Q/E in the bag.' };
|
||||
if (bag[m] <= 0) return { placed: 0, status: 'None of the selected materials are in the bag.' };
|
||||
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);
|
||||
@@ -64,6 +85,10 @@ function toolBlow(cx, cy, r, m, mix) {
|
||||
if (!e) return { placed: 0, status: 'No room to throw a bar from here.' };
|
||||
bagTake(m, 1); return { placed: 1, status: '' };
|
||||
}
|
||||
if (!inReach(cx, cy)) {
|
||||
cx = nz.ox + nz.dx * nz.dist - 0.5;
|
||||
cy = nz.oy + nz.dy * nz.dist - 0.5;
|
||||
}
|
||||
if (flyers.length > 3000) return { placed: 0, status: '' };
|
||||
// nozzle 0 is a trickle for careful work, the widest a torrent
|
||||
const rate = 1 + r + ((r * r) >> 1);
|
||||
@@ -72,7 +97,7 @@ function toolBlow(cx, cy, r, m, mix) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const gm = pickM ? pickM() : m;
|
||||
if (!gm) break;
|
||||
// aim each grain at a spot in the brush around the cursor, on an arc that passes
|
||||
// aim each grain at a spot in the brush around the target, on an arc that passes
|
||||
// through it; past that point gravity carries it on until it hits something
|
||||
const a = rnd() * Math.PI * 2, rr = Math.sqrt(rnd()) * (r + 0.5);
|
||||
const tx = cx + 0.5 + Math.cos(a) * rr, ty = cy + 0.5 + Math.sin(a) * rr;
|
||||
@@ -107,7 +132,10 @@ function depositNear(m, T, x, y, skip) {
|
||||
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; }
|
||||
if (j !== skip && flyOpen(m, j)) {
|
||||
if (mat[j] === STEAM) absorbSteam(xx); // displaced steam still belongs to the world's water
|
||||
setCell(j, m, T, 0); return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -120,7 +148,10 @@ function landFlyer(f, x, y) {
|
||||
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 (flyOpen(f.m, j)) {
|
||||
if (mat[j] === STEAM) absorbSteam(x); // landing replaces the gas, so keep its water in the clouds
|
||||
setCell(j, f.m, f.T, 0); return;
|
||||
}
|
||||
}
|
||||
if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds)
|
||||
}
|
||||
@@ -135,7 +166,10 @@ function flyTick() {
|
||||
if (cx < 0 || cx >= W) { f.vx = 0; continue; } // the world's edge stops it; it drops from there
|
||||
if (cy >= H) { landFlyer(f, px, H - 1); done = true; break; }
|
||||
if (cy < 0) { f.x = nx; f.y = ny; continue; }
|
||||
const open = flyOpen(f.m, cy * W + cx);
|
||||
// A free flyer must not cut across the point where two solid cells meet.
|
||||
const sealed = cx !== px && cy !== py && px >= 0 && px < W && py >= 0 && py < H &&
|
||||
PH[mat[py * W + cx]] === PH_SOLID && PH[mat[cy * W + px]] === PH_SOLID;
|
||||
const open = !sealed && flyOpen(f.m, cy * W + cx);
|
||||
if (!f.free) {
|
||||
// still inside whatever the bot is flying through: pass until the first open cell
|
||||
// a blocked nozzle spills: the grain backs up into the nearest open space
|
||||
|
||||
+7
-5
@@ -25,7 +25,7 @@ function worldWater() {
|
||||
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
|
||||
return Math.round(n + bag[WATER] + bag[SNOW]);
|
||||
}
|
||||
function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
|
||||
function absorbSteam(x) { if (chunkWork) { chunkEvent('moist', x); return; } const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
|
||||
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
|
||||
function spawnRain(x0, x1, k) {
|
||||
if (flyers.length > 2500) return 0;
|
||||
@@ -71,15 +71,17 @@ function weatherTick() {
|
||||
if (sh.left !== undefined) { sh.left -= made; if (sh.left <= 0 && sh.until > tick) sh.until = tick; } // made up the shortfall: it passes
|
||||
}
|
||||
}
|
||||
// clouds made of your steam build up, then rain it back out where they hang
|
||||
// clouds made of your steam build up, then rain it back out over where they hang and a band
|
||||
// either side, so water boiled off one spot does not all fall back on it
|
||||
for (let b = 0; b < n; b++) {
|
||||
if (!w.raining[b] && w.moist[b] > 150) w.raining[b] = 1;
|
||||
if (w.raining[b]) { w.moist[b] -= spawnRain(b * WBAND, (b + 1) * WBAND, Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; }
|
||||
if (w.raining[b]) { w.moist[b] -= spawnRain(Math.max(0, (b - 1) * WBAND), Math.min(W, (b + 2) * WBAND), Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; }
|
||||
}
|
||||
// and drift a little downwind every second; at the edge of the world they pile up instead
|
||||
// and drift a little downwind every second, raining ones at half the pace; at the edge of the
|
||||
// world they pile up instead
|
||||
if (tick % 60 === 0) {
|
||||
const out = new Float32Array(n), d = w.wind > 0 ? 1 : -1, f = Math.min(0.12, Math.abs(w.wind) * 0.1);
|
||||
for (let b = 0; b < n; b++) { const mv = w.raining[b] ? 0 : w.moist[b] * f; out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; }
|
||||
for (let b = 0; b < n; b++) { const mv = w.moist[b] * f * (w.raining[b] ? 0.5 : 1); out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; }
|
||||
w.moist = out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
|
||||
let simPool = null, chunkWork = null, inlineWork = null;
|
||||
function threadWorld() {
|
||||
return { W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
|
||||
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
|
||||
powderArea,roofDirty,renderDirty,AMB,roofY };
|
||||
}
|
||||
function threadBind(s) {
|
||||
({ W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
|
||||
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
|
||||
powderArea,roofDirty,renderDirty,AMB,roofY } = s);
|
||||
}
|
||||
function threadContext() {
|
||||
return { wake: new Uint8Array(NCH), heat: new Uint8Array(NCH), level: new Uint8Array(NCH),
|
||||
ownHot: new Uint8Array(NCH).fill(255), ownLevel: new Uint8Array(NCH), events: [], c: -1,
|
||||
x0: 0, x1: 0 };
|
||||
}
|
||||
function resetChunkWork(work) {
|
||||
work.wake.fill(0); work.heat.fill(0); work.level.fill(0); work.ownHot.fill(255); work.events.length = 0;
|
||||
return work;
|
||||
}
|
||||
function inlineChunkWork() {
|
||||
if (!inlineWork || inlineWork.wake.length !== NCH) inlineWork = threadContext();
|
||||
return resetChunkWork(inlineWork);
|
||||
}
|
||||
function chunkEvent(kind, ...args) { chunkWork.events.push([chunkWork.c, kind, ...args]); }
|
||||
function chunkWake(c, n = 3) {
|
||||
if (chunkWork) { if (chunkWork.wake[c] < n) chunkWork.wake[c] = n; }
|
||||
else awake[c] = n;
|
||||
}
|
||||
function chunkHeat(c, level = 2) {
|
||||
if (chunkWork) { chunkWork.heat[c] = 6; if (chunkWork.level[c] < level) chunkWork.level[c] = level; }
|
||||
else { hot[c] = 6; if (level) hotLvl[c] = level; }
|
||||
}
|
||||
function chunkSeed(c, pass) { return hash2(c, tick, worldSeed ^ Math.imul(pass + 1, 0x9e3779b9)) | 1; }
|
||||
function runChunks(chunks, pass, work) {
|
||||
const savedRS = RS;
|
||||
chunkWork = work;
|
||||
try {
|
||||
for (const c of chunks) {
|
||||
work.c = c; work.x0 = (c % CW) * 32; work.x1 = Math.min(W, work.x0 + 32);
|
||||
RS = chunkSeed(c, pass);
|
||||
if (pass < 4) moveChunk(c); else {
|
||||
const sc = hotLvl[c] === 1 ? 4 : 1;
|
||||
heatChunk(c, sc);
|
||||
}
|
||||
}
|
||||
} finally { chunkWork = null; RS = savedRS; }
|
||||
}
|
||||
function mergeChunkWork(works) {
|
||||
for (const work of works) for (let c = 0; c < NCH; c++) if (work.ownHot[c] !== 255) {
|
||||
hot[c] = work.ownHot[c]; hotLvl[c] = work.ownLevel[c];
|
||||
}
|
||||
for (const work of works) for (let c = 0; c < NCH; c++) {
|
||||
if (awake[c] < work.wake[c]) awake[c] = work.wake[c];
|
||||
if (hot[c] < work.heat[c]) hot[c] = work.heat[c];
|
||||
if (hotLvl[c] < work.level[c]) hotLvl[c] = work.level[c];
|
||||
}
|
||||
const events = works.flatMap(w => w.events).sort((a,b) => a[0] - b[0]);
|
||||
for (const [,kind,...a] of events) {
|
||||
if (kind === 'discover') discover(...a);
|
||||
else if (kind === 'emit') emit(a[0]);
|
||||
else if (kind === 'flow') { const mm = machines[a[0]]; if (mm) mm.flow += a[1]; }
|
||||
else if (kind === 'moist') absorbSteam(a[0]);
|
||||
else if (kind === 'lost') partLost(a[0]);
|
||||
else if (kind === 'broken') brokenMachines.push(a[0]);
|
||||
else if (kind === 'ignite') stats.ignitions++;
|
||||
else if (kind === 'liquid') {
|
||||
const i = a[0];
|
||||
if (stamp[i] !== tickStamp && mat[i] === a[1]) liquidSearch(i, i % W, (i / W) | 0, ...a.slice(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
function chunkPasses(heat) {
|
||||
for (let color = 0; color < 4; color++) {
|
||||
const chunks = [];
|
||||
for (let c = 0; c < NCH; c++) {
|
||||
if (((c % CW & 1) | (((c / CW) & 1) << 1)) !== color) continue;
|
||||
if (heat ? (!hot[c] || (hotLvl[c] === 1 && ((tick + c) & 3) !== 0)) : !awake[c]) continue;
|
||||
chunks.push(c);
|
||||
}
|
||||
if (!chunks.length) continue;
|
||||
const pass = color + (heat ? 4 : 0);
|
||||
if (simPool && simPool.world === mat && chunks.length >= (simPool.force ? 1 : (heat ? 16 : 64))) threadPass(simPool, chunks, pass);
|
||||
else { const work = inlineChunkWork(); runChunks(chunks, pass, work); mergeChunkWork([work]); }
|
||||
}
|
||||
}
|
||||
function threadParams() {
|
||||
return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,
|
||||
seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
|
||||
}
|
||||
function threadSetParams(s) {
|
||||
({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s);
|
||||
seen.set(s.seen); Object.assign(bot, s.bot);
|
||||
weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
|
||||
}
|
||||
// Transport is injected by the browser or Node harness; no browser globals in the simulation.
|
||||
function threadSlot() {
|
||||
// shortcut: 1 MiB of events per helper per pass; grow or stream it if dense reactions fill it.
|
||||
return { control: sharedArray(Int32Array, 2), bytes: sharedArray(Uint8Array, 1024 * 1024),
|
||||
wake: sharedArray(Uint8Array, NCH), heat: sharedArray(Uint8Array, NCH),
|
||||
level: sharedArray(Uint8Array, NCH), ownHot: sharedArray(Uint8Array, NCH),
|
||||
ownLevel: sharedArray(Uint8Array, NCH) };
|
||||
}
|
||||
function threadExecute(slot, chunks, pass) {
|
||||
slot.wake.fill(0); slot.heat.fill(0); slot.level.fill(0); slot.ownHot.fill(255);
|
||||
const work = { ...slot, events: [], c: -1 };
|
||||
runChunks(chunks, pass, work);
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(work.events));
|
||||
if (encoded.length > slot.bytes.length) throw Error('Simulation event buffer full');
|
||||
slot.bytes.set(encoded); Atomics.store(slot.control, 1, encoded.length);
|
||||
}
|
||||
function threadPass(pool, chunks, pass) {
|
||||
const params = threadParams(), n = Math.min(pool.slots.length, (pool.force ? chunks.length : Math.max(1, Math.floor(chunks.length / (pass < 4 ? 32 : 8)))));
|
||||
pool.activeMax = Math.max(pool.activeMax || 0, n);
|
||||
const batches = Array.from({length:n + 1}, () => []);
|
||||
for (let k = 0; k < chunks.length; k++) batches[k % batches.length].push(chunks[k]);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const slot = pool.slots[k]; Atomics.store(slot.control, 0, 0);
|
||||
pool.workers[k].postMessage({ chunks: batches[k], pass, params });
|
||||
}
|
||||
const local = inlineChunkWork(); runChunks(batches[n], pass, local);
|
||||
const works = [local], deadline = Date.now() + 10000;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const slot = pool.slots[k];
|
||||
threadWait(slot, deadline);
|
||||
const events = JSON.parse(new TextDecoder().decode(slot.bytes.slice(0, Atomics.load(slot.control, 1))));
|
||||
if (Atomics.load(slot.control, 0) < 0) throw Error('Simulation worker: ' + events);
|
||||
works.push({ ...slot, events });
|
||||
}
|
||||
mergeChunkWork(works);
|
||||
}
|
||||
function threadWait(slot, deadline) {
|
||||
let spins = 0;
|
||||
// Atomics.wait is forbidden on the browser main thread. Bound polling to fail closed.
|
||||
while (Atomics.load(slot.control, 0) === 0) {
|
||||
if (++spins === 16384) { spins = 0; if (Date.now() > deadline) throw Error('Simulation worker timed out'); }
|
||||
}
|
||||
}
|
||||
function threadFailure(slot, error) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(String(error.stack || error)));
|
||||
slot.bytes.set(bytes.subarray(0, slot.bytes.length));
|
||||
Atomics.store(slot.control, 1, Math.min(bytes.length, slot.bytes.length));
|
||||
Atomics.store(slot.control, 0, -1); Atomics.notify(slot.control, 0);
|
||||
}
|
||||
function threadMessage(data) {
|
||||
if (data.world) { threadBind(data.world); globalThis.threadWorkerSlot = data.slot; Atomics.store(data.slot.control, 0, 1); return; }
|
||||
const slot = globalThis.threadWorkerSlot;
|
||||
try {
|
||||
threadSetParams(data.params); threadExecute(slot, data.chunks, data.pass);
|
||||
Atomics.store(slot.control, 0, 1); Atomics.notify(slot.control, 0);
|
||||
} catch (e) { threadFailure(slot, e); }
|
||||
}
|
||||
+43
-16
@@ -13,10 +13,12 @@ let ents = []; // loose objects (bars)
|
||||
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
|
||||
let flyers = []; // grains in flight, blown out of the bot
|
||||
let ghostBlocks = new Map(), ghostMolds = [];
|
||||
let awake, hot, hotLvl;
|
||||
let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk.
|
||||
let awake, hot, hotLvl, powderArea, roofDirty;
|
||||
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
|
||||
let tick = 0, tickStamp = 1;
|
||||
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
|
||||
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
|
||||
let beltL = [], beltR = [], screwLists = Array.from({ length: IS_SCREW.length }, () => []);
|
||||
let brokenMachines = [];
|
||||
let ghostMachines = [], ghostBlocked = new Set();
|
||||
const EV = [];
|
||||
@@ -27,30 +29,43 @@ let seen = new Uint8Array(NMAT);
|
||||
let ledger = {};
|
||||
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
|
||||
|
||||
function emit(e) { if (EV.length < 400) EV.push(e); }
|
||||
function emit(e) { if (chunkWork) { chunkEvent('emit', e); return; } if (EV.length < 400) EV.push(e); }
|
||||
function bagCap() { return TANK_TIERS[player.tankTier].cap; }
|
||||
// Things count as discovered only when they happen near the player (or in the bag).
|
||||
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
|
||||
function discover(m, x, y, from, how, T) {
|
||||
if (seen[m]) return;
|
||||
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
|
||||
if (chunkWork) { chunkEvent('discover', m, x, y, from, how, T); return; }
|
||||
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
|
||||
}
|
||||
|
||||
function sharedArray(Type, length) {
|
||||
const Buffer = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
|
||||
return new Type(new Buffer(length * Type.BYTES_PER_ELEMENT));
|
||||
}
|
||||
function alloc(w, h) {
|
||||
resetDevConsole();
|
||||
W = w; H = h; N = w * h;
|
||||
fogAlloc();
|
||||
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
|
||||
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
|
||||
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
|
||||
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
|
||||
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map();
|
||||
cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
|
||||
mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
|
||||
stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(Uint8Array, N);
|
||||
noiseB = sharedArray(Uint8Array, N); bgm = sharedArray(Uint8Array, N); airS = sharedArray(Uint16Array, N); netId = sharedArray(Uint16Array, N); isPow = sharedArray(Uint8Array, N); occ = sharedArray(Uint8Array, N);
|
||||
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = sharedArray(Uint8Array, NB); blockV = sharedArray(Uint8Array, NB); slopePhase = sharedArray(Uint8Array, NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
|
||||
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
|
||||
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
|
||||
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
|
||||
AMB = new Float32Array(h);
|
||||
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
|
||||
surfY = sharedArray(Int16Array, w); roofY = sharedArray(Int16Array, w); dirtY = sharedArray(Int16Array, w); graniteY = sharedArray(Int16Array, w); basaltY = sharedArray(Int16Array, w); magmaY = sharedArray(Int16Array, w);
|
||||
AMB = sharedArray(Float32Array, h);
|
||||
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
|
||||
renderDirty = sharedArray(Uint8Array, NCH).fill(1);
|
||||
cleatChunks = sharedArray(Uint8Array, NCH);
|
||||
awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
|
||||
roofDirty = sharedArray(Uint8Array, CW).fill(1);
|
||||
powderArea = sharedArray(Uint8Array, NCH).fill(1); // unknown areas get one visit before being ruled out
|
||||
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
|
||||
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
|
||||
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
|
||||
ghostMachines = []; ghostBlocked = new Set();
|
||||
tick = 0; tickStamp = 1; drillPulses = []; lastPulseTick = -99; undoStack = []; undoRec = null;
|
||||
pipeNets = []; pipeNetAt = null; pipesDirty = true;
|
||||
@@ -64,8 +79,9 @@ function depthU(y) {
|
||||
}
|
||||
// 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 meanSurface() { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; return s / W; }
|
||||
function computeAmbient() {
|
||||
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W;
|
||||
const sm = meanSurface();
|
||||
for (let y = 0; y < H; y++) {
|
||||
let a = 18;
|
||||
if (y > sm) a += Math.min(60, (y - sm) * 0.04);
|
||||
@@ -81,16 +97,27 @@ function fillNoise() {
|
||||
}
|
||||
|
||||
// ---------- Waking ----------
|
||||
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
|
||||
function renderDirtyChunk(c) {
|
||||
const cx = c % CW, cy = (c / CW) | 0;
|
||||
for (let yy = Math.max(0, cy - 1); yy <= Math.min(CH - 1, cy + 1); yy++)
|
||||
for (let xx = Math.max(0, cx - 1); xx <= Math.min(CW - 1, cx + 1); xx++) renderDirty[yy * CW + xx] = 1;
|
||||
}
|
||||
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
|
||||
function wakeXY(x, y) {
|
||||
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
|
||||
awake[c] = 3;
|
||||
if (!chunkWork) renderDirty[c] = 1;
|
||||
roofDirty[cx] = 1;
|
||||
chunkWake(c);
|
||||
const lx = x & 31, ly = y & 31;
|
||||
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
|
||||
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
|
||||
if (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
|
||||
if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
|
||||
}
|
||||
function wakeHeatXY(x, y) {
|
||||
const c = (y >> 5) * CW + (x >> 5);
|
||||
hot[c] = 6; hotLvl[c] = 2;
|
||||
if (!chunkWork) renderDirty[c] = 1;
|
||||
roofDirty[x >> 5] = 1;
|
||||
chunkHeat(c);
|
||||
}
|
||||
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
|
||||
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// World generation helpers: noise and removal of tiny hard rock specks.
|
||||
// ---------- Perlin noise ----------
|
||||
function makeNoise(rng) {
|
||||
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
|
||||
const p = []; for (let i = 0; i < 256; i++) p.push(i);
|
||||
for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
|
||||
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||||
for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
|
||||
return function (x, y) {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
|
||||
const X = xi & 255, Y = yi & 255;
|
||||
const p0 = perm[Y], p1 = perm[Y + 1];
|
||||
const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1];
|
||||
const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf;
|
||||
const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1);
|
||||
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
|
||||
return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41;
|
||||
};
|
||||
}
|
||||
function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; }
|
||||
|
||||
// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a
|
||||
// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves
|
||||
// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock
|
||||
// around it. Bigger veins and pockets stay. live: the world is in play (an older save).
|
||||
const SPECK_MIN = 30;
|
||||
function clearHardSpecks(live) {
|
||||
const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1);
|
||||
let n = 0;
|
||||
for (const h0 of [6, 5, 4]) {
|
||||
seen.fill(0);
|
||||
const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; };
|
||||
const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; };
|
||||
for (let i0 = 0; i0 < N; i0++) {
|
||||
if (seen[i0] || !mem(i0)) continue;
|
||||
let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
|
||||
const hosts = new Map();
|
||||
st[sp++] = i0; seen[i0] = 1;
|
||||
while (sp) {
|
||||
const i = st[--sp], x = i % W, y = (i / W) | 0;
|
||||
if (sz <= SPECK_MIN) cells[sz] = i;
|
||||
sz++;
|
||||
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
|
||||
for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
|
||||
if (j < 0 || j >= N || seen[j]) continue;
|
||||
if (mem(j)) { seen[j] = 1; st[sp++] = j; }
|
||||
else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1);
|
||||
}
|
||||
}
|
||||
if (sz > SPECK_MIN) continue;
|
||||
// a speck left floating (in a cave, or dug round) takes the rock nearest round it
|
||||
if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); }
|
||||
let host = 0, best = 0;
|
||||
for (const [m, c] of hosts) if (c > best) { best = c; host = m; }
|
||||
if (!host) continue;
|
||||
for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; }
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
+45
-74
@@ -1,66 +1,7 @@
|
||||
// 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: staged terrain, ore, caves and placement, with synchronous and async entry points.
|
||||
// ---------- World generation ----------
|
||||
function generateWorld(seedIn, w, h, onProgress) {
|
||||
function* worldSteps(seedIn, w, h) {
|
||||
yield [0, 'Shaping the land'];
|
||||
worldSeed = seedIn >>> 0;
|
||||
alloc(w, h);
|
||||
RS = (worldSeed ^ 0x9E3779B9) | 1;
|
||||
@@ -146,6 +87,7 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1;
|
||||
}
|
||||
computeAmbient();
|
||||
yield [0, 'Laying rock and ore'];
|
||||
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++) {
|
||||
@@ -177,24 +119,34 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
}
|
||||
// ores
|
||||
if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) {
|
||||
const host = m;
|
||||
// Leave the shallow quarter unchanged; deeper rock has broader, richer lenses.
|
||||
const rich = Math.min(1, Math.max(0, (fy - 0.25) / 0.6));
|
||||
const c = nCoal(x / 13, y / 9);
|
||||
if (d > 6 && fy < 0.45 && c > 0.5 && m !== CLAY) m = COALV;
|
||||
if ((d > 6 && fy < 0.45 && c > 0.5 - 0.55 * rich && m !== CLAY) ||
|
||||
(fy >= 0.45 && (m === STONE || m === GRANITE) && c > 0.62 - 0.3 * rich) ||
|
||||
(m === BASALT && fy > 0.6 && c > 0.58 - 0.4 * rich)) m = COALV;
|
||||
else {
|
||||
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;
|
||||
if ((cuBand && (m === STONE || m === DIRT) && cu > (d < 140 ? 0.5 : 0.56) - 0.65 * rich) ||
|
||||
(fy > 0.48 && fy < 0.8 && (m === STONE || m === GRANITE) && cu > 0.6 - 0.46 * rich)) m = CUV;
|
||||
else {
|
||||
// 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;
|
||||
if (fy > 0.28 && (m === STONE || m === GRANITE || m === BASALT) && fe > (m === STONE ? 0.55 : 0.47) - 0.55 * rich) m = FEV;
|
||||
// rarer ores in deeper bands: tin, then zinc, silver in granite, gold deepest
|
||||
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;
|
||||
else if (((fy > 0.15 && fy < 0.55 && m === STONE) ||
|
||||
(fy >= 0.55 && fy < 0.8 && (m === STONE || m === GRANITE))) &&
|
||||
nSn(x / 8, y / 7) > 0.62 + Math.max(0, fy - 0.55) * 0.4 - 0.425 * rich) m = SNV;
|
||||
else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6 - 0.49 * rich) m = ZNV;
|
||||
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66 - 0.32 * rich) m = AGV;
|
||||
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7 - 0.18 * rich) m = AUV;
|
||||
}
|
||||
}
|
||||
// no ore where the rock is naturally hot enough to light or melt it: it would burn or run forever
|
||||
if (m !== host && (IGN[m] ? AMB[y] > IGN[m] - 120 : MELT[m] && AMB[y] > MELT[m] - 200)) m = host;
|
||||
}
|
||||
// caves
|
||||
if (d > 9) {
|
||||
@@ -221,9 +173,11 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
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);
|
||||
if ((y & 63) === 0) yield [y / h * 0.7, 'Laying rock and ore'];
|
||||
}
|
||||
yield [0.7, 'Carving caves'];
|
||||
clearHardSpecks(false);
|
||||
yield [0.71, 'Snow and trees'];
|
||||
// (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++) {
|
||||
@@ -234,6 +188,7 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
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]; } }
|
||||
}
|
||||
yield [0.72, 'Snow and trees'];
|
||||
// trees
|
||||
for (let x = 12; x < w - 12;) {
|
||||
const sy = surfY[x];
|
||||
@@ -257,6 +212,7 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
x += 16 + Math.floor(rng() * 20);
|
||||
} else x += 4;
|
||||
}
|
||||
yield [0.73, 'Springs and water'];
|
||||
// springs: in cave side walls just above the cave floor
|
||||
{
|
||||
const cands = [];
|
||||
@@ -286,16 +242,19 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
void picks;
|
||||
}
|
||||
fillNoise();
|
||||
if (onProgress) onProgress(0.75);
|
||||
// settle loose material
|
||||
yield [0.75, 'Settling loose ground'];
|
||||
// settle loose material at the water and sorting speeds the seeds were made with
|
||||
awake.fill(3);
|
||||
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = false;
|
||||
for (let k = 0; k < 260; k++) {
|
||||
tick++; tickStamp = (tickStamp % 255) + 1;
|
||||
simMove();
|
||||
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);
|
||||
if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
|
||||
}
|
||||
LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal;
|
||||
yield [0.99, 'Finishing'];
|
||||
// re-ambient after settling, then start clean
|
||||
for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; }
|
||||
tick = 0;
|
||||
@@ -311,5 +270,17 @@ function generateWorld(seedIn, w, h, onProgress) {
|
||||
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);
|
||||
fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
yield [1, 'Finishing'];
|
||||
}
|
||||
|
||||
function generateWorld(seedIn, w, h, onProgress) {
|
||||
for (const [f, label] of worldSteps(seedIn, w, h)) if (onProgress) onProgress(f, label);
|
||||
}
|
||||
async function generateWorldAsync(seedIn, w, h, onProgress) {
|
||||
let last = Date.now();
|
||||
for (const [f, label] of worldSteps(seedIn, w, h)) {
|
||||
if (onProgress) onProgress(f, label);
|
||||
if (Date.now() - last >= 30) { await new Promise(r => setTimeout(r, 0)); last = Date.now(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#dev-console {
|
||||
position: fixed; z-index: 15; bottom: 0; left: 0; right: 0;
|
||||
padding: 12px 18px; border-radius: 0; border-top: 2px solid var(--patina);
|
||||
background: rgba(15, 13, 12, 0.97); max-height: 55vh;
|
||||
}
|
||||
.dev-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.dev-head strong { color: var(--patina); }
|
||||
.dev-head button { margin-left: auto; }
|
||||
#dev-output {
|
||||
height: 160px; max-height: 28vh; overflow-y: auto; margin: 8px 0;
|
||||
font: 12px/1.6 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
#dev-form { display: flex; align-items: center; gap: 10px; }
|
||||
#dev-input { flex: 1; min-width: 0; font-family: var(--font-mono); }
|
||||
.dev-badge { display: inline-block; padding: 1px 6px; border: 1px solid var(--patina-dim); border-radius: 4px; color: var(--patina); font-size: 11px; }
|
||||
+82
-18
@@ -29,8 +29,7 @@ body {
|
||||
font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4;
|
||||
-webkit-user-select: none; user-select: none; touch-action: none;
|
||||
}
|
||||
canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||
canvas#game.nocursor { cursor: none; }
|
||||
canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */
|
||||
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
|
||||
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
|
||||
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
|
||||
@@ -61,8 +60,15 @@ canvas#game.nocursor { cursor: none; }
|
||||
.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; }
|
||||
#buildbar { position: relative; }
|
||||
.bb-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
#bb-pop { position: absolute; bottom: calc(100% + 8px); width: min(calc(300px * var(--ui)), calc(100vw - 32px)); box-sizing: border-box; padding: calc(10px * var(--ui)); display: flex; flex-direction: column; gap: 8px; background: var(--plate); border: 1px solid var(--soot-3); border-radius: 5px; box-shadow: 0 4px 18px #0008; color: var(--bone); pointer-events: none; }
|
||||
.bb-title { display: flex; align-items: center; gap: 8px; }
|
||||
.bb-title svg { width: calc(26px * var(--ui)); height: calc(26px * var(--ui)); color: var(--melt-hot); flex-shrink: 0; }
|
||||
.bb-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.bb-desc { font-size: 0.88em; }
|
||||
.bb-place { color: var(--ash); font-size: 0.82em; }
|
||||
.bb-place b { display: block; color: var(--bone); margin-bottom: 3px; }
|
||||
#ovbadges { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.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; }
|
||||
@@ -111,9 +117,6 @@ kbd {
|
||||
#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; }
|
||||
@@ -170,11 +173,17 @@ table.mt td.dip { color: var(--melt); }
|
||||
#baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
|
||||
#baglist .bagrow .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; }
|
||||
#baglist .tag { flex: none; padding: 0 3px; color: var(--patina); font-size: 11px; }
|
||||
#selcard .vacrow { flex-wrap: wrap; gap: 4px; }
|
||||
#selcard .vacrow .sw { width: 9px; height: 9px; }
|
||||
#bagfoot:empty { display: none; }
|
||||
#bagfoot { border-top: 1px solid var(--soot-3); margin-top: 4px; }
|
||||
#bagfoot .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; }
|
||||
.bagsep { margin: 6px 4px 2px; padding-top: 5px; border-top: 1px solid var(--ash-dim); color: var(--ash-dim); font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
#baglist .bagrow.none { opacity: 0.55; }
|
||||
#baglist .bagrow.none:hover { opacity: 1; }
|
||||
#bagfoot .bagclear { display: block; width: 100%; margin-top: 6px; padding: 4px 8px; background: rgba(92, 186, 166, 0.12); color: var(--bone); border: 1px solid var(--patina-dim); border-radius: 3px; font-weight: 600; cursor: pointer; }
|
||||
#bagfoot .bagclear:hover { background: rgba(92, 186, 166, 0.24); border-color: var(--patina); }
|
||||
#bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; }
|
||||
|
||||
#info { padding: 7px 10px; min-width: calc(220px * var(--ui)); max-width: min(360px, 60vw); display: flex; flex-direction: column; gap: 2px; font-size: 0.92em; }
|
||||
@@ -182,7 +191,7 @@ table.mt td.dip { color: var(--melt); }
|
||||
#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; }
|
||||
#cards { display: flex; flex-direction: column; gap: 6px; align-items: center; }
|
||||
.card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; }
|
||||
.card-pop .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; }
|
||||
@@ -202,11 +211,14 @@ table.mt td.dip { color: var(--melt); }
|
||||
.toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; }
|
||||
.toast.big { border-color: var(--melt-deep); padding: 10px 16px; }
|
||||
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; }
|
||||
.toast.err { border-color: var(--danger); border-left-width: 3px; }
|
||||
.toast.info { border-color: var(--patina); border-left-width: 3px; }
|
||||
.toast.out { opacity: 0; transition: opacity 0.4s; }
|
||||
@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) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
|
||||
#perf:not(:empty) ~ .br { }
|
||||
|
||||
/* ---------- modals ---------- */
|
||||
@@ -279,6 +291,8 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
|
||||
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
|
||||
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
|
||||
#cap-notice { max-width: 64ch; padding: 8px 14px; color: var(--melt-hot); }
|
||||
#cap-notice p { margin: 6px 0; }
|
||||
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
|
||||
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
|
||||
.tmenu .btn small { color: var(--ash); font-size: 0.82em; }
|
||||
@@ -286,6 +300,7 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
.newworld { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 10px; }
|
||||
.newworld 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 label:first-child { grid-column: 1 / -1; }
|
||||
.newworld .full { grid-column: 1 / -1; }
|
||||
.tfoot { text-align: center; color: var(--ash-dim); font-size: 0.82em; font-family: var(--font-mono); }
|
||||
#loading { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(11, 9, 8, 0.86); }
|
||||
@@ -307,6 +322,16 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast { animation: none; }
|
||||
}
|
||||
/* wall material picker */
|
||||
.wpick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; }
|
||||
.wpick .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; }
|
||||
.wpick .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; }
|
||||
.wpick .wp span span { display: block; color: var(--ash); margin-top: 3px; }
|
||||
.wpick .wp.short { opacity: 0.55; }
|
||||
.wpick .wp:hover { background: #2d2724; }
|
||||
.wpick .wp.on { border-color: #e59a52; background: #33261c; }
|
||||
.wpick .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
|
||||
[data-wall-picker] { cursor: pointer; }
|
||||
/* filter picker */
|
||||
.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; }
|
||||
@@ -333,8 +358,47 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
|
||||
.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; }
|
||||
|
||||
|
||||
/* saved worlds */
|
||||
.world-row { padding: 12px 0; border-bottom: 1px solid var(--soot-3); }
|
||||
.world-row:last-child { border-bottom: none; }
|
||||
.world-info { overflow-wrap: anywhere; }
|
||||
.world-info .note { margin: 4px 0; }
|
||||
.world-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.world-actions input { flex: 1; min-width: 120px; max-width: 100%; }
|
||||
#m-dialog { z-index: 21; }
|
||||
#continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; }
|
||||
|
||||
.vacchoice { display: flex; align-items: center; gap: 4px; text-align: left; min-width: 0; background: transparent; border: 0; padding: 0; cursor: pointer; }
|
||||
|
||||
#saving { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: 54px; z-index: 10; padding: 5px 9px; border-radius: 12px; background: rgba(11, 9, 8, 0.86); color: var(--ash); font-size: 12px; pointer-events: none; }
|
||||
#saving i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: saving-pulse 1.5s ease-in-out infinite alternate; }
|
||||
@keyframes saving-pulse { to { opacity: 0.3; } }
|
||||
@media (prefers-reduced-motion: reduce) { #saving i { animation: none; } }
|
||||
#loadstep { color: var(--ash); font-size: 13px; }
|
||||
|
||||
|
||||
/* Maps share the same grain colours and exploration. */
|
||||
#minimap { padding: 5px; }
|
||||
#mini-canvas { display: block; width: calc(200px * var(--ui)); height: calc(130px * var(--ui)); image-rendering: pixelated; }
|
||||
.map-buttons { display: flex; justify-content: flex-end; gap: 4px; margin-top: 4px; }
|
||||
.map-buttons button { padding: 2px 8px; }
|
||||
#hud:has(#minimap:not([hidden])) ~ #perf { bottom: calc(190px * var(--ui) + 16px); }
|
||||
#hud:has(#minimap:not([hidden])) ~ #saving { bottom: calc(190px * var(--ui) + 58px); }
|
||||
#m-map > .plate { width: min(1200px, 96vw); height: 88vh; max-height: none; }
|
||||
#map-canvas { display: block; width: 100%; flex: 1; min-height: 0; cursor: grab; touch-action: none; }
|
||||
#m-map .mfoot { align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 900px) {
|
||||
#mini-canvas { width: calc(150px * var(--ui)); height: calc(98px * var(--ui)); }
|
||||
}
|
||||
@media (max-width: 720px) { #mini-plus, #mini-minus { display: none; } }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#hud.building .br { bottom: calc(160px * var(--ui)); }
|
||||
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
|
||||
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
|
||||
}
|
||||
|
||||
canvas#gpu { pointer-events: none; }
|
||||
canvas#gpu[hidden] { display: none; }
|
||||
|
||||
+69
-57
@@ -27,52 +27,54 @@ function doInteract() {
|
||||
if (fb >= 0) {
|
||||
if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; }
|
||||
const run = filterRun(fb);
|
||||
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
|
||||
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0, code: blockP[fb] }), f => {
|
||||
applyFilterRun(run, f);
|
||||
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
|
||||
setStatus(`${run.length === 1 ? 'This filter' : `These ${run.length} filters`} now ${filterSentence(f)}, ${f.dir ? 'right' : 'left'}.`, 'info');
|
||||
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${beltDirection(blockP[fb], f.dir ? 128 : 0)}.`, 'info');
|
||||
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
|
||||
GAME.editRun = run;
|
||||
return;
|
||||
}
|
||||
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
|
||||
if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; }
|
||||
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0, code: pieceCode(piece()) }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
|
||||
let msg = '';
|
||||
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
if (!inReach(ax + dx, ay + dy)) continue;
|
||||
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'); }
|
||||
if (msg) setStatus(msg, 'info', /^New (drill bit|bag)/.test(msg));
|
||||
else if ((moldAt(ax, ay) || {}).kind !== 'ingot') setStatus('Nothing to use there.', 'info'); // an ingot mold needs no F: say nothing
|
||||
}
|
||||
// Pick (G or middle mouse): the piece under the cursor becomes the one you build, with its
|
||||
// Pick (middle mouse): the piece under the cursor becomes the one you build, with its
|
||||
// direction, wall material and filter settings. On loose material it picks that to blow out.
|
||||
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 i = ay * W + ax, m = mat[i];
|
||||
let bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pid = null;
|
||||
// a sloped belt's band is drawn across its block's edge, so the belt you see can lie in the next block
|
||||
if (!blockP[bi] && !ghostBlocks.has(bi)) { const sb = slopeBeltAt(i, ax, ay, R.smoothOn); if (sb >= 0) bi = sb; }
|
||||
const ghost = ghostBlocks.get(bi);
|
||||
if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; }
|
||||
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; note = MD[WALL_OF[mo.wall]].name.toLowerCase(); }
|
||||
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; }
|
||||
else if (blockP[bi] || ghost) {
|
||||
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 (isBelt(code) || isFilter(code)) { GAME.pieceShape = GAME.pieceShape || {}; GAME.pieceShape[P.id] = code; }
|
||||
if (isWall(code)) { GAME.wallMat = v; GAME.pieceDir.wall = isSlope(code) ? code - BP_SLOPE_BL + 1 : 0; }
|
||||
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; }
|
||||
else if (P.dirs) GAME.pieceDir[P.id] = v % P.dirs.length;
|
||||
}
|
||||
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');
|
||||
setStatus(`Picked: ${P.name}.`, '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'); }
|
||||
if (t) { GAME.blow = new Set([t]); selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
|
||||
else setStatus('Nothing built here to pick.', 'info');
|
||||
}
|
||||
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
|
||||
@@ -81,7 +83,7 @@ function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx -
|
||||
// Copy and cut: hold C or X and drag a box with the left button; letting go does it.
|
||||
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; }
|
||||
if (!GAME.clip) { setStatus('Hold C and drag over built pieces to copy.', 'info'); playSfx('error'); return; }
|
||||
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
|
||||
}
|
||||
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
|
||||
@@ -95,12 +97,14 @@ function doUndo() {
|
||||
}
|
||||
// right-click take-backs while the button is held count as one thing to undo
|
||||
function endRmbRecord() { if (GAME.rmbRec) { const r = GAME.rmbRec; GAME.rmbRec = null; const n = r.ops.length; r.label = `taking back ${plural(n, 'piece')}`; undoPush(r); } }
|
||||
// Belts, filters and tubes run the way you drag them; a single click uses the direction R sets
|
||||
// Belts, filters and screws run the way you drag them; a single click uses the direction R sets
|
||||
function dragDirFor(P, a, b) {
|
||||
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || P.filter)) return -1;
|
||||
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1;
|
||||
const dx = b.bx - a.bx, dy = b.by - a.by;
|
||||
if (!dx && !dy) return -1;
|
||||
if (P.id === 'tube') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
|
||||
if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return P.filter ? (dx * dy > 0 ? 1 : 0) : (dx > 0 ? 1 : 0);
|
||||
if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
|
||||
if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
|
||||
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
|
||||
}
|
||||
function blockLabel(P, rec) {
|
||||
@@ -119,33 +123,29 @@ function primaryPress(shift) {
|
||||
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;
|
||||
GAME.vacFilter = shift ? -1 : GAME.blow.size ? -2 : grainAt(ax, ay);
|
||||
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 { bw, bh } = pieceSize(P);
|
||||
const { bx, by } = stampOrigin(bw, bh);
|
||||
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
|
||||
if (!inBuildRange(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
|
||||
const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by);
|
||||
const check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
|
||||
let err = '';
|
||||
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)) {
|
||||
// G: take down what you built in the way (refunded) and put this in its place
|
||||
if (err && GAME.replace && /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; }
|
||||
if (err) { setStatus(err + (/already/.test(err) ? `; hold G to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,51 +156,63 @@ function primaryRelease(shift) {
|
||||
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'); }
|
||||
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks; click to paste.`, 'info'); }
|
||||
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
|
||||
} else {
|
||||
const bp = copyRegion(sd.x, sd.y, ax, ay);
|
||||
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks. Click to place it.`, 'info'); }
|
||||
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks; click to place.`, 'info'); }
|
||||
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t === 'build' && GAME.drag) {
|
||||
const P = piece(), b = aimBlock();
|
||||
const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by);
|
||||
const dd = dragDirFor(P, GAME.drag, b);
|
||||
if (dd >= 0) GAME.pieceDir[P.id] = dd;
|
||||
const v = pieceV(P), ex = pieceExtra(P);
|
||||
const P = piece(), plan = dragPlan(P, GAME.drag, aimBlock(), shift), { code, v, cells: list } = plan;
|
||||
if (plan.dd >= 0) GAME.pieceDir[P.id] = plan.dd;
|
||||
const ex = pieceExtra(P);
|
||||
// blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length
|
||||
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
|
||||
let ghosts = 0;
|
||||
const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => {
|
||||
const res = placeBlocks(P.code, v, near, ex, GAME.ctrl);
|
||||
const res = placeBlocks(code, v, near, ex, GAME.replace);
|
||||
const g = { t: 'paste', blocks: [], machines: [], molds: [] };
|
||||
for (const [bx, by] of far) {
|
||||
const b = by * BW + bx;
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, P.code, v, ex) || ghostBlocks.has(b)) continue;
|
||||
ghostBlocks.set(b, ex ? [P.code, v, ex] : [P.code, v]); g.blocks.push([bx, by, P.code]); ghosts++;
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue;
|
||||
// held G over a different piece: the ghost replaces it when the bot comes in range
|
||||
ghostBlocks.set(b, GAME.replace && blockP[b] ? [code, v, ex, 1] : ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++;
|
||||
}
|
||||
if (ghosts) undoNote(g);
|
||||
return res;
|
||||
});
|
||||
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.` : '';
|
||||
const hint = /already built/.test(r.status) ? `; hold G to replace it.` : '';
|
||||
const gtxt = ghosts ? `${r.n} placed; ${ghosts} ghosts waiting for the bot.` : '';
|
||||
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
|
||||
GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
|
||||
}
|
||||
if (t === 'vacuum') { GAME.vacFilter = 0; GAME.vacLocked = false; hudDirty = true; }
|
||||
if (t === 'vacuum') { GAME.vacFilter = 0; hudDirty = true; }
|
||||
}
|
||||
function secondaryPress() {
|
||||
function secondaryPress(shift) {
|
||||
const t = toolId();
|
||||
GAME.rmbSeen = new Set();
|
||||
GAME.rmbSeen = new Set(); GAME.blowAll = !!shift;
|
||||
if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; }
|
||||
else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; }
|
||||
else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
|
||||
else if (t === 'drill') { firePing(); GAME.rmbConsumed = true; }
|
||||
else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
|
||||
}
|
||||
|
||||
// Right click with the drill: a resonance ping. The echo is drawn by drawPing (overlays.js).
|
||||
const PING_RECHARGE = 600;
|
||||
// Seconds until the ping is ready again (0: ready).
|
||||
function pingWait() { return GAME.pingTick !== undefined && tick >= GAME.pingTick ? Math.max(0, Math.ceil((PING_RECHARGE - (tick - GAME.pingTick)) / 60)) : 0; }
|
||||
function firePing() {
|
||||
if (pingWait()) { setStatus(`The ping is recharging: ${pingWait()} s.`); playSfx('error'); return; }
|
||||
const p = resonancePing(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
GAME.ping = Object.assign(p, { t0: performance.now() }); GAME.pingTick = tick;
|
||||
playSfx('pulse', 2);
|
||||
const names = p.found.map(m => MD[m].name.toLowerCase());
|
||||
setStatus(names.length ? `Ping: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ' and more' : ''} nearby.` : `Ping: no veins within ${Math.round(p.r / BS)} blocks.`, 'info');
|
||||
}
|
||||
// continuous actions, once per simulation tick
|
||||
function toolTick() {
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
@@ -209,30 +221,30 @@ function toolTick() {
|
||||
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 (GAME.vacFilter === 0) { GAME.vacFilter = grainAt(ax, ay); if (GAME.vacFilter) hudDirty = true; }
|
||||
const f = GAME.vacFilter;
|
||||
if (f !== 0) {
|
||||
const r = toolVacuum(ax, ay, GAME.brush[1], f === -2 ? m => GAME.blow.has(m) : f < 0 ? () => true : 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.'); }
|
||||
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Aim at coal or wood touching air to light it.'); }
|
||||
else setStatus('Out of reach.');
|
||||
}
|
||||
}
|
||||
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 (!GAME.blowAll && !GAME.blow.size) { setStatus('Nothing selected: click or Q/E in the bag.'); return; }
|
||||
const list = GAME.blowAll ? blowAllList() : blowList();
|
||||
if (!GAME.blowAll && !list.length) { setStatus('None of the selected materials are in the bag.'); return; }
|
||||
const r = GAME.blowAll && !list.length ? { placed: 0, status: 'Nothing loose in the bag to blow.' } : toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null);
|
||||
// blown down to nothing: it leaves the selection, so an empty selection picks up what you click again
|
||||
for (const m of list) if (bag[m] <= 0 && GAME.blow.delete(m)) { lastBagKey = ''; hudDirty = true; }
|
||||
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
|
||||
} else if (t === 'build') {
|
||||
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
|
||||
if (!GAME.rmbSeen.has(key) && inReach(ax, ay)) {
|
||||
if (!GAME.rmbSeen.has(key) && inBuildRange(ax, ay)) {
|
||||
GAME.rmbSeen.add(key);
|
||||
const keep = undoRec; undoRec = GAME.rmbRec;
|
||||
const msg = removePieceAt(ax, ay);
|
||||
|
||||
+24
-3
@@ -75,7 +75,7 @@ function tone(freq, dur, opts = {}) {
|
||||
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.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
|
||||
o.start(t); o.stop(t + dur + 0.02);
|
||||
}
|
||||
function noiseBurst(dur, opts = {}) {
|
||||
@@ -87,7 +87,7 @@ function noiseBurst(dur, opts = {}) {
|
||||
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.connect(f); f.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
|
||||
s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02);
|
||||
}
|
||||
const SFX = {
|
||||
@@ -118,7 +118,28 @@ const SFX = {
|
||||
sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); },
|
||||
discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); },
|
||||
};
|
||||
function playSfx(name, arg) { if (!AU.ctx || !SFX[name]) return; try { SFX[name](arg); } catch (e) { /* ignore */ } }
|
||||
// World sounds are heard around the bot, whatever the zoom: full within 24 grains, silent at 96
|
||||
const SFX_NEAR = 24, SFX_FAR = 96;
|
||||
function sfxPlace(x, y) {
|
||||
if (typeof bot === 'undefined' || !bot.w) return { gain: 1, pan: 0 };
|
||||
const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2), d = Math.hypot(dx, dy);
|
||||
return { gain: Math.max(0, Math.min(1, (SFX_FAR - d) / (SFX_FAR - SFX_NEAR))), pan: Math.max(-1, Math.min(1, dx / SFX_FAR)) * 0.6 };
|
||||
}
|
||||
function playSfx(name, arg, x, y) {
|
||||
if (!AU.ctx || !SFX[name]) return;
|
||||
const place = typeof x === 'number' && typeof y === 'number' ? sfxPlace(x, y) : null;
|
||||
if (place && place.gain < 0.02) return;
|
||||
const prev = AU.bus; let gain, pan;
|
||||
try {
|
||||
if (place) {
|
||||
gain = AU.ctx.createGain(); pan = AU.ctx.createStereoPanner();
|
||||
gain.gain.value = place.gain; pan.pan.value = place.pan;
|
||||
gain.connect(pan); pan.connect(AU.sfx); AU.bus = gain;
|
||||
}
|
||||
SFX[name](arg);
|
||||
} catch (e) { /* ignore */ }
|
||||
finally { AU.bus = prev; if (gain || pan) setTimeout(() => { if (gain) gain.disconnect(); if (pan) pan.disconnect(); }, 1500); }
|
||||
}
|
||||
|
||||
// Ambient layer driven by what is on screen.
|
||||
function auAmbient(info) {
|
||||
|
||||
+62
-23
@@ -2,9 +2,9 @@
|
||||
// ---------- Build menu ----------
|
||||
const BUILD_CATS = [
|
||||
['Structure', ['wall', 'grate', 'gate']],
|
||||
['Moving', ['belt', 'filter', 'tube']],
|
||||
['Moving', ['belt', 'filter', 'screw']],
|
||||
['Liquids', ['pipe', 'pump', 'outlet']],
|
||||
['Machines', ['fan', 'refiner', 'cutter', 'silo']],
|
||||
['Machines', ['fan', 'grinder', 'saw', 'press', 'rack', 'silo']],
|
||||
['Molds', ['ingotmold', 'bitmold', 'tankmold']],
|
||||
];
|
||||
const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]);
|
||||
@@ -12,56 +12,95 @@ const PIECE_ICONS = {
|
||||
wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>',
|
||||
grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>',
|
||||
gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>',
|
||||
belt: '<rect x="2" y="9" width="20" height="8" rx="2"/><circle cx="7" cy="13" r="2"/><circle cx="17" cy="13" r="2"/><path d="M9 5h7M14 3l2 2-2 2"/>',
|
||||
filter: '<rect x="2" y="10" width="20" height="8" rx="2"/><path d="M7 10v8M12 10v8M17 10v8M2 14h20"/><path d="M12 2v5M10 5l2 2 2-2"/>',
|
||||
tube: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
|
||||
belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M3 7V3Q5 1 7 3T11 3T15 3T19 3H21V7M7 7V3M13 7V3M19 7V3"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>',
|
||||
filter: '<g transform="rotate(45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>',
|
||||
screw: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
|
||||
pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>',
|
||||
pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>',
|
||||
outlet: '<path d="M2 7h12a4 4 0 0 1 4 4v3"/><path d="M2 11h10a2 2 0 0 1 2 2v1"/><path d="M15 18v1.5M18 20v1.5M12 20v1.5"/>',
|
||||
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"/>',
|
||||
grinder: '<path d="M3 4h18l-5 7H8z"/><circle cx="9" cy="15.5" r="2.8"/><circle cx="15" cy="15.5" r="2.8"/><path d="M12 20v2"/>',
|
||||
press: '<path d="M5 3h14l-3 5H8zM12 8v5M5 13h14v3H5z"/><rect x="8" y="18" width="8" height="3"/>',
|
||||
saw: '<path d="M5 3h14l-3 5H8z"/><circle cx="12" cy="15" r="5"/><circle cx="12" cy="15" r="1.2"/><path d="M12 9.2v1.6M17.8 15h-1.6M12 20.8v-1.6M6.2 15h1.6"/>',
|
||||
rack: '<path d="M4 3v18M20 3v18M4 11h16M4 20h16"/><path d="M7 6h10v3H7zM7 15h10v3H7z"/>',
|
||||
silo: '<path d="M6 5.5h12v11H6z"/><path d="M6 5.5l1-2h10l1 2M6 16.5l4 3h4l4-3M7 16.5v5M17 16.5v5"/><path d="M15.5 8v6"/>',
|
||||
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 pieceIcon(id) {
|
||||
const v = GAME.drag && GAME.dragDir >= 0 ? GAME.dragDir : GAME.pieceDir.screw || 0;
|
||||
const body = PIECE_ICONS[id] || '';
|
||||
const icon = id === 'screw' && v >= 4 ? `<g transform="rotate(${v === 4 || v === 6 ? -45 : 45} 12 12)">${body}</g>` : body;
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${icon}</svg>`;
|
||||
}
|
||||
function catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; }
|
||||
let lastBuildKey = '';
|
||||
let lastBuildKey = '', lastBuildMenu = '';
|
||||
function selectBuildCat(c) {
|
||||
GAME.buildCat = c;
|
||||
const ids = BUILD_CATS[c][1], keep = GAME.catPick && GAME.catPick[c];
|
||||
GAME.pieceIdx = PIECE_BY_ID[ids.includes(keep) ? keep : ids[0]].index;
|
||||
GAME.catPick = GAME.catPick || {}; GAME.catPick[c] = piece().id;
|
||||
GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui');
|
||||
}
|
||||
function cycleBuildCat(d) {
|
||||
const c = GAME.buildCat != null ? GAME.buildCat : catOf(piece().id);
|
||||
selectBuildCat((c + d + BUILD_CATS.length) % BUILD_CATS.length);
|
||||
}
|
||||
function buildVariantChips(P) {
|
||||
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
|
||||
// while dragging a line of belts, filters or screws, the direction is the drag's
|
||||
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
|
||||
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓', 'up-right': '↗', 'down-right': '↘', 'down-left': '↙', 'up-left': '↖' })[d] || '';
|
||||
const code = pieceCode(P), direction = dragging ? GAME.dragDir : GAME.pieceDir[P.id] || 0;
|
||||
const slopeDir = isSlopeBelt(code) ? beltDirection(code, P.filter ? direction << 7 : direction) : '';
|
||||
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(slopeDir || P.dirs[GAME.dragDir])} ${esc(slopeDir || P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(slopeDir || dir)} ${esc(slopeDir || dir)} · R</span>`;
|
||||
const fc = GAME.filterCfg;
|
||||
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
|
||||
return pieceUsesWall(P) ? `<button class="chip hot" data-wall-picker=""><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R · F</button>${P.code === BP_WALL ? `<span class="chip hot">${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip</span>` : ''}`
|
||||
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
|
||||
: dir ? dirChip : '';
|
||||
}
|
||||
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 (bar.hidden === show) { bar.hidden = !show; $('hud').classList.toggle('building', 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 ids = BUILD_CATS[cat][1], tip = PIECE_BY_ID[GAME.usingPad ? P.id : hov && ids.includes(hov) ? hov : ''];
|
||||
const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join('');
|
||||
const key = [cat, P.id, hov, afford, GAME.wallMat, Math.round(money)].join('|');
|
||||
const key = [cat, P.id, tip && tip.id, afford, GAME.wallMat, Math.round(money), GAME.pieceDir[P.id], GAME.dragDir, GAME.previewCode, GAME.filterCfg.inv, GAME.filterCfg.m.join('.'), innerWidth, innerHeight, SET.uiScale].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">';
|
||||
+ '<span class="bb-keys"><kbd>Q</kbd>/<kbd>E</kbd> cycle · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab</span></div><div class="bb-tiles">';
|
||||
for (const id of ids) {
|
||||
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;
|
||||
h += '</div>';
|
||||
if (!bar.querySelector('.bb-menu')) bar.innerHTML = '<div class="bb-menu"></div><div id="bb-pop" hidden></div>';
|
||||
const menu = bar.querySelector('.bb-menu');
|
||||
if (lastBuildMenu !== h) { menu.innerHTML = h; lastBuildMenu = h; }
|
||||
const pop = $('bb-pop'); pop.hidden = !tip;
|
||||
if (!tip) return;
|
||||
const size = tip.kind === 'machine' ? `${tip.bw}×${tip.bh} blocks` : tip.kind === 'mold' ? `${MOLDS[tip.mold].bw}×${MOLDS[tip.mold].bh} blocks` : '1 block';
|
||||
pop.innerHTML = `<div class="bb-title">${pieceIcon(tip.id)}<b>${esc(tip.name)}</b></div><div class="bb-chips">${chipCost(pieceCostNow(tip))}<span class="chip">${size}</span>${tip.power ? `<span class="chip">${tip.power} power</span>` : ''}</div>
|
||||
<div class="bb-desc">${esc(tip.desc)}</div><div class="bb-place"><b>How to place</b> ${tip.kind === 'block' ? (lineOnly(tip.code) ? 'Drag for a line' : 'Drag for a line, Shift-drag for a rectangle') + '; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses <kbd>R</kbd>.' : tip.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(tip) ? ' <kbd>R</kbd> cycles materials; <kbd>F</kbd> opens the material menu.' : ''}</div>
|
||||
${tip.filter ? `<div class="bb-place">New ones will ${esc(filterSentence(GAME.filterCfg))}. <kbd>F</kbd> sets materials and mode (or click materials in the bag, or <kbd>T</kbd> over one); <kbd>F</kbd> on a placed filter changes its whole row.</div>` : ''}
|
||||
<div class="bb-place">hold <kbd>G</kbd> replace · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · right click take back · middle mouse pick · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste</div>`;
|
||||
const tile = bar.querySelector(`[data-piece="${tip.id}"]`); if (!tile) { pop.hidden = true; return; }
|
||||
const r = tile.getBoundingClientRect(), br = bar.getBoundingClientRect(), w = pop.getBoundingClientRect().width;
|
||||
pop.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2)) - br.left}px`;
|
||||
}
|
||||
function setupBuildBar() {
|
||||
$('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); });
|
||||
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); }
|
||||
if (t.dataset.cat != null) { selectBuildCat(+t.dataset.cat); return; }
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Shared player state; the world save remains a complete standalone fallback.
|
||||
let career = null, claimBusy = false;
|
||||
function careerState() {
|
||||
const carriedBag = Array.from(bag);
|
||||
for (const f of flyers) if (f.bag) carriedBag[f.m]++;
|
||||
return { money, earned, bag: carriedBag, player: { ...player }, seen: Array.from(seen), ledger: { ...ledger }, stats: { ...stats } };
|
||||
}
|
||||
function validCareer(c, id, worldId) {
|
||||
const obj = v => v && typeof v === 'object' && !Array.isArray(v);
|
||||
const count = v => Number.isFinite(v) && v >= 0;
|
||||
const counts = v => obj(v) && Object.values(v).every(count);
|
||||
return obj(c) && c.id === id && Array.isArray(c.claims) && c.claims.length > 0 &&
|
||||
c.claims.every(v => typeof v === 'string' && v.length > 0) && new Set(c.claims).size === c.claims.length && c.claims.includes(worldId) &&
|
||||
Number.isSafeInteger(c.claimNo) && c.claimNo >= c.claims.length && count(c.money) && count(c.earned) &&
|
||||
Array.isArray(c.bag) && c.bag.length > 0 && c.bag.every(v => Number.isInteger(v) && v >= 0 && v <= 2147483647) &&
|
||||
Array.isArray(c.seen) && c.seen.length > 0 && c.seen.every(v => v === 0 || v === 1) &&
|
||||
obj(c.player) && Number.isInteger(c.player.bitTier) && !!BIT_TIERS[c.player.bitTier] &&
|
||||
Number.isInteger(c.player.tankTier) && !!TANK_TIERS[c.player.tankTier] &&
|
||||
typeof c.player.lens === 'boolean' && typeof c.player.flask === 'boolean' &&
|
||||
counts(c.ledger) && counts(c.stats) && ['dug', 'moved', 'placed', 'ignitions', 'cast', 'bars'].every(k => count(c.stats[k]));
|
||||
}
|
||||
function applyCareerState(c) {
|
||||
money = c.money; earned = c.earned;
|
||||
bag = new Int32Array(NMAT); bag.set(c.bag.slice(0, NMAT));
|
||||
bagTotal = bag.reduce((n, v) => n + v, 0);
|
||||
// These grains are already included in the shared bag, even on an older claim.
|
||||
flyers = flyers.filter(f => !f.bag);
|
||||
for (const k of Object.keys(player)) player[k] = c.player[k];
|
||||
seen = new Uint8Array(NMAT); seen.set(c.seen.slice(0, NMAT));
|
||||
ledger = { ...c.ledger };
|
||||
for (const k of Object.keys(stats)) delete stats[k];
|
||||
Object.assign(stats, c.stats);
|
||||
}
|
||||
async function loadCareer() {
|
||||
career = null; GAME.careerId = ''; GAME.claimNo = 1;
|
||||
try {
|
||||
if (!GAME.worldId) GAME.worldId = newWorldId();
|
||||
const entry = (await listWorlds()).find(w => w.id === GAME.worldId);
|
||||
const c = entry && entry.career ? await DB.get('career:' + entry.career) : null;
|
||||
if (entry && validCareer(c, entry.career, GAME.worldId)) {
|
||||
career = c; applyCareerState(c); GAME.claimNo = Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
|
||||
} else {
|
||||
// Preserve surviving claim membership when repairing a missing or damaged record.
|
||||
const claims = entry && entry.career ? (await listWorlds()).filter(w => w.career === entry.career) : [];
|
||||
career = { id: entry && entry.career || newWorldId(), claims: claims.length ? claims.map(w => w.id) : [GAME.worldId],
|
||||
claimNo: Math.max(1, ...claims.map(w => Number.isSafeInteger(w.claimNo) && w.claimNo > 0 ? w.claimNo : 1)), ...careerState() };
|
||||
GAME.claimNo = entry && Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
|
||||
await worldWrite(async () => {
|
||||
const list = await listWorlds();
|
||||
let w = list.find(w => w.id === GAME.worldId);
|
||||
if (!w) { w = { id: GAME.worldId, name: GAME.worldName || 'World', seed: worldSeed, w: W, h: H, savedAt: 0 }; list.push(w); }
|
||||
Object.assign(w, { career: career.id, claimNo: GAME.claimNo, debt });
|
||||
await DB.op('readwrite', st => { st.put(career, 'career:' + career.id); st.put(list, 'worlds'); });
|
||||
});
|
||||
}
|
||||
GAME.careerId = career.id;
|
||||
} catch (e) { career = null; toast('Career could not be loaded. Using this world’s own money and bag.'); }
|
||||
}
|
||||
// Seeded camp names stay the same unless an existing world already uses one.
|
||||
function claimName(seed, taken = []) {
|
||||
const first = ['Copper', 'Iron', 'Red', 'Black', 'Grey', 'Silver', 'Deep', 'Cinder', 'Ash', 'Granite', 'Basalt', 'Slate', 'Rust', 'Ember', 'Flint', 'Cold', 'Hollow', 'Lone', 'Twin', 'Broken'];
|
||||
const last = ['Hollow', 'Ridge', 'Flats', 'Gulch', 'Mesa', 'Seam', 'Reach', 'Bluff', 'Basin', 'Crag', 'Shelf', 'Draw', 'Cut', 'Rise', 'Pit', 'Vein', 'Wash', 'Fork', 'Run', 'Bench'];
|
||||
const count = first.length * last.length, start = Math.floor(mulberry32(seed)() * count);
|
||||
for (let offset = 0; offset < count; offset++) {
|
||||
const i = (start + offset) % count, a = first[Math.floor(i / last.length)], b = last[i % last.length];
|
||||
const name = a + ' ' + b;
|
||||
if (a !== b && !taken.includes(name)) return name;
|
||||
}
|
||||
// every camp name is taken: fall back to numbered names
|
||||
let n = 1;
|
||||
while (taken.includes('Mining Camp ' + n)) n++;
|
||||
return 'Mining Camp ' + n;
|
||||
}
|
||||
async function openNewClaim() {
|
||||
if (claimBusy || !career || debt > 0 || money < 5000) return false;
|
||||
claimBusy = true; const paused = GAME.paused; GAME.paused = true;
|
||||
let previous;
|
||||
try {
|
||||
if (!await saveWorld('main', true)) throw new Error('Save failed');
|
||||
previous = { save: serializeWorld(), id: GAME.worldId, name: GAME.worldName, time: GAME.playTime, career, claimNo: GAME.claimNo };
|
||||
const carried = careerState(), n = career.claimNo + 1, w = W, h = H;
|
||||
carried.money -= 5000;
|
||||
let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed);
|
||||
const name = claimName(seed, (await listWorlds()).map(w => w.name));
|
||||
await setupNewWorld(seed, w, h, name, carried, 'Opening a new claim');
|
||||
debt = CLAIM_DEBT * 2 ** (n - 1);
|
||||
career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n };
|
||||
GAME.claimNo = n;
|
||||
showLoading(true, 0.9, 'Saving');
|
||||
if (!await saveWorld('auto', true)) throw new Error('Save failed');
|
||||
showLoading(true, 1, 'Saving');
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); closeAllModals(); enterGame(true);
|
||||
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (previous) {
|
||||
deserializeWorld(previous.save.meta, previous.save.buffers);
|
||||
GAME.worldId = previous.id; GAME.worldName = previous.name; GAME.playTime = previous.time;
|
||||
career = previous.career; GAME.claimNo = previous.claimNo;
|
||||
}
|
||||
showLoading(false); GAME.paused = paused; toast('Could not open a claim. Your old claim has been kept.'); return false;
|
||||
} finally { claimBusy = false; }
|
||||
}
|
||||
async function travel(worldId) {
|
||||
if (claimBusy || !career || worldId === GAME.worldId || !career.claims.includes(worldId)) return false;
|
||||
claimBusy = true; const paused = GAME.paused; let arrived = false; GAME.paused = true;
|
||||
try {
|
||||
const entry = (await listWorlds()).find(w => w.id === worldId);
|
||||
if (!entry || entry.career !== GAME.careerId) return false;
|
||||
const progress = (f, s) => showLoading(true, f, s, 'Travelling to ' + entry.name);
|
||||
progress(0, 'Saving');
|
||||
if (!await saveWorld('main', true)) throw new Error('Save failed');
|
||||
const sv = await worldNewest(worldId, progress);
|
||||
if (!sv || !await startFromSave(sv, progress)) return false;
|
||||
closeAllModals(); arrived = true; return true;
|
||||
} catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; }
|
||||
finally { showLoading(false); claimBusy = false; if (!arrived) GAME.paused = paused; }
|
||||
}
|
||||
async function renderClaimsTab(body) {
|
||||
body.innerHTML = '<p class=note>Loading claims…</p>';
|
||||
try {
|
||||
const list = (await listWorlds()).filter(w => career && w.career === GAME.careerId && career.claims.includes(w.id));
|
||||
if (nbTab !== 'Claims') return;
|
||||
body.innerHTML = list.map(w => `<div class="world-row"><div class="world-info"><strong>${esc(w.name)}${w.id === GAME.worldId ? ' · here' : ''}</strong> <small class="note">Claim ${esc(w.claimNo)}</small><div class="note">${esc(w.w)}×${esc(w.h)} grains · ${(w.id === GAME.worldId ? debt : w.debt) <= 0 ? 'paid off ✓' : fmtMoney(w.id === GAME.worldId ? debt : w.debt || 0) + ' owed'} · Last played: ${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Just opened'}</div></div><button class="btn" data-rename="${esc(w.id)}">Rename</button>${w.id === GAME.worldId ? '' : `<button class="btn" data-travel="${esc(w.id)}">Travel</button>`}</div>`).join('') +
|
||||
(debt <= 0 ? `<button class="btn primary" id="claim-open" ${money < 5000 || !career ? 'disabled' : ''}>Open a new claim: $5,000 down, then ${fmtMoney(CLAIM_DEBT * 2 ** (career ? career.claimNo : 1))} owed</button>${money < 5000 ? '<p class=note>You need $5,000 for the down payment.</p>' : ''}` : '<p class=note>A new claim opens once this one is paid off.</p>') +
|
||||
'<p class=note>Machines on other claims wait until you come back.</p>';
|
||||
body.querySelectorAll('[data-rename]').forEach(b => b.onclick = () => {
|
||||
const w = list.find(w => w.id === b.dataset.rename);
|
||||
dialog('Rename claim', `<input class="text" type="text" id="claim-name" aria-label="Claim name" maxlength="40" value="${esc(w.name)}">`, [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Save', cls: 'primary', fn: async () => {
|
||||
const name = cleanWorldName($('claim-name').value, w.name);
|
||||
try { await renameWorld(w.id, name); closeModal('m-dialog'); renderClaimsTab(body); }
|
||||
catch (e) { toast('Could not rename this claim. Try again.'); }
|
||||
} },
|
||||
]);
|
||||
});
|
||||
const button = $('claim-open');
|
||||
if (button) button.onclick = () => dialog('Open a new claim?', `<p class="note">Pay $5,000 down and owe ${fmtMoney(CLAIM_DEBT * 2 ** career.claimNo)} on fresh land. Your buildings stay here.</p>`, [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Open claim', cls: 'primary', fn: () => { closeModal('m-dialog'); openNewClaim(); } },
|
||||
]);
|
||||
body.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => dialog('Travel to this claim?', '<p class=note>Your current claim will be saved. Money, bag, upgrades and knowledge come with you.</p>', [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Travel', cls: 'primary', fn: () => { closeModal('m-dialog'); travel(b.dataset.travel); } },
|
||||
]));
|
||||
} catch (e) { if (nbTab === 'Claims') body.innerHTML = '<p class=note>Could not load claims from browser storage.</p>'; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Hover work is shared by the cursor label and the vein outline.
|
||||
const CURSOR_ORES = new Uint8Array(NMAT);
|
||||
for (let m = 0; m < NMAT; m++) if (PING_VEINS[m]) { CURSOR_ORES[m] = 1; CURSOR_ORES[YLD[m]] = 1; }
|
||||
function oreLike(m, bar) { return !!(bar || CURSOR_ORES[m] || SOLIDMETAL[m]); }
|
||||
function bitNeeded(m) {
|
||||
if (HARD[m] > 6) return 'cannot be drilled';
|
||||
return 'needs ' + BIT_TIERS.find(b => b.hard >= HARD[m]).name.toLowerCase();
|
||||
}
|
||||
function pingVisible(x, y) {
|
||||
const p = GAME.ping;
|
||||
if (!p || x < 0 || y < 0 || x >= W || y >= H) return false;
|
||||
if (!p.cellSet) p.cellSet = new Set(p.cells);
|
||||
if (!p.cellSet.has(y * W + x)) return false;
|
||||
const start = Math.hypot(x - p.x, y - p.y) / PING_SPEED * 1000, ms = performance.now() - p.t0;
|
||||
return ms >= start && ms < start + PING_HOLD + PING_FADE;
|
||||
}
|
||||
function cursorVein(i) {
|
||||
const m = mat[i], cells = new Set([i]), queue = [i], edges = [];
|
||||
const same = q => q >= 0 && q < N && mat[q] === m && (flags[q] & F_NAT);
|
||||
const add = n => { if (cells.size < 6000 && same(n) && !cells.has(n)) { cells.add(n); queue.push(n); } };
|
||||
for (let k = 0; k < queue.length; k++) {
|
||||
const q = queue[k], x = q % W;
|
||||
if (x) add(q - 1);
|
||||
if (x < W - 1) add(q + 1);
|
||||
add(q - W); add(q + W);
|
||||
}
|
||||
for (const q of cells) {
|
||||
const x = q % W, y = (q / W) | 0;
|
||||
if (!x || !same(q - 1)) edges.push([x, y, x, y + 1]);
|
||||
if (x === W - 1 || !same(q + 1)) edges.push([x + 1, y, x + 1, y + 1]);
|
||||
if (!same(q - W)) edges.push([x, y, x + 1, y]);
|
||||
if (!same(q + W)) edges.push([x, y + 1, x + 1, y + 1]);
|
||||
}
|
||||
return { cells, edges, m, world: mat };
|
||||
}
|
||||
let cursorVeinCache = null, cursorLabelKey = null, cursorLabelSince = 0;
|
||||
function hoveredVein(x, y) {
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) return null;
|
||||
const i = y * W + x, m = mat[i];
|
||||
if (!PING_VEINS[m] || !(flags[i] & F_NAT) || (SET.fog && fogSeen(x, y) < 64 && !pingVisible(x, y))) return null;
|
||||
if (!cursorVeinCache || cursorVeinCache.world !== mat || cursorVeinCache.m !== m || !cursorVeinCache.cells.has(i)) cursorVeinCache = cursorVein(i);
|
||||
return cursorVeinCache;
|
||||
}
|
||||
function cursorLabelAlpha(key, now) {
|
||||
if (key !== cursorLabelKey) { cursorLabelKey = key; cursorLabelSince = now; }
|
||||
return Math.max(0, Math.min(1, (2400 - (now - cursorLabelSince)) / 400));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Bottom panel; command dispatch and state live in sim/devconsole.js.
|
||||
function closeDevConsole() {
|
||||
$('dev-console').hidden = true;
|
||||
$('dev-input').blur();
|
||||
}
|
||||
function showDevConsole() {
|
||||
$('dev-console').hidden = false;
|
||||
if (!$('dev-output').children.length) devPrint(['Type help to list commands. Backtick or Escape closes this panel.']);
|
||||
$('dev-input').focus();
|
||||
}
|
||||
function toggleDevConsole() {
|
||||
if (!GAME.running || GAME.loading) return;
|
||||
if (!$('dev-console').hidden) { closeDevConsole(); return; }
|
||||
if (devWorld) { showDevConsole(); return; }
|
||||
dialog('Enable dev console?', '<p>This save will be permanently marked as a dev-console world. If achievements are added later, they will not unlock in this world.</p><p>You can change money, materials, upgrades and game speed. These commands are logged but cannot be undone.</p>', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Enable dev console', cls: 'primary', fn: () => {
|
||||
enableDevConsole(); closeModal('m-dialog'); showDevConsole(); saveWorld('main'); return true;
|
||||
} },
|
||||
]);
|
||||
}
|
||||
function devPrint(lines) {
|
||||
const log = $('dev-output');
|
||||
for (const line of lines) { const row = document.createElement('div'); row.textContent = line; log.appendChild(row); }
|
||||
while (log.children.length > 400) log.firstChild.remove();
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
function runDevCommand(text) {
|
||||
devPrint(devCommand(text, GAME.aim));
|
||||
hudDirty = true; lastBagKey = ''; lastBuildKey = '';
|
||||
renderWallet();
|
||||
if (!$('m-notebook').hidden) renderNotebook();
|
||||
}
|
||||
$('dev-close').onclick = closeDevConsole;
|
||||
$('dev-input').onfocus = () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.drag = null; GAME.replace = false; };
|
||||
$('dev-input').onkeydown = e => {
|
||||
if (e.key === 'Escape' || e.key === '`') { e.preventDefault(); e.stopPropagation(); closeDevConsole(); }
|
||||
};
|
||||
$('dev-form').onsubmit = e => {
|
||||
e.preventDefault();
|
||||
const input = $('dev-input'); runDevCommand(input.value); input.value = ''; input.focus();
|
||||
};
|
||||
+3
-5
@@ -11,10 +11,7 @@ function drainEvents() {
|
||||
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');
|
||||
playSfx('cast', undefined, e.x, e.y);
|
||||
} 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',
|
||||
@@ -22,6 +19,7 @@ function drainEvents() {
|
||||
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 === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72');
|
||||
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
|
||||
else if (e.t === 'order_new') {
|
||||
const o = e.o;
|
||||
@@ -38,7 +36,7 @@ function drainEvents() {
|
||||
}
|
||||
else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); }
|
||||
else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); }
|
||||
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a); }
|
||||
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a, e.x, e.y); }
|
||||
else if (e.t === 'toast') { if (e.text) toast(e.text); }
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ function pollPad() {
|
||||
const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz);
|
||||
const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0);
|
||||
const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry;
|
||||
if (pressedAny && !GAME.usingPad) { GAME.usingPad = true; canvas.classList.add('nocursor'); }
|
||||
if (pressedAny) GAME.usingPad = true;
|
||||
const modal = topModal();
|
||||
if (modal || !GAME.running) {
|
||||
const now = performance.now();
|
||||
@@ -45,7 +45,7 @@ function pollPad() {
|
||||
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, 1)) { if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; } else if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } else if (toolId() === 'vacuum' && GAME.blow.size) { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); } }
|
||||
if (padPressed(gp, 10)) bot.lamp = !bot.lamp;
|
||||
if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; }
|
||||
if (padPressed(gp, 8)) openNotebook();
|
||||
|
||||
+11
-11
@@ -11,7 +11,7 @@ function renderHotbar() {
|
||||
}
|
||||
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).
|
||||
// it is the tool modifier (vacuum everything, fill a rectangle).
|
||||
function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; }
|
||||
function modShift() { return keys.has('Shift') && !steering(); }
|
||||
// The loose material exactly under the cursor, or the nearest one touching it.
|
||||
@@ -25,16 +25,16 @@ function grainAt(ax, ay) {
|
||||
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;
|
||||
// The bag's usual order first, then anything else it holds, so nothing in it is ever hidden.
|
||||
const IN_BAG_ORDER = new Uint8Array(NMAT); for (const m of BAG_ORDER) IN_BAG_ORDER[m] = 1;
|
||||
function bagMaterials() {
|
||||
const l = BAG_ORDER.filter(m => bag[m] > 0);
|
||||
for (let m = 1; m < NMAT; m++) if (bag[m] > 0 && !IN_BAG_ORDER[m]) l.push(m);
|
||||
return l;
|
||||
}
|
||||
function blowList() { return [...GAME.blow].filter(m => bag[m] > 0); }
|
||||
// Shift + right click: an even mix of everything loose in the bag (bars never mix)
|
||||
function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM && (PH[m] !== PH_LIQUID || player.flask)); }
|
||||
function blowingAll() { return toolId() === 'vacuum' && (GAME.rmb ? !GAME.rmbConsumed && GAME.blowAll : modShift()); }
|
||||
function piece() { return PIECES[GAME.pieceIdx]; }
|
||||
|
||||
+71
-30
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// Short cards at the bottom centre when you find or make something new. They fade
|
||||
// on their own (hover holds them); everything they say is also in the notebook.
|
||||
function showCard(c) {
|
||||
if (!SET.cardSeconds) return;
|
||||
@@ -31,8 +31,9 @@ function howText(e) {
|
||||
case 'blow': return 'Air blown through molten iron burned off its extra carbon.';
|
||||
case '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.`;
|
||||
case 'refine': return `Ground out of ${f}.`;
|
||||
case 'press': return `Pressed from ${f} by a Brick press.`;
|
||||
case 'cut': return `Cut from ${f} by a saw.`;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -76,62 +77,95 @@ function reactionsOf(m, forNotebook) {
|
||||
// What is under the cursor: t1 is the name, t2 the details, warn a short problem
|
||||
// (too hard, out of reach) for the label next to the cursor.
|
||||
function hoverInfo(ax, ay) {
|
||||
const pinged = pingVisible(ax, ay);
|
||||
if (SET.fog && fogSeen(ax, ay) < 64 && !pinged) return { t1: 'Unexplored', t2: [], t3: [] };
|
||||
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
|
||||
const i = ay * W + ax, m = mat[i];
|
||||
let warn = '';
|
||||
let t1 = MD[m].name, t2 = [];
|
||||
const p = part[i], id = mach[i];
|
||||
let t1 = MD[m].name, t2 = [], contents = '';
|
||||
const p = part[i];
|
||||
// Cone gaps have no machine cells; hover still belongs to the silo's whole outline.
|
||||
const silo = machines.find(mm => mm && mm.type === 'silo' && ax >= mm.x && ax < mm.x + mm.w && ay >= mm.y && ay < mm.y + mm.h);
|
||||
const id = silo ? silo.id : mach[i];
|
||||
const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)];
|
||||
const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null;
|
||||
if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
|
||||
if (bar && !silo) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
|
||||
else if (id && machines[id]) {
|
||||
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
|
||||
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
|
||||
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
|
||||
if (mm.type === 'fan') t2.push(mm.on ? 'on' : 'off · press F');
|
||||
if (mm.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`);
|
||||
if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
|
||||
if (mm.type === 'silo') {
|
||||
contents = mm.sn ? `${MD[mm.sm].name.toLowerCase()} ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')}` : 'empty';
|
||||
t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
|
||||
}
|
||||
} else if (b === BP_RACK) {
|
||||
const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
|
||||
let n = 0, capacity = 0;
|
||||
for (let q = top; q < NB && blockP[q] === BP_RACK; q += BW) {
|
||||
const s = rackState.get(q); capacity += RACK_CAP; n += s.n;
|
||||
for (const [m, count] of s.q) counts.set(m, (counts.get(m) || 0) + count);
|
||||
}
|
||||
t1 = 'Bar rack'; t2.push(`${n} / ${capacity} bars`);
|
||||
t2.push(...[...counts].slice(0, 4).map(([m, count]) => `${count} ${MD[m].name.toLowerCase()}`));
|
||||
if (counts.size > 4) t2.push('+ more');
|
||||
t2.push(rackState.get(top).open ? 'open: letting bars out' : 'closed: F lets bars out');
|
||||
if (n === capacity) t2.push('full');
|
||||
} else if (isProcCode(b)) {
|
||||
const s = procState.get((ay >> 2) * BW + (ax >> 2)), saw = b === BP_SAW, press = b === BP_PRESS;
|
||||
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
|
||||
t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
|
||||
} else if (p === P_FILTER) {
|
||||
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
|
||||
const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = beltDirection(blockP[fb], blockV[fb]);
|
||||
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
|
||||
} else if (p === P_PIPE) {
|
||||
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
|
||||
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
|
||||
t2.push(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 ? '←' : '→');
|
||||
t2.push(pipeContentsText(pn));
|
||||
} else if (IS_SCREW[p]) t1 = 'Screw conveyor ' + PIECE_BY_ID.screw.dirs[SCREW_PARTS.indexOf(p)] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (p) t1 = ({ 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
|
||||
else if (m === BELTL || m === BELTR) { const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : (ay >> 2) * BW + (ax >> 2); t1 = 'Belt ' + beltDirection(blockP[fb], blockV[fb]); }
|
||||
else if (m === PIPE && isPipeCode(b)) {
|
||||
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
|
||||
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
|
||||
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
|
||||
t2.push(pipeContentsText(pn));
|
||||
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
|
||||
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
|
||||
}
|
||||
else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`;
|
||||
else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`;
|
||||
const jb = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
|
||||
if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`);
|
||||
for (let end = 0; end < 2 && !id && !bar; end++) {
|
||||
const miss = nearMiss(jb % BW, (jb / BW) | 0, end);
|
||||
if (miss) { t2.push(`doesn't connect: place it one block ${miss < 0 ? 'higher' : 'lower'}`); warn = warn || "doesn't connect"; break; }
|
||||
}
|
||||
const n = nets[netId[i]];
|
||||
if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`);
|
||||
if (player.lens && !bar) t2.push(fmtT(temp[i]));
|
||||
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
|
||||
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
|
||||
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
|
||||
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
|
||||
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : bitNeeded(m));
|
||||
if (!ok && toolId() === 'drill') warn = bitNeeded(m);
|
||||
}
|
||||
if (!bar && PH[m] === PH_POWDER) t2.push(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 (!bar && PH[m] === PH_POWDER) t2.push('loose');
|
||||
if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
|
||||
if (flags[i] & F_BURN) t2.push('burning');
|
||||
const mo = moldAt(ax, ay);
|
||||
const mo = silo ? null : moldAt(ax, ay);
|
||||
if (mo) {
|
||||
t1 = MOLDS[mo.kind].name;
|
||||
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set · press F` : `${MD[mo.metal].name} casting set · press F`) : mo.state === 'flawed' || mo.state === 'short' ? mo.flaw : mo.state === 'filling' ? `filling ${Math.round(mo.fill * 100)}%` : 'empty'];
|
||||
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`];
|
||||
if (mo.blocked) t2.push('blocked: leave one open block under it');
|
||||
if (mo.flaw) t2.push(mo.flaw);
|
||||
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
|
||||
if (player.lens) t2.push(fmtT(temp[i]));
|
||||
}
|
||||
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
|
||||
const reachOk = toolId() === 'drill' || (toolId() === 'build' ? inBuildRange(ax, ay) : inReach(ax, ay));
|
||||
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
|
||||
// what it does, unless the cursor is on a machine or mold, which have their own details
|
||||
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
|
||||
const t3 = what ? reactionsOf(what) : [];
|
||||
return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
|
||||
if (pinged) t2.push('found by the ping');
|
||||
return { t1, t2, t3, warn, contents, ore: oreLike(m, bar), m: bar ? bar.m : m, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
|
||||
}
|
||||
function renderInfo() {
|
||||
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
@@ -141,15 +175,19 @@ function renderInfo() {
|
||||
}
|
||||
// Name of what is under the cursor, drawn right next to it
|
||||
function drawCursorLabel(px, py, r) {
|
||||
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return;
|
||||
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) { cursorLabelKey = null; return; }
|
||||
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
if (!info.t1 || info.air) return;
|
||||
if (!info.t1 || (info.air && !info.warn) || (SET.cursorLabel === 'ores' && !info.ore && !info.warn)) { cursorLabelKey = null; return; }
|
||||
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
|
||||
const alpha = cursorLabelAlpha(vein || info.m || info.t1, performance.now());
|
||||
if (!alpha && !info.warn) return;
|
||||
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
|
||||
const fs = Math.round(12 * dpr * SET.uiScale);
|
||||
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
|
||||
const main = info.t1 + (player.lens && R.lens ? '' : '');
|
||||
const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
|
||||
const main = alpha ? info.t1 + (info.t2.includes('found by the ping') ? ' · found by the ping' : '') + (info.contents && info.warn ? ` ${info.contents}` : '') : '';
|
||||
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
|
||||
ctx.save();
|
||||
ctx.globalAlpha = info.warn ? 1 : alpha;
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`;
|
||||
const w1 = ctx.measureText(main).width;
|
||||
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
|
||||
@@ -161,7 +199,10 @@ function drawCursorLabel(px, py, r) {
|
||||
ctx.fillStyle = 'rgba(20,17,15,0.82)';
|
||||
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2);
|
||||
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
|
||||
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.globalAlpha = alpha; ctx.fillText(main, x + padX, y + h / 2);
|
||||
ctx.globalAlpha = info.warn ? 1 : alpha;
|
||||
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pipeContentsText(pn) { return pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`; }
|
||||
|
||||
+30
-14
@@ -3,15 +3,17 @@
|
||||
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 ((e.key === 'Control' || e.key === 'Meta') && !GAME.ctrl) { GAME.ctrl = true; lastBagKey = ''; hudDirty = true; }
|
||||
if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; }
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (GAME.running && k === 'm' && e.shiftKey && !e.repeat) { SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); return; }
|
||||
if (anyModal()) {
|
||||
if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); }
|
||||
else if (k === 'm' && !e.repeat && topModal() === 'm-map') closeModal('m-map');
|
||||
else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook');
|
||||
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(); }
|
||||
@@ -22,31 +24,32 @@ window.addEventListener('keydown', e => {
|
||||
if (e.repeat) return;
|
||||
GAME.usingPad = false;
|
||||
switch (k) {
|
||||
case '`': e.preventDefault(); toggleDevConsole(); break;
|
||||
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
|
||||
case 'c': case 'x': hudDirty = true; break;
|
||||
case 'v': startPaste(); break;
|
||||
case 'g': pickUnderCursor(); break;
|
||||
case 'q': cycleItem(-1); break;
|
||||
case 'e': cycleItem(1); break;
|
||||
case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break;
|
||||
case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break;
|
||||
case 'r': rotate(); break;
|
||||
case 't': if (toolId() === 'vacuum') toggleCollectUnderCursor(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
|
||||
case 't': if (toolId() === 'build' && piece().code === BP_WALL) turnSlope(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
|
||||
case 'f': doInteract(); break;
|
||||
case 'g': GAME.replace = true; hudDirty = true; break;
|
||||
case 'h': toggleLens(); break;
|
||||
case 'i': GAME.infoOverlay = !GAME.infoOverlay; playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, and which way belts and tubes run. I hides it.' : 'Info overlay off.'); break;
|
||||
case 'i': GAME.infoOverlay = !GAME.infoOverlay; setFlowCounting(GAME.infoOverlay); playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, which way belts and screw conveyors run, and how many grains a second each line moves (orange: backing up). I hides it.' : 'Info overlay off.'); break;
|
||||
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
|
||||
case 'n': openNotebook(); break;
|
||||
case 'b': openNotebook('Blueprints'); break;
|
||||
case 'm': SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); break;
|
||||
case 'm': openMap(); break;
|
||||
case '[': setBrush(-1); break;
|
||||
case ']': setBrush(1); break;
|
||||
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
|
||||
case 'Escape': if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
|
||||
case 'Escape': if (!$('dev-console').hidden) closeDevConsole(); else if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
|
||||
case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break;
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if (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'); } });
|
||||
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if ((k === 'Control' || k === 'Meta') && GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } if (k === 'g') { GAME.replace = false; hudDirty = true; } if (k === 'c' || k === 'x') hudDirty = true; });
|
||||
window.addEventListener('blur', () => { keys.clear(); if (GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } GAME.lmb = GAME.rmb = false; GAME.replace = false; GAME.selDrag = null; endRmbRecord(); });
|
||||
canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; GAME.usingPad = false; });
|
||||
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
@@ -54,11 +57,11 @@ canvas.addEventListener('mousedown', e => {
|
||||
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 === 2) { GAME.rmb = true; secondaryPress(e.shiftKey && !steering()); }
|
||||
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; }
|
||||
if (e.button === 0) { if (GAME.lmb) primaryRelease(modShift()); GAME.lmb = false; }
|
||||
else if (e.button === 2) { GAME.rmb = false; endRmbRecord(); }
|
||||
});
|
||||
canvas.addEventListener('wheel', e => {
|
||||
@@ -68,9 +71,22 @@ canvas.addEventListener('wheel', e => {
|
||||
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 });
|
||||
// Ctrl + wheel would zoom the browser page (Ctrl shows the full bag list): scroll the list under the mouse instead
|
||||
window.addEventListener('wheel', e => {
|
||||
if (!e.ctrlKey || e.target === canvas) return;
|
||||
e.preventDefault();
|
||||
for (let n = e.target; n && n !== document.body; n = n.parentElement) {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) { n.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? n.clientHeight : 1); break; }
|
||||
}
|
||||
}, { passive: false });
|
||||
window.addEventListener('resize', () => { if (R.canvas) resizeRender(); });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && GAME.running) saveWorld('auto', true);
|
||||
if (!AU.ctx) return;
|
||||
if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); if (GAME.running) saveWorld('auto', true); }
|
||||
if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); }
|
||||
else AU.ctx.resume();
|
||||
});
|
||||
// Ctrl+W closes the tab and the page cannot stop it:
|
||||
// while a world is open, ask the browser to confirm leaving
|
||||
window.addEventListener('beforeunload', e => { if (GAME.running) { e.preventDefault(); e.returnValue = ''; } });
|
||||
|
||||
+57
-25
@@ -12,20 +12,24 @@ function scanVisible() {
|
||||
}
|
||||
let ambientT = 0;
|
||||
function frame(now) {
|
||||
if (GPU.stopped) return;
|
||||
requestAnimationFrame(frame);
|
||||
if (GPU.recovering) { lastT = now; return; }
|
||||
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
|
||||
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
|
||||
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
|
||||
if (GAME.loading) { acc = 0; return; }
|
||||
if (!GAME.running) { pollPad(); return; }
|
||||
const t0 = performance.now();
|
||||
let frameSim = 0, frameSteps = 0;
|
||||
const pad = pollPad();
|
||||
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
|
||||
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
|
||||
if (!GAME.paused) {
|
||||
acc += dtReal * SET.simSpeed;
|
||||
if (!GAME.paused && (!devPaused || devSteps > 0)) {
|
||||
acc += devPaused ? 0 : dtReal * SET.simSpeed * devSpeed;
|
||||
let steps = 0;
|
||||
const step = 1 / 60;
|
||||
while (acc >= step && steps < SET.maxSteps) {
|
||||
while ((devPaused ? devSteps > 0 : acc >= step) && steps < SET.maxSteps) {
|
||||
let ix = 0, iy = 0;
|
||||
if (keys.has('a') || keys.has('ArrowLeft')) ix -= 1; if (keys.has('d') || keys.has('ArrowRight')) ix += 1;
|
||||
if (keys.has('w') || keys.has('ArrowUp') || keys.has(' ')) iy -= 1; if (keys.has('s') || keys.has('ArrowDown')) iy += 1;
|
||||
@@ -36,8 +40,16 @@ function frame(now) {
|
||||
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
|
||||
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
|
||||
toolTick();
|
||||
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
|
||||
acc -= step; steps++;
|
||||
const s0 = performance.now();
|
||||
try { simTick(); }
|
||||
catch (e) {
|
||||
GAME.paused = true; acc = 0; console.error(e);
|
||||
toast('The world stopped updating. Reload this world to try again.', {big:true}); break;
|
||||
}
|
||||
const elapsed = performance.now() - s0;
|
||||
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
|
||||
if (devPaused) devSteps--; else acc -= step;
|
||||
steps++;
|
||||
GAME.playTime += step;
|
||||
}
|
||||
if (steps >= SET.maxSteps) acc = 0;
|
||||
@@ -45,47 +57,52 @@ function frame(now) {
|
||||
} 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; }
|
||||
if (SET.cameraLead && GAME.usingPad) { lookX = GAME.padAim.x * 0.5; lookY = GAME.padAim.y * 0.5; }
|
||||
else if (SET.cameraLead && GAME.mouse.in && toolId() !== 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld()) { const dpr = R.canvas.width / window.innerWidth; lookX = (GAME.mouse.x - window.innerWidth / 2) * dpr / R.zp; lookY = (GAME.mouse.y - window.innerHeight / 2) * dpr / R.zp; }
|
||||
// hold the camera's look-ahead still while dragging out a line, box or blueprint
|
||||
// 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);
|
||||
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
|
||||
R.frame++;
|
||||
drawWorld();
|
||||
drawCracks();
|
||||
const overlayStart = SET.showPerf ? performance.now() : 0;
|
||||
if (!weather.seen && (R.rain || 0) > 0.12) {
|
||||
weather.seen = true;
|
||||
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
|
||||
}
|
||||
if (SET.look === 'smooth' && SM.tried && !SM.ok) {
|
||||
SET.look = 'pixel'; saveSettings();
|
||||
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
|
||||
}
|
||||
drawMachines();
|
||||
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
|
||||
drawParticles();
|
||||
const hb = GAME.mouse.in || GAME.usingPad ? filterBlockAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)) : -1;
|
||||
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
|
||||
if (GAME.infoOverlay) drawFlowArrows();
|
||||
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
|
||||
drawJoinMarks();
|
||||
drawPing();
|
||||
drawHoveredVein();
|
||||
drawPingRecharge();
|
||||
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
|
||||
drawFloaters();
|
||||
drawRain();
|
||||
drawBot();
|
||||
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
|
||||
if (GAME.ctrl !== GAME.ctrlShown) { GAME.ctrlShown = GAME.ctrl; hudDirty = true; }
|
||||
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
|
||||
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
|
||||
renderBuildBar();
|
||||
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
|
||||
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;
|
||||
updateMap(now);
|
||||
const frameWork = performance.now() - t0;
|
||||
frameMs = frameMs * 0.9 + frameWork * 0.1;
|
||||
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
|
||||
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
|
||||
if (SET.showPerf && R.frame % 15 === 0) {
|
||||
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).toFixed(1)}) · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,20 +114,35 @@ function boot(hotData) {
|
||||
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);
|
||||
let autoName = '', worldNames = [];
|
||||
const newWorldSeed = () => parseInt(String($('nw-seed').value).replace(/\D/g, ''), 10) || Math.floor(Math.random() * 1e6);
|
||||
$('btn-new').onclick = async () => {
|
||||
auInit(); playSfx('ui'); $('newworld').hidden = !$('newworld').hidden;
|
||||
if ($('newworld').hidden) return;
|
||||
try { worldNames = (await listWorlds()).map(w => w.name); }
|
||||
catch (e) { worldNames = []; }
|
||||
$('nw-name').value = autoName = claimName(newWorldSeed(), worldNames);
|
||||
$('nw-name').focus(); $('nw-name').select();
|
||||
};
|
||||
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); };
|
||||
$('nw-seed').oninput = () => {
|
||||
if ($('nw-name').value === autoName) $('nw-name').value = autoName = claimName(newWorldSeed(), worldNames);
|
||||
};
|
||||
for (const id of ['nw-name', 'nw-seed']) $(id).onkeydown = e => { if (e.key === 'Enter') $('btn-generate').click(); };
|
||||
$('btn-generate').onclick = () => {
|
||||
const seed = newWorldSeed();
|
||||
const [w, h] = $('nw-size').value.split('x').map(Number);
|
||||
startNewWorld(seed, w, h, $('nw-name').value);
|
||||
};
|
||||
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); await loadSavedWorld(); };
|
||||
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
|
||||
$('btn-title-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); }
|
||||
});
|
||||
$('title').hidden = true;
|
||||
migrateWorlds().then(() => refreshContinue()).then(async () => {
|
||||
$('title').hidden = false; if (!TITLE.raf) drawTitle();
|
||||
if (hotData && hotData.resume) { await loadSavedWorld(); }
|
||||
}).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
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 */ }
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Shared world colours, minimap and full map.
|
||||
let MAP_S = 2;
|
||||
const MAP = { canvas: document.createElement('canvas'), image: null, w: 0, h: 0, init: 0, rr: 0, near: 0, miniT: 0, fullT: 0, x: 0, y: 0, zoom: 1, drag: null };
|
||||
function resetMap() {
|
||||
MAP_S = W > 4096 ? 4 : 2; MAP.w = W; MAP.h = H; MAP.fog = SET.fog;
|
||||
MAP.canvas.width = Math.ceil(W / MAP_S); MAP.canvas.height = Math.ceil(H / MAP_S);
|
||||
MAP.ctx = MAP.canvas.getContext('2d'); MAP.image = MAP.ctx.createImageData(MAP.canvas.width, MAP.canvas.height);
|
||||
MAP.init = MAP.rr = MAP.near = MAP.miniT = MAP.fullT = 0;
|
||||
if (![1, 2, 4, 8, 16].includes(SET.minimapZoom)) SET.minimapZoom = 4;
|
||||
}
|
||||
function mapArea(cx, cy) {
|
||||
const s = MAP_S, x0 = cx * 32, y0 = cy * 32, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32), data = MAP.image.data, mw = MAP.canvas.width;
|
||||
for (let y = y0; y < y1; y += s) for (let x = x0; x < x1; x += s) {
|
||||
const xx = Math.min(W - 1, x + s / 2), yy = Math.min(H - 1, y + s / 2), seen = SET.fog ? fogSeen(xx, yy) : 255;
|
||||
let m = mat[yy * W + xx];
|
||||
if (!m || PH[m] === PH_GAS) for (let c = 0; c < 4; c++) {
|
||||
const k = mat[Math.min(H - 1, y + (c >> 1) * (s - 1)) * W + Math.min(W - 1, x + (c & 1) * (s - 1))];
|
||||
if (k && PH[k] !== PH_GAS) { m = k; break; }
|
||||
}
|
||||
const sky = yy < surfY[xx], f = seen < 64 ? 0 : seen / 255, i = (y / s * mw + x / s) * 4;
|
||||
data[i] = (m ? COLR[m] : sky ? 78 : 20) * f;
|
||||
data[i + 1] = (m ? COLG[m] : sky ? 114 : 18) * f;
|
||||
data[i + 2] = (m ? COLB[m] : sky ? 142 : 17) * f; data[i + 3] = 255;
|
||||
}
|
||||
MAP.ctx.putImageData(MAP.image, 0, 0, x0 / s, y0 / s, (x1 - x0) / s, (y1 - y0) / s);
|
||||
}
|
||||
function updateMap(now) {
|
||||
if (!GAME.running || GAME.loading) return;
|
||||
$('minimap').hidden = !SET.minimap;
|
||||
if (!SET.minimap && $('m-map').hidden) return;
|
||||
if (MAP.w !== W || MAP.h !== H || MAP.fog !== SET.fog) resetMap();
|
||||
const nx = Math.ceil(W / 32), ny = Math.ceil(H / 32), total = nx * ny;
|
||||
if (MAP.init < total) {
|
||||
const end = performance.now() + 8;
|
||||
do { const n = MAP.init++; mapArea(n % nx, Math.floor(n / nx)); } while (MAP.init < total && performance.now() < end);
|
||||
if (MAP.init === total) MAP.fullT = MAP.miniT = 0;
|
||||
} else {
|
||||
mapArea(Math.max(0, Math.min(nx - 1, Math.floor(bot.x / 32))), Math.max(0, Math.min(ny - 1, Math.floor(bot.y / 32))));
|
||||
const rx = Math.ceil(R.bw / 32), ry = Math.ceil(R.bh / 32), span = rx * 2 + 1;
|
||||
for (let n = 0; n < 15; n++) {
|
||||
const k = MAP.near++ % (span * (ry * 2 + 1)), x = Math.floor(bot.x / 32) - rx + k % span, y = Math.floor(bot.y / 32) - ry + Math.floor(k / span);
|
||||
if (x >= 0 && y >= 0 && x < nx && y < ny) mapArea(x, y);
|
||||
}
|
||||
for (let n = 0; n < 8; n++) { const k = MAP.rr++ % total; mapArea(k % nx, Math.floor(k / nx)); }
|
||||
}
|
||||
if (SET.minimap && now >= MAP.miniT) { MAP.miniT = now + 100; drawMap($('mini-canvas'), bot.x, bot.y, 1 / SET.minimapZoom, false); }
|
||||
if (!$('m-map').hidden && now >= MAP.fullT) { MAP.fullT = now + 1000; drawFullMap(); }
|
||||
}
|
||||
function drawMap(c, x, y, z, full) {
|
||||
const ctx = c.getContext('2d'), w = c.width, h = c.height;
|
||||
ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#080706'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(MAP.canvas, w / 2 - x * z, h / 2 - y * z, W * z, H * z);
|
||||
const marker = (mx, my, colour, label) => {
|
||||
const px = w / 2 + (mx - x) * z, py = h / 2 + (my - y) * z;
|
||||
if (px < 0 || py < 0 || px > w || py > h) return;
|
||||
ctx.fillStyle = '#080706'; ctx.fillRect(px - 4, py - 4, 8, 8); ctx.fillStyle = colour; ctx.fillRect(px - 2, py - 2, 4, 4);
|
||||
if (full) { ctx.font = '13px sans-serif'; ctx.lineWidth = 3; ctx.strokeStyle = '#080706'; const ly = label === 'Trade dock' ? py + 17 : py - 7; ctx.strokeText(label, px + 7, ly); ctx.fillText(label, px + 7, ly); }
|
||||
};
|
||||
for (const m of machines) if (m && m.type === 'dock' && (!SET.fog || fogSeen(m.x, m.y) >= 64)) marker(m.x + m.w / 2, m.y + m.h / 2, '#e59a52', 'Trade dock');
|
||||
marker(bot.x, bot.y, '#c9fff2', 'You are here');
|
||||
}
|
||||
function mapFit() { const c = $('map-canvas'); return Math.min(c.clientWidth / W, c.clientHeight / H); }
|
||||
function drawFullMap() {
|
||||
if (GAME.loading || !GAME.running || $('m-map').hidden || !MAP.image) return;
|
||||
const c = $('map-canvas'); c.width = Math.max(1, c.clientWidth); c.height = Math.max(1, c.clientHeight);
|
||||
MAP.zoom = Math.max(mapFit(), Math.min(4, MAP.zoom));
|
||||
const hx = c.width / (2 * MAP.zoom), hy = c.height / (2 * MAP.zoom);
|
||||
MAP.x = hx >= W / 2 ? W / 2 : Math.max(hx, Math.min(W - hx, MAP.x));
|
||||
MAP.y = hy >= H / 2 ? H / 2 : Math.max(hy, Math.min(H - hy, MAP.y));
|
||||
drawMap(c, MAP.x, MAP.y, MAP.zoom, true);
|
||||
}
|
||||
function openMap() {
|
||||
if (!GAME.running || GAME.loading) return;
|
||||
openModal('m-map'); MAP.x = bot.x; MAP.y = bot.y; MAP.zoom = mapFit(); drawFullMap();
|
||||
}
|
||||
function zoomMap(factor, px, py) {
|
||||
const c = $('map-canvas'), old = MAP.zoom, z = Math.max(mapFit(), Math.min(4, old * factor));
|
||||
MAP.x += ((px === undefined ? c.width / 2 : px) - c.width / 2) * (1 / old - 1 / z);
|
||||
MAP.y += ((py === undefined ? c.height / 2 : py) - c.height / 2) * (1 / old - 1 / z);
|
||||
MAP.zoom = z; drawFullMap();
|
||||
}
|
||||
function zoomMinimap(d) { SET.minimapZoom = Math.max(1, Math.min(16, SET.minimapZoom * (d > 0 ? 0.5 : 2))); saveSettings(); MAP.miniT = 0; }
|
||||
$('mini-plus').onclick = () => zoomMinimap(1);
|
||||
$('mini-minus').onclick = () => zoomMinimap(-1);
|
||||
$('mini-map').onclick = openMap;
|
||||
$('map-plus').onclick = () => zoomMap(2);
|
||||
$('map-minus').onclick = () => zoomMap(0.5);
|
||||
$('map-centre').onclick = () => { MAP.x = bot.x; MAP.y = bot.y; drawFullMap(); };
|
||||
$('map-canvas').addEventListener('wheel', e => { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); zoomMap(e.deltaY < 0 ? 1.25 : 0.8, e.clientX - r.left, e.clientY - r.top); }, { passive: false });
|
||||
$('map-canvas').onpointerdown = e => { if (e.button !== 0) return; MAP.drag = [e.clientX, e.clientY]; e.currentTarget.setPointerCapture(e.pointerId); };
|
||||
$('map-canvas').onpointermove = e => { if (!MAP.drag) return; MAP.x -= (e.clientX - MAP.drag[0]) / MAP.zoom; MAP.y -= (e.clientY - MAP.drag[1]) / MAP.zoom; MAP.drag = [e.clientX, e.clientY]; drawFullMap(); };
|
||||
$('map-canvas').onpointerup = $('map-canvas').onpointercancel = () => { MAP.drag = null; };
|
||||
window.addEventListener('resize', drawFullMap);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Notebook blueprints: starter designs, saved library and code sharing.
|
||||
function renderBlueprintTab(body) {
|
||||
const lib = libLoad();
|
||||
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
|
||||
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
|
||||
STARTER_DESIGNS.forEach((d, k) => {
|
||||
const bp = d.bp();
|
||||
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
|
||||
});
|
||||
h += '</div><p class="label grouphead">Your blueprints</p>';
|
||||
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
|
||||
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
|
||||
h += `<p class="label grouphead">Library</p>`;
|
||||
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
|
||||
else {
|
||||
h += '<div class="cards">';
|
||||
lib.forEach((it, k) => {
|
||||
const ok = bpValid(it.bp);
|
||||
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
body.innerHTML = h;
|
||||
const sv = $('bp-save'); if (sv) sv.onclick = () => {
|
||||
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
|
||||
]);
|
||||
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
|
||||
};
|
||||
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
|
||||
$('bp-import').onclick = () => {
|
||||
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
|
||||
]);
|
||||
};
|
||||
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
|
||||
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
|
||||
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
|
||||
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
|
||||
}
|
||||
function copyCode(code) {
|
||||
const done = () => toast('Blueprint code copied.');
|
||||
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
|
||||
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Notebook market: prices, orders, claim payments and wallet.
|
||||
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
|
||||
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
|
||||
function orderLeft(o) { return Math.max(0, o.due - tick); }
|
||||
const MARKET_GROUPS = [
|
||||
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
|
||||
['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]],
|
||||
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
|
||||
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
|
||||
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
|
||||
];
|
||||
function renderMarketTab(body) {
|
||||
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
|
||||
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
|
||||
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
|
||||
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
|
||||
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
|
||||
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.</p>`;
|
||||
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
|
||||
if (orders.length) {
|
||||
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
|
||||
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
|
||||
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
|
||||
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
|
||||
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
|
||||
let rows = '', unknown = 0;
|
||||
for (const [g, list] of MARKET_GROUPS) {
|
||||
const known = list.filter(m => seen[m]);
|
||||
unknown += list.length - known.length;
|
||||
if (!known.length) continue;
|
||||
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
|
||||
for (const m of known) {
|
||||
const f = priceFactor(m), now = priceNow(m);
|
||||
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
|
||||
}
|
||||
}
|
||||
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
|
||||
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
|
||||
h += `<p class="label grouphead">Sales, minute by minute</p>`;
|
||||
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
|
||||
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
|
||||
body.innerHTML = h;
|
||||
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
|
||||
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
|
||||
const paid = payClaim(v);
|
||||
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
|
||||
renderNotebook(); hudDirty = true;
|
||||
});
|
||||
}
|
||||
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
|
||||
function itemsText(items) {
|
||||
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
|
||||
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
|
||||
if (e.length > 3) parts.push(`${e.length - 3} more`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
function renderWallet() {
|
||||
$('w-money').textContent = fmtMoney(money);
|
||||
const c = $('w-claim');
|
||||
c.className = 'line' + (debt <= 0 ? ' paid' : '');
|
||||
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
|
||||
const h = orders.length ? orders.map(o => {
|
||||
const left = orderLeft(o);
|
||||
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
|
||||
}).join('') : '<span class="none">No orders yet</span>';
|
||||
const box = $('w-orders');
|
||||
if (box.innerHTML !== h) box.innerHTML = h;
|
||||
}
|
||||
+23
-133
@@ -1,10 +1,10 @@
|
||||
// The notebook (N): milestones, market, wallet, blueprints, controls.
|
||||
// Notebook: tab navigation, ledger, materials, pieces and 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']) {
|
||||
for (const name of ['Ledger', 'Market', 'Claims', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
|
||||
const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
|
||||
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
|
||||
tabs.appendChild(b);
|
||||
@@ -25,9 +25,11 @@ function renderNotebook() {
|
||||
body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
|
||||
} else if (nbTab === 'Market') {
|
||||
renderMarketTab(body);
|
||||
} else if (nbTab === 'Claims') {
|
||||
renderClaimsTab(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,
|
||||
const list = [DIRT, LDIRT, STONE, RSTONE, BARSB, PLANK, GREENB, BARBR, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS,
|
||||
BRICK, RBRICK, GLASS, CULLET, COPPER, SCRCU, BARCU, IRON, SCRFE, BARFE, STEEL, SCRST, BARST, ASH, SLAG, MSLAG, WATER, STEAM, MAGMA, MCU, MFE, MST, MGL, FIRE, SMOKE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS];
|
||||
for (const m of list) {
|
||||
if (!seen[m]) { unknown++; continue; }
|
||||
@@ -36,13 +38,13 @@ function renderNotebook() {
|
||||
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>`;
|
||||
body.innerHTML = `<p class="note">What you have found so far. Hardness is what your drill bit has to match. Solid rock breaks into loose rubble that you pick up with the Pneumatic nozzle and build with.${unknown ? ` ${unknown} more to discover.` : ''}</p><div class="tablewrap"><table class="mt"><thead><tr><th>Material</th><th>State</th><th>Hard</th><th>What it does</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
} else if (nbTab === 'Pieces') {
|
||||
let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Your bot flies straight through anything you build.</p><div class="cards">';
|
||||
let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Hover a tile for its cost, full description and placement help. Your bot flies straight through anything you build.</p><div class="cards">';
|
||||
for (const P of PIECES) {
|
||||
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 cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : isWall(P.code) ? 'Square: 16 grains or 2 bars; slope: 8 grains or 1 bar' : costText(P.cost, 1);
|
||||
const afford = P.kind === 'mold' || isWall(P.code) ? true : canAfford(P.cost, 1);
|
||||
const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1';
|
||||
h += `<div class="card"><div class="h"><span>${esc(P.name)}</span><span class="chip ${afford ? 'ok' : ''}">${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}</span></div><div class="p">${esc(P.desc)}</div><div class="p">Cost: ${esc(cost)}</div></div>`;
|
||||
}
|
||||
@@ -53,136 +55,23 @@ function renderNotebook() {
|
||||
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'],
|
||||
['Dev console (marks the world)', 'Backtick (`)'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Resonance ping: light up nearby veins (Drill)', 'Right mouse'],
|
||||
['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall: cycle shape · flip diagonal drag', 'T'], ['Wall or mold material menu (Build)', 'F'],
|
||||
['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
|
||||
['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and screw conveyor directions, grains per second', 'I'], ['Bot lamp', 'L'],
|
||||
['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'Shift + M'], ['Full map', 'M'], ['Minimap zoom', '+ / − on minimap'],
|
||||
];
|
||||
const pad = [
|
||||
['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'],
|
||||
['Select one to blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tune drill', 'X'], ['Thermal lens', 'Y'],
|
||||
['Cancel · clear selection', '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.'],
|
||||
['2 Pneumatic nozzle', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."],
|
||||
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, screw conveyors, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle (belts, screw conveyors, filters, shafts and pipes always draw a line). A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Uphill belts use cleats to carry pockets up; downhill they carry like a flat belt. Extra grains roll back. Diagonal filters always screen downhill, whichever way you drag. R leaves a picked sloped filter downhill. Right click takes a piece back. Your bot flies through anything you build.'],
|
||||
['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'],
|
||||
['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'],
|
||||
];
|
||||
@@ -191,11 +80,12 @@ function controlsHtml() {
|
||||
<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>
|
||||
2. With the Pneumatic nozzle, left drag to pick up the selection, or what you click if none. Shift takes everything.<br>
|
||||
3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br>
|
||||
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>
|
||||
4. Ctrl+click coal and copper ore in the bag to blow a mix. Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br>
|
||||
5. When the ingot sets, bars drop out of the bottom of the mold onto whatever is under it. Leave one open block under it: a belt below that carries them off, or pick them up with the nozzle. Leftover metal stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.<br>
|
||||
Screw conveyors carry grains and whole bars: feed bars in from a belt, turn corners and unload at the mouth. Drag at about 45° for a diagonal run; R cycles eight directions.<br>
|
||||
6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw conveyors and fans; pay down the claim there too.<br>
|
||||
7. From there: deeper ores (tin, zinc, silver, gold), alloys, fans for hotter fires, iron and steel.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||
let PERF_MARKS = false, PERF_FRAME = null;
|
||||
const perfMarks = {}, perfTimes = {}, perfLast = {};
|
||||
for (const name of ['drawWorld', 'simMove', 'heatPass']) {
|
||||
const original = window[name];
|
||||
window[name] = function (...args) {
|
||||
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
|
||||
const start = performance.now();
|
||||
const result = original.apply(this, args);
|
||||
const ms = performance.now() - start;
|
||||
perfLast[name] = ms;
|
||||
perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Drill pulse wavefronts and aiming fan.
|
||||
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
function pulseRGB(tune) {
|
||||
if (!tune) return '150,215,255';
|
||||
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 col = pulseRGB(GAME.drillTune);
|
||||
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
|
||||
ctx.save();
|
||||
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
|
||||
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;
|
||||
// A solid far edge marks where the pulse stops, even with a distant cursor.
|
||||
ctx.beginPath(); far.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y));
|
||||
ctx.strokeStyle = `rgba(${col},0.85)`; ctx.lineWidth = Math.max(1, zp * 0.22); ctx.stroke();
|
||||
// the emitter glows across the bot's face while it fires
|
||||
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
|
||||
if (GAME.lmb && hot > 0) {
|
||||
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 = 'rgba(239,230,216,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
|
||||
const k = zp * 0.9;
|
||||
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
|
||||
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Selection previews: labelled tags, copy and cut boxes, and paste ghosts.
|
||||
// a small labelled tag drawn on the world (screen pixels)
|
||||
function drawTag(text, col, x, y, sub) {
|
||||
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
|
||||
renderMonoFont();
|
||||
ctx.save();
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
|
||||
const tw = ctx.measureText(text).width;
|
||||
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
|
||||
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
|
||||
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
|
||||
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
|
||||
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
|
||||
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
|
||||
ctx.restore();
|
||||
}
|
||||
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
|
||||
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
|
||||
function drawSelection(mode, ax, ay) {
|
||||
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
|
||||
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
|
||||
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
|
||||
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
|
||||
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
|
||||
ctx.save();
|
||||
// what is inside: built blocks, and machines and molds that fit wholly inside
|
||||
let n = 0;
|
||||
if (sd) {
|
||||
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
|
||||
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
for (const mm of machines) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
|
||||
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
for (const mo of molds) {
|
||||
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
}
|
||||
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
|
||||
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
|
||||
if (mode === 'cut') {
|
||||
// red hatching: this will be taken down
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
|
||||
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
const step = Math.max(8, zp * 2.5);
|
||||
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
|
||||
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
ctx.restore();
|
||||
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
|
||||
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
|
||||
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
|
||||
}
|
||||
// Pasting: the clipboard follows the cursor, snapped to the grid
|
||||
function drawPastePreview() {
|
||||
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
|
||||
drawGrid(aimBlock().bx, aimBlock().by, 7);
|
||||
ctx.save();
|
||||
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
|
||||
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
|
||||
const planned = new Map(bp.blocks.map(([dx, dy, code, v]) => [(oy + dy) * BW + ox + dx, [code, v]]));
|
||||
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; previewBlock(code, bx2, by2, bw2, bh2, false, code === BP_SCREW || isBelt(code) ? v : -1, ox + dx, oy + dy, planned); }
|
||||
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
ctx.restore();
|
||||
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');
|
||||
}
|
||||
+120
-213
@@ -1,4 +1,4 @@
|
||||
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
|
||||
// Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
|
||||
// ---------- Overlay previews ----------
|
||||
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) {
|
||||
@@ -14,112 +14,51 @@ function drawGrid(cbx, cby, radius) {
|
||||
ctx.restore();
|
||||
}
|
||||
function previewColor(code, v) {
|
||||
if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
|
||||
if (code === BP_FILTER) return '96,100,110';
|
||||
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
|
||||
if (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
|
||||
if (isFilter(code)) return '96,100,110';
|
||||
if (isBelt(code)) return '110,114,124';
|
||||
return { [BP_BELT]: '110,114,124', [BP_SCREW]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
|
||||
}
|
||||
// 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);
|
||||
// Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
|
||||
function drawAirStreaks(ax, ay, p, r, blowing) {
|
||||
if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return;
|
||||
const ctx = R.ctx, zp = R.zp, now = performance.now();
|
||||
const cx = p.x + zp / 2, cy = p.y + zp / 2, ring = (r + 0.5) * zp;
|
||||
ctx.save(); ctx.lineWidth = Math.max(1, zp * 0.2); ctx.lineCap = 'round';
|
||||
if (blowing) {
|
||||
const bx = bot.x + bot.w / 2, by = bot.y + bot.h / 2;
|
||||
const dx = ax + 0.5 - bx, dy = ay + 0.5 - by, d = Math.hypot(dx, dy) || 1;
|
||||
const o = worldToScreenPx(bx + dx / d * 3, by + dy / d * 3);
|
||||
const px = cx - o.x, py = cy - o.y, len = Math.hypot(px, py) || 1;
|
||||
const ux = px / len, uy = py / len;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const phase = (now * 0.0016 + i * 0.618) % 1;
|
||||
const side = ((i * 0.37) % 1 - 0.5) * 2 * ring * phase;
|
||||
const x = o.x + px * phase - uy * side, y = o.y + py * phase + ux * side;
|
||||
ctx.strokeStyle = `rgba(255,210,122,${Math.sin(phase * Math.PI) * 0.55})`;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ux * 1.6 * zp, y + uy * 1.6 * zp); ctx.stroke();
|
||||
}
|
||||
}
|
||||
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();
|
||||
} else {
|
||||
const travel = ring * 0.65 + 3 * zp;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const phase = (now * 0.0012 + i * 0.618) % 1;
|
||||
const dist = ring + 3 * zp - travel * phase;
|
||||
const angle = i * Math.PI * 2 / 12 + now * 0.0004 + phase * 1.2;
|
||||
const co = Math.cos(angle), si = Math.sin(angle);
|
||||
const x = cx + co * dist, y = cy + si * dist;
|
||||
const vx = -travel * co - dist * (1.2 + 1 / 3) * si;
|
||||
const vy = -travel * si + dist * (1.2 + 1 / 3) * co;
|
||||
const scale = 1.2 * zp / Math.hypot(vx, vy);
|
||||
ctx.strokeStyle = `rgba(92,186,166,${Math.sin(phase * Math.PI) * 0.5})`;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + vx * scale, y + vy * scale); 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 drawReachCircle(radius, alpha) {
|
||||
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = `rgba(239,106,85,${alpha})`; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, radius * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
function drawPreviews() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
@@ -128,15 +67,8 @@ function drawPreviews() {
|
||||
ctx.save();
|
||||
const reachOk = inReach(ax, ay);
|
||||
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
|
||||
if (!reachOk && t !== 'drill' && !sel) {
|
||||
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([]);
|
||||
if (!reachOk && t !== 'drill' && t !== 'build' && !sel) {
|
||||
drawReachCircle(REACH, 0.35);
|
||||
}
|
||||
drawDrillPulses();
|
||||
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
|
||||
@@ -146,67 +78,60 @@ function drawPreviews() {
|
||||
const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool];
|
||||
const reachOk = inReach(ax, ay);
|
||||
const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed;
|
||||
ctx.strokeStyle = !reachOk ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
|
||||
const nz = blowing && !reachOk ? nozzle(ax + 0.5, ay + 0.5) : null;
|
||||
const p = nz ? worldToScreenPx(nz.ox + nz.dx * nz.dist - 0.5, nz.oy + nz.dy * nz.dist - 0.5) : worldToScreenPx(ax, ay);
|
||||
ctx.strokeStyle = !reachOk && !nz ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
|
||||
ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]);
|
||||
ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
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] : [];
|
||||
const swatches = GAME.lmb ? GAME.vacFilter === -2 ? [...GAME.blow] : GAME.vacFilter > 0 ? [GAME.vacFilter] : [] : blowingAll() ? blowAllList() : blowList();
|
||||
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
|
||||
if (blowing || (GAME.lmb && GAME.vacFilter)) {
|
||||
if (blowing || GAME.lmb) {
|
||||
// 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();
|
||||
}
|
||||
if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing);
|
||||
}
|
||||
} else if (t === 'build') {
|
||||
const P = piece(), b = aimBlock();
|
||||
const P = piece(), b = aimBlock(), plan = P.kind === 'block' ? dragPlan(P, GAME.drag, b, modShift()) : null;
|
||||
const code = plan ? plan.code : pieceCode(P, b);
|
||||
if (GAME.previewCode !== code) { GAME.previewCode = code; hudDirty = true; }
|
||||
drawGrid(b.bx, b.by, 9);
|
||||
let cells = [], col = '120,200,190';
|
||||
if (P.kind === 'block') {
|
||||
const v = pieceV(P);
|
||||
col = previewColor(P.code, v);
|
||||
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by)) : [[b.bx, b.by]];
|
||||
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
|
||||
const { v, dd, dir: dIdx, planned } = plan;
|
||||
col = previewColor(code, v);
|
||||
cells = plan.cells;
|
||||
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
|
||||
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
|
||||
const afford = canAfford(pieceCostNow(P), cells.length);
|
||||
for (const [bx, by] of cells) {
|
||||
const err = canPlaceBlock(bx, by), rb = by * BW + bx;
|
||||
const swap = GAME.ctrl && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
|
||||
const err = canPlaceBlock(bx, by, code), rb = by * BW + bx;
|
||||
const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P));
|
||||
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
|
||||
const ok = (!err || swap) && !ghost && afford;
|
||||
const [x, y, w, h] = blockRectPx(bx, by, 1, 1);
|
||||
ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
previewBlock(code, x, y, w, h, false, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned);
|
||||
ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
if (ghost) ctx.setLineDash([zp, zp * 0.7]);
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]);
|
||||
if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
|
||||
previewBlock(code, x, y, w, h, true, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]);
|
||||
if (P.dirs && (P.id === 'belt' || P.id === 'screw' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
|
||||
// a chevron on every block, pointing the way it will carry (or pump, or pour)
|
||||
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
|
||||
const [dx, dy] = isSlopeBelt(code) ? [dIdx ? 1 : -1, beltGrade(code) * (dIdx ? 1 : -1)] : P.id === 'screw' ? SCREW_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
|
||||
const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx;
|
||||
ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); };
|
||||
chev(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(1.2, zp * 0.45); chev();
|
||||
}
|
||||
}
|
||||
// dragging a line of belts, filters or tubes: one long arrow over it, the way it will run
|
||||
if (GAME.drag && dd >= 0 && cells.length > 1 && !modShift()) {
|
||||
const [x0, y0, w0, h0] = blockRectPx(cells[0][0], cells[0][1], 1, 1), [x1, y1, w1, h1] = blockRectPx(cells[cells.length - 1][0], cells[cells.length - 1][1], 1, 1);
|
||||
let sx = x0 + w0 / 2, sy = y0 + h0 / 2, ex = x1 + w1 / 2, ey = y1 + h1 / 2;
|
||||
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dd] : [[-1, 0], [1, 0]][dd];
|
||||
if ((ex - sx) * dx + (ey - sy) * dy < 0) { const tx = sx, ty = sy; sx = ex; sy = ey; ex = tx; ey = ty; }
|
||||
const off = BS * zp * 1.0, hx = ex + dx * BS * zp * 0.55, hy = ey + dy * BS * zp * 0.55, ah = Math.max(8, BS * zp * 0.5);
|
||||
const ox = dy ? off : 0, oy = dx ? off : 0;
|
||||
const arrow = () => { ctx.beginPath(); ctx.moveTo(sx - dx * BS * zp * 0.3 + ox, sy - dy * BS * zp * 0.3 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.moveTo(hx - dx * ah - dy * ah * 0.6 + ox, hy - dy * ah - dx * ah * 0.6 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.lineTo(hx - dx * ah + dy * ah * 0.6 + ox, hy - dy * ah + dx * ah * 0.6 + oy); ctx.stroke(); };
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(20,16,12,0.6)'; ctx.lineWidth = Math.max(4, zp * 1.1); arrow();
|
||||
ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(2, zp * 0.55); arrow();
|
||||
}
|
||||
// ends of the line that would join one block up or down get the "doesn't connect" mark
|
||||
for (const [bx, by] of cells.length > 1 ? [cells[0], cells[cells.length - 1]] : cells)
|
||||
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, planned)) drawJoinMark(bx, by, end, code, v);
|
||||
if (GAME.drag && cells.length > 1) {
|
||||
const label = `${cells.length} × ${costText(pieceCostNow(P), 1)}`;
|
||||
ctx.font = `600 12px ui-monospace, monospace`; ctx.fillStyle = 'rgba(14,12,11,0.85)';
|
||||
@@ -214,11 +139,11 @@ function drawPreviews() {
|
||||
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
|
||||
}
|
||||
} else {
|
||||
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
|
||||
const { bw, bh } = pieceSize(P);
|
||||
const { bx, by } = stampOrigin(bw, bh);
|
||||
const 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 swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
|
||||
const ok = (!err || swap) && inBuildRange(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
|
||||
const [x, y, w, h] = blockRectPx(bx, by, bw, bh);
|
||||
if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); }
|
||||
if (P.kind === 'mold') {
|
||||
@@ -250,85 +175,67 @@ function drawPreviews() {
|
||||
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
if (t === 'build' && GAME.ctrl && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
|
||||
if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
|
||||
drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
|
||||
}
|
||||
// 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();
|
||||
|
||||
function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) {
|
||||
const ctx = R.ctx;
|
||||
if (code === BP_SCREW && (direction >= 4 || (bx >= 0 && straightScrewJoins(bx, by, direction, planned)))) {
|
||||
const joins = bx >= 0 ? (direction >= 4 ? diagonalScrewJoins(bx, by, direction, planned) : straightScrewJoins(bx, by, direction, planned)) : 0;
|
||||
const scale = w / BS, step = SET.look === "smooth" ? 0.25 : 1;
|
||||
const b = bx >= 0 ? by * BW + bx : 0, ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
|
||||
ctx.beginPath();
|
||||
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
|
||||
if (direction < 4 && ((direction & 1) ? xx < 0 || xx >= BS : yy < 0 || yy >= BS)) continue;
|
||||
const sd = diagonalScrewShape(ox + xx + step / 2, oy + yy + step / 2, b, direction, joins);
|
||||
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||||
}
|
||||
// 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++; }
|
||||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||||
else ctx.fill();
|
||||
return;
|
||||
}
|
||||
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++; }
|
||||
if (isSlopeBelt(code)) {
|
||||
const b = by * BW + bx, joins = bx >= 0 ? slopeBeltConnections(bx, by, code, planned) : 0;
|
||||
const scale = w / BS, step = SET.look === 'smooth' ? 0.25 : 1;
|
||||
const ox = bx * BS, oy = by * BS;
|
||||
ctx.beginPath();
|
||||
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
|
||||
const wx = ox + xx, wy = oy + yy, ix = Math.floor(wx), iy = Math.floor(wy);
|
||||
if (bx >= 0 && (ix < ox || ix >= ox + BS || iy < oy || iy >= oy + BS)) {
|
||||
if (ix < 0 || ix >= W || iy < 0 || iy >= H) continue;
|
||||
const i = iy * W + ix, n = (iy >> 2) * BW + (ix >> 2), next = planned && planned.get(n);
|
||||
if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || (blockP[n] && !isSlopeBelt(blockP[n])) || (next && !isSlopeBelt(next[0]))) continue;
|
||||
}
|
||||
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 sd = slopeBeltShape(wx + step / 2, wy + step / 2, b, joins, code);
|
||||
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||||
}
|
||||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||||
else ctx.fill();
|
||||
// Use the same filled wall profile as placed slopes; ribs stay on the physics feet.
|
||||
if (!isFilter(code) && (direction < 0 || slopeUphill(code, direction))) { // downhill: a plain belt
|
||||
ctx.beginPath();
|
||||
for (let yy = -9; yy < 8; yy += step) for (let xx = -5; xx < 9; xx += step)
|
||||
if (slopeWallShape(ox + xx + step / 2, oy + yy + step / 2, b, code) <= 0)
|
||||
ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||||
const alpha = ctx.globalAlpha; ctx.globalAlpha *= 0.45; ctx.fill(); ctx.globalAlpha = alpha;
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < BS; k++) if (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0) {
|
||||
const fy = beltFaceY(code, 0, 0, k);
|
||||
for (let h = 0; h <= CLEAT_REACH; h++)
|
||||
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
|
||||
}
|
||||
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();
|
||||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||||
else ctx.fill();
|
||||
}
|
||||
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);
|
||||
return;
|
||||
}
|
||||
// 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');
|
||||
code = slopeShape(code);
|
||||
if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; }
|
||||
const right = code === BP_SLOPE_BR || code === BP_SLOPE_TR, above = code >= BP_SLOPE_TL;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + (right ? w : 0), y + (above ? 0 : h));
|
||||
ctx.lineTo(x + (right ? w : 0), y + (above ? h : 0));
|
||||
ctx.lineTo(x + (right ? 0 : w), y + (above ? 0 : h)); ctx.closePath();
|
||||
if (stroke) ctx.stroke(); else ctx.fill();
|
||||
}
|
||||
|
||||
+130
-12
@@ -15,29 +15,116 @@ const DB = {
|
||||
},
|
||||
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); });
|
||||
return new Promise((res, rej) => {
|
||||
const tx = db.transaction('saves', mode); let rq;
|
||||
tx.oncomplete = () => res(rq && rq.result);
|
||||
tx.onabort = tx.onerror = () => rej(tx.error || new Error('Storage transaction failed.'));
|
||||
try { rq = fn(tx.objectStore('saves')); } catch (e) { tx.abort(); rej(e); }
|
||||
});
|
||||
},
|
||||
put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
|
||||
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) {
|
||||
let worldIdTime = 0, worldIdCounter = 0, worldWrites = Promise.resolve();
|
||||
function newWorldId() {
|
||||
const now = Date.now(); worldIdCounter = now === worldIdTime ? worldIdCounter + 1 : 0; worldIdTime = now;
|
||||
return 'w' + now + (worldIdCounter ? '-' + worldIdCounter : '');
|
||||
}
|
||||
function worldWrite(fn) { const p = worldWrites.then(fn); worldWrites = p.catch(() => {}); return p; }
|
||||
async function listWorlds() { return (await DB.get('worlds') || []).sort((a, b) => b.savedAt - a.savedAt); }
|
||||
function putWorldEntry(entry) {
|
||||
return worldWrite(async () => {
|
||||
const list = await listWorlds(), i = list.findIndex(w => w.id === entry.id);
|
||||
if (i < 0) list.push(entry); else list[i] = { ...list[i], ...entry };
|
||||
await DB.put('worlds', list);
|
||||
});
|
||||
}
|
||||
async function worldNewest(id, onProgress) {
|
||||
if (onProgress) onProgress(0.05, 'Reading the save');
|
||||
const a = await DB.get(`${id}:main`), b = await DB.get(`${id}:auto`);
|
||||
const sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
|
||||
if (!sv) return null;
|
||||
const entry = (await listWorlds()).find(w => w.id === id);
|
||||
return { ...sv, buffers: await unpackBuffers(sv.buffers, onProgress), worldId: id, worldName: entry ? entry.name : 'World' };
|
||||
}
|
||||
function deleteWorld(id) {
|
||||
return worldWrite(async () => {
|
||||
const worlds = await listWorlds(), entry = worlds.find(w => w.id === id), list = worlds.filter(w => w.id !== id);
|
||||
const stored = entry && entry.career ? await DB.get('career:' + entry.career) : null;
|
||||
const c = stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : null;
|
||||
const claims = entry && entry.career ? list.filter(w => w.career === entry.career).map(w => w.id) : [];
|
||||
if (c) c.claims = claims;
|
||||
await DB.op('readwrite', st => {
|
||||
st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds');
|
||||
if (entry && entry.career) {
|
||||
if (!claims.length) st.delete('career:' + entry.career);
|
||||
else if (c) st.put(c, 'career:' + entry.career);
|
||||
}
|
||||
});
|
||||
if (career && c && career.id === c.id) career.claims = c.claims;
|
||||
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
|
||||
});
|
||||
}
|
||||
function cleanWorldName(name, fallback) { return String(name).trim().slice(0, 40) || fallback; }
|
||||
function renameWorld(id, name) {
|
||||
return worldWrite(async () => {
|
||||
const list = await listWorlds(), entry = list.find(w => w.id === id);
|
||||
if (!entry) return;
|
||||
entry.name = cleanWorldName(name, entry.name);
|
||||
await DB.put('worlds', list);
|
||||
if (GAME.worldId === id) GAME.worldName = entry.name;
|
||||
});
|
||||
}
|
||||
async function migrateWorlds() {
|
||||
try {
|
||||
const s = serializeWorld();
|
||||
await DB.put(slot, { meta: s.meta, buffers: s.buffers, savedAt: Date.now(), playTime: GAME.playTime });
|
||||
if (await DB.get('worlds') !== undefined) return;
|
||||
const a = await DB.get('main'), b = await DB.get('auto'), sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
|
||||
if (!sv) return;
|
||||
const id = newWorldId();
|
||||
await DB.op('readwrite', st => { if (a) st.put(a, `${id}:main`); if (b) st.put(b, `${id}:auto`); });
|
||||
// Verify every byte before retiring the only old copies.
|
||||
for (const [slot, old] of [['main', a], ['auto', b]]) if (old) {
|
||||
const copy = await DB.get(`${id}:${slot}`);
|
||||
if (!copy || JSON.stringify(copy.meta) !== JSON.stringify(old.meta) || copy.savedAt !== old.savedAt || copy.playTime !== old.playTime) throw new Error('Save copy failed.');
|
||||
const original = await unpackBuffers(old.buffers), copied = await unpackBuffers(copy.buffers);
|
||||
for (const k in original) {
|
||||
const x = original[k], y = copied[k];
|
||||
if (!y || x.constructor !== y.constructor || x.length !== y.length || !x.every((v, i) => v === y[i])) throw new Error('Save copy failed.');
|
||||
}
|
||||
}
|
||||
await DB.op('readwrite', st => {
|
||||
st.put([{ id, name: 'World 1', seed: sv.meta.seed, w: sv.meta.W, h: sv.meta.H, createdAt: sv.savedAt, savedAt: sv.savedAt, playTime: sv.playTime || 0 }], 'worlds');
|
||||
st.delete('main'); st.delete('auto');
|
||||
});
|
||||
} catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; }
|
||||
}
|
||||
let savingCount = 0;
|
||||
async function saveWorld(slot, quiet) {
|
||||
savingCount++;
|
||||
const indicator = setTimeout(() => { if (!GAME.loading) $('saving').hidden = false; }, 150);
|
||||
try {
|
||||
const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
|
||||
if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
|
||||
const id = GAME.worldId, name = GAME.worldName, claimNo = GAME.claimNo, c = GAME.careerId && career ? { ...career, ...careerState() } : null;
|
||||
await worldWrite(async () => {
|
||||
const buffers = await packBuffers(s.buffers);
|
||||
const list = await listWorlds();
|
||||
let entry = list.find(w => w.id === id);
|
||||
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); }
|
||||
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, dev: s.meta.dev, ...(c ? { career: c.id, claimNo } : {}) });
|
||||
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); if (c) st.put(c, 'career:' + c.id); });
|
||||
});
|
||||
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.');
|
||||
return false;
|
||||
} finally { clearTimeout(indicator); if (!--savingCount) $('saving').hidden = true; }
|
||||
}
|
||||
}
|
||||
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; }
|
||||
async function newestSave(onProgress) {
|
||||
try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id, onProgress); if (sv) return sv; } return 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; }
|
||||
@@ -46,6 +133,30 @@ async function gz(u8, inflate) {
|
||||
const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip'));
|
||||
return new Uint8Array(await new Response(st).arrayBuffer());
|
||||
}
|
||||
async function packBuffers(buffers) {
|
||||
const packed = {};
|
||||
for (const k in buffers) {
|
||||
const arr = buffers[k], bytes = await gz(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
|
||||
packed[k] = bytes ? { type: arr.constructor.name, gz: true, bytes } : arr;
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
async function unpackBuffers(buffers, onProgress) {
|
||||
let n = 0; const total = Object.keys(buffers).length;
|
||||
const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array };
|
||||
for (const k in buffers) {
|
||||
if (onProgress) onProgress(0.1 + 0.6 * n++ / total, 'Unpacking');
|
||||
const b = buffers[k];
|
||||
if (ArrayBuffer.isView(b)) { raw[k] = b; continue; }
|
||||
if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k);
|
||||
if (!Object.hasOwn(types, b.type)) throw new Error('Unknown saved buffer type: ' + b.type);
|
||||
const bytes = await gz(b.bytes, true), C = types[b.type];
|
||||
if (!bytes) throw new Error('This browser cannot decompress saved worlds.');
|
||||
raw[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
|
||||
}
|
||||
if (onProgress) onProgress(0.7, 'Unpacking');
|
||||
return raw;
|
||||
}
|
||||
async function exportWorld() {
|
||||
const s = serializeWorld();
|
||||
const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} };
|
||||
@@ -73,20 +184,27 @@ async function exportWorld() {
|
||||
catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); }
|
||||
}
|
||||
async function importWorldFile(file) {
|
||||
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
|
||||
progress(0.05, 'Reading the save');
|
||||
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 };
|
||||
let n = 0; const total = Object.keys(obj.buffers).length;
|
||||
for (const k in obj.buffers) {
|
||||
progress(0.1 + 0.6 * n++ / total, 'Unpacking');
|
||||
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 });
|
||||
const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
|
||||
if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name }, progress)) return;
|
||||
if (!await saveWorld('auto')) return;
|
||||
toast('World imported.');
|
||||
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
|
||||
finally { showLoading(false); }
|
||||
}
|
||||
|
||||
// ---------- Blueprint library ----------
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Selection panels: current tool card and bag materials.
|
||||
function renderSelCard() {
|
||||
const t = toolId(), c = $('selcard');
|
||||
let h = '';
|
||||
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
|
||||
c.hidden = false;
|
||||
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
|
||||
if (sel === 'cut') {
|
||||
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
|
||||
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
|
||||
} else if (sel === 'copy') {
|
||||
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
|
||||
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
|
||||
} else if (GAME.paste && GAME.clip) {
|
||||
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
|
||||
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
|
||||
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
|
||||
} else if (t === 'drill') {
|
||||
const b = BIT_TIERS[player.bitTier];
|
||||
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
|
||||
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
|
||||
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${pingWait() ? `<span class="chip">ping ${pingWait()}s</span>` : '<span class="chip ok">ping ready</span>'}${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
|
||||
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Right click pings: veins nearby light up, even unexplored. Built blocks are never harmed.</div>`;
|
||||
} else if (t === 'build') {
|
||||
// the build tool in general; the piece itself is described in the menu's hover popup
|
||||
const P = piece();
|
||||
const chips = buildVariantChips(P) + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
|
||||
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '');
|
||||
h = '<div class="row"><span class="name grow">Build</span></div>'
|
||||
+ (chips ? `<div class="row" style="flex-wrap:wrap;gap:4px">${chips}</div>` : '')
|
||||
+ '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>'
|
||||
+ '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>';
|
||||
} else if (t === 'vacuum') {
|
||||
const all = blowingAll(), list = all ? blowAllList() : blowList();
|
||||
const sws = ms => ms.slice(0, 4).map(m => `<span class="sw" title="${esc(MD[m].name)}" style="background:${rgbOf(m)}"></span>`).join('') + (ms.length > 4 ? `+${ms.length - 4}` : '');
|
||||
const count = list.reduce((n, m) => n + bag[m], 0);
|
||||
h = `<div class="row"><span class="name grow">Pneumatic nozzle</span><span class="chip">width ${GAME.brush[1]}</span></div>
|
||||
<div class="row vacrow"><b>Pick up</b><span>${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything</span>${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}</div>
|
||||
<div class="row vacrow"><b>Blow out</b><kbd>Q</kbd><span class="name grow vacchoice">${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}</span><span class="num chip${count ? '' : ' bad'}">${count ? count.toLocaleString('en-US') : 'none'}</span><kbd>E</kbd></div>
|
||||
<div class="sub">Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.</div>`;
|
||||
} else if (t === 'ignite') {
|
||||
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
|
||||
}
|
||||
if (c.innerHTML !== h) c.innerHTML = h;
|
||||
}
|
||||
let lastBagKey = '';
|
||||
function renderBag() {
|
||||
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
|
||||
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
|
||||
$('bagbar').firstChild.style.width = pct + '%';
|
||||
$('bagbar').classList.toggle('full', bagTotal >= cap);
|
||||
const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow];
|
||||
const missing = [...GAME.blow].filter(m => bag[m] <= 0);
|
||||
if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m);
|
||||
const l = bagMaterials().concat(missing);
|
||||
const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.');
|
||||
if (key === lastBagKey) return;
|
||||
lastBagKey = key;
|
||||
const box = $('baglist'); box.innerHTML = '';
|
||||
// the hint and Clear sit under the list, outside its scroll, so they never scroll away
|
||||
const foot = $('bagfoot'); foot.innerHTML = '';
|
||||
if (t === 'vacuum') {
|
||||
foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next"));
|
||||
if (GAME.blow.size) {
|
||||
const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection');
|
||||
clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); };
|
||||
foot.appendChild(clear);
|
||||
}
|
||||
}
|
||||
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; }
|
||||
for (const m of l) {
|
||||
if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag'));
|
||||
const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
|
||||
const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') });
|
||||
const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name });
|
||||
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}</span>`;
|
||||
// Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click
|
||||
// arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen.
|
||||
b.oncontextmenu = e => e.preventDefault();
|
||||
b.onclick = e => {
|
||||
if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; }
|
||||
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
|
||||
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
|
||||
else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); }
|
||||
hudDirty = true; lastBagKey = ''; playSfx('ui');
|
||||
};
|
||||
row.appendChild(b);
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Selection pickers: filter rules, filter settings and wall materials.
|
||||
// What a filter can hold: loose things and bars you have found, plus anything it already holds
|
||||
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
|
||||
function matList(ms) {
|
||||
const n = ms.map(m => MD[m].name.toLowerCase());
|
||||
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
|
||||
}
|
||||
// What a filter does, in words: what falls through and what rides along
|
||||
function filterParts(f) {
|
||||
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
|
||||
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
|
||||
}
|
||||
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
|
||||
// Change the whole run of matching filters at once
|
||||
function applyFilterRun(run, f) {
|
||||
undoRecord('filter settings', () => {
|
||||
for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(blockP[b], f.dir ? 128 : 0); setFilter(b, f); }
|
||||
});
|
||||
filtersDirty = true;
|
||||
}
|
||||
function openFilterPicker(cur, onDone, title) {
|
||||
const sel = new Set(cur.m);
|
||||
let inv = cur.inv ? 1 : 0, dir = beltGrade(cur.code) ? (beltGrade(cur.code)>0 ? 1 : 0) : cur.dir ? 1 : 0;
|
||||
const list = filterChoices(cur.m);
|
||||
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
|
||||
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
|
||||
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
|
||||
</div>
|
||||
<div class="fdir" role="radiogroup" aria-label="Screen travel"><span class="label">Screen travel</span>${beltGrade(cur.code) ? `<span>${esc(beltDirection(cur.code, dir << 7))}</span>` : '<button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button>'}</div>`
|
||||
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
|
||||
+ `<p class="note fp-sum" id="fp-sum"></p>`;
|
||||
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
|
||||
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)}, ${beltGrade(cur.code) ? beltDirection(cur.code, dir << 7) : dir ? 'right' : 'left'}.`;
|
||||
}
|
||||
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
|
||||
sync();
|
||||
}
|
||||
function openWallPicker() {
|
||||
const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material';
|
||||
const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m]));
|
||||
const body = `<div class="wpick" role="radiogroup" aria-label="${esc(title)}">${list.map(m => {
|
||||
const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat;
|
||||
// two sources build the same wall (glass from shards or from bars): say which
|
||||
const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name;
|
||||
const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt";
|
||||
return `<button class="wp${on ? ' on' : ''}${canAfford(cost, 1) ? '' : ' short'}" role="radio" aria-checked="${on}" data-wall="${m}"><span class="sw" style="background:${rgbOf(WALL_OF[m])}"></span><span><b>${esc(name)}</b><span>you have ${bag[m].toLocaleString('en-US')}${PH[m] === PH_ITEM ? ` ${esc(MD[m].name.toLowerCase())}${bag[m] === 1 ? '' : 's'}` : ''}</span><span>cost: ${esc(costText(cost, 1))}</span><span>${heat}</span></span></button>`;
|
||||
}).join('')}</div>`;
|
||||
dialog(title, body, [{ label: 'Cancel' }]);
|
||||
$('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => {
|
||||
GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = '';
|
||||
setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info');
|
||||
});
|
||||
}
|
||||
+52
-165
@@ -1,10 +1,11 @@
|
||||
// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
|
||||
// Tool selection: cycling, drill tuning, piece variants and costs.
|
||||
function pieceSize(P) { return P.kind === 'mold' ? MOLDS[P.mold] : P; }
|
||||
function pieceUsesWall(P) { return P.variants === 'wall'; }
|
||||
function 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];
|
||||
const what = ['long beam', 'balanced cone', 'broad fan'][n];
|
||||
setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info');
|
||||
}
|
||||
// R with the drill: tune it to the material under the cursor, or back to everything
|
||||
@@ -15,19 +16,16 @@ function tuneUnderCursor() {
|
||||
const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0;
|
||||
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');
|
||||
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only it shatters.` : 'Untuned: the drill shatters any rock it can.', 'info');
|
||||
}
|
||||
function cycleItem(d) {
|
||||
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');
|
||||
const l = bagMaterials();
|
||||
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;
|
||||
const first = blowList()[0], k = first ? l.indexOf(first) : d > 0 ? -1 : 0;
|
||||
GAME.blow = new Set([l[(k + d + l.length) % l.length]]);
|
||||
} 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]);
|
||||
@@ -49,17 +47,17 @@ function rotate() {
|
||||
else if (t === 'build') {
|
||||
const P = piece();
|
||||
if (pieceUsesWall(P)) cycleWall(1);
|
||||
else if (P.filter && beltGrade(pieceCode(P))) return;
|
||||
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
|
||||
else return;
|
||||
} else if (t === 'vacuum') { toggleCollectUnderCursor(); return; }
|
||||
else if (t === 'drill') { tuneUnderCursor(); 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 */ }
|
||||
function toggleBlow(m) {
|
||||
if (GAME.blow.has(m)) GAME.blow.delete(m);
|
||||
else if (PH[m] === PH_ITEM) GAME.blow = new Set([m]);
|
||||
else { for (const k of GAME.blow) if (PH[k] === PH_ITEM) GAME.blow.delete(k); GAME.blow.add(m); }
|
||||
lastBagKey = ''; hudDirty = true; playSfx('ui');
|
||||
}
|
||||
function pickFilterUnderCursor() {
|
||||
@@ -74,13 +72,7 @@ function toggleFilterMat(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');
|
||||
setStatus(`New filter selection updated.`, 'info');
|
||||
}
|
||||
function setBrush(d) {
|
||||
const t = toolId();
|
||||
@@ -91,156 +83,51 @@ function setBrush(d) {
|
||||
// The block variant a piece is placed with: wall material, or direction (a filter's
|
||||
// materials and mode travel separately, as GAME.filterCfg)
|
||||
function pieceV(P) {
|
||||
if (P.code === BP_WALL) return GAME.wallMat;
|
||||
if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7;
|
||||
if (isWall(P.code)) return GAME.wallMat;
|
||||
if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7);
|
||||
return GAME.pieceDir[P.id] || 0;
|
||||
}
|
||||
// One drag for the build tool and its preview: code, direction, variant, blocks, and the planned
|
||||
// blocks the join rule sees. A line that joins one block up or down snaps there (joinSnap).
|
||||
function dragPlan(P, a, b, shift) {
|
||||
const code = pieceCode(P, b), dd = a ? dragDirFor(P, a, b) : -1, dir = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
|
||||
const v = isWall(P.code) ? GAME.wallMat : P.filter ? filterVariant(code, dir << 7) : dir;
|
||||
let cells = !a ? [[b.bx, b.by]] : shift && !lineOnly(code) ? blockRect(a.bx, a.by, b.bx, b.by)
|
||||
: blockLine(a.bx, a.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW);
|
||||
const snap = lineOnly(code) ? joinSnap(code, v, cells) : 0;
|
||||
if (snap) cells = cells.map(([x, y]) => [x, y + snap]);
|
||||
return { code, dd, dir, v, cells, snap, planned: new Map(cells.map(([x, y]) => [y * BW + x, [code, v]])) };
|
||||
}
|
||||
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
|
||||
// What a filter can hold: loose things and bars you have found, plus anything it already holds
|
||||
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
|
||||
function matList(ms) {
|
||||
const n = ms.map(m => MD[m].name.toLowerCase());
|
||||
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
|
||||
}
|
||||
// What a filter does, in words: what falls through and what rides along
|
||||
function filterParts(f) {
|
||||
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
|
||||
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
|
||||
}
|
||||
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
|
||||
// Change the whole run of matching filters at once
|
||||
function applyFilterRun(run, f) {
|
||||
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
|
||||
filtersDirty = true;
|
||||
}
|
||||
function openFilterPicker(cur, onDone, title) {
|
||||
const sel = new Set(cur.m);
|
||||
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
|
||||
const list = filterChoices(cur.m);
|
||||
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
|
||||
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
|
||||
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
|
||||
</div>
|
||||
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
|
||||
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
|
||||
+ `<p class="note fp-sum" id="fp-sum"></p>`;
|
||||
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
|
||||
const box = $('dlg-body');
|
||||
function sync() {
|
||||
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
|
||||
const f = { m: [...sel].sort((a, b) => a - b), inv };
|
||||
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
|
||||
}
|
||||
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
|
||||
sync();
|
||||
}
|
||||
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
|
||||
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; }
|
||||
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
|
||||
function pieceCostNow(P) {
|
||||
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);
|
||||
function pieceCostNow(P, m = GAME.wallMat) {
|
||||
if (devFree) return [[CASH, 0]];
|
||||
if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
|
||||
if (P.kind === 'block' && isWall(P.code)) return pieceCost(pieceCode(P), m);
|
||||
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>`;
|
||||
|
||||
function pieceCode(P, end = aimBlock()) {
|
||||
if (P.id === 'belt' || P.filter) {
|
||||
const a = GAME.drag || end;
|
||||
// Shift changes nothing here: belts and filters only ever draw a line
|
||||
if (GAME.drag && diagonalDrag(end.bx - a.bx, end.by - a.by))
|
||||
return (P.filter ? BP_FILTER_UP : BP_BELT_UP) + ((end.bx - a.bx) * (end.by - a.by) > 0 ? 1 : 0);
|
||||
if (end.bx === a.bx && end.by === a.by) return GAME.pieceShape && GAME.pieceShape[P.id] || P.code;
|
||||
return P.code;
|
||||
}
|
||||
h += `<div class="status${GAME.statusKind === 'info' ? ' info' : ''}">${esc(status)}</div>`;
|
||||
if (c.innerHTML !== h) c.innerHTML = h;
|
||||
if (P.code !== BP_WALL) return P.code;
|
||||
const a = GAME.drag || end, dx = end.bx - a.bx, dy = end.by - a.by;
|
||||
if (GAME.drag && (dx || dy)) {
|
||||
return diagonalDrag(dx, dy) && !(typeof modShift === 'function' && modShift()) ? slopeCode(dx, dy, a.flip ? 2 : 0) : BP_WALL;
|
||||
}
|
||||
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);
|
||||
const shape = GAME.pieceDir.wall || 0;
|
||||
return shape ? BP_SLOPE_BL + shape - 1 : BP_WALL;
|
||||
}
|
||||
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.'));
|
||||
function turnSlope() {
|
||||
const a = GAME.drag, b = aimBlock();
|
||||
if (a && diagonalDrag(b.bx - a.bx, b.by - a.by)) a.flip = !a.flip;
|
||||
else GAME.pieceDir.wall = ((GAME.pieceDir.wall || 0) + 1) % 5;
|
||||
hudDirty = true;
|
||||
}
|
||||
|
||||
+99
-24
@@ -1,65 +1,112 @@
|
||||
// Starting, loading and quitting a game; modal dialogs; the pause menu.
|
||||
// ---------- Start / stop ----------
|
||||
function showLoading(on, p) {
|
||||
function showLoading(on, p, label, title) {
|
||||
GAME.loading = on;
|
||||
if (on) {
|
||||
$('saving').hidden = true;
|
||||
if (title) $('loading').querySelector('.label').textContent = title;
|
||||
$('loadstep').textContent = label || '';
|
||||
}
|
||||
$('loading').hidden = !on;
|
||||
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
|
||||
}
|
||||
function enterGame() {
|
||||
function enterGame(renderReady = false) {
|
||||
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
|
||||
$('cards').innerHTML = '';
|
||||
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
|
||||
GAME.status = ''; GAME.statusT = 0;
|
||||
$('title').hidden = true; $('hud').hidden = false;
|
||||
GAME.running = true; GAME.paused = false;
|
||||
buildSky(); resizeRender(); snapCamera();
|
||||
if (!renderReady) { buildSky(); resizeRender(); snapCamera(); }
|
||||
resetMap();
|
||||
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);
|
||||
async function setupNewWorld(seed, w, h, name, carried, title = 'Generating world') {
|
||||
auInit(); showLoading(true, 0, 'Shaping the land', title);
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
for (const k of Object.keys(stats)) stats[k] = 0;
|
||||
await generateWorldAsync(seed, w, h, (f, s) => showLoading(true, f * 0.9, s, title));
|
||||
GAME.worldId = newWorldId(); GAME.worldName = name;
|
||||
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
|
||||
if (carried) applyCareerState(carried);
|
||||
}
|
||||
function startFromSave(sv) {
|
||||
async function startNewWorld(seed, w, h, name = '') {
|
||||
if (!await canStartGame()) return false;
|
||||
try {
|
||||
const worlds = await listWorlds();
|
||||
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
|
||||
career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 };
|
||||
GAME.careerId = career.id; GAME.claimNo = 1;
|
||||
showLoading(true, 0.9, 'Saving');
|
||||
await saveWorld('auto');
|
||||
showLoading(true, 1, 'Saving');
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); closeAllModals(); enterGame(true);
|
||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
||||
}
|
||||
async function startFromSave(sv, onProgress) {
|
||||
if (!await canStartGame()) return false;
|
||||
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
|
||||
auInit();
|
||||
try { deserializeWorld(sv.meta, sv.buffers); }
|
||||
GAME.paused = true;
|
||||
try {
|
||||
for (const k of Object.keys(stats)) stats[k] = 0;
|
||||
onProgress(0.75, 'Building the world');
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
deserializeWorld(sv.meta, sv.buffers);
|
||||
}
|
||||
catch (e) {
|
||||
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
|
||||
showLoading(false);
|
||||
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
|
||||
return false;
|
||||
}
|
||||
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
|
||||
GAME.playTime = sv.playTime || 0;
|
||||
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0;
|
||||
enterGame();
|
||||
GAME.drag = null; GAME.blow.clear();
|
||||
onProgress(0.95, 'Your gear and notebook');
|
||||
await loadCareer();
|
||||
await gpuWarmWorld();
|
||||
showLoading(false); enterGame(true);
|
||||
return true;
|
||||
}
|
||||
async function loadSavedWorld(id) {
|
||||
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
|
||||
progress(0, 'Reading the save');
|
||||
try {
|
||||
const sv = id ? await worldNewest(id, progress) : await newestSave(progress);
|
||||
if (sv) return await startFromSave(sv, progress);
|
||||
toast('No saved world found.'); return false;
|
||||
} catch (e) { toast('Could not reach browser storage.'); return false; }
|
||||
finally { showLoading(false); }
|
||||
}
|
||||
async function quitToTitle() {
|
||||
await saveWorld('main', true);
|
||||
GAME.running = false; closeAllModals();
|
||||
GAME.running = false; closeDevConsole(); closeAllModals();
|
||||
$('hud').hidden = true; $('title').hidden = false;
|
||||
refreshContinue();
|
||||
if (!TITLE.raf) drawTitle();
|
||||
}
|
||||
async function refreshContinue() {
|
||||
const sv = await newestSave();
|
||||
$('btn-worlds').hidden = !(await listWorlds()).length;
|
||||
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' })}`;
|
||||
$('continue-meta').textContent = `${sv.worldName} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
|
||||
} else b.hidden = true;
|
||||
}
|
||||
|
||||
// ---------- Modals ----------
|
||||
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); }
|
||||
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].some(id => !$(id).hidden); }
|
||||
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
|
||||
function 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 closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
|
||||
function topModal() { for (const id of ['m-dialog', 'm-map', 'm-worlds', '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 = '';
|
||||
@@ -82,6 +129,34 @@ $('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
|
||||
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
|
||||
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
|
||||
function openPause() {
|
||||
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`;
|
||||
$('pause-meta').textContent = `${GAME.worldName} · seed ${worldSeed} · ${W}×${H}`;
|
||||
openModal('m-pause');
|
||||
}
|
||||
|
||||
// ---------- World list ----------
|
||||
async function renderWorlds(editId) {
|
||||
const list = await listWorlds(), body = $('worlds-list'); body.innerHTML = '';
|
||||
if (!list.length) body.innerHTML = '<p class="note">No worlds saved yet. Start a new world.</p>';
|
||||
for (const w of list) {
|
||||
const row = el('div', { class: 'world-row', 'data-world': w.id });
|
||||
const mins = Math.floor((w.playTime || 0) / 60);
|
||||
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong>${w.dev ? ' <span class="dev-badge">Dev console</span>' : ''}<div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
|
||||
const actions = el('div', { class: 'world-actions' });
|
||||
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
|
||||
if (editId === w.id) {
|
||||
const input = el('input', { type: 'text', maxlength: 40, 'aria-label': 'World name' }); input.value = w.name; actions.appendChild(input);
|
||||
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
|
||||
button('Cancel', () => renderWorlds());
|
||||
} else {
|
||||
button('Load', async () => { const ok = await loadSavedWorld(w.id); if (ok) closeAllModals(); });
|
||||
button('Rename', () => renderWorlds(w.id));
|
||||
button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Delete', cls: 'danger', fn: async () => { try { await deleteWorld(w.id); closeModal('m-dialog'); await renderWorlds(); await refreshContinue(); } catch (e) { toast('Could not delete the world.'); } } },
|
||||
]), 'danger');
|
||||
}
|
||||
row.appendChild(actions); body.appendChild(row);
|
||||
}
|
||||
if (editId) { const input = body.querySelector('input'); if (input) { input.focus(); input.select(); } }
|
||||
}
|
||||
$('btn-worlds').onclick = async () => { try { await renderWorlds(); openModal('m-worlds'); } catch (e) { toast('Could not reach browser storage.'); } };
|
||||
|
||||
+14
-6
@@ -8,9 +8,11 @@ const SET_TABS = {
|
||||
{ k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
|
||||
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
|
||||
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' },
|
||||
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] },
|
||||
{ k: 'minimap', label: 'Show minimap', type: 'toggle' },
|
||||
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
|
||||
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ k: 'cameraLead', label: 'Camera looks toward the pointer', d: 'The view drifts toward the mouse or right stick. Turn off to keep the bot centred.', type: 'toggle' },
|
||||
{ k: 'shake', label: 'Screen shake', type: 'toggle' },
|
||||
],
|
||||
Graphics: [
|
||||
@@ -27,6 +29,7 @@ const SET_TABS = {
|
||||
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
|
||||
],
|
||||
Simulation: [
|
||||
{ k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v },
|
||||
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
|
||||
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
|
||||
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
|
||||
@@ -44,12 +47,13 @@ const SET_TABS = {
|
||||
{ html: () => controlsHtml() },
|
||||
],
|
||||
Game: [
|
||||
{ k: 'fog', label: 'Fog of war', d: 'Places you have not seen yet are black. Off shows the whole world.', type: 'toggle' },
|
||||
{ k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] },
|
||||
{ k: '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 },
|
||||
{ k: 'wipe', label: 'Delete all worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete all worlds', danger: true, fn: confirmWipe },
|
||||
],
|
||||
};
|
||||
let setTab = 'Display';
|
||||
@@ -94,8 +98,6 @@ function renderSettings() {
|
||||
}
|
||||
}
|
||||
function applySettings() {
|
||||
// choosing Smooth again after it was too slow: try once more, at half resolution
|
||||
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
|
||||
saveSettings();
|
||||
document.documentElement.style.setProperty('--ui', SET.uiScale);
|
||||
if (R.canvas) { resizeRender(); R.snapNext = true; }
|
||||
@@ -109,8 +111,14 @@ function toggleFullscreen() {
|
||||
} 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>', [
|
||||
dialog('Delete all worlds?', '<p class="note">This removes every world and its autosave from this browser. Exported world files are not affected.</p>', [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } },
|
||||
{ label: 'Delete all worlds', cls: 'danger', fn: async () => {
|
||||
try {
|
||||
await worldWrite(() => DB.op('readwrite', st => st.clear()));
|
||||
GAME.worldId = ''; GAME.worldName = '';
|
||||
closeModal('m-dialog'); toast('All worlds deleted.'); await refreshContinue();
|
||||
} catch (e) { toast('Could not reach browser storage.'); }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user