318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Inventory Full Flippy Bounce & Feedback Tests (Issue #392).
|
|
*
|
|
* Requirements:
|
|
* 1. Inventory Full Gate: pickup refused when no grid space, item preserved on ground, metrics incremented.
|
|
* 2. Authentic Parabolic Flippy Bounce:
|
|
* - Primary phase: duration ~350ms, peak height ~28px.
|
|
* - Secondary phase: duration ~175ms, peak height ~7px.
|
|
* - Resting state after secondary bounce completes.
|
|
* 3. Airborne Flippy Tumble & Grounded Shadow:
|
|
* - Shadow stays on the ground plane (item.y) while item rises to baseY = item.y - bounceH.
|
|
* - Width oscillation during airborne tumble.
|
|
* 4. Audio-Visual Feedback & Notification:
|
|
* - "包裹已满。" status & center banner notification.
|
|
* - Audio refusal cue invoked.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
|
import {
|
|
calculateBounceHeight,
|
|
triggerFlippyBounce,
|
|
GroundItemManager,
|
|
type GroundItemEntity,
|
|
} from '../src/game/ground-items.ts'
|
|
import { SceneMouseController, drawGroundItem } from '../src/scene/act-scene.ts'
|
|
import type { SpriteRenderer } from '../src/render/renderer.ts'
|
|
|
|
function createSmallEngine() {
|
|
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
|
return new GameEngine(terrain, {
|
|
spawn: { x: 500, y: 500 },
|
|
stats: [],
|
|
xpTable: DEMO_EXPERIENCE,
|
|
skills: DEMO_SKILLS,
|
|
questDefs: DEMO_QUESTS,
|
|
combatOptions: {
|
|
playerSpeed: 4,
|
|
playerReach: 50,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 5,
|
|
playerManaPerAttack: 1,
|
|
respawnTicks: 100,
|
|
},
|
|
talkRadius: 48,
|
|
pickupRadius: 48,
|
|
inventoryCols: 2,
|
|
inventoryRows: 2, // 2x2 = 4 slots total
|
|
lootSeed: 1234,
|
|
npcDefs: [],
|
|
})
|
|
}
|
|
|
|
describe('Issue #392 — Inventory Full Flippy Bounce & Feedback Ground Truth', () => {
|
|
describe('1. Inventory Full Gate & Refusal Mechanics', () => {
|
|
it('refuses pickup, preserves item on ground, increments inventoryRefusals and triggers bounce', () => {
|
|
const engine = createSmallEngine()
|
|
// Fill the 2x2 bag with two 1x2 items
|
|
engine.bag.add({ id: 'item1', name: 'Item 1', invWidth: 1, invHeight: 2 } as any)
|
|
engine.bag.add({ id: 'item2', name: 'Item 2', invWidth: 1, invHeight: 2 } as any)
|
|
expect(engine.bag.contents.length).toBe(2)
|
|
|
|
// Drop another 1x2 item
|
|
const groundItem = engine.dropItem(
|
|
{ id: 'overflow-axe', name: 'Axe', nameZh: '巨斧', invWidth: 1, invHeight: 2 },
|
|
510,
|
|
510,
|
|
)
|
|
expect(engine.groundItems.count).toBe(1)
|
|
const initialRefusals = engine.metrics.inventoryRefusals
|
|
|
|
// Attempt pickup
|
|
const res = engine.pickupItem(groundItem.id)
|
|
expect(res.success).toBe(false)
|
|
expect(res.reason).toBe('full')
|
|
expect(res.item).toBe(groundItem)
|
|
|
|
// Item remains on ground without deletion
|
|
expect(engine.groundItems.count).toBe(1)
|
|
expect(engine.metrics.inventoryRefusals).toBe(initialRefusals + 1)
|
|
|
|
// Bounce state is triggered with authentic 1.13c properties
|
|
expect(groundItem.bounceState).toBeDefined()
|
|
expect(groundItem.bounceState!.phase).toBe('primary')
|
|
expect(groundItem.bounceState!.durationMs).toBe(350)
|
|
expect(groundItem.bounceState!.peakHeightPx).toBe(28)
|
|
})
|
|
})
|
|
|
|
describe('2. Parabolic Flippy Bounce Trajectory Math', () => {
|
|
it('calculates symmetrical parabolic height curve h(t) peaking at midpoint', () => {
|
|
const bounce = {
|
|
startTime: 1000,
|
|
durationMs: 350,
|
|
peakHeightPx: 28,
|
|
phase: 'primary' as const,
|
|
}
|
|
|
|
// At start (t = 1000): height is 0
|
|
expect(calculateBounceHeight(bounce, 1000)).toBe(0)
|
|
|
|
// At apex midpoint (t = 1175): height is peakHeightPx (28px)
|
|
expect(calculateBounceHeight(bounce, 1175)).toBeCloseTo(28, 2)
|
|
|
|
// At quarter point (p = 0.25): 4 * 28 * 0.25 * 0.75 = 21px
|
|
expect(calculateBounceHeight(bounce, 1000 + 350 * 0.25)).toBeCloseTo(21, 2)
|
|
|
|
// At three-quarter point (p = 0.75): 4 * 28 * 0.75 * 0.25 = 21px
|
|
expect(calculateBounceHeight(bounce, 1000 + 350 * 0.75)).toBeCloseTo(21, 2)
|
|
|
|
// At completion (t >= 1350): height returns to 0
|
|
expect(calculateBounceHeight(bounce, 1350)).toBe(0)
|
|
expect(calculateBounceHeight(bounce, 1500)).toBe(0)
|
|
})
|
|
|
|
it('handles two-phase decay: primary -> secondary -> rested', () => {
|
|
const mgr = new GroundItemManager()
|
|
const item: GroundItemEntity = {
|
|
id: 'test_item',
|
|
item: {},
|
|
name: 'Item',
|
|
nameZh: '物品',
|
|
quality: 'normal',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
dropTime: 0,
|
|
x: 100,
|
|
y: 100,
|
|
cellX: 3,
|
|
cellY: 3,
|
|
sparklePhase: 0,
|
|
}
|
|
mgr.addEntity(item)
|
|
|
|
// Trigger flippy bounce at t = 1000
|
|
mgr.triggerBounce('test_item', 1000)
|
|
expect(item.bounceState?.phase).toBe('primary')
|
|
expect(item.bounceState?.peakHeightPx).toBe(28)
|
|
expect(item.bounceState?.durationMs).toBe(350)
|
|
|
|
// Step during primary bounce (t = 1200)
|
|
mgr.update(1200, 200)
|
|
expect(item.bounceState?.phase).toBe('primary')
|
|
|
|
// Step past primary bounce (t = 1360): transitions to secondary bounce
|
|
mgr.update(1360, 160)
|
|
expect(item.bounceState?.phase).toBe('secondary')
|
|
expect(item.bounceState?.peakHeightPx).toBe(7) // 25% of 28 = 7
|
|
expect(item.bounceState?.durationMs).toBe(175) // 50% of 350 = 175
|
|
|
|
// Step past secondary bounce (t = 1550): rested on ground
|
|
mgr.update(1550, 190)
|
|
expect(item.bounceState).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('3. Airborne Rendering & Grounded Shadow Anchoring', () => {
|
|
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 }
|
|
}
|
|
|
|
it('anchors drop shadow on ground plane (item.y - 2) while flippy sprite rises to baseY', () => {
|
|
const { renderer, drawnQuads, drawnSprites } = createMockRenderer()
|
|
const item: GroundItemEntity = {
|
|
id: 'flippy_helm',
|
|
item: { code: 'ghm', name: 'Great Helm' },
|
|
name: 'Great Helm',
|
|
nameZh: '高级头盔',
|
|
quality: 'rare',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
dropTime: 0,
|
|
x: 300,
|
|
y: 400,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
bounceState: {
|
|
startTime: 1000,
|
|
durationMs: 350,
|
|
peakHeightPx: 28,
|
|
phase: 'primary',
|
|
},
|
|
}
|
|
|
|
// Render at apex (t = 1175, bounceH = 28px)
|
|
drawGroundItem(renderer, item, 1175)
|
|
|
|
const shadow = drawnQuads[0]!
|
|
const sprite = drawnSprites[0]!
|
|
|
|
// Shadow y remains anchored at ground plane item.y - 2 = 398
|
|
expect(shadow.y).toBe(item.y - 2)
|
|
|
|
// Sprite y rises by bounceH (28px): baseY - baseH = (400 - 28) - sprite.options.height
|
|
expect(sprite.y).toBe(item.y - 28 - sprite.options.height)
|
|
})
|
|
|
|
it('attenuates ground shadow alpha proportionally to airborne bounce height', () => {
|
|
const { renderer: rGround, drawnQuads: qGround } = createMockRenderer()
|
|
const { renderer: rAir, drawnQuads: qAir } = createMockRenderer()
|
|
|
|
const baseItem: GroundItemEntity = {
|
|
id: 'tumble_item',
|
|
item: { code: 'qui', name: 'Quilted Armor' },
|
|
name: 'Armor',
|
|
nameZh: '盔甲',
|
|
quality: 'normal',
|
|
isGold: false,
|
|
amount: 1,
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
dropTime: 0,
|
|
x: 200,
|
|
y: 200,
|
|
cellX: 0,
|
|
cellY: 0,
|
|
sparklePhase: 0,
|
|
}
|
|
|
|
// Resting item
|
|
drawGroundItem(rGround, baseItem, 1000)
|
|
const restShadow = qGround[0]!
|
|
expect(restShadow.color[3]).toBeCloseTo(0.38, 2)
|
|
|
|
// Airborne bouncing item at apex (t = 1175, bounceH = 28px)
|
|
const airborneItem: GroundItemEntity = {
|
|
...baseItem,
|
|
bounceState: {
|
|
startTime: 1000,
|
|
durationMs: 350,
|
|
peakHeightPx: 28,
|
|
phase: 'primary',
|
|
},
|
|
}
|
|
|
|
drawGroundItem(rAir, airborneItem, 1175)
|
|
const airShadow = qAir[0]!
|
|
expect(airShadow.color[3]).toBeLessThan(restShadow.color[3])
|
|
expect(airShadow.color[3]).toBeGreaterThanOrEqual(0.1)
|
|
})
|
|
})
|
|
|
|
describe('4. Audio-Visual Feedback & Notification Integration', () => {
|
|
let mockStatus: { textContent: string }
|
|
let mockCanvas: any
|
|
|
|
beforeEach(() => {
|
|
mockStatus = { textContent: '' }
|
|
mockCanvas = {
|
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
}
|
|
})
|
|
|
|
it('triggers 包裹已满 notification and audio refusal when picking up with full inventory', () => {
|
|
const engine = createSmallEngine()
|
|
// Fill bag completely
|
|
engine.bag.add({ id: 'f1', name: 'F1', invWidth: 1, invHeight: 2 } as any)
|
|
engine.bag.add({ id: 'f2', name: 'F2', invWidth: 1, invHeight: 2 } as any)
|
|
|
|
const dropped = engine.dropItem(
|
|
{ id: 'full-shield', name: 'Large Shield', nameZh: '大盾牌', invWidth: 2, invHeight: 2 },
|
|
515,
|
|
500,
|
|
)
|
|
|
|
const controller = new SceneMouseController({
|
|
canvas: mockCanvas,
|
|
engine,
|
|
camera: { zoom: 1 } as any,
|
|
input: {
|
|
takeLeftClick: () => null,
|
|
takeRightClick: () => null,
|
|
shiftHeld: false,
|
|
movement: () => ({ x: 0, y: 0 }),
|
|
} as any,
|
|
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
|
hudManager: null,
|
|
waypointNetwork: null as any,
|
|
status: mockStatus as any,
|
|
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
|
getCharacter: () => null,
|
|
})
|
|
|
|
const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback')
|
|
const notificationSpy = vi.spyOn(controller, 'showNotification')
|
|
|
|
// Execute pickup on full bag
|
|
controller.pickupGroundItem(dropped)
|
|
|
|
expect(mockStatus.textContent).toBe('包裹已满。')
|
|
expect(feedbackSpy).toHaveBeenCalled()
|
|
expect(notificationSpy).toHaveBeenCalledWith('包裹已满。')
|
|
expect(dropped.bounceState).toBeDefined()
|
|
expect(dropped.bounceState!.phase).toBe('primary')
|
|
expect(engine.metrics.inventoryRefusals).toBe(1)
|
|
})
|
|
})
|
|
})
|