Added kidney_labe and Cyste_kid
This commit is contained in:
67
kidney_lab/CLAUDE.md
Normal file
67
kidney_lab/CLAUDE.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Running the game
|
||||
|
||||
Open `index.html` directly in a browser — there is no build step, no bundler, and no package manager. All JS files are loaded as plain `<script>` tags in dependency order.
|
||||
|
||||
For local development use any static file server, e.g.:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
# then open http://localhost:8080
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The game is split into seven files. Load order in `index.html` reflects the dependency chain:
|
||||
|
||||
```
|
||||
settings.js → player.js → conveyor.js → lab.js → highscore.js → game.js → intro.js
|
||||
```
|
||||
|
||||
### File responsibilities
|
||||
|
||||
| File | Exports | Role |
|
||||
|---|---|---|
|
||||
| `settings.js` | `SETTINGS` (frozen object) | All numeric constants and colours. No side-effects. Tweak values here only. |
|
||||
| `player.js` | `Player` class | Grid position (`col` 0-2, `row` 0-2), inventory, movement cooldown, walk animation. No rendering. |
|
||||
| `conveyor.js` | `LeftConveyorSystem`, `RightConveyorSystem`, `Good`, `Medication` classes + state enums | Belt physics and item lifecycle. No rendering. |
|
||||
| `lab.js` | `Lab` class, `LabState` enum | Accepts deposited goods, detects specials, runs analysis timer, fires `onMedicationReady` callback. No rendering. |
|
||||
| `game.js` | `Game.init(name1, name2)` | Game loop, all canvas rendering, input handling, 2P split-screen logic. Calls `HighScoreScreen.show()` on time-out. |
|
||||
| `intro.js` | `IntroScreen.show()` | Two-step intro: player count selection → name entry → `Game.init()`. |
|
||||
| `highscore.js` | `HighScoreScreen.show(names, scores, playerCount)` | Persists top-10 to `localStorage` (`kidneylab_scores`), renders results. Both 1P (scalar) and 2P (array) calls are supported. |
|
||||
|
||||
### 2-player split-screen (game.js)
|
||||
|
||||
All mutable state is held in per-player arrays (`gPlayers[i]`, `gScores[i]`, etc.). The `RS` proxy object exposes `_pIdx`-indexed getters so `renderOneSide()` can be called twice without duplication.
|
||||
|
||||
P2's half is drawn with a mirror transform (`ctx.translate(W, 0); ctx.scale(-1, 1)`). Text inside that transform must go through the `ft(text, x, y)` helper, which applies a local `scale(-1, 1)` to cancel the outer flip.
|
||||
|
||||
P2 keyboard input (WASD) has left/right swapped to match the mirrored visual: pressing visual-left (A) calls `move('right')`. The virtual joystick applies the same swap via the `swapLR` flag in `makeJoystick()`.
|
||||
|
||||
### Interaction model
|
||||
|
||||
Interactions are purely position-based — no action button. `handleInteractionsFor(pIdx)` runs every frame:
|
||||
|
||||
- `col === 0` → attempt pickup from left belt at current row
|
||||
- `col === PLAYER_COLS - 1, row === 2` → deposit all carried goods to lab
|
||||
- `col === PLAYER_COLS - 1, row === 1` → pick up medication from right belt
|
||||
- `col === PLAYER_COLS - 1, row === 0` → deliver medication to patient
|
||||
|
||||
### Layout constants (all in `settings.js`)
|
||||
|
||||
```
|
||||
x: 0 350 600 900
|
||||
|←LEFT→ |←MIDDLE → |←RIGHT →|
|
||||
belts player grid lab/belt/patient
|
||||
```
|
||||
|
||||
Rows are shared vertically: `BELT_ROWS: [155, 300, 445]` (top→bottom, y-centre).
|
||||
|
||||
Player pixel position is computed in `Player.pixelX()` — evenly distributed across the middle zone with a 30 px margin from each edge.
|
||||
|
||||
### Mobile joystick
|
||||
|
||||
`makeJoystick()` in `game.js` builds a virtual joystick per player and appends it to the game screen div. The joystick uses `position: fixed` anchored via `.joy-left` / `.joy-right` CSS classes. It is hidden on non-touch devices via `@media (pointer: coarse)`. Repeated movement while held is driven by `setInterval` at 160 ms; the player's own `moveCooldown` (180 ms) naturally gates actual step rate.
|
||||
637
kidney_lab/css/styles.css
Normal file
637
kidney_lab/css/styles.css
Normal file
@@ -0,0 +1,637 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
styles.css — Mario Lab (MW-56 Game & Watch clone)
|
||||
All look-and-feel: layout, typography, colours, animations.
|
||||
No game logic lives here.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Reset & base ────────────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #06101e;
|
||||
--surface: #0d1e38;
|
||||
--surface2: #10243f;
|
||||
--border: #1a3a60;
|
||||
--border2: #2a5a90;
|
||||
--text: #88ccff;
|
||||
--text-dim: #4a7aaa;
|
||||
--text-bright:#cceeff;
|
||||
--gold: #ffdd44;
|
||||
--green: #44ffaa;
|
||||
--red: #ff4455;
|
||||
--accent: #4499ff;
|
||||
--font-mono: "Courier New", "Lucida Console", monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Screen system ───────────────────────────────────────────── */
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Game screen: canvas centred ─────────────────────────────── */
|
||||
|
||||
#game-screen {
|
||||
gap: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
display: block;
|
||||
border: 2px solid var(--border2);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 0 0 4px var(--surface),
|
||||
0 0 30px rgba(68,153,255,0.25),
|
||||
0 0 60px rgba(0,0,0,0.6);
|
||||
outline: none;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.controls-legend {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls-legend kbd {
|
||||
display: inline-block;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Shared button style ─────────────────────────────────────── */
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #1a5caa 0%, #0a3070 100%);
|
||||
color: #cceeff;
|
||||
border: 2px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
padding: 12px 36px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 0 16px rgba(68,153,255,0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #2a7aee 0%, #1a4aa0 100%);
|
||||
box-shadow: 0 0 24px rgba(68,153,255,0.55);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
INTRO SCREEN
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
#intro-screen {
|
||||
background: radial-gradient(ellipse at center, #0d1e38 0%, #06101e 100%);
|
||||
}
|
||||
|
||||
.intro-inner {
|
||||
max-width: 560px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* Logo block */
|
||||
.intro-logo {
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.logo-gw {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
display: block;
|
||||
font-size: clamp(32px, 8vw, 54px);
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
color: var(--text-bright);
|
||||
text-shadow:
|
||||
0 0 20px rgba(68,153,255,0.7),
|
||||
0 0 40px rgba(68,153,255,0.4);
|
||||
animation: title-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.logo-model {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--gold);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@keyframes title-pulse {
|
||||
0%, 100% { text-shadow: 0 0 20px rgba(68,153,255,0.7), 0 0 40px rgba(68,153,255,0.4); }
|
||||
50% { text-shadow: 0 0 30px rgba(68,200,255,1), 0 0 60px rgba(68,153,255,0.7); }
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.intro-description {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-description strong {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* Rules */
|
||||
.intro-rules {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.intro-rules table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.intro-rules td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-rules .pts {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.intro-rules .pos { color: var(--green); }
|
||||
.intro-rules .neg { color: var(--red); }
|
||||
|
||||
.rule-note {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rule-note strong {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
/* Controls hint */
|
||||
.intro-controls {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intro-controls kbd {
|
||||
display: inline-block;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-controls em {
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.intro-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.intro-form label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro-form input[type="text"] {
|
||||
background: var(--surface2);
|
||||
border: 2px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
padding: 10px 20px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
width: 240px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.intro-form input[type="text"]:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 16px rgba(68,153,255,0.4);
|
||||
}
|
||||
|
||||
.intro-form input[type="text"]::placeholder {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Player count selection */
|
||||
.player-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.player-select-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-select-btns {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.btn-player-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 110px;
|
||||
height: 100px;
|
||||
background: var(--surface2);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-player-count:hover {
|
||||
background: linear-gradient(135deg, #1a5caa 0%, #0a3070 100%);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(68,153,255,0.45);
|
||||
}
|
||||
|
||||
.btn-player-count:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.btn-player-count .pcount {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: var(--gold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-player-count .pcount-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Mode badge & name rows (step 2) */
|
||||
.mode-badge {
|
||||
font-size: 11px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--gold);
|
||||
background: rgba(255,221,68,0.1);
|
||||
border: 1px solid rgba(255,221,68,0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.name-row label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Secondary (back) button */
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--border2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
HIGH SCORE SCREEN
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
#highscore-screen {
|
||||
background: radial-gradient(ellipse at center, #0d1e38 0%, #06101e 100%);
|
||||
}
|
||||
|
||||
.hs-inner {
|
||||
max-width: 580px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.hs-logo {
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hs-result {
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hs-result.hs-gold {
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 24px rgba(255,221,68,0.35);
|
||||
animation: gold-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gold-pulse {
|
||||
0%, 100% { box-shadow: 0 0 24px rgba(255,221,68,0.35); }
|
||||
50% { box-shadow: 0 0 40px rgba(255,221,68,0.65); }
|
||||
}
|
||||
|
||||
.hs-rank-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--gold);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.hs-player {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 5px;
|
||||
}
|
||||
|
||||
.hs-score-display {
|
||||
font-size: 56px;
|
||||
font-weight: bold;
|
||||
color: var(--gold);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 20px rgba(255,221,68,0.6);
|
||||
}
|
||||
|
||||
.hs-score-label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.hs-table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hs-heading {
|
||||
font-size: 12px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hs-table th {
|
||||
text-align: left;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
padding: 4px 8px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hs-table td {
|
||||
padding: 6px 8px;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid rgba(26,58,96,0.4);
|
||||
}
|
||||
|
||||
.hs-table .rank-col { color: var(--text-dim); width: 30px; }
|
||||
.hs-table .name-col { font-weight: bold; letter-spacing: 2px; }
|
||||
.hs-table .score-col { color: var(--gold); text-align: right; }
|
||||
.hs-table .date-col { color: var(--text-dim); font-size: 11px; text-align: right; }
|
||||
|
||||
.hs-table tr.my-row td {
|
||||
background: rgba(68,153,255,0.12);
|
||||
color: #cceeff;
|
||||
}
|
||||
|
||||
.hs-table tr.my-row .score-col { color: var(--gold); }
|
||||
|
||||
.hs-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
MOBILE JOYSTICK CONTROLS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.joystick-wrap {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
/* horizontal position is set entirely by .joy-left / .joy-right */
|
||||
display: none; /* hidden by default on desktop/mouse */
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Only show on touch/coarse-pointer devices (phones, tablets) */
|
||||
@media (pointer: coarse) {
|
||||
.joystick-wrap { display: flex; }
|
||||
}
|
||||
|
||||
/* Explicit left AND right on every variant so fixed positioning
|
||||
never falls back to the element's static (centred) position */
|
||||
.joystick-wrap.joy-right { right: 24px; left: unset; }
|
||||
.joystick-wrap.joy-left { left: 24px; right: unset; }
|
||||
|
||||
.joystick-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Outer ring */
|
||||
.joystick-base {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(13, 30, 56, 0.75);
|
||||
border: 2px solid var(--border2);
|
||||
box-shadow: 0 0 24px rgba(0,0,0,0.55), inset 0 0 16px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.joystick-base.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 24px rgba(68,153,255,0.35), inset 0 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Direction hint rings (subtle) */
|
||||
.joystick-base::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(68,153,255,0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Draggable knob */
|
||||
.joystick-knob {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, #4499ff, #1a4aa0);
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 14px rgba(68,153,255,0.55);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
transition: box-shadow 0.1s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.joystick-base.active .joystick-knob {
|
||||
box-shadow: 0 0 22px rgba(68,153,255,0.85);
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ───────────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border2); }
|
||||
40
kidney_lab/description.txt
Normal file
40
kidney_lab/description.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
# Kidney Lab – MW-56
|
||||
|
||||
> Dieses Spiel ist lose an das **Game & Watch MW-56 Mario Bros.** von Nintendo aus dem Jahr **1983** angelehnt.
|
||||
> Wer das Original in Aktion sehen möchte, findet es hier: [Game & Watch MW-56 auf YouTube](https://www.youtube.com/watch?v=ajWf49au8mo)
|
||||
|
||||
## Das Spiel
|
||||
|
||||
Du schlüpfst in die Rolle eines Arztes, der in einem hektischen Labor arbeitet. Auf drei Förderbändern rollen ununterbrochen Nieren heran – und es liegt an dir, sie rechtzeitig aufzusammeln, bevor sie vom Band fallen und verloren gehen.
|
||||
|
||||
Trage die Nieren zum Labor am rechten Bildschirmrand und liefere sie dort zur Analyse ab. Je mehr Nieren du erfolgreich abgibst, desto höher dein Punktestand. Aber Vorsicht: Lässt du eine Niere fallen, kostet dich das wertvolle Punkte.
|
||||
|
||||
## Die Besonderheit
|
||||
|
||||
Gelegentlich befindet sich unter den Nieren eine ganz besondere – eine Spenderniere mit außergewöhnlichen Eigenschaften. Gibt der Arzt eine solche Niere ins Labor ab, beginnt dort sofort eine Analyse. Nach kurzer Zeit erscheint auf dem rechten Förderband ein spezielles Medikament. Hole es ab und bringe es schnell zum Patienten oben rechts im Bild – er leidet und wartet dringend auf Linderung. Eine erfolgreiche Lieferung wird mit satten fünf Bonuspunkten belohnt.
|
||||
|
||||
## Punktewertung
|
||||
|
||||
| Aktion | Punkte |
|
||||
|---|---|
|
||||
| Niere ins Labor gebracht | +1 |
|
||||
| Medikament zum Patienten gebracht | +5 |
|
||||
| Niere fallen lassen | −1 |
|
||||
| Besondere Niere fallen lassen | −3 |
|
||||
|
||||
## Spielregeln
|
||||
|
||||
- Der Arzt kann maximal **5 Nieren** gleichzeitig tragen.
|
||||
- Jede siebte Niere ist im Durchschnitt eine besondere Spenderniere.
|
||||
- Du hast **3 Minuten** Zeit – nutze sie klug!
|
||||
|
||||
## Mehrspieler-Modus
|
||||
|
||||
Kidney Lab kann auch zu zweit gespielt werden. Beide Spieler steuern ihren eigenen Arzt auf einem gespiegelten Spielfeld und sammeln unabhängig voneinander Punkte. Am Ende gewinnt, wer die meisten Punkte erzielt hat – Teamwork ist erlaubt, der Ruhm gehört aber dem Besten!
|
||||
|
||||
## Steuerung
|
||||
|
||||
| Gerät | Spieler 1 | Spieler 2 |
|
||||
|---|---|---|
|
||||
| Tastatur | Pfeiltasten | W / A / S / D |
|
||||
| Touchscreen | Joystick rechts unten | Joystick links unten |
|
||||
50
kidney_lab/index.html
Normal file
50
kidney_lab/index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kidney Lab — MW-56 Game & Watch</title>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<!-- ── Intro screen ─────────────────────────────────────── -->
|
||||
<div id="intro-screen" class="screen active"></div>
|
||||
|
||||
<!-- ── Game screen (canvas injected by game.js) ─────────── -->
|
||||
<div id="game-screen" class="screen"></div>
|
||||
|
||||
<!-- ── High-score screen ────────────────────────────────── -->
|
||||
<div id="highscore-screen" class="screen"></div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Script load order matters — each file depends only on what
|
||||
was loaded before it:
|
||||
settings → (no deps)
|
||||
player → settings
|
||||
conveyor → settings
|
||||
lab → settings
|
||||
game → settings + player + conveyor + lab + highscore
|
||||
intro → game
|
||||
highscore → intro (back button)
|
||||
|
||||
We load highscore before intro so game.js can call HighScoreScreen.show().
|
||||
intro.js is last so it can call Game.init() immediately on boot.
|
||||
-->
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/conveyor.js"></script>
|
||||
<script src="js/lab.js"></script>
|
||||
<script src="js/highscore.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
<script src="js/intro.js"></script>
|
||||
|
||||
<script>
|
||||
// Boot: show the intro screen.
|
||||
IntroScreen.show();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
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)',
|
||||
|
||||
});
|
||||
24
kidney_lab/readme.txt
Normal file
24
kidney_lab/readme.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
Introduction:
|
||||
|
||||
We want to create a html/javascript game that can be played in the browser. The game is a customized clone from the Game and Watch MW-56 Mario game.
|
||||
The game is build up from the folowing. It start with an introduction screen with some text, a name input field and a start button.
|
||||
On Click on the start button the game should start.
|
||||
When finished a high score page is shown with a button to go back to the start screen.
|
||||
|
||||
The GAME:
|
||||
|
||||
On top of the screen there is the amount of points. The name of the player. The amount of time the user has left.
|
||||
The game screen is build up from three different area's from left to right.
|
||||
|
||||
The left part of the game contains three Automated conveyor belts under each other comming from the left of the screen and transport goods to the right.
|
||||
The Automated conveyor belt should contain about one third of the screen. The different belt's randomly transport goods. We need to make sure that no two goods start exactly at the same time.
|
||||
|
||||
The second thrird of the screen contains a playable toon that needs to collect the goods that come from the Automated conveyor belts. For this we can go up and down to the location from the three Automated conveyor belts. And he can go to the right three steps.
|
||||
|
||||
|
||||
The last 1/3 of the screen contains at the bottom a laboratory space. Above the laboratory a Automated conveyor belt that runs from right to left. At the top a patient who is in pain.
|
||||
|
||||
|
||||
Now to play the game the user needs to control the playbale toon. He needs to collect the goods and bring them to the laboratory for analyzing the goods. He can carry a maximum of 5 goods. If he tries to carry more they drop on the floor. At random one of the goods is a special kind of goods. This should happen randonly so that one of the seven goods is a special kind of good. If the special kind of good is added to the laboratory than after a short analyzing a special medication is comming from the Automated conveyor belt on thr right that needs to be taken from the playable toon and bring to the patient.
|
||||
For every good the player gets into the laboratory the user get 1 point. For giving the special good to the patient he gets 5 points. For every good that drops on the floor the user gets -1 point. If the good dropped was a special good he gets -3 points. The user has 3 minutes playing time and needs to collect as many points as he can.
|
||||
|
||||
Reference in New Issue
Block a user