Files
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

159 lines
7.4 KiB
JavaScript

// AsciiRenderer: draws a Scene as a monospace glyph grid on a 2D canvas.
// The always-available fallback tier: tiny, legible, accessible.
(function (root) {
'use strict';
function AsciiRenderer(host, opts) {
opts = opts || {};
this.name = 'ascii';
this.host = host;
this.cell = opts.cell || 22;
this.canvas = document.createElement('canvas');
this.canvas.className = 'rk-canvas';
this.ctx = this.canvas.getContext('2d');
host.appendChild(this.canvas);
}
AsciiRenderer.prototype.render = function (scene) {
if (!scene) return;
this.lastScene = scene;
var cell = this.cell, ctx = this.ctx, now = Date.now(), hasTorch = false;
this.canvas.width = scene.cols * cell;
this.canvas.height = scene.rows * cell;
ctx.fillStyle = scene.background || '#000';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
var glyphFont = (cell - 4) + "px 'Courier New', monospace";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = glyphFont;
// For a designed room, ASCII shows a floor-plan from the walkability grid.
var room = (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) ? RK.activeRoom.desc : null;
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
if (room) {
var walk = room.walkable[y] && room.walkable[y][x];
ctx.fillStyle = walk ? '#2f4030' : '#39404c';
ctx.fillText(walk ? '·' : '#', x * cell + cell / 2, y * cell + cell / 2);
} else {
var kind = scene.grid[y][x];
var def = scene.legend[kind] || { char: '?', color: '#555' };
var gx = x * cell + cell / 2, gy = y * cell + cell / 2;
if (def.fx === 'fire') {
// Torches are ACTUAL light sources: a warm radial glow behind the glyph that
// gently flickers; the glyph itself stays at full brightness (never dimmed).
hasTorch = true;
var seed = x * 7 + y * 13;
var fl = 0.7 + Math.sin(now / 170 + seed) * 0.3;
var rad = cell * (1.9 + Math.sin(now / 240 + seed) * 0.18);
var g = ctx.createRadialGradient(gx, gy, 0, gx, gy, rad);
g.addColorStop(0, 'rgba(255,196,96,' + (0.6 * fl) + ')');
g.addColorStop(0.45, 'rgba(255,140,44,' + (0.24 * fl) + ')');
g.addColorStop(1, 'rgba(255,120,30,0)');
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(gx - rad, gy - rad, rad * 2, rad * 2);
ctx.fillStyle = '#ffd9a0';
ctx.fillText(def.char, gx, gy);
} else {
// Brighter glyph colours for readability: the dungeon legend colours are
// near-black (tuned for TILE fog, not glyphs), which made ASCII walls/floors
// almost invisible. Features (entrance/exit/treasure) keep their own colours.
ctx.fillStyle = kind === 'wall' ? '#8b95a6'
: (kind === 'floor' || kind === 'floor2') ? '#5b6472'
: def.color;
// Dungeon lighting: dim tiles in shadow.
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
var b = scene.lightGrid[y][x];
if (b < 1) ctx.globalAlpha = b;
}
ctx.fillText(def.char, gx, gy);
ctx.globalAlpha = 1;
}
}
}
}
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2;
ctx.font = glyphFont;
ctx.fillStyle = e.color || '#fff';
// Dungeon features and entities use their own char/color.
if (e.kind === 'feature' || e.kind === 'item' || e.kind === 'monster') {
ctx.fillStyle = e.color || '#fff';
ctx.fillText(e.char || '?', cx, cy);
if (e.you) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(e.x * cell + 2, e.y * cell + 2, cell - 4, cell - 4);
}
if (e.label) {
ctx.font = '10px monospace';
ctx.fillStyle = '#d7dbe0';
ctx.fillText(e.label, cx, e.y * cell - 1);
}
continue;
}
ctx.fillStyle = e.color || '#fff';
ctx.fillText(e.char || '@', cx, cy);
if (e.you) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(e.x * cell + 2, e.y * cell + 2, cell - 4, cell - 4);
}
if (e.label) {
ctx.font = '10px monospace';
ctx.fillStyle = '#d7dbe0';
ctx.fillText(e.label, cx, e.y * cell - 1);
}
}
// Camera focus: centre the SP camera on the player cell (same convention as the tiled
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
var focusFallback = null;
for (var pj = 0; pj < scene.entities.length; pj++) {
var pe = scene.entities[pj];
if (pe.kind === 'player' && !focusFallback) focusFallback = pe;
if (pe.you || pe.cameraTarget) {
this.focusPoint = { x: pe.x * cell + cell / 2, y: pe.y * cell + cell / 2 };
focusFallback = null;
break;
}
}
if (focusFallback) this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
// Keep a light RAF alive so the torch glows flicker between game updates (~22fps).
this._hasTorches = hasTorch;
if (hasTorch) this._scheduleFlicker();
};
// Re-render on a throttled RAF so torch glows breathe even when the game isn't updating.
AsciiRenderer.prototype._scheduleFlicker = function () {
if (this._flickerRaf || !this._hasTorches || !this.ctx) return;
var self = this;
this._flickerRaf = requestAnimationFrame(function () {
self._flickerRaf = null;
if (!self.ctx || !self._hasTorches || !self.lastScene) return;
var t = Date.now();
if (self._lastFlick && t - self._lastFlick < 45) { self._scheduleFlicker(); return; }
self._lastFlick = t;
self.render(self.lastScene);
});
};
AsciiRenderer.prototype.destroy = function () {
this._hasTorches = false;
if (this._flickerRaf) { cancelAnimationFrame(this._flickerRaf); this._flickerRaf = null; }
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
this.canvas = null;
this.ctx = null;
};
root.RK = root.RK || {};
root.RK.AsciiRenderer = AsciiRenderer;
})(window);