This commit is contained in:
33333-33333 2026-06-08 22:05:54 +09:00
commit b54f36c0e8
19 changed files with 1768 additions and 422 deletions

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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" /> <link rel="stylesheet" href="./styles.css" />
</head> </head>
<body> <body>
@ -17,6 +17,7 @@
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</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"><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 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>
<div class="hud hud-top-right" aria-label="Day result details"> <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 id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h1>CHICK SORTER <span>v27.0</span></h1> <h1>CHICK SORTER <span>v27.6</span></h1>
<p>Build, drag, connect at least one valid EGG route, then start the next day.</p> <p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
</div> </div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button> <button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div> </div>
@ -54,6 +55,7 @@
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</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="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="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 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>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div> <div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
@ -70,6 +72,8 @@
</div> </div>
</div> </div>
<div id="manualScannerMonitor" class="scanner-monitor" aria-label="Offscreen manual scanner monitor"></div>
<div class="control-dock" aria-label="Sorting controls"> <div class="control-dock" aria-label="Sorting controls">
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button> <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="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> <button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div> </div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></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> </section>
</main> </main>
@ -88,6 +105,6 @@
</div> </div>
</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> </body>
</html> </html>

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay. // Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps. // Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = { export const BALANCE = {
version: 'v27.0 cleanup build', version: 'v27.6 direct conveyor chick movement',
time: { time: {
daySeconds: 60, daySeconds: 60,
farmShutdownGraceSeconds: 10 farmShutdownGraceSeconds: 10
@ -10,11 +10,18 @@ export const BALANCE = {
starting: 250 starting: 250
}, },
grid: { grid: {
x: 190, x: 170,
y: 142, y: 78,
cols: 22, cols: 20,
rows: 13, rows: 10,
cell: 46 cell: 46,
expansion: {
colsPerPurchase: 10,
rowsPerPurchase: 10,
costBase: 1000,
costMultiplier: 4 / 3,
truncateUnit: 100
}
}, },
map: { map: {
blockedRatio: 0.075 blockedRatio: 0.075
@ -62,12 +69,232 @@ export const BALANCE = {
bearingSpeedMultiplier: 1.075 bearingSpeedMultiplier: 1.075
}, },
cards: { 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, commonWeight: 8,
rareWeight: 2, rareWeight: 2,
ultraRareWeight: 0.45, ultraRareWeight: 0.45,
baseDraftSize: 3, baseDraftSize: 3,
maxDudsPerDraft: 2, maxDudsPerDraft: 2,
dudChancePerCard: 0.30, dudChancePerCard: 0.30,
dudChanceReductionPerFilter: 0.05,
extraCardsAddedPicks: 2, extraCardsAddedPicks: 2,
rerollCost: { rerollCost: {
dayDivisor: 2, dayDivisor: 2,
@ -94,8 +321,8 @@ export const BALANCE = {
}, },
repairman: { repairman: {
dailyCost: 100, dailyCost: 100,
secondsPerOnePercent: 1 / 3, secondsPerOnePercent: 0.5,
walkSpeed: 140 walkSpeed: 90
} }
}, },
facilities: { facilities: {

View file

@ -18,6 +18,8 @@ export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier; export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
export const CARD_BALANCE = BALANCE.cards; 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 EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
export const GRID = BALANCE.grid; export const GRID = BALANCE.grid;
export const FACILITY_DEFS = BALANCE.facilities; export const FACILITY_DEFS = BALANCE.facilities;

View file

@ -22,6 +22,7 @@ export function createEggFarm(game, col, row) {
const farm = { const farm = {
type: 'eggFarm', id: game.nextId++, col, row, type: 'eggFarm', id: game.nextId++, col, row,
level: 1, nextSpawn: 2, lastInterval: 2, level: 1, nextSpawn: 2, lastInterval: 2,
extraEggOutlet: 0,
price: BUILD_COSTS.eggFarm, price: BUILD_COSTS.eggFarm,
builtSession: game.buildSession builtSession: game.buildSession
}; };
@ -97,15 +98,17 @@ export function rollChickSex(game) {
export function createChick(game, routeData) { export function createChick(game, routeData) {
const sex = rollChickSex(game); 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 { return {
id: game.nextId++, sex, id: game.nextId++, sex,
x: start.x, y: start.y, x: start.x, y: start.y,
route: routeData.route, route,
targetIndex: 1, targetIndex: route.length > 1 ? 1 : route.length,
stage: 'input', stage: routeData.stage || 'belt',
scannerId: routeData.scannerId, scannerId: routeData.scannerId ?? null,
nextScannerId: null, nextScannerId: null,
pendingScannerId: null,
radius: sex === 'poop' ? 15 : 17, radius: sex === 'poop' ? 15 : 17,
bob: Math.random() * Math.PI * 2, bob: Math.random() * Math.PI * 2,
queueIndex: -1, queueIndex: -1,

160
src/core/gridExpansion.js Normal file
View 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));
}

View file

@ -41,3 +41,25 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] }
const target = Math.round(GRID.cols * GRID.rows * targetRatio); const target = Math.round(GRID.cols * GRID.rows * targetRatio);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); 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));
}

View file

@ -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 { 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 { BALANCE } from './balance.js';
import { createRunSeed, generateBlockedCells } from './mapGen.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() { export function newTurnStats() {
return { return {
@ -96,6 +97,12 @@ export function createGame() {
lastTimestamp: 0, lastTimestamp: 0,
nextId: 10, nextId: 10,
runSeed: createRunSeed(), 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(), blockedCells: new Set(),
buildTool: null, buildTool: null,
buildSession: 0, buildSession: 0,
@ -106,7 +113,7 @@ export function createGame() {
selectionBox: null, selectionBox: null,
groupDrag: null, groupDrag: null,
pan: null, pan: null,
view: { x: 0, y: 0 }, view: { x: 0, y: 0, scale: 1 },
hover: null, hover: null,
chicks: [], chicks: [],
effects: [], effects: [],
@ -128,12 +135,12 @@ export function createGame() {
lastExplodedComponent: new Map(), lastExplodedComponent: new Map(),
contractOffer: null, contractOffer: null,
contractActive: 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 }, cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null, cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 }, manualCombo: { count: 0, lastBonus: 0 },
pendingRepairWorkerWages: 0, pendingRepairWorkerWages: 0,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 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(), stats: newTurnStats(),
lastResult: null, lastResult: null,
totals: newTotalStats() totals: newTotalStats()
@ -165,34 +172,34 @@ function facilityBodyForEntry(id, entry, side) {
export function defaultFacilities() { export function defaultFacilities() {
return { return {
// Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge. // Initial grid is 20 columns × 10 rows. Machine bodies sit outside the starter grid.
mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'), mixer: facilityBodyForEntry('mixer', { col: 0, row: 4 }, 'left'),
trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'), trash: facilityBodyForEntry('trash', { col: 12, row: 9 }, 'bottom'),
truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom') truck: facilityBodyForEntry('truck', { col: 18, row: 9 }, 'bottom')
}; };
} }
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it. // Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated
[4, 1], [4, 2], [4, 3], [4, 4], // 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. // 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. // S1 right output -> S2 top input.
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6],
[11, 6], [11, 7],
// S2 left output -> Waste Shredder receiver on bottom grid edge. // 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. // 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([ 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', keys: { left: { key: 'a', code: 'KeyA', label: 'A' }, right: { key: 'd', code: 'KeyD', label: 'D' } } }, { 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: 11, row: 8, 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' } } } { 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([ 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 = {}) { function addConveyorTile(game, col, row, options = {}) {
@ -205,11 +212,34 @@ function addConveyorTile(game, col, row, options = {}) {
builtSession: null, builtSession: null,
uses: 0, uses: 0,
durability: BALANCE.maintenance.durability.conveyor, 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) { function scannerPortCells(scanner) {
return [ return [
{ col: scanner.col, row: scanner.row - 1 }, { col: scanner.col, row: scanner.row - 1 },
@ -243,7 +273,9 @@ function protectedInitialCells(game) {
} }
function installInitialConveyorBackbone(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) { function scrubBlockedCellsFromInitialBackbone(game) {
@ -252,6 +284,21 @@ function scrubBlockedCellsFromInitialBackbone(game) {
} }
export function resetLayout(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.conveyorTiles.clear();
game.conveyorMeta.clear(); game.conveyorMeta.clear();
game.blockedCells = new Set(); game.blockedCells = new Set();

View file

@ -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 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 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 lerp(a, b, t) { return a + (b - a) * t; }
export function hexToRgb(hex) { export function hexToRgb(hex) {
const s = hex.replace('#', ''); const s = hex.replace('#', '');

View file

@ -1,7 +1,8 @@
import { TURN_SECONDS, THEME } from './core/config.js'; import { GRID, TURN_SECONDS, THEME } from './core/config.js';
import { buildToolButtonHtml } from './core/text.js'; import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.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 { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
import { applyRevenue, 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 { undo, redo } from './systems/history.js';
@ -9,7 +10,7 @@ import { drawAll } from './render/draw.js';
import { createBuildSystem } from './systems/buildSystem.js'; import { createBuildSystem } from './systems/buildSystem.js';
import { createUISystem } from './systems/uiSystem.js'; import { createUISystem } from './systems/uiSystem.js';
import { createChickSystem } from './systems/chickSystem.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 { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js'; import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
@ -19,13 +20,25 @@ const ctx = canvas.getContext('2d');
const ui = { const ui = {
shell: document.getElementById('gameShell'), 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'), 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'), 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'), 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'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'), hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'), 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: { buttons: {
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'), 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') 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(); const game = createGame();
let build; let build;
let uiSystem; let uiSystem;
@ -62,13 +84,60 @@ function currentConveyorSpeed(conveyorKey = null) {
function startGame() { function startGame() {
Object.assign(game, createGame()); Object.assign(game, createGame());
game.phase = 'running'; game.phase = 'running';
game.view = { x: 0, y: 0 }; game.view = { x: 0, y: 0, scale: 1 };
resetLayout(game); resetLayout(game);
resetCameraToFactoryStart();
ensureMaintenanceState(game); ensureMaintenanceState(game);
uiSystem.hideModal(); uiSystem.hideModal();
chicks.updateCongestion(); chicks.updateCongestion();
uiSystem.updatePanels(); uiSystem.updatePanels();
uiSystem.updateUI(); 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() { function startNextTurn() {
@ -87,8 +156,8 @@ function startNextTurn() {
ensureMaintenanceState(game); ensureMaintenanceState(game);
commitFactoryGraphForDay(game); commitFactoryGraphForDay(game);
activateRepairmanForDay(game); activateRepairmanForDay(game);
const zundaBasisCash = game.cash; const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const zunda = collectZundaTax(game, zundaBasisCash); const zunda = collectZundaTax(game, zundaBasisProfit);
const loanRepayment = collectLoanRepayments(game); const loanRepayment = collectLoanRepayments(game);
game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 }; 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); if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
@ -106,12 +175,12 @@ function startNextTurn() {
game.floatingTexts = []; game.floatingTexts = [];
game.shake = { time: 0, strength: 0 }; game.shake = { time: 0, strength: 0 };
game.truckCargo = []; game.truckCargo = [];
game.view = { x: 0, y: 0 }; resetCameraToFactoryStart();
game.stats = newTurnStats(); game.stats = newTurnStats();
game.stats.zundaTax = game.lastStartFees?.zundaTax || 0; game.stats.zundaTax = 0;
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0; game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0; game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
const carriedStartCosts = game.stats.zundaTax + game.stats.loanRepayment + game.stats.repairWorkerWages; const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages;
if (carriedStartCosts > 0) { if (carriedStartCosts > 0) {
game.stats.penalty += carriedStartCosts; game.stats.penalty += carriedStartCosts;
game.stats.profit -= carriedStartCosts; game.stats.profit -= carriedStartCosts;
@ -234,21 +303,96 @@ function update(timestamp) {
if (!game.lastTimestamp) game.lastTimestamp = timestamp; if (!game.lastTimestamp) game.lastTimestamp = timestamp;
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05); const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
game.lastTimestamp = timestamp; game.lastTimestamp = timestamp;
applyDebugState();
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn }); if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
updateEffects(game, dt, canvas, build.equipmentHitBoxes); updateEffects(game, dt, canvas, build.equipmentHitBoxes);
chicks.updateCongestion(); chicks.updateCongestion();
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle }); drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
uiSystem.updateUI(); uiSystem.updateUI();
updateManualScannerMonitor();
updateDebugReadout();
requestAnimationFrame(update); requestAnimationFrame(update);
} }
// Build/edit/selection operations live in src/systems/buildSystem.js. // Build/edit/selection operations live in src/systems/buildSystem.js.
// HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js. // HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js.
// Hitbox generation lives in buildSystem.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 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 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 startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; } function worldToScreen(p) { const s = viewScale(); return { x: p.x * s + (game.view?.x || 0), y: p.y * s + (game.view?.y || 0) }; }
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 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 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) { function objectFromHit(hit) {
if (!hit) return null; if (!hit) return null;
@ -298,7 +442,115 @@ function clickSelect(event) {
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj); if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
} }
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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('contextmenu', event => event.preventDefault());
canvas.addEventListener('wheel', zoomAt, { passive: false });
canvas.addEventListener('pointerdown', event => { canvas.addEventListener('pointerdown', event => {
if (game.phase !== 'build') return; if (game.phase !== 'build') return;
canvas.setPointerCapture(event.pointerId); canvas.setPointerCapture(event.pointerId);
@ -306,8 +558,14 @@ canvas.addEventListener('pointerdown', event => {
if (event.button === 2) { startPan(event); return; } if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return; if (event.button !== 0) return;
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); 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 === 'erase') { build.eraseAtPoint(world); return; }
if (game.buildTool) { if (game.buildTool) {
if (game.buildTool === 'conveyor') {
build.beginConveyorDrag(pointToCell(world.x, world.y));
return;
}
const hit = build.equipmentAtPoint(world); const hit = build.equipmentAtPoint(world);
if (hit) { game.buildTool = null; clickSelect(event); return; } if (hit) { game.buildTool = null; clickSelect(event); return; }
build.buildAtPoint(world); build.buildAtPoint(world);
@ -322,7 +580,7 @@ canvas.addEventListener('pointermove', event => {
if (game.pan && (event.buttons & 2)) updatePan(event); if (game.pan && (event.buttons & 2)) updatePan(event);
if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event); if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(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)); if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
}); });
canvas.addEventListener('pointerup', event => { canvas.addEventListener('pointerup', event => {
@ -331,11 +589,12 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event); if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag(); build.finishGroupDrag();
} }
if (game.buildTool === 'conveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null; game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ } try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
}); });
canvas.addEventListener('pointerleave', hideHoverTooltip); 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 => { document.addEventListener('pointerdown', event => {
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return; if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
if (event.target.closest('#modal .modal-card')) return; if (event.target.closest('#modal .modal-card')) return;
@ -345,7 +604,11 @@ 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.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.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.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); 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) { function scannerKeyMatches(scanner, side, event) {
const binding = scanner.keys?.[side]; const binding = scanner.keys?.[side];
if (!binding) return false; if (!binding) return false;
@ -361,8 +624,8 @@ window.addEventListener('keydown', event => {
if (scannerKeyMatches(scanner, 'right', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'right'); 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)) uiSystem.updatePanels(); } 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)) uiSystem.updatePanels(); } 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(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); }
}); });
@ -372,4 +635,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () =>
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() }); cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick }); 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);

View file

@ -1,4 +1,5 @@
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 { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } 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 { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
@ -42,8 +43,11 @@ export function drawAll(ctx, canvas, game, helpers) {
ctx.save(); ctx.save();
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0; 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; 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); drawGrid(ctx, game);
drawExpansionOffers(ctx, game);
drawConveyors(ctx, game); drawConveyors(ctx, game);
drawScannerConnectors(ctx, game); drawScannerConnectors(ctx, game);
drawFacilities(ctx, game); drawFacilities(ctx, game);
@ -85,11 +89,11 @@ function drawDisconnectedWarnings(ctx, game) {
ctx.fillStyle = THEME.danger; ctx.fillStyle = THEME.danger;
ctx.strokeStyle = THEME.white; ctx.strokeStyle = THEME.white;
ctx.lineWidth = 4; ctx.lineWidth = 4;
ctx.font = '900 18px ui-monospace, monospace'; ctx.font = '900 16px ui-monospace, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.strokeText('!', b.cx, b.y + 24); ctx.strokeText('!', b.cx, b.y + 24);
ctx.fillText('!', b.cx, b.y + 24); ctx.fillText('!', b.cx, b.y + 24);
ctx.font = '900 9px ui-monospace, monospace'; ctx.font = '900 11px ui-monospace, monospace';
ctx.strokeText('NO ROUTE', b.cx, b.y + b.h - 8); ctx.strokeText('NO ROUTE', b.cx, b.y + b.h - 8);
ctx.fillText('NO ROUTE', b.cx, b.y + b.h - 8); ctx.fillText('NO ROUTE', b.cx, b.y + b.h - 8);
ctx.restore(); ctx.restore();
@ -100,7 +104,7 @@ 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(); 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) { 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) { function drawImageIfLoaded(ctx, image, x, y, w, h) {
if (!image?.loaded) return false; if (!image?.loaded) return false;
@ -117,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(); } for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.restore(); 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) { function drawGrid(ctx, game) {
ctx.save(); ctx.save();
const cells = ownedCellKeys(game);
ctx.fillStyle = 'rgba(255,255,255,.46)'; ctx.fillStyle = 'rgba(255,255,255,.46)';
ctx.strokeStyle = 'rgba(16,32,21,.055)'; ctx.strokeStyle = 'rgba(16,32,21,.055)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
rect(ctx, GRID.x - 10, GRID.y - 10, GRID.cols * GRID.cell + 20, GRID.rows * GRID.cell + 20, true, true); for (const k of cells) {
for (let c = 0; c <= GRID.cols; c += 1) { const p = parseKey(k);
const x = GRID.x + c * GRID.cell; const c = cellCenter(p.col, p.row);
ctx.beginPath(); ctx.moveTo(x, GRID.y); ctx.lineTo(x, GRID.y + GRID.rows * GRID.cell); ctx.stroke(); 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) { ctx.strokeStyle = 'rgba(16,32,21,.16)';
const y = GRID.y + r * GRID.cell; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke(); 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 || []) { for (const k of game.blockedCells || []) {
const p = parseKey(k); const p = parseKey(k);
if (!isOwnedCell(game, p.col, p.row)) continue;
const c = cellCenter(p.col, p.row); const c = cellCenter(p.col, p.row);
ctx.fillStyle = 'rgba(16,32,21,.30)'; ctx.fillStyle = 'rgba(16,32,21,.30)';
ctx.strokeStyle = 'rgba(16,32,21,.60)'; ctx.strokeStyle = 'rgba(16,32,21,.60)';
@ -142,6 +162,40 @@ function drawGrid(ctx, game) {
ctx.restore(); 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) { function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) {
const wear = wearRatio(target); const wear = wearRatio(target);
if (wear < 0.50) return; if (wear < 0.50) return;
@ -236,8 +290,22 @@ function addMarkerFromRoute(game, markers, route) {
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle); 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) { function collectConveyorDirectionMarkers(game) {
const markers = new Map(); 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 farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
for (const scanner of game.scanners) { for (const scanner of game.scanners) {
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route); for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
@ -266,7 +334,7 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
function drawScannerConnectors(ctx, game) { function drawScannerConnectors(ctx, game) {
ctx.save(); ctx.save();
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.font = '900 10px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
for (const scanner of game.scanners) { for (const scanner of game.scanners) {
const outputs = scannerOutputs(scanner); const outputs = scannerOutputs(scanner);
@ -294,13 +362,18 @@ function drawEggFarm(ctx, farm, game) {
ctx.save(); ctx.save();
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) {
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm); 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); drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
ctx.restore(); return; ctx.restore(); return;
} }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true); 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.fillStyle = THEME.green; ctx.font = '900 19px 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); 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)); const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
if (shutter > 0) { if (shutter > 0) {
const h = 56 * shutter; const h = 56 * shutter;
@ -308,7 +381,7 @@ function drawEggFarm(ctx, farm, game) {
rect(ctx, c.x - 28, c.y - 28, 56, h, true, false); rect(ctx, c.x - 28, c.y - 28, 56, h, true, false);
ctx.strokeStyle = THEME.white; ctx.lineWidth = 2; 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(); } 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); drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68); drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
@ -340,9 +413,9 @@ function drawScanner(ctx, scanner, game) {
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true); rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink; 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.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14);
ctx.font = '900 9px ui-monospace, monospace'; ctx.font = '900 11px ui-monospace, monospace';
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left'); const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right'); 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); ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y + 3);
@ -350,11 +423,11 @@ function drawScanner(ctx, scanner, game) {
if (scanner.kind === 'manual') { if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c); drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; 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); ctx.fillText(`Q:${q}`, c.x, c.y + 32);
} else { } else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; 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); 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); drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
} }
@ -378,7 +451,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
ctx.fillStyle = '#f7fff5'; ctx.fillStyle = '#f7fff5';
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = THEME.ink;
rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true); 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'; ctx.textAlign = 'center';
for (let i = 0; i < labels.length; i += 1) { for (let i = 0; i < labels.length; i += 1) {
const keyDef = labels[i]; const keyDef = labels[i];
@ -397,7 +470,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
ctx.fillText(keyDef.text, x + 14, y + 13); ctx.fillText(keyDef.text, x + 14, y + 13);
} }
ctx.fillStyle = THEME.muted; 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.fillText('KEYBOARD', c.x, baseY - 7);
ctx.restore(); ctx.restore();
} }
@ -417,12 +490,12 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
rect(ctx, x, y, w, 32, true, true); rect(ctx, x, y, w, 32, true, true);
drawItemIcon(ctx, x + 18, y + 16, type); drawItemIcon(ctx, x + 18, y + 16, type);
ctx.fillStyle = THEME.ink; ctx.fillStyle = THEME.ink;
ctx.font = '900 10px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(title, x + 34, y + 13); ctx.fillText(title, x + 34, y + 13);
if (subtitle) { if (subtitle) {
ctx.fillStyle = THEME.muted; 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.fillText(subtitle, x + 34, y + 25);
} }
ctx.restore(); ctx.restore();
@ -447,7 +520,7 @@ function drawFacilityReceiver(ctx, game, id) {
ctx.lineWidth = 2; ctx.lineWidth = 2;
rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true); rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true);
drawItemIcon(ctx, c.x, c.y - 3, info.type); 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.textAlign = 'center';
ctx.fillStyle = THEME.ink; ctx.fillStyle = THEME.ink;
ctx.fillText(info.title, c.x, c.y + 20); ctx.fillText(info.title, c.x, c.y + 20);
@ -484,8 +557,8 @@ function drawMixer(ctx, game) {
drawExternalDuct(ctx, m); 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; } 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.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.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 10px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48); 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'); 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; 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(); } 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(); }
@ -499,7 +572,7 @@ function drawTrash(ctx, game) {
drawExternalDuct(ctx, t); 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; } 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.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); } 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'); 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); drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
@ -512,7 +585,7 @@ function drawTruck(ctx, game) {
drawExternalDuct(ctx, t); 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; } 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.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); 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); 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'); drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
@ -566,7 +639,7 @@ function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
function drawTinyChick(ctx, x, y, sex, warning = false) { 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.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(); 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(); ctx.restore();
} }
function selectionToken(type, id) { return `${type}:${id}`; } function selectionToken(type, id) { return `${type}:${id}`; }
@ -587,7 +660,7 @@ function drawSelection(ctx, game, type, id, x, y, w, h) {
function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) { function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
if (game.phase !== 'build') return; if (game.phase !== 'build') return;
if ((game.multiSelected || []).length > 1) { 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; return;
} }
// Single equipment inspection is handled by the DOM hover tooltip. // Single equipment inspection is handled by the DOM hover tooltip.
@ -602,7 +675,8 @@ function drawCardTargetOverlay(ctx, canvas, game) {
// so every non-candidate object is reliably dimmed even after panning. // so every non-candidate object is reliably dimmed even after panning.
ctx.fillStyle = 'rgba(0,0,0,.66)'; ctx.fillStyle = 'rgba(0,0,0,.66)';
ctx.beginPath(); 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) { for (const item of items) {
const b = item.bounds; const b = item.bounds;
const pad = 8; const pad = 8;
@ -614,11 +688,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.strokeStyle = THEME.ink; ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3; 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.fillStyle = THEME.danger;
ctx.font = '900 12px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center'; 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(); ctx.restore();
return; return;
} }
@ -627,11 +701,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.lineWidth = 3; ctx.lineWidth = 3;
const remaining = Math.max(1, game.cardTargetPick?.remaining || 1); 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.'; 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.fillStyle = THEME.ink;
ctx.font = '900 12px ui-monospace, monospace'; ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center'; 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(); ctx.restore();
} }
@ -669,7 +743,7 @@ function drawEffects(ctx, game) {
} }
} }
function drawFloatingTexts(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); } 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(); ctx.restore();
} }
@ -700,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.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.fillStyle = THEME.ink;
ctx.font = '900 9px ui-monospace, monospace'; ctx.font = '900 11px ui-monospace, monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33); 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); if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
@ -710,12 +784,12 @@ function drawRepairman(ctx, game) {
function drawCanvasHints(ctx, game, canvas) { function drawCanvasHints(ctx, game, canvas) {
ctx.save(); 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. // 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; ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
rect(ctx, canvas.width - 284, 18, 260, 34, true, true); 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.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 = []; const lines = [];
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2'); 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`); if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect 1 EGG route to any exit`);

View file

@ -1,7 +1,10 @@
import { BALANCE } from '../core/balance.js';
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { getSpawnRange } from '../core/state.js'; import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.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 { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } 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 { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
@ -14,8 +17,95 @@ import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScanne
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; } let lastConveyorBuildCell = null;
function isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); } 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 rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
function rectsOverlap(a, b, margin = 10) { function rectsOverlap(a, b, margin = 10) {
@ -93,24 +183,133 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger); 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) { function buildAtCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid); if (!cell) return fail(TEXT.fail.outOfGrid);
const { col, row } = cell; const { col, row } = cell;
if (game.buildTool === 'conveyor') { if (game.buildTool === 'conveyor') {
if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied); const result = buildConveyorCell(cell, null);
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
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 };
refreshRoutingAfterEdit(game); refreshRoutingAfterEdit(game);
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
return; return;
} }
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
@ -374,8 +573,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function positionEquipmentPopover(obj) { function positionEquipmentPopover(obj) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const p = selectedPopoverWorldPoint(obj); const p = selectedPopoverWorldPoint(obj);
const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18; const scale = game.view?.scale || 1;
const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12; 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 maxX = Math.max(12, window.innerWidth - 274);
const maxY = Math.max(12, window.innerHeight - 220); const maxY = Math.max(12, window.innerHeight - 220);
ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`); ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`);
@ -410,6 +610,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function setBuildTool(tool) { function setBuildTool(tool) {
if (game.phase !== 'build') return; if (game.phase !== 'build') return;
endConveyorDrag();
game.buildTool = game.buildTool === tool ? null : tool; game.buildTool = game.buildTool === tool ? null : tool;
game.selected = null; game.selected = null;
game.multiSelected = []; game.multiSelected = [];
@ -457,7 +658,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Cell: ${obj.id}`); lines.push(`Cell: ${obj.id}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`); 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)}`); lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'facility') { } else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`); lines.push(`Price: ${yen(equipmentPrice(obj))}`);
@ -515,6 +716,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
finishGroupDrag: selection.finishGroupDrag, finishGroupDrag: selection.finishGroupDrag,
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
selectedInfoLines, flavorText, disconnectedWarningFor, selectedInfoLines, flavorText, disconnectedWarningFor,
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
removeSelected, switchScannerRole, showManualScannerMenu, removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes, isEquipmentCell, equipmentHitBoxes,

View file

@ -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 { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js'; import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
@ -11,221 +11,7 @@ const RARE_WEIGHT = CARD_BALANCE.rareWeight;
const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45; const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45;
const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize; const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize;
const DEFAULT_EFFECTS = Object.freeze({ export const CARD_DEFS = CARD_DEFINITIONS;
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.'
}
];
const CARD_TAGS = {
upgradeEgg: ['EGG', 'ACTIVE'],
upgradeAutoScanner: ['SCANNER', 'ACTIVE'],
upgradeMixer: ['ECONOMY', 'ACTIVE'],
upgradeTruck: ['ECONOMY', 'ACTIVE'],
upgradeTrash: ['POOP', 'ACTIVE'],
crowdedFarming: ['EGG', 'RISK', 'PASSIVE'],
hatchingFeed: ['EGG', 'POOP', 'PASSIVE'],
safetyCover: ['RISK', 'PASSIVE'],
preventiveMaintenance: ['MAINTENANCE', 'PASSIVE'],
durabilityCoating: ['MAINTENANCE', 'PASSIVE'],
bearing: ['CONVEYOR', 'PASSIVE'],
extraEggOutlet: ['EGG', 'RARE', 'PASSIVE'],
recyclingSubsidy: ['ECONOMY', 'RARE', 'PASSIVE'],
dynamite: ['RISK', 'RARE', 'ONE-SHOT'],
usedMachine: ['ECONOMY', 'RISK', 'RARE'],
newMachine: ['ECONOMY', 'ONE-SHOT'],
flattery: ['ECONOMY', 'RARE', 'ONE-SHOT'],
loan: ['ECONOMY', 'RISK', 'RARE'],
extraCards: ['RARE', 'ACTIVE'],
legalWork: ['ECONOMY', 'RARE', 'PASSIVE'],
chemicalWeaponSubsidy: ['POOP', 'ULTRA RARE', 'PASSIVE'],
laborExploitation: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'],
rescueLoan: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT']
};
for (const card of CARD_DEFS) card.tags = CARD_TAGS[card.id] || [];
function effectCount(game, id) { function effectCount(game, id) {
const effects = ensureCardState(game); const effects = ensureCardState(game);
@ -233,8 +19,8 @@ function effectCount(game, id) {
} }
export function ensureCardState(game) { export function ensureCardState(game) {
if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] }; if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] };
for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) { 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[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
} }
if (game.cardEffects.flattery != null) { if (game.cardEffects.flattery != null) {
@ -254,8 +40,8 @@ export function eggProductionDelayMultiplier(game) {
return crowded * feed; return crowded * feed;
} }
export function extraEggOutletCount(game) { export function extraEggOutletCount(_game, farm = null) {
return Math.min(3, effectCount(game, 'extraEggOutlet')); return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0));
} }
export function conveyorSpeedForGame(game, conveyorKey = null) { export function conveyorSpeedForGame(game, conveyorKey = null) {
@ -281,11 +67,48 @@ export function cardById(id) {
return CARD_DEFS.find(card => card.id === id) || null; 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) { function targetLabel(game, target) {
if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`; if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`;
if (target.type === 'eggFarm') { if (target.type === 'eggFarm') {
return `EGG #${target.id} L${target.level} -> L${target.level + 1}`; 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') { 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`; 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`;
} }
@ -328,6 +151,9 @@ export function targetsForCard(game, cardOrId) {
if (!card) return []; if (!card) return [];
if (card.target === 'blockedCell') return blockedCellTargets(game); if (card.target === 'blockedCell') return blockedCellTargets(game);
if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4); 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 === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : []; if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : [];
if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : []; if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : [];
@ -338,16 +164,26 @@ export function targetsForCard(game, cardOrId) {
function cardAtCap(game, card) { function cardAtCap(game, card) {
const effects = ensureCardState(game); const effects = ensureCardState(game);
if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10; 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 === '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 === 'laborExploitation') return !!effects.laborExploitation;
if (card.id === 'usedMachine') return !!effects.usedMachineActive; if (card.id === 'usedMachine') return !!effects.usedMachineActive;
if (card.id === 'newMachine') return !effects.usedMachineActive; if (card.id === 'newMachine') return !effects.usedMachineActive;
return false; return false;
} }
function repairmanCardVisible(game) {
const r = game.repairman || {};
return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0);
}
function availableCards(game) { function availableCards(game) {
return CARD_DEFS.filter(card => { return CARD_DEFS.filter(card => {
if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false;
if (cardAtCap(game, card)) return false; if (cardAtCap(game, card)) return false;
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false; if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
return true; return true;
@ -382,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]; const result = [...choices];
let duds = 0; let duds = 0;
const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 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) { for (let i = 0; i < result.length && duds < maxDuds; i += 1) {
if (Math.random() < chance) { if (Math.random() < chance) {
result[i] = dudCard(); result[i] = dudCard();
@ -409,13 +252,14 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) {
const i = pool.findIndex(card => card.id === picked.id); const i = pool.findIndex(card => card.id === picked.id);
if (i >= 0) pool.splice(i, 1); if (i >= 0) pool.splice(i, 1);
} }
return applyDudsToChoices(choices); return applyDudsToChoices(game, choices);
} }
function targetKey(target) { function targetKey(target) {
if (!target) return ''; if (!target) return '';
if (target.type === 'blockedCell') return `blockedCell:${target.id}`; if (target.type === 'blockedCell') return `blockedCell:${target.id}`;
if (target.type === 'eggFarm') return `eggFarm:${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 === 'scanner') return `scanner:${target.id}`;
if (target.type === 'facility') return `facility:${target.id}`; if (target.type === 'facility') return `facility:${target.id}`;
return `${target.type}:${target.id}`; return `${target.type}:${target.id}`;
@ -427,7 +271,7 @@ function boundsForTarget(target) {
const c = cellCenter(target.col, target.row); 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 }; 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); 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 }; return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
} }
@ -460,6 +304,15 @@ function targetAtPoint(game, p) {
} }
function applyEquipmentUpgrade(game, target) { 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; target.level = (target.level || 1) + 1;
if (target.type === 'eggFarm') { if (target.type === 'eggFarm') {
target.nextSpawn = nextSpawnDelay(target); target.nextSpawn = nextSpawnDelay(target);
@ -484,12 +337,12 @@ function applyInstantCard(game, card) {
if (card.id === 'bearing') inc('bearing'); if (card.id === 'bearing') inc('bearing');
if (card.id === 'legalWork') inc('legalWork'); if (card.id === 'legalWork') inc('legalWork');
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1); 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 === 'hatchingFeed') inc('hatchingFeed');
if (card.id === 'safetyCover') inc('safetyCover'); if (card.id === 'safetyCover') inc('safetyCover');
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1); if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy'); if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance'); if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
if (card.id === 'dudFilter') inc('dudFilter');
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); } if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
if (card.id === 'flattery') inc('fairiesFlatteryNext'); if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true; if (card.id === 'usedMachine') effects.usedMachineActive = true;
@ -560,12 +413,24 @@ function cardDescription(game, card) {
const next = Math.min(10, (e.crowdedFarming || 0) + 1); 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))}.`; 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 === '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 === '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 === '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 === '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 === '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 === '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 === '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.'; if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';

View file

@ -1,8 +1,8 @@
import { GRID, THEME } from '../core/config.js'; import { DIRS, GRID, THEME } from '../core/config.js';
import { createChick } from '../core/entities.js'; import { createChick } from '../core/entities.js';
import { nextSpawnDelay } from '../core/state.js'; import { nextSpawnDelay } from '../core/state.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.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 { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js'; import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
@ -17,35 +17,23 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function spawnChick(farm) { function spawnChick(farm) {
const farmCenter = cellCenter(farm.col, farm.row); const farmCenter = cellCenter(farm.col, farm.row);
const maxOutputs = 1 + extraEggOutletCount(game); const maxOutputs = 1 + extraEggOutletCount(game, farm);
const routes = []; const starts = farmOutputStarts(farm, maxOutputs);
const usedStarts = new Set(); if (!starts.length) {
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) {
suppressUnconnectedFarmSpawn(farm, farmCenter); suppressUnconnectedFarmSpawn(farm, farmCenter);
return true; return true;
} }
let spawned = 0; let spawned = 0;
let blocked = 0; let blocked = 0;
for (const data of routes) { for (const startCell of starts) {
const start = data.route[0]; const start = cellCenter(startCell.col, startCell.row);
const blocker = spawnTileBlocker(start); const blocker = spawnTileBlocker(start);
if (blocker) { if (blocker) {
blocker.stoppedTimer = 0.85; blocker.stoppedTimer = 0.85;
blocked += 1; blocked += 1;
continue; continue;
} }
const chick = createChick(game, data); const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
if (chick.sex === 'poop') countPoopSpawned(game); if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick); game.chicks.push(chick);
recordFarmProduction(game, farm); 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) { function spawnTileBlocker(start) {
return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null; return 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); moveAlongRoute(chick, dt);
if (chick.route && chick.targetIndex < chick.route.length) return; if (chick.route && chick.targetIndex < chick.route.length) return;
if (chick.stage === 'input' || chick.stage === 'toScanner') { if (chick.pendingScannerId) {
const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId); const scanner = scannerById(game, chick.pendingScannerId);
if (!scanner) { chick.pendingScannerId = null;
removeChick(index, 'NO SCANNER'); if (!scanner) removeChick(index, 'NO SCANNER');
return; else enqueueChick(chick, scanner, chick.route);
}
enqueueChick(chick, scanner, chick.route);
return; return;
} }
if (chick.stage === 'toMixer') resolveMixer(index); advanceByCurrentConveyor(chick, index);
else if (chick.stage === 'toTruck') resolveTruck(index);
else if (chick.stage === 'toTrash') resolveTrash(index);
} }
function blockedByFrontChick(chick) { function blockedByFrontChick(chick) {
if (!chick.route || chick.targetIndex >= chick.route.length) return false; if (!chick.route || chick.targetIndex >= chick.route.length) return false;
const next = chick.route[chick.targetIndex]; const next = chick.route[chick.targetIndex];
return targetBlockedByChick(chick, next);
}
function targetBlockedByChick(chick, target) {
if (!target) return false;
for (const other of game.chicks) { for (const other of game.chicks) {
if (other.id === chick.id || other.stage === 'flying') continue; if (chick && other.id === chick.id) continue;
if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true; if (other.stage === 'flying') continue;
if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true;
} }
return false; return false;
} }
function routeProgress(chick) { function appendPoint(points, p) {
return chick.targetIndex || 0; 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) { function conveyorKeyFromPoint(p) {
@ -341,13 +438,35 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return bonus; 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) { function sortChickByIndex(index, side, auto) {
const chick = game.chicks[index]; const chick = game.chicks[index];
if (!chick || chick.stage !== 'queued') return false; if (!chick || chick.stage !== 'queued') return false;
const scanner = scannerById(game, chick.scannerId); const scanner = scannerById(game, chick.scannerId);
if (!scanner) return false; if (!scanner) return false;
const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true); const startCell = scannerOutputStartCell(scanner, side);
if (!plan) { if (!startCell) {
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner); if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger);
return false; return false;
@ -357,14 +476,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
else resetManualCombo(scanner); else resetManualCombo(scanner);
} }
scanner.queue = scanner.queue.filter(id => id !== chick.id); 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.stage = 'belt';
chick.route = plan.route; chick.route = scannerReleaseRoute(chick, scanner, side, startCell);
chick.targetIndex = 1; chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length;
chick.nextScannerId = plan.nextScannerId || null; chick.scannerId = null;
chick.nextScannerId = null;
chick.pendingScannerId = null;
chick.queueRoute = null; chick.queueRoute = null;
chick.queueIndex = -1; chick.queueIndex = -1;
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination)); const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink);
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination)); scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor);
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor);
return true; return true;
} }

View file

@ -230,12 +230,15 @@ export function settleTruckRevenue(game) {
return { base, adjusted, target, targetCount, unitPrice }; return { base, adjusted, target, targetCount, unitPrice };
} }
export function collectZundaTax(game, basisCash = game.cash) { export function collectZundaTax(game, basisProfit = 0) {
const info = zundaTaxInfo(basisCash, game); const info = zundaTaxInfo(basisProfit, game);
if (info.tax <= 0) return info; if (info.tax <= 0) return info;
game.stats.zundaTax += info.tax; // ZUNDA TAX is settled between days. It reduces cash and lifetime totals,
game.totals.zundaTax += info.tax; // but it is intentionally excluded from the next day's NET display.
applyPenalty(game, info.tax); 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; return info;
} }

View file

@ -1,3 +1,5 @@
import { BALANCE } from '../core/balance.js';
import { GRID } from '../core/config.js';
export function cleanScanner(scanner) { export function cleanScanner(scanner) {
return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 }; return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 };
} }
@ -7,6 +9,16 @@ export function snapshot(game) {
stats: game.stats, stats: game.stats,
totals: game.totals, totals: game.totals,
nextId: game.nextId, 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, selected: game.selected,
multiSelected: game.multiSelected || [], multiSelected: game.multiSelected || [],
buildTool: game.buildTool, buildTool: game.buildTool,
@ -28,10 +40,24 @@ export function restore(game, text) {
game.stats = data.stats; game.stats = data.stats;
game.totals = data.totals; game.totals = data.totals;
game.nextId = data.nextId; 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.selected = data.selected;
game.multiSelected = data.multiSelected || []; game.multiSelected = data.multiSelected || [];
game.buildTool = data.buildTool; 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.facilities = data.facilities || {};
game.eggFarms = data.eggFarms || []; game.eggFarms = data.eggFarms || [];
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 })); game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));

View file

@ -94,6 +94,14 @@ export function refreshRoutingAfterEdit(game) {
function graphNodeKey(col, row) { return key(col, row); } function graphNodeKey(col, row) { return key(col, row); }
function graphCell(k) { return parseKey(k); } 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) { function emptyFactoryGraph(game) {
return { return {
version: game.routingVersion || 0, version: game.routingVersion || 0,
@ -132,7 +140,9 @@ export function buildFactoryGraph(game) {
for (const k of graph.cells) { for (const k of graph.cells) {
const p = graphCell(k); 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); 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 }); if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name });
} }
@ -270,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 }))); if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p })));
const startState = `${startK}|none`; const startState = `${startK}|none`;
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }]; const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none', depth: 0 }];
const visited = new Set([startState]); const depthByState = new Map([[startState, 0]]);
const parent = new Map(); const parents = new Map();
const found = []; const found = [];
let bestGoalDepth = Infinity;
const maxParentsPerState = 8;
const maxGoalStates = 24;
const maxPaths = 80;
while (queue.length) { while (queue.length) {
const cur = queue.shift(); const cur = queue.shift();
if (cur.depth > bestGoalDepth) break;
const curStateK = `${cur.key}|${cur.incoming}`; 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) || []) { for (const n of graph.adjacency.get(cur.key) || []) {
const next = graphCell(n.key); const next = graphCell(n.key);
const outDir = dirBetween(cur, next); const outDir = dirBetween(cur, next);
if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue; 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}`; const nextStateK = `${n.key}|${outDir}`;
if (visited.has(nextStateK)) continue; const knownDepth = depthByState.get(nextStateK);
visited.add(nextStateK); if (knownDepth == null) {
parent.set(nextStateK, curStateK); depthByState.set(nextStateK, nextDepth);
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir }); 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 = []; const paths = [];
let cursor = goalState; const expand = (state, suffix) => {
while (cursor) { if (paths.length >= maxPaths) return;
const [cellK] = cursor.split('|'); const [cellK] = state.split('|');
reversed.push(graphCell(cellK)); const nextSuffix = [graphCell(cellK), ...suffix];
if (cellK === startK) break; if (state === startState || cellK === startK) {
cursor = parent.get(cursor); paths.push(nextSuffix);
return;
} }
return reversed.reverse(); for (const parent of parents.get(state) || []) expand(parent, nextSuffix);
}); };
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p })))); for (const goalState of found) expand(goalState, []);
return paths;
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) { export function buildConveyorComponents(game) {
@ -363,6 +403,25 @@ function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) {
return points; 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) { export function chooseRoundRobin(game, id, candidates, advance = false) {
if (!candidates.length) return null; if (!candidates.length) return null;
const sorted = [...candidates].sort((a, b) => { const sorted = [...candidates].sort((a, b) => {
@ -376,9 +435,14 @@ export function chooseRoundRobin(game, id, candidates, advance = false) {
const kb = b.key || JSON.stringify(b.route || b.cells || b); const kb = b.key || JSON.stringify(b.route || b.cells || b);
return ka.localeCompare(kb); 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; const n = game.branchCounters.get(id) || 0;
if (advance) game.branchCounters.set(id, n + 1); game.branchCounters.set(id, n + 1);
return sorted[n % sorted.length]; return pool[Math.floor(Math.random() * pool.length)];
} }
export function scannerRules(scanner) { export function scannerRules(scanner) {

View file

@ -159,6 +159,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (origin.type !== 'facility') continue; if (origin.type !== 'facility') continue;
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter); const { entry, side } = nearestGridEdge(nextCenter);
if (!pointInGrid(entry.col, entry.row)) return true;
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true; if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
const draft = { ...origin.obj, entry: { ...entry }, side }; const draft = { ...origin.obj, entry: { ...entry }, side };
layoutFacilityOnEdge(draft, entry, side); layoutFacilityOnEdge(draft, entry, side);

View file

@ -22,6 +22,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
hasBuildableOption = true; hasBuildableOption = true;
if (game.cash >= buildPrice(id, game)) return false; if (game.cash >= buildPrice(id, game)) return false;
} }
if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false;
return hasBuildableOption; return hasBuildableOption;
} }
@ -60,6 +61,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
: ''; : '';
} }
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; if (ui.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) { if (ui.hireRepairmanButton) {
const hired = !!game.repairman?.hiredForNextDay; const hired = !!game.repairman?.hiredForNextDay;
const cost = repairmanDailyCost(game); const cost = repairmanDailyCost(game);
@ -67,7 +77,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.hireRepairmanButton.disabled = blocked; ui.hireRepairmanButton.disabled = blocked;
ui.hireRepairmanButton.classList.toggle('active', hired); ui.hireRepairmanButton.classList.toggle('active', hired);
ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost); 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(); const cashout = noAffordableBuildTools();
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout); ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
@ -93,21 +103,22 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updateTurnSummary() { function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length; const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game); 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 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 tribute = fairiesTributeInfo(game, tributeDay);
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null; const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0; const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
ui.turnSummary.innerHTML = [ 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>`, `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>`, `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 lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}` ? `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)}` : ''}`, : `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>`, `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>`,
`Manual combo: <b>${game.manualCombo?.count || 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>`, `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>' : '', 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>` issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
].join('<br>'); ].join('<br>');
@ -156,6 +167,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
: `${Math.max(0, game.timeLeft).toFixed(1)}s`; : `${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.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0)));
ui.phase.textContent = phaseLabel(); ui.phase.textContent = phaseLabel();
if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0;
updatePriorityStrip(); updatePriorityStrip();
ui.turnProfit.textContent = yen(game.stats.profit); ui.turnProfit.textContent = yen(game.stats.profit);
ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0); ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
@ -178,7 +190,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`; ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`;
ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`; ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`;
} }
const nextTax = zundaTaxInfo(game.cash, game).tax; 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.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); ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
} }

View file

@ -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); } .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; } #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 { 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-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 { 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 { 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.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 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 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; } .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 { 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; } .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); } .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; } .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 { 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.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 { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); }
@ -479,3 +549,156 @@ body { font-size: 18px; }
.build-panel { width: auto; } .build-panel { width: auto; }
.control-dock { grid-template-columns: repeat(2, 172px); } .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; }
}