Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ <h2>Controls</h2>
<li><kbd>Space</kbd> ×2<span>Toggle flight</span></li>
<li><kbd>Ctrl</kbd><span>Sprint</span></li>
<li><kbd>Mouse</kbd><span>Look around</span></li>
<li><kbd>L-Click</kbd><span>Break block</span></li>
<li><kbd>Hold L-Click</kbd><span>Mine block (progress in survival)</span></li>
<li><kbd>R-Click</kbd><span>Place block</span></li>
<li><kbd>Hold R-Click</kbd><span>Eat selected food</span></li>
<li><kbd>1</kbd>–<kbd>9</kbd><span>Select hotbar slot</span></li>
<li><kbd>Scroll</kbd><span>Cycle hotbar</span></li>
<li><kbd>E</kbd><span>Open inventory / switch mode</span></li>
<li><kbd>F</kbd><span>Cycle selected block</span></li>
<li><kbd>P</kbd><span>Capture screenshot</span></li>
<li><kbd>Esc</kbd><span>Pause</span></li>
Expand Down Expand Up @@ -119,15 +121,26 @@ <h2>Paused</h2>
</div>
</section>

<!-- ===== Inventory screen (built by InventoryUI) ===== -->
<section id="inventory-screen" aria-label="Inventory" hidden></section>

<!-- ===== In-game HUD ===== -->
<div id="hud" hidden>
<div id="crosshair" aria-hidden="true"></div>
<div id="status">
<div id="fps" class="badge" hidden>0 fps</div>
<div id="mode-indicator" class="badge">Walking</div>
<div id="coords" class="badge">x 0 y 0 z 0</div>
<div id="lock-hint" class="badge lock-hint" hidden>Click to capture mouse · or aim with cursor &amp; click to build</div>
</div>
<div id="hotbar-wrap">
<div id="stats">
<div id="hearts" class="stat-row stat-left"></div>
<div id="air" class="stat-row stat-right" hidden></div>
<div id="hunger" class="stat-row stat-right"></div>
</div>
<div id="hotbar" aria-label="Hotbar"></div>
</div>
<div id="hotbar" aria-label="Block selection"></div>
<div id="toast" class="toast" hidden></div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
132 changes: 120 additions & 12 deletions src/engine/Input.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -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 => {
Expand Down Expand Up @@ -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<void> | 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);
}
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
}
Expand Down
6 changes: 3 additions & 3 deletions src/game/Blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
Loading
Loading