Merge branch 'issue-392' into main (Fixes #392)
This commit is contained in:
commit
a31997a2b7
|
|
@ -14,7 +14,7 @@ import { getMonsterTreasureClass } from './monsters.ts'
|
|||
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
|
||||
import { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
import { GroundItemManager } from './ground-items.ts'
|
||||
import { GroundItemManager, triggerFlippyBounce } from './ground-items.ts'
|
||||
import type { GroundItemEntity, GroundItemSource } from './ground-items.ts'
|
||||
|
||||
export interface EngineInput {
|
||||
|
|
@ -634,6 +634,7 @@ export class GameEngine {
|
|||
const placed = this.bag.add(entity.item as any)
|
||||
if (placed === null) {
|
||||
this.metrics.inventoryRefusals += 1
|
||||
triggerFlippyBounce(entity, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
return { success: false, reason: 'full', item: entity }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,24 @@ export function calculateBounceHeight(bounceState: GroundItemBounceState, now: n
|
|||
return 4 * bounceState.peakHeightPx * p * (1 - p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger authentic Diablo II v1.13c parabolic flippy bounce animation on a ground item.
|
||||
* - Primary phase: duration ~350ms, peak height ~28px.
|
||||
* - Secondary phase: duration ~175ms, peak height ~7px.
|
||||
*/
|
||||
export function triggerFlippyBounce(
|
||||
entity: GroundItemEntity,
|
||||
now: number = typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
options?: Partial<GroundItemBounceState>,
|
||||
): void {
|
||||
entity.bounceState = {
|
||||
startTime: now,
|
||||
durationMs: options?.durationMs ?? 350,
|
||||
peakHeightPx: options?.peakHeightPx ?? 28,
|
||||
phase: options?.phase ?? 'primary',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radial spiral neighbor search around origin (D2Common.dll ITEM_Drop parity).
|
||||
* Searches radius 0, 1, 2, ... up to maxRadius for the first walkable cell
|
||||
|
|
@ -291,6 +309,12 @@ export class GroundItemManager {
|
|||
return entity
|
||||
}
|
||||
|
||||
/** Add an existing ground item entity. */
|
||||
addEntity(entity: GroundItemEntity): GroundItemEntity {
|
||||
this.items.set(entity.id, entity)
|
||||
return entity
|
||||
}
|
||||
|
||||
/** Remove a ground item by id (e.g. when picked up). */
|
||||
remove(id: string): GroundItemEntity | undefined {
|
||||
const entity = this.items.get(id)
|
||||
|
|
@ -370,4 +394,12 @@ export class GroundItemManager {
|
|||
const h = calculateBounceHeight(item.bounceState, now)
|
||||
return { x: 0, y: -h }
|
||||
}
|
||||
|
||||
/** Trigger flippy bounce by ground item ID. */
|
||||
triggerBounce(id: string, now?: number, options?: Partial<GroundItemBounceState>): boolean {
|
||||
const item = this.items.get(id)
|
||||
if (!item) return false
|
||||
triggerFlippyBounce(item, now, options)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, calculateBounceHeight, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
GroundLabelOverlay,
|
||||
computeInitialGroundLabelLayouts,
|
||||
|
|
@ -1366,7 +1366,9 @@ export class SceneMouseController {
|
|||
const placed = this.hudManager.inventory.autoPlaceInGrid(item.item)
|
||||
if (!placed) {
|
||||
this.engine.metrics.inventoryRefusals += 1
|
||||
this.status.textContent = '包裹已满。'
|
||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
this.playInventoryFullFeedback()
|
||||
this.showNotification('包裹已满。')
|
||||
return
|
||||
}
|
||||
this.engine.groundItems.remove(item.id)
|
||||
|
|
@ -1376,7 +1378,8 @@ export class SceneMouseController {
|
|||
} else {
|
||||
const res = this.engine.pickupItem(item.id)
|
||||
if (!res.success && res.reason === 'full') {
|
||||
this.status.textContent = '包裹已满。'
|
||||
this.playInventoryFullFeedback()
|
||||
this.showNotification('包裹已满。')
|
||||
} else if (res.success) {
|
||||
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
|
||||
}
|
||||
|
|
@ -1391,6 +1394,91 @@ export class SceneMouseController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play authentic Diablo II audio rejection feedback when inventory is full.
|
||||
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||
*/
|
||||
playInventoryFullFeedback(): void {
|
||||
try {
|
||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
||||
if (AudioCtx) {
|
||||
const ctx = new AudioCtx()
|
||||
// 1. Refusal tone (low pitch descending rejection cue)
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sawtooth'
|
||||
osc.frequency.setValueAtTime(175, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.28)
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.3)
|
||||
|
||||
// 2. Delayed light ground touch thud upon primary bounce landing (~350ms)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ctx.state !== 'closed') {
|
||||
const thudOsc = ctx.createOscillator()
|
||||
const thudGain = ctx.createGain()
|
||||
thudOsc.type = 'triangle'
|
||||
thudOsc.frequency.setValueAtTime(110, ctx.currentTime)
|
||||
thudOsc.frequency.exponentialRampToValueAtTime(45, ctx.currentTime + 0.08)
|
||||
thudGain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1)
|
||||
thudOsc.connect(thudGain)
|
||||
thudGain.connect(ctx.destination)
|
||||
thudOsc.start()
|
||||
thudOsc.stop(ctx.currentTime + 0.1)
|
||||
}
|
||||
} catch {
|
||||
// Ignore closed audio context
|
||||
}
|
||||
}, 350)
|
||||
}
|
||||
} catch {
|
||||
// AudioContext unavailable or blocked
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
|
||||
*/
|
||||
showNotification(message: string, durationMs = 2000): void {
|
||||
if (this.status) {
|
||||
this.status.textContent = message
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
let banner = document.getElementById('d2-center-notification')
|
||||
if (!banner && document.body) {
|
||||
banner = document.createElement('div')
|
||||
banner.id = 'd2-center-notification'
|
||||
banner.style.position = 'fixed'
|
||||
banner.style.top = '18%'
|
||||
banner.style.left = '50%'
|
||||
banner.style.transform = 'translateX(-50%)'
|
||||
banner.style.color = '#e03030'
|
||||
banner.style.fontFamily = 'monospace, sans-serif'
|
||||
banner.style.fontSize = '20px'
|
||||
banner.style.fontWeight = 'bold'
|
||||
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
|
||||
banner.style.pointerEvents = 'none'
|
||||
banner.style.zIndex = '99999'
|
||||
banner.style.transition = 'opacity 0.4s ease-out'
|
||||
document.body.appendChild(banner)
|
||||
}
|
||||
if (banner) {
|
||||
banner.textContent = message
|
||||
banner.style.opacity = '1'
|
||||
clearTimeout((banner as any)._timeout)
|
||||
;(banner as any)._timeout = setTimeout(() => {
|
||||
if (banner) banner.style.opacity = '0'
|
||||
}, durationMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playStashOpenSound(): void {
|
||||
try {
|
||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
||||
|
|
@ -3238,8 +3326,9 @@ export function drawGroundItem(
|
|||
h = 10
|
||||
}
|
||||
|
||||
// Shadow on ground
|
||||
renderer.drawSolid(item.x - w / 2 - 1, baseY - 2, w + 2, 4, [0, 0, 0, 0.35])
|
||||
// Shadow on ground: stays on ground plane (item.y - 2), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.35 * (1 - bounceH / 45)) : 0.35
|
||||
renderer.drawSolid(item.x - w / 2 - 1, item.y - 2, w + 2, 4, [0, 0, 0, shadowAlpha])
|
||||
|
||||
// Gold coin pile body
|
||||
renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [0.92, 0.75, 0.18, 1.0])
|
||||
|
|
@ -3250,19 +3339,29 @@ export function drawGroundItem(
|
|||
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])
|
||||
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
|
||||
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
|
||||
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
|
||||
|
||||
// Authentic flippy tumble width modulation during vertical bounce
|
||||
let renderW = w
|
||||
if (bounceH > 0 && item.bounceState) {
|
||||
const elapsed = now - item.bounceState.startTime
|
||||
// Spin oscillation creating flipping card / tumbling item illusion
|
||||
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
|
||||
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
|
||||
}
|
||||
|
||||
// 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])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 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])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
|
||||
}
|
||||
|
||||
// 3. Glint / Sparkle 4-Point Star Animation
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* 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 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 }
|
||||
}
|
||||
|
||||
it('anchors drop shadow on ground plane (item.y) while item rises to baseY', () => {
|
||||
const { renderer, drawnQuads } = createMockRenderer()
|
||||
const item: GroundItemEntity = {
|
||||
id: 'flippy_helm',
|
||||
item: {},
|
||||
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)
|
||||
|
||||
// Quad 0 is shadow, Quad 1 is body
|
||||
const shadow = drawnQuads[0]!
|
||||
const body = drawnQuads[1]!
|
||||
|
||||
// Shadow y remains anchored at ground plane item.y - 3 = 397
|
||||
expect(shadow.y).toBe(item.y - 3)
|
||||
|
||||
// Body y rises by bounceH (28px): baseY - h = (400 - 28) - 24 = 348
|
||||
expect(body.y).toBe(item.y - 28 - 24)
|
||||
})
|
||||
|
||||
it('modulates item width to produce an authentic airborne tumble illusion', () => {
|
||||
const { renderer: rGround, drawnQuads: qGround } = createMockRenderer()
|
||||
const { renderer: rAir, drawnQuads: qAir } = createMockRenderer()
|
||||
|
||||
const baseItem: GroundItemEntity = {
|
||||
id: 'tumble_item',
|
||||
item: {},
|
||||
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 restBody = qGround[1]!
|
||||
expect(restBody.w).toBe(22) // base width for invWidth > 1
|
||||
|
||||
// Airborne bouncing item at t = 1087 (spin cos ~ 0)
|
||||
const airborneItem: GroundItemEntity = {
|
||||
...baseItem,
|
||||
bounceState: {
|
||||
startTime: 1000,
|
||||
durationMs: 350,
|
||||
peakHeightPx: 28,
|
||||
phase: 'primary',
|
||||
},
|
||||
}
|
||||
|
||||
drawGroundItem(rAir, airborneItem, 1087)
|
||||
const airBody = qAir[1]!
|
||||
// During spin, width compresses to give 2.5D tumbling effect
|
||||
expect(airBody.w).toBeLessThan(restBody.w)
|
||||
expect(airBody.w).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue