import { describe, expect, it } from 'vitest' import { getRoofBounds, getCombinedRoofBounds, isPointInRoofBounds, boundsOverlap, getPlayerBounds, isPlayerOverlappingRoofBounds, isPlayerInRoofCell, isPlayerInRoofSubTile, isPlayerUnderRoof, stepRoofAlpha, clusterRoofs, RoofFadeController, DEFAULT_MIN_ROOF_ALPHA, DEFAULT_MAX_ROOF_ALPHA, DEFAULT_ROOF_FADE_DURATION_MS, type RoofDrawable, } from '../src/render/roofs.ts' /** Helper to create a minimal roof tile. */ function makeRoof( x: number, y: number, width: number, height: number, cellX?: number, cellY?: number, ): RoofDrawable { return { x, y, frame: { width, height }, ...(cellX !== undefined ? { cellX } : {}), ...(cellY !== undefined ? { cellY } : {}), } } describe('Roof Bounding Detection', () => { it('computes correct bounds for a single roof tile', () => { const roof = makeRoof(100, 200, 80, 60) const bounds = getRoofBounds(roof) expect(bounds).toEqual({ minX: 100, minY: 200, maxX: 180, maxY: 260, }) }) it('computes correct bounds with negative coordinates and offsets', () => { const roof = makeRoof(-50, -30, 100, 70) const bounds = getRoofBounds(roof) expect(bounds).toEqual({ minX: -50, minY: -30, maxX: 50, maxY: 40, }) }) it('returns null for combined bounds of empty roof list', () => { expect(getCombinedRoofBounds([])).toBeNull() }) it('computes combined bounding box enclosing multiple roof tiles', () => { const roofs = [ makeRoof(100, 150, 40, 40), makeRoof(130, 180, 50, 30), makeRoof(80, 200, 30, 60), ] const combined = getCombinedRoofBounds(roofs) expect(combined).toEqual({ minX: 80, minY: 150, maxX: 180, // 130 + 50 maxY: 260, // 80..110, 200 + 60 }) }) it('detects whether a 2D point is inside roof bounds', () => { const bounds = { minX: 50, minY: 100, maxX: 150, maxY: 200 } // Inside expect(isPointInRoofBounds(100, 150, bounds)).toBe(true) // Boundaries (inclusive) expect(isPointInRoofBounds(50, 100, bounds)).toBe(true) expect(isPointInRoofBounds(150, 200, bounds)).toBe(true) // Outside expect(isPointInRoofBounds(49, 150, bounds)).toBe(false) expect(isPointInRoofBounds(151, 150, bounds)).toBe(false) expect(isPointInRoofBounds(100, 99, bounds)).toBe(false) expect(isPointInRoofBounds(100, 201, bounds)).toBe(false) }) it('detects bounding box overlap and non-overlap', () => { const boxA = { minX: 0, minY: 0, maxX: 100, maxY: 100 } // Fully overlapping expect(boundsOverlap(boxA, { minX: 20, minY: 20, maxX: 80, maxY: 80 })).toBe(true) // Partially overlapping expect(boundsOverlap(boxA, { minX: 50, minY: 50, maxX: 150, maxY: 150 })).toBe(true) // Touching edge expect(boundsOverlap(boxA, { minX: 100, minY: 0, maxX: 200, maxY: 100 })).toBe(true) // Separated in X expect(boundsOverlap(boxA, { minX: 101, minY: 0, maxX: 200, maxY: 100 })).toBe(false) // Separated in Y expect(boundsOverlap(boxA, { minX: 0, minY: 101, maxX: 100, maxY: 200 })).toBe(false) }) }) describe('Player-Under-Roof Condition', () => { const roof = makeRoof(100, 100, 80, 80, 5, 10) describe('bounding box overlap', () => { it('detects player point inside roof bounds', () => { expect(isPlayerOverlappingRoofBounds({ x: 140, y: 140 }, roof)).toBe(true) }) it('detects player sprite overlapping lower edge of roof', () => { // Player feet at y=190, sprite extends upward to y=142, overlapping roof [100..180] expect(isPlayerOverlappingRoofBounds({ x: 140, y: 190 }, roof)).toBe(true) }) it('returns false when player is far away from roof', () => { expect(isPlayerOverlappingRoofBounds({ x: 500, y: 500 }, roof)).toBe(false) expect(isPlayerOverlappingRoofBounds({ x: 20, y: 50 }, roof)).toBe(false) }) it('supports explicit player bounding box', () => { expect(isPlayerOverlappingRoofBounds({ left: 110, top: 120, right: 130, bottom: 160 }, roof)).toBe(true) expect(isPlayerOverlappingRoofBounds({ left: 300, top: 300, right: 320, bottom: 340 }, roof)).toBe(false) }) }) describe('cell and sub-tile coverage', () => { it('matches player cell coordinates with roof tile cell', () => { expect(isPlayerInRoofCell({ x: 5, y: 10 }, roof)).toBe(true) expect(isPlayerInRoofCell({ x: 4, y: 10 }, roof)).toBe(false) expect(isPlayerInRoofCell({ x: 5, y: 9 }, roof)).toBe(false) }) it('matches player cell against an array of roof tiles', () => { const roofTiles = [ makeRoof(0, 0, 80, 40, 5, 10), makeRoof(0, 0, 80, 40, 5, 11), makeRoof(0, 0, 80, 40, 6, 10), ] expect(isPlayerInRoofCell({ x: 5, y: 11 }, roofTiles)).toBe(true) expect(isPlayerInRoofCell({ x: 6, y: 10 }, roofTiles)).toBe(true) expect(isPlayerInRoofCell({ x: 7, y: 12 }, roofTiles)).toBe(false) }) it('detects player sub-tile coverage (5x5 sub-tiles per cell)', () => { // Cell (5, 10) corresponds to sub-tiles [25..29, 50..54] expect(isPlayerInRoofSubTile({ x: 25, y: 50 }, roof)).toBe(true) expect(isPlayerInRoofSubTile({ x: 29, y: 54 }, roof)).toBe(true) expect(isPlayerInRoofSubTile({ x: 27, y: 52 }, roof)).toBe(true) // Adjacent sub-tile in cell (4, 10) or (5, 9) expect(isPlayerInRoofSubTile({ x: 24, y: 50 }, roof)).toBe(false) expect(isPlayerInRoofSubTile({ x: 25, y: 49 }, roof)).toBe(false) }) it('combines cell coverage and bounding box in isPlayerUnderRoof', () => { // Cell matches even if screen point is outside expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 5, y: 10 })).toBe(true) // Bounding box matches even if cell is different expect(isPlayerUnderRoof({ x: 140, y: 140 }, roof, { x: 99, y: 99 })).toBe(true) // Neither matches expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 99, y: 99 })).toBe(false) }) }) describe('shelter clustering', () => { it('groups adjacent roof tiles into a single shelter cluster', () => { const akaraTent = [ makeRoof(100, 100, 80, 80, 10, 10), makeRoof(140, 120, 80, 80, 10, 11), makeRoof(120, 140, 80, 80, 11, 10), ] const clusters = clusterRoofs(akaraTent) expect(clusters).toHaveLength(1) expect(clusters[0]!.roofs).toHaveLength(3) expect(clusters[0]!.cells.has('10,10')).toBe(true) expect(clusters[0]!.cells.has('10,11')).toBe(true) expect(clusters[0]!.cells.has('11,10')).toBe(true) }) it('separates distant roof groups into distinct shelters', () => { const akaraTent = [ makeRoof(100, 100, 80, 80, 10, 10), makeRoof(140, 120, 80, 80, 10, 11), ] const charsiForge = [ makeRoof(800, 800, 80, 80, 50, 50), makeRoof(840, 820, 80, 80, 50, 51), ] const clusters = clusterRoofs([...akaraTent, ...charsiForge]) expect(clusters).toHaveLength(2) const clusterA = clusters.find(c => c.cells.has('10,10'))! const clusterB = clusters.find(c => c.cells.has('50,50'))! expect(clusterA.roofs).toHaveLength(2) expect(clusterB.roofs).toHaveLength(2) }) }) }) describe('Roof Opacity Fade', () => { describe('stepRoofAlpha', () => { it('fades down towards minAlpha over durationMs', () => { // 200ms total duration, from 1.0 to 0.25 const start = 1.0 const target = 0.25 // After 50ms (25% progress) -> 1.0 - 0.75 * 0.25 = 0.8125 const step1 = stepRoofAlpha(start, target, 50, 200, 0.25, 1.0) expect(step1).toBeCloseTo(0.8125, 4) // After 100ms (50% progress) -> 1.0 - 0.75 * 0.5 = 0.625 const step2 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0) expect(step2).toBeCloseTo(0.625, 4) // After 200ms (100% progress) -> exactly 0.25 const step3 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0) expect(step3).toBe(0.25) // Clamps and does not overshoot below minAlpha const step4 = stepRoofAlpha(0.25, target, 100, 200, 0.25, 1.0) expect(step4).toBe(0.25) }) it('fades up towards maxAlpha when exiting shelter', () => { const start = 0.25 const target = 1.0 // After 100ms -> halfway back to 1.0 (0.625) const step1 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0) expect(step1).toBeCloseTo(0.625, 4) // After 200ms -> full 1.0 const step2 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0) expect(step2).toBe(1.0) // Clamps at maxAlpha const step3 = stepRoofAlpha(1.0, target, 50, 200, 0.25, 1.0) expect(step3).toBe(1.0) }) it('smoothly reverses direction mid-fade without discontinuities', () => { // Player enters shelter, fades down partially to 0.70 let alpha = 1.0 alpha = stepRoofAlpha(alpha, 0.25, 80, 200, 0.25, 1.0) expect(alpha).toBeCloseTo(0.70, 4) // Player immediately steps back out: smoothly fades back up towards 1.0 alpha = stepRoofAlpha(alpha, 1.0, 40, 200, 0.25, 1.0) expect(alpha).toBeGreaterThan(0.70) expect(alpha).toBeCloseTo(0.85, 4) // Reaches 1.0 alpha = stepRoofAlpha(alpha, 1.0, 100, 200, 0.25, 1.0) expect(alpha).toBe(1.0) }) it('returns currentAlpha when deltaMs is zero or negative', () => { expect(stepRoofAlpha(0.5, 0.25, 0, 200)).toBe(0.5) expect(stepRoofAlpha(0.5, 0.25, -10, 200)).toBe(0.5) }) it('snaps immediately to target when durationMs is zero', () => { expect(stepRoofAlpha(1.0, 0.25, 16, 0)).toBe(0.25) }) }) describe('RoofFadeController', () => { const akaraTent = [ makeRoof(100, 100, 80, 80, 10, 10), makeRoof(140, 120, 80, 80, 10, 11), ] const charsiForge = [ makeRoof(800, 800, 80, 80, 50, 50), ] it('initializes all roofs to full opacity (1.0)', () => { const controller = new RoofFadeController([...akaraTent, ...charsiForge]) expect(controller.getAlpha(0)).toBe(1.0) expect(controller.getAlpha(1)).toBe(1.0) expect(controller.getAlpha(2)).toBe(1.0) expect(controller.overallAlpha).toBe(1.0) expect(controller.isPlayerUnderAnyRoof).toBe(false) }) it('fades only the shelter the player is under', () => { const controller = new RoofFadeController([...akaraTent, ...charsiForge], { fadeDurationMs: 200, minAlpha: 0.25, }) // Player stands inside Akara's tent at cell (10, 10) const playerPos = { x: 140, y: 140 } const playerCell = { x: 10, y: 10 } // Advance by 100ms (halfway) controller.update(playerPos, 100, playerCell) expect(controller.isPlayerUnderAnyRoof).toBe(true) // Akara's tent tiles (indices 0 and 1) fade in unison expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4) expect(controller.getAlpha(1)).toBeCloseTo(0.625, 4) // Charsi's forge (index 2) remains fully opaque expect(controller.getAlpha(2)).toBe(1.0) // Advance by another 100ms (reaches minAlpha 0.25) controller.update(playerPos, 100, playerCell) expect(controller.getAlpha(0)).toBe(0.25) expect(controller.getAlpha(1)).toBe(0.25) expect(controller.getAlpha(2)).toBe(1.0) expect(controller.overallAlpha).toBe(0.25) }) it('smoothly restores full opacity when player exits shelter', () => { const controller = new RoofFadeController(akaraTent, { fadeDurationMs: 200, minAlpha: 0.25, }) // Player inside -> fully faded controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 }) expect(controller.getAlpha(0)).toBe(0.25) // Player exits shelter to town square const outsidePos = { x: 500, y: 500 } const outsideCell = { x: 25, y: 25 } // 100ms elapsed controller.update(outsidePos, 100, outsideCell) expect(controller.isPlayerUnderAnyRoof).toBe(false) expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4) // 200ms elapsed -> fully restored controller.update(outsidePos, 100, outsideCell) expect(controller.getAlpha(0)).toBe(1.0) expect(controller.overallAlpha).toBe(1.0) }) it('handles empty roof list safely', () => { const controller = new RoofFadeController([]) controller.update({ x: 100, y: 100 }, 16) expect(controller.getAlpha(0)).toBe(1.0) expect(controller.overallAlpha).toBe(1.0) expect(controller.isPlayerUnderAnyRoof).toBe(false) }) it('allows manual alpha override via setAlpha', () => { const controller = new RoofFadeController(akaraTent) controller.setAlpha(0.2) expect(controller.getAlpha(0)).toBe(0.2) expect(controller.getAlpha(1)).toBe(0.2) }) }) })