mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
feat: lively tavern (NPCs + speech bubbles), spectator camera, dungeon render adapter
- tavernLife.js: client-side NPCs (bartender, patrons, cat) with autonomous
movement, random speech bubbles, and real-player chat bubbles
- T2b: spectator camera — watch live dungeon runs from inside the tavern
(game list, spectate_start/update, ESC to leave)
- R1: sceneFromGameState adapter — converts dungeon game state to render-kit
Scene with lighting grid and entity mapping
- R2: pre-populated Kenney roguelikeSheet tile defaults for the tiled renderer
- Dungeon renderer support: TileRenderer + AsciiRenderer handle lightGrid,
dungeon features/items/monster entities
- 3 new tests (242 total, 36 suites, all passing)
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
|
||||
ctx.font = glyphFont;
|
||||
// For a designed room, ASCII shows a floor-plan from the walkability grid.
|
||||
var room = (window.RK && RK.roomReady && RK.roomReady()) ? RK.activeRoom.desc : null;
|
||||
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) {
|
||||
@@ -39,7 +39,13 @@
|
||||
} else {
|
||||
var def = scene.legend[scene.grid[y][x]] || { char: '?', color: '#555' };
|
||||
ctx.fillStyle = 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, x * cell + cell / 2, y * cell + cell / 2);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +55,23 @@
|
||||
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';
|
||||
|
||||
@@ -109,9 +109,15 @@
|
||||
'roguelike-interior': {
|
||||
id: 'roguelike-interior',
|
||||
label: 'Kenney Roguelike Interior',
|
||||
tileset: { url: 'assets/kenney/roguelikeIndoor.png', tile: 16, spacing: 1 },
|
||||
// Defaults are null until mapped via the tile-picker (localStorage overrides win).
|
||||
tiles: { floor: null, wall: null, bar: null, table: null }
|
||||
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
|
||||
// Default tile picks from the sheet metadata ( roguelikeSheet_meta.json).
|
||||
// The tile-picker overlay can override these per-user via localStorage.
|
||||
tiles: {
|
||||
floor: [5, 2], // tile 119 — floor_wood
|
||||
wall: [13, 12], // tile 697 — stone wall
|
||||
bar: [28, 0], // tile 28 — counter
|
||||
table: [20, 3] // tile 191 — table
|
||||
}
|
||||
}
|
||||
};
|
||||
RK.activeThemeId = 'roguelike-interior';
|
||||
|
||||
@@ -74,6 +74,143 @@
|
||||
AVATAR_COLORS: AVATAR_COLORS
|
||||
};
|
||||
|
||||
// ---- Dungeon game-state adapter ------------------------------------------------
|
||||
// Converts a game state (from the server's game_start/game_update/spectator_update) into a
|
||||
// renderer-agnostic Scene, so the render kit can draw the dungeon just like the tavern.
|
||||
// This is the bridge for milestone R1 (main game → render kit) and T2b (tavern spectator).
|
||||
|
||||
var DUNGEON_LEGEND = {
|
||||
wall: { char: '#', color: '#2a2f38', solid: true },
|
||||
floor: { char: '·', color: '#15191f', solid: false },
|
||||
floor2: { char: '·', color: '#181c22', solid: false },
|
||||
entrance: { char: '<', color: '#3fb950', solid: false },
|
||||
exit: { char: '>', color: '#d29922', solid: false },
|
||||
treasure: { char: '$', color: '#fbbf24', solid: false },
|
||||
torch: { char: '#', color: '#3a3028', solid: true },
|
||||
dark: { char: ' ', color: '#0a0c0f', solid: true }
|
||||
};
|
||||
|
||||
// Map dungeon tile characters to scene tile kinds.
|
||||
function dungeonTileKind(ch) {
|
||||
if (ch === '#') return 'wall';
|
||||
if (ch === 'torch') return 'torch';
|
||||
if (ch === "'1" || ch === 0) return 'floor';
|
||||
if (ch === "'2") return 'floor2';
|
||||
if (ch === '<') return 'entrance';
|
||||
if (ch === '>') return 'exit';
|
||||
if (ch === '$' || ch === '$W' || ch === '$M') return 'treasure';
|
||||
return 'floor';
|
||||
}
|
||||
|
||||
function sceneFromGameState(state, opts) {
|
||||
state = state || {};
|
||||
opts = opts || {};
|
||||
var visible = state.visibleTiles || {};
|
||||
var explored = state.exploredTiles || {};
|
||||
var lighting = state.lighting || {};
|
||||
var rows = visible.length || (state.dungeonRows || 0);
|
||||
var cols = 0;
|
||||
for (var y = 0; y < rows; y++) {
|
||||
if (visible[y]) cols = Math.max(cols, visible[y].length);
|
||||
if (explored[y]) cols = Math.max(cols, explored[y].length);
|
||||
}
|
||||
if (!cols && state.dungeonCols) cols = state.dungeonCols;
|
||||
|
||||
var grid = [];
|
||||
var lightGrid = [];
|
||||
for (var y = 0; y < rows; y++) {
|
||||
var row = [];
|
||||
var lrow = [];
|
||||
for (var x = 0; x < cols; x++) {
|
||||
var ch = null;
|
||||
var isVisible = visible[y] && visible[y][x] !== undefined;
|
||||
if (isVisible) {
|
||||
ch = visible[y][x];
|
||||
} else if (explored[y] && explored[y][x] !== undefined) {
|
||||
ch = explored[y][x];
|
||||
}
|
||||
if (ch === null || ch === undefined || ch === ' ') {
|
||||
row.push('dark');
|
||||
lrow.push(0);
|
||||
} else {
|
||||
row.push(dungeonTileKind(ch));
|
||||
// Lighting: 0 = fully lit, higher = darker. Convert to brightness (1 = lit).
|
||||
var la = (lighting[y] && lighting[y][x]) || 0;
|
||||
lrow.push(isVisible ? Math.max(0.15, 1 - Math.min(la, 0.8)) : 0.25);
|
||||
}
|
||||
}
|
||||
grid.push(row);
|
||||
lightGrid.push(lrow);
|
||||
}
|
||||
|
||||
var entities = [];
|
||||
|
||||
// Entrance / exit / treasure as entities (so they layer above tiles).
|
||||
if (state.entrance) {
|
||||
entities.push({ id: 'entrance', x: state.entrance[0], y: state.entrance[1], kind: 'feature', char: '<', color: '#3fb950', label: null });
|
||||
}
|
||||
if (state.exit) {
|
||||
entities.push({ id: 'exit', x: state.exit[0], y: state.exit[1], kind: 'feature', char: '>', color: '#d29922', label: null });
|
||||
}
|
||||
if (state.treasure) {
|
||||
var tChar = opts.cryptoType === 'XMR' ? '$M' : '$W';
|
||||
entities.push({ id: 'treasure', x: state.treasure[0], y: state.treasure[1], kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
}
|
||||
|
||||
// Items.
|
||||
if (state.items) {
|
||||
for (var key in state.items) {
|
||||
if (state.items.hasOwnProperty(key)) {
|
||||
var item = state.items[key];
|
||||
if (item && typeof item.x === 'number') {
|
||||
entities.push({ id: 'item:' + key, x: item.x, y: item.y, kind: 'item', char: '$', color: '#fbbf24', label: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monster.
|
||||
if (state.monster) {
|
||||
entities.push({
|
||||
id: 'monster', x: state.monster.x, y: state.monster.y,
|
||||
kind: 'monster', char: '~', color: '#f85149', label: null
|
||||
});
|
||||
}
|
||||
|
||||
// Player.
|
||||
if (state.player) {
|
||||
var playerEntity = {
|
||||
id: 'player', x: state.player.x, y: state.player.y,
|
||||
kind: 'player', char: '@', color: '#9aa4b2',
|
||||
facing: state.player.facing || 'down', label: null,
|
||||
you: !state.isSpectating
|
||||
};
|
||||
// Attach the player's appearance if available (for render-kit character rendering).
|
||||
if (opts.playerAppearance) {
|
||||
playerEntity.avatar = opts.playerAppearance.avatar || 'default';
|
||||
playerEntity.appearance = opts.playerAppearance;
|
||||
} else {
|
||||
playerEntity.avatar = 'default';
|
||||
playerEntity.appearance = { avatar: 'default', tint: 'none', equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } };
|
||||
}
|
||||
entities.push(playerEntity);
|
||||
}
|
||||
|
||||
return {
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
grid: grid,
|
||||
legend: DUNGEON_LEGEND,
|
||||
entities: entities,
|
||||
lightGrid: lightGrid,
|
||||
background: '#0a0c0f',
|
||||
isDungeon: true
|
||||
};
|
||||
}
|
||||
|
||||
api.sceneFromGameState = sceneFromGameState;
|
||||
api.DUNGEON_LEGEND = DUNGEON_LEGEND;
|
||||
|
||||
root.RK = root.RK || {};
|
||||
root.RK.scene = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// TavernLife — client-side NPCs (bartender, patrons, cat) + speech bubbles that make the
|
||||
// tavern feel alive. NPCs are decorative: they wander, sit, and chat. They're client-side
|
||||
// only (no server round-trips), so each client sees its own lively room — the real players
|
||||
// come from the server snapshot. Speech bubbles pop above any occupant (NPC or real player)
|
||||
// when they speak.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
|
||||
// roundRect polyfill for older browsers.
|
||||
if (!root.CanvasRenderingContext2D.prototype.roundRect) {
|
||||
root.CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
|
||||
if (w < 2 * r) r = w / 2;
|
||||
if (h < 2 * r) r = h / 2;
|
||||
this.beginPath();
|
||||
this.moveTo(x + r, y);
|
||||
this.arcTo(x + w, y, x + w, y + h, r);
|
||||
this.arcTo(x + w, y + h, x, y + h, r);
|
||||
this.arcTo(x, y + h, x, y, r);
|
||||
this.arcTo(x, y, x + w, y, r);
|
||||
this.closePath();
|
||||
return this;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Speech lines -----------------------------------------------------------
|
||||
var BARTENDER_LINES = [
|
||||
"What'll it be, traveler?", "Ale's fresh today.", "Tab's full, friend.",
|
||||
"Watch the monsters out there.", "Block's coming — hurry back.",
|
||||
"I've seen braver than you not return.", "House special: liquid courage.",
|
||||
"No credit — coin on the barrel.", "Heard someone found treasure last block.",
|
||||
"The dungeon's hungry tonight.", "Keep your wits about you.", "One ale, coming up."
|
||||
];
|
||||
var PATRON_LINES = [
|
||||
"Another round!", "I almost made it out...", "The dungeon claims another.",
|
||||
"Heard someone found the bag!", "Cheers!", "This ale is questionable.",
|
||||
"Anyone seen my shield?", "Last time I swear it was right there...",
|
||||
"The monster was RIGHT behind me.", "Need more credits...", "One more run.",
|
||||
"I used to be an adventurer too.", "Bloody hell, that was close.", "Pour me another.",
|
||||
"You new here?", "Don't go in unprepared.", "The exit was so close..."
|
||||
];
|
||||
var CAT_LINES = ["meow", "mrrrow", "prrrrp", "hisss", "...", "mew"];
|
||||
var WELCOME_LINES = ["Welcome!", "Pull up a stool.", "New face — nice.", "Come in, sit down."];
|
||||
|
||||
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
// ---- NPC definitions --------------------------------------------------------
|
||||
// NPCs are identified by a prefix so they don't collide with real socket ids.
|
||||
var NPC_PREFIX = 'npc:';
|
||||
|
||||
function makeNPC(id, kind, avatar, x, y, opts) {
|
||||
opts = opts || {};
|
||||
return {
|
||||
id: NPC_PREFIX + id,
|
||||
kind: kind,
|
||||
avatar: avatar,
|
||||
appearance: { avatar: avatar, tint: 'none', equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } },
|
||||
x: x, y: y, facing: 'down',
|
||||
targetX: x, targetY: y,
|
||||
moveCooldown: 0,
|
||||
sitTimer: 0,
|
||||
speechTimer: 0,
|
||||
speech: null, speechUntil: 0,
|
||||
color: opts.color || '#d7dbe0',
|
||||
label: opts.label || null,
|
||||
role: opts.role || 'patron',
|
||||
roamArea: opts.roamArea || null
|
||||
};
|
||||
}
|
||||
|
||||
function bartendersArea(cols, rows) {
|
||||
// Bartender roams along the top interior (bar counter row ~ y=2).
|
||||
return { minY: 1, maxY: 3, minX: 2, maxX: Math.max(5, cols - 3) };
|
||||
}
|
||||
function patronsArea(cols, rows) {
|
||||
// Patrons roam the middle/lower floor.
|
||||
return { minY: 3, maxY: rows - 2, minX: 1, maxX: cols - 2 };
|
||||
}
|
||||
function catArea(cols, rows) {
|
||||
return { minY: 1, maxY: rows - 1, minX: 1, maxX: cols - 2 };
|
||||
}
|
||||
|
||||
// ---- Speech bubbles ---------------------------------------------------------
|
||||
var BUBBLE_FONT = '12px ui-monospace, Menlo, Consolas, monospace';
|
||||
var BUBBLE_PADDING = 8;
|
||||
var BUBBLE_RADIUS = 6;
|
||||
var BUBBLE_TAIL = 7;
|
||||
var BUBBLE_DURATION = 4200;
|
||||
var BUBBLE_FADE = 500;
|
||||
|
||||
function measureBubble(ctx, text) {
|
||||
ctx.font = BUBBLE_FONT;
|
||||
var metrics = ctx.measureText(text);
|
||||
var w = Math.ceil(metrics.width) + BUBBLE_PADDING * 2;
|
||||
var h = 22 + BUBBLE_PADDING * 2;
|
||||
return { w: w, h: h };
|
||||
}
|
||||
|
||||
function drawBubble(ctx, x, y, text, alpha) {
|
||||
if (alpha <= 0 || !text) return;
|
||||
var dim = measureBubble(ctx, text);
|
||||
var bw = dim.w, bh = dim.h;
|
||||
// Clamp so the bubble stays on screen.
|
||||
var bx = Math.max(4, Math.min(x - bw / 2, ctx.canvas.width - bw - 4));
|
||||
var by = y - bh - BUBBLE_TAIL;
|
||||
if (by < 4) by = y + BUBBLE_TAIL + 4; // flip below if no room above
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.font = BUBBLE_FONT;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// Bubble body (rounded rect).
|
||||
ctx.fillStyle = 'rgba(18,24,32,0.94)';
|
||||
ctx.strokeStyle = 'rgba(120,140,165,0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bx, by, bw, bh, BUBBLE_RADIUS);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Tail pointer.
|
||||
var tailX = Math.max(bx + 12, Math.min(x, bx + bw - 12));
|
||||
ctx.fillStyle = 'rgba(18,24,32,0.94)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX - 5, by + bh);
|
||||
ctx.lineTo(tailX + 5, by + bh);
|
||||
ctx.lineTo(tailX, by + bh + (by < y ? BUBBLE_TAIL : -BUBBLE_TAIL));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Text.
|
||||
ctx.fillStyle = '#e8edf3';
|
||||
ctx.fillText(text, bx + bw / 2, by + bh / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- The TavernLife controller ----------------------------------------------
|
||||
function TavernLife(host, getScene, getCell) {
|
||||
this.host = host;
|
||||
this.getScene = getScene;
|
||||
this.getCell = getCell || function () { return 24; };
|
||||
this.npcs = [];
|
||||
this.bubbles = {}; // occupantId -> { text, until, born }
|
||||
this._overlay = null;
|
||||
this._octx = null;
|
||||
this._raf = null;
|
||||
this._lastTick = 0;
|
||||
this._spawned = false;
|
||||
this._realBubbles = {}; // socketId -> { text, until } from real chat
|
||||
}
|
||||
|
||||
TavernLife.prototype._ensureOverlay = function () {
|
||||
if (this._overlay && this._overlay.parentNode) return this._octx;
|
||||
var cv = root.document.createElement('canvas');
|
||||
cv.className = 'tavern-life-overlay';
|
||||
cv.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:10;';
|
||||
this.host.style.position = this.host.style.position || 'relative';
|
||||
this.host.appendChild(cv);
|
||||
this._overlay = cv;
|
||||
this._octx = cv.getContext('2d');
|
||||
return this._octx;
|
||||
};
|
||||
|
||||
TavernLife.prototype._syncOverlaySize = function (w, h) {
|
||||
var ctx = this._ensureOverlay();
|
||||
if (!ctx) return;
|
||||
if (this._overlay.width !== w) this._overlay.width = w;
|
||||
if (this._overlay.height !== h) this._overlay.height = h;
|
||||
return ctx;
|
||||
};
|
||||
|
||||
TavernLife.prototype.spawn = function () {
|
||||
if (this._spawned) return;
|
||||
this._spawned = true;
|
||||
var scene = this.getScene();
|
||||
if (!scene) return;
|
||||
var cols = scene.cols, rows = scene.rows;
|
||||
var grid = scene.grid;
|
||||
|
||||
// Find walkable tiles for spawning.
|
||||
function isFloor(x, y) {
|
||||
return y >= 0 && y < rows && x >= 0 && x < cols && grid[y] && grid[y][x] === 'floor';
|
||||
}
|
||||
function findFloor(cx, cy) {
|
||||
for (var r = 1; r < 8; r++) {
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
for (var dx = -r; dx <= r; dx++) {
|
||||
var x = cx + dx, y = cy + dy;
|
||||
if (isFloor(x, y)) return { x: x, y: y };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 1, y: 1 };
|
||||
}
|
||||
|
||||
// Bartender near the bar (top area).
|
||||
var barSpot = findFloor(Math.floor(cols / 2), 3);
|
||||
var bartender = makeNPC('bartender', 'bartender', 'char-merchant', barSpot.x, barSpot.y, {
|
||||
label: 'Bartender', color: '#c9a25e', role: 'bartender',
|
||||
roamArea: bartendersArea(cols, rows)
|
||||
});
|
||||
this.npcs.push(bartender);
|
||||
|
||||
// 2-3 patrons near tables (middle area).
|
||||
var patronAvatars = ['char-villager', 'char-elder', 'char-bard', 'char-wizard', 'char-ranger', 'char-rogue'];
|
||||
var patronColors = ['#7eb6c9', '#c9a0d4', '#a0c97e', '#d4b0a0', '#8fa0d4'];
|
||||
var numPatrons = 2 + Math.floor(Math.random() * 2);
|
||||
for (var i = 0; i < numPatrons; i++) {
|
||||
var px = 4 + Math.floor(Math.random() * (cols - 8));
|
||||
var py = 5 + Math.floor(Math.random() * (rows - 8));
|
||||
var spot = findFloor(px, py);
|
||||
this.npcs.push(makeNPC('patron' + i, 'patron', patronAvatars[i % patronAvatars.length],
|
||||
spot.x, spot.y, {
|
||||
label: 'Patron', color: patronColors[i % patronColors.length],
|
||||
roamArea: patronsArea(cols, rows),
|
||||
sitTimer: 2000 + Math.random() * 4000
|
||||
}));
|
||||
}
|
||||
|
||||
// Cat — a small wandering creature.
|
||||
var catSpot = findFloor(3, rows - 3);
|
||||
this.npcs.push(makeNPC('cat', 'cat', 'char-goblin', catSpot.x, catSpot.y, {
|
||||
label: null, color: '#e8a060', role: 'cat',
|
||||
roamArea: catArea(cols, rows)
|
||||
}));
|
||||
};
|
||||
|
||||
TavernLife.prototype._pickTarget = function (npc, scene) {
|
||||
var area = npc.roamArea;
|
||||
if (!area) return;
|
||||
var grid = scene.grid;
|
||||
var cols = scene.cols, rows = scene.rows;
|
||||
for (var tries = 0; tries < 12; tries++) {
|
||||
var tx = area.minX + Math.floor(Math.random() * (area.maxX - area.minX + 1));
|
||||
var ty = area.minY + Math.floor(Math.random() * (area.maxY - area.minY + 1));
|
||||
if (ty >= 0 && ty < rows && tx >= 0 && tx < cols && grid[ty] && grid[ty][tx] === 'floor') {
|
||||
npc.targetX = tx;
|
||||
npc.targetY = ty;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TavernLife.prototype._moveNPC = function (npc, scene, dt) {
|
||||
if (npc.sitTimer > 0) {
|
||||
npc.sitTimer -= dt;
|
||||
return;
|
||||
}
|
||||
npc.moveCooldown -= dt;
|
||||
if (npc.moveCooldown > 0) return;
|
||||
|
||||
// Arrived at target?
|
||||
if (npc.x === npc.targetX && npc.y === npc.targetY) {
|
||||
var sitTime = npc.role === 'cat' ? 1000 + Math.random() * 2000 : 3000 + Math.random() * 5000;
|
||||
npc.sitTimer = sitTime;
|
||||
this._pickTarget(npc, scene);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step toward target (one tile, cardinal only).
|
||||
var dx = npc.targetX - npc.x;
|
||||
var dy = npc.targetY - npc.y;
|
||||
var stepX = 0, stepY = 0;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) stepX = Math.sign(dx);
|
||||
else if (dy !== 0) stepY = Math.sign(dy);
|
||||
else if (dx !== 0) stepX = Math.sign(dx);
|
||||
|
||||
var nx = npc.x + stepX, ny = npc.y + stepY;
|
||||
var grid = scene.grid;
|
||||
if (ny >= 0 && ny < scene.rows && nx >= 0 && nx < scene.cols && grid[ny] && grid[ny][nx] === 'floor') {
|
||||
npc.x = nx; npc.y = ny;
|
||||
if (stepX < 0) npc.facing = 'left';
|
||||
else if (stepX > 0) npc.facing = 'right';
|
||||
else if (stepY < 0) npc.facing = 'up';
|
||||
else if (stepY > 0) npc.facing = 'down';
|
||||
} else {
|
||||
// Blocked — pick a new target.
|
||||
this._pickTarget(npc, scene);
|
||||
}
|
||||
// Cat moves faster; bartender slower.
|
||||
npc.moveCooldown = npc.role === 'cat' ? 280 : npc.role === 'bartender' ? 500 : 380;
|
||||
};
|
||||
|
||||
TavernLife.prototype._maybeSpeak = function (npc, now) {
|
||||
if (npc.speech && now < npc.speechUntil) return;
|
||||
npc.speechTimer -= 16;
|
||||
if (npc.speechTimer > 0) return;
|
||||
var lines;
|
||||
if (npc.role === 'bartender') lines = BARTENDER_LINES;
|
||||
else if (npc.role === 'cat') lines = CAT_LINES;
|
||||
else lines = PATRON_LINES;
|
||||
npc.speech = pick(lines);
|
||||
npc.speechUntil = now + BUBBLE_DURATION;
|
||||
npc.speechTimer = 8000 + Math.random() * 12000; // next speech gap
|
||||
if (npc.role === 'cat') npc.speechTimer = 6000 + Math.random() * 10000;
|
||||
};
|
||||
|
||||
// Inject NPC entities into a scene so the renderer draws them.
|
||||
TavernLife.prototype.injectInto = function (scene) {
|
||||
if (!scene || !this._spawned) return scene;
|
||||
if (!scene._npcInjected) {
|
||||
for (var i = 0; i < this.npcs.length; i++) {
|
||||
var npc = this.npcs[i];
|
||||
scene.entities.push({
|
||||
id: npc.id,
|
||||
x: npc.x, y: npc.y,
|
||||
kind: 'avatar',
|
||||
avatar: npc.avatar,
|
||||
appearance: npc.appearance,
|
||||
color: npc.color,
|
||||
char: npc.role === 'cat' ? 'c' : '@',
|
||||
facing: npc.facing,
|
||||
label: npc.label,
|
||||
you: false,
|
||||
_npc: true
|
||||
});
|
||||
}
|
||||
scene._npcInjected = true;
|
||||
}
|
||||
return scene;
|
||||
};
|
||||
|
||||
// Show a speech bubble for a real player (called on chat_broadcast).
|
||||
TavernLife.prototype.showPlayerBubble = function (socketId, text) {
|
||||
this._realBubbles[socketId] = { text: text, until: Date.now() + BUBBLE_DURATION };
|
||||
};
|
||||
|
||||
// Get the screen position of an entity for a given renderer/cell size.
|
||||
// This is renderer-agnostic: top-down uses grid coords * cell; iso uses projection.
|
||||
TavernLife.prototype._entityScreenPos = function (e, scene, cell, mode) {
|
||||
if (mode === 'iso') {
|
||||
// Match IsoRenderer._project: originX = margin + rows * tileW/2 + tileW; originY = margin + imageH
|
||||
var tileW = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.w) || 84;
|
||||
var tileH = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.h) || 42;
|
||||
var imageH = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.imageH) || 184;
|
||||
var margin = 28;
|
||||
var originX = margin + scene.rows * tileW / 2 + tileW;
|
||||
var originY = margin + imageH;
|
||||
return {
|
||||
x: originX + (e.x - e.y) * tileW / 2,
|
||||
y: originY + (e.x + e.y) * tileH / 2
|
||||
};
|
||||
}
|
||||
if (mode === '3d') {
|
||||
// 3D renderer uses Three.js — we can't easily get screen coords.
|
||||
// Skip speech bubbles for 3D mode.
|
||||
return null;
|
||||
}
|
||||
// top-down / ascii / fancy: grid coords * cell
|
||||
return { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
|
||||
};
|
||||
|
||||
TavernLife.prototype._drawBubbles = function (scene, cell, mode, now) {
|
||||
var ctx = this._octx;
|
||||
if (!ctx || !scene) return;
|
||||
ctx.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||
|
||||
// NPC bubbles.
|
||||
for (var i = 0; i < this.npcs.length; i++) {
|
||||
var npc = this.npcs[i];
|
||||
if (!npc.speech || now >= npc.speechUntil) continue;
|
||||
var alpha = 1;
|
||||
var remaining = npc.speechUntil - now;
|
||||
if (remaining < BUBBLE_FADE) alpha = remaining / BUBBLE_FADE;
|
||||
var pos = this._entityScreenPos(npc, scene, cell, mode);
|
||||
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3, npc.speech, alpha);
|
||||
}
|
||||
|
||||
// Real player bubbles (from chat).
|
||||
var occupants = scene.entities;
|
||||
for (var j = 0; j < occupants.length; j++) {
|
||||
var e = occupants[j];
|
||||
if (e._npc) continue;
|
||||
var bubble = this._realBubbles[e.id];
|
||||
if (!bubble || now >= bubble.until) {
|
||||
if (bubble) delete this._realBubbles[e.id];
|
||||
continue;
|
||||
}
|
||||
var rAlpha = 1;
|
||||
var rRemaining = bubble.until - now;
|
||||
if (rRemaining < BUBBLE_FADE) rAlpha = rRemaining / BUBBLE_FADE;
|
||||
var rPos = this._entityScreenPos(e, scene, cell, mode);
|
||||
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3, bubble.text, rAlpha);
|
||||
}
|
||||
};
|
||||
|
||||
// Main update — called every frame via rAF.
|
||||
TavernLife.prototype.update = function (scene, cell, mode) {
|
||||
if (!scene || !this._spawned) return;
|
||||
var now = Date.now();
|
||||
var dt = now - (this._lastTick || now);
|
||||
this._lastTick = now;
|
||||
|
||||
// Update NPC movement and speech.
|
||||
for (var i = 0; i < this.npcs.length; i++) {
|
||||
this._moveNPC(this.npcs[i], scene, dt);
|
||||
this._maybeSpeak(this.npcs[i], now);
|
||||
}
|
||||
|
||||
// Update NPC entity positions in the scene.
|
||||
for (var j = 0; j < this.npcs.length; j++) {
|
||||
var npc = this.npcs[j];
|
||||
for (var k = 0; k < scene.entities.length; k++) {
|
||||
if (scene.entities[k].id === npc.id) {
|
||||
scene.entities[k].x = npc.x;
|
||||
scene.entities[k].y = npc.y;
|
||||
scene.entities[k].facing = npc.facing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw speech bubbles on the overlay.
|
||||
var rendererCanvas = this.host.querySelector('.rk-canvas');
|
||||
if (rendererCanvas) {
|
||||
this._syncOverlaySize(rendererCanvas.width, rendererCanvas.height);
|
||||
}
|
||||
this._drawBubbles(scene, cell, mode, now);
|
||||
};
|
||||
|
||||
TavernLife.prototype.start = function () {
|
||||
var self = this;
|
||||
function loop() {
|
||||
var scene = self.getScene();
|
||||
if (scene) {
|
||||
var cell = self.getCell();
|
||||
// Re-inject NPCs if the scene was rebuilt.
|
||||
if (!scene._npcInjected) self.injectInto(scene);
|
||||
self.update(scene, cell, self._mode || 'tiles');
|
||||
}
|
||||
self._raf = root.requestAnimationFrame(loop);
|
||||
}
|
||||
this._raf = root.requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
TavernLife.prototype.setMode = function (mode) { this._mode = mode; };
|
||||
|
||||
TavernLife.prototype.stop = function () {
|
||||
if (this._raf) root.cancelAnimationFrame(this._raf);
|
||||
this._raf = null;
|
||||
if (this._overlay && this._overlay.parentNode) this._overlay.parentNode.removeChild(this._overlay);
|
||||
this._overlay = null;
|
||||
this._octx = null;
|
||||
};
|
||||
|
||||
TavernLife.prototype.welcomeBubbles = function () {
|
||||
// Make the bartender greet when the player joins.
|
||||
if (this.npcs.length > 0 && this.npcs[0].role === 'bartender') {
|
||||
var now = Date.now();
|
||||
this.npcs[0].speech = pick(WELCOME_LINES);
|
||||
this.npcs[0].speechUntil = now + BUBBLE_DURATION;
|
||||
this.npcs[0].speechTimer = 5000;
|
||||
}
|
||||
};
|
||||
|
||||
RK.TavernLife = TavernLife;
|
||||
})(window);
|
||||
@@ -30,7 +30,7 @@
|
||||
ctx.fillStyle = scene.background || '#000';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (window.RK && RK.roomReady && RK.roomReady()) {
|
||||
if (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) {
|
||||
// A designed room (imported .tmx) is loaded — draw its layered tiles.
|
||||
RK.drawRoomCanvas(ctx, cell);
|
||||
} else {
|
||||
@@ -54,6 +54,14 @@
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.fillRect(px, py, cell, Math.max(1, cell * 0.14));
|
||||
}
|
||||
// Dungeon lighting: darken tiles based on the light grid.
|
||||
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
|
||||
var brightness = scene.lightGrid[y][x];
|
||||
if (brightness < 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,' + (1 - brightness) + ')';
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +69,38 @@
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
// Dungeon features (entrance, exit, treasure) — draw as colored glyphs.
|
||||
if (e.kind === 'feature') {
|
||||
var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fff';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(e.char || '?', cx, cy);
|
||||
continue;
|
||||
}
|
||||
// Dungeon items.
|
||||
if (e.kind === 'item') {
|
||||
var ix = e.x * cell + cell / 2, iy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fbbf24';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('$', ix, iy);
|
||||
continue;
|
||||
}
|
||||
// Dungeon monster.
|
||||
if (e.kind === 'monster') {
|
||||
var mx = e.x * cell + cell / 2, my = e.y * cell + cell / 2, mr = cell * 0.34;
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, mr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = e.color || '#f85149';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,100,100,0.4)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
continue;
|
||||
}
|
||||
// Roguelike character sprite, or premium animated skin; else the fallback circle.
|
||||
if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) {
|
||||
var visual = RK.avatarVisuals.resolve(e.appearance || { avatar: e.avatar }, {
|
||||
|
||||
+55
-1
@@ -20,6 +20,7 @@
|
||||
<script src="js/render/fancyRenderer.js"></script>
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="js/render/tavernLife.js"></script>
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<style>
|
||||
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #d7dbe0; --accent: #3fb950; }
|
||||
@@ -78,6 +79,21 @@
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Spectator camera: watch live dungeon runs from inside the tavern -->
|
||||
<button id="watchGamesBtn" class="hidden" style="margin-top:8px;">👁️ Watch Live Games</button>
|
||||
<div id="spectatorPanel" class="panel hidden">
|
||||
<h3 style="margin:0 0 8px;font-size:14px;">Live Games</h3>
|
||||
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
|
||||
<button id="closeGameListBtn" style="margin-top:8px;">Close</button>
|
||||
</div>
|
||||
<div id="spectatorStage" class="hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span id="spectatorLabel" class="muted"></span>
|
||||
<button id="leaveSpectateBtn">← Back to tavern</button>
|
||||
</div>
|
||||
<div id="spectatorRender" style="position:relative;min-height:300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -120,6 +136,8 @@
|
||||
var currentRenderer = null;
|
||||
var currentMode = null;
|
||||
var currentScene = null;
|
||||
var tavernLife = null;
|
||||
var rafActive = false;
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
function applyEntitlements(data) {
|
||||
@@ -137,7 +155,26 @@
|
||||
currentScene = RK.scene.sceneFromTavern(state, state.you);
|
||||
}
|
||||
function draw() {
|
||||
if (currentRenderer && currentScene) currentRenderer.render(currentScene);
|
||||
if (!currentScene) return;
|
||||
// Inject client-side NPCs (bartender, patrons, cat) into the scene.
|
||||
if (tavernLife) tavernLife.injectInto(currentScene);
|
||||
if (currentRenderer) currentRenderer.render(currentScene);
|
||||
}
|
||||
|
||||
// rAF loop for smooth NPC animation + speech bubbles.
|
||||
function startRafLoop() {
|
||||
if (rafActive) return;
|
||||
rafActive = true;
|
||||
function loop() {
|
||||
if (!rafActive) return;
|
||||
if (currentScene && tavernLife) {
|
||||
// Re-inject NPCs if the scene was rebuilt by a snapshot.
|
||||
if (!currentScene._npcInjected) tavernLife.injectInto(currentScene);
|
||||
tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles');
|
||||
}
|
||||
root.requestAnimationFrame(loop);
|
||||
}
|
||||
root.requestAnimationFrame(loop);
|
||||
}
|
||||
function lockedModeMessage(mode) {
|
||||
var meta = RK.modeMeta ? RK.modeMeta(mode) : null;
|
||||
@@ -152,6 +189,7 @@
|
||||
if (!RK.canUseMode(mode)) { setStatus(lockedModeMessage(mode)); return; }
|
||||
if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; }
|
||||
currentMode = mode;
|
||||
if (tavernLife) tavernLife.setMode(mode);
|
||||
RK.saveMode(mode);
|
||||
updateModeButtons();
|
||||
if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…');
|
||||
@@ -199,6 +237,7 @@
|
||||
});
|
||||
socket.on('game_mode_info', function (data) {
|
||||
if (data && data.entitlements) applyEntitlements(data.entitlements);
|
||||
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
|
||||
});
|
||||
socket.on('credits_update', applyEntitlements);
|
||||
socket.on('session_token', function (data) {
|
||||
@@ -231,6 +270,14 @@
|
||||
rebuildScene();
|
||||
buildModebar();
|
||||
setMode(RK.loadMode('tiles'));
|
||||
// Spawn client-side NPCs (bartender, patrons, cat) for a lively tavern.
|
||||
tavernLife = new RK.TavernLife(stageEl, function () { return currentScene; }, function () {
|
||||
return currentRenderer && currentRenderer.cell || 24;
|
||||
});
|
||||
tavernLife.setMode(currentMode || 'tiles');
|
||||
tavernLife.spawn();
|
||||
tavernLife.welcomeBubbles();
|
||||
startRafLoop();
|
||||
// Load the designed room (if the server is using one); re-render when ready.
|
||||
if (state.roomUrl) RK.loadRoom(state.roomUrl, function (room) {
|
||||
if (room && room.desc && room.desc.walkable) state.walkable = room.desc.walkable;
|
||||
@@ -238,6 +285,7 @@
|
||||
draw();
|
||||
});
|
||||
setStatus('You are in the tavern.');
|
||||
watchGamesBtn.classList.remove('hidden');
|
||||
});
|
||||
|
||||
socket.on('tavern_update', function (snap) {
|
||||
@@ -261,6 +309,12 @@
|
||||
socket.on('chat_broadcast', function (m) {
|
||||
if (!m || !state.channel || m.scope !== state.channel) return;
|
||||
addChatLine(m.username, m.message);
|
||||
// Show a speech bubble above the speaker's avatar.
|
||||
if (tavernLife && m.socketId) {
|
||||
// m.message is server-escaped HTML; strip tags for the bubble.
|
||||
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
|
||||
tavernLife.showPlayerBubble(m.socketId, text);
|
||||
}
|
||||
});
|
||||
socket.on('tavern_notice', function (n) {
|
||||
if (n && n.message) addSystemLine(n.message);
|
||||
|
||||
@@ -53,3 +53,67 @@ describe('sceneFromTavern (render kit adapter)', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sceneFromGameState (dungeon adapter)', () => {
|
||||
const api = require('../html/js/render/sceneModel.js');
|
||||
|
||||
test('converts visible tiles, entities, and lighting into a dungeon Scene', () => {
|
||||
const scene = api.sceneFromGameState({
|
||||
visibleTiles: [['#', "'1", '>'], ["'1", "'1", "'1"]],
|
||||
exploredTiles: [['#', "'1", '>'], ["'1", "'1", "'1"]],
|
||||
lighting: [[0, 0.5, 0], [0, 0, 0]],
|
||||
entrance: [0, 1],
|
||||
exit: [2, 0],
|
||||
treasure: null,
|
||||
player: { x: 1, y: 1, facing: 'down' },
|
||||
monster: { x: 2, y: 1 },
|
||||
items: {},
|
||||
isSpectating: true
|
||||
});
|
||||
|
||||
expect(scene.isDungeon).toBe(true);
|
||||
expect(scene.cols).toBe(3);
|
||||
expect(scene.rows).toBe(2);
|
||||
expect(scene.grid[0]).toEqual(['wall', 'floor', 'exit']);
|
||||
expect(scene.grid[1]).toEqual(['floor', 'floor', 'floor']);
|
||||
expect(scene.lightGrid[0][1]).toBeCloseTo(0.5, 1);
|
||||
// Entities: entrance, exit, monster, player (no treasure)
|
||||
const kinds = scene.entities.map(e => e.kind);
|
||||
expect(kinds).toContain('feature');
|
||||
expect(kinds).toContain('monster');
|
||||
expect(kinds).toContain('player');
|
||||
// Player entity should be marked as not-you for spectators
|
||||
const player = scene.entities.find(e => e.kind === 'player');
|
||||
expect(player.you).toBe(false);
|
||||
});
|
||||
|
||||
test('treasure entity appears when treasure position is set', () => {
|
||||
const scene = api.sceneFromGameState({
|
||||
visibleTiles: [["'1", '$']],
|
||||
entrance: [0, 0],
|
||||
exit: [1, 0],
|
||||
treasure: [1, 0],
|
||||
player: { x: 0, y: 0 },
|
||||
monster: null,
|
||||
items: {},
|
||||
isSpectating: false
|
||||
});
|
||||
const treasure = scene.entities.find(e => e.id === 'treasure');
|
||||
expect(treasure).toBeTruthy();
|
||||
expect(treasure.char).toContain('$');
|
||||
});
|
||||
|
||||
test('unexplored tiles become dark', () => {
|
||||
const scene = api.sceneFromGameState({
|
||||
visibleTiles: [["'1", undefined]],
|
||||
exploredTiles: [["'1", undefined]],
|
||||
lighting: [[0, 0]],
|
||||
player: { x: 0, y: 0 },
|
||||
monster: null,
|
||||
items: {},
|
||||
isSpectating: true
|
||||
});
|
||||
expect(scene.grid[0][1]).toBe('dark');
|
||||
expect(scene.lightGrid[0][1]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user