Created
December 30, 2025 12:06
-
-
Save goldenratio/467262f8f3e1724a2d244b833cb4930c to your computer and use it in GitHub Desktop.
fake3d tank
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> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Wireframe Tank Shooter (Fake 3D)</title> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| background: #05070a; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| .hud { | |
| position: fixed; | |
| left: 12px; | |
| top: 10px; | |
| color: #b8c7d9; | |
| font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial; | |
| user-select: none; | |
| pointer-events: none; | |
| text-shadow: 0 1px 0 rgba(0, 0, 0, .6); | |
| } | |
| .hud b { | |
| color: #e8f2ff; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="c"></canvas> | |
| <div class="hud" id="hud"></div> | |
| <script> | |
| (() => { | |
| const canvas = document.getElementById('c'); | |
| const ctx = canvas.getContext('2d'); | |
| const hud = document.getElementById('hud'); | |
| const PLAYFIELD_HALF = 40; | |
| // ---------- Math helpers ---------- | |
| const TAU = Math.PI * 2; | |
| const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); | |
| const lerp = (a, b, t) => a + (b - a) * t; | |
| function v3(x = 0, y = 0, z = 0) {return {x, y, z};} | |
| function add(a, b) {return v3(a.x + b.x, a.y + b.y, a.z + b.z);} | |
| function sub(a, b) {return v3(a.x - b.x, a.y - b.y, a.z - b.z);} | |
| function mul(a, s) {return v3(a.x * s, a.y * s, a.z * s);} | |
| function dot(a, b) {return a.x * b.x + a.y * b.y + a.z * b.z;} | |
| function len(a) {return Math.hypot(a.x, a.y, a.z);} | |
| function norm(a) {const l = len(a) || 1; return v3(a.x / l, a.y / l, a.z / l);} | |
| function rotY(p, ang) { | |
| const c = Math.cos(ang), s = Math.sin(ang); | |
| return v3(p.x * c - p.z * s, p.y, p.x * s + p.z * c); | |
| } | |
| // ---------- Fake 3D camera + projection ---------- | |
| const cam = { | |
| pos: v3(0, 7, -14), | |
| yaw: 0, | |
| pitch: 0.15, | |
| fov: 520, // affects perspective strength | |
| near: 0.2 | |
| }; | |
| function worldToCamera(P) { | |
| // Translate | |
| let p = sub(P, cam.pos); | |
| // Yaw rotate (around Y) | |
| p = rotY(p, -cam.yaw); | |
| // Pitch rotate (around X) | |
| const c = Math.cos(-cam.pitch), s = Math.sin(-cam.pitch); | |
| const y = p.y * c - p.z * s; | |
| const z = p.y * s + p.z * c; | |
| p = v3(p.x, y, z); | |
| return p; | |
| } | |
| function project(P) { | |
| // P is in WORLD space | |
| const p = worldToCamera(P); | |
| // In our camera space, forward is +z | |
| if (p.z < cam.near) return null; | |
| const x = (p.x / p.z) * cam.fov + canvas.width / 2; | |
| const y = (-p.y / p.z) * cam.fov + canvas.height / 2; | |
| return {x, y, z: p.z}; | |
| } | |
| function projectCameraSpace(p) { // p is already in CAMERA space | |
| if (p.z < cam.near) return null; | |
| return { | |
| x: (p.x / p.z) * cam.fov + canvas.width / 2, | |
| y: (-p.y / p.z) * cam.fov + canvas.height / 2, | |
| z: p.z | |
| }; | |
| } | |
| function line3(aWorld, bWorld, alpha = 1) { | |
| // Convert endpoints to camera space | |
| let a = worldToCamera(aWorld); | |
| let b = worldToCamera(bWorld); | |
| // If both are behind near plane, skip | |
| if (a.z < cam.near && b.z < cam.near) return; | |
| // If one is behind, clip the segment to z = cam.near | |
| if (a.z < cam.near || b.z < cam.near) { | |
| const t = (cam.near - a.z) / (b.z - a.z); // intersection fraction along a->b | |
| const ix = a.x + (b.x - a.x) * t; | |
| const iy = a.y + (b.y - a.y) * t; | |
| const iz = cam.near; | |
| if (a.z < cam.near) a = {x: ix, y: iy, z: iz}; | |
| else b = {x: ix, y: iy, z: iz}; | |
| } | |
| const pa = projectCameraSpace(a); | |
| const pb = projectCameraSpace(b); | |
| if (!pa || !pb) return; | |
| ctx.globalAlpha = alpha; | |
| ctx.beginPath(); | |
| ctx.moveTo(pa.x, pa.y); | |
| ctx.lineTo(pb.x, pb.y); | |
| ctx.stroke(); | |
| } | |
| function polyline3(points, closed = false, alpha = 1) { | |
| const ps = points.map(project); | |
| if (ps.some(p => !p)) return; | |
| ctx.globalAlpha = alpha; | |
| ctx.beginPath(); | |
| ctx.moveTo(ps[0].x, ps[0].y); | |
| for (let i = 1; i < ps.length; i++) ctx.lineTo(ps[i].x, ps[i].y); | |
| if (closed) ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| // ---------- Input ---------- | |
| const keys = new Set(); | |
| window.addEventListener('keydown', e => { | |
| keys.add(e.code); | |
| if (e.code === 'Space') e.preventDefault(); | |
| if (e.code === 'KeyR') reset(); | |
| }); | |
| window.addEventListener('keyup', e => keys.delete(e.code)); | |
| const mouse = {x: 0, y: 0, down: false}; | |
| window.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width); | |
| mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height); | |
| }); | |
| window.addEventListener('mousedown', () => {mouse.down = true; shoot();}); | |
| window.addEventListener('mouseup', () => {mouse.down = false;}); | |
| // ---------- Game state ---------- | |
| const state = { | |
| t: 0, | |
| score: 0, | |
| hp: 100, | |
| over: false, | |
| enemies: [], | |
| bullets: [], | |
| sparks: [] | |
| }; | |
| const tank = { | |
| pos: v3(0, 0, 0), | |
| yaw: 0, // hull rotation | |
| turretYaw: 0, // turret rotation | |
| speed: 0 | |
| }; | |
| function reset() { | |
| state.t = 0; | |
| state.score = 0; | |
| state.hp = 100; | |
| state.over = false; | |
| state.enemies = []; | |
| state.bullets = []; | |
| state.sparks = []; | |
| tank.pos = v3(0, 0, 0); | |
| tank.yaw = 0; | |
| tank.turretYaw = 0; | |
| tank.speed = 0; | |
| spawnWave(6); | |
| } | |
| function spawnWave(n) { | |
| for (let i = 0; i < n; i++) { | |
| const r = 22 + Math.random() * 18; | |
| const a = Math.random() * TAU; | |
| state.enemies.push({ | |
| pos: v3(Math.cos(a) * r, 0, Math.sin(a) * r), | |
| yaw: Math.random() * TAU, | |
| hp: 30, | |
| cooldown: 0 | |
| }); | |
| } | |
| } | |
| // ---------- World helpers ---------- | |
| function groundY() {return 0;} | |
| // Ray from screen into ground plane y=0 (approx) | |
| // We invert our simple projection by constructing a camera-space ray. | |
| function screenRayToGround(sx, sy) { | |
| // Convert screen point to normalized camera space direction | |
| const nx = (sx - canvas.width / 2) / cam.fov; | |
| const ny = (sy - canvas.height / 2) / cam.fov; | |
| // In camera space, point at z=1 is (nx, ny, 1) | |
| let dir = norm(v3(nx, ny, 1)); | |
| // Undo pitch then yaw to get world-space direction | |
| // inverse of worldToCamera rotations | |
| // Pitch inverse (around X) | |
| { | |
| const c = Math.cos(cam.pitch), s = Math.sin(cam.pitch); | |
| const y = dir.y * c - dir.z * s; | |
| const z = dir.y * s + dir.z * c; | |
| dir = v3(dir.x, y, z); | |
| } | |
| // Yaw inverse (around Y) | |
| dir = rotY(dir, cam.yaw); | |
| // Ray origin is camera pos | |
| const o = cam.pos; | |
| // Intersect with plane y=0: o.y + t*dir.y = 0 | |
| const denom = dir.y; | |
| if (Math.abs(denom) < 1e-6) return null; | |
| const t = (groundY() - o.y) / denom; | |
| if (t < 0) return null; | |
| return add(o, mul(dir, t)); | |
| } | |
| // ---------- Shooting ---------- | |
| let lastShot = 0; | |
| function shoot() { | |
| if (state.over) return; | |
| const now = performance.now(); | |
| if (now - lastShot < 140) return; // fire rate | |
| lastShot = now; | |
| const muzzleLocal = v3(0, 0.65, 1.6); | |
| const muzzleWorld = add(tank.pos, rotY(muzzleLocal, tank.turretYaw)); | |
| const forward = rotY(v3(0, 0, 1), tank.turretYaw); | |
| state.bullets.push({ | |
| pos: muzzleWorld, | |
| vel: mul(forward, 28), | |
| life: 1.6 | |
| }); | |
| // little recoil sparks | |
| for (let i = 0; i < 10; i++) { | |
| state.sparks.push({ | |
| pos: muzzleWorld, | |
| vel: add(mul(forward, 8 + Math.random() * 8), v3((Math.random() - 0.5) * 2, (Math.random() * 2), (Math.random() - 0.5) * 2)), | |
| life: 0.45 | |
| }); | |
| } | |
| } | |
| // ---------- Rendering wireframe tank ---------- | |
| function drawTank(pos, hullYaw, turretYaw, alpha = 1) { | |
| ctx.lineWidth = 1.5; | |
| // Hull box | |
| const hull = { | |
| w: 1.9, h: 0.6, l: 2.6 | |
| }; | |
| const base = [ | |
| v3(-hull.w / 2, 0, -hull.l / 2), | |
| v3(hull.w / 2, 0, -hull.l / 2), | |
| v3(hull.w / 2, 0, hull.l / 2), | |
| v3(-hull.w / 2, 0, hull.l / 2), | |
| ]; | |
| const top = base.map(p => v3(p.x, p.y + hull.h, p.z)); | |
| const baseW = base.map(p => add(pos, rotY(p, hullYaw))); | |
| const topW = top.map(p => add(pos, rotY(p, hullYaw))); | |
| polyline3(baseW, true, alpha); | |
| polyline3(topW, true, alpha); | |
| for (let i = 0; i < 4; i++) line3(baseW[i], topW[i], alpha); | |
| // Treads (simple rectangles on sides) | |
| const treadOffset = hull.w / 2 + 0.25; | |
| const treadL = hull.l * 0.95; | |
| const treadH = 0.45; | |
| function tread(side) { | |
| const x = side * treadOffset; | |
| const b = [ | |
| v3(x - 0.18, 0, -treadL / 2), | |
| v3(x + 0.18, 0, -treadL / 2), | |
| v3(x + 0.18, 0, treadL / 2), | |
| v3(x - 0.18, 0, treadL / 2), | |
| ].map(p => add(pos, rotY(p, hullYaw))); | |
| const t = b.map(p => add(p, rotY(v3(0, treadH, 0), hullYaw))); | |
| polyline3(b, true, alpha * 0.9); | |
| polyline3(t, true, alpha * 0.9); | |
| for (let i = 0; i < 4; i++) line3(b[i], t[i], alpha * 0.9); | |
| } | |
| tread(-1); tread(1); | |
| // Turret (smaller box) | |
| const turret = {w: 1.2, h: 0.35, l: 1.3}; | |
| const turretPos = add(pos, rotY(v3(0, hull.h + 0.05, 0.1), hullYaw)); | |
| const tbase = [ | |
| v3(-turret.w / 2, 0, -turret.l / 2), | |
| v3(turret.w / 2, 0, -turret.l / 2), | |
| v3(turret.w / 2, 0, turret.l / 2), | |
| v3(-turret.w / 2, 0, turret.l / 2), | |
| ]; | |
| const ttop = tbase.map(p => v3(p.x, p.y + turret.h, p.z)); | |
| const tbaseW = tbase.map(p => add(turretPos, rotY(p, turretYaw))); | |
| const ttopW = ttop.map(p => add(turretPos, rotY(p, turretYaw))); | |
| polyline3(tbaseW, true, alpha); | |
| polyline3(ttopW, true, alpha); | |
| for (let i = 0; i < 4; i++) line3(tbaseW[i], ttopW[i], alpha); | |
| // Cannon | |
| const c0 = add(turretPos, rotY(v3(0, turret.h * 0.65, turret.l / 2), turretYaw)); | |
| const c1 = add(turretPos, rotY(v3(0, turret.h * 0.65, turret.l / 2 + 1.6), turretYaw)); | |
| const c2 = add(turretPos, rotY(v3(0, turret.h * 0.65 + 0.08, turret.l / 2 + 1.6), turretYaw)); | |
| line3(c0, c1, alpha); | |
| line3(c1, c2, alpha * 0.85); | |
| } | |
| function drawEnemy(e) { | |
| // enemy tank-ish wireframe | |
| ctx.lineWidth = 1.4; | |
| drawTank(e.pos, e.yaw, e.yaw, 0.85); | |
| } | |
| function drawBullet(b) { | |
| ctx.lineWidth = 1.2; | |
| const p0 = b.pos; | |
| const p1 = sub(b.pos, mul(norm(b.vel), 0.5)); | |
| line3(p1, p0, 0.9); | |
| } | |
| function drawSpark(s) { | |
| ctx.lineWidth = 1.0; | |
| const p1 = sub(s.pos, mul(norm(s.vel), 0.2)); | |
| line3(p1, s.pos, 0.5); | |
| } | |
| function drawGroundGrid() { | |
| ctx.lineWidth = 1.0; | |
| const step = 2; | |
| const radius = 40; | |
| // snap grid origin to world-space multiples | |
| const ox = Math.floor(tank.pos.x / step) * step; | |
| const oz = Math.floor(tank.pos.z / step) * step; | |
| for (let x = -radius; x <= radius; x += step) { | |
| const a = v3(ox + x, 0, oz - radius); | |
| const b = v3(ox + x, 0, oz + radius); | |
| line3(a, b, 0.35); | |
| } | |
| for (let z = -radius; z <= radius; z += step) { | |
| const a = v3(ox - radius, 0, oz + z); | |
| const b = v3(ox + radius, 0, oz + z); | |
| line3(a, b, 0.35); | |
| } | |
| } | |
| function drawCrosshair() { | |
| // simple 2D overlay crosshair | |
| const hit = screenRayToGround(mouse.x, mouse.y); | |
| if (!hit) return; | |
| const p = project(hit); | |
| if (!p) return; | |
| ctx.save(); | |
| ctx.globalAlpha = 0.8; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, 8, 0, TAU); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x - 14, p.y); | |
| ctx.lineTo(p.x - 5, p.y); | |
| ctx.moveTo(p.x + 5, p.y); | |
| ctx.lineTo(p.x + 14, p.y); | |
| ctx.moveTo(p.x, p.y - 14); | |
| ctx.lineTo(p.x, p.y - 5); | |
| ctx.moveTo(p.x, p.y + 5); | |
| ctx.lineTo(p.x, p.y + 14); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function keepEnemyInBounds(e) { | |
| // If outside, push back and reflect facing direction | |
| if (e.pos.x < -PLAYFIELD_HALF) {e.pos.x = -PLAYFIELD_HALF; e.yaw = Math.PI - e.yaw;} | |
| if (e.pos.x > PLAYFIELD_HALF) {e.pos.x = PLAYFIELD_HALF; e.yaw = Math.PI - e.yaw;} | |
| if (e.pos.z < -PLAYFIELD_HALF) {e.pos.z = -PLAYFIELD_HALF; e.yaw = -e.yaw;} | |
| if (e.pos.z > PLAYFIELD_HALF) {e.pos.z = PLAYFIELD_HALF; e.yaw = -e.yaw;} | |
| } | |
| // ---------- Simulation ---------- | |
| function update(dt) { | |
| if (state.over) return; | |
| // Aim turret at mouse-ground intersection | |
| const aimPoint = screenRayToGround(mouse.x, mouse.y); | |
| if (aimPoint) { | |
| const to = sub(aimPoint, tank.pos); | |
| tank.turretYaw = Math.atan2(to.x, to.z); | |
| } | |
| // Drive controls | |
| const forward = keys.has('KeyW') ? 1 : 0; | |
| const back = keys.has('KeyS') ? 1 : 0; | |
| const left = keys.has('KeyA') ? 1 : 0; | |
| const right = keys.has('KeyD') ? 1 : 0; | |
| const targetSpeed = (forward - back) * 8; | |
| tank.speed = lerp(tank.speed, targetSpeed, 1 - Math.pow(0.001, dt)); | |
| const turn = (right - left) * (0.9 + 0.15 * Math.abs(tank.speed)); | |
| tank.yaw += turn * dt; | |
| const fwd = rotY(v3(0, 0, 1), tank.yaw); | |
| tank.pos = add(tank.pos, mul(fwd, tank.speed * dt)); | |
| // Shoot via space | |
| if (keys.has('Space')) shoot(); | |
| // Camera follows behind tank (third-person) | |
| { | |
| const behind = rotY(v3(0, 0, -1), tank.yaw); | |
| cam.pos = add(tank.pos, add(mul(behind, 12.5), v3(0, 7.5, 0))); | |
| cam.yaw = tank.yaw; | |
| cam.pitch = 0.22; | |
| } | |
| // Bullets | |
| for (const b of state.bullets) { | |
| b.pos = add(b.pos, mul(b.vel, dt)); | |
| b.life -= dt; | |
| } | |
| state.bullets = state.bullets.filter(b => b.life > 0); | |
| // Sparks | |
| for (const s of state.sparks) { | |
| s.pos = add(s.pos, mul(s.vel, dt)); | |
| s.vel.y -= 10 * dt; | |
| s.life -= dt; | |
| } | |
| state.sparks = state.sparks.filter(s => s.life > 0); | |
| // Enemies | |
| for (const e of state.enemies) { | |
| // steer toward player | |
| const toP = sub(tank.pos, e.pos); | |
| const d = Math.hypot(toP.x, toP.z); | |
| const desired = Math.atan2(toP.x, toP.z); | |
| let da = ((desired - e.yaw + Math.PI) % TAU) - Math.PI; | |
| e.yaw += clamp(da, -1.6 * dt, 1.6 * dt); | |
| // move | |
| const ef = rotY(v3(0, 0, 1), e.yaw); | |
| const sp = d > 3 ? 3.2 : 0.8; | |
| e.pos = add(e.pos, mul(ef, sp * dt)); | |
| keepEnemyInBounds(e); | |
| // enemy fire | |
| e.cooldown -= dt; | |
| if (d < 18 && e.cooldown <= 0) { | |
| e.cooldown = 1.2 + Math.random() * 0.7; | |
| // shoot a slower projectile at player | |
| const muzzle = add(e.pos, rotY(v3(0, 0.65, 1.6), e.yaw)); | |
| const dir = norm(v3(toP.x, 0, toP.z)); | |
| state.bullets.push({ | |
| pos: muzzle, | |
| vel: mul(dir, 18), | |
| life: 1.9, | |
| enemy: true | |
| }); | |
| } | |
| } | |
| // Collisions bullet vs enemies / player | |
| for (const b of state.bullets) { | |
| if (b.enemy) { | |
| // hit player | |
| const d = Math.hypot(b.pos.x - tank.pos.x, b.pos.z - tank.pos.z); | |
| if (d < 1.3) { | |
| b.life = -1; | |
| state.hp -= 12; | |
| for (let i = 0; i < 18; i++) { | |
| state.sparks.push({ | |
| pos: add(tank.pos, v3((Math.random() - 0.5) * 1.0, 0.6 + Math.random() * 0.6, (Math.random() - 0.5) * 1.0)), | |
| vel: v3((Math.random() - 0.5) * 6, (Math.random() * 7), (Math.random() - 0.5) * 6), | |
| life: 0.55 | |
| }); | |
| } | |
| } | |
| } else { | |
| // hit enemies | |
| for (const e of state.enemies) { | |
| const d = Math.hypot(b.pos.x - e.pos.x, b.pos.z - e.pos.z); | |
| if (d < 1.3) { | |
| b.life = -1; | |
| e.hp -= 18; | |
| for (let i = 0; i < 18; i++) { | |
| state.sparks.push({ | |
| pos: add(e.pos, v3((Math.random() - 0.5) * 1.0, 0.6 + Math.random() * 0.6, (Math.random() - 0.5) * 1.0)), | |
| vel: v3((Math.random() - 0.5) * 6, (Math.random() * 7), (Math.random() - 0.5) * 6), | |
| life: 0.55 | |
| }); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| state.bullets = state.bullets.filter(b => b.life > 0); | |
| // Remove dead enemies, add score | |
| const before = state.enemies.length; | |
| state.enemies = state.enemies.filter(e => e.hp > 0); | |
| const killed = before - state.enemies.length; | |
| if (killed > 0) state.score += killed * 100; | |
| // Spawn more if empty | |
| if (state.enemies.length === 0) { | |
| spawnWave(6 + Math.floor(state.score / 400)); | |
| } | |
| // Game over | |
| if (state.hp <= 0) { | |
| state.hp = 0; | |
| state.over = true; | |
| } | |
| } | |
| // ---------- Render ---------- | |
| function render() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Wireframe aesthetic | |
| ctx.strokeStyle = '#d7f1ff'; | |
| ctx.fillStyle = '#d7f1ff'; | |
| // Subtle background gradient | |
| const g = ctx.createLinearGradient(0, 0, 0, canvas.height); | |
| g.addColorStop(0, '#060a10'); | |
| g.addColorStop(1, '#020305'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.strokeStyle = 'rgba(200,240,255,0.95)'; | |
| drawGroundGrid(); | |
| // Sort objects by depth (painter-ish for nicer overlaps) | |
| const drawables = []; | |
| drawables.push({zHint: len(sub(tank.pos, cam.pos)), fn: () => drawTank(tank.pos, tank.yaw, tank.turretYaw, 1)}); | |
| for (const e of state.enemies) { | |
| const zHint = len(sub(e.pos, cam.pos)); | |
| drawables.push({zHint, fn: () => drawEnemy(e)}); | |
| } | |
| for (const b of state.bullets) { | |
| const zHint = len(sub(b.pos, cam.pos)); | |
| drawables.push({zHint, fn: () => drawBullet(b)}); | |
| } | |
| for (const s of state.sparks) { | |
| const zHint = len(sub(s.pos, cam.pos)); | |
| drawables.push({zHint, fn: () => drawSpark(s)}); | |
| } | |
| drawables.sort((a, b) => b.zHint - a.zHint); | |
| for (const d of drawables) d.fn(); | |
| drawCrosshair(); | |
| // HUD | |
| hud.innerHTML = | |
| `<b>Wireframe Tank Shooter</b><br>` + | |
| `HP: <b>${state.hp}</b> Score: <b>${state.score}</b> Enemies: <b>${state.enemies.length}</b><br>` + | |
| `WASD move • Mouse aim • Click/Space shoot • R restart` + | |
| (state.over ? `<br><br><b>GAME OVER</b> — press <b>R</b>` : ``); | |
| // vignette | |
| ctx.save(); | |
| ctx.globalAlpha = 0.18; | |
| ctx.lineWidth = 10; | |
| ctx.strokeStyle = 'rgba(0,0,0,0.8)'; | |
| ctx.strokeRect(0, 0, canvas.width, canvas.height); | |
| ctx.restore(); | |
| } | |
| // ---------- Loop + resize ---------- | |
| function resize() { | |
| const dpr = Math.max(1, Math.min(2, devicePixelRatio || 1)); | |
| canvas.width = Math.floor(innerWidth * dpr); | |
| canvas.height = Math.floor(innerHeight * dpr); | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.scale(1, 1); | |
| // keep line widths consistent-ish in HiDPI: | |
| ctx.lineJoin = 'round'; | |
| ctx.lineCap = 'round'; | |
| } | |
| window.addEventListener('resize', resize); | |
| let last = performance.now(); | |
| function frame(now) { | |
| const dt = clamp((now - last) / 1000, 0, 0.033); | |
| last = now; | |
| state.t += dt; | |
| update(dt); | |
| render(); | |
| requestAnimationFrame(frame); | |
| } | |
| resize(); | |
| reset(); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment