This commit is contained in:
33333-33333 2026-06-09 15:19:38 +09:00
commit 82a6b2c787
13 changed files with 215 additions and 103 deletions

46
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,7 @@ export const BALANCE = {
fairiesTribute: {
perDay: 10
},
incomeUpgradeRate: 1.05,
incomeUpgradeRate: 1.10,
explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30,
shredderBonus: {
@ -205,6 +205,14 @@ export const BALANCE = {
description: 'Turns shredder waste into suspicious fertilizer. Gain JPY 3 per copy when SHREDDER processes poop.',
tags: ['POOP', 'ECONOMY', 'PASSIVE']
},
{
id: 'scannerQueueSpacing',
title: 'Scanner Queue Spacing',
rarity: 'common',
type: 'instant',
description: 'Manual and Auto Scanner queue spacing -15%. No card limit.',
tags: ['SCANNER', 'PASSIVE']
},
{
id: 'extraEggOutlet',
title: 'Extra Egg Outlet',
@ -321,6 +329,7 @@ export const BALANCE = {
dudRefund: 0,
freeReroll: 0,
composter: 0,
scannerQueueSpacing: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
@ -369,7 +378,7 @@ export const BALANCE = {
buildable: true, upgradeable: false
},
boostConveyor: {
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 50,
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
buildable: true, upgradeable: false, speedMultiplier: 2
},
eggFarm: {

View file

@ -141,7 +141,7 @@ export function createGame() {
lastExplodedComponent: new Map(),
contractOffer: null,
contractActive: null,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 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, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 },

View file

@ -3,7 +3,7 @@ import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
import { expansionLots, lotBounds } from './core/gridExpansion.js';
import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
import { commitFactoryGraphForDay, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { undo, redo } from './systems/history.js';
import { drawAll } from './render/draw.js';
@ -385,10 +385,11 @@ function resetCameraToFactoryStart() {
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 startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y, moved: false }; }
function updatePan(event) {
if (!game.pan) return;
const p = rawCanvasPoint(event);
if (Math.hypot(p.x - game.pan.start.x, p.y - game.pan.start.y) > 5) game.pan.moved = true;
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;
@ -487,6 +488,8 @@ function chickDisplay(chick) {
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 inputCell = scannerConnector(scanner, 'inputA');
const inputKey = inputCell ? key(inputCell.col, inputCell.row) : null;
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
const activeSex = chick?.sex || '';
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
@ -500,7 +503,7 @@ function miniScannerGrid(scanner, chick) {
classes.push('scanner');
if (activeSex) classes.push(activeSex);
label = activeLabel || 'S';
} else if (row === scanner.row - 1 && col === scanner.col) {
} else if (k === inputKey) {
classes.push('input');
label = 'IN';
} else if (farmCells.has(k)) {
@ -589,6 +592,14 @@ canvas.addEventListener('pointerdown', event => {
const expansionOffer = build.expansionOfferAtPoint?.(world);
if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; }
if (build.cycleBranchModeAtPoint?.(world)) { uiSystem.updatePanels(); uiSystem.updateUI(); return; }
const clickedCell = pointToCell(world.x, world.y);
const clickedHit = build.equipmentAtPoint(world);
if (!game.buildTool && clickedHit?.type === 'conveyor' && clickedCell && build.cycleSingleConveyorDirection?.(clickedCell)) {
uiSystem.updatePanels();
uiSystem.updateUI();
build.showSelectedMenu?.();
return;
}
if (game.buildTool === 'erase') {
if (!build.equipmentAtPoint(world)) { cancelBuildAction(); return; }
build.eraseAtPoint(world);
@ -624,6 +635,7 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag();
}
if (event.button === 2 && game.pan && !game.pan.moved && game.buildTool) cancelBuildAction();
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }

View file

@ -1,7 +1,7 @@
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { key, parseKey, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { getConveyorNeighbors, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
import { cardTargetBounds } from '../systems/cards.js';
import { wearRatio } from '../systems/maintenance.js';
@ -270,7 +270,7 @@ function drawConveyors(ctx, game) {
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta });
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
drawBranchModeLabel(ctx, c, meta, reachableConveyorDirs(game, k, meta));
drawBranchModeLabel(ctx, c, meta, reachableConveyorDirs(game, k, meta), connectedConveyorDirs(game, k));
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
}
ctx.restore();
@ -295,65 +295,74 @@ function reachableConveyorDirs(game, k, meta) {
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name) && !neighborPointsIntoCell(game, p, name));
const inferred = neighborNames.filter(name => !neighborPointsIntoCell(game, p, name));
.filter(name => neighborNames.includes(name));
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
}
function connectedConveyorDirs(game, k) {
const p = parseKey(k);
return DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
}
function effectiveConveyorDirs(game, k, meta) {
const p = parseKey(k);
const neighborNames = DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name) && !neighborPointsIntoCell(game, p, name));
const inferred = neighborNames.filter(name => !neighborPointsIntoCell(game, p, name));
.filter(name => neighborNames.includes(name));
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
}
function drawBranchModeLabel(ctx, center, meta, dirs) {
if (dirs.length < 3) return;
function drawBranchModeLabel(ctx, center, meta, dirs, connectedDirs = dirs) {
if (connectedDirs.length < 3 || dirs.length < 2) return;
ctx.save();
ctx.font = '900 11px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.fillStyle = 'rgba(255,255,255,.96)';
const mode = meta.branchMode === 'pass' && dirs.length >= 4 ? 'pass' : (dirs.includes(meta.branchMode) ? meta.branchMode : 'random');
ctx.strokeStyle = mode !== 'random' ? THEME.warn : THEME.green;
rect(ctx, center.x - 24, center.y + 11, 48, 24, true, true);
ctx.fillStyle = mode !== 'random' ? THEME.warn : THEME.ink;
const isFixed = mode !== 'random';
const text = branchModeText(mode);
ctx.fillText(text, center.x, center.y + 27);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = 4;
ctx.fillStyle = 'rgba(255,255,255,.9)';
ctx.strokeStyle = isFixed ? THEME.warn : THEME.green;
rect(ctx, center.x - 28, center.y - 19, 56, 38, true, true);
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(center.x - 18, center.y);
ctx.lineTo(center.x + 18, center.y);
ctx.moveTo(center.x, center.y - 13);
ctx.lineTo(center.x, center.y + 13);
ctx.stroke();
ctx.fillStyle = isFixed ? THEME.warn : THEME.green;
ctx.beginPath();
ctx.arc(center.x, center.y, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = '900 9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = THEME.ink;
ctx.fillText(text, center.x, center.y + 0.5);
ctx.restore();
}
function cellKeyFromPoint(game, p) {
const cell = pointToCell(p.x, p.y);
if (!cell) return null;
const center = cellCenter(cell.col, cell.row);
if (Math.hypot(center.x - p.x, center.y - p.y) > GRID.cell * 0.38) return null;
const k = key(cell.col, cell.row);
return game.conveyorTiles.has(k) ? k : null;
}
function addMarkerFromRoute(game, markers, route) {
if (!route || route.length < 2) return;
for (let i = 0; i < route.length - 1; i += 1) {
const a = route[i], b = route[i + 1];
const ak = cellKeyFromPoint(game, a);
const bk = cellKeyFromPoint(game, b);
if (!ak || !bk || ak === bk) continue;
const ac = parseKey(ak), bc = parseKey(bk);
const dc = bc.col - ac.col, dr = bc.row - ac.row;
if (Math.abs(dc) + Math.abs(dr) !== 1) continue;
const angle = Math.atan2(dr, dc);
if (!markers.has(ak)) markers.set(ak, []);
const list = markers.get(ak);
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
}
}
function explicitConveyorAngles(game, k, meta) {
if (!meta) return [];
return effectiveConveyorDirs(game, k, meta)
const p = parseKey(k);
const neighborNames = DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name));
const dirs = explicit.length ? explicit : effectiveConveyorDirs(game, k, meta).slice(0, 1);
return dirs
.map(name => DIRS.find(d => d.name === name)?.angle)
.filter(angle => Number.isFinite(angle));
}
@ -364,10 +373,6 @@ function collectConveyorDirectionMarkers(game) {
const angles = explicitConveyorAngles(game, k, meta);
if (angles.length) markers.set(k, angles);
}
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
for (const scanner of game.scanners) {
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
}
return markers;
}
function drawConveyorDirectionMarkers(ctx, center, angles) {

View file

@ -23,7 +23,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
let conveyorDragMoved = false;
const BRANCH_MODES = ['random', 'pass', 'up', 'right', 'down', 'left'];
const BRANCH_LABELS = { random: 'RND', pass: 'PASS', up: 'UP', right: 'RT', down: 'DN', left: 'LF' };
const BRANCH_MARKER = { w: 48, h: 24, y: 23 };
const BRANCH_MARKER = { w: 56, h: 38, y: 0 };
function gridExpansionCost() { return expansionCost(game); }
@ -243,8 +243,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const explicit = normalizeOutDirs(meta);
const neighbors = conveyorNeighborOutDirs(cell);
const explicitReachable = [...new Set([...explicit, meta?.dir].filter(Boolean))]
.filter(dir => neighbors.includes(dir) && !neighborPointsIntoCell(cell, dir));
const inferredExits = neighbors.filter(dir => !neighborPointsIntoCell(cell, dir));
.filter(dir => neighbors.includes(dir));
const inferredExits = neighbors;
const reachable = explicitReachable.length ? explicitReachable : inferredExits;
if (meta) {
meta.outDirs = explicit.filter(dir => neighbors.includes(dir));
@ -273,7 +273,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function availableConveyorOutDirs(cell) {
if (!cell) return [];
return reachableOutDirsForKey(key(cell.col, cell.row));
const neighbors = conveyorNeighborOutDirs(cell);
return neighbors.length ? neighbors : DIRS.map(d => d.name);
}
function cycleSingleConveyorDirection(cell) {
@ -709,10 +710,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
ui.modalTitle.textContent = selectedTitle(obj);
ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div>
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
${['mixer', 'truck'].includes(obj.baseId || obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base x 1.10^upgrades)</p>' : ''}`;
ui.modalActions.innerHTML = '';
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'conveyor' && isBranchKey(obj.id)) {
ui.modalActions.appendChild(modalButton(`Branch: ${branchModeLabel(game.conveyorMeta.get(obj.id)?.branchMode || 'random')}`, () => { cycleBranchModeForKey(obj.id); showSelectedMenu(); }, 'facility-action warn'));
}
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) {
@ -764,8 +768,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function isBranchKey(k) {
const dirs = branchOutDirsForKey(k);
return dirs.length >= 3;
if (!game.conveyorTiles.has(k)) return false;
return conveyorNeighborOutDirs(parseKey(k)).length >= 3;
}
function branchSwitcherAtPoint(p) {
@ -784,6 +788,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function cycleBranchModeAtPoint(p) {
const k = branchSwitcherAtPoint(p);
if (!k) return false;
return cycleBranchModeForKey(k);
}
function cycleBranchModeForKey(k) {
const meta = game.conveyorMeta.get(k);
if (!meta) return false;
const reachable = reachableOutDirsForKey(k);
@ -829,8 +837,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Type: ${meta?.kind === 'boostConveyor' ? 'BOOST BELT' : 'Normal Belt'} | Speed x${Number(meta?.speedMultiplier || 1).toFixed(1)}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
if (isBranchKey(obj.id)) {
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Click the branch marker to cycle Random / Up / Right / Down / Left.');
lines.push('Use the Branch button below or the on-belt branch badge.');
} else {
lines.push('Branch mode: needs 3+ conveyor exits.');
}
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
@ -891,7 +903,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
selectedInfoLines, flavorText, disconnectedWarningFor,
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
branchSwitcherAtPoint, cycleBranchModeAtPoint,
cycleSingleConveyorDirection,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey,
removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,

View file

@ -45,6 +45,10 @@ export function extraEggOutletCount(_game, farm = null) {
return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0));
}
export function scannerQueueSpacingMultiplier(game) {
return Math.pow(0.85, effectCount(game, 'scannerQueueSpacing'));
}
export function conveyorSpeedForGame(game, conveyorKey = null) {
const effects = ensureCardState(game);
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
@ -114,7 +118,7 @@ function targetLabel(game, target) {
if (target.type === 'scanner') {
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
}
if (target.type === 'facility' && target.id === 'mixer') {
if (target.type === 'facility' && (target.baseId || target.id) === 'mixer') {
const beforeLevel = target.level;
const before = upgradedMixerPrice(game);
target.level = beforeLevel + 1;
@ -122,7 +126,7 @@ function targetLabel(game, target) {
target.level = beforeLevel;
return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
}
if (target.type === 'facility' && target.id === 'truck') {
if (target.type === 'facility' && (target.baseId || target.id) === 'truck') {
const beforeLevel = target.level;
const before = upgradedTruckPrice(game);
target.level = beforeLevel + 1;
@ -370,6 +374,7 @@ function applyInstantCard(game, card) {
if (card.id === 'dudRefund') inc('dudRefund');
if (card.id === 'freeReroll') inc('freeReroll');
if (card.id === 'composter') inc('composter');
if (card.id === 'scannerQueueSpacing') inc('scannerQueueSpacing');
if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true;
if (card.id === 'newMachine') effects.usedMachineActive = false;
@ -400,6 +405,18 @@ function incomeAtLevel(base, level) {
return Math.ceil(base * Math.pow(ECONOMY.incomeUpgradeRate, Math.max(0, level - 1)));
}
function incomeUpgradePreview(game, id, base) {
const targets = targetsForCard(game, id === 'mixer' ? 'upgradeMixer' : 'upgradeTruck');
const target = targets[0];
if (!target) return { before: incomeAtLevel(base, 1), after: incomeAtLevel(base, 2) };
const before = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
const beforeLevel = target.level || 1;
target.level = beforeLevel + 1;
const after = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
target.level = beforeLevel;
return { before, after };
}
function cardDescription(game, card) {
const e = ensureCardState(game);
if (card.id === 'upgradeEgg') {
@ -414,12 +431,12 @@ function cardDescription(game, card) {
return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`;
}
if (card.id === 'upgradeMixer') {
const level = game.facilities?.mixer?.level || 1;
return `MIXER level +1. Male-chick income ${yen(incomeAtLevel(ECONOMY.income.mixer, level))} -> ${yen(incomeAtLevel(ECONOMY.income.mixer, level + 1))}.`;
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.mixer);
return `Choose one MIXER. Earning +10%. Male-chick income ${yen(preview.before)} -> ${yen(preview.after)}.`;
}
if (card.id === 'upgradeTruck') {
const level = game.facilities?.truck?.level || 1;
return `TRUCK level +1. Correct shipment income ${yen(incomeAtLevel(ECONOMY.income.truck, level))} -> ${yen(incomeAtLevel(ECONOMY.income.truck, level + 1))}.`;
const preview = incomeUpgradePreview(game, 'truck', ECONOMY.income.truck);
return `Choose one TRUCK. Earning +10%. Correct shipment income ${yen(preview.before)} -> ${yen(preview.after)}.`;
}
if (card.id === 'upgradeTrash') {
const before = shredderUpgradeCount(game);
@ -462,6 +479,7 @@ function cardDescription(game, card) {
if (card.id === 'dudRefund') return `Held ${e.dudRefund || 0}. After pick: DUD clicks award ${yen(50 * ((e.dudRefund || 0) + 1))}.`;
if (card.id === 'freeReroll') return `Held ${e.freeReroll || 0}. After pick: each card screen starts with ${(e.freeReroll || 0) + 1} free reroll${(e.freeReroll || 0) + 1 === 1 ? '' : 's'}.`;
if (card.id === 'composter') return `Held ${e.composter || 0}. After pick: SHREDDER poop grants ${yen(3 * ((e.composter || 0) + 1))}.`;
if (card.id === 'scannerQueueSpacing') return `Held ${e.scannerQueueSpacing || 0}. After pick: Manual/Auto Scanner queue spacing ${formatPercent(Math.pow(0.85, (e.scannerQueueSpacing || 0) + 1))}.`;
if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`;
if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';

View file

@ -4,7 +4,7 @@ import { nextSpawnDelay } from '../core/state.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid, directionNameBetweenCells } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount, scannerQueueSpacingMultiplier } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
@ -104,6 +104,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
updateScannerQueues(dt);
updateCongestion();
checkCongestionExplosions();
if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn();
else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup();
@ -176,8 +177,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const inferred = DIRS
.filter(d => movementOptionFromDir(cell, d.name))
.map(d => d.name);
const cleanExplicit = explicit.filter(dir => inferred.includes(dir) && !neighborPointsIntoCell(cell, dir));
const inferredExits = inferred.filter(dir => !neighborPointsIntoCell(cell, dir));
const cleanExplicit = explicit.filter(dir => inferred.includes(dir));
const inferredExits = inferred;
return cleanExplicit.length ? cleanExplicit : inferredExits;
}
@ -354,7 +355,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function positionScannerQueue(scanner) {
scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued'));
const spacing = GRID.cell * 0.86;
const spacing = GRID.cell * 0.86 * scannerQueueSpacingMultiplier(game);
scanner.queue.forEach((id, idx) => {
const chick = game.chicks.find(ch => ch.id === id);
if (!chick) return;
@ -683,6 +684,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
}
if (comp.ratio >= 1) {
const victims = chicksInComponent(comp.id);
if (!victims.length || victims.some(chick => chick.stage !== 'queued' && chick.stoppedTimer <= 0.2)) continue;
const last = game.lastExplodedComponent.get(comp.id) || 0;
const now = performance.now();
if (now - last > 900) {

View file

@ -34,7 +34,10 @@ export function buildPrice(id, game = null) {
}
export function facilityUpgradeCount(game, id) {
return Math.max(0, (game.facilities?.[id]?.level || 1) - 1);
if (!game?.facilities) return 0;
return Object.values(game.facilities)
.filter(f => (f?.baseId || f?.id) === id)
.reduce((sum, f) => sum + Math.max(0, (f.level || 1) - 1), 0);
}
export function incomeMultiplier(game, id) {

View file

@ -64,7 +64,7 @@ export function restore(game, text) {
game.conveyorTiles = new Set(data.conveyorTiles || []);
game.conveyorMeta = new Map(data.conveyorMeta || []);
game.branchCounters = new Map(data.branchCounters || []);
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, flattery: 0 };
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 };
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
game.cardDraft = data.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;

View file

@ -72,7 +72,8 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
// -----------------------------------------------------------------------------
export function scannerInputCells(game, scanner) {
const connector = scannerConnector(scanner, 'inputA');
return visualPortConveyorCells(game, connector, scannerFootprintCells(scanner));
if (!connector || !inGrid(connector.col, connector.row)) return [];
return game.conveyorTiles.has(key(connector.col, connector.row)) ? [{ ...connector, viaTolerance: false }] : [];
}
function outputStartCells(game, scanner, side) {
@ -135,8 +136,8 @@ function inferredConveyorOutDirNames(game, cellKey) {
const neighbors = DIRS
.filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir) && !neighborPointsIntoCell(game, p, dir));
const inferred = neighbors.filter(dir => !neighborPointsIntoCell(game, p, dir));
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
const inferred = neighbors;
return explicit.length ? explicit : inferred;
}
@ -229,7 +230,9 @@ function indexGraphPorts(game, graph) {
const ports = {};
for (const type of ['inputA', 'left', 'right']) {
const cell = scannerConnector(scanner, type);
const cells = graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
const cells = type === 'inputA'
? (cell && graph.cells.has(graphNodeKey(cell.col, cell.row)) ? [{ ...cell }] : [])
: graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
const connected = cellKeys.length > 0;

View file

@ -214,7 +214,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function showTitle() {
ui.modalTitle.textContent = TEXT.versionTitle;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">髫ィ蜷カ繝サ/span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">髫ィ蜊債€</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">・滓ィ頑c</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">♂</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">♀</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">💩</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button'));
ui.modal.classList.remove('equipment-popover', 'gameover-modal');

View file

@ -588,6 +588,52 @@ body { font-size: 20px; }
.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; }
/* Keep construction flavor text readable as a hover bubble, not button content. */
.large-tools .tool-button {
min-height: 62px;
position: relative;
overflow: visible;
}
.large-tools .tool-button:hover {
min-height: 62px;
}
.large-tools .tool-button .tool-flavor {
display: none;
position: absolute;
z-index: 90;
right: 0;
top: calc(100% + 8px);
width: min(320px, calc(100vw - 40px));
max-height: none;
opacity: 1;
overflow: visible;
margin: 0;
padding: 10px 12px;
border: 3px solid var(--line);
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 14px;
line-height: 1.35;
pointer-events: none;
overflow-wrap: anywhere;
}
.large-tools .tool-button:hover .tool-flavor {
display: block;
}
.large-tools .tool-button .tool-flavor::before {
content: '';
position: absolute;
right: 18px;
top: -10px;
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
}
.hover-tooltip span { font-size: 15px; }
.sort-button { font-size: 17px; }