diff --git a/index.html b/index.html index 66180fb..ac3deb0 100644 --- a/index.html +++ b/index.html @@ -45,10 +45,12 @@

Controls

  • Space ×2Toggle flight
  • CtrlSprint
  • MouseLook around
  • -
  • L-ClickBreak block
  • +
  • Hold L-ClickMine block (progress in survival)
  • R-ClickPlace block
  • +
  • Hold R-ClickEat selected food
  • 19Select hotbar slot
  • ScrollCycle hotbar
  • +
  • EOpen inventory / switch mode
  • FCycle selected block
  • PCapture screenshot
  • EscPause
  • @@ -119,6 +121,9 @@

    Paused

    + + + +
    +
    +
    + +
    +
    +
    -
    diff --git a/src/constants.ts b/src/constants.ts index ce878f7..3afba57 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,7 +26,7 @@ export const FLY_SPRINT_SPEED = 24; // blocks/s export const TERMINAL_VELOCITY = 60; /** Reach distance for block break/place raycasts. */ -export const REACH = 6; +export const REACH = 8; /** Max chunks generated/meshed per frame to avoid hitches. */ export const MAX_CHUNK_GEN_PER_FRAME = 2; diff --git a/src/engine/Input.ts b/src/engine/Input.ts index 33bfe8f..252ae7a 100644 --- a/src/engine/Input.ts +++ b/src/engine/Input.ts @@ -1,6 +1,8 @@ // Input manager: keyboard state, mouse-look deltas, mouse-button edges, and // pointer-lock handling. Also detects double-tap-Space for flight toggling. +import { dbg, dbgErr, dbgWarn } from "../state/Debug"; + export interface ClickEdges { break: boolean; // left mouse pressed this frame place: boolean; // right mouse pressed this frame @@ -12,12 +14,15 @@ export class Input { private mouseDY = 0; private breakQueued = false; private placeQueued = false; + private _leftHeld = false; + private _rightHeld = false; private lastSpaceTap = 0; private doubleTapSpace = false; private _locked = false; readonly canvas: HTMLCanvasElement; onPointerLockChange?: (locked: boolean) => void; + onPointerLockError?: () => void; onDoubleTapSpace?: () => void; onNumberKey?: (n: number) => void; onScroll?: (dir: number) => void; @@ -32,24 +37,68 @@ export class Input { window.addEventListener("keydown", this.handleKeyDown); window.addEventListener("keyup", this.handleKeyUp); window.addEventListener("mousemove", this.handleMouseMove); - this.canvas.addEventListener("mousedown", this.handleMouseDown); - this.canvas.addEventListener("contextmenu", this.handleContext); + // mousedown is bound to window (capture phase) — NOT the canvas — so clicks + // are caught even if an overlay element happens to sit above the canvas. + window.addEventListener("mousedown", this.handleMouseDown, true); + window.addEventListener("mouseup", this.handleMouseUp); + // Firefox fallback: `click` (left) and `contextmenu`/`auxclick` (right) + // fire reliably even when `mousedown` is flaky during focus/pointer-lock + // transitions, so we also set the break/place edges from these events. + window.addEventListener("click", this.handleClick, true); + window.addEventListener("auxclick", this.handleAuxClick, true); + window.addEventListener("contextmenu", this.handleContext, true); this.canvas.addEventListener("wheel", this.handleWheel, { passive: false }); document.addEventListener("pointerlockchange", this.handlePointerLockChange); + document.addEventListener("pointerlockerror", this.handlePointerLockError); + // Focus diagnostics — if these fire, the window lost focus (which kills + // pointer lock and swallows clicks until the user clicks to re-focus). + window.addEventListener("blur", () => dbgWarn("⚠ window BLUR — game lost focus (clicks will be ignored until you click the game again)")); + window.addEventListener("focus", () => dbg("window focus — game regained focus")); + document.addEventListener("visibilitychange", () => dbgWarn("visibilitychange — hidden=" + document.hidden)); } dispose(): void { window.removeEventListener("keydown", this.handleKeyDown); window.removeEventListener("keyup", this.handleKeyUp); window.removeEventListener("mousemove", this.handleMouseMove); - this.canvas.removeEventListener("mousedown", this.handleMouseDown); - this.canvas.removeEventListener("contextmenu", this.handleContext); + window.removeEventListener("mousedown", this.handleMouseDown, true); + window.removeEventListener("mouseup", this.handleMouseUp); + window.removeEventListener("click", this.handleClick, true); + window.removeEventListener("auxclick", this.handleAuxClick, true); + window.removeEventListener("contextmenu", this.handleContext, true); this.canvas.removeEventListener("wheel", this.handleWheel); document.removeEventListener("pointerlockchange", this.handlePointerLockChange); + document.removeEventListener("pointerlockerror", this.handlePointerLockError); } private handleContext = (e: Event): void => { + const t = (e as MouseEvent).target as Element | null; + // Let visible UI (inventory/menus) handle its own context menu. + if (t && t.closest && t.closest(".screen:not([hidden])")) return; e.preventDefault(); + dbg("contextmenu (robust right-click) -> placeQueued"); + this.placeQueued = true; + }; + + private handleClick = (e: MouseEvent): void => { + // `click` fires reliably in Firefox even when mousedown is flaky, so this + // is the primary break trigger. (click only fires for the left button.) + const t = e.target as Element | null; + if (t && t.closest && t.closest(".screen:not([hidden])")) return; + dbg("click (robust left-click) -> breakQueued"); + this.breakQueued = true; + }; + + private handleAuxClick = (e: MouseEvent): void => { + // `auxclick` is the right-button analog of `click` and fires reliably in + // Firefox; `contextmenu` can be suppressed during pointer lock, so this is + // the primary place trigger. + const t = e.target as Element | null; + if (t && t.closest && t.closest(".screen:not([hidden])")) return; + if (e.button === 2) { + dbg("auxclick (robust right-click) -> placeQueued"); + this.placeQueued = true; + } }; private handleKeyDown = (e: KeyboardEvent): void => { @@ -89,30 +138,79 @@ export class Input { }; private handleMouseDown = (e: MouseEvent): void => { - if (!this._locked) return; - if (e.button === 0) this.breakQueued = true; - else if (e.button === 2) this.placeQueued = true; + const t = e.target as Element | null; + const describe = (el: Element | null): string => { + if (!el) return "null"; + const tag = el.tagName.toLowerCase(); + const id = el.id ? "#" + el.id : ""; + const cls = typeof el.className === "string" && el.className ? "." + el.className.trim().split(/\s+/).join(".") : ""; + return tag + id + cls; + }; + // Ignore clicks that land on a visible UI overlay (main menu, pause, + // settings, inventory) — those have their own handlers. + if (t && t.closest && t.closest(".screen:not([hidden])")) { + dbg(`mousedown on UI (${describe(t)}) — ignored`); + return; + } + dbg(`mousedown button=${e.button} locked=${this._locked} target=${describe(t)} pointerLockElement=${document.pointerLockElement ? "yes" : "no"}`); + // Register the interaction FIRST, unconditionally, so clicks always mine/ + // place whether or not pointer lock is engaged. + if (e.button === 0) { + this.breakQueued = true; + this._leftHeld = true; + } else if (e.button === 2) { + this.placeQueued = true; + this._rightHeld = true; + } + dbg(` queued break=${this.breakQueued} place=${this.placeQueued} leftHeld=${this._leftHeld}`); + // Best-effort: try to engage pointer lock for mouse-look on a left click. + if (!this._locked && e.button === 0) this.requestLock(); + }; + + private handleMouseUp = (e: MouseEvent): void => { + dbg("mouseup button=" + e.button); + if (e.button === 0) this._leftHeld = false; + else if (e.button === 2) this._rightHeld = false; }; private handleWheel = (e: WheelEvent): void => { - if (!this._locked) return; + // Cycle the hotbar whether or not pointer lock is held (cursor-aiming mode + // included). Let visible UI overlays (e.g. the inventory) scroll normally. + const t = e.target as Element | null; + if (t && t.closest && t.closest(".screen:not([hidden])")) return; e.preventDefault(); this.onScroll?.(e.deltaY > 0 ? 1 : -1); }; private handlePointerLockChange = (): void => { this._locked = document.pointerLockElement === this.canvas; + dbg("pointerlockchange -> locked=" + this._locked); + if (!this._locked) { + this._leftHeld = false; + this._rightHeld = false; + } this.onPointerLockChange?.(this._locked); }; + private handlePointerLockError = (): void => { + this._locked = false; + dbgErr("pointerlockerror — the browser REFUSED pointer lock (cursor-aiming fallback is active)"); + this.onPointerLockError?.(); + }; + requestLock(): void { - if (!this._locked) { - // requestPointerLock may return a Promise (newer browsers); swallow any - // rejection (e.g. not triggered by a user gesture) instead of crashing. + if (this._locked) return; + try { + dbg("requestLock: calling canvas.requestPointerLock()…"); const result = this.canvas.requestPointerLock() as unknown as Promise | undefined; if (result && typeof result.then === "function") { - result.catch(() => {}); + result.then( + () => dbg("requestPointerLock promise RESOLVED"), + (err) => dbgWarn("requestPointerLock promise REJECTED:", err), + ); } + } catch (err) { + dbgWarn("requestPointerLock THREW synchronously (caught):", err); } } @@ -124,6 +222,14 @@ export class Input { return this._locked; } + get leftHeld(): boolean { + return this._leftHeld; + } + + get rightHeld(): boolean { + return this._rightHeld; + } + isDown(code: string): boolean { return this.keys.has(code); } @@ -155,6 +261,8 @@ export class Input { this.mouseDY = 0; this.breakQueued = false; this.placeQueued = false; + this._leftHeld = false; + this._rightHeld = false; this.doubleTapSpace = false; this.keys.clear(); } diff --git a/src/game/Blocks.ts b/src/game/Blocks.ts index f3e81e6..e34810f 100644 --- a/src/game/Blocks.ts +++ b/src/game/Blocks.ts @@ -365,6 +365,9 @@ export const BLOCKS: readonly BlockDef[] = [ ]; export const AIR_BLOCK = 0; +export const WATER_BLOCK = 7; +export const CACTUS_BLOCK = 19; +export const MUSHROOM_BLOCK = 23; export function isAir(id: BlockId): boolean { return id === AIR_BLOCK; @@ -373,6 +376,3 @@ export function isAir(id: BlockId): boolean { export function getBlock(id: BlockId): BlockDef { return BLOCKS[id] ?? AIR; } - -/** Blocks available in the hotbar (creative palette). */ -export const HOTBAR_BLOCKS: readonly BlockId[] = [1, 2, 3, 4, 5, 6, 7, 9, 19]; diff --git a/src/game/Game.ts b/src/game/Game.ts index 9d504ab..3c06e7c 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -3,6 +3,7 @@ import { Color4, DynamicTexture, LinesMesh, + Mesh, MeshBuilder, Scene, StandardMaterial, @@ -20,21 +21,38 @@ import { Input } from "../engine/Input"; import { captureScreenshot } from "../engine/Screenshot"; import { World } from "./World"; import { Player } from "./Player"; -import { HOTBAR_BLOCKS, getBlock } from "./Blocks"; +import { getBlock } from "./Blocks"; +import { + STARTER_CREATIVE_HOTBAR, + STARTER_SURVIVAL_KIT, + digTime, + dropForBlock, + getItem, + isBreakable, + isFood, + type GameMode, +} from "./Items"; +import { Inventory } from "./Inventory"; +import { PlayerState } from "./PlayerState"; +import { clearSave, loadSave, writeSave } from "../state/SaveData"; import { ScreenManager } from "../ui/ScreenManager"; import { HUD } from "../ui/HUD"; import { Menus } from "../ui/Menus"; +import { InventoryUI } from "../ui/InventoryUI"; import { loadSettings, saveSettings } from "../state/Settings"; +import { dbg, dbgWarn } from "../state/Debug"; const SPAWN_PREGEN_RADIUS = 2; - const SKY_COLOR_HEX = "#bfe3ff"; +const EAT_TIME = 1.6; +const INVENTORY_SIZE = 36; +const HOTBAR_SIZE = 9; /** * Top-level orchestrator. Owns the renderer (Babylon Engine), the scene, the - * world, player, input, and UI; runs the fixed-pace game loop; and drives the - * menu/play/pause state machine plus block editing, hotbar, settings, and - * screenshots. + * world, player, input, UI, the inventory + survival systems, and the game loop. + * Drives the menu/play/pause state machine plus block editing, mining progress, + * eating, health/hunger/breath, death/respawn, and per-seed saving. */ export class Game { private state: GameState = "menu"; @@ -51,6 +69,12 @@ export class Game { private settings: Settings; private readonly highlight: LinesMesh; + private readonly breakOverlay: Mesh; + private readonly breakMaterial: StandardMaterial; + + private readonly inventory = new Inventory(INVENTORY_SIZE, HOTBAR_SIZE); + private readonly stats = new PlayerState(); + private readonly invUI: InventoryUI; private selectedIndex = 0; private last = performance.now(); @@ -58,20 +82,28 @@ export class Game { private hudTimer = 0; private running = false; + private inventoryOpen = false; + private spawnPoint = new Vector3(0.5, 40, 0.5); + + // Mining / interaction transient state + private mining: { x: number; y: number; z: number; progress: number } | null = null; + private breakCooldown = 0; + private eatProgress = 0; + private sprintExhaustT = 0; + private cactusT = 0; + private saveTimer = 0; + private lastTargetKey = ""; + constructor(host: HTMLElement) { this.settings = loadSettings(); this.renderer = new Renderer(host); const scene = new Scene(this.renderer.engine); - // Right-handed coordinates — keeps the world-gen, physics, and raycast math - // identical to the prior three.js implementation (camera looks down -Z, - // +X right, +Y up). scene.useRightHandedSystem = true; const sky = Color3.FromHexString(SKY_COLOR_HEX); scene.clearColor = new Color4(sky.r, sky.g, sky.b, 1); scene.ambientColor = new Color3(1, 1, 1); - // Fog is read live by the cloud ShaderMaterial via Sky.update(). scene.fogMode = Scene.FOGMODE_LINEAR; scene.fogColor = sky.clone(); scene.fogStart = 60; @@ -84,18 +116,26 @@ export class Game { this.player = new Player(window.innerWidth / window.innerHeight, scene); this.player.setFov(this.settings.fov); + this.player.canFly = this.settings.mode === "creative"; scene.activeCamera = this.player.camera; this.input = new Input(this.renderer.canvas); this.screens = new ScreenManager(); this.hud = new HUD(); this.menus = new Menus(this.screens, this.settings); + this.invUI = new InventoryUI( + document.getElementById("inventory-screen") as HTMLElement, + this.inventory, + () => this.settings.mode, + ); this.highlight = makeHighlight(scene); + const { mesh, material } = makeBreakOverlay(scene); + this.breakOverlay = mesh; + this.breakMaterial = material; this.sky.setCloudsEnabled(this.settings.clouds); this.hud.setFpsVisible(this.settings.showFps); - this.hud.setSelected(this.selectedIndex); this.screens.setMenuSeed(this.settings.seed); this.bindCallbacks(); @@ -118,26 +158,34 @@ export class Game { this.menus.onSettingsChange = (patch) => this.applySettings(patch); this.menus.onRegenerate = (seed) => this.regenerate(seed); + this.invUI.onModeChange = (mode) => this.setMode(mode); + this.invUI.onClose = () => this.closeInventory(); + this.invUI.onRefresh = () => this.refreshHud(); + this.input.onPointerLockChange = (locked) => { - // Don't auto-pause when the pointer leaves — only auto-resume if the - // user re-locks while already paused. if (locked && this.state === "paused") this.setPlaying(); }; + this.input.onPointerLockError = () => { + this.hud.showToast("Click the game to capture the mouse"); + }; this.input.onNumberKey = (n) => this.selectSlot(n - 1); this.input.onScroll = (dir) => this.selectSlot(this.selectedIndex + dir); this.input.onKey = (code, down) => { if (!down) return; if (code === "KeyP") void this.takeScreenshot(); if (code === "KeyF") this.selectSlot(this.selectedIndex + 1); - if (code === "Escape" && this.state === "playing") this.pause(); + if (code === "KeyE" && (this.state === "playing" || this.inventoryOpen)) this.toggleInventory(); + if (code === "Escape") { + if (this.inventoryOpen) this.closeInventory(); + else if (this.state === "playing") this.pause(); + } }; } private bindGlobalEvents(): void { window.addEventListener("resize", this.handleResize); - // Clicking the canvas while playing re-acquires pointer lock if lost. this.renderer.canvas.addEventListener("click", () => { - if (this.state === "playing" && !this.input.locked) this.input.requestLock(); + if (this.state === "playing" && !this.input.locked && !this.inventoryOpen) this.input.requestLock(); }); } @@ -157,20 +205,23 @@ export class Game { if (patch.fov !== undefined) this.player.setFov(patch.fov); if (patch.clouds !== undefined) this.sky.setCloudsEnabled(patch.clouds); if (patch.showFps !== undefined) this.hud.setFpsVisible(patch.showFps); - // viewDistance + mouseSensitivity are read live during the loop. - if (patch.viewDistance !== undefined) { - this.updateFog(); + if (patch.viewDistance !== undefined) this.updateFog(); + if (patch.mode !== undefined) { + this.player.canFly = patch.mode === "creative"; + if (patch.mode === "survival") this.player.flying = false; + this.invUI.refresh(); + this.refreshHud(); } this.menus.updateCurrent(this.settings); } - // -------------------------------------------------------- game states --- + // ------------------------------------------------------ game states --- private startGame(): void { this.setState("loading"); this.createWorld(this.settings.seed); - // Synchronous pre-generation around spawn so the player has ground. this.player.spawn(this.world!, 0, 0); + this.spawnPoint = this.player.position.clone(); const px = this.player.position.x; const pz = this.player.position.z; const pcx = Math.floor(px / 16); @@ -180,22 +231,27 @@ export class Game { this.world!.ensureGenerated(pcx + dx, pcz + dz); } } - // Pre-mesh the close area so the first frame already looks good. for (let i = 0; i < 10; i++) this.world!.update(px, pz, this.settings.viewDistance); this.updateFog(); + this.loadOrCreateProgress(); + this.player.canFly = this.settings.mode === "creative"; + this.refreshHud(); this.setPlaying(); - // Request pointer lock within the user gesture (Play click). this.input.requestLock(); } private regenerate(seed: string): void { + this.saveState(); this.applySettings({ seed }); if (this.state === "playing" || this.state === "paused") { this.setState("loading"); this.createWorld(seed); this.player.spawn(this.world!, 0, 0); + this.spawnPoint = this.player.position.clone(); for (let i = 0; i < 10; i++) this.world!.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); this.updateFog(); + this.loadOrCreateProgress(); + this.refreshHud(); this.setPlaying(); this.input.requestLock(); } @@ -207,10 +263,31 @@ export class Game { this.world = null; } this.world = new World(seed, this.atlas, this.scene); - // Re-seed the cloud field so it matches the new world. this.sky.setCloudSeed(seed); } + /** Load inventory+vitals for this seed, or seed a fresh starter kit. */ + private loadOrCreateProgress(): void { + const save = loadSave(this.settings.seed); + if (save) { + this.inventory.load(save.inventory); + this.stats.load(save.stats); + if (save.mode && save.mode !== this.settings.mode) { + this.applySettings({ mode: save.mode }); + } + return; + } + this.inventory.clear(); + this.stats.reset(); + if (this.settings.mode === "creative") { + for (let i = 0; i < STARTER_CREATIVE_HOTBAR.length && i < HOTBAR_SIZE; i++) { + this.inventory.setSlot(i, { id: STARTER_CREATIVE_HOTBAR[i], count: 64 }); + } + } else { + for (const kit of STARTER_SURVIVAL_KIT) this.inventory.add(kit.id, kit.count); + } + } + private setPlaying(): void { this.state = "playing"; this.screens.applyState("playing"); @@ -228,14 +305,14 @@ export class Game { private resume(): void { this.input.requestLock(); - // setPlaying happens once the lock is acquired (pointerlockchange). - // Fallback in case the lock is granted without firing: setTimeout(() => { if (this.state === "paused") this.setPlaying(); }, 200); } private quitToMenu(): void { + this.saveState(); + this.closeInventorySilent(); this.input.exitLock(); this.input.clearTransient(); this.setState("menu"); @@ -247,6 +324,45 @@ export class Game { if (state === "menu") this.hud.setCrosshairVisible(false); } + // ----------------------------------------------------------- inventory --- + + private toggleInventory(): void { + if (this.inventoryOpen) this.closeInventory(); + else this.openInventory(); + } + + private openInventory(): void { + this.inventoryOpen = true; + this.mining = null; + this.breakOverlay.isVisible = false; + this.highlight.isVisible = false; + this.hud.setCrosshairVisible(false); + this.input.exitLock(); + this.invUI.open(); + } + + private closeInventory(): void { + this.invUI.close(); + this.inventoryOpen = false; + this.hud.setCrosshairVisible(true); + this.refreshHud(); + this.saveState(); + if (this.state === "playing") this.input.requestLock(); + } + + /** Close without re-locking pointer (used on quit). */ + private closeInventorySilent(): void { + if (!this.inventoryOpen) return; + this.invUI.close(); + this.inventoryOpen = false; + } + + private setMode(mode: GameMode): void { + this.applySettings({ mode }); + this.hud.showToast(mode === "creative" ? "Creative mode" : "Survival mode"); + this.saveState(); + } + // ------------------------------------------------------------- loop --- start(): void { @@ -260,28 +376,76 @@ export class Game { const now = performance.now(); let dt = (now - this.last) / 1000; this.last = now; - if (dt > 0.05) dt = 0.05; // clamp to avoid huge steps after tab switches + if (dt > 0.05) dt = 0.05; if (this.state === "playing") { - this.update(dt); + if (this.inventoryOpen) { + // Freeze the action but keep the world streaming + rendering. + this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); + } else { + this.update(dt); + } } else if (this.state === "paused") { - // Keep the world rendering but freeze simulation; still stream meshes so - // resuming is instant. this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); } this.sky.update(dt, this.player.camera.position); this.scene.render(); - this.updateFps(dt); }; private update(dt: number): void { - this.player.update(dt, this.world!, this.input, this.settings); - this.world!.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); + const world = this.world!; + const mode = this.settings.mode; + this.player.update(dt, world, this.input, this.settings); + world.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); + + if (this.breakCooldown > 0) this.breakCooldown -= dt; + + // --- Survival vitals --- + if (mode === "survival") { + const fall = this.player.consumeFallDistance(); + const fallDmg = PlayerState.fallDamage(fall); + if (fallDmg > 0) this.stats.damage(fallDmg, mode); + + if (this.player.touchingCactus(world)) { + this.cactusT += dt; + if (this.cactusT >= 1) { + this.cactusT -= 1; + this.stats.damage(1, mode); + } + } else { + this.cactusT = 0; + } + + const sprinting = this.input.isDown("ControlLeft") || this.input.isDown("ControlRight"); + const moving = this.input.isDown("KeyW") || this.input.isDown("KeyA") || this.input.isDown("KeyS") || this.input.isDown("KeyD"); + if (sprinting && moving && this.player.onGround && !this.player.flying) { + this.sprintExhaustT += dt; + if (this.sprintExhaustT >= 1) { + this.sprintExhaustT -= 1; + this.stats.addExhaustion(100); + } + } - // Block highlight + this.stats.tick(dt, mode, this.player.headSubmerged(world)); + if (this.stats.dead) { + this.respawn(); + return; + } + } else { + this.stats.breath = 10; + } + + // --- Block highlight --- const target = this.player.getTarget(); + { + const key = target ? `${target.x},${target.y},${target.z}=#${target.block}` : "none"; + if (key !== this.lastTargetKey) { + this.lastTargetKey = key; + dbg("target ->", target ? JSON.stringify({ x: target.x, y: target.y, z: target.z, block: target.block, px: target.px, py: target.py, pz: target.pz }) : "null (no block in reach / not aimed at one)"); + } + } if (target) { this.highlight.isVisible = true; this.highlight.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5); @@ -289,42 +453,155 @@ export class Game { this.highlight.isVisible = false; } - // Break / place + // --- Break / place / eat --- const clicks = this.input.consumeClicks(); - if (clicks.break && target) this.breakBlock(target); - if (clicks.place && target) this.placeBlock(target); + if (clicks.break || clicks.place) dbg("consumeClicks ->", JSON.stringify(clicks)); + + // Break: creative = instant on click edge; survival = hold-to-mine. + if (mode === "creative") { + if (clicks.break && target) { + dbg("creative: instant break on click"); + this.breakBlock(target.x, target.y, target.z); + } + } else { + this.updateMining(dt, target, mode); + } + + // Place / eat (right mouse) + const selected = this.inventory.getSlot(this.selectedIndex); + const foodSelected = !!selected && isFood(selected.id); + if (clicks.place && target && !foodSelected) this.placeBlock(target); + this.updateEating(dt, foodSelected, mode); - // HUD status (throttled) + // --- HUD status (throttled) --- this.hudTimer += dt; if (this.hudTimer >= 0.15) { this.hudTimer = 0; const p = this.player.position; this.hud.setCoords(p.x, p.y, p.z); - this.hud.setMode(this.player.flying ? "Flying" : this.player.inWater ? "Swimming" : "Walking"); + this.hud.setMode(mode === "creative" ? "Creative" : this.player.flying ? "Flying" : this.player.inWater ? "Swimming" : "Survival"); + this.hud.setLockHint(this.input.locked); + this.refreshHud(); + } + + // --- Periodic save --- + this.saveTimer += dt; + if (this.saveTimer >= 5) { + this.saveTimer = 0; + this.saveState(); } } - private breakBlock(t: { x: number; y: number; z: number }): void { - const id = this.world!.getBlock(t.x, t.y, t.z); - if (id === 8) { - this.hud.showToast("Bedrock is unbreakable"); + private updateMining(dt: number, target: ReturnType, mode: GameMode): void { + if (this.breakCooldown > 0) { + if (this.input.leftHeld && target) dbgWarn("mining blocked by cooldown=" + this.breakCooldown.toFixed(3)); + this.breakOverlay.isVisible = false; return; } - this.world!.setBlock(t.x, t.y, t.z, 0); + if (this.input.leftHeld && target) { + const id = this.world!.getBlock(target.x, target.y, target.z); + if (!isBreakable(id)) { + dbgWarn("target block " + id + " is not breakable"); + this.mining = null; + this.breakOverlay.isVisible = false; + this.hud.showToast("Can't break this block"); + this.breakCooldown = 0.3; + return; + } + if (!this.mining || this.mining.x !== target.x || this.mining.y !== target.y || this.mining.z !== target.z) { + dbg("start mining", JSON.stringify({ x: target.x, y: target.y, z: target.z, id, digTime: digTime(id, mode) })); + this.mining = { x: target.x, y: target.y, z: target.z, progress: 0 }; + } + const t = digTime(id, mode); + if (t === 0) { + dbg("instant break (creative) id=" + id); + this.breakBlock(this.mining.x, this.mining.y, this.mining.z); + this.mining = null; + this.breakCooldown = 0.12; + this.breakOverlay.isVisible = false; + return; + } + this.mining.progress += dt; + // Show break overlay tint growing with progress. + this.breakOverlay.isVisible = true; + this.breakOverlay.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5); + this.breakMaterial.alpha = Math.min(0.7, 0.12 + (this.mining.progress / t) * 0.6); + if (this.mining.progress >= t) { + dbg("mining complete (progress " + this.mining.progress.toFixed(2) + " >= " + t + ")"); + this.breakBlock(this.mining.x, this.mining.y, this.mining.z); + this.mining = null; + this.breakCooldown = 0.12; + this.breakOverlay.isVisible = false; + } + } else { + if (this.mining) dbg("mining cancelled (leftHeld=" + this.input.leftHeld + " target=" + (target ? "yes" : "no") + ")"); + this.mining = null; + this.breakOverlay.isVisible = false; + } + } + + private updateEating(dt: number, foodSelected: boolean, mode: GameMode): void { + if (this.input.rightHeld && foodSelected) { + this.eatProgress += dt; + if (this.eatProgress >= EAT_TIME) { + const sel = this.inventory.getSlot(this.selectedIndex); + if (sel && isFood(sel.id)) { + const def = getItem(sel.id); + if (def?.food) { + this.stats.eat(def.food); + if (mode === "survival") this.inventory.consumeOne(this.selectedIndex); + this.refreshHud(); + this.hud.showToast(`Ate ${def.name}`); + } + } + this.eatProgress = 0; + } + } else { + this.eatProgress = 0; + } + } + + private breakBlock(x: number, y: number, z: number): void { + const world = this.world!; + const id = world.getBlock(x, y, z); + dbg("breakBlock", JSON.stringify({ x, y, z, id, breakable: isBreakable(id) })); + if (!isBreakable(id)) return; + const changed = world.setBlock(x, y, z, 0); + dbg(" setBlock -> changed=" + changed); + if (this.settings.mode === "survival") { + const drop = dropForBlock(id); + if (drop !== null) { + const leftover = this.inventory.add(drop, 1); + if (leftover > 0) this.hud.showToast("Inventory full"); + } + this.stats.addExhaustion(5); + this.refreshHud(); + } } private placeBlock(t: { px: number; py: number; pz: number }): void { - const id = HOTBAR_BLOCKS[this.selectedIndex]; - if (!getBlock(id).solid) { - // Allow water placement too; skip the inside-player check for fluids. - this.world!.setBlock(t.px, t.py, t.pz, id); + const sel = this.inventory.getSlot(this.selectedIndex); + dbg("placeBlock", JSON.stringify({ sel: sel ? sel.id : null, px: t.px, py: t.py, pz: t.pz })); + if (!sel) { + dbgWarn(" no item in selected slot " + this.selectedIndex + " — nothing to place"); + return; + } + const def = getItem(sel.id); + if (!def || def.block === undefined) { + dbgWarn(" selected item " + sel.id + " is not placeable (food/non-block)"); return; } - if (this.intersectsPlayer(t.px, t.py, t.pz)) { + const block = def.block; + if (getBlock(block).solid && this.intersectsPlayer(t.px, t.py, t.pz)) { this.hud.showToast("Can't place a block inside yourself"); return; } - this.world!.setBlock(t.px, t.py, t.pz, id); + const changed = this.world!.setBlock(t.px, t.py, t.pz, block); + dbg(" setBlock block=" + block + " -> changed=" + changed); + if (changed && this.settings.mode === "survival") { + this.inventory.consumeOne(this.selectedIndex); + this.refreshHud(); + } } private intersectsPlayer(bx: number, by: number, bz: number): boolean { @@ -346,11 +623,37 @@ export class Game { } private selectSlot(index: number): void { - const len = HOTBAR_BLOCKS.length; - this.selectedIndex = ((index % len) + len) % len; + this.selectedIndex = ((index % HOTBAR_SIZE) + HOTBAR_SIZE) % HOTBAR_SIZE; this.hud.setSelected(this.selectedIndex); } + private respawn(): void { + this.stats.reset(); + this.closeInventorySilent(); + this.mining = null; + this.breakOverlay.isVisible = false; + this.player.spawn(this.world!, this.spawnPoint.x, this.spawnPoint.z); + this.hud.showToast("You died — respawning at spawn"); + this.refreshHud(); + this.setPlaying(); + this.input.requestLock(); + this.saveState(); + } + + private refreshHud(): void { + this.hud.refreshHotbar(this.inventory, this.selectedIndex, this.settings.mode); + this.hud.setStats(this.stats.hp, this.stats.hunger, this.stats.breath); + } + + private saveState(): void { + if (!this.world) return; + writeSave(this.settings.seed, { + inventory: this.inventory.serialize(), + stats: this.stats.serialize(), + mode: this.settings.mode, + }); + } + // --------------------------------------------------------- screens --- private updateFog(): void { @@ -369,7 +672,6 @@ export class Game { // ------------------------------------------------------ screenshot --- async takeScreenshot(): Promise { - // Ensure the canvas holds a freshly rendered frame. this.scene.render(); const name = this.state === "menu" || this.state === "loading" ? "main-menu.png" : "in-game.png"; try { @@ -383,6 +685,7 @@ export class Game { // --------------------------------------------------------- dispose --- dispose(): void { + this.saveState(); this.renderer.engine.stopRenderLoop(); this.running = false; window.removeEventListener("resize", this.handleResize); @@ -390,6 +693,8 @@ export class Game { this.world?.dispose(); this.sky.dispose(); this.atlas.dispose(); + this.breakMaterial.dispose(); + this.breakOverlay.dispose(); this.highlight.dispose(); this.scene.dispose(); this.renderer.dispose(); @@ -415,45 +720,65 @@ export class Game { return this.state; } + /** Clear the saved survival progress for the current seed (debug helper). */ + _resetProgress(): void { + clearSave(this.settings.seed); + this.inventory.clear(); + this.stats.reset(); + this.refreshHud(); + } + /** TEMP debug: dump all chunk coords that are loaded. */ _loadedChunks(): unknown { if (!this.world) return []; - const chunks = (this.world as any).chunks as Map; + const chunks = (this.world as unknown as { chunks: Map }).chunks; return [...chunks.keys()]; } - /** TEMP debug: replace all chunk materials with a flat unlit colour so the - * world can be inspected without atlas/texture noise. Toggle via - * `__voxl.debugFlat()` from the devtools console. */ + /** TEMP debug: replace all chunk materials with a flat unlit colour. */ _enableDebugFlat(): void { if (!this.world) return; const mat = new StandardMaterial("debug-flat", this.scene); mat.diffuseColor = new Color3(1, 1, 1); mat.emissiveColor = new Color3(0.6, 0.6, 0.6); mat.specularColor = new Color3(0, 0, 0); - for (const m of (this.world as any).root.getChildMeshes() as any[]) { + for (const m of (this.world as unknown as { root: { getChildMeshes: () => Mesh[] } }).root.getChildMeshes()) { m.material = mat; } } + + /** TEMP debug: inspect interaction state. */ + _debugInfo(): Record { + const t = this.player.getTarget(); + const sel = this.inventory.getSlot(this.selectedIndex); + return { + state: this.state, + mode: this.settings.mode, + inventoryOpen: this.inventoryOpen, + locked: this.input.locked, + leftHeld: this.input.leftHeld, + rightHeld: this.input.rightHeld, + selectedIndex: this.selectedIndex, + selected: sel ? `${sel.id} x${sel.count}` : null, + target: t ? { x: t.x, y: t.y, z: t.z, block: t.block, px: t.px, py: t.py, pz: t.pz } : null, + pos: { x: this.player.position.x, y: this.player.position.y, z: this.player.position.z }, + }; + } } -/** Builds the wireframe block-selection outline as a LinesMesh with the 12 - * edges of a unit cube (matches the prior three.js BoxGeometry+EdgesGeometry). */ +/** Wireframe block-selection outline (12 edges of a unit cube). */ function makeHighlight(scene: Scene): LinesMesh { - const s = 1.002 / 2; // half-extent + const s = 1.002 / 2; const v = (x: number, y: number, z: number) => new Vector3(x, y, z); const edges: Vector3[][] = [ - // bottom face [v(-s, -s, -s), v(s, -s, -s)], [v(s, -s, -s), v(s, -s, s)], [v(s, -s, s), v(-s, -s, s)], [v(-s, -s, s), v(-s, -s, -s)], - // top face [v(-s, s, -s), v(s, s, -s)], [v(s, s, -s), v(s, s, s)], [v(s, s, s), v(-s, s, s)], [v(-s, s, s), v(-s, s, -s)], - // verticals [v(-s, -s, -s), v(-s, s, -s)], [v(s, -s, -s), v(s, s, -s)], [v(s, -s, s), v(s, s, s)], @@ -468,3 +793,24 @@ function makeHighlight(scene: Scene): LinesMesh { lines.applyFog = false; return lines; } + +/** Translucent cube overlaid on the block being mined; its alpha tracks dig + * progress to give a "cracking" darkening cue. */ +function makeBreakOverlay(scene: Scene): { mesh: Mesh; material: StandardMaterial } { + const material = new StandardMaterial("break-overlay", scene); + material.diffuseColor = new Color3(0.05, 0.02, 0.02); + material.emissiveColor = new Color3(0.12, 0.04, 0.04); + material.specularColor = new Color3(0, 0, 0); + material.alpha = 0; + material.disableDepthWrite = true; + material.backFaceCulling = false; + material.transparencyMode = 2; // MATERIAL_ALPHABLEND + + const mesh = MeshBuilder.CreateBox("break-overlay", { size: 1.004 }, scene); + mesh.material = material; + mesh.isPickable = false; + mesh.applyFog = false; + mesh.alwaysSelectAsActiveMesh = true; + mesh.isVisible = false; + return { mesh, material }; +} diff --git a/src/game/Inventory.ts b/src/game/Inventory.ts new file mode 100644 index 0000000..3f07f2c --- /dev/null +++ b/src/game/Inventory.ts @@ -0,0 +1,132 @@ +import { getItem } from "./Items"; +import type { ItemId } from "./Items"; + +export interface ItemStack { + id: ItemId; + count: number; +} + +function stackMax(id: ItemId): number { + return getItem(id)?.maxStack ?? 64; +} + +/** + * Fixed-size slot inventory. Slots 0..hotbarSize-1 are the hotbar (shared with + * the main grid in the inventory screen). Supports add-with-merge, cursor-based + * pickup/place/swap, and JSON serialization. + */ +export class Inventory { + readonly slots: (ItemStack | null)[]; + readonly hotbarSize: number; + + constructor(size: number, hotbarSize = 9) { + this.slots = new Array(size).fill(null); + this.hotbarSize = hotbarSize; + } + + get size(): number { + return this.slots.length; + } + + getSlot(i: number): ItemStack | null { + return this.slots[i] ?? null; + } + + setSlot(i: number, stack: ItemStack | null): void { + if (i < 0 || i >= this.slots.length) return; + if (stack && stack.count <= 0) stack = null; + this.slots[i] = stack; + } + + hotbarSlot(i: number): ItemStack | null { + return this.slots[i] ?? null; + } + + /** Add items, merging into existing stacks first. Returns leftover count. */ + add(id: ItemId, count: number): number { + const max = stackMax(id); + // Pass 1: merge into existing stacks of the same id. + for (let i = 0; i < this.slots.length && count > 0; i++) { + const s = this.slots[i]; + if (s && s.id === id && s.count < max) { + const room = max - s.count; + const take = Math.min(room, count); + s.count += take; + count -= take; + } + } + // Pass 2: fill empty slots. + for (let i = 0; i < this.slots.length && count > 0; i++) { + if (!this.slots[i]) { + const take = Math.min(max, count); + this.slots[i] = { id, count: take }; + count -= take; + } + } + return count; + } + + /** Remove one item from a slot; returns true if something was consumed. */ + consumeOne(i: number): boolean { + const s = this.slots[i]; + if (!s) return false; + s.count -= 1; + if (s.count <= 0) this.slots[i] = null; + return true; + } + + /** Remove up to count of an item id from anywhere. Returns removed count. */ + remove(id: ItemId, count: number): number { + let removed = 0; + for (let i = 0; i < this.slots.length && removed < count; i++) { + const s = this.slots[i]; + if (s && s.id === id) { + const take = Math.min(s.count, count - removed); + s.count -= take; + removed += take; + if (s.count <= 0) this.slots[i] = null; + } + } + return removed; + } + + countOf(id: ItemId): number { + let n = 0; + for (const s of this.slots) if (s && s.id === id) n += s.count; + return n; + } + + clear(): void { + for (let i = 0; i < this.slots.length; i++) this.slots[i] = null; + } + + isEmpty(): boolean { + return this.slots.every((s) => !s); + } + + serialize(): SerializedSlot[] { + const out: SerializedSlot[] = []; + for (let i = 0; i < this.slots.length; i++) { + const s = this.slots[i]; + if (s) out.push({ i, id: s.id, count: s.count }); + } + return out; + } + + load(data: SerializedSlot[] | undefined): void { + this.clear(); + if (!data) return; + for (const e of data) { + if (e.i >= 0 && e.i < this.slots.length && e.count > 0 && getItem(e.id)) { + const max = stackMax(e.id); + this.slots[e.i] = { id: e.id, count: Math.min(e.count, max) }; + } + } + } +} + +export interface SerializedSlot { + i: number; + id: ItemId; + count: number; +} diff --git a/src/game/Items.ts b/src/game/Items.ts new file mode 100644 index 0000000..994691d --- /dev/null +++ b/src/game/Items.ts @@ -0,0 +1,162 @@ +import type { BlockId } from "../types"; +import { BLOCKS, WATER_BLOCK, MUSHROOM_BLOCK } from "./Blocks"; + +/** + * Items generalize blocks: every non-air block becomes a placeable block item + * ("b"), and a handful of standalone food items exist for survival. Item + * ids are strings so the registry can grow to tools/materials later without + * disturbing the numeric block ids stored in chunk data. + */ + +export type ItemId = string; + +export interface FoodDef { + /** Hunger points restored (0–20 scale, 20 = full). */ + hunger: number; + /** Hidden saturation buffer restored. */ + saturation: number; +} + +export interface ItemDef { + id: ItemId; + name: string; + /** Representative UI color (hex). */ + color: string; + maxStack: number; + /** UI rendering hint. */ + icon: "block" | "food"; + /** If set, using this item places the given block. */ + block?: BlockId; + /** If set, this item can be eaten. */ + food?: FoodDef; +} + +/** Build the block item id for a numeric block id. */ +export function blockItemId(id: BlockId): ItemId { + return `b${id}`; +} + +const FOOD_ITEMS: ItemDef[] = [ + { id: "apple", name: "Apple", color: "#d24440", maxStack: 64, icon: "food", food: { hunger: 4, saturation: 2.4 } }, + { id: "bread", name: "Bread", color: "#c89a5a", maxStack: 64, icon: "food", food: { hunger: 5, saturation: 6 } }, + { id: "cooked_beef", name: "Cooked Beef", color: "#8a4a3a", maxStack: 64, icon: "food", food: { hunger: 8, saturation: 12.8 } }, + { id: "cookie", name: "Cookie", color: "#b07a3a", maxStack: 64, icon: "food", food: { hunger: 2, saturation: 0.4 } }, + { id: "golden_apple", name: "Golden Apple", color: "#f2c94c", maxStack: 64, icon: "food", food: { hunger: 8, saturation: 9.6 } }, +]; + +/** Blocks whose placement form is edible. */ +const EDIBLE_BLOCKS = new Set([MUSHROOM_BLOCK]); + +function buildBlockItems(): ItemDef[] { + const items: ItemDef[] = []; + for (const def of BLOCKS) { + if (def.id === 0) continue; // skip air + const item: ItemDef = { + id: blockItemId(def.id), + name: def.name, + color: def.color, + maxStack: 64, + icon: "block", + block: def.id, + }; + if (EDIBLE_BLOCKS.has(def.id)) { + item.food = { hunger: 1, saturation: 0.6 }; + } + items.push(item); + } + return items; +} + +export const ITEMS: readonly ItemDef[] = [...buildBlockItems(), ...FOOD_ITEMS]; + +const ITEM_INDEX = new Map(ITEMS.map((it) => [it.id, it])); + +export function getItem(id: ItemId): ItemDef | undefined { + return ITEM_INDEX.get(id); +} + +export function isFood(id: ItemId): boolean { + const it = ITEM_INDEX.get(id); + return !!it && !!it.food; +} + +/** Order of items shown in the creative palette. */ +export const CREATIVE_PALETTE: readonly ItemId[] = [ + "b1", "b2", "b3", "b4", "b5", "b6", "b9", "b19", + "b10", "b11", "b12", "b13", "b14", "b15", "b27", + "b16", "b17", "b18", + "b20", "b21", "b22", "b23", + "b24", "b25", "b26", + "b7", + "apple", "bread", "cooked_beef", "cookie", "golden_apple", +]; + +/** + * Default survival hotbar/quick items for a freshly spawned creative world + * (mirrors the classic palette so creative feels like the old hotbar). + */ +export const STARTER_CREATIVE_HOTBAR: readonly ItemId[] = [ + "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b9", "b19", +]; + +/** + * A small welcome kit for survival spawns so the player isn't helpless. + */ +export const STARTER_SURVIVAL_KIT: ReadonlyArray<{ id: ItemId; count: number }> = [ + { id: "bread", count: 5 }, + { id: "apple", count: 3 }, +]; + +/** + * Block drops in survival. Returns the item id dropped (or null for nothing), + * or undefined to mean "drops itself". + */ +const DROP_TABLE: Record = { + 1: "b2", // grass block -> dirt + 6: null, // leaves -> nothing + 10: "b2", // snowy grass -> dirt + 11: null, // ice -> melts to nothing + 26: null, // jungle leaves -> nothing +}; + +export function dropForBlock(blockId: BlockId): ItemId | null { + if (blockId in DROP_TABLE) return DROP_TABLE[blockId]; + return blockItemId(blockId); +} + +type Hardness = "instant" | "soft" | "medium" | "unbreakable"; + +const HARDNESS: Record = { + 8: "unbreakable", // bedrock + [WATER_BLOCK]: "unbreakable", // fluids can't be punched away + 20: "instant", 21: "instant", 22: "instant", [MUSHROOM_BLOCK]: "instant", // plantlike +}; + +function hardnessOf(blockId: BlockId): Hardness { + if (blockId in HARDNESS) return HARDNESS[blockId]; + switch (blockId) { + case 1: case 2: case 4: case 9: case 10: case 12: case 15: case 24: case 25: + return "soft"; + default: + return "medium"; + } +} + +const DIG_TIME: Record = { + instant: 0, + soft: 0.35, + medium: 0.9, + unbreakable: Infinity, +}; + +export type GameMode = "survival" | "creative"; + +/** Seconds to break a block with bare hands in the given mode. */ +export function digTime(blockId: BlockId, mode: GameMode): number { + if (mode === "creative") return 0; + return DIG_TIME[hardnessOf(blockId)]; +} + +export function isBreakable(blockId: BlockId): boolean { + return hardnessOf(blockId) !== "unbreakable"; +} diff --git a/src/game/Player.ts b/src/game/Player.ts index bfc45da..9e8e8f9 100644 --- a/src/game/Player.ts +++ b/src/game/Player.ts @@ -1,4 +1,4 @@ -import { Scene, UniversalCamera, Vector3 } from "@babylonjs/core"; +import { Matrix, Scene, UniversalCamera, Vector3 } from "@babylonjs/core"; import { PLAYER_HALF_WIDTH, PLAYER_EYE_HEIGHT, @@ -13,7 +13,7 @@ import { REACH, } from "../constants"; import type { Settings } from "../types"; -import { getBlock } from "./Blocks"; +import { getBlock, WATER_BLOCK, CACTUS_BLOCK } from "./Blocks"; import type { World } from "./World"; import type { Input } from "../engine/Input"; import { raycastVoxel } from "./BlockRaycaster"; @@ -40,10 +40,17 @@ export class Player { flying = false; onGround = false; inWater = false; + /** Whether double-tap-Space may toggle flight (creative only). */ + canFly = true; /** Latest block targeted by the camera (for highlight + break/place). */ target: RaycastHit | null = null; + /** Fall-damage tracking (peak Y reached while airborne, in blocks). */ + private fallPeakY: number | null = null; + private wasOnGround = true; + private pendingFall = 0; + constructor(aspect: number, scene: Scene) { void aspect; // Babylon derives aspect from the engine/canvas automatically. // We don't attachControl — input is handled by our own Input class. The @@ -72,8 +79,14 @@ export class Player { this.position.set(x + 0.5, ground + 2.2, z + 0.5); this.velocity.set(0, 0, 0); this.yaw = Math.PI * 0.25; - this.pitch = -0.18; + // Look down steeply at spawn so the ground is immediately within reach and + // the first click targets a block (otherwise the near-horizontal ray sails + // over the terrain and targeting is null). + this.pitch = -0.42; this.flying = false; + this.fallPeakY = null; + this.wasOnGround = true; + this.pendingFall = 0; } private collides(world: World, px: number, py: number, pz: number): boolean { @@ -98,13 +111,13 @@ export class Player { Math.floor(this.position.x), Math.floor(this.position.y + 0.5), Math.floor(this.position.z), - ) === 7; + ) === WATER_BLOCK; } update(dt: number, world: World, input: Input, settings: Settings): void { // --- Mouse look --- const { dx, dy } = input.consumeMouseDelta(); - const sens = settings.mouseSensitivity * 0.0022; + const sens = settings.mouseSensitivity * 0.005; this.yaw -= dx * sens; this.pitch -= dy * sens; const lim = Math.PI / 2 - 0.02; @@ -127,8 +140,8 @@ export class Player { const sprinting = input.isDown("ControlLeft") || input.isDown("ControlRight"); - // Toggle flight on double-tap space. - if (input.consumeDoubleTapSpace()) { + // Toggle flight on double-tap space (creative only). + if (input.consumeDoubleTapSpace() && this.canFly) { this.flying = !this.flying; } @@ -192,6 +205,8 @@ export class Player { } this.velocity.set(vx, vy, vz); + this.trackFall(pos.y); + // Safety: never fall below the world. if (pos.y < -10) { pos.y = 40; @@ -211,16 +226,90 @@ export class Player { // --- Targeting raycast --- // Forward = Ry(yaw) * Rx(pitch) * (0, 0, -1) — same as three.js YXZ. - const cp = Math.cos(this.pitch); - const fx = -Math.sin(this.yaw) * cp; - const fy = Math.sin(this.pitch); - const fz = -Math.cos(this.yaw) * cp; const eye = this.camera.position; - this.target = raycastVoxel(world, eye.x, eye.y, eye.z, fx, fy, fz, REACH); + if (input.locked) { + const cp = Math.cos(this.pitch); + const fx = -Math.sin(this.yaw) * cp; + const fy = Math.sin(this.pitch); + const fz = -Math.cos(this.yaw) * cp; + this.target = raycastVoxel(world, eye.x, eye.y, eye.z, fx, fy, fz, REACH); + } else { + // Pointer lock unavailable: aim via the cursor position instead so the + // game stays fully playable (build/mine) without mouse-look. + const sc = this.camera.getScene() as Scene; + const ray = sc.createPickingRay(sc.pointerX, sc.pointerY, Matrix.Identity(), this.camera); + this.target = raycastVoxel( + world, + ray.origin.x, + ray.origin.y, + ray.origin.z, + ray.direction.x, + ray.direction.y, + ray.direction.z, + REACH, + ); + } } /** The block the camera is currently looking at (for break/place). */ getTarget(): RaycastHit | null { return this.target; } + + /** Blocks fallen (peak → landing). Consumed once on landing; 0 otherwise. */ + consumeFallDistance(): number { + const f = this.pendingFall; + this.pendingFall = 0; + return f; + } + + private trackFall(y: number): void { + if (this.flying || this.inWater) { + // Flight and water both cushion falls — don't track a fall distance. + this.fallPeakY = null; + this.wasOnGround = this.onGround; + return; + } + if (!this.onGround) { + if (this.fallPeakY === null) this.fallPeakY = y; + else if (y > this.fallPeakY) this.fallPeakY = y; + } else if (!this.wasOnGround) { + if (this.fallPeakY !== null) { + this.pendingFall = Math.max(0, this.fallPeakY - y); + } + this.fallPeakY = null; + } + this.wasOnGround = this.onGround; + } + + /** Head (eye) submerged in water — used for the drowning breath meter. */ + headSubmerged(world: World): boolean { + const eyeY = this.position.y + PLAYER_EYE_HEIGHT; + return world.getBlock( + Math.floor(this.position.x), + Math.floor(eyeY), + Math.floor(this.position.z), + ) === WATER_BLOCK; + } + + /** Touching a cactus block anywhere in the player's AABB. */ + touchingCactus(world: World): boolean { + const px = this.position.x; + const py = this.position.y; + const pz = this.position.z; + const minX = Math.floor(px - HW); + const maxX = Math.floor(px + HW); + const minY = Math.floor(py); + const maxY = Math.floor(py + PH - 1e-3); + const minZ = Math.floor(pz - HW); + const maxZ = Math.floor(pz + HW); + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + if (world.getBlock(x, y, z) === CACTUS_BLOCK) return true; + } + } + } + return false; + } } diff --git a/src/game/PlayerState.ts b/src/game/PlayerState.ts new file mode 100644 index 0000000..992207b --- /dev/null +++ b/src/game/PlayerState.ts @@ -0,0 +1,173 @@ +import type { FoodDef, GameMode } from "./Items"; + +/** + * Survival vitals: health, hunger, saturation, exhaustion and breath. Tuned to + * Luanti/VoxeLibre conventions (20 HP shown as 10 hearts, 20 hunger shown as 10 + * drumsticks, breath 10 bubbles, exhaustion overflow at 4000). All damage and + * regen flows through here so death is detected in one place. + */ + +export const MAX_HP = 20; +export const MAX_HUNGER = 20; +export const MAX_SATURATION = 20; +export const EXHAUST_LEVEL = 4000; +export const MAX_BREATH = 10; + +const BREATH_LOSS_INTERVAL = 2; +const BREATH_REGEN_INTERVAL = 0.5; +const FOOD_TICK_INTERVAL = 4; +const DROWN_DAMAGE = 1; +const STARVE_MIN_HP = 1; +const SAFE_FALL_BLOCKS = 3; + +export interface SerializedStats { + hp: number; + hunger: number; + saturation: number; + exhaustion: number; + breath: number; +} + +export class PlayerState { + hp = MAX_HP; + hunger = MAX_HUNGER; + saturation = 5; + exhaustion = 0; + breath = MAX_BREATH; + dead = false; + + private breathLossT = 0; + private breathRegenT = 0; + private foodT = 0; + + get alive(): boolean { + return !this.dead; + } + + reset(): void { + this.hp = MAX_HP; + this.hunger = MAX_HUNGER; + this.saturation = 5; + this.exhaustion = 0; + this.breath = MAX_BREATH; + this.dead = false; + this.breathLossT = 0; + this.breathRegenT = 0; + this.foodT = 0; + } + + invulnerable(mode: GameMode): boolean { + return mode === "creative"; + } + + /** Deal damage. Returns true if this blow killed the player. */ + damage(amount: number, mode: GameMode): boolean { + if (this.invulnerable(mode) || this.dead || amount <= 0) return false; + this.hp = Math.max(0, this.hp - amount); + this.exhaustion += 100; + if (this.hp <= 0) { + this.dead = true; + return true; + } + this.cascadeExhaustion(); + return false; + } + + heal(amount: number): void { + if (this.dead) return; + this.hp = Math.min(MAX_HP, this.hp + amount); + } + + eat(food: FoodDef): void { + this.hunger = Math.min(MAX_HUNGER, this.hunger + food.hunger); + this.saturation = Math.min(MAX_SATURATION, this.saturation + food.saturation); + } + + addExhaustion(amount: number): void { + this.exhaustion += amount; + this.cascadeExhaustion(); + } + + private cascadeExhaustion(): void { + while (this.exhaustion >= EXHAUST_LEVEL) { + this.exhaustion -= EXHAUST_LEVEL; + if (this.saturation > 0) { + this.saturation = Math.max(0, this.saturation - 1); + } else if (this.hunger > 0) { + this.hunger = Math.max(0, this.hunger - 1); + } else { + this.exhaustion = 0; + break; + } + } + } + + /** Per-frame survival ticking (breath, regen, starvation). */ + tick(dt: number, mode: GameMode, submerged: boolean): void { + if (this.invulnerable(mode)) { + this.breath = MAX_BREATH; + return; + } + if (this.dead) return; + + // --- Breath / drowning --- + if (submerged) { + this.breathRegenT = 0; + this.breathLossT += dt; + if (this.breathLossT >= BREATH_LOSS_INTERVAL) { + this.breathLossT -= BREATH_LOSS_INTERVAL; + if (this.breath > 0) { + this.breath -= 1; + } else { + this.damage(DROWN_DAMAGE, mode); + } + } + } else { + this.breathLossT = 0; + this.breathRegenT += dt; + if (this.breathRegenT >= BREATH_REGEN_INTERVAL) { + this.breathRegenT -= BREATH_REGEN_INTERVAL; + this.breath = Math.min(MAX_BREATH, this.breath + 1); + } + } + + // --- Food cycle (regen + starvation) --- + this.foodT += dt; + if (this.foodT >= FOOD_TICK_INTERVAL) { + this.foodT -= FOOD_TICK_INTERVAL; + if (this.hunger >= 18 && this.hp < MAX_HP) { + this.heal(1); + this.addExhaustion(6000); + } else if (this.hunger <= 0) { + // Starvation drains HP but cannot kill on normal difficulty. + if (this.hp > STARVE_MIN_HP) this.hp = Math.max(STARVE_MIN_HP, this.hp - 1); + } + } + } + + /** Convert a fall distance (in blocks) into damage, or 0 if safe. */ + static fallDamage(blocksFallen: number): number { + if (blocksFallen <= SAFE_FALL_BLOCKS) return 0; + return Math.floor(blocksFallen - SAFE_FALL_BLOCKS); + } + + serialize(): SerializedStats { + return { + hp: this.hp, + hunger: this.hunger, + saturation: this.saturation, + exhaustion: this.exhaustion, + breath: this.breath, + }; + } + + load(data: SerializedStats | undefined): void { + if (!data) return; + this.hp = data.hp ?? MAX_HP; + this.hunger = data.hunger ?? MAX_HUNGER; + this.saturation = data.saturation ?? 5; + this.exhaustion = data.exhaustion ?? 0; + this.breath = data.breath ?? MAX_BREATH; + this.dead = this.hp <= 0; + } +} diff --git a/src/main.ts b/src/main.ts index 12a726f..b314ea2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ function boot(): void { // Debug hooks (not part of the gameplay API). debugFlat?: () => void; loadedChunks?: () => unknown; + debugInfo?: () => Record; } (window as unknown as { __voxl?: VoxlAutomation }).__voxl = { beginPlay: () => game.beginPlay(), @@ -30,6 +31,7 @@ function boot(): void { takeScreenshot: () => game.takeScreenshot(), debugFlat: () => game._enableDebugFlat(), loadedChunks: () => game._loadedChunks(), + debugInfo: () => game._debugInfo(), }; } diff --git a/src/state/Debug.ts b/src/state/Debug.ts new file mode 100644 index 0000000..622e2ca --- /dev/null +++ b/src/state/Debug.ts @@ -0,0 +1,27 @@ +/** + * Lightweight debug logger for the browser console. Prefixed `[VOXL]` so it's + * easy to filter. Off by default; enable at runtime with + * localStorage.setItem("voxl.debug", "1") + * (then reload). Disable again by setting it to anything other than "1". + */ +const enabled: boolean = (() => { + try { + return localStorage.getItem("voxl.debug") === "1"; + } catch { + return false; + } +})(); + +export function dbg(...args: unknown[]): void { + if (enabled) console.log("%c[VOXL]", "color:#37c46a;font-weight:bold", ...args); +} + +export function dbgWarn(...args: unknown[]): void { + if (enabled) console.warn("%c[VOXL]", "color:#f5a524;font-weight:bold", ...args); +} + +export function dbgErr(...args: unknown[]): void { + if (enabled) console.error("[VOXL]", ...args); +} + +export const DEBUG_ENABLED = enabled; diff --git a/src/state/SaveData.ts b/src/state/SaveData.ts new file mode 100644 index 0000000..1dab6d1 --- /dev/null +++ b/src/state/SaveData.ts @@ -0,0 +1,54 @@ +import type { SerializedSlot } from "../game/Inventory"; +import type { SerializedStats } from "../game/PlayerState"; +import type { GameMode } from "../game/Items"; + +/** + * Per-seed survival save (inventory + vitals). Stored under a versioned + * localStorage key so a fresh world or a re-seed starts clean. + */ + +const VERSION = "v1"; + +export interface SaveData { + inventory: SerializedSlot[]; + stats: SerializedStats; + /** Optional — omitted/invalid values are ignored on load. */ + mode?: GameMode; +} + +function key(seed: string): string { + return `voxl.save.${VERSION}.${seed}`; +} + +export function loadSave(seed: string): SaveData | null { + try { + const raw = localStorage.getItem(key(seed)); + if (!raw) return null; + const parsed = JSON.parse(raw) as SaveData; + if (!parsed || !Array.isArray(parsed.inventory) || !parsed.stats) return null; + // Validate mode; an invalid/corrupt value is dropped so the caller falls + // back to the current global setting. + if (parsed.mode !== "survival" && parsed.mode !== "creative") { + parsed.mode = undefined; + } + return parsed; + } catch { + return null; + } +} + +export function writeSave(seed: string, data: SaveData): void { + try { + localStorage.setItem(key(seed), JSON.stringify(data)); + } catch { + // Storage full / disabled — survival persistence is best-effort. + } +} + +export function clearSave(seed: string): void { + try { + localStorage.removeItem(key(seed)); + } catch { + // ignore + } +} diff --git a/src/state/Settings.ts b/src/state/Settings.ts index 97a2948..f226388 100644 --- a/src/state/Settings.ts +++ b/src/state/Settings.ts @@ -10,6 +10,7 @@ export const DEFAULT_SETTINGS: Settings = { showFps: false, clouds: true, seed: DEFAULT_SEED, + mode: "creative", }; export function loadSettings(): Settings { @@ -17,7 +18,11 @@ export function loadSettings(): Settings { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_SETTINGS }; const parsed = JSON.parse(raw) as Partial; - return { ...DEFAULT_SETTINGS, ...parsed }; + const mode = + parsed.mode === "survival" || parsed.mode === "creative" + ? parsed.mode + : DEFAULT_SETTINGS.mode; + return { ...DEFAULT_SETTINGS, ...parsed, mode }; } catch { return { ...DEFAULT_SETTINGS }; } diff --git a/src/types.ts b/src/types.ts index ccb44cd..2b5614b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ export interface Settings { showFps: boolean; clouds: boolean; seed: string; + /** Survival vs creative game mode (global preference; saved per-world too). */ + mode: "survival" | "creative"; } /** Result of a voxel DDA raycast. */ diff --git a/src/ui/HUD.ts b/src/ui/HUD.ts index 955f6a1..b274d25 100644 --- a/src/ui/HUD.ts +++ b/src/ui/HUD.ts @@ -1,10 +1,14 @@ -import { HOTBAR_BLOCKS, getBlock } from "../game/Blocks"; +import { getItem } from "../game/Items"; +import type { GameMode } from "../game/Items"; +import type { Inventory } from "../game/Inventory"; +import { MAX_BREATH } from "../game/PlayerState"; function $(id: string): HTMLElement { return document.getElementById(id) as HTMLElement; } -/** In-game HUD: crosshair, hotbar, status badges (fps/mode/coords), toast. */ +/** In-game HUD: crosshair, stat bars (health/hunger/air), inventory hotbar, + * status badges (fps/mode/coords), and toast. */ export class HUD { private readonly slots: HTMLElement[] = []; private readonly fpsEl: HTMLElement; @@ -12,6 +16,11 @@ export class HUD { private readonly coordsEl: HTMLElement; private readonly toastEl: HTMLElement; private readonly crosshair: HTMLElement; + private readonly heartFills: HTMLElement[] = []; + private readonly hungerFills: HTMLElement[] = []; + private readonly airFills: HTMLElement[] = []; + private readonly airRow: HTMLElement; + private readonly lockHint: HTMLElement; private toastTimer: number | null = null; constructor() { @@ -20,36 +29,95 @@ export class HUD { this.coordsEl = $("coords"); this.toastEl = $("toast"); this.crosshair = $("crosshair"); + this.airRow = $("air"); + this.lockHint = $("lock-hint"); this.buildHotbar(); + this.buildStatBar("hearts", this.heartFills); + this.buildStatBar("hunger", this.hungerFills); + this.buildStatBar("air", this.airFills); + this.setSelected(0); } private buildHotbar(): void { const root = $("hotbar"); root.innerHTML = ""; - HOTBAR_BLOCKS.forEach((id, i) => { - const def = getBlock(id); + for (let i = 0; i < 9; i++) { const slot = document.createElement("div"); slot.className = "hotbar-slot"; - slot.title = def.name; slot.dataset.index = String(i); - const swatch = document.createElement("div"); - swatch.className = "swatch"; - swatch.style.background = def.color; const num = document.createElement("span"); num.className = "slot-num"; num.textContent = String(i + 1); - slot.appendChild(swatch); slot.appendChild(num); root.appendChild(slot); this.slots.push(slot); - }); - this.setSelected(0); + } + } + + private buildStatBar(id: string, fills: HTMLElement[]): void { + const root = $(id); + root.innerHTML = ""; + for (let i = 0; i < 10; i++) { + const cell = document.createElement("div"); + cell.className = "stat-cell"; + const fill = document.createElement("div"); + fill.className = "stat-fill"; + cell.appendChild(fill); + root.appendChild(cell); + fills.push(fill); + } + } + + /** Repaint the hotbar from the inventory (slots 0..8). */ + refreshHotbar(inv: Inventory, selected: number, mode: GameMode): void { + for (let i = 0; i < 9; i++) { + const node = this.slots[i]; + const stack = inv.getSlot(i); + node.classList.toggle("filled", !!stack); + // keep the slot number label; clear the rest + const num = node.querySelector(".slot-num"); + node.innerHTML = ""; + if (num) node.appendChild(num); + if (stack) { + const def = getItem(stack.id); + const sw = document.createElement("div"); + sw.className = "swatch"; + if (def?.icon === "food") sw.classList.add("swatch-food"); + sw.style.background = def?.color ?? "#888"; + node.appendChild(sw); + if (mode === "survival" && stack.count > 1) { + const c = document.createElement("span"); + c.className = "count"; + c.textContent = String(stack.count); + node.appendChild(c); + } + } + } + this.setSelected(selected); } setSelected(index: number): void { - this.slots.forEach((el, i) => { - el.classList.toggle("selected", i === index); - }); + this.slots.forEach((node, i) => node.classList.toggle("selected", i === index)); + } + + /** hp/hunger are 0–20 (10 hearts/drumsticks), breath is 0–10 (10 bubbles). */ + setStats(hp: number, hunger: number, breath: number): void { + this.paintHalfCells(this.heartFills, hp); + this.paintHalfCells(this.hungerFills, hunger); + // air: 1 bubble per breath point + for (let i = 0; i < this.airFills.length; i++) { + const v = Math.max(0, Math.min(1, breath - i)); + this.airFills[i].style.width = `${v * 100}%`; + } + if (breath < MAX_BREATH) this.airRow.removeAttribute("hidden"); + else this.airRow.setAttribute("hidden", ""); + } + + private paintHalfCells(fills: HTMLElement[], value: number): void { + for (let i = 0; i < fills.length; i++) { + const v = Math.max(0, Math.min(2, value - i * 2)); + fills[i].style.width = `${(v / 2) * 100}%`; + } } setFpsVisible(visible: boolean): void { @@ -73,6 +141,12 @@ export class HUD { this.crosshair.style.opacity = visible ? "1" : "0"; } + /** Show the "capture mouse" hint whenever pointer lock is not held. */ + setLockHint(locked: boolean): void { + if (locked) this.lockHint.setAttribute("hidden", ""); + else this.lockHint.removeAttribute("hidden"); + } + showToast(message: string, ms = 1800): void { this.toastEl.textContent = message; this.toastEl.removeAttribute("hidden"); diff --git a/src/ui/InventoryUI.ts b/src/ui/InventoryUI.ts new file mode 100644 index 0000000..24d90fe --- /dev/null +++ b/src/ui/InventoryUI.ts @@ -0,0 +1,324 @@ +import { Inventory, type ItemStack } from "../game/Inventory"; +import { + CREATIVE_PALETTE, + getItem, + isFood, + type GameMode, + type ItemId, +} from "../game/Items"; + +function el(tag: string, cls?: string): HTMLElement { + const e = document.createElement(tag); + if (cls) e.className = cls; + return e; +} + +/** + * Full-screen inventory (Minetest/Luanti-style). Renders the player's backpack + * grid + hotbar (shared slots), a crafting placeholder, and — in creative — a + * searchable palette of every item. Supports cursor-based pickup/place/swap and + * a Survival/Creative mode toggle. Mutates the Inventory directly and notifies + * the Game via callbacks for mode changes, refresh and close. + */ +export class InventoryUI { + private readonly root: HTMLElement; + private readonly inventory: Inventory; + private getMode: () => GameMode; + + onModeChange?: (mode: GameMode) => void; + onClose?: () => void; + onRefresh?: () => void; + + private held: ItemStack | null = null; + private heldEl: HTMLElement; + private titleEl!: HTMLElement; + private toggleBtn!: HTMLButtonElement; + private paletteWrap!: HTMLElement; + private paletteGrid!: HTMLElement; + private searchInput!: HTMLInputElement; + private readonly slotEls: HTMLElement[] = []; + + private searchTerm = ""; + + constructor(root: HTMLElement, inventory: Inventory, getMode: () => GameMode) { + this.root = root; + this.inventory = inventory; + this.getMode = getMode; + this.heldEl = el("div", "held-stack"); + this.heldEl.style.display = "none"; + this.build(); + } + + private build(): void { + this.root.innerHTML = ""; + this.root.classList.add("inv-screen", "screen", "overlay"); + this.root.setAttribute("hidden", ""); + + const panel = el("div", "inv-panel"); + this.titleEl = el("div", "inv-title"); + this.toggleBtn = document.createElement("button"); + this.toggleBtn.className = "btn btn-small inv-toggle"; + this.toggleBtn.addEventListener("click", () => { + this.onModeChange?.(this.getMode() === "creative" ? "survival" : "creative"); + }); + const closeBtn = document.createElement("button"); + closeBtn.className = "btn btn-small inv-close"; + closeBtn.textContent = "Close"; + closeBtn.addEventListener("click", () => this.onClose?.()); + + const head = el("div", "inv-head"); + head.append(this.titleEl, this.toggleBtn, closeBtn); + + const body = el("div", "inv-body"); + + // --- Left: player inventory + crafting placeholder --- + const main = el("div", "inv-main"); + + const craft = el("div", "inv-craft"); + const craftLabel = el("div", "inv-section-label"); + craftLabel.textContent = "Crafting — Tier 3"; + const craftGrid = el("div", "inv-craft-grid"); + for (let i = 0; i < 4; i++) { + const s = el("div", "slot slot-disabled"); + craftGrid.append(s); + } + const craftArrow = el("div", "inv-arrow"); + craftArrow.textContent = "→"; + const craftOut = el("div", "slot slot-disabled"); + craft.append(craftLabel, craftGrid, craftArrow, craftOut, this.makeTrash()); + + const backpack = el("div", "inv-grid"); + for (let i = this.inventory.hotbarSize; i < this.inventory.size; i++) { + backpack.append(this.makeSlot(i)); + } + + const hotbar = el("div", "inv-hotbar"); + for (let i = 0; i < this.inventory.hotbarSize; i++) { + hotbar.append(this.makeSlot(i)); + } + + main.append(craft, backpack, hotbar); + + // --- Right: creative palette --- + const palette = el("div", "inv-palette"); + this.searchInput = document.createElement("input"); + this.searchInput.type = "text"; + this.searchInput.placeholder = "Search items…"; + this.searchInput.className = "text-input inv-search"; + this.searchInput.addEventListener("input", () => { + this.searchTerm = this.searchInput.value.toLowerCase().trim(); + this.renderPalette(); + }); + // Escape closes the inventory even while the search field has focus (the + // global Input handler bails on keydowns originating from s). + this.searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + this.onClose?.(); + } + }); + this.paletteGrid = el("div", "inv-palette-grid"); + this.paletteWrap = palette; + palette.append(this.searchInput, this.paletteGrid); + + body.append(main, palette); + panel.append(head, body); + this.root.append(panel, this.heldEl); + + this.root.addEventListener("mousemove", (e) => { + this.heldEl.style.left = `${e.clientX + 4}px`; + this.heldEl.style.top = `${e.clientY + 4}px`; + }); + this.root.addEventListener("contextmenu", (e) => e.preventDefault()); + + this.refresh(); + } + + private makeTrash(): HTMLElement { + const trash = el("div", "slot slot-trash"); + trash.title = "Trash — click with a held stack to destroy it"; + trash.innerHTML = + ''; + trash.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (this.held) { + this.held = null; + this.renderHeld(); + this.onRefresh?.(); + } + }); + return trash; + } + + private makeSlot(index: number): HTMLElement { + const s = el("div", "slot inv-slot"); + s.dataset.index = String(index); + s.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (e.button === 0) this.leftClickSlot(index); + else if (e.button === 2) this.rightClickSlot(index); + }); + s.addEventListener("contextmenu", (ev) => ev.preventDefault()); + this.slotEls[index] = s; + return s; + } + + private leftClickSlot(index: number): void { + const stack = this.inventory.getSlot(index); + if (!this.held) { + if (stack) { + this.held = stack; + this.inventory.setSlot(index, null); + } + } else if (!stack) { + this.inventory.setSlot(index, this.held); + this.held = null; + } else if (stack.id === this.held.id) { + const max = getItem(stack.id)?.maxStack ?? 64; + const room = max - stack.count; + const take = Math.min(room, this.held.count); + stack.count += take; + this.held.count -= take; + if (this.held.count <= 0) this.held = null; + } else { + this.inventory.setSlot(index, this.held); + this.held = stack; + } + this.afterChange(); + } + + private rightClickSlot(index: number): void { + const stack = this.inventory.getSlot(index); + if (!this.held) { + if (stack && stack.count > 0) { + const half = Math.ceil(stack.count / 2); + this.held = { id: stack.id, count: half }; + stack.count -= half; + if (stack.count <= 0) this.inventory.setSlot(index, null); + } + } else { + if (!stack) { + this.inventory.setSlot(index, { id: this.held.id, count: 1 }); + this.held.count -= 1; + } else if (stack.id === this.held.id) { + const max = getItem(stack.id)?.maxStack ?? 64; + if (stack.count < max) { + stack.count += 1; + this.held.count -= 1; + } + } + if (this.held.count <= 0) this.held = null; + } + this.afterChange(); + } + + private giveFromPalette(id: ItemId, full: boolean): void { + const max = getItem(id)?.maxStack ?? 64; + this.held = { id, count: full ? max : 1 }; + this.afterChange(); + } + + private afterChange(): void { + this.renderSlots(); + this.renderHeld(); + this.onRefresh?.(); + } + + private renderSlots(): void { + for (let i = 0; i < this.slotEls.length; i++) { + this.paintSlot(this.slotEls[i], this.inventory.getSlot(i)); + } + } + + private paintSlot(node: HTMLElement, stack: ItemStack | null): void { + node.classList.toggle("filled", !!stack); + node.innerHTML = ""; + if (!stack) return; + const def = getItem(stack.id); + const sw = el("div", "swatch"); + if (def?.icon === "food") sw.classList.add("swatch-food"); + sw.style.background = def?.color ?? "#888"; + node.append(sw); + if (stack.count > 1) { + const c = el("span", "count"); + c.textContent = String(stack.count); + node.append(c); + } + } + + private renderHeld(): void { + if (!this.held) { + this.heldEl.style.display = "none"; + this.heldEl.innerHTML = ""; + return; + } + this.heldEl.innerHTML = ""; + const def = getItem(this.held.id); + const sw = el("div", "swatch"); + if (def?.icon === "food") sw.classList.add("swatch-food"); + sw.style.background = def?.color ?? "#888"; + this.heldEl.append(sw); + if (this.held.count > 1) { + const c = el("span", "count"); + c.textContent = String(this.held.count); + this.heldEl.append(c); + } + this.heldEl.style.display = "flex"; + } + + private renderPalette(): void { + this.paletteGrid.innerHTML = ""; + const term = this.searchTerm; + for (const id of CREATIVE_PALETTE) { + const def = getItem(id); + if (!def) continue; + if (term && !def.name.toLowerCase().includes(term)) continue; + const node = el("div", "slot palette-slot"); + node.title = def.name; + const sw = el("div", "swatch"); + if (def.icon === "food") sw.classList.add("swatch-food"); + sw.style.background = def.color; + node.append(sw); + node.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (e.button === 0) this.giveFromPalette(id, true); + else if (e.button === 2) this.giveFromPalette(id, false); + }); + node.addEventListener("contextmenu", (ev) => ev.preventDefault()); + this.paletteGrid.append(node); + } + } + + refresh(): void { + const mode = this.getMode(); + this.titleEl.textContent = mode === "creative" ? "Creative Inventory" : "Survival Inventory"; + this.toggleBtn.textContent = mode === "creative" ? "Switch to Survival" : "Switch to Creative"; + this.paletteWrap.style.display = mode === "creative" ? "flex" : "none"; + this.renderSlots(); + this.renderPalette(); + this.renderHeld(); + } + + open(): void { + this.held = null; + this.searchTerm = ""; + this.searchInput.value = ""; + this.refresh(); + this.root.removeAttribute("hidden"); + } + + close(): void { + // Return anything on the cursor to the inventory. + if (this.held) { + const leftover = this.inventory.add(this.held.id, this.held.count); + this.held = null; + if (leftover > 0) this.onRefresh?.(); + } + this.root.setAttribute("hidden", ""); + } + + get isOpen(): boolean { + return !this.root.hasAttribute("hidden"); + } +} diff --git a/src/ui/ui.css b/src/ui/ui.css index 24edb72..234f47d 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -404,12 +404,14 @@ input[type="checkbox"] { color: var(--accent); border-color: rgba(55, 196, 106, 0.5); } +.lock-hint { + color: var(--warm); + border-color: rgba(245, 165, 36, 0.5); + max-width: 320px; +} #hotbar { - position: absolute; - bottom: 20px; - left: 50%; - transform: translateX(-50%); + position: relative; display: flex; gap: 8px; padding: 8px; @@ -444,6 +446,25 @@ input[type="checkbox"] { font-size: 10px; color: var(--ink-dim); } +.hotbar-slot .count, +.slot .count, +.held-stack .count { + position: absolute; + bottom: 0px; + right: 3px; + font-family: var(--mono); + font-size: 13px; + font-weight: 800; + color: #fff; + text-shadow: 0 1px 2px #000, 0 0 3px #000; + pointer-events: none; +} +.swatch.swatch-food, +.hotbar-slot .swatch-food, +.slot .swatch-food { + border-radius: 50%; + box-shadow: inset 0 -6px 0 rgba(0, 0, 0, 0.25), inset 0 3px 0 rgba(255, 255, 255, 0.28); +} .hotbar-slot.selected { border-color: var(--accent-2); transform: translateY(-5px) scale(1.06); @@ -503,3 +524,244 @@ input[type="checkbox"] { .lb { animation: none; } .spinner { animation-duration: 2s; } } + +/* ========================================================================= + Survival HUD: hotbar wrapper + stat bars (hearts / air / hunger) + ========================================================================= */ +#hotbar-wrap { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +#stats { + width: 478px; + max-width: 86vw; + height: 30px; + position: relative; +} +.stat-row { + position: absolute; + left: 0; + right: 0; + display: flex; + gap: 2px; + height: 13px; +} +#hearts { top: 0; justify-content: flex-start; } +#hunger { bottom: 0; justify-content: flex-end; } +#air { top: 0; justify-content: flex-end; } + +.stat-cell { + width: 14px; + height: 13px; + position: relative; + background-size: 14px 13px; + background-repeat: no-repeat; + background-position: center; +} +.stat-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0; + background-size: 14px 13px; + background-repeat: no-repeat; + background-position: left center; + overflow: hidden; + transition: width 0.12s linear; +} + +:root { + --heart-full: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' fill='%23ef5a6f'/%3E%3C/svg%3E"); + --heart-empty: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' fill='%23443048'/%3E%3C/svg%3E"); + --hunger-full: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='9' fill='%23f5a524'/%3E%3C/svg%3E"); + --hunger-empty: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='9' fill='%233a3320'/%3E%3C/svg%3E"); + --air-full: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8' fill='%23bfe3ff'/%3E%3C/svg%3E"); + --air-empty: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='8' fill='%23223648'/%3E%3C/svg%3E"); +} +#hearts .stat-cell { background-image: var(--heart-empty); } +#hearts .stat-fill { background-image: var(--heart-full); } +#hunger .stat-cell { background-image: var(--hunger-empty); } +#hunger .stat-fill { background-image: var(--hunger-full); } +#air .stat-cell { background-image: var(--air-empty); } +#air .stat-fill { background-image: var(--air-full); } + +/* ========================================================================= + Inventory screen (Minetest/Luanti-style) + ========================================================================= */ +.inv-screen { + z-index: 30; +} +.inv-panel { + background: var(--panel); + border: 2px solid var(--panel-border); + border-radius: 14px; + box-shadow: var(--shadow-soft); + padding: 22px 26px; + width: min(920px, 94vw); + max-height: 94vh; + overflow: auto; +} +.inv-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} +.inv-title { + flex: 1; + font-family: var(--mono); + font-size: 20px; + letter-spacing: 0.06em; + color: var(--ink); +} +.inv-toggle { + background: linear-gradient(180deg, #46d97f, var(--accent)); + border-color: #2fae57; + color: #06210f; +} +.inv-close { + margin-left: auto; +} +.inv-body { + display: flex; + gap: 28px; + flex-wrap: wrap; +} +.inv-main { + display: flex; + flex-direction: column; + gap: 14px; +} +.inv-grid, +.inv-hotbar, +.inv-craft-grid, +.inv-palette-grid { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.inv-grid, +.inv-hotbar { + width: 410px; + max-width: 70vw; +} +.inv-craft { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.inv-craft-grid { + width: 88px; +} +.inv-section-label { + width: 100%; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-dim); + letter-spacing: 0.05em; +} +.inv-arrow { + font-size: 20px; + color: var(--ink-dim); +} +.inv-palette { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-width: 240px; +} +.inv-search { + width: 100%; +} +.inv-palette-grid { + max-height: 360px; + overflow-y: auto; + padding: 6px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--panel-border); + border-radius: 10px; +} + +/* Generic slot (inventory + palette + held) */ +.slot { + width: 42px; + height: 42px; + border-radius: 8px; + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.04); + position: relative; + display: flex; + align-items: center; + justify-content: center; +} +.slot .swatch { + width: 30px; + height: 30px; + border-radius: 5px; + box-shadow: inset 0 -7px 0 rgba(0, 0, 0, 0.28), inset 0 3px 0 rgba(255, 255, 255, 0.18); +} +.inv-slot { + cursor: pointer; +} +.inv-slot:hover { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.08); +} +.palette-slot { + cursor: pointer; +} +.palette-slot:hover { + border-color: var(--accent-2); + background: rgba(45, 212, 191, 0.14); + transform: translateY(-1px); +} +.slot.slot-disabled { + opacity: 0.35; + cursor: default; +} +.slot.slot-trash { + font-size: 18px; + color: var(--ink-dim); + cursor: pointer; +} +.slot.slot-trash:hover { + color: var(--danger); + border-color: var(--danger); +} + +/* Cursor-held stack */ +.held-stack { + position: fixed; + left: 0; + top: 0; + width: 42px; + height: 42px; + pointer-events: none; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} +.held-stack .swatch { + width: 30px; + height: 30px; + border-radius: 5px; + box-shadow: inset 0 -7px 0 rgba(0, 0, 0, 0.28), inset 0 3px 0 rgba(255, 255, 255, 0.18); +} + +@media (max-width: 560px) { + #stats { width: 86vw; } + .inv-grid, .inv-hotbar { width: 70vw; } + .slot { width: 36px; height: 36px; } + .slot .swatch { width: 24px; height: 24px; } +}