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:
Денис Шкабатур
2026-02-12 13:31:12 +03:00
parent 0396170303
commit d173ada466
3 changed files with 233 additions and 9 deletions

66
src/player/quickslots.ts Normal file
View 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;
}
}

View File

@@ -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<number, ResourceInfo>;
private projectileData!: Map<number, ProjectileData>;
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 {