/** * conveyor.js * Left conveyor belts (3×, goods travel → right). * Right conveyor belt (1×, medication travels ← left). * Pure logic — no rendering, no DOM. */ /* ────────────────────────────── Good ──────────────────────────── */ const GoodState = Object.freeze({ SLIDING: 'sliding', // moving along belt READY: 'ready', // at belt's right end, waiting for pickup COLLECTED: 'collected', // grabbed by player DROPPED: 'dropped', // fell — penalty already applied }); class Good { constructor(beltRow, isSpecial) { this.id = Good._nextId++; this.beltRow = beltRow; this.isSpecial = isSpecial; this.x = SETTINGS.LEFT_ZONE_START + SETTINGS.GOOD_SIZE / 2; this.y = SETTINGS.BELT_ROWS[beltRow]; this.state = GoodState.SLIDING; this.waitTimer = 0; } } Good._nextId = 0; /* ────────────────────────── LeftConveyorSystem ─────────────────── */ class LeftConveyorSystem { constructor() { // Belt strip animation offset (px, wraps at 40) this.animOffset = 0; // Per-belt state: goods array + countdown to next spawn this.belts = SETTINGS.BELT_INIT_OFFSETS.map(offset => ({ goods: [], spawnTimer: offset, })); } _randomSpawnTime() { return SETTINGS.BELT_MIN_SPAWN + Math.random() * (SETTINGS.BELT_MAX_SPAWN - SETTINGS.BELT_MIN_SPAWN); } /** * Update all belts. * @param {number} dt – delta time in ms * @param {function} onDrop – called with (good) when a good drops off */ update(dt, onDrop) { this.animOffset = (this.animOffset + SETTINGS.LEFT_BELT_SPEED * dt / 1000) % 40; this.belts.forEach((belt, row) => { // ── Spawn ── belt.spawnTimer -= dt; if (belt.spawnTimer <= 0) { belt.spawnTimer = this._randomSpawnTime(); const isSpecial = Math.random() < SETTINGS.SPECIAL_GOOD_CHANCE; belt.goods.push(new Good(row, isSpecial)); } // ── Move & age goods ── belt.goods.forEach(good => { if (good.state === GoodState.SLIDING) { good.x += SETTINGS.LEFT_BELT_SPEED * dt / 1000; const pickupX = SETTINGS.LEFT_ZONE_END - SETTINGS.GOOD_SIZE * 0.5; if (good.x >= pickupX) { good.x = pickupX; good.state = GoodState.READY; good.waitTimer = SETTINGS.GOOD_WAIT_TIME; } } else if (good.state === GoodState.READY) { good.waitTimer -= dt; if (good.waitTimer <= 0) { good.state = GoodState.DROPPED; onDrop(good); } } }); // ── Prune finished goods ── belt.goods = belt.goods.filter( g => g.state === GoodState.SLIDING || g.state === GoodState.READY ); }); } /** * Try to collect the READY good on the given row. * Returns the Good object on success, null otherwise. */ tryCollect(row) { const belt = this.belts[row]; const good = belt.goods.find(g => g.state === GoodState.READY); if (!good) return null; good.state = GoodState.COLLECTED; return good; } /** True if belt[row] has a READY good waiting at the pickup point. */ hasReadyGood(row) { return this.belts[row].goods.some(g => g.state === GoodState.READY); } /** All goods in SLIDING or READY state across all belts (for rendering). */ allGoods() { return this.belts.flatMap(b => b.goods); } } /* ─────────────────────── Medication (right belt) ──────────────── */ const MedState = Object.freeze({ ON_BELT: 'on_belt', // sliding left READY: 'ready', // at left end, waiting for player PICKED: 'picked', // player has it EXPIRED: 'expired', // timed out while waiting }); class Medication { constructor() { this.id = Medication._nextId++; // Start at right edge of right zone this.x = SETTINGS.RIGHT_ZONE_END - SETTINGS.GOOD_SIZE; this.y = SETTINGS.BELT_ROWS[1]; // always middle row this.state = MedState.ON_BELT; this.waitTimer = 0; } } Medication._nextId = 0; /* ────────────────────────── RightConveyorSystem ────────────────── */ class RightConveyorSystem { constructor() { this.animOffset = 0; this.medications = []; } addMedication() { this.medications.push(new Medication()); } /** * Update right belt medications. * @param {number} dt – delta time ms */ update(dt) { this.animOffset = (this.animOffset + SETTINGS.RIGHT_BELT_SPEED * dt / 1000) % 40; this.medications.forEach(med => { if (med.state === MedState.ON_BELT) { med.x -= SETTINGS.RIGHT_BELT_SPEED * dt / 1000; const pickupX = SETTINGS.RIGHT_ZONE_START + SETTINGS.GOOD_SIZE * 0.5; if (med.x <= pickupX) { med.x = pickupX; med.state = MedState.READY; med.waitTimer = SETTINGS.MED_WAIT_TIME; } } else if (med.state === MedState.READY) { med.waitTimer -= dt; if (med.waitTimer <= 0) { med.state = MedState.EXPIRED; } } }); // Prune expired / picked medications this.medications = this.medications.filter( m => m.state === MedState.ON_BELT || m.state === MedState.READY ); } /** True if there is a READY medication at the pickup point (row 1 left end). */ hasReadyMedication() { return this.medications.some(m => m.state === MedState.READY); } /** * Try to pick up the READY medication. * Returns true on success. */ tryPickup() { const med = this.medications.find(m => m.state === MedState.READY); if (!med) return false; med.state = MedState.PICKED; return true; } /** Visible medications for rendering (ON_BELT and READY). */ visibleMedications() { return this.medications.filter( m => m.state === MedState.ON_BELT || m.state === MedState.READY ); } }