--- a/client/webapp/js/ui/war_fog_renderer.js +++ b/client/webapp/js/ui/war_fog_renderer.js @@ -1,287 +1,191 @@ // ========================================================================= -// FREEEXILE: HIGH-DEFINITION PROCEDURAL MAP & WAR-FOG CANVAS RENDERER -// Module: war_fog_renderer.js (Tam Phân Lập Native ES Modules, Soft Cap <= 350 lines) +// FREEEXILE: HIGH-DEFINITION ISOMETRIC WAR-FOG CANVAS RENDERER +// Module: war_fog_renderer.js (Tam Phân Lập Native ES Modules, Hard Cap <= 250 lines) +// 0=UNEXPLORED (pitch black), 1=EXPLORED_FOGGED (semi-transparent shadow), 2=VISIBLE (clear) // ========================================================================= -export function renderProceduralMap(ctx, state) { - const { - gridSize, - tilePx, - biome, - mapTiles, - fogMatrix, - simulatedPath, - playerPos, - bossPos, - bossRoom, - bossGate, - bossGateBreached, - viewRadius, - animTime = Date.now() * 0.003 - } = state; - - ctx.fillStyle = '#030508'; - ctx.fillRect(0, 0, 480, 480); - - const offX = Math.floor((480 - gridSize * tilePx) / 2); - const offY = Math.floor((480 - gridSize * tilePx) / 2); - - // 1. Base Layer: Tiles & Terrain Architecture - for (let y = 0; y < gridSize; y++) { - for (let x = 0; x < gridSize; x++) { - const fog = fogMatrix[y] ? fogMatrix[y][x] : 0; - const tile = mapTiles[y] ? mapTiles[y][x] : 0; - const px = offX + x * tilePx; - const py = offY + y * tilePx; - - if (fog === 0) continue; // Unexplored dark abyss - - if (tile === 0) { - // Wall Tile: 3D Beveled Grimdark Stone - ctx.fillStyle = biome.color; - ctx.fillRect(px, py, tilePx, tilePx); - ctx.strokeStyle = biome.wallAccent || '#292524'; - ctx.lineWidth = 1; - ctx.strokeRect(px + 0.5, py + 0.5, tilePx - 1, tilePx - 1); - ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; - ctx.fillRect(px + 1, py + 1, tilePx - 2, 2); - } else { - // Floor Tile: Organic Biome Ground - ctx.fillStyle = biome.floor; - ctx.fillRect(px, py, tilePx, tilePx); - if ((x + y) % 2 === 0) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; - ctx.fillRect(px + 1, py + 1, tilePx - 2, tilePx - 2); - } - } - - // Specialized Map Hazards & Props - if (tile === 2) drawMudPool(ctx, px, py, tilePx, animTime); - else if (tile === 3) drawBarricade(ctx, px, py, tilePx); - else if (tile === 4) drawDebris(ctx, px, py, tilePx); - else if (tile === 5) drawSpikes(ctx, px, py, tilePx, animTime); - else if (tile === 6) drawBossGate(ctx, px, py, tilePx, animTime, bossGateBreached); - else if (tile === 7) drawBossAltar(ctx, px, py, tilePx, animTime, biome); - else if (tile === 8) drawRunicFloor(ctx, px, py, tilePx, animTime, biome); - else if (tile === 9) drawBrazier(ctx, px, py, tilePx, animTime); - - // Explored Fog Overlay (Seen in past, dynamic entities masked) - if (fog === 1) { - ctx.fillStyle = 'rgba(3, 7, 18, 0.72)'; - ctx.fillRect(px, py, tilePx, tilePx); - } - } - } - - // 2. Boss Arena Perimeter Glow - if (bossRoom) { - ctx.save(); - ctx.strokeStyle = 'rgba(168, 85, 247, 0.35)'; - ctx.lineWidth = 1.5; - ctx.setLineDash([4, 4]); - ctx.strokeRect(offX + bossRoom.x * tilePx, offY + bossRoom.y * tilePx, bossRoom.w * tilePx, bossRoom.h * tilePx); - ctx.restore(); - } - - // 3. Sinuous Anti-Bot Guided Path Line - if (simulatedPath && simulatedPath.length > 1) { - ctx.save(); - ctx.beginPath(); - ctx.strokeStyle = 'rgba(168, 185, 129, 0.5)'; - ctx.lineWidth = Math.max(1.5, tilePx * 0.16); - ctx.setLineDash([4, 4]); - ctx.lineDashOffset = -animTime * 10; - simulatedPath.forEach((pt, i) => { - const cx = offX + pt.x * tilePx + tilePx / 2; - const cy = offY + pt.y * tilePx + tilePx / 2; - if (i === 0) ctx.moveTo(cx, cy); - else ctx.lineTo(cx, cy); - }); - ctx.stroke(); - ctx.restore(); - } - - // 4. Boss Entity in Boss Arena - if (fogMatrix[bossPos.y] && fogMatrix[bossPos.y][bossPos.x] === 2) { - drawBossEntity(ctx, offX + bossPos.x * tilePx + tilePx / 2, offY + bossPos.y * tilePx + tilePx / 2, tilePx, animTime); - } - - // 5. Player Entity & Vision Aura - const plX = offX + playerPos.x * tilePx + tilePx / 2; - const plY = offY + playerPos.y * tilePx + tilePx / 2; - const torchFlicker = Math.sin(animTime * 4.0) * 3; - const vRad = viewRadius * tilePx + torchFlicker; - const haloGrad = ctx.createRadialGradient(plX, plY, tilePx * 0.5, plX, plY, vRad); - haloGrad.addColorStop(0, 'rgba(251, 191, 36, 0.14)'); - haloGrad.addColorStop(0.7, 'rgba(56, 189, 248, 0.05)'); - haloGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = haloGrad; - ctx.beginPath(); - ctx.arc(plX, plY, vRad, 0, Math.PI * 2); - ctx.fill(); - - drawPlayerEntity(ctx, plX, plY, tilePx, animTime); -} - -function drawMudPool(ctx, px, py, s, t) { - ctx.fillStyle = '#3b3117'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - const bubble = Math.sin(t * 3.0 + px) * 1.5; - ctx.fillStyle = '#655022'; - ctx.beginPath(); - ctx.arc(px + s * 0.5, py + s * 0.5, Math.max(1, s * 0.22 + bubble), 0, Math.PI * 2); - ctx.fill(); -} - -function drawBarricade(ctx, px, py, s) { - ctx.fillStyle = '#78350f'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.strokeStyle = '#f59e0b'; - ctx.lineWidth = 1.2; - ctx.strokeRect(px + 1, py + 1, s - 2, s - 2); - ctx.beginPath(); - ctx.moveTo(px + 2, py + 2); - ctx.lineTo(px + s - 2, py + s - 2); - ctx.moveTo(px + s - 2, py + 2); - ctx.lineTo(px + 2, py + s - 2); - ctx.stroke(); -} - -function drawDebris(ctx, px, py, s) { - ctx.fillStyle = '#44403c'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.fillStyle = '#78716c'; - ctx.beginPath(); - ctx.arc(px + s * 0.35, py + s * 0.4, s * 0.2, 0, Math.PI * 2); - ctx.arc(px + s * 0.65, py + s * 0.6, s * 0.22, 0, Math.PI * 2); - ctx.fill(); -} - -function drawSpikes(ctx, px, py, s, t) { - ctx.fillStyle = '#292524'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.strokeStyle = '#ef4444'; - ctx.lineWidth = 1; - ctx.strokeRect(px + 2, py + 2, s - 4, s - 4); - ctx.fillStyle = '#dc2626'; - ctx.beginPath(); - ctx.moveTo(px + s * 0.5, py + 2); - ctx.lineTo(px + 3, py + s - 3); - ctx.lineTo(px + s - 3, py + s - 3); - ctx.closePath(); - ctx.fill(); -} - -function drawBossGate(ctx, px, py, s, t, breached) { - if (breached) { - ctx.fillStyle = '#064e3b'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.strokeStyle = '#34d399'; - ctx.lineWidth = 1.5; - ctx.strokeRect(px + 1.5, py + 1.5, s - 3, s - 3); - ctx.fillStyle = '#a7f3d0'; - ctx.font = `bold ${Math.max(8, Math.floor(s * 0.6))}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('🔓', px + s / 2, py + s / 2); - return; - } - ctx.fillStyle = '#1e1b4b'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - const pulse = Math.sin(t * 3.0) * 0.3 + 0.7; - ctx.strokeStyle = `rgba(168, 85, 247, ${pulse})`; - ctx.lineWidth = 1.8; - ctx.strokeRect(px + 1.5, py + 1.5, s - 3, s - 3); - ctx.fillStyle = '#c084fc'; - ctx.font = `bold ${Math.max(8, Math.floor(s * 0.6))}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('🔒', px + s / 2, py + s / 2); -} - -function drawBossAltar(ctx, px, py, s, t, biome) { - ctx.fillStyle = '#2e1065'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.strokeStyle = biome.rune || '#c084fc'; - ctx.lineWidth = 1.5; - ctx.strokeRect(px + 2, py + 2, s - 4, s - 4); - const pulse = Math.sin(t * 4.0) * 0.2 + 0.8; - ctx.fillStyle = `rgba(243, 232, 255, ${pulse})`; - ctx.font = `bold ${Math.max(8, Math.floor(s * 0.65))}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('💀', px + s / 2, py + s / 2); -} - -function drawRunicFloor(ctx, px, py, s, t, biome) { - ctx.fillStyle = '#1c1917'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - ctx.strokeStyle = biome.rune || '#c084fc'; - const pulse = Math.sin(t * 2.5 + px * 0.1) * 0.2 + 0.6; - ctx.globalAlpha = pulse; - ctx.lineWidth = 1.2; - ctx.beginPath(); - ctx.arc(px + s / 2, py + s / 2, s * 0.36, 0, Math.PI * 2); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(px + s / 2, py + s / 2, s * 0.18, 0, Math.PI * 2); - ctx.stroke(); - ctx.globalAlpha = 1.0; -} - -function drawBrazier(ctx, px, py, s, t) { - ctx.fillStyle = '#1c1917'; - ctx.fillRect(px + 1, py + 1, s - 2, s - 2); - const flamePulse = Math.sin(t * 5.0 + px) * 1.5; - const bGrad = ctx.createRadialGradient(px + s / 2, py + s / 2, 1, px + s / 2, py + s / 2, Math.max(3, s * 0.42 + flamePulse)); - bGrad.addColorStop(0, '#fef08a'); - bGrad.addColorStop(0.4, '#f97316'); - bGrad.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = bGrad; - ctx.beginPath(); - ctx.arc(px + s / 2, py + s / 2, Math.max(3, s * 0.42 + flamePulse), 0, Math.PI * 2); - ctx.fill(); -} - -function drawPlayerEntity(ctx, cx, cy, s, t) { - const r = Math.max(4, s * 0.45); - ctx.fillStyle = '#10b981'; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#a7f3d0'; - ctx.lineWidth = 1.8; - ctx.stroke(); - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.arc(cx, cy, Math.max(1.5, r * 0.35), 0, Math.PI * 2); - ctx.fill(); -} - -function drawBossEntity(ctx, cx, cy, s, t) { - const r = Math.max(5, s * 0.55); - const vPulse = Math.sin(t * 3.5) * 2; - const bGrad = ctx.createRadialGradient(cx, cy, 2, cx, cy, r + 4 + vPulse); - bGrad.addColorStop(0, '#c084fc'); - bGrad.addColorStop(0.6, '#7e22ce'); - bGrad.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = bGrad; - ctx.beginPath(); - ctx.arc(cx, cy, r + 4 + vPulse, 0, Math.PI * 2); - ctx.fill(); - - ctx.fillStyle = '#9333ea'; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#f5d0fe'; - ctx.lineWidth = 2; - ctx.stroke(); - - ctx.fillStyle = '#ffffff'; - ctx.font = `bold ${Math.max(7, Math.floor(s * 0.55))}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('👹', cx, cy); -} - +const root = typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : global); +if (typeof window === 'undefined') root.window = root; + +export const FogState = Object.freeze({ + UNEXPLORED: 0, + EXPLORED_FOGGED: 1, + VISIBLE: 2 +}); + +export class WarFogRenderer { + constructor() { + this.mapWidth = 0; + this.mapHeight = 0; + this.dirty = true; + this.shroudColor = 'rgba(2, 6, 23, 1.0)'; // 0 = UNEXPLORED (pitch black) + this.fogColor = 'rgba(2, 6, 23, 0.55)'; // 1 = EXPLORED_FOGGED (semi-transparent shadow) + this.horizonOuterRadius = 8.5; // Radius for smooth radial falloff + this.horizonInnerRadius = 6.8; + } + + init(width, height) { + this.mapWidth = width || root.currentMapWidth || 0; + this.mapHeight = height || root.currentMapHeight || 0; + this.dirty = true; + } + + isEntityVisible(wx, wy) { + if (!root.currentMapGrid && !root.fogGrid) return true; + const tx = Math.floor(wx), ty = Math.floor(wy); + if (root.WarFog && typeof root.WarFog.getFogState === 'function') { + return root.WarFog.getFogState(tx, ty) === FogState.VISIBLE; + } + if (root.fogGrid && this.mapWidth > 0 && this.mapHeight > 0) { + if (tx < 0 || tx >= this.mapWidth || ty < 0 || ty >= this.mapHeight) return false; + return root.fogGrid[ty * this.mapWidth + tx] === FogState.VISIBLE; + } + return true; + } + + render(ctx, camera, viewport) { + if (!ctx || (viewport && (viewport.clientWidth <= 0 || viewport.clientHeight <= 0))) return; + const width = this.mapWidth || root.currentMapWidth || 0; + const height = this.mapHeight || root.currentMapHeight || 0; + if (width <= 0 || height <= 0) return; + + const fogGrid = root.fogGrid; + const warFog = root.WarFog; + if (!fogGrid && (!warFog || typeof warFog.getFogState !== 'function')) return; + + const vpW = (viewport && viewport.clientWidth) ? viewport.clientWidth : 390; + const vpH = (viewport && viewport.clientHeight) ? viewport.clientHeight : 844; + const halfVpW = vpW * 0.5; + const halfVpH = vpH * 0.5; + const camX = (camera && Number.isFinite(camera.wx)) ? camera.wx : 0; + const camY = (camera && Number.isFinite(camera.wy)) ? camera.wy : 0; + + // Viewport frustum culling: calculate tile bounds intersecting screen (+2 tile margin) + const Rw = Math.ceil((vpW / 64) + (vpH / 32)) + 2; + const minTx = Math.max(0, Math.floor(camX - Rw)); + const maxTx = Math.min(width - 1, Math.ceil(camX + Rw)); + const minTy = Math.max(0, Math.floor(camY - Rw)); + const maxTy = Math.min(height - 1, Math.ceil(camY + Rw)); + if (minTx > maxTx || minTy > maxTy) return; + + let hasUnexplored = false; + let hasFogged = false; + + // Pass 1: Build Unexplored Shroud Path (State 0: Pitch Black) + ctx.save(); + ctx.beginPath(); + for (let ty = minTy; ty <= maxTy; ty++) { + const rowOffset = ty * width; + for (let tx = minTx; tx <= maxTx; tx++) { + const state = warFog ? warFog.getFogState(tx, ty) : fogGrid[rowOffset + tx]; + if (state !== FogState.UNEXPLORED) continue; + + const relX = tx - camX; + const relY = ty - camY; + const sx = (relX - relY) * 32 + halfVpW; + if (sx < -36 || sx > vpW + 36) continue; + const sy = (relX + relY) * 16 + halfVpH; + if (sy < -20 || sy > vpH + 36) continue; + + hasUnexplored = true; + ctx.moveTo(sx, sy - 0.5); + ctx.lineTo(sx + 32.5, sy + 16); + ctx.lineTo(sx, sy + 32.5); + ctx.lineTo(sx - 32.5, sy + 16); + ctx.closePath(); + } + } + if (hasUnexplored) { + ctx.fillStyle = this.shroudColor; + ctx.fill(); + } + + // Pass 2: Build Explored Fogged Veil Path (State 1: 55% Semi-Transparent Shadow) + ctx.beginPath(); + for (let ty = minTy; ty <= maxTy; ty++) { + const rowOffset = ty * width; + for (let tx = minTx; tx <= maxTx; tx++) { + const state = warFog ? warFog.getFogState(tx, ty) : fogGrid[rowOffset + tx]; + if (state !== FogState.EXPLORED_FOGGED) continue; + + const relX = tx - camX; + const relY = ty - camY; + const sx = (relX - relY) * 32 + halfVpW; + if (sx < -36 || sx > vpW + 36) continue; + const sy = (relX + relY) * 16 + halfVpH; + if (sy < -20 || sy > vpH + 36) continue; + + hasFogged = true; + ctx.moveTo(sx, sy - 0.5); + ctx.lineTo(sx + 32.5, sy + 16); + ctx.lineTo(sx, sy + 32.5); + ctx.lineTo(sx - 32.5, sy + 16); + ctx.closePath(); + } + } + if (hasFogged) { + ctx.fillStyle = this.fogColor; + ctx.fill(); + } + + // Pass 3: Smooth Radial Horizon Falloff (Blends 2.5D Isometric Vision Perimeter) + const pl = root.player; + if (pl && Number.isFinite(pl.wx) && Number.isFinite(pl.wy)) { + const pRelX = pl.wx - camX; + const pRelY = pl.wy - camY; + const plSx = (pRelX - pRelY) * 32 + halfVpW; + const plSy = (pRelX + pRelY) * 16 + halfVpH; + + ctx.save(); + ctx.translate(plSx, plSy); + ctx.scale(1.0, 0.5); // 2:1 isometric aspect + const rInner = this.horizonInnerRadius * 32; + const rOuter = this.horizonOuterRadius * 32; + const halo = ctx.createRadialGradient(0, 0, rInner, 0, 0, rOuter); + halo.addColorStop(0, 'rgba(2, 6, 23, 0.0)'); + halo.addColorStop(1, 'rgba(2, 6, 23, 0.45)'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, rOuter, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + ctx.restore(); + } +} + +// Backward compatibility helper for procedural modal simulator +export function renderProceduralMap(ctx, state) { + if (!ctx || !state) return; + const { gridSize = 32, tilePx = 14, biome = { color: '#272522', floor: '#3a3630' }, mapTiles = [], fogMatrix = [] } = state; + ctx.fillStyle = '#030508'; + ctx.fillRect(0, 0, 480, 480); + const offX = Math.floor((480 - gridSize * tilePx) / 2); + const offY = Math.floor((480 - gridSize * tilePx) / 2); + for (let y = 0; y < gridSize; y++) { + for (let x = 0; x < gridSize; x++) { + const fog = fogMatrix[y] ? fogMatrix[y][x] : 0; + if (fog === 0) continue; + const tile = mapTiles[y] ? mapTiles[y][x] : 0; + const px = offX + x * tilePx, py = offY + y * tilePx; + ctx.fillStyle = tile === 0 ? (biome.color || '#272522') : (biome.floor || '#3a3630'); + ctx.fillRect(px, py, tilePx, tilePx); + if (fog === 1) { + ctx.fillStyle = 'rgba(3, 7, 18, 0.72)'; + ctx.fillRect(px, py, tilePx, tilePx); + } + } + } +} + +const defaultWarFogRenderer = new WarFogRenderer(); +root.WarFogRenderer = WarFogRenderer; +root.defaultWarFogRenderer = defaultWarFogRenderer; +root.FogState = FogState; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { WarFogRenderer, defaultWarFogRenderer, FogState, renderProceduralMap }; +} + +export default defaultWarFogRenderer;