14. SNES · 1990
Mode 7 floor
About
For each scanline below the horizon, a distance is recovered from camera height and the line is a slice of a 2D texture. That is Mode 7 / Space Harrier: no mesh, just a perspective divide on Y. A few pylons are billboards. Move the pointer to yaw.
Move the pointer left or right to steer.
Browser APIs
- Canvas 2D
- ImageData
- Pointer Events
- requestAnimationFrame
- matchMedia
If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.
Source
(function () {
const PARAMS = {
color: "#6ee7b7",
speed: 1,
scale: 1
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [110, 231, 183];
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const canvas = document.getElementById("c");
const fallback = document.getElementById("fallback");
const ctx = canvas.getContext("2d");
if (!ctx) {
fallback.hidden = false;
fallback.textContent = "Canvas 2D is not available in this browser.";
return;
}
const buf = document.createElement("canvas");
const BW = 320, BH = 180;
buf.width = BW; buf.height = BH;
const bctx = buf.getContext("2d");
const img = bctx.createImageData(BW, BH);
const px = img.data;
let camX = 0, camZ = 0, yaw = 0;
let look = 0;
let last = 0;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const CAM_H = 1.15;
canvas.addEventListener("pointermove", function (ev) {
const r = canvas.getBoundingClientRect();
look = ((ev.clientX - r.left) / r.width - 0.5) * 1.2;
});
canvas.addEventListener("pointerleave", function () { look = 0; });
function draw() {
const rgb = hexToRgb(PARAMS.color);
const horizon = (BH * 0.42) | 0;
for (let i = 0; i < px.length; i += 4) {
const y = (i / 4 / BW) | 0;
if (y < horizon) {
const t = y / horizon;
px[i] = 28 + t * 40; px[i + 1] = 36 + t * 50; px[i + 2] = 58 + t * 40; px[i + 3] = 255;
}
}
const sunX = BW * 0.7, sunY = horizon * 0.45;
for (let y = 0; y < horizon; y++) {
for (let x = 0; x < BW; x++) {
if (Math.hypot(x - sunX, y - sunY) < 6) {
const i = (y * BW + x) * 4;
px[i] = 255; px[i + 1] = 220; px[i + 2] = 140;
}
}
}
const view = yaw + look * 0.35;
const cs = Math.cos(view), sn = Math.sin(view);
const cell = 1.35 / PARAMS.scale;
const fogFar = 42;
for (let y = horizon; y < BH; y++) {
const row = (y - horizon + 1) / BH;
const dist = CAM_H / row;
const fog = Math.max(0.08, 1 - dist / fogFar);
const halfW = dist * 0.92;
for (let x = 0; x < BW; x++) {
const across = ((x / BW) - 0.5) * 2 * halfW;
const wx = camX + cs * dist - sn * across;
const wz = camZ + sn * dist + cs * across;
const cx = Math.floor(wx / cell);
const cz = Math.floor(wz / cell);
const on = (cx + cz) & 1;
const stripe = ((cx & 7) === 0 || (cz & 7) === 0) ? 1.18 : 1;
const r = ((on ? rgb[0] : rgb[0] * 0.18) * stripe) * fog + 22 * (1 - fog);
const g = ((on ? rgb[1] : rgb[1] * 0.18) * stripe) * fog + 26 * (1 - fog);
const b = ((on ? rgb[2] : rgb[2] * 0.22) * stripe) * fog + 32 * (1 - fog);
const i = (y * BW + x) * 4;
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
const pylons = [
[4, 6], [8, -3], [-5, 10], [12, 8], [-8, -6], [2, -11], [-10, 3]
];
ctx.save();
const scaleY = canvas.height / BH;
const scaleX = canvas.width / BW;
ctx.scale(scaleX, scaleY);
for (let p = 0; p < pylons.length; p++) {
const ox = pylons[p][0] * cell - camX;
const oz = pylons[p][1] * cell - camZ;
const lz = ox * cs + oz * sn;
const lx = -ox * sn + oz * cs;
if (lz < 0.6 || lz > fogFar) continue;
const sx = BW * 0.5 + (lx / lz) * (BW * 0.5 / 0.92);
const sy = horizon + (CAM_H / lz) * BH;
const ph = (1.4 / lz) * BH;
const pw = ph * 0.18;
const fog = Math.max(0.15, 1 - lz / fogFar);
ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + fog + ")";
ctx.fillRect(sx - pw * 0.5, sy - ph, pw, ph);
}
ctx.restore();
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function frame(now) {
const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
last = now;
if (!reduced) {
yaw += look * dt * 1.6;
camX += Math.cos(yaw) * 4.2 * PARAMS.speed * dt;
camZ += Math.sin(yaw) * 4.2 * PARAMS.speed * dt;
}
resize();
draw();
if (!reduced) requestAnimationFrame(frame);
}
if (reduced) { resize(); draw(); }
else requestAnimationFrame(frame);
})();