From 2fe3fb17637701eb5f3ae14bf02b14dc48586f30 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Sat, 2 May 2026 18:38:18 +0000 Subject: [PATCH] Complete refactor text-dungeon to new ECS architecture. --- src/common/rpg/components/item.ts | 4 +- src/common/rpg/components/position.ts | 6 +- src/common/rpg/components/sprite.ts | 18 +- src/common/rpg/systems/render/text.ts | 6 +- src/games/playground/index.tsx | 64 +----- src/games/text-dungeon/const.ts | 18 -- src/games/text-dungeon/drawable.ts | 18 -- src/games/text-dungeon/figure.ts | 53 ----- src/games/text-dungeon/images.ts | 81 +++----- src/games/text-dungeon/index.ts | 278 +++++++++++++++----------- src/games/text-dungeon/item.ts | 68 ++++--- src/games/text-dungeon/map.ts | 38 ---- src/games/text-dungeon/player.ts | 70 +++---- src/games/text-dungeon/room.ts | 265 +++++++++++++++--------- src/games/text-dungeon/sprite.ts | 38 ---- src/games/text-dungeon/types.d.ts | 10 - src/games/text-dungeon/utils.ts | 23 --- 17 files changed, 461 insertions(+), 597 deletions(-) delete mode 100644 src/games/text-dungeon/drawable.ts delete mode 100644 src/games/text-dungeon/figure.ts delete mode 100644 src/games/text-dungeon/map.ts delete mode 100644 src/games/text-dungeon/sprite.ts delete mode 100644 src/games/text-dungeon/types.d.ts delete mode 100644 src/games/text-dungeon/utils.ts diff --git a/src/common/rpg/components/item.ts b/src/common/rpg/components/item.ts index 08c4eaa..4543b8f 100644 --- a/src/common/rpg/components/item.ts +++ b/src/common/rpg/components/item.ts @@ -11,7 +11,7 @@ interface ItemState { @component export class Item extends Component { - constructor(name: string, description = '') { + constructor(name = '', description = '') { super({ name, description }); } @@ -63,7 +63,7 @@ export namespace Items { equippable?: { slotType: string }; } - export function register(world: World, id: string, name: string, options?: RegisterOptions) { + export function register(world: World, id: string, name?: string, options?: RegisterOptions) { const entity = world.createEntity(id); entity.add(new Item(name, options?.description)); if (options?.maxStack !== undefined) { diff --git a/src/common/rpg/components/position.ts b/src/common/rpg/components/position.ts index ed760c7..7ce42d2 100644 --- a/src/common/rpg/components/position.ts +++ b/src/common/rpg/components/position.ts @@ -1,4 +1,4 @@ -import { Component } from "../core/world"; +import { Component, Entity } from "../core/world"; import { component } from "../utils/decorators"; @component({ variables: ['x', 'y', 'z'] }) @@ -6,4 +6,6 @@ export class Position extends Component<{ x: number, y: number, z: number }> { constructor(x = 0, y = 0, z = 0) { super({ x, y, z }); } -} \ No newline at end of file +} + +export const getPosition = (entity: Entity) => entity.get(Position)?.state; \ No newline at end of file diff --git a/src/common/rpg/components/sprite.ts b/src/common/rpg/components/sprite.ts index 0694631..1fa1ad5 100644 --- a/src/common/rpg/components/sprite.ts +++ b/src/common/rpg/components/sprite.ts @@ -1,5 +1,4 @@ import { Component } from "../core/world"; -import { SpriteSystem } from "../systems/render/sprite"; import { component } from "../utils/decorators"; @component @@ -20,9 +19,18 @@ export class Sprite extends Component<{ }); } - override onAdd(): void { - if (Number.isFinite(this.state.animationDelay) && !this.world.hasSystem(SpriteSystem)) { - this.world.addSystem(new SpriteSystem()); + override async onAdd() { + if (Number.isFinite(this.state.animationDelay)) { + const { SpriteSystem } = await import('../systems/render/sprite'); // dynamic import to break cyclic dependency + if (!this.world.hasSystem(SpriteSystem)) { + this.world.addSystem(new SpriteSystem()); + } } } -} \ No newline at end of file +} + +@component export class Hidden extends Component<{}> { + constructor() { + super({}); + } +} diff --git a/src/common/rpg/systems/render/text.ts b/src/common/rpg/systems/render/text.ts index d9d2662..9a3acc0 100644 --- a/src/common/rpg/systems/render/text.ts +++ b/src/common/rpg/systems/render/text.ts @@ -1,6 +1,6 @@ import { TextRegion, TextDisplay } from "@common/display/text"; import { Position } from "@common/rpg/components/position"; -import { Sprite } from "@common/rpg/components/sprite"; +import { Hidden, Sprite } from "@common/rpg/components/sprite"; import { System, World } from "@common/rpg/core/world"; import { Resources } from "@common/rpg/utils/resources"; @@ -13,7 +13,9 @@ export class TextDisplaySystem extends System { } override update(world: World) { - for (const [, sprite, pos] of world.query(Sprite, Position)) { + const sprites = Array.from(world.query(Sprite, Position)).sort((a, b) => a[2].state.z - b[2].state.z); + for (const [e, sprite, pos] of sprites) { + if (e.has(Hidden)) continue; const { frames, currentFrame } = sprite.state; const { x, y } = pos.state; diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index ea7f71b..92039dd 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,59 +1,17 @@ -import { World } from "@common/rpg/core/world"; +import { TextDisplay } from "@common/display/text"; import { Inventory } from "@common/rpg/components/inventory"; -import { Health, Stat } from "@common/rpg/components/stat"; -import { Variables } from "@common/rpg/components/variables"; -import { QuestLog } from "@common/rpg/components/questLog"; -import { QuestSystem } from "@common/rpg/systems/quest"; -import { Items } from "@common/rpg/components/item"; -import { resolveVariables, resolveActions, executeAction } from "@common/rpg/utils/variables"; -import { Serialization } from "@common/rpg/core/serialization"; -import { Factions } from "@common/rpg/components/faction"; -import { Effect } from "@common/rpg/components/effect"; -import { Equipment } from "@common/rpg/components/equipment"; import { Position } from "@common/rpg/components/position"; +import { World } from "@common/rpg/core/world"; +import { TextDisplaySystem } from "@common/rpg/systems/render/text"; +console.log("Hello, world 1"); export default async function main() { const world = new World(); - world.addSystem(new QuestSystem()); - - Items.register(world, 'helmet', 'Iron Helmet'); - const boots = Items.register(world, 'boots', 'Leather Boots', { maxStack: 2, equippable: { slotType: 'feet' } }); - boots.add(new Effect({ - targetStat: 'agl', - delta: 10, - })); - - const player = world.createEntity('player'); - const inventory = player.add(new Inventory()); - player.add(new Equipment('head', { slotName: 'feet', type: 'feet' })); - player.add(new Health({ value: 100, max: 100 })); - player.add(new Variables()); - player.add(new QuestLog([{ - id: 'test', - description: 'Test quest', - title: 'Test', - stages: [], - }])); - player.add('str', new Stat({ value: 100 })); - player.add('agl', new Stat({ value: 100 })); - player.add(new Position(42, 69)); - - Factions.join(player, 'boobs'); - Factions.adjustReputation(player, 'guards', 10); - Factions.adjustReputation(player, 'bandits', -10); - - console.log(resolveVariables(world)); - - inventory.add({ itemId: 'helmet', amount: 1 }); - inventory.add({ itemId: 'boots', amount: 1 }); - inventory.equip('boots'); - - const vars = player.get(Variables)!; - vars.set({ key: 'test', value: 'test' }); - - await executeAction('player.QuestLog.test.start', world); - - console.log(resolveActions(world)); - console.log(resolveVariables(world)); - console.log(JSON.parse(Serialization.serialize(world))); + const e = world.createEntity(); + e.add(new Position(1, 1)); + e.add(new Inventory()); + console.log("Hello, world!"); + const display = new TextDisplay(); + new TextDisplaySystem(display); + console.log("Hello, world 2"); } diff --git a/src/games/text-dungeon/const.ts b/src/games/text-dungeon/const.ts index 74a663d..3d0720b 100644 --- a/src/games/text-dungeon/const.ts +++ b/src/games/text-dungeon/const.ts @@ -16,24 +16,6 @@ export const MAP_Y = 0; export const MAP_WIDTH = GAME_WIDTH - ROOM_AREA_WIDTH - MAP_X; export const MAP_HEIGHT = 10; -export enum Color { - BLACK = 0b0000, - DARK_BLUE = 0b0001, - DARK_CYAN = 0b0011, - DARK_GREEN = 0b0010, - DARK_YELLOW = 0b0110, - DARK_RED = 0b0100, - DARK_MAGENTA = 0b0101, - GRAY = 0b0111, - BLUE = 0b1001, - CYAN = 0b1011, - GREEN = 0b1010, - YELLOW = 0b1110, - RED = 0b1100, - MAGENTA = 0b1101, - WHITE = 0b1111, -} - export enum Direction { NORTH = 0, EAST = 1, diff --git a/src/games/text-dungeon/drawable.ts b/src/games/text-dungeon/drawable.ts deleted file mode 100644 index cd67b86..0000000 --- a/src/games/text-dungeon/drawable.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { TextDisplay } from "@common/display/text"; - -export abstract class Drawable { - protected dirty: boolean = true; - abstract doDraw(): void; - constructor(protected display: TextDisplay) { } - - draw() { - if (this.dirty) { - this.doDraw(); - this.dirty = false; - } - } - - invalidate() { - this.dirty = true; - } -} \ No newline at end of file diff --git a/src/games/text-dungeon/figure.ts b/src/games/text-dungeon/figure.ts deleted file mode 100644 index 6f93de7..0000000 --- a/src/games/text-dungeon/figure.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { TextDisplay } from "@common/display/text"; -import { Drawable } from "./drawable"; -import type { IRegion, ISpriteDefinition } from "./types"; - -export class Figure extends Drawable { - private frames: IRegion[]; - private animationCounter = 0; - private animationPeriod; - - constructor( - display: TextDisplay, - public x: number, - public y: number, - definition: ISpriteDefinition, - public frame: number = 0, - ) { - super(display); - this.x = x | 0; - this.y = y | 0; - this.frames = definition.frames; - this.animationCounter = 0; - this.animationPeriod = definition.animationPeriod ?? Number.POSITIVE_INFINITY; - } - - override doDraw() { - this.animationCounter++; - if (this.animationCounter >= this.animationPeriod) { - this.animationCounter = 0; - this.frame = (this.frame + 1) % this.numFrames; - } - - this.display.setRegion(this.x, this.y, this.width, this.height, this.image); - } - - get width() { - const frame = this.frames[(this.frame % this.numFrames)]; - const line = frame[0]; - return line?.length ?? 0; - } - - get height() { - const frame = this.frames[(this.frame % this.numFrames)]; - return frame.length; - } - - get numFrames() { - return this.frames.length; - } - - get image() { - return this.frames[(this.frame % this.numFrames)]; - } -} \ No newline at end of file diff --git a/src/games/text-dungeon/images.ts b/src/games/text-dungeon/images.ts index 480aa1b..3331279 100644 --- a/src/games/text-dungeon/images.ts +++ b/src/games/text-dungeon/images.ts @@ -1,57 +1,30 @@ -import { Color } from "./const"; -import type { IChar, ISpriteDefinition } from "./types"; +import { Color, TextRegion } from "@common/display/text" +import { Resources } from "@common/rpg/utils/resources"; -const l = (line: string, fg = Color.WHITE, bg = Color.BLACK): IChar[] => - Array.from(line).map((c) => [c, fg, bg]); +export const PLAYER_SPRITE = [ + new TextRegion('@', Color.YELLOW) +].map(Resources.add); -export const PLAYER_SPRITE: ISpriteDefinition = { - frames: [ - [ - l('@', Color.YELLOW), - ], - ], -}; +export const DOOR_SPRITE = [ + new TextRegion('┘ └'), + new TextRegion([ + '└', + ' ', + '┌', + ]), + new TextRegion('┐ ┌'), + new TextRegion([ + '┘', + ' ', + '┐', + ]), + new TextRegion('^', Color.CYAN), + new TextRegion('_', Color.GREEN), +].map(Resources.add); -export const DOOR_SPRITE: ISpriteDefinition = { - frames: [ - [ - l('┘ └'), - ], - [ - l('└'), - l(' '), - l('┌'), - ], - [ - l('┐ ┌'), - ], - [ - l('┘'), - l(' '), - l('┐'), - ], - [ - l('^', Color.CYAN), - ], - [ - l('_', Color.GREEN), - ], - ], -} - -export const ITEM_KEY_SPRITE: ISpriteDefinition = { - frames: [ - [ - l('ъ', Color.MAGENTA), - ], - [ - l('ъ', Color.YELLOW), - ], - [ - l('ъ', Color.CYAN), - ], - [ - l('ъ', Color.GREEN), - ], - ], -} \ No newline at end of file +export const ITEM_SPRITE = [ + new TextRegion('ъ', Color.MAGENTA), + new TextRegion('ъ', Color.YELLOW), + new TextRegion('ъ', Color.CYAN), + new TextRegion('ъ', Color.GREEN), +].map(Resources.add); diff --git a/src/games/text-dungeon/index.ts b/src/games/text-dungeon/index.ts index e0df26f..77b944e 100644 --- a/src/games/text-dungeon/index.ts +++ b/src/games/text-dungeon/index.ts @@ -1,144 +1,196 @@ -import { TextDisplay } from '@common/display/text'; +import { Color, TextDisplay } from '@common/display/text'; import Input from '@common/input'; +import { Inventory } from '@common/rpg/components/inventory'; +import { Item } from '@common/rpg/components/item'; +import { Position } from '@common/rpg/components/position'; +import { Entity, System, World } from '@common/rpg/core/world'; +import { TextDisplaySystem } from '@common/rpg/systems/render/text'; import { nextFrame } from '@common/utils'; import './assets/style.css'; -import { Color, Direction, getOppositeDirection } from './const'; -import { createItems, getItemsCount } from './item'; -import { GameMap } from './map'; -import { Player } from './player'; -import { getPossibleRoomsCount, getRoom, getRoomsCount } from './room'; - -const display = new TextDisplay(); -let currentRoom = getRoom(display, 0, 0, 0); -const map = new GameMap(display, currentRoom); -currentRoom.draw(); -const player = new Player(display, currentRoom.x + currentRoom.width / 2, currentRoom.y + currentRoom.height / 2); +import { Direction, getOppositeDirection, MAP_HEIGHT, MAP_WIDTH, MAP_X, MAP_Y, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from './const'; +import { getPossibleRoomsCount, getRoom, getRoomsCount, getRoomsForLayer, getMapRoomChar, Room, Door } from './room'; +import { createPlayer, Player } from './player'; let lastMove = Date.now(); -function handleInput() { - const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE); - if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return; - lastMove = Date.now(); - let newX = player.x; - let newY = player.y; - let moved = isSpacePressed; +class GameSystem extends System { + private readonly display = new TextDisplay(); + private room: Entity | undefined; + private player: Entity | undefined; - if (Input.isHeld(Input.KeyCode.UP)) { - newY--; - moved = true; + override onAdd(world: World) { + world.addSystem(new TextDisplaySystem(this.display)); + + this.display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN }); + + this.room = getRoom(world, 0, 0, 0); + const room = this.room?.get(Room); + if (room) { + room.onEnter(); + this.player = createPlayer(world, (room.x + room.width / 2) | 0, (room.y + room.height / 2) | 0); + } } - if (Input.isHeld(Input.KeyCode.DOWN)) { - newY++; - moved = true; + override update(world: World) { + this.handleInput(world); + + this.drawRoom(); + this.drawMap(world); + this.drawInfo(world); } - if (Input.isHeld(Input.KeyCode.LEFT)) { - newX--; - moved = true; + private drawRoom() { + const room = this.room?.get(Room); + if (!room) return; + + this.display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] }); + this.display.drawBox(room.x, room.y, room.width, room.height, { fill: ['.', Color.GRAY] }); } - if (Input.isHeld(Input.KeyCode.RIGHT)) { - newX++; - moved = true; + private drawMap(world: World) { + if (!this.room) return; + + this.display.drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' }); + const worldPos = this.room.get(Position, 'world'); + const centerX = worldPos ? worldPos.state.x - MAP_X - MAP_WIDTH / 2 : 0; + const centerY = worldPos ? worldPos.state.y - MAP_Y - MAP_HEIGHT / 2 : 0; + const worldZ = worldPos ? worldPos.state.z : 0; + + for (const room of getRoomsForLayer(world, worldZ)) { + const roomPos = room.get(Position, 'world'); + const worldX = roomPos ? roomPos.state.x : 0; + const worldY = roomPos ? roomPos.state.y : 0; + const x = Math.round(worldX - centerX); + const y = Math.round(worldY - centerY); + + if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) { + const char = getMapRoomChar(room); + this.display.setChar(x, y, [char, room === this.room ? Color.YELLOW : Color.WHITE]); + } + } } - if (moved) { - const activatedDoor = currentRoom.getActivatedDoor(newX, newY); - if (activatedDoor) { - const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed; + private drawInfo(world: World) { + if (!this.room || !this.player) return; - if (shouldTravel) { - currentRoom = getRoom(display, activatedDoor.worldX, activatedDoor.worldY, activatedDoor.worldZ); - currentRoom.invalidate(); - map.currentRoom = currentRoom; - map.invalidate(); - player.skipNextBackgroundRestore = true; - const oppositeDoor = currentRoom.doors[getOppositeDirection(activatedDoor.direction)]; + const worldPos = this.room.get(Position, 'world'); + const coords = worldPos ? `${worldPos.state.x},${worldPos.state.y},${worldPos.state.z}`.padStart(9) : '0,0,0'; + const foundRooms = getRoomsCount(world); + const totalRooms = getPossibleRoomsCount(world); + const rooms = `${foundRooms}/${totalRooms}${foundRooms === totalRooms ? '' : '+'}`.padStart(coords.length - 2); + const inv = this.player.get(Inventory); + const foundItems = inv ? Object.values(inv.state.slots).length : 0; + const totalItems = Array.from(world.query(Item)).length; + const items = `${foundItems}/${totalItems}`.padStart(coords.length - 2); + this.display.drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' }); + } - if (oppositeDoor) { - switch (activatedDoor.direction) { - case Direction.NORTH: - newX = oppositeDoor.x + 1; - newY = oppositeDoor.y - 1; - break; - case Direction.SOUTH: - newX = oppositeDoor.x + 1; - newY = oppositeDoor.y + 1; - break; - case Direction.EAST: - newX = oppositeDoor.x + 1; - newY = oppositeDoor.y + 1; - break; - case Direction.WEST: - newX = oppositeDoor.x - 1; - newY = oppositeDoor.y + 1; - break; - case Direction.UP: - case Direction.DOWN: - newX = oppositeDoor.x; - newY = oppositeDoor.y; - break; + private handleInput(world: World) { + const player = this.player?.get(Player); + let room = this.room?.get(Room); + if (!player || !room) return; + + Input.updateKeys(); + + const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE); + + if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return; + lastMove = Date.now(); + + let newX = player.x; + let newY = player.y; + let moved = isSpacePressed; + + if (Input.isHeld(Input.KeyCode.UP)) { + newY--; + moved = true; + } + + if (Input.isHeld(Input.KeyCode.DOWN)) { + newY++; + moved = true; + } + + if (Input.isHeld(Input.KeyCode.LEFT)) { + newX--; + moved = true; + } + + if (Input.isHeld(Input.KeyCode.RIGHT)) { + newX++; + moved = true; + } + + if (moved) { + const activatedDoor = room.getActivatedDoor(newX, newY)?.get(Door); + console.log({ activatedDoor }); + if (activatedDoor) { + const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed; + + if (shouldTravel) { + room.onLeave(); + this.room = getRoom(world, activatedDoor.worldX, activatedDoor.worldY, activatedDoor.worldZ); + room = this.room.get(Room); + if (!room) return; + room.onEnter(); + const oppositeDoor = room.doors[getOppositeDirection(activatedDoor.direction)]?.get(Door); + + if (oppositeDoor) { + switch (activatedDoor.direction) { + case Direction.NORTH: + newX = oppositeDoor.x + 1; + newY = oppositeDoor.y - 1; + break; + case Direction.SOUTH: + newX = oppositeDoor.x + 1; + newY = oppositeDoor.y + 1; + break; + case Direction.EAST: + newX = oppositeDoor.x + 1; + newY = oppositeDoor.y + 1; + break; + case Direction.WEST: + newX = oppositeDoor.x - 1; + newY = oppositeDoor.y + 1; + break; + case Direction.UP: + case Direction.DOWN: + newX = oppositeDoor.x; + newY = oppositeDoor.y; + break; + } + } else { + newX = room.x + room.width / 2; + newY = room.y + room.height / 2; } - } else { - newX = currentRoom.x + currentRoom.width / 2; - newY = currentRoom.y + currentRoom.height / 2; + } + } else { + if (newX < room.x + 1 || newX >= room.x + room.width + 1) { + newX = player.x; + } + if (newY < room.y + 1 || newY >= room.y + room.height + 1) { + newY = player.y; } } - } else { - if (newX < currentRoom.x + 1 || newX >= currentRoom.x + currentRoom.width + 1) { - newX = player.x; - } - if (newY < currentRoom.y + 1 || newY >= currentRoom.y + currentRoom.height + 1) { - newY = player.y; - } - } - const pickedItem = currentRoom.pickItem(newX, newY); - if (pickedItem) { - player.addItem(pickedItem); - } + // const pickedItem = room.pickItem(newX, newY); + // if (pickedItem) { + // player.addItem(pickedItem); + // } - player.x = newX; - player.y = newY; - player.invalidate(); + player.x = newX; + player.y = newY; + } } } -function handleLogic() { - -} - -function drawInfo() { - const coords = `${currentRoom.worldX},${currentRoom.worldY},${currentRoom.worldZ}`.padStart(9); - const foundRooms = getRoomsCount(); - const totalRooms = getPossibleRoomsCount(); - const rooms = `${foundRooms}/${totalRooms}${foundRooms === totalRooms ? '' : '+'}`.padStart(coords.length - 2); - const items = `${player.foundItems}/${getItemsCount()}`.padStart(coords.length - 2); - display.drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' }); -} - -function draw() { - currentRoom.draw(); - map.draw(); - player.draw(); - - drawInfo(); -} - export default async function main() { - createItems(display); - currentRoom.invalidate(); - player.invalidate(); - display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN }); + const world = new World(); + world.addSystem(new GameSystem()); while (true) { - Input.updateKeys(); - handleInput(); - handleLogic(); - draw(); + const dt = await nextFrame(); - await nextFrame(); + world.update(dt); } } diff --git a/src/games/text-dungeon/item.ts b/src/games/text-dungeon/item.ts index 958e16c..d70ef1c 100644 --- a/src/games/text-dungeon/item.ts +++ b/src/games/text-dungeon/item.ts @@ -1,39 +1,43 @@ -import type { TextDisplay } from "@common/display/text"; -import { Drawable } from "./drawable"; -import { Figure } from "./figure"; -import { ITEM_KEY_SPRITE } from "./images"; +import { Component, type Entity, type World } from "@common/rpg/core/world"; +import { Position } from "@common/rpg/components/position"; +import { Sprite } from "@common/rpg/components/sprite"; +import { ITEM_SPRITE } from "./images"; +import { Item, Items } from "@common/rpg/components/item"; +import { component } from "@common/rpg/utils/decorators"; -let globalItemId = 0; -const idMap = new Map(); - -export const getItemsCount = () => idMap.size; - -export function createItems(display: TextDisplay) { - for (let frame = 0; frame < ITEM_KEY_SPRITE.frames.length; frame++) { - idMap.set( - globalItemId++, - new Figure(display, 0, 0, ITEM_KEY_SPRITE, frame), - ); +@component +class PickupCount extends Component<{ count: number }> { + constructor(count = 1) { + super({ count }); } } -export class Item extends Drawable { - constructor( - display: TextDisplay, - public id: number, - public count: number = 1, - public x: number = -1, - public y: number = -1, - ) { - super(display); - } - - override doDraw() { - const figure = idMap.get(this.id); - if (figure) { - figure.x = this.x; - figure.y = this.y; - figure.doDraw(); +export function findItem(world: World, id: number): Entity | undefined { + for (const [item, , component] of world.query(Item)) { + if (component.state.name === id.toString()) { + return item; } } + return undefined; +} + +export function createItem(world: World, id: number): Entity { + const item = Items.register(world, 'item_*', id.toString(), { maxStack: 1 }); + item.add(new Sprite(ITEM_SPRITE[id])); + return item; +} + +export function createItems(world: World) { + for (let id = 0; id < ITEM_SPRITE.length; id++) { + createItem(world, id); + } +} + +export function spawnItem(world: World, id: number, count: number, x: number, y: number): Entity { + const item = findItem(world, id) ?? createItem(world, id); + + item.add(new Position(x, y)); + item.add(new PickupCount(count)); + + return item; } diff --git a/src/games/text-dungeon/map.ts b/src/games/text-dungeon/map.ts deleted file mode 100644 index 24ed162..0000000 --- a/src/games/text-dungeon/map.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { TextDisplay } from "@common/display/text"; -import { Color, MAP_HEIGHT, MAP_ROOM_CHARS, MAP_WIDTH, MAP_X, MAP_Y } from "./const"; -import { Drawable } from "./drawable"; -import { getRoomsForLayer, Room } from "./room"; - -export class GameMap extends Drawable { - constructor(display: TextDisplay, public currentRoom: Room) { - super(display); - } - - override doDraw() { - this.display.drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' }); - const centerX = this.currentRoom.worldX - MAP_X - MAP_WIDTH / 2; - const centerY = this.currentRoom.worldY - MAP_Y - MAP_HEIGHT / 2; - - for (const room of getRoomsForLayer(this.currentRoom.worldZ)) { - const x = Math.round(room.worldX - centerX); - const y = Math.round(room.worldY - centerY); - - if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) { - const char = getMapRoomChar(room); - this.display.setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]); - } - } - } -} - -function getMapRoomChar(room: Room): string { - let doorsMask = 0; - for (let i = 0; i < 4; i++) { - const mask = 1 << i; - if (room.doors[i]) { - doorsMask |= mask; - } - } - - return MAP_ROOM_CHARS[doorsMask]; -} diff --git a/src/games/text-dungeon/player.ts b/src/games/text-dungeon/player.ts index bb5e32f..64131bd 100644 --- a/src/games/text-dungeon/player.ts +++ b/src/games/text-dungeon/player.ts @@ -1,47 +1,43 @@ -import { INVENTORY_X, INVENTORY_Y } from "./const"; -import { getItemsCount, Item } from "./item"; -import { Sprite } from "./sprite"; +import { component } from "@common/rpg/utils/decorators"; +import { Component, Entity, World } from "@common/rpg/core/world"; +import { Position } from "@common/rpg/components/position"; +import { Sprite } from "@common/rpg/components/sprite"; +import { PLAYER_SPRITE } from "./images"; -export class Player extends Sprite { - private inventory: Item[] = []; - - addItem(item: Item) { - for (const i of this.inventory) { - if (i.id === item.id) { - i.count += item.count; - return; - } - } - this.inventory.push(new Item( - this.display, - item.id, - item.count, - INVENTORY_X + 1 + this.inventory.length, - INVENTORY_Y + 1, - )); +@component +export class Player extends Component<{}> { + constructor() { + super({}); } - hasItem(item: Item) { - for (const i of this.inventory) { - if (i.id === item.id) { - return true; - } - } - return false; + get x() { + const pos = this.entity.get(Position); + return pos ? pos.state.x : 0; } - get foundItems() { - return this.inventory.length; + get y() { + const pos = this.entity.get(Position); + return pos ? pos.state.y : 0; } - override doDraw() { - super.doDraw(); + set x(x: number) { + const pos = this.entity.get(Position); + if (pos) pos.state.x = x; + } - this.display.drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' }); - this.inventory.forEach((item) => { - if (item.count > 0) { - item.doDraw(); - } - }); + set y(y: number) { + const pos = this.entity.get(Position); + if (pos) pos.state.y = y; } } + +export const createPlayer = (world: World, x: number, y: number): Entity => { + const player = world.createEntity('player'); + + player.add(new Position(x, y, 999)); + player.add(new Position(), 'world'); + player.add(new Sprite(PLAYER_SPRITE)); + player.add(new Player()); + + return player; +} \ No newline at end of file diff --git a/src/games/text-dungeon/room.ts b/src/games/text-dungeon/room.ts index f49fb68..494cd3a 100644 --- a/src/games/text-dungeon/room.ts +++ b/src/games/text-dungeon/room.ts @@ -1,93 +1,131 @@ +import { Inventory } from "@common/rpg/components/inventory"; +import { getPosition, Position } from "@common/rpg/components/position"; +import { Hidden, Sprite } from "@common/rpg/components/sprite"; +import { Component, type Entity, type World } from "@common/rpg/core/world"; +import { component } from "@common/rpg/utils/decorators"; import { randInt } from "@common/utils"; -import { Color, Direction, DIRECTION_OFFSETS, getOppositeDirection, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from "./const"; -import { Drawable } from "./drawable"; -import { Figure } from "./figure"; -import { DOOR_SPRITE } from "./images"; -import { getItemsCount, Item } from "./item"; -import type { TextDisplay } from "@common/display/text"; +import { Direction, DIRECTION_OFFSETS, getOppositeDirection, MAP_ROOM_CHARS, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from "./const"; +import { DOOR_SPRITE, ITEM_SPRITE } from "./images"; +import { spawnItem } from "./item"; +import { Resources } from "@common/rpg/utils/resources"; +import { TextRegion } from "@common/display/text"; -type IDoors = [Door | null, Door | null, Door | null, Door | null, Door | null, Door | null]; +type IDoors = [Entity?, Entity?, Entity?, Entity?, Entity?, Entity?]; -export class Door extends Figure { - constructor( - display: TextDisplay, - x: number, - y: number, - direction: Direction, - public worldX: number, - public worldY: number, - public worldZ: number, - ) { - super(display, x, y, DOOR_SPRITE); - this.frame = direction; +@component +export class Room extends Component<{ doors: IDoors }> { + get x() { + const pos = this.entity.get(Position); + return pos ? pos.state.x : 0; } - - isActivated(x: number, y: number): boolean { - const cx = Math.floor(this.x + this.width / 2); - const cy = Math.floor(this.y + this.height / 2); - - return (cx === x && cy === y); + get y() { + const pos = this.entity.get(Position); + return pos ? pos.state.y : 0; } - - get direction(): Direction { - return this.frame as Direction; + get width() { + const pos = this.entity.get(Position, 'size'); + return pos ? pos.state.x : 0; } -} - -export class Room extends Drawable { - constructor( - display: TextDisplay, - public readonly x: number, - public readonly y: number, - public readonly width: number, - public readonly height: number, - public readonly worldX: number, - public readonly worldY: number, - public readonly worldZ: number, - public readonly doors: IDoors = [null, null, null, null, null, null], - public readonly items: Item[] = [], - ) { - super(display); + get height() { + const pos = this.entity.get(Position, 'size'); + return pos ? pos.state.y : 0; } + get worldX() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.x : 0; + } + get worldY() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.y : 0; + } + get worldZ() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.z : 0; + } + get doors() { + return this.state.doors; + } + getActivatedDoor(px: number, py: number): Entity | undefined { + return this.state.doors.find((door) => { + const doorPos = door?.get(Position); + if (!doorPos) return false; - override doDraw() { - this.display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] }); - this.display.drawBox(this.x, this.y, this.width, this.height, { fill: ['.', Color.GRAY] }); + const offset = door?.get(Position, 'offset'); + const { x: dx = 0, y: dy = 0 } = offset?.state ?? {} - this.doors.forEach((door) => { - if (door) { - door.doDraw(); - } + const { x, y } = doorPos.state; + return px === x - dx && py === y - dy; }); - - this.items.forEach((item) => item.doDraw()); } - getActivatedDoor(x: number, y: number): Door | null { - return this.doors.find((door) => door?.isActivated(x, y)) ?? null; + onEnter() { + this.doors.forEach(door => door?.remove(Hidden)); } - pickItem(x: number, y: number): Item | null { - const itemIndex = this.items.findIndex((item) => item.x === x && item.y === y); - if (itemIndex >= 0) { - const [item] = this.items.splice(itemIndex, 1); - this.invalidate(); - return item; - } - return null; + onLeave() { + this.doors.forEach(door => door?.add(new Hidden())); } } -type IRoomBlank = { - -readonly [P in keyof Room]?: Room[P] +@component +export class Door extends Component<{ direction: Direction }> { + constructor(direction: Direction) { + super({ direction }); + } + get x() { + const pos = this.entity.get(Position); + return pos ? pos.state.x : 0; + } + get y() { + const pos = this.entity.get(Position); + return pos ? pos.state.y : 0; + } + get direction() { + return this.state.direction; + } + get worldX() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.x : 0; + } + get worldY() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.y : 0; + } + get worldZ() { + const pos = this.entity.get(Position, 'world'); + return pos ? pos.state.z : 0; + } +} + +interface IRoomBlank { + x: number; + y: number; + width: number; + height: number; + worldX: number; + worldY: number; + worldZ: number; + doors: IDoors; }; -const rooms = new Map(); +const roomId = (worldX: number, worldY: number, worldZ: number) => `room_${worldX}_${worldY}_${worldZ}`; -const generateRoomId = (worldX: number, worldY: number, worldZ: number) => `${worldX},${worldY},${worldZ}`; +const createDoor = (world: World, x: number, y: number, direction: Direction, worldX: number, worldY: number, worldZ: number): Entity => { + const door = world.createEntity('door_*'); + door.add(new Position(x, y)); + door.add(new Position(worldX, worldY, worldZ), 'world'); + door.add(new Door(direction)); + const resourceId = DOOR_SPRITE[direction]; + door.add(new Sprite(resourceId)); + const spr = Resources.get(TextRegion, resourceId); + if (spr) { + door.add(new Position((-spr.width / 2) | 0, (-spr.height / 2) | 0), 'offset'); + } + return door; +} -const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => { - const doors: IDoors = [null, null, null, null, null, null]; +const generateDoors = (world: World, { x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => { + const doors: IDoors = []; if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') { console.error('World coordinates not defined for doors generation'); return doors; @@ -101,9 +139,9 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl const [xoff, yoff] = DIRECTION_OFFSETS[i]; const doorWorldX = worldX + xoff; const doorWorldY = worldY + yoff; - const doorId = generateRoomId(doorWorldX, doorWorldY, worldZ); - const doorRoom = rooms.get(doorId); - if (doorRoom && !doorRoom.doors[getOppositeDirection(i)]) { + const doorId = roomId(doorWorldX, doorWorldY, worldZ); + const doorRoom = world.getEntity(doorId); + if (doorRoom && !doorRoom.get(Room)?.state.doors[getOppositeDirection(i)]) { continue; } @@ -118,7 +156,7 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl case Direction.EAST: dx = x + width + 1; dy = doorY; break; case Direction.WEST: dx = x; dy = doorY; break; } - doors[i] = new Door(display, dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ); + doors[i] = createDoor(world, dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ); } } @@ -126,7 +164,7 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl const doorX = randInt(x + 1, x + width - 2); const doorY = randInt(y + 1, y + height - 2); const [, , zoff] = DIRECTION_OFFSETS[Direction.DOWN]; - doors[Direction.DOWN] = new Door(display, doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff); + doors[Direction.DOWN] = createDoor(world, doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff); if (worldZ !== 0) { let upDoorX: number; @@ -136,7 +174,7 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl upDoorY = randInt(y + 1, y + height - 2); } while (doorX === upDoorX && doorY === upDoorY); const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP]; - doors[Direction.UP] = new Door(display, upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff); + doors[Direction.UP] = createDoor(world, upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff); } } @@ -145,24 +183,24 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl const generatedItems = new Set(); -const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRoomBlank): Item[] => { - const items: Item[] = []; +const generateItems = (world: World, { x, y, width, height, doors }: IRoomBlank): Entity[] => { + const items: Entity[] = []; if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') { console.error('Screen coordinates not defined for items generation'); - return items; + return []; } if (typeof doors === 'undefined') { console.error('Doors not defined for items generation'); - return items; + return []; } const hasObjectAt = (x: number, y: number) => - doors.some((d) => d?.x === x && d?.y === y) || - items.some((i) => i.x === x && i.y === y); + doors.flat().filter(x => x != null).map(getPosition).some((d) => d?.x === x && d.y === y) || + items.map(getPosition).some((i) => i?.x === x && i.y === y); const oneDoor = doors.filter((d) => Boolean(d)).length === 1; - const id = randInt(0, getItemsCount()); + const id = randInt(0, ITEM_SPRITE.length); if (Math.random() < 0.4 && oneDoor && !generatedItems.has(id)) { let newItemX: number; @@ -172,7 +210,7 @@ const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRo newItemX = randInt(x + 1, x + width - 2); newItemY = randInt(y + 1, y + height - 2); } while (hasObjectAt(newItemX, newItemY)); - const item = new Item(display, id, 1, newItemX, newItemY); + const item = spawnItem(world, id, 1, newItemX, newItemY); items.push(item); generatedItems.add(id); @@ -181,36 +219,48 @@ const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRo return items; }; -export function getRoom(display: TextDisplay, worldX: number, worldY: number, worldZ: number) { - const id = generateRoomId(worldX, worldY, worldZ); - const existingRoom = rooms.get(id); +export function getRoom(world: World, worldX: number, worldY: number, worldZ: number): Entity { + const id = roomId(worldX, worldY, worldZ); + const existingRoom = world.getEntity(id); if (existingRoom) { return existingRoom; } + const room = world.createEntity(id); + const width = randInt(5, ROOM_AREA_WIDTH - 5); const height = randInt(5, ROOM_AREA_HEIGHT - 5); const x = Math.max(ROOM_AREA_X + randInt(2, ROOM_AREA_WIDTH - width - 2), ROOM_AREA_X + 2); const y = Math.max(ROOM_AREA_Y + randInt(2, ROOM_AREA_HEIGHT - height - 2), ROOM_AREA_Y + 2); - const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ }; + room.add(new Position(x, y)); + room.add(new Position(width, height), 'size'); + room.add(new Position(worldX, worldY, worldZ), 'world'); - roomBlank.doors = generateDoors(display, roomBlank); - roomBlank.items = generateItems(display, roomBlank); + const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ, doors: [] }; - const room = new Room(display, x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items); + const doors = generateDoors(world, roomBlank); + room.add(new Room({ doors })); + const inv = room.add(new Inventory()); + + const items = generateItems(world, roomBlank); + + items.forEach((item) => inv.add(item)); - rooms.set(id, room); return room; } -export const getRoomsCount = () => rooms.size; -export const getPossibleRoomsCount = () => { - const roomsSet = new Set(rooms.keys()); - for (const room of rooms.values()) { - room.doors.forEach((d) => { - if (d) { - const id = generateRoomId(d.worldX, d.worldY, d.worldZ); +export const getAllRooms = (world: World) => Array.from(world.query(Room)).map(x => x[0]); +export const getRoomsCount = (world: World) => Array.from(world.query(Room)).length; +export const getPossibleRoomsCount = (world: World) => { + const rooms = getAllRooms(world); + const roomsSet = new Set(rooms.map(x => x.id)); + for (const room of rooms) { + const roomComp = room.get(Room); + roomComp?.state.doors.forEach((d) => { + const worldPos = d?.get(Position, 'world'); + if (worldPos) { + const id = roomId(worldPos.state.x, worldPos.state.y, worldPos.state.z); roomsSet.add(id); } }); @@ -219,4 +269,21 @@ export const getPossibleRoomsCount = () => { return roomsSet.size; }; -export const getRoomsForLayer = (z: number): Room[] => Array.from(rooms.values()).filter((r) => r.worldZ === z); +export const getRoomsForLayer = (world: World, z: number): Entity[] => + getAllRooms(world).filter(r => r.get(Position, 'world')?.state.z === z); + +export function getMapRoomChar(room: Entity): string { + const roomComp = room.get(Room); + if (!roomComp) return ' '; + + const doors = roomComp.state.doors; + let doorsMask = 0; + for (let i = 0; i < 4; i++) { + const mask = 1 << i; + if (doors[i]) { + doorsMask |= mask; + } + } + + return MAP_ROOM_CHARS[doorsMask]; +} diff --git a/src/games/text-dungeon/sprite.ts b/src/games/text-dungeon/sprite.ts deleted file mode 100644 index 56fd2de..0000000 --- a/src/games/text-dungeon/sprite.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { TextDisplay } from "@common/display/text"; -import { Figure } from "./figure"; -import { PLAYER_SPRITE } from "./images"; -import type { IRegion } from "./types"; - -export class Sprite extends Figure { - private prevX: number; - private prevY: number; - private prevImage: IRegion; - - public skipNextBackgroundRestore = false; - - constructor( - display: TextDisplay, - x: number, - y: number, - ) { - super(display, x, y, PLAYER_SPRITE); - - this.prevX = x | 0; - this.prevY = y | 0; - this.prevImage = this.display.getRegion(x, y, this.width, this.height); - } - - override doDraw() { - if (this.skipNextBackgroundRestore) { - this.skipNextBackgroundRestore = false; - } else { - this.display.setRegion(this.prevX, this.prevY, this.width, this.height, this.prevImage); - } - this.prevImage = this.display.getRegion(this.x, this.y, this.width, this.height); - - super.doDraw(); - - this.prevX = this.x; - this.prevY = this.y; - } -} \ No newline at end of file diff --git a/src/games/text-dungeon/types.d.ts b/src/games/text-dungeon/types.d.ts deleted file mode 100644 index 0c8697f..0000000 --- a/src/games/text-dungeon/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Color } from "./const"; - -export type IColorLike = string | number | Color; -export type IChar = [string, IColorLike?, IColorLike?]; -export type IRegion = IChar[][]; - -export interface ISpriteDefinition { - frames: IRegion[]; - animationPeriod?: number; -} \ No newline at end of file diff --git a/src/games/text-dungeon/utils.ts b/src/games/text-dungeon/utils.ts deleted file mode 100644 index 47368d7..0000000 --- a/src/games/text-dungeon/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { randInt } from "@common/utils"; - -export const randChar = (min = ' ', max = '~') => - String.fromCharCode(randInt( - min.charCodeAt(0), - max.charCodeAt(0) + 1, - )); - -export const generateColors = () => { - const colors: string[] = []; - for (let i = 0; i < 16; i++) { - const high = ((i & 0b1000) > 0) ? 'ff' : '7f'; - - const b = ((i & 0b0001) > 0) ? high : '00'; - const g = ((i & 0b0010) > 0) ? high : '00'; - const r = ((i & 0b0100) > 0) ? high : '00'; - - const color = `#${r}${g}${b}`; - colors.push(color); - } - - return colors; -};