twe
This commit is contained in:
parent
b13e07a074
commit
22e6366dc2
35 changed files with 2595 additions and 379 deletions
93
README.md
93
README.md
|
|
@ -2,22 +2,93 @@
|
|||
|
||||
Serve this folder with a static HTTP server and open `index.html`.
|
||||
|
||||
The launch page uses the unversioned app assets:
|
||||
The launch page uses:
|
||||
|
||||
- `styles.css`
|
||||
- `dist/game.js`
|
||||
|
||||
## Build
|
||||
|
||||
Run `npm install` once, then `npm run build` after editing JavaScript or `styles.css`. Without a local Node/npm toolchain, `dist/game.js` remains a source-module wrapper for local static serving.
|
||||
Run `npm install` once, then `npm run build` after editing JavaScript.
|
||||
|
||||
## Current changes
|
||||
## Current gameplay specification (v28.21)
|
||||
|
||||
- Initial no-build tiles are 3% of the starting 20x10 land.
|
||||
- Each purchased 10x10 lot increases its own no-build ratio by 0.5 percentage points, capped at 10%.
|
||||
- Floor slime is sparse, deterministic splatter instead of frequent puddles.
|
||||
- No-build tiles use subdued black/yellow hazard striping.
|
||||
- The duplicate right-side event panel was removed.
|
||||
- The Status panel no longer repeats event, belt speed, combo, or truck target data.
|
||||
- NEXT DAY displays the Fairies fee on a contained second line.
|
||||
- Dynamite lets the player select any highlighted no-build tiles, up to three. Empty clicks do not cancel selection.
|
||||
### Boost conveyor rendering hotfix (v28.21)
|
||||
|
||||
- Fixed a runtime `ReferenceError` caused by an undeclared `boost` variable in the animated conveyor renderer.
|
||||
- Added a runtime smoke test for both normal and boost conveyor drawing.
|
||||
|
||||
|
||||
### Input and motion fixes (v28.20)
|
||||
|
||||
- Clicking either end of a conveyor run now reverses the complete run, including the clicked terminal tile.
|
||||
- Conveyor stripe animation uses the same effective pixels per second as chicks on each tile. Boost Conveyor is therefore exactly 2× the normal animation speed before other modifiers.
|
||||
- NEXT DAY no longer rebuilds its child markup every frame, preventing lost clicks during pointer press/release.
|
||||
- Explosion debris horizontal velocity is reduced to 82% while vertical launch energy is unchanged.
|
||||
|
||||
### Land expansion
|
||||
|
||||
- Each purchase adds a 10×10 lot.
|
||||
- First expansion costs JPY 850.
|
||||
- Later prices use linear growth: `850 × (1 + 0.75 × previous purchases)`, rounded down to JPY 50.
|
||||
- Expected prices: JPY 850, 1,450, 2,100, 2,750, 3,400...
|
||||
|
||||
### Map obstacles
|
||||
|
||||
- Initial no-build ratio: 0%.
|
||||
- Each purchased lot adds 0.75 percentage points to that lot's obstacle ratio.
|
||||
- Obstacle ratio is capped at 12%.
|
||||
|
||||
### Scanners
|
||||
|
||||
- Scanner footprint: 2×2 grid cells.
|
||||
- Lv1 Auto Scanner cooldown: 1.5 seconds, about 40 items/day.
|
||||
- This equals about 2.1 average Lv1 Egg Farms.
|
||||
- Auto Scanner local and global tuning each multiply cooldown by 0.95 per upgrade.
|
||||
|
||||
### Mixer / Sausage production chain
|
||||
|
||||
- Mixer remains an external edge facility.
|
||||
- When mounted along a horizontal map edge, its chick inlet is on the top of the body and its meat outlet is on the bottom.
|
||||
- When mounted along a vertical map edge, its chick inlet is on the left of the body and its meat outlet is on the right.
|
||||
- The inlet and outlet use separate receiver cells, leaving the cell between them open.
|
||||
- A non-poop chick processed by Mixer creates 1 meat when the outlet conveyor is present and clear.
|
||||
- If meat cannot leave the outlet because no conveyor is connected or the outlet cell is occupied, Mixer falls back to a fixed direct sale of JPY 5. Mixer upgrades affect sausage value, not this fallback.
|
||||
- Sausage Machine costs JPY 380 and occupies exactly 3×3 owned grid cells.
|
||||
- Its single meat inlet is one cell left of the middle row; its single sausage outlet is one cell right of the middle row.
|
||||
- Placement is rejected if the 3×3 body or either port would be outside owned, usable grid space.
|
||||
- It consumes 2 meat and outputs 1 sausage.
|
||||
- Sausages are accepted by Truck regardless of the live-cargo event target.
|
||||
- Base sausage sale value is JPY 26.
|
||||
- MIXER Improvement and SAUSAGE MACHINE Improvement each multiply sausage value by 1.10 per installed level.
|
||||
|
||||
### Conveyor branches
|
||||
|
||||
- Fixed branch direction is shown by a triangle rather than an `RT`/direction abbreviation.
|
||||
- A branch never offers a direction that points back toward an incoming conveyor.
|
||||
- Branch selection is unavailable unless at least two outgoing conveyors remain after incoming directions are removed.
|
||||
|
||||
### Camera controls
|
||||
|
||||
- Build phase: right- or middle-drag pans the camera.
|
||||
- Factory-running phase: left-, right-, or middle-drag pans the camera.
|
||||
- Mouse wheel zoom remains available during both phases.
|
||||
|
||||
### Shredder upgrades
|
||||
|
||||
- SHREDDER Improvement uses a nonlinear scrap lottery.
|
||||
- Upgrade count increases payout approximately with `1.6 × cards^1.25`.
|
||||
- It also increases hit chance from 28%, by 1.8 percentage points per upgrade, capped at 82%.
|
||||
- Winning payouts roll 1×, 2×, 4×, or 10×.
|
||||
- Big-win and jackpot odds increase as more SHREDDER Improvement cards are installed.
|
||||
|
||||
### Card eligibility
|
||||
|
||||
- Equipment-targeted cards are excluded when no valid target exists.
|
||||
- Equipment-specific passive cards can declare facility requirements and are excluded while those facilities are absent.
|
||||
|
||||
### Other verified rules
|
||||
|
||||
- Floor slime is deterministic decoration.
|
||||
- Dynamite removes up to 3 selected blocked cells.
|
||||
- Bankruptcy is checked only after day-end settlement.
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
# Assets organization (prepared, not yet wired into the game)
|
||||
|
||||
This patch adds a **dark factory** art set without changing runtime references yet.
|
||||
|
||||
## Folder guide
|
||||
- `assets/dark_factory/facilities/` : new factory equipment sprites
|
||||
- `assets/dark_factory/previews/` : visual overview sheet
|
||||
- `assets/images/` : user-supplied chick and poop sprites used by the renderer
|
||||
- `assets/images/` : left untouched for compatibility with the current build
|
||||
|
||||
## Notes
|
||||
- Grid base unit: **16x16**
|
||||
- Non-grid UI object base unit: **32x32**
|
||||
- Color count intentionally limited for a coarse pixel-art look
|
||||
- Sprites are supplied as individual PNG files with transparency
|
||||
- This patch **does not yet hook the new art into the live game**
|
||||
|
|
@ -1 +0,0 @@
|
|||
Dark factory facility sprites. See ../manifest.json for file roles and dimensions.\n
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 475 B |
Binary file not shown.
|
Before Width: | Height: | Size: 326 B |
Binary file not shown.
|
Before Width: | Height: | Size: 401 B |
|
|
@ -59,28 +59,25 @@
|
|||
{
|
||||
"id": "mixer",
|
||||
"files": [
|
||||
"mixer_32.png",
|
||||
"mixer_compact_16.png"
|
||||
"mixer_32.png"
|
||||
],
|
||||
"size": "32x32 + 16x16 compact",
|
||||
"size": "32x32",
|
||||
"folder": "facilities/machines"
|
||||
},
|
||||
{
|
||||
"id": "shredder",
|
||||
"files": [
|
||||
"shredder_32.png",
|
||||
"shredder_compact_16.png"
|
||||
"shredder_32.png"
|
||||
],
|
||||
"size": "32x32 + 16x16 compact",
|
||||
"size": "32x32",
|
||||
"folder": "facilities/machines"
|
||||
},
|
||||
{
|
||||
"id": "truck",
|
||||
"files": [
|
||||
"truck_32.png",
|
||||
"truck_compact_16.png"
|
||||
"truck_32.png"
|
||||
],
|
||||
"size": "32x32 + 16x16 compact",
|
||||
"size": "32x32",
|
||||
"folder": "facilities/vehicles"
|
||||
},
|
||||
{
|
||||
|
|
@ -100,4 +97,4 @@
|
|||
"folder": "facilities/utility"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Preview sheet(s) for quick inspection. These are reference only, not runtime assets.\n
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
# Image assets
|
||||
|
||||
`poop.png` is bundled and loaded by default. Other equipment/chick art is optional and currently falls back to canvas drawing unless external art loading is enabled in `src/render/draw.js`.
|
||||
903
codex_all_new_ideas_prompt_en.md
Normal file
903
codex_all_new_ideas_prompt_en.md
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
# Codex Implementation Prompt — Chick Sorter v29.00
|
||||
|
||||
Implement the next major version of the attached **Chick Sorter v28.21 source project**.
|
||||
|
||||
The objective is to add a deterministic power-network system, fuel-based generation, inbound logistics, egg-farm quality overflow, and realistic repair-worker navigation while preserving every feature and regression fix already present in v28.21.
|
||||
|
||||
Treat this prompt as the authoritative implementation brief. Do not expand the mandatory scope with optional backlog items unless they are explicitly marked as required.
|
||||
|
||||
---
|
||||
|
||||
## 1. Non-negotiable engineering rules
|
||||
|
||||
1. Do not edit the minified production bundle directly.
|
||||
2. Modify source files under `src/` and the appropriate source HTML/CSS/README files, then rebuild the production bundle.
|
||||
3. Preserve all v28.21 behavior and regression fixes.
|
||||
4. Keep all tunable values in the existing centralized balance/configuration module.
|
||||
5. Prefer isolated modules for new systems, including:
|
||||
- `src/systems/power.js`
|
||||
- `src/systems/imports.js`
|
||||
- `src/systems/farmQuality.js` if farm-quality logic cannot remain cleanly isolated in the existing farm module.
|
||||
6. Do not add texture assets. Draw new equipment with Canvas geometry, symbols, labels, and simple temporary colors.
|
||||
7. Update the version to `29.0.0`, the visible version to `v29.00`, and the bundle cache key to `game.js?v=29.00` or the equivalent used by the project.
|
||||
8. Implement the work in stages. After every stage, run relevant syntax checks and tests.
|
||||
9. Do not silently discard transport items because of congestion, power loss, or invalid machine state.
|
||||
10. Update README so every documented value matches the actual implementation constants.
|
||||
|
||||
After each implementation stage, report:
|
||||
|
||||
- files changed;
|
||||
- behavior implemented;
|
||||
- tests run and results;
|
||||
- unresolved assumptions or defects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Preserve all existing v28.21 features
|
||||
|
||||
Do not regress any of the following:
|
||||
|
||||
- land expansion starts at 850 yen and uses the current reduced linear increase model;
|
||||
- the level-1 automatic scanner occupies 2x2 cells and processes approximately 2.1 level-1 farms;
|
||||
- shredder upgrades scale nonlinearly and retain high random variance;
|
||||
- the mixer emits meat through its configured output and pays a fixed fallback revenue of exactly +5 yen when meat cannot be emitted;
|
||||
- the 3x3 sausage machine consumes two meat items and emits one sausage;
|
||||
- sausage revenue is collected by the shipping truck;
|
||||
- equipment-specific cards are excluded from the card pool when the relevant equipment does not exist;
|
||||
- README matches implemented obstacle and balance behavior;
|
||||
- mixer input/output ports, sausage-machine placement, conveyor branching rules, triangle direction indicators, and running-phase camera panning remain intact;
|
||||
- clicking an edge conveyor reverses the connected conveyor line correctly;
|
||||
- Boost Conveyor animation is twice the regular conveyor animation speed;
|
||||
- conveyor animation speed stays synchronized with actual transported-item movement, including modifiers and wear;
|
||||
- NEXT DAY works on a single click;
|
||||
- explosion horizontal impulse remains reduced to the v28.20 value;
|
||||
- normal and Boost Conveyor drawing never throws `boost is not defined` or another undefined-variable error.
|
||||
|
||||
Keep the existing internal truck ID for compatibility. Rename only the player-facing label of the current truck to **Shipping Truck**.
|
||||
|
||||
---
|
||||
|
||||
# Mandatory v29.00 systems
|
||||
|
||||
## 3. Power model
|
||||
|
||||
### 3.1 Equipment exempt from electricity
|
||||
|
||||
The following operate without voltage:
|
||||
|
||||
- manual scanner;
|
||||
- egg farm;
|
||||
- regular conveyor;
|
||||
- Boost Conveyor;
|
||||
- regular power wire;
|
||||
- insulated power wire;
|
||||
- footbridge;
|
||||
- starter generator;
|
||||
- biomass generator;
|
||||
- coal generator.
|
||||
|
||||
Every other active machine requires sufficient received voltage.
|
||||
|
||||
When a machine is under-voltage:
|
||||
|
||||
- stop accepting or processing new items;
|
||||
- do not remove an item from an upstream conveyor until acceptance is confirmed;
|
||||
- pause processing timers rather than resetting them;
|
||||
- retain internal queues and pending outputs;
|
||||
- resume normally after voltage is restored.
|
||||
|
||||
### 3.2 Voltage terminology
|
||||
|
||||
This is an intentionally simplified game system, not a physical electrical simulation.
|
||||
|
||||
Each powered machine has:
|
||||
|
||||
- `minVoltage`: minimum received voltage required to operate;
|
||||
- `drawV`: its contribution to generator load sag;
|
||||
- `relayDropV`: optional extra route loss only if the implementation allows a power route to pass through a declared machine relay node.
|
||||
|
||||
Use the player-facing term **Voltage Load** for `drawV`.
|
||||
|
||||
### 3.3 Generator load sag
|
||||
|
||||
A pure path-loss system would allow one 25V generator to power unlimited parallel machines. Prevent that with source load sag.
|
||||
|
||||
For each source:
|
||||
|
||||
```text
|
||||
busVoltage = currentGeneratorOutputV - sum(drawV of admitted machines)
|
||||
receivedVoltage(machine) = busVoltage - routeLossV(machine)
|
||||
```
|
||||
|
||||
Admit a machine only when adding its load keeps every already admitted machine at or above its own `minVoltage`.
|
||||
|
||||
Different generators do not add their voltages together in v29.00.
|
||||
|
||||
A machine is assigned to at most one generator.
|
||||
|
||||
Choose and admit sources deterministically:
|
||||
|
||||
1. calculate the best route from every available generator;
|
||||
2. prefer the source with the highest open-circuit received voltage;
|
||||
3. admit higher `powerPriority` first;
|
||||
4. then lower route loss;
|
||||
5. then earlier build order;
|
||||
6. then stable equipment ID.
|
||||
|
||||
Use default `powerPriority = 50`.
|
||||
|
||||
Use priority 100 for the initial mixer, shredder, and shipping truck.
|
||||
|
||||
Repeat assignment until stable, with a hard maximum of eight passes.
|
||||
|
||||
### 3.4 Power routing
|
||||
|
||||
Power wires form a four-directional graph.
|
||||
|
||||
- Regular Power Wire loses **0.5V per traversed tile**.
|
||||
- Insulated Power Wire loses **0.15V per traversed tile**.
|
||||
- Wire types connect directly to each other.
|
||||
- Branches do not divide voltage.
|
||||
- Loops must never increase voltage.
|
||||
- Separate generators are not summed.
|
||||
- Active fuel pulses in the same fuel generator are summed.
|
||||
- Stop exploring a path at 0V or below.
|
||||
- Use a maximum-priority Dijkstra-style propagation or an equivalent monotonic minimum-loss algorithm.
|
||||
|
||||
Do not run a complete graph search every render frame. Recalculate after structural changes and at no more than 10Hz while timed generator pulses change.
|
||||
|
||||
Recommended public functions:
|
||||
|
||||
```js
|
||||
ensurePowerState(game)
|
||||
markPowerDirty(game)
|
||||
updatePowerSystem(game, dt)
|
||||
recalculatePowerNetwork(game)
|
||||
receivedVoltageFor(game, equipment)
|
||||
isEquipmentPowered(game, equipment)
|
||||
powerSummary(game)
|
||||
```
|
||||
|
||||
### 3.5 Power layer
|
||||
|
||||
Power wires use an overlay layer separate from equipment and conveyor occupancy.
|
||||
|
||||
Wires may overlap:
|
||||
|
||||
- regular conveyors;
|
||||
- Boost Conveyors;
|
||||
- footbridges.
|
||||
|
||||
Wires may not occupy:
|
||||
|
||||
- unowned cells;
|
||||
- obstacle cells;
|
||||
- equipment body cells, unless a future explicit relay contact supports it.
|
||||
|
||||
Equipment receives power through an adjacent wire touching a declared power contact.
|
||||
|
||||
When Power Overlay is off, wires should not interfere with normal conveyor clicking.
|
||||
|
||||
### 3.6 Power-wire equipment
|
||||
|
||||
#### Regular Power Wire
|
||||
|
||||
- ID: `powerWire`
|
||||
- Price: 10 yen per tile
|
||||
- Loss: 0.5V per tile
|
||||
- Temporary art: thin dark line, brighter in Power Overlay mode
|
||||
|
||||
#### Insulated Power Wire
|
||||
|
||||
- ID: `insulatedPowerWire`
|
||||
- Price: 22 yen per tile
|
||||
- Loss: 0.15V per tile
|
||||
- Temporary art: thicker line with a light outer casing
|
||||
- Replacing one wire type with the other should be a direct operation that charges or refunds the price difference.
|
||||
|
||||
### 3.7 Powered-machine values
|
||||
|
||||
Place these values in the centralized balance table.
|
||||
|
||||
| Equipment | Minimum voltage | Voltage load | Relay drop |
|
||||
|---|---:|---:|---:|
|
||||
| Mixer | 10V | 4V | 4V |
|
||||
| Shredder | 8V | 3V | 3V |
|
||||
| Shipping Truck | 6V | 2V | 2V |
|
||||
| Automatic Scanner | 8V | 2V | 2V |
|
||||
| Sausage Machine | 12V | 5V | 5V |
|
||||
| Maintenance Room | 6V | 1V | 1V |
|
||||
| Inbound Truck | 5V | 1V | 1V |
|
||||
|
||||
### 3.8 Starter generator and initial wiring
|
||||
|
||||
Add one starter generator to every new game.
|
||||
|
||||
- ID: `starterGenerator`
|
||||
- Size: 2x2
|
||||
- Continuous output: 25V
|
||||
- Exists from game start
|
||||
- Cannot be sold
|
||||
- Cannot be built again
|
||||
- May be moved during build phase
|
||||
- Requires no electricity
|
||||
- Temporary art: dark body, lightning symbol, and `25V` label
|
||||
|
||||
A new game must also contain free regular power wires connecting the starter generator to:
|
||||
|
||||
- the initial mixer;
|
||||
- the initial shredder;
|
||||
- the initial shipping truck.
|
||||
|
||||
All three machines must be powered on day one without player intervention.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
1. reserve the generator footprint and required route cells before obstacle generation;
|
||||
2. create default equipment first;
|
||||
3. run a deterministic helper such as `createStarterPowerLayout(game)`;
|
||||
4. route through owned, non-obstacle cells;
|
||||
5. allow wires to cross conveyor cells because wires are on a separate layer;
|
||||
6. never route through equipment bodies;
|
||||
7. charge zero money for the starter generator and initial wires;
|
||||
8. validate the completed layout;
|
||||
9. fail loudly in development if valid initial routing cannot be created.
|
||||
|
||||
The initial mixer, shredder, and shipping truck create 9V total load, leaving a 16V bus before path loss.
|
||||
|
||||
### 3.9 Biomass generator
|
||||
|
||||
- ID: `biomassGenerator`
|
||||
- Size: 3x3
|
||||
- Price: 520 yen
|
||||
- Inputs: three ports at left-center, top-center, and bottom-center
|
||||
- Accepts male chicks, female chicks, and poop
|
||||
- Rejects meat, sausage, coal, and zunda mochi
|
||||
- Each accepted item adds +5V for 2 seconds
|
||||
- Multiple active pulses in the same generator stack additively
|
||||
- Three overlapping inputs therefore provide +15V during the overlap
|
||||
- Clear pulses at day end
|
||||
- Requires no electricity
|
||||
- Temporary art: leaf/flame symbol and current output label
|
||||
|
||||
### 3.10 Coal generator
|
||||
|
||||
- ID: `coalGenerator`
|
||||
- Size: 2x3
|
||||
- Price: 680 yen
|
||||
- One coal input at left-center
|
||||
- Each coal adds +15V for 3 seconds
|
||||
- Multiple active pulses in the same generator stack additively
|
||||
- Reject all non-coal items
|
||||
- Clear pulses at day end
|
||||
- Requires no electricity
|
||||
|
||||
### 3.11 Power UI
|
||||
|
||||
Add a build-phase **POWER OVERLAY** toggle.
|
||||
|
||||
Display:
|
||||
|
||||
- wire type;
|
||||
- generator output;
|
||||
- assigned source;
|
||||
- source bus voltage after admitted load;
|
||||
- route loss;
|
||||
- received voltage;
|
||||
- minimum required voltage;
|
||||
- Voltage Load;
|
||||
- under-voltage state.
|
||||
|
||||
Example equipment tooltip:
|
||||
|
||||
```text
|
||||
Power: 9.5V / 10V
|
||||
Source: Starter Generator
|
||||
Source bus: 15.5V
|
||||
Route loss: 6.0V
|
||||
Voltage Load: 4V
|
||||
Status: UNDER VOLTAGE
|
||||
```
|
||||
|
||||
Show a red crossed-lightning symbol on an under-voltage machine during operation.
|
||||
|
||||
Warn before NEXT DAY when machines are unpowered, but do not block starting the day.
|
||||
|
||||
---
|
||||
|
||||
## 4. Inbound logistics
|
||||
|
||||
### 4.1 Inbound Truck
|
||||
|
||||
Add a new edge machine.
|
||||
|
||||
- ID: `inboundTruck`
|
||||
- Price: 350 yen
|
||||
- Multiple inbound trucks may be built
|
||||
- One output port faces the grid
|
||||
- Requires 5V and contributes 1V Voltage Load
|
||||
- Imports coal and zunda mochi
|
||||
- Clicking it opens a per-truck configuration panel
|
||||
|
||||
Store:
|
||||
|
||||
```js
|
||||
inboundOrder: {
|
||||
coalPerDay: 0,
|
||||
zundaMochiPerDay: 0
|
||||
}
|
||||
```
|
||||
|
||||
Daily limits per truck:
|
||||
|
||||
- coal: 0–60;
|
||||
- zunda mochi: 0–30;
|
||||
- combined total: 80 items.
|
||||
|
||||
Purchase prices:
|
||||
|
||||
- coal: **2 yen each**;
|
||||
- zunda mochi: **4 yen each**.
|
||||
|
||||
Charge the complete order cost at the beginning of the day.
|
||||
|
||||
### 4.2 Delivery timing
|
||||
|
||||
Distribute imports across the 60-second operating day.
|
||||
|
||||
For `n` items:
|
||||
|
||||
```js
|
||||
baseTime = ((i + 0.5) / n) * TURN_SECONDS
|
||||
```
|
||||
|
||||
Apply deterministic or seeded jitter within ±15% of the base interval.
|
||||
|
||||
Merge coal and zunda schedules and dispatch in timestamp order.
|
||||
|
||||
If the output is blocked:
|
||||
|
||||
- retain the item in the truck queue;
|
||||
- do not delete it;
|
||||
- resume release when space becomes available.
|
||||
|
||||
If the truck is under-voltage, pause release timing and resume after power restoration.
|
||||
|
||||
Items still unreleased at day end are undelivered, do not carry over, and are not refunded.
|
||||
|
||||
### 4.3 New transport types
|
||||
|
||||
Add transport-item types compatible with the current entity structure:
|
||||
|
||||
```js
|
||||
sex: 'coal'
|
||||
sex: 'zundaMochi'
|
||||
```
|
||||
|
||||
Temporary art:
|
||||
|
||||
- coal: small black rounded square;
|
||||
- zunda mochi: small green circle or diamond.
|
||||
|
||||
Manual and automatic scanners must reject both items without deleting them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Egg-farm output cap and product quality
|
||||
|
||||
### 5.1 Design goal
|
||||
|
||||
Farm upgrades currently reach a point where farms produce faster than even a Boost Conveyor can remove items.
|
||||
|
||||
From v29.00 onward:
|
||||
|
||||
- production speed up to the theoretical capacity of one Boost Conveyor increases item count;
|
||||
- theoretical production beyond that capacity no longer increases item count;
|
||||
- the excess becomes a diminishing product-quality multiplier.
|
||||
|
||||
Never derive quality from real congestion, a blocked output, queue size, or deliberate obstruction.
|
||||
|
||||
### 5.2 Derive Boost Conveyor capacity
|
||||
|
||||
Do not introduce a duplicate hard-coded throughput constant.
|
||||
|
||||
Derive capacity from the live movement model, for example:
|
||||
|
||||
```js
|
||||
boostOutputCapacityPerSecond =
|
||||
BALANCE.conveyor.boostSpeedPxPerSecond /
|
||||
BALANCE.transport.minimumItemSpacingPx;
|
||||
```
|
||||
|
||||
Use the actual variable names from the project.
|
||||
|
||||
Changing Boost Conveyor speed or minimum spacing must automatically change the farm cap.
|
||||
|
||||
### 5.3 Farm formula
|
||||
|
||||
For each farm:
|
||||
|
||||
```text
|
||||
R = theoretical production rate after permanent upgrades, cards, events, and zunda boost
|
||||
C = theoretical maximum output rate of one Boost Conveyor
|
||||
actualSpawnRate = min(R, C)
|
||||
overflowRatio = max(0, R / C - 1)
|
||||
qualityMultiplier = clamp(1 + 0.45 * overflowRatio^0.85, 1.0, 2.5)
|
||||
```
|
||||
|
||||
Store the formula constants in centralized balance data.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
| Theoretical rate | Actual item rate | Approximate quality |
|
||||
|---:|---:|---:|
|
||||
| 0.8C | 0.8C | 1.00x |
|
||||
| 1.0C | 1.0C | 1.00x |
|
||||
| 1.5C | 1.0C | about 1.25x |
|
||||
| 2.0C | 1.0C | 1.45x |
|
||||
| 3.0C | 1.0C | about 1.81x |
|
||||
| 5.0C | 1.0C | about 2.46x |
|
||||
|
||||
Snapshot quality when an item is spawned:
|
||||
|
||||
```js
|
||||
sourceQuality: 1.0
|
||||
```
|
||||
|
||||
### 5.4 Quality inheritance
|
||||
|
||||
- A female chick shipped directly multiplies shipping revenue by its `sourceQuality`.
|
||||
- A male chick processed into meat transfers its quality to that meat item.
|
||||
- A sausage made from two meat items uses the arithmetic mean of the two meat-quality values.
|
||||
- Shipping sausage multiplies its base value by sausage quality.
|
||||
- Poop has no quality multiplier.
|
||||
- Biomass power output ignores quality.
|
||||
- Coal and zunda have no quality.
|
||||
- The mixer blocked-output fallback remains exactly +5 yen and ignores quality.
|
||||
|
||||
### 5.5 Farm UI
|
||||
|
||||
Show:
|
||||
|
||||
- theoretical production rate;
|
||||
- actual capped output rate;
|
||||
- Boost Conveyor cap;
|
||||
- current quality multiplier;
|
||||
- quality grade.
|
||||
|
||||
Display-only grades:
|
||||
|
||||
- 1.00–1.14x: Standard
|
||||
- 1.15–1.34x: Select
|
||||
- 1.35–1.74x: Premium
|
||||
- 1.75x and above: Luxury
|
||||
|
||||
Revenue must use the continuous multiplier, not grade buckets.
|
||||
|
||||
If the connected belt is slower than the farm's actual capped output, show:
|
||||
|
||||
```text
|
||||
OUTPUT BELT BELOW FARM CAPACITY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Zunda mochi farm boost
|
||||
|
||||
A zunda mochi item reaching a conveyor cell orthogonally adjacent to an egg farm is consumed by that farm.
|
||||
|
||||
- Accept from any of the four adjacent directions.
|
||||
- Each item adds 8 seconds to the boost timer.
|
||||
- Maximum stored boost time: 30 seconds.
|
||||
- While active, production interval multiplier: 0.55.
|
||||
- Multiple items extend duration but do not multiply the speed factor.
|
||||
- Clear remaining boost time at day end.
|
||||
- Keep the existing safe minimum production interval of 0.25 seconds.
|
||||
- Zunda changes theoretical rate `R`.
|
||||
- If zunda raises `R` above the Boost Conveyor cap, the excess becomes quality rather than extra item count.
|
||||
|
||||
Record realized zunda value in daily statistics.
|
||||
|
||||
Do not add all of the following balancing restrictions at once. They are tuning controls only if playtests show zunda is dominant:
|
||||
|
||||
- reduce duration from 8 seconds to 6 seconds;
|
||||
- add a per-farm consumption cooldown;
|
||||
- reduce only the zunda-derived quality-conversion efficiency;
|
||||
- add a per-farm daily appetite limit.
|
||||
|
||||
---
|
||||
|
||||
## 7. Repair-worker navigation and footbridges
|
||||
|
||||
### 7.1 Collision rules
|
||||
|
||||
Replace direct straight-line repair-worker movement with four-directional A* or BFS grid pathfinding.
|
||||
|
||||
A worker may enter a cell only when it is:
|
||||
|
||||
- owned;
|
||||
- not an obstacle;
|
||||
- not occupied by equipment, a farm, scanner, or maintenance room;
|
||||
- not occupied by a conveyor unless a footbridge covers that conveyor cell.
|
||||
|
||||
Power wires do not block walking.
|
||||
|
||||
### 7.2 Footbridge
|
||||
|
||||
- ID: `footbridge`
|
||||
- Price: 90 yen
|
||||
- Size: 1x3 or 3x1
|
||||
- Rotatable
|
||||
- May overlap conveyor and power-wire layers
|
||||
- May not overlap obstacles, unowned cells, or equipment bodies
|
||||
- Affects repair-worker movement only
|
||||
- Must not alter conveyor transport, direction editing, or branch selection
|
||||
- Temporary art: translucent deck with side rails
|
||||
|
||||
### 7.3 Path caching
|
||||
|
||||
Each worker should store:
|
||||
|
||||
```js
|
||||
path
|
||||
pathIndex
|
||||
pathVersion
|
||||
```
|
||||
|
||||
Maintain `game.walkabilityVersion` and increment it whenever construction changes passability.
|
||||
|
||||
Recalculate only when:
|
||||
|
||||
- target changes;
|
||||
- walkability version changes;
|
||||
- current path becomes invalid.
|
||||
|
||||
Do not run pathfinding every frame.
|
||||
|
||||
### 7.4 Repair position
|
||||
|
||||
Workers repair from a reachable cell orthogonally adjacent to the target footprint.
|
||||
|
||||
They must not stand inside the target machine.
|
||||
|
||||
- Generate adjacent candidate work cells.
|
||||
- Choose the nearest reachable candidate.
|
||||
- Repair conveyors from an adjacent walkable cell or a valid footbridge cell.
|
||||
- Repair edge equipment from the nearest reachable grid-side work cell.
|
||||
- Skip unreachable targets.
|
||||
- Show `NO REPAIR ACCESS` in details for unreachable equipment.
|
||||
- Workers must exit the maintenance room from a valid perimeter cell rather than its center.
|
||||
|
||||
---
|
||||
|
||||
## 8. Layer selection and editing
|
||||
|
||||
Because conveyors, power wires, and footbridges may overlap:
|
||||
|
||||
- equipment selection has highest priority;
|
||||
- with Power Overlay enabled, wires participate in hit testing;
|
||||
- with Power Overlay disabled, conveyors take priority and wires are not directly clickable;
|
||||
- footbridges must remain selectable without breaking conveyor editing;
|
||||
- erase one layer at a time in this order:
|
||||
1. footbridge;
|
||||
2. power wire;
|
||||
3. conveyor;
|
||||
- optionally support Shift+Erase to remove all overlay layers on one cell.
|
||||
|
||||
Do not regress edge-conveyor reversal or conveyor branch selection rules.
|
||||
|
||||
---
|
||||
|
||||
## 9. Power gates for existing machines
|
||||
|
||||
Add power checks at these points:
|
||||
|
||||
- automatic scanner before queue processing;
|
||||
- mixer before acceptance and processing;
|
||||
- shredder before acceptance and processing;
|
||||
- shipping truck before acceptance;
|
||||
- sausage machine before acceptance, processing, and output;
|
||||
- inbound truck before item release;
|
||||
- maintenance room before worker dispatch and while repair work is progressing.
|
||||
|
||||
Do not remove an item from the upstream conveyor before the destination confirms that it is powered and has capacity.
|
||||
|
||||
---
|
||||
|
||||
## 10. Card behavior and durability
|
||||
|
||||
Preserve equipment-dependent card filtering.
|
||||
|
||||
Do not add generator, wire, inbound-truck, or footbridge cards in v29.00.
|
||||
|
||||
Repairable new equipment:
|
||||
|
||||
- starter generator;
|
||||
- biomass generator;
|
||||
- coal generator;
|
||||
- inbound truck.
|
||||
|
||||
No wear in v29.00:
|
||||
|
||||
- regular wire;
|
||||
- insulated wire;
|
||||
- footbridge.
|
||||
|
||||
Generator wear must not reduce output voltage in this version. Use the existing normal delay/maintenance model only if required.
|
||||
|
||||
---
|
||||
|
||||
## 11. Statistics and UI
|
||||
|
||||
Add daily statistics for:
|
||||
|
||||
- coal ordered, released, consumed, and undelivered;
|
||||
- zunda ordered, released, consumed, and undelivered;
|
||||
- import purchase cost;
|
||||
- fuel consumed by generator type;
|
||||
- maximum output per generator;
|
||||
- under-voltage seconds per machine;
|
||||
- generator-source assignment changes;
|
||||
- zunda boost seconds per farm;
|
||||
- farm theoretical rate;
|
||||
- farm actual output rate;
|
||||
- average and maximum quality multiplier;
|
||||
- revenue added by quality;
|
||||
- unreachable repair-target count.
|
||||
|
||||
Build controls:
|
||||
|
||||
- POWER WIRE
|
||||
- INSULATED WIRE
|
||||
- BIOMASS GEN
|
||||
- COAL GEN
|
||||
- INBOUND TRUCK
|
||||
- FOOTBRIDGE
|
||||
- POWER OVERLAY
|
||||
|
||||
Do not add a build button for the starter generator.
|
||||
|
||||
Inbound Truck panel:
|
||||
|
||||
- coal per day;
|
||||
- zunda mochi per day;
|
||||
- combined total;
|
||||
- daily purchase cost;
|
||||
- average release interval;
|
||||
- current output queue.
|
||||
|
||||
Editable only in build phase.
|
||||
|
||||
Top-level power summary example:
|
||||
|
||||
```text
|
||||
POWER: Starter 25V / 3 powered / 1 under-voltage
|
||||
```
|
||||
|
||||
Do not display a summed `TOTAL V`, because independent generators are not combined.
|
||||
|
||||
---
|
||||
|
||||
## 12. README requirements
|
||||
|
||||
Document the implemented values and behavior for:
|
||||
|
||||
- machines that do and do not require electricity;
|
||||
- generator load sag;
|
||||
- regular-wire loss of 0.5V per tile;
|
||||
- insulated-wire loss of 0.15V per tile;
|
||||
- non-splitting branches;
|
||||
- non-additive separate generators;
|
||||
- machine minimum voltage and Voltage Load;
|
||||
- starter, biomass, and coal generators;
|
||||
- free initial generator and wiring;
|
||||
- inbound orders and item prices;
|
||||
- zunda farm boost;
|
||||
- Boost Conveyor production cap;
|
||||
- quality overflow and inheritance;
|
||||
- repair-worker collision rules;
|
||||
- footbridges;
|
||||
- temporary geometric art for new machines.
|
||||
|
||||
Every README number must come from or match the centralized balance values.
|
||||
|
||||
---
|
||||
|
||||
## 13. Required tests
|
||||
|
||||
Retain all v28.21 tests and add a dedicated v29.00 test suite.
|
||||
|
||||
### Power
|
||||
|
||||
1. One regular wire tile loses 0.5V.
|
||||
2. Ten regular wire tiles lose 5V.
|
||||
3. One insulated wire tile loses 0.15V.
|
||||
4. Ten insulated wire tiles lose 1.5V.
|
||||
5. Mixed paths calculate exact combined loss.
|
||||
6. Branches do not divide voltage.
|
||||
7. Loops never increase voltage.
|
||||
8. Separate generators are not summed.
|
||||
9. Three overlapping biomass pulses produce +15V.
|
||||
10. One coal pulse produces +15V for 3 seconds.
|
||||
11. Starter mixer, shredder, and shipping truck create 9V total load.
|
||||
12. A machine that would invalidate the admitted source set remains unpowered.
|
||||
13. Under-voltage machines do not consume items.
|
||||
14. Paused processing resumes after power restoration.
|
||||
15. New games include the starter generator and initial wires.
|
||||
16. Initial mixer, shredder, and shipping truck are powered on day one.
|
||||
17. Starter power equipment costs zero.
|
||||
18. Initial power cells do not overlap obstacles or equipment bodies.
|
||||
|
||||
### Imports
|
||||
|
||||
19. Coal costs 2 yen each.
|
||||
20. Zunda mochi costs 4 yen each.
|
||||
21. Sixty coal items schedule at approximately one-second intervals.
|
||||
22. Six items schedule at approximately ten-second intervals.
|
||||
23. A blocked inbound output retains its item.
|
||||
24. Coal and zunda cannot enter scanners.
|
||||
25. Order cost is charged correctly at day start.
|
||||
26. Under-voltage pauses release without losing queued imports.
|
||||
|
||||
### Farm quality
|
||||
|
||||
27. Spawn count is unchanged below the Boost Conveyor capacity.
|
||||
28. Spawn rate is capped at the derived Boost Conveyor capacity.
|
||||
29. Quality is exactly 1.0x at or below the cap.
|
||||
30. Quality increases monotonically above the cap.
|
||||
31. Quality never exceeds 2.5x.
|
||||
32. Blocking farm output does not raise quality.
|
||||
33. Female shipping revenue uses source quality.
|
||||
34. Meat inherits chick quality.
|
||||
35. Sausage quality equals the arithmetic mean of its two meat inputs.
|
||||
36. Mixer fallback remains exactly +5 yen regardless of quality.
|
||||
37. Poop and generator output ignore farm quality.
|
||||
38. Changing Boost Conveyor movement speed changes the derived cap without editing another constant.
|
||||
|
||||
### Zunda
|
||||
|
||||
39. A farm consumes adjacent zunda mochi.
|
||||
40. One item adds 8 seconds up to 30 seconds.
|
||||
41. Zunda changes theoretical farm rate.
|
||||
42. Zunda cannot increase count beyond the Boost Conveyor cap.
|
||||
43. Zunda overflow increases quality through the same formula.
|
||||
44. Multiple zunda items extend duration but do not multiply the speed factor.
|
||||
|
||||
### Repair workers
|
||||
|
||||
45. Workers cannot pass through equipment.
|
||||
46. Workers cannot walk on uncovered conveyor cells.
|
||||
47. Workers can cross conveyor cells covered by a footbridge.
|
||||
48. Workers repair from an adjacent cell.
|
||||
49. Unreachable machines are skipped and flagged.
|
||||
50. Construction edits invalidate and recalculate cached paths.
|
||||
|
||||
### Regression and build
|
||||
|
||||
51. Every v28.21 test passes.
|
||||
52. `npm run build` succeeds.
|
||||
53. Generated output contains no `boost is not defined` regression.
|
||||
54. NEXT DAY enters the running phase with one click.
|
||||
55. Regular and Boost Conveyor drawing does not throw.
|
||||
56. Edge-conveyor reversal still works.
|
||||
57. Conveyor item movement remains synchronized with conveyor animation.
|
||||
58. A new factory runs without manual power edits.
|
||||
59. The final ZIP contains source, updated README, and rebuilt production files.
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation order
|
||||
|
||||
Implement in this order, with a working checkpoint after each stage:
|
||||
|
||||
1. balance constants, IDs, equipment definitions, temporary drawing;
|
||||
2. starter-generator placement reservation and free initial wiring;
|
||||
3. wire overlay and route-loss calculation;
|
||||
4. generator load admission and machine power gates;
|
||||
5. biomass and coal generators;
|
||||
6. inbound truck and imported transport items;
|
||||
7. egg-farm throughput cap and quality inheritance;
|
||||
8. zunda integration;
|
||||
9. footbridge and repair-worker pathfinding;
|
||||
10. UI, statistics, and README;
|
||||
11. full regression tests, production build, and release ZIP.
|
||||
|
||||
Do not implement all systems as one large patch.
|
||||
|
||||
---
|
||||
|
||||
## 15. Completion criteria
|
||||
|
||||
The implementation is complete only when:
|
||||
|
||||
- a new game visibly includes a starter generator and connected wires;
|
||||
- the initial mixer, shredder, and shipping truck work on day one;
|
||||
- long wire runs reduce received voltage;
|
||||
- insulated wire loses exactly 0.15V per tile;
|
||||
- adding powered machines creates source load and can cause under-voltage;
|
||||
- under-voltage never deletes or silently consumes items;
|
||||
- fuel inputs create timed additive output pulses inside each generator;
|
||||
- coal and zunda imports are distributed across the day;
|
||||
- farm output count cannot exceed one Boost Conveyor's theoretical transport capacity;
|
||||
- production above that cap increases product quality rather than count;
|
||||
- deliberate congestion never creates quality;
|
||||
- quality survives the female shipping and meat-to-sausage chains;
|
||||
- repair workers avoid machines and uncovered conveyors;
|
||||
- footbridges permit repair-worker conveyor crossings;
|
||||
- README values match implementation values;
|
||||
- all old and new tests pass;
|
||||
- the production bundle builds successfully.
|
||||
|
||||
---
|
||||
|
||||
# Optional future-design backlog — do not implement in v29.00
|
||||
|
||||
Record these ideas in a short `FUTURE_IDEAS.md` or an equivalent non-executable design note. Do not implement them unless explicitly requested.
|
||||
|
||||
## A. Capacitor / battery
|
||||
|
||||
Store short biomass and coal voltage pulses and release them gradually to reduce intermittent under-voltage.
|
||||
|
||||
Potential parameters:
|
||||
|
||||
- storage capacity;
|
||||
- maximum charge rate;
|
||||
- maximum discharge rate;
|
||||
- round-trip efficiency;
|
||||
- player-selectable reserve threshold.
|
||||
|
||||
## B. Transformer or power-combiner station
|
||||
|
||||
Allow multiple generators to feed one managed bus only through a dedicated late-game machine. This would intentionally replace the v29.00 rule that independent generator voltages are never summed.
|
||||
|
||||
## C. Editable power priorities
|
||||
|
||||
Allow Low, Normal, and High priority per machine so nonessential production shuts down before critical logistics during shortages.
|
||||
|
||||
## D. Advanced power statistics
|
||||
|
||||
Possible additions:
|
||||
|
||||
- average received voltage;
|
||||
- brownout count;
|
||||
- energy/fuel cost per shipped item;
|
||||
- source utilization percentage;
|
||||
- unused generator output;
|
||||
- voltage-loss heat map.
|
||||
|
||||
## E. Additional wire tiers
|
||||
|
||||
Possible late-game wire types with lower loss, higher purchase price, or limited placement requirements. Do not reduce Insulated Power Wire below its required 0.15V-per-tile value in v29.00.
|
||||
|
||||
## F. Zunda balancing controls
|
||||
|
||||
Only if telemetry shows zunda is dominant:
|
||||
|
||||
- shorter duration;
|
||||
- farm consumption cooldown;
|
||||
- reduced zunda-only contribution to quality overflow;
|
||||
- daily appetite limit.
|
||||
|
||||
Change one control at a time and measure the result.
|
||||
|
||||
## G. Repair-access visualization
|
||||
|
||||
A build-mode overlay showing:
|
||||
|
||||
- reachable worker cells;
|
||||
- planned repair paths;
|
||||
- machines with no adjacent work cell;
|
||||
- recommended footbridge locations.
|
||||
|
||||
## H. Power-aware card ideas
|
||||
|
||||
Possible future cards after the base system is stable:
|
||||
|
||||
- reduced machine Voltage Load;
|
||||
- lower regular-wire loss;
|
||||
- longer fuel pulses;
|
||||
- improved biomass voltage per item;
|
||||
- temporary emergency generator output;
|
||||
- capacitor efficiency.
|
||||
|
||||
Do not add these cards in v29.00 because the base power economy must be measured first.
|
||||
53
dist/game.js
vendored
53
dist/game.js
vendored
File diff suppressed because one or more lines are too long
16
index.html
16
index.html
|
|
@ -6,13 +6,13 @@
|
|||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Chick Sorter v27.20 — Card and Expansion Patch</title>
|
||||
<title>Chick Sorter v28.21 — Boost Draw Hotfix</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="gameShell" class="game-shell" data-build="v27.20" aria-label="Game screen">
|
||||
<div class="build-stamp" aria-label="loaded build">v27.20 VERIFIED</div>
|
||||
<section id="gameShell" class="game-shell" data-build="v28.21" aria-label="Game screen">
|
||||
<div class="build-stamp" aria-label="loaded build">v28.21 VERIFIED</div>
|
||||
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
|
||||
|
||||
<div class="hud hud-top-left" aria-label="Run status">
|
||||
|
|
@ -34,14 +34,12 @@
|
|||
<div id="eventBrief" class="event-brief status-chip">NO ACTIVE EVENT</div>
|
||||
<div id="speedBrief" class="speed-brief status-chip">BELT: 52 px/s</div>
|
||||
<div class="combo-brief status-chip"><span>COMBO</span><strong id="comboCount">0</strong></div>
|
||||
<div id="targetBrief" class="target-brief status-chip">TRUCK TARGET: FEMALE</div>
|
||||
</div>
|
||||
|
||||
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v27.20</span></h1>
|
||||
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
|
||||
<h1>CHICK SORTER <span>v28.21</span></h1>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||
</div>
|
||||
|
|
@ -53,11 +51,12 @@
|
|||
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
|
||||
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
|
||||
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
|
||||
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 3.5s cooldown</span></button>
|
||||
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 1.5s cooldown</span></button>
|
||||
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
|
||||
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
|
||||
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
|
||||
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
|
||||
<button id="buildSausageMakerButton" class="tool-button" type="button"><strong>Sausage Machine</strong><span>¥380 / 3×3</span></button>
|
||||
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
|
||||
<button id="buildMaintenanceRoomButton" class="tool-button repair" type="button"><strong>Maintenance Room</strong><span>¥220 / 2×2</span></button>
|
||||
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
|
||||
|
|
@ -94,6 +93,7 @@
|
|||
<button id="debugGrantAllCardsButton" type="button">All Cards</button>
|
||||
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
|
||||
<label class="debug-target">Target <select id="debugTargetSelect"></select></label>
|
||||
<label class="debug-event">Next Event <select id="debugEventSelect"></select></label>
|
||||
<button id="debugGrantCardButton" type="button">Grant Selected</button>
|
||||
<div id="debugReadout" class="debug-readout"></div>
|
||||
</div>
|
||||
|
|
@ -110,6 +110,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./dist/game.js"></script>
|
||||
<script type="module" src="./dist/game.js?v=28.21"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "zunda-shiwake",
|
||||
"version": "27.20.0",
|
||||
"version": "28.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zunda-shiwake",
|
||||
"version": "27.20.0",
|
||||
"version": "28.2.1",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"name": "zunda-shiwake",
|
||||
"version": "27.20.0",
|
||||
"version": "28.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js"
|
||||
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js",
|
||||
"test": "node test_v28_21.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Central balance sheet for tuning gameplay.
|
||||
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
|
||||
export const BALANCE = {
|
||||
version: 'v27.20 CARD + EXPANSION PATCH',
|
||||
version: 'v28.21 BOOST DRAW HOTFIX',
|
||||
time: {
|
||||
daySeconds: 60,
|
||||
farmShutdownGraceSeconds: 10
|
||||
|
|
@ -18,9 +18,9 @@ export const BALANCE = {
|
|||
expansion: {
|
||||
colsPerPurchase: 10,
|
||||
rowsPerPurchase: 10,
|
||||
costBase: 1000,
|
||||
costMultiplier: 4 / 3,
|
||||
truncateUnit: 100
|
||||
costBase: 850,
|
||||
costGrowthRate: 0.75,
|
||||
truncateUnit: 50
|
||||
}
|
||||
},
|
||||
map: {
|
||||
|
|
@ -31,7 +31,8 @@ export const BALANCE = {
|
|||
economy: {
|
||||
income: {
|
||||
mixer: 5,
|
||||
truck: 10
|
||||
truck: 10,
|
||||
sausage: 26
|
||||
},
|
||||
poopFine: 10,
|
||||
fairiesTribute: {
|
||||
|
|
@ -45,7 +46,21 @@ export const BALANCE = {
|
|||
maleTruckFinePerHalfDay: 30,
|
||||
shredderBonus: {
|
||||
maxCards: 30,
|
||||
incomePerUpgrade: 1.25
|
||||
payoutCoefficient: 1.6,
|
||||
payoutExponent: 1.25,
|
||||
baseHitChance: 0.28,
|
||||
hitChancePerUpgrade: 0.018,
|
||||
maxHitChance: 0.82,
|
||||
jackpotBaseChance: 0.01,
|
||||
jackpotChancePerUpgrade: 0.0025,
|
||||
jackpotMaxChance: 0.08,
|
||||
bigWinBaseChance: 0.07,
|
||||
bigWinChancePerUpgrade: 0.0015,
|
||||
bigWinMaxChance: 0.12,
|
||||
doubleChance: 0.20,
|
||||
jackpotMultiplier: 10,
|
||||
bigWinMultiplier: 4,
|
||||
doubleMultiplier: 2
|
||||
},
|
||||
chemicalWeaponSubsidy: {
|
||||
poopPerBlock: 70,
|
||||
|
|
@ -54,7 +69,7 @@ export const BALANCE = {
|
|||
},
|
||||
production: {
|
||||
poopRate: 0.08,
|
||||
autoScannerCooldown: 3.5,
|
||||
autoScannerCooldown: 1.5,
|
||||
autoScannerUpgradeRate: 0.95,
|
||||
autoScannerCardUpgradeRate: 0.95,
|
||||
autoScannerMinCooldown: 0.5,
|
||||
|
|
@ -71,6 +86,9 @@ export const BALANCE = {
|
|||
maxSpeed: 300,
|
||||
bearingSpeedMultiplier: 1.075
|
||||
},
|
||||
effects: {
|
||||
explosionHorizontalVelocityMultiplier: 0.82
|
||||
},
|
||||
cards: {
|
||||
definitions: [
|
||||
{
|
||||
|
|
@ -97,6 +115,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All AUTO SCANNER cooldowns use default cooldown x0.95 per copy.',
|
||||
requiresFacility: 'autoScanner',
|
||||
tags: ['SCANNER', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -105,7 +124,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'mixer',
|
||||
description: 'Choose MIXER on the map and raise it by 1 level.',
|
||||
description: 'Choose one MIXER. Each level improves meat quality and raises sausage sale value by 10%.',
|
||||
tags: ['ECONOMY', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -117,13 +136,22 @@ export const BALANCE = {
|
|||
description: 'Choose TRUCK on the map and raise it by 1 level.',
|
||||
tags: ['ECONOMY', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeSausageMaker',
|
||||
title: 'SAUSAGE MACHINE Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'sausageMaker',
|
||||
description: 'Choose one SAUSAGE MACHINE. Each level raises sausage sale value by 10%.',
|
||||
tags: ['ECONOMY', 'SAUSAGE', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeTrash',
|
||||
title: 'SHREDDER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'trash',
|
||||
description: 'Shred poops for a little money. Max 30 upgrades.',
|
||||
description: 'Adds a nonlinear scrap-lottery upgrade. More copies raise both payout and win chance; max 30.',
|
||||
tags: ['POOP', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -132,6 +160,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
|
||||
requiresFacility: 'eggFarm',
|
||||
tags: ['EGG', 'RISK', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -140,6 +169,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
|
||||
requiresFacility: 'eggFarm',
|
||||
tags: ['EGG', 'POOP', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -164,6 +194,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment deterioration speed -5%. No card limit.',
|
||||
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
|
||||
tags: ['MAINTENANCE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -180,6 +211,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment maximum durability +3%. No card limit.',
|
||||
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
|
||||
tags: ['MAINTENANCE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -188,6 +220,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Conveyor speed +7.5%.',
|
||||
requiresFacility: 'conveyor',
|
||||
tags: ['CONVEYOR', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -196,6 +229,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
|
||||
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
|
||||
tags: ['MAINTENANCE', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -220,6 +254,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Turns shredder waste into suspicious fertilizer. Gain JPY 3 per copy when SHREDDER processes poop.',
|
||||
requiresFacility: 'trash',
|
||||
tags: ['POOP', 'ECONOMY', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -228,6 +263,7 @@ export const BALANCE = {
|
|||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Manual and Auto Scanner queue spacing -15%. No card limit.',
|
||||
requiresAnyFacility: ['manualScanner', 'autoScanner'],
|
||||
tags: ['SCANNER', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
|
|
@ -253,7 +289,7 @@ export const BALANCE = {
|
|||
rarity: 'rare',
|
||||
type: 'cellAction',
|
||||
target: 'blockedCell',
|
||||
description: 'Choose and remove up to 10 unbuildable cells.',
|
||||
description: 'Choose and remove up to 3 unbuildable cells.',
|
||||
tags: ['RISK', 'RARE', 'ONE-SHOT']
|
||||
},
|
||||
{
|
||||
|
|
@ -369,11 +405,13 @@ export const BALANCE = {
|
|||
eggFarm: 475,
|
||||
autoScanner: 850,
|
||||
mixer: 775,
|
||||
trash: 950
|
||||
trash: 950,
|
||||
sausageMaker: 825
|
||||
},
|
||||
processingDelayMaxSeconds: {
|
||||
mixer: 1.20,
|
||||
trash: 1.00
|
||||
trash: 1.00,
|
||||
sausageMaker: 0.90
|
||||
},
|
||||
repairman: {
|
||||
secondsPerOnePercent: 0.5,
|
||||
|
|
@ -413,6 +451,10 @@ export const BALANCE = {
|
|||
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
|
||||
},
|
||||
sausageMaker: {
|
||||
id: 'sausageMaker', type: 'facility', name: 'Sausage Machine', shortName: 'SAUSAGE', price: 380,
|
||||
buildable: true, upgradeable: true, incomeKey: 'sausage', footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }, outputProduct: 'sausage'
|
||||
},
|
||||
maintenanceRoom: {
|
||||
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
|
||||
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
|
|||
export const GRID = BALANCE.grid;
|
||||
export const FACILITY_DEFS = BALANCE.facilities;
|
||||
|
||||
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'maintenanceRoom'];
|
||||
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
|
||||
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'sausageMaker', 'maintenanceRoom'];
|
||||
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const GRID_FACILITY_IDS = ['sausageMaker'];
|
||||
export const MACHINE_FACILITY_IDS = [...EDGE_FACILITY_IDS, ...GRID_FACILITY_IDS];
|
||||
export const REQUIRED_RECEIVER_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck', 'sausageMaker'];
|
||||
|
||||
export const BUILD_COSTS = Object.fromEntries(Object.entries(FACILITY_DEFS).map(([id, def]) => [id, def.price]));
|
||||
|
||||
|
|
@ -48,6 +51,8 @@ export const THEME = {
|
|||
mixerBlue: '#4aa3ff',
|
||||
truckPink: '#ffb33e',
|
||||
wasteGreen: '#88d35a',
|
||||
meatRed: '#d96b72',
|
||||
sausageOrange: '#d78b3f',
|
||||
danger: '#e34a36',
|
||||
warn: '#ffb827',
|
||||
white: '#e6edf2',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
|
||||
import { isOwnedCell } from './gridExpansion.js';
|
||||
import { nextSpawnDelay } from './state.js';
|
||||
import { currentPoopRate } from '../systems/events.js';
|
||||
|
||||
|
|
@ -49,7 +50,45 @@ export function createScanner(game, col, row, kind) {
|
|||
};
|
||||
}
|
||||
|
||||
export function nearestGridEdge(point) {
|
||||
function distanceToSegment(point, a, b) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 > 0 ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / len2)) : 0;
|
||||
const x = a.x + dx * t;
|
||||
const y = a.y + dy * t;
|
||||
return Math.hypot(point.x - x, point.y - y);
|
||||
}
|
||||
|
||||
function ownedBoundaryEdges(game) {
|
||||
const edges = [];
|
||||
const cells = game?.ownedCells?.size
|
||||
? [...game.ownedCells].map(k => {
|
||||
const [col, row] = String(k).split(',').map(Number);
|
||||
return { col, row };
|
||||
})
|
||||
: Array.from({ length: GRID.cols * GRID.rows }, (_, i) => ({ col: i % GRID.cols, row: Math.floor(i / GRID.cols) }));
|
||||
for (const cell of cells) {
|
||||
const x0 = GRID.x + cell.col * GRID.cell;
|
||||
const y0 = GRID.y + cell.row * GRID.cell;
|
||||
const x1 = x0 + GRID.cell;
|
||||
const y1 = y0 + GRID.cell;
|
||||
if (!isOwnedCell(game, cell.col - 1, cell.row)) edges.push({ side: 'left', entry: cell, a: { x: x0, y: y0 }, b: { x: x0, y: y1 } });
|
||||
if (!isOwnedCell(game, cell.col + 1, cell.row)) edges.push({ side: 'right', entry: cell, a: { x: x1, y: y0 }, b: { x: x1, y: y1 } });
|
||||
if (!isOwnedCell(game, cell.col, cell.row - 1)) edges.push({ side: 'top', entry: cell, a: { x: x0, y: y0 }, b: { x: x1, y: y0 } });
|
||||
if (!isOwnedCell(game, cell.col, cell.row + 1)) edges.push({ side: 'bottom', entry: cell, a: { x: x0, y: y1 }, b: { x: x1, y: y1 } });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
export function nearestGridEdge(point, game = null) {
|
||||
const boundary = ownedBoundaryEdges(game);
|
||||
if (boundary.length) {
|
||||
const best = boundary
|
||||
.map(edge => ({ ...edge, distance: distanceToSegment(point, edge.a, edge.b) }))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
return { side: best.side, entry: { ...best.entry } };
|
||||
}
|
||||
const colFloat = (point.x - GRID.x) / GRID.cell;
|
||||
const rowFloat = (point.y - GRID.y) / GRID.cell;
|
||||
const col = Math.max(0, Math.min(GRID.cols - 1, Math.round(colFloat - 0.5)));
|
||||
|
|
@ -65,20 +104,83 @@ export function nearestGridEdge(point) {
|
|||
return { side, entry: { col, row: GRID.rows - 1 } };
|
||||
}
|
||||
|
||||
function facilityKind(facility) { return facility?.baseId || facility?.id || ''; }
|
||||
|
||||
export function syncGridFacilityGeometry(facility) {
|
||||
if (!facility?.gridPlaced) return facility;
|
||||
facility.wCells = Math.max(1, facility.wCells || 3);
|
||||
facility.hCells = Math.max(1, facility.hCells || 3);
|
||||
facility.x = GRID.x + facility.col * GRID.cell;
|
||||
facility.y = GRID.y + facility.row * GRID.cell;
|
||||
facility.w = facility.wCells * GRID.cell;
|
||||
facility.h = facility.hCells * GRID.cell;
|
||||
if (facilityKind(facility) === 'sausageMaker') {
|
||||
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
|
||||
facility.side = 'grid';
|
||||
}
|
||||
return facility;
|
||||
}
|
||||
|
||||
export function gridFacilityFootprintCells(facility) {
|
||||
if (!facility?.gridPlaced) return [];
|
||||
const cells = [];
|
||||
for (let dr = 0; dr < (facility.hCells || 3); dr += 1) {
|
||||
for (let dc = 0; dc < (facility.wCells || 3); dc += 1) cells.push({ col: facility.col + dc, row: facility.row + dr });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function orientEdgeFacility(facility, side) {
|
||||
if (facilityKind(facility) !== 'mixer') return;
|
||||
const baseW = facility.baseW || facility.w || 168;
|
||||
const baseH = facility.baseH || facility.h || 110;
|
||||
facility.baseW = baseW;
|
||||
facility.baseH = baseH;
|
||||
const horizontal = side === 'top' || side === 'bottom';
|
||||
facility.w = horizontal ? Math.max(baseW, baseH) : Math.min(baseW, baseH);
|
||||
facility.h = horizontal ? Math.min(baseW, baseH) : Math.max(baseW, baseH);
|
||||
}
|
||||
|
||||
export function facilityBodyPortPoint(facility, type = 'input') {
|
||||
if (!facility) return null;
|
||||
const kind = facilityKind(facility);
|
||||
if (kind === 'mixer') {
|
||||
const horizontal = facility.side === 'top' || facility.side === 'bottom';
|
||||
if (horizontal) return type === 'input'
|
||||
? { x: facility.x + facility.w / 2, y: facility.y }
|
||||
: { x: facility.x + facility.w / 2, y: facility.y + facility.h };
|
||||
return type === 'input'
|
||||
? { x: facility.x, y: facility.y + facility.h / 2 }
|
||||
: { x: facility.x + facility.w, y: facility.y + facility.h / 2 };
|
||||
}
|
||||
if (kind === 'sausageMaker' && facility.gridPlaced) {
|
||||
return type === 'input'
|
||||
? { x: facility.x, y: facility.y + facility.h / 2 }
|
||||
: { x: facility.x + facility.w, y: facility.y + facility.h / 2 };
|
||||
}
|
||||
return { x: facility.x + facility.w / 2, y: facility.y + facility.h / 2 };
|
||||
}
|
||||
|
||||
export function layoutFacilityOnEdge(facility, entry, side) {
|
||||
const center = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
|
||||
const edge = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
|
||||
orientEdgeFacility(facility, side);
|
||||
const cell = {
|
||||
x0: GRID.x + entry.col * GRID.cell,
|
||||
y0: GRID.y + entry.row * GRID.cell,
|
||||
x1: GRID.x + (entry.col + 1) * GRID.cell,
|
||||
y1: GRID.y + (entry.row + 1) * GRID.cell
|
||||
};
|
||||
const center = { x: (cell.x0 + cell.x1) / 2, y: (cell.y0 + cell.y1) / 2 };
|
||||
facility.entry = { ...entry };
|
||||
facility.side = side;
|
||||
if (side === 'left') { facility.x = edge.left - facility.w - 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'right') { facility.x = edge.right + 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = edge.top - facility.h - 18; }
|
||||
else { facility.x = center.x - facility.w / 2; facility.y = edge.bottom + 18; }
|
||||
if (side === 'left') { facility.x = cell.x0 - facility.w - 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'right') { facility.x = cell.x1 + 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = cell.y0 - facility.h - 18; }
|
||||
else { facility.x = center.x - facility.w / 2; facility.y = cell.y1 + 18; }
|
||||
return facility;
|
||||
}
|
||||
|
||||
export function createFacility(game, id, p, price) {
|
||||
const { entry, side } = nearestGridEdge(p);
|
||||
const { entry, side } = nearestGridEdge(p, game);
|
||||
const def = FACILITY_DEFS[id];
|
||||
const spec = def
|
||||
? { name: def.name, w: def.body?.w || 168, h: def.body?.h || 110, price: def.price }
|
||||
|
|
@ -86,7 +188,7 @@ export function createFacility(game, id, p, price) {
|
|||
const f = {
|
||||
type: 'facility', id,
|
||||
name: spec.name,
|
||||
x: 0, y: 0, w: spec.w, h: spec.h,
|
||||
x: 0, y: 0, w: spec.w, h: spec.h, baseW: spec.w, baseH: spec.h,
|
||||
level: 1,
|
||||
price: price || spec.price,
|
||||
builtSession: game.buildSession
|
||||
|
|
@ -95,6 +197,24 @@ export function createFacility(game, id, p, price) {
|
|||
}
|
||||
|
||||
|
||||
export function createGridFacility(game, id, col, row, price) {
|
||||
const def = FACILITY_DEFS[id];
|
||||
const f = {
|
||||
type: 'facility', id,
|
||||
name: def?.name || id,
|
||||
baseId: id,
|
||||
col, row,
|
||||
wCells: id === 'sausageMaker' ? 3 : 1,
|
||||
hCells: id === 'sausageMaker' ? 3 : 1,
|
||||
gridPlaced: true,
|
||||
level: 1,
|
||||
price: price || def?.price || 0,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
return syncGridFacilityGeometry(f);
|
||||
}
|
||||
|
||||
|
||||
export function maintenanceRoomFootprint(room) {
|
||||
if (!room) return [];
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -15,17 +15,20 @@ function parseChunkKey(value) {
|
|||
export function expansionCost(game) {
|
||||
const cfg = BALANCE.grid.expansion || {};
|
||||
const purchaseCount = Math.max(1, (game.gridExpansionPurchases || 0) + 1);
|
||||
const raw = (cfg.costBase || 1000) * purchaseCount * (cfg.costMultiplier || (4 / 3));
|
||||
const unit = Math.max(1, cfg.truncateUnit || 100);
|
||||
const base = Math.max(0, Number(cfg.costBase) || 850);
|
||||
const growth = Math.max(0, Number(cfg.costGrowthRate) || 0.75);
|
||||
const raw = base * (1 + growth * (purchaseCount - 1));
|
||||
const unit = Math.max(1, cfg.truncateUnit || 50);
|
||||
return Math.floor(raw / unit) * unit;
|
||||
}
|
||||
|
||||
export function resetOwnedCells(game) {
|
||||
game.ownedCells = new Set();
|
||||
game.ownedChunks = new Set();
|
||||
game.gridChunkOriginX = 0;
|
||||
game.gridChunkOriginY = 0;
|
||||
game.gridExpansionPurchases = 0;
|
||||
game.gridExpansionPurchasesByDirection = { right: 0, up: 0, down: 0 };
|
||||
game.gridExpansionPurchasesByDirection = { left: 0, right: 0, up: 0, down: 0 };
|
||||
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
|
||||
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
|
|
@ -41,7 +44,8 @@ export function ensureOwnedChunks(game) {
|
|||
const originY = game.gridChunkOriginY || 0;
|
||||
for (const k of game.ownedCells) {
|
||||
const p = parseKey(k);
|
||||
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE);
|
||||
const originX = game.gridChunkOriginX || 0;
|
||||
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE) + originX;
|
||||
const chunkY = Math.floor(p.row / GRID_CHUNK_SIZE) + originY;
|
||||
game.ownedChunks.add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
|
|
@ -63,13 +67,14 @@ export function countOwnedCells(game) {
|
|||
}
|
||||
|
||||
export function lotFromChunk(game, chunkX, chunkY, direction = 'up') {
|
||||
const originX = game.gridChunkOriginX || 0;
|
||||
const originY = game.gridChunkOriginY || 0;
|
||||
return {
|
||||
direction,
|
||||
key: chunkKey(chunkX, chunkY),
|
||||
chunkX,
|
||||
chunkY,
|
||||
colStart: chunkX * GRID_CHUNK_SIZE,
|
||||
colStart: (chunkX - originX) * GRID_CHUNK_SIZE,
|
||||
rowStart: (chunkY - originY) * GRID_CHUNK_SIZE,
|
||||
cols: GRID_CHUNK_SIZE,
|
||||
rows: GRID_CHUNK_SIZE
|
||||
|
|
@ -81,17 +86,17 @@ export function expansionLots(game) {
|
|||
const candidates = new Map();
|
||||
for (const k of owned) {
|
||||
const { chunkX, chunkY } = parseChunkKey(k);
|
||||
const left = { chunkX: chunkX - 1, chunkY, direction: 'left' };
|
||||
const right = { chunkX: chunkX + 1, chunkY, direction: 'right' };
|
||||
const up = { chunkX, chunkY: chunkY - 1, direction: 'up' };
|
||||
const down = { chunkX, chunkY: chunkY + 1, direction: 'down' };
|
||||
for (const candidate of [right, up, down]) {
|
||||
if (candidate.chunkX < 0) continue;
|
||||
for (const candidate of [left, right, up, down]) {
|
||||
const ck = chunkKey(candidate.chunkX, candidate.chunkY);
|
||||
if (owned.has(ck) || candidates.has(ck)) continue;
|
||||
candidates.set(ck, lotFromChunk(game, candidate.chunkX, candidate.chunkY, candidate.direction));
|
||||
}
|
||||
}
|
||||
const directionOrder = { right: 0, up: 1, down: 2 };
|
||||
const directionOrder = { left: 0, right: 1, up: 2, down: 3 };
|
||||
return [...candidates.values()].sort((a, b) => {
|
||||
const dir = (directionOrder[a.direction] ?? 9) - (directionOrder[b.direction] ?? 9);
|
||||
if (dir) return dir;
|
||||
|
|
@ -157,6 +162,24 @@ export function shiftRowIndexedMap(map, rowDelta) {
|
|||
return next;
|
||||
}
|
||||
|
||||
export function shiftColIndexedSet(set, colDelta) {
|
||||
const next = new Set();
|
||||
for (const k of set || []) {
|
||||
const p = parseKey(k);
|
||||
next.add(key(p.col + colDelta, p.row));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shiftColIndexedMap(map, colDelta) {
|
||||
const next = new Map();
|
||||
for (const [k, v] of map || []) {
|
||||
const p = parseKey(k);
|
||||
next.set(key(p.col + colDelta, p.row), v);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ownChunk(game, chunkX, chunkY) {
|
||||
ensureOwnedChunks(game).add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export function newTurnStats() {
|
|||
pendingTruckRevenue: 0,
|
||||
mixerRevenue: 0,
|
||||
truckRevenue: 0,
|
||||
sausageRevenue: 0,
|
||||
meatProduced: 0,
|
||||
sausageProduced: 0,
|
||||
sausageShipped: 0,
|
||||
chickShipmentIncome: 0,
|
||||
poopShipmentIncome: 0,
|
||||
manualComboBonus: 0,
|
||||
|
|
@ -62,6 +66,9 @@ export function newTotalStats() {
|
|||
poopTruck: 0,
|
||||
poopTrash: 0,
|
||||
poopMixer: 0,
|
||||
meatProduced: 0,
|
||||
sausageProduced: 0,
|
||||
sausageShipped: 0,
|
||||
explosionDamage: 0,
|
||||
shredderBonus: 0,
|
||||
eventBonus: 0,
|
||||
|
|
@ -102,7 +109,8 @@ export function createGame() {
|
|||
gridCols: BALANCE.grid.cols,
|
||||
gridRows: BALANCE.grid.rows,
|
||||
gridExpansionPurchases: 0,
|
||||
gridExpansionPurchasesByDirection: { right: 0, up: 0 },
|
||||
gridExpansionPurchasesByDirection: { left: 0, right: 0, up: 0, down: 0 },
|
||||
gridChunkOriginX: 0,
|
||||
gridChunkOriginY: 0,
|
||||
ownedCells: new Set(),
|
||||
blockedCells: new Set(),
|
||||
|
|
@ -136,6 +144,19 @@ export function createGame() {
|
|||
factoryGraphSnapshot: null,
|
||||
mixerHalfTimer: 0,
|
||||
congestion: new Map(),
|
||||
componentLookup: new Map(),
|
||||
frameId: 0,
|
||||
frameCache: {
|
||||
frameId: -1,
|
||||
routingVersion: -1,
|
||||
chickRevision: 0,
|
||||
spatialRevision: -1,
|
||||
spatial: new Map(),
|
||||
congestionFrame: -1,
|
||||
componentChicks: new Map(),
|
||||
manualMonitorSignature: '',
|
||||
draw: {}
|
||||
},
|
||||
lastExplodedComponent: new Map(),
|
||||
eventOffer: null,
|
||||
eventActive: null,
|
||||
|
|
@ -171,7 +192,14 @@ function facilityBodyForEntry(id, entry, side) {
|
|||
else if (side === 'right') { x = g.right + 18; y = c.y - spec.h / 2; }
|
||||
else if (side === 'bottom') { x = c.x - spec.w / 2; y = g.bottom + 18; }
|
||||
else if (side === 'top') { x = c.x - spec.w / 2; y = g.top - spec.h - 18; }
|
||||
return { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
|
||||
const facility = { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, baseW: spec.w, baseH: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
|
||||
if (id === 'mixer' && (side === 'left' || side === 'right')) {
|
||||
facility.w = Math.min(spec.w, spec.h);
|
||||
facility.h = Math.max(spec.w, spec.h);
|
||||
if (side === 'left') { facility.x = g.left - facility.w - 18; facility.y = c.y - facility.h / 2; }
|
||||
else { facility.x = g.right + 18; facility.y = c.y - facility.h / 2; }
|
||||
}
|
||||
return facility;
|
||||
}
|
||||
|
||||
export function defaultFacilities() {
|
||||
|
|
@ -263,9 +291,7 @@ function scannerBodyCells(scanner) {
|
|||
{ col: scanner.col, row: scanner.row - 1 },
|
||||
{ col: scanner.col + 1, row: scanner.row - 1 },
|
||||
{ col: scanner.col, row: scanner.row },
|
||||
{ col: scanner.col + 1, row: scanner.row },
|
||||
{ col: scanner.col, row: scanner.row + 1 },
|
||||
{ col: scanner.col + 1, row: scanner.row + 1 }
|
||||
{ col: scanner.col + 1, row: scanner.row }
|
||||
].filter(p => inGrid(p.col, p.row));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const TEXT = {
|
|||
mixer: 'MIXER',
|
||||
truck: 'TRUCK',
|
||||
trash: 'WASTE',
|
||||
sausageMaker: 'SAUSAGE',
|
||||
scanner: 'NEXT',
|
||||
'scanner-role-1': 'NEXT',
|
||||
input: 'IN'
|
||||
|
|
@ -66,19 +67,37 @@ export function buildToolFlavorText(id) {
|
|||
conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.',
|
||||
boostConveyor: 'Moves chicks twice as fast. Wears down like a normal belt.',
|
||||
eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.',
|
||||
autoScanner: 'Slower than hands without upgrades.',
|
||||
autoScanner: 'Processes about 40 items per day at Lv1, roughly 2.1 Lv1 farms.',
|
||||
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
|
||||
mixer: 'Male chicks become revenue here. Do not feed it poop.',
|
||||
mixer: 'Makes meat when OUT is clear; otherwise falls back to direct sale.',
|
||||
trash: 'A polite shredder for poop and other regrets.',
|
||||
truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.',
|
||||
truck: 'Ships target chicks and all sausages. Wrong live cargo still leaves a paper trail.',
|
||||
sausageMaker: 'A 3×3 grid machine. Left inlet takes 2 meat; right outlet emits 1 sausage.',
|
||||
maintenanceRoom: '2×2 repair bay. Each room dispatches one repairman during the day.'
|
||||
};
|
||||
return flavors[id] || '';
|
||||
}
|
||||
|
||||
function buildToolIconSrc(id) {
|
||||
const icons = {
|
||||
conveyor: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png',
|
||||
boostConveyor: './assets/dark_factory/facilities/conveyor/boost_conveyor_straight_h_16.png',
|
||||
eggFarm: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory',
|
||||
autoScanner: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png',
|
||||
manualScanner: './assets/dark_factory/facilities/machines/manual_scanner_32x48.png',
|
||||
mixer: './assets/dark_factory/facilities/machines/mixer_32.png',
|
||||
trash: './assets/dark_factory/facilities/machines/shredder_32.png',
|
||||
truck: './assets/dark_factory/facilities/vehicles/truck_32.png',
|
||||
sausageMaker: './assets/dark_factory/facilities/machines/mixer_32.png',
|
||||
maintenanceRoom: './assets/dark_factory/facilities/utility/maintenance_room_32.png'
|
||||
};
|
||||
return icons[id] || '';
|
||||
}
|
||||
|
||||
export function buildToolButtonHtml(id) {
|
||||
const flavor = buildToolFlavorText(id);
|
||||
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
|
||||
const icon = buildToolIconSrc(id);
|
||||
return `${icon ? `<img class="tool-icon" src="${icon}" alt="" aria-hidden="true" />` : ''}<strong>${equipmentName(id)}</strong><span class="tool-price">${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
|
||||
}
|
||||
|
||||
export function routeLabel(dest) {
|
||||
|
|
|
|||
162
src/game.js
162
src/game.js
|
|
@ -1,9 +1,9 @@
|
|||
import { GRID, TURN_SECONDS, THEME } from './core/config.js';
|
||||
import { GRID, THEME } from './core/config.js';
|
||||
import { buildToolButtonHtml } from './core/text.js';
|
||||
import { createGame, resetLayout, newTurnStats } from './core/state.js';
|
||||
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
|
||||
import { expansionLots, lotBounds } from './core/gridExpansion.js';
|
||||
import { commitFactoryGraphForDay, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
|
||||
import { commitFactoryGraphForDay, disconnectedBuildWarnings, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
|
||||
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, settleTruckRevenue } from './systems/economy.js';
|
||||
import { undo, redo } from './systems/history.js';
|
||||
import { drawAll } from './render/draw.js';
|
||||
|
|
@ -12,7 +12,7 @@ import { createUISystem } from './systems/uiSystem.js';
|
|||
import { createChickSystem } from './systems/chickSystem.js';
|
||||
import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js';
|
||||
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
|
||||
import { rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
|
||||
import { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
|
||||
import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
|
|
@ -21,7 +21,7 @@ const ctx = canvas.getContext('2d');
|
|||
const ui = {
|
||||
shell: document.getElementById('gameShell'),
|
||||
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
|
||||
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
|
||||
eventBrief: document.getElementById('eventBrief'), speedBrief: document.getElementById('speedBrief'),
|
||||
turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
|
||||
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'),
|
||||
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
|
||||
|
|
@ -35,13 +35,14 @@ const ui = {
|
|||
zeroTimer: document.getElementById('debugZeroTimerButton'),
|
||||
cardSelect: document.getElementById('debugCardSelect'),
|
||||
targetSelect: document.getElementById('debugTargetSelect'),
|
||||
eventSelect: document.getElementById('debugEventSelect'),
|
||||
grantCard: document.getElementById('debugGrantCardButton'),
|
||||
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
|
||||
readout: document.getElementById('debugReadout')
|
||||
},
|
||||
buttons: {
|
||||
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'),
|
||||
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
|
||||
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), sausageMaker: document.getElementById('buildSausageMakerButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ function initializeStaticText() {
|
|||
mixer: 'mixer',
|
||||
trash: 'trash',
|
||||
truck: 'truck',
|
||||
sausageMaker: 'sausageMaker',
|
||||
maintenanceRoom: 'maintenanceRoom'
|
||||
};
|
||||
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
|
||||
|
|
@ -74,7 +76,7 @@ function debugCategoryForCard(card) {
|
|||
if (['safetyCover', 'preventiveMaintenance', 'durabilityCoating', 'sparePartsBin', 'laborExploitation'].includes(card.id) || tags.has('MAINTENANCE')) return 'Maintenance / Risk';
|
||||
if (['dudFilter', 'dudRefund', 'freeReroll', 'extraCards'].includes(card.id) || tags.has('CARD')) return 'Card Engine';
|
||||
if (tags.has('ULTRA RARE')) return 'Ultra Rare';
|
||||
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
|
||||
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'upgradeSausageMaker', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
|
|
@ -103,9 +105,38 @@ function initializeDebugPanel() {
|
|||
ui.debug.cardSelect.appendChild(optGroup);
|
||||
}
|
||||
ui.debug.cardSelect.addEventListener('change', refreshDebugTargetSelect);
|
||||
initializeDebugEventSelect();
|
||||
refreshDebugTargetSelect();
|
||||
}
|
||||
|
||||
function initializeDebugEventSelect() {
|
||||
if (!ui.debug?.eventSelect) return;
|
||||
ui.debug.eventSelect.innerHTML = '';
|
||||
const roll = document.createElement('option');
|
||||
roll.value = '__roll__';
|
||||
roll.textContent = 'Roll normally';
|
||||
ui.debug.eventSelect.appendChild(roll);
|
||||
const none = document.createElement('option');
|
||||
none.value = '__none__';
|
||||
none.textContent = 'No event';
|
||||
ui.debug.eventSelect.appendChild(none);
|
||||
for (const event of EVENT_TARGETS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = event.key;
|
||||
option.textContent = event.title;
|
||||
ui.debug.eventSelect.appendChild(option);
|
||||
}
|
||||
ui.debug.eventSelect.value = game.debugNextEventKey || '__roll__';
|
||||
ui.debug.eventSelect.addEventListener('change', debugSetNextEvent);
|
||||
}
|
||||
|
||||
function debugNextEventOffer() {
|
||||
const key = game.debugNextEventKey || '__roll__';
|
||||
if (key === '__none__') return null;
|
||||
if (key === '__roll__') return rollEventOffer(game);
|
||||
return createEventOffer(game, key);
|
||||
}
|
||||
|
||||
function refreshDebugTargetSelect() {
|
||||
if (!ui.debug?.targetSelect) return;
|
||||
const id = ui.debug.cardSelect?.value;
|
||||
|
|
@ -144,9 +175,23 @@ function debugSetDay() {
|
|||
updateDebugReadout(`Day set to ${day}.`);
|
||||
}
|
||||
|
||||
function debugSetNextEvent() {
|
||||
game.debugNextEventKey = ui.debug.eventSelect?.value || '__roll__';
|
||||
if (game.phase === 'build') {
|
||||
game.eventOffer = debugNextEventOffer();
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
}
|
||||
const label = ui.debug.eventSelect?.selectedOptions?.[0]?.textContent || 'Roll normally';
|
||||
updateDebugReadout(`Next event: ${label}.`);
|
||||
}
|
||||
|
||||
function debugZeroTimer() {
|
||||
const wasClearing = game.phase === 'running' && (game.timeLeft <= 0 || game.shutdownTimeLeft > 0 || game.cleanupTimer !== null);
|
||||
game.timeLeft = 0;
|
||||
if (game.phase === 'running') closeFarmShutters();
|
||||
if (game.cleanupTimer !== null) game.cleanupTimer = 0;
|
||||
if (wasClearing) game.shutdownTimeLeft = 0;
|
||||
else if (game.phase === 'running') closeFarmShutters();
|
||||
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
|
|
@ -161,7 +206,7 @@ function debugGrantSelectedCard() {
|
|||
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Failed';
|
||||
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Selected'; }, 900);
|
||||
}
|
||||
chicks?.updateCongestion?.();
|
||||
chicks?.updateCongestion?.(game.frameId || 0);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
refreshDebugTargetSelect();
|
||||
|
|
@ -171,7 +216,7 @@ function debugGrantSelectedCard() {
|
|||
function debugGrantAllCards() {
|
||||
const results = CARD_DEFS.map(card => debugGrantCard(game, card, { targetKey: '__auto__' }));
|
||||
const ok = results.filter(result => result.ok).length;
|
||||
chicks?.updateCongestion?.();
|
||||
chicks?.updateCongestion?.(game.frameId || 0);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
refreshDebugTargetSelect();
|
||||
|
|
@ -193,6 +238,7 @@ function updateDebugReadout(message = '') {
|
|||
yen(game.cash),
|
||||
`Combo ${game.manualCombo?.count || 0}`,
|
||||
`Event ${game.eventActive?.title || game.eventOffer?.title || 'none'}`,
|
||||
`Next ${ui.debug?.eventSelect?.selectedOptions?.[0]?.textContent || 'Roll normally'}`,
|
||||
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
|
||||
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
|
||||
].filter(Boolean).join(' | ');
|
||||
|
|
@ -217,7 +263,7 @@ function startGame() {
|
|||
resetCameraToFactoryStart();
|
||||
ensureMaintenanceState(game);
|
||||
uiSystem.hideModal();
|
||||
chicks.updateCongestion();
|
||||
chicks.updateCongestion(game.frameId || 0);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
updateDebugReadout();
|
||||
|
|
@ -232,6 +278,7 @@ function startNextTurn() {
|
|||
}
|
||||
const issues = facilityConnectionIssues(game);
|
||||
if (issues.length) {
|
||||
focusFirstStartIssue();
|
||||
build.fail(`Cannot start: ${issues[0]}`);
|
||||
uiSystem.updatePanels();
|
||||
return;
|
||||
|
|
@ -270,7 +317,7 @@ function startNextTurn() {
|
|||
game.groupDrag = null;
|
||||
game.selectionBox = null;
|
||||
game.pan = null;
|
||||
chicks.updateCongestion();
|
||||
chicks.updateCongestion(game.frameId || 0);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
}
|
||||
|
|
@ -320,7 +367,7 @@ function enterBuildPhase() {
|
|||
for (const scanner of game.scanners) scanner.queue = [];
|
||||
for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; }
|
||||
deactivateRepairman(game);
|
||||
game.eventOffer = rollEventOffer(game);
|
||||
game.eventOffer = debugNextEventOffer();
|
||||
if (cardSystem) cardSystem.prepareDraft();
|
||||
game.cardTargetPick = null;
|
||||
game.buildTool = null;
|
||||
|
|
@ -366,9 +413,11 @@ function update(timestamp) {
|
|||
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
|
||||
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
|
||||
game.lastTimestamp = timestamp;
|
||||
game.frameId = (game.frameId || 0) + 1;
|
||||
chicks.beginFrame?.(game.frameId);
|
||||
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||
chicks.updateCongestion();
|
||||
chicks.updateCongestion(game.frameId);
|
||||
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
|
||||
applyDebugState();
|
||||
uiSystem.updateUI();
|
||||
|
|
@ -434,6 +483,43 @@ function resetCameraToFactoryStart() {
|
|||
game.view.y = 190 - anchor.y;
|
||||
clampCamera();
|
||||
}
|
||||
|
||||
function focusCameraOnWorldPoint(point) {
|
||||
if (!point) return false;
|
||||
ensureView();
|
||||
const s = viewScale();
|
||||
game.view.x = canvas.width / 2 - point.x * s;
|
||||
game.view.y = canvas.height / 2 - point.y * s;
|
||||
clampCamera();
|
||||
return true;
|
||||
}
|
||||
|
||||
function warningFocusPoint(warning) {
|
||||
const ref = warning?.ref;
|
||||
if (!ref) return null;
|
||||
if (warning.type === 'facility') return { x: ref.x + ref.w / 2, y: ref.y + ref.h / 2 };
|
||||
if (warning.type === 'eggFarm') return cellCenter(ref.col, ref.row);
|
||||
if (warning.type === 'scanner') return { x: cellCenter(ref.col, ref.row).x + GRID.cell / 2, y: cellCenter(ref.col, ref.row).y };
|
||||
if (warning.type === 'maintenanceRoom') return { x: cellCenter(ref.col, ref.row).x + GRID.cell / 2, y: cellCenter(ref.col, ref.row).y + GRID.cell / 2 };
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusFirstStartIssue() {
|
||||
const warnings = disconnectedBuildWarnings(game);
|
||||
const warning = warnings.find(item => item.type === 'facility') || warnings[0] || fallbackStartIssue();
|
||||
const point = warningFocusPoint(warning);
|
||||
if (focusCameraOnWorldPoint(point) && warning) game.selected = { type: warning.type, id: warning.id };
|
||||
}
|
||||
|
||||
function fallbackStartIssue() {
|
||||
const facility = Object.values(game.facilities || {}).find(f => f?.entry && game.conveyorTiles.has(key(f.entry.col, f.entry.row)))
|
||||
|| Object.values(game.facilities || {})[0];
|
||||
if (facility) return { type: 'facility', id: facility.id, ref: facility };
|
||||
const scanner = game.scanners?.find(s => s);
|
||||
if (scanner) return { type: 'scanner', id: scanner.id, ref: scanner };
|
||||
const farm = game.eggFarms?.[0];
|
||||
return farm ? { type: 'eggFarm', id: farm.id, ref: farm } : null;
|
||||
}
|
||||
function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y, moved: false }; }
|
||||
function updatePan(event) {
|
||||
if (!game.pan) return;
|
||||
|
|
@ -530,7 +616,7 @@ function activeQueuedForScanner(scanner) {
|
|||
|
||||
function chickDisplay(chick) {
|
||||
if (!chick) return '<span class="scanner-current empty">WAIT</span>';
|
||||
const label = chick.sex === 'poop' ? '💩 POOP' : chick.sex === 'male' ? '♂ MALE' : '♀ FEMALE';
|
||||
const label = chick.sex === 'poop' ? 'POOP' : chick.sex === 'male' ? 'M MALE' : 'F FEMALE';
|
||||
return `<span class="scanner-current ${chick.sex}">${label}</span>`;
|
||||
}
|
||||
|
||||
|
|
@ -541,7 +627,7 @@ function miniScannerGrid(scanner, chick) {
|
|||
const inputKey = inputCell ? key(inputCell.col, inputCell.row) : null;
|
||||
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
|
||||
const activeSex = chick?.sex || '';
|
||||
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
|
||||
const activeLabel = activeSex === 'poop' ? 'P' : activeSex === 'male' ? 'M' : activeSex === 'female' ? 'F' : '';
|
||||
const cells = [];
|
||||
for (let row = scanner.row - 2; row <= scanner.row + 2; row += 1) {
|
||||
for (let col = scanner.col - 2; col <= scanner.col + 2; col += 1) {
|
||||
|
|
@ -566,10 +652,10 @@ function miniScannerGrid(scanner, chick) {
|
|||
label = 'P';
|
||||
} else if (game.conveyorTiles.has(k)) {
|
||||
classes.push('belt');
|
||||
label = '•';
|
||||
label = '.';
|
||||
} else if (game.blockedCells?.has?.(k)) {
|
||||
classes.push('blocked');
|
||||
label = '×';
|
||||
label = 'X';
|
||||
}
|
||||
cells.push(`<span class="${classes.join(' ')}">${label}</span>`);
|
||||
}
|
||||
|
|
@ -585,6 +671,26 @@ function scannerIsOffscreen(scanner) {
|
|||
|
||||
function updateManualScannerMonitor() {
|
||||
if (!ui.manualScannerMonitor) return;
|
||||
const cache = game.frameCache || (game.frameCache = {});
|
||||
const view = game.view || {};
|
||||
const signature = [
|
||||
game.phase,
|
||||
Math.round((view.x || 0) * 10),
|
||||
Math.round((view.y || 0) * 10),
|
||||
Math.round((view.scale || 1) * 100),
|
||||
game.chicks.length,
|
||||
game.scanners.map(scanner => [
|
||||
scanner.id,
|
||||
scanner.kind,
|
||||
scanner.col,
|
||||
scanner.row,
|
||||
scanner.queue?.join(',') || '',
|
||||
scanner.keyPressSide || '',
|
||||
Math.ceil((scanner.keyPressTime || 0) * 10)
|
||||
].join(':')).join('|')
|
||||
].join(';');
|
||||
if (cache.manualMonitorSignature === signature) return;
|
||||
cache.manualMonitorSignature = signature;
|
||||
if (game.phase !== 'running') {
|
||||
ui.manualScannerMonitor.classList.remove('visible');
|
||||
ui.manualScannerMonitor.innerHTML = '';
|
||||
|
|
@ -611,8 +717,8 @@ function updateManualScannerMonitor() {
|
|||
</div>
|
||||
${miniScannerGrid(scanner, chick)}
|
||||
<div class="scanner-monitor-actions">
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} → ${leftDest}</button>
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} → ${rightDest}</button>
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} -> ${leftDest}</button>
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} -> ${rightDest}</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}).join('');
|
||||
|
|
@ -631,11 +737,15 @@ ui.manualScannerMonitor?.addEventListener('click', event => {
|
|||
canvas.addEventListener('contextmenu', event => event.preventDefault());
|
||||
canvas.addEventListener('wheel', zoomAt, { passive: false });
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
if (game.phase !== 'build') return;
|
||||
if (!['build', 'running'].includes(game.phase)) return;
|
||||
if (event.button === 2 || event.button === 1 || (game.phase === 'running' && event.button === 0)) {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
startPan(event);
|
||||
return;
|
||||
}
|
||||
if (game.phase !== 'build' || event.button !== 0) return;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const world = canvasPoint(event);
|
||||
if (event.button === 2) { startPan(event); return; }
|
||||
if (event.button !== 0) return;
|
||||
if (game.cardTargetPick?.mode === 'autoScannerMenu') { cancelBuildAction(); return; }
|
||||
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
|
||||
const expansionOffer = build.expansionOfferAtPoint?.(world);
|
||||
|
|
@ -669,9 +779,9 @@ canvas.addEventListener('pointerdown', event => {
|
|||
build.startSelectionBox(event);
|
||||
});
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (game.pan && (event.buttons & 7)) updatePan(event);
|
||||
if (game.phase !== 'build') { hideHoverTooltip(); return; }
|
||||
updateHover(event);
|
||||
if (game.pan && (event.buttons & 2)) updatePan(event);
|
||||
if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
|
||||
if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event);
|
||||
if ((game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
|
||||
|
|
@ -683,7 +793,7 @@ canvas.addEventListener('pointerup', event => {
|
|||
if (!game.groupDrag.committed) clickSelect(event);
|
||||
build.finishGroupDrag();
|
||||
}
|
||||
if (event.button === 2 && game.pan && !game.pan.moved && game.buildTool) cancelBuildAction();
|
||||
if ((event.button === 2 || event.button === 1) && game.pan && !game.pan.moved && game.buildTool && game.phase === 'build') cancelBuildAction();
|
||||
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') build.endConveyorDrag?.();
|
||||
game.groupDrag = null; game.pan = null;
|
||||
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
|
||||
|
|
@ -697,7 +807,7 @@ document.addEventListener('pointerdown', event => {
|
|||
}, true);
|
||||
|
||||
ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right'));
|
||||
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
|
||||
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.sausageMaker.addEventListener('click', () => build.setBuildTool('sausageMaker')); ui.buttons.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
|
||||
ui.debug.setDay?.addEventListener('click', debugSetDay);
|
||||
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
|
||||
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
|
||||
|
|
@ -733,4 +843,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () =>
|
|||
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
|
||||
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
|
||||
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick });
|
||||
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);
|
||||
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(game.frameId || 0); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
|
||||
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
|
||||
import { key, parseKey, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
|
||||
import { scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
|
||||
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
|
||||
import { cardTargetBounds } from '../systems/cards.js';
|
||||
import { wearRatio } from '../systems/maintenance.js';
|
||||
import { scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, facilityInputCell, facilityOutputCell, facilityOutputHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
|
||||
import { upgradedTruckPrice, upgradedSausagePrice } from '../systems/economy.js';
|
||||
import { cardTargetBounds, conveyorSpeedForGame } from '../systems/cards.js';
|
||||
import { wearRatio, conveyorSpeedFactorForKey } from '../systems/maintenance.js';
|
||||
import { facilityBodyPortPoint } from '../core/entities.js';
|
||||
|
||||
// Optional art overlay hook.
|
||||
// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs.
|
||||
|
|
@ -33,9 +34,6 @@ const ASSET_PATHS = {
|
|||
mixer: './assets/dark_factory/facilities/machines/mixer_32.png',
|
||||
shredder: './assets/dark_factory/facilities/machines/shredder_32.png',
|
||||
truck: './assets/dark_factory/facilities/vehicles/truck_32.png',
|
||||
mixerCompact: './assets/dark_factory/facilities/machines/mixer_compact_16.png',
|
||||
shredderCompact: './assets/dark_factory/facilities/machines/shredder_compact_16.png',
|
||||
truckCompact: './assets/dark_factory/facilities/vehicles/truck_compact_16.png',
|
||||
maintenanceRoom: './assets/dark_factory/facilities/utility/maintenance_room_32.png',
|
||||
repairman: './assets/dark_factory/facilities/utility/repairman_16.png',
|
||||
obstacleCrate: './assets/dark_factory/facilities/utility/obstacle_crate_16.png'
|
||||
|
|
@ -55,10 +53,23 @@ for (const [name, src] of Object.entries(ASSET_PATHS)) {
|
|||
|
||||
// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes.
|
||||
|
||||
function drawCache(game) {
|
||||
if (!game.frameCache) game.frameCache = {};
|
||||
if (!game.frameCache.draw) game.frameCache.draw = {};
|
||||
return game.frameCache.draw;
|
||||
}
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = Math.max(1, Math.ceil(width));
|
||||
c.height = Math.max(1, Math.ceil(height));
|
||||
return c;
|
||||
}
|
||||
|
||||
export function drawAll(ctx, canvas, game, helpers) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawBackground(ctx, canvas);
|
||||
drawBackground(ctx, canvas, game);
|
||||
ctx.save();
|
||||
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
|
||||
const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
|
||||
|
|
@ -190,7 +201,7 @@ function drawFloorSlime(ctx, x, y, col, row) {
|
|||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawBackground(ctx, canvas) {
|
||||
function renderBackground(ctx, canvas) {
|
||||
ctx.fillStyle = THEME.bg;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
|
|
@ -208,6 +219,18 @@ function drawBackground(ctx, canvas) {
|
|||
for (let y = 0; y < canvas.height; y += 64) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBackground(ctx, canvas, game = null) {
|
||||
const cache = game ? drawCache(game) : null;
|
||||
const signature = `${canvas.width}x${canvas.height}`;
|
||||
if (!cache) return renderBackground(ctx, canvas);
|
||||
if (!cache.background || cache.background.signature !== signature) {
|
||||
const layer = makeCanvas(canvas.width, canvas.height);
|
||||
renderBackground(layer.getContext('2d'), layer);
|
||||
cache.background = { signature, layer };
|
||||
}
|
||||
ctx.drawImage(cache.background.layer, 0, 0);
|
||||
}
|
||||
function ownedCellKeys(game) {
|
||||
if (game.ownedCells?.size) return [...game.ownedCells];
|
||||
const cells = [];
|
||||
|
|
@ -215,7 +238,7 @@ function ownedCellKeys(game) {
|
|||
return cells;
|
||||
}
|
||||
|
||||
function drawGrid(ctx, game) {
|
||||
function renderGrid(ctx, game) {
|
||||
ctx.save();
|
||||
const cells = ownedCellKeys(game);
|
||||
for (const k of cells) {
|
||||
|
|
@ -253,17 +276,17 @@ function drawGrid(ctx, game) {
|
|||
ctx.beginPath();
|
||||
ctx.rect(Math.round(x + 1), Math.round(y + 1), GRID.cell - 2, GRID.cell - 2);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = 'rgba(70,72,76,.16)';
|
||||
ctx.fillStyle = 'rgba(124,82,36,.36)';
|
||||
ctx.fillRect(Math.round(x), Math.round(y), GRID.cell, GRID.cell);
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = 'rgba(165,145,73,.24)';
|
||||
ctx.strokeStyle = 'rgba(255,199,73,.72)';
|
||||
for (let stripe = -GRID.cell; stripe < GRID.cell * 2; stripe += 14) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + stripe, y + GRID.cell);
|
||||
ctx.lineTo(x + stripe + GRID.cell, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(27,29,33,.22)';
|
||||
ctx.strokeStyle = 'rgba(18,20,24,.58)';
|
||||
for (let stripe = -GRID.cell + 7; stripe < GRID.cell * 2; stripe += 14) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + stripe, y + GRID.cell);
|
||||
|
|
@ -271,21 +294,70 @@ function drawGrid(ctx, game) {
|
|||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(20,23,26,.42)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255,213,91,.86)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(Math.round(x) + 1.5, Math.round(y) + 1.5, GRID.cell - 3, GRID.cell - 3);
|
||||
ctx.strokeStyle = 'rgba(20,23,26,.82)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(Math.round(x) + 5.5, Math.round(y) + 5.5, GRID.cell - 11, GRID.cell - 11);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function gridCacheSignature(game) {
|
||||
const owned = game.ownedCells?.size ? [...game.ownedCells].sort().join('|') : `default:${GRID.cols}x${GRID.rows}`;
|
||||
const blocked = [...(game.blockedCells || [])].sort().join('|');
|
||||
return `${GRID.x},${GRID.y},${GRID.cols},${GRID.rows};${owned};${blocked}`;
|
||||
}
|
||||
|
||||
function gridLayerBounds(game) {
|
||||
const cells = ownedCellKeys(game).map(parseKey);
|
||||
for (const k of game.blockedCells || []) cells.push(parseKey(k));
|
||||
if (!cells.length) return { x: 0, y: 0, w: 1, h: 1 };
|
||||
const minCol = Math.min(...cells.map(p => p.col));
|
||||
const maxCol = Math.max(...cells.map(p => p.col));
|
||||
const minRow = Math.min(...cells.map(p => p.row));
|
||||
const maxRow = Math.max(...cells.map(p => p.row));
|
||||
return {
|
||||
x: GRID.x + minCol * GRID.cell - 6,
|
||||
y: GRID.y + minRow * GRID.cell - 6,
|
||||
w: (maxCol - minCol + 1) * GRID.cell + 12,
|
||||
h: (maxRow - minRow + 1) * GRID.cell + 12
|
||||
};
|
||||
}
|
||||
|
||||
function drawGrid(ctx, game) {
|
||||
const cache = drawCache(game);
|
||||
const signature = gridCacheSignature(game);
|
||||
if (!cache.grid || cache.grid.signature !== signature) {
|
||||
const bounds = gridLayerBounds(game);
|
||||
const layer = makeCanvas(bounds.w, bounds.h);
|
||||
const layerCtx = layer.getContext('2d');
|
||||
layerCtx.translate(-bounds.x, -bounds.y);
|
||||
renderGrid(layerCtx, game);
|
||||
cache.grid = { signature, bounds, layer };
|
||||
}
|
||||
const { bounds, layer } = cache.grid;
|
||||
ctx.drawImage(layer, bounds.x, bounds.y);
|
||||
}
|
||||
function drawExpansionOffers(ctx, game) {
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return;
|
||||
const cost = expansionCost(game);
|
||||
const affordable = game.cash >= cost;
|
||||
const lotBlockedByFacility = lot => {
|
||||
const b = lotBounds(lot);
|
||||
return Object.values(game.facilities || {}).some(f => {
|
||||
const kind = f?.baseId || f?.id;
|
||||
if (!['mixer', 'trash', 'truck'].includes(kind)) return false;
|
||||
return !(b.x + b.w <= f.x || f.x + f.w <= b.x || b.y + b.h <= f.y || f.y + f.h <= b.y);
|
||||
});
|
||||
};
|
||||
for (const lot of expansionLots(game)) {
|
||||
const disabled = lotBlockedByFacility(lot);
|
||||
const b = lotBounds(lot);
|
||||
ctx.save();
|
||||
ctx.fillStyle = affordable ? 'rgba(183,245,198,.34)' : 'rgba(255,255,255,.22)';
|
||||
ctx.strokeStyle = affordable ? THEME.green : THEME.muted;
|
||||
ctx.fillStyle = disabled ? 'rgba(112,130,118,.34)' : (affordable ? 'rgba(118,226,142,.36)' : 'rgba(255,255,255,.18)');
|
||||
ctx.strokeStyle = disabled ? '#708278' : (affordable ? '#6bd579' : THEME.muted);
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([16, 10]);
|
||||
rect(ctx, b.x, b.y, b.w, b.h, true, true);
|
||||
|
|
@ -297,17 +369,16 @@ function drawExpansionOffers(ctx, game) {
|
|||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 4;
|
||||
rect(ctx, button.x, button.y, button.w, button.h, true, true);
|
||||
ctx.fillStyle = affordable ? THEME.green : THEME.danger;
|
||||
ctx.fillStyle = disabled ? '#7c9285' : (affordable ? THEME.green : THEME.danger);
|
||||
ctx.font = '900 16px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const lotLabel = lot.direction === 'right' ? 'BUY RIGHT LOT' : (lot.direction === 'down' ? 'BUY LOWER LOT' : 'BUY UPPER LOT');
|
||||
const lotLabel = lot.direction === 'left'
|
||||
? 'BUY LEFT LOT'
|
||||
: (lot.direction === 'right' ? 'BUY RIGHT LOT' : (lot.direction === 'down' ? 'BUY LOWER LOT' : 'BUY UPPER LOT'));
|
||||
ctx.fillText(lotLabel, cx, cy - 12);
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 16px ui-monospace, monospace';
|
||||
ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15);
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
ctx.fillText('blocked tiles included', cx, cy + 30);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
|
@ -352,7 +423,35 @@ function conveyorAssetForTile(game, k, meta = {}) {
|
|||
if (dirs.length === 1) return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
|
||||
return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
|
||||
}
|
||||
function drawConveyors(ctx, game) {
|
||||
|
||||
function conveyorCacheSignature(game) {
|
||||
const assetsReady = Object.entries(assets)
|
||||
.filter(([name]) => name.includes('conveyor') || name.includes('Conveyor'))
|
||||
.map(([name, img]) => `${name}:${img.loaded ? 1 : 0}`)
|
||||
.join('|');
|
||||
const tiles = [...game.conveyorTiles].sort().map(k => {
|
||||
const meta = game.conveyorMeta.get(k) || {};
|
||||
return `${k}:${meta.kind || ''}:${meta.dir || ''}:${(meta.outDirs || []).join(',')}:${meta.branchMode || ''}`;
|
||||
}).join('|');
|
||||
return `${game.routingVersion || 0};${assetsReady};${tiles}`;
|
||||
}
|
||||
|
||||
function conveyorLayerBounds(game) {
|
||||
if (!game.conveyorTiles?.size) return { x: 0, y: 0, w: 1, h: 1 };
|
||||
const cells = [...game.conveyorTiles].map(parseKey);
|
||||
const minCol = Math.min(...cells.map(p => p.col));
|
||||
const maxCol = Math.max(...cells.map(p => p.col));
|
||||
const minRow = Math.min(...cells.map(p => p.row));
|
||||
const maxRow = Math.max(...cells.map(p => p.row));
|
||||
return {
|
||||
x: GRID.x + minCol * GRID.cell - 36,
|
||||
y: GRID.y + minRow * GRID.cell - 36,
|
||||
w: (maxCol - minCol + 1) * GRID.cell + 72,
|
||||
h: (maxRow - minRow + 1) * GRID.cell + 72
|
||||
};
|
||||
}
|
||||
|
||||
function conveyorRenderData(game) {
|
||||
const edges = [];
|
||||
const seen = new Set();
|
||||
for (const k of game.conveyorTiles) {
|
||||
|
|
@ -364,64 +463,122 @@ function drawConveyors(ctx, game) {
|
|||
const e = [k, nk].sort().join('|');
|
||||
if (seen.has(e)) continue;
|
||||
seen.add(e);
|
||||
const ratio = Math.max(componentRatio(game, k), componentRatio(game, nk));
|
||||
edges.push({ a: cellCenter(col, row), b: cellCenter(n.col, n.row), ratio });
|
||||
edges.push({ fromKey: k, toKey: nk, a: cellCenter(col, row), b: cellCenter(n.col, n.row) });
|
||||
}
|
||||
}
|
||||
const directionMarkers = collectConveyorDirectionMarkers(game);
|
||||
const tiles = [...game.conveyorTiles].map(k => {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const meta = game.conveyorMeta.get(k) || {};
|
||||
const connectedDirs = connectedConveyorDirs(game, k);
|
||||
return {
|
||||
k,
|
||||
c,
|
||||
meta,
|
||||
connectedDirs,
|
||||
branchDirs: branchSelectableExitDirs(game, k, connectedDirs),
|
||||
markers: directionMarkers.get(k) || [],
|
||||
sprite: conveyorAssetForTile(game, k, meta)
|
||||
};
|
||||
});
|
||||
return { edges, tiles };
|
||||
}
|
||||
|
||||
function renderConveyorBase(ctx, data) {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
for (const layer of [
|
||||
{ width: 31, color: '#0b0c0f', alpha: 1 },
|
||||
{ width: 23, color: '#444b56', alpha: 1 },
|
||||
{ width: 15, color: '#171a1f', alpha: 1 }
|
||||
]) {
|
||||
ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
|
||||
for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
|
||||
ctx.globalAlpha = layer.alpha;
|
||||
ctx.strokeStyle = layer.color;
|
||||
ctx.lineWidth = layer.width;
|
||||
for (const e of data.edges) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.a.x, e.a.y);
|
||||
ctx.lineTo(e.b.x, e.b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
const t = Math.max(0, (e.ratio - 0.5) / 0.5);
|
||||
ctx.globalAlpha = 1;
|
||||
for (const tile of data.tiles) {
|
||||
const { c, meta, sprite } = tile;
|
||||
ctx.fillStyle = 'rgba(0,0,0,.35)';
|
||||
ctx.strokeStyle = 'rgba(255,184,39,.34)';
|
||||
ctx.lineWidth = 1;
|
||||
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
|
||||
if (!drawImageIfLoaded(ctx, sprite, c.x - 19, c.y - 19, 38, 38)) {
|
||||
ctx.fillStyle = meta.kind === 'boostConveyor' ? '#315e92' : '#454b55';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 18, c.y - 18, 36, 36, true, true);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawConveyors(ctx, game) {
|
||||
const cache = drawCache(game);
|
||||
const signature = conveyorCacheSignature(game);
|
||||
if (!cache.conveyors || cache.conveyors.signature !== signature) {
|
||||
const data = conveyorRenderData(game);
|
||||
const bounds = conveyorLayerBounds(game);
|
||||
const layer = makeCanvas(bounds.w, bounds.h);
|
||||
const layerCtx = layer.getContext('2d');
|
||||
layerCtx.translate(-bounds.x, -bounds.y);
|
||||
renderConveyorBase(layerCtx, data);
|
||||
cache.conveyors = { signature, bounds, layer, data };
|
||||
}
|
||||
const { bounds, layer, data } = cache.conveyors;
|
||||
ctx.drawImage(layer, bounds.x, bounds.y);
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
for (const e of data.edges) {
|
||||
const ratio = Math.max(componentRatio(game, e.fromKey), componentRatio(game, e.toKey));
|
||||
const t = Math.max(0, (ratio - 0.5) / 0.5);
|
||||
ctx.strokeStyle = mixHex(THEME.green, THEME.danger, t);
|
||||
ctx.globalAlpha = e.ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
|
||||
ctx.globalAlpha = ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
|
||||
}
|
||||
const directionMarkers = collectConveyorDirectionMarkers(game);
|
||||
for (const k of game.conveyorTiles) {
|
||||
const c = cellCenter(...Object.values(parseKey(k)));
|
||||
const meta = game.conveyorMeta.get(k) || {};
|
||||
ctx.globalAlpha = 1;
|
||||
for (const tile of data.tiles) {
|
||||
const { k, c, meta } = tile;
|
||||
const ratio = componentRatio(game, k);
|
||||
const sprite = conveyorAssetForTile(game, k, meta);
|
||||
const congested = ratio > 0.5;
|
||||
ctx.fillStyle = congested ? 'rgba(196,36,36,.28)' : 'rgba(0,0,0,.35)';
|
||||
ctx.strokeStyle = congested ? THEME.danger : 'rgba(255,184,39,.34)';
|
||||
ctx.lineWidth = congested ? 3 : 1;
|
||||
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
|
||||
if (!drawImageIfLoaded(ctx, sprite, c.x - 19, c.y - 19, 38, 38)) {
|
||||
const baseFill = meta.kind === 'boostConveyor' ? '#315e92' : '#454b55';
|
||||
ctx.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#a12c2c', Math.max(0, (ratio - .5) / .5)) : baseFill;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 18, c.y - 18, 36, 36, true, true);
|
||||
if (ratio > 0.5) {
|
||||
ctx.fillStyle = 'rgba(196,36,36,.28)';
|
||||
ctx.strokeStyle = THEME.danger;
|
||||
ctx.lineWidth = 3;
|
||||
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
|
||||
}
|
||||
drawAnimatedConveyorStripes(ctx, c, k, meta, ratio);
|
||||
drawAnimatedConveyorStripes(ctx, game, c, k, meta, ratio);
|
||||
drawDirtOverlay(ctx, c.x - 18, c.y - 18, 36, 36, { meta });
|
||||
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
|
||||
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
|
||||
const connectedDirs = connectedConveyorDirs(game, k);
|
||||
drawBranchModeIcon(ctx, c, meta, branchSelectableExitDirs(game, k, connectedDirs), connectedDirs);
|
||||
drawConveyorDirectionMarkers(ctx, c, tile.markers);
|
||||
drawBranchModeIcon(ctx, c, meta, tile.branchDirs, tile.connectedDirs);
|
||||
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
function drawAnimatedConveyorStripes(ctx, center, tileKey, meta = {}, ratio = 0) {
|
||||
export function drawAnimatedConveyorStripes(ctx, game, center, tileKey, meta = {}, ratio = 0) {
|
||||
const boost = meta.kind === 'boostConveyor';
|
||||
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
|
||||
const primary = names[0] || 'right';
|
||||
const verticalTravel = primary === 'up' || primary === 'down';
|
||||
const reverse = primary === 'left' || primary === 'up';
|
||||
const boost = meta.kind === 'boostConveyor';
|
||||
const t = typeof performance !== 'undefined' ? performance.now() / (boost ? 70 : 105) : 0;
|
||||
const phase = ((reverse ? -t : t) % 10 + 10) % 10;
|
||||
// Stripe travel uses the same effective px/s as chicks on this exact tile,
|
||||
// including Boost x2, card upgrades, events, and wear. This keeps the visual
|
||||
// belt movement synchronized with item movement instead of using a fixed timer.
|
||||
const effectiveSpeed = conveyorSpeedForGame(game, tileKey) * conveyorSpeedFactorForKey(game, tileKey);
|
||||
const elapsedSeconds = typeof performance !== 'undefined' ? performance.now() / 1000 : 0;
|
||||
const travel = elapsedSeconds * effectiveSpeed;
|
||||
const phase = ((reverse ? -travel : travel) % 10 + 10) % 10;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(center.x - 18, center.y - 18, 36, 36);
|
||||
|
|
@ -471,6 +628,7 @@ function reachableConveyorDirs(game, k, meta) {
|
|||
.map(d => d.name);
|
||||
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
|
||||
.filter(name => neighborNames.includes(name));
|
||||
if (neighborNames.length === 1 && (explicit.length ? explicit.includes(neighborNames[0]) : true) && neighborPointsIntoCell(game, p, neighborNames[0])) return [];
|
||||
const inferred = neighborNames;
|
||||
return explicit.length ? explicit : inferred;
|
||||
}
|
||||
|
|
@ -496,7 +654,7 @@ function branchExitCandidates(game, k, dirs, connectedDirs = connectedConveyorDi
|
|||
const p = parseKey(k);
|
||||
const physicalEntrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir)));
|
||||
const physicalExits = connectedDirs.filter(dir => !physicalEntrances.has(dir));
|
||||
if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
|
||||
if (physicalEntrances.size) return physicalExits;
|
||||
const explicit = dirs.filter(dir => connectedDirs.includes(dir));
|
||||
if (explicit.length >= 2) return [...new Set(explicit)];
|
||||
return connectedDirs;
|
||||
|
|
@ -521,6 +679,7 @@ function effectiveConveyorDirs(game, k, meta) {
|
|||
.map(d => d.name);
|
||||
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
|
||||
.filter(name => neighborNames.includes(name));
|
||||
if (neighborNames.length === 1 && (explicit.length ? explicit.includes(neighborNames[0]) : true) && neighborPointsIntoCell(game, p, neighborNames[0])) return [];
|
||||
const inferred = neighborNames;
|
||||
return explicit.length ? explicit : inferred;
|
||||
}
|
||||
|
|
@ -542,7 +701,7 @@ function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
|
|||
ctx.fillStyle = '#020304';
|
||||
if (mode !== 'random') {
|
||||
const d = DIRS.find(item => item.name === mode);
|
||||
drawDirectionTriangle(ctx, center.x, center.y, d?.angle || 0, 9, 6);
|
||||
drawDirectionTriangle(ctx, center.x, center.y - 4, d?.angle || 0, 8, 5);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(center.x - 10, center.y);
|
||||
|
|
@ -554,6 +713,16 @@ function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
|
|||
drawDirectionTriangle(ctx, center.x + 9, center.y - 9, -Math.PI / 4, 4, 3);
|
||||
drawDirectionTriangle(ctx, center.x + 9, center.y + 9, Math.PI / 4, 4, 3);
|
||||
}
|
||||
if (mode === 'random') {
|
||||
ctx.font = '900 9px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#020304';
|
||||
ctx.fillStyle = THEME.warn;
|
||||
ctx.strokeText('RND', center.x, center.y + 11);
|
||||
ctx.fillText('RND', center.x, center.y + 11);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
@ -604,9 +773,9 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
|
|||
function drawScannerPortDuct(ctx, scanner, type, p, color) {
|
||||
const c = scannerCenter(scanner);
|
||||
let target = null;
|
||||
if (type === 'inputA') target = { x: c.x, y: c.y - 67 };
|
||||
if (type === 'left') target = { x: c.x - 49, y: c.y + 14 };
|
||||
if (type === 'right') target = { x: c.x + 49, y: c.y + 14 };
|
||||
if (type === 'inputA') target = { x: c.x, y: c.y - 40 };
|
||||
if (type === 'left') target = { x: c.x - 42, y: c.y + 10 };
|
||||
if (type === 'right') target = { x: c.x + 42, y: c.y + 10 };
|
||||
if (!target) return;
|
||||
const elbow = type === 'inputA'
|
||||
? { x: p.x, y: target.y }
|
||||
|
|
@ -716,42 +885,39 @@ function drawScanner(ctx, scanner, game) {
|
|||
ctx.save();
|
||||
ctx.globalAlpha = Math.min(0.55, 0.16 + combo / 120);
|
||||
ctx.strokeStyle = combo >= 30 ? THEME.warn : THEME.green;
|
||||
ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10));
|
||||
ctx.lineWidth = Math.min(12, 4 + Math.floor(combo / 10));
|
||||
ctx.shadowColor = ctx.strokeStyle;
|
||||
ctx.shadowBlur = Math.min(28, 8 + combo);
|
||||
rect(ctx, c.x - 54, c.y - 76, 108, 152, false, true);
|
||||
ctx.shadowBlur = Math.min(24, 8 + combo);
|
||||
rect(ctx, c.x - 48, c.y - 48, 96, 96, false, true);
|
||||
ctx.restore();
|
||||
}
|
||||
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
|
||||
if (drawImageIfLoaded(ctx, img, c.x - 50, c.y - 73, 100, 146)) {
|
||||
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
|
||||
if (drawImageIfLoaded(ctx, img, c.x - 44, c.y - 44, 88, 88)) {
|
||||
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 44, c.y - 44, 88, 88, scanner);
|
||||
else drawManualKeyboardIcon(ctx, scanner, c);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 96, 96);
|
||||
ctx.restore(); return;
|
||||
}
|
||||
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
|
||||
rect(ctx, c.x - 50, c.y - 73, 100, 146, true, true);
|
||||
rect(ctx, c.x - 44, c.y - 44, 88, 88, true, true);
|
||||
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
|
||||
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 48);
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
ctx.font = '900 16px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText(scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`, c.x, c.y - 24);
|
||||
ctx.font = '900 9px ui-monospace, monospace';
|
||||
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
|
||||
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
|
||||
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y - 28);
|
||||
ctx.fillText(scanner.kind === 'auto' ? '2.1 FARMS' : `${leftKey}/${rightKey}`, c.x, c.y - 8);
|
||||
const q = scanner.queue.length;
|
||||
if (scanner.kind === 'manual') {
|
||||
drawManualKeyboardIcon(ctx, scanner, c);
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`Q:${q}`, c.x, c.y + 54);
|
||||
} else {
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 54);
|
||||
drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 35);
|
||||
drawDirtOverlay(ctx, c.x - 44, c.y - 44, 88, 88, scanner);
|
||||
}
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 96, 96);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
@ -765,7 +931,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
|
|||
labels[1].text = scanner.keys?.right?.label || labels[1].text;
|
||||
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
|
||||
const baseX = c.x - 30;
|
||||
const baseY = c.y + 18;
|
||||
const baseY = c.y + 8;
|
||||
ctx.save();
|
||||
ctx.lineWidth = 3;
|
||||
ctx.fillStyle = '#f7fff5';
|
||||
|
|
@ -800,6 +966,8 @@ function targetForTruck(game) {
|
|||
}
|
||||
function drawItemIcon(ctx, x, y, type) {
|
||||
if (type === 'poop') drawTinyPoop(ctx, x, y);
|
||||
else if (type === 'meat') drawMeatItem(ctx, x, y, 9);
|
||||
else if (type === 'sausage') drawSausageItem(ctx, x, y, 10);
|
||||
else drawTinyChick(ctx, x, y, type, false);
|
||||
}
|
||||
function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
|
||||
|
|
@ -823,6 +991,7 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
|
|||
function receiverTitle(id, game) {
|
||||
if (id === 'mixer') return { title: 'IN: MALE', type: 'male', color: THEME.mixerBlue };
|
||||
if (id === 'trash') return { title: 'IN: POOP', type: 'poop', color: THEME.wasteGreen };
|
||||
if (id === 'sausageMaker') return { title: 'IN: MEAT', type: 'meat', color: THEME.sausageOrange };
|
||||
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
|
||||
return { title: 'IN', type: 'female', color: THEME.green };
|
||||
}
|
||||
|
|
@ -831,10 +1000,22 @@ function facilityKind(fOrId) {
|
|||
}
|
||||
function drawFacilityReceiver(ctx, game, facility) {
|
||||
const f = typeof facility === 'string' ? game.facilities[facility] : facility;
|
||||
if (!f?.entry) return;
|
||||
const input = facilityInputCell(game, f);
|
||||
if (!f || !input) return;
|
||||
const id = facilityKind(f);
|
||||
const c = cellCenter(f.entry.col, f.entry.row);
|
||||
const c = cellCenter(input.col, input.row);
|
||||
const info = receiverTitle(id, game);
|
||||
if (f.gridPlaced) {
|
||||
const bodyPort = facilityBodyPortPoint(f, 'input');
|
||||
if (bodyPort) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#07080a'; ctx.lineWidth = 11; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bodyPort.x, bodyPort.y); ctx.stroke();
|
||||
ctx.strokeStyle = '#6f4a43'; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bodyPort.x, bodyPort.y); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#191c22';
|
||||
ctx.strokeStyle = info.color;
|
||||
|
|
@ -940,17 +1121,56 @@ function drawFacilities(ctx, game) {
|
|||
if (kind === 'mixer') drawMixer(ctx, game, f);
|
||||
else if (kind === 'trash') drawTrash(ctx, game, f);
|
||||
else if (kind === 'truck') drawTruck(ctx, game, f);
|
||||
else if (kind === 'sausageMaker') drawSausageMaker(ctx, game, f);
|
||||
}
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
drawFacilityReceiver(ctx, game, f);
|
||||
drawFacilityOutput(ctx, game, f);
|
||||
}
|
||||
for (const f of Object.values(game.facilities || {})) drawFacilityReceiver(ctx, game, f);
|
||||
}
|
||||
function drawFacilityOutput(ctx, game, f) {
|
||||
const p = facilityOutputCell(game, f);
|
||||
if (!p) return;
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const bodyPort = facilityBodyPortPoint(f, 'output');
|
||||
if (bodyPort) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#07080a';
|
||||
ctx.lineWidth = 11;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(bodyPort.x, bodyPort.y); ctx.lineTo(c.x, c.y); ctx.stroke();
|
||||
ctx.strokeStyle = '#77524a'; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(bodyPort.x, bodyPort.y); ctx.lineTo(c.x, c.y); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
const kind = facilityKind(f);
|
||||
const product = kind === 'mixer' ? 'meat' : 'sausage';
|
||||
const color = kind === 'mixer' ? THEME.meatRed : THEME.sausageOrange;
|
||||
const connected = facilityOutputHasConveyor(game, f);
|
||||
ctx.save();
|
||||
ctx.fillStyle = connected ? '#1d2423' : '#3a1e22';
|
||||
ctx.strokeStyle = connected ? color : THEME.danger;
|
||||
ctx.lineWidth = 5;
|
||||
rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
|
||||
drawItemIcon(ctx, c.x, c.y - 3, product);
|
||||
ctx.fillStyle = THEME.white;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('OUT', c.x, c.y + 20);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawExternalDuct(ctx, f) {
|
||||
if (!f?.entry) return;
|
||||
if (!f?.entry || f.gridPlaced) return;
|
||||
const c = cellCenter(f.entry.col, f.entry.row);
|
||||
let bx = f.x + f.w / 2, by = f.y + f.h / 2;
|
||||
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
|
||||
else if (f.side === 'right') { bx = f.x; by = c.y; }
|
||||
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
|
||||
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
|
||||
const bodyPort = facilityBodyPortPoint(f, 'input');
|
||||
let bx = bodyPort?.x ?? (f.x + f.w / 2), by = bodyPort?.y ?? (f.y + f.h / 2);
|
||||
if (facilityKind(f) !== 'mixer') {
|
||||
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
|
||||
else if (f.side === 'right') { bx = f.x; by = c.y; }
|
||||
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
|
||||
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
|
||||
}
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#07080a';
|
||||
ctx.lineWidth = 11;
|
||||
|
|
@ -968,7 +1188,7 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
|
|||
ctx.fillStyle = THEME.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
|
||||
ctx.fillText(`MIXER L${m.level}`, m.x + 14, m.y + 18);
|
||||
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + 14, m.y + 34);
|
||||
ctx.fillText(`MEAT ${m.meatBuffer || 0} | SAUSAGE ${yen(upgradedSausagePrice(game))}`, m.x + 14, m.y + 34);
|
||||
ctx.fillStyle = '#1a2028'; ctx.strokeStyle = '#87bfff'; ctx.lineWidth = 4;
|
||||
rect(ctx, m.x + 44, m.y + 34, 76, 50, true, true);
|
||||
ctx.strokeStyle = '#4aa3ff'; ctx.lineWidth = 3;
|
||||
|
|
@ -979,7 +1199,7 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
|
|||
rect(ctx, m.x + 56, m.y + 24, 12, 10, true, true);
|
||||
rect(ctx, m.x + 96, m.y + 24, 12, 10, true, true);
|
||||
ctx.fillStyle = '#7ea9dc'; ctx.fillRect(m.x + 48, m.y + 42, 68, 5);
|
||||
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
|
||||
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'CHICK -> MEAT', 'meat', 'connect OUT to sausage machine');
|
||||
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
|
||||
drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
|
||||
ctx.restore();
|
||||
|
|
@ -1013,6 +1233,30 @@ function drawTrash(ctx, game, t = game.facilities.trash) {
|
|||
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawSausageMaker(ctx, game, s) {
|
||||
ctx.save();
|
||||
drawMetalPanel(ctx, s.x, s.y, s.w, s.h, '#3b3029', THEME.ink, THEME.sausageOrange);
|
||||
drawFacilityDetails(ctx, s, THEME.sausageOrange);
|
||||
ctx.fillStyle = THEME.white;
|
||||
ctx.font = '900 14px ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`SAUSAGE L${s.level}`, s.x + 14, s.y + 18);
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`2 MEAT -> 1 | ${yen(upgradedSausagePrice(game))}`, s.x + 14, s.y + 34);
|
||||
ctx.fillStyle = '#211915';
|
||||
ctx.strokeStyle = THEME.sausageOrange;
|
||||
ctx.lineWidth = 4;
|
||||
rect(ctx, s.x + 20, s.y + 43, s.w - 40, 38, true, true);
|
||||
for (let i = 0; i < 3; i += 1) drawSausageItem(ctx, s.x + s.w / 2 - 28 + i * 28, s.y + 62, 9);
|
||||
ctx.fillStyle = THEME.meatRed; ctx.fillRect(s.x, s.y + s.h / 2 - 8, 8, 16);
|
||||
ctx.fillStyle = THEME.sausageOrange; ctx.fillRect(s.x + s.w - 8, s.y + s.h / 2 - 8, 8, 16);
|
||||
drawTargetBadge(ctx, s.x + 10, s.y + s.h - 42, s.w - 20, '2 MEAT -> SAUSAGE', 'sausage', `buffer ${s.meatBuffer || 0}/2`);
|
||||
drawDirtOverlay(ctx, s.x, s.y, s.w, s.h, s);
|
||||
drawSelection(ctx, game, 'facility', s.id, s.x + s.w / 2, s.y + s.h / 2, s.w + 12, s.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTruck(ctx, game, t = game.facilities.truck) {
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, t);
|
||||
|
|
@ -1022,11 +1266,15 @@ function drawTruck(ctx, game, t = game.facilities.truck) {
|
|||
ctx.fillText(`TRUCK L${t.level}`, t.x + 14, t.y + 18);
|
||||
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
|
||||
drawFacilityTexture(ctx, assets.truckCompact.loaded ? assets.truckCompact : assets.truck, t, 2.0);
|
||||
drawFacilityTexture(ctx, assets.truck, t, 2.0);
|
||||
const truckTargetType = targetForTruck(game);
|
||||
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.eventOffer && !game.eventActive ? 'next event' : 'truck cargo');
|
||||
ctx.fillStyle = 'rgba(39,60,36,.9)'; rect(ctx, t.x + 13, t.y + 88, t.w - 26, Math.max(18, t.h - 103), true, false);
|
||||
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
|
||||
for (const cargo of game.truckCargo) {
|
||||
if (cargo.sex === 'poop') drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y);
|
||||
else if (cargo.sex === 'sausage') drawSausageItem(ctx, t.x + cargo.x, t.y + cargo.y, 7);
|
||||
else drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
|
||||
}
|
||||
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
|
||||
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||
ctx.restore();
|
||||
|
|
@ -1046,6 +1294,8 @@ function drawChick(ctx, chick, active, game) {
|
|||
if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); }
|
||||
if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); }
|
||||
if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
|
||||
else if (chick.sex === 'meat') drawMeatItem(ctx, chick.x, y, chick.radius);
|
||||
else if (chick.sex === 'sausage') drawSausageItem(ctx, chick.x, y, chick.radius);
|
||||
else {
|
||||
const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
|
||||
const spriteDrawn = drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2);
|
||||
|
|
@ -1092,6 +1342,31 @@ function drawPoop(ctx, x, y, radius) {
|
|||
ctx.fillStyle = THEME.white; ctx.beginPath(); ctx.arc(x - 5, y + 1, 2, 0, Math.PI * 2); ctx.arc(x + 5, y + 1, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore();
|
||||
}
|
||||
function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
|
||||
function drawMeatItem(ctx, x, y, radius = 10) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = THEME.meatRed;
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, radius * 1.15, radius * 0.78, -0.25, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#f1b8b8';
|
||||
ctx.beginPath(); ctx.arc(x - radius * 0.25, y - radius * 0.12, radius * 0.22, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawSausageItem(ctx, x, y, radius = 10) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y); ctx.rotate(-0.2);
|
||||
ctx.fillStyle = THEME.sausageOrange;
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 3;
|
||||
rect(ctx, -radius, -radius * 0.45, radius * 2, radius * 0.9, true, true);
|
||||
ctx.strokeStyle = '#8a4823'; ctx.lineWidth = 2;
|
||||
for (const dx of [-radius * 0.45, radius * 0.45]) {
|
||||
ctx.beginPath(); ctx.moveTo(dx, -radius * 0.36); ctx.lineTo(dx, radius * 0.36); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawTinyChick(ctx, x, y, sex, warning = false) {
|
||||
ctx.save();
|
||||
const bodyColor = sex === 'male' ? THEME.male : THEME.female;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { BALANCE } from '../core/balance.js';
|
||||
import { DIRS, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
|
||||
import { DIRS, EDGE_FACILITY_IDS, GRID_FACILITY_IDS, MACHINE_FACILITY_IDS, GRID, THEME, ECONOMY } from '../core/config.js';
|
||||
import { getSpawnRange } from '../core/state.js';
|
||||
import { createEggFarm, createScanner, createFacility, createMaintenanceRoom, maintenanceRoomFootprint } from '../core/entities.js';
|
||||
import { createEggFarm, createScanner, createFacility, createGridFacility, createMaintenanceRoom, maintenanceRoomFootprint, gridFacilityFootprintCells, syncGridFacilityGeometry } from '../core/entities.js';
|
||||
import { generateBlockedCellsInRect, blockedRatioForExpansionPurchase } from '../core/mapGen.js';
|
||||
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
|
||||
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftColIndexedSet, shiftColIndexedMap, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
|
||||
import { TEXT, equipmentName } from '../core/text.js';
|
||||
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
|
||||
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusIncome, shredderBonusMaxCards } from './economy.js';
|
||||
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, facilityOutputHasConveyor, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
|
||||
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusIncome, shredderBonusChance, shredderBonusMaxCards, upgradedSausagePrice } from './economy.js';
|
||||
import { autoScannerCooldownSeconds } from './cards.js';
|
||||
import { record } from './history.js';
|
||||
import { floating, eraseEffect } from './effects.js';
|
||||
|
|
@ -22,7 +22,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
let conveyorDragStartCell = null;
|
||||
let conveyorDragMoved = false;
|
||||
const BRANCH_MODES = ['random', 'up', 'right', 'down', 'left'];
|
||||
const BRANCH_LABELS = { random: 'RND', up: 'UP', right: 'RT', down: 'DN', left: 'LF' };
|
||||
const BRANCH_LABELS = { random: 'RND', up: '▲', right: '▶', down: '▼', left: '◀' };
|
||||
const BRANCH_MARKER = { w: 40, h: 40, y: 0 };
|
||||
|
||||
function gridExpansionCost() { return expansionCost(game); }
|
||||
|
|
@ -45,7 +45,26 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
for (const farm of game.eggFarms || []) farm.row += rowDelta;
|
||||
for (const scanner of game.scanners || []) scanner.row += rowDelta;
|
||||
for (const facility of Object.values(game.facilities || {})) {
|
||||
if (facility.entry) facility.entry.row += rowDelta;
|
||||
if (facility.gridPlaced) { facility.row += rowDelta; syncGridFacilityGeometry(facility); }
|
||||
else if (facility.entry) facility.entry.row += rowDelta;
|
||||
}
|
||||
}
|
||||
|
||||
function shiftWorldColsRight(colDelta) {
|
||||
if (!colDelta) return;
|
||||
GRID.x -= colDelta * GRID.cell;
|
||||
GRID.cols += colDelta;
|
||||
game.gridChunkOriginX = (game.gridChunkOriginX || 0) - Math.floor(colDelta / 10);
|
||||
game.ownedCells = shiftColIndexedSet(game.ownedCells, colDelta);
|
||||
game.blockedCells = shiftColIndexedSet(game.blockedCells, colDelta);
|
||||
game.conveyorTiles = shiftColIndexedSet(game.conveyorTiles, colDelta);
|
||||
game.conveyorMeta = shiftColIndexedMap(game.conveyorMeta, colDelta);
|
||||
for (const farm of game.eggFarms || []) farm.col += colDelta;
|
||||
for (const scanner of game.scanners || []) scanner.col += colDelta;
|
||||
for (const room of game.maintenanceRooms || []) room.col += colDelta;
|
||||
for (const facility of Object.values(game.facilities || {})) {
|
||||
if (facility.gridPlaced) { facility.col += colDelta; syncGridFacilityGeometry(facility); }
|
||||
else if (facility.entry) facility.entry.col += colDelta;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,12 +75,36 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function expansionOfferAtPoint(p) {
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
|
||||
return expansionLots(game).find(lot => lot && lotContainsPoint(lot, p)) || null;
|
||||
return expansionLots(game).map(lot => expansionLotStatus(lot)).find(lot => lot && lotContainsPoint(lot, p)) || null;
|
||||
}
|
||||
|
||||
function lotRect(lot) {
|
||||
const b = lotBounds(lot);
|
||||
return { x: b.x, y: b.y, w: b.w, h: b.h };
|
||||
}
|
||||
|
||||
function facilityBlocksExpansionLot(lot) {
|
||||
const rect = lotRect(lot);
|
||||
return Object.values(game.facilities || {}).some(f => {
|
||||
const kind = f?.baseId || f?.id;
|
||||
if (!EDGE_FACILITY_IDS.includes(kind) || f.gridPlaced) return false;
|
||||
return rectsOverlap(rect, rectOfFacility(f), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function expansionLotStatus(lot) {
|
||||
if (!lot) return null;
|
||||
const blockedByFacility = facilityBlocksExpansionLot(lot);
|
||||
return {
|
||||
...lot,
|
||||
disabled: blockedByFacility,
|
||||
disabledReason: blockedByFacility ? 'Move edge facilities before buying this lot.' : ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExpansionRequest(request = 'right') {
|
||||
if (request && typeof request === 'object' && Number.isFinite(request.chunkX) && Number.isFinite(request.chunkY)) return request;
|
||||
const direction = ['right', 'up', 'down'].includes(request) ? request : 'right';
|
||||
const direction = ['left', 'right', 'up', 'down'].includes(request) ? request : 'right';
|
||||
return expansionLot(game, direction);
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +112,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const offer = resolveExpansionRequest(request);
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return { ok: false, reason: 'Cannot expand now.' };
|
||||
if (!offer) return { ok: false, reason: 'Invalid expansion lot.' };
|
||||
if (facilityBlocksExpansionLot(offer)) {
|
||||
const reason = 'Move edge facilities before buying this lot.';
|
||||
fail(reason);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
const direction = offer.direction || 'right';
|
||||
const cost = gridExpansionCost();
|
||||
if (game.cash < cost) {
|
||||
|
|
@ -78,8 +126,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0, down: 0 };
|
||||
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { left: 0, right: 0, up: 0, down: 0 };
|
||||
const originY = game.gridChunkOriginY || 0;
|
||||
const originX = game.gridChunkOriginX || 0;
|
||||
if (offer.chunkX < originX) {
|
||||
const chunksToAdd = originX - offer.chunkX;
|
||||
shiftWorldColsRight(chunksToAdd * (BALANCE.grid.expansion?.colsPerPurchase || 10));
|
||||
}
|
||||
if (offer.chunkY < originY) {
|
||||
const chunksToAdd = originY - offer.chunkY;
|
||||
shiftWorldRowsDown(chunksToAdd * (BALANCE.grid.expansion?.rowsPerPurchase || 10));
|
||||
|
|
@ -136,10 +189,21 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
|
||||
const existingRoom = maintenanceRoomAt(game, col, row);
|
||||
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
|
||||
const existingGridFacility = gridFacilityAt(game, col, row);
|
||||
if (existingGridFacility && !(moving?.type === 'facility' && moving.ref?.id === existingGridFacility.id)) return true;
|
||||
if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function facilityPortAt(col, row, moving = null) {
|
||||
return Object.values(game.facilities || {}).find(facility => {
|
||||
if (moving?.type === 'facility' && moving.ref?.id === facility.id) return false;
|
||||
const input = facilityInputCell(game, facility);
|
||||
const output = facilityOutputCell(game, facility);
|
||||
return (input && input.col === col && input.row === row) || (output && output.col === col && output.row === row);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function maintenanceRoomAt(gameRef, col, row) {
|
||||
return (gameRef.maintenanceRooms || []).find(room => maintenanceRoomFootprint(room).some(cell => cell.col === col && cell.row === row)) || null;
|
||||
}
|
||||
|
|
@ -155,6 +219,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (existingScanner) return true;
|
||||
const existingRoom = maintenanceRoomAt(game, cell.col, cell.row);
|
||||
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
|
||||
const existingGridFacility = gridFacilityAt(game, cell.col, cell.row);
|
||||
if (existingGridFacility) return true;
|
||||
if (facilityPortAt(cell.col, cell.row, moving)) return true;
|
||||
if (game.conveyorTiles.has(k)) return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -169,11 +236,34 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (existingFarm) return true;
|
||||
const existingScanner = scannerAt(game, cell.col, cell.row);
|
||||
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
|
||||
const existingGridFacility = gridFacilityAt(game, cell.col, cell.row);
|
||||
if (existingGridFacility) return true;
|
||||
if (facilityPortAt(cell.col, cell.row, moving)) return true;
|
||||
if (game.conveyorTiles.has(k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function gridFacilityPlacementBlocked(id, col, row, moving = null) {
|
||||
const draft = { baseId: id, id, gridPlaced: true, col, row, wCells: id === 'sausageMaker' ? 3 : 1, hCells: id === 'sausageMaker' ? 3 : 1 };
|
||||
for (const cell of gridFacilityFootprintCells(draft)) {
|
||||
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row) || isEquipmentCell(cell.col, cell.row, moving) || facilityPortAt(cell.col, cell.row, moving)) return true;
|
||||
}
|
||||
if (id === 'sausageMaker') {
|
||||
const input = { col: col - 1, row: row + 1 };
|
||||
const output = { col: col + 3, row: row + 1 };
|
||||
if (!pointInGrid(input.col, input.row) || !pointInGrid(output.col, output.row)) return true;
|
||||
if (isBlockedCell(input.col, input.row) || isBlockedCell(output.col, output.row)) return true;
|
||||
for (const port of [input, output]) {
|
||||
const portGridFacility = gridFacilityAt(game, port.col, port.row);
|
||||
if (portGridFacility && portGridFacility.id !== moving?.ref?.id) return true;
|
||||
if (farmAt(game, port.col, port.row) || scannerAt(game, port.col, port.row) || maintenanceRoomAt(game, port.col, port.row)) return true;
|
||||
if (facilityPortAt(port.col, port.row, moving)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectedObject() {
|
||||
if (!game.selected) return null;
|
||||
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
|
||||
|
|
@ -269,6 +359,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
return dirs.includes(incoming);
|
||||
}
|
||||
|
||||
function deadEndReverseDir(cell, dirs) {
|
||||
const neighbors = conveyorNeighborOutDirs(cell);
|
||||
if (neighbors.length !== 1) return null;
|
||||
const only = neighbors[0];
|
||||
return dirs.includes(only) && neighborPointsIntoCell(cell, only) ? only : null;
|
||||
}
|
||||
|
||||
function reachableOutDirsForKey(k) {
|
||||
if (!game.conveyorTiles.has(k)) return [];
|
||||
const cell = parseKey(k);
|
||||
|
|
@ -278,7 +375,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const explicitReachable = [...new Set([...explicit, meta?.dir].filter(Boolean))]
|
||||
.filter(dir => neighbors.includes(dir));
|
||||
const inferredExits = neighbors;
|
||||
const reachable = explicitReachable.length ? explicitReachable : inferredExits;
|
||||
const candidateReachable = explicitReachable.length ? explicitReachable : inferredExits;
|
||||
const reverseDeadEnd = deadEndReverseDir(cell, candidateReachable);
|
||||
const reachable = explicitReachable.length
|
||||
? explicitReachable.filter(dir => dir !== reverseDeadEnd)
|
||||
: inferredExits.filter(dir => dir !== reverseDeadEnd);
|
||||
if (meta) {
|
||||
meta.outDirs = explicit.filter(dir => neighbors.includes(dir));
|
||||
if (meta.dir && !neighbors.includes(meta.dir)) meta.dir = meta.outDirs[0] || null;
|
||||
|
|
@ -291,7 +392,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (!game.conveyorTiles.has(k)) return [];
|
||||
const cell = parseKey(k);
|
||||
const connected = conveyorNeighborOutDirs(cell);
|
||||
if (!isBranchCell(cell)) return connected.length ? connected : reachableOutDirsForKey(k);
|
||||
if (!isBranchCell(cell)) return reachableOutDirsForKey(k);
|
||||
return connected;
|
||||
}
|
||||
|
||||
|
|
@ -302,11 +403,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const meta = game.conveyorMeta.get(k) || {};
|
||||
const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
|
||||
const physicalExits = connected.filter(dir => !physicalEntrances.has(dir));
|
||||
if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
|
||||
if (physicalEntrances.size) return physicalExits.length >= 2 ? physicalExits : [];
|
||||
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
|
||||
.filter(dir => connected.includes(dir));
|
||||
if (explicit.length >= 2) return explicit;
|
||||
return connected;
|
||||
return explicit.length >= 2 ? explicit : connected;
|
||||
}
|
||||
|
||||
function markConveyorDirection(from, to) {
|
||||
|
|
@ -326,6 +426,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
|
||||
function cycleSingleConveyorDirection(cell) {
|
||||
// A true isolated tile has no neighboring belt. An end tile has one neighbor
|
||||
// and must reverse the whole connected run rather than rotating by itself.
|
||||
if (conveyorNeighborCells(cell).length === 0) return cycleIsolatedConveyorDirection(cell);
|
||||
const path = conveyorRunPathFrom(cell);
|
||||
if (path.length < 2) return cycleIsolatedConveyorDirection(cell);
|
||||
record(game);
|
||||
|
|
@ -340,7 +443,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const k = key(cell.col, cell.row);
|
||||
const meta = game.conveyorMeta.get(k);
|
||||
if (!meta || isBranchCell(cell)) return false;
|
||||
const choices = DIRS.map(d => d.name);
|
||||
const blockedReverseDir = deadEndReverseDir(cell, DIRS.map(d => d.name));
|
||||
const choices = DIRS.map(d => d.name).filter(dir => dir !== blockedReverseDir);
|
||||
const current = meta.dir && choices.includes(meta.dir) ? meta.dir : null;
|
||||
const next = choices[(Math.max(-1, choices.indexOf(current)) + 1) % choices.length];
|
||||
record(game);
|
||||
|
|
@ -413,6 +517,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
meta.outDirs = [dir];
|
||||
meta.branchMode = 'random';
|
||||
}
|
||||
// The last belt has no following belt in the run, but it still needs an
|
||||
// explicit outward direction. Without this, clicking the end tile only
|
||||
// reversed the interior tiles and left the clicked end visually unchanged.
|
||||
if (target.length >= 2) {
|
||||
const previous = target[target.length - 2];
|
||||
const terminal = target[target.length - 1];
|
||||
if (!isBranchCell(terminal)) {
|
||||
const terminalMeta = game.conveyorMeta.get(key(terminal.col, terminal.row));
|
||||
const terminalDir = directionNameBetweenCells(previous, terminal);
|
||||
if (terminalMeta && terminalDir) {
|
||||
terminalMeta.dir = terminalDir;
|
||||
terminalMeta.outDirs = [terminalDir];
|
||||
terminalMeta.branchMode = 'random';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function conveyorToolDef() {
|
||||
|
|
@ -425,7 +545,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const k = key(col, row);
|
||||
if (game.conveyorTiles.has(k)) return { ok: false, exists: true };
|
||||
if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' };
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
|
||||
if (isEquipmentCell(col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
|
||||
const toolDef = conveyorToolDef();
|
||||
const kind = toolDef.id || 'conveyor';
|
||||
const cost = buildPrice(kind, game);
|
||||
|
|
@ -533,10 +653,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
return;
|
||||
}
|
||||
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
|
||||
if (facilityPortAt(col, row) && !['conveyor', 'boostConveyor'].includes(game.buildTool)) return fail('Machine port must remain open for a conveyor.');
|
||||
if (game.buildTool === 'maintenanceRoom') {
|
||||
if (maintenanceRoomPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
return buildMaintenanceRoom(col, row);
|
||||
}
|
||||
if (GRID_FACILITY_IDS.includes(game.buildTool)) {
|
||||
if (gridFacilityPlacementBlocked(game.buildTool, col, row)) return fail('3x3 machine or its ports do not fit here.');
|
||||
return buildGridFacility(col, row, game.buildTool);
|
||||
}
|
||||
if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
if (game.buildTool === 'eggFarm') buildFarm(col, row);
|
||||
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
|
||||
|
|
@ -545,8 +670,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function buildAtPoint(p) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom'].includes(game.buildTool)) return buildAtCell(cell);
|
||||
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom', ...GRID_FACILITY_IDS].includes(game.buildTool)) return buildAtCell(cell);
|
||||
if (EDGE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||
}
|
||||
|
||||
function buildFarm(col, row) {
|
||||
|
|
@ -597,6 +722,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
return { x: first.x + GRID.cell / 2, y: first.y + GRID.cell / 2 };
|
||||
}
|
||||
|
||||
function buildGridFacility(col, row, id) {
|
||||
const cost = buildPrice(id, game);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
if (gridFacilityPlacementBlocked(id, col, row)) return fail('3x3 machine or its ports do not fit here.');
|
||||
const f = applyBuildQuality(createGridFacility(game, id, col, row, cost));
|
||||
const storageId = game.facilities[id] ? `${id}:${game.nextId++}` : id;
|
||||
f.baseId = id;
|
||||
f.id = storageId;
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
game.facilities[storageId] = f;
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'facility', id: storageId };
|
||||
floating(game, f.x + f.w / 2, f.y + f.h / 2, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function buildFacility(p, id) {
|
||||
const cost = buildPrice(id, game);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
|
|
@ -605,6 +746,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
f.baseId = id;
|
||||
f.id = storageId;
|
||||
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
|
||||
if (f.entry && (farmAt(game, f.entry.col, f.entry.row) || scannerAt(game, f.entry.col, f.entry.row) || maintenanceRoomAt(game, f.entry.col, f.entry.row) || gridFacilityAt(game, f.entry.col, f.entry.row))) {
|
||||
return fail('Receiver cell must remain free of equipment. A conveyor may occupy it.');
|
||||
}
|
||||
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
|
|
@ -863,7 +1007,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
ui.modalActions.innerHTML = '';
|
||||
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
|
||||
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
|
||||
if (obj.type === 'conveyor' && isBranchKey(obj.id)) {
|
||||
if (obj.type === 'conveyor' && isBranchKey(obj.id) && branchSelectableExitDirsForKey(obj.id).length >= 2) {
|
||||
const mode = game.conveyorMeta.get(obj.id)?.branchMode || 'random';
|
||||
ui.modalActions.appendChild(modalButton(`Branch: ${branchModeLabel(mode)}`, () => {
|
||||
cycleBranchModeForKey(obj.id);
|
||||
|
|
@ -930,7 +1074,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
function branchSwitcherAtPoint(p) {
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
|
||||
for (const k of game.conveyorTiles) {
|
||||
if (!isBranchKey(k)) continue;
|
||||
if (!isBranchKey(k) || branchSelectableExitDirsForKey(k).length < 2) continue;
|
||||
const cell = parseKey(k);
|
||||
const c = cellCenter(cell.col, cell.row);
|
||||
const left = c.x - BRANCH_MARKER.w / 2;
|
||||
|
|
@ -993,8 +1137,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
|
||||
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
|
||||
if (isBranchKey(obj.id)) {
|
||||
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
|
||||
lines.push('Click the Branch button or the large on-belt badge to cycle RANDOM / fixed exit.');
|
||||
const selectableExits = branchSelectableExitDirsForKey(obj.id);
|
||||
if (selectableExits.length >= 2) {
|
||||
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
|
||||
lines.push('Click the Branch button or the on-belt triangle to cycle RANDOM / fixed exit.');
|
||||
} else {
|
||||
lines.push('Branch mode: unavailable — fewer than two outgoing conveyors.');
|
||||
}
|
||||
} else {
|
||||
lines.push('Branch mode: needs 3+ conveyor connections.');
|
||||
}
|
||||
|
|
@ -1007,11 +1156,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
} else if (obj.type === 'facility') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
{ const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); }
|
||||
lines.push(['mixer', 'truck', 'trash'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
|
||||
lines.push(['mixer', 'truck', 'trash', 'sausageMaker'].includes(facilityKind) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
|
||||
if (facilityKind === 'truck') lines.push('Durability: none | Always normal');
|
||||
if (['mixer', 'trash'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`);
|
||||
if (['mixer', 'trash', 'sausageMaker'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`);
|
||||
if (facilityKind === 'mixer') {
|
||||
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
|
||||
lines.push(`Output: 1 meat per non-poop chick | meat buffer ${obj.meatBuffer || 0}`);
|
||||
lines.push(`Meat quality: x${incomeMultiplier(game, 'mixer').toFixed(3)} to sausage value`);
|
||||
const out = facilityOutputCell(game, obj);
|
||||
lines.push(`OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
|
||||
lines.push(`Fallback sale: ${yen(ECONOMY.income.mixer)} when OUT is missing or blocked`);
|
||||
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
|
||||
}
|
||||
if (facilityKind === 'truck') {
|
||||
|
|
@ -1021,10 +1174,20 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (facilityKind === 'trash') {
|
||||
const cards = shredderUpgradeCount(game);
|
||||
const income = shredderBonusIncome(cards);
|
||||
const chance = shredderBonusChance(cards);
|
||||
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
|
||||
lines.push(`Poop income bonus: ${yen(income)} per processed poop.`);
|
||||
lines.push(`Expected poop bonus: ${yen(income)} | hit chance ${Math.round(chance * 100)}%`);
|
||||
lines.push('Winning payouts roll 1x / 2x / 4x / 10x, with jackpot odds rising per card.');
|
||||
lines.push(`With enough upgrades, poop can out-earn male/female routes.`);
|
||||
}
|
||||
if (facilityKind === 'sausageMaker') {
|
||||
const input = facilityInputCell(game, obj);
|
||||
const out = facilityOutputCell(game, obj);
|
||||
lines.push('Size: 3×3 grid cells | IN left / OUT right');
|
||||
lines.push(`Recipe: 2 meat -> 1 sausage | meat buffer ${obj.meatBuffer || 0} | sausage buffer ${obj.sausageBuffer || 0}`);
|
||||
lines.push(`Sausage sale value: ${yen(upgradedSausagePrice(game))}`);
|
||||
lines.push(`IN: ${input ? `${input.col},${input.row}` : 'none'} | OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
|
||||
}
|
||||
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
|
||||
}
|
||||
return lines;
|
||||
|
|
@ -1043,11 +1206,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (obj.type === 'conveyor' && game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor') return 'Moves chicks twice as fast. Wears down like a normal belt.';
|
||||
if (obj.type === 'conveyor') return 'A stubborn belt tile. It only cares about the next cell.';
|
||||
if (obj.type === 'eggFarm') return 'A tiny gatehouse producing questionable eggs on schedule.';
|
||||
if (obj.type === 'scanner' && obj.kind === 'auto') return 'Slower than hands without upgrades.';
|
||||
if (obj.type === 'scanner' && obj.kind === 'auto') return 'A compact 2×2 scanner sized for roughly 2.1 level-1 farms.';
|
||||
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Chicks become meat when OUT is clear; a blocked OUT falls back to direct sale.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo and sausages pay; wrong cargo complains.';
|
||||
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'sausageMaker') return 'A 3×3 in-grid machine with one meat inlet on the left and one sausage outlet on the right.';
|
||||
if (obj.type === 'maintenanceRoom') return 'A cramped 2×2 storage and break room with lockers, spare parts, a bench and one dispatched repairman.';
|
||||
return 'Factory equipment.';
|
||||
}
|
||||
|
|
@ -1065,7 +1229,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
|
||||
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
|
||||
cycleSingleConveyorDirection,
|
||||
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey,
|
||||
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
|
||||
removeSelected, switchScannerRole, showManualScannerMenu,
|
||||
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
||||
isEquipmentCell, equipmentHitBoxes,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_CARD_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
|
||||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { cellCenter, key, yen } from '../core/utils.js';
|
||||
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusIncome, shredderBonusMaxCards } from './economy.js';
|
||||
import { applyPenalty, applyRevenue, upgradedTruckPrice, upgradedSausagePrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusIncome, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
|
||||
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
|
||||
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState, repairAllEquipment } from './maintenance.js';
|
||||
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
|
||||
|
|
@ -146,13 +146,15 @@ function targetLabel(game, target) {
|
|||
if (target.type === 'scanner') {
|
||||
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
|
||||
}
|
||||
if (target.type === 'facility' && (target.baseId || target.id) === 'mixer') {
|
||||
const beforeLevel = target.level;
|
||||
const before = upgradedMixerPrice(game);
|
||||
if (target.type === 'facility' && ['mixer', 'sausageMaker'].includes(target.baseId || target.id)) {
|
||||
const kind = target.baseId || target.id;
|
||||
const beforeLevel = target.level || 1;
|
||||
const before = upgradedSausagePrice(game);
|
||||
target.level = beforeLevel + 1;
|
||||
const after = upgradedMixerPrice(game);
|
||||
const after = upgradedSausagePrice(game);
|
||||
target.level = beforeLevel;
|
||||
return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
|
||||
const label = kind === 'mixer' ? 'MIXER' : 'SAUSAGE MACHINE';
|
||||
return `${label} L${target.level} -> L${target.level + 1} | sausage ${yen(before)} -> ${yen(after)}`;
|
||||
}
|
||||
if (target.type === 'facility' && (target.baseId || target.id) === 'truck') {
|
||||
const beforeLevel = target.level;
|
||||
|
|
@ -162,12 +164,12 @@ function targetLabel(game, target) {
|
|||
target.level = beforeLevel;
|
||||
return `TRUCK L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
|
||||
}
|
||||
if (target.type === 'facility' && target.id === 'trash') {
|
||||
if (target.type === 'facility' && (target.baseId || target.id) === 'trash') {
|
||||
const before = shredderUpgradeCount(game);
|
||||
const after = Math.min(shredderBonusMaxCards(), before + 1);
|
||||
const beforeIncome = shredderBonusIncome(before);
|
||||
const afterIncome = shredderBonusIncome(after);
|
||||
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item`;
|
||||
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | expected ${yen(beforeIncome)} -> ${yen(afterIncome)} per poop`;
|
||||
}
|
||||
return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`;
|
||||
}
|
||||
|
|
@ -191,6 +193,7 @@ export function targetsForCard(game, cardOrId) {
|
|||
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
|
||||
if (card.target === 'mixer') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'mixer');
|
||||
if (card.target === 'truck') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'truck');
|
||||
if (card.target === 'sausageMaker') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'sausageMaker');
|
||||
if (card.target === 'trash') return rawShredderUpgradeCount(game) < shredderBonusMaxCards()
|
||||
? Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'trash')
|
||||
: [];
|
||||
|
|
@ -216,9 +219,24 @@ function repairmanCardVisible(game) {
|
|||
return !!((game.maintenanceRooms || []).length || (game.repairmen || []).length || game.repairman?.active);
|
||||
}
|
||||
|
||||
function facilityRequirementMet(game, id) {
|
||||
if (id === 'conveyor') return (game.conveyorTiles?.size || 0) > 0;
|
||||
if (id === 'autoScanner') return (game.scanners || []).some(s => s.kind === 'auto');
|
||||
if (id === 'manualScanner') return (game.scanners || []).some(s => s.kind === 'manual');
|
||||
if (id === 'eggFarm') return (game.eggFarms || []).length > 0;
|
||||
return Object.values(game.facilities || {}).some(f => (f.baseId || f.id) === id);
|
||||
}
|
||||
|
||||
function cardRequirementsMet(game, card) {
|
||||
if (card.requiresFacility && !facilityRequirementMet(game, card.requiresFacility)) return false;
|
||||
if (Array.isArray(card.requiresAnyFacility) && !card.requiresAnyFacility.some(id => facilityRequirementMet(game, id))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function availableCards(game) {
|
||||
return CARD_DEFS.filter(card => {
|
||||
if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false;
|
||||
if (!cardRequirementsMet(game, card)) return false;
|
||||
if (cardAtCap(game, card)) return false;
|
||||
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
|
||||
return true;
|
||||
|
|
@ -313,7 +331,7 @@ function boundsForTarget(target) {
|
|||
}
|
||||
if (target.type === 'scanner') {
|
||||
const c = scannerCenter(target);
|
||||
return { x: c.x - 54, y: c.y - 77, w: 108, h: 154, cx: c.x, cy: c.y };
|
||||
return { x: c.x - 48, y: c.y - 48, w: 96, h: 96, cx: c.x, cy: c.y };
|
||||
}
|
||||
if (target.type === 'facility') {
|
||||
return { x: target.x, y: target.y, w: target.w, h: target.h, cx: target.x + target.w / 2, cy: target.y + target.h / 2 };
|
||||
|
|
@ -424,13 +442,14 @@ function incomeAtLevel(base, level) {
|
|||
}
|
||||
|
||||
function incomeUpgradePreview(game, id, base) {
|
||||
const targets = targetsForCard(game, id === 'mixer' ? 'upgradeMixer' : 'upgradeTruck');
|
||||
const target = targets[0];
|
||||
const cardId = id === 'mixer' ? 'upgradeMixer' : id === 'sausageMaker' ? 'upgradeSausageMaker' : 'upgradeTruck';
|
||||
const target = targetsForCard(game, cardId)[0];
|
||||
if (!target) return { before: incomeAtLevel(base, 1), after: incomeAtLevel(base, 2) };
|
||||
const before = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
|
||||
const price = () => id === 'truck' ? upgradedTruckPrice(game) : upgradedSausagePrice(game);
|
||||
const before = price();
|
||||
const beforeLevel = target.level || 1;
|
||||
target.level = beforeLevel + 1;
|
||||
const after = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
|
||||
const after = price();
|
||||
target.level = beforeLevel;
|
||||
return { before, after };
|
||||
}
|
||||
|
|
@ -449,8 +468,12 @@ function cardDescription(game, card) {
|
|||
return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`;
|
||||
}
|
||||
if (card.id === 'upgradeMixer') {
|
||||
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.mixer);
|
||||
return `Choose one MIXER. Earning +10%. Male-chick income ${yen(preview.before)} -> ${yen(preview.after)}.`;
|
||||
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.sausage);
|
||||
return `Choose one MIXER. Meat quality +10%. Sausage sale value ${yen(preview.before)} -> ${yen(preview.after)}.`;
|
||||
}
|
||||
if (card.id === 'upgradeSausageMaker') {
|
||||
const preview = incomeUpgradePreview(game, 'sausageMaker', ECONOMY.income.sausage);
|
||||
return `Choose one SAUSAGE MACHINE. Product value +10%. Sausage sale value ${yen(preview.before)} -> ${yen(preview.after)}.`;
|
||||
}
|
||||
if (card.id === 'upgradeTruck') {
|
||||
const preview = incomeUpgradePreview(game, 'truck', ECONOMY.income.truck);
|
||||
|
|
@ -461,7 +484,9 @@ function cardDescription(game, card) {
|
|||
const after = Math.min(shredderBonusMaxCards(), before + 1);
|
||||
const beforeIncome = shredderBonusIncome(before);
|
||||
const afterIncome = shredderBonusIncome(after);
|
||||
return `SHREDDER level +1. Poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item.`;
|
||||
const beforeChance = Math.round(shredderBonusChance(before) * 100);
|
||||
const afterChance = Math.round(shredderBonusChance(after) * 100);
|
||||
return `SHREDDER level +1. Expected value ${yen(beforeIncome)} -> ${yen(afterIncome)} per poop; hit chance ${beforeChance}% -> ${afterChance}%.`;
|
||||
}
|
||||
if (card.id === 'bearing') {
|
||||
const before = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, e.bearing || 0)));
|
||||
|
|
@ -507,7 +532,7 @@ function cardDescription(game, card) {
|
|||
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
|
||||
if (card.id === 'laborExploitation') return 'Repairman dispatch paperwork is streamlined. Repair rooms keep operating; one time only.';
|
||||
if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`;
|
||||
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 10. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
|
||||
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 3. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
|
||||
return card.description;
|
||||
}
|
||||
|
||||
|
|
@ -699,24 +724,25 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
function cardIconFor(card) {
|
||||
const tags = new Set(card.tags || []);
|
||||
const byTarget = {
|
||||
eggFarm: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
|
||||
eggOutlet: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
|
||||
eggFarm: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' },
|
||||
eggOutlet: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' },
|
||||
autoScanner: { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' },
|
||||
mixer: { type: 'image', src: './assets/dark_factory/facilities/machines/mixer_compact_16.png', alt: 'MIX' },
|
||||
truck: { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_compact_16.png', alt: 'TRUCK' },
|
||||
trash: { type: 'image', src: './assets/dark_factory/facilities/machines/shredder_compact_16.png', alt: 'SHR' },
|
||||
mixer: { type: 'image', src: './assets/dark_factory/facilities/machines/mixer_32.png', alt: 'MIX' },
|
||||
truck: { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_32.png', alt: 'TRUCK' },
|
||||
trash: { type: 'image', src: './assets/dark_factory/facilities/machines/shredder_32.png', alt: 'SHR' },
|
||||
sausageMaker: { type: 'glyph', text: 'S' },
|
||||
blockedCell: { type: 'image', src: './assets/dark_factory/facilities/utility/obstacle_crate_16.png', alt: 'BLOCK' }
|
||||
};
|
||||
if (byTarget[card.target]) return byTarget[card.target];
|
||||
if (card.id === 'bearing' || tags.has('CONVEYOR')) return { type: 'image', src: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png', alt: 'BELT' };
|
||||
if (tags.has('SCANNER')) return { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' };
|
||||
if (tags.has('EGG')) return { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' };
|
||||
if (tags.has('POOP')) return { type: 'glyph', text: '💩' };
|
||||
if (tags.has('EGG')) return { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' };
|
||||
if (tags.has('POOP')) return { type: 'image', src: './assets/images/poop.png', alt: 'POOP' };
|
||||
if (tags.has('MAINTENANCE')) return { type: 'image', src: './assets/dark_factory/facilities/utility/repairman_16.png', alt: 'FIX' };
|
||||
if (tags.has('CARD')) return { type: 'glyph', text: '▣' };
|
||||
if (tags.has('ECONOMY')) return { type: 'glyph', text: '¥' };
|
||||
if (tags.has('CARD')) return { type: 'image', src: './assets/dark_factory/facilities/utility/maintenance_room_32.png', alt: 'CARD' };
|
||||
if (tags.has('ECONOMY')) return { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_32.png', alt: 'YEN' };
|
||||
if (tags.has('RISK')) return { type: 'glyph', text: '!' };
|
||||
return { type: 'glyph', text: '◆' };
|
||||
return { type: 'glyph', text: '*' };
|
||||
}
|
||||
|
||||
function cardIconHtml(card) {
|
||||
|
|
@ -724,7 +750,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
if (icon.type === 'image') {
|
||||
return `<div class="card-badge-icon image"><img src="${icon.src}" alt="${icon.alt || ''}" /></div><div class="card-icon image" aria-hidden="true"><img src="${icon.src}" alt="" /></div>`;
|
||||
}
|
||||
return `<div class="card-badge-icon glyph" aria-hidden="true">${icon.text || '◆'}</div><div class="card-icon glyph" aria-hidden="true">${icon.text || '◆'}</div>`;
|
||||
return `<div class="card-badge-icon glyph" aria-hidden="true">${icon.text || '*'}</div><div class="card-icon glyph" aria-hidden="true">${icon.text || '*'}</div>`;
|
||||
}
|
||||
|
||||
function cardButton(card, index) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { DIRS, GRID, THEME } from '../core/config.js';
|
|||
import { createChick } from '../core/entities.js';
|
||||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js';
|
||||
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
|
||||
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, facilityOutputCell, facilityOutputHasConveyor, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, upgradedSausagePrice, applyShredderBonus, applyRevenue } from './economy.js';
|
||||
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount, scannerQueueSpacingMultiplier } from './cards.js';
|
||||
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck, shredderPayoutMultiplier, congestionExplosionThreshold, congestionWarningThreshold } from './events.js';
|
||||
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
|
||||
|
|
@ -11,6 +11,74 @@ import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDesti
|
|||
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
|
||||
|
||||
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
|
||||
function frameCache() {
|
||||
if (!game.frameCache) {
|
||||
game.frameCache = {
|
||||
frameId: -1,
|
||||
routingVersion: -1,
|
||||
chickRevision: 0,
|
||||
spatialRevision: -1,
|
||||
spatial: new Map(),
|
||||
congestionFrame: -1,
|
||||
componentChicks: new Map(),
|
||||
manualMonitorSignature: '',
|
||||
draw: {}
|
||||
};
|
||||
}
|
||||
return game.frameCache;
|
||||
}
|
||||
|
||||
function markChicksChanged() {
|
||||
const cache = frameCache();
|
||||
cache.chickRevision = (cache.chickRevision || 0) + 1;
|
||||
cache.congestionFrame = -1;
|
||||
}
|
||||
|
||||
function markPositionsChanged() {
|
||||
frameCache().congestionFrame = -1;
|
||||
}
|
||||
|
||||
function beginFrame(frameId) {
|
||||
const cache = frameCache();
|
||||
if (cache.frameId === frameId) return;
|
||||
cache.frameId = frameId;
|
||||
cache.spatialRevision = -1;
|
||||
cache.congestionFrame = -1;
|
||||
}
|
||||
|
||||
function spatialKeyForPoint(x, y) {
|
||||
return `${Math.floor(x / GRID.cell)},${Math.floor(y / GRID.cell)}`;
|
||||
}
|
||||
|
||||
function buildSpatialIndex() {
|
||||
const cache = frameCache();
|
||||
if (cache.spatialRevision === cache.chickRevision) return cache.spatial;
|
||||
const spatial = new Map();
|
||||
for (const chick of game.chicks) {
|
||||
if (chick.stage === 'flying') continue;
|
||||
const k = spatialKeyForPoint(chick.x, chick.y);
|
||||
if (!spatial.has(k)) spatial.set(k, []);
|
||||
spatial.get(k).push(chick);
|
||||
}
|
||||
cache.spatial = spatial;
|
||||
cache.spatialRevision = cache.chickRevision;
|
||||
return spatial;
|
||||
}
|
||||
|
||||
function nearbyChicks(target) {
|
||||
const spatial = buildSpatialIndex();
|
||||
const sx = Math.floor(target.x / GRID.cell);
|
||||
const sy = Math.floor(target.y / GRID.cell);
|
||||
const result = [];
|
||||
for (let y = sy - 1; y <= sy + 1; y += 1) {
|
||||
for (let x = sx - 1; x <= sx + 1; x += 1) {
|
||||
const list = spatial.get(`${x},${y}`);
|
||||
if (list) result.push(...list);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function currentSpawnDelay(farm) {
|
||||
return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
|
||||
}
|
||||
|
|
@ -36,6 +104,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
|
||||
if (chick.sex === 'poop') countPoopSpawned(game);
|
||||
game.chicks.push(chick);
|
||||
markChicksChanged();
|
||||
recordFarmProduction(game, farm);
|
||||
spawned += 1;
|
||||
}
|
||||
|
|
@ -65,12 +134,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
|
||||
|
||||
function spawnTileBlocker(start) {
|
||||
return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null;
|
||||
return nearbyChicks(start).find(ch => Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null;
|
||||
}
|
||||
|
||||
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
|
||||
updateRepairman(game, dt);
|
||||
updateProcessingCooldowns(game, dt);
|
||||
flushFacilityOutputs();
|
||||
if (game.cleanupTimer !== null) {
|
||||
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
|
||||
if (game.cleanupTimer <= 0) completeTurn();
|
||||
|
|
@ -103,7 +173,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
updateRoutedChick(chick, i, dt);
|
||||
}
|
||||
updateScannerQueues(dt);
|
||||
updateCongestion();
|
||||
updateCongestion(game.frameId);
|
||||
checkCongestionExplosions();
|
||||
if (game.timeLeft <= 0) {
|
||||
if (game.chicks.length === 0) completeTurn();
|
||||
|
|
@ -119,7 +189,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
for (const chick of victims) flyingDebris(game, chick.sex, chick.x, chick.y, -Math.PI / 2, true);
|
||||
game.chicks = [];
|
||||
for (const scanner of game.scanners) scanner.queue = [];
|
||||
updateCongestion();
|
||||
markChicksChanged();
|
||||
updateCongestion(game.frameId);
|
||||
if (victims.length) {
|
||||
const cx = victims.reduce((sum, chick) => sum + chick.x, 0) / victims.length;
|
||||
const cy = victims.reduce((sum, chick) => sum + chick.y, 0) / victims.length;
|
||||
|
|
@ -153,9 +224,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
|
||||
function targetBlockedByChick(chick, target) {
|
||||
if (!target) return false;
|
||||
for (const other of game.chicks) {
|
||||
for (const other of nearbyChicks(target)) {
|
||||
if (chick && other.id === chick.id) continue;
|
||||
if (other.stage === 'flying') continue;
|
||||
if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -175,9 +245,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const explicit = [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
|
||||
const cell = parseKey(cellKey);
|
||||
const inferred = DIRS
|
||||
.filter(d => movementOptionFromDir(cell, d.name))
|
||||
.filter(d => movementOptionFromDir(cell, d.name, null))
|
||||
.map(d => d.name);
|
||||
const cleanExplicit = explicit.filter(dir => inferred.includes(dir));
|
||||
const connected = DIRS
|
||||
.filter(d => game.conveyorTiles.has(key(cell.col + d.dc, cell.row + d.dr)))
|
||||
.map(d => d.name);
|
||||
if (connected.length === 1 && (cleanExplicit.length ? cleanExplicit.includes(connected[0]) : true) && neighborPointsIntoCell(cell, connected[0])) {
|
||||
return cleanExplicit.filter(dir => dir !== connected[0]);
|
||||
}
|
||||
const inferredExits = inferred;
|
||||
return cleanExplicit.length ? cleanExplicit : inferredExits;
|
||||
}
|
||||
|
|
@ -187,7 +263,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function scannerAtBodyCell(col, row) {
|
||||
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row + 1) || null;
|
||||
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row) || null;
|
||||
}
|
||||
|
||||
function scannerReceivingFrom(cell, dirName) {
|
||||
|
|
@ -208,13 +284,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function dirExitsToFacility(facility, dirName) {
|
||||
if (facility?.gridPlaced && facilityKind(facility) === 'sausageMaker') return dirName === 'right';
|
||||
return (facility.side === 'left' && dirName === 'left')
|
||||
|| (facility.side === 'right' && dirName === 'right')
|
||||
|| (facility.side === 'top' && dirName === 'up')
|
||||
|| (facility.side === 'bottom' && dirName === 'down');
|
||||
}
|
||||
|
||||
function movementOptionFromDir(cell, dirName) {
|
||||
function movementOptionFromDir(cell, dirName, item = null) {
|
||||
const d = dirByName(dirName);
|
||||
if (!d) return null;
|
||||
const next = { col: cell.col + d.dc, row: cell.row + d.dr };
|
||||
|
|
@ -222,7 +299,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
|
||||
}
|
||||
const scanner = scannerReceivingFrom(cell, dirName);
|
||||
if (scanner) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
|
||||
if (scanner && !['meat', 'sausage'].includes(item?.sex)) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
|
||||
const facilityPair = facilityAtEntryCell(cell);
|
||||
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
|
||||
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
|
||||
|
|
@ -250,11 +327,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
if (connected.length < 3) return dirs;
|
||||
const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
|
||||
const physicalExits = connected.filter(dir => !entrances.has(dir));
|
||||
let candidates = entrances.size && physicalExits.length >= 2
|
||||
let candidates = entrances.size
|
||||
? physicalExits
|
||||
: [...new Set(dirs.filter(dir => connected.includes(dir)))];
|
||||
if (candidates.length < 2) candidates = connected;
|
||||
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.includes(branchMode)) return [branchMode];
|
||||
if (!candidates.length && !entrances.size) candidates = connected;
|
||||
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.length >= 2 && candidates.includes(branchMode)) return [branchMode];
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
|
@ -263,7 +340,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function pickMovementOption(chick, cell, dirs, branchMode = 'random') {
|
||||
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
|
||||
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir, chick)).filter(Boolean);
|
||||
if (!candidates.length) return null;
|
||||
const moving = candidates.filter(opt => opt.type !== 'facility');
|
||||
const empty = moving.filter(opt => !targetBlockedByChick(chick, opt.target));
|
||||
|
|
@ -301,6 +378,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
if (kind === 'mixer') resolveMixer(index, option.facility);
|
||||
else if (kind === 'truck') resolveTruck(index, option.facility);
|
||||
else if (kind === 'trash') resolveTrash(index, option.facility);
|
||||
else if (kind === 'sausageMaker') resolveSausageMaker(index, option.facility);
|
||||
else removeChick(index, 'DONE');
|
||||
return;
|
||||
}
|
||||
|
|
@ -345,10 +423,12 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
recordPassAtPoint(chick, target);
|
||||
chick.targetIndex += 1;
|
||||
remaining -= dist;
|
||||
markPositionsChanged();
|
||||
} else {
|
||||
chick.x += dx / dist * remaining;
|
||||
chick.y += dy / dist * remaining;
|
||||
remaining = 0;
|
||||
markPositionsChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -359,6 +439,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
chick.queueRoute = routeOverride ? routeOverride.map(p => ({ x: p.x, y: p.y })) : [scannerCenter(scanner)];
|
||||
chick.scannerId = scanner.id;
|
||||
if (!scanner.queue.includes(chick.id)) scanner.queue.push(chick.id);
|
||||
markChicksChanged();
|
||||
positionScannerQueue(scanner);
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +460,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const end = route[route.length - 1];
|
||||
chick.x = end.x;
|
||||
chick.y = end.y;
|
||||
markPositionsChanged();
|
||||
return;
|
||||
}
|
||||
let remain = distanceBack;
|
||||
|
|
@ -389,6 +471,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const t = 1 - remain / len;
|
||||
chick.x = a.x + (b.x - a.x) * t;
|
||||
chick.y = a.y + (b.y - a.y) * t;
|
||||
markPositionsChanged();
|
||||
return;
|
||||
}
|
||||
remain -= len;
|
||||
|
|
@ -396,6 +479,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const start = route[0];
|
||||
chick.x = start.x;
|
||||
chick.y = start.y;
|
||||
markPositionsChanged();
|
||||
}
|
||||
|
||||
function updateScannerQueues(dt) {
|
||||
|
|
@ -492,7 +576,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const graph = ensureFactoryGraph(game);
|
||||
const keys = (graph.scannerPorts.get(scanner.id)?.[side]?.keys || []).filter(Boolean);
|
||||
if (!keys.length) return null;
|
||||
const parsed = keys.map(parseKey);
|
||||
const parsed = keys.map(parseKey).sort((a, b) => {
|
||||
const ac = cellCenter(a.col, a.row);
|
||||
const bc = cellCenter(b.col, b.row);
|
||||
const ab = targetBlockedByChick(null, ac) ? 1 : 0;
|
||||
const bb = targetBlockedByChick(null, bc) ? 1 : 0;
|
||||
return ab - bb;
|
||||
});
|
||||
const exact = parsed.find(p => p.col === connector.col && p.row === connector.row);
|
||||
return exact || parsed[0] || null;
|
||||
}
|
||||
|
|
@ -530,6 +620,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
chick.pendingScannerId = null;
|
||||
chick.queueRoute = null;
|
||||
chick.queueIndex = -1;
|
||||
markChicksChanged();
|
||||
const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink);
|
||||
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor);
|
||||
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor);
|
||||
|
|
@ -540,6 +631,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const chick = game.chicks[index];
|
||||
if (!chick) return null;
|
||||
game.chicks.splice(index, 1);
|
||||
markChicksChanged();
|
||||
return chick;
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +647,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
countMixer(game);
|
||||
recordFacilityProcess(game, mixer);
|
||||
setFacilityCooldownAfterProcess(game, mixer);
|
||||
if (chick.sex === 'meat' || chick.sex === 'sausage') {
|
||||
countMistake(game);
|
||||
floating(game, x, y - 18, 'CHICKS ONLY', THEME.warn);
|
||||
return;
|
||||
}
|
||||
if (chick.sex === 'poop') {
|
||||
const penalty = applyMixerPoopFine(game);
|
||||
countPoopDestination(game, 'mixer');
|
||||
|
|
@ -563,11 +660,20 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
|
||||
return;
|
||||
}
|
||||
const amount = applyMixerIncome(game);
|
||||
if (chick.sex === 'male') countCorrect(game);
|
||||
else countMistake(game);
|
||||
const out = facilityOutputCell(game, mixer);
|
||||
if (!out || !facilityOutputHasConveyor(game, mixer) || !outputCellFree(out)) {
|
||||
const amount = applyMixerIncome(game);
|
||||
floating(game, x, y - 18, `DIRECT +${yen(amount)}`, THEME.green);
|
||||
return;
|
||||
}
|
||||
mixer.meatBuffer = Math.max(0, mixer.meatBuffer || 0) + 1;
|
||||
game.stats.meatProduced = (game.stats.meatProduced || 0) + 1;
|
||||
game.totals.meatProduced = (game.totals.meatProduced || 0) + 1;
|
||||
meatEffect(game, x, y);
|
||||
floating(game, x, y - 18, `+${yen(amount)}`, THEME.green);
|
||||
floating(game, x, y - 18, 'MEAT +1', THEME.meatRed);
|
||||
flushFacilityOutputs();
|
||||
}
|
||||
|
||||
function resolveTruck(index, truck = game.facilities.truck) {
|
||||
|
|
@ -579,6 +685,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
countTruckCargo(game, chick.sex);
|
||||
const target = truckTarget(game);
|
||||
const isTarget = isTargetTruckCargo(game, chick.sex);
|
||||
if (chick.sex === 'sausage') {
|
||||
game.stats.sausageShipped = (game.stats.sausageShipped || 0) + 1;
|
||||
game.totals.sausageShipped = (game.totals.sausageShipped || 0) + 1;
|
||||
floating(game, x, y - 18, `SAUSAGE +${yen(upgradedSausagePrice(game))}`, THEME.sausageOrange);
|
||||
return;
|
||||
}
|
||||
if (chick.sex === 'meat') {
|
||||
countMistake(game);
|
||||
floating(game, x, y - 18, 'RAW MEAT REJECT', THEME.warn);
|
||||
return;
|
||||
}
|
||||
if (chick.sex === 'poop') {
|
||||
countPoopDestination(game, 'truck');
|
||||
if (isTarget) {
|
||||
|
|
@ -616,6 +733,72 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
}
|
||||
|
||||
function createProduct(product, cell) {
|
||||
const c = cellCenter(cell.col, cell.row);
|
||||
return {
|
||||
id: game.nextId++, sex: product, x: c.x, y: c.y,
|
||||
route: [{ x: c.x, y: c.y }], targetIndex: 1, stage: 'belt',
|
||||
scannerId: null, nextScannerId: null, pendingScannerId: null,
|
||||
radius: product === 'sausage' ? 11 : 10, bob: Math.random() * Math.PI * 2,
|
||||
queueIndex: -1, queueRoute: null, stoppedTimer: 0
|
||||
};
|
||||
}
|
||||
|
||||
function outputCellFree(cell) {
|
||||
if (!cell || !game.conveyorTiles.has(key(cell.col, cell.row))) return false;
|
||||
const c = cellCenter(cell.col, cell.row);
|
||||
return !targetBlockedByChick(null, c);
|
||||
}
|
||||
|
||||
function emitFromFacility(facility, product, bufferKey) {
|
||||
const out = facilityOutputCell(game, facility);
|
||||
if (!out || !facilityOutputHasConveyor(game, facility) || !outputCellFree(out)) return false;
|
||||
if (Math.max(0, facility[bufferKey] || 0) <= 0) return false;
|
||||
facility[bufferKey] -= 1;
|
||||
game.chicks.push(createProduct(product, out));
|
||||
markChicksChanged();
|
||||
const c = cellCenter(out.col, out.row);
|
||||
floating(game, c.x, c.y - 16, product === 'meat' ? 'MEAT OUT' : 'SAUSAGE OUT', product === 'meat' ? THEME.meatRed : THEME.sausageOrange);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushFacilityOutputs() {
|
||||
for (const facility of Object.values(game.facilities || {})) {
|
||||
const kind = facilityKind(facility);
|
||||
if (kind === 'mixer') emitFromFacility(facility, 'meat', 'meatBuffer');
|
||||
if (kind === 'sausageMaker') emitFromFacility(facility, 'sausage', 'sausageBuffer');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSausageMaker(index, machine) {
|
||||
if (machine?.processingCooldown > 0) {
|
||||
const item = game.chicks[index];
|
||||
if (item) item.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
const item = takeChick(index);
|
||||
if (!item) return;
|
||||
const { x, y } = item;
|
||||
if (item.sex !== 'meat') {
|
||||
countMistake(game);
|
||||
floating(game, x, y - 18, 'MEAT ONLY', THEME.warn);
|
||||
return;
|
||||
}
|
||||
recordFacilityProcess(game, machine);
|
||||
setFacilityCooldownAfterProcess(game, machine);
|
||||
machine.meatBuffer = Math.max(0, machine.meatBuffer || 0) + 1;
|
||||
if (machine.meatBuffer >= 2) {
|
||||
machine.meatBuffer -= 2;
|
||||
machine.sausageBuffer = Math.max(0, machine.sausageBuffer || 0) + 1;
|
||||
game.stats.sausageProduced = (game.stats.sausageProduced || 0) + 1;
|
||||
game.totals.sausageProduced = (game.totals.sausageProduced || 0) + 1;
|
||||
floating(game, x, y - 18, 'SAUSAGE +1', THEME.sausageOrange);
|
||||
} else {
|
||||
floating(game, x, y - 18, 'MEAT 1/2', THEME.meatRed);
|
||||
}
|
||||
flushFacilityOutputs();
|
||||
}
|
||||
|
||||
function resolveTrash(index, trash = game.facilities.trash) {
|
||||
if (trash?.processingCooldown > 0) {
|
||||
const chick = game.chicks[index];
|
||||
|
|
@ -633,7 +816,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
let bonusText = '';
|
||||
if (chick.sex === 'poop') {
|
||||
bonus = applyShredderBonus(game);
|
||||
bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
|
||||
bonusText = bonus.amount > 0 ? ` ${bonus.tier === 'jackpot' ? 'JACKPOT ' : '+'}${yen(bonus.amount)}` : ' MISS';
|
||||
countPoopDestination(game, 'trash');
|
||||
countCorrect(game);
|
||||
const compost = Math.ceil(Math.max(0, Number(game.cardEffects?.composter) || 0) * 3 * shredderPayoutMultiplier(game));
|
||||
|
|
@ -669,15 +852,29 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const k = key(cell.col, cell.row);
|
||||
if (game.conveyorTiles.has(k)) return k;
|
||||
}
|
||||
const nearest = nearestConveyorKey(game, chick.x, chick.y);
|
||||
if (!nearest) return null;
|
||||
const p = parseKey(nearest);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
return Math.hypot(chick.x - c.x, chick.y - c.y) <= GRID.cell * 0.55 ? nearest : null;
|
||||
if (!cell) return null;
|
||||
let nearest = null;
|
||||
let nearestDistance = Infinity;
|
||||
for (const p of [cell, ...DIRS.map(d => ({ col: cell.col + d.dc, row: cell.row + d.dr }))]) {
|
||||
if (!inGrid(p.col, p.row)) continue;
|
||||
const k = key(p.col, p.row);
|
||||
if (!game.conveyorTiles.has(k)) continue;
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const distance = Math.hypot(chick.x - c.x, chick.y - c.y);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = k;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearestDistance <= GRID.cell * 0.55 ? nearest : null;
|
||||
}
|
||||
|
||||
function updateCongestion() {
|
||||
function updateCongestion(frameId = game.frameId || 0) {
|
||||
const cache = frameCache();
|
||||
const routingVersion = game.routingVersion || 0;
|
||||
if (cache.congestionFrame === frameId && cache.routingVersion === routingVersion) return game.congestion;
|
||||
const { components, cellToComponent } = buildConveyorComponents(game);
|
||||
const componentChicks = new Map();
|
||||
for (const chick of game.chicks) {
|
||||
if (chick.stage === 'flying') continue;
|
||||
const k = conveyorKeyAtChick(chick);
|
||||
|
|
@ -685,10 +882,18 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const id = cellToComponent.get(k);
|
||||
const comp = components.get(id);
|
||||
if (comp) comp.count += 1;
|
||||
if (id != null) {
|
||||
if (!componentChicks.has(id)) componentChicks.set(id, []);
|
||||
componentChicks.get(id).push(chick);
|
||||
}
|
||||
}
|
||||
for (const comp of components.values()) comp.ratio = comp.count / comp.capacity;
|
||||
game.congestion = components;
|
||||
game.componentLookup = cellToComponent;
|
||||
cache.routingVersion = routingVersion;
|
||||
cache.congestionFrame = frameId;
|
||||
cache.componentChicks = componentChicks;
|
||||
return game.congestion;
|
||||
}
|
||||
|
||||
function checkCongestionExplosions() {
|
||||
|
|
@ -714,10 +919,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function chicksInComponent(id) {
|
||||
return game.chicks.filter(ch => {
|
||||
const k = conveyorKeyAtChick(ch);
|
||||
return k && game.componentLookup?.get(k) === id;
|
||||
});
|
||||
updateCongestion(game.frameId || 0);
|
||||
return game.frameCache?.componentChicks?.get(id) || [];
|
||||
}
|
||||
|
||||
function explodeComponent(comp) {
|
||||
|
|
@ -726,6 +929,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
for (const v of victims) flyingDebris(game, v.sex, v.x, v.y);
|
||||
game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
|
||||
for (const scanner of game.scanners) scanner.queue = scanner.queue.filter(id => game.chicks.some(c => c.id === id));
|
||||
markChicksChanged();
|
||||
for (const k of comp.cells) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
|
|
@ -743,6 +947,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
|
||||
return {
|
||||
activeQueuedChick,
|
||||
beginFrame,
|
||||
currentSpawnDelay,
|
||||
sortSlot,
|
||||
updateCongestion,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export function upgradedTruckPrice(game) {
|
|||
return Math.ceil(ECONOMY.income.truck * incomeMultiplier(game, 'truck'));
|
||||
}
|
||||
|
||||
export function upgradedSausagePrice(game) {
|
||||
return Math.ceil((ECONOMY.income.sausage || 26) * incomeMultiplier(game, 'mixer') * incomeMultiplier(game, 'sausageMaker'));
|
||||
}
|
||||
|
||||
export function mixerPoopPenalty(game) {
|
||||
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer') * poopFineMultiplier(game));
|
||||
}
|
||||
|
|
@ -81,21 +85,54 @@ export function shredderBonusMaxCards() {
|
|||
return ECONOMY.shredderBonus.maxCards;
|
||||
}
|
||||
|
||||
export function shredderBonusIncome(gameOrCount) {
|
||||
function shredderRules() {
|
||||
return ECONOMY.shredderBonus || {};
|
||||
}
|
||||
|
||||
export function shredderBasePayout(gameOrCount) {
|
||||
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
|
||||
const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count));
|
||||
return capped > 0 ? Math.ceil(capped * (ECONOMY.shredderBonus.incomePerUpgrade || 1)) : 0;
|
||||
const rules = shredderRules();
|
||||
const capped = Math.min(rules.maxCards || 30, Math.max(0, count));
|
||||
return capped > 0 ? Math.ceil((rules.payoutCoefficient || 1.6) * Math.pow(capped, rules.payoutExponent || 1.25)) : 0;
|
||||
}
|
||||
|
||||
export function shredderBonusChance(gameOrCount) {
|
||||
return shredderBonusIncome(gameOrCount) > 0 ? 1 : 0;
|
||||
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
|
||||
if (count <= 0) return 0;
|
||||
const rules = shredderRules();
|
||||
return Math.min(rules.maxHitChance || 0.82, (rules.baseHitChance || 0.28) + count * (rules.hitChancePerUpgrade || 0.018));
|
||||
}
|
||||
|
||||
function shredderExpectedMultiplier(count) {
|
||||
const rules = shredderRules();
|
||||
const jackpot = Math.min(rules.jackpotMaxChance || 0.08, (rules.jackpotBaseChance || 0.01) + count * (rules.jackpotChancePerUpgrade || 0.0025));
|
||||
const big = Math.min(rules.bigWinMaxChance || 0.12, (rules.bigWinBaseChance || 0.07) + count * (rules.bigWinChancePerUpgrade || 0.0015));
|
||||
const double = Math.min(1 - jackpot - big, rules.doubleChance || 0.20);
|
||||
const normal = Math.max(0, 1 - jackpot - big - double);
|
||||
return jackpot * (rules.jackpotMultiplier || 10) + big * (rules.bigWinMultiplier || 4) + double * (rules.doubleMultiplier || 2) + normal;
|
||||
}
|
||||
|
||||
export function shredderBonusIncome(gameOrCount) {
|
||||
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
|
||||
return Math.round(shredderBasePayout(count) * shredderBonusChance(count) * shredderExpectedMultiplier(count));
|
||||
}
|
||||
|
||||
export function rollShredderBonus(game) {
|
||||
const cards = shredderUpgradeCount(game);
|
||||
const baseAmount = shredderBonusIncome(cards);
|
||||
const amount = Math.ceil(baseAmount * shredderPayoutMultiplier(game));
|
||||
return { cards, chance: amount > 0 ? 1 : 0, amount, expected: amount, baseAmount };
|
||||
const chance = shredderBonusChance(cards);
|
||||
const baseAmount = shredderBasePayout(cards);
|
||||
if (cards <= 0 || Math.random() >= chance) return { cards, chance, amount: 0, expected: shredderBonusIncome(cards), baseAmount, multiplier: 0, tier: 'miss' };
|
||||
const rules = shredderRules();
|
||||
const jackpot = Math.min(rules.jackpotMaxChance || 0.08, (rules.jackpotBaseChance || 0.01) + cards * (rules.jackpotChancePerUpgrade || 0.0025));
|
||||
const big = Math.min(rules.bigWinMaxChance || 0.12, (rules.bigWinBaseChance || 0.07) + cards * (rules.bigWinChancePerUpgrade || 0.0015));
|
||||
const double = Math.min(1 - jackpot - big, rules.doubleChance || 0.20);
|
||||
const roll = Math.random();
|
||||
let multiplier = 1, tier = 'normal';
|
||||
if (roll < jackpot) { multiplier = rules.jackpotMultiplier || 10; tier = 'jackpot'; }
|
||||
else if (roll < jackpot + big) { multiplier = rules.bigWinMultiplier || 4; tier = 'big'; }
|
||||
else if (roll < jackpot + big + double) { multiplier = rules.doubleMultiplier || 2; tier = 'double'; }
|
||||
const amount = Math.ceil(baseAmount * multiplier * shredderPayoutMultiplier(game));
|
||||
return { cards, chance, amount, expected: shredderBonusIncome(cards), baseAmount, multiplier, tier };
|
||||
}
|
||||
|
||||
export function applyShredderBonus(game) {
|
||||
|
|
@ -180,7 +217,7 @@ export function refundCash(game, amount) {
|
|||
}
|
||||
|
||||
export function applyMixerIncome(game) {
|
||||
const amount = upgradedMixerPrice(game);
|
||||
const amount = ECONOMY.income.mixer;
|
||||
game.stats.mixerRevenue += amount;
|
||||
applyRevenue(game, amount);
|
||||
return amount;
|
||||
|
|
@ -223,13 +260,17 @@ export function settleTruckRevenue(game) {
|
|||
const targetCount = targetTruckCount(game.stats, target);
|
||||
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
|
||||
const base = targetCount * unitPrice;
|
||||
const adjusted = positivePayout(game, base);
|
||||
const sausageCount = Math.max(0, game?.stats?.sausageShipped || 0);
|
||||
const sausageUnitPrice = upgradedSausagePrice(game);
|
||||
const sausageRevenue = sausageCount * sausageUnitPrice;
|
||||
const adjusted = positivePayout(game, base + sausageRevenue);
|
||||
game.stats.pendingTruckRevenue = adjusted;
|
||||
game.stats.truckRevenue = adjusted;
|
||||
if (target === 'poop') game.stats.poopShipmentIncome = adjusted;
|
||||
else game.stats.chickShipmentIncome = adjusted;
|
||||
game.stats.sausageRevenue = sausageRevenue;
|
||||
if (target === 'poop') game.stats.poopShipmentIncome = base;
|
||||
else game.stats.chickShipmentIncome = base;
|
||||
if (adjusted > 0) applyRevenue(game, adjusted);
|
||||
return { base, adjusted, target, targetCount, unitPrice };
|
||||
return { base, adjusted, target, targetCount, unitPrice, sausageCount, sausageUnitPrice, sausageRevenue };
|
||||
}
|
||||
|
||||
export function collectFairiesTribute(game, turn = game.turn, basisProfit = null, options = {}) {
|
||||
|
|
|
|||
4
src/systems/effects.js
vendored
4
src/systems/effects.js
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import { EFFECT_PRIORITY, THEME } from '../core/config.js';
|
||||
import { BALANCE } from '../core/balance.js';
|
||||
import { randomBetween, yen } from '../core/utils.js';
|
||||
import { applyExplosionDamage } from './economy.js';
|
||||
|
||||
|
|
@ -46,7 +47,8 @@ export function smokeBurst(game, x, y, count = 10) {
|
|||
export function flyingDebris(game, sex, x, y, baseAngle = null, noDamage = false) {
|
||||
const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9);
|
||||
const speed = randomBetween(260, 620);
|
||||
game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8), noDamage });
|
||||
const horizontalMultiplier = Math.max(0, Number(BALANCE.effects?.explosionHorizontalVelocityMultiplier) || 1);
|
||||
game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed * horizontalMultiplier, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8), noDamage });
|
||||
}
|
||||
export function eraseEffect(game, x, y) {
|
||||
game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 });
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { TURN_SECONDS, POOP_RATE, POOP_RATE_CARD_BONUS, DAILY_EVENT_CHANCE, DAIL
|
|||
import { getSpawnRange } from '../core/state.js';
|
||||
import { applyRevenue } from './economy.js';
|
||||
|
||||
const EVENT_TARGETS = [
|
||||
export const EVENT_TARGETS = [
|
||||
{
|
||||
key: 'premiumFemaleShipment',
|
||||
target: 'female',
|
||||
|
|
@ -138,11 +138,11 @@ function estimateForTemplate(game, template) {
|
|||
return { estimate, poopRate };
|
||||
}
|
||||
|
||||
export function rollEventOffer(game) {
|
||||
const nextTurn = game.turn + 1;
|
||||
if (nextTurn < DAILY_EVENT_FIRST_TURN) return null;
|
||||
if (Math.random() > DAILY_EVENT_CHANCE) return null;
|
||||
const template = EVENT_TARGETS[Math.floor(Math.random() * EVENT_TARGETS.length)];
|
||||
export function createEventOffer(game, templateOrKey) {
|
||||
const template = typeof templateOrKey === 'string'
|
||||
? EVENT_TARGETS.find(item => item.key === templateOrKey)
|
||||
: templateOrKey;
|
||||
if (!template) return null;
|
||||
const { estimate, poopRate } = estimateForTemplate(game, template);
|
||||
let targetAmount = 0;
|
||||
if (template.objective === 'truckTarget') targetAmount = Math.max(1, Math.ceil(estimate * 0.3));
|
||||
|
|
@ -169,6 +169,14 @@ export function rollEventOffer(game) {
|
|||
};
|
||||
}
|
||||
|
||||
export function rollEventOffer(game) {
|
||||
const nextTurn = game.turn + 1;
|
||||
if (nextTurn < DAILY_EVENT_FIRST_TURN) return null;
|
||||
if (Math.random() > DAILY_EVENT_CHANCE) return null;
|
||||
const template = EVENT_TARGETS[Math.floor(Math.random() * EVENT_TARGETS.length)];
|
||||
return createEventOffer(game, template);
|
||||
}
|
||||
|
||||
export function activateEventOffer(game) {
|
||||
game.eventActive = game.eventOffer ? { ...game.eventOffer } : null;
|
||||
game.eventOffer = null;
|
||||
|
|
@ -249,7 +257,7 @@ function countLowDurabilityMachines(game) {
|
|||
for (const scanner of game.scanners || []) if (scanner.kind === 'auto') add(scanner.maintenance?.uses, scanner.maintenance?.durability);
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
const kind = f?.baseId || f?.id;
|
||||
if (kind === 'mixer' || kind === 'trash') add(f.maintenance?.uses, f.maintenance?.durability);
|
||||
if (kind === 'mixer' || kind === 'trash' || kind === 'sausageMaker') add(f.maintenance?.uses, f.maintenance?.durability);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export function snapshot(game) {
|
|||
gridCols: game.gridCols || GRID.cols,
|
||||
gridRows: game.gridRows || GRID.rows,
|
||||
gridExpansionPurchases: game.gridExpansionPurchases || 0,
|
||||
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { right: 0, up: 0 },
|
||||
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { left: 0, right: 0, up: 0, down: 0 },
|
||||
gridChunkOriginX: game.gridChunkOriginX || 0,
|
||||
gridChunkOriginY: game.gridChunkOriginY || 0,
|
||||
ownedCells: [...(game.ownedCells || [])],
|
||||
ownedChunks: [...(game.ownedChunks || [])],
|
||||
|
|
@ -47,7 +48,10 @@ export function restore(game, text) {
|
|||
game.gridCols = GRID.cols;
|
||||
game.gridRows = GRID.rows;
|
||||
game.gridExpansionPurchases = data.gridExpansionPurchases || 0;
|
||||
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { right: 0, up: 0 };
|
||||
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { left: 0, right: 0, up: 0, down: 0 };
|
||||
if (game.gridExpansionPurchasesByDirection.left == null) game.gridExpansionPurchasesByDirection.left = 0;
|
||||
if (game.gridExpansionPurchasesByDirection.down == null) game.gridExpansionPurchasesByDirection.down = 0;
|
||||
game.gridChunkOriginX = data.gridChunkOriginX || 0;
|
||||
game.gridChunkOriginY = data.gridChunkOriginY || 0;
|
||||
game.ownedCells = new Set(data.ownedCells || []);
|
||||
game.ownedChunks = new Set(data.ownedChunks || []);
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export function facilityProcessingDelay(game, target) {
|
|||
ensureMaintenanceState(game);
|
||||
const f = facilityFromTarget(game, target);
|
||||
const id = facilityKind(f || target);
|
||||
if (!f || !['mixer', 'trash'].includes(id)) return 0;
|
||||
if (!f || !['mixer', 'trash', 'sausageMaker'].includes(id)) return 0;
|
||||
attachMaintenance(f, id);
|
||||
const t = Math.max(0, delayMultiplier(f) - 1);
|
||||
return t * (RULES.processingDelayMaxSeconds?.[id] || 1);
|
||||
|
|
@ -195,7 +195,7 @@ export function recordFacilityProcess(game, target) {
|
|||
ensureMaintenanceState(game);
|
||||
const f = facilityFromTarget(game, target);
|
||||
const id = facilityKind(f || target);
|
||||
if (!f || !['mixer', 'trash'].includes(id)) return;
|
||||
if (!f || !['mixer', 'trash', 'sausageMaker'].includes(id)) return;
|
||||
attachMaintenance(f, id);
|
||||
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
|
||||
}
|
||||
|
|
@ -210,7 +210,7 @@ export function setFacilityCooldownAfterProcess(game, target) {
|
|||
|
||||
export function updateProcessingCooldowns(game, dt) {
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
if (!['mixer', 'trash'].includes(f.baseId || f.id)) continue;
|
||||
if (!['mixer', 'trash', 'sausageMaker'].includes(f.baseId || f.id)) continue;
|
||||
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +223,7 @@ export function equipmentMaintenanceTargets(game) {
|
|||
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
const kind = facilityKind(f);
|
||||
if (!['mixer', 'trash'].includes(kind)) continue;
|
||||
if (!['mixer', 'trash', 'sausageMaker'].includes(kind)) continue;
|
||||
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
|
||||
}
|
||||
return targets;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DIRS, GRID, MACHINE_FACILITY_IDS } from '../core/config.js';
|
||||
import { DIRS, GRID, MACHINE_FACILITY_IDS, REQUIRED_RECEIVER_FACILITY_IDS } from '../core/config.js';
|
||||
import { routeLabel } from '../core/text.js';
|
||||
import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js';
|
||||
|
||||
|
|
@ -11,14 +11,12 @@ export function scannerFootprintCells(scanner) {
|
|||
{ col: scanner.col, row: scanner.row - 1 },
|
||||
{ col: scanner.col + 1, row: scanner.row - 1 },
|
||||
{ col: scanner.col, row: scanner.row },
|
||||
{ col: scanner.col + 1, row: scanner.row },
|
||||
{ col: scanner.col, row: scanner.row + 1 },
|
||||
{ col: scanner.col + 1, row: scanner.row + 1 }
|
||||
{ col: scanner.col + 1, row: scanner.row }
|
||||
];
|
||||
}
|
||||
export function scannerCenter(scanner) {
|
||||
const c = cellCenter(scanner.col, scanner.row);
|
||||
return { x: c.x + GRID.cell / 2, y: c.y };
|
||||
return { x: c.x + GRID.cell / 2, y: c.y - GRID.cell / 2 };
|
||||
}
|
||||
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
|
||||
export function scannerAt(game, col, row) { return game.scanners.find(s => scannerFootprintCells(s).some(p => p.col === col && p.row === row)) || null; }
|
||||
|
|
@ -29,6 +27,52 @@ function facilityEntriesForKind(game, kind) {
|
|||
return Object.entries(game.facilities || {}).filter(([, f]) => facilityKind(f) === kind);
|
||||
}
|
||||
|
||||
function usableOwnedCell(game, p) {
|
||||
return !!(p && inGrid(p.col, p.row) && (!game?.ownedCells?.size || game.ownedCells.has(key(p.col, p.row))));
|
||||
}
|
||||
|
||||
export function gridFacilityAt(game, col, row) {
|
||||
return Object.values(game?.facilities || {}).find(f => {
|
||||
if (!f?.gridPlaced) return false;
|
||||
return col >= f.col && col < f.col + (f.wCells || 3) && row >= f.row && row < f.row + (f.hCells || 3);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
export function facilityInputCell(game, facility) {
|
||||
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
|
||||
if (!f) return null;
|
||||
if (facilityKind(f) === 'sausageMaker' && f.gridPlaced) {
|
||||
const p = { col: f.col - 1, row: f.row + Math.floor((f.hCells || 3) / 2) };
|
||||
return usableOwnedCell(game, p) ? p : null;
|
||||
}
|
||||
return usableOwnedCell(game, f.entry) ? { ...f.entry } : null;
|
||||
}
|
||||
|
||||
export function facilityOutputCell(game, facility) {
|
||||
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
|
||||
const kind = facilityKind(f);
|
||||
if (!f || !['mixer', 'sausageMaker'].includes(kind)) return null;
|
||||
if (kind === 'sausageMaker' && f.gridPlaced) {
|
||||
const p = { col: f.col + (f.wCells || 3), row: f.row + Math.floor((f.hCells || 3) / 2) };
|
||||
return usableOwnedCell(game, p) ? p : null;
|
||||
}
|
||||
const input = facilityInputCell(game, f);
|
||||
if (!input) return null;
|
||||
const offsets = [2, -2, 3, -3, 1, -1];
|
||||
for (const offset of offsets) {
|
||||
const p = (f.side === 'left' || f.side === 'right')
|
||||
? { col: input.col, row: input.row + offset }
|
||||
: { col: input.col + offset, row: input.row };
|
||||
if (usableOwnedCell(game, p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function facilityOutputHasConveyor(game, facility) {
|
||||
const p = facilityOutputCell(game, facility);
|
||||
return !!(p && game?.conveyorTiles?.has?.(key(p.col, p.row)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Port definitions. Ports prefer the exact connector cell, but also accept
|
||||
// visually touching adjacent conveyor cells so drawn connections and routing match.
|
||||
|
|
@ -73,7 +117,7 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
|
|||
export function scannerInputCells(game, scanner) {
|
||||
const connector = scannerConnector(scanner, 'inputA');
|
||||
if (!connector || !inGrid(connector.col, connector.row)) return [];
|
||||
return game.conveyorTiles.has(key(connector.col, connector.row)) ? [{ ...connector, viaTolerance: false }] : [];
|
||||
return graphPortConveyorCells(game, connector, scannerFootprintCells(scanner));
|
||||
}
|
||||
|
||||
function outputStartCells(game, scanner, side) {
|
||||
|
|
@ -84,7 +128,8 @@ function outputStartCells(game, scanner, side) {
|
|||
export function facilityEntryPoint(game, dest) {
|
||||
const f = game.facilities[dest] || facilityEntriesForKind(game, dest)[0]?.[1];
|
||||
if (!f) return null;
|
||||
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
|
||||
const input = facilityInputCell(game, f);
|
||||
if (input) return cellCenter(input.col, input.row);
|
||||
const kind = facilityKind(f) || dest;
|
||||
if (kind === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
|
||||
if (kind === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
|
||||
|
|
@ -103,6 +148,11 @@ export function refreshRoutingAfterEdit(game) {
|
|||
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||
game.factoryGraph = null;
|
||||
game.factoryGraphCommittedVersion = -1;
|
||||
if (game.frameCache) {
|
||||
game.frameCache.routingVersion = -1;
|
||||
game.frameCache.congestionFrame = -1;
|
||||
game.frameCache.draw = {};
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
@ -139,12 +189,13 @@ function inferredConveyorOutDirNames(game, cellKey) {
|
|||
.map(d => d.name);
|
||||
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
|
||||
const dirs = explicit.length ? explicit : neighbors;
|
||||
if (neighbors.length === 1 && (explicit.length ? explicit.includes(neighbors[0]) : true) && neighborPointsIntoCell(game, p, neighbors[0])) return [];
|
||||
if (neighbors.length < 3) return dirs;
|
||||
const entrances = new Set(neighbors.filter(dir => neighborPointsIntoCell(game, p, dir)));
|
||||
const physicalExits = neighbors.filter(dir => !entrances.has(dir));
|
||||
let candidates = entrances.size && physicalExits.length >= 2 ? physicalExits : [...new Set(dirs)];
|
||||
if (candidates.length < 2) candidates = neighbors;
|
||||
if (meta.branchMode && meta.branchMode !== 'random' && candidates.includes(meta.branchMode)) return [meta.branchMode];
|
||||
let candidates = entrances.size ? physicalExits : [...new Set(dirs)];
|
||||
if (!candidates.length && !entrances.size) candidates = neighbors;
|
||||
if (meta.branchMode && meta.branchMode !== 'random' && candidates.length >= 2 && candidates.includes(meta.branchMode)) return [meta.branchMode];
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
|
@ -237,10 +288,8 @@ function indexGraphPorts(game, graph) {
|
|||
const ports = {};
|
||||
for (const type of ['inputA', 'left', 'right']) {
|
||||
const cell = scannerConnector(scanner, type);
|
||||
const cells = type === 'inputA'
|
||||
? (cell && graph.cells.has(graphNodeKey(cell.col, cell.row)) ? [{ ...cell }] : [])
|
||||
: graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
|
||||
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
const cells = graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
|
||||
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
|
||||
const connected = cellKeys.length > 0;
|
||||
ports[type] = { type, cell, cells, keys: cellKeys, key: cellKeys[0] || null, connected };
|
||||
|
|
@ -252,11 +301,12 @@ function indexGraphPorts(game, graph) {
|
|||
}
|
||||
|
||||
for (const [id, f] of Object.entries(game.facilities)) {
|
||||
if (!f.entry) continue;
|
||||
const cells = graphPortConveyorCells(game, f.entry).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
const input = facilityInputCell(game, f);
|
||||
if (!input) continue;
|
||||
const cells = graphPortConveyorCells(game, input).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
|
||||
const connected = cellKeys.length > 0;
|
||||
graph.facilityPorts.set(id, { id, cell: { ...f.entry }, cells, keys: cellKeys, key: cellKeys[0] || null, connected });
|
||||
graph.facilityPorts.set(id, { id, cell: { ...input }, cells, keys: cellKeys, key: cellKeys[0] || null, connected });
|
||||
graph.metrics.facilityPorts += 1;
|
||||
if (connected) graph.metrics.connectedFacilityPorts += 1;
|
||||
}
|
||||
|
|
@ -458,7 +508,12 @@ function candidateCongestionScore(game, candidate) {
|
|||
const cells = candidate.cells || [];
|
||||
let score = 0;
|
||||
const limit = Math.min(cells.length, 8);
|
||||
for (let i = 0; i < limit; i += 1) score += chickCellOccupancyScore(game, cells[i], i);
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const cell = cells[i];
|
||||
const compId = game.componentLookup?.get?.(graphNodeKey(cell.col, cell.row));
|
||||
const ratio = compId ? (game.congestion?.get?.(compId)?.ratio || 0) : 0;
|
||||
score += chickCellOccupancyScore(game, cell, i) + ratio * 25;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
|
|
@ -478,8 +533,9 @@ export function chooseRoundRobin(game, id, candidates, advance = false) {
|
|||
if (!advance || sorted.length === 1) return sorted[0];
|
||||
const bestBias = sorted[0].roleBias ?? 0;
|
||||
let pool = sorted.filter(item => (item.roleBias ?? 0) === bestBias);
|
||||
const bestScore = Math.min(...pool.map(item => candidateCongestionScore(game, item)));
|
||||
pool = pool.filter(item => candidateCongestionScore(game, item) === bestScore);
|
||||
const scored = pool.map(item => ({ item, score: candidateCongestionScore(game, item) }));
|
||||
const bestScore = Math.min(...scored.map(entry => entry.score));
|
||||
pool = scored.filter(entry => entry.score === bestScore).map(entry => entry.item);
|
||||
const n = game.branchCounters.get(id) || 0;
|
||||
game.branchCounters.set(id, n + 1);
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
|
|
@ -503,7 +559,7 @@ export function autoSideFor(scanner, chick) {
|
|||
|
||||
export function destinationLabel(dest) { return routeLabel(dest); }
|
||||
export function destinationColor(dest) {
|
||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', sausageMaker: '#d78b3f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
|
|
@ -592,7 +648,7 @@ function outputRouteReachesFacility(game, scanner, visited = new Set()) {
|
|||
for (const side of ['left', 'right']) {
|
||||
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false);
|
||||
if (!plan) continue;
|
||||
if (['mixer', 'trash', 'truck'].includes(plan.destination)) return true;
|
||||
if (['mixer', 'trash', 'truck', 'sausageMaker'].includes(plan.destination)) return true;
|
||||
if (plan.destination === 'scanner') {
|
||||
const next = scannerById(game, plan.nextScannerId);
|
||||
if (outputRouteReachesFacility(game, next, visited)) return true;
|
||||
|
|
@ -654,7 +710,7 @@ export function minimumStartConnectionIssues(game) {
|
|||
if (!graph.metrics.farmOutputs) return ['Connect at least one EGG to a conveyor.'];
|
||||
if (!game.scanners?.length) return ['Build at least one scanner and connect it to an EGG route.'];
|
||||
if (!exitCount) return ['Connect at least one receiver exit: Mixer, Shredder, or Truck.'];
|
||||
return ['Connect at least one EGG through a scanner to any valid exit.'];
|
||||
return ['No route from an EGG scanner to a receiver.'];
|
||||
}
|
||||
|
||||
function scannerName(scanner) {
|
||||
|
|
@ -665,12 +721,12 @@ function scannerName(scanner) {
|
|||
export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), options = {}) {
|
||||
const includeRoutes = options.includeRoutes !== false;
|
||||
const issues = [];
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck', sausageMaker: 'Sausage Machine' };
|
||||
|
||||
for (const id of MACHINE_FACILITY_IDS) {
|
||||
const facilities = facilityEntriesForKind(game, id);
|
||||
if (!facilities.length) {
|
||||
issues.push(`${labels[id] || id} is missing`);
|
||||
if (REQUIRED_RECEIVER_FACILITY_IDS.includes(id)) issues.push(`${labels[id] || id} is missing`);
|
||||
continue;
|
||||
}
|
||||
for (const [facilityId] of facilities) {
|
||||
|
|
@ -706,7 +762,3 @@ export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), opt
|
|||
export function facilityConnectionIssues(game) {
|
||||
return minimumStartConnectionIssues(game);
|
||||
}
|
||||
|
||||
export function factoryReady(game) {
|
||||
return facilityConnectionIssues(game).length === 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { GRID } from '../core/config.js';
|
||||
import { DIRS, GRID } from '../core/config.js';
|
||||
import { key, parseKey, cellCenter } from '../core/utils.js';
|
||||
import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint } from '../core/entities.js';
|
||||
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint, gridFacilityFootprintCells, syncGridFacilityGeometry } from '../core/entities.js';
|
||||
import { farmAt, scannerAt, scannerCenter, scannerConnector, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { snapshot } from './history.js';
|
||||
import { buildPrice } from './economy.js';
|
||||
|
||||
|
|
@ -124,7 +124,9 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: buildPrice('conveyor'), builtSession: null }, col: cell.col, row: cell.row };
|
||||
}
|
||||
if (sel.type === 'eggFarm' || sel.type === 'scanner' || sel.type === 'maintenanceRoom') return { ...sel, obj, col: obj.col, row: obj.row };
|
||||
if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
|
||||
if (sel.type === 'facility') return obj.gridPlaced
|
||||
? { ...sel, obj, gridPlaced: true, col: obj.col, row: obj.row, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } }
|
||||
: { ...sel, obj, gridPlaced: false, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -157,16 +159,47 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
|
||||
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
|
||||
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return true;
|
||||
const gridFacility = gridFacilityAt(game, col, row);
|
||||
if (gridFacility && !selectedTokens.has(`facility:${gridFacility.id}`)) return true;
|
||||
if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function facilityPortOccupiedByNonSelected(col, row, selectedTokens) {
|
||||
for (const facility of Object.values(game.facilities || {})) {
|
||||
if (selectedTokens.has(`facility:${facility.id}`)) continue;
|
||||
const input = facilityInputCell(game, facility);
|
||||
const output = facilityOutputCell(game, facility);
|
||||
if ((input && input.col === col && input.row === row) || (output && output.col === col && output.row === row)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function gridFacilityPortOccupiedByNonSelected(facility, col, row, selectedTokens) {
|
||||
const farm = farmAt(game, col, row);
|
||||
if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
|
||||
const scanner = scannerAt(game, col, row);
|
||||
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
|
||||
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
|
||||
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return true;
|
||||
const gridFacility = gridFacilityAt(game, col, row);
|
||||
if (gridFacility && gridFacility.id !== facility.id && !selectedTokens.has(`facility:${gridFacility.id}`)) return true;
|
||||
for (const other of Object.values(game.facilities || {})) {
|
||||
if (other.id === facility.id || selectedTokens.has(`facility:${other.id}`)) continue;
|
||||
const input = facilityInputCell(game, other);
|
||||
const output = facilityOutputCell(game, other);
|
||||
if ((input && input.col === col && input.row === row) || (output && output.col === col && output.row === row)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function facilityDragWouldOverlap(dx, dy, selectedTokens) {
|
||||
const candidateRects = [];
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
if (origin.type !== 'facility') continue;
|
||||
if (origin.type !== 'facility' || origin.gridPlaced) continue;
|
||||
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
|
||||
const { entry, side } = nearestGridEdge(nextCenter);
|
||||
const { entry, side } = nearestGridEdge(nextCenter, game);
|
||||
if (!pointInGrid(entry.col, entry.row)) return true;
|
||||
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
|
||||
const draft = { ...origin.obj, entry: { ...entry }, side };
|
||||
|
|
@ -187,6 +220,27 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
return false;
|
||||
}
|
||||
|
||||
function repairScannerOutputDirections(scanner) {
|
||||
const body = new Set(scannerFootprintCells(scanner).map(cell => key(cell.col, cell.row)));
|
||||
for (const side of ['left', 'right']) {
|
||||
const port = scannerConnector(scanner, side);
|
||||
const portKey = key(port.col, port.row);
|
||||
if (!game.conveyorTiles.has(portKey)) continue;
|
||||
const meta = game.conveyorMeta.get(portKey);
|
||||
if (!meta) continue;
|
||||
const preferred = DIRS.find(d => d.name === side);
|
||||
const candidates = DIRS.filter(d => {
|
||||
const nk = key(port.col + d.dc, port.row + d.dr);
|
||||
return game.conveyorTiles.has(nk) && !body.has(nk);
|
||||
});
|
||||
const chosen = candidates.find(d => d.name === preferred?.name) || candidates[0] || preferred;
|
||||
if (!chosen) continue;
|
||||
meta.dir = chosen.name;
|
||||
meta.outDirs = [chosen.name];
|
||||
meta.branchMode = 'random';
|
||||
}
|
||||
}
|
||||
|
||||
function updateGroupDrag(event) {
|
||||
if (!game.groupDrag) return;
|
||||
const p = canvasPoint(event);
|
||||
|
|
@ -198,17 +252,31 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
const selectedTokens = new Set(game.groupDrag.selections.map(selectionToken));
|
||||
const targetCells = new Set();
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
if (origin.type === 'facility') continue;
|
||||
if (origin.type === 'facility' && !origin.gridPlaced) continue;
|
||||
const col = origin.col + dcol, row = origin.row + drow;
|
||||
const cells = origin.type === 'scanner' ? scannerFootprintCells({ col, row }) : (origin.type === 'maintenanceRoom' ? maintenanceRoomFootprint({ col, row }) : [{ col, row }]);
|
||||
const cells = origin.type === 'scanner'
|
||||
? scannerFootprintCells({ col, row })
|
||||
: (origin.type === 'maintenanceRoom'
|
||||
? maintenanceRoomFootprint({ col, row })
|
||||
: (origin.type === 'facility' ? gridFacilityFootprintCells({ ...origin.obj, col, row }) : [{ col, row }]));
|
||||
for (const cell of cells) {
|
||||
if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid');
|
||||
if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground');
|
||||
const tk = key(cell.col, cell.row);
|
||||
if (targetCells.has(tk)) return fail('Selection overlap');
|
||||
if (cellOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Cell occupied');
|
||||
if (origin.type !== 'conveyor' && facilityPortOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Machine port must remain open');
|
||||
targetCells.add(tk);
|
||||
}
|
||||
if (origin.type === 'facility' && origin.gridPlaced) {
|
||||
const draft = { ...origin.obj, col, row };
|
||||
const input = { col: col - 1, row: row + Math.floor((draft.hCells || 3) / 2) };
|
||||
const output = { col: col + (draft.wCells || 3), row: row + Math.floor((draft.hCells || 3) / 2) };
|
||||
for (const port of [input, output]) {
|
||||
if (!pointInGrid(port.col, port.row) || game.blockedCells?.has?.(key(port.col, port.row))) return fail('Machine port outside usable grid');
|
||||
if (gridFacilityPortOccupiedByNonSelected(origin.obj, port.col, port.row, selectedTokens)) return fail('Machine port is occupied');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
|
||||
commitGroupDragHistory();
|
||||
|
|
@ -232,12 +300,17 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
} else if (origin.type === 'scanner') {
|
||||
origin.obj.col = origin.col + dcol;
|
||||
origin.obj.row = origin.row + drow;
|
||||
repairScannerOutputDirections(origin.obj);
|
||||
} else if (origin.type === 'maintenanceRoom') {
|
||||
origin.obj.col = origin.col + dcol;
|
||||
origin.obj.row = origin.row + drow;
|
||||
} else if (origin.type === 'facility' && origin.gridPlaced) {
|
||||
origin.obj.col = origin.col + dcol;
|
||||
origin.obj.row = origin.row + drow;
|
||||
syncGridFacilityGeometry(origin.obj);
|
||||
} else if (origin.type === 'facility') {
|
||||
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
|
||||
const { entry, side } = nearestGridEdge(nextCenter);
|
||||
const { entry, side } = nearestGridEdge(nextCenter, game);
|
||||
layoutFacilityOnEdge(origin.obj, entry, side);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, DAILY_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
|
||||
import { yen } from '../core/utils.js';
|
||||
import { TEXT } from '../core/text.js';
|
||||
import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js';
|
||||
import { routeFromFarmToScanner, facilityConnectionIssues } from './routing.js';
|
||||
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
|
||||
import { truckTarget } from './events.js';
|
||||
import { conveyorSpeedForGame } from './cards.js';
|
||||
import { maintenanceSummary } from './maintenance.js';
|
||||
|
||||
|
|
@ -129,16 +128,21 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
}
|
||||
const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
|
||||
const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount;
|
||||
ui.buttons.nextTurn.innerHTML = game.phase === 'build' && nextTribute > 0
|
||||
const nextTurnHtml = game.phase === 'build' && nextTribute > 0
|
||||
? `<span class="next-day-main">NEXT DAY</span><small class="next-day-fee">FAIRIES -${yen(nextTribute)}</small>`
|
||||
: `<span class="next-day-main">${TEXT.actions.nextDay}</span>`;
|
||||
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
|
||||
// Replacing a pressed button's child nodes every animation frame can cancel
|
||||
// the browser's pointerdown -> pointerup -> click sequence. Render only when
|
||||
// the label actually changes, so one deliberate press always reaches the handler.
|
||||
if (ui.buttons.nextTurn.dataset.renderedHtml !== nextTurnHtml) {
|
||||
ui.buttons.nextTurn.innerHTML = nextTurnHtml;
|
||||
ui.buttons.nextTurn.dataset.renderedHtml = nextTurnHtml;
|
||||
}
|
||||
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
|
||||
}
|
||||
|
||||
function updatePriorityStrip() {
|
||||
if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return;
|
||||
const target = truckTarget(game).toUpperCase();
|
||||
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
|
||||
if (!ui.eventBrief || !ui.speedBrief) return;
|
||||
ui.speedBrief.textContent = `BELT SPEED: ${displaySpeed()} px/s`;
|
||||
ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
|
||||
const active = game.eventActive;
|
||||
|
|
@ -168,7 +172,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
|
||||
function showTitle() {
|
||||
ui.modalTitle.textContent = TEXT.versionTitle;
|
||||
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">♂</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">♀</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">💩</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
|
||||
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">♂</span><strong>Male</strong><b>-></b><em>Mixer -> Meat</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">♀</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">💩</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
|
||||
|
|
@ -186,7 +190,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
<div class="result-grid settlement-grid">
|
||||
<div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div>
|
||||
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
|
||||
<div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div>
|
||||
<div><strong>Sausage shipment income</strong>${positive(r.sausageRevenue || 0)}</div>
|
||||
<div><strong>Meat / sausages</strong><span>${r.meatProduced || 0} meat -> ${r.sausageProduced || 0} made / ${r.sausageShipped || 0} shipped</span></div>
|
||||
<div><strong>Composter income</strong>${positive(r.composterIncome || 0)}</div>
|
||||
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
|
||||
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
|
||||
|
|
|
|||
24
styles.css
24
styles.css
|
|
@ -37,7 +37,6 @@ h1, h2, p { margin: 0; }
|
|||
.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.event-brief.locked { background: #edf3ec; color: var(--muted); }
|
||||
.target-brief { color: var(--green); }
|
||||
.speed-brief { color: var(--ink); }
|
||||
|
||||
.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; }
|
||||
|
|
@ -52,6 +51,7 @@ h1, h2, p { margin: 0; }
|
|||
.tool-button.small { min-height: 40px; padding: 6px; }
|
||||
.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); }
|
||||
.tool-button { min-height: 50px; text-align: left; padding: 8px; }
|
||||
.tool-button .tool-icon { width: 24px; height: 24px; image-rendering: pixelated; float: left; margin: 0 8px 4px 0; object-fit: contain; }
|
||||
.tool-button strong { display: block; font-size: 12px; }
|
||||
.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); }
|
||||
.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); }
|
||||
|
|
@ -104,6 +104,7 @@ h1, h2, p { margin: 0; }
|
|||
.debug-check,
|
||||
.debug-card,
|
||||
.debug-target,
|
||||
.debug-event,
|
||||
.debug-readout {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
|
@ -600,7 +601,7 @@ body { font-size: 20px; }
|
|||
.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; }
|
||||
.hover-tooltip strong { font-size: 18px; }
|
||||
|
||||
/* Keep construction flavor text readable as a hover bubble, not button content. */
|
||||
/* Keep construction flavor text readable as a hover bubble. */
|
||||
.large-tools .tool-button {
|
||||
min-height: 62px;
|
||||
position: relative;
|
||||
|
|
@ -626,7 +627,7 @@ body { font-size: 20px; }
|
|||
background: rgba(255,255,255,.98);
|
||||
color: var(--ink);
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
overflow-wrap: anywhere;
|
||||
|
|
@ -846,7 +847,6 @@ body {
|
|||
.event-brief.active { background: #38251a; box-shadow: inset 0 0 0 3px var(--orange), 4px 4px 0 rgba(0,0,0,.66); }
|
||||
.event-brief.next { background: #1c2c20; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.66); }
|
||||
.event-brief.locked { background: #181b20; color: var(--muted); }
|
||||
.target-brief { color: var(--warn); }
|
||||
.speed-brief { color: var(--cyan); }
|
||||
.build-panel {
|
||||
top: 82px;
|
||||
|
|
@ -1164,7 +1164,6 @@ body {
|
|||
.left-bottom-status .event-brief.next { background: #22301f; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.68); }
|
||||
.left-bottom-status .event-brief.locked { background: #1c2027; color: var(--muted); }
|
||||
.left-bottom-status .speed-brief { color: #d9e2e8; }
|
||||
.left-bottom-status .target-brief { color: #ffcf68; }
|
||||
.left-bottom-status .combo-brief { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.left-bottom-status .combo-brief span { color: var(--muted); font-size: 10px; }
|
||||
.left-bottom-status .combo-brief strong { color: var(--green); font-size: 22px; line-height: 1; }
|
||||
|
|
@ -1273,6 +1272,21 @@ body {
|
|||
.card-choice.ultra-rare .card-icon { opacity: .17; }
|
||||
.card-choice.dud .card-icon { opacity: .08; filter: grayscale(1); }
|
||||
|
||||
/* Construction tool asset icons. */
|
||||
.large-tools .tool-button .tool-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
float: left;
|
||||
margin: 0 8px 4px 0;
|
||||
filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
|
||||
}
|
||||
.large-tools .tool-button strong,
|
||||
.large-tools .tool-button .tool-price {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.hud-top-left { grid-template-columns: repeat(3, minmax(80px, 1fr)); }
|
||||
.left-bottom-status { width: 290px; left: 8px; bottom: 8px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue