feat(render): authentic ground item rendering and glint sparkles (Fixes #389)

This commit is contained in:
troytt 2026-09-23 02:27:31 +00:00
parent 2c5c5855f6
commit 2733ba625b
2 changed files with 385 additions and 5 deletions

View File

@ -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<string, readonly [number, number, number, number]> = {
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) {

View File

@ -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)
})
})
})