Compare commits
2 commits
33cad83ccb
...
b54f36c0e8
| Author | SHA1 | Date | |
|---|---|---|---|
| b54f36c0e8 | |||
| 16bde0b87d |
26 changed files with 2191 additions and 1373 deletions
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 v27.0</title>
|
||||
<title>Chick Sorter v27.6</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
|
||||
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
|
||||
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
|
||||
<div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="hud hud-top-right" aria-label="Day result details">
|
||||
|
|
@ -35,8 +36,8 @@
|
|||
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v27.0</span></h1>
|
||||
<p>Build, drag, connect at least one valid EGG route, then start the next day.</p>
|
||||
<h1>CHICK SORTER <span>v27.6</span></h1>
|
||||
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||
</div>
|
||||
|
|
@ -54,6 +55,7 @@
|
|||
<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>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>
|
||||
<button id="expandGridButton" class="tool-button expand" type="button"><strong>Expand Grid</strong><span>¥1,300 / +10×10</span><small class="tool-flavor">Canvas buttons buy adjacent right or upper 10×10 lots. New ground includes blocked no-build tiles.</small></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>
|
||||
|
|
@ -70,6 +72,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manualScannerMonitor" class="scanner-monitor" aria-label="Offscreen manual scanner monitor"></div>
|
||||
|
||||
<div class="control-dock" aria-label="Sorting controls">
|
||||
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
|
||||
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
|
||||
|
|
@ -77,6 +81,19 @@
|
|||
<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 class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label>
|
||||
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
|
||||
<button id="debugSetDayButton" type="button">Set Day</button>
|
||||
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
|
||||
<button id="debugGrantCardButton" type="button">Grant Card</button>
|
||||
<button id="debugGrantAllCardsButton" type="button">All Cards</button>
|
||||
<div id="debugReadout" class="debug-readout"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
|
@ -88,6 +105,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/game.js?v=27.0-cleanup"></script>
|
||||
<script type="module" src="./src/game.js?v=27.6-direct-conveyor-chicks"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
# Chick Sorter v11.0 Routing / Compact Upgrades
|
||||
|
||||
Open `index.html` in a browser.
|
||||
|
||||
## v11.0 changes
|
||||
|
||||
- Egg Farm price is now JPY 300.
|
||||
- Initial S2-to-Truck conveyor lane is longer.
|
||||
- Egg Farm shutters reopen in Build phase after each day.
|
||||
- Mixer and Truck upgrades have no level cap. Each upgrade increases income and matching fines by `ceil(base × 1.05^upgrade count)`.
|
||||
- Manual Scanner and Conveyor upgrade choices are removed.
|
||||
- Equipment click menu is a compact bubble instead of a large overlay.
|
||||
- Explosion damage is `ceil(equipment price / 30)`.
|
||||
- Spawn blocking now highlights the stopped chick instead of showing `WAIT`.
|
||||
- Scanner/facility routing is more tolerant after manual conveyor dragging.
|
||||
|
||||
## Controls
|
||||
|
||||
Sorting:
|
||||
- S1: A = Mixer, D = S2
|
||||
- S2: Left Arrow = Shredder, Right Arrow = Truck
|
||||
|
||||
Build phase:
|
||||
- Left drag empty area: box select
|
||||
- Drag selected equipment: move selection
|
||||
- Right drag: pan
|
||||
- Ctrl+Z / Ctrl+Y: undo / redo
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Central balance sheet for tuning gameplay.
|
||||
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
|
||||
export const BALANCE = {
|
||||
version: 'v27.0 cleanup build',
|
||||
version: 'v27.6 direct conveyor chick movement',
|
||||
time: {
|
||||
daySeconds: 60,
|
||||
farmShutdownGraceSeconds: 10
|
||||
|
|
@ -10,11 +10,18 @@ export const BALANCE = {
|
|||
starting: 250
|
||||
},
|
||||
grid: {
|
||||
x: 190,
|
||||
y: 142,
|
||||
cols: 22,
|
||||
rows: 13,
|
||||
cell: 46
|
||||
x: 170,
|
||||
y: 78,
|
||||
cols: 20,
|
||||
rows: 10,
|
||||
cell: 46,
|
||||
expansion: {
|
||||
colsPerPurchase: 10,
|
||||
rowsPerPurchase: 10,
|
||||
costBase: 1000,
|
||||
costMultiplier: 4 / 3,
|
||||
truncateUnit: 100
|
||||
}
|
||||
},
|
||||
map: {
|
||||
blockedRatio: 0.075
|
||||
|
|
@ -62,12 +69,232 @@ export const BALANCE = {
|
|||
bearingSpeedMultiplier: 1.075
|
||||
},
|
||||
cards: {
|
||||
definitions: [
|
||||
{
|
||||
id: 'upgradeEgg',
|
||||
title: 'EGG Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'eggFarm',
|
||||
description: 'Choose one EGG FARM on the map and raise it by 1 level.',
|
||||
tags: ['EGG', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeAutoScanner',
|
||||
title: 'AUTO SCANNER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'autoScanner',
|
||||
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.',
|
||||
tags: ['SCANNER', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeMixer',
|
||||
title: 'MIXER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'mixer',
|
||||
description: 'Choose MIXER on the map and raise it by 1 level.',
|
||||
tags: ['ECONOMY', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeTruck',
|
||||
title: 'TRUCK Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'truck',
|
||||
description: 'Choose TRUCK on the map and raise it by 1 level.',
|
||||
tags: ['ECONOMY', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'upgradeTrash',
|
||||
title: 'SHREDDER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'trash',
|
||||
description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.',
|
||||
tags: ['POOP', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'crowdedFarming',
|
||||
title: 'Crowded Farming Contract',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
|
||||
tags: ['EGG', 'RISK', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'hatchingFeed',
|
||||
title: 'Hatching Feed',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
|
||||
tags: ['EGG', 'POOP', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'safetyCover',
|
||||
title: 'Safety Cover',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Explosion damage -5%. No card limit.',
|
||||
tags: ['RISK', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'preventiveMaintenance',
|
||||
title: 'Preventive Maintenance Manual',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment deterioration speed -5%. No card limit.',
|
||||
tags: ['MAINTENANCE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'dudFilter',
|
||||
title: 'DUD Filter',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'DUD chance -5 percentage points. Stacks until DUD chance reaches 0%.',
|
||||
tags: ['CARD', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'durabilityCoating',
|
||||
title: 'Durability Coating',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment maximum durability +3%. No card limit.',
|
||||
tags: ['MAINTENANCE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'bearing',
|
||||
title: 'High-Quality Bearing',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Conveyor speed +7.5%.',
|
||||
tags: ['CONVEYOR', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'extraEggOutlet',
|
||||
title: 'Extra Egg Outlet',
|
||||
rarity: 'rare',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'eggOutlet',
|
||||
description: 'Choose one EGG FARM and add +1 output port to that EGG. Max +3 per EGG.',
|
||||
tags: ['EGG', 'RARE', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'recyclingSubsidy',
|
||||
title: 'Recycling Subsidy',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.',
|
||||
tags: ['ECONOMY', 'RARE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'dynamite',
|
||||
title: 'Dynamite',
|
||||
rarity: 'rare',
|
||||
type: 'cellAction',
|
||||
target: 'blockedCell',
|
||||
description: 'Remove any 3 blocked cells with a burst effect.',
|
||||
tags: ['RISK', 'RARE', 'ONE-SHOT']
|
||||
},
|
||||
{
|
||||
id: 'usedMachine',
|
||||
title: 'Used Machines',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.',
|
||||
tags: ['ECONOMY', 'RISK', 'RARE']
|
||||
},
|
||||
{
|
||||
id: 'newMachine',
|
||||
title: 'New Machines',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Cancel Used Machines and restore normal prices, refunds, and durability.',
|
||||
tags: ['ECONOMY', 'ONE-SHOT']
|
||||
},
|
||||
{
|
||||
id: 'flattery',
|
||||
title: 'Flattery',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Next Fairies tribute -50%.',
|
||||
tags: ['ECONOMY', 'RARE', 'ONE-SHOT']
|
||||
},
|
||||
{
|
||||
id: 'loan',
|
||||
title: 'Loan',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.',
|
||||
tags: ['ECONOMY', 'RISK', 'RARE']
|
||||
},
|
||||
{
|
||||
id: 'extraCards',
|
||||
title: 'Extra Cards',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Draw 2 cards now and choose both.',
|
||||
tags: ['RARE', 'ACTIVE']
|
||||
},
|
||||
{
|
||||
id: 'legalWork',
|
||||
title: 'Legal Work',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.',
|
||||
tags: ['ECONOMY', 'RARE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'chemicalWeaponSubsidy',
|
||||
title: 'Chemical Weapons Subsidy',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.',
|
||||
tags: ['POOP', 'ULTRA RARE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'laborExploitation',
|
||||
title: 'Motivational Exploitation',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.',
|
||||
tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE']
|
||||
},
|
||||
{
|
||||
id: 'rescueLoan',
|
||||
title: 'Emergency Loan',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.',
|
||||
tags: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT']
|
||||
}
|
||||
],
|
||||
defaultEffects: {
|
||||
bearing: 0,
|
||||
legalWork: 0,
|
||||
fairiesFlatteryNext: 0,
|
||||
crowdedFarming: 0,
|
||||
extraEggOutlet: 0,
|
||||
hatchingFeed: 0,
|
||||
safetyCover: 0,
|
||||
recyclingSubsidy: 0,
|
||||
chemicalWeaponSubsidy: 0,
|
||||
preventiveMaintenance: 0,
|
||||
dudFilter: 0,
|
||||
durabilityCoating: 0,
|
||||
laborExploitation: 0,
|
||||
usedMachineActive: false,
|
||||
rescueLoanCharges: 0,
|
||||
loans: []
|
||||
},
|
||||
commonWeight: 8,
|
||||
rareWeight: 2,
|
||||
ultraRareWeight: 0.45,
|
||||
baseDraftSize: 3,
|
||||
maxDudsPerDraft: 2,
|
||||
dudChancePerCard: 0.30,
|
||||
dudChanceReductionPerFilter: 0.05,
|
||||
extraCardsAddedPicks: 2,
|
||||
rerollCost: {
|
||||
dayDivisor: 2,
|
||||
|
|
@ -94,8 +321,8 @@ export const BALANCE = {
|
|||
},
|
||||
repairman: {
|
||||
dailyCost: 100,
|
||||
secondsPerOnePercent: 1 / 3,
|
||||
walkSpeed: 140
|
||||
secondsPerOnePercent: 0.5,
|
||||
walkSpeed: 90
|
||||
}
|
||||
},
|
||||
facilities: {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
|
|||
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
|
||||
|
||||
export const CARD_BALANCE = BALANCE.cards;
|
||||
export const CARD_DEFS = BALANCE.cards.definitions;
|
||||
export const CARD_DEFAULT_EFFECTS = BALANCE.cards.defaultEffects;
|
||||
export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
|
||||
export const GRID = BALANCE.grid;
|
||||
export const FACILITY_DEFS = BALANCE.facilities;
|
||||
|
|
@ -26,14 +28,7 @@ export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScan
|
|||
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
|
||||
|
||||
// Compatibility exports. New code should prefer FACILITY_DEFS and ECONOMY.
|
||||
export const BUILD_COSTS = Object.fromEntries(Object.entries(FACILITY_DEFS).map(([id, def]) => [id, def.price]));
|
||||
export const FACILITY_PRICES = Object.fromEntries(MACHINE_FACILITY_IDS.map(id => [id, FACILITY_DEFS[id].price]));
|
||||
export const MIXER_PRICE = ECONOMY.income.mixer;
|
||||
export const TRUCK_PRICE = ECONOMY.income.truck;
|
||||
export const POOP_FINE = ECONOMY.poopFine;
|
||||
export const ZUNDA_TAX = ECONOMY.zundaTax;
|
||||
export const INCOME_UPGRADE_RATE = ECONOMY.incomeUpgradeRate;
|
||||
|
||||
export const DIRS = [
|
||||
{ name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' },
|
||||
|
|
|
|||
|
|
@ -2,10 +2,27 @@ import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
|
|||
import { nextSpawnDelay } from './state.js';
|
||||
import { currentPoopRate } from '../systems/contracts.js';
|
||||
|
||||
const DEFAULT_MANUAL_KEYS = [
|
||||
[{ key: 'a', code: 'KeyA', label: 'A' }, { key: 'd', code: 'KeyD', label: 'D' }],
|
||||
[{ key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, { key: 'arrowright', code: 'ArrowRight', label: 'Right' }],
|
||||
[{ key: 'j', code: 'KeyJ', label: 'J' }, { key: 'l', code: 'KeyL', label: 'L' }],
|
||||
[{ key: 'f', code: 'KeyF', label: 'F' }, { key: 'h', code: 'KeyH', label: 'H' }],
|
||||
[{ key: 'q', code: 'KeyQ', label: 'Q' }, { key: 'e', code: 'KeyE', label: 'E' }],
|
||||
[{ key: 'z', code: 'KeyZ', label: 'Z' }, { key: 'x', code: 'KeyX', label: 'X' }],
|
||||
[{ key: 'u', code: 'KeyU', label: 'U' }, { key: 'o', code: 'KeyO', label: 'O' }],
|
||||
[{ key: 'n', code: 'KeyN', label: 'N' }, { key: 'm', code: 'KeyM', label: 'M' }]
|
||||
];
|
||||
|
||||
function manualKeysForSlot(slot) {
|
||||
const pair = DEFAULT_MANUAL_KEYS[slot] || DEFAULT_MANUAL_KEYS[0];
|
||||
return { left: { ...pair[0] }, right: { ...pair[1] } };
|
||||
}
|
||||
|
||||
export function createEggFarm(game, col, row) {
|
||||
const farm = {
|
||||
type: 'eggFarm', id: game.nextId++, col, row,
|
||||
level: 1, nextSpawn: 2, lastInterval: 2,
|
||||
extraEggOutlet: 0,
|
||||
price: BUILD_COSTS.eggFarm,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
|
|
@ -25,7 +42,8 @@ export function createScanner(game, col, row, kind) {
|
|||
queue: [], cooldown: 0,
|
||||
price: cost,
|
||||
builtSession: game.buildSession,
|
||||
autoMode: 'standard'
|
||||
autoMode: 'standard',
|
||||
keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -80,15 +98,17 @@ export function rollChickSex(game) {
|
|||
|
||||
export function createChick(game, routeData) {
|
||||
const sex = rollChickSex(game);
|
||||
const start = routeData.route[0];
|
||||
const route = (routeData.route || []).map(p => ({ x: p.x, y: p.y }));
|
||||
const start = route[0];
|
||||
return {
|
||||
id: game.nextId++, sex,
|
||||
x: start.x, y: start.y,
|
||||
route: routeData.route,
|
||||
targetIndex: 1,
|
||||
stage: 'input',
|
||||
scannerId: routeData.scannerId,
|
||||
route,
|
||||
targetIndex: route.length > 1 ? 1 : route.length,
|
||||
stage: routeData.stage || 'belt',
|
||||
scannerId: routeData.scannerId ?? null,
|
||||
nextScannerId: null,
|
||||
pendingScannerId: null,
|
||||
radius: sex === 'poop' ? 15 : 17,
|
||||
bob: Math.random() * Math.PI * 2,
|
||||
queueIndex: -1,
|
||||
|
|
|
|||
160
src/core/gridExpansion.js
Normal file
160
src/core/gridExpansion.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { BALANCE } from './balance.js';
|
||||
import { GRID } from './config.js';
|
||||
import { key, parseKey } from './utils.js';
|
||||
|
||||
export const GRID_CHUNK_SIZE = 10;
|
||||
const INITIAL_CHUNKS_X = 2;
|
||||
const INITIAL_CHUNKS_Y = 1;
|
||||
|
||||
function chunkKey(chunkX, chunkY) { return `${chunkX},${chunkY}`; }
|
||||
function parseChunkKey(value) {
|
||||
const [chunkX, chunkY] = String(value).split(',').map(Number);
|
||||
return { chunkX, chunkY };
|
||||
}
|
||||
|
||||
export function expansionCost(game) {
|
||||
const cfg = BALANCE.grid.expansion || {};
|
||||
const purchaseCount = Math.max(1, (game.gridExpansionPurchases || 0) + 1);
|
||||
const raw = (cfg.costBase || 1000) * purchaseCount * (cfg.costMultiplier || (4 / 3));
|
||||
const unit = Math.max(1, cfg.truncateUnit || 100);
|
||||
return Math.floor(raw / unit) * unit;
|
||||
}
|
||||
|
||||
export function resetOwnedCells(game) {
|
||||
game.ownedCells = new Set();
|
||||
game.ownedChunks = new Set();
|
||||
game.gridChunkOriginY = 0;
|
||||
game.gridExpansionPurchases = 0;
|
||||
game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
|
||||
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
|
||||
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
for (let row = 0; row < BALANCE.grid.rows; row += 1) {
|
||||
for (let col = 0; col < BALANCE.grid.cols; col += 1) game.ownedCells.add(key(col, row));
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureOwnedChunks(game) {
|
||||
if (game.ownedChunks?.size) return game.ownedChunks;
|
||||
game.ownedChunks = new Set();
|
||||
if (game.ownedCells?.size) {
|
||||
const originY = game.gridChunkOriginY || 0;
|
||||
for (const k of game.ownedCells) {
|
||||
const p = parseKey(k);
|
||||
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE);
|
||||
const chunkY = Math.floor(p.row / GRID_CHUNK_SIZE) + originY;
|
||||
game.ownedChunks.add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
} else {
|
||||
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
|
||||
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
}
|
||||
return game.ownedChunks;
|
||||
}
|
||||
|
||||
export function isOwnedCell(game, col, row) {
|
||||
if (!game?.ownedCells || game.ownedCells.size === 0) return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows;
|
||||
return game.ownedCells.has(key(col, row));
|
||||
}
|
||||
|
||||
export function countOwnedCells(game) {
|
||||
return game?.ownedCells?.size || (GRID.cols * GRID.rows);
|
||||
}
|
||||
|
||||
export function lotFromChunk(game, chunkX, chunkY, direction = 'up') {
|
||||
const originY = game.gridChunkOriginY || 0;
|
||||
return {
|
||||
direction,
|
||||
key: chunkKey(chunkX, chunkY),
|
||||
chunkX,
|
||||
chunkY,
|
||||
colStart: chunkX * GRID_CHUNK_SIZE,
|
||||
rowStart: (chunkY - originY) * GRID_CHUNK_SIZE,
|
||||
cols: GRID_CHUNK_SIZE,
|
||||
rows: GRID_CHUNK_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
export function expansionLots(game) {
|
||||
const owned = ensureOwnedChunks(game);
|
||||
const candidates = new Map();
|
||||
for (const k of owned) {
|
||||
const { chunkX, chunkY } = parseChunkKey(k);
|
||||
const right = { chunkX: chunkX + 1, chunkY, direction: 'right' };
|
||||
const up = { chunkX, chunkY: chunkY - 1, direction: 'up' };
|
||||
for (const candidate of [right, up]) {
|
||||
if (candidate.chunkX < 0 || candidate.chunkY > 0) continue;
|
||||
const ck = chunkKey(candidate.chunkX, candidate.chunkY);
|
||||
if (owned.has(ck) || candidates.has(ck)) continue;
|
||||
candidates.set(ck, lotFromChunk(game, candidate.chunkX, candidate.chunkY, candidate.direction));
|
||||
}
|
||||
}
|
||||
return [...candidates.values()].sort((a, b) => {
|
||||
const dir = (a.direction === b.direction) ? 0 : (a.direction === 'right' ? -1 : 1);
|
||||
if (dir) return dir;
|
||||
if (a.chunkY !== b.chunkY) return a.chunkY - b.chunkY;
|
||||
return a.chunkX - b.chunkX;
|
||||
});
|
||||
}
|
||||
|
||||
export function expansionLot(game, direction = 'right') {
|
||||
const lots = expansionLots(game);
|
||||
return lots.find(lot => lot.direction === direction) || lots[0] || null;
|
||||
}
|
||||
|
||||
export function lotBounds(lot) {
|
||||
return {
|
||||
x: GRID.x + lot.colStart * GRID.cell,
|
||||
y: GRID.y + lot.rowStart * GRID.cell,
|
||||
w: lot.cols * GRID.cell,
|
||||
h: lot.rows * GRID.cell
|
||||
};
|
||||
}
|
||||
|
||||
export function expansionButtonBounds(lot) {
|
||||
const b = lotBounds(lot);
|
||||
return {
|
||||
x: b.x + b.w / 2 - 116,
|
||||
y: b.y + b.h / 2 - 36,
|
||||
w: 232,
|
||||
h: 72,
|
||||
cx: b.x + b.w / 2,
|
||||
cy: b.y + b.h / 2
|
||||
};
|
||||
}
|
||||
|
||||
export function lotContainsPoint(lot, p) {
|
||||
const b = expansionButtonBounds(lot);
|
||||
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
|
||||
}
|
||||
|
||||
export function cellsInLot(lot) {
|
||||
const cells = [];
|
||||
for (let row = lot.rowStart; row < lot.rowStart + lot.rows; row += 1) {
|
||||
for (let col = lot.colStart; col < lot.colStart + lot.cols; col += 1) cells.push({ col, row });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function shiftRowIndexedSet(set, rowDelta) {
|
||||
const next = new Set();
|
||||
for (const k of set || []) {
|
||||
const p = parseKey(k);
|
||||
next.add(key(p.col, p.row + rowDelta));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shiftRowIndexedMap(map, rowDelta) {
|
||||
const next = new Map();
|
||||
for (const [k, v] of map || []) {
|
||||
const p = parseKey(k);
|
||||
next.set(key(p.col, p.row + rowDelta), v);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ownChunk(game, chunkX, chunkY) {
|
||||
ensureOwnedChunks(game).add(chunkKey(chunkX, chunkY));
|
||||
}
|
||||
|
|
@ -41,3 +41,25 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] }
|
|||
const target = Math.round(GRID.cols * GRID.rows * targetRatio);
|
||||
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
|
||||
}
|
||||
|
||||
export function generateBlockedCellsInRect({ seed, colStart = 0, colEnd = GRID.cols, rowStart = 0, rowEnd = GRID.rows, 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 = [];
|
||||
const c0 = Math.max(0, Math.floor(colStart));
|
||||
const c1 = Math.max(c0, Math.floor(colEnd));
|
||||
const r0 = Math.max(0, Math.floor(rowStart));
|
||||
const r1 = Math.max(r0, Math.floor(rowEnd));
|
||||
for (let row = r0; row < r1; row += 1) {
|
||||
for (let col = c0; col < c1; 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((c1 - c0) * (r1 - r0) * targetRatio);
|
||||
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
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';
|
||||
import { resetOwnedCells } from './gridExpansion.js';
|
||||
import { randomBetween, key, inGrid, directionNameBetweenCells } from './utils.js';
|
||||
|
||||
export function newTurnStats() {
|
||||
return {
|
||||
|
|
@ -20,6 +21,12 @@ export function newTurnStats() {
|
|||
pendingTruckRevenue: 0,
|
||||
mixerRevenue: 0,
|
||||
truckRevenue: 0,
|
||||
chickShipmentIncome: 0,
|
||||
poopShipmentIncome: 0,
|
||||
manualComboBonus: 0,
|
||||
manualComboSuccess: 0,
|
||||
manualComboFailure: 0,
|
||||
repairWorkerWages: 0,
|
||||
zundaTax: 0,
|
||||
fairiesTribute: 0,
|
||||
loanRepayment: 0,
|
||||
|
|
@ -62,6 +69,10 @@ export function newTotalStats() {
|
|||
loanRepayment: 0,
|
||||
chemicalWeaponSubsidy: 0,
|
||||
rescueLoan: 0,
|
||||
manualComboBonus: 0,
|
||||
manualComboSuccess: 0,
|
||||
manualComboFailure: 0,
|
||||
repairWorkerWages: 0,
|
||||
cardRerollCost: 0,
|
||||
mixerPoopFine: 0,
|
||||
truckPoopFine: 0,
|
||||
|
|
@ -86,6 +97,12 @@ export function createGame() {
|
|||
lastTimestamp: 0,
|
||||
nextId: 10,
|
||||
runSeed: createRunSeed(),
|
||||
gridCols: BALANCE.grid.cols,
|
||||
gridRows: BALANCE.grid.rows,
|
||||
gridExpansionPurchases: 0,
|
||||
gridExpansionPurchasesByDirection: { right: 0, up: 0 },
|
||||
gridChunkOriginY: 0,
|
||||
ownedCells: new Set(),
|
||||
blockedCells: new Set(),
|
||||
buildTool: null,
|
||||
buildSession: 0,
|
||||
|
|
@ -96,7 +113,7 @@ export function createGame() {
|
|||
selectionBox: null,
|
||||
groupDrag: null,
|
||||
pan: null,
|
||||
view: { x: 0, y: 0 },
|
||||
view: { x: 0, y: 0, scale: 1 },
|
||||
hover: null,
|
||||
chicks: [],
|
||||
effects: [],
|
||||
|
|
@ -118,10 +135,12 @@ export function createGame() {
|
|||
lastExplodedComponent: new Map(),
|
||||
contractOffer: null,
|
||||
contractActive: null,
|
||||
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
|
||||
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
|
||||
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 },
|
||||
manualCombo: { count: 0, lastBonus: 0 },
|
||||
pendingRepairWorkerWages: 0,
|
||||
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
|
||||
stats: newTurnStats(),
|
||||
lastResult: null,
|
||||
totals: newTotalStats()
|
||||
|
|
@ -153,34 +172,34 @@ function facilityBodyForEntry(id, entry, side) {
|
|||
|
||||
export function defaultFacilities() {
|
||||
return {
|
||||
// Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge.
|
||||
mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'),
|
||||
trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'),
|
||||
truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom')
|
||||
// Initial grid is 20 columns × 10 rows. Machine bodies sit outside the starter grid.
|
||||
mixer: facilityBodyForEntry('mixer', { col: 0, row: 4 }, 'left'),
|
||||
trash: facilityBodyForEntry('trash', { col: 12, row: 9 }, 'bottom'),
|
||||
truck: facilityBodyForEntry('truck', { col: 18, row: 9 }, 'bottom')
|
||||
};
|
||||
}
|
||||
|
||||
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],
|
||||
// Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated
|
||||
// belt streams keep at least one blank tile between side-by-side flows.
|
||||
[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2], [8, 3],
|
||||
// S1 left output -> Mixer receiver on the left grid edge.
|
||||
[3, 5], [2, 5], [1, 5], [0, 5],
|
||||
[7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4],
|
||||
// S1 right output -> S2 top input.
|
||||
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
|
||||
[11, 6], [11, 7],
|
||||
[9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6],
|
||||
// S2 left output -> Waste Shredder receiver on bottom grid edge.
|
||||
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
|
||||
[14, 7], [13, 7], [12, 7], [12, 8], [12, 9],
|
||||
// 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]
|
||||
[16, 7], [17, 7], [18, 7], [18, 8], [18, 9]
|
||||
]);
|
||||
|
||||
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' }
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 8, row: 4, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'a', code: 'KeyA', label: 'A' }, right: { key: 'd', code: 'KeyD', label: 'D' } } },
|
||||
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 15, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, right: { key: 'arrowright', code: 'ArrowRight', label: 'Right' } } }
|
||||
]);
|
||||
|
||||
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 }
|
||||
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
|
||||
]);
|
||||
|
||||
function addConveyorTile(game, col, row, options = {}) {
|
||||
|
|
@ -193,11 +212,34 @@ function addConveyorTile(game, col, row, options = {}) {
|
|||
builtSession: null,
|
||||
uses: 0,
|
||||
durability: BALANCE.maintenance.durability.conveyor,
|
||||
maintenanceType: 'conveyor'
|
||||
maintenanceType: 'conveyor',
|
||||
dir: options.dir || null,
|
||||
outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : []
|
||||
});
|
||||
} else if (options.dir || options.outDirs) {
|
||||
const meta = game.conveyorMeta.get(k);
|
||||
if (options.dir) meta.dir = options.dir;
|
||||
if (Array.isArray(options.outDirs)) meta.outDirs = [...new Set([...(meta.outDirs || []), ...options.outDirs])];
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConveyorMeta(game, col, row) {
|
||||
const k = key(col, row);
|
||||
if (!game.conveyorMeta.has(k)) addConveyorTile(game, col, row);
|
||||
return game.conveyorMeta.get(k);
|
||||
}
|
||||
|
||||
function markInitialConveyorDirection(game, from, to) {
|
||||
const dir = directionNameBetweenCells(from, to);
|
||||
if (!dir) return;
|
||||
const fromMeta = ensureConveyorMeta(game, from.col, from.row);
|
||||
fromMeta.outDirs = Array.isArray(fromMeta.outDirs) ? fromMeta.outDirs : [];
|
||||
if (!fromMeta.outDirs.includes(dir)) fromMeta.outDirs.push(dir);
|
||||
fromMeta.dir = dir;
|
||||
const toMeta = ensureConveyorMeta(game, to.col, to.row);
|
||||
if (!toMeta.dir) toMeta.dir = dir;
|
||||
}
|
||||
|
||||
function scannerPortCells(scanner) {
|
||||
return [
|
||||
{ col: scanner.col, row: scanner.row - 1 },
|
||||
|
|
@ -231,7 +273,9 @@ function protectedInitialCells(game) {
|
|||
}
|
||||
|
||||
function installInitialConveyorBackbone(game) {
|
||||
for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row);
|
||||
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
|
||||
for (const { col, row } of cells) addConveyorTile(game, col, row);
|
||||
for (let i = 0; i < cells.length - 1; i += 1) markInitialConveyorDirection(game, cells[i], cells[i + 1]);
|
||||
}
|
||||
|
||||
function scrubBlockedCellsFromInitialBackbone(game) {
|
||||
|
|
@ -240,12 +284,27 @@ function scrubBlockedCellsFromInitialBackbone(game) {
|
|||
}
|
||||
|
||||
export function resetLayout(game) {
|
||||
GRID.x = BALANCE.grid.x;
|
||||
GRID.y = BALANCE.grid.y;
|
||||
GRID.cols = BALANCE.grid.cols;
|
||||
GRID.rows = BALANCE.grid.rows;
|
||||
game.gridCols = GRID.cols;
|
||||
game.gridRows = GRID.rows;
|
||||
resetOwnedCells(game);
|
||||
game.gridOwnedCells = game.ownedCells.size;
|
||||
if (game.repairman) {
|
||||
game.repairman.x = GRID.x + GRID.cell * 0.5;
|
||||
game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84;
|
||||
game.repairman.target = null;
|
||||
game.repairman.active = false;
|
||||
game.repairman.hiredForNextDay = false;
|
||||
}
|
||||
game.conveyorTiles.clear();
|
||||
game.conveyorMeta.clear();
|
||||
game.blockedCells = new Set();
|
||||
installInitialConveyorBackbone(game);
|
||||
game.facilities = defaultFacilities();
|
||||
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] }));
|
||||
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] }));
|
||||
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
|
||||
const nextSpawn = nextSpawnDelay(farm);
|
||||
return { ...farm, nextSpawn, lastInterval: nextSpawn };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_TOOL_IDS, FACILITY_DEFS, VERSION } from './config.js';
|
||||
import { AUTO_SCANNER_COOLDOWN, FACILITY_DEFS, VERSION } from './config.js';
|
||||
import { yen } from './utils.js';
|
||||
|
||||
export const TEXT = {
|
||||
|
|
@ -10,6 +10,7 @@ export const TEXT = {
|
|||
build: 'Build',
|
||||
running: 'Sorting',
|
||||
clearing: 'Clearing',
|
||||
loanDecision: 'Emergency Loan',
|
||||
gameover: 'Game Over'
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -52,10 +53,6 @@ export function equipmentName(id) {
|
|||
return FACILITY_DEFS[id]?.name || String(id || '').replace(/^[a-z]/, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function shortEquipmentName(id) {
|
||||
return FACILITY_DEFS[id]?.shortName || equipmentName(id);
|
||||
}
|
||||
|
||||
export function buildToolPriceText(id) {
|
||||
const def = FACILITY_DEFS[id];
|
||||
if (!def) return '';
|
||||
|
|
@ -83,10 +80,6 @@ export function buildToolButtonHtml(id) {
|
|||
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
|
||||
}
|
||||
|
||||
export function buildToolButtonIds() {
|
||||
return BUILD_TOOL_IDS;
|
||||
}
|
||||
|
||||
export function routeLabel(dest) {
|
||||
return TEXT.routeLabels[dest] || String(dest).toUpperCase();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ export function pointToCell(x, y) {
|
|||
}
|
||||
export function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
|
||||
export function sameCell(a, b) { return a && b && a.col === b.col && a.row === b.row; }
|
||||
|
||||
export function directionNameBetweenCells(a, b) {
|
||||
if (!a || !b) return null;
|
||||
const dc = Math.sign((b.col ?? 0) - (a.col ?? 0));
|
||||
const dr = Math.sign((b.row ?? 0) - (a.row ?? 0));
|
||||
if (Math.abs((b.col ?? 0) - (a.col ?? 0)) + Math.abs((b.row ?? 0) - (a.row ?? 0)) !== 1) return null;
|
||||
if (dc === 1 && dr === 0) return 'right';
|
||||
if (dc === -1 && dr === 0) return 'left';
|
||||
if (dc === 0 && dr === 1) return 'down';
|
||||
if (dc === 0 && dr === -1) return 'up';
|
||||
return null;
|
||||
}
|
||||
export function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
export function hexToRgb(hex) {
|
||||
const s = hex.replace('#', '');
|
||||
|
|
|
|||
371
src/game.js
371
src/game.js
|
|
@ -1,15 +1,16 @@
|
|||
import { TURN_SECONDS, THEME } from './core/config.js';
|
||||
import { GRID, TURN_SECONDS, THEME } from './core/config.js';
|
||||
import { buildToolButtonHtml } from './core/text.js';
|
||||
import { createGame, resetLayout, newTurnStats } from './core/state.js';
|
||||
import { clamp, pointToCell, cellCenter, yen } from './core/utils.js';
|
||||
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
|
||||
import { expansionLots, lotBounds } from './core/gridExpansion.js';
|
||||
import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
|
||||
import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
|
||||
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, 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 { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } from './systems/cards.js';
|
||||
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
|
||||
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
|
||||
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
|
||||
|
|
@ -19,13 +20,25 @@ const ctx = canvas.getContext('2d');
|
|||
|
||||
const ui = {
|
||||
shell: document.getElementById('gameShell'),
|
||||
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'),
|
||||
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
|
||||
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
|
||||
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'),
|
||||
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
|
||||
hoverTooltip: document.getElementById('hoverTooltip'),
|
||||
manualScannerMonitor: document.getElementById('manualScannerMonitor'),
|
||||
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
|
||||
expandGridButton: document.getElementById('expandGridButton'),
|
||||
debug: {
|
||||
panel: document.getElementById('debugPanel'),
|
||||
infiniteCash: document.getElementById('debugInfiniteCash'),
|
||||
dayInput: document.getElementById('debugDayInput'),
|
||||
setDay: document.getElementById('debugSetDayButton'),
|
||||
cardSelect: document.getElementById('debugCardSelect'),
|
||||
grantCard: document.getElementById('debugGrantCardButton'),
|
||||
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
|
||||
readout: document.getElementById('debugReadout')
|
||||
},
|
||||
buttons: {
|
||||
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'),
|
||||
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), 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')
|
||||
|
|
@ -48,6 +61,15 @@ function initializeStaticText() {
|
|||
}
|
||||
}
|
||||
|
||||
function initializeDebugPanel() {
|
||||
if (!ui.debug?.panel) return;
|
||||
if (ui.debug.cardSelect) {
|
||||
ui.debug.cardSelect.innerHTML = CARD_DEFS
|
||||
.map(card => `<option value="${card.id}">${card.title}</option>`)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
const game = createGame();
|
||||
let build;
|
||||
let uiSystem;
|
||||
|
|
@ -62,13 +84,60 @@ function currentConveyorSpeed(conveyorKey = null) {
|
|||
function startGame() {
|
||||
Object.assign(game, createGame());
|
||||
game.phase = 'running';
|
||||
game.view = { x: 0, y: 0 };
|
||||
game.view = { x: 0, y: 0, scale: 1 };
|
||||
resetLayout(game);
|
||||
resetCameraToFactoryStart();
|
||||
ensureMaintenanceState(game);
|
||||
uiSystem.hideModal();
|
||||
chicks.updateCongestion();
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
updateDebugReadout();
|
||||
}
|
||||
|
||||
function debugSetDay() {
|
||||
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
|
||||
game.turn = day;
|
||||
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
|
||||
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
updateDebugReadout(`Day set to ${day}.`);
|
||||
}
|
||||
|
||||
function debugGrantSelectedCard() {
|
||||
const id = ui.debug.cardSelect?.value;
|
||||
const result = debugGrantCard(game, id);
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
updateDebugReadout(result.reason);
|
||||
}
|
||||
|
||||
function debugGrantAllCards() {
|
||||
const results = CARD_DEFS.map(card => debugGrantCard(game, card));
|
||||
const ok = results.filter(result => result.ok).length;
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
|
||||
}
|
||||
|
||||
function applyDebugState() {
|
||||
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
|
||||
game.cash = 999999;
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDebugReadout(message = '') {
|
||||
if (!ui.debug?.readout) return;
|
||||
ui.debug.readout.textContent = [
|
||||
message,
|
||||
`D${game.turn}`,
|
||||
yen(game.cash),
|
||||
`Combo ${game.manualCombo?.count || 0}`,
|
||||
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
|
||||
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
|
||||
].filter(Boolean).join(' | ');
|
||||
}
|
||||
|
||||
function startNextTurn() {
|
||||
|
|
@ -87,8 +156,8 @@ function startNextTurn() {
|
|||
ensureMaintenanceState(game);
|
||||
commitFactoryGraphForDay(game);
|
||||
activateRepairmanForDay(game);
|
||||
const zundaBasisCash = game.cash;
|
||||
const zunda = collectZundaTax(game, zundaBasisCash);
|
||||
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
|
||||
const zunda = collectZundaTax(game, zundaBasisProfit);
|
||||
const loanRepayment = collectLoanRepayments(game);
|
||||
game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 };
|
||||
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
|
||||
|
|
@ -106,8 +175,17 @@ function startNextTurn() {
|
|||
game.floatingTexts = [];
|
||||
game.shake = { time: 0, strength: 0 };
|
||||
game.truckCargo = [];
|
||||
game.view = { x: 0, y: 0 };
|
||||
resetCameraToFactoryStart();
|
||||
game.stats = newTurnStats();
|
||||
game.stats.zundaTax = 0;
|
||||
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
|
||||
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
|
||||
const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages;
|
||||
if (carriedStartCosts > 0) {
|
||||
game.stats.penalty += carriedStartCosts;
|
||||
game.stats.profit -= carriedStartCosts;
|
||||
}
|
||||
game.pendingRepairWorkerWages = 0;
|
||||
for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
|
||||
for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
|
||||
game.buildTool = null;
|
||||
|
|
@ -139,17 +217,33 @@ function completeTurn() {
|
|||
const ship = settleTruckRevenue(game);
|
||||
const contract = resolveContract(game);
|
||||
const chemical = collectChemicalWeaponSubsidy(game);
|
||||
if (ship.adjusted > 0) {
|
||||
const truck = game.facilities.truck;
|
||||
const x = truck ? truck.x + truck.w / 2 : canvas.width / 2 - game.view.x;
|
||||
const y = truck ? truck.y + 26 : 96 - game.view.y;
|
||||
floating(game, x, y, `${ship.target === 'poop' ? 'POOP SHIP' : 'SHIP'} +${yen(ship.adjusted)}`, THEME.green);
|
||||
}
|
||||
if (contract?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `CONTRACT +${yen(contract.bonus)}`, THEME.green);
|
||||
if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green);
|
||||
clearActiveContract(game);
|
||||
const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1);
|
||||
const tribute = collectFairiesTribute(game, settlementDay);
|
||||
game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay };
|
||||
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
|
||||
const rescue = applyRescueLoanIfNeeded(game);
|
||||
if (rescue.amount > 0) floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(rescue.amount)}`, THEME.green);
|
||||
game.totals.turnsCompleted += 1;
|
||||
game.lastResult = { ...game.stats, ship, contract, chemical, rescue, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
game.lastResult = { ...game.stats, ship, contract, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
|
||||
if (game.cash < 0) {
|
||||
if ((game.cardEffects?.rescueLoanCharges || 0) > 0) {
|
||||
game.phase = 'loanDecision';
|
||||
uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver);
|
||||
return;
|
||||
}
|
||||
game.phase = 'gameover'; uiSystem.showGameOver(); return;
|
||||
}
|
||||
enterBuildPhase();
|
||||
}
|
||||
|
||||
function enterBuildPhase() {
|
||||
game.phase = 'build';
|
||||
game.truckCargo = [];
|
||||
game.chicks = [];
|
||||
|
|
@ -174,6 +268,24 @@ function completeTurn() {
|
|||
uiSystem.showTurnResult(game.lastResult);
|
||||
}
|
||||
|
||||
function useEmergencyLoan() {
|
||||
if ((game.cardEffects?.rescueLoanCharges || 0) <= 0) return acceptGameOver();
|
||||
game.cardEffects.rescueLoanCharges -= 1;
|
||||
game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500;
|
||||
game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500;
|
||||
const amount = applyRevenue(game, 500);
|
||||
game.lastResult = { ...(game.lastResult || {}), ...game.stats, rescue: { amount: ((game.lastResult?.rescue?.amount || 0) + amount), used: ((game.lastResult?.rescue?.used || 0) + 1), remaining: game.cardEffects.rescueLoanCharges }, cash: game.cash };
|
||||
floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(amount)}`, THEME.green);
|
||||
if (game.cash >= 0) enterBuildPhase();
|
||||
else if ((game.cardEffects?.rescueLoanCharges || 0) > 0) { game.phase = 'loanDecision'; uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver); }
|
||||
else acceptGameOver();
|
||||
}
|
||||
|
||||
function acceptGameOver() {
|
||||
game.phase = 'gameover';
|
||||
uiSystem.showGameOver();
|
||||
}
|
||||
|
||||
function closeFarmShutters() {
|
||||
if (game.shutdownTimeLeft <= 0) game.shutdownTimeLeft = game.shutdownGraceSeconds || 10;
|
||||
for (const farm of game.eggFarms) {
|
||||
|
|
@ -191,21 +303,96 @@ function update(timestamp) {
|
|||
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
|
||||
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
|
||||
game.lastTimestamp = timestamp;
|
||||
applyDebugState();
|
||||
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||
chicks.updateCongestion();
|
||||
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
|
||||
uiSystem.updateUI();
|
||||
updateManualScannerMonitor();
|
||||
updateDebugReadout();
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
// Build/edit/selection operations live in src/systems/buildSystem.js.
|
||||
// HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js.
|
||||
// Hitbox generation lives in buildSystem.js.
|
||||
function viewScale() { return game.view?.scale || 1; }
|
||||
function ensureView() {
|
||||
if (!game.view) game.view = { x: 0, y: 0, scale: 1 };
|
||||
if (game.view.scale == null) game.view.scale = 1;
|
||||
}
|
||||
function rawCanvasPoint(event) { const r = canvas.getBoundingClientRect(); return { x: (event.clientX - r.left) * canvas.width / r.width, y: (event.clientY - r.top) * canvas.height / r.height }; }
|
||||
function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x - game.view.x, y: p.y - game.view.y }; }
|
||||
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 canvasPoint(event) { const p = rawCanvasPoint(event); const s = viewScale(); return { x: (p.x - (game.view?.x || 0)) / s, y: (p.y - (game.view?.y || 0)) / s }; }
|
||||
function worldToScreen(p) { const s = viewScale(); return { x: p.x * s + (game.view?.x || 0), y: p.y * s + (game.view?.y || 0) }; }
|
||||
function cameraWorldBounds() {
|
||||
const bounds = {
|
||||
left: GRID.x - 90,
|
||||
top: GRID.y - 130,
|
||||
right: GRID.x + GRID.cols * GRID.cell + 260,
|
||||
bottom: GRID.y + GRID.rows * GRID.cell + 220
|
||||
};
|
||||
if (game.phase === 'build') {
|
||||
for (const lot of expansionLots(game)) {
|
||||
const b = lotBounds(lot);
|
||||
bounds.left = Math.min(bounds.left, b.x - 60);
|
||||
bounds.top = Math.min(bounds.top, b.y - 80);
|
||||
bounds.right = Math.max(bounds.right, b.x + b.w + 80);
|
||||
bounds.bottom = Math.max(bounds.bottom, b.y + b.h + 80);
|
||||
}
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function normalizedClampRange(min, max) {
|
||||
if (min <= max) return [min, max];
|
||||
const mid = (min + max) / 2;
|
||||
return [mid, mid];
|
||||
}
|
||||
|
||||
function clampCamera() {
|
||||
ensureView();
|
||||
const s = viewScale();
|
||||
const b = cameraWorldBounds();
|
||||
const [minX, maxX] = normalizedClampRange(canvas.width - 160 - b.right * s, 110 - b.left * s);
|
||||
const [minY, maxY] = normalizedClampRange(canvas.height - 150 - b.bottom * s, 150 - b.top * s);
|
||||
game.view.x = clamp(game.view.x, minX, maxX);
|
||||
game.view.y = clamp(game.view.y, minY, maxY);
|
||||
}
|
||||
|
||||
function resetCameraToFactoryStart() {
|
||||
ensureView();
|
||||
game.view.scale = 1;
|
||||
const farm = game.eggFarms?.[0];
|
||||
const anchor = farm ? cellCenter(farm.col, farm.row) : { x: GRID.x, y: GRID.y };
|
||||
// Keep the initial EGG and the left-side Mixer fully visible. The previous
|
||||
// anchor placed the EGG at 150,150, which clipped the Mixer body at the left edge.
|
||||
game.view.x = 280 - anchor.x;
|
||||
game.view.y = 190 - anchor.y;
|
||||
clampCamera();
|
||||
}
|
||||
function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
|
||||
function updatePan(event) {
|
||||
if (!game.pan) return;
|
||||
const p = rawCanvasPoint(event);
|
||||
ensureView();
|
||||
game.view.x = game.pan.viewX + p.x - game.pan.start.x;
|
||||
game.view.y = game.pan.viewY + p.y - game.pan.start.y;
|
||||
clampCamera();
|
||||
}
|
||||
function zoomAt(event) {
|
||||
event.preventDefault();
|
||||
ensureView();
|
||||
const raw = rawCanvasPoint(event);
|
||||
const before = canvasPoint(event);
|
||||
const factor = Math.exp(-event.deltaY * 0.0012);
|
||||
const nextScale = clamp(viewScale() * factor, 0.55, 1.8);
|
||||
game.view.scale = nextScale;
|
||||
game.view.x = raw.x - before.x * nextScale;
|
||||
game.view.y = raw.y - before.y * nextScale;
|
||||
clampCamera();
|
||||
hideHoverTooltip();
|
||||
}
|
||||
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;
|
||||
|
|
@ -226,8 +413,9 @@ function showHoverTooltip(event, hit) {
|
|||
if (!obj) return hideHoverTooltip();
|
||||
const title = build.selectedTitle(obj);
|
||||
const flavor = build.flavorText ? build.flavorText(obj) : '';
|
||||
const warning = build.disconnectedWarningFor ? build.disconnectedWarningFor(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('')}`;
|
||||
ui.hoverTooltip.innerHTML = `<strong>${title}</strong>${warning ? `<em class="warning">${warning}</em>` : (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`;
|
||||
|
|
@ -250,9 +438,119 @@ function clickSelect(event) {
|
|||
game.selected = sel;
|
||||
game.multiSelected = [sel];
|
||||
uiSystem.updatePanels();
|
||||
const obj = build.selectedObject();
|
||||
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
|
||||
function activeQueuedForScanner(scanner) {
|
||||
const id = scanner?.queue?.[0];
|
||||
return id == null ? null : game.chicks.find(chick => chick.id === id) || null;
|
||||
}
|
||||
|
||||
function chickDisplay(chick) {
|
||||
if (!chick) return '<span class="scanner-current empty">WAIT</span>';
|
||||
const label = chick.sex === 'poop' ? '💩 POOP' : chick.sex === 'male' ? '♂ MALE' : '♀ FEMALE';
|
||||
return `<span class="scanner-current ${chick.sex}">${label}</span>`;
|
||||
}
|
||||
|
||||
function miniScannerGrid(scanner, chick) {
|
||||
const farmCells = new Set(game.eggFarms.map(farm => key(farm.col, farm.row)));
|
||||
const scannerCells = new Map(game.scanners.map(s => [key(s.col, s.row), s]));
|
||||
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
|
||||
const activeSex = chick?.sex || '';
|
||||
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
|
||||
const cells = [];
|
||||
for (let row = scanner.row - 2; row <= scanner.row + 2; row += 1) {
|
||||
for (let col = scanner.col - 2; col <= scanner.col + 2; col += 1) {
|
||||
const k = key(col, row);
|
||||
const classes = ['mini-cell'];
|
||||
let label = '';
|
||||
if (col === scanner.col && row === scanner.row) {
|
||||
classes.push('scanner');
|
||||
if (activeSex) classes.push(activeSex);
|
||||
label = activeLabel || 'S';
|
||||
} else if (row === scanner.row - 1 && col === scanner.col) {
|
||||
classes.push('input');
|
||||
label = 'IN';
|
||||
} else if (farmCells.has(k)) {
|
||||
classes.push('egg');
|
||||
label = 'E';
|
||||
} else if (scannerCells.has(k)) {
|
||||
classes.push('scanner-other');
|
||||
label = 'S';
|
||||
} else if (facilityEntryCells.has(k)) {
|
||||
classes.push('port');
|
||||
label = 'P';
|
||||
} else if (game.conveyorTiles.has(k)) {
|
||||
classes.push('belt');
|
||||
label = '•';
|
||||
} else if (game.blockedCells?.has?.(k)) {
|
||||
classes.push('blocked');
|
||||
label = '×';
|
||||
}
|
||||
cells.push(`<span class="${classes.join(' ')}">${label}</span>`);
|
||||
}
|
||||
}
|
||||
return `<div class="scanner-mini-grid" aria-label="scanner vicinity">${cells.join('')}</div>`;
|
||||
}
|
||||
|
||||
function scannerIsOffscreen(scanner) {
|
||||
const screen = worldToScreen(cellCenter(scanner.col, scanner.row));
|
||||
const margin = 76;
|
||||
return screen.x < -margin || screen.y < -margin || screen.x > canvas.width + margin || screen.y > canvas.height + margin;
|
||||
}
|
||||
|
||||
function updateManualScannerMonitor() {
|
||||
if (!ui.manualScannerMonitor) return;
|
||||
if (game.phase !== 'running') {
|
||||
ui.manualScannerMonitor.classList.remove('visible');
|
||||
ui.manualScannerMonitor.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const scanners = game.scanners.filter(scanner => scanner.kind === 'manual' && scannerIsOffscreen(scanner));
|
||||
if (!scanners.length) {
|
||||
ui.manualScannerMonitor.classList.remove('visible');
|
||||
ui.manualScannerMonitor.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
ui.manualScannerMonitor.innerHTML = scanners.map(scanner => {
|
||||
const chick = activeQueuedForScanner(scanner);
|
||||
const left = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
|
||||
const right = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
|
||||
const leftDest = scanner.role === 0 ? 'MIXER' : 'WASTE';
|
||||
const rightDest = scanner.role === 0 ? 'S2' : 'TRUCK';
|
||||
const disabled = chick ? '' : ' disabled';
|
||||
return `
|
||||
<section class="scanner-monitor-card">
|
||||
<header><strong>S${(scanner.slot ?? 0) + 1}</strong><span>OFFSCREEN / Q:${scanner.queue.length}</span></header>
|
||||
<div class="scanner-mini-map">
|
||||
<span>IN</span><b>${chickDisplay(chick)}</b><span>SCANNER</span>
|
||||
</div>
|
||||
${miniScannerGrid(scanner, chick)}
|
||||
<div class="scanner-monitor-actions">
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} → ${leftDest}</button>
|
||||
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} → ${rightDest}</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}).join('');
|
||||
ui.manualScannerMonitor.classList.add('visible');
|
||||
}
|
||||
|
||||
ui.manualScannerMonitor?.addEventListener('click', event => {
|
||||
const button = event.target.closest('button[data-scanner-id]');
|
||||
if (!button || button.disabled) return;
|
||||
const scanner = game.scanners.find(s => s.id === Number(button.dataset.scannerId));
|
||||
if (!scanner) return;
|
||||
chicks.sortScanner(scanner, button.dataset.side);
|
||||
updateManualScannerMonitor();
|
||||
});
|
||||
|
||||
canvas.addEventListener('contextmenu', event => event.preventDefault());
|
||||
canvas.addEventListener('wheel', zoomAt, { passive: false });
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
if (game.phase !== 'build') return;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
|
|
@ -260,8 +558,14 @@ canvas.addEventListener('pointerdown', event => {
|
|||
if (event.button === 2) { startPan(event); return; }
|
||||
if (event.button !== 0) return;
|
||||
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
|
||||
const expansionOffer = build.expansionOfferAtPoint?.(world);
|
||||
if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; }
|
||||
if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
|
||||
if (game.buildTool) {
|
||||
if (game.buildTool === 'conveyor') {
|
||||
build.beginConveyorDrag(pointToCell(world.x, world.y));
|
||||
return;
|
||||
}
|
||||
const hit = build.equipmentAtPoint(world);
|
||||
if (hit) { game.buildTool = null; clickSelect(event); return; }
|
||||
build.buildAtPoint(world);
|
||||
|
|
@ -276,7 +580,7 @@ canvas.addEventListener('pointermove', event => {
|
|||
if (game.pan && (event.buttons & 2)) updatePan(event);
|
||||
if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
|
||||
if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event);
|
||||
if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.buildAtCell(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
|
||||
if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
|
||||
if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
|
||||
});
|
||||
canvas.addEventListener('pointerup', event => {
|
||||
|
|
@ -285,11 +589,12 @@ canvas.addEventListener('pointerup', event => {
|
|||
if (!game.groupDrag.committed) clickSelect(event);
|
||||
build.finishGroupDrag();
|
||||
}
|
||||
if (game.buildTool === 'conveyor') build.endConveyorDrag?.();
|
||||
game.groupDrag = null; game.pan = null;
|
||||
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
|
||||
});
|
||||
canvas.addEventListener('pointerleave', hideHoverTooltip);
|
||||
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); });
|
||||
canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); 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;
|
||||
|
|
@ -299,8 +604,30 @@ 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(); } });
|
||||
ui.expandGridButton?.addEventListener('click', () => { build?.buyGridExpansion?.('right'); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); });
|
||||
ui.debug.setDay?.addEventListener('click', debugSetDay);
|
||||
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
|
||||
ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards);
|
||||
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } });
|
||||
function scannerKeyMatches(scanner, side, event) {
|
||||
const binding = scanner.keys?.[side];
|
||||
if (!binding) return false;
|
||||
return (binding.code && event.code === binding.code) || (binding.key && event.key.toLowerCase() === binding.key);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
if (event.repeat) return;
|
||||
const name = event.key.toLowerCase();
|
||||
if (game.phase === 'running') {
|
||||
for (const scanner of game.scanners.filter(s => s.kind === 'manual')) {
|
||||
if (scannerKeyMatches(scanner, 'left', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'left'); return; }
|
||||
if (scannerKeyMatches(scanner, 'right', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'right'); return; }
|
||||
}
|
||||
}
|
||||
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
||||
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
|
||||
if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); }
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
|
@ -308,4 +635,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () =>
|
|||
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
|
||||
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
|
||||
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick });
|
||||
initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
|
||||
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);
|
||||
|
|
|
|||
106
src/index.html
106
src/index.html
|
|
@ -1,106 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chick Sorter v27.0</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="gameShell" class="game-shell" aria-label="Game screen">
|
||||
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
|
||||
|
||||
<div class="hud hud-top-left" aria-label="Run status">
|
||||
<div class="hud-card cash"><span>CASH</span><strong id="money">¥250</strong></div>
|
||||
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
|
||||
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
|
||||
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
|
||||
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="hud hud-top-right" aria-label="Day result details">
|
||||
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
|
||||
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="priority-strip" aria-label="Important day info">
|
||||
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
|
||||
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
|
||||
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
|
||||
</div>
|
||||
|
||||
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v27.0</span></h1>
|
||||
<p>Build, drag, connect at least one valid EGG route, then start the next day.</p>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid">
|
||||
<section class="equipment-section">
|
||||
<h2>Add / Edit Equipment</h2>
|
||||
<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 / 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>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>
|
||||
</section>
|
||||
|
||||
<section class="contract-section">
|
||||
<h2>Irregular One-Day Event</h2>
|
||||
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
|
||||
</section>
|
||||
<section class="rules-section compact-rules">
|
||||
<h2>Status</h2>
|
||||
<div id="turnSummary" class="mini-box"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-dock" aria-label="Sorting controls">
|
||||
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
|
||||
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
|
||||
<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>
|
||||
|
||||
<div id="modal" class="modal visible">
|
||||
<div class="modal-card">
|
||||
<h2 id="modalTitle">Chick Sorter</h2>
|
||||
<div id="modalBody"></div>
|
||||
<div id="modalActions" class="modal-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./game.js?v=27.0-combo-settlement"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
|
||||
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
|
||||
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
|
||||
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js';
|
||||
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
|
||||
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
|
||||
import { cardTargetBounds } from '../systems/cards.js';
|
||||
import { wearRatio } from '../systems/maintenance.js';
|
||||
|
|
@ -42,13 +43,17 @@ export function drawAll(ctx, canvas, game, helpers) {
|
|||
ctx.save();
|
||||
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
|
||||
const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
|
||||
ctx.translate(game.view.x + sx, game.view.y + sy);
|
||||
const scale = game.view?.scale || 1;
|
||||
ctx.translate((game.view?.x || 0) + sx, (game.view?.y || 0) + sy);
|
||||
ctx.scale(scale, scale);
|
||||
drawGrid(ctx, game);
|
||||
drawExpansionOffers(ctx, game);
|
||||
drawConveyors(ctx, game);
|
||||
drawScannerConnectors(ctx, game);
|
||||
drawFacilities(ctx, game);
|
||||
drawEggFarms(ctx, game);
|
||||
drawScanners(ctx, game);
|
||||
drawDisconnectedWarnings(ctx, game);
|
||||
drawChicks(ctx, game, helpers.activeQueuedChick);
|
||||
drawRepairman(ctx, game);
|
||||
drawCardTargetOverlay(ctx, canvas, game);
|
||||
|
|
@ -60,11 +65,46 @@ export function drawAll(ctx, canvas, game, helpers) {
|
|||
drawCanvasHints(ctx, game, canvas);
|
||||
}
|
||||
|
||||
function warningBounds(item) {
|
||||
if (item.type === 'eggFarm') {
|
||||
const c = cellCenter(item.ref.col, item.ref.row);
|
||||
return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
|
||||
}
|
||||
const f = item.ref;
|
||||
return { x: f.x, y: f.y, w: f.w, h: f.h, cx: f.x + f.w / 2, cy: f.y + f.h / 2 };
|
||||
}
|
||||
|
||||
function drawDisconnectedWarnings(ctx, game) {
|
||||
if (game.phase !== 'build') return;
|
||||
const warnings = disconnectedBuildWarnings(game);
|
||||
for (const item of warnings) {
|
||||
const b = warningBounds(item);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(196,36,36,.22)';
|
||||
ctx.strokeStyle = THEME.danger;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([10, 6]);
|
||||
rect(ctx, b.x, b.y, b.w, b.h, true, true);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = THEME.danger;
|
||||
ctx.strokeStyle = THEME.white;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.font = '900 16px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.strokeText('!', b.cx, b.y + 24);
|
||||
ctx.fillText('!', b.cx, b.y + 24);
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
ctx.strokeText('NO ROUTE', b.cx, b.y + b.h - 8);
|
||||
ctx.fillText('NO ROUTE', b.cx, b.y + b.h - 8);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function rect(ctx, x, y, w, h, fill = true, stroke = true) {
|
||||
ctx.beginPath(); ctx.rect(x, y, w, h); if (fill) ctx.fill(); if (stroke) ctx.stroke();
|
||||
}
|
||||
function label(ctx, x, y, text, color = THEME.ink) {
|
||||
ctx.save(); ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore();
|
||||
ctx.save(); ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore();
|
||||
}
|
||||
function drawImageIfLoaded(ctx, image, x, y, w, h) {
|
||||
if (!image?.loaded) return false;
|
||||
|
|
@ -81,22 +121,38 @@ 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 ownedCellKeys(game) {
|
||||
if (game.ownedCells?.size) return [...game.ownedCells];
|
||||
const cells = [];
|
||||
for (let row = 0; row < GRID.rows; row += 1) for (let col = 0; col < GRID.cols; col += 1) cells.push(key(col, row));
|
||||
return cells;
|
||||
}
|
||||
|
||||
function drawGrid(ctx, game) {
|
||||
ctx.save();
|
||||
const cells = ownedCellKeys(game);
|
||||
ctx.fillStyle = 'rgba(255,255,255,.46)';
|
||||
ctx.strokeStyle = 'rgba(16,32,21,.055)';
|
||||
ctx.lineWidth = 1;
|
||||
rect(ctx, GRID.x - 10, GRID.y - 10, GRID.cols * GRID.cell + 20, GRID.rows * GRID.cell + 20, true, true);
|
||||
for (let c = 0; c <= GRID.cols; c += 1) {
|
||||
const x = GRID.x + c * GRID.cell;
|
||||
ctx.beginPath(); ctx.moveTo(x, GRID.y); ctx.lineTo(x, GRID.y + GRID.rows * GRID.cell); ctx.stroke();
|
||||
for (const k of cells) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
rect(ctx, c.x - GRID.cell / 2, c.y - GRID.cell / 2, GRID.cell, GRID.cell, true, true);
|
||||
}
|
||||
for (let r = 0; r <= GRID.rows; r += 1) {
|
||||
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();
|
||||
ctx.strokeStyle = 'rgba(16,32,21,.16)';
|
||||
ctx.lineWidth = 3;
|
||||
for (const k of cells) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const x = c.x - GRID.cell / 2, y = c.y - GRID.cell / 2;
|
||||
if (!isOwnedCell(game, p.col - 1, p.row)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + GRID.cell); ctx.stroke(); }
|
||||
if (!isOwnedCell(game, p.col + 1, p.row)) { ctx.beginPath(); ctx.moveTo(x + GRID.cell, y); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); }
|
||||
if (!isOwnedCell(game, p.col, p.row - 1)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + GRID.cell, y); ctx.stroke(); }
|
||||
if (!isOwnedCell(game, p.col, p.row + 1)) { ctx.beginPath(); ctx.moveTo(x, y + GRID.cell); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); }
|
||||
}
|
||||
for (const k of game.blockedCells || []) {
|
||||
const p = parseKey(k);
|
||||
if (!isOwnedCell(game, p.col, p.row)) continue;
|
||||
const c = cellCenter(p.col, p.row);
|
||||
ctx.fillStyle = 'rgba(16,32,21,.30)';
|
||||
ctx.strokeStyle = 'rgba(16,32,21,.60)';
|
||||
|
|
@ -106,6 +162,40 @@ function drawGrid(ctx, game) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawExpansionOffers(ctx, game) {
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return;
|
||||
const cost = expansionCost(game);
|
||||
const affordable = game.cash >= cost;
|
||||
for (const lot of expansionLots(game)) {
|
||||
const b = lotBounds(lot);
|
||||
ctx.save();
|
||||
ctx.fillStyle = affordable ? 'rgba(183,245,198,.34)' : 'rgba(255,255,255,.22)';
|
||||
ctx.strokeStyle = affordable ? THEME.green : THEME.muted;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([16, 10]);
|
||||
rect(ctx, b.x, b.y, b.w, b.h, true, true);
|
||||
ctx.setLineDash([]);
|
||||
const button = expansionButtonBounds(lot);
|
||||
const cx = button.cx;
|
||||
const cy = button.cy;
|
||||
ctx.fillStyle = 'rgba(255,255,255,.94)';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 4;
|
||||
rect(ctx, button.x, button.y, button.w, button.h, true, true);
|
||||
ctx.fillStyle = affordable ? THEME.green : THEME.danger;
|
||||
ctx.font = '900 16px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(lot.direction === 'right' ? 'BUY RIGHT LOT' : 'BUY UPPER LOT', cx, cy - 12);
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 16px ui-monospace, monospace';
|
||||
ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15);
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
ctx.fillText('blocked tiles included', cx, cy + 30);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) {
|
||||
const wear = wearRatio(target);
|
||||
if (wear < 0.50) return;
|
||||
|
|
@ -200,8 +290,22 @@ function addMarkerFromRoute(game, markers, route) {
|
|||
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
|
||||
}
|
||||
}
|
||||
function explicitConveyorAngles(meta) {
|
||||
if (!meta) return [];
|
||||
const names = [];
|
||||
if (Array.isArray(meta.outDirs)) names.push(...meta.outDirs);
|
||||
if (meta.dir) names.push(meta.dir);
|
||||
return [...new Set(names)]
|
||||
.map(name => DIRS.find(d => d.name === name)?.angle)
|
||||
.filter(angle => Number.isFinite(angle));
|
||||
}
|
||||
|
||||
function collectConveyorDirectionMarkers(game) {
|
||||
const markers = new Map();
|
||||
for (const [k, meta] of game.conveyorMeta || []) {
|
||||
const angles = explicitConveyorAngles(meta);
|
||||
if (angles.length) markers.set(k, angles);
|
||||
}
|
||||
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
|
||||
for (const scanner of game.scanners) {
|
||||
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
|
||||
|
|
@ -230,7 +334,7 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
|
|||
function drawScannerConnectors(ctx, game) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
for (const scanner of game.scanners) {
|
||||
const outputs = scannerOutputs(scanner);
|
||||
|
|
@ -258,13 +362,18 @@ function drawEggFarm(ctx, farm, game) {
|
|||
ctx.save();
|
||||
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) {
|
||||
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm);
|
||||
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
|
||||
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
rect(ctx, c.x + 9, c.y + 12, 30, 18, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`O${outlets}`, c.x + 24, c.y + 24);
|
||||
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);
|
||||
ctx.font = '900 9px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26);
|
||||
ctx.fillStyle = THEME.green; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
|
||||
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
|
||||
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level} O${outlets}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26);
|
||||
const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
|
||||
if (shutter > 0) {
|
||||
const h = 56 * shutter;
|
||||
|
|
@ -272,7 +381,7 @@ function drawEggFarm(ctx, farm, game) {
|
|||
rect(ctx, c.x - 28, c.y - 28, 56, h, true, false);
|
||||
ctx.strokeStyle = THEME.white; ctx.lineWidth = 2;
|
||||
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); }
|
||||
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 12px 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);
|
||||
|
|
@ -282,6 +391,17 @@ function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ct
|
|||
function drawScanner(ctx, scanner, game) {
|
||||
const c = scannerCenter(scanner);
|
||||
ctx.save();
|
||||
if (scanner.kind === 'manual' && (game.manualCombo?.count || 0) >= 10) {
|
||||
const combo = game.manualCombo.count;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.min(0.55, 0.16 + combo / 120);
|
||||
ctx.strokeStyle = combo >= 30 ? THEME.warn : THEME.green;
|
||||
ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10));
|
||||
ctx.shadowColor = ctx.strokeStyle;
|
||||
ctx.shadowBlur = Math.min(28, 8 + combo);
|
||||
rect(ctx, c.x - 60, c.y - 44, 120, 88, false, true);
|
||||
ctx.restore();
|
||||
}
|
||||
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
|
||||
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);
|
||||
|
|
@ -293,19 +413,21 @@ function drawScanner(ctx, scanner, game) {
|
|||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
|
||||
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
|
||||
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14);
|
||||
ctx.font = '900 9px ui-monospace, monospace';
|
||||
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 3);
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
|
||||
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
|
||||
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y + 3);
|
||||
const q = scanner.queue.length;
|
||||
if (scanner.kind === 'manual') {
|
||||
drawManualKeyboardIcon(ctx, scanner, c);
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.font = '900 8px ui-monospace, monospace';
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(`Q:${q}`, c.x, c.y + 32);
|
||||
} else {
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25);
|
||||
drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
|
||||
}
|
||||
|
|
@ -319,6 +441,8 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
|
|||
const labels = slot === 0
|
||||
? [{ side: 'left', text: 'A' }, { side: 'right', text: 'D' }]
|
||||
: [{ side: 'left', text: '←' }, { side: 'right', text: '→' }];
|
||||
labels[0].text = scanner.keys?.left?.label || labels[0].text;
|
||||
labels[1].text = scanner.keys?.right?.label || labels[1].text;
|
||||
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
|
||||
const baseX = c.x - 30;
|
||||
const baseY = c.y + 8;
|
||||
|
|
@ -327,7 +451,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
|
|||
ctx.fillStyle = '#f7fff5';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true);
|
||||
ctx.font = '900 13px ui-monospace, monospace';
|
||||
ctx.font = '900 19px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
for (let i = 0; i < labels.length; i += 1) {
|
||||
const keyDef = labels[i];
|
||||
|
|
@ -346,7 +470,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
|
|||
ctx.fillText(keyDef.text, x + 14, y + 13);
|
||||
}
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 6px ui-monospace, monospace';
|
||||
ctx.font = '900 8px ui-monospace, monospace';
|
||||
ctx.fillText('KEYBOARD', c.x, baseY - 7);
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -366,12 +490,12 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
|
|||
rect(ctx, x, y, w, 32, true, true);
|
||||
drawItemIcon(ctx, x + 18, y + 16, type);
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, x + 34, y + 13);
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 8px ui-monospace, monospace';
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(subtitle, x + 34, y + 25);
|
||||
}
|
||||
ctx.restore();
|
||||
|
|
@ -396,7 +520,7 @@ function drawFacilityReceiver(ctx, game, id) {
|
|||
ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true);
|
||||
drawItemIcon(ctx, c.x, c.y - 3, info.type);
|
||||
ctx.font = '900 8px ui-monospace, monospace';
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.fillText(info.title, c.x, c.y + 20);
|
||||
|
|
@ -433,8 +557,8 @@ function drawMixer(ctx, game) {
|
|||
drawExternalDuct(ctx, m);
|
||||
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);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
|
||||
ctx.font = '900 12px 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(); }
|
||||
|
|
@ -448,7 +572,7 @@ function drawTrash(ctx, game) {
|
|||
drawExternalDuct(ctx, t);
|
||||
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);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 19px 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);
|
||||
|
|
@ -461,7 +585,7 @@ function drawTruck(ctx, game) {
|
|||
drawExternalDuct(ctx, t);
|
||||
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', 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(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
|
||||
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42);
|
||||
const truckTargetType = targetForTruck(game);
|
||||
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
|
||||
|
|
@ -515,7 +639,7 @@ function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
|
|||
function drawTinyChick(ctx, x, y, sex, warning = false) {
|
||||
ctx.save(); ctx.fillStyle = sex === 'male' ? THEME.male : THEME.female; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
|
||||
if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
|
||||
ctx.restore();
|
||||
}
|
||||
function selectionToken(type, id) { return `${type}:${id}`; }
|
||||
|
|
@ -536,7 +660,7 @@ function drawSelection(ctx, game, type, id, x, y, w, h) {
|
|||
function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
|
||||
if (game.phase !== 'build') return;
|
||||
if ((game.multiSelected || []).length > 1) {
|
||||
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();
|
||||
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 11px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
|
||||
return;
|
||||
}
|
||||
// Single equipment inspection is handled by the DOM hover tooltip.
|
||||
|
|
@ -551,7 +675,8 @@ function drawCardTargetOverlay(ctx, canvas, game) {
|
|||
// so every non-candidate object is reliably dimmed even after panning.
|
||||
ctx.fillStyle = 'rgba(0,0,0,.66)';
|
||||
ctx.beginPath();
|
||||
ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height);
|
||||
const scale = game.view?.scale || 1;
|
||||
ctx.rect(-(game.view?.x || 0) / scale, -(game.view?.y || 0) / scale, canvas.width / scale, canvas.height / scale);
|
||||
for (const item of items) {
|
||||
const b = item.bounds;
|
||||
const pad = 8;
|
||||
|
|
@ -563,11 +688,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
|
|||
ctx.fillStyle = 'rgba(255,255,255,.95)';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 3;
|
||||
rect(ctx, 260 - game.view.x, 96 - game.view.y, 420, 42, true, true);
|
||||
rect(ctx, (260 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 420 / scale, 42 / scale, true, true);
|
||||
ctx.fillStyle = THEME.danger;
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y);
|
||||
ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', (470 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
|
@ -576,11 +701,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
|
|||
ctx.lineWidth = 3;
|
||||
const remaining = Math.max(1, game.cardTargetPick?.remaining || 1);
|
||||
const msg = isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
|
||||
rect(ctx, 216 - game.view.x, 96 - game.view.y, 560, 42, true, true);
|
||||
rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true);
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 12px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y);
|
||||
ctx.fillText(msg, (496 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
@ -618,7 +743,7 @@ function drawEffects(ctx, game) {
|
|||
}
|
||||
}
|
||||
function drawFloatingTexts(ctx, game) {
|
||||
ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
ctx.save(); ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
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();
|
||||
}
|
||||
|
|
@ -649,7 +774,7 @@ function drawRepairman(ctx, game) {
|
|||
ctx.beginPath(); ctx.moveTo(r.x - 11, r.y + 18); ctx.lineTo(r.x - 20, r.y + 24); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(r.x + 11, r.y + 18); ctx.lineTo(r.x + 20, r.y + 24); ctx.stroke();
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 9px ui-monospace, monospace';
|
||||
ctx.font = '900 11px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33);
|
||||
if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
|
||||
|
|
@ -659,12 +784,12 @@ function drawRepairman(ctx, game) {
|
|||
|
||||
function drawCanvasHints(ctx, game, canvas) {
|
||||
ctx.save();
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
|
||||
// Visible version marker helps avoid browser/file-cache confusion when testing zips.
|
||||
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
|
||||
rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
|
||||
ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
|
||||
ctx.font = '900 19px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
|
||||
const lines = [];
|
||||
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');
|
||||
if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect 1 EGG route to any exit`);
|
||||
|
|
|
|||
623
src/styles.css
623
src/styles.css
|
|
@ -1,623 +0,0 @@
|
|||
:root {
|
||||
--bg: #f2fff0;
|
||||
--screen: #eefbea;
|
||||
--panel: rgba(245, 255, 242, 0.96);
|
||||
--ink: #102015;
|
||||
--muted: #526456;
|
||||
--line: #102015;
|
||||
--green: #22b94f;
|
||||
--green-soft: #b7f5c6;
|
||||
--white: #ffffff;
|
||||
--danger: #c42424;
|
||||
--shadow: 8px 8px 0 rgba(16, 32, 21, 0.22);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; }
|
||||
button { font: inherit; }
|
||||
h1, h2, p { margin: 0; }
|
||||
#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; }
|
||||
.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); }
|
||||
#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; }
|
||||
.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; }
|
||||
.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); }
|
||||
.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); }
|
||||
.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); }
|
||||
.hud-card.primary span { font-size: 9px; }
|
||||
.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); }
|
||||
.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
|
||||
.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); }
|
||||
.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; }
|
||||
.cash-negative { color: var(--danger) !important; }
|
||||
|
||||
.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; }
|
||||
.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.event-brief.locked { background: #edf3ec; color: var(--muted); }
|
||||
.target-brief { color: var(--green); }
|
||||
.speed-brief { color: var(--ink); }
|
||||
|
||||
.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; }
|
||||
.panel-head { display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
.panel-head h1 { font-size: 15px; letter-spacing: .06em; }
|
||||
.panel-head h1 span { font-size: 9px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
|
||||
.panel-head p { color: var(--muted); margin-top: 4px; font-size: 9px; line-height: 1.25; }
|
||||
.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }
|
||||
.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; }
|
||||
.tool-button.small { min-height: 40px; padding: 6px; }
|
||||
.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); }
|
||||
.tool-button { min-height: 50px; text-align: left; padding: 8px; }
|
||||
.tool-button strong { display: block; font-size: 12px; }
|
||||
.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); }
|
||||
.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); }
|
||||
.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); }
|
||||
.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; }
|
||||
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; }
|
||||
.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
|
||||
.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; }
|
||||
.facility-card p { margin: 4px 0; }
|
||||
.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.facility-action { padding: 7px 9px; font-size: 9px; }
|
||||
.facility-action.good { background: var(--green-soft); }
|
||||
.facility-action.warn { background: #fff0d1; }
|
||||
.facility-action.danger { background: #ffdada; }
|
||||
.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
|
||||
.sort-button { min-height: 46px; font-size: 12px; }
|
||||
.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
|
||||
.modal.visible { display: flex; }
|
||||
.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); }
|
||||
.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.modal-card p { line-height: 1.55; }
|
||||
.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); }
|
||||
.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
|
||||
.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
|
||||
.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; }
|
||||
.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; }
|
||||
.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; }
|
||||
.route-card b { font-size: 24px; color: var(--green); text-align: center; }
|
||||
.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; }
|
||||
.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
|
||||
.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; }
|
||||
.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); }
|
||||
.game-shell:not(.phase-build) .build-panel { display: none; }
|
||||
.game-shell.phase-running .hud-top-right { display: none; }
|
||||
.game-shell:not(.phase-running) .control-dock { display: none; }
|
||||
@media (max-width: 1180px) {
|
||||
#app { padding: 0; }
|
||||
.game-shell { min-height: 100vh; }
|
||||
#gameCanvas { height: 100vh; }
|
||||
.hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); }
|
||||
.priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; }
|
||||
.build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; }
|
||||
.large-tools { grid-template-columns: 1fr 1fr; }
|
||||
.control-dock { grid-template-columns: repeat(2, 150px); }
|
||||
.route-cards, .result-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
|
||||
.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
|
||||
.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.contract-card p { margin: 5px 0; }
|
||||
.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
|
||||
.contract-card.declined { background: #f1f1f1; color: var(--muted); }
|
||||
.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
|
||||
.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
|
||||
.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
|
||||
.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); }
|
||||
.result-section-title:first-child { margin-top: 0; }
|
||||
.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
|
||||
|
||||
.compact-rules .mini-box { padding: 8px; font-size: 9px; }
|
||||
.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }
|
||||
.formula-box {
|
||||
border: 3px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.equipment-menu-lines {
|
||||
border: 3px solid var(--line);
|
||||
background: #ffffff;
|
||||
padding: 10px;
|
||||
margin: 8px 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.equipment-menu-lines p { margin: 4px 0; }
|
||||
|
||||
/* v11 compact clicked-equipment bubble */
|
||||
.modal.equipment-popover {
|
||||
background: transparent;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.modal.equipment-popover .modal-card {
|
||||
position: absolute;
|
||||
left: var(--popover-x, 16px);
|
||||
top: var(--popover-y, 16px);
|
||||
width: min(250px, calc(100vw - 24px));
|
||||
max-height: min(280px, calc(100vh - 24px));
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-width: 3px;
|
||||
pointer-events: auto;
|
||||
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
|
||||
}
|
||||
.modal.equipment-popover .modal-card h2 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.modal.equipment-popover .modal-actions {
|
||||
margin-top: 8px;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.modal.equipment-popover .modal-actions button {
|
||||
padding: 5px 7px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.equipment-menu-lines.compact {
|
||||
padding: 7px;
|
||||
margin: 6px 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
.formula-box.compact {
|
||||
padding: 7px;
|
||||
margin: 6px 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* v15 pre-build card draft */
|
||||
.card-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 220px));
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card-choice {
|
||||
min-height: 132px;
|
||||
border: 3px solid var(--line);
|
||||
background: #f7fff5;
|
||||
color: var(--ink);
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.16);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
}
|
||||
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
|
||||
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
|
||||
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
|
||||
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
|
||||
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
|
||||
.card-choice.ultra-rare span { background: #c42424; color: #fff; }
|
||||
.muted-card-note { color: var(--muted); font-weight: 700; }
|
||||
.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(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68);
|
||||
opacity: 0;
|
||||
transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s 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;
|
||||
}
|
||||
|
||||
|
||||
.route-card .route-icon.male,
|
||||
.route-card .route-icon.female,
|
||||
.route-card .route-icon.poop {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid var(--line, #102015);
|
||||
background: var(--white, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
.route-card .route-icon.male { color: #2477ff; background: #d9ecff; }
|
||||
.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; }
|
||||
.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; }
|
||||
.restart-button { min-width: 160px; }
|
||||
.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; }
|
||||
|
||||
/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */
|
||||
.tool-button .tool-flavor {
|
||||
display: block;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
margin-top: 0;
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
line-height: 1.22;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease;
|
||||
}
|
||||
.tool-button:hover .tool-flavor {
|
||||
max-height: 42px;
|
||||
opacity: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.large-tools .tool-button { min-height: 58px; }
|
||||
.large-tools .tool-button:hover { min-height: 78px; }
|
||||
.history-tools .tool-button.small { min-height: 40px; }
|
||||
.primary-button.cashout-emphasis,
|
||||
.tool-button.cashout-emphasis {
|
||||
opacity: 1 !important;
|
||||
filter: none !important;
|
||||
animation: cashoutPulse 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
.primary-button.cashout-emphasis {
|
||||
box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22);
|
||||
}
|
||||
.tool-button.cashout-emphasis {
|
||||
background: #fff0d1;
|
||||
box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20);
|
||||
}
|
||||
@keyframes cashoutPulse {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(-1px, -1px); }
|
||||
}
|
||||
.modal.gameover-modal { padding: 12px; }
|
||||
.modal.gameover-modal .modal-card {
|
||||
width: min(640px, 92vw);
|
||||
max-height: min(82vh, 720px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal.gameover-modal .modal-card h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
|
||||
.gameover-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.gameover-breakdown {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.modal.gameover-modal .result-grid div { padding: 7px; }
|
||||
.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; }
|
||||
.modal.gameover-modal .result-grid span { font-size: 12px; }
|
||||
.gameover-details {
|
||||
margin-top: 10px;
|
||||
border: 3px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 8px;
|
||||
}
|
||||
.gameover-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; }
|
||||
@media (max-width: 720px) {
|
||||
.gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* v26.0 readability and stable build-menu tooltip pass */
|
||||
body { font-size: 18px; }
|
||||
.build-panel { width: clamp(330px, 26vw, 420px); }
|
||||
.panel-head h1 { font-size: 20px; }
|
||||
.panel-head h1 span { font-size: 12px; }
|
||||
.panel-head p { font-size: 13px; line-height: 1.35; }
|
||||
.build-panel h2 { font-size: 14px; }
|
||||
.priority-strip > div { font-size: 15px; }
|
||||
.hud-card span { font-size: 12px; }
|
||||
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
|
||||
.mini-box, .contract-card, .facility-panel-empty, .facility-card { font-size: 14px; line-height: 1.45; }
|
||||
.compact-rules .mini-box { font-size: 13px; }
|
||||
.tool-button {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
min-height: 62px;
|
||||
padding: 10px;
|
||||
}
|
||||
.large-tools .tool-button { min-height: 62px; }
|
||||
.large-tools .tool-button:hover { min-height: 62px; }
|
||||
.tool-button strong { font-size: 15px; line-height: 1.15; }
|
||||
.tool-button span { font-size: 12px; }
|
||||
.tool-button .tool-flavor {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
left: 0;
|
||||
top: calc(100% + 7px);
|
||||
width: min(300px, calc(100vw - 40px));
|
||||
max-height: none;
|
||||
opacity: 1;
|
||||
overflow: visible;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.98);
|
||||
color: var(--ink);
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
pointer-events: none;
|
||||
transition: none;
|
||||
}
|
||||
.tool-button .tool-flavor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: -10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-left: 3px solid var(--line);
|
||||
border-top: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.98);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.tool-button:hover .tool-flavor {
|
||||
display: block;
|
||||
max-height: none;
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.primary-button { font-size: 16px; padding: 13px 16px; }
|
||||
#nextTurnButton { min-height: 56px; font-size: 18px; }
|
||||
@keyframes cashoutPulse {
|
||||
from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); }
|
||||
to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); }
|
||||
}
|
||||
.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; }
|
||||
.card-choice { min-height: 164px; padding: 15px; }
|
||||
.card-choice strong { font-size: 18px; }
|
||||
.card-choice span { font-size: 11px; }
|
||||
.card-choice small { font-size: 15px; line-height: 1.4; }
|
||||
.muted-card-note { font-size: 15px; line-height: 1.45; }
|
||||
.modal-card { width: min(980px, 96vw); }
|
||||
.modal-card p, .modal-card li { font-size: 16px; }
|
||||
.modal-actions .reroll-button {
|
||||
order: 99;
|
||||
flex: 1 1 100%;
|
||||
min-height: 64px;
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
background: #fff0d1;
|
||||
box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20);
|
||||
}
|
||||
.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); }
|
||||
.hover-tooltip strong { font-size: 17px; }
|
||||
.hover-tooltip span { font-size: 14px; }
|
||||
.sort-button { font-size: 16px; min-height: 56px; }
|
||||
.control-dock { grid-template-columns: repeat(4, 172px); }
|
||||
@media (max-width: 1180px) {
|
||||
.build-panel { width: auto; }
|
||||
.control-dock { grid-template-columns: repeat(2, 172px); }
|
||||
}
|
||||
|
||||
/* v27 combo / tags / settlement clarity */
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.card-tags i {
|
||||
display: inline-block;
|
||||
border: 2px solid var(--line);
|
||||
background: rgba(255,255,255,.86);
|
||||
color: var(--ink);
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
padding: 1px 6px;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.settlement-hero {
|
||||
border: 4px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.14);
|
||||
}
|
||||
.settlement-hero.positive { box-shadow: inset 0 0 0 4px var(--green), 5px 5px 0 rgba(16,32,21,.14); }
|
||||
.settlement-hero.negative { box-shadow: inset 0 0 0 4px var(--danger), 5px 5px 0 rgba(16,32,21,.14); }
|
||||
.settlement-hero strong { display: block; font-size: 34px; line-height: 1; color: var(--green); }
|
||||
.settlement-hero.negative strong { color: var(--danger); }
|
||||
.settlement-hero span { display: block; margin-top: 8px; font-weight: 900; color: var(--muted); }
|
||||
.settlement-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.settlement-grid div {
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.9);
|
||||
padding: 9px;
|
||||
}
|
||||
.settlement-grid strong { display: block; font-size: 11px; text-transform: uppercase; color: var(--muted); }
|
||||
.settlement-grid span { display: block; margin-top: 5px; font-size: 20px; font-weight: 900; color: var(--ink); }
|
||||
.settlement-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 3px solid var(--line);
|
||||
background: #fff;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.settlement-table td {
|
||||
border-bottom: 2px solid rgba(16,32,21,.18);
|
||||
padding: 7px 9px;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.settlement-table td:last-child { text-align: right; }
|
||||
.settlement-table tr.income td:last-child { color: var(--green); }
|
||||
.settlement-table tr.cost td:last-child { color: var(--danger); }
|
||||
.settlement-notes {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 3px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.key-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.key-config-grid label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-weight: 900;
|
||||
color: var(--muted);
|
||||
}
|
||||
.key-config-grid input,
|
||||
.key-config-grid select {
|
||||
border: 3px solid var(--line);
|
||||
background: var(--white);
|
||||
color: var(--ink);
|
||||
padding: 7px 8px;
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
}
|
||||
.modal.rescue-modal .modal-card {
|
||||
width: min(560px, 92vw);
|
||||
}
|
||||
.hover-tooltip .cash-negative { color: var(--danger) !important; font-weight: 900; }
|
||||
@media (max-width: 720px) {
|
||||
.settlement-grid { grid-template-columns: 1fr 1fr; }
|
||||
.settlement-hero strong { font-size: 26px; }
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { BALANCE } from '../core/balance.js';
|
||||
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.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 { generateBlockedCellsInRect } from '../core/mapGen.js';
|
||||
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
|
||||
import { TEXT, equipmentName } from '../core/text.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.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';
|
||||
|
|
@ -14,8 +17,95 @@ import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScanne
|
|||
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)); }
|
||||
let lastConveyorBuildCell = null;
|
||||
let conveyorDragRecordedDirectionEdit = false;
|
||||
|
||||
function gridExpansionCost() { return expansionCost(game); }
|
||||
|
||||
function syncGridSizeToGame() {
|
||||
game.gridCols = GRID.cols;
|
||||
game.gridRows = GRID.rows;
|
||||
game.gridOwnedCells = countOwnedCells(game);
|
||||
}
|
||||
|
||||
function shiftWorldRowsDown(rowDelta) {
|
||||
if (!rowDelta) return;
|
||||
GRID.y -= rowDelta * GRID.cell;
|
||||
GRID.rows += rowDelta;
|
||||
game.gridChunkOriginY = (game.gridChunkOriginY || 0) - Math.floor(rowDelta / 10);
|
||||
game.ownedCells = shiftRowIndexedSet(game.ownedCells, rowDelta);
|
||||
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
|
||||
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta);
|
||||
game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta);
|
||||
for (const farm of game.eggFarms || []) farm.row += rowDelta;
|
||||
for (const scanner of game.scanners || []) scanner.row += rowDelta;
|
||||
for (const facility of Object.values(game.facilities || {})) {
|
||||
if (facility.entry) facility.entry.row += rowDelta;
|
||||
}
|
||||
}
|
||||
|
||||
function addOwnedLotCells(lot) {
|
||||
game.ownedCells = game.ownedCells || new Set();
|
||||
for (const cell of cellsInLot(lot)) game.ownedCells.add(key(cell.col, cell.row));
|
||||
}
|
||||
|
||||
function expansionOfferAtPoint(p) {
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
|
||||
return expansionLots(game).find(lot => lot && lotContainsPoint(lot, p)) || null;
|
||||
}
|
||||
|
||||
function resolveExpansionRequest(request = 'right') {
|
||||
if (request && typeof request === 'object' && Number.isFinite(request.chunkX) && Number.isFinite(request.chunkY)) return request;
|
||||
const direction = ['right', 'up'].includes(request) ? request : 'right';
|
||||
return expansionLot(game, direction);
|
||||
}
|
||||
|
||||
function buyGridExpansion(request = 'right') {
|
||||
const offer = resolveExpansionRequest(request);
|
||||
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return { ok: false, reason: 'Cannot expand now.' };
|
||||
if (!offer) return { ok: false, reason: 'Invalid expansion lot.' };
|
||||
const direction = offer.direction || 'right';
|
||||
const cost = gridExpansionCost();
|
||||
if (game.cash < cost) {
|
||||
const reason = `Need ${yen(cost - game.cash)} more.`;
|
||||
fail(reason);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
|
||||
const originY = game.gridChunkOriginY || 0;
|
||||
if (offer.chunkY < originY) {
|
||||
const chunksToAdd = originY - offer.chunkY;
|
||||
shiftWorldRowsDown(chunksToAdd * (BALANCE.grid.expansion?.rowsPerPurchase || 10));
|
||||
}
|
||||
const lot = lotFromChunk(game, offer.chunkX, offer.chunkY, direction);
|
||||
if (lot.colStart + lot.cols > GRID.cols) GRID.cols = lot.colStart + lot.cols;
|
||||
if (lot.rowStart + lot.rows > GRID.rows) GRID.rows = lot.rowStart + lot.rows;
|
||||
addOwnedLotCells(lot);
|
||||
ownChunk(game, lot.chunkX, lot.chunkY);
|
||||
game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1;
|
||||
game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1;
|
||||
syncGridSizeToGame();
|
||||
const blocked = generateBlockedCellsInRect({
|
||||
seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`,
|
||||
colStart: lot.colStart,
|
||||
colEnd: lot.colStart + lot.cols,
|
||||
rowStart: lot.rowStart,
|
||||
rowEnd: lot.rowStart + lot.rows,
|
||||
targetRatio: BALANCE.map.blockedRatio
|
||||
});
|
||||
for (const k of blocked) game.blockedCells.add(k);
|
||||
refreshRoutingAfterEdit(game);
|
||||
const addedCells = lot.cols * lot.rows;
|
||||
const b = lotBounds(lot);
|
||||
floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green);
|
||||
updatePanels();
|
||||
return { ok: true, direction, cost, addedCells, blocked: blocked.size, chunkX: lot.chunkX, chunkY: lot.chunkY };
|
||||
}
|
||||
|
||||
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); }
|
||||
function isBlockedCell(col, row) { return !isOwnedCell(game, col, row) || 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) {
|
||||
|
|
@ -88,33 +178,138 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
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);
|
||||
}
|
||||
|
||||
function conveyorMetaForCell(cell) {
|
||||
if (!cell) return null;
|
||||
const k = key(cell.col, cell.row);
|
||||
return game.conveyorMeta.get(k) || null;
|
||||
}
|
||||
|
||||
function normalizeOutDirs(meta) {
|
||||
if (!meta) return [];
|
||||
if (!Array.isArray(meta.outDirs)) meta.outDirs = [];
|
||||
meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))];
|
||||
return meta.outDirs;
|
||||
}
|
||||
|
||||
function markConveyorDirection(from, to) {
|
||||
const dir = directionNameBetweenCells(from, to);
|
||||
if (!dir) return false;
|
||||
const fromKey = key(from.col, from.row);
|
||||
const toKey = key(to.col, to.row);
|
||||
const fromMeta = game.conveyorMeta.get(fromKey);
|
||||
const toMeta = game.conveyorMeta.get(toKey);
|
||||
if (!fromMeta || !toMeta) return false;
|
||||
const outDirs = normalizeOutDirs(fromMeta);
|
||||
const changed = !outDirs.includes(dir) || fromMeta.dir !== dir || !toMeta.dir;
|
||||
if (!outDirs.includes(dir)) outDirs.push(dir);
|
||||
fromMeta.dir = dir;
|
||||
if (!toMeta.dir) toMeta.dir = dir;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function buildConveyorCell(cell, incomingDir = null) {
|
||||
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
|
||||
const { col, row } = cell;
|
||||
const k = key(col, row);
|
||||
if (game.conveyorTiles.has(k)) return { ok: false, exists: true };
|
||||
if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' };
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
|
||||
const cost = buildPrice('conveyor', game);
|
||||
if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash };
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
game.conveyorTiles.add(k);
|
||||
const quality = buildQualityPatch();
|
||||
game.conveyorMeta.set(k, {
|
||||
price: cost,
|
||||
builtSession: game.buildSession,
|
||||
uses: 0,
|
||||
durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier),
|
||||
maintenanceType: 'conveyor',
|
||||
dir: incomingDir || null,
|
||||
outDirs: [],
|
||||
...quality
|
||||
});
|
||||
game.selected = { type: 'conveyor', id: k };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
return { ok: true, cell, key: k };
|
||||
}
|
||||
|
||||
function handleConveyorDragCell(cell) {
|
||||
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||
const currentKey = key(cell.col, cell.row);
|
||||
const exists = game.conveyorTiles.has(currentKey);
|
||||
const previous = lastConveyorBuildCell;
|
||||
if (previous && previous.col === cell.col && previous.row === cell.row) return;
|
||||
|
||||
if (exists) {
|
||||
if (previous && markConveyorDirection(previous, cell)) {
|
||||
if (!conveyorDragRecordedDirectionEdit) {
|
||||
// Direction edits are intentionally free; record once per drag gesture so undo can restore a major reroute.
|
||||
record(game);
|
||||
conveyorDragRecordedDirectionEdit = true;
|
||||
}
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
game.selected = { type: 'conveyor', id: currentKey };
|
||||
lastConveyorBuildCell = { col: cell.col, row: cell.row };
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingDir = previous ? directionNameBetweenCells(previous, cell) : null;
|
||||
const result = buildConveyorCell(cell, incomingDir);
|
||||
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
|
||||
if (previous) markConveyorDirection(previous, cell);
|
||||
lastConveyorBuildCell = { col: cell.col, row: cell.row };
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
|
||||
function stepTowardCell(from, to) {
|
||||
if (!from || !to) return to;
|
||||
const dc = to.col - from.col;
|
||||
const dr = to.row - from.row;
|
||||
if (dc === 0 && dr === 0) return from;
|
||||
if (Math.abs(dc) >= Math.abs(dr)) return { col: from.col + Math.sign(dc), row: from.row };
|
||||
return { col: from.col, row: from.row + Math.sign(dr) };
|
||||
}
|
||||
|
||||
function beginConveyorDrag(cell) {
|
||||
conveyorDragRecordedDirectionEdit = false;
|
||||
lastConveyorBuildCell = null;
|
||||
handleConveyorDragCell(cell);
|
||||
}
|
||||
|
||||
function continueConveyorDrag(cell) {
|
||||
if (!cell) return;
|
||||
if (!lastConveyorBuildCell) return handleConveyorDragCell(cell);
|
||||
let cursor = lastConveyorBuildCell;
|
||||
let guard = 0;
|
||||
while ((cursor.col !== cell.col || cursor.row !== cell.row) && guard < 64) {
|
||||
const next = stepTowardCell(cursor, cell);
|
||||
handleConveyorDragCell(next);
|
||||
cursor = lastConveyorBuildCell || next;
|
||||
guard += 1;
|
||||
if (!lastConveyorBuildCell) break;
|
||||
}
|
||||
}
|
||||
|
||||
function endConveyorDrag() {
|
||||
lastConveyorBuildCell = null;
|
||||
conveyorDragRecordedDirectionEdit = false;
|
||||
}
|
||||
|
||||
function buildAtCell(cell) {
|
||||
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||
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', game);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
const k = key(col, row);
|
||||
game.conveyorTiles.add(k);
|
||||
const quality = buildQualityPatch();
|
||||
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), maintenanceType: 'conveyor', ...quality });
|
||||
game.selected = { type: 'conveyor', id: k };
|
||||
const result = buildConveyorCell(cell, null);
|
||||
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
|
||||
refreshRoutingAfterEdit(game);
|
||||
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.');
|
||||
|
|
@ -144,6 +339,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
|
||||
function buildScanner(col, row, kind) {
|
||||
if (kind === 'manual' && game.scanners.filter(s => s.kind === 'manual').length >= 8) return fail('Manual Scanner limit reached (8 max).');
|
||||
const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
|
|
@ -281,11 +477,88 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
ui.modal.classList.add('visible', 'equipment-popover');
|
||||
}
|
||||
|
||||
function formatKeyBinding(binding) {
|
||||
return binding?.label || binding?.key || '?';
|
||||
}
|
||||
|
||||
function bindingFromEvent(event) {
|
||||
const label = event.key === ' ' ? 'Space' : event.key.length === 1 ? event.key.toUpperCase() : event.key.replace(/^Arrow/, '');
|
||||
return { key: event.key.toLowerCase(), code: event.code, label };
|
||||
}
|
||||
|
||||
function keyConflict(scanner, side, binding) {
|
||||
for (const s of game.scanners.filter(item => item.kind === 'manual')) {
|
||||
for (const candidateSide of ['left', 'right']) {
|
||||
if (s.id === scanner.id && candidateSide === side) continue;
|
||||
const existing = s.keys?.[candidateSide];
|
||||
if (existing && (existing.code === binding.code || existing.key === binding.key)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showManualScannerMenu(scanner, message = '') {
|
||||
if (!scanner || scanner.kind !== 'manual') return;
|
||||
ui.modalTitle.textContent = `Manual Scanner #${scanner.id}`;
|
||||
const left = formatKeyBinding(scanner.keys?.left);
|
||||
const right = formatKeyBinding(scanner.keys?.right);
|
||||
const conflict = manualKeyConflicts(scanner);
|
||||
ui.modalBody.innerHTML = `
|
||||
<div class="equipment-menu-lines compact">
|
||||
<p>Left route key: <b>${left}</b></p>
|
||||
<p>Right route key: <b>${right}</b></p>
|
||||
<p>Role: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}</p>
|
||||
${message ? `<p class="cash-positive">${message}</p>` : ''}
|
||||
${conflict ? `<p class="cash-negative">${conflict}</p>` : ''}
|
||||
</div>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(modalButton('Set Left Key', () => captureManualKey(scanner, 'left'), 'facility-action warn'));
|
||||
ui.modalActions.appendChild(modalButton('Set Right Key', () => captureManualKey(scanner, 'right'), 'facility-action warn'));
|
||||
ui.modalActions.appendChild(modalButton('Close', hideModal, 'facility-action'));
|
||||
positionEquipmentPopover(scanner);
|
||||
ui.modal.classList.add('visible', 'equipment-popover');
|
||||
}
|
||||
|
||||
function manualKeyConflicts(scanner) {
|
||||
const own = [
|
||||
{ side: 'left', binding: scanner.keys?.left },
|
||||
{ side: 'right', binding: scanner.keys?.right }
|
||||
];
|
||||
if (own[0].binding && own[1].binding && (own[0].binding.code === own[1].binding.code || own[0].binding.key === own[1].binding.key)) return 'Left and right keys conflict.';
|
||||
for (const item of own) {
|
||||
if (!item.binding) continue;
|
||||
for (const other of game.scanners.filter(s => s.kind === 'manual' && s.id !== scanner.id)) {
|
||||
if (other.keys?.left && (other.keys.left.code === item.binding.code || other.keys.left.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} left.`;
|
||||
if (other.keys?.right && (other.keys.right.code === item.binding.code || other.keys.right.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} right.`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function captureManualKey(scanner, side) {
|
||||
ui.modalBody.innerHTML = `<div class="equipment-menu-lines compact"><p>Press a key for ${side.toUpperCase()} route.</p><p>Esc cancels.</p></div>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
const onKey = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
if (event.key === 'Escape') return showManualScannerMenu(scanner);
|
||||
const binding = bindingFromEvent(event);
|
||||
const conflict = keyConflict(scanner, side, binding);
|
||||
record(game);
|
||||
scanner.keys = scanner.keys || { left: null, right: null };
|
||||
scanner.keys[side] = binding;
|
||||
updatePanels();
|
||||
showManualScannerMenu(scanner, conflict ? `${binding.label} assigned, but another scanner already uses it.` : `${side.toUpperCase()} set to ${binding.label}.`);
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
}
|
||||
|
||||
function switchScannerRole() {
|
||||
const obj = selectedObject();
|
||||
if (!obj || obj.type !== 'scanner') return;
|
||||
if (obj.kind === 'auto') return showAutoScannerMenu(obj);
|
||||
updatePanels();
|
||||
return showManualScannerMenu(obj);
|
||||
}
|
||||
|
||||
function selectedPopoverWorldPoint(obj) {
|
||||
|
|
@ -300,8 +573,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
function positionEquipmentPopover(obj) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const p = selectedPopoverWorldPoint(obj);
|
||||
const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18;
|
||||
const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12;
|
||||
const scale = game.view?.scale || 1;
|
||||
const screenX = rect.left + ((p.x * scale + (game.view?.x || 0)) / canvas.width) * rect.width + 18;
|
||||
const screenY = rect.top + ((p.y * scale + (game.view?.y || 0)) / canvas.height) * rect.height - 12;
|
||||
const maxX = Math.max(12, window.innerWidth - 274);
|
||||
const maxY = Math.max(12, window.innerHeight - 220);
|
||||
ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`);
|
||||
|
|
@ -318,6 +592,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
|
||||
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
|
||||
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
|
||||
const saleReason = lastProtectedSaleReason(hit);
|
||||
if (saleReason) {
|
||||
|
|
@ -335,6 +610,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function setBuildTool(tool) {
|
||||
if (game.phase !== 'build') return;
|
||||
endConveyorDrag();
|
||||
game.buildTool = game.buildTool === tool ? null : tool;
|
||||
game.selected = null;
|
||||
game.multiSelected = [];
|
||||
|
|
@ -382,7 +658,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
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('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.');
|
||||
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))}`);
|
||||
|
|
@ -410,6 +686,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
return lines;
|
||||
}
|
||||
|
||||
function disconnectedWarningFor(obj) {
|
||||
if (!obj || game.phase !== 'build') return '';
|
||||
const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id);
|
||||
if (!match) return '';
|
||||
return match.message || 'Not connected to conveyor route';
|
||||
}
|
||||
|
||||
|
||||
function flavorText(obj) {
|
||||
if (!obj) return '';
|
||||
|
|
@ -432,8 +715,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
updateGroupDrag: selection.updateGroupDrag,
|
||||
finishGroupDrag: selection.finishGroupDrag,
|
||||
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
|
||||
selectedInfoLines, flavorText,
|
||||
removeSelected, switchScannerRole,
|
||||
selectedInfoLines, flavorText, disconnectedWarningFor,
|
||||
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
|
||||
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
|
||||
removeSelected, switchScannerRole, showManualScannerMenu,
|
||||
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
||||
isEquipmentCell, equipmentHitBoxes,
|
||||
routeFromFarmToScanner
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
|
||||
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
|
||||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { cellCenter, key, yen } from '../core/utils.js';
|
||||
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
|
||||
|
|
@ -11,193 +11,7 @@ const RARE_WEIGHT = CARD_BALANCE.rareWeight;
|
|||
const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45;
|
||||
const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize;
|
||||
|
||||
const DEFAULT_EFFECTS = Object.freeze({
|
||||
bearing: 0,
|
||||
legalWork: 0,
|
||||
fairiesFlatteryNext: 0,
|
||||
crowdedFarming: 0,
|
||||
extraEggOutlet: 0,
|
||||
hatchingFeed: 0,
|
||||
safetyCover: 0,
|
||||
recyclingSubsidy: 0,
|
||||
chemicalWeaponSubsidy: 0,
|
||||
preventiveMaintenance: 0,
|
||||
durabilityCoating: 0,
|
||||
laborExploitation: 0,
|
||||
usedMachineActive: false,
|
||||
rescueLoanCharges: 0,
|
||||
loans: []
|
||||
});
|
||||
|
||||
export const CARD_DEFS = [
|
||||
{
|
||||
id: 'upgradeEgg',
|
||||
title: 'EGG Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'eggFarm',
|
||||
description: 'Choose one EGG FARM on the map and raise it by 1 level.'
|
||||
},
|
||||
{
|
||||
id: 'upgradeAutoScanner',
|
||||
title: 'AUTO SCANNER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'autoScanner',
|
||||
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.'
|
||||
},
|
||||
{
|
||||
id: 'upgradeMixer',
|
||||
title: 'MIXER Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
target: 'mixer',
|
||||
description: 'Choose MIXER on the map and raise it by 1 level.'
|
||||
},
|
||||
{
|
||||
id: 'upgradeTruck',
|
||||
title: 'TRUCK Improvement',
|
||||
rarity: 'common',
|
||||
type: 'equipmentUpgrade',
|
||||
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 and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.'
|
||||
},
|
||||
{
|
||||
id: 'crowdedFarming',
|
||||
title: 'Crowded Farming Contract',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.'
|
||||
},
|
||||
{
|
||||
id: 'hatchingFeed',
|
||||
title: 'Hatching Feed',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'EGG production interval -4%. Poop rate +3%. No card limit.'
|
||||
},
|
||||
{
|
||||
id: 'safetyCover',
|
||||
title: 'Safety Cover',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Explosion damage -5%. No card limit.'
|
||||
},
|
||||
{
|
||||
id: 'preventiveMaintenance',
|
||||
title: 'Preventive Maintenance Manual',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment deterioration speed -5%. No card limit.'
|
||||
},
|
||||
{
|
||||
id: 'durabilityCoating',
|
||||
title: 'Durability Coating',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'All equipment maximum durability +3%. No card limit.'
|
||||
},
|
||||
{
|
||||
id: 'bearing',
|
||||
title: 'High-Quality Bearing',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Conveyor speed +7.5%.'
|
||||
},
|
||||
{
|
||||
id: 'extraEggOutlet',
|
||||
title: 'Extra Egg Outlet',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'EGG output ports +1. Each connected belt can receive eggs. Max 3 cards.'
|
||||
},
|
||||
{
|
||||
id: 'recyclingSubsidy',
|
||||
title: 'Recycling Subsidy',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.'
|
||||
},
|
||||
{
|
||||
id: 'dynamite',
|
||||
title: 'Dynamite',
|
||||
rarity: 'rare',
|
||||
type: 'cellAction',
|
||||
target: 'blockedCell',
|
||||
description: 'Remove any 3 blocked cells with a burst effect.'
|
||||
},
|
||||
{
|
||||
id: 'usedMachine',
|
||||
title: 'Used Machines',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.'
|
||||
},
|
||||
{
|
||||
id: 'newMachine',
|
||||
title: 'New Machines',
|
||||
rarity: 'common',
|
||||
type: 'instant',
|
||||
description: 'Cancel Used Machines and restore normal prices, refunds, and durability.'
|
||||
},
|
||||
{
|
||||
id: 'flattery',
|
||||
title: 'Flattery',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Next Fairies tribute -50%.'
|
||||
},
|
||||
{
|
||||
id: 'loan',
|
||||
title: 'Loan',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.'
|
||||
},
|
||||
{
|
||||
id: 'extraCards',
|
||||
title: 'Extra Cards',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'Draw 2 cards now and choose both.'
|
||||
},
|
||||
{
|
||||
id: 'legalWork',
|
||||
title: 'Legal Work',
|
||||
rarity: 'rare',
|
||||
type: 'instant',
|
||||
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.'
|
||||
},
|
||||
{
|
||||
id: 'chemicalWeaponSubsidy',
|
||||
title: 'Chemical Weapons Subsidy',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.'
|
||||
},
|
||||
{
|
||||
id: 'laborExploitation',
|
||||
title: 'Motivational Exploitation',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.'
|
||||
},
|
||||
{
|
||||
id: 'rescueLoan',
|
||||
title: 'Emergency Loan',
|
||||
rarity: 'ultraRare',
|
||||
type: 'instant',
|
||||
description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.'
|
||||
}
|
||||
];
|
||||
export const CARD_DEFS = CARD_DEFINITIONS;
|
||||
|
||||
function effectCount(game, id) {
|
||||
const effects = ensureCardState(game);
|
||||
|
|
@ -205,8 +19,8 @@ function effectCount(game, id) {
|
|||
}
|
||||
|
||||
export function ensureCardState(game) {
|
||||
if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] };
|
||||
for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) {
|
||||
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] };
|
||||
for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) {
|
||||
if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
|
||||
}
|
||||
if (game.cardEffects.flattery != null) {
|
||||
|
|
@ -226,12 +40,8 @@ export function eggProductionDelayMultiplier(game) {
|
|||
return crowded * feed;
|
||||
}
|
||||
|
||||
export function poopRateCardMultiplier(game) {
|
||||
return Math.pow(1.03, effectCount(game, 'hatchingFeed'));
|
||||
}
|
||||
|
||||
export function extraEggOutletCount(game) {
|
||||
return Math.min(3, effectCount(game, 'extraEggOutlet'));
|
||||
export function extraEggOutletCount(_game, farm = null) {
|
||||
return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0));
|
||||
}
|
||||
|
||||
export function conveyorSpeedForGame(game, conveyorKey = null) {
|
||||
|
|
@ -247,13 +57,6 @@ export function autoScannerCooldownSeconds(scanner, game = null) {
|
|||
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base;
|
||||
}
|
||||
|
||||
export function flatteryReductionForTurn(game, turn = game.turn) {
|
||||
const day = Math.max(1, Math.floor(Number(turn) || 1));
|
||||
const base = Math.ceil(day * 10);
|
||||
const charges = Math.max(0, ensureCardState(game).fairiesFlatteryNext || 0);
|
||||
return charges > 0 ? Math.ceil(base * (1 - Math.pow(0.5, charges))) : 0;
|
||||
}
|
||||
|
||||
export function rerollCost(game) {
|
||||
const draft = game.cardDraft || { rerolls: 0 };
|
||||
const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1);
|
||||
|
|
@ -264,11 +67,48 @@ export function cardById(id) {
|
|||
return CARD_DEFS.find(card => card.id === id) || null;
|
||||
}
|
||||
|
||||
export function debugGrantCard(game, cardOrId) {
|
||||
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
|
||||
ensureCardState(game);
|
||||
if (!card || card.type === 'dud') return { ok: false, reason: 'Card not found.' };
|
||||
if (card.id === 'extraCards') {
|
||||
game.cardDraft = game.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
|
||||
game.cardDraft.choices.push(...dealCards(game, 2));
|
||||
return { ok: true, reason: `${card.title} added draft cards.` };
|
||||
}
|
||||
if (card.type === 'instant') {
|
||||
applyInstantCard(game, card);
|
||||
return { ok: true, reason: `${card.title} granted.` };
|
||||
}
|
||||
if (card.type === 'equipmentUpgrade') {
|
||||
const target = targetsForCard(game, card)[0];
|
||||
if (!target) return { ok: false, reason: `No valid target for ${card.title}.` };
|
||||
applyEquipmentUpgrade(game, target);
|
||||
return { ok: true, reason: `${card.title} applied.` };
|
||||
}
|
||||
if (card.type === 'cellAction' && card.target === 'blockedCell') {
|
||||
const targets = targetsForCard(game, card).slice(0, 3);
|
||||
if (!targets.length) return { ok: false, reason: 'No blocked cells to remove.' };
|
||||
for (const target of targets) {
|
||||
game.blockedCells.delete(key(target.col, target.row));
|
||||
const c = cellCenter(target.col, target.row);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
}
|
||||
refreshRoutingAfterEdit(game);
|
||||
return { ok: true, reason: `${card.title} removed ${targets.length} blocked cells.` };
|
||||
}
|
||||
return { ok: false, reason: `${card.title} cannot be debug-granted.` };
|
||||
}
|
||||
|
||||
function targetLabel(game, target) {
|
||||
if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`;
|
||||
if (target.type === 'eggFarm') {
|
||||
return `EGG #${target.id} L${target.level} -> L${target.level + 1}`;
|
||||
}
|
||||
if (target.type === 'eggOutlet') {
|
||||
const held = Math.max(0, Number(target.farm?.extraEggOutlet) || 0);
|
||||
return `EGG #${target.id} OUTLET ${1 + held} -> ${2 + held}`;
|
||||
}
|
||||
if (target.type === 'scanner') {
|
||||
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
|
||||
}
|
||||
|
|
@ -311,6 +151,9 @@ export function targetsForCard(game, cardOrId) {
|
|||
if (!card) return [];
|
||||
if (card.target === 'blockedCell') return blockedCellTargets(game);
|
||||
if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4);
|
||||
if (card.target === 'eggOutlet') return game.eggFarms
|
||||
.filter(f => Math.max(0, Number(f.extraEggOutlet) || 0) < 3)
|
||||
.map(f => ({ type: 'eggOutlet', id: f.id, col: f.col, row: f.row, farm: f }));
|
||||
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] : [];
|
||||
|
|
@ -321,16 +164,26 @@ export function targetsForCard(game, cardOrId) {
|
|||
function cardAtCap(game, card) {
|
||||
const effects = ensureCardState(game);
|
||||
if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10;
|
||||
if (card.id === 'extraEggOutlet') return (effects.extraEggOutlet || 0) >= 3;
|
||||
if (card.id === 'recyclingSubsidy') return (effects.recyclingSubsidy || 0) >= 3;
|
||||
if (card.id === 'dudFilter') {
|
||||
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
|
||||
const step = Math.max(0.0001, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05));
|
||||
return (effects.dudFilter || 0) >= Math.ceil(base / step);
|
||||
}
|
||||
if (card.id === 'laborExploitation') return !!effects.laborExploitation;
|
||||
if (card.id === 'usedMachine') return !!effects.usedMachineActive;
|
||||
if (card.id === 'newMachine') return !effects.usedMachineActive;
|
||||
return false;
|
||||
}
|
||||
|
||||
function repairmanCardVisible(game) {
|
||||
const r = game.repairman || {};
|
||||
return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0);
|
||||
}
|
||||
|
||||
function availableCards(game) {
|
||||
return CARD_DEFS.filter(card => {
|
||||
if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false;
|
||||
if (cardAtCap(game, card)) return false;
|
||||
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
|
||||
return true;
|
||||
|
|
@ -365,11 +218,18 @@ function dudCard() {
|
|||
};
|
||||
}
|
||||
|
||||
function applyDudsToChoices(choices) {
|
||||
function dudChanceForGame(game) {
|
||||
const effects = ensureCardState(game);
|
||||
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
|
||||
const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0));
|
||||
return Math.max(0, base - step * Math.max(0, effects.dudFilter || 0));
|
||||
}
|
||||
|
||||
function applyDudsToChoices(game, 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));
|
||||
const chance = dudChanceForGame(game);
|
||||
for (let i = 0; i < result.length && duds < maxDuds; i += 1) {
|
||||
if (Math.random() < chance) {
|
||||
result[i] = dudCard();
|
||||
|
|
@ -392,13 +252,14 @@ 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 applyDudsToChoices(choices);
|
||||
return applyDudsToChoices(game, choices);
|
||||
}
|
||||
|
||||
function targetKey(target) {
|
||||
if (!target) return '';
|
||||
if (target.type === 'blockedCell') return `blockedCell:${target.id}`;
|
||||
if (target.type === 'eggFarm') return `eggFarm:${target.id}`;
|
||||
if (target.type === 'eggOutlet') return `eggOutlet:${target.id}`;
|
||||
if (target.type === 'scanner') return `scanner:${target.id}`;
|
||||
if (target.type === 'facility') return `facility:${target.id}`;
|
||||
return `${target.type}:${target.id}`;
|
||||
|
|
@ -410,7 +271,7 @@ function boundsForTarget(target) {
|
|||
const c = cellCenter(target.col, target.row);
|
||||
return { x: c.x - GRID.cell * 0.48, y: c.y - GRID.cell * 0.48, w: GRID.cell * 0.96, h: GRID.cell * 0.96, cx: c.x, cy: c.y };
|
||||
}
|
||||
if (target.type === 'eggFarm') {
|
||||
if (target.type === 'eggFarm' || target.type === 'eggOutlet') {
|
||||
const c = cellCenter(target.col, target.row);
|
||||
return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
|
||||
}
|
||||
|
|
@ -443,6 +304,15 @@ function targetAtPoint(game, p) {
|
|||
}
|
||||
|
||||
function applyEquipmentUpgrade(game, target) {
|
||||
if (target.type === 'eggOutlet') {
|
||||
const farm = target.farm || game.eggFarms.find(f => f.id === target.id);
|
||||
if (!farm) return;
|
||||
farm.extraEggOutlet = Math.min(3, Math.max(0, Number(farm.extraEggOutlet) || 0) + 1);
|
||||
const c = cellCenter(farm.col, farm.row);
|
||||
floating(game, c.x, c.y - 30, `OUTLET ${1 + farm.extraEggOutlet}`, THEME.green);
|
||||
refreshRoutingAfterEdit(game);
|
||||
return;
|
||||
}
|
||||
target.level = (target.level || 1) + 1;
|
||||
if (target.type === 'eggFarm') {
|
||||
target.nextSpawn = nextSpawnDelay(target);
|
||||
|
|
@ -467,12 +337,12 @@ function applyInstantCard(game, card) {
|
|||
if (card.id === 'bearing') inc('bearing');
|
||||
if (card.id === 'legalWork') inc('legalWork');
|
||||
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1);
|
||||
if (card.id === 'extraEggOutlet') effects.extraEggOutlet = Math.min(3, (effects.extraEggOutlet || 0) + 1);
|
||||
if (card.id === 'hatchingFeed') inc('hatchingFeed');
|
||||
if (card.id === 'safetyCover') inc('safetyCover');
|
||||
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
|
||||
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
|
||||
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
|
||||
if (card.id === 'dudFilter') inc('dudFilter');
|
||||
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
|
||||
if (card.id === 'flattery') inc('fairiesFlatteryNext');
|
||||
if (card.id === 'usedMachine') effects.usedMachineActive = true;
|
||||
|
|
@ -480,13 +350,7 @@ function applyInstantCard(game, card) {
|
|||
if (card.id === 'laborExploitation') effects.laborExploitation = 1;
|
||||
if (card.id === 'rescueLoan') {
|
||||
inc('rescueLoanCharges');
|
||||
if (game.cash < 0) {
|
||||
effects.rescueLoanCharges = Math.max(0, (effects.rescueLoanCharges || 0) - 1);
|
||||
applyRevenue(game, 500);
|
||||
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, '+¥500 RESCUE', THEME.green);
|
||||
} else {
|
||||
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
|
||||
}
|
||||
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
|
||||
}
|
||||
if (card.id === 'loan') {
|
||||
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
|
||||
|
|
@ -497,11 +361,6 @@ function applyInstantCard(game, card) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatMultiplier(value) {
|
||||
const rounded = Math.round(Number(value || 0) * 100) / 100;
|
||||
return `x${rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')}`;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
|
@ -554,12 +413,24 @@ function cardDescription(game, card) {
|
|||
const next = Math.min(10, (e.crowdedFarming || 0) + 1);
|
||||
return `Held ${e.crowdedFarming || 0}/10. After pick: EGG interval ${formatPercent(Math.max(0.5, 1 - 0.05 * next))}, deterioration ${formatPercent(Math.min(1.4, 1 + 0.04 * next))}.`;
|
||||
}
|
||||
if (card.id === 'extraEggOutlet') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`;
|
||||
if (card.id === 'extraEggOutlet') {
|
||||
const targets = targetsForCard(game, card);
|
||||
const held = Math.max(0, Number(targets[0]?.farm?.extraEggOutlet) || 0);
|
||||
return `Choose one EGG. Output ports ${1 + held} -> ${2 + held}. Max 4 ports per EGG.`;
|
||||
}
|
||||
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
|
||||
if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`;
|
||||
if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`;
|
||||
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`;
|
||||
if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`;
|
||||
if (card.id === 'dudFilter') {
|
||||
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
|
||||
const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05));
|
||||
const held = Math.max(0, e.dudFilter || 0);
|
||||
const before = Math.max(0, base - step * held);
|
||||
const after = Math.max(0, base - step * (held + 1));
|
||||
return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`;
|
||||
}
|
||||
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
|
||||
if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`;
|
||||
if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';
|
||||
|
|
@ -732,7 +603,8 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
|
|||
const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'ultraRare' ? 'ultra-rare' : (card.rarity === 'rare' ? 'rare' : 'common'));
|
||||
b.className = `card-choice ${kind}`;
|
||||
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'));
|
||||
b.innerHTML = `<strong>${card.title}</strong><span>${rarity}</span><small>${cardDescription(game, card)}</small>`;
|
||||
const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5);
|
||||
b.innerHTML = `<strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small>`;
|
||||
b.addEventListener('click', () => chooseCard(card, b));
|
||||
b.dataset.index = String(index);
|
||||
return b;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { GRID, THEME } from '../core/config.js';
|
||||
import { DIRS, GRID, THEME } from '../core/config.js';
|
||||
import { createChick } from '../core/entities.js';
|
||||
import { nextSpawnDelay } from '../core/state.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js';
|
||||
import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js';
|
||||
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
|
||||
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
|
||||
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } 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';
|
||||
|
|
@ -17,35 +17,23 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
|
||||
function spawnChick(farm) {
|
||||
const farmCenter = cellCenter(farm.col, farm.row);
|
||||
const maxOutputs = 1 + extraEggOutletCount(game);
|
||||
const routes = [];
|
||||
const usedStarts = new Set();
|
||||
const maxAttempts = Math.max(maxOutputs * 5, 5);
|
||||
for (let attempt = 0; attempt < maxAttempts && routes.length < maxOutputs; attempt += 1) {
|
||||
const data = routeFromFarmToScanner(game, farm, true);
|
||||
if (!data) break;
|
||||
const start = data.route?.[0];
|
||||
if (!start) continue;
|
||||
const startKey = `${Math.round(start.x)}:${Math.round(start.y)}`;
|
||||
if (usedStarts.has(startKey) && routes.length > 0) continue;
|
||||
usedStarts.add(startKey);
|
||||
routes.push(data);
|
||||
}
|
||||
if (!routes.length) {
|
||||
const maxOutputs = 1 + extraEggOutletCount(game, farm);
|
||||
const starts = farmOutputStarts(farm, maxOutputs);
|
||||
if (!starts.length) {
|
||||
suppressUnconnectedFarmSpawn(farm, farmCenter);
|
||||
return true;
|
||||
}
|
||||
let spawned = 0;
|
||||
let blocked = 0;
|
||||
for (const data of routes) {
|
||||
const start = data.route[0];
|
||||
for (const startCell of starts) {
|
||||
const start = cellCenter(startCell.col, startCell.row);
|
||||
const blocker = spawnTileBlocker(start);
|
||||
if (blocker) {
|
||||
blocker.stoppedTimer = 0.85;
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const chick = createChick(game, data);
|
||||
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
|
||||
if (chick.sex === 'poop') countPoopSpawned(game);
|
||||
game.chicks.push(chick);
|
||||
recordFarmProduction(game, farm);
|
||||
|
|
@ -66,6 +54,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
}
|
||||
|
||||
function farmOutputStarts(farm, limit) {
|
||||
const starts = DIRS
|
||||
.map(d => ({ col: farm.col + d.dc, row: farm.row + d.dr }))
|
||||
.filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
|
||||
const scored = starts.map(p => ({ p, blocked: targetBlockedByChick(null, cellCenter(p.col, p.row)) ? 1 : 0, roll: Math.random() }));
|
||||
scored.sort((a, b) => a.blocked - b.blocked || a.roll - b.roll);
|
||||
return scored.slice(0, Math.max(1, limit || 1)).map(item => item.p);
|
||||
}
|
||||
|
||||
|
||||
function spawnTileBlocker(start) {
|
||||
return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null;
|
||||
|
|
@ -138,32 +135,132 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
moveAlongRoute(chick, dt);
|
||||
if (chick.route && chick.targetIndex < chick.route.length) return;
|
||||
if (chick.stage === 'input' || chick.stage === 'toScanner') {
|
||||
const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId);
|
||||
if (!scanner) {
|
||||
removeChick(index, 'NO SCANNER');
|
||||
return;
|
||||
}
|
||||
enqueueChick(chick, scanner, chick.route);
|
||||
if (chick.pendingScannerId) {
|
||||
const scanner = scannerById(game, chick.pendingScannerId);
|
||||
chick.pendingScannerId = null;
|
||||
if (!scanner) removeChick(index, 'NO SCANNER');
|
||||
else enqueueChick(chick, scanner, chick.route);
|
||||
return;
|
||||
}
|
||||
if (chick.stage === 'toMixer') resolveMixer(index);
|
||||
else if (chick.stage === 'toTruck') resolveTruck(index);
|
||||
else if (chick.stage === 'toTrash') resolveTrash(index);
|
||||
advanceByCurrentConveyor(chick, index);
|
||||
}
|
||||
|
||||
function blockedByFrontChick(chick) {
|
||||
if (!chick.route || chick.targetIndex >= chick.route.length) return false;
|
||||
const next = chick.route[chick.targetIndex];
|
||||
return targetBlockedByChick(chick, next);
|
||||
}
|
||||
|
||||
function targetBlockedByChick(chick, target) {
|
||||
if (!target) return false;
|
||||
for (const other of game.chicks) {
|
||||
if (other.id === chick.id || other.stage === 'flying') continue;
|
||||
if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true;
|
||||
if (chick && other.id === chick.id) continue;
|
||||
if (other.stage === 'flying') continue;
|
||||
if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function routeProgress(chick) {
|
||||
return chick.targetIndex || 0;
|
||||
function appendPoint(points, p) {
|
||||
if (!p) return;
|
||||
const last = points[points.length - 1];
|
||||
if (!last || Math.hypot(last.x - p.x, last.y - p.y) > 1) points.push({ x: p.x, y: p.y });
|
||||
}
|
||||
|
||||
function conveyorOutDirNames(cellKey) {
|
||||
const meta = game.conveyorMeta?.get?.(cellKey) || {};
|
||||
const dirs = [];
|
||||
if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs);
|
||||
if (meta.dir) dirs.push(meta.dir);
|
||||
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
|
||||
}
|
||||
|
||||
function dirByName(name) {
|
||||
return DIRS.find(d => d.name === name) || null;
|
||||
}
|
||||
|
||||
function scannerAtBodyCell(col, row) {
|
||||
return game.scanners.find(scanner => scanner.col === col && scanner.row === row) || null;
|
||||
}
|
||||
|
||||
function scannerReceivingFrom(cell, dirName) {
|
||||
const d = dirByName(dirName);
|
||||
if (!d) return null;
|
||||
const scanner = scannerAtBodyCell(cell.col + d.dc, cell.row + d.dr);
|
||||
if (!scanner) return null;
|
||||
const input = scannerConnector(scanner, 'inputA');
|
||||
return input && input.col === cell.col && input.row === cell.row ? scanner : null;
|
||||
}
|
||||
|
||||
function facilityAtEntryCell(cell) {
|
||||
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
|
||||
}
|
||||
|
||||
function dirExitsToFacility(facility, dirName) {
|
||||
return (facility.side === 'left' && dirName === 'left')
|
||||
|| (facility.side === 'right' && dirName === 'right')
|
||||
|| (facility.side === 'top' && dirName === 'up')
|
||||
|| (facility.side === 'bottom' && dirName === 'down');
|
||||
}
|
||||
|
||||
function movementOptionFromDir(cell, dirName) {
|
||||
const d = dirByName(dirName);
|
||||
if (!d) return null;
|
||||
const next = { col: cell.col + d.dc, row: cell.row + d.dr };
|
||||
if (inGrid(next.col, next.row) && game.conveyorTiles.has(key(next.col, next.row))) {
|
||||
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
|
||||
}
|
||||
const scanner = scannerReceivingFrom(cell, dirName);
|
||||
if (scanner) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
|
||||
const facilityPair = facilityAtEntryCell(cell);
|
||||
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
|
||||
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickMovementOption(chick, cell, dirs) {
|
||||
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
|
||||
if (!candidates.length) return null;
|
||||
const moving = candidates.filter(opt => opt.type !== 'facility');
|
||||
const empty = moving.filter(opt => !targetBlockedByChick(chick, opt.target));
|
||||
const pool = empty.length ? empty : candidates.filter(opt => opt.type === 'facility');
|
||||
if (!pool.length) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
function setSingleSegmentRoute(chick, target) {
|
||||
const route = [];
|
||||
appendPoint(route, { x: chick.x, y: chick.y });
|
||||
appendPoint(route, target);
|
||||
chick.route = route.length ? route : [{ x: chick.x, y: chick.y }];
|
||||
chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length;
|
||||
}
|
||||
|
||||
function advanceByCurrentConveyor(chick, index) {
|
||||
const cell = pointToCell(chick.x, chick.y);
|
||||
if (!cell) return removeChick(index, 'OFF GRID');
|
||||
const currentKey = key(cell.col, cell.row);
|
||||
if (!game.conveyorTiles.has(currentKey)) return removeChick(index, 'OFF BELT');
|
||||
const dirs = conveyorOutDirNames(currentKey);
|
||||
if (!dirs.length) {
|
||||
chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
const option = pickMovementOption(chick, cell, dirs);
|
||||
if (!option) {
|
||||
chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
if (option.type === 'facility') {
|
||||
if (option.facilityId === 'mixer') resolveMixer(index);
|
||||
else if (option.facilityId === 'truck') resolveTruck(index);
|
||||
else if (option.facilityId === 'trash') resolveTrash(index);
|
||||
else removeChick(index, 'DONE');
|
||||
return;
|
||||
}
|
||||
if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id;
|
||||
setSingleSegmentRoute(chick, option.target);
|
||||
}
|
||||
|
||||
function conveyorKeyFromPoint(p) {
|
||||
|
|
@ -284,7 +381,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
function sortSlot(slot, side) {
|
||||
if (game.phase !== 'running') return;
|
||||
const scanner = scannerBySlot(game, slot);
|
||||
if (!scanner) return;
|
||||
sortScanner(scanner, side);
|
||||
}
|
||||
|
||||
function sortScanner(scanner, side) {
|
||||
if (game.phase !== 'running' || !scanner || scanner.kind !== 'manual') return;
|
||||
scanner.keyPressTime = 0.18;
|
||||
scanner.keyPressSide = side;
|
||||
if (!scanner.queue.length) return;
|
||||
|
|
@ -292,25 +393,100 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
if (index >= 0) sortChickByIndex(index, side, false);
|
||||
}
|
||||
|
||||
function manualScanSucceeded(scanner, chick, side) {
|
||||
return scanner.kind === 'manual' && autoSideFor(scanner, chick) === side;
|
||||
}
|
||||
|
||||
function resetManualCombo(scanner) {
|
||||
const previous = game.manualCombo?.count || 0;
|
||||
if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 };
|
||||
game.manualCombo.count = 0;
|
||||
game.manualCombo.lastBonus = 0;
|
||||
game.stats.manualComboFailure = (game.stats.manualComboFailure || 0) + 1;
|
||||
game.totals.manualComboFailure = (game.totals.manualComboFailure || 0) + 1;
|
||||
if (previous >= 10) {
|
||||
const c = scannerCenter(scanner);
|
||||
shake(game, 10, 0.25);
|
||||
floating(game, c.x, c.y - 36, 'COMBO RESET', THEME.danger);
|
||||
}
|
||||
}
|
||||
|
||||
function awardManualCombo(scanner) {
|
||||
if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 };
|
||||
game.manualCombo.count = Math.max(0, game.manualCombo.count || 0) + 1;
|
||||
game.stats.manualComboSuccess = (game.stats.manualComboSuccess || 0) + 1;
|
||||
game.totals.manualComboSuccess = (game.totals.manualComboSuccess || 0) + 1;
|
||||
const combo = game.manualCombo.count;
|
||||
game.manualCombo.lastBonus = 0;
|
||||
if (combo < 10) return 0;
|
||||
const trials = Math.max(1, Math.floor(combo / 10));
|
||||
const manualScannerCount = game.scanners.filter(s => s.kind === 'manual').length;
|
||||
const chance = Math.min(1, (20 + manualScannerCount * 10) / 100);
|
||||
let successfulTrials = 0;
|
||||
for (let i = 0; i < trials; i += 1) {
|
||||
if (Math.random() < chance) successfulTrials += 1;
|
||||
}
|
||||
const bonus = Math.floor(successfulTrials * Math.max(1, game.turn || 1) * 5 / 4);
|
||||
if (bonus > 0) {
|
||||
applyRevenue(game, bonus);
|
||||
game.stats.manualComboBonus = (game.stats.manualComboBonus || 0) + bonus;
|
||||
game.totals.manualComboBonus = (game.totals.manualComboBonus || 0) + bonus;
|
||||
game.manualCombo.lastBonus = bonus;
|
||||
const c = scannerCenter(scanner);
|
||||
floating(game, c.x, c.y - 42, `COMBO +${yen(bonus)}`, THEME.green);
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
function scannerOutputStartCell(scanner, side) {
|
||||
const connector = scannerConnector(scanner, side);
|
||||
if (!connector) return null;
|
||||
const connectorKey = key(connector.col, connector.row);
|
||||
if (game.conveyorTiles.has(connectorKey)) return { ...connector };
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const keys = (graph.scannerPorts.get(scanner.id)?.[side]?.keys || []).filter(Boolean);
|
||||
if (!keys.length) return null;
|
||||
const parsed = keys.map(parseKey);
|
||||
const exact = parsed.find(p => p.col === connector.col && p.row === connector.row);
|
||||
return exact || parsed[0] || null;
|
||||
}
|
||||
|
||||
function scannerReleaseRoute(chick, scanner, side, startCell) {
|
||||
const route = [];
|
||||
appendPoint(route, { x: chick.x, y: chick.y });
|
||||
const connector = scannerConnector(scanner, side);
|
||||
if (connector) appendPoint(route, cellCenter(connector.col, connector.row));
|
||||
appendPoint(route, cellCenter(startCell.col, startCell.row));
|
||||
return route;
|
||||
}
|
||||
|
||||
function sortChickByIndex(index, side, auto) {
|
||||
const chick = game.chicks[index];
|
||||
if (!chick || chick.stage !== 'queued') return false;
|
||||
const scanner = scannerById(game, chick.scannerId);
|
||||
if (!scanner) return false;
|
||||
const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true);
|
||||
if (!plan) {
|
||||
const startCell = scannerOutputStartCell(scanner, side);
|
||||
if (!startCell) {
|
||||
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
|
||||
floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger);
|
||||
return false;
|
||||
}
|
||||
if (!auto && scanner.kind === 'manual') {
|
||||
if (manualScanSucceeded(scanner, chick, side)) awardManualCombo(scanner);
|
||||
else resetManualCombo(scanner);
|
||||
}
|
||||
scanner.queue = scanner.queue.filter(id => id !== chick.id);
|
||||
chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`;
|
||||
chick.route = plan.route;
|
||||
chick.targetIndex = 1;
|
||||
chick.nextScannerId = plan.nextScannerId || null;
|
||||
chick.stage = 'belt';
|
||||
chick.route = scannerReleaseRoute(chick, scanner, side, startCell);
|
||||
chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length;
|
||||
chick.scannerId = null;
|
||||
chick.nextScannerId = null;
|
||||
chick.pendingScannerId = null;
|
||||
chick.queueRoute = null;
|
||||
chick.queueIndex = -1;
|
||||
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination));
|
||||
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination));
|
||||
const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink);
|
||||
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor);
|
||||
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -502,25 +678,12 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
|
||||
}
|
||||
|
||||
function explodeRoute(route, extras, label) {
|
||||
const victims = game.chicks.filter(ch => route.some(p => Math.hypot(ch.x - p.x, ch.y - p.y) < GRID.cell));
|
||||
game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
|
||||
for (const v of victims) flyingDebris(game, v.sex, v.x, v.y);
|
||||
for (const e of extras) flyingDebris(game, e.sex, e.x, e.y);
|
||||
for (const p of route) {
|
||||
shockwave(game, p.x, p.y, THEME.danger);
|
||||
sparkBurst(game, p.x, p.y, 4);
|
||||
smokeBurst(game, p.x, p.y, 3);
|
||||
}
|
||||
shake(game, 18, .75);
|
||||
if (route[0]) floating(game, route[0].x, route[0].y - 18, label, THEME.danger);
|
||||
}
|
||||
|
||||
return {
|
||||
activeQueuedChick,
|
||||
currentSpawnDelay,
|
||||
sortSlot,
|
||||
updateCongestion,
|
||||
sortScanner,
|
||||
updateRunning
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TU
|
|||
import { getSpawnRange } from '../core/state.js';
|
||||
import { applyRevenue } from './economy.js';
|
||||
|
||||
export const CONTRACT_TARGETS = [
|
||||
const CONTRACT_TARGETS = [
|
||||
{
|
||||
target: 'female',
|
||||
title: 'Premium Female Shipment',
|
||||
|
|
@ -107,10 +107,6 @@ export function isTargetTruckCargo(game, sex) {
|
|||
return truckTarget(game) === sex;
|
||||
}
|
||||
|
||||
export function isPoopSoilActive(game) {
|
||||
return truckTarget(game) !== 'poop';
|
||||
}
|
||||
|
||||
export function shouldFineMaleTruck(game) {
|
||||
return truckTarget(game) !== 'male';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,10 +82,6 @@ export function shredderBonusChance(gameOrCount) {
|
|||
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);
|
||||
|
|
@ -117,10 +113,6 @@ export function zundaTaxInfo(cash, game = null) {
|
|||
return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork };
|
||||
}
|
||||
|
||||
export function zundaTaxForCash(cash, game = null) {
|
||||
return zundaTaxInfo(cash, game).tax;
|
||||
}
|
||||
|
||||
export function fairiesTributeInfo(game, turn = game.turn) {
|
||||
const day = Math.max(1, Math.floor(Number(turn) || 1));
|
||||
const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay);
|
||||
|
|
@ -146,18 +138,7 @@ export function explosionDamageForPrice(price, game = null) {
|
|||
return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game)));
|
||||
}
|
||||
|
||||
export function upgradeCostFor(obj) {
|
||||
if (!obj) return null;
|
||||
if (obj.type === 'eggFarm') return FACILITY_DEFS.eggFarm.upgradeCosts[obj.level] || null;
|
||||
if (obj.type === 'facility' && INCOME_FACILITY_IDS.includes(obj.id)) {
|
||||
const level = obj.level || 1;
|
||||
if (level <= 1) return 300;
|
||||
return Math.ceil(900 * Math.pow(1.65, level - 2));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function positivePayout(_game, amount) {
|
||||
function positivePayout(_game, amount) {
|
||||
return Math.max(0, Math.ceil(amount));
|
||||
}
|
||||
|
||||
|
|
@ -243,16 +224,21 @@ export function settleTruckRevenue(game) {
|
|||
const adjusted = positivePayout(game, base);
|
||||
game.stats.pendingTruckRevenue = adjusted;
|
||||
game.stats.truckRevenue = adjusted;
|
||||
if (target === 'poop') game.stats.poopShipmentIncome = adjusted;
|
||||
else game.stats.chickShipmentIncome = adjusted;
|
||||
if (adjusted > 0) applyRevenue(game, adjusted);
|
||||
return { base, adjusted, target, targetCount, unitPrice };
|
||||
}
|
||||
|
||||
export function collectZundaTax(game, basisCash = game.cash) {
|
||||
const info = zundaTaxInfo(basisCash, game);
|
||||
export function collectZundaTax(game, basisProfit = 0) {
|
||||
const info = zundaTaxInfo(basisProfit, game);
|
||||
if (info.tax <= 0) return info;
|
||||
game.stats.zundaTax += info.tax;
|
||||
game.totals.zundaTax += info.tax;
|
||||
applyPenalty(game, info.tax);
|
||||
// ZUNDA TAX is settled between days. It reduces cash and lifetime totals,
|
||||
// but it is intentionally excluded from the next day's NET display.
|
||||
game.totals.zundaTax = (game.totals.zundaTax || 0) + info.tax;
|
||||
game.totals.penalty = (game.totals.penalty || 0) + info.tax;
|
||||
game.totals.profit = (game.totals.profit || 0) - info.tax;
|
||||
game.cash -= info.tax;
|
||||
return info;
|
||||
}
|
||||
|
||||
|
|
@ -269,21 +255,6 @@ export function collectFairiesTribute(game, turn = game.turn) {
|
|||
return info;
|
||||
}
|
||||
|
||||
export function applyRescueLoanIfNeeded(game) {
|
||||
if (!game?.cardEffects || game.cash >= 0) return { amount: 0, used: 0, remaining: Math.max(0, game?.cardEffects?.rescueLoanCharges || 0) };
|
||||
let used = 0;
|
||||
let amount = 0;
|
||||
while (game.cash < 0 && (game.cardEffects.rescueLoanCharges || 0) > 0) {
|
||||
game.cardEffects.rescueLoanCharges -= 1;
|
||||
used += 1;
|
||||
amount += 500;
|
||||
game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500;
|
||||
game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500;
|
||||
applyRevenue(game, 500);
|
||||
}
|
||||
return { amount, used, remaining: Math.max(0, game.cardEffects.rescueLoanCharges || 0) };
|
||||
}
|
||||
|
||||
export function collectChemicalWeaponSubsidy(game) {
|
||||
const cards = effectCount(game, 'chemicalWeaponSubsidy');
|
||||
const poopTruck = Math.max(0, game?.stats?.poopTruck || 0);
|
||||
|
|
@ -337,6 +308,3 @@ export function finalScore(game) {
|
|||
));
|
||||
return { score, factoryValue: value, base, dailyEarned, dayPenalty, correctBonus, explosionPenalty, days };
|
||||
}
|
||||
|
||||
export const MIXER_PRICE = ECONOMY.income.mixer;
|
||||
export const TRUCK_PRICE = ECONOMY.income.truck;
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { BALANCE } from '../core/balance.js';
|
||||
import { GRID } from '../core/config.js';
|
||||
export function cleanScanner(scanner) {
|
||||
return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 };
|
||||
return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 };
|
||||
}
|
||||
export function snapshot(game) {
|
||||
return JSON.stringify({
|
||||
|
|
@ -7,6 +9,16 @@ export function snapshot(game) {
|
|||
stats: game.stats,
|
||||
totals: game.totals,
|
||||
nextId: game.nextId,
|
||||
gridX: GRID.x,
|
||||
gridY: GRID.y,
|
||||
gridCols: game.gridCols || GRID.cols,
|
||||
gridRows: game.gridRows || GRID.rows,
|
||||
gridExpansionPurchases: game.gridExpansionPurchases || 0,
|
||||
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { right: 0, up: 0 },
|
||||
gridChunkOriginY: game.gridChunkOriginY || 0,
|
||||
ownedCells: [...(game.ownedCells || [])],
|
||||
ownedChunks: [...(game.ownedChunks || [])],
|
||||
blockedCells: [...(game.blockedCells || [])],
|
||||
selected: game.selected,
|
||||
multiSelected: game.multiSelected || [],
|
||||
buildTool: game.buildTool,
|
||||
|
|
@ -28,10 +40,24 @@ export function restore(game, text) {
|
|||
game.stats = data.stats;
|
||||
game.totals = data.totals;
|
||||
game.nextId = data.nextId;
|
||||
GRID.x = data.gridX ?? BALANCE.grid.x;
|
||||
GRID.y = data.gridY ?? BALANCE.grid.y;
|
||||
GRID.cols = data.gridCols || BALANCE.grid.cols;
|
||||
GRID.rows = data.gridRows || BALANCE.grid.rows;
|
||||
game.gridCols = GRID.cols;
|
||||
game.gridRows = GRID.rows;
|
||||
game.gridExpansionPurchases = data.gridExpansionPurchases || 0;
|
||||
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { right: 0, up: 0 };
|
||||
game.gridChunkOriginY = data.gridChunkOriginY || 0;
|
||||
game.ownedCells = new Set(data.ownedCells || []);
|
||||
game.ownedChunks = new Set(data.ownedChunks || []);
|
||||
game.gridOwnedCells = game.ownedCells.size || (GRID.cols * GRID.rows);
|
||||
game.blockedCells = new Set(data.blockedCells || []);
|
||||
game.selected = data.selected;
|
||||
game.multiSelected = data.multiSelected || [];
|
||||
game.buildTool = data.buildTool;
|
||||
game.view = data.view || { x: 0, y: 0 };
|
||||
game.view = data.view || { x: 0, y: 0, scale: 1 };
|
||||
if (game.view.scale == null) game.view.scale = 1;
|
||||
game.facilities = data.facilities || {};
|
||||
game.eggFarms = data.eggFarms || [];
|
||||
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));
|
||||
|
|
|
|||
|
|
@ -101,10 +101,6 @@ export function performanceFactor(target) {
|
|||
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;
|
||||
|
|
@ -221,6 +217,8 @@ export function hireRepairmanForNextDay(game) {
|
|||
const cost = repairmanDailyCost(game);
|
||||
if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` };
|
||||
spendCash(game, cost);
|
||||
game.pendingRepairWorkerWages = (game.pendingRepairWorkerWages || 0) + cost;
|
||||
game.totals.repairWorkerWages = (game.totals.repairWorkerWages || 0) + cost;
|
||||
game.repairman.hiredForNextDay = true;
|
||||
return { ok: true, cost };
|
||||
}
|
||||
|
|
@ -285,7 +283,7 @@ export function updateRepairman(game, dt) {
|
|||
if (targetWear(target) <= 0.001) r.target = null;
|
||||
}
|
||||
|
||||
export function worstDegradation(game) {
|
||||
function worstDegradation(game) {
|
||||
let worst = 0;
|
||||
let label = 'none';
|
||||
for (const t of equipmentMaintenanceTargets(game)) {
|
||||
|
|
|
|||
|
|
@ -29,14 +29,6 @@ function adjacentCells(point) {
|
|||
return DIRS.map(d => ({ col: point.col + d.dc, row: point.row + d.dr })).filter(p => inGrid(p.col, p.row));
|
||||
}
|
||||
|
||||
function conveyorAt(game, point) {
|
||||
return !!point && inGrid(point.col, point.row) && game.conveyorTiles.has(key(point.col, point.row));
|
||||
}
|
||||
|
||||
function exactConveyorCell(game, point) {
|
||||
return conveyorAt(game, point) ? [{ ...point }] : [];
|
||||
}
|
||||
|
||||
function visualPortConveyorCells(game, point, blocked = [], preferred = []) {
|
||||
if (!point || !inGrid(point.col, point.row)) return [];
|
||||
const result = [];
|
||||
|
|
@ -57,21 +49,6 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
|
|||
return exact.length ? exact : cells;
|
||||
}
|
||||
|
||||
// Kept for explicit non-port use.
|
||||
export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferred = []) {
|
||||
if (!point || !inGrid(point.col, point.row)) return [];
|
||||
const result = [];
|
||||
const add = p => {
|
||||
if (!p || !inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) return;
|
||||
if (blocked.some(b => b && sameCell(p, b))) return;
|
||||
if (!result.some(x => sameCell(x, p))) result.push({ ...p, viaTolerance: !sameCell(p, point) });
|
||||
};
|
||||
add(point);
|
||||
for (const p of preferred) add(p);
|
||||
for (const p of adjacentCells(point)) add(p);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Strict connection tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
@ -95,34 +72,11 @@ export function facilityEntryPoint(game, dest) {
|
|||
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
}
|
||||
|
||||
export function facilityEntryCell(game, dest) {
|
||||
return game.facilities[dest]?.entry || null;
|
||||
}
|
||||
|
||||
export function facilityEndpointCells(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f?.entry) return [];
|
||||
return visualPortConveyorCells(game, f.entry);
|
||||
}
|
||||
|
||||
export function portHasConveyor(game, port) {
|
||||
return visualPortConveyorCells(game, port).length > 0;
|
||||
}
|
||||
|
||||
export function scannerPortHasConveyor(game, scanner, type) {
|
||||
if (type === 'inputA') return scannerInputCells(game, scanner).length > 0;
|
||||
return outputStartCells(game, scanner, type).length > 0;
|
||||
}
|
||||
|
||||
export function getInputScanner(game, col, row) {
|
||||
return game.scanners.find(scanner => scannerInputCells(game, scanner).some(p => sameCell({ col, row }, p))) || null;
|
||||
}
|
||||
export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); }
|
||||
export function isOutputConnector(game, col, row) {
|
||||
return game.scanners.some(scanner => sameCell({ col, row }, scannerConnector(scanner, 'left')) || sameCell({ col, row }, scannerConnector(scanner, 'right')));
|
||||
}
|
||||
export function inputCellsForScanner(game, scanner) { return scannerInputCells(game, scanner); }
|
||||
|
||||
export function refreshRoutingAfterEdit(game) {
|
||||
game.branchCounters?.clear?.();
|
||||
game.routeCache = null;
|
||||
|
|
@ -140,6 +94,14 @@ export function refreshRoutingAfterEdit(game) {
|
|||
function graphNodeKey(col, row) { return key(col, row); }
|
||||
function graphCell(k) { return parseKey(k); }
|
||||
|
||||
function conveyorOutDirNames(game, cellKey) {
|
||||
const meta = game.conveyorMeta?.get?.(cellKey) || {};
|
||||
const dirs = [];
|
||||
if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs);
|
||||
if (meta.dir) dirs.push(meta.dir);
|
||||
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
|
||||
}
|
||||
|
||||
function emptyFactoryGraph(game) {
|
||||
return {
|
||||
version: game.routingVersion || 0,
|
||||
|
|
@ -178,7 +140,9 @@ export function buildFactoryGraph(game) {
|
|||
|
||||
for (const k of graph.cells) {
|
||||
const p = graphCell(k);
|
||||
for (const d of DIRS) {
|
||||
const explicitDirs = conveyorOutDirNames(game, k);
|
||||
const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : DIRS;
|
||||
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 });
|
||||
}
|
||||
|
|
@ -271,7 +235,7 @@ export function commitFactoryGraphForDay(game) {
|
|||
return graph;
|
||||
}
|
||||
|
||||
export function graphSummary(graph) {
|
||||
function graphSummary(graph) {
|
||||
if (!graph) return null;
|
||||
return {
|
||||
version: graph.version,
|
||||
|
|
@ -296,11 +260,6 @@ export function getConveyorNeighbors(game, 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) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
|
||||
}
|
||||
|
||||
function dirBetween(a, b) {
|
||||
const dc = b.col - a.col, dr = b.row - a.row;
|
||||
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
|
||||
|
|
@ -321,38 +280,68 @@ export function bfsAllRoutes(game, start, isGoal) {
|
|||
if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p })));
|
||||
|
||||
const startState = `${startK}|none`;
|
||||
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }];
|
||||
const visited = new Set([startState]);
|
||||
const parent = new Map();
|
||||
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none', depth: 0 }];
|
||||
const depthByState = new Map([[startState, 0]]);
|
||||
const parents = new Map();
|
||||
const found = [];
|
||||
let bestGoalDepth = Infinity;
|
||||
const maxParentsPerState = 8;
|
||||
const maxGoalStates = 24;
|
||||
const maxPaths = 80;
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
if (cur.depth > bestGoalDepth) break;
|
||||
const curStateK = `${cur.key}|${cur.incoming}`;
|
||||
if (isGoal(cur)) found.push(curStateK);
|
||||
if (isGoal(cur)) {
|
||||
found.push(curStateK);
|
||||
bestGoalDepth = cur.depth;
|
||||
if (found.length >= maxGoalStates) break;
|
||||
continue;
|
||||
}
|
||||
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 nextDepth = cur.depth + 1;
|
||||
if (nextDepth > bestGoalDepth) continue;
|
||||
const nextStateK = `${n.key}|${outDir}`;
|
||||
if (visited.has(nextStateK)) continue;
|
||||
visited.add(nextStateK);
|
||||
parent.set(nextStateK, curStateK);
|
||||
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir });
|
||||
const knownDepth = depthByState.get(nextStateK);
|
||||
if (knownDepth == null) {
|
||||
depthByState.set(nextStateK, nextDepth);
|
||||
parents.set(nextStateK, [curStateK]);
|
||||
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir, depth: nextDepth });
|
||||
} else if (knownDepth === nextDepth) {
|
||||
const list = parents.get(nextStateK) || [];
|
||||
if (list.length < maxParentsPerState && !list.includes(curStateK)) list.push(curStateK);
|
||||
parents.set(nextStateK, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
const paths = found.map(goalState => {
|
||||
const reversed = [];
|
||||
let cursor = goalState;
|
||||
while (cursor) {
|
||||
const [cellK] = cursor.split('|');
|
||||
reversed.push(graphCell(cellK));
|
||||
if (cellK === startK) break;
|
||||
cursor = parent.get(cursor);
|
||||
|
||||
const paths = [];
|
||||
const expand = (state, suffix) => {
|
||||
if (paths.length >= maxPaths) return;
|
||||
const [cellK] = state.split('|');
|
||||
const nextSuffix = [graphCell(cellK), ...suffix];
|
||||
if (state === startState || cellK === startK) {
|
||||
paths.push(nextSuffix);
|
||||
return;
|
||||
}
|
||||
return reversed.reverse();
|
||||
});
|
||||
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p }))));
|
||||
return paths;
|
||||
for (const parent of parents.get(state) || []) expand(parent, nextSuffix);
|
||||
};
|
||||
for (const goalState of found) expand(goalState, []);
|
||||
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const path of paths) {
|
||||
const sig = path.map(p => graphNodeKey(p.col, p.row)).join('>');
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
unique.push(path);
|
||||
}
|
||||
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, unique.map(path => path.map(p => ({ ...p }))));
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function buildConveyorComponents(game) {
|
||||
|
|
@ -414,6 +403,25 @@ function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) {
|
|||
return points;
|
||||
}
|
||||
|
||||
function chickCellOccupancyScore(game, cell, depth = 0) {
|
||||
const c = cellCenter(cell.col, cell.row);
|
||||
let score = 0;
|
||||
for (const chick of game.chicks || []) {
|
||||
if (chick.stage === 'flying' || chick.stage === 'queued') continue;
|
||||
const d = Math.hypot(chick.x - c.x, chick.y - c.y);
|
||||
if (d < GRID.cell * 0.50) score += depth <= 1 ? 100 : Math.max(1, 12 - depth);
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function candidateCongestionScore(game, candidate) {
|
||||
const cells = candidate.cells || [];
|
||||
let score = 0;
|
||||
const limit = Math.min(cells.length, 8);
|
||||
for (let i = 0; i < limit; i += 1) score += chickCellOccupancyScore(game, cells[i], i);
|
||||
return score;
|
||||
}
|
||||
|
||||
export function chooseRoundRobin(game, id, candidates, advance = false) {
|
||||
if (!candidates.length) return null;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
|
|
@ -427,9 +435,14 @@ export function chooseRoundRobin(game, id, candidates, advance = false) {
|
|||
const kb = b.key || JSON.stringify(b.route || b.cells || b);
|
||||
return ka.localeCompare(kb);
|
||||
});
|
||||
if (!advance || sorted.length === 1) return sorted[0];
|
||||
const bestBias = sorted[0].roleBias ?? 0;
|
||||
let pool = sorted.filter(item => (item.roleBias ?? 0) === bestBias);
|
||||
const bestScore = Math.min(...pool.map(item => candidateCongestionScore(game, item)));
|
||||
pool = pool.filter(item => candidateCongestionScore(game, item) === bestScore);
|
||||
const n = game.branchCounters.get(id) || 0;
|
||||
if (advance) game.branchCounters.set(id, n + 1);
|
||||
return sorted[n % sorted.length];
|
||||
game.branchCounters.set(id, n + 1);
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
export function scannerRules(scanner) {
|
||||
|
|
@ -453,13 +466,6 @@ export function destinationColor(dest) {
|
|||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
}
|
||||
|
||||
export function isFacilityEndpoint(game, p, dest, connector) {
|
||||
if (sameCell(p, connector)) return false;
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const port = graph.facilityPorts.get(dest);
|
||||
return !!port?.connected && (port.keys || [port.key]).includes(graphNodeKey(p.col, p.row));
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell);
|
||||
|
|
@ -552,6 +558,44 @@ function outputRouteReachesFacility(game, scanner, visited = new Set()) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function scannerOutputReachesDestination(game, scanner, dest, visited = new Set()) {
|
||||
if (!scanner || visited.has(scanner.id)) return false;
|
||||
visited.add(scanner.id);
|
||||
for (const side of ['left', 'right']) {
|
||||
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false);
|
||||
if (!plan) continue;
|
||||
if (plan.destination === dest) return true;
|
||||
if (plan.destination === 'scanner') {
|
||||
const next = scannerById(game, plan.nextScannerId);
|
||||
if (scannerOutputReachesDestination(game, next, dest, visited)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function eggFarmHasValidRoute(game, farm) {
|
||||
return !!routeFromFarmToScanner(game, farm, false)?.scannerId;
|
||||
}
|
||||
|
||||
export function facilityHasValidRoute(game, dest) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
if (!graph.facilityPorts.get(dest)?.connected) return false;
|
||||
return game.scanners.some(scanner => scannerOutputReachesDestination(game, scanner, dest));
|
||||
}
|
||||
|
||||
export function disconnectedBuildWarnings(game) {
|
||||
if (game.phase !== 'build') return [];
|
||||
const warnings = [];
|
||||
for (const farm of game.eggFarms || []) {
|
||||
if (!eggFarmHasValidRoute(game, farm)) warnings.push({ type: 'eggFarm', id: farm.id, ref: farm, message: 'This Egg will not produce chicks' });
|
||||
}
|
||||
for (const id of MACHINE_FACILITY_IDS) {
|
||||
const facility = game.facilities?.[id];
|
||||
if (facility && !facilityHasValidRoute(game, id)) warnings.push({ type: 'facility', id, ref: facility, message: 'This facility cannot receive items' });
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function minimumStartConnectionIssues(game) {
|
||||
const graph = ensureFactoryGraph(game);
|
||||
for (const farm of game.eggFarms || []) {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,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 (!pointInGrid(entry.col, entry.row)) return true;
|
||||
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
|
||||
const draft = { ...origin.obj, entry: { ...entry }, side };
|
||||
layoutFacilityOnEdge(draft, entry, side);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
hasBuildableOption = true;
|
||||
if (game.cash >= buildPrice(id, game)) return false;
|
||||
}
|
||||
if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false;
|
||||
return hasBuildableOption;
|
||||
}
|
||||
|
||||
|
|
@ -45,18 +46,30 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
const priceSpan = btn.querySelector('span');
|
||||
if (priceSpan) priceSpan.textContent = id === 'conveyor' ? `${yen(price)} / tile` : yen(price);
|
||||
const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id];
|
||||
const manualLimitReached = id === 'manualScanner' && game.scanners.filter(s => s.kind === 'manual').length >= 8;
|
||||
const unaffordable = game.cash < price;
|
||||
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt;
|
||||
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt || manualLimitReached;
|
||||
if (btn.disabled && game.buildTool === id) game.buildTool = null;
|
||||
btn.classList.toggle('unaffordable', unaffordable);
|
||||
btn.classList.toggle('already-built', uniqueAlreadyBuilt);
|
||||
btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached);
|
||||
btn.title = unaffordable
|
||||
? `Need ${yen(price - game.cash)} more`
|
||||
: uniqueAlreadyBuilt
|
||||
: manualLimitReached
|
||||
? 'Manual Scanner limit reached (8 max)'
|
||||
: uniqueAlreadyBuilt
|
||||
? `${FACILITY_DEFS[id]?.name || id} already exists`
|
||||
: '';
|
||||
}
|
||||
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
|
||||
if (ui.expandGridButton && build?.gridExpansionCost) {
|
||||
const cost = build.gridExpansionCost();
|
||||
const priceSpan = ui.expandGridButton.querySelector('span');
|
||||
if (priceSpan) priceSpan.textContent = `${yen(cost)} / +10×10`;
|
||||
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || game.cash < cost;
|
||||
ui.expandGridButton.disabled = blocked;
|
||||
ui.expandGridButton.classList.toggle('unaffordable', game.cash < cost);
|
||||
ui.expandGridButton.title = game.cash < cost ? `Need ${yen(cost - game.cash)} more` : `Add the next right 10×10 lot, or click an upper/right lot button on the canvas. Next purchase count: ${(game.gridExpansionPurchases || 0) + 1}`;
|
||||
}
|
||||
if (ui.hireRepairmanButton) {
|
||||
const hired = !!game.repairman?.hiredForNextDay;
|
||||
const cost = repairmanDailyCost(game);
|
||||
|
|
@ -64,7 +77,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
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. Repairs 3% durability per second.`;
|
||||
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 2% durability per second.`;
|
||||
}
|
||||
const cashout = noAffordableBuildTools();
|
||||
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
|
||||
|
|
@ -90,21 +103,22 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
function updateTurnSummary() {
|
||||
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
|
||||
const issues = facilityConnectionIssues(game);
|
||||
const tax = zundaTaxInfo(game.cash, game);
|
||||
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
|
||||
const tax = zundaTaxInfo(zundaBasisProfit, game);
|
||||
const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1);
|
||||
const tribute = fairiesTributeInfo(game, tributeDay);
|
||||
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
|
||||
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
|
||||
ui.turnSummary.innerHTML = [
|
||||
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
|
||||
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}×${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | 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)}`,
|
||||
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
|
||||
lastTribute != null
|
||||
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
|
||||
: `Fairies tribute before Build: <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 with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
|
||||
`Manual combo: <b>${game.manualCombo?.count || 0}</b> | Next expansion: <b>${yen(build?.gridExpansionCost ? build.gridExpansionCost() : 0)}</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 with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
|
||||
game.cardEffects?.usedMachineActive ? '<span class="cash-negative">Procurement: USED MACHINE MODE / no refunds / 60% durability</span>' : '',
|
||||
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
|
||||
].join('<br>');
|
||||
|
|
@ -143,6 +157,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
ui.shell.classList.toggle('phase-build', game.phase === 'build');
|
||||
ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover');
|
||||
ui.shell.classList.toggle('phase-title', game.phase === 'title');
|
||||
ui.shell.classList.toggle('phase-loanDecision', game.phase === 'loanDecision');
|
||||
ui.money.textContent = yen(game.cash);
|
||||
ui.money.classList.toggle('cash-negative', game.cash < 0);
|
||||
ui.money.classList.toggle('cash-positive', game.cash > STARTING_CASH);
|
||||
|
|
@ -152,6 +167,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
: `${Math.max(0, game.timeLeft).toFixed(1)}s`;
|
||||
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();
|
||||
if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0;
|
||||
updatePriorityStrip();
|
||||
ui.turnProfit.textContent = yen(game.stats.profit);
|
||||
ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
|
||||
|
|
@ -164,7 +180,17 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0);
|
||||
ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1);
|
||||
ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1);
|
||||
const nextTax = zundaTaxInfo(game.cash, game).tax;
|
||||
const s1 = game.scanners.find(s => s.kind === 'manual' && s.slot === 0);
|
||||
const s2 = game.scanners.find(s => s.kind === 'manual' && s.slot === 1);
|
||||
if (s1) {
|
||||
ui.buttons.s1Left.textContent = `S1 ${s1.keys?.left?.label || 'A'} -> Mixer`;
|
||||
ui.buttons.s1Right.textContent = `S1 ${s1.keys?.right?.label || 'D'} -> S2`;
|
||||
}
|
||||
if (s2) {
|
||||
ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`;
|
||||
ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`;
|
||||
}
|
||||
const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), game).tax;
|
||||
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay;
|
||||
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
|
||||
}
|
||||
|
|
@ -173,7 +199,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return;
|
||||
const target = truckTarget(game).toUpperCase();
|
||||
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
|
||||
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`;
|
||||
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`;
|
||||
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;
|
||||
|
|
@ -194,6 +220,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
|
||||
function phaseLabel() {
|
||||
if (game.phase === 'running') return game.timeLeft <= 0 ? TEXT.phases.clearing : TEXT.phases.running;
|
||||
if (game.phase === 'loanDecision') return TEXT.phases.loanDecision;
|
||||
if (game.phase === 'build') return TEXT.phases.build;
|
||||
if (game.phase === 'gameover') return TEXT.phases.gameover;
|
||||
return TEXT.phases.title;
|
||||
|
|
@ -209,16 +236,50 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
}
|
||||
|
||||
function showTurnResult(r) {
|
||||
const positive = value => `<span class="${value > 0 ? 'cash-positive' : ''}">${value > 0 ? '+' : ''}${yen(value)}</span>`;
|
||||
const negative = value => `<span class="${value > 0 ? 'cash-negative' : ''}">${value > 0 ? '-' : ''}${yen(value)}</span>`;
|
||||
const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative';
|
||||
const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0);
|
||||
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
|
||||
ui.modalBody.innerHTML = `
|
||||
<p class="formula-box compact">${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</p>
|
||||
<p><strong>Final profit: ${yen(r.profit)}</strong></p>`;
|
||||
<div class="settlement-net ${netClass}"><span>Net result</span><strong>${yen(r.profit)}</strong></div>
|
||||
<div class="result-grid settlement-grid">
|
||||
<div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div>
|
||||
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
|
||||
<div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div>
|
||||
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
|
||||
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
|
||||
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
|
||||
<div><strong>Penalties</strong>${negative(penalties)}</div>
|
||||
<div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div>
|
||||
<div><strong>Repair worker wages</strong>${negative(r.repairWorkerWages || 0)}</div>
|
||||
<div><strong>Loan repayments</strong>${negative(r.loanRepayment || 0)}</div>
|
||||
</div>
|
||||
<p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function showEmergencyLoanDecision(onUse, onDecline) {
|
||||
const charges = Math.max(0, game.cardEffects?.rescueLoanCharges || 0);
|
||||
ui.modalTitle.textContent = 'Emergency Loan Available';
|
||||
ui.modalBody.innerHTML = `
|
||||
<p class="gameover-reason">End-of-day cash is below ${yen(0)}.</p>
|
||||
<div class="result-grid settlement-grid">
|
||||
<div><strong>Current money</strong><span class="cash-negative">${yen(game.cash)}</span></div>
|
||||
<div><strong>Emergency Loan</strong><span class="cash-positive">+${yen(500)}</span></div>
|
||||
<div><strong>Cards remaining</strong><span>${charges}</span></div>
|
||||
</div>
|
||||
<p class="formula-box compact">Use one Emergency Loan to gain ${yen(500)} immediately. If cash is still negative, another decision will be shown if another card remains.</p>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Accept Game Over', onDecline, 'facility-action danger'));
|
||||
ui.modalActions.appendChild(button('Use Emergency Loan', onUse, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
|
||||
function showGameOver() {
|
||||
const processed = game.totals.processed;
|
||||
|
|
@ -256,5 +317,5 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
// Bankruptcy is intentionally checked only in completeTurn(), after all day-end revenue, tribute, and rescue-loan effects are settled.
|
||||
}
|
||||
|
||||
return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver };
|
||||
return { updatePanels, updateUI, showTitle, showTurnResult, showEmergencyLoanDecision, showGameOver, hideModal, checkGameOver };
|
||||
}
|
||||
|
|
|
|||
246
styles.css
246
styles.css
|
|
@ -20,7 +20,7 @@ h1, h2, p { margin: 0; }
|
|||
.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); }
|
||||
#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; }
|
||||
.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; }
|
||||
.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); }
|
||||
.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(6, minmax(82px, auto)); }
|
||||
.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); }
|
||||
.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); }
|
||||
|
|
@ -29,7 +29,7 @@ h1, h2, p { margin: 0; }
|
|||
.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
|
||||
.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); }
|
||||
.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; }
|
||||
.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo strong, .cash-positive { color: var(--green) !important; }
|
||||
.cash-negative { color: var(--danger) !important; }
|
||||
|
||||
.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; }
|
||||
|
|
@ -66,6 +66,76 @@ h1, h2, p { margin: 0; }
|
|||
.facility-action.danger { background: #ffdada; }
|
||||
.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
|
||||
.sort-button { min-height: 46px; font-size: 12px; }
|
||||
.debug-panel {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: min(292px, calc(100% - 20px));
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
|
||||
font-weight: 900;
|
||||
}
|
||||
.debug-panel summary {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.debug-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 74px;
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
border-top: 3px solid var(--line);
|
||||
font-size: 13px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.debug-body label,
|
||||
.debug-body button,
|
||||
.debug-body select,
|
||||
.debug-body input {
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.debug-check,
|
||||
.debug-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.debug-day {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.debug-body button {
|
||||
border: 2px solid var(--line);
|
||||
background: var(--white);
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
padding: 4px 5px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.debug-body input[type="number"],
|
||||
.debug-body select {
|
||||
width: 100%;
|
||||
border: 2px solid var(--line);
|
||||
background: var(--white);
|
||||
padding: 3px 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.debug-readout {
|
||||
grid-column: 1 / -1;
|
||||
border: 2px solid var(--line);
|
||||
background: #f7fff5;
|
||||
color: var(--muted);
|
||||
padding: 5px;
|
||||
line-height: 1.2;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
|
||||
.modal.visible { display: flex; }
|
||||
.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); }
|
||||
|
|
@ -202,6 +272,8 @@ h1, h2, p { margin: 0; }
|
|||
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
|
||||
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
|
||||
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 7px 0; }
|
||||
.card-tags span { margin: 0; padding: 2px 5px; font-size: 8px; line-height: 1.1; letter-spacing: .04em; }
|
||||
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
|
||||
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
|
||||
|
|
@ -245,6 +317,7 @@ body { font-size: 16px; }
|
|||
.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 em.warning { color: var(--danger); }
|
||||
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
|
||||
|
|
@ -334,6 +407,22 @@ body { font-size: 16px; }
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
|
||||
.settlement-net {
|
||||
border: 3px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.settlement-net strong { font-size: 24px; }
|
||||
.settlement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.settlement-grid div { padding: 8px; }
|
||||
.settlement-grid span { display: block; font-size: 14px; }
|
||||
.gameover-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
|
|
@ -460,3 +549,156 @@ body { font-size: 18px; }
|
|||
.build-panel { width: auto; }
|
||||
.control-dock { grid-template-columns: repeat(2, 172px); }
|
||||
}
|
||||
|
||||
/* v27.1 expansion/readability pass */
|
||||
body { font-size: 20px; }
|
||||
.panel-head h1 { font-size: 22px; }
|
||||
.panel-head h1 span { font-size: 13px; }
|
||||
.panel-head p { font-size: 14px; line-height: 1.35; }
|
||||
.build-panel h2 { font-size: 15px; }
|
||||
.priority-strip > div { font-size: 16px; }
|
||||
.hud-card span { font-size: 13px; }
|
||||
.hud-card strong { font-size: clamp(22px, 1.8vw, 30px); }
|
||||
.mini-box, .contract-card { font-size: 15px; line-height: 1.5; }
|
||||
.compact-rules .mini-box { font-size: 14px; }
|
||||
.tool-button strong { font-size: 16px; line-height: 1.15; }
|
||||
.tool-button span { font-size: 13px; line-height: 1.2; }
|
||||
.tool-button .tool-flavor {
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: min(320px, calc(100vw - 40px));
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tool-button .tool-flavor::before { left: auto; right: 18px; }
|
||||
.tool-button.unaffordable .tool-flavor,
|
||||
.tool-button.unaffordable:hover .tool-flavor,
|
||||
.tool-button:disabled .tool-flavor,
|
||||
.tool-button:disabled:hover .tool-flavor { display: none; }
|
||||
.facility-action { font-size: 12px; }
|
||||
.primary-button { font-size: 17px; }
|
||||
#nextTurnButton { font-size: 19px; }
|
||||
.card-choice strong { font-size: 19px; }
|
||||
.card-choice span { font-size: 12px; }
|
||||
.card-choice small { font-size: 16px; line-height: 1.45; }
|
||||
.muted-card-note { font-size: 16px; }
|
||||
.modal-card p, .modal-card li { font-size: 17px; }
|
||||
.modal.equipment-popover .modal-card h2 { font-size: 16px; }
|
||||
.modal.equipment-popover .modal-actions button { font-size: 11px; }
|
||||
.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; }
|
||||
.hover-tooltip strong { font-size: 18px; }
|
||||
.hover-tooltip span { font-size: 15px; }
|
||||
.sort-button { font-size: 17px; }
|
||||
|
||||
/* v27.3 offscreen scanner monitor / expansion UI pass */
|
||||
.scanner-monitor {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
left: 12px;
|
||||
bottom: 92px;
|
||||
display: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
width: min(560px, calc(100% - 24px));
|
||||
pointer-events: auto;
|
||||
}
|
||||
.scanner-monitor.visible { display: grid; }
|
||||
.scanner-monitor-card {
|
||||
border: 3px solid var(--line);
|
||||
background: rgba(255,255,255,.95);
|
||||
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
|
||||
padding: 9px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.scanner-monitor-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.scanner-monitor-card header strong { font-size: 20px; color: var(--green); }
|
||||
.scanner-monitor-card header span { font-size: 12px; color: var(--muted); }
|
||||
.scanner-mini-map {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr 82px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scanner-mini-map > span,
|
||||
.scanner-mini-map > b {
|
||||
border: 2px solid var(--line);
|
||||
background: #f7fff5;
|
||||
padding: 7px 6px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.scanner-current { display: block; color: var(--ink); }
|
||||
.scanner-current.male { color: #2477ff; }
|
||||
.scanner-current.female { color: #d62a85; }
|
||||
.scanner-current.poop { color: #7b4a23; }
|
||||
.scanner-current.empty { color: var(--muted); }
|
||||
|
||||
.scanner-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 27px);
|
||||
gap: 3px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
margin: 0 0 8px;
|
||||
border: 2px solid var(--line);
|
||||
background: #f6f1df;
|
||||
}
|
||||
.scanner-mini-grid .mini-cell {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(22,38,25,.28);
|
||||
background: #fffdfa;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
.scanner-mini-grid .mini-cell.belt { background: #e7f7e4; color: var(--green-dark); }
|
||||
.scanner-mini-grid .mini-cell.input { background: #fff3bf; color: var(--ink); font-size: 9px; }
|
||||
.scanner-mini-grid .mini-cell.scanner { background: var(--green); color: #fff; border-color: var(--line); font-size: 16px; }
|
||||
.scanner-mini-grid .mini-cell.scanner.male { background: #2477ff; }
|
||||
.scanner-mini-grid .mini-cell.scanner.female { background: #d62a85; }
|
||||
.scanner-mini-grid .mini-cell.scanner.poop { background: #7b4a23; }
|
||||
.scanner-mini-grid .mini-cell.scanner-other { background: #b7f5c6; color: var(--ink); }
|
||||
.scanner-mini-grid .mini-cell.egg { background: #fff7c4; color: #7a5a00; }
|
||||
.scanner-mini-grid .mini-cell.port { background: #dbe7ff; color: #27427d; }
|
||||
.scanner-mini-grid .mini-cell.blocked { background: #d7cfc1; color: #6d4d35; }
|
||||
|
||||
.scanner-monitor-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
.scanner-monitor-actions button {
|
||||
border: 3px solid var(--line);
|
||||
background: var(--green-soft);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
min-height: 44px;
|
||||
box-shadow: 3px 3px 0 rgba(16,32,21,.18);
|
||||
}
|
||||
.scanner-monitor-actions button:disabled {
|
||||
background: #edf3ec;
|
||||
color: var(--muted);
|
||||
opacity: .62;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue