feat: implement game scene with entity rendering, camera, and input
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,270 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { SocketClient } from '../network/SocketClient.js';
|
||||
import { CharacterCompositor } from '../sprites/CharacterCompositor.js';
|
||||
import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared';
|
||||
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction } from '@dflike/shared';
|
||||
|
||||
interface EntitySprite {
|
||||
sprite: Phaser.GameObjects.Sprite;
|
||||
lastState: EntityState;
|
||||
}
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private client!: SocketClient;
|
||||
private compositor!: CharacterCompositor;
|
||||
private worldState!: WorldState;
|
||||
private entitySprites: Map<number, EntitySprite> = new Map();
|
||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||
private wasd!: Record<string, Phaser.Input.Keyboard.Key>;
|
||||
private mode: 'avatar' | 'camera' = 'camera';
|
||||
private modeText!: Phaser.GameObjects.Text;
|
||||
private moveThrottle = 0;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
init(_data: unknown): void {}
|
||||
init(data: { client: SocketClient; worldState: WorldState }): void {
|
||||
this.client = data.client;
|
||||
this.worldState = data.worldState;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.add.text(100, 100, 'Game scene loaded', { color: '#ffffff' });
|
||||
this.compositor = new CharacterCompositor(this);
|
||||
|
||||
// Draw tile grid
|
||||
this.drawWorld();
|
||||
|
||||
// Setup camera
|
||||
const worldPixelW = this.worldState.worldWidth * TILE_SIZE;
|
||||
const worldPixelH = this.worldState.worldHeight * TILE_SIZE;
|
||||
this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);
|
||||
|
||||
// Input
|
||||
this.cursors = this.input.keyboard!.createCursorKeys();
|
||||
this.wasd = {
|
||||
W: this.input.keyboard!.addKey('W'),
|
||||
A: this.input.keyboard!.addKey('A'),
|
||||
S: this.input.keyboard!.addKey('S'),
|
||||
D: this.input.keyboard!.addKey('D'),
|
||||
};
|
||||
|
||||
// Tab to toggle mode
|
||||
this.input.keyboard!.addKey('TAB').on('down', () => {
|
||||
this.mode = this.mode === 'avatar' ? 'camera' : 'avatar';
|
||||
this.client.sendInput({ type: 'toggle-mode' });
|
||||
this.updateModeText();
|
||||
});
|
||||
|
||||
// UI text
|
||||
this.modeText = this.add.text(10, 10, '', {
|
||||
fontSize: '16px', color: '#ffffff', backgroundColor: '#000000aa',
|
||||
padding: { x: 8, y: 4 },
|
||||
}).setScrollFactor(0).setDepth(1000);
|
||||
this.updateModeText();
|
||||
|
||||
// Spawn initial entities
|
||||
this.spawnEntities(this.worldState.entities);
|
||||
|
||||
// Listen for state updates
|
||||
this.client.onStateUpdate = (update: StateUpdate) => {
|
||||
this.handleStateUpdate(update);
|
||||
};
|
||||
|
||||
this.client.onPlayerLeft = (_data) => {
|
||||
// Entity removal will be handled by next state update
|
||||
};
|
||||
}
|
||||
|
||||
private drawWorld(): void {
|
||||
const { worldWidth, worldHeight, obstacles, pointsOfInterest } = this.worldState;
|
||||
|
||||
// Ground tiles
|
||||
const graphics = this.add.graphics();
|
||||
for (let x = 0; x < worldWidth; x++) {
|
||||
for (let y = 0; y < worldHeight; y++) {
|
||||
const shade = ((x + y) % 2 === 0) ? 0x2d5a27 : 0x326b2e;
|
||||
graphics.fillStyle(shade, 1);
|
||||
graphics.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
// Obstacles
|
||||
for (const obs of obstacles) {
|
||||
graphics.fillStyle(0x555555, 1);
|
||||
graphics.fillRect(obs.x * TILE_SIZE, obs.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
|
||||
// Points of interest
|
||||
for (const poi of pointsOfInterest) {
|
||||
const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc;
|
||||
graphics.fillStyle(color, 1);
|
||||
graphics.fillRect(
|
||||
poi.position.x * TILE_SIZE + 8,
|
||||
poi.position.y * TILE_SIZE + 8,
|
||||
TILE_SIZE - 16,
|
||||
TILE_SIZE - 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async spawnEntities(entities: EntityState[]): Promise<void> {
|
||||
for (const entity of entities) {
|
||||
if (!this.entitySprites.has(entity.id) && entity.appearance) {
|
||||
await this.createEntitySprite(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createEntitySprite(entity: EntityState): Promise<void> {
|
||||
const textureKey = await this.compositor.preloadAppearance(entity.appearance);
|
||||
|
||||
// Create animations for this texture
|
||||
this.createAnimations(textureKey);
|
||||
|
||||
const sprite = this.add.sprite(
|
||||
entity.position.x * TILE_SIZE + TILE_SIZE / 2,
|
||||
entity.position.y * TILE_SIZE + TILE_SIZE / 2,
|
||||
textureKey,
|
||||
0,
|
||||
);
|
||||
|
||||
this.entitySprites.set(entity.id, { sprite, lastState: entity });
|
||||
this.playAnimation(sprite, textureKey, entity.movement.direction, entity.movement.state);
|
||||
}
|
||||
|
||||
private createAnimations(textureKey: string): void {
|
||||
const dirNames = ['down', 'left', 'right', 'up'];
|
||||
for (let dir = 0; dir < 4; dir++) {
|
||||
const walkKey = `${textureKey}_walk_${dirNames[dir]}`;
|
||||
const idleKey = `${textureKey}_idle_${dirNames[dir]}`;
|
||||
|
||||
if (!this.anims.exists(walkKey)) {
|
||||
this.anims.create({
|
||||
key: walkKey,
|
||||
frames: this.anims.generateFrameNumbers(textureKey, {
|
||||
start: dir * SPRITE_COLS + 1,
|
||||
end: dir * SPRITE_COLS + SPRITE_COLS - 1,
|
||||
}),
|
||||
frameRate: 8,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
if (!this.anims.exists(idleKey)) {
|
||||
this.anims.create({
|
||||
key: idleKey,
|
||||
frames: [{ key: textureKey, frame: dir * SPRITE_COLS }],
|
||||
frameRate: 1,
|
||||
repeat: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private playAnimation(sprite: Phaser.GameObjects.Sprite, textureKey: string, direction: number, state: string): void {
|
||||
const dirNames = ['down', 'left', 'right', 'up'];
|
||||
const dirName = dirNames[direction] ?? 'down';
|
||||
const animKey = state === 'walking'
|
||||
? `${textureKey}_walk_${dirName}`
|
||||
: `${textureKey}_idle_${dirName}`;
|
||||
if (sprite.anims.currentAnim?.key !== animKey) {
|
||||
sprite.play(animKey);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStateUpdate(update: StateUpdate): void {
|
||||
const activeIds = new Set<number>();
|
||||
|
||||
for (const entity of update.entities) {
|
||||
activeIds.add(entity.id);
|
||||
const existing = this.entitySprites.get(entity.id);
|
||||
|
||||
if (existing) {
|
||||
// Interpolation target
|
||||
const targetX = entity.position.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const targetY = entity.position.y * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
// Smooth movement via tween
|
||||
const dx = Math.abs(existing.sprite.x - targetX);
|
||||
const dy = Math.abs(existing.sprite.y - targetY);
|
||||
if (dx > 1 || dy > 1) {
|
||||
this.tweens.add({
|
||||
targets: existing.sprite,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
duration: 200,
|
||||
ease: 'Linear',
|
||||
});
|
||||
}
|
||||
|
||||
// Update animation
|
||||
const textureKey = this.compositor.getKey(entity.appearance);
|
||||
this.playAnimation(existing.sprite, textureKey, entity.movement.direction, entity.movement.state);
|
||||
existing.lastState = entity;
|
||||
} else if (entity.appearance) {
|
||||
this.createEntitySprite(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove entities no longer in update
|
||||
for (const [id, es] of this.entitySprites) {
|
||||
if (!activeIds.has(id)) {
|
||||
es.sprite.destroy();
|
||||
this.entitySprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(_time: number, delta: number): void {
|
||||
this.handleCameraAndMovement(delta);
|
||||
}
|
||||
|
||||
private handleCameraAndMovement(delta: number): void {
|
||||
const cam = this.cameras.main;
|
||||
const speed = 5; // tiles per second for camera pan
|
||||
|
||||
const left = this.cursors.left?.isDown || this.wasd.A.isDown;
|
||||
const right = this.cursors.right?.isDown || this.wasd.D.isDown;
|
||||
const up = this.cursors.up?.isDown || this.wasd.W.isDown;
|
||||
const down = this.cursors.down?.isDown || this.wasd.S.isDown;
|
||||
|
||||
if (this.mode === 'camera') {
|
||||
// Pan camera freely
|
||||
const panSpeed = speed * TILE_SIZE;
|
||||
if (left) cam.scrollX -= panSpeed * delta / 1000;
|
||||
if (right) cam.scrollX += panSpeed * delta / 1000;
|
||||
if (up) cam.scrollY -= panSpeed * delta / 1000;
|
||||
if (down) cam.scrollY += panSpeed * delta / 1000;
|
||||
} else {
|
||||
// Avatar mode — send movement input (throttled)
|
||||
this.moveThrottle -= delta;
|
||||
if (this.moveThrottle <= 0) {
|
||||
let dx = 0, dy = 0;
|
||||
if (left) dx = -1;
|
||||
else if (right) dx = 1;
|
||||
else if (up) dy = -1;
|
||||
else if (down) dy = 1;
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
this.client.sendInput({ type: 'move', direction: { dx, dy } });
|
||||
this.moveThrottle = 150; // ms between moves
|
||||
}
|
||||
}
|
||||
|
||||
// Follow own entity
|
||||
const myEntityId = this.client.entityId;
|
||||
if (myEntityId != null) {
|
||||
const mySprite = this.entitySprites.get(myEntityId);
|
||||
if (mySprite) {
|
||||
cam.centerOn(mySprite.sprite.x, mySprite.sprite.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateModeText(): void {
|
||||
this.modeText.setText(
|
||||
`Mode: ${this.mode.toUpperCase()} [TAB to toggle]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user