| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
import { |
| 16 |
castRay, |
| 17 |
columnOffset, |
| 18 |
hitResult, |
| 19 |
project, |
| 20 |
projection, |
| 21 |
projectResult, |
| 22 |
sliceHeight, |
| 23 |
smokeAlong, |
| 24 |
spriteColumnVisible, |
| 25 |
wallLight, |
| 26 |
} from "./raycast.js"; |
| 27 |
|
| 28 |
const EYE = 40; |
| 29 |
const WALL = 80; |
| 30 |
const BODY = 58; |
| 31 |
const MAX_SIGHT = 1800; |
| 32 |
const LEVELS = 48; |
| 33 |
|
| 34 |
export const AGENT_COLOUR = { |
| 35 |
ember: "#f08a4b", |
| 36 |
veil: "#8f7fe8", |
| 37 |
sable: "#4fd6a0", |
| 38 |
mender: "#e0c060", |
| 39 |
rook: "#d96a8f", |
| 40 |
}; |
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
const SHADES = (() => { |
| 46 |
const out = []; |
| 47 |
for (let i = 0; i < LEVELS; i++) { |
| 48 |
const k = (i + 0.5) / LEVELS; |
| 49 |
out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); |
| 50 |
} |
| 51 |
return out; |
| 52 |
})(); |
| 53 |
const FOG = "rgb(198,203,212)"; |
| 54 |
|
| 55 |
export function makeScene() { |
| 56 |
return { |
| 57 |
depth: new Float32Array(0), |
| 58 |
fog: new Float32Array(0), |
| 59 |
hit: hitResult(), |
| 60 |
proj: projectResult(), |
| 61 |
order: [], |
| 62 |
smokes: [], |
| 63 |
sky: null, |
| 64 |
skyKey: "", |
| 65 |
}; |
| 66 |
} |
| 67 |
|
| 68 |
const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))]; |
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
export function drawScene(ctx, scene, world, eye, snap, view) { |
| 79 |
const W = view.width; |
| 80 |
const H = view.height; |
| 81 |
const step = W > 1100 ? 2 : 1; |
| 82 |
const columns = Math.ceil(W / step); |
| 83 |
if (scene.depth.length !== columns) { |
| 84 |
scene.depth = new Float32Array(columns); |
| 85 |
scene.fog = new Float32Array(columns); |
| 86 |
} |
| 87 |
const proj = projection(W); |
| 88 |
const horizon = H / 2; |
| 89 |
|
| 90 |
drawSky(ctx, scene, W, H, horizon); |
| 91 |
|
| 92 |
const smokes = scene.smokes; |
| 93 |
smokes.length = 0; |
| 94 |
for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); |
| 95 |
|
| 96 |
const depth = scene.depth; |
| 97 |
const fog = scene.fog; |
| 98 |
const hit = scene.hit; |
| 99 |
for (let i = 0; i < columns; i++) { |
| 100 |
const offset = columnOffset(i * step + step / 2, W); |
| 101 |
const angle = eye.angle + offset; |
| 102 |
castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); |
| 103 |
const perp = Math.max(1, hit.dist * Math.cos(offset)); |
| 104 |
depth[i] = perp; |
| 105 |
let haze = 0; |
| 106 |
if (smokes.length) { |
| 107 |
const dx = Math.cos(angle); |
| 108 |
const dy = Math.sin(angle); |
| 109 |
for (let s = 0; s < smokes.length; s++) { |
| 110 |
const z = smokes[s]; |
| 111 |
haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); |
| 112 |
if (haze >= 1) break; |
| 113 |
} |
| 114 |
haze = Math.min(1, haze); |
| 115 |
} |
| 116 |
fog[i] = haze; |
| 117 |
if (!hit.wall) continue; |
| 118 |
const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); |
| 119 |
const top = horizon - h / 2; |
| 120 |
ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile)); |
| 121 |
ctx.fillRect(i * step, top, step + 1, h); |
| 122 |
if (haze > 0.01) { |
| 123 |
ctx.globalAlpha = haze; |
| 124 |
ctx.fillStyle = FOG; |
| 125 |
ctx.fillRect(i * step, top, step + 1, h); |
| 126 |
ctx.globalAlpha = 1; |
| 127 |
} |
| 128 |
} |
| 129 |
|
| 130 |
drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
function drawSky(ctx, scene, W, H, horizon) { |
| 135 |
const key = `${W}x${H}`; |
| 136 |
if (scene.skyKey !== key || !scene.sky) { |
| 137 |
const sky = ctx.createLinearGradient(0, 0, 0, H); |
| 138 |
sky.addColorStop(0, "#0a0c11"); |
| 139 |
sky.addColorStop(0.49, "#23262f"); |
| 140 |
sky.addColorStop(0.5, "#3a3026"); |
| 141 |
sky.addColorStop(1, "#15120e"); |
| 142 |
scene.sky = sky; |
| 143 |
scene.skyKey = key; |
| 144 |
} |
| 145 |
ctx.fillStyle = scene.sky; |
| 146 |
ctx.fillRect(0, 0, W, H); |
| 147 |
ctx.fillStyle = "rgba(255,255,255,0.09)"; |
| 148 |
ctx.fillRect(0, horizon - 1, W, 2); |
| 149 |
} |
| 150 |
|
| 151 |
|
| 152 |
|
| 153 |
|
| 154 |
|
| 155 |
function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) { |
| 156 |
const W = view.width; |
| 157 |
const things = scene.order; |
| 158 |
things.length = 0; |
| 159 |
|
| 160 |
for (const zone of snap.zones) { |
| 161 |
if (zone.k === "smoke") continue; |
| 162 |
things.push({ kind: "zone", zone, x: zone.x, y: zone.y }); |
| 163 |
} |
| 164 |
for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y }); |
| 165 |
if (snap.spike.x !== null && !snap.spike.carried) |
| 166 |
things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y }); |
| 167 |
for (const player of snap.players) { |
| 168 |
if (player.id === snap.me.id) continue; |
| 169 |
const at = view.look(player); |
| 170 |
things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle }); |
| 171 |
} |
| 172 |
|
| 173 |
const p = scene.proj; |
| 174 |
for (const thing of things) { |
| 175 |
project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); |
| 176 |
thing.perp = p.perp; |
| 177 |
thing.column = p.column; |
| 178 |
thing.size = p.size; |
| 179 |
thing.behind = p.behind; |
| 180 |
} |
| 181 |
things.sort((a, b) => b.perp - a.perp); |
| 182 |
|
| 183 |
for (const thing of things) { |
| 184 |
if (thing.behind) continue; |
| 185 |
const floorY = horizon + (EYE * proj) / thing.perp; |
| 186 |
const haze = hazeAt(scene, thing.column, step, columns); |
| 187 |
ctx.globalAlpha = Math.max(0, 1 - haze); |
| 188 |
if (ctx.globalAlpha < 0.05) continue; |
| 189 |
if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); |
| 190 |
else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); |
| 191 |
else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); |
| 192 |
else drawZone(ctx, scene, thing, floorY, proj, step, columns); |
| 193 |
ctx.globalAlpha = 1; |
| 194 |
} |
| 195 |
ctx.globalAlpha = 1; |
| 196 |
|
| 197 |
drawTracers(ctx, scene, eye, snap, W, proj, horizon); |
| 198 |
} |
| 199 |
|
| 200 |
const hazeAt = (scene, column, step, columns) => { |
| 201 |
const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); |
| 202 |
return scene.fog[i] ?? 0; |
| 203 |
}; |
| 204 |
|
| 205 |
|
| 206 |
|
| 207 |
|
| 208 |
|
| 209 |
|
| 210 |
function withRuns(ctx, scene, left, right, perp, step, columns, draw) { |
| 211 |
let runStart = -1; |
| 212 |
const from = Math.max(0, Math.floor(left / step)); |
| 213 |
const to = Math.min(columns - 1, Math.ceil(right / step)); |
| 214 |
for (let i = from; i <= to + 1; i++) { |
| 215 |
const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); |
| 216 |
if (shown && runStart < 0) runStart = i; |
| 217 |
if (!shown && runStart >= 0) { |
| 218 |
ctx.save(); |
| 219 |
ctx.beginPath(); |
| 220 |
ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); |
| 221 |
ctx.clip(); |
| 222 |
draw(); |
| 223 |
ctx.restore(); |
| 224 |
runStart = -1; |
| 225 |
} |
| 226 |
} |
| 227 |
} |
| 228 |
|
| 229 |
function drawBody(ctx, scene, thing, floorY, step, columns) { |
| 230 |
const p = thing.player; |
| 231 |
const h = thing.size; |
| 232 |
const w = h * 0.46; |
| 233 |
const cx = thing.column; |
| 234 |
const top = floorY - h; |
| 235 |
const mate = p.mate; |
| 236 |
const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f"; |
| 237 |
const trim = AGENT_COLOUR[p.agent] || "#999"; |
| 238 |
|
| 239 |
withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => { |
| 240 |
|
| 241 |
ctx.fillStyle = skin; |
| 242 |
ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); |
| 243 |
ctx.fillStyle = trim; |
| 244 |
ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); |
| 245 |
ctx.beginPath(); |
| 246 |
ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); |
| 247 |
ctx.fillStyle = skin; |
| 248 |
ctx.fill(); |
| 249 |
ctx.lineWidth = Math.max(1, h * 0.03); |
| 250 |
ctx.strokeStyle = trim; |
| 251 |
ctx.stroke(); |
| 252 |
if (!p.alive) return; |
| 253 |
if (p.spike) { |
| 254 |
ctx.fillStyle = "#f0d060"; |
| 255 |
ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); |
| 256 |
} |
| 257 |
if (p.hp < 100) { |
| 258 |
ctx.fillStyle = "#000a"; |
| 259 |
ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); |
| 260 |
ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; |
| 261 |
ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); |
| 262 |
} |
| 263 |
|
| 264 |
if (mate) { |
| 265 |
const size = Math.max(9, Math.min(18, h * 0.16)); |
| 266 |
ctx.font = `${size}px system-ui, sans-serif`; |
| 267 |
ctx.textAlign = "center"; |
| 268 |
ctx.fillStyle = "rgba(10,12,16,0.65)"; |
| 269 |
const tw = ctx.measureText(p.name).width + 8; |
| 270 |
ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4); |
| 271 |
ctx.fillStyle = "#cfe8ff"; |
| 272 |
ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05); |
| 273 |
ctx.textAlign = "left"; |
| 274 |
} |
| 275 |
}); |
| 276 |
} |
| 277 |
|
| 278 |
function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { |
| 279 |
const h = thing.size * 0.42; |
| 280 |
const cx = thing.column; |
| 281 |
const planted = snap.spike.planted; |
| 282 |
const blink = planted && Math.floor(Date.now() / 220) % 2 === 0; |
| 283 |
withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => { |
| 284 |
ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c"; |
| 285 |
ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h); |
| 286 |
ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418"; |
| 287 |
ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22); |
| 288 |
}); |
| 289 |
} |
| 290 |
|
| 291 |
function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) { |
| 292 |
const r = thing.size * scale; |
| 293 |
const cx = thing.column; |
| 294 |
withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => { |
| 295 |
ctx.fillStyle = colour; |
| 296 |
ctx.beginPath(); |
| 297 |
ctx.arc(cx, floorY - r, r, 0, 7); |
| 298 |
ctx.fill(); |
| 299 |
}); |
| 300 |
} |
| 301 |
|
| 302 |
function drawZone(ctx, scene, thing, floorY, proj, step, columns) { |
| 303 |
const z = thing.zone; |
| 304 |
const wide = (z.r * proj) / thing.perp; |
| 305 |
const cx = thing.column; |
| 306 |
if (z.k === "wall") { |
| 307 |
const h = (WALL * proj) / thing.perp; |
| 308 |
withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { |
| 309 |
ctx.fillStyle = "rgba(120,200,255,0.45)"; |
| 310 |
ctx.fillRect(cx - wide, floorY - h, wide * 2, h); |
| 311 |
ctx.strokeStyle = "rgba(180,225,255,0.8)"; |
| 312 |
ctx.strokeRect(cx - wide, floorY - h, wide * 2, h); |
| 313 |
}); |
| 314 |
return; |
| 315 |
} |
| 316 |
const colour = |
| 317 |
z.k === "molly" ? "rgba(235,120,50,0.6)" : z.k === "flash" ? "rgba(255,235,170,0.9)" : z.k === "heal" ? "rgba(120,230,140,0.55)" : z.k === "trip" ? "rgba(230,200,90,0.75)" : "rgba(90,220,170,0.5)"; |
| 318 |
withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { |
| 319 |
ctx.fillStyle = colour; |
| 320 |
ctx.beginPath(); |
| 321 |
ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7); |
| 322 |
ctx.fill(); |
| 323 |
}); |
| 324 |
} |
| 325 |
|
| 326 |
|
| 327 |
function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { |
| 328 |
if (!snap.shots.length) return; |
| 329 |
const p = scene.proj; |
| 330 |
ctx.lineWidth = 2; |
| 331 |
for (const shot of snap.shots) { |
| 332 |
project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); |
| 333 |
if (p.behind) continue; |
| 334 |
const x1 = p.column; |
| 335 |
const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; |
| 336 |
project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); |
| 337 |
if (p.behind) continue; |
| 338 |
ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; |
| 339 |
ctx.beginPath(); |
| 340 |
ctx.moveTo(x1, y1); |
| 341 |
ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); |
| 342 |
ctx.stroke(); |
| 343 |
} |
| 344 |
} |