diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 0ce9ae4..6fd2a09 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -47,7 +47,7 @@ import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData } from '../game/skills.ts' import type { Projectile } from '../game/skills.ts' -import { findSafeDropPosition } from '../game/ground-items.ts' +import { findSafeDropPosition, calculateBounceHeight, type GroundItemEntity } from '../game/ground-items.ts' import type { AtlasFrame } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle } from '../render/renderer.ts' @@ -3018,6 +3018,105 @@ export function drawFireExplosion(renderer: SpriteRenderer, exp: ActiveExplosion } } +/** Quality colors for ground item highlights and glints. */ +export const GROUND_ITEM_QUALITY_COLORS: Record = { + normal: [0.88, 0.88, 0.88, 1.0], + low: [0.55, 0.55, 0.55, 1.0], + superior: [0.95, 0.95, 0.95, 1.0], + magic: [0.35, 0.52, 1.0, 1.0], + rare: [1.0, 0.92, 0.22, 1.0], + set: [0.12, 0.95, 0.12, 1.0], + unique: [0.85, 0.72, 0.42, 1.0], + craft: [1.0, 0.62, 0.12, 1.0], + rune: [1.0, 0.62, 0.12, 1.0], + gold: [1.0, 0.84, 0.2, 1.0], +} + +/** + * Render a ground item entity with authentic Diablo II 1.13c appearance: + * - Parabolic bounce height offset (when dropping or inventory full) + * - Ground item icon / scaled silhouette + * - Periodic glint/sparkle 4-point star animation + */ +export function drawGroundItem( + renderer: SpriteRenderer, + item: GroundItemEntity, + now: number, +): void { + const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0 + const baseY = item.y - bounceH + + const qColor = item.isGold + ? GROUND_ITEM_QUALITY_COLORS.gold! + : (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!) + + // 1. Gold Rendering + if (item.isGold) { + let w = 10 + let h = 8 + if (item.amount >= 5000) { + w = 18 + h = 13 + } else if (item.amount >= 100) { + w = 14 + h = 10 + } + + // Shadow on ground + renderer.drawSolid(item.x - w / 2 - 1, baseY - 2, w + 2, 4, [0, 0, 0, 0.35]) + + // Gold coin pile body + renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [0.92, 0.75, 0.18, 1.0]) + // Highlight glint coin rim + renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0]) + } else { + // 2. Equipment / Potion / Rune Item Rendering + const w = item.invWidth > 1 ? 22 : 14 + const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14 + + // Drop shadow + renderer.drawSolid(item.x - w / 2 - 2, baseY - 3, w + 4, 5, [0, 0, 0, 0.38]) + + // Body + const qr = qColor[0] + const qg = qColor[1] + const qb = qColor[2] + renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9]) + // Border rim + renderer.drawSolid(item.x - w / 2, baseY - h, w, 2, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x - w / 2, baseY - 2, w, 2, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x - w / 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x + w / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) + } + + // 3. Glint / Sparkle 4-Point Star Animation + // Flash every ~2000ms with a pseudo-random phase offset based on coordinates + const period = 2000 + const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period + const cycleTime = (now + phaseOffset) % period + const flashDuration = 320 + + if (cycleTime < flashDuration) { + const p = cycleTime / flashDuration + // Sine bell curve for smooth brightening and fading + const intensity = Math.sin(p * Math.PI) + if (intensity > 0.05) { + const gx = item.x + (item.invWidth > 1 ? 3 : 0) + const gy = baseY - (item.isGold ? 6 : 12) + const rayLen = Math.round(3 + 6 * intensity) + + // Horizontal ray + renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9]) + // Vertical ray + renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9]) + // Brilliant central core + renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity]) + // Subtle quality aura + renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35]) + } + } +} + /** * Run the loop for a runtime. * @@ -4096,13 +4195,21 @@ if (typeof window !== 'undefined') { }) } - for (const entry of engine.ground) { - pushEntity(entry.x, entry.y, () => { - const size = 6 - renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1]) + for (const gItem of engine.groundItems.all) { + pushEntity(gItem.x, gItem.y, () => { + drawGroundItem(renderer, gItem, renderStarted) }) } + if (engine.groundItems.count === 0 && engine.ground.length > 0) { + for (const entry of engine.ground) { + pushEntity(entry.x, entry.y, () => { + const size = 6 + renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1]) + }) + } + } + entities.sort((a, b) => a.depth - b.depth) for (const entity of entities) { diff --git a/tests/ground-items-render.test.ts b/tests/ground-items-render.test.ts new file mode 100644 index 0000000..709113c --- /dev/null +++ b/tests/ground-items-render.test.ts @@ -0,0 +1,273 @@ +/** + * 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 renderer = { + drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { + drawnQuads.push({ x, y, w, h, color }) + }, + } as unknown as SpriteRenderer + + return { renderer, drawnQuads } + } + + 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).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 small gold pile (< 100 gold) with shadow, body, and coin rim', () => { + const { renderer, drawnQuads } = 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) + + // At least 3 quads: shadow, body, highlight rim + expect(drawnQuads.length).toBeGreaterThanOrEqual(3) + + const shadow = drawnQuads[0]! + expect(shadow.w).toBe(12) // w + 2 = 10 + 2 = 12 + expect(shadow.color[3]).toBeLessThan(0.5) // shadow alpha + + const body = drawnQuads[1]! + expect(body.w).toBe(10) + expect(body.h).toBe(8) + // Gold color + expect(body.color[0]).toBeGreaterThan(0.8) + expect(body.color[1]).toBeGreaterThan(0.6) + }) + + it('scales gold pile dimensions for large piles (>= 5000 gold)', () => { + const { renderer, drawnQuads } = 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) + const body = drawnQuads[1]! + expect(body.w).toBe(18) + expect(body.h).toBe(13) + }) + }) + + describe('3. Equipment Item Rendering with Quality Framing & Bounce Offset', () => { + it('draws equipment item with shadow, quality body, and 4 border edges', () => { + const { renderer, drawnQuads } = createMockRenderer() + const uniqueSword: GroundItemEntity = { + id: 'unique_sword', + item: { name: 'The Patriarch' }, + 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) + + // Shadow + body + 4 border edges = 6 quads + expect(drawnQuads.length).toBeGreaterThanOrEqual(6) + + const body = drawnQuads[1]! + const topBorder = drawnQuads[2]! + // Unique color tint + const uniqueColor = GROUND_ITEM_QUALITY_COLORS.unique! + expect(topBorder.color[0]).toBeCloseTo(uniqueColor[0], 2) + expect(topBorder.color[1]).toBeCloseTo(uniqueColor[1], 2) + }) + + it('translates vertical rendering position upwards during parabolic bounce', () => { + const { renderer: rGround, drawnQuads: quadsGround } = createMockRenderer() + const { renderer: rAir, drawnQuads: quadsAir } = createMockRenderer() + + const restingItem: GroundItemEntity = { + id: 'item_rest', + item: {}, + 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 groundBody = quadsGround[1]! + const airBody = quadsAir[1]! + + // air body y should be exactly ground body y - 30 + expect(airBody.y).toBe(groundBody.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 } = createMockRenderer() + const gem: GroundItemEntity = { + id: 'gem_ruby', + item: {}, + 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: base item (6) + sparkle (horizontal ray, vertical ray, core, aura = 4) = 10 + expect(drawnQuads.length).toBe(10) + + const horizontalRay = drawnQuads[6]! + const verticalRay = drawnQuads[7]! + const core = drawnQuads[8]! + const aura = drawnQuads[9]! + + // 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) + + // 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 } = createMockRenderer() + const item: GroundItemEntity = { + id: 'item_dormant', + item: {}, + 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 base item quads (6), no sparkle quads + expect(drawnQuads.length).toBe(6) + }) + }) +})