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
1–9Select hotbar slot
ScrollCycle hotbar
+ EOpen inventory / switch mode
FCycle selected block
PCapture screenshot
EscPause
@@ -119,6 +121,9 @@ Paused
+
+
+
@@ -126,8 +131,16 @@
Paused
0 fps
Walking
x 0 y 0 z 0
+
Click to capture mouse · or aim with cursor & click to build
+
+
-
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; }
+}