Created
May 7, 2026 05:49
-
-
Save EncodeTheCode/d275cfff136490c5631ca960352634cd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" /> | |
| <title>Syphon-Style Canvas FPS - Exact Wall Face Impacts</title> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #000; | |
| font-family: system-ui, sans-serif; | |
| user-select: none; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| image-rendering: auto; | |
| cursor: crosshair; | |
| outline: none; | |
| } | |
| .hud { | |
| position: fixed; | |
| top: 10px; | |
| left: 10px; | |
| color: rgba(255,255,255,.92); | |
| font-size: 13px; | |
| line-height: 1.35; | |
| text-shadow: 0 1px 2px rgba(0,0,0,.85); | |
| pointer-events: none; | |
| background: rgba(0,0,0,.22); | |
| padding: 8px 10px; | |
| border: 1px solid rgba(255,255,255,.12); | |
| border-radius: 10px; | |
| backdrop-filter: blur(2px); | |
| } | |
| .hud strong { font-weight: 700; } | |
| .lockHint { | |
| position: fixed; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| pointer-events: none; | |
| color: rgba(255,255,255,.9); | |
| text-shadow: 0 1px 3px black; | |
| font-size: 16px; | |
| transition: opacity .15s ease; | |
| } | |
| .lockHint > div { | |
| background: rgba(0,0,0,.45); | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255,255,255,.12); | |
| } | |
| .hidden { opacity: 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game" tabindex="1"></canvas> | |
| <div class="hud"> | |
| <strong>WASD</strong> move • mouse aim • click to lock • left click to fire • ESC unlock<br> | |
| Exact wall-face bullet impacts • no tile-center snapping | |
| </div> | |
| <div id="lockHint" class="lockHint"> | |
| <div>Click the game to lock mouse and play.</div> | |
| </div> | |
| <script> | |
| (() => { | |
| const canvas = document.getElementById("game"); | |
| const ctx = canvas.getContext("2d", { alpha: false }); | |
| const lockHint = document.getElementById("lockHint"); | |
| if (!ctx) { | |
| document.body.innerHTML = '<p style="color:white;padding:20px">Canvas unsupported.</p>'; | |
| return; | |
| } | |
| const DPR = Math.min(2, window.devicePixelRatio || 1); | |
| let W = 0; | |
| let H = 0; | |
| function resize() { | |
| W = Math.floor(innerWidth); | |
| H = Math.floor(innerHeight); | |
| canvas.width = Math.floor(W * DPR); | |
| canvas.height = Math.floor(H * DPR); | |
| canvas.style.width = W + "px"; | |
| canvas.style.height = H + "px"; | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| ctx.imageSmoothingEnabled = false; | |
| } | |
| addEventListener("resize", resize); | |
| resize(); | |
| const map = [ | |
| [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [1,0,1,1,1,1,0,1,1,1,1,0,1,1,1,0,1,1,1,1,1,0,0,1], | |
| [1,0,1,0,0,1,0,1,0,0,1,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,1,0,0,1,0,1,0,0,1,0,1,0,1,1,1,0,1,0,1,1,0,1], | |
| [1,0,1,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1,0,1], | |
| [1,0,1,1,1,1,1,0,1,1,1,1,1,0,1,0,1,1,1,1,0,1,0,1], | |
| [1,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,1,0,0,0,0,1,0,1], | |
| [1,1,1,1,1,0,1,1,1,1,1,0,1,0,1,0,1,0,1,1,1,1,0,1], | |
| [1,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1], | |
| [1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,1,1,1,1,1,0,1], | |
| [1,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1], | |
| [1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,1,1,1,1,1,0,1,0,1], | |
| [1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1], | |
| [1,0,1,1,1,0,1,0,1,1,1,1,1,1,1,0,1,0,0,1,1,1,0,1], | |
| [1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,1], | |
| [1,0,1,0,1,1,1,1,1,1,1,1,1,0,1,0,1,1,1,1,0,1,0,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1], | |
| [1,0,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,0,1], | |
| [1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,1,0,1], | |
| [1,0,1,1,1,1,0,1,0,1,0,1,1,1,1,1,0,1,1,1,0,1,0,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] | |
| ]; | |
| const MAP_W = map[0].length; | |
| const MAP_H = map.length; | |
| const player = { | |
| x: 1.5, | |
| y: 1.5, | |
| a: 0.25, | |
| pitch: 0, | |
| moveSpeed: 3.2, | |
| radius: 0.16 | |
| }; | |
| const keys = Object.create(null); | |
| let pointerLocked = false; | |
| let mouseDown = false; | |
| let shootCooldown = 0; | |
| const bullets = []; | |
| const MAX_BULLETS = 48; | |
| const bulletHoles = []; | |
| const MAX_HOLES = 150; | |
| function isWallTile(tx, ty) { | |
| return tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H || map[ty][tx] === 1; | |
| } | |
| function collides(x, y) { | |
| const r = player.radius; | |
| return ( | |
| isWallTile(Math.floor(x - r), Math.floor(y - r)) || | |
| isWallTile(Math.floor(x + r), Math.floor(y - r)) || | |
| isWallTile(Math.floor(x - r), Math.floor(y + r)) || | |
| isWallTile(Math.floor(x + r), Math.floor(y + r)) | |
| ); | |
| } | |
| function moveWithSlide(dx, dy) { | |
| const steps = Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / 0.05); | |
| const sx = dx / steps; | |
| const sy = dy / steps; | |
| for (let i = 0; i < steps; i++) { | |
| const nx = player.x + sx; | |
| if (!collides(nx, player.y)) player.x = nx; | |
| const ny = player.y + sy; | |
| if (!collides(player.x, ny)) player.y = ny; | |
| } | |
| } | |
| function normalizeAngle(a) { | |
| while (a > Math.PI) a -= Math.PI * 2; | |
| while (a < -Math.PI) a += Math.PI * 2; | |
| return a; | |
| } | |
| function projectPoint(wx, wy, projPlane) { | |
| const dx = wx - player.x; | |
| const dy = wy - player.y; | |
| const sin = Math.sin(player.a); | |
| const cos = Math.cos(player.a); | |
| const camX = -dx * sin + dy * cos; | |
| const camZ = dx * cos + dy * sin; | |
| if (camZ <= 0.0001) return null; | |
| return { | |
| screenX: W * 0.5 + (camX / camZ) * projPlane, | |
| depth: camZ | |
| }; | |
| } | |
| function addBulletHole(hitX, hitY, side, pitchAtFire) { | |
| const eps = 0.0001; | |
| const exactX = hitX + Math.cos(player.a) * eps; | |
| const exactY = hitY + Math.sin(player.a) * eps; | |
| const faceU = side === 0 | |
| ? exactY - Math.floor(exactY) | |
| : exactX - Math.floor(exactX); | |
| bulletHoles.push({ | |
| x: exactX, | |
| y: exactY, | |
| side, | |
| faceU, | |
| pitchAtFire, | |
| rot: Math.random() * Math.PI * 2, | |
| size: 7 + Math.random() * 5 | |
| }); | |
| if (bulletHoles.length > MAX_HOLES) bulletHoles.shift(); | |
| } | |
| function castRayFrom(startX, startY, rayAngle, maxDistance = Infinity) { | |
| const rayDirX = Math.cos(rayAngle); | |
| const rayDirY = Math.sin(rayAngle); | |
| let mapX = Math.floor(startX); | |
| let mapY = Math.floor(startY); | |
| const deltaDistX = Math.abs(1 / (rayDirX || 1e-9)); | |
| const deltaDistY = Math.abs(1 / (rayDirY || 1e-9)); | |
| let stepX, stepY; | |
| let sideDistX, sideDistY; | |
| if (rayDirX < 0) { | |
| stepX = -1; | |
| sideDistX = (startX - mapX) * deltaDistX; | |
| } else { | |
| stepX = 1; | |
| sideDistX = (mapX + 1 - startX) * deltaDistX; | |
| } | |
| if (rayDirY < 0) { | |
| stepY = -1; | |
| sideDistY = (startY - mapY) * deltaDistY; | |
| } else { | |
| stepY = 1; | |
| sideDistY = (mapY + 1 - startY) * deltaDistY; | |
| } | |
| let hit = false; | |
| let side = 0; | |
| let maxSteps = 128; | |
| while (!hit && maxSteps-- > 0) { | |
| if (sideDistX < sideDistY) { | |
| sideDistX += deltaDistX; | |
| mapX += stepX; | |
| side = 0; | |
| } else { | |
| sideDistY += deltaDistY; | |
| mapY += stepY; | |
| side = 1; | |
| } | |
| if (isWallTile(mapX, mapY)) hit = true; | |
| } | |
| if (!hit) return null; | |
| let dist; | |
| if (side === 0) { | |
| dist = (mapX - startX + (1 - stepX) / 2) / (rayDirX || 1e-9); | |
| } else { | |
| dist = (mapY - startY + (1 - stepY) / 2) / (rayDirY || 1e-9); | |
| } | |
| dist = Math.max(0.0001, Math.abs(dist)); | |
| if (dist > maxDistance) return null; | |
| const hitX = startX + rayDirX * dist; | |
| const hitY = startY + rayDirY * dist; | |
| return { dist, side, hitX, hitY }; | |
| } | |
| function fireBullet() { | |
| if (shootCooldown > 0) return; | |
| shootCooldown = 0.14; | |
| const dirX = Math.cos(player.a); | |
| const dirY = Math.sin(player.a); | |
| bullets.push({ | |
| x: player.x + dirX * 0.28, | |
| y: player.y + dirY * 0.28, | |
| dx: dirX, | |
| dy: dirY, | |
| speed: 9.5, | |
| life: 2.0, | |
| pitchAtFire: player.pitch | |
| }); | |
| if (bullets.length > MAX_BULLETS) bullets.shift(); | |
| } | |
| function updateBullets(dt) { | |
| for (let i = bullets.length - 1; i >= 0; i--) { | |
| const b = bullets[i]; | |
| const step = b.speed * dt; | |
| const angle = Math.atan2(b.dy, b.dx); | |
| const hit = castRayFrom(b.x, b.y, angle, step + 1e-6); | |
| if (hit) { | |
| addBulletHole(hit.hitX, hit.hitY, hit.side, b.pitchAtFire); | |
| bullets.splice(i, 1); | |
| continue; | |
| } | |
| b.x += b.dx * step; | |
| b.y += b.dy * step; | |
| b.life -= dt; | |
| if (b.life <= 0 || isWallTile(Math.floor(b.x), Math.floor(b.y))) { | |
| const impact = castRayFrom(b.x - b.dx * 0.02, b.y - b.dy * 0.02, angle, 0.05); | |
| if (impact) addBulletHole(impact.hitX, impact.hitY, impact.side, b.pitchAtFire); | |
| bullets.splice(i, 1); | |
| } | |
| } | |
| } | |
| addEventListener("keydown", e => { | |
| keys[e.code] = true; | |
| if (e.code === "Escape" && document.pointerLockElement === canvas) { | |
| document.exitPointerLock(); | |
| } | |
| }); | |
| addEventListener("keyup", e => { | |
| keys[e.code] = false; | |
| }); | |
| canvas.addEventListener("click", () => { | |
| if (!pointerLocked) { | |
| canvas.requestPointerLock(); | |
| canvas.focus(); | |
| } | |
| }); | |
| canvas.addEventListener("mousedown", e => { | |
| if (e.button === 0) { | |
| mouseDown = true; | |
| if (pointerLocked) fireBullet(); | |
| } | |
| }); | |
| canvas.addEventListener("mouseup", e => { | |
| if (e.button === 0) mouseDown = false; | |
| }); | |
| document.addEventListener("pointerlockchange", () => { | |
| pointerLocked = document.pointerLockElement === canvas; | |
| lockHint.classList.toggle("hidden", pointerLocked); | |
| canvas.style.cursor = pointerLocked ? "none" : "crosshair"; | |
| }); | |
| canvas.addEventListener("mousemove", e => { | |
| if (!pointerLocked) return; | |
| player.a += e.movementX * 0.0024; | |
| player.pitch += e.movementY * 0.7; | |
| const limit = H * 0.35; | |
| if (player.pitch > limit) player.pitch = limit; | |
| if (player.pitch < -limit) player.pitch = -limit; | |
| }); | |
| function update(dt) { | |
| if (shootCooldown > 0) shootCooldown = Math.max(0, shootCooldown - dt); | |
| if (pointerLocked && mouseDown) fireBullet(); | |
| let forward = 0; | |
| let strafe = 0; | |
| if (keys.KeyW) forward += 1; | |
| if (keys.KeyS) forward -= 1; | |
| if (keys.KeyD) strafe += 1; | |
| if (keys.KeyA) strafe -= 1; | |
| const mag = Math.hypot(forward, strafe) || 1; | |
| forward /= mag; | |
| strafe /= mag; | |
| const sin = Math.sin(player.a); | |
| const cos = Math.cos(player.a); | |
| const dx = (cos * forward - sin * strafe) * player.moveSpeed * dt; | |
| const dy = (sin * forward + cos * strafe) * player.moveSpeed * dt; | |
| moveWithSlide(dx, dy); | |
| updateBullets(dt); | |
| } | |
| function castRay(rayAngle) { | |
| return castRayFrom(player.x, player.y, rayAngle, Infinity); | |
| } | |
| function drawBackground() { | |
| const horizon = H * 0.5 - player.pitch; | |
| const sky = ctx.createLinearGradient(0, 0, 0, horizon); | |
| sky.addColorStop(0, "#1c2230"); | |
| sky.addColorStop(1, "#0b0f15"); | |
| ctx.fillStyle = sky; | |
| ctx.fillRect(0, 0, W, horizon); | |
| const floor = ctx.createLinearGradient(0, horizon, 0, H); | |
| floor.addColorStop(0, "#222"); | |
| floor.addColorStop(1, "#090909"); | |
| ctx.fillStyle = floor; | |
| ctx.fillRect(0, horizon, W, H - horizon); | |
| } | |
| function drawBulletHoles(fov, projPlane) { | |
| for (const hole of bulletHoles) { | |
| const dx = hole.x - player.x; | |
| const dy = hole.y - player.y; | |
| const dist = Math.hypot(dx, dy); | |
| const ang = Math.atan2(dy, dx); | |
| const rel = normalizeAngle(ang - player.a); | |
| if (Math.abs(rel) > fov * 0.5 + 0.2) continue; | |
| const viewHit = castRayFrom(player.x, player.y, ang, Infinity); | |
| if (!viewHit) continue; | |
| if (dist > viewHit.dist + 0.03) continue; | |
| const proj = projectPoint(hole.x, hole.y, projPlane); | |
| if (!proj) continue; | |
| const corrected = Math.max(0.0001, proj.depth); | |
| const wallH = Math.min(H * 2, projPlane / corrected); | |
| const wallTop = (H - wallH) * 0.5 - player.pitch; | |
| const pitchNormalized = Math.max(-1, Math.min(1, hole.pitchAtFire / (H * 0.35))); | |
| const holeX = proj.screenX; | |
| const holeY = wallTop + (0.5 + pitchNormalized * 0.34) * wallH; | |
| const size = Math.max(4, Math.min(18, hole.size * (projPlane / (corrected * 70)) + 3)); | |
| ctx.save(); | |
| ctx.translate(holeX, holeY); | |
| ctx.rotate(hole.rot); | |
| ctx.fillStyle = "rgba(16,16,16,0.92)"; | |
| ctx.fillRect(-size * 0.5, -size * 0.5, size, size); | |
| ctx.strokeStyle = "rgba(255,255,255,0.06)"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(-size * 0.5, -size * 0.5, size, size); | |
| ctx.fillStyle = "rgba(255,255,255,0.05)"; | |
| ctx.fillRect(-size * 0.18, -size * 0.18, size * 0.36, size * 0.36); | |
| ctx.restore(); | |
| } | |
| } | |
| function drawScene() { | |
| drawBackground(); | |
| const fov = Math.PI / 3; | |
| const columnCount = Math.max(220, Math.floor(W / 2)); | |
| const sliceW = W / columnCount; | |
| const projPlane = (W * 0.5) / Math.tan(fov * 0.5); | |
| for (let i = 0; i < columnCount; i++) { | |
| const t = i / (columnCount - 1); | |
| const rayAngle = player.a - fov * 0.5 + t * fov; | |
| const hit = castRay(rayAngle); | |
| if (!hit) continue; | |
| const corrected = Math.max(0.0001, hit.dist * Math.cos(rayAngle - player.a)); | |
| const wallH = Math.min(H * 2, projPlane / corrected); | |
| const y = (H - wallH) * 0.5 - player.pitch; | |
| const x = i * sliceW; | |
| let shade = 255 - corrected * 26; | |
| if (shade < 0) shade = 0; | |
| if (shade > 255) shade = 255; | |
| if (hit.side === 1) shade *= 0.78; | |
| ctx.fillStyle = `rgb(${shade|0},${shade|0},${shade|0})`; | |
| ctx.fillRect(x, y, sliceW + 1, wallH); | |
| ctx.fillStyle = "rgba(0,0,0,0.12)"; | |
| ctx.fillRect(x, y, 1, wallH); | |
| } | |
| drawBulletHoles(fov, projPlane); | |
| drawCrosshair(); | |
| drawMinimap(); | |
| drawBulletsOnMinimap(); | |
| } | |
| function drawCrosshair() { | |
| ctx.save(); | |
| ctx.translate(W / 2, H / 2); | |
| ctx.strokeStyle = "rgba(255,255,255,0.8)"; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(-10, 0); ctx.lineTo(-3, 0); | |
| ctx.moveTo(3, 0); ctx.lineTo(10, 0); | |
| ctx.moveTo(0, -10); ctx.lineTo(0, -3); | |
| ctx.moveTo(0, 3); ctx.lineTo(0, 10); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function drawNorthArrow(cx, cy) { | |
| const g = ctx.createLinearGradient(cx, cy - 18, cx, cy + 18); | |
| g.addColorStop(0, "rgba(255,255,255,1)"); | |
| g.addColorStop(0.5, "rgba(245,245,245,0.95)"); | |
| g.addColorStop(1, "rgba(190,190,190,0.72)"); | |
| ctx.save(); | |
| ctx.translate(cx, cy); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -13); | |
| ctx.lineTo(13, 9); | |
| ctx.lineTo(5, 7); | |
| ctx.lineTo(5, 18); | |
| ctx.lineTo(-5, 18); | |
| ctx.lineTo(-5, 7); | |
| ctx.lineTo(-13, 9); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawMinimap() { | |
| const boxSize = Math.min(184, Math.floor(Math.min(W, H) * 0.23)); | |
| const x0 = 18; | |
| const y0 = H - boxSize - 26; | |
| const scale = boxSize / MAP_W; | |
| ctx.save(); | |
| ctx.fillStyle = "rgba(255,255,255,0.045)"; | |
| ctx.fillRect(x0, y0, boxSize, boxSize); | |
| ctx.strokeStyle = "rgba(255,255,255,0.24)"; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(x0, y0, boxSize, boxSize); | |
| ctx.strokeStyle = "rgba(255,255,255,0.05)"; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < MAP_W; i++) { | |
| const gx = x0 + i * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(gx, y0); | |
| ctx.lineTo(gx, y0 + boxSize); | |
| ctx.stroke(); | |
| } | |
| for (let j = 1; j < MAP_H; j++) { | |
| const gy = y0 + j * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(x0, gy); | |
| ctx.lineTo(x0 + boxSize, gy); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < MAP_H; y++) { | |
| for (let x = 0; x < MAP_W; x++) { | |
| if (!map[y][x]) continue; | |
| ctx.fillStyle = "rgba(165,165,165,0.86)"; | |
| ctx.fillRect(x0 + x * scale + 0.5, y0 + y * scale + 0.5, scale - 1, scale - 1); | |
| } | |
| } | |
| drawNorthArrow(x0 + boxSize * 0.5, y0 + 16); | |
| const px = x0 + player.x * scale; | |
| const py = y0 + player.y * scale; | |
| ctx.strokeStyle = "rgba(255,255,255,0.45)"; | |
| ctx.lineWidth = 1.25; | |
| ctx.beginPath(); | |
| ctx.moveTo(px, py); | |
| ctx.lineTo(px + Math.cos(player.a - 0.28) * 15, py + Math.sin(player.a - 0.28) * 15); | |
| ctx.moveTo(px, py); | |
| ctx.lineTo(px + Math.cos(player.a + 0.28) * 15, py + Math.sin(player.a + 0.28) * 15); | |
| ctx.stroke(); | |
| ctx.fillStyle = "rgba(255,255,255,0.95)"; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 3.3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(0,0,0,0.55)"; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 1.1, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawBulletsOnMinimap() { | |
| if (!bullets.length) return; | |
| const boxSize = Math.min(184, Math.floor(Math.min(W, H) * 0.23)); | |
| const x0 = 18; | |
| const y0 = H - boxSize - 26; | |
| const scale = boxSize / MAP_W; | |
| ctx.save(); | |
| ctx.fillStyle = "rgba(255,220,120,0.92)"; | |
| for (const b of bullets) { | |
| const x = x0 + b.x * scale; | |
| const y = y0 + b.y * scale; | |
| ctx.fillRect(x - 1, y - 1, 2, 2); | |
| } | |
| ctx.restore(); | |
| } | |
| addEventListener("keydown", e => { | |
| keys[e.code] = true; | |
| if (e.code === "Escape" && document.pointerLockElement === canvas) { | |
| document.exitPointerLock(); | |
| } | |
| }); | |
| addEventListener("keyup", e => { | |
| keys[e.code] = false; | |
| }); | |
| canvas.addEventListener("click", () => { | |
| if (!pointerLocked) { | |
| canvas.requestPointerLock(); | |
| canvas.focus(); | |
| } | |
| }); | |
| canvas.addEventListener("mousedown", e => { | |
| if (e.button === 0) { | |
| mouseDown = true; | |
| if (pointerLocked) fireBullet(); | |
| } | |
| }); | |
| canvas.addEventListener("mouseup", e => { | |
| if (e.button === 0) mouseDown = false; | |
| }); | |
| document.addEventListener("pointerlockchange", () => { | |
| pointerLocked = document.pointerLockElement === canvas; | |
| lockHint.classList.toggle("hidden", pointerLocked); | |
| canvas.style.cursor = pointerLocked ? "none" : "crosshair"; | |
| }); | |
| canvas.addEventListener("mousemove", e => { | |
| if (!pointerLocked) return; | |
| player.a += e.movementX * 0.0024; | |
| player.pitch += e.movementY * 0.7; | |
| const limit = H * 0.35; | |
| if (player.pitch > limit) player.pitch = limit; | |
| if (player.pitch < -limit) player.pitch = -limit; | |
| }); | |
| function update(dt) { | |
| if (shootCooldown > 0) shootCooldown = Math.max(0, shootCooldown - dt); | |
| if (pointerLocked && mouseDown) fireBullet(); | |
| let forward = 0; | |
| let strafe = 0; | |
| if (keys.KeyW) forward += 1; | |
| if (keys.KeyS) forward -= 1; | |
| if (keys.KeyD) strafe += 1; | |
| if (keys.KeyA) strafe -= 1; | |
| const mag = Math.hypot(forward, strafe) || 1; | |
| forward /= mag; | |
| strafe /= mag; | |
| const sin = Math.sin(player.a); | |
| const cos = Math.cos(player.a); | |
| const dx = (cos * forward - sin * strafe) * player.moveSpeed * dt; | |
| const dy = (sin * forward + cos * strafe) * player.moveSpeed * dt; | |
| moveWithSlide(dx, dy); | |
| updateBullets(dt); | |
| } | |
| function castRay(rayAngle) { | |
| return castRayFrom(player.x, player.y, rayAngle, Infinity); | |
| } | |
| function drawScene() { | |
| drawBackground(); | |
| const fov = Math.PI / 3; | |
| const columnCount = Math.max(220, Math.floor(W / 2)); | |
| const sliceW = W / columnCount; | |
| const projPlane = (W * 0.5) / Math.tan(fov * 0.5); | |
| for (let i = 0; i < columnCount; i++) { | |
| const t = i / (columnCount - 1); | |
| const rayAngle = player.a - fov * 0.5 + t * fov; | |
| const hit = castRay(rayAngle); | |
| if (!hit) continue; | |
| const corrected = Math.max(0.0001, hit.dist * Math.cos(rayAngle - player.a)); | |
| const wallH = Math.min(H * 2, projPlane / corrected); | |
| const y = (H - wallH) * 0.5 - player.pitch; | |
| const x = i * sliceW; | |
| let shade = 255 - corrected * 26; | |
| if (shade < 0) shade = 0; | |
| if (shade > 255) shade = 255; | |
| if (hit.side === 1) shade *= 0.78; | |
| ctx.fillStyle = `rgb(${shade|0},${shade|0},${shade|0})`; | |
| ctx.fillRect(x, y, sliceW + 1, wallH); | |
| ctx.fillStyle = "rgba(0,0,0,0.12)"; | |
| ctx.fillRect(x, y, 1, wallH); | |
| } | |
| drawBulletHoles(fov, projPlane); | |
| drawCrosshair(); | |
| drawMinimap(); | |
| drawBulletsOnMinimap(); | |
| } | |
| function loop(now) { | |
| requestAnimationFrame(loop); | |
| const dt = Math.min(0.05, (now - last) / 1000); | |
| last = now; | |
| accumulator += dt; | |
| while (accumulator >= fixedStep) { | |
| update(fixedStep); | |
| accumulator -= fixedStep; | |
| } | |
| if (now - lastRender < renderInterval) return; | |
| lastRender = now; | |
| drawScene(); | |
| } | |
| let last = performance.now(); | |
| let accumulator = 0; | |
| const fixedStep = 1 / 60; | |
| const renderInterval = 1000 / 30; | |
| let lastRender = 0; | |
| requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment