diff --git a/src/game/engine.ts b/src/game/engine.ts index 5dcfb03..949e246 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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 } } diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index 2aad7d5..a3fb43c 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -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, +): 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): boolean { + const item = this.items.get(id) + if (!item) return false + triggerFlippyBounce(item, now, options) + return true + } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index ed43368..81326bc 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, 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 diff --git a/tests/ground-items-full-bounce.test.ts b/tests/ground-items-full-bounce.test.ts new file mode 100644 index 0000000..24d3350 --- /dev/null +++ b/tests/ground-items-full-bounce.test.ts @@ -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) + }) + }) +})