Added kidney_labe and Cyste_kid
This commit is contained in:
200
kidney_lab/js/conveyor.js
Normal file
200
kidney_lab/js/conveyor.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
}
|
||||
1080
kidney_lab/js/game.js
Normal file
1080
kidney_lab/js/game.js
Normal file
File diff suppressed because it is too large
Load Diff
144
kidney_lab/js/highscore.js
Normal file
144
kidney_lab/js/highscore.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* highscore.js
|
||||
* High-score screen: displays final score(s), persists top-10 to localStorage,
|
||||
* shows leaderboard, offers back-to-start button.
|
||||
*
|
||||
* show(names, scores, playerCount)
|
||||
* names — string (1P) or array of strings (2P)
|
||||
* scores — number (1P) or array of numbers (2P)
|
||||
* playerCount — 1 or 2
|
||||
*/
|
||||
|
||||
const HighScoreScreen = (() => {
|
||||
|
||||
const STORAGE_KEY = 'kidneylab_scores';
|
||||
|
||||
/* ── Persistence ─────────────────────────────────────────────── */
|
||||
|
||||
function loadScores() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveScore(name, score) {
|
||||
const scores = loadScores();
|
||||
scores.push({ name: name.toUpperCase().slice(0, 12), score, date: new Date().toLocaleDateString() });
|
||||
scores.sort((a, b) => b.score - a.score);
|
||||
const top10 = scores.slice(0, 10);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(top10));
|
||||
return top10;
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function rankLabel(rank) {
|
||||
if (rank === 1) return '🏆 NEW HIGH SCORE!';
|
||||
if (rank === 2) return '🥈 2nd place!';
|
||||
if (rank === 3) return '🥉 3rd place!';
|
||||
return `Rank #${rank}`;
|
||||
}
|
||||
|
||||
function resultCardHTML(playerName, finalScore, scores, myRank) {
|
||||
const isTop = myRank === 1;
|
||||
return `
|
||||
<div class="hs-result ${isTop ? 'hs-gold' : ''}">
|
||||
<div class="hs-rank-label">${rankLabel(myRank)}</div>
|
||||
<div class="hs-player">${playerName.toUpperCase()}</div>
|
||||
<div class="hs-score-display">${finalScore >= 0 ? finalScore : 0}</div>
|
||||
<div class="hs-score-label">POINTS</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tableHTML(scores, highlightNames, highlightScores) {
|
||||
const rowsHTML = scores.map((s, i) => {
|
||||
// highlight any entry that matches one of the just-played players
|
||||
const isMe = highlightNames.some((n, ni) =>
|
||||
s.name === n.toUpperCase().slice(0, 12) && s.score === highlightScores[ni]
|
||||
);
|
||||
return `<tr class="${isMe ? 'my-row' : ''}">
|
||||
<td class="rank-col">${i + 1}</td>
|
||||
<td class="name-col">${s.name}</td>
|
||||
<td class="score-col">${s.score}</td>
|
||||
<td class="date-col">${s.date}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="hs-table-wrap">
|
||||
<h2 class="hs-heading">HALL OF FAME</h2>
|
||||
<table class="hs-table">
|
||||
<thead><tr><th>#</th><th>NAME</th><th>SCORE</th><th>DATE</th></tr></thead>
|
||||
<tbody>${rowsHTML}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Public show ─────────────────────────────────────────────── */
|
||||
|
||||
function show(names, scores, playerCount) {
|
||||
// Normalise to arrays
|
||||
const nameArr = Array.isArray(names) ? names : [names];
|
||||
const scoreArr = Array.isArray(scores) ? scores : [scores];
|
||||
const count = playerCount || nameArr.length;
|
||||
|
||||
// Switch screen
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('highscore-screen').classList.add('active');
|
||||
|
||||
// Save all players' scores; last save wins for the leaderboard table
|
||||
let leaderboard;
|
||||
nameArr.forEach((n, i) => {
|
||||
leaderboard = saveScore(n, scoreArr[i]);
|
||||
});
|
||||
|
||||
// Build rank info for each player
|
||||
const rankInfo = nameArr.map((n, i) => {
|
||||
const rank = leaderboard.findIndex(
|
||||
s => s.name === n.toUpperCase().slice(0, 12) && s.score === scoreArr[i]
|
||||
) + 1;
|
||||
return { name: n, score: scoreArr[i], rank };
|
||||
});
|
||||
|
||||
render(rankInfo, leaderboard, nameArr, scoreArr);
|
||||
}
|
||||
|
||||
function render(rankInfo, leaderboard, nameArr, scoreArr) {
|
||||
const el = document.getElementById('highscore-screen');
|
||||
|
||||
// One result card per player
|
||||
const cardsHTML = rankInfo.map(r =>
|
||||
resultCardHTML(r.name, r.score, leaderboard, r.rank)
|
||||
).join('');
|
||||
|
||||
// Cards side-by-side for 2P, centred for 1P
|
||||
const cardsWrap = rankInfo.length > 1
|
||||
? `<div style="display:flex;gap:16px;justify-content:center;flex-wrap:wrap;">${cardsHTML}</div>`
|
||||
: cardsHTML;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="hs-inner">
|
||||
<div class="hs-logo">
|
||||
<span class="logo-gw">GAME & WATCH</span>
|
||||
<span class="logo-title">KIDNEY LAB</span>
|
||||
</div>
|
||||
|
||||
${cardsWrap}
|
||||
|
||||
${tableHTML(leaderboard, nameArr, scoreArr)}
|
||||
|
||||
<div class="hs-actions">
|
||||
<button id="back-btn" class="btn-primary">PLAY AGAIN</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelector('#back-btn').addEventListener('click', () => {
|
||||
IntroScreen.show();
|
||||
});
|
||||
}
|
||||
|
||||
return { show };
|
||||
})();
|
||||
140
kidney_lab/js/intro.js
Normal file
140
kidney_lab/js/intro.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* intro.js
|
||||
* Introduction screen — two-step flow:
|
||||
* Step 1: select 1 or 2 players
|
||||
* Step 2: enter name(s), then start
|
||||
* Owns the #intro-screen element.
|
||||
*/
|
||||
|
||||
const IntroScreen = (() => {
|
||||
|
||||
function show() {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('intro-screen').classList.add('active');
|
||||
renderStep1();
|
||||
}
|
||||
|
||||
/* ── Shared header (logo + rules) ─────────────────────────── */
|
||||
function headerHTML() {
|
||||
return `
|
||||
<div class="intro-logo">
|
||||
<span class="logo-gw">GAME & WATCH</span>
|
||||
<span class="logo-title">KIDNEY LAB</span>
|
||||
<span class="logo-model">MW-56</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-description">
|
||||
<p>Help <strong>the Doctor</strong> collect kidneys from the supply belts
|
||||
and deliver them to the laboratory for analysis.</p>
|
||||
<p>Bring special medication to the patient to earn bonus points!</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-rules">
|
||||
<table>
|
||||
<tr><td>Kidney delivered to lab</td><td class="pts pos">+1 pt</td></tr>
|
||||
<tr><td>Special med to patient</td><td class="pts pos">+5 pts</td></tr>
|
||||
<tr><td>Kidney dropped</td><td class="pts neg">−1 pt</td></tr>
|
||||
<tr><td>Special kidney dropped</td><td class="pts neg">−3 pts</td></tr>
|
||||
</table>
|
||||
<p class="rule-note">Carry up to <strong>5 kidneys</strong> · 1 in 7 chance is special · <strong>3 minutes</strong> on the clock</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-controls">
|
||||
<p>Move with <kbd>↑↓←→</kbd> or <kbd>W A S D</kbd></p>
|
||||
<p>Stand at <em>column 0</em> (left) to collect · at <em>column 3</em> (right) to deposit / pickup / deliver</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Step 1: player count selection ───────────────────────── */
|
||||
function renderStep1() {
|
||||
const el = document.getElementById('intro-screen');
|
||||
el.innerHTML = `
|
||||
<div class="intro-inner">
|
||||
${headerHTML()}
|
||||
<div class="player-select">
|
||||
<p class="player-select-label">SELECT NUMBER OF PLAYERS</p>
|
||||
<div class="player-select-btns">
|
||||
<button class="btn-player-count" id="btn-1p">
|
||||
<span class="pcount">1</span>
|
||||
<span class="pcount-label">PLAYER</span>
|
||||
</button>
|
||||
<button class="btn-player-count" id="btn-2p">
|
||||
<span class="pcount">2</span>
|
||||
<span class="pcount-label">PLAYERS</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelector('#btn-1p').addEventListener('click', () => renderStep2(1));
|
||||
el.querySelector('#btn-2p').addEventListener('click', () => renderStep2(2));
|
||||
}
|
||||
|
||||
/* ── Step 2: name entry ────────────────────────────────────── */
|
||||
function renderStep2(playerCount) {
|
||||
const el = document.getElementById('intro-screen');
|
||||
|
||||
const nameFields = playerCount === 2
|
||||
? `
|
||||
<div class="name-row">
|
||||
<label for="name-p1">Player 1 Name</label>
|
||||
<input type="text" id="name-p1" maxlength="12"
|
||||
placeholder="DOCTOR" autocomplete="off" spellcheck="false"/>
|
||||
</div>
|
||||
<div class="name-row">
|
||||
<label for="name-p2">Player 2 Name</label>
|
||||
<input type="text" id="name-p2" maxlength="12"
|
||||
placeholder="NURSE" autocomplete="off" spellcheck="false"/>
|
||||
</div>`
|
||||
: `
|
||||
<div class="name-row">
|
||||
<label for="name-p1">Your Name</label>
|
||||
<input type="text" id="name-p1" maxlength="12"
|
||||
placeholder="DOCTOR" autocomplete="off" spellcheck="false"/>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="intro-inner">
|
||||
${headerHTML()}
|
||||
<div class="intro-form">
|
||||
<div class="mode-badge">${playerCount === 2 ? '2 PLAYER MODE' : '1 PLAYER MODE'}</div>
|
||||
${nameFields}
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" id="back-btn">← BACK</button>
|
||||
<button class="btn-primary" id="start-btn">START GAME</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const p1Input = el.querySelector('#name-p1');
|
||||
p1Input.focus();
|
||||
|
||||
el.querySelector('#back-btn').addEventListener('click', renderStep1);
|
||||
|
||||
el.querySelector('#start-btn').addEventListener('click', () => startGame(playerCount));
|
||||
|
||||
// Enter on last field starts game
|
||||
const lastInput = el.querySelector(playerCount === 2 ? '#name-p2' : '#name-p1');
|
||||
lastInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') startGame(playerCount);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Launch game ───────────────────────────────────────────── */
|
||||
function startGame(playerCount) {
|
||||
const el = document.getElementById('intro-screen');
|
||||
const name1 = (el.querySelector('#name-p1').value.trim() || 'DOCTOR').toUpperCase();
|
||||
const name2 = playerCount === 2
|
||||
? ((el.querySelector('#name-p2').value.trim() || 'NURSE').toUpperCase())
|
||||
: null;
|
||||
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('game-screen').classList.add('active');
|
||||
Game.init(name1, name2);
|
||||
}
|
||||
|
||||
return { show };
|
||||
})();
|
||||
84
kidney_lab/js/lab.js
Normal file
84
kidney_lab/js/lab.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* lab.js
|
||||
* Laboratory logic: accept goods, analyse for special goods,
|
||||
* trigger medication production. No rendering, no DOM.
|
||||
*/
|
||||
|
||||
const LabState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
ANALYZING: 'analyzing',
|
||||
});
|
||||
|
||||
class Lab {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = LabState.IDLE;
|
||||
this.analyzeTimer = 0;
|
||||
this.totalProcessed = 0;
|
||||
this.flashTimer = 0; // brief visual flash on deposit
|
||||
this._onMedReady = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit an array of goods into the lab.
|
||||
* @param {Good[]} goods – goods from player inventory
|
||||
* @param {function} onMedicationReady – called (no args) when special analysis completes
|
||||
* @returns {number} count of goods deposited
|
||||
*/
|
||||
depositGoods(goods, onMedicationReady) {
|
||||
if (!goods.length) return 0;
|
||||
|
||||
let hasSpecial = false;
|
||||
goods.forEach(good => {
|
||||
this.totalProcessed++;
|
||||
if (good.isSpecial) hasSpecial = true;
|
||||
});
|
||||
|
||||
// Flash feedback
|
||||
this.flashTimer = 500;
|
||||
|
||||
// Start analysis only if we got a special good and aren't already analysing
|
||||
if (hasSpecial && this.state === LabState.IDLE) {
|
||||
this.state = LabState.ANALYZING;
|
||||
this.analyzeTimer = SETTINGS.LAB_ANALYZE_TIME;
|
||||
this._onMedReady = onMedicationReady;
|
||||
}
|
||||
|
||||
return goods.length;
|
||||
}
|
||||
|
||||
/** True briefly after goods are deposited (visual feedback). */
|
||||
isFlashing() {
|
||||
return this.flashTimer > 0;
|
||||
}
|
||||
|
||||
/** True while analysing a special good. */
|
||||
isAnalyzing() {
|
||||
return this.state === LabState.ANALYZING;
|
||||
}
|
||||
|
||||
/** Progress 0–1 of the current analysis (0 if idle). */
|
||||
analyzeProgress() {
|
||||
if (this.state !== LabState.ANALYZING) return 0;
|
||||
return 1 - this.analyzeTimer / SETTINGS.LAB_ANALYZE_TIME;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (this.flashTimer > 0) this.flashTimer -= dt;
|
||||
|
||||
if (this.state === LabState.ANALYZING) {
|
||||
this.analyzeTimer -= dt;
|
||||
if (this.analyzeTimer <= 0) {
|
||||
this.analyzeTimer = 0;
|
||||
this.state = LabState.IDLE;
|
||||
if (this._onMedReady) {
|
||||
this._onMedReady();
|
||||
this._onMedReady = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
kidney_lab/js/player.js
Normal file
140
kidney_lab/js/player.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* player.js
|
||||
* Player state, grid movement, and inventory management.
|
||||
* No rendering, no DOM — pure logic.
|
||||
*/
|
||||
|
||||
class Player {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Grid position: col 0-3 (left → right), row 0-2 (top → bottom)
|
||||
this.col = 0;
|
||||
this.row = 1;
|
||||
|
||||
// Carried goods (max SETTINGS.MAX_CARRY)
|
||||
this.goods = [];
|
||||
|
||||
// Whether the player is holding the medication vial
|
||||
this.hasMedication = false;
|
||||
|
||||
// Movement cooldown (ms)
|
||||
this.moveCooldown = 0;
|
||||
|
||||
// Walk-cycle: phase advances (radians) while moveCooldown > 0
|
||||
this.walkPhase = 0;
|
||||
this.animFrame = 0;
|
||||
this.animTimer = 0;
|
||||
|
||||
// Flash timer when interacting
|
||||
this.interactFlash = 0;
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────── */
|
||||
|
||||
/** Pixel x centre of current column inside the middle zone. */
|
||||
pixelX() {
|
||||
// Evenly distribute columns across the middle zone with a 30px margin
|
||||
// from each edge, so col 0 sits just right of the belt end and
|
||||
// col (PLAYER_COLS-1) sits just left of the right-zone border.
|
||||
const margin = 30;
|
||||
const avail = SETTINGS.MIDDLE_ZONE_END - SETTINGS.MIDDLE_ZONE_START - margin * 2;
|
||||
const step = avail / (SETTINGS.PLAYER_COLS - 1);
|
||||
return SETTINGS.MIDDLE_ZONE_START + margin + this.col * step;
|
||||
}
|
||||
|
||||
/** Pixel y centre of current row. */
|
||||
pixelY() {
|
||||
return SETTINGS.BELT_ROWS[this.row];
|
||||
}
|
||||
|
||||
canMove() {
|
||||
return this.moveCooldown <= 0;
|
||||
}
|
||||
|
||||
canCarryMore() {
|
||||
return this.goods.length < SETTINGS.MAX_CARRY;
|
||||
}
|
||||
|
||||
/* ── Movement ─────────────────────────────────────────────── */
|
||||
|
||||
move(direction) {
|
||||
if (!this.canMove()) return false;
|
||||
|
||||
let nc = this.col;
|
||||
let nr = this.row;
|
||||
|
||||
switch (direction) {
|
||||
case 'up': nr = Math.max(0, nr - 1); break;
|
||||
case 'down': nr = Math.min(SETTINGS.PLAYER_ROWS - 1, nr + 1); break;
|
||||
case 'left': nc = Math.max(0, nc - 1); break;
|
||||
case 'right': nc = Math.min(SETTINGS.PLAYER_COLS - 1, nc + 1); break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
if (nc === this.col && nr === this.row) return false;
|
||||
|
||||
this.col = nc;
|
||||
this.row = nr;
|
||||
this.moveCooldown = SETTINGS.PLAYER_MOVE_COOLDOWN;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Inventory ────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Attempt to add a good to the inventory.
|
||||
* Returns true on success, false if full.
|
||||
*/
|
||||
addGood(good) {
|
||||
if (!this.canCarryMore()) return false;
|
||||
this.goods.push(good);
|
||||
this.interactFlash = 300;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return all carried goods (for lab deposit).
|
||||
*/
|
||||
depositGoods() {
|
||||
const deposited = [...this.goods];
|
||||
this.goods = [];
|
||||
this.interactFlash = 400;
|
||||
return deposited;
|
||||
}
|
||||
|
||||
pickupMedication() {
|
||||
this.hasMedication = true;
|
||||
this.interactFlash = 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver medication to patient.
|
||||
* Returns true if medication was delivered.
|
||||
*/
|
||||
deliverMedication() {
|
||||
if (!this.hasMedication) return false;
|
||||
this.hasMedication = false;
|
||||
this.interactFlash = 600;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Update ───────────────────────────────────────────────── */
|
||||
|
||||
update(dt) {
|
||||
if (this.moveCooldown > 0) {
|
||||
this.moveCooldown -= dt;
|
||||
// Advance walk cycle: ~π radians per 180 ms move cooldown → one leg swing per step
|
||||
this.walkPhase += dt * 0.0175;
|
||||
}
|
||||
if (this.interactFlash > 0) this.interactFlash -= dt;
|
||||
|
||||
this.animTimer += dt;
|
||||
if (this.animTimer >= 200) {
|
||||
this.animTimer -= 200;
|
||||
this.animFrame = (this.animFrame + 1) % 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
kidney_lab/js/settings.js
Normal file
101
kidney_lab/js/settings.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* settings.js
|
||||
* Static game constants — all tunable values live here.
|
||||
* Do not import game state; this file has zero side-effects.
|
||||
*/
|
||||
const SETTINGS = Object.freeze({
|
||||
|
||||
/* ── Canvas ───────────────────────────────────────────────── */
|
||||
CANVAS_WIDTH: 900,
|
||||
CANVAS_HEIGHT: 520,
|
||||
HEADER_HEIGHT: 60,
|
||||
|
||||
/* ── Timing ───────────────────────────────────────────────── */
|
||||
GAME_DURATION: 180, // seconds
|
||||
PLAYER_MOVE_COOLDOWN: 180, // ms between tile moves
|
||||
LAB_ANALYZE_TIME: 3000, // ms to analyze a special good
|
||||
GOOD_WAIT_TIME: 2500, // ms a good waits at belt end before dropping
|
||||
MED_WAIT_TIME: 3500, // ms medication waits at pickup point
|
||||
|
||||
/* ── Scoring ──────────────────────────────────────────────── */
|
||||
POINTS_GOOD_LAB: 1,
|
||||
POINTS_SPECIAL_PATIENT: 5,
|
||||
POINTS_DROP_GOOD: -1,
|
||||
POINTS_DROP_SPECIAL: -3,
|
||||
|
||||
/* ── Carry limit ──────────────────────────────────────────── */
|
||||
MAX_CARRY: 5,
|
||||
|
||||
/* ── Special good probability (1 in 7) ───────────────────── */
|
||||
SPECIAL_GOOD_CHANCE: 1 / 7,
|
||||
|
||||
/* ── Layout — zone x boundaries ──────────────────────────── */
|
||||
LEFT_ZONE_START: 0,
|
||||
LEFT_ZONE_END: 350,
|
||||
MIDDLE_ZONE_START: 350,
|
||||
MIDDLE_ZONE_END: 600,
|
||||
RIGHT_ZONE_START: 600,
|
||||
RIGHT_ZONE_END: 900,
|
||||
|
||||
/* ── Vertical rows (y centres, shared across all zones) ───── */
|
||||
// Row 0 = top, Row 1 = middle, Row 2 = bottom
|
||||
BELT_ROWS: [155, 300, 445],
|
||||
|
||||
/* ── Player grid (4 cols × 3 rows inside middle zone) ──────── */
|
||||
PLAYER_COLS: 3,
|
||||
PLAYER_ROWS: 3,
|
||||
PLAYER_COL_WIDTH: 100, // px between column centres
|
||||
PLAYER_SIZE: 36, // bounding box size for drawing
|
||||
BELT_HEIGHT: 13, // visual belt strip height (px)
|
||||
|
||||
/* ── Belt speeds ──────────────────────────────────────────── */
|
||||
LEFT_BELT_SPEED: 45, // px / sec (goods move → right)
|
||||
RIGHT_BELT_SPEED: 33, // px / sec (medication moves ← left)
|
||||
|
||||
/* ── Left belt spawn intervals (ms) ──────────────────────── */
|
||||
BELT_MIN_SPAWN: 3200,
|
||||
BELT_MAX_SPAWN: 6500,
|
||||
|
||||
/* ── Staggered initial spawn offsets per belt (ms) ─────────── */
|
||||
BELT_INIT_OFFSETS: [600, 2400, 4200],
|
||||
|
||||
/* ── Item sizes ───────────────────────────────────────────── */
|
||||
GOOD_SIZE: 26,
|
||||
|
||||
/* ── Patient happy flash duration ────────────────────────── */
|
||||
PATIENT_HAPPY_DURATION: 1800, // ms
|
||||
|
||||
/* ── Colours ──────────────────────────────────────────────── */
|
||||
COLOR_BG: '#0a1628',
|
||||
COLOR_HEADER: '#0d1e38',
|
||||
COLOR_HEADER_LINE: '#1a4070',
|
||||
COLOR_BELT: '#152840',
|
||||
COLOR_BELT_STRIPE: '#1e3d5c',
|
||||
COLOR_BELT_EDGE: '#2a5a80',
|
||||
COLOR_BELT_R: '#14283c',
|
||||
COLOR_BELT_STRIPE_R:'#1c3650',
|
||||
COLOR_GOOD: '#f0a030',
|
||||
COLOR_GOOD_OUTLINE: '#c07010',
|
||||
COLOR_SPECIAL: '#ff44aa',
|
||||
COLOR_SPECIAL_OUT: '#cc1177',
|
||||
COLOR_MED: '#44ffaa',
|
||||
COLOR_MED_OUT: '#11cc77',
|
||||
COLOR_PLAYER: '#4499ff',
|
||||
COLOR_PLAYER_DARK: '#2266cc',
|
||||
COLOR_PLAYER_CARRY: '#88ccff',
|
||||
COLOR_LAB: '#1a4830',
|
||||
COLOR_LAB_LIGHT: '#22aa55',
|
||||
COLOR_LAB_OUTLINE: '#33dd77',
|
||||
COLOR_PATIENT: '#ff8844',
|
||||
COLOR_PATIENT_DARK: '#cc5511',
|
||||
COLOR_PATIENT_HAPPY:'#ffdd44',
|
||||
COLOR_TEXT: '#88ccff',
|
||||
COLOR_SCORE_VAL: '#ffdd44',
|
||||
COLOR_TIMER_OK: '#44dd88',
|
||||
COLOR_TIMER_WARN: '#ffaa22',
|
||||
COLOR_TIMER_DANGER: '#ff4444',
|
||||
COLOR_ZONE_DIV: '#1a3a5c',
|
||||
COLOR_ZONE_DIV2: '#0f2a40',
|
||||
COLOR_SHADOW: 'rgba(0,0,0,0.45)',
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user