380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Ground Item Rendering & Glint Sparkle Test Suite
|
|
*
|
|
* Requirements (Issue #389):
|
|
* 1. Isometric depth sorting & entity queue integration.
|
|
* 2. Adaptive item & gold icon silhouettes with bottom-center anchor and shadows.
|
|
* 3. Authentic quality colors: normal, magic, rare, set, unique, craft, rune, gold.
|
|
* 4. Periodic 4-point star glint & sparkle animation with sine easing.
|
|
* 5. Parabolic bounce vertical translation.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
drawGroundItem,
|
|
GROUND_ITEM_QUALITY_COLORS,
|
|
} from '../src/scene/act-scene.ts'
|
|
import type { GroundItemEntity } from '../src/game/ground-items.ts'
|
|
import type { SpriteRenderer } from '../src/render/renderer.ts'
|
|
|
|
describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', () => {
|
|
const createMockRenderer = () => {
|
|
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
const renderer = {
|
|
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
|
drawnQuads.push({ x, y, w, h, color })
|
|
},
|
|
draw(frame: any, x: number, y: number, options: any) {
|
|
drawnSprites.push({ frame, x, y, options })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
|
|
return { renderer, drawnQuads, drawnSprites }
|
|
}
|
|
|
|
describe('1. GROUND_ITEM_QUALITY_COLORS Palette Parity', () => {
|
|
it('provides distinct, authentic RGBA color tuples for all D2 quality tiers', () => {
|
|
const requiredQualities = ['normal', 'low', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune', 'gold']
|
|
for (const q of requiredQualities) {
|
|
const col = GROUND_ITEM_QUALITY_COLORS[q]
|
|
expect(col).toBeDefined()
|
|
expect(col!.length).toBe(4)
|
|
for (const c of col!) {
|
|
expect(c).toBeGreaterThanOrEqual(0)
|
|
expect(c).toBeGreaterThanOrEqual(0)
|
|
expect(c).toBeLessThanOrEqual(1)
|
|
}
|
|
}
|
|
|
|
// Magic is blue
|
|
expect(GROUND_ITEM_QUALITY_COLORS.magic![2]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.magic![0])
|
|
// Rare is yellow (red & green high, blue low)
|
|
expect(GROUND_ITEM_QUALITY_COLORS.rare![0]).toBeGreaterThan(0.8)
|
|
expect(GROUND_ITEM_QUALITY_COLORS.rare![1]).toBeGreaterThan(0.8)
|
|
// Set is green
|
|
expect(GROUND_ITEM_QUALITY_COLORS.set![1]).toBeGreaterThan(GROUND_ITEM_QUALITY_COLORS.set![0])
|
|
// Unique is gold/tan
|
|
expect(GROUND_ITEM_QUALITY_COLORS.unique![0]).toBeGreaterThan(0.7)
|
|
})
|
|
})
|
|
|
|
describe('2. Gold Pile Rendering & Scaling', () => {
|
|
it('draws medium gold pile (50 gold) using authentic flpgld_1 sprite and ground shadow', () => {
|
|
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
const goldItem: GroundItemEntity = {
|
|
id: 'gold_small',
|
|
item: { isGold: true },
|
|
name: '50 Gold',
|
|
nameZh: '50 金币',
|
|
quality: 'normal',
|
|
isGold: true,
|
|
amount: 50,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
dropTime: 0,
|
|
x: 200,
|
|
y: 150,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
// Use a timestamp outside the sparkle window (e.g. 1000ms in a 2000ms cycle)
|
|
drawGroundItem(renderer, goldItem, 1000)
|
|
|
|
// Exactly 1 ground contact shadow quad and 1 authentic flippy sprite
|
|
expect(drawnQuads.length).toBe(1)
|
|
expect(drawnSprites.length).toBe(1)
|
|
|
|
const shadow = drawnQuads[0]!
|
|
expect(shadow.y).toBe(150 - 2)
|
|
expect(shadow.color[3]).toBeLessThan(0.5)
|
|
|
|
const sprite = drawnSprites[0]!
|
|
expect(sprite.options.width).toBe(26)
|
|
expect(sprite.options.height).toBe(13)
|
|
})
|
|
|
|
it('scales gold pile sprite dimensions for large piles (>= 500 gold -> flpgld_2 32x20)', () => {
|
|
const { renderer, drawnSprites } = createMockRenderer()
|
|
const largeGold: GroundItemEntity = {
|
|
id: 'gold_large',
|
|
item: { isGold: true },
|
|
name: '10000 Gold',
|
|
nameZh: '10000 金币',
|
|
quality: 'normal',
|
|
isGold: true,
|
|
amount: 10000,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
dropTime: 0,
|
|
x: 300,
|
|
y: 250,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
drawGroundItem(renderer, largeGold, 1000)
|
|
expect(drawnSprites.length).toBe(1)
|
|
const sprite = drawnSprites[0]!
|
|
expect(sprite.options.width).toBe(32)
|
|
expect(sprite.options.height).toBe(20)
|
|
})
|
|
})
|
|
|
|
describe('3. Equipment Item Rendering with Authentic Flippy Sprite & Bounce Offset', () => {
|
|
it('draws equipment item with ground shadow and authentic flippy sprite (zero procedural border quads)', () => {
|
|
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
const uniqueSword: GroundItemEntity = {
|
|
id: 'unique_sword',
|
|
item: { name: 'The Patriarch', code: 'gis' },
|
|
name: 'The Patriarch',
|
|
nameZh: '族长',
|
|
quality: 'unique',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 400,
|
|
y: 350,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
drawGroundItem(renderer, uniqueSword, 1000)
|
|
|
|
// Only 1 shadow quad (outside sparkle window) + 1 flippy sprite
|
|
expect(drawnQuads.length).toBe(1)
|
|
expect(drawnSprites.length).toBe(1)
|
|
expect(drawnQuads[0]!.y).toBe(350 - 2)
|
|
expect(drawnSprites[0]!.options.width).toBeGreaterThan(0)
|
|
expect(drawnSprites[0]!.options.height).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('translates vertical sprite rendering position upwards during parabolic bounce', () => {
|
|
const { renderer: rGround, drawnQuads: quadsGround, drawnSprites: spritesGround } = createMockRenderer()
|
|
const { renderer: rAir, drawnQuads: quadsAir, drawnSprites: spritesAir } = createMockRenderer()
|
|
|
|
const restingItem: GroundItemEntity = {
|
|
id: 'item_rest',
|
|
item: { code: 'hlm', name: 'Helm' },
|
|
name: 'Helm',
|
|
nameZh: '头盔',
|
|
quality: 'magic',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 200,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
const bouncingItem: GroundItemEntity = {
|
|
...restingItem,
|
|
id: 'item_bounce',
|
|
bounceState: {
|
|
startTime: 1000,
|
|
durationMs: 400,
|
|
peakHeightPx: 30,
|
|
},
|
|
}
|
|
|
|
// Draw resting item at t = 1200
|
|
drawGroundItem(rGround, restingItem, 1200)
|
|
// Draw bouncing item at apex (t = 1200, apex = 30px)
|
|
drawGroundItem(rAir, bouncingItem, 1200)
|
|
|
|
const groundSprite = spritesGround[0]!
|
|
const airSprite = spritesAir[0]!
|
|
|
|
// Shadow stays anchored on ground plane (item.y - 2 = 198)
|
|
expect(quadsGround[0]!.y).toBe(198)
|
|
expect(quadsAir[0]!.y).toBe(198)
|
|
|
|
// Air sprite y should be exactly ground sprite y - 30
|
|
expect(airSprite.y).toBe(groundSprite.y - 30)
|
|
})
|
|
})
|
|
|
|
describe('4. Glint & Sparkle 4-Point Star Animation', () => {
|
|
it('spawns 4-point star rays, core, and quality aura during the active flash window', () => {
|
|
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
const gem: GroundItemEntity = {
|
|
id: 'gem_ruby',
|
|
item: { code: 'gcr', name: 'Perfect Ruby' },
|
|
name: 'Perfect Ruby',
|
|
nameZh: '完美红宝石',
|
|
quality: 'rare',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
dropTime: 0,
|
|
x: 500,
|
|
y: 400,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
// At t = 160ms (middle of flash window 0..320ms, p = 0.5, intensity = 1.0)
|
|
drawGroundItem(renderer, gem, 160)
|
|
|
|
// Total quads: shadow (1) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 5
|
|
expect(drawnSprites.length).toBe(1)
|
|
expect(drawnQuads.length).toBe(5)
|
|
|
|
const horizontalRay = drawnQuads[1]!
|
|
const verticalRay = drawnQuads[2]!
|
|
const core = drawnQuads[3]!
|
|
const aura = drawnQuads[4]!
|
|
|
|
// Brilliant core color (near-white)
|
|
expect(core.color[0]).toBeGreaterThan(0.9)
|
|
expect(core.color[1]).toBeGreaterThan(0.9)
|
|
expect(core.color[3]).toBeCloseTo(1.0, 1)
|
|
|
|
// Aura matches rare quality color
|
|
expect(aura.color[0]).toBeCloseTo(GROUND_ITEM_QUALITY_COLORS.rare![0], 2)
|
|
|
|
// Rays are centered on sparkle point
|
|
expect(horizontalRay.h).toBe(2)
|
|
expect(verticalRay.w).toBe(2)
|
|
expect(horizontalRay.w).toBeGreaterThan(10)
|
|
expect(verticalRay.h).toBeGreaterThan(10)
|
|
})
|
|
|
|
it('suppresses sparkle rendering during the dormant period of the cycle', () => {
|
|
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
const item: GroundItemEntity = {
|
|
id: 'item_dormant',
|
|
item: { code: 'rin', name: 'Ring' },
|
|
name: 'Ring',
|
|
nameZh: '戒指',
|
|
quality: 'unique',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
dropTime: 0,
|
|
x: 200,
|
|
y: 200,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
// At t = 1000ms (far outside 0..320ms window)
|
|
drawGroundItem(renderer, item, 1000)
|
|
|
|
// Only ground shadow quad (1), no sparkle quads
|
|
expect(drawnSprites.length).toBe(1)
|
|
expect(drawnQuads.length).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => {
|
|
it('renders authentic item sprite using itemsAtlas instead of solid color box', () => {
|
|
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
|
|
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
|
|
const renderer = {
|
|
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
|
|
drawnQuads.push({ x, y, w, h, color })
|
|
},
|
|
draw(frame: any, x: number, y: number, options: any) {
|
|
drawnSprites.push({ frame, x, y, options })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
|
const magicSword: GroundItemEntity = {
|
|
id: 'magic_sword',
|
|
item: { name: 'Short Sword', code: 'ssd' },
|
|
name: 'Short Sword',
|
|
nameZh: '短剑',
|
|
quality: 'magic',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 200,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
drawGroundItem(renderer, magicSword, 1000, mockAtlas)
|
|
|
|
// Verified: Sprite is drawn using itemsAtlas
|
|
expect(drawnSprites.length).toBe(1)
|
|
const sprite = drawnSprites[0]!
|
|
expect(sprite.options.atlas).toBe(mockAtlas)
|
|
expect(sprite.options.width).toBeGreaterThan(0)
|
|
expect(sprite.options.height).toBeGreaterThan(0)
|
|
// Drop shadow is drawn on ground
|
|
expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true)
|
|
// Crucial: No opaque blue body box drawn in quads
|
|
const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
|
|
expect(blueBody).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('6. Authentic Ground Slanted Quad Projection (D2 1.13c Ground Truth)', () => {
|
|
it('calls renderer.drawQuad with 2:1 isometric ground foreshortened height and shear angle', () => {
|
|
const drawnQuads: any[] = []
|
|
const renderer = {
|
|
drawSolid() {},
|
|
drawQuad(frame: any, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, options: any) {
|
|
drawnQuads.push({ frame, x0, y0, x1, y1, x2, y2, x3, y3, options })
|
|
},
|
|
} as unknown as SpriteRenderer
|
|
|
|
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
|
|
const groundSword: GroundItemEntity = {
|
|
id: 'ground_sword',
|
|
item: { name: 'Broad Sword', code: 'bsd' },
|
|
name: 'Broad Sword',
|
|
nameZh: '阔剑',
|
|
quality: 'normal',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 300,
|
|
y: 250,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
drawGroundItem(renderer, groundSword, 1000, mockAtlas)
|
|
|
|
expect(drawnQuads.length).toBe(1)
|
|
const q = drawnQuads[0]!
|
|
// Bottom edge rests at baseY (y = 250)
|
|
expect(q.y2).toBe(250)
|
|
expect(q.y3).toBe(250)
|
|
// Top edge is above ground (y0 < 250)
|
|
expect(q.y0).toBeLessThan(250)
|
|
expect(q.y1).toBeLessThan(250)
|
|
// Top edge is sheared horizontally along 2:1 isometric diamond (x0 < x2)
|
|
expect(q.x0).toBeLessThan(q.x2)
|
|
expect(q.x1).toBeLessThan(q.x3)
|
|
// Slanted parallelogram: width of top edge equals width of bottom edge
|
|
expect(q.x1 - q.x0).toBe(q.x3 - q.x2)
|
|
})
|
|
})
|
|
})
|
|
|