feat: quick slots with 1-4 hotkeys (Phase 4.6)
4 quick slots bound to keys 1-4. Active slot determines what F key throws. Auto-assigns new items on collection. Clears slot when item depleted. 15 new tests (213 total). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
66
src/player/quickslots.ts
Normal file
66
src/player/quickslots.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
projectileSystem,
|
projectileSystem,
|
||||||
type ProjectileData,
|
type ProjectileData,
|
||||||
} from '../player/projectile';
|
} from '../player/projectile';
|
||||||
|
import { QuickSlots } from '../player/quickslots';
|
||||||
import type { InputState } from '../player/types';
|
import type { InputState } from '../player/types';
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
@@ -40,6 +41,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private tileSize!: number;
|
private tileSize!: number;
|
||||||
private resourceData!: Map<number, ResourceInfo>;
|
private resourceData!: Map<number, ResourceInfo>;
|
||||||
private projectileData!: Map<number, ProjectileData>;
|
private projectileData!: Map<number, ProjectileData>;
|
||||||
|
private quickSlots!: QuickSlots;
|
||||||
private keys!: {
|
private keys!: {
|
||||||
W: Phaser.Input.Keyboard.Key;
|
W: Phaser.Input.Keyboard.Key;
|
||||||
A: Phaser.Input.Keyboard.Key;
|
A: Phaser.Input.Keyboard.Key;
|
||||||
@@ -47,6 +49,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
D: Phaser.Input.Keyboard.Key;
|
D: Phaser.Input.Keyboard.Key;
|
||||||
E: Phaser.Input.Keyboard.Key;
|
E: Phaser.Input.Keyboard.Key;
|
||||||
F: 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
|
// Interaction feedback
|
||||||
@@ -89,6 +95,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
|
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
|
||||||
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
|
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
|
||||||
this.inventory = new Inventory(500, 20);
|
this.inventory = new Inventory(500, 20);
|
||||||
|
this.quickSlots = new QuickSlots();
|
||||||
|
|
||||||
// 7. Camera — follow player, zoom via scroll wheel
|
// 7. Camera — follow player, zoom via scroll wheel
|
||||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
@@ -113,6 +120,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
D: keyboard.addKey('D'),
|
D: keyboard.addKey('D'),
|
||||||
E: keyboard.addKey('E'),
|
E: keyboard.addKey('E'),
|
||||||
F: keyboard.addKey('F'),
|
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
|
// 9. Minimap
|
||||||
@@ -189,10 +200,22 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
||||||
);
|
);
|
||||||
if (interaction) {
|
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);
|
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 isFDown = this.keys.F.isDown;
|
||||||
const justPressedF = isFDown && !this.wasFDown;
|
const justPressedF = isFDown && !this.wasFDown;
|
||||||
this.wasFDown = isFDown;
|
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 {
|
private tryLaunchProjectile(): void {
|
||||||
const items = this.inventory.getItems();
|
const itemId = this.quickSlots.getActive();
|
||||||
if (items.length === 0) {
|
if (!itemId || !this.inventory.hasItem(itemId)) {
|
||||||
this.showInteractionFeedback('nothing_nearby');
|
this.showInteractionFeedback('nothing_nearby');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use first item in inventory (quick slots will replace this in 4.6)
|
const removed = this.inventory.removeItem(itemId, 1);
|
||||||
const item = items[0];
|
|
||||||
const removed = this.inventory.removeItem(item.id, 1);
|
|
||||||
if (removed === 0) return;
|
if (removed === 0) return;
|
||||||
|
|
||||||
// Get mouse world position for direction
|
// Get mouse world position for direction
|
||||||
@@ -256,10 +277,16 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.projectileData,
|
this.projectileData,
|
||||||
px, py,
|
px, py,
|
||||||
worldPoint.x, worldPoint.y,
|
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 {
|
private showInteractionFeedback(type: string, itemId?: string): void {
|
||||||
|
|||||||
131
tests/quickslots.test.ts
Normal file
131
tests/quickslots.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user