diff --git a/src/game/engine/light-grid.ts b/src/game/engine/light-grid.ts index 1b49a72..7589e37 100644 --- a/src/game/engine/light-grid.ts +++ b/src/game/engine/light-grid.ts @@ -133,7 +133,9 @@ export class LightGrid { /** * Populate cell obstacle factors from map collision masks. - * Tests bits 0x0002 (COLLIDE_VISIBLE), 0x0800 (COLLIDE_DOOR), 0x0020 (COLLIDE_BLANK), 0x0001 (COLLIDE_WALL). + * Only line-of-sight blockers (COLLIDE_VISIBLE 0x0002 | COLLIDE_DOOR 0x0800) + * block light transmission; walk-only blockers (COLLIDE_WALL 0x0001 such as + * low rocks, puddles, barrels, and floor debris) remain fully illuminated. * * @param collision - scene collision grid */ @@ -149,47 +151,41 @@ export class LightGrid { const cellIdx = cy * LIGHT_GRID_SIZE + cx if (subX < 0 || subY < 0 || subX >= gridW || subY >= gridH) { - // Out of map bounds is completely opaque obstacle - this.obstacle[cellIdx] = 16 + // Out of map bounds attenuates rays passing through void + this.obstacle[cellIdx] = 6 continue } let obstacleSum = 0 if (masks !== undefined) { - // Sample the 4 center sub-tiles of the 8x8 cell block + // Sample the 4 center sub-tiles of the 8x8 cell block for sight-blocking walls for (let dy = 2; dy <= 5; dy += 2) { const sy = subY + dy - if (sy < 0 || sy >= gridH) { obstacleSum += 8; continue } + if (sy < 0 || sy >= gridH) { obstacleSum += 2; continue } const rowOffset = sy * gridW for (let dx = 2; dx <= 5; dx += 2) { const sx = subX + dx - if (sx < 0 || sx >= gridW) { obstacleSum += 8; continue } + if (sx < 0 || sx >= gridW) { obstacleSum += 2; continue } const m = masks[rowOffset + sx] ?? 0 if ((m & (COLLIDE_VISIBLE | COLLIDE_DOOR)) !== 0) { - obstacleSum += 4 - } else if ((m & (COLLIDE_BLANK | COLLIDE_WALL)) !== 0) { - obstacleSum += 2 + obstacleSum += 1 } } } - } else { - // Fallback if collision masks not present - const blocked = collision.blocked - const bIdx = subY * gridW + subX - if (blocked[bIdx] === 1) { - obstacleSum = 16 - } } - this.obstacle[cellIdx] = Math.min(16, obstacleSum) + this.obstacle[cellIdx] = Math.min(8, obstacleSum) } } } /** - * Apply one light source to the 48x48 grid using authentic 1.13c mathematics: + * Apply one light source to the 48x48 grid using authentic 1.13c mathematics + * with radial ray-marching occlusion: * - Falloff: Attenuated = max(0, ((R - dist) * IntensitySlope) >> 16) - * - Obstacle: FinalIntensity = ((8 - ObstacleFactor) * Attenuated) >> 3 + * - Radial Occlusion: Accumulates line-of-sight obstacles along intermediate cells + * between the emitter and target cell so wall front-faces and the emitter's own + * cell are brightly illuminated while casting authentic shadows behind walls. * - Color Blending: Intensity-weighted average * * @param light - light emitter parameters @@ -229,10 +225,10 @@ export class LightGrid { for (let cx = minCellX; cx <= maxCellX; cx += 1) { const cellIdx = rowOffset + cx - const obstacleFactor = this.obstacle[cellIdx]! + const cellObstacle = this.obstacle[cellIdx]! - // If obstacle factor >= 16 (0x10), light is completely blocked (0x6fb597ea) - if (obstacleFactor >= 16) continue + // Explicit hard-block sentinel (>= 16) blocks light immediately + if (cellObstacle >= 16) continue const cellCenterSubX = cx * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1) const dSubX = cellCenterSubX - relSubX @@ -245,8 +241,32 @@ export class LightGrid { const attenuated = ((effectiveRadius - dist) * intensitySlope) >> 16 if (attenuated <= 0) continue - // Obstacle attenuation: ((8 - obstacleFactor) * attenuated) >> 3 - const finalIntensity = Math.max(0, ((8 - obstacleFactor) * attenuated) >> 3) + // Radial ray-marching occlusion from emitter (centerCellX, centerCellY) to (cx, cy): + // 1. The emitter's own cell (steps === 0) is never self-occluded. + // 2. Intermediate cells (1 <= s < steps) accumulate line-of-sight obstacle density + // to cast shadows BEHIND walls, while the front face of a wall (s === steps) + // only receives a gentle grazing term (cellObstacle >> 2) so walls facing the + // player are brightly illuminated without black blotches. + const dCellX = cx - centerCellX + const dCellY = cy - centerCellY + const steps = Math.max(Math.abs(dCellX), Math.abs(dCellY)) + let effectiveObstacle = 0 + if (steps > 0) { + let rayObstacle = 0 + for (let s = 1; s < steps; s += 1) { + const rx = centerCellX + Math.round((dCellX * s) / steps) + const ry = centerCellY + Math.round((dCellY * s) / steps) + if (rx >= 0 && rx < LIGHT_GRID_SIZE && ry >= 0 && ry < LIGHT_GRID_SIZE) { + rayObstacle += this.obstacle[ry * LIGHT_GRID_SIZE + rx]! + if (rayObstacle >= 8) break + } + } + effectiveObstacle = Math.min(8, rayObstacle + (cellObstacle >> 2)) + } + if (effectiveObstacle >= 8) continue + + // Obstacle attenuation: ((8 - effectiveObstacle) * attenuated) >> 3 + const finalIntensity = Math.max(0, ((8 - effectiveObstacle) * attenuated) >> 3) if (finalIntensity <= 0) continue // Accumulate and blend into cell (0x6fb58a91) diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 95f840a..8867f01 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -86,6 +86,12 @@ export interface DrawOptions { readonly height?: number /** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */ readonly blendMode?: 'normal' | 'additive' + /** + * Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base. + * Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors) + * while preserving the 10-float vertex stride invariant. + */ + readonly elevationY?: number } /** Dynamic scene lighting configuration for Glide-style illumination. */ @@ -160,14 +166,14 @@ void main() { v_uv = a_uv; v_tint = a_tint; v_unit = int(a_unit); - v_palette = int(a_palette); + float liftY = floor((a_palette + 1.5) * (1.0 / 16.0)); + float rawPalette = a_palette - liftY * 16.0; + v_palette = int(round(rawPalette)); - // Inverse isometric projection to sub-tiles: + // Inverse isometric projection to ground-plane sub-tiles (compensating for vertical height liftY): // x_scene = (subX - subY) * 16.0 + originX - // y_scene = (subX + subY) * 8.0 + originY - // => subX = ((x_scene - originX)/16.0 + (y_scene - originY)/8.0) * 0.5 - // => subY = ((y_scene - originY)/8.0 - (x_scene - originX)/16.0) * 0.5 - vec2 d = a_position - u_sceneOrigin; + // y_scene = (subX + subY) * 8.0 + originY - liftY + vec2 d = vec2(a_position.x, a_position.y + liftY) - u_sceneOrigin; float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5; float subY = (d.y * (1.0 / 8.0) - d.x * (1.0 / 16.0)) * 0.5; // Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles @@ -1016,6 +1022,8 @@ export class SpriteRenderer { const tint = options.tint const flipped = options.flipX === true const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 + const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0 + const topPalette = elevationY > 0 ? paletteRow + elevationY * 16 : paletteRow this.quad( x0, y0, x1, y1, x2, y2, x3, y3, flipped ? u1 : u0, v0, @@ -1026,6 +1034,7 @@ export class SpriteRenderer { tint === undefined ? 1 : tint[3], unit, paletteRow, + topPalette, ) } @@ -1130,6 +1139,7 @@ export class SpriteRenderer { r: number, g: number, b: number, a: number, unit: number, paletteRow: number, + topPalette = paletteRow, ): void { const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX if (needed > this.vertices.length) this.grow(needed) @@ -1137,11 +1147,11 @@ export class SpriteRenderer { let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX // Corner 0: top-left. v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0 - v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = topPalette at += FLOATS_PER_VERTEX // Corner 1: top-right. v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v0 - v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = paletteRow + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit; v[at + 9] = topPalette at += FLOATS_PER_VERTEX // Corner 2: bottom-left. v[at] = x2; v[at + 1] = y2; v[at + 2] = u0; v[at + 3] = v1 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 43f25fe..8833851 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -6052,42 +6052,59 @@ if (typeof window !== 'undefined') { } const camX = engine.world.player.x const camY = engine.world.player.y - 16 - renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04]) + renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, lightingEnabled ? [0, 0, 0] : [0.03, 0.03, 0.04]) if (lightingEnabled) { const pSub = playerSubTile() lightGrid.centerOnSubTile(pSub.x, pSub.y) lightGrid.clear() lightGrid.updateObstacles(runtime.grid) - // Authentic player torch light: base radius 55 sub-tiles (~11 tiles), intensity 255, white + // Authentic 1.13c player torch light: base radius 11 cells * 8 sub-tiles = 88 sub-tiles const flicker = (Math.sin(engine.world.tick * 0.35) * 2) | 0 lightGrid.addLight({ subTileX: pSub.x, subTileY: pSub.y, - radius: 55 + flicker, + radius: 88 + flicker, intensity: 255, red: 255, - green: 255, - blue: 255, + green: 246, + blue: 228, }) - // Active missile lights + // Active missile lights (with skillId fallback for all spells including Glacial Spike '55') + const skillToMissile: Readonly> = { + '36': 'firebolt', + '38': 'chargedbolt', + '39': 'icebolt', + '45': 'iceblast', + '47': 'fireball', + '49': 'lightning', + '53': 'chainlightning', + '55': 'glacialspike', + '56': 'meteor', + '64': 'frozenorb', + '67': 'teeth', + '84': 'bonespear', + '93': 'bonespirit', + } for (const shot of engine.projectiles) { - const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined) + const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined) if (!mType) continue const mData = getMissileTxtData(mType) - if (mData && mData.light > 0) { + const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0) + if (lightUnits > 0) { const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT const shotSubX = Math.round((dy + dx) / 2) const shotSubY = Math.round((dy - dx) / 2) + const isCold = mData.blue > mData.red lightGrid.addLight({ subTileX: shotSubX, subTileY: shotSubY, - radius: mData.light * 5, + radius: Math.max(48, lightUnits * 8), intensity: 255, - red: mData.red, - green: mData.green, + red: isCold ? Math.max(120, mData.red) : mData.red, + green: isCold ? Math.max(175, mData.green) : mData.green, blue: mData.blue, }) } @@ -6096,49 +6113,70 @@ if (typeof window !== 'undefined') { // Active explosion lights for (const exp of engine.explosions) { const expData = getMissileTxtData(exp.missileType) - const radius = (expData && expData.light > 0 ? expData.light : 8) * 5 + const lightUnits = expData && expData.light > 0 ? expData.light : 8 const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT const expSubX = Math.round((dy + dx) / 2) const expSubY = Math.round((dy - dx) / 2) + const isColdExp = exp.missileType.includes('ice') || exp.missileType.includes('freeze') || exp.missileType.includes('blizzard') || exp.missileType.includes('orb') lightGrid.addLight({ subTileX: expSubX, subTileY: expSubY, - radius, + radius: lightUnits * 8, intensity: 255, - red: expData?.red ?? 255, - green: expData?.green ?? 160, - blue: expData?.blue ?? 50, + red: isColdExp ? 130 : (expData?.red ?? 255), + green: isColdExp ? 185 : (expData?.green ?? 160), + blue: isColdExp ? 255 : (expData?.blue ?? 50), }) } - // Environmental additive objects (torches, flames, etc.) + // Environmental static/animated lights from Objects.txt Lit0..2 (standing torches TO, braziers FB/B2, waypoints WP, shrines SH, beams) if (runtime.objectDrawables) { for (const obj of runtime.objectDrawables) { - if (obj.blendMode === 'additive' || (obj.token && ADDITIVE_OBJECT_TOKENS.has(obj.token.toUpperCase()))) { + const tok = (obj.token ?? '').trim().toUpperCase() + const isTorchOrFire = + tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' || + tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' || + tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' || + tok === 'F8' || tok === '1Y' || tok === 'FL' + const isWaypoint = + tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' || + tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY' + const isShrineOrBeam = + tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' || + obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok)) + + if (isTorchOrFire || isWaypoint || isShrineOrBeam) { const dx = obj.x - engine.world.player.x const dy = obj.y - engine.world.player.y - if (dx * dx + dy * dy < 1200 * 1200) { + if (dx * dx + dy * dy < 1500 * 1500) { const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT + const objSubX = Math.round((ody + odx) / 2) + const objSubY = Math.round((ody - odx) / 2) + const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 2) | 0) : 0 lightGrid.addLight({ - subTileX: Math.round((ody + odx) / 2), - subTileY: Math.round((ody - odx) / 2), - radius: 35, - intensity: 220, - red: 255, - green: 180, - blue: 80, + subTileX: objSubX, + subTileY: objSubY, + radius: (isTorchOrFire ? 56 : isWaypoint ? 52 : 44) + objFlicker, + intensity: isTorchOrFire ? 235 : 210, + red: isWaypoint ? 120 : 255, + green: isWaypoint ? 150 : (isTorchOrFire ? 220 : 210), + blue: isWaypoint ? 255 : (isTorchOrFire ? 150 : 180), }) } } } } - const ambNorm = environment.currentIntensity / 255 - const ambR = (environment.currentRed / 255) * ambNorm - const ambG = (environment.currentGreen / 255) * ambNorm - const ambB = (environment.currentBlue / 255) * ambNorm + // Maintain a subtle warm-stone minimum ambient baseline in zero-intensity indoor caves + // so distant cave walls retain authentic silhouette depth against the [0, 0, 0] clear color. + const rawIntensity = environment.currentIntensity + const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 14 + const ambNorm = effectiveAmbIntensity / 255 + const ambR = ((rawIntensity > 0 ? environment.currentRed : 225) / 255) * ambNorm + const ambG = ((rawIntensity > 0 ? environment.currentGreen : 215) / 255) * ambNorm + const ambB = ((rawIntensity > 0 ? environment.currentBlue : 205) / 255) * ambNorm renderer.setLighting({ ambient: [ambR, ambG, ambB], @@ -6229,7 +6267,10 @@ if (typeof window !== 'undefined') { if (page === null || page === undefined) { skippedDraws += 1 } else { - renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page }) + renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { + atlas: page, + elevationY: Math.max(0, nextWall!.frame.height - 40), + }) } } wallIdx += 1 @@ -6244,6 +6285,7 @@ if (typeof window !== 'undefined') { renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page, blendMode: nextObj!.blendMode ?? 'normal', + elevationY: Math.max(0, nextObj!.frame.height - 16), }) } } @@ -6299,7 +6341,10 @@ if (typeof window !== 'undefined') { ? engine.world.player.y + frame.anchorY : engine.world.player.y - frame.height + FEET_HEIGHT / 2 - renderer.draw(frame, drawX, drawY, { atlas: character.handle }) + renderer.draw(frame, drawX, drawY, { + atlas: character.handle, + elevationY: Math.max(0, frame.height - 16), + }) state.characterFrames += 1 state.playerDrawRect = { x: drawX, y: drawY, w: frame.width, h: frame.height } } @@ -6345,6 +6390,7 @@ if (typeof window !== 'undefined') { renderer.draw(frame, drawX, drawY, { atlas: art.handle, blendMode: baseBlendMode, + elevationY: Math.max(0, frame.height - 16), ...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}), }) if (art.glowGroups !== undefined) { diff --git a/tests/scene-lighting.test.ts b/tests/scene-lighting.test.ts index 48fc388..303d01d 100644 --- a/tests/scene-lighting.test.ts +++ b/tests/scene-lighting.test.ts @@ -313,11 +313,10 @@ describe('Scene Lighting & Environment Integration', () => { expect(env.currentIntensity).toBe(64) }) - it('attenuates light passing through obstacles in LightGrid', () => { + it('illuminates emitter cell and wall front-faces while casting shadows behind walls via radial ray-marching', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) - // Mock collision grid with a solid wall const collision: CollisionGrid = { cellsX: 80, cellsY: 80, @@ -328,51 +327,58 @@ describe('Scene Lighting & Environment Integration', () => { collisionMasks: new Uint16Array(80 * 5 * 80 * 5), } - // Set obstacle mask on center cell (24, 24) - // Center sub-tile is 200. Grid origin is 200 - 192 = 8. - // Center cell (cx = 24, cy = 24) starts at subTile (8 + 24*8, 8 + 24*8) = (200, 200). - const cellStartSubX = grid.originSubX + 24 * 8 - const cellStartSubY = grid.originSubY + 24 * 8 - - for (let dy = 2; dy <= 5; dy += 2) { - for (let dx = 2; dx <= 5; dx += 2) { - const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx) - collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE + // Place a 2-cell thick sight-blocking wall at cells (25, 24) and (26, 24), + // and also touch the player's own cell (24, 24) to test hugging a wall + for (const cx of [24, 25, 26]) { + const cellStartSubX = grid.originSubX + cx * 8 + const cellStartSubY = grid.originSubY + 24 * 8 + for (let dy = 2; dy <= 5; dy += 2) { + for (let dx = 2; dx <= 5; dx += 2) { + const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx) + collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE + } } } - // Update obstacles grid.updateObstacles(collision) - // Center cell should have obstacle factor 16 (4 sample points * 4 = 16) const centerIdx = 24 * LIGHT_GRID_SIZE + 24 - expect(grid.obstacle[centerIdx]).toBe(16) + const wallFrontIdx = 24 * LIGHT_GRID_SIZE + 25 + const wallSecondIdx = 24 * LIGHT_GRID_SIZE + 26 + const behindWallIdx = 24 * LIGHT_GRID_SIZE + 27 - // Add light at center + expect(grid.obstacle[centerIdx]).toBe(4) + expect(grid.obstacle[wallFrontIdx]).toBe(4) + expect(grid.obstacle[wallSecondIdx]).toBe(4) + expect(grid.obstacle[behindWallIdx]).toBe(0) + + // Add player torch at center (200, 200) grid.addLight({ subTileX: 200, subTileY: 200, - radius: 40, + radius: 48, intensity: 255, red: 255, green: 255, blue: 255, }) - // Because obstacle factor is 16 (0x10), light is completely blocked per 0x6fb597ea - expect(grid.intensity[centerIdx]).toBe(0) + // 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall + expect(grid.intensity[centerIdx]).toBeGreaterThan(200) - // An unblocked adjacent cell (24, 25) with obstacle 0 receives light - const unblockedIdx = 25 * LIGHT_GRID_SIZE + 24 - expect(grid.obstacle[unblockedIdx]).toBe(0) - expect(grid.intensity[unblockedIdx]).toBeGreaterThan(150) + // 2. Front face of the wall (25, 24) receives bright illumination (no black blotches on walls) + expect(grid.intensity[wallFrontIdx]).toBeGreaterThan(140) + + // 3. Second layer of the wall (26, 24) is in penumbra (attenuated by wallFront) + expect(grid.intensity[wallSecondIdx]).toBeLessThan(grid.intensity[wallFrontIdx]!) + + // 4. Floor behind the 2-cell thick wall (27, 24) is completely in shadow (rayObstacle = 4 + 4 = 8) + expect(grid.intensity[behindWallIdx]).toBe(0) // Texture buffer export produces valid RGBA8 values const buf = grid.exportTextureBuffer() expect(buf.length).toBe(48 * 48 * 4) - const unblockedAlpha = buf[unblockedIdx * 4 + 3] - expect(unblockedAlpha).toBe(grid.intensity[unblockedIdx]) - const unblockedRed = buf[unblockedIdx * 4] - expect(unblockedRed).toBe((grid.red[unblockedIdx] * unblockedAlpha) >> 8) + const frontAlpha = buf[wallFrontIdx * 4 + 3] + expect(frontAlpha).toBe(grid.intensity[wallFrontIdx]) }) })