feat(render): authentic ground item rendering and glint sparkles (Fixes #389)
This commit is contained in:
parent
2c5c5855f6
commit
2733ba625b
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue