From 7e46d1ed1df1d6f4e7fbe8104c8677f7f391f510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 13:36:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20HUD=20overlay=20via=20UIScene=20?= =?UTF-8?q?=E2=80=94=20health=20bar,=20quick=20slots,=20inventory=20info?= =?UTF-8?q?=20(Phase=204.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIScene renders as overlay on top of GameScene: - Health bar (top-left) with color transitions green→yellow→red - 4 quick slots (bottom-center) with active highlight, item symbols and counts - Inventory weight/slots info (bottom-right) - Controls hint (top-right) GameScene pushes state to Phaser registry each frame. Phase 4 (Player Systems) complete — 222 tests passing. Co-authored-by: Cursor --- PROGRESS.md | 25 +++-- src/config.ts | 3 +- src/scenes/GameScene.ts | 29 ++++-- src/scenes/UIScene.ts | 196 ++++++++++++++++++++++++++++++++++++++++ tests/ui.test.ts | 73 +++++++++++++++ 5 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 src/scenes/UIScene.ts create mode 100644 tests/ui.test.ts diff --git a/PROGRESS.md b/PROGRESS.md index 8ddbf8e..9e11541 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Synthesis — Development Progress > **Last updated:** 2026-02-12 -> **Current phase:** Phase 3 ✅ → Ready for Phase 4 +> **Current phase:** Phase 4 ✅ → Ready for Phase 5 --- @@ -48,23 +48,27 @@ - [x] 3.7 Minimap — canvas-based 160x160 overview, viewport indicator, border (`src/world/minimap.ts`) - [x] Unit tests — 21 passing (`tests/world.test.ts`) +### Phase 4: Player Systems ✅ +- [x] 4.1 Player entity + WASD controller (`src/player/input.ts`, `src/player/collision.ts`, `src/player/spawn.ts`, `src/player/factory.ts`) +- [x] 4.2 Inventory — weight-based, element stacking, AMU mass from registries (`src/player/inventory.ts`) +- [x] 4.3 Element collection from world objects — resource entities, proximity interaction, E key (`src/player/interaction.ts`, `src/world/resources.ts`) +- [x] 4.4 Crafting — chemistry engine integration, inventory consume/produce, condition checking (`src/player/crafting.ts`) +- [x] 4.5 Projectile system — throw elements toward mouse, lifetime + tile collision (`src/player/projectile.ts`) +- [x] 4.6 Quick slots — 4 hotkeys, auto-assign on pickup, active slot for throw (`src/player/quickslots.ts`) +- [x] 4.7 HUD — UIScene overlay: health bar, quick slots, inventory info, controls hint (`src/scenes/UIScene.ts`) +- [x] Unit tests — 39 player + 28 inventory + 12 interaction + 11 crafting + 13 projectile + 15 quickslots + 8 UI = 126 tests (222 total) + --- ## In Progress -_None — ready to begin Phase 4_ +_None — ready to begin Phase 5_ --- -## Up Next: Phase 4 — Player Systems +## Up Next: Phase 5 — Creatures & AI -- [ ] 4.1 Player entity + WASD controller -- [ ] 4.2 Inventory (weight-based, element stacking) -- [ ] 4.3 Element collection from world objects -- [ ] 4.4 Crafting (chemistry engine integration) -- [ ] 4.5 Projectile system (throw elements/compounds) -- [ ] 4.6 Quick slots (1-2-3-4 hotkeys) -- [ ] 4.7 HUD (UIScene: health ring, inventory bar, element info) +_(See IMPLEMENTATION-PLAN.md for details)_ --- @@ -82,3 +86,4 @@ None | 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing | | 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing | | 4 | 2026-02-12 | Phase 3 | World generation: simplex noise (seeded), 80x80 tilemap with 8 tile types, Catalytic Wastes biome, camera WASD+zoom, minimap with viewport indicator, 21 tests passing (95 total) | +| 5 | 2026-02-12 | Phase 4 | Player systems: WASD movement + tile collision, weight-based inventory, resource collection, crafting via chemistry engine, projectile throw, 4 quick slots, UIScene HUD overlay (health bar, slots, inventory), 126 new tests (222 total) | diff --git a/src/config.ts b/src/config.ts index cdceb8c..7ec3bd4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { BootScene } from './scenes/BootScene'; import { GameScene } from './scenes/GameScene'; +import { UIScene } from './scenes/UIScene'; export const GAME_WIDTH = 1280; export const GAME_HEIGHT = 720; @@ -11,7 +12,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = { height: GAME_HEIGHT, backgroundColor: '#0a0a0a', parent: document.body, - scene: [BootScene, GameScene], + scene: [BootScene, GameScene, UIScene], physics: { default: 'arcade', arcade: { diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index faae1ad..2658d2a 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,6 +1,6 @@ import Phaser from 'phaser'; import { createGameWorld, updateTime, type GameWorld } from '../ecs/world'; -import { Position } from '../ecs/components'; +import { Health, Position } from '../ecs/components'; import { movementSystem } from '../ecs/systems/movement'; import { healthSystem } from '../ecs/systems/health'; import { removeGameEntity } from '../ecs/factory'; @@ -154,6 +154,9 @@ export class GameScene extends Phaser.Scene { this.interactionText.setOrigin(0.5); this.interactionText.setDepth(100); this.interactionText.setAlpha(0); + + // 11. Launch UIScene overlay + this.scene.launch('UIScene'); } update(_time: number, delta: number): void { @@ -243,15 +246,27 @@ export class GameScene extends Phaser.Scene { } } - // 13. Stats + // 13. Push shared state to registry for UIScene + this.registry.set('health', Health.current[this.playerEid] ?? 100); + this.registry.set('healthMax', Health.max[this.playerEid] ?? 100); + this.registry.set('quickSlots', this.quickSlots.getAll()); + this.registry.set('activeSlot', this.quickSlots.activeIndex); + this.registry.set('invWeight', this.inventory.getTotalWeight()); + this.registry.set('invMaxWeight', this.inventory.maxWeight); + this.registry.set('invSlots', this.inventory.slotCount); + + // Build counts map for UIScene + const counts = new Map(); + for (const item of this.inventory.getItems()) { + counts.set(item.id, item.count); + } + this.registry.set('invCounts', counts); + + // 14. Debug stats overlay const fps = delta > 0 ? Math.round(1000 / delta) : 0; const px = Math.round(Position.x[this.playerEid]); const py = Math.round(Position.y[this.playerEid]); - const invWeight = Math.round(this.inventory.getTotalWeight()); - const invSlots = this.inventory.slotCount; - this.statsText.setText( - `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | inv: ${invSlots} items, ${invWeight} AMU | WASD/E/F/scroll`, - ); + this.statsText.setText(`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py}`); } /** Try to launch a projectile from active quick slot toward mouse */ diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts new file mode 100644 index 0000000..a6201ff --- /dev/null +++ b/src/scenes/UIScene.ts @@ -0,0 +1,196 @@ +/** + * UIScene — HUD overlay on top of GameScene + * + * Renders health bar, quick slots, inventory weight. + * Reads shared game state from Phaser registry. + */ + +import Phaser from 'phaser'; +import { SLOT_COUNT } from '../player/quickslots'; +import { ElementRegistry } from '../chemistry/elements'; +import { CompoundRegistry } from '../chemistry/compounds'; + +const SLOT_SIZE = 44; +const SLOT_GAP = 4; +const HEALTH_BAR_WIDTH = 160; +const HEALTH_BAR_HEIGHT = 14; + +/** Get color for a chemical item as number (for rendering) */ +function getItemDisplayColor(itemId: string): number { + const el = ElementRegistry.getBySymbol(itemId); + if (el) return parseInt(el.color.replace('#', ''), 16); + const comp = CompoundRegistry.getById(itemId); + if (comp) return parseInt(comp.color.replace('#', ''), 16); + return 0xffffff; +} + +/** Get display name for a chemical item */ +function getItemDisplayName(itemId: string): string { + const el = ElementRegistry.getBySymbol(itemId); + if (el) return el.symbol; + const comp = CompoundRegistry.getById(itemId); + if (comp) return comp.formula; + return itemId; +} + +export class UIScene extends Phaser.Scene { + // Health + private healthBarBg!: Phaser.GameObjects.Rectangle; + private healthBarFill!: Phaser.GameObjects.Rectangle; + private healthText!: Phaser.GameObjects.Text; + + // Quick slots + private slotBoxes: Phaser.GameObjects.Rectangle[] = []; + private slotTexts: Phaser.GameObjects.Text[] = []; + private slotNumTexts: Phaser.GameObjects.Text[] = []; + private slotCountTexts: Phaser.GameObjects.Text[] = []; + + // Inventory + private invText!: Phaser.GameObjects.Text; + + // Controls hint + private controlsText!: Phaser.GameObjects.Text; + + constructor() { + super({ key: 'UIScene' }); + } + + create(): void { + const w = this.cameras.main.width; + const h = this.cameras.main.height; + + // === Health bar (top-left) === + const hpX = 12; + const hpY = 12; + + this.healthBarBg = this.add.rectangle( + hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2, + HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x330000, + ); + this.healthBarBg.setStrokeStyle(1, 0x660000); + + this.healthBarFill = this.add.rectangle( + hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2, + HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x00cc44, + ); + + this.healthText = this.add.text( + hpX + HEALTH_BAR_WIDTH + 8, hpY, '', { + fontSize: '12px', + color: '#00ff88', + fontFamily: 'monospace', + }, + ); + + // === Quick slots (bottom-center) === + const totalW = SLOT_COUNT * SLOT_SIZE + (SLOT_COUNT - 1) * SLOT_GAP; + const startX = (w - totalW) / 2; + const startY = h - SLOT_SIZE - 12; + + for (let i = 0; i < SLOT_COUNT; i++) { + const x = startX + i * (SLOT_SIZE + SLOT_GAP); + const cx = x + SLOT_SIZE / 2; + const cy = startY + SLOT_SIZE / 2; + + // Slot background + const box = this.add.rectangle(cx, cy, SLOT_SIZE, SLOT_SIZE, 0x111111, 0.85); + box.setStrokeStyle(2, 0x444444); + this.slotBoxes.push(box); + + // Slot number (top-left corner) + const numText = this.add.text(x + 3, startY + 2, `${i + 1}`, { + fontSize: '10px', + color: '#555555', + fontFamily: 'monospace', + }); + this.slotNumTexts.push(numText); + + // Item name (center) + const itemText = this.add.text(cx, cy - 2, '', { + fontSize: '14px', + color: '#ffffff', + fontFamily: 'monospace', + fontStyle: 'bold', + }); + itemText.setOrigin(0.5); + this.slotTexts.push(itemText); + + // Count (bottom-right) + const countText = this.add.text(x + SLOT_SIZE - 3, startY + SLOT_SIZE - 3, '', { + fontSize: '10px', + color: '#aaaaaa', + fontFamily: 'monospace', + }); + countText.setOrigin(1, 1); + this.slotCountTexts.push(countText); + } + + // === Inventory info (bottom-right) === + this.invText = this.add.text(w - 12, h - 14, '', { + fontSize: '11px', + color: '#888888', + fontFamily: 'monospace', + backgroundColor: '#000000aa', + padding: { x: 4, y: 2 }, + }); + this.invText.setOrigin(1, 1); + + // === Controls hint (top-right) === + this.controlsText = this.add.text(w - 12, 12, 'WASD move | E collect | F throw | 1-4 slots | scroll zoom', { + fontSize: '10px', + color: '#444444', + fontFamily: 'monospace', + }); + this.controlsText.setOrigin(1, 0); + } + + update(): void { + // === Read shared state from registry === + const health = (this.registry.get('health') as number) ?? 100; + const healthMax = (this.registry.get('healthMax') as number) ?? 100; + const slots = (this.registry.get('quickSlots') as (string | null)[]) ?? []; + const activeSlot = (this.registry.get('activeSlot') as number) ?? 0; + const invWeight = (this.registry.get('invWeight') as number) ?? 0; + const invMaxWeight = (this.registry.get('invMaxWeight') as number) ?? 500; + const invSlots = (this.registry.get('invSlots') as number) ?? 0; + const invCounts = (this.registry.get('invCounts') as Map) ?? new Map(); + + // === Update health bar === + const ratio = healthMax > 0 ? health / healthMax : 0; + const fillWidth = HEALTH_BAR_WIDTH * ratio; + this.healthBarFill.width = fillWidth; + this.healthBarFill.x = this.healthBarBg.x - (HEALTH_BAR_WIDTH - fillWidth) / 2; + + // Color: green → yellow → red + const hpColor = ratio > 0.5 ? 0x00cc44 : ratio > 0.25 ? 0xccaa00 : 0xcc2200; + this.healthBarFill.setFillStyle(hpColor); + this.healthText.setText(`${Math.round(health)}/${healthMax}`); + + // === Update quick slots === + for (let i = 0; i < SLOT_COUNT; i++) { + const itemId = i < slots.length ? slots[i] : null; + const isActive = i === activeSlot; + + // Border color + this.slotBoxes[i].setStrokeStyle(2, isActive ? 0x00ff88 : 0x444444); + this.slotBoxes[i].setFillStyle(isActive ? 0x1a2a1a : 0x111111, 0.85); + + if (itemId) { + const displayName = getItemDisplayName(itemId); + const displayColor = getItemDisplayColor(itemId); + this.slotTexts[i].setText(displayName); + this.slotTexts[i].setColor(`#${displayColor.toString(16).padStart(6, '0')}`); + + const count = invCounts.get(itemId) ?? 0; + this.slotCountTexts[i].setText(count > 0 ? `${count}` : '0'); + this.slotCountTexts[i].setColor(count > 0 ? '#aaaaaa' : '#660000'); + } else { + this.slotTexts[i].setText(''); + this.slotCountTexts[i].setText(''); + } + } + + // === Update inventory === + this.invText.setText(`${Math.round(invWeight)}/${invMaxWeight} AMU | ${invSlots} items`); + } +} diff --git a/tests/ui.test.ts b/tests/ui.test.ts new file mode 100644 index 0000000..22e8f6d --- /dev/null +++ b/tests/ui.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for UIScene helper functions and HUD state contract + */ +import { describe, it, expect } from 'vitest'; +import { ElementRegistry } from '../src/chemistry/elements'; +import { CompoundRegistry } from '../src/chemistry/compounds'; +import { QuickSlots, SLOT_COUNT } from '../src/player/quickslots'; +import { Inventory } from '../src/player/inventory'; +import { Health } from '../src/ecs/components'; + +/** Mirror of UIScene's getItemDisplayColor for testability */ +function getItemDisplayColor(itemId: string): number { + const el = ElementRegistry.getBySymbol(itemId); + if (el) return parseInt(el.color.replace('#', ''), 16); + const comp = CompoundRegistry.getById(itemId); + if (comp) return parseInt(comp.color.replace('#', ''), 16); + return 0xffffff; +} + +/** Mirror of UIScene's getItemDisplayName for testability */ +function getItemDisplayName(itemId: string): string { + const el = ElementRegistry.getBySymbol(itemId); + if (el) return el.symbol; + const comp = CompoundRegistry.getById(itemId); + if (comp) return comp.formula; + return itemId; +} + +describe('HUD display helpers', () => { + it('getItemDisplayColor returns correct color for element', () => { + const color = getItemDisplayColor('Fe'); + expect(color).toBeGreaterThan(0); + expect(color).not.toBe(0xffffff); // Not the fallback + }); + + it('getItemDisplayColor returns fallback for unknown item', () => { + expect(getItemDisplayColor('totally_unknown')).toBe(0xffffff); + }); + + it('getItemDisplayName returns symbol for element', () => { + expect(getItemDisplayName('Fe')).toBe('Fe'); + expect(getItemDisplayName('Na')).toBe('Na'); + }); + + it('getItemDisplayName returns formula for compound', () => { + const name = getItemDisplayName('H2O'); + expect(name).toBe('H₂O'); + }); + + it('getItemDisplayName returns raw id for unknown', () => { + expect(getItemDisplayName('xyz')).toBe('xyz'); + }); +}); + +describe('HUD registry state contract', () => { + it('QuickSlots.getAll returns array of length SLOT_COUNT', () => { + const qs = new QuickSlots(); + expect(qs.getAll()).toHaveLength(SLOT_COUNT); + }); + + it('Inventory provides required state fields', () => { + const inv = new Inventory(500, 20); + expect(typeof inv.getTotalWeight()).toBe('number'); + expect(typeof inv.maxWeight).toBe('number'); + expect(typeof inv.slotCount).toBe('number'); + expect(Array.isArray(inv.getItems())).toBe(true); + }); + + it('Health component arrays are accessible', () => { + expect(Array.isArray(Health.current)).toBe(true); + expect(Array.isArray(Health.max)).toBe(true); + }); +});