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
36 changes: 29 additions & 7 deletions src/display/input/InputManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class InputManager {

_handleKeyDown(e) {
const { key, code } = e;
const keyLower = typeof key === "string" ? key.toLowerCase() : "";
// If any UI panel is open, ignore movement/consumable bindings to let UI handle keys
try {
const openPanels = Array.from(document.querySelectorAll('.ui-panel')).filter(p => p && p.style.display === 'block');
Expand All @@ -100,41 +101,62 @@ export class InputManager {
} catch {}

// Open Inventory: 'i'
if (key?.toLowerCase() === 'i') {
if (keyLower === 'i') {
e.preventDefault();
this._emit(makeAction(Actions.OpenInventory));
return;
}
// Wait intent: '.' (period)
if (key === ".") {
if (key === "." || code === "Numpad5") {
e.preventDefault();
this._emit(makeAction(Actions.Wait));
return;
}
// Diagonal movement (numpad-style)
if (code === "Home" || code === "Numpad7" || keyLower === "y") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: -1, dy: -1 }));
return;
}
if (code === "PageUp" || code === "Numpad9" || keyLower === "u") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: 1, dy: -1 }));
return;
}
if (code === "End" || code === "Numpad1" || keyLower === "b") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: -1, dy: 1 }));
return;
}
if (code === "PageDown" || code === "Numpad3" || keyLower === "n") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: 1, dy: 1 }));
return;
}
// Move left / right
if (code === "ArrowLeft" || key === "a" || key === "h") {
if (code === "ArrowLeft" || code === "Numpad4" || keyLower === "a" || keyLower === "h") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: -1, dy: 0 }));
return;
}
if (code === "ArrowRight" || key === "d" || key === "l") {
if (code === "ArrowRight" || code === "Numpad6" || keyLower === "d" || keyLower === "l") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: 1, dy: 0 }));
return;
}
// Move up / down
if (code === "ArrowUp" || key === "w" || key === "k") {
if (code === "ArrowUp" || code === "Numpad8" || keyLower === "w" || keyLower === "k") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: 0, dy: -1 }));
return;
}
if (code === "ArrowDown" || key === "s" || key === "j") {
if (code === "ArrowDown" || code === "Numpad2" || keyLower === "s" || keyLower === "j") {
e.preventDefault();
this._emit(makeAction(Actions.Move, { dx: 0, dy: 1 }));
return;
}
// Drink potion (common roguelike: 'q' for quaff)
if (key?.toLowerCase() === "q") {
if (keyLower === "q") {
e.preventDefault();
this._emit(makeAction(Actions.DrinkPotion));
return;
Expand Down
2 changes: 1 addition & 1 deletion src/display/palette/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const basePalette = {

// Tiles
// Colors tuned to match display defaults in drawDungeon
floor: { glyph: ".", fg: "#576072", glow: "#1c2029" },
floor: { glyph: ".", fg: "#2e323c", glow: "#1f2129" },
wall: { glyph: "#", fg: "#8e96ab", glow: "#1f232c" },
door_closed: { glyph: "+", fg: "#cc9", glow: "#aa7" },
door_open: { glyph: "/", fg: "#cc9", glow: "#aa7" },
Expand Down
78 changes: 64 additions & 14 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const dungeonRenderState = {
versionKey: null,
primitives: [],
dots: [],
wallTiles: [],
kernel: null,
mbr: null,
options: null,
Expand Down Expand Up @@ -368,6 +369,7 @@ function ensureDungeonRenderState(dungeon) {
dungeonRenderState.versionKey = null;
dungeonRenderState.primitives.length = 0;
dungeonRenderState.dots.length = 0;
dungeonRenderState.wallTiles.length = 0;
dungeonRenderState.kernel = null;
dungeonRenderState.mbr = null;
dungeonRenderState.options = null;
Expand Down Expand Up @@ -401,6 +403,7 @@ function ensureDungeonRenderState(dungeon) {

function computeDungeonDots(state) {
state.dots = [];
state.wallTiles = [];
const kernel = state.kernel;
const mbr = state.mbr;
if (!kernel || !mbr) return;
Expand All @@ -409,12 +412,44 @@ function computeDungeonDots(state) {
const maxX = Math.ceil(mbr.maxX);
const minY = Math.floor(mbr.minY);
const maxY = Math.ceil(mbr.maxY);

const floorKeys = new Set();
/** @type {Array<{x:number,y:number}>} */
const floorTiles = [];

for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
const px = x + 0.5;
const py = y + 0.5;
if (kernel.distanceMove(px, py) > 0.25) {
const dist = kernel.distanceMove(px, py);
if (dist > 0.25) {
state.dots.push({ x: px, y: py });
const key = `${x},${y}`;
floorKeys.add(key);
floorTiles.push({ x, y });
}
}
}

const neighborOffsets = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
];
const wallKeys = new Set();

for (let i = 0; i < floorTiles.length; i++) {
const tile = floorTiles[i];
for (let j = 0; j < neighborOffsets.length; j++) {
const [dx, dy] = neighborOffsets[j];
const nx = tile.x + dx;
const ny = tile.y + dy;
const key = `${nx},${ny}`;
if (floorKeys.has(key) || wallKeys.has(key)) continue;
const px = nx + 0.5;
const py = ny + 0.5;
if (kernel.distanceMove(px, py) <= 0.25) {
state.wallTiles.push({ x: px, y: py });
wallKeys.add(key);
}
}
}
Comment on lines +440 to 455
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The wall tile detection algorithm could have performance issues for large dungeons. For each floor tile, it checks 8 neighbors, and for each neighbor it calls kernel.distanceMove(). This results in O(n × 8) distance queries where n is the number of floor tiles.

For large dungeons, consider:

  1. Caching distance query results
  2. Using the existing geometry primitives more directly
  3. Computing wall tiles only once and invalidating on geometry changes

The nested loops at lines 440-454 could be optimized by batching distance queries or using spatial indexing.

Copilot uses AI. Check for mistakes.
Expand All @@ -424,7 +459,7 @@ function drawDungeon(ctx, palette, glyphAtlas, state) {
if (!state || state.primitives.length === 0) return;
const wallFill = palette.wall?.glow || "#1f232c";
const wallHighlight = palette.wall?.fg || "#8e96ab";
const floorFill = palette.floor?.glow || "#1c2029";
const floorFill = palette.floor?.glow || "#1f2129";
const floorAccent = palette.floor?.fg || "#576072";

ctx.save();
Expand Down Expand Up @@ -492,6 +527,16 @@ function drawDungeon(ctx, palette, glyphAtlas, state) {
}
}

if (glyphAtlas && Array.isArray(state.wallTiles) && state.wallTiles.length > 0) {
ctx.save();
ctx.globalAlpha = 0.55;
for (let i = 0; i < state.wallTiles.length; i++) {
const tile = state.wallTiles[i];
drawKind(glyphAtlas, ctx, "wall", tile.x, tile.y);
}
ctx.restore();
}

if (glyphAtlas && state.dots.length > 0) {
ctx.save();
ctx.globalAlpha = 0.28;
Expand Down Expand Up @@ -786,30 +831,35 @@ function syncLightEmitters(lights, fx, time) {
// For moving emitters, allow slight variability without heavy flicker
origins.push({ key, x: light.x, y: light.y });
} else if (light?.emitter === "burning") {
// Subtle embers and heat shimmer for burning status
// Enhanced embers and heat shimmer for burning status
const key = `burning:${light.id ?? i}`;
seen.add(key);
const emitter = fx.ensureEmitter(key, {
continuous: true,
rate: 8,
spread: Math.PI / 10,
speed: 0.5,
speedJitter: 0.3,
life: 0.6,
lifeJitter: 0.3,
size: 0.14,
sizeEnd: 0.04,
rate: 14,
spread: Math.PI / 8,
speed: 0.65,
speedJitter: 0.35,
life: 0.85,
lifeJitter: 0.35,
size: 0.26,
sizeEnd: 0.08,
angle: -Math.PI / 2,
ax: 0,
ay: -0.25,
ay: -0.32,
color: light.color || "#ff7a2a",
alpha0: 0.55,
alpha0: 0.75,
alpha1: 0.0,
offsetX: 0,
offsetY: -0.1,
offsetY: -0.14,
});
const rgb = parseRgb(light.color || "#ff7a2a");
emitter.r = rgb.r; emitter.g = rgb.g; emitter.b = rgb.b;
const seed = hashToUnit(light.id ?? `${light.x},${light.y}`);
const flicker = Math.sin((time || 0) * 4.4 + seed * 17.1) * 0.18 + Math.sin((time || 0) * 2.9 + seed * 9.7) * 0.12;
emitter.rate = 12 + flicker * 6;
emitter.size = 0.24 + flicker * 0.08;
emitter.life = 0.75 + Math.abs(flicker) * 0.2;
origins.push({ key, x: light.x, y: light.y });
} else if (light?.emitter === "meteorFlame") {
// Heavier fiery trail for meteor head
Expand Down
59 changes: 58 additions & 1 deletion src/main/ui/setupUIEventListeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,62 @@ export function setupUIEventListeners(world, deps) {
// Simple spell targeting latch for Meteor
let _meteorTargeting = null; // { vx, vy }

/**
* Maps an arbitrary direction vector to the nearest 8-direction grid step.
* @param {number} dx
* @param {number} dy
*/
const resolveGridStep = (dx, dy) => {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return { dx: 0, dy: 0 };
const len = Math.hypot(dx, dy);
if (len <= 1e-5) return { dx: 0, dy: 0 };
const nx = dx / len;
const ny = dy / len;
const angle = Math.atan2(ny, nx);
const wrap = (r) => Math.atan2(Math.sin(r), Math.cos(r));

const CARDINAL_WIDTH = Math.PI / 4 * 1.4; // widen primary axes (~63deg)
const DIAGONAL_WIDTH = Math.PI / 4 * 0.75; // narrower (~34deg)

const cardinals = [
{ dx: 1, dy: 0, angle: 0 },
{ dx: -1, dy: 0, angle: Math.PI },
{ dx: 0, dy: 1, angle: Math.PI / 2 },
{ dx: 0, dy: -1, angle: -Math.PI / 2 },
];
for (let i = 0; i < cardinals.length; i++) {
const target = cardinals[i];
const diff = Math.abs(wrap(angle - target.angle));
if (diff <= CARDINAL_WIDTH * 0.5) {
return target;
}
}

const diagonals = [
{ dx: 1, dy: 1, angle: Math.PI / 4 },
{ dx: 1, dy: -1, angle: -Math.PI / 4 },
{ dx: -1, dy: 1, angle: (3 * Math.PI) / 4 },
{ dx: -1, dy: -1, angle: (-3 * Math.PI) / 4 },
];
let best = diagonals[0];
let bestScore = -Infinity;
for (let i = 0; i < diagonals.length; i++) {
const target = diagonals[i];
const diff = Math.abs(wrap(angle - target.angle));
const score = DIAGONAL_WIDTH * 0.5 - diff;
if (diff <= DIAGONAL_WIDTH * 0.5 && score > bestScore) {
bestScore = score;
best = target;
}
}
if (bestScore > -Infinity) {
return best;
}

// Fallback to raw component sign (should rarely occur)
return { dx: Math.sign(nx), dy: Math.sign(ny) };
};

const displayHandler = (action) => {
switch (action.type) {
case "display.tapWorld": {
Expand Down Expand Up @@ -115,8 +171,9 @@ export function setupUIEventListeners(world, deps) {
// Default: move toward tap
const dx = wx - pe.pos.x;
const dy = wy - pe.pos.y;
const step = resolveGridStep(dx, dy);
const handler = resolveRulesDispatcher(world, () => (playerEntity(world)?.id || 0));
handler({ type: "rules.move", payload: { dx, dy } });
handler({ type: "rules.move", payload: { dx: step.dx, dy: step.dy } });
}
break;
}
Expand Down
2 changes: 2 additions & 0 deletions src/rules/archetypes/Spawner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { defineArchetype } from "../../lib/ecs-js/archetype.js";
import { Position } from "../components/Position.js";
import { NamedIdentity } from "../components/NamedIdentity.js";
import { Vitality } from "../components/Vitality.js";
import { Collider } from "../components/Collider.js";
import { MonsterSpawner } from "../components/MonsterSpawner.js";

export const Spawner = defineArchetype(
"Spawner",
[Position, (p) => ({ x: p.x ?? 0, y: p.y ?? 0 })],
[NamedIdentity, (p) => ({ name: p.name ?? "Monster Spawner", identity: p.identity ?? "spawner" })],
[Collider, () => ({ solid: false, blocksSight: false })],
[Vitality, (p) => ({ maxHp: p.maxHp ?? 100, hp: p.hp ?? (p.maxHp ?? 100) })],
[MonsterSpawner, (p) => ({
maxConcurrent: p.maxConcurrent ?? 3,
Expand Down
7 changes: 4 additions & 3 deletions src/rules/systems/aiChaseSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ export function aiChaseSystem(world) {

const vx = playerPos.x - pos.x;
const vy = playerPos.y - pos.y;
const mag = Math.hypot(vx, vy);
if (mag <= 1e-4) continue;
const stepX = Math.abs(vx) > 1e-4 ? (vx > 0 ? 1 : -1) : 0;
const stepY = Math.abs(vy) > 1e-4 ? (vy > 0 ? 1 : -1) : 0;
if (stepX === 0 && stepY === 0) continue;

try { world.add(id, MoveIntent, { dx: vx, dy: vy }); } catch {}
try { world.add(id, MoveIntent, { dx: stepX, dy: stepY }); } catch {}
}
}
Loading