// Logic game độc lập với Canvas/WebGL để có thể kiểm thử bằng Node.js. export const levels = { easy: { size: 9, gems: 3, enemies: 1, speed: 2.4, enemySpeed: .7 }, normal: { size: 13, gems: 5, enemies: 2, speed: 2.6, enemySpeed: .9 }, hard: { size: 17, gems: 7, enemies: 3, speed: 2.8, enemySpeed: 1.05 } }; export function random(seed) { let n = seed >>> 0; return () => { n = (Math.imul(n, 1664525) + 1013904223) >>> 0; return n / 4294967296; }; } export function neighbors(grid, x, z) { return [[x + 1, z], [x - 1, z], [x, z + 1], [x, z - 1]].filter(([a, b]) => grid[b]?.[a] === 0); } export function pathTo(grid, start, goal) { const queue = [[Math.floor(start.x), Math.floor(start.z)]], parents = new Map([[queue[0].join(','), null]]); let head = 0; const target = Math.floor(goal.x) + ',' + Math.floor(goal.z); while (head < queue.length) { const node = queue[head++], key = node.join(','); if (key === target) { const route = []; for (let current = key; current !== null; current = parents.get(current)) { const [x, z] = current.split(',').map(Number); route.push({ x: x + .5, z: z + .5 }); } return route.reverse(); } for (const next of neighbors(grid, ...node)) { const key = next.join(','); if (!parents.has(key)) { parents.set(key, node.join(',')); queue.push(next); } } } return []; } export function makeMaze(size, seed = 71) { const rng = random(seed), grid = Array.from({ length: size }, () => Array(size).fill(1)), stack = [[1, 1]]; grid[1][1] = 0; while (stack.length) { const [x, z] = stack.at(-1), choices = [[2, 0], [-2, 0], [0, 2], [0, -2]].filter(([dx, dz]) => x + dx > 0 && z + dz > 0 && x + dx < size - 1 && z + dz < size - 1 && grid[z + dz][x + dx] === 1); if (!choices.length) { stack.pop(); continue; } const [dx, dz] = choices[Math.floor(rng() * choices.length)]; grid[z + dz / 2][x + dx / 2] = 0; grid[z + dz][x + dx] = 0; stack.push([x + dx, z + dz]); } return grid; } export function createGame(mode = '2d', level = 'easy', seed = 71) { const config = { ...(levels[level] ?? levels.easy) }, grid = makeMaze(config.size, seed), start = { x: 1.5, z: 1.5 }, exit = { x: config.size - 1.5, z: config.size - 1.5 }; const cells = []; grid.forEach((row, z) => row.forEach((wall, x) => { if (!wall && !(x === 1 && z === 1) && !(x === config.size - 2 && z === config.size - 2)) cells.push({ x: x + .5, z: z + .5 }); })); const distances = cells.map(cell => ({ cell, d: pathTo(grid, start, cell).length })).sort((a, b) => a.d - b.d); const gems = Array.from({ length: config.gems }, (_, i) => ({ ...distances[Math.min(distances.length - 1, Math.floor((i + 1) * distances.length / (config.gems + 1)))].cell, taken: false })); const enemies = mode === '2d' ? Array.from({ length: config.enemies }, (_, i) => ({ ...distances[Math.floor(distances.length * (i + 1) / (config.enemies + 1))].cell, target: null, previous: null })) : []; return { mode, level, seed, config, grid, start, exit, gems, enemies, player: { ...start, yaw: grid[1][2] === 0 ? Math.PI / 2 : Math.PI }, lives: 3, time: 0, immunity: 0, status: 'ready', collected: 0, collisions: 0, rng: random(seed + 31) }; } export function canStand(grid, x, z, radius = .18) { const x0 = Math.floor(x - radius), x1 = Math.floor(x + radius), z0 = Math.floor(z - radius), z1 = Math.floor(z + radius); for (let a = x0; a <= x1; a++) for (let b = z0; b <= z1; b++) { if (grid[b]?.[a] !== 0) { const dx = x - Math.max(a, Math.min(x, a + 1)), dz = z - Math.max(b, Math.min(z, b + 1)); if (dx * dx + dz * dz < radius * radius) return false; } } return true; } export function movePlayer(game, dx, dz) { const length = Math.hypot(dx, dz), steps = Math.max(1, Math.ceil(length / .09)); let hit = false; for (let i = 0; i < steps; i++) { const p = game.player; if (canStand(game.grid, p.x + dx / steps, p.z)) p.x += dx / steps; else hit = true; if (canStand(game.grid, p.x, p.z + dz / steps)) p.z += dz / steps; else hit = true; } if (hit) game.collisions++; } export function tick(game, input, seconds) { if (game.status !== 'running') return; const dt = Math.max(0, Math.min(seconds, .05)), p = game.player; game.time += dt; game.immunity = Math.max(0, game.immunity - dt); let dx = 0, dz = 0; if (game.mode === '2d') { dx = (input.right ? 1 : 0) - (input.left ? 1 : 0); dz = (input.back ? 1 : 0) - (input.forward ? 1 : 0); } else { p.yaw += ((input.right ? 1 : 0) - (input.left ? 1 : 0)) * 1.9 * dt; const forward = (input.forward ? 1 : 0) - (input.back ? 1 : 0), strafe = (input.strafeRight ? 1 : 0) - (input.strafeLeft ? 1 : 0); dx = Math.sin(p.yaw) * forward + Math.cos(p.yaw) * strafe; dz = -Math.cos(p.yaw) * forward + Math.sin(p.yaw) * strafe; } const norm = Math.hypot(dx, dz) || 1; movePlayer(game, dx / norm * game.config.speed * dt, dz / norm * game.config.speed * dt); for (const gem of game.gems) if (!gem.taken && Math.hypot(gem.x - p.x, gem.z - p.z) < .38) { gem.taken = true; game.collected++; } for (const enemy of game.enemies) { if (!enemy.target) { const options = neighbors(game.grid, Math.floor(enemy.x), Math.floor(enemy.z)).map(([x, z]) => ({ x: x + .5, z: z + .5 })); const route = pathTo(game.grid, enemy, p); if (route.length > 1 && route.length <= 5) enemy.target = route[1]; else { const choices = options.filter(option => option.x !== enemy.previous?.x || option.z !== enemy.previous?.z); enemy.target = (choices.length ? choices : options)[Math.floor(game.rng() * (choices.length || options.length))]; } } if (enemy.target) { const dx = enemy.target.x - enemy.x, dz = enemy.target.z - enemy.z, d = Math.hypot(dx, dz), step = game.config.enemySpeed * dt; if (d <= step) { enemy.previous = { x: enemy.x, z: enemy.z }; enemy.x = enemy.target.x; enemy.z = enemy.target.z; enemy.target = null; } else { enemy.x += dx / d * step; enemy.z += dz / d * step; } } if (game.immunity <= 0 && Math.hypot(enemy.x - p.x, enemy.z - p.z) < .36) { game.lives--; game.immunity = 2; p.x = game.start.x; p.z = game.start.z; if (game.lives <= 0) game.status = 'lost'; } } if (game.collected === game.gems.length && Math.hypot(p.x - game.exit.x, p.z - game.exit.z) < .4) game.status = 'won'; } export function hintPath(game) { const target = game.gems.filter(g => !g.taken).map(g => ({ target: g, path: pathTo(game.grid, game.player, g) })).sort((a, b) => a.path.length - b.path.length)[0]; return target?.path ?? pathTo(game.grid, game.player, game.exit); }