diff --git a/src/player/quickslots.ts b/src/player/quickslots.ts new file mode 100644 index 0000000..a514c61 --- /dev/null +++ b/src/player/quickslots.ts @@ -0,0 +1,66 @@ +/** + * Quick Slots — 4 Hotkey Slots for Fast Item Access + * + * Keys 1-4 select active slot. Active slot determines + * what gets thrown (F) or used. Items are referenced + * by id from the inventory. + */ + +/** Number of quick slots */ +export const SLOT_COUNT = 4; + +/** + * Quick slots manager. + * Stores item ids assigned to 4 slots (keys 1-4). + * Active slot determines current "equipped" item. + */ +export class QuickSlots { + private slots: (string | null)[] = new Array(SLOT_COUNT).fill(null); + + /** Currently active slot index (0-3) */ + activeIndex = 0; + + /** Assign an item id to a slot (null to clear) */ + assign(index: number, itemId: string | null): void { + if (index < 0 || index >= SLOT_COUNT) return; + this.slots[index] = itemId; + } + + /** Get item id in a slot (null if empty) */ + getSlot(index: number): string | null { + if (index < 0 || index >= SLOT_COUNT) return null; + return this.slots[index]; + } + + /** Get item id in the active slot */ + getActive(): string | null { + return this.slots[this.activeIndex]; + } + + /** Set active slot index (clamped to valid range) */ + setActive(index: number): void { + this.activeIndex = Math.max(0, Math.min(SLOT_COUNT - 1, index)); + } + + /** Get all slot contents as array */ + getAll(): (string | null)[] { + return [...this.slots]; + } + + /** + * Auto-assign an item to the first empty slot. + * Returns slot index, or -1 if no empty slot or item already assigned. + */ + autoAssign(itemId: string): number { + // Don't duplicate + if (this.slots.includes(itemId)) return -1; + + for (let i = 0; i < SLOT_COUNT; i++) { + if (this.slots[i] === null) { + this.slots[i] = itemId; + return i; + } + } + return -1; + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 3835a4f..faae1ad 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -23,6 +23,7 @@ import { projectileSystem, type ProjectileData, } from '../player/projectile'; +import { QuickSlots } from '../player/quickslots'; import type { InputState } from '../player/types'; export class GameScene extends Phaser.Scene { @@ -40,6 +41,7 @@ export class GameScene extends Phaser.Scene { private tileSize!: number; private resourceData!: Map; private projectileData!: Map; + private quickSlots!: QuickSlots; private keys!: { W: Phaser.Input.Keyboard.Key; A: Phaser.Input.Keyboard.Key; @@ -47,6 +49,10 @@ export class GameScene extends Phaser.Scene { D: Phaser.Input.Keyboard.Key; E: Phaser.Input.Keyboard.Key; F: Phaser.Input.Keyboard.Key; + ONE: Phaser.Input.Keyboard.Key; + TWO: Phaser.Input.Keyboard.Key; + THREE: Phaser.Input.Keyboard.Key; + FOUR: Phaser.Input.Keyboard.Key; }; // Interaction feedback @@ -89,6 +95,7 @@ export class GameScene extends Phaser.Scene { const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2; this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY); this.inventory = new Inventory(500, 20); + this.quickSlots = new QuickSlots(); // 7. Camera — follow player, zoom via scroll wheel const worldPixelW = biome.mapWidth * biome.tileSize; @@ -113,6 +120,10 @@ export class GameScene extends Phaser.Scene { D: keyboard.addKey('D'), E: keyboard.addKey('E'), F: keyboard.addKey('F'), + ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), + TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), + THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), + FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR), }; // 9. Minimap @@ -189,10 +200,22 @@ export class GameScene extends Phaser.Scene { this.gameWorld.world, justPressedE, this.inventory, this.resourceData, ); if (interaction) { + // Auto-assign collected items to quick slots + if (interaction.type === 'collected' || interaction.type === 'depleted') { + if (interaction.itemId) { + this.quickSlots.autoAssign(interaction.itemId); + } + } this.showInteractionFeedback(interaction.type, interaction.itemId); } - // 8. Throw projectile (F key, debounced) + // 8. Quick slot selection (1-4 keys) + if (this.keys.ONE.isDown) this.quickSlots.setActive(0); + if (this.keys.TWO.isDown) this.quickSlots.setActive(1); + if (this.keys.THREE.isDown) this.quickSlots.setActive(2); + if (this.keys.FOUR.isDown) this.quickSlots.setActive(3); + + // 9. Throw projectile (F key, debounced) — uses active quick slot const isFDown = this.keys.F.isDown; const justPressedF = isFDown && !this.wasFDown; this.wasFDown = isFDown; @@ -231,17 +254,15 @@ export class GameScene extends Phaser.Scene { ); } - /** Try to launch a projectile from the first inventory item toward mouse */ + /** Try to launch a projectile from active quick slot toward mouse */ private tryLaunchProjectile(): void { - const items = this.inventory.getItems(); - if (items.length === 0) { + const itemId = this.quickSlots.getActive(); + if (!itemId || !this.inventory.hasItem(itemId)) { this.showInteractionFeedback('nothing_nearby'); return; } - // Use first item in inventory (quick slots will replace this in 4.6) - const item = items[0]; - const removed = this.inventory.removeItem(item.id, 1); + const removed = this.inventory.removeItem(itemId, 1); if (removed === 0) return; // Get mouse world position for direction @@ -256,10 +277,16 @@ export class GameScene extends Phaser.Scene { this.projectileData, px, py, worldPoint.x, worldPoint.y, - item.id, + itemId, ); - this.showInteractionFeedback('collected', `Threw ${item.id}`); + // Clear quick slot if inventory is now empty for this item + if (!this.inventory.hasItem(itemId)) { + const slotIdx = this.quickSlots.getAll().indexOf(itemId); + if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null); + } + + this.showInteractionFeedback('collected', `Threw ${itemId}`); } private showInteractionFeedback(type: string, itemId?: string): void { diff --git a/tests/quickslots.test.ts b/tests/quickslots.test.ts new file mode 100644 index 0000000..491f2f8 --- /dev/null +++ b/tests/quickslots.test.ts @@ -0,0 +1,131 @@ +/** + * Quick Slots Tests — Phase 4.6 + * + * Tests: slot assignment, active selection, key switching. + */ + +import { describe, it, expect } from 'vitest'; +import { QuickSlots, SLOT_COUNT } from '../src/player/quickslots'; + +describe('QuickSlots — creation', () => { + it('starts with all slots empty', () => { + const qs = new QuickSlots(); + for (let i = 0; i < SLOT_COUNT; i++) { + expect(qs.getSlot(i)).toBeNull(); + } + }); + + it('starts with slot 0 active', () => { + const qs = new QuickSlots(); + expect(qs.activeIndex).toBe(0); + }); + + it('has 4 slots', () => { + expect(SLOT_COUNT).toBe(4); + const qs = new QuickSlots(); + expect(qs.getAll()).toHaveLength(4); + }); +}); + +describe('QuickSlots — assignment', () => { + it('assigns item to slot', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + expect(qs.getSlot(0)).toBe('Na'); + }); + + it('replaces existing item in slot', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + qs.assign(0, 'Fe'); + expect(qs.getSlot(0)).toBe('Fe'); + }); + + it('clears slot with null', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + qs.assign(0, null); + expect(qs.getSlot(0)).toBeNull(); + }); + + it('ignores invalid index', () => { + const qs = new QuickSlots(); + qs.assign(-1, 'Na'); + qs.assign(4, 'Na'); + // No crash, no change + expect(qs.getAll()).toEqual([null, null, null, null]); + }); +}); + +describe('QuickSlots — active slot', () => { + it('returns active slot item', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + expect(qs.getActive()).toBe('Na'); + }); + + it('returns null for empty active slot', () => { + const qs = new QuickSlots(); + expect(qs.getActive()).toBeNull(); + }); + + it('switches active slot', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + qs.assign(1, 'Fe'); + qs.assign(2, 'Cu'); + + qs.setActive(1); + expect(qs.activeIndex).toBe(1); + expect(qs.getActive()).toBe('Fe'); + + qs.setActive(2); + expect(qs.getActive()).toBe('Cu'); + }); + + it('clamps active index to valid range', () => { + const qs = new QuickSlots(); + qs.setActive(-1); + expect(qs.activeIndex).toBe(0); + + qs.setActive(10); + expect(qs.activeIndex).toBe(3); + }); +}); + +describe('QuickSlots — getAll', () => { + it('returns snapshot of all slots', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + qs.assign(2, 'Fe'); + expect(qs.getAll()).toEqual(['Na', null, 'Fe', null]); + }); +}); + +describe('QuickSlots — auto-assign', () => { + it('assigns to first empty slot', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + const index = qs.autoAssign('Fe'); + expect(index).toBe(1); + expect(qs.getSlot(1)).toBe('Fe'); + }); + + it('returns -1 when all slots full', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + qs.assign(1, 'Fe'); + qs.assign(2, 'Cu'); + qs.assign(3, 'S'); + const index = qs.autoAssign('H'); + expect(index).toBe(-1); + }); + + it('does not duplicate item already in a slot', () => { + const qs = new QuickSlots(); + qs.assign(0, 'Na'); + const index = qs.autoAssign('Na'); + expect(index).toBe(-1); // already assigned + expect(qs.getAll().filter(s => s === 'Na')).toHaveLength(1); + }); +});