import { TileType, TILE_SIZE, CharacterState, Direction } from '../types' import type { TileType as TileTypeVal, FurnitureInstance, Character, SpriteData, Seat, FloorColor } from '../types' import { getCachedSprite, getOutlineSprite } from '../sprites/spriteCache' import { getCharacterSprites, BUBBLE_PERMISSION_SPRITE, BUBBLE_WAITING_SPRITE } from '../sprites/spriteData' import { getCatSprites } from '../sprites/catSprites' import { getCharacterSprite } from './characters' import { renderMatrixEffect } from './matrixEffect' import { getColorizedFloorSprite, hasFloorSprites, WALL_COLOR } from '../floorTiles' import { hasWallSprites, getWallInstances, wallColorToHex } from '../wallTiles' import type { BugEntity } from '../bugs/types' import { renderBugs } from '../bugs/renderer' import { CHARACTER_SITTING_OFFSET_PX, CHARACTER_Z_SORT_OFFSET, OUTLINE_Z_SORT_OFFSET, SELECTED_OUTLINE_ALPHA, HOVERED_OUTLINE_ALPHA, GHOST_PREVIEW_SPRITE_ALPHA, GHOST_PREVIEW_TINT_ALPHA, SELECTION_DASH_PATTERN, BUTTON_MIN_RADIUS, BUTTON_RADIUS_ZOOM_FACTOR, BUTTON_ICON_SIZE_FACTOR, BUTTON_LINE_WIDTH_MIN, BUTTON_LINE_WIDTH_ZOOM_FACTOR, BUBBLE_FADE_DURATION_SEC, BUBBLE_SITTING_OFFSET_PX, BUBBLE_VERTICAL_OFFSET_PX, FALLBACK_FLOOR_COLOR, SEAT_OWN_COLOR, SEAT_AVAILABLE_COLOR, SEAT_BUSY_COLOR, GRID_LINE_COLOR, VOID_TILE_OUTLINE_COLOR, VOID_TILE_DASH_PATTERN, GHOST_BORDER_HOVER_FILL, GHOST_BORDER_HOVER_STROKE, GHOST_BORDER_STROKE, GHOST_VALID_TINT, GHOST_INVALID_TINT, SELECTION_HIGHLIGHT_COLOR, DELETE_BUTTON_BG, ROTATE_BUTTON_BG, HEATMAP_CELL_SIZE, HEATMAP_CELL_GAP, HEATMAP_BOTTOM_MARGIN, } from '../constants' // ── GitHub Contribution Heatmap ───────────────────────────────── export interface ContributionDay { count: number; date: string } export interface ContributionWeek { days: ContributionDay[] } export interface ContributionData { weeks: ContributionWeek[]; username: string } const HEATMAP_COLORS = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'] function contributionLevel(count: number): number { if (count === 0) return 0 if (count <= 3) return 1 if (count <= 6) return 2 if (count <= 9) return 3 return 4 } function renderContributionHeatmap( ctx: CanvasRenderingContext2D, data: ContributionData, offsetX: number, offsetY: number, zoom: number, ): void { if (!data.weeks.length) return const tileW = TILE_SIZE * zoom // Draw 52×7 heatmap grid across left room top wall (row 0, cols 1-9) const areaX = offsetX + 1 * tileW const areaW = 9 * tileW const areaH = tileW const areaY = offsetY // row 0 top edge // Calculate cell size to fit 52 cols × 7 rows with 1px gaps const gapPx = Math.max(0.5, 0.5 * zoom) const cellW = (areaW - (data.weeks.length - 1) * gapPx) / data.weeks.length const cellH = (areaH - 6 * gapPx) / 7 // Fill background so gaps between cells are consistent ctx.fillStyle = '#ffffff' ctx.fillRect(Math.round(areaX), Math.round(areaY), Math.round(areaW), Math.round(areaH)) for (let w = 0; w < data.weeks.length; w++) { const week = data.weeks[w] for (let d = 0; d < week.days.length; d++) { const level = contributionLevel(week.days[d].count) ctx.fillStyle = HEATMAP_COLORS[level] const x = areaX + w * (cellW + gapPx) const y = areaY + d * (cellH + gapPx) ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(cellW), Math.ceil(cellH)) } } } /** Render photograph on right room wall (row 0, cols 12-18) */ function renderPhotograph( ctx: CanvasRenderingContext2D, img: HTMLImageElement, offsetX: number, offsetY: number, zoom: number, ): void { const tileW = TILE_SIZE * zoom const margin = 1 * zoom // 1px border const baseW = 7 * tileW - margin * 2 const baseH = tileW - margin * 2 const scale = 4 / 3 const areaW = baseW * scale const areaH = baseH * scale // Anchor bottom edge: shift areaY up by the extra height const baseY = offsetY + margin - tileW / 8 const areaY = baseY + baseH - areaH // Center horizontally relative to original area const baseX = offsetX + 10 * tileW + margin const areaX = baseX - (areaW - baseW) / 2 // Fit image preserving aspect ratio const imgAspect = img.width / img.height const areaAspect = areaW / areaH let drawW: number, drawH: number, drawX: number, drawY: number if (imgAspect > areaAspect) { drawW = areaW drawH = areaW / imgAspect drawX = areaX drawY = areaY + (areaH - drawH) / 2 } else { drawH = areaH drawW = areaH * imgAspect drawX = areaX + (areaW - drawW) / 2 drawY = areaY } ctx.drawImage(img, Math.round(drawX), Math.round(drawY), Math.round(drawW), Math.round(drawH)) } // ── Render functions ──────────────────────────────────────────── export function renderTileGrid( ctx: CanvasRenderingContext2D, tileMap: TileTypeVal[][], offsetX: number, offsetY: number, zoom: number, tileColors?: Array, cols?: number, ): void { const s = TILE_SIZE * zoom const useSpriteFloors = hasFloorSprites() const tmRows = tileMap.length const tmCols = tmRows > 0 ? tileMap[0].length : 0 const layoutCols = cols ?? tmCols const GRID_STEP = 3 // Draw grid lines every N tiles for (let r = 0; r < tmRows; r++) { for (let c = 0; c < tmCols; c++) { const tile = tileMap[r][c] if (tile === TileType.VOID) continue if (tile === TileType.WALL) { const colorIdx = r * layoutCols + c const wallColor = tileColors?.[colorIdx] ctx.fillStyle = wallColor ? wallColorToHex(wallColor) : WALL_COLOR ctx.fillRect(offsetX + c * s, offsetY + r * s, s, s) continue } if (!useSpriteFloors) { ctx.fillStyle = FALLBACK_FLOOR_COLOR ctx.fillRect(offsetX + c * s, offsetY + r * s, s, s) continue } // Floor tile: use fillRect with colorized color (no seams) const colorIdx = r * layoutCols + c const color = tileColors?.[colorIdx] ?? { h: 0, s: 0, b: 0, c: 0 } const sprite = getColorizedFloorSprite(tile, color) const fillColor = sprite[0]?.[0] || FALLBACK_FLOOR_COLOR ctx.fillStyle = fillColor ctx.fillRect(offsetX + c * s, offsetY + r * s, s, s) } } // Subtle grid lines at half-tile intervals (ceramic tile effect) ctx.save() ctx.strokeStyle = 'rgba(0,0,0,0.06)' ctx.lineWidth = 2 const halfS = s / 2 // Vertical lines — all solid ctx.beginPath() for (let c = 0; c <= tmCols * 2; c++) { const x = Math.round(offsetX + c * halfS) + 0.5 ctx.moveTo(x, offsetY) ctx.lineTo(x, offsetY + tmRows * s) } ctx.stroke() // Horizontal lines — alternate solid and dashed for (let r = 0; r <= tmRows * 2; r++) { const y = Math.round(offsetY + r * halfS) + 0.5 ctx.beginPath() if (r % 2 === 1) { ctx.setLineDash([3, 6]) } else { ctx.setLineDash([]) } ctx.moveTo(offsetX, y) ctx.lineTo(offsetX + tmCols * s, y) ctx.stroke() } ctx.setLineDash([]) ctx.restore() } interface ZDrawable { zY: number draw: (ctx: CanvasRenderingContext2D) => void } export function renderScene( ctx: CanvasRenderingContext2D, furniture: FurnitureInstance[], characters: Character[], offsetX: number, offsetY: number, zoom: number, selectedAgentId: number | null, hoveredAgentId: number | null, contributions?: ContributionData, photograph?: HTMLImageElement, gatewayHealthy?: boolean, ): void { const drawables: ZDrawable[] = [] const laptopSizeScale = 0.7 const laptopXTiltRad = (50 * Math.PI) / 180 const laptopUpwardSpinRad = -Math.PI / 12 const laptopTiltScaleY = Math.max(0.22, Math.abs(Math.cos(laptopXTiltRad))) const laptopTiltSkewX = -Math.sin(laptopXTiltRad) * 0.35 const visibleSubagentStoolIds = new Set() for (const ch of characters) { if (!ch.isSubagent || ch.state !== CharacterState.TYPE || !ch.seatId) continue if (!ch.seatId.startsWith('stool-r')) continue if (ch.matrixEffect === 'despawn') continue visibleSubagentStoolIds.add(ch.seatId) } // Wall decorations as z-sorted drawables (zY just above row 0 walls so they render on top of walls but below characters) const wallDecoZY = TILE_SIZE + 0.5 if (contributions && contributions.weeks.length > 0) { drawables.push({ zY: wallDecoZY, draw: () => { renderContributionHeatmap(ctx, contributions, offsetX, offsetY, zoom) }}) } if (photograph) { drawables.push({ zY: wallDecoZY, draw: () => { renderPhotograph(ctx, photograph, offsetX, offsetY, zoom) }}) } // Furniture for (const f of furniture) { if (f.uid?.startsWith('stool-r') && !visibleSubagentStoolIds.has(f.uid)) { continue } const fx = offsetX + f.x * zoom const fy = offsetY + f.y * zoom if (f.emoji) { const emojiSize = TILE_SIZE * zoom const emojiX = fx + emojiSize / 2 const emojiY = fy + emojiSize * 0.8 drawables.push({ zY: f.zY, draw: (c) => { const scale = f.emojiScale ?? 1 c.font = `${emojiSize * 0.7 * scale}px serif` c.textAlign = 'center' c.textBaseline = 'middle' if (f.rotation) { c.save() c.translate(emojiX, emojiY) c.rotate((f.rotation * Math.PI) / 180) c.fillText(f.emoji!, 0, 0) c.restore() } else { c.fillText(f.emoji!, emojiX, emojiY) } // Camera flash effect: brief white burst every 10 seconds if (f.emoji === '📷') { const flashCycle = (Date.now() % 10000) / 10000 if (flashCycle < 0.03) { const flashAlpha = 1 - flashCycle / 0.03 const flashR = emojiSize * 1.5 const grad = c.createRadialGradient(emojiX, emojiY, 0, emojiX, emojiY, flashR) grad.addColorStop(0, `rgba(255,255,255,${flashAlpha * 0.9})`) grad.addColorStop(0.3, `rgba(255,255,200,${flashAlpha * 0.5})`) grad.addColorStop(1, `rgba(255,255,200,0)`) c.fillStyle = grad c.fillRect(emojiX - flashR, emojiY - flashR, flashR * 2, flashR * 2) } } }, }) } else { const cached = getCachedSprite(f.sprite, zoom) drawables.push({ zY: f.zY, draw: (c) => { c.drawImage(cached, fx, fy) }, }) // Server alarm beacon (top of the server rack in the lounge left wall). if (f.uid === 'server-b-left') { const healthy = gatewayHealthy !== false const now = Date.now() const healthyPulse = (Math.sin(now / 900) + 1) / 2 const unhealthyPulse = (Math.sin(now / 120) + 1) / 2 const blinkAlpha = healthy ? (0.55 + healthyPulse * 0.4) // slow breathing blink : (0.15 + unhealthyPulse * 0.85) // fast urgent blink drawables.push({ zY: f.zY + 0.15, draw: (c) => { const lampX = Math.round(fx + 15 * zoom) const lampTopY = Math.round(fy + 1 * zoom) const lampW = Math.max(3, Math.round(3.6 * zoom)) const lampH = Math.max(2, Math.round(2.2 * zoom)) const stemW = Math.max(1, Math.round(1.1 * zoom)) const stemH = Math.max(1, Math.round(1.4 * zoom)) const baseW = Math.max(2, Math.round(2.6 * zoom)) const baseH = Math.max(1, Math.round(1.1 * zoom)) const lampLeft = Math.round(lampX - lampW / 2) const stemLeft = Math.round(lampX - stemW / 2) const stemTop = lampTopY + lampH const baseLeft = Math.round(lampX - baseW / 2) const baseTop = stemTop + stemH c.save() c.globalAlpha = blinkAlpha // Lamp cover (pixel warning light, not a sphere) c.fillStyle = '#2B2F45' c.fillRect(lampLeft - 1, lampTopY - 1, lampW + 2, lampH + 2) c.fillStyle = healthy ? '#63E46F' : '#F25F5C' c.fillRect(lampLeft, lampTopY, lampW, lampH) // Lamp stem + base c.fillStyle = '#3A425E' c.fillRect(stemLeft, stemTop, stemW, stemH) c.fillRect(baseLeft, baseTop, baseW, baseH) // Pixel glow bands to keep a lamp-like look (avoid spherical aura) const glowOuter = Math.max(8, Math.round(8.4 * zoom)) const glowMid = Math.max(5, Math.round(5.8 * zoom)) const glowInner = Math.max(3, Math.round(3.4 * zoom)) c.fillStyle = healthy ? 'rgba(99,228,111,0.14)' : 'rgba(242,95,92,0.2)' c.fillRect(lampX - glowOuter, lampTopY - glowOuter, glowOuter * 2, glowOuter * 2) c.fillStyle = healthy ? 'rgba(99,228,111,0.2)' : 'rgba(242,95,92,0.28)' c.fillRect(lampX - glowMid, lampTopY - glowMid, glowMid * 2, glowMid * 2) c.fillStyle = healthy ? 'rgba(99,228,111,0.28)' : 'rgba(242,95,92,0.35)' c.fillRect(lampX - glowInner, lampTopY - glowInner, glowInner * 2, glowInner * 2) c.restore() }, }) } } } // Characters for (const ch of characters) { const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET // Subagent temporary laptop: place it in front of the character using // live world coordinates, so it stays aligned with seated offsets. if (ch.isSubagent && ch.state === CharacterState.TYPE && ch.seatId) { let dx = 0 let dy = 0 if (ch.dir === Direction.LEFT) dx = -1 else if (ch.dir === Direction.RIGHT) dx = 1 else if (ch.dir === Direction.UP) dy = -1 else dy = 1 const forwardOffsetPx = TILE_SIZE * 0.62 const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0 const laptopWorldX = ch.x + dx * forwardOffsetPx const laptopWorldY = ch.y + sittingOffset + dy * forwardOffsetPx - 5 - TILE_SIZE / 8 const laptopX = offsetX + laptopWorldX * zoom const laptopY = offsetY + laptopWorldY * zoom const laptopFacing = ch.dir === Direction.LEFT ? Direction.RIGHT : ch.dir === Direction.RIGHT ? Direction.LEFT : ch.dir === Direction.UP ? Direction.DOWN : Direction.UP const laptopRotation = laptopFacing === Direction.DOWN ? 0 : laptopFacing === Direction.LEFT ? 90 : laptopFacing === Direction.UP ? 180 : 270 const laptopZY = laptopWorldY + TILE_SIZE * 0.45 drawables.push({ zY: laptopZY, draw: (c) => { const emojiSize = TILE_SIZE * zoom * laptopSizeScale c.save() c.translate(Math.round(laptopX), Math.round(laptopY)) c.rotate((laptopRotation * Math.PI) / 180) // Composite transform: X-axis tilt + extra upward spin around laptop center. c.rotate(laptopUpwardSpinRad) c.transform(1, 0, laptopTiltSkewX, laptopTiltScaleY, 0, 0) c.font = `${emojiSize}px serif` c.textAlign = 'center' c.textBaseline = 'middle' c.fillText('💻', 0, 0) c.restore() }, }) } if (ch.isLobster) { const lobsterX = Math.round(offsetX + ch.x * zoom) const lobsterY = Math.round(offsetY + ch.y * zoom + 2 * zoom) const lobsterAngle = ch.dir === Direction.RIGHT ? Math.PI / 2 : ch.dir === Direction.DOWN ? Math.PI : ch.dir === Direction.LEFT ? -Math.PI / 2 : 0 if (ch.lobsterBubbles.length > 0) { const bubbles = ch.lobsterBubbles drawables.push({ zY: charZY - 0.05, draw: (c) => { c.save() c.textAlign = 'center' c.textBaseline = 'middle' c.font = `${Math.max(10, Math.round(6 * zoom))}px serif` for (const b of bubbles) { const progress = b.age / 0.8 const bx = lobsterX + b.x * zoom const by = lobsterY + (b.y - progress * 8) * zoom const alpha = progress < 0.2 ? progress / 0.2 : progress > 0.7 ? (1 - progress) / 0.3 : 1 c.globalAlpha = alpha * 0.9 c.fillText('🫧', bx, by) } c.restore() }, }) } drawables.push({ zY: charZY, draw: (c) => { c.save() c.translate(lobsterX, lobsterY) c.rotate(lobsterAngle) c.textAlign = 'center' c.textBaseline = 'middle' c.font = `${Math.max(14, Math.round(9 * zoom))}px serif` c.fillText('🦞', 0, 0) c.restore() }, }) continue } const sprites = ch.isCat ? getCatSprites() : getCharacterSprites(ch.palette, ch.hueShift) const spriteData = getCharacterSprite(ch, sprites) const cached = getCachedSprite(spriteData, zoom) // Sitting offset: shift character down when seated so they visually sit in the chair const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0 // Anchor at bottom-center of character — round to integer device pixels const drawX = Math.round(offsetX + ch.x * zoom - cached.width / 2) const drawY = Math.round(offsetY + (ch.y + sittingOffset) * zoom - cached.height) // Sort characters by bottom of their tile (not center) so they render // in front of same-row furniture (e.g. chairs) but behind furniture // at lower rows (e.g. desks, bookshelves that occlude from below). // Matrix spawn/despawn effect — skip outline, use per-pixel rendering if (ch.matrixEffect) { const mDrawX = drawX const mDrawY = drawY const mSpriteData = spriteData const mCh = ch drawables.push({ zY: charZY, draw: (c) => { renderMatrixEffect(c, mCh, mSpriteData, mDrawX, mDrawY, zoom) }, }) continue } // White outline: full opacity for selected, 50% for hover const isSelected = selectedAgentId !== null && ch.id === selectedAgentId const isHovered = hoveredAgentId !== null && ch.id === hoveredAgentId if (isSelected || isHovered) { const outlineAlpha = isSelected ? SELECTED_OUTLINE_ALPHA : HOVERED_OUTLINE_ALPHA const outlineData = getOutlineSprite(spriteData) const outlineCached = getCachedSprite(outlineData, zoom) const olDrawX = drawX - zoom // 1 sprite-pixel offset, scaled const olDrawY = drawY - zoom // outline follows sitting offset via drawY drawables.push({ zY: charZY - OUTLINE_Z_SORT_OFFSET, // sort just before character draw: (c) => { c.save() c.globalAlpha = outlineAlpha c.drawImage(outlineCached, olDrawX, olDrawY) c.restore() }, }) } drawables.push({ zY: charZY, draw: (c) => { c.drawImage(cached, drawX, drawY) }, }) // Agent label above head if (ch.label) { const labelX = Math.round(offsetX + ch.x * zoom) const labelY = drawY - 2 * zoom const fontSize = Math.max(12, Math.round(5.25 * zoom)) const isWorking = ch.isActive && ch.state === CharacterState.TYPE const now = Date.now() // Blink effect for working state: use time-based alpha const labelAlpha = isWorking ? 0.7 + 0.3 * Math.sin(now / 300) : 1.0 let labelColor = ch.isSubagent ? (isWorking ? `rgba(220,38,38,${labelAlpha})` : '#991B1B') : (isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700') if (ch.systemRoleType === 'gateway_sre') { const state = ch.systemStatus ?? 'unknown' if (state === 'healthy') { const alpha = 0.65 + 0.3 * ((Math.sin(now / 760) + 1) / 2) labelColor = `rgba(34,197,94,${alpha})` } else if (state === 'degraded') { const alpha = 0.45 + 0.55 * ((Math.sin(now / 220) + 1) / 2) labelColor = `rgba(250,204,21,${alpha})` } else if (state === 'down') { const alpha = 0.28 + 0.72 * ((Math.sin(now / 110) + 1) / 2) labelColor = `rgba(153,27,27,${alpha})` } else { labelColor = '#9CA3AF' } } drawables.push({ zY: charZY + 0.1, draw: (c) => { c.save() c.font = `bold ${fontSize}px sans-serif` c.textAlign = 'center' c.textBaseline = 'bottom' const textW = c.measureText(ch.label).width const padX = 4 * zoom const padY = 2 * zoom const boxX = labelX - textW / 2 - padX const boxY = labelY - fontSize - padY const boxW = textW + padX * 2 const boxH = fontSize + padY * 2 const r = 3 * zoom c.beginPath() c.moveTo(boxX + r, boxY) c.lineTo(boxX + boxW - r, boxY) c.arcTo(boxX + boxW, boxY, boxX + boxW, boxY + r, r) c.lineTo(boxX + boxW, boxY + boxH - r) c.arcTo(boxX + boxW, boxY + boxH, boxX + boxW - r, boxY + boxH, r) c.lineTo(boxX + r, boxY + boxH) c.arcTo(boxX, boxY + boxH, boxX, boxY + boxH - r, r) c.lineTo(boxX, boxY + r) c.arcTo(boxX, boxY, boxX + r, boxY, r) c.closePath() c.fillStyle = 'rgba(0,0,0,0.55)' c.fill() c.fillStyle = 'rgba(0,0,0,0.9)' c.fillText(ch.label, labelX, labelY + 1) c.fillStyle = labelColor c.fillText(ch.label, labelX, labelY) c.restore() }, }) } // Code snippet particles are rendered as DOM overlays in app/pixel-office/page.tsx // so they can float beyond the canvas area and pass over the top agent list. } // Sort by Y (lower = in front = drawn later) drawables.sort((a, b) => a.zY - b.zY) for (const d of drawables) { d.draw(ctx) } } // ── Seat indicators ───────────────────────────────────────────── export function renderSeatIndicators( ctx: CanvasRenderingContext2D, seats: Map, characters: Map, selectedAgentId: number | null, hoveredTile: { col: number; row: number } | null, offsetX: number, offsetY: number, zoom: number, ): void { if (selectedAgentId === null || !hoveredTile) return const selectedChar = characters.get(selectedAgentId) if (!selectedChar) return // Only show indicator for the hovered seat tile for (const [uid, seat] of seats) { if (seat.seatCol !== hoveredTile.col || seat.seatRow !== hoveredTile.row) continue const s = TILE_SIZE * zoom const x = offsetX + seat.seatCol * s const y = offsetY + seat.seatRow * s if (selectedChar.seatId === uid) { // Selected agent's own seat — blue ctx.fillStyle = SEAT_OWN_COLOR } else if (!seat.assigned) { // Available seat — green ctx.fillStyle = SEAT_AVAILABLE_COLOR } else { // Busy (assigned to another agent) — red ctx.fillStyle = SEAT_BUSY_COLOR } ctx.fillRect(x, y, s, s) break } } // ── Edit mode overlays ────────────────────────────────────────── export function renderGridOverlay( ctx: CanvasRenderingContext2D, offsetX: number, offsetY: number, zoom: number, cols: number, rows: number, tileMap?: TileTypeVal[][], ): void { const s = TILE_SIZE * zoom ctx.strokeStyle = GRID_LINE_COLOR ctx.lineWidth = 1 ctx.beginPath() // Vertical lines — offset by 0.5 for crisp 1px lines for (let c = 0; c <= cols; c++) { const x = offsetX + c * s + 0.5 ctx.moveTo(x, offsetY) ctx.lineTo(x, offsetY + rows * s) } // Horizontal lines for (let r = 0; r <= rows; r++) { const y = offsetY + r * s + 0.5 ctx.moveTo(offsetX, y) ctx.lineTo(offsetX + cols * s, y) } ctx.stroke() // Draw faint dashed outlines on VOID tiles if (tileMap) { ctx.save() ctx.strokeStyle = VOID_TILE_OUTLINE_COLOR ctx.lineWidth = 1 ctx.setLineDash(VOID_TILE_DASH_PATTERN) for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (tileMap[r]?.[c] === TileType.VOID) { ctx.strokeRect(offsetX + c * s + 0.5, offsetY + r * s + 0.5, s - 1, s - 1) } } } ctx.restore() } } /** Draw faint expansion placeholders 1 tile outside grid bounds (ghost border). */ export function renderGhostBorder( ctx: CanvasRenderingContext2D, offsetX: number, offsetY: number, zoom: number, cols: number, rows: number, ghostHoverCol: number, ghostHoverRow: number, ): void { const s = TILE_SIZE * zoom ctx.save() // Collect ghost border tiles: one ring around the grid const ghostTiles: Array<{ c: number; r: number }> = [] // Top and bottom rows for (let c = -1; c <= cols; c++) { ghostTiles.push({ c, r: -1 }) ghostTiles.push({ c, r: rows }) } // Left and right columns (excluding corners already added) for (let r = 0; r < rows; r++) { ghostTiles.push({ c: -1, r }) ghostTiles.push({ c: cols, r }) } for (const { c, r } of ghostTiles) { const x = offsetX + c * s const y = offsetY + r * s const isHovered = c === ghostHoverCol && r === ghostHoverRow if (isHovered) { ctx.fillStyle = GHOST_BORDER_HOVER_FILL ctx.fillRect(x, y, s, s) } ctx.strokeStyle = isHovered ? GHOST_BORDER_HOVER_STROKE : GHOST_BORDER_STROKE ctx.lineWidth = 1 ctx.setLineDash(VOID_TILE_DASH_PATTERN) ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1) } ctx.restore() } export function renderGhostPreview( ctx: CanvasRenderingContext2D, sprite: SpriteData, col: number, row: number, valid: boolean, offsetX: number, offsetY: number, zoom: number, ): void { const cached = getCachedSprite(sprite, zoom) const x = offsetX + col * TILE_SIZE * zoom const y = offsetY + row * TILE_SIZE * zoom ctx.save() ctx.globalAlpha = GHOST_PREVIEW_SPRITE_ALPHA ctx.drawImage(cached, x, y) // Tint overlay ctx.globalAlpha = GHOST_PREVIEW_TINT_ALPHA ctx.fillStyle = valid ? GHOST_VALID_TINT : GHOST_INVALID_TINT ctx.fillRect(x, y, cached.width, cached.height) ctx.restore() } export function renderSelectionHighlight( ctx: CanvasRenderingContext2D, col: number, row: number, w: number, h: number, offsetX: number, offsetY: number, zoom: number, ): void { const s = TILE_SIZE * zoom const x = offsetX + col * s const y = offsetY + row * s ctx.save() ctx.strokeStyle = SELECTION_HIGHLIGHT_COLOR ctx.lineWidth = 2 ctx.setLineDash(SELECTION_DASH_PATTERN) ctx.strokeRect(x + 1, y + 1, w * s - 2, h * s - 2) ctx.restore() } export function renderDeleteButton( ctx: CanvasRenderingContext2D, col: number, row: number, w: number, _h: number, offsetX: number, offsetY: number, zoom: number, ): DeleteButtonBounds { const s = TILE_SIZE * zoom // Position at top-right corner of selected furniture const cx = offsetX + (col + w) * s + 1 const cy = offsetY + row * s - 1 const radius = Math.max(BUTTON_MIN_RADIUS, zoom * BUTTON_RADIUS_ZOOM_FACTOR) // Circle background ctx.save() ctx.beginPath() ctx.arc(cx, cy, radius, 0, Math.PI * 2) ctx.fillStyle = DELETE_BUTTON_BG ctx.fill() // X mark ctx.strokeStyle = '#fff' ctx.lineWidth = Math.max(BUTTON_LINE_WIDTH_MIN, zoom * BUTTON_LINE_WIDTH_ZOOM_FACTOR) ctx.lineCap = 'round' const xSize = radius * BUTTON_ICON_SIZE_FACTOR ctx.beginPath() ctx.moveTo(cx - xSize, cy - xSize) ctx.lineTo(cx + xSize, cy + xSize) ctx.moveTo(cx + xSize, cy - xSize) ctx.lineTo(cx - xSize, cy + xSize) ctx.stroke() ctx.restore() return { cx, cy, radius } } export function renderRotateButton( ctx: CanvasRenderingContext2D, col: number, row: number, _w: number, _h: number, offsetX: number, offsetY: number, zoom: number, ): RotateButtonBounds { const s = TILE_SIZE * zoom // Position to the left of the delete button (which is at top-right corner) const radius = Math.max(BUTTON_MIN_RADIUS, zoom * BUTTON_RADIUS_ZOOM_FACTOR) const cx = offsetX + col * s - 1 const cy = offsetY + row * s - 1 // Circle background ctx.save() ctx.beginPath() ctx.arc(cx, cy, radius, 0, Math.PI * 2) ctx.fillStyle = ROTATE_BUTTON_BG ctx.fill() // Circular arrow icon ctx.strokeStyle = '#fff' ctx.lineWidth = Math.max(BUTTON_LINE_WIDTH_MIN, zoom * BUTTON_LINE_WIDTH_ZOOM_FACTOR) ctx.lineCap = 'round' const arcR = radius * BUTTON_ICON_SIZE_FACTOR ctx.beginPath() // Draw a 270-degree arc ctx.arc(cx, cy, arcR, -Math.PI * 0.8, Math.PI * 0.7) ctx.stroke() // Draw arrowhead at the end of the arc const endAngle = Math.PI * 0.7 const endX = cx + arcR * Math.cos(endAngle) const endY = cy + arcR * Math.sin(endAngle) const arrowSize = radius * 0.35 ctx.beginPath() ctx.moveTo(endX + arrowSize * 0.6, endY - arrowSize * 0.3) ctx.lineTo(endX, endY) ctx.lineTo(endX + arrowSize * 0.7, endY + arrowSize * 0.5) ctx.stroke() ctx.restore() return { cx, cy, radius } } // ── Speech bubbles ────────────────────────────────────────────── export function renderBubbles( ctx: CanvasRenderingContext2D, characters: Character[], offsetX: number, offsetY: number, zoom: number, ): void { for (const ch of characters) { if (!ch.bubbleType) continue const sprite = ch.bubbleType === 'permission' ? BUBBLE_PERMISSION_SPRITE : BUBBLE_WAITING_SPRITE // Compute opacity: permission = full, waiting = fade in last 0.5s let alpha = 1.0 if (ch.bubbleType === 'waiting' && ch.bubbleTimer < BUBBLE_FADE_DURATION_SEC) { alpha = ch.bubbleTimer / BUBBLE_FADE_DURATION_SEC } const cached = getCachedSprite(sprite, zoom) // Position: centered above the character's head // Character is anchored bottom-center at (ch.x, ch.y), sprite is 16x24 // Place bubble above head with a small gap; follow sitting offset const sittingOff = ch.state === CharacterState.TYPE ? BUBBLE_SITTING_OFFSET_PX : 0 const bubbleX = Math.round(offsetX + ch.x * zoom - cached.width / 2) const bubbleY = Math.round(offsetY + (ch.y + sittingOff - BUBBLE_VERTICAL_OFFSET_PX) * zoom - cached.height - 1 * zoom) ctx.save() if (alpha < 1.0) ctx.globalAlpha = alpha ctx.drawImage(cached, bubbleX, bubbleY) ctx.restore() } } export function renderPhotoComments( ctx: CanvasRenderingContext2D, characters: Character[], offsetX: number, offsetY: number, zoom: number, ): void { const lifetime = 4.0 const canvasH = ctx.canvas.height / (window.devicePixelRatio || 1) for (const ch of characters) { if (ch.photoComments.length === 0) continue const sittingOff = ch.state === CharacterState.TYPE ? BUBBLE_SITTING_OFFSET_PX : 0 const anchorX = Math.round(offsetX + ch.x * zoom) const anchorY = Math.round(offsetY + (ch.y + sittingOff - BUBBLE_VERTICAL_OFFSET_PX) * zoom) const fontSize = Math.max(10, Math.round(4 * zoom)) const totalFloatDist = anchorY + 20 // distance from character to top of canvas ctx.save() ctx.font = `bold ${fontSize}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'bottom' for (const pc of ch.photoComments) { const progress = pc.age / lifetime let alpha = 1.0 if (pc.age < 0.3) alpha = pc.age / 0.3 if (progress > 0.6) alpha = (1 - progress) / 0.4 const floatY = progress * totalFloatDist const baseX = anchorX + pc.x * zoom const baseY = anchorY - floatY ctx.globalAlpha = alpha * 0.95 const tw = ctx.measureText(pc.text).width const px = 4 * (zoom / 3) const py = 2 * (zoom / 3) const rx = baseX - tw / 2 - px const ry = baseY - fontSize - py * 2 const rw = tw + px * 2 const rh = fontSize + py * 2 const cr = 4 // Background pill ctx.fillStyle = 'rgba(0,0,0,0.8)' ctx.beginPath() ctx.moveTo(rx + cr, ry) ctx.lineTo(rx + rw - cr, ry) ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + cr) ctx.lineTo(rx + rw, ry + rh - cr) ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - cr, ry + rh) ctx.lineTo(rx + cr, ry + rh) ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - cr) ctx.lineTo(rx, ry + cr) ctx.quadraticCurveTo(rx, ry, rx + cr, ry) ctx.closePath() ctx.fill() // Text ctx.fillStyle = '#FFD700' ctx.fillText(pc.text, baseX, baseY - py) } ctx.restore() } } export interface ButtonBounds { /** Center X in device pixels */ cx: number /** Center Y in device pixels */ cy: number /** Radius in device pixels */ radius: number } export type DeleteButtonBounds = ButtonBounds export type RotateButtonBounds = ButtonBounds export interface EditorRenderState { showGrid: boolean ghostSprite: SpriteData | null ghostCol: number ghostRow: number ghostValid: boolean selectedCol: number selectedRow: number selectedW: number selectedH: number hasSelection: boolean isRotatable: boolean /** Updated each frame by renderDeleteButton */ deleteButtonBounds: DeleteButtonBounds | null /** Updated each frame by renderRotateButton */ rotateButtonBounds: RotateButtonBounds | null /** Whether to show ghost border (expansion tiles outside grid) */ showGhostBorder: boolean /** Hovered ghost border tile col (-1 to cols) */ ghostBorderHoverCol: number /** Hovered ghost border tile row (-1 to rows) */ ghostBorderHoverRow: number } export interface SelectionRenderState { selectedAgentId: number | null hoveredAgentId: number | null hoveredTile: { col: number; row: number } | null seats: Map characters: Map } export function renderFrame( ctx: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, tileMap: TileTypeVal[][], furniture: FurnitureInstance[], characters: Character[], zoom: number, panX: number, panY: number, selection?: SelectionRenderState, editor?: EditorRenderState, tileColors?: Array, layoutCols?: number, layoutRows?: number, bugs?: BugEntity[], contributions?: ContributionData, photograph?: HTMLImageElement, gatewayHealthy?: boolean, ): { offsetX: number; offsetY: number } { // Clear ctx.clearRect(0, 0, canvasWidth, canvasHeight) // Use layout dimensions (fallback to tileMap size) const cols = layoutCols ?? (tileMap.length > 0 ? tileMap[0].length : 0) const rows = layoutRows ?? tileMap.length // Center map in viewport + pan offset (integer device pixels) const mapW = cols * TILE_SIZE * zoom const mapH = rows * TILE_SIZE * zoom const offsetX = Math.floor((canvasWidth - mapW) / 2) + Math.round(panX) const offsetY = Math.floor((canvasHeight - mapH) / 2) + Math.round(panY) // Draw tiles (floor + wall base color) renderTileGrid(ctx, tileMap, offsetX, offsetY, zoom, tileColors, layoutCols) if (bugs && bugs.length > 0) { renderBugs(ctx, bugs, offsetX, offsetY, zoom) } // Seat indicators (below furniture/characters, on top of floor) if (selection) { renderSeatIndicators(ctx, selection.seats, selection.characters, selection.selectedAgentId, selection.hoveredTile, offsetX, offsetY, zoom) } // Build wall instances for z-sorting with furniture and characters const wallInstances = hasWallSprites() ? getWallInstances(tileMap, tileColors, layoutCols) : [] const allFurniture = wallInstances.length > 0 ? [...wallInstances, ...furniture] : furniture // Draw walls + furniture + characters (z-sorted) const selectedId = selection?.selectedAgentId ?? null const hoveredId = selection?.hoveredAgentId ?? null renderScene(ctx, allFurniture, characters, offsetX, offsetY, zoom, selectedId, hoveredId, contributions, photograph, gatewayHealthy) // Speech bubbles (always on top of characters) renderBubbles(ctx, characters, offsetX, offsetY, zoom) // Editor overlays if (editor) { if (editor.showGrid) { renderGridOverlay(ctx, offsetX, offsetY, zoom, cols, rows, tileMap) } if (editor.showGhostBorder) { renderGhostBorder(ctx, offsetX, offsetY, zoom, cols, rows, editor.ghostBorderHoverCol, editor.ghostBorderHoverRow) } if (editor.ghostSprite && editor.ghostCol >= 0) { renderGhostPreview(ctx, editor.ghostSprite, editor.ghostCol, editor.ghostRow, editor.ghostValid, offsetX, offsetY, zoom) } if (editor.hasSelection) { renderSelectionHighlight(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom) editor.deleteButtonBounds = renderDeleteButton(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom) if (editor.isRotatable) { editor.rotateButtonBounds = renderRotateButton(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom) } else { editor.rotateButtonBounds = null } } else { editor.deleteButtonBounds = null editor.rotateButtonBounds = null } } return { offsetX, offsetY } }