k
This commit is contained in:
parent
ca40a0cb73
commit
2c7e525c53
21 changed files with 1730 additions and 285 deletions
107
README.md
107
README.md
|
|
@ -1,4 +1,57 @@
|
|||
# Chick Sorter v16.1 Card Target Overlay Fix
|
||||
# Chick Sorter v23.0 Phase 2 Routefix Hotfix
|
||||
|
||||
|
||||
This build changes the SHREDDER Improvement payout rule.
|
||||
|
||||
## v18 changes
|
||||
- `SHREDDER Improvement` now uses one payout roll per processed item.
|
||||
- Probability after n cards: `((√n / 2) × n)%`.
|
||||
- Maximum SHREDDER Improvement cards counted: 30.
|
||||
- At 30 cards, the chance is about 82.16%, so the expected payout is about ¥0.8216 per processed item.
|
||||
|
||||
## SHREDDER Improvement expected value
|
||||
|
||||
```text
|
||||
n = min(SHREDDER Improvement cards, 30)
|
||||
p = ((√n / 2) × n)% = n√n / 200
|
||||
Per item payout X ~ Bernoulli(p)
|
||||
E[X] = p yen = n√n / 200 yen
|
||||
```
|
||||
|
||||
For k shredded items, expected total bonus is `k × n√n / 200` yen.
|
||||
|
||||
---
|
||||
|
||||
# Previous build notes
|
||||
|
||||
# Chick Sorter v17.0 Shredder Card and Cleanup
|
||||
|
||||
This build adjusts selling constraints, timeout cleanup, settlement UI, and adds the SHREDDER improvement card.
|
||||
|
||||
## v17 changes
|
||||
- The last EGG FARM, MIXER, SHREDDER, and TRUCK can no longer be sold.
|
||||
- Non-last equipment still follows the existing rule: same Build phase removal refunds 100%, older equipment sells for 50%.
|
||||
- When the day timer and shutdown grace expire, remaining chicks/poop are blown off the conveyors with no equipment damage.
|
||||
- Settlement appears 1 second after that cleanup.
|
||||
- The day settlement modal is reduced to two lines: one simple formula and final profit.
|
||||
- Added `SHREDDER Improvement` card.
|
||||
- Each card adds one independent trial per shredded item.
|
||||
- Each trial has a 1/3 chance to pay ¥1.
|
||||
- Expected value per processed item after n cards: n / 3 yen.
|
||||
- EGG FARM price remains ¥250.
|
||||
|
||||
## SHREDDER Improvement expected value
|
||||
|
||||
```text
|
||||
Per item payout X ~ Binomial(n, 1/3)
|
||||
E[X] = n × 1/3 = n/3 yen
|
||||
```
|
||||
|
||||
For k shredded items, expected total bonus is `k × n / 3` yen.
|
||||
|
||||
---
|
||||
|
||||
## Previous notes
|
||||
|
||||
This build refines the v15 card and tax system.
|
||||
|
||||
|
|
@ -38,3 +91,55 @@ ZUNDA TAX = ceil(taxable × rate)
|
|||
```
|
||||
|
||||
With no `Legal Work`, `exemption = ¥500`, so ¥501 starts at 5% and ¥10,000 reaches 95%.
|
||||
|
||||
## v19 changes
|
||||
- Added collapsed bottom-left debug panel: infinite cash, day override, free upgrade purchase.
|
||||
- Added `src/core/balance.js` as the primary balance-tuning sheet for prices, income, penalties, timing, card rates, and caps.
|
||||
- Increased UI text size.
|
||||
- Auto Scanner base cooldown is now 2.25 seconds.
|
||||
- High-Quality Bearing now adds +7.5% conveyor speed per card.
|
||||
- Equipment click popover removed; hover now shows flavor text and equipment information.
|
||||
- ERASE button renamed to SELL.
|
||||
- Card drafts can contain up to 2 dud cards; each slot has a 30% dud chance. Dud cards can be clicked away without spending a pick.
|
||||
|
||||
## v20 Phase 1 structural changes
|
||||
- Added a canonical FactoryGraph in `src/systems/routing.js`.
|
||||
- Build/edit operations invalidate the graph.
|
||||
- Route planning, connection validation, congestion component lookup, and debug metrics now read from the same graph model.
|
||||
- Scanner and facility ports are exact grid cells; visual adjacency is not accepted as connectivity.
|
||||
- `Next Day` now commits the current FactoryGraph before fees and day start.
|
||||
- The committed snapshot records conveyor cells, edges, components, port counts, farm outputs, and validation issues.
|
||||
- Added `src/systems/gameEvents.js`.
|
||||
- Major lifecycle, cash, build/sell, move, cleanup, and jam events are stored in a bounded run log.
|
||||
- Expanded the debug panel from cheats only into an observability panel.
|
||||
- Shows FactoryGraph size, components, port status, farm outputs, dead ends, active/queued items, max congestion, and recent events.
|
||||
- This is a structural pass, not a content/balance pass. It is meant to make later map generation, market contracts, route diagnostics, replay, and deeper factory simulation safer to add.
|
||||
|
||||
## v21 Phase 2 structural changes
|
||||
|
||||
Implemented the Phase 2 foundation without the market board or programmable scanner logic.
|
||||
|
||||
- Added seeded no-build terrain generation. Roughly 20% of grid cells become blocked each run while preserving the initial critical route.
|
||||
- Added equipment degradation based on actual use.
|
||||
- Truck and Manual Scanner are exempt and always operate normally.
|
||||
- Conveyor tiles degrade from item pass counts and reduce local belt speed.
|
||||
- Egg Farms degrade from egg production and gradually increase spawn interval.
|
||||
- Mixer, Waste Shredder, and Auto Scanner degrade from processing counts and add delay.
|
||||
- Equipment keeps full performance until 50% durability remains; at 0% durability it performs at half speed.
|
||||
- Added dirty overlay rendering for degraded equipment.
|
||||
- Added a one-day repairman hire button. The repairman costs ¥100 for the next production phase, walks freely across the map, and repairs 1% degradation every 3 seconds.
|
||||
- Expanded debug/hover readouts with blocked-cell and degradation information.
|
||||
|
||||
Market contracts and deeper scanner-programming logic are intentionally left for later Phase 2 work.
|
||||
|
||||
## v22 Phase 2 starter-route protection
|
||||
|
||||
No-build generation now protects the complete initial factory footprint before blocked cells are placed. The protected footprint includes the default Egg Farm, scanner bodies, scanner input/output ports, machine receiver cells, and every starter conveyor segment. After blocked-cell generation, the starter backbone is scrubbed from the blocked set and reinstalled as a final guard.
|
||||
|
||||
Result: even with roughly 20% no-build cells, the initial line remains continuous from Egg Farm to S1, S1 to S2, and S2 to both Shredder and Truck exits.
|
||||
|
||||
|
||||
## v23 hotfix
|
||||
|
||||
- Fixed the startup ReferenceError caused by binding the repairman button to a missing `hireRepairman` wrapper.
|
||||
- Disabled optional external art probing by default, preventing 404 console noise for placeholder image filenames. Canvas fallback art remains active.
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chick Sorter v15.0 Cards / Tribute</title>
|
||||
<title>Chick Sorter v21.0 Phase 2 Map / Degradation</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v15.0</span></h1>
|
||||
<h1>CHICK SORTER <span>v21.0</span></h1>
|
||||
<p>Build, drag, connect all receiver ports, then start the next day.</p>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||
|
|
@ -47,12 +47,13 @@
|
|||
<div class="large-tools">
|
||||
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / 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>¥360 / 4.5s cooldown</span></button>
|
||||
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 2.25s 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="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</span></button>
|
||||
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
|
||||
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>¥100 / next day</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>
|
||||
</div>
|
||||
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
|
||||
|
|
@ -81,6 +82,20 @@
|
|||
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
|
||||
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
|
||||
</div>
|
||||
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
|
||||
|
||||
<details id="debugPanel" class="debug-panel">
|
||||
<summary>DBG</summary>
|
||||
<div class="debug-body">
|
||||
<label><input id="debugInfiniteCash" type="checkbox" /> Infinite cash</label>
|
||||
<label>Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
|
||||
<button id="debugSetDayButton" type="button">Set Day</button>
|
||||
<label>Upgrade <select id="debugUpgradeSelect"></select></label>
|
||||
<button id="debugApplyUpgradeButton" type="button">Free Buy</button>
|
||||
<div id="debugObservations" class="debug-readout"></div>
|
||||
<div id="debugEventLog" class="debug-readout event-log"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
|
@ -92,6 +107,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/game.js?v=15.0-cards-tribute"></script>
|
||||
<script type="module" src="./src/game.js?v=23.0-phase2-routefix-hotfix"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
130
src/core/balance.js
Normal file
130
src/core/balance.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// Central balance sheet for tuning gameplay.
|
||||
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
|
||||
export const BALANCE = {
|
||||
version: 'v23.0 Phase 2 Routefix Hotfix',
|
||||
time: {
|
||||
daySeconds: 60,
|
||||
farmShutdownGraceSeconds: 10
|
||||
},
|
||||
cash: {
|
||||
starting: 250
|
||||
},
|
||||
grid: {
|
||||
x: 190,
|
||||
y: 142,
|
||||
cols: 22,
|
||||
rows: 13,
|
||||
cell: 46
|
||||
},
|
||||
map: {
|
||||
blockedRatio: 0.20
|
||||
},
|
||||
economy: {
|
||||
income: {
|
||||
mixer: 5,
|
||||
truck: 10
|
||||
},
|
||||
poopFine: 10,
|
||||
zundaTax: {
|
||||
exemption: 500,
|
||||
legalWorkExemptionBonus: 250,
|
||||
maxRateCash: 10000,
|
||||
stepAmount: 500,
|
||||
stepRate: 0.05,
|
||||
maxRate: 0.95
|
||||
},
|
||||
fairiesTribute: {
|
||||
perDay: 10
|
||||
},
|
||||
incomeUpgradeRate: 1.05,
|
||||
explosionDamageDivisor: 30,
|
||||
maleTruckFinePerHalfDay: 30,
|
||||
shredderBonus: {
|
||||
maxCards: 30,
|
||||
probabilityPercentDivisor: 2
|
||||
}
|
||||
},
|
||||
production: {
|
||||
poopRate: 0.08,
|
||||
autoScannerCooldown: 2.25,
|
||||
autoScannerUpgradeRate: 0.95,
|
||||
autoScannerMinCooldown: 0.5,
|
||||
eggSpawnRanges: [
|
||||
[1.95, 4.35],
|
||||
[1.55, 3.45],
|
||||
[1.25, 2.80],
|
||||
[1.05, 2.35]
|
||||
]
|
||||
},
|
||||
conveyor: {
|
||||
baseSpeed: 52,
|
||||
maxSpeed: 300,
|
||||
bearingSpeedMultiplier: 1.075
|
||||
},
|
||||
cards: {
|
||||
commonWeight: 8,
|
||||
rareWeight: 2,
|
||||
baseDraftSize: 3,
|
||||
maxDudsPerDraft: 2,
|
||||
dudChancePerCard: 0.30,
|
||||
extraCardsAddedPicks: 2,
|
||||
rerollCost: {
|
||||
dayDivisor: 2,
|
||||
multiplier: 100
|
||||
}
|
||||
},
|
||||
contracts: {
|
||||
eventChance: 0.70,
|
||||
firstTurn: 8
|
||||
},
|
||||
maintenance: {
|
||||
fullPerformanceUntilWear: 0.50,
|
||||
minimumPerformance: 0.50,
|
||||
durability: {
|
||||
conveyor: 260,
|
||||
eggFarm: 95,
|
||||
autoScanner: 170,
|
||||
mixer: 155,
|
||||
trash: 190
|
||||
},
|
||||
processingDelayMaxSeconds: {
|
||||
mixer: 1.20,
|
||||
trash: 1.00
|
||||
},
|
||||
repairman: {
|
||||
dailyCost: 100,
|
||||
secondsPerOnePercent: 3,
|
||||
walkSpeed: 140
|
||||
}
|
||||
},
|
||||
facilities: {
|
||||
conveyor: {
|
||||
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
eggFarm: {
|
||||
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
||||
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
|
||||
},
|
||||
manualScanner: {
|
||||
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
autoScanner: {
|
||||
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
mixer: {
|
||||
id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 }
|
||||
},
|
||||
trash: {
|
||||
id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240,
|
||||
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
|
||||
},
|
||||
truck: {
|
||||
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,70 +1,26 @@
|
|||
export const VERSION = 'v16.1 Card Target Overlay Fix';
|
||||
import { BALANCE } from './balance.js';
|
||||
|
||||
export const TURN_SECONDS = 60;
|
||||
export const STARTING_CASH = 250;
|
||||
export const VERSION = BALANCE.version;
|
||||
|
||||
export const ECONOMY = {
|
||||
income: {
|
||||
mixer: 5,
|
||||
truck: 10
|
||||
},
|
||||
poopFine: 10,
|
||||
zundaTax: {
|
||||
exemption: 500,
|
||||
legalWorkExemptionBonus: 250,
|
||||
maxRateCash: 10000,
|
||||
stepAmount: 500,
|
||||
stepRate: 0.05,
|
||||
maxRate: 0.95
|
||||
},
|
||||
fairiesTribute: {
|
||||
perDay: 10
|
||||
},
|
||||
incomeUpgradeRate: 1.05,
|
||||
explosionDamageDivisor: 30,
|
||||
maleTruckFinePerHalfDay: 30
|
||||
};
|
||||
export const TURN_SECONDS = BALANCE.time.daySeconds;
|
||||
export const STARTING_CASH = BALANCE.cash.starting;
|
||||
export const ECONOMY = BALANCE.economy;
|
||||
|
||||
export const FARM_SHUTDOWN_GRACE_SECONDS = 10;
|
||||
export const AUTO_SCANNER_COOLDOWN = 4.5;
|
||||
export const CONTRACT_EVENT_CHANCE = 0.70;
|
||||
export const CONTRACT_EVENT_FIRST_TURN = 8;
|
||||
export const POOP_RATE = 0.08;
|
||||
export const CONVEYOR_SPEED = 52;
|
||||
export const CONVEYOR_SPEED_MAX = 300;
|
||||
export const FARM_SHUTDOWN_GRACE_SECONDS = BALANCE.time.farmShutdownGraceSeconds;
|
||||
export const AUTO_SCANNER_COOLDOWN = BALANCE.production.autoScannerCooldown;
|
||||
export const AUTO_SCANNER_UPGRADE_RATE = BALANCE.production.autoScannerUpgradeRate;
|
||||
export const AUTO_SCANNER_MIN_COOLDOWN = BALANCE.production.autoScannerMinCooldown;
|
||||
export const CONTRACT_EVENT_CHANCE = BALANCE.contracts.eventChance;
|
||||
export const CONTRACT_EVENT_FIRST_TURN = BALANCE.contracts.firstTurn;
|
||||
export const POOP_RATE = BALANCE.production.poopRate;
|
||||
export const CONVEYOR_SPEED = BALANCE.conveyor.baseSpeed;
|
||||
export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
|
||||
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
|
||||
|
||||
export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 };
|
||||
|
||||
export const FACILITY_DEFS = {
|
||||
conveyor: {
|
||||
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
eggFarm: {
|
||||
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
||||
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
|
||||
},
|
||||
manualScanner: {
|
||||
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
autoScanner: {
|
||||
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
mixer: {
|
||||
id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 }
|
||||
},
|
||||
trash: {
|
||||
id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240,
|
||||
buildable: true, upgradeable: false, body: { w: 180, h: 100 }
|
||||
},
|
||||
truck: {
|
||||
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
|
||||
}
|
||||
};
|
||||
export const CARD_BALANCE = BALANCE.cards;
|
||||
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', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
|
||||
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
|
|
|
|||
43
src/core/mapGen.js
Normal file
43
src/core/mapGen.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { GRID } from './config.js';
|
||||
import { key } from './utils.js';
|
||||
|
||||
function hashSeed(seedText) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < String(seedText).length; i += 1) {
|
||||
h ^= String(seedText).charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t += 0x6D2B79F5;
|
||||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function createRunSeed() {
|
||||
return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] } = {}) {
|
||||
const rng = mulberry32(hashSeed(seed || createRunSeed()));
|
||||
const reservedSet = new Set(reserved.map(p => typeof p === 'string' ? p : key(p.col, p.row)));
|
||||
const candidates = [];
|
||||
for (let row = 0; row < GRID.rows; row += 1) {
|
||||
for (let col = 0; col < GRID.cols; col += 1) {
|
||||
const k = key(col, row);
|
||||
if (!reservedSet.has(k)) candidates.push({ col, row, k });
|
||||
}
|
||||
}
|
||||
for (let i = candidates.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
}
|
||||
const target = Math.round(GRID.cols * GRID.rows * targetRatio);
|
||||
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
|
||||
import { randomBetween } from './utils.js';
|
||||
import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID, EGG_SPAWN_RANGES } from './config.js';
|
||||
import { BALANCE } from './balance.js';
|
||||
import { createRunSeed, generateBlockedCells } from './mapGen.js';
|
||||
import { randomBetween, key, inGrid } from './utils.js';
|
||||
|
||||
export function newTurnStats() {
|
||||
return {
|
||||
|
|
@ -25,10 +27,13 @@ export function newTurnStats() {
|
|||
truckPoopFine: 0,
|
||||
maleTruckFine: 0,
|
||||
explosionDamage: 0,
|
||||
shredderBonus: 0,
|
||||
correct: 0,
|
||||
mistakes: 0,
|
||||
contractBonus: 0,
|
||||
contractResult: null
|
||||
contractResult: null,
|
||||
conveyorPasses: 0,
|
||||
eggProduced: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +52,7 @@ export function newTotalStats() {
|
|||
poopTrash: 0,
|
||||
poopMixer: 0,
|
||||
explosionDamage: 0,
|
||||
shredderBonus: 0,
|
||||
contractBonus: 0,
|
||||
zundaTax: 0,
|
||||
fairiesTribute: 0,
|
||||
|
|
@ -55,7 +61,9 @@ export function newTotalStats() {
|
|||
truckPoopFine: 0,
|
||||
maleTruckFine: 0,
|
||||
contractSuccess: 0,
|
||||
contractFailed: 0
|
||||
contractFailed: 0,
|
||||
conveyorPasses: 0,
|
||||
eggProduced: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +75,13 @@ export function createGame() {
|
|||
timeLeft: TURN_SECONDS,
|
||||
shutdownTimeLeft: 0,
|
||||
shutdownGraceSeconds: FARM_SHUTDOWN_GRACE_SECONDS,
|
||||
cleanupTimer: null,
|
||||
cleanupBlown: false,
|
||||
lastTimestamp: 0,
|
||||
nextId: 10,
|
||||
nextEventId: 1,
|
||||
runSeed: createRunSeed(),
|
||||
blockedCells: new Set(),
|
||||
buildTool: null,
|
||||
buildSession: 0,
|
||||
undoStack: [],
|
||||
|
|
@ -80,6 +93,8 @@ export function createGame() {
|
|||
pan: null,
|
||||
view: { x: 0, y: 0 },
|
||||
debug: false,
|
||||
debugInfiniteCash: false,
|
||||
hover: null,
|
||||
chicks: [],
|
||||
effects: [],
|
||||
floatingTexts: [],
|
||||
|
|
@ -91,6 +106,10 @@ export function createGame() {
|
|||
scanners: [],
|
||||
facilities: {},
|
||||
branchCounters: new Map(),
|
||||
routingVersion: 0,
|
||||
factoryGraph: null,
|
||||
factoryGraphCommittedVersion: -1,
|
||||
factoryGraphSnapshot: null,
|
||||
mixerHalfTimer: 0,
|
||||
congestion: new Map(),
|
||||
lastExplodedComponent: new Map(),
|
||||
|
|
@ -99,15 +118,16 @@ export function createGame() {
|
|||
cardEffects: { bearing: 0, legalWork: 0, flattery: 0 },
|
||||
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
|
||||
cardTargetPick: null,
|
||||
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 },
|
||||
stats: newTurnStats(),
|
||||
lastResult: null,
|
||||
eventLog: [],
|
||||
totals: newTotalStats()
|
||||
};
|
||||
}
|
||||
|
||||
export function getSpawnRange(farm) {
|
||||
const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]];
|
||||
return ranges[(farm?.level || 1) - 1] || ranges[0];
|
||||
return EGG_SPAWN_RANGES[(farm?.level || 1) - 1] || EGG_SPAWN_RANGES[EGG_SPAWN_RANGES.length - 1] || [2, 4];
|
||||
}
|
||||
|
||||
export function nextSpawnDelay(farm) {
|
||||
|
|
@ -138,34 +158,107 @@ export function defaultFacilities() {
|
|||
};
|
||||
}
|
||||
|
||||
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
|
||||
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it.
|
||||
[4, 1], [4, 2], [4, 3], [4, 4],
|
||||
// S1 left output -> Mixer receiver on the left grid edge.
|
||||
[3, 5], [2, 5], [1, 5], [0, 5],
|
||||
// S1 right output -> S2 top input.
|
||||
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
|
||||
[11, 6], [11, 7],
|
||||
// S2 left output -> Waste Shredder receiver on bottom grid edge.
|
||||
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
|
||||
// S2 right output -> Truck receiver on the bottom grid edge.
|
||||
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
|
||||
]);
|
||||
|
||||
const INITIAL_SCANNERS = Object.freeze([
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
|
||||
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
|
||||
]);
|
||||
|
||||
const INITIAL_EGG_FARMS = Object.freeze([
|
||||
{ type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
|
||||
]);
|
||||
|
||||
function addConveyorTile(game, col, row, options = {}) {
|
||||
if (!inGrid(col, row)) return;
|
||||
const k = key(col, row);
|
||||
game.conveyorTiles.add(k);
|
||||
if (!game.conveyorMeta.has(k) || options.forceMeta) {
|
||||
game.conveyorMeta.set(k, {
|
||||
price: BUILD_COSTS.conveyor,
|
||||
builtSession: null,
|
||||
uses: 0,
|
||||
durability: BALANCE.maintenance.durability.conveyor,
|
||||
maintenanceType: 'conveyor'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scannerPortCells(scanner) {
|
||||
return [
|
||||
{ col: scanner.col, row: scanner.row - 1 },
|
||||
{ col: scanner.col - 1, row: scanner.row },
|
||||
{ col: scanner.col + 1, row: scanner.row }
|
||||
].filter(p => inGrid(p.col, p.row));
|
||||
}
|
||||
|
||||
function farmOutputCells(farm) {
|
||||
return [
|
||||
{ col: farm.col, row: farm.row + 1 },
|
||||
{ col: farm.col - 1, row: farm.row },
|
||||
{ col: farm.col + 1, row: farm.row },
|
||||
{ col: farm.col, row: farm.row - 1 }
|
||||
].filter(p => inGrid(p.col, p.row));
|
||||
}
|
||||
|
||||
function protectedInitialCells(game) {
|
||||
const reserved = new Set([...game.conveyorTiles]);
|
||||
const add = p => { if (p && inGrid(p.col, p.row)) reserved.add(key(p.col, p.row)); };
|
||||
for (const scanner of game.scanners) {
|
||||
add(scanner);
|
||||
for (const p of scannerPortCells(scanner)) add(p);
|
||||
}
|
||||
for (const farm of game.eggFarms) {
|
||||
add(farm);
|
||||
for (const p of farmOutputCells(farm)) add(p);
|
||||
}
|
||||
for (const f of Object.values(game.facilities)) add(f.entry);
|
||||
return reserved;
|
||||
}
|
||||
|
||||
function installInitialConveyorBackbone(game) {
|
||||
for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row);
|
||||
}
|
||||
|
||||
function scrubBlockedCellsFromInitialBackbone(game) {
|
||||
const protectedCells = protectedInitialCells(game);
|
||||
for (const k of protectedCells) game.blockedCells.delete(k);
|
||||
}
|
||||
|
||||
export function resetLayout(game) {
|
||||
game.conveyorTiles.clear();
|
||||
game.conveyorMeta.clear();
|
||||
const initial = [
|
||||
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it.
|
||||
[4, 1], [4, 2], [4, 3], [4, 4],
|
||||
// S1 left output -> Mixer receiver on the left grid edge.
|
||||
[3, 5], [2, 5], [1, 5], [0, 5],
|
||||
// S1 right output -> S2 top input.
|
||||
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
|
||||
[11, 6], [11, 7],
|
||||
// S2 left output -> Waste Shredder receiver on bottom grid edge.
|
||||
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
|
||||
// S2 right output -> Truck receiver on the bottom grid edge.
|
||||
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
|
||||
];
|
||||
for (const [col, row] of initial) {
|
||||
const k = `${col},${row}`;
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null });
|
||||
}
|
||||
game.blockedCells = new Set();
|
||||
installInitialConveyorBackbone(game);
|
||||
game.facilities = defaultFacilities();
|
||||
game.scanners = [
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
|
||||
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
|
||||
];
|
||||
const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null };
|
||||
farm.nextSpawn = nextSpawnDelay(farm);
|
||||
farm.lastInterval = farm.nextSpawn;
|
||||
game.eggFarms = [farm];
|
||||
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] }));
|
||||
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
|
||||
const nextSpawn = nextSpawnDelay(farm);
|
||||
return { ...farm, nextSpawn, lastInterval: nextSpawn };
|
||||
});
|
||||
|
||||
// Generate no-build cells only after the complete initial factory footprint is
|
||||
// installed. The protected footprint includes every default conveyor segment,
|
||||
// scanner body/ports, farm output, and machine receiver. This guarantees that
|
||||
// the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even
|
||||
// when roughly 20% of the map is blocked.
|
||||
const reserved = protectedInitialCells(game);
|
||||
game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] });
|
||||
scrubBlockedCellsFromInitialBackbone(game);
|
||||
installInitialConveyorBackbone(game);
|
||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||
game.factoryGraph = null;
|
||||
game.factoryGraphCommittedVersion = -1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ export const TEXT = {
|
|||
autoMenu: 'Auto Menu'
|
||||
},
|
||||
status: {
|
||||
build: tool => `BUILD: ${(tool || 'SELECT').toUpperCase()} | Right-drag pan | Click equipment for menu`,
|
||||
build: tool => `BUILD: ${(tool === 'erase' ? 'SELL' : (tool || 'SELECT')).toUpperCase()} | Right-drag pan | Hover equipment for info`,
|
||||
sorting: 'Sorting: use scanner keys. Build after clearing the day.',
|
||||
allPortsConnected: 'All required ports connected.',
|
||||
equipmentPanelEmpty: 'Click equipment in Build phase.',
|
||||
equipmentPanelEmpty: 'Hover equipment in Build phase.',
|
||||
eventPanelRunning: 'Forced events appear in Build phase.'
|
||||
},
|
||||
routeLabels: {
|
||||
|
|
@ -43,7 +43,7 @@ export const TEXT = {
|
|||
notEnoughCash: 'Not enough cash',
|
||||
facilityExists: 'Facility already exists',
|
||||
facilityOverlap: 'Facility overlap',
|
||||
nothingToErase: 'Nothing to erase',
|
||||
nothingToErase: 'Nothing to sell',
|
||||
noUpgrade: 'No upgrade available'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
177
src/game.js
177
src/game.js
|
|
@ -1,17 +1,19 @@
|
|||
import { TURN_SECONDS, THEME } from './core/config.js';
|
||||
import { buildToolButtonHtml } from './core/text.js';
|
||||
import { createGame, resetLayout, newTurnStats } from './core/state.js';
|
||||
import { createGame, resetLayout, newTurnStats, nextSpawnDelay } from './core/state.js';
|
||||
import { clamp, pointToCell, cellCenter, yen } from './core/utils.js';
|
||||
import { facilityConnectionIssues } from './systems/routing.js';
|
||||
import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
|
||||
import { collectFairiesTribute, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
|
||||
import { undo, redo } from './systems/history.js';
|
||||
import { drawAll } from './render/draw.js';
|
||||
import { createBuildSystem } from './systems/buildSystem.js';
|
||||
import { createUISystem } from './systems/uiSystem.js';
|
||||
import { createChickSystem } from './systems/chickSystem.js';
|
||||
import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js';
|
||||
import { conveyorSpeedForGame, createCardSystem, ensureCardState } from './systems/cards.js';
|
||||
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
|
||||
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
|
||||
import { clearGameEvents, emitGameEvent } from './systems/gameEvents.js';
|
||||
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
|
@ -23,6 +25,16 @@ const ui = {
|
|||
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'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'),
|
||||
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
|
||||
hoverTooltip: document.getElementById('hoverTooltip'),
|
||||
debugPanel: document.getElementById('debugPanel'),
|
||||
debugInfiniteCash: document.getElementById('debugInfiniteCash'),
|
||||
debugDayInput: document.getElementById('debugDayInput'),
|
||||
debugSetDayButton: document.getElementById('debugSetDayButton'),
|
||||
debugUpgradeSelect: document.getElementById('debugUpgradeSelect'),
|
||||
debugApplyUpgradeButton: document.getElementById('debugApplyUpgradeButton'),
|
||||
debugObservations: document.getElementById('debugObservations'),
|
||||
debugEventLog: document.getElementById('debugEventLog'),
|
||||
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
|
||||
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'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
|
||||
|
|
@ -51,13 +63,21 @@ let uiSystem;
|
|||
let chicks;
|
||||
let cardSystem;
|
||||
|
||||
function currentConveyorSpeed() { return conveyorSpeedForGame(game); }
|
||||
function currentConveyorSpeed(conveyorKey = null) {
|
||||
const base = conveyorSpeedForGame(game, conveyorKey);
|
||||
return conveyorKey ? base * conveyorSpeedFactorForKey(game, conveyorKey) : base;
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
Object.assign(game, createGame());
|
||||
game.phase = 'running';
|
||||
game.view = { x: 0, y: 0 };
|
||||
resetLayout(game);
|
||||
ensureMaintenanceState(game);
|
||||
clearGameEvents(game);
|
||||
emitGameEvent(game, 'RunStarted', { cash: game.cash });
|
||||
game.debugInfiniteCash = !!ui.debugInfiniteCash?.checked;
|
||||
refreshDebugUpgradeSelect();
|
||||
uiSystem.hideModal();
|
||||
chicks.updateCongestion();
|
||||
uiSystem.updatePanels();
|
||||
|
|
@ -77,6 +97,10 @@ function startNextTurn() {
|
|||
uiSystem.updatePanels();
|
||||
return;
|
||||
}
|
||||
ensureMaintenanceState(game);
|
||||
const graph = commitFactoryGraphForDay(game);
|
||||
emitGameEvent(game, 'FactoryGraphCommitted', { cells: graph.metrics.conveyorCells, edges: graph.metrics.edges, components: graph.metrics.components });
|
||||
activateRepairmanForDay(game);
|
||||
const zundaBasisCash = game.cash;
|
||||
const zunda = collectZundaTax(game, zundaBasisCash);
|
||||
const tribute = collectFairiesTribute(game, game.turn);
|
||||
|
|
@ -85,10 +109,13 @@ function startNextTurn() {
|
|||
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
activateAcceptedContract(game);
|
||||
emitGameEvent(game, 'StartDay', { day: game.turn + 1, zundaTax: zunda.tax || 0, fairiesTribute: tribute.amount || 0 });
|
||||
game.phase = 'running';
|
||||
game.turn += 1;
|
||||
game.timeLeft = TURN_SECONDS;
|
||||
game.shutdownTimeLeft = 0;
|
||||
game.cleanupTimer = null;
|
||||
game.cleanupBlown = false;
|
||||
game.chicks = [];
|
||||
game.effects = [];
|
||||
game.floatingTexts = [];
|
||||
|
|
@ -108,6 +135,20 @@ function startNextTurn() {
|
|||
uiSystem.updateUI();
|
||||
}
|
||||
|
||||
|
||||
function hireRepairman() {
|
||||
ensureMaintenanceState(game);
|
||||
const result = hireRepairmanForNextDay(game);
|
||||
if (!result.ok) {
|
||||
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, result.reason || 'Cannot hire repairman.', THEME.danger);
|
||||
build?.fail?.(result.reason || 'Cannot hire repairman.');
|
||||
} else {
|
||||
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `Repairman hired -${yen(result.cost)}`, THEME.green);
|
||||
}
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
}
|
||||
|
||||
function completeTurn() {
|
||||
if (game.phase !== 'running') return;
|
||||
const ship = settleTruckRevenue(game);
|
||||
|
|
@ -115,17 +156,21 @@ function completeTurn() {
|
|||
clearActiveContract(game);
|
||||
game.totals.turnsCompleted += 1;
|
||||
game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash };
|
||||
emitGameEvent(game, 'EndDay', { day: game.turn, revenue: game.stats.revenue, penalty: game.stats.penalty, profit: game.stats.profit, cash: game.cash });
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
game.phase = 'build';
|
||||
game.truckCargo = [];
|
||||
game.chicks = [];
|
||||
game.shutdownTimeLeft = 0;
|
||||
game.cleanupTimer = null;
|
||||
game.cleanupBlown = false;
|
||||
game.buildSession += 1;
|
||||
game.undoStack = [];
|
||||
game.redoStack = [];
|
||||
game.timeLeft = 0;
|
||||
for (const scanner of game.scanners) scanner.queue = [];
|
||||
for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; }
|
||||
deactivateRepairman(game);
|
||||
game.contractOffer = rollContractOffer(game);
|
||||
if (cardSystem) cardSystem.prepareDraft();
|
||||
game.cardTargetPick = null;
|
||||
|
|
@ -147,6 +192,7 @@ function closeFarmShutters() {
|
|||
shockwave(game, c.x, c.y, THEME.ink);
|
||||
sparkBurst(game, c.x, c.y + 10, 10);
|
||||
floating(game, c.x, c.y - 34, 'SHUT +10s', THEME.ink);
|
||||
emitGameEvent(game, 'FarmShutterClosed', { farmId: farm.id });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +200,7 @@ function update(timestamp) {
|
|||
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
|
||||
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
|
||||
game.lastTimestamp = timestamp;
|
||||
if (game.debugInfiniteCash && game.cash < 999999) { game.cash = 999999; game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); }
|
||||
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||
chicks.updateCongestion();
|
||||
|
|
@ -170,14 +217,49 @@ function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x -
|
|||
function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
|
||||
function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); }
|
||||
function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; }
|
||||
function objectFromHit(hit) {
|
||||
if (!hit) return null;
|
||||
if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey };
|
||||
return hit.ref || null;
|
||||
}
|
||||
|
||||
function hideHoverTooltip() {
|
||||
if (!ui.hoverTooltip) return;
|
||||
ui.hoverTooltip.classList.remove('visible');
|
||||
ui.hoverTooltip.setAttribute('aria-hidden', 'true');
|
||||
game.hover = null;
|
||||
}
|
||||
|
||||
function showHoverTooltip(event, hit) {
|
||||
if (!ui.hoverTooltip || game.phase !== 'build' || game.cardTargetPick?.pending || game.groupDrag || game.selectionBox || game.pan) return hideHoverTooltip();
|
||||
const obj = objectFromHit(hit);
|
||||
if (!obj) return hideHoverTooltip();
|
||||
const title = build.selectedTitle(obj);
|
||||
const flavor = build.flavorText ? build.flavorText(obj) : '';
|
||||
const lines = build.selectedInfoLines(obj).slice(0, 7);
|
||||
ui.hoverTooltip.innerHTML = `<strong>${title}</strong>${flavor ? `<em>${flavor}</em>` : ''}${lines.map(line => `<span>${line}</span>`).join('')}`;
|
||||
const x = Math.min(window.innerWidth - 330, event.clientX + 18);
|
||||
const y = Math.min(window.innerHeight - 220, event.clientY + 18);
|
||||
ui.hoverTooltip.style.left = `${Math.max(8, x)}px`;
|
||||
ui.hoverTooltip.style.top = `${Math.max(8, y)}px`;
|
||||
ui.hoverTooltip.classList.add('visible');
|
||||
ui.hoverTooltip.setAttribute('aria-hidden', 'false');
|
||||
game.hover = { type: hit.type, id: hit.oldKey || hit.ref?.id || null };
|
||||
}
|
||||
|
||||
function updateHover(event) {
|
||||
if (game.phase !== 'build') return hideHoverTooltip();
|
||||
showHoverTooltip(event, build.equipmentAtPoint(canvasPoint(event)));
|
||||
}
|
||||
|
||||
function clickSelect(event) {
|
||||
hideHoverTooltip();
|
||||
const p = canvasPoint(event), hit = build.equipmentAtPoint(p);
|
||||
const sel = selectionForHit(hit);
|
||||
if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; }
|
||||
game.selected = sel;
|
||||
game.multiSelected = [sel];
|
||||
uiSystem.updatePanels();
|
||||
build.showSelectedMenu();
|
||||
}
|
||||
|
||||
canvas.addEventListener('contextmenu', event => event.preventDefault());
|
||||
|
|
@ -199,7 +281,8 @@ canvas.addEventListener('pointerdown', event => {
|
|||
build.startSelectionBox(event);
|
||||
});
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (game.phase !== 'build') return;
|
||||
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);
|
||||
|
|
@ -215,7 +298,8 @@ canvas.addEventListener('pointerup', event => {
|
|||
game.groupDrag = null; game.pan = null;
|
||||
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; });
|
||||
canvas.addEventListener('pointerleave', hideHoverTooltip);
|
||||
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); });
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
|
||||
if (event.target.closest('#modal .modal-card')) return;
|
||||
|
|
@ -224,11 +308,88 @@ document.addEventListener('pointerdown', event => {
|
|||
|
||||
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.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.erase.addEventListener('click', () => build.setBuildTool('erase'));
|
||||
ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
|
||||
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); });
|
||||
window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } });
|
||||
|
||||
|
||||
function debugUpgradeOptions() {
|
||||
const options = [
|
||||
{ value: 'effect:bearing', label: `Bearing +1 (${game.cardEffects?.bearing || 0})` },
|
||||
{ value: 'effect:legalWork', label: `Legal Work +1 (${game.cardEffects?.legalWork || 0})` },
|
||||
{ value: 'effect:flattery', label: `Flattery +1 (${game.cardEffects?.flattery || 0})` }
|
||||
];
|
||||
for (const farm of game.eggFarms) options.push({ value: `eggFarm:${farm.id}`, label: `EGG #${farm.id} L${farm.level}` });
|
||||
for (const scanner of game.scanners.filter(s => s.kind === 'auto')) options.push({ value: `scanner:${scanner.id}`, label: `AUTO #${scanner.id} L${scanner.level}` });
|
||||
for (const id of ['mixer', 'trash', 'truck']) {
|
||||
const f = game.facilities[id];
|
||||
if (f) options.push({ value: `facility:${id}`, label: `${f.shortName || f.name} L${f.level}` });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function refreshDebugUpgradeSelect() {
|
||||
if (!ui.debugUpgradeSelect) return;
|
||||
const previous = ui.debugUpgradeSelect.value;
|
||||
const options = debugUpgradeOptions();
|
||||
ui.debugUpgradeSelect.innerHTML = options.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
|
||||
if (options.some(o => o.value === previous)) ui.debugUpgradeSelect.value = previous;
|
||||
}
|
||||
|
||||
function applyDebugUpgrade() {
|
||||
ensureCardState(game);
|
||||
const value = ui.debugUpgradeSelect?.value || '';
|
||||
const [kind, id] = value.split(':');
|
||||
if (kind === 'effect') {
|
||||
game.cardEffects[id] = Math.max(0, game.cardEffects[id] || 0) + 1;
|
||||
floating(game, canvas.width / 2 - game.view.x, 112 - game.view.y, `${id.toUpperCase()} +1`, THEME.green);
|
||||
} else if (kind === 'eggFarm') {
|
||||
const farm = game.eggFarms.find(f => String(f.id) === id);
|
||||
if (farm) {
|
||||
farm.level = Math.min(4, (farm.level || 1) + 1);
|
||||
farm.nextSpawn = nextSpawnDelay(farm);
|
||||
farm.lastInterval = farm.nextSpawn;
|
||||
floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 34, `LV ${farm.level}`, THEME.green);
|
||||
}
|
||||
} else if (kind === 'scanner') {
|
||||
const scanner = game.scanners.find(sc => String(sc.id) === id);
|
||||
if (scanner) {
|
||||
scanner.level = (scanner.level || 1) + 1;
|
||||
const c = cellCenter(scanner.col, scanner.row);
|
||||
floating(game, c.x, c.y - 42, `LV ${scanner.level}`, THEME.green);
|
||||
}
|
||||
} else if (kind === 'facility') {
|
||||
const f = game.facilities[id];
|
||||
if (f) {
|
||||
if (id === 'trash') f.level = Math.min(31, (f.level || 1) + 1);
|
||||
else f.level = (f.level || 1) + 1;
|
||||
floating(game, f.x + f.w / 2, f.y + 20, `LV ${f.level}`, THEME.green);
|
||||
}
|
||||
}
|
||||
refreshDebugUpgradeSelect();
|
||||
uiSystem?.updatePanels();
|
||||
}
|
||||
|
||||
function wireDebugControls() {
|
||||
if (!ui.debugPanel) return;
|
||||
refreshDebugUpgradeSelect();
|
||||
ui.debugPanel.addEventListener('toggle', refreshDebugUpgradeSelect);
|
||||
ui.debugInfiniteCash?.addEventListener('change', () => {
|
||||
game.debugInfiniteCash = !!ui.debugInfiniteCash.checked;
|
||||
if (game.debugInfiniteCash) game.cash = Math.max(game.cash, 999999);
|
||||
uiSystem?.updateUI();
|
||||
});
|
||||
ui.debugSetDayButton?.addEventListener('click', () => {
|
||||
game.turn = Math.max(1, Math.floor(Number(ui.debugDayInput?.value) || 1));
|
||||
uiSystem?.updatePanels();
|
||||
uiSystem?.updateUI();
|
||||
});
|
||||
ui.debugApplyUpgradeButton?.addEventListener('click', applyDebugUpgrade);
|
||||
ui.debugUpgradeSelect?.addEventListener('pointerdown', refreshDebugUpgradeSelect);
|
||||
}
|
||||
|
||||
build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() });
|
||||
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(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
|
||||
wireDebugControls(); initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); refreshDebugUpgradeSelect(); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } fr
|
|||
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js';
|
||||
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
|
||||
import { cardTargetBounds } from '../systems/cards.js';
|
||||
import { wearRatio, remainingPercent } from '../systems/maintenance.js';
|
||||
|
||||
// Optional art overlay hook.
|
||||
// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs.
|
||||
// To use replacement art, set ENABLE_EXTERNAL_ART_ASSETS to true and place files under assets/images/.
|
||||
const ENABLE_EXTERNAL_ART_ASSETS = false;
|
||||
const ASSET_PATHS = {
|
||||
chickMale: './assets/images/chick_male.png',
|
||||
chickFemale: './assets/images/chick_female.png',
|
||||
|
|
@ -20,16 +25,16 @@ const ASSET_PATHS = {
|
|||
export const assets = {};
|
||||
for (const [name, src] of Object.entries(ASSET_PATHS)) {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.loaded = false;
|
||||
img.onload = () => { img.loaded = true; };
|
||||
img.onerror = () => { img.loaded = false; };
|
||||
if (ENABLE_EXTERNAL_ART_ASSETS && src) {
|
||||
img.onload = () => { img.loaded = true; };
|
||||
img.src = src;
|
||||
}
|
||||
assets[name] = img;
|
||||
}
|
||||
|
||||
// ART OVERLAY HOOK:
|
||||
// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS.
|
||||
// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes.
|
||||
// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI.
|
||||
// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes.
|
||||
|
||||
export function drawAll(ctx, canvas, game, helpers) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
|
@ -38,13 +43,14 @@ export function drawAll(ctx, canvas, game, helpers) {
|
|||
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;
|
||||
ctx.translate(game.view.x + sx, game.view.y + sy);
|
||||
drawGrid(ctx);
|
||||
drawGrid(ctx, game);
|
||||
drawConveyors(ctx, game);
|
||||
drawScannerConnectors(ctx, game);
|
||||
drawFacilities(ctx, game);
|
||||
drawEggFarms(ctx, game);
|
||||
drawScanners(ctx, game);
|
||||
drawChicks(ctx, game, helpers.activeQueuedChick);
|
||||
drawRepairman(ctx, game);
|
||||
drawCardTargetOverlay(ctx, canvas, game);
|
||||
drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle);
|
||||
drawSelectionBox(ctx, game);
|
||||
|
|
@ -76,7 +82,7 @@ function drawBackground(ctx, canvas) {
|
|||
for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
function drawGrid(ctx) {
|
||||
function drawGrid(ctx, game) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255,255,255,.46)';
|
||||
ctx.strokeStyle = 'rgba(16,32,21,.055)';
|
||||
|
|
@ -90,8 +96,40 @@ function drawGrid(ctx) {
|
|||
const y = GRID.y + r * GRID.cell;
|
||||
ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke();
|
||||
}
|
||||
for (const k of game.blockedCells || []) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
ctx.fillStyle = 'rgba(16,32,21,.30)';
|
||||
ctx.strokeStyle = 'rgba(16,32,21,.60)';
|
||||
ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - GRID.cell / 2 + 4, c.y - GRID.cell / 2 + 4, GRID.cell - 8, GRID.cell - 8, true, true);
|
||||
ctx.fillStyle = 'rgba(255,255,255,.70)';
|
||||
ctx.font = '900 9px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('NO BUILD', c.x, c.y + 3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) {
|
||||
const wear = wearRatio(target);
|
||||
if (wear <= 0.01) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.min(0.62, 0.12 + wear * 0.54);
|
||||
ctx.fillStyle = '#6b4a23';
|
||||
if (radius > 0) {
|
||||
ctx.beginPath(); ctx.roundRect?.(x, y, w, h, radius);
|
||||
if (ctx.roundRect) ctx.fill();
|
||||
else rect(ctx, x, y, w, h, true, false);
|
||||
} else rect(ctx, x, y, w, h, true, false);
|
||||
ctx.globalAlpha = Math.min(0.9, 0.25 + wear * 0.6);
|
||||
ctx.strokeStyle = '#3a2a16';
|
||||
ctx.lineWidth = Math.max(2, Math.floor(2 + wear * 5));
|
||||
ctx.setLineDash([5, 6]);
|
||||
rect(ctx, x + 3, y + 3, w - 6, h - 6, false, true);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function componentRatio(game, k) {
|
||||
const id = game.componentLookup?.get(k);
|
||||
const data = id ? game.congestion.get(id) : null;
|
||||
|
|
@ -135,6 +173,7 @@ function drawConveyors(ctx, game) {
|
|||
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
|
||||
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta: game.conveyorMeta.get(k) });
|
||||
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
|
||||
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
|
||||
}
|
||||
|
|
@ -221,7 +260,11 @@ function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm
|
|||
function drawEggFarm(ctx, farm, game) {
|
||||
const c = cellCenter(farm.col, farm.row);
|
||||
ctx.save();
|
||||
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { ctx.restore(); return; }
|
||||
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) {
|
||||
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm);
|
||||
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
|
||||
ctx.restore(); return;
|
||||
}
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
|
||||
rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true);
|
||||
ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
|
||||
|
|
@ -235,6 +278,7 @@ function drawEggFarm(ctx, farm, game) {
|
|||
for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); }
|
||||
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
|
||||
}
|
||||
drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm);
|
||||
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -243,7 +287,11 @@ function drawScanner(ctx, scanner, game) {
|
|||
const c = scannerCenter(scanner);
|
||||
ctx.save();
|
||||
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
|
||||
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; }
|
||||
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) {
|
||||
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 54, c.y - 38, 108, 76, scanner);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
|
||||
ctx.restore(); return;
|
||||
}
|
||||
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
|
||||
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
|
||||
|
|
@ -255,6 +303,7 @@ function drawScanner(ctx, scanner, game) {
|
|||
const q = scanner.queue.length;
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25);
|
||||
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -339,13 +388,14 @@ function drawMixer(ctx, game) {
|
|||
const m = game.facilities.mixer;
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, m);
|
||||
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
|
||||
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
|
||||
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
|
||||
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
|
||||
ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
|
||||
for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
|
||||
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
|
||||
drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -353,11 +403,12 @@ function drawTrash(ctx, game) {
|
|||
const t = game.facilities.trash;
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, t);
|
||||
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
|
||||
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
|
||||
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
|
||||
drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
|
||||
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
|
||||
drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -439,13 +490,8 @@ function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
|
|||
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
|
||||
return;
|
||||
}
|
||||
const obj = selectedObject();
|
||||
if (!obj) return;
|
||||
let x = 80, y = 80;
|
||||
if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; }
|
||||
else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; }
|
||||
else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; }
|
||||
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore();
|
||||
// Single equipment inspection is handled by the DOM hover tooltip.
|
||||
// Click selection remains only for dragging/multi-select; no clicked equipment popover is drawn.
|
||||
}
|
||||
|
||||
function drawCardTargetOverlay(ctx, canvas, game) {
|
||||
|
|
@ -537,6 +583,23 @@ function drawFloatingTexts(ctx, game) {
|
|||
for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRepairman(ctx, game) {
|
||||
const r = game.repairman;
|
||||
if (!r?.active) return;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff0d1';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.arc(r.x, r.y, 15, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('R', r.x, r.y + 4);
|
||||
if (r.target?.label) label(ctx, r.x, r.y - 22, r.target.label, THEME.green);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCanvasHints(ctx, game, canvas) {
|
||||
ctx.save();
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
|
||||
import { BALANCE } from '../core/balance.js';
|
||||
import { getSpawnRange } from '../core/state.js';
|
||||
import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js';
|
||||
import { TEXT, equipmentName } from '../core/text.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor } from './economy.js';
|
||||
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
|
||||
import { autoScannerCooldownSeconds } from './cards.js';
|
||||
import { record } from './history.js';
|
||||
import { floating, eraseEffect } from './effects.js';
|
||||
import { createSelectionSystem } from './selectionSystem.js';
|
||||
import { emitGameEvent } from './gameEvents.js';
|
||||
import { ensureMaintenanceState, remainingPercent, degradationPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay } from './maintenance.js';
|
||||
|
||||
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
|
||||
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
|
||||
|
||||
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; }
|
||||
function isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); }
|
||||
|
||||
function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
|
||||
function rectsOverlap(a, b, margin = 10) {
|
||||
|
|
@ -26,6 +30,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function isEquipmentCell(col, row, moving = null) {
|
||||
if (!pointInGrid(col, row)) return false;
|
||||
if (isBlockedCell(col, row)) return true;
|
||||
const k = key(col, row);
|
||||
const existingFarm = farmAt(game, col, row);
|
||||
if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
|
||||
|
|
@ -57,6 +62,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
return equipmentBasePrice(hit);
|
||||
}
|
||||
|
||||
function lastProtectedSaleReason(hit) {
|
||||
const obj = hit?.ref || hit || {};
|
||||
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
|
||||
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
|
||||
}
|
||||
if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.id)) {
|
||||
const count = Object.values(game.facilities).filter(f => f.id === obj.id).length;
|
||||
if (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function canSellHit(hit) {
|
||||
return !lastProtectedSaleReason(hit);
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
ui.buildStatus.textContent = reason;
|
||||
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger);
|
||||
|
|
@ -67,6 +88,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const { col, row } = cell;
|
||||
if (game.buildTool === 'conveyor') {
|
||||
if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied);
|
||||
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
const cost = buildPrice('conveyor');
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
|
|
@ -74,12 +96,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
spendCash(game, cost);
|
||||
const k = key(col, row);
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession });
|
||||
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: BALANCE.maintenance.durability.conveyor, maintenanceType: 'conveyor' });
|
||||
game.selected = { type: 'conveyor', id: k };
|
||||
refreshRoutingAfterEdit(game);
|
||||
emitGameEvent(game, 'BuildEquipment', { type: 'conveyor', cell: k, cost });
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
return;
|
||||
}
|
||||
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
|
||||
if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
if (game.buildTool === 'eggFarm') buildFarm(col, row);
|
||||
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
|
||||
|
|
@ -101,6 +125,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
game.eggFarms.push(farm);
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'eggFarm', id: farm.id };
|
||||
emitGameEvent(game, 'BuildEquipment', { type: 'eggFarm', id: farm.id, col, row, cost });
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +138,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
game.scanners.push(scanner);
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'scanner', id: scanner.id };
|
||||
emitGameEvent(game, 'BuildEquipment', { type: kind + 'Scanner', id: scanner.id, col, row, cost });
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
|
|
@ -121,12 +147,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (game.facilities[id]) return fail(TEXT.fail.facilityExists);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
const f = createFacility(game, id, p, cost);
|
||||
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
|
||||
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
game.facilities[id] = f;
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'facility', id };
|
||||
emitGameEvent(game, 'BuildEquipment', { type: id, cost });
|
||||
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +169,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
function eraseAtPoint(p) {
|
||||
const hit = equipmentAtPoint(p);
|
||||
if (!hit) return fail(TEXT.fail.nothingToErase);
|
||||
const saleReason = lastProtectedSaleReason(hit);
|
||||
if (saleReason) return fail(saleReason);
|
||||
record(game);
|
||||
emitGameEvent(game, 'SellEquipment', { type: hit.type, id: hit.oldKey || hit.ref?.id || hit.ref?.type || 'unknown', value: resaleValueFor(hit, game).amount });
|
||||
if (hit.type === 'conveyor') {
|
||||
const c = parseKey(hit.oldKey);
|
||||
const center = cellCenter(c.col, c.row);
|
||||
|
|
@ -276,9 +307,16 @@ 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'));
|
||||
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
|
||||
const resale = resaleValueFor(hit, game);
|
||||
const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`;
|
||||
ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger'));
|
||||
const saleReason = lastProtectedSaleReason(hit);
|
||||
if (saleReason) {
|
||||
const disabled = modalButton(saleReason, () => {}, 'facility-action danger disabled');
|
||||
disabled.disabled = true;
|
||||
ui.modalActions.appendChild(disabled);
|
||||
} else {
|
||||
const resale = resaleValueFor(hit, game);
|
||||
const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`;
|
||||
ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger'));
|
||||
}
|
||||
positionEquipmentPopover(obj);
|
||||
ui.modal.classList.add('visible', 'equipment-popover');
|
||||
}
|
||||
|
|
@ -305,20 +343,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
|
||||
function selectedInfoLines(obj) {
|
||||
ensureMaintenanceState(game);
|
||||
const lines = [];
|
||||
if (obj.type === 'eggFarm') {
|
||||
const [min, max] = getSpawnRange(obj);
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
{ 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(`Level: ${obj.level}/4`);
|
||||
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
||||
lines.push(`Durability: ${remainingPercent(obj)}% | Spawn delay x${eggSpawnDelayMultiplier(obj).toFixed(2)}`);
|
||||
} else if (obj.type === 'scanner') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
|
||||
lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`);
|
||||
lines.push(`Queue: ${obj.queue.length}`);
|
||||
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj).toFixed(1)}s`);
|
||||
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
|
||||
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
|
||||
if (obj.kind === 'manual') lines.push('Durability: none | Always normal');
|
||||
|
||||
} else if (obj.type === 'conveyor') {
|
||||
const meta = game.conveyorMeta.get(obj.id);
|
||||
|
|
@ -327,12 +369,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
lines.push(`Price: ${yen(buildPrice('conveyor'))}`);
|
||||
lines.push(`Cell: ${obj.id}`);
|
||||
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
|
||||
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
|
||||
lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
|
||||
} else if (obj.type === 'facility') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
lines.push(['mixer', 'truck'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
|
||||
{ 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}`);
|
||||
if (obj.id === 'truck') lines.push('Durability: none | Always normal');
|
||||
if (['mixer', 'trash'].includes(obj.id)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj.id).toFixed(2)}s`);
|
||||
if (obj.id === 'mixer') {
|
||||
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
|
||||
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
|
||||
|
|
@ -341,11 +386,31 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`);
|
||||
lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`);
|
||||
}
|
||||
if (obj.id === 'trash') {
|
||||
const cards = shredderUpgradeCount(game);
|
||||
const chance = shredderBonusChance(cards);
|
||||
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
|
||||
lines.push(`Bonus chance: ${(chance * 100).toFixed(2)}% for ${yen(1)} per processed item.`);
|
||||
lines.push(`Expected value: ${chance.toFixed(3)}円 per item.`);
|
||||
}
|
||||
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
function flavorText(obj) {
|
||||
if (!obj) return '';
|
||||
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 'An automated judge. Faster than hands, still very sure of itself.';
|
||||
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
|
||||
if (obj.type === 'facility' && obj.id === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
|
||||
if (obj.type === 'facility' && obj.id === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
|
||||
if (obj.type === 'facility' && obj.id === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
|
||||
return 'Factory equipment.';
|
||||
}
|
||||
|
||||
return {
|
||||
buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint,
|
||||
startSelectionBox: selection.startSelectionBox,
|
||||
|
|
@ -355,7 +420,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
updateGroupDrag: selection.updateGroupDrag,
|
||||
finishGroupDrag: selection.finishGroupDrag,
|
||||
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
|
||||
selectedInfoLines,
|
||||
selectedInfoLines, flavorText,
|
||||
removeSelected, switchScannerRole,
|
||||
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
||||
isEquipmentCell, equipmentHitBoxes,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js';
|
||||
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js';
|
||||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { cellCenter, yen } from '../core/utils.js';
|
||||
import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice } from './economy.js';
|
||||
import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
|
||||
import { floating } from './effects.js';
|
||||
import { averageConveyorPerformance, autoScannerDelayMultiplier } from './maintenance.js';
|
||||
import { scannerCenter } from './routing.js';
|
||||
|
||||
const COMMON_WEIGHT = 8;
|
||||
const RARE_WEIGHT = 2;
|
||||
const AUTO_SCANNER_COOLDOWN_RATE = 0.95;
|
||||
const AUTO_SCANNER_MIN_COOLDOWN = 1.0;
|
||||
const BASE_DRAFT_SIZE = 3;
|
||||
const COMMON_WEIGHT = CARD_BALANCE.commonWeight;
|
||||
const RARE_WEIGHT = CARD_BALANCE.rareWeight;
|
||||
const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize;
|
||||
|
||||
export const CARD_DEFS = [
|
||||
{
|
||||
|
|
@ -44,12 +43,20 @@ export const CARD_DEFS = [
|
|||
target: 'truck',
|
||||
description: 'Choose TRUCK on the map and raise it by 1 level.'
|
||||
},
|
||||
{
|
||||
id: 'upgradeTrash',
|
||||
title: 'SHREDDER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'trash',
|
||||
description: 'Choose SHREDDER on the map and raise it by 1 level. Per processed item: ((√cards / 2) × cards)% chance to pay ¥1. Max 30 cards.'
|
||||
},
|
||||
{
|
||||
id: 'bearing',
|
||||
title: 'High-Quality Bearing',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Conveyor speed +2%.'
|
||||
description: 'Conveyor speed +7.5%.'
|
||||
},
|
||||
{
|
||||
id: 'extraCards',
|
||||
|
|
@ -82,15 +89,17 @@ export function ensureCardState(game) {
|
|||
return game.cardEffects;
|
||||
}
|
||||
|
||||
export function conveyorSpeedForGame(game) {
|
||||
export function conveyorSpeedForGame(game, conveyorKey = null) {
|
||||
const effects = ensureCardState(game);
|
||||
const speed = CONVEYOR_SPEED * Math.pow(1.02, Math.max(0, effects.bearing || 0));
|
||||
return Math.min(CONVEYOR_SPEED_MAX, speed);
|
||||
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
|
||||
const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
|
||||
return Math.min(CONVEYOR_SPEED_MAX, speed * perf);
|
||||
}
|
||||
|
||||
export function autoScannerCooldownSeconds(scanner) {
|
||||
export function autoScannerCooldownSeconds(scanner, game = null) {
|
||||
const upgrades = Math.max(0, (scanner?.level || 1) - 1);
|
||||
return Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_COOLDOWN_RATE, upgrades));
|
||||
const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_UPGRADE_RATE, upgrades));
|
||||
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base;
|
||||
}
|
||||
|
||||
export function flatteryReductionForTurn(game, turn = game.turn) {
|
||||
|
|
@ -103,7 +112,7 @@ export function flatteryReductionForTurn(game, turn = game.turn) {
|
|||
export function rerollCost(game) {
|
||||
const draft = game.cardDraft || { rerolls: 0 };
|
||||
const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1);
|
||||
return Math.ceil((Math.max(1, game.turn || 1) / 2) * nextRerollCount * 100);
|
||||
return Math.ceil((Math.max(1, game.turn || 1) / CARD_BALANCE.rerollCost.dayDivisor) * nextRerollCount * CARD_BALANCE.rerollCost.multiplier);
|
||||
}
|
||||
|
||||
export function cardById(id) {
|
||||
|
|
@ -115,7 +124,7 @@ function targetLabel(game, target) {
|
|||
return `EGG #${target.id} L${target.level} -> L${target.level + 1}`;
|
||||
}
|
||||
if (target.type === 'scanner') {
|
||||
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }).toFixed(1)}s`;
|
||||
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.id === 'mixer') {
|
||||
const beforeLevel = target.level;
|
||||
|
|
@ -133,6 +142,13 @@ 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') {
|
||||
const before = shredderUpgradeCount(game);
|
||||
const after = Math.min(shredderBonusMaxCards(), before + 1);
|
||||
const beforePercent = (shredderBonusChance(before) * 100).toFixed(2);
|
||||
const afterPercent = (shredderBonusChance(after) * 100).toFixed(2);
|
||||
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | ${beforePercent}% -> ${afterPercent}% per item`;
|
||||
}
|
||||
return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`;
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +160,7 @@ export function targetsForCard(game, cardOrId) {
|
|||
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
|
||||
if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : [];
|
||||
if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : [];
|
||||
if (card.target === 'trash') return game.facilities.trash && rawShredderUpgradeCount(game) < shredderBonusMaxCards() ? [game.facilities.trash] : [];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +178,32 @@ function weightedPick(pool) {
|
|||
return pool[pool.length - 1];
|
||||
}
|
||||
|
||||
let dudSerial = 0;
|
||||
function dudCard() {
|
||||
dudSerial += 1;
|
||||
return {
|
||||
id: `dud:${Date.now()}:${dudSerial}`,
|
||||
title: 'スカ',
|
||||
rarity: 'dud',
|
||||
type: 'dud',
|
||||
description: 'Click to flick this dud away. It does not consume a pick.'
|
||||
};
|
||||
}
|
||||
|
||||
function applyDudsToChoices(choices) {
|
||||
const result = [...choices];
|
||||
let duds = 0;
|
||||
const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0);
|
||||
const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
|
||||
for (let i = 0; i < result.length && duds < maxDuds; i += 1) {
|
||||
if (Math.random() < chance) {
|
||||
result[i] = dudCard();
|
||||
duds += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function dealCards(game, count = BASE_DRAFT_SIZE) {
|
||||
ensureCardState(game);
|
||||
const source = availableCards(game);
|
||||
|
|
@ -174,7 +217,7 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) {
|
|||
const i = pool.findIndex(card => card.id === picked.id);
|
||||
if (i >= 0) pool.splice(i, 1);
|
||||
}
|
||||
return choices;
|
||||
return applyDudsToChoices(choices);
|
||||
}
|
||||
|
||||
function targetKey(target) {
|
||||
|
|
@ -299,12 +342,22 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
|
||||
function chooseExtraCards(card) {
|
||||
removeOneChoice(game, card);
|
||||
game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + 2;
|
||||
game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + CARD_BALANCE.extraCardsAddedPicks;
|
||||
game.cardDraft.choices.push(...dealCards(game, 2));
|
||||
showDraft();
|
||||
}
|
||||
|
||||
function chooseCard(card) {
|
||||
function chooseDud(card, buttonEl) {
|
||||
if (buttonEl) buttonEl.classList.add('flung');
|
||||
window.setTimeout(() => {
|
||||
removeOneChoice(game, card);
|
||||
refillChoicesIfNeeded();
|
||||
showDraft();
|
||||
}, 190);
|
||||
}
|
||||
|
||||
function chooseCard(card, buttonEl = null) {
|
||||
if (card.type === 'dud') return chooseDud(card, buttonEl);
|
||||
if (card.id === 'extraCards') return chooseExtraCards(card);
|
||||
if (card.type === 'equipmentUpgrade') return startMapTargetPicker(card);
|
||||
applyInstantCard(game, card);
|
||||
|
|
@ -356,10 +409,11 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
function cardButton(card, index) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = `card-choice ${card.rarity === 'rare' ? 'rare' : 'common'}`;
|
||||
const rarity = card.rarity === 'rare' ? 'RARE' : 'COMMON';
|
||||
const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'rare' ? 'rare' : 'common');
|
||||
b.className = `card-choice ${kind}`;
|
||||
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'rare' ? 'RARE' : 'COMMON');
|
||||
b.innerHTML = `<strong>${card.title}</strong><span>${rarity}</span><small>${cardDescription(game, card)}</small>`;
|
||||
b.addEventListener('click', () => chooseCard(card));
|
||||
b.addEventListener('click', () => chooseCard(card, b));
|
||||
b.dataset.index = String(index);
|
||||
return b;
|
||||
}
|
||||
|
|
@ -373,7 +427,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
const cost = rerollCost(game);
|
||||
const remaining = Math.max(1, game.cardDraft.picksRemaining || 1);
|
||||
ui.modalTitle.textContent = 'Choose Upgrade Card';
|
||||
ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Equipment cards are applied by selecting a white-highlighted machine on the map.</p><div id="cardChoices" class="card-choices"></div>`;
|
||||
ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Each shown card has a 30% dud chance, capped at 2 duds per draft. Click dud cards to flick them away. Equipment cards are applied by selecting a white-highlighted machine on the map.</p><div id="cardChoices" class="card-choices"></div>`;
|
||||
const wrap = ui.modalBody.querySelector('#cardChoices');
|
||||
choices.forEach((card, index) => wrap.appendChild(cardButton(card, index)));
|
||||
ui.modalActions.innerHTML = '';
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ import { createChick } from '../core/entities.js';
|
|||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js';
|
||||
import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice } from './economy.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js';
|
||||
import { autoScannerCooldownSeconds } from './cards.js';
|
||||
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
|
||||
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
|
||||
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
|
||||
import { emitGameEvent } from './gameEvents.js';
|
||||
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
|
||||
|
||||
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
|
||||
function currentSpawnDelay(farm) {
|
||||
return nextSpawnDelay(farm) / productionMultiplier(game);
|
||||
return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) / productionMultiplier(game);
|
||||
}
|
||||
|
||||
function spawnChick(farm) {
|
||||
|
|
@ -30,6 +32,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const chick = createChick(game, data);
|
||||
if (chick.sex === 'poop') countPoopSpawned(game);
|
||||
game.chicks.push(chick);
|
||||
recordFarmProduction(game, farm);
|
||||
spawnPulse(game, farmCenter.x, farmCenter.y);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -39,6 +42,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
|
||||
updateRepairman(game, dt);
|
||||
updateProcessingCooldowns(game, dt);
|
||||
if (game.cleanupTimer !== null) {
|
||||
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
|
||||
if (game.cleanupTimer <= 0) completeTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
const producing = game.timeLeft > 0;
|
||||
game.timeLeft = Math.max(0, game.timeLeft - dt);
|
||||
if (producing && game.timeLeft <= 0) closeFarmShutters();
|
||||
|
|
@ -65,11 +76,32 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
updateScannerQueues(dt);
|
||||
updateCongestion();
|
||||
checkCongestionExplosions();
|
||||
if (game.timeLeft <= 0 && (game.chicks.length === 0 || game.shutdownTimeLeft <= 0)) completeTurn();
|
||||
if (game.timeLeft > 0 || game.shutdownTimeLeft > 0) checkCongestionExplosions();
|
||||
if (game.timeLeft <= 0) {
|
||||
if (game.chicks.length === 0) completeTurn();
|
||||
else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup();
|
||||
}
|
||||
onGameOverCheck();
|
||||
}
|
||||
|
||||
function blowOffRemainingForCleanup() {
|
||||
if (game.cleanupBlown) return;
|
||||
game.cleanupBlown = true;
|
||||
game.cleanupTimer = 1;
|
||||
const victims = [...game.chicks];
|
||||
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();
|
||||
if (victims.length) {
|
||||
emitGameEvent(game, 'CleanupBlowoff', { count: 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;
|
||||
shockwave(game, cx, cy, THEME.warn, 58);
|
||||
floating(game, cx, cy - 24, 'CLEANUP', THEME.warn);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRoutedChick(chick, index, dt) {
|
||||
if (blockedByFrontChick(chick)) {
|
||||
chick.stoppedTimer = 0.35;
|
||||
|
|
@ -105,19 +137,40 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
return chick.targetIndex || 0;
|
||||
}
|
||||
|
||||
function conveyorKeyFromPoint(p) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (!cell) return null;
|
||||
const k = key(cell.col, cell.row);
|
||||
return game.conveyorTiles.has(k) ? k : null;
|
||||
}
|
||||
|
||||
function speedForChick(chick) {
|
||||
const k = conveyorKeyFromPoint(chick) || conveyorKeyFromPoint(chick.route?.[chick.targetIndex] || chick);
|
||||
return currentConveyorSpeed(k);
|
||||
}
|
||||
|
||||
function recordPassAtPoint(chick, p) {
|
||||
const k = conveyorKeyFromPoint(p);
|
||||
if (!k || chick.lastWearKey === k) return;
|
||||
chick.lastWearKey = k;
|
||||
recordConveyorPass(game, k);
|
||||
}
|
||||
|
||||
function moveAlongRoute(chick, dt) {
|
||||
let remaining = currentConveyorSpeed() * dt;
|
||||
let remaining = speedForChick(chick) * dt;
|
||||
while (remaining > 0 && chick.targetIndex < chick.route.length) {
|
||||
const target = chick.route[chick.targetIndex];
|
||||
const dx = target.x - chick.x, dy = target.y - chick.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= 0.001) {
|
||||
recordPassAtPoint(chick, target);
|
||||
chick.targetIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (remaining >= dist) {
|
||||
chick.x = target.x;
|
||||
chick.y = target.y;
|
||||
recordPassAtPoint(chick, target);
|
||||
chick.targetIndex += 1;
|
||||
remaining -= dist;
|
||||
} else {
|
||||
|
|
@ -185,7 +238,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
continue;
|
||||
}
|
||||
if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) {
|
||||
scanner.cooldown = autoScannerCooldownSeconds(scanner);
|
||||
recordAutoScan(game, scanner);
|
||||
scanner.cooldown = autoScannerCooldownSeconds(scanner, game);
|
||||
countAutoSorted(game);
|
||||
}
|
||||
}
|
||||
|
|
@ -235,10 +289,18 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function resolveMixer(index) {
|
||||
const mixer = game.facilities.mixer;
|
||||
if (mixer?.processingCooldown > 0) {
|
||||
const chick = game.chicks[index];
|
||||
if (chick) chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
const chick = takeChick(index);
|
||||
if (!chick) return;
|
||||
const { x, y } = chick;
|
||||
countMixer(game);
|
||||
recordFacilityProcess(game, 'mixer');
|
||||
setFacilityCooldownAfterProcess(game, 'mixer');
|
||||
if (chick.sex === 'poop') {
|
||||
const penalty = applyMixerPoopFine(game);
|
||||
countPoopDestination(game, 'mixer');
|
||||
|
|
@ -301,18 +363,28 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function resolveTrash(index) {
|
||||
const trash = game.facilities.trash;
|
||||
if (trash?.processingCooldown > 0) {
|
||||
const chick = game.chicks[index];
|
||||
if (chick) chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
const chick = takeChick(index);
|
||||
if (!chick) return;
|
||||
const { x, y } = chick;
|
||||
shredEffect(game, x, y, chick.sex);
|
||||
countTrash(game);
|
||||
recordFacilityProcess(game, 'trash');
|
||||
setFacilityCooldownAfterProcess(game, 'trash');
|
||||
const bonus = applyShredderBonus(game);
|
||||
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
|
||||
if (chick.sex === 'poop') {
|
||||
countPoopDestination(game, 'trash');
|
||||
countCorrect(game);
|
||||
floating(game, x, y - 18, 'CLEAN', THEME.green);
|
||||
floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green);
|
||||
} else {
|
||||
countMistake(game);
|
||||
floating(game, x, y - 18, 'WASTE', THEME.ink);
|
||||
floating(game, x, y - 18, bonus.amount > 0 ? `WASTE${bonusText}` : 'WASTE', bonus.amount > 0 ? THEME.green : THEME.ink);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +471,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
const first = parseKey(comp.cells[0]);
|
||||
const fc = cellCenter(first.col, first.row);
|
||||
floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
|
||||
emitGameEvent(game, 'JamExplosion', { component: comp.id, victims: victims.length });
|
||||
}
|
||||
|
||||
function explodeRoute(route, extras, label) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js';
|
||||
import { factoryValue, targetTruckCount, truckTarget } from './contracts.js';
|
||||
import { emitGameEvent } from './gameEvents.js';
|
||||
|
||||
export function equipmentBasePrice(objOrHit) {
|
||||
const obj = objOrHit?.ref || objOrHit || {};
|
||||
|
|
@ -44,6 +45,47 @@ export function maleTruckPenalty(game) {
|
|||
return Math.ceil(baseFine * incomeMultiplier(game, 'truck'));
|
||||
}
|
||||
|
||||
export function shredderUpgradeCount(game) {
|
||||
const raw = Math.max(0, (game.facilities?.trash?.level || 1) - 1);
|
||||
return Math.min(ECONOMY.shredderBonus.maxCards, raw);
|
||||
}
|
||||
|
||||
export function rawShredderUpgradeCount(game) {
|
||||
return Math.max(0, (game.facilities?.trash?.level || 1) - 1);
|
||||
}
|
||||
|
||||
export function shredderBonusMaxCards() {
|
||||
return ECONOMY.shredderBonus.maxCards;
|
||||
}
|
||||
|
||||
export function shredderBonusChance(gameOrCount) {
|
||||
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
|
||||
const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count));
|
||||
const percent = (Math.sqrt(capped) / ECONOMY.shredderBonus.probabilityPercentDivisor) * capped;
|
||||
return Math.min(1, percent / 100);
|
||||
}
|
||||
|
||||
export function shredderBonusExpectedPerItem(gameOrCount) {
|
||||
return shredderBonusChance(gameOrCount);
|
||||
}
|
||||
|
||||
export function rollShredderBonus(game) {
|
||||
const cards = shredderUpgradeCount(game);
|
||||
const chance = shredderBonusChance(cards);
|
||||
const amount = cards > 0 && Math.random() < chance ? 1 : 0;
|
||||
return { cards, chance, amount, expected: chance };
|
||||
}
|
||||
|
||||
export function applyShredderBonus(game) {
|
||||
const result = rollShredderBonus(game);
|
||||
if (result.amount > 0) {
|
||||
game.stats.shredderBonus = (game.stats.shredderBonus || 0) + result.amount;
|
||||
game.totals.shredderBonus = (game.totals.shredderBonus || 0) + result.amount;
|
||||
applyRevenue(game, result.amount);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function zundaTaxInfo(cash, game = null) {
|
||||
const rules = ECONOMY.zundaTax;
|
||||
const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0);
|
||||
|
|
@ -100,12 +142,14 @@ export function positivePayout(_game, amount) {
|
|||
}
|
||||
|
||||
export function applyCashDelta(game, delta) {
|
||||
const before = game.cash;
|
||||
game.cash += delta;
|
||||
game.stats.profit += delta;
|
||||
game.totals.profit += delta;
|
||||
if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; }
|
||||
else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); }
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
emitGameEvent(game, 'CashDelta', { delta, before, after: game.cash });
|
||||
}
|
||||
|
||||
export function applyRevenue(game, amount) {
|
||||
|
|
@ -126,10 +170,12 @@ export function spendCash(game, amount) {
|
|||
|
||||
export function refundCash(game, amount) {
|
||||
if (amount <= 0) return;
|
||||
const before = game.cash;
|
||||
game.cash += amount;
|
||||
game.stats.profit += amount;
|
||||
game.totals.profit += amount;
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
emitGameEvent(game, 'CashRefund', { amount, before, after: game.cash });
|
||||
}
|
||||
|
||||
export function applyMixerIncome(game) {
|
||||
|
|
|
|||
6
src/systems/effects.js
vendored
6
src/systems/effects.js
vendored
|
|
@ -43,10 +43,10 @@ export function smokeBurst(game, x, y, count = 10) {
|
|||
game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) });
|
||||
}
|
||||
}
|
||||
export function flyingDebris(game, sex, x, y, baseAngle = null) {
|
||||
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) });
|
||||
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 });
|
||||
}
|
||||
export function eraseEffect(game, x, y) {
|
||||
game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 });
|
||||
|
|
@ -66,7 +66,7 @@ export function updateEffects(game, dt, canvas, equipmentHitBoxes) {
|
|||
e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt;
|
||||
} else if (e.type === 'flyingChick') {
|
||||
e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt;
|
||||
handleFlyingChickDamage(game, e, equipmentHitBoxes);
|
||||
if (!e.noDamage) handleFlyingChickDamage(game, e, equipmentHitBoxes);
|
||||
const sx = e.x + game.view.x, sy = e.y + game.view.y;
|
||||
if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0;
|
||||
}
|
||||
|
|
|
|||
39
src/systems/gameEvents.js
Normal file
39
src/systems/gameEvents.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const MAX_EVENTS = 160;
|
||||
|
||||
export function emitGameEvent(game, type, payload = {}) {
|
||||
if (!game) return null;
|
||||
if (!Array.isArray(game.eventLog)) game.eventLog = [];
|
||||
const id = game.nextEventId || 1;
|
||||
game.nextEventId = id + 1;
|
||||
const event = {
|
||||
id,
|
||||
at: Date.now(),
|
||||
day: game.turn || 1,
|
||||
phase: game.phase || 'unknown',
|
||||
type,
|
||||
payload: sanitizePayload(payload)
|
||||
};
|
||||
game.eventLog.push(event);
|
||||
if (game.eventLog.length > MAX_EVENTS) game.eventLog.splice(0, game.eventLog.length - MAX_EVENTS);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function recentGameEvents(game, limit = 8) {
|
||||
const events = Array.isArray(game?.eventLog) ? game.eventLog : [];
|
||||
return events.slice(Math.max(0, events.length - limit)).reverse();
|
||||
}
|
||||
|
||||
export function clearGameEvents(game) {
|
||||
if (game) game.eventLog = [];
|
||||
}
|
||||
|
||||
function sanitizePayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean' || v == null) out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.slice(0, 12).map(x => typeof x === 'object' ? JSON.stringify(x).slice(0, 80) : x);
|
||||
else out[k] = JSON.stringify(v).slice(0, 120);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -46,7 +46,11 @@ export function restore(game, text) {
|
|||
game.groupDrag = null;
|
||||
game.selectionBox = null;
|
||||
game.pan = null;
|
||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||
game.factoryGraph = null;
|
||||
game.factoryGraphCommittedVersion = -1;
|
||||
}
|
||||
|
||||
export function record(game) {
|
||||
if (game.phase !== 'build') return;
|
||||
game.undoStack.push(snapshot(game));
|
||||
|
|
|
|||
276
src/systems/maintenance.js
Normal file
276
src/systems/maintenance.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { BALANCE } from '../core/balance.js';
|
||||
import { GRID, THEME } from '../core/config.js';
|
||||
import { cellCenter, parseKey, key, yen } from '../core/utils.js';
|
||||
import { spendCash } from './economy.js';
|
||||
import { floating } from './effects.js';
|
||||
import { emitGameEvent } from './gameEvents.js';
|
||||
|
||||
const RULES = BALANCE.maintenance;
|
||||
|
||||
export function ensureMaintenanceState(game) {
|
||||
if (!game.repairman) {
|
||||
game.repairman = {
|
||||
hiredForNextDay: false,
|
||||
active: false,
|
||||
x: GRID.x + GRID.cell * 0.5,
|
||||
y: GRID.y + GRID.rows * GRID.cell + 84,
|
||||
target: null,
|
||||
repairedToday: 0
|
||||
};
|
||||
}
|
||||
for (const [k, meta] of game.conveyorMeta || []) {
|
||||
if (meta.uses == null) meta.uses = 0;
|
||||
if (meta.durability == null) meta.durability = RULES.durability.conveyor;
|
||||
if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor';
|
||||
}
|
||||
for (const farm of game.eggFarms || []) attachMaintenance(farm, 'eggFarm');
|
||||
for (const scanner of game.scanners || []) {
|
||||
if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner');
|
||||
}
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
if (f.id === 'mixer') attachMaintenance(f, 'mixer');
|
||||
if (f.id === 'trash') attachMaintenance(f, 'trash');
|
||||
}
|
||||
}
|
||||
|
||||
function attachMaintenance(obj, type) {
|
||||
if (!obj) return;
|
||||
if (!obj.maintenance) obj.maintenance = {};
|
||||
obj.maintenance.type = type;
|
||||
if (obj.maintenance.uses == null) obj.maintenance.uses = 0;
|
||||
if (obj.maintenance.durability == null) obj.maintenance.durability = RULES.durability[type] || 1000;
|
||||
}
|
||||
|
||||
function usesOf(target) {
|
||||
if (!target) return 0;
|
||||
if (target.meta) return Math.max(0, target.meta.uses || 0);
|
||||
return Math.max(0, target.maintenance?.uses || 0);
|
||||
}
|
||||
function durabilityOf(target) {
|
||||
if (!target) return 1;
|
||||
if (target.meta) return Math.max(1, target.meta.durability || RULES.durability.conveyor);
|
||||
return Math.max(1, target.maintenance?.durability || RULES.durability[target.maintenance?.type] || 1000);
|
||||
}
|
||||
function setUses(target, uses) {
|
||||
if (!target) return;
|
||||
if (target.meta) target.meta.uses = Math.max(0, uses);
|
||||
else if (target.maintenance) target.maintenance.uses = Math.max(0, uses);
|
||||
}
|
||||
|
||||
export function wearRatio(target) {
|
||||
return Math.max(0, Math.min(1, usesOf(target) / durabilityOf(target)));
|
||||
}
|
||||
|
||||
export function remainingPercent(target) {
|
||||
return Math.max(0, Math.round((1 - wearRatio(target)) * 100));
|
||||
}
|
||||
|
||||
export function performanceFactor(target) {
|
||||
const wear = wearRatio(target);
|
||||
if (wear <= RULES.fullPerformanceUntilWear) return 1;
|
||||
const t = (wear - RULES.fullPerformanceUntilWear) / (1 - RULES.fullPerformanceUntilWear);
|
||||
return Math.max(RULES.minimumPerformance, 1 - t * (1 - RULES.minimumPerformance));
|
||||
}
|
||||
|
||||
export function degradationPercent(target) {
|
||||
return Math.round(wearRatio(target) * 100);
|
||||
}
|
||||
|
||||
export function conveyorTarget(game, conveyorKey) {
|
||||
const meta = game.conveyorMeta?.get(conveyorKey);
|
||||
return meta ? { type: 'conveyor', key: conveyorKey, meta } : null;
|
||||
}
|
||||
|
||||
export function conveyorSpeedFactorForKey(game, conveyorKey) {
|
||||
ensureMaintenanceState(game);
|
||||
const target = conveyorTarget(game, conveyorKey);
|
||||
return target ? performanceFactor(target) : 1;
|
||||
}
|
||||
|
||||
export function averageConveyorPerformance(game) {
|
||||
ensureMaintenanceState(game);
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const k of game.conveyorTiles || []) {
|
||||
sum += conveyorSpeedFactorForKey(game, k);
|
||||
count += 1;
|
||||
}
|
||||
return count ? sum / count : 1;
|
||||
}
|
||||
|
||||
export function delayMultiplier(target) {
|
||||
return 1 / performanceFactor(target);
|
||||
}
|
||||
|
||||
export function autoScannerDelayMultiplier(scanner) {
|
||||
if (!scanner || scanner.kind !== 'auto') return 1;
|
||||
attachMaintenance(scanner, 'autoScanner');
|
||||
return delayMultiplier(scanner);
|
||||
}
|
||||
|
||||
export function eggSpawnDelayMultiplier(farm) {
|
||||
attachMaintenance(farm, 'eggFarm');
|
||||
return delayMultiplier(farm);
|
||||
}
|
||||
|
||||
export function facilityProcessingDelay(game, id) {
|
||||
ensureMaintenanceState(game);
|
||||
const f = game.facilities?.[id];
|
||||
if (!f || !['mixer', 'trash'].includes(id)) return 0;
|
||||
attachMaintenance(f, id);
|
||||
const t = Math.max(0, delayMultiplier(f) - 1);
|
||||
return t * (RULES.processingDelayMaxSeconds?.[id] || 1);
|
||||
}
|
||||
|
||||
export function recordConveyorPass(game, conveyorKey) {
|
||||
ensureMaintenanceState(game);
|
||||
const meta = game.conveyorMeta?.get(conveyorKey);
|
||||
if (!meta) return;
|
||||
meta.uses = Math.min(meta.durability || RULES.durability.conveyor, (meta.uses || 0) + 1);
|
||||
game.stats.conveyorPasses = (game.stats.conveyorPasses || 0) + 1;
|
||||
}
|
||||
|
||||
export function recordFarmProduction(game, farm) {
|
||||
ensureMaintenanceState(game);
|
||||
attachMaintenance(farm, 'eggFarm');
|
||||
farm.maintenance.uses = Math.min(farm.maintenance.durability, (farm.maintenance.uses || 0) + 1);
|
||||
game.stats.eggProduced = (game.stats.eggProduced || 0) + 1;
|
||||
}
|
||||
|
||||
export function recordAutoScan(game, scanner) {
|
||||
ensureMaintenanceState(game);
|
||||
if (!scanner || scanner.kind !== 'auto') return;
|
||||
attachMaintenance(scanner, 'autoScanner');
|
||||
scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + 1);
|
||||
}
|
||||
|
||||
export function recordFacilityProcess(game, id) {
|
||||
ensureMaintenanceState(game);
|
||||
const f = game.facilities?.[id];
|
||||
if (!f || !['mixer', 'trash'].includes(id)) return;
|
||||
attachMaintenance(f, id);
|
||||
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + 1);
|
||||
}
|
||||
|
||||
export function setFacilityCooldownAfterProcess(game, id) {
|
||||
const f = game.facilities?.[id];
|
||||
if (!f) return 0;
|
||||
const delay = facilityProcessingDelay(game, id);
|
||||
f.processingCooldown = Math.max(f.processingCooldown || 0, delay);
|
||||
return delay;
|
||||
}
|
||||
|
||||
export function updateProcessingCooldowns(game, dt) {
|
||||
for (const id of ['mixer', 'trash']) {
|
||||
const f = game.facilities?.[id];
|
||||
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
|
||||
}
|
||||
}
|
||||
|
||||
export function equipmentMaintenanceTargets(game) {
|
||||
ensureMaintenanceState(game);
|
||||
const targets = [];
|
||||
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `Belt ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
|
||||
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
|
||||
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 id of ['mixer', 'trash']) {
|
||||
const f = game.facilities?.[id];
|
||||
if (f) targets.push({ type: id, ref: f, label: f.shortName || f.name || id, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function targetWear(t) { return t.meta ? wearRatio(t) : wearRatio(t.ref); }
|
||||
function targetUses(t) { return t.meta ? usesOf(t) : usesOf(t.ref); }
|
||||
function targetDurability(t) { return t.meta ? durabilityOf(t) : durabilityOf(t.ref); }
|
||||
function reduceTargetUses(t, amount) { setUses(t.meta ? t : t.ref, Math.max(0, targetUses(t) - amount)); }
|
||||
function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${t.type}:${t.ref?.id || t.ref?.type || t.type}`; }
|
||||
|
||||
export function hireRepairmanForNextDay(game) {
|
||||
ensureMaintenanceState(game);
|
||||
if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' };
|
||||
const cost = RULES.repairman.dailyCost;
|
||||
if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` };
|
||||
spendCash(game, cost);
|
||||
game.repairman.hiredForNextDay = true;
|
||||
emitGameEvent(game, 'HireRepairman', { cost });
|
||||
return { ok: true, cost };
|
||||
}
|
||||
|
||||
export function activateRepairmanForDay(game) {
|
||||
ensureMaintenanceState(game);
|
||||
game.repairman.active = !!game.repairman.hiredForNextDay;
|
||||
game.repairman.hiredForNextDay = false;
|
||||
game.repairman.target = null;
|
||||
game.repairman.repairedToday = 0;
|
||||
if (game.repairman.active) emitGameEvent(game, 'RepairmanStarted', { day: game.turn });
|
||||
}
|
||||
|
||||
export function deactivateRepairman(game) {
|
||||
ensureMaintenanceState(game);
|
||||
if (game.repairman.active) emitGameEvent(game, 'RepairmanStopped', { repairedPercent: Math.round(game.repairman.repairedToday || 0) });
|
||||
game.repairman.active = false;
|
||||
game.repairman.target = null;
|
||||
}
|
||||
|
||||
function resolveStoredTarget(game, stored) {
|
||||
if (!stored) return null;
|
||||
const targets = equipmentMaintenanceTargets(game);
|
||||
return targets.find(t => targetKey(t) === stored.key) || null;
|
||||
}
|
||||
|
||||
function chooseRepairTarget(game) {
|
||||
let best = null;
|
||||
for (const t of equipmentMaintenanceTargets(game)) {
|
||||
const w = targetWear(t);
|
||||
if (w <= 0.001) continue;
|
||||
if (!best || w > targetWear(best)) best = t;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function updateRepairman(game, dt) {
|
||||
ensureMaintenanceState(game);
|
||||
const r = game.repairman;
|
||||
if (!r.active || game.phase !== 'running') return;
|
||||
let target = resolveStoredTarget(game, r.target) || chooseRepairTarget(game);
|
||||
if (!target) {
|
||||
r.target = null;
|
||||
return;
|
||||
}
|
||||
r.target = { key: targetKey(target), label: target.label };
|
||||
const speed = RULES.repairman.walkSpeed;
|
||||
const dx = target.center.x - r.x;
|
||||
const dy = target.center.y - r.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > 8) {
|
||||
const step = Math.min(dist, speed * dt);
|
||||
r.x += dx / dist * step;
|
||||
r.y += dy / dist * step;
|
||||
return;
|
||||
}
|
||||
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * 100);
|
||||
const usesBefore = targetUses(target);
|
||||
const usesDelta = repairWear * targetDurability(target);
|
||||
reduceTargetUses(target, usesDelta);
|
||||
const repairedPct = Math.max(0, (usesBefore - targetUses(target)) / targetDurability(target) * 100);
|
||||
r.repairedToday = (r.repairedToday || 0) + repairedPct;
|
||||
if (repairedPct > 0 && Math.random() < 0.02) floating(game, r.x, r.y - 28, 'REPAIR', THEME.green);
|
||||
if (targetWear(target) <= 0.001) r.target = null;
|
||||
}
|
||||
|
||||
export function worstDegradation(game) {
|
||||
let worst = 0;
|
||||
let label = 'none';
|
||||
for (const t of equipmentMaintenanceTargets(game)) {
|
||||
const w = targetWear(t);
|
||||
if (w > worst) { worst = w; label = t.label; }
|
||||
}
|
||||
return { ratio: worst, percent: Math.round(worst * 100), label };
|
||||
}
|
||||
|
||||
export function maintenanceSummary(game) {
|
||||
const worst = worstDegradation(game);
|
||||
const avgBeltPerformance = averageConveyorPerformance(game);
|
||||
return { worst, avgBeltPerformance, repairman: game.repairman || null };
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ export function scannerById(game, id) { return game.scanners.find(s => s.id ===
|
|||
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Port definitions
|
||||
// Port definitions: only exact receiver/output cells are valid connections.
|
||||
// Visual adjacency is deliberately not treated as a connection.
|
||||
// -----------------------------------------------------------------------------
|
||||
export function scannerConnector(scanner, type) {
|
||||
return {
|
||||
|
|
@ -104,22 +105,191 @@ export function inputCellsForScanner(game, scanner) { return scannerInputCells(g
|
|||
export function refreshRoutingAfterEdit(game) {
|
||||
game.branchCounters?.clear?.();
|
||||
game.routeCache = null;
|
||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||
game.factoryGraph = null;
|
||||
game.factoryGraphCommittedVersion = -1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FactoryGraph: build once after edits, then route and validate against this
|
||||
// graph. The graph contains conveyor cells, exact ports, component membership,
|
||||
// and cached path results. This makes the simulation read a single canonical
|
||||
// connectivity model instead of re-interpreting the map in several systems.
|
||||
// -----------------------------------------------------------------------------
|
||||
function graphNodeKey(col, row) { return key(col, row); }
|
||||
function graphCell(k) { return parseKey(k); }
|
||||
|
||||
function emptyFactoryGraph(game) {
|
||||
return {
|
||||
version: game.routingVersion || 0,
|
||||
cells: new Set(),
|
||||
adjacency: new Map(),
|
||||
components: new Map(),
|
||||
cellToComponent: new Map(),
|
||||
scannerInputs: new Map(),
|
||||
scannerPorts: new Map(),
|
||||
facilityPorts: new Map(),
|
||||
farmOutputs: new Map(),
|
||||
pathCache: new Map(),
|
||||
metrics: {
|
||||
conveyorCells: 0,
|
||||
edges: 0,
|
||||
components: 0,
|
||||
deadEnds: 0,
|
||||
crosses: 0,
|
||||
scannerPorts: 0,
|
||||
connectedScannerPorts: 0,
|
||||
facilityPorts: 0,
|
||||
connectedFacilityPorts: 0,
|
||||
farmOutputs: 0
|
||||
},
|
||||
issues: []
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFactoryGraph(game) {
|
||||
const graph = emptyFactoryGraph(game);
|
||||
|
||||
for (const k of game.conveyorTiles) {
|
||||
graph.cells.add(k);
|
||||
graph.adjacency.set(k, []);
|
||||
}
|
||||
|
||||
for (const k of graph.cells) {
|
||||
const p = graphCell(k);
|
||||
for (const d of DIRS) {
|
||||
const nk = graphNodeKey(p.col + d.dc, p.row + d.dr);
|
||||
if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name });
|
||||
}
|
||||
}
|
||||
|
||||
let edgePairs = 0;
|
||||
for (const [k, ns] of graph.adjacency.entries()) {
|
||||
edgePairs += ns.length;
|
||||
const names = new Set(ns.map(n => n.dir));
|
||||
if (ns.length === 1) graph.metrics.deadEnds += 1;
|
||||
if (ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down')) graph.metrics.crosses += 1;
|
||||
}
|
||||
graph.metrics.conveyorCells = graph.cells.size;
|
||||
graph.metrics.edges = Math.floor(edgePairs / 2);
|
||||
|
||||
buildGraphComponents(graph);
|
||||
indexGraphPorts(game, graph);
|
||||
graph.issues = validateFactoryGraph(game, graph, { includeRoutes: false });
|
||||
return graph;
|
||||
}
|
||||
|
||||
function buildGraphComponents(graph) {
|
||||
let next = 1;
|
||||
for (const k of graph.cells) {
|
||||
if (graph.cellToComponent.has(k)) continue;
|
||||
const id = next++;
|
||||
const queue = [k];
|
||||
const cells = [];
|
||||
graph.cellToComponent.set(k, id);
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
cells.push(cur);
|
||||
for (const n of graph.adjacency.get(cur) || []) {
|
||||
if (graph.cellToComponent.has(n.key)) continue;
|
||||
graph.cellToComponent.set(n.key, id);
|
||||
queue.push(n.key);
|
||||
}
|
||||
}
|
||||
graph.components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
|
||||
}
|
||||
graph.metrics.components = graph.components.size;
|
||||
}
|
||||
|
||||
function indexGraphPorts(game, graph) {
|
||||
for (const scanner of game.scanners) {
|
||||
const ports = {};
|
||||
for (const type of ['inputA', 'left', 'right']) {
|
||||
const cell = scannerConnector(scanner, type);
|
||||
const cellKey = cell ? graphNodeKey(cell.col, cell.row) : null;
|
||||
const connected = !!cellKey && graph.cells.has(cellKey);
|
||||
ports[type] = { type, cell, key: connected ? cellKey : null, connected };
|
||||
graph.metrics.scannerPorts += 1;
|
||||
if (connected) graph.metrics.connectedScannerPorts += 1;
|
||||
if (type === 'inputA' && connected) graph.scannerInputs.set(cellKey, scanner.id);
|
||||
}
|
||||
graph.scannerPorts.set(scanner.id, ports);
|
||||
}
|
||||
|
||||
for (const [id, f] of Object.entries(game.facilities)) {
|
||||
if (!f.entry) continue;
|
||||
const cellKey = graphNodeKey(f.entry.col, f.entry.row);
|
||||
const connected = graph.cells.has(cellKey);
|
||||
graph.facilityPorts.set(id, { id, cell: { ...f.entry }, key: connected ? cellKey : null, connected });
|
||||
graph.metrics.facilityPorts += 1;
|
||||
if (connected) graph.metrics.connectedFacilityPorts += 1;
|
||||
}
|
||||
|
||||
for (const farm of game.eggFarms) {
|
||||
const outputs = adjacentCells({ col: farm.col, row: farm.row })
|
||||
.map(p => graphNodeKey(p.col, p.row))
|
||||
.filter(k => graph.cells.has(k));
|
||||
graph.farmOutputs.set(farm.id, outputs);
|
||||
graph.metrics.farmOutputs += outputs.length;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureFactoryGraph(game) {
|
||||
const version = game.routingVersion || 0;
|
||||
if (!game.factoryGraph || game.factoryGraph.version !== version) game.factoryGraph = buildFactoryGraph(game);
|
||||
return game.factoryGraph;
|
||||
}
|
||||
|
||||
export function commitFactoryGraphForDay(game) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
game.factoryGraphCommittedVersion = graph.version;
|
||||
game.factoryGraphSnapshot = graphSummary(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
export function graphSummary(graph) {
|
||||
if (!graph) return null;
|
||||
return {
|
||||
version: graph.version,
|
||||
conveyorCells: graph.metrics.conveyorCells,
|
||||
edges: graph.metrics.edges,
|
||||
components: graph.metrics.components,
|
||||
deadEnds: graph.metrics.deadEnds,
|
||||
crosses: graph.metrics.crosses,
|
||||
scannerPorts: `${graph.metrics.connectedScannerPorts}/${graph.metrics.scannerPorts}`,
|
||||
facilityPorts: `${graph.metrics.connectedFacilityPorts}/${graph.metrics.facilityPorts}`,
|
||||
farmOutputs: graph.metrics.farmOutputs,
|
||||
issues: [...(graph.issues || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function factoryGraphMetrics(game) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const routeIssues = validateFactoryGraph(game, graph, { includeRoutes: true });
|
||||
const maxCongestion = game.congestion?.size ? Math.max(...[...game.congestion.values()].map(c => c.ratio || 0), 0) : 0;
|
||||
const queued = game.scanners.reduce((sum, scanner) => sum + (scanner.queue?.length || 0), 0);
|
||||
return {
|
||||
...graphSummary(graph),
|
||||
issues: routeIssues,
|
||||
activeItems: game.chicks.length,
|
||||
queuedItems: queued,
|
||||
movingItems: Math.max(0, game.chicks.length - queued),
|
||||
maxCongestion
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Conveyor graph and pathfinding
|
||||
// -----------------------------------------------------------------------------
|
||||
export function getConveyorNeighbors(game, col, row) {
|
||||
const result = [];
|
||||
for (const d of DIRS) {
|
||||
const nc = col + d.dc, nr = row + d.dr;
|
||||
if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d });
|
||||
}
|
||||
return result;
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const k = graphNodeKey(col, row);
|
||||
return (graph.adjacency.get(k) || []).map(n => ({ ...graphCell(n.key), dir: DIRS.find(d => d.name === n.dir) || { name: n.dir, dc: 0, dr: 0 } }));
|
||||
}
|
||||
|
||||
export function getAdjacentConveyors(game, col, row) {
|
||||
return adjacentCells({ col, row }).filter(p => game.conveyorTiles.has(key(p.col, p.row)));
|
||||
const graph = ensureFactoryGraph(game);
|
||||
return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
}
|
||||
|
||||
function dirBetween(a, b) {
|
||||
|
|
@ -127,79 +297,67 @@ function dirBetween(a, b) {
|
|||
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
|
||||
}
|
||||
|
||||
function isCross(game, col, row) {
|
||||
const ns = getConveyorNeighbors(game, col, row);
|
||||
const names = new Set(ns.map(n => n.dir.name));
|
||||
function isCrossInGraph(graph, cellKey) {
|
||||
const ns = graph.adjacency.get(cellKey) || [];
|
||||
const names = new Set(ns.map(n => n.dir));
|
||||
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
|
||||
}
|
||||
|
||||
// A four-way cross conveyor is an overpass/crossing. Chicks never turn there.
|
||||
export function bfsAllRoutes(game, start, isGoal) {
|
||||
const startK = key(start.col, start.row);
|
||||
if (!game.conveyorTiles.has(startK)) return [];
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const startK = graphNodeKey(start.col, start.row);
|
||||
if (!graph.cells.has(startK)) return [];
|
||||
const cacheKey = `${startK}|${isGoal.cacheKey || 'dynamic'}`;
|
||||
if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p })));
|
||||
|
||||
const startState = `${startK}|none`;
|
||||
const queue = [{ col: start.col, row: start.row, incoming: 'none' }];
|
||||
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }];
|
||||
const visited = new Set([startState]);
|
||||
const parent = new Map();
|
||||
const found = [];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
const curK = key(cur.col, cur.row);
|
||||
const stateK = `${curK}|${cur.incoming}`;
|
||||
if (isGoal(cur)) found.push(stateK);
|
||||
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
|
||||
const outDir = dirBetween(cur, n);
|
||||
if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue;
|
||||
const nextStateK = `${key(n.col, n.row)}|${outDir}`;
|
||||
const curStateK = `${cur.key}|${cur.incoming}`;
|
||||
if (isGoal(cur)) found.push(curStateK);
|
||||
for (const n of graph.adjacency.get(cur.key) || []) {
|
||||
const next = graphCell(n.key);
|
||||
const outDir = dirBetween(cur, next);
|
||||
if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue;
|
||||
const nextStateK = `${n.key}|${outDir}`;
|
||||
if (visited.has(nextStateK)) continue;
|
||||
visited.add(nextStateK);
|
||||
parent.set(nextStateK, stateK);
|
||||
queue.push({ col: n.col, row: n.row, incoming: outDir });
|
||||
parent.set(nextStateK, curStateK);
|
||||
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir });
|
||||
}
|
||||
}
|
||||
return found.map(goalState => {
|
||||
const paths = found.map(goalState => {
|
||||
const reversed = [];
|
||||
let cursor = goalState;
|
||||
while (cursor) {
|
||||
const [cellK] = cursor.split('|');
|
||||
reversed.push(parseKey(cellK));
|
||||
reversed.push(graphCell(cellK));
|
||||
if (cellK === startK) break;
|
||||
cursor = parent.get(cursor);
|
||||
}
|
||||
return reversed.reverse();
|
||||
});
|
||||
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p }))));
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function buildConveyorComponents(game) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const components = new Map();
|
||||
const cellToComponent = new Map();
|
||||
let next = 1;
|
||||
for (const k of game.conveyorTiles) {
|
||||
if (cellToComponent.has(k)) continue;
|
||||
const id = next++;
|
||||
const queue = [parseKey(k)];
|
||||
const cells = [];
|
||||
cellToComponent.set(k, id);
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
const ck = key(cur.col, cur.row);
|
||||
cells.push(ck);
|
||||
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
|
||||
const nk = key(n.col, n.row);
|
||||
if (cellToComponent.has(nk)) continue;
|
||||
cellToComponent.set(nk, id);
|
||||
queue.push({ col: n.col, row: n.row });
|
||||
}
|
||||
}
|
||||
components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
|
||||
}
|
||||
return { components, cellToComponent };
|
||||
for (const [id, comp] of graph.components.entries()) components.set(id, { id, cells: [...comp.cells], capacity: comp.capacity, count: 0, ratio: 0 });
|
||||
return { components, cellToComponent: new Map(graph.cellToComponent) };
|
||||
}
|
||||
|
||||
export function nearestConveyorKey(game, x, y) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const k of game.conveyorTiles) {
|
||||
for (const k of graph.cells) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const d = Math.hypot(c.x - x, c.y - y);
|
||||
|
|
@ -254,8 +412,6 @@ export function scannerOutputs(scanner) {
|
|||
export function autoSideFor(scanner, chick) {
|
||||
const rules = scannerRules(scanner);
|
||||
if (rules.left.match === chick.sex) return 'left';
|
||||
if (rules.right.match === chick.sex) return 'right';
|
||||
if (rules.left.match === 'default') return 'left';
|
||||
return 'right';
|
||||
}
|
||||
|
||||
|
|
@ -266,22 +422,23 @@ export function destinationColor(dest) {
|
|||
|
||||
export function isFacilityEndpoint(game, p, dest, connector) {
|
||||
if (sameCell(p, connector)) return false;
|
||||
const f = game.facilities[dest];
|
||||
if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
|
||||
const degree = getConveyorNeighbors(game, p.col, p.row).length;
|
||||
if (degree > 1) return false;
|
||||
const entry = facilityEntryPoint(game, dest);
|
||||
return !!entry && distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const port = graph.facilityPorts.get(dest);
|
||||
return !!port?.connected && graphNodeKey(p.col, p.row) === port.key;
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
const starts = getAdjacentConveyors(game, farm.col, farm.row);
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell);
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
|
||||
const isGoal = p => graph.scannerInputs.has(graphNodeKey(p.col, p.row));
|
||||
isGoal.cacheKey = `farmInput:${start.col},${start.row}:v${graph.version}`;
|
||||
const routes = bfsAllRoutes(game, start, isGoal);
|
||||
for (const cells of routes) {
|
||||
const last = cells[cells.length - 1];
|
||||
const scanner = getInputScanner(game, last.col, last.row);
|
||||
const scannerId = graph.scannerInputs.get(graphNodeKey(last.col, last.row));
|
||||
const scanner = scannerById(game, scannerId);
|
||||
if (!scanner) continue;
|
||||
candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
|
||||
}
|
||||
|
|
@ -295,8 +452,10 @@ export function routeFromFarmToScanner(game, farm, advance = false) {
|
|||
export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
|
||||
const scanner = scannerById(game, scannerId);
|
||||
if (!scanner) return null;
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const port = graph.scannerPorts.get(scanner.id)?.[side];
|
||||
const connector = scannerConnector(scanner, side);
|
||||
const starts = outputStartCells(game, scanner, side);
|
||||
const starts = port?.connected ? [graphCell(port.key)] : [];
|
||||
if (!connector || !starts.length) return null;
|
||||
const dest = scannerOutputs(scanner)[side];
|
||||
if (dest === 'scanner-role-1') return routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance);
|
||||
|
|
@ -304,15 +463,17 @@ export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
|
|||
}
|
||||
|
||||
function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance) {
|
||||
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && graph.scannerPorts.get(s.id)?.inputA?.connected);
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
for (const target of targets) {
|
||||
const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row)));
|
||||
const routes = bfsAllRoutes(game, start, p => inputKeys.has(key(p.col, p.row)));
|
||||
for (const cells of routes) {
|
||||
candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
|
||||
}
|
||||
const targetKey = graph.scannerPorts.get(target.id)?.inputA?.key;
|
||||
if (!targetKey) continue;
|
||||
const isGoal = p => graphNodeKey(p.col, p.row) === targetKey;
|
||||
isGoal.cacheKey = `toScanner:${start.col},${start.row}:${target.id}:v${graph.version}`;
|
||||
const routes = bfsAllRoutes(game, start, isGoal);
|
||||
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
|
||||
}
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance);
|
||||
|
|
@ -321,12 +482,15 @@ function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, a
|
|||
}
|
||||
|
||||
function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const port = graph.facilityPorts.get(dest);
|
||||
if (!port?.connected) return null;
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
const routes = bfsAllRoutes(game, start, p => isFacilityEndpoint(game, p, dest, connector));
|
||||
for (const cells of routes) {
|
||||
candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
|
||||
}
|
||||
const isGoal = p => graphNodeKey(p.col, p.row) === port.key;
|
||||
isGoal.cacheKey = `toFacility:${start.col},${start.row}:${dest}:v${graph.version}`;
|
||||
const routes = bfsAllRoutes(game, start, isGoal);
|
||||
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
|
|
@ -334,40 +498,56 @@ function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Connection validation
|
||||
// Connection validation: uses FactoryGraph as the single source of truth.
|
||||
// -----------------------------------------------------------------------------
|
||||
function scannerName(scanner) {
|
||||
if (scanner.kind === 'manual') return `S${(scanner.slot ?? 0) + 1}`;
|
||||
return `Auto Scanner #${scanner.id}`;
|
||||
}
|
||||
|
||||
export function facilityConnectionIssues(game) {
|
||||
export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), options = {}) {
|
||||
const includeRoutes = options.includeRoutes !== false;
|
||||
const issues = [];
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
|
||||
|
||||
for (const id of MACHINE_FACILITY_IDS) {
|
||||
const f = game.facilities[id];
|
||||
if (!f) {
|
||||
issues.push(`${labels[id] || id} is missing`);
|
||||
continue;
|
||||
}
|
||||
if (!facilityEndpointCells(game, id).length) issues.push(`${labels[id] || id} receiver has no conveyor`);
|
||||
if (!graph.facilityPorts.get(id)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
|
||||
}
|
||||
|
||||
for (const scanner of game.scanners) {
|
||||
if (!scannerPortHasConveyor(game, scanner, 'inputA')) issues.push(`${scannerName(scanner)} input has no conveyor`);
|
||||
if (!scannerPortHasConveyor(game, scanner, 'left')) issues.push(`${scannerName(scanner)} left output has no conveyor`);
|
||||
if (!scannerPortHasConveyor(game, scanner, 'right')) issues.push(`${scannerName(scanner)} right output has no conveyor`);
|
||||
const ports = graph.scannerPorts.get(scanner.id) || {};
|
||||
if (!ports.inputA?.connected) issues.push(`${scannerName(scanner)} input has no conveyor`);
|
||||
if (!ports.left?.connected) issues.push(`${scannerName(scanner)} left output has no conveyor`);
|
||||
if (!ports.right?.connected) issues.push(`${scannerName(scanner)} right output has no conveyor`);
|
||||
if (!includeRoutes) continue;
|
||||
for (const side of ['left', 'right']) {
|
||||
if (scannerPortHasConveyor(game, scanner, side) && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) {
|
||||
issues.push(`${scannerName(scanner)} ${side} route is incomplete`);
|
||||
}
|
||||
if (ports[side]?.connected && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) issues.push(`${scannerName(scanner)} ${side} route is incomplete`);
|
||||
}
|
||||
}
|
||||
for (const farm of game.eggFarms) {
|
||||
if (!routeFromFarmToScanner(game, farm)) issues.push(`Egg Farm #${farm.id} has no scanner route`);
|
||||
|
||||
if (includeRoutes) {
|
||||
for (const farm of game.eggFarms) {
|
||||
const route = routeFromFarmToScanner(game, farm);
|
||||
if (!route?.scannerId) issues.push(`Egg Farm #${farm.id} has no scanner route`);
|
||||
}
|
||||
} else {
|
||||
for (const farm of game.eggFarms) {
|
||||
if (!(graph.farmOutputs.get(farm.id) || []).length) issues.push(`Egg Farm #${farm.id} has no output conveyor`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(issues)];
|
||||
}
|
||||
|
||||
export function facilityConnectionIssues(game) {
|
||||
return validateFactoryGraph(game, ensureFactoryGraph(game), { includeRoutes: true });
|
||||
}
|
||||
|
||||
export function factoryReady(game) {
|
||||
return facilityConnectionIssues(game).length === 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
|
|||
import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { snapshot } from './history.js';
|
||||
import { buildPrice } from './economy.js';
|
||||
import { emitGameEvent } from './gameEvents.js';
|
||||
|
||||
export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) {
|
||||
function selectionToken(item) {
|
||||
|
|
@ -159,6 +160,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
if (origin.type !== 'facility') continue;
|
||||
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
|
||||
const { entry, side } = nearestGridEdge(nextCenter);
|
||||
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
|
||||
const draft = { ...origin.obj, entry: { ...entry }, side };
|
||||
layoutFacilityOnEdge(draft, entry, side);
|
||||
candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) });
|
||||
|
|
@ -191,6 +193,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
if (origin.type === 'facility') continue;
|
||||
const col = origin.col + dcol, row = origin.row + drow;
|
||||
if (!pointInGrid(col, row)) return fail('Selection outside grid');
|
||||
if (game.blockedCells?.has?.(key(col, row))) return fail('Selection hits blocked ground');
|
||||
const tk = key(col, row);
|
||||
if (targetCells.has(tk)) return fail('Selection overlap');
|
||||
if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
|
||||
|
|
@ -208,7 +211,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
for (const o of conveyorOrigins) {
|
||||
const newK = key(o.col + dcol, o.row + drow);
|
||||
game.conveyorTiles.add(newK);
|
||||
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null });
|
||||
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 });
|
||||
o.currentKey = newK;
|
||||
}
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
|
|
@ -233,6 +236,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
|
||||
function finishGroupDrag() {
|
||||
if (!game.groupDrag) return;
|
||||
if (game.groupDrag.committed) emitGameEvent(game, 'MoveEquipment', { count: game.groupDrag.selections?.length || 0 });
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.groupDrag = null;
|
||||
updatePanels();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_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, factoryReady, facilityConnectionIssues, factoryGraphMetrics } from './routing.js';
|
||||
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js';
|
||||
import { truckTarget } from './contracts.js';
|
||||
import { conveyorSpeedForGame } from './cards.js';
|
||||
import { recentGameEvents } from './gameEvents.js';
|
||||
import { maintenanceSummary } from './maintenance.js';
|
||||
import { BALANCE } from '../core/balance.js';
|
||||
|
||||
export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) {
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
|
||||
function displaySpeed() { return Math.round(conveyorSpeedForGame(game)); }
|
||||
|
||||
function updatePanels() {
|
||||
|
|
@ -16,6 +23,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
updateContractPanel();
|
||||
updateTurnSummary();
|
||||
updateHistoryButtons();
|
||||
updateDebugPanel();
|
||||
}
|
||||
|
||||
function updateToolButtons() {
|
||||
|
|
@ -40,6 +48,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
: '';
|
||||
}
|
||||
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
|
||||
if (ui.hireRepairmanButton) {
|
||||
const hired = !!game.repairman?.hiredForNextDay;
|
||||
const cost = BALANCE.maintenance.repairman.dailyCost;
|
||||
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || hired || game.cash < cost;
|
||||
ui.hireRepairmanButton.disabled = blocked;
|
||||
ui.hireRepairmanButton.classList.toggle('active', hired);
|
||||
ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost);
|
||||
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day.`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBuildStatus() {
|
||||
|
|
@ -54,20 +71,48 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
}
|
||||
|
||||
function updateTurnSummary() {
|
||||
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length;
|
||||
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
|
||||
const issues = facilityConnectionIssues(game);
|
||||
const tax = zundaTaxInfo(game.cash, game);
|
||||
const tribute = fairiesTributeInfo(game, game.turn);
|
||||
ui.turnSummary.innerHTML = [
|
||||
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
|
||||
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
|
||||
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
|
||||
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
|
||||
`Fairies tribute on Next Day: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | flattery -${yen(tribute.reduction)}` : ''}`,
|
||||
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
|
||||
`Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
|
||||
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
|
||||
].join('<br>');
|
||||
}
|
||||
|
||||
|
||||
function updateDebugPanel() {
|
||||
if (!ui.debugObservations && !ui.debugEventLog) return;
|
||||
let m = null;
|
||||
try { m = factoryGraphMetrics(game); } catch (_err) { m = null; }
|
||||
if (ui.debugObservations) {
|
||||
if (!m) ui.debugObservations.innerHTML = '<strong>FactoryGraph</strong><span>not available</span>';
|
||||
else ui.debugObservations.innerHTML = [
|
||||
'<strong>FactoryGraph</strong>',
|
||||
`<span>v${m.version} | cells ${m.conveyorCells} | edges ${m.edges} | components ${m.components}</span>`,
|
||||
`<span>ports scanner ${m.scannerPorts} / facility ${m.facilityPorts} | farm outputs ${m.farmOutputs}</span>`,
|
||||
`<span>dead ends ${m.deadEnds} | crosses ${m.crosses} | max jam ${Math.round((m.maxCongestion || 0) * 100)}%</span>`,
|
||||
`<span>items moving ${m.movingItems} / queued ${m.queuedItems} / total ${m.activeItems}</span>`,
|
||||
`<span>blocked cells ${(game.blockedCells?.size || 0)} | worst dirt ${maintenanceSummary(game).worst.percent}% ${escapeHtml(maintenanceSummary(game).worst.label)}</span>`,
|
||||
(m.issues?.length ? `<span class="cash-negative">issue: ${m.issues[0]}</span>` : '<span class="cash-positive">graph valid</span>')
|
||||
].join('');
|
||||
}
|
||||
if (ui.debugEventLog) {
|
||||
const events = recentGameEvents(game, 8);
|
||||
ui.debugEventLog.innerHTML = ['<strong>Event Log</strong>', ...events.map(ev => {
|
||||
const payload = Object.entries(ev.payload || {}).map(([k, v]) => `${escapeHtml(k)}:${escapeHtml(v)}`).join(' ');
|
||||
return `<span>D${ev.day} ${escapeHtml(ev.type)}${payload ? ` | ${payload}` : ''}</span>`;
|
||||
})].join('');
|
||||
}
|
||||
}
|
||||
|
||||
function updateContractPanel() {
|
||||
if (!ui.contractPanel) return;
|
||||
const offer = game.contractOffer;
|
||||
|
|
@ -126,6 +171,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0)));
|
||||
ui.phase.textContent = phaseLabel();
|
||||
updatePriorityStrip();
|
||||
updateDebugPanel();
|
||||
ui.turnProfit.textContent = yen(game.stats.profit);
|
||||
ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
|
||||
ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0);
|
||||
|
|
@ -149,7 +195,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
const target = truckTarget(game).toUpperCase();
|
||||
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
|
||||
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`;
|
||||
ui.speedBrief.title = `Conveyor speed does not increase by day. Cap: ${CONVEYOR_SPEED_MAX}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.contractActive;
|
||||
const offer = game.contractOffer;
|
||||
if (active) {
|
||||
|
|
@ -184,39 +230,17 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
}
|
||||
|
||||
function showTurnResult(r) {
|
||||
ui.modalTitle.textContent = `Day ${r.turn} Result`;
|
||||
const contractHtml = r.contract ? `
|
||||
<div class="result-section-title">Limited Contract</div>
|
||||
<div class="result-grid">
|
||||
<div><strong>Contract</strong><span>${r.contract.title}</span></div>
|
||||
<div><strong>Rule</strong><span>${r.contract.route}</span></div>
|
||||
<div><strong>Progress</strong><span>${r.contract.count} / ${r.contract.targetAmount}</span></div>
|
||||
<div><strong>Bonus</strong><span>${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}</span></div>
|
||||
</div>` : '<div class="result-section-title muted-title">No limited contract active this day.</div>';
|
||||
const contractBonus = r.contract?.bonus || 0;
|
||||
const dailyEarned = r.revenue;
|
||||
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
|
||||
ui.modalBody.innerHTML = `
|
||||
<div class="result-section-title">Shipment / Daily Revenue</div>
|
||||
<div class="result-grid">
|
||||
<div><strong>Truck target</strong><span>${r.ship.target.toUpperCase()}</span></div>
|
||||
<div><strong>Truck formula</strong><span>${r.ship.targetCount} × ${yen(r.ship.unitPrice)} = ${yen(r.ship.base)}</span></div>
|
||||
<div><strong>Final truck revenue</strong><span>${yen(r.ship.adjusted)}</span></div>
|
||||
<div><strong>Mixer income</strong><span>${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}</span></div>
|
||||
<div><strong>Poop fine</strong><span>Mixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}</span></div>
|
||||
<div><strong>Wrong truck fine</strong><span>${yen(r.maleTruckFine)}</span></div>
|
||||
<div><strong>Next Day fees</strong><span>ZUNDA TAX and Fairies tribute are paid before the next run and are not included in this day's profit.</span></div>
|
||||
<div><strong>Explosion penalty</strong><span>${yen(r.explosionDamage)}</span></div>
|
||||
<div><strong>Daily earned formula</strong><span>(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}</span></div>
|
||||
<div><strong>Net profit formula</strong><span>${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</span></div>
|
||||
<div><strong>Cash</strong><span>${yen(r.cash)}</span></div>
|
||||
</div>
|
||||
${contractHtml}`;
|
||||
<p class="formula-box compact">${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</p>
|
||||
<p><strong>Final profit: ${yen(r.profit)}</strong></p>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover');
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
|
||||
function showGameOver() {
|
||||
const processed = game.totals.processed;
|
||||
const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0;
|
||||
|
|
|
|||
114
styles.css
114
styles.css
|
|
@ -208,3 +208,117 @@ h1, h2, p { margin: 0; }
|
|||
.card-targets { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.card-target-button:hover { background: var(--green-soft); }
|
||||
|
||||
/* v19 readability pass */
|
||||
body { font-size: 16px; }
|
||||
.hud-card span { font-size: 11px; }
|
||||
.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); }
|
||||
.priority-strip > div { font-size: 14px; }
|
||||
.panel-head h1 { font-size: 18px; }
|
||||
.panel-head p { font-size: 11px; }
|
||||
.build-panel h2 { font-size: 13px; }
|
||||
.tool-button strong { font-size: 14px; }
|
||||
.tool-button span { font-size: 11px; }
|
||||
.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; }
|
||||
.facility-card h3 { font-size: 14px; }
|
||||
.sort-button { font-size: 14px; }
|
||||
.modal-card h2 { font-size: 29px; }
|
||||
.card-choice strong { font-size: 16px; }
|
||||
.card-choice small { font-size: 13px; }
|
||||
|
||||
.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); }
|
||||
|
||||
.hover-tooltip {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
width: min(320px, calc(100vw - 20px));
|
||||
display: none;
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.96);
|
||||
color: var(--ink);
|
||||
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.hover-tooltip.visible { display: block; }
|
||||
.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; }
|
||||
.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; }
|
||||
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.debug-panel {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 12;
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
|
||||
max-width: 330px;
|
||||
}
|
||||
.debug-panel summary {
|
||||
cursor: pointer;
|
||||
padding: 5px 8px;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
.debug-body {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border-top: 3px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); }
|
||||
.debug-body input, .debug-body select, .debug-body button {
|
||||
border: 2px solid var(--line);
|
||||
background: var(--white);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
.debug-body button { cursor: pointer; background: var(--green-soft); }
|
||||
|
||||
.card-choice.dud {
|
||||
background: #eeeeee;
|
||||
color: #777;
|
||||
filter: grayscale(1);
|
||||
border-style: dashed;
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.10);
|
||||
}
|
||||
.card-choice.dud span { background: #f8f8f8; }
|
||||
.card-choice.flung {
|
||||
pointer-events: none;
|
||||
transform: translate(180px, -60px) rotate(22deg);
|
||||
opacity: 0;
|
||||
transition: transform .18s ease-in, opacity .18s ease-in;
|
||||
}
|
||||
|
||||
/* v20 Phase 1 observability */
|
||||
.debug-readout {
|
||||
border: 2px solid var(--line);
|
||||
background: rgba(247,255,245,.92);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.debug-readout strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.debug-readout span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.debug-readout.event-log {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue