201 lines
6.2 KiB
JavaScript
201 lines
6.2 KiB
JavaScript
/**
|
||
* 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
|
||
);
|
||
}
|
||
}
|