diff --git a/html/js/render/asciiRenderer.js b/html/js/render/asciiRenderer.js index 48834a9..ee5258a 100644 --- a/html/js/render/asciiRenderer.js +++ b/html/js/render/asciiRenderer.js @@ -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'; diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js index b126117..aab20b0 100644 --- a/html/js/render/assetPacks.js +++ b/html/js/render/assetPacks.js @@ -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'; diff --git a/html/js/render/sceneModel.js b/html/js/render/sceneModel.js index 7beabea..78b2205 100644 --- a/html/js/render/sceneModel.js +++ b/html/js/render/sceneModel.js @@ -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; diff --git a/html/js/render/tavernLife.js b/html/js/render/tavernLife.js new file mode 100644 index 0000000..54bd19f --- /dev/null +++ b/html/js/render/tavernLife.js @@ -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); diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js index ded1c39..0925263 100644 --- a/html/js/render/tileRenderer.js +++ b/html/js/render/tileRenderer.js @@ -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 }, { diff --git a/html/tavern.html b/html/tavern.html index 6cf3c61..2880997 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -20,6 +20,7 @@ +