1
0
Fork 0

Complete refactor text-dungeon to new ECS architecture.

This commit is contained in:
Pabloader 2026-05-02 18:38:18 +00:00
parent 762feba096
commit 2fe3fb1763
17 changed files with 461 additions and 597 deletions

View File

@ -11,7 +11,7 @@ interface ItemState {
@component @component
export class Item extends Component<ItemState> { export class Item extends Component<ItemState> {
constructor(name: string, description = '') { constructor(name = '', description = '') {
super({ name, description }); super({ name, description });
} }
@ -63,7 +63,7 @@ export namespace Items {
equippable?: { slotType: string }; 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); const entity = world.createEntity(id);
entity.add(new Item(name, options?.description)); entity.add(new Item(name, options?.description));
if (options?.maxStack !== undefined) { if (options?.maxStack !== undefined) {

View File

@ -1,4 +1,4 @@
import { Component } from "../core/world"; import { Component, Entity } from "../core/world";
import { component } from "../utils/decorators"; import { component } from "../utils/decorators";
@component({ variables: ['x', 'y', 'z'] }) @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) { constructor(x = 0, y = 0, z = 0) {
super({ x, y, z }); super({ x, y, z });
} }
} }
export const getPosition = (entity: Entity) => entity.get(Position)?.state;

View File

@ -1,5 +1,4 @@
import { Component } from "../core/world"; import { Component } from "../core/world";
import { SpriteSystem } from "../systems/render/sprite";
import { component } from "../utils/decorators"; import { component } from "../utils/decorators";
@component @component
@ -20,9 +19,18 @@ export class Sprite extends Component<{
}); });
} }
override onAdd(): void { override async onAdd() {
if (Number.isFinite(this.state.animationDelay) && !this.world.hasSystem(SpriteSystem)) { if (Number.isFinite(this.state.animationDelay)) {
this.world.addSystem(new SpriteSystem()); const { SpriteSystem } = await import('../systems/render/sprite'); // dynamic import to break cyclic dependency
if (!this.world.hasSystem(SpriteSystem)) {
this.world.addSystem(new SpriteSystem());
}
} }
} }
} }
@component export class Hidden extends Component<{}> {
constructor() {
super({});
}
}

View File

@ -1,6 +1,6 @@
import { TextRegion, TextDisplay } from "@common/display/text"; import { TextRegion, TextDisplay } from "@common/display/text";
import { Position } from "@common/rpg/components/position"; 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 { System, World } from "@common/rpg/core/world";
import { Resources } from "@common/rpg/utils/resources"; import { Resources } from "@common/rpg/utils/resources";
@ -13,7 +13,9 @@ export class TextDisplaySystem extends System {
} }
override update(world: World) { 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 { frames, currentFrame } = sprite.state;
const { x, y } = pos.state; const { x, y } = pos.state;

View File

@ -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 { 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 { 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() { export default async function main() {
const world = new World(); const world = new World();
world.addSystem(new QuestSystem()); const e = world.createEntity();
e.add(new Position(1, 1));
Items.register(world, 'helmet', 'Iron Helmet'); e.add(new Inventory());
const boots = Items.register(world, 'boots', 'Leather Boots', { maxStack: 2, equippable: { slotType: 'feet' } }); console.log("Hello, world!");
boots.add(new Effect({ const display = new TextDisplay();
targetStat: 'agl', new TextDisplaySystem(display);
delta: 10, console.log("Hello, world 2");
}));
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)));
} }

View File

@ -16,24 +16,6 @@ export const MAP_Y = 0;
export const MAP_WIDTH = GAME_WIDTH - ROOM_AREA_WIDTH - MAP_X; export const MAP_WIDTH = GAME_WIDTH - ROOM_AREA_WIDTH - MAP_X;
export const MAP_HEIGHT = 10; 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 { export enum Direction {
NORTH = 0, NORTH = 0,
EAST = 1, EAST = 1,

View File

@ -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;
}
}

View File

@ -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)];
}
}

View File

@ -1,57 +1,30 @@
import { Color } from "./const"; import { Color, TextRegion } from "@common/display/text"
import type { IChar, ISpriteDefinition } from "./types"; import { Resources } from "@common/rpg/utils/resources";
const l = (line: string, fg = Color.WHITE, bg = Color.BLACK): IChar[] => export const PLAYER_SPRITE = [
Array.from(line).map((c) => [c, fg, bg]); new TextRegion('@', Color.YELLOW)
].map(Resources.add);
export const PLAYER_SPRITE: ISpriteDefinition = { export const DOOR_SPRITE = [
frames: [ new TextRegion('┘ └'),
[ new TextRegion([
l('@', Color.YELLOW), '└',
], ' ',
], '┌',
}; ]),
new TextRegion('┐ ┌'),
new TextRegion([
'┘',
' ',
'┐',
]),
new TextRegion('^', Color.CYAN),
new TextRegion('_', Color.GREEN),
].map(Resources.add);
export const DOOR_SPRITE: ISpriteDefinition = { export const ITEM_SPRITE = [
frames: [ new TextRegion('ъ', Color.MAGENTA),
[ new TextRegion('ъ', Color.YELLOW),
l('┘ └'), new TextRegion('ъ', Color.CYAN),
], new TextRegion('ъ', Color.GREEN),
[ ].map(Resources.add);
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),
],
],
}

View File

@ -1,144 +1,196 @@
import { TextDisplay } from '@common/display/text'; import { Color, TextDisplay } from '@common/display/text';
import Input from '@common/input'; 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 { nextFrame } from '@common/utils';
import './assets/style.css'; import './assets/style.css';
import { Color, Direction, getOppositeDirection } from './const'; 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 { createItems, getItemsCount } from './item'; import { getPossibleRoomsCount, getRoom, getRoomsCount, getRoomsForLayer, getMapRoomChar, Room, Door } from './room';
import { GameMap } from './map'; import { createPlayer, Player } from './player';
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);
let lastMove = Date.now(); 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; class GameSystem extends System {
lastMove = Date.now(); private readonly display = new TextDisplay();
let newX = player.x; private room: Entity | undefined;
let newY = player.y; private player: Entity | undefined;
let moved = isSpacePressed;
if (Input.isHeld(Input.KeyCode.UP)) { override onAdd(world: World) {
newY--; world.addSystem(new TextDisplaySystem(this.display));
moved = true;
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)) { override update(world: World) {
newY++; this.handleInput(world);
moved = true;
this.drawRoom();
this.drawMap(world);
this.drawInfo(world);
} }
if (Input.isHeld(Input.KeyCode.LEFT)) { private drawRoom() {
newX--; const room = this.room?.get(Room);
moved = true; 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)) { private drawMap(world: World) {
newX++; if (!this.room) return;
moved = true;
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) { private drawInfo(world: World) {
const activatedDoor = currentRoom.getActivatedDoor(newX, newY); if (!this.room || !this.player) return;
if (activatedDoor) {
const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed;
if (shouldTravel) { const worldPos = this.room.get(Position, 'world');
currentRoom = getRoom(display, activatedDoor.worldX, activatedDoor.worldY, activatedDoor.worldZ); const coords = worldPos ? `${worldPos.state.x},${worldPos.state.y},${worldPos.state.z}`.padStart(9) : '0,0,0';
currentRoom.invalidate(); const foundRooms = getRoomsCount(world);
map.currentRoom = currentRoom; const totalRooms = getPossibleRoomsCount(world);
map.invalidate(); const rooms = `${foundRooms}/${totalRooms}${foundRooms === totalRooms ? '' : '+'}`.padStart(coords.length - 2);
player.skipNextBackgroundRestore = true; const inv = this.player.get(Inventory);
const oppositeDoor = currentRoom.doors[getOppositeDirection(activatedDoor.direction)]; 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) { private handleInput(world: World) {
switch (activatedDoor.direction) { const player = this.player?.get(Player);
case Direction.NORTH: let room = this.room?.get(Room);
newX = oppositeDoor.x + 1; if (!player || !room) return;
newY = oppositeDoor.y - 1;
break; Input.updateKeys();
case Direction.SOUTH:
newX = oppositeDoor.x + 1; const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE);
newY = oppositeDoor.y + 1;
break; if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return;
case Direction.EAST: lastMove = Date.now();
newX = oppositeDoor.x + 1;
newY = oppositeDoor.y + 1; let newX = player.x;
break; let newY = player.y;
case Direction.WEST: let moved = isSpacePressed;
newX = oppositeDoor.x - 1;
newY = oppositeDoor.y + 1; if (Input.isHeld(Input.KeyCode.UP)) {
break; newY--;
case Direction.UP: moved = true;
case Direction.DOWN: }
newX = oppositeDoor.x;
newY = oppositeDoor.y; if (Input.isHeld(Input.KeyCode.DOWN)) {
break; 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; } else {
newY = currentRoom.y + currentRoom.height / 2; 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); // const pickedItem = room.pickItem(newX, newY);
if (pickedItem) { // if (pickedItem) {
player.addItem(pickedItem); // player.addItem(pickedItem);
} // }
player.x = newX; player.x = newX;
player.y = newY; player.y = newY;
player.invalidate(); }
} }
} }
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() { export default async function main() {
createItems(display); const world = new World();
currentRoom.invalidate(); world.addSystem(new GameSystem());
player.invalidate();
display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN });
while (true) { while (true) {
Input.updateKeys(); const dt = await nextFrame();
handleInput();
handleLogic();
draw();
await nextFrame(); world.update(dt);
} }
} }

View File

@ -1,39 +1,43 @@
import type { TextDisplay } from "@common/display/text"; import { Component, type Entity, type World } from "@common/rpg/core/world";
import { Drawable } from "./drawable"; import { Position } from "@common/rpg/components/position";
import { Figure } from "./figure"; import { Sprite } from "@common/rpg/components/sprite";
import { ITEM_KEY_SPRITE } from "./images"; import { ITEM_SPRITE } from "./images";
import { Item, Items } from "@common/rpg/components/item";
import { component } from "@common/rpg/utils/decorators";
let globalItemId = 0; @component
const idMap = new Map<number, Figure>(); class PickupCount extends Component<{ count: number }> {
constructor(count = 1) {
export const getItemsCount = () => idMap.size; super({ count });
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),
);
} }
} }
export class Item extends Drawable { export function findItem(world: World, id: number): Entity | undefined {
constructor( for (const [item, , component] of world.query(Item)) {
display: TextDisplay, if (component.state.name === id.toString()) {
public id: number, return item;
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();
} }
} }
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;
} }

View File

@ -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];
}

View File

@ -1,47 +1,43 @@
import { INVENTORY_X, INVENTORY_Y } from "./const"; import { component } from "@common/rpg/utils/decorators";
import { getItemsCount, Item } from "./item"; import { Component, Entity, World } from "@common/rpg/core/world";
import { Sprite } from "./sprite"; import { Position } from "@common/rpg/components/position";
import { Sprite } from "@common/rpg/components/sprite";
import { PLAYER_SPRITE } from "./images";
export class Player extends Sprite { @component
private inventory: Item[] = []; export class Player extends Component<{}> {
constructor() {
addItem(item: Item) { super({});
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,
));
} }
hasItem(item: Item) { get x() {
for (const i of this.inventory) { const pos = this.entity.get(Position);
if (i.id === item.id) { return pos ? pos.state.x : 0;
return true;
}
}
return false;
} }
get foundItems() { get y() {
return this.inventory.length; const pos = this.entity.get(Position);
return pos ? pos.state.y : 0;
} }
override doDraw() { set x(x: number) {
super.doDraw(); const pos = this.entity.get(Position);
if (pos) pos.state.x = x;
}
this.display.drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' }); set y(y: number) {
this.inventory.forEach((item) => { const pos = this.entity.get(Position);
if (item.count > 0) { if (pos) pos.state.y = y;
item.doDraw();
}
});
} }
} }
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;
}

View File

@ -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 { 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 { Direction, DIRECTION_OFFSETS, getOppositeDirection, MAP_ROOM_CHARS, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from "./const";
import { Drawable } from "./drawable"; import { DOOR_SPRITE, ITEM_SPRITE } from "./images";
import { Figure } from "./figure"; import { spawnItem } from "./item";
import { DOOR_SPRITE } from "./images"; import { Resources } from "@common/rpg/utils/resources";
import { getItemsCount, Item } from "./item"; import { TextRegion } from "@common/display/text";
import type { TextDisplay } 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 { @component
constructor( export class Room extends Component<{ doors: IDoors }> {
display: TextDisplay, get x() {
x: number, const pos = this.entity.get(Position);
y: number, return pos ? pos.state.x : 0;
direction: Direction,
public worldX: number,
public worldY: number,
public worldZ: number,
) {
super(display, x, y, DOOR_SPRITE);
this.frame = direction;
} }
get y() {
isActivated(x: number, y: number): boolean { const pos = this.entity.get(Position);
const cx = Math.floor(this.x + this.width / 2); return pos ? pos.state.y : 0;
const cy = Math.floor(this.y + this.height / 2);
return (cx === x && cy === y);
} }
get width() {
get direction(): Direction { const pos = this.entity.get(Position, 'size');
return this.frame as Direction; return pos ? pos.state.x : 0;
} }
} get height() {
const pos = this.entity.get(Position, 'size');
export class Room extends Drawable { return pos ? pos.state.y : 0;
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 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() { const offset = door?.get(Position, 'offset');
this.display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] }); const { x: dx = 0, y: dy = 0 } = offset?.state ?? {}
this.display.drawBox(this.x, this.y, this.width, this.height, { fill: ['.', Color.GRAY] });
this.doors.forEach((door) => { const { x, y } = doorPos.state;
if (door) { return px === x - dx && py === y - dy;
door.doDraw();
}
}); });
this.items.forEach((item) => item.doDraw());
} }
getActivatedDoor(x: number, y: number): Door | null { onEnter() {
return this.doors.find((door) => door?.isActivated(x, y)) ?? null; this.doors.forEach(door => door?.remove(Hidden));
} }
pickItem(x: number, y: number): Item | null { onLeave() {
const itemIndex = this.items.findIndex((item) => item.x === x && item.y === y); this.doors.forEach(door => door?.add(new Hidden()));
if (itemIndex >= 0) {
const [item] = this.items.splice(itemIndex, 1);
this.invalidate();
return item;
}
return null;
} }
} }
type IRoomBlank = { @component
-readonly [P in keyof Room]?: Room[P] 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<string, Room>(); 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 generateDoors = (world: World, { x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => {
const doors: IDoors = [null, null, null, null, null, null]; const doors: IDoors = [];
if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') { if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') {
console.error('World coordinates not defined for doors generation'); console.error('World coordinates not defined for doors generation');
return doors; return doors;
@ -101,9 +139,9 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl
const [xoff, yoff] = DIRECTION_OFFSETS[i]; const [xoff, yoff] = DIRECTION_OFFSETS[i];
const doorWorldX = worldX + xoff; const doorWorldX = worldX + xoff;
const doorWorldY = worldY + yoff; const doorWorldY = worldY + yoff;
const doorId = generateRoomId(doorWorldX, doorWorldY, worldZ); const doorId = roomId(doorWorldX, doorWorldY, worldZ);
const doorRoom = rooms.get(doorId); const doorRoom = world.getEntity(doorId);
if (doorRoom && !doorRoom.doors[getOppositeDirection(i)]) { if (doorRoom && !doorRoom.get(Room)?.state.doors[getOppositeDirection(i)]) {
continue; 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.EAST: dx = x + width + 1; dy = doorY; break;
case Direction.WEST: dx = x; 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 doorX = randInt(x + 1, x + width - 2);
const doorY = randInt(y + 1, y + height - 2); const doorY = randInt(y + 1, y + height - 2);
const [, , zoff] = DIRECTION_OFFSETS[Direction.DOWN]; 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) { if (worldZ !== 0) {
let upDoorX: number; let upDoorX: number;
@ -136,7 +174,7 @@ const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worl
upDoorY = randInt(y + 1, y + height - 2); upDoorY = randInt(y + 1, y + height - 2);
} while (doorX === upDoorX && doorY === upDoorY); } while (doorX === upDoorX && doorY === upDoorY);
const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP]; 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<number>(); const generatedItems = new Set<number>();
const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRoomBlank): Item[] => { const generateItems = (world: World, { x, y, width, height, doors }: IRoomBlank): Entity[] => {
const items: Item[] = []; const items: Entity[] = [];
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') { if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') {
console.error('Screen coordinates not defined for items generation'); console.error('Screen coordinates not defined for items generation');
return items; return [];
} }
if (typeof doors === 'undefined') { if (typeof doors === 'undefined') {
console.error('Doors not defined for items generation'); console.error('Doors not defined for items generation');
return items; return [];
} }
const hasObjectAt = (x: number, y: number) => const hasObjectAt = (x: number, y: number) =>
doors.some((d) => d?.x === x && d?.y === y) || doors.flat().filter(x => x != null).map(getPosition).some((d) => d?.x === x && d.y === y) ||
items.some((i) => i.x === x && i.y === y); items.map(getPosition).some((i) => i?.x === x && i.y === y);
const oneDoor = doors.filter((d) => Boolean(d)).length === 1; 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)) { if (Math.random() < 0.4 && oneDoor && !generatedItems.has(id)) {
let newItemX: number; let newItemX: number;
@ -172,7 +210,7 @@ const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRo
newItemX = randInt(x + 1, x + width - 2); newItemX = randInt(x + 1, x + width - 2);
newItemY = randInt(y + 1, y + height - 2); newItemY = randInt(y + 1, y + height - 2);
} while (hasObjectAt(newItemX, newItemY)); } while (hasObjectAt(newItemX, newItemY));
const item = new Item(display, id, 1, newItemX, newItemY); const item = spawnItem(world, id, 1, newItemX, newItemY);
items.push(item); items.push(item);
generatedItems.add(id); generatedItems.add(id);
@ -181,36 +219,48 @@ const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRo
return items; return items;
}; };
export function getRoom(display: TextDisplay, worldX: number, worldY: number, worldZ: number) { export function getRoom(world: World, worldX: number, worldY: number, worldZ: number): Entity {
const id = generateRoomId(worldX, worldY, worldZ); const id = roomId(worldX, worldY, worldZ);
const existingRoom = rooms.get(id); const existingRoom = world.getEntity(id);
if (existingRoom) { if (existingRoom) {
return existingRoom; return existingRoom;
} }
const room = world.createEntity(id);
const width = randInt(5, ROOM_AREA_WIDTH - 5); const width = randInt(5, ROOM_AREA_WIDTH - 5);
const height = randInt(5, ROOM_AREA_HEIGHT - 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 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 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); const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ, doors: [] };
roomBlank.items = generateItems(display, roomBlank);
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; return room;
} }
export const getRoomsCount = () => rooms.size; export const getAllRooms = (world: World) => Array.from(world.query(Room)).map(x => x[0]);
export const getPossibleRoomsCount = () => { export const getRoomsCount = (world: World) => Array.from(world.query(Room)).length;
const roomsSet = new Set(rooms.keys()); export const getPossibleRoomsCount = (world: World) => {
for (const room of rooms.values()) { const rooms = getAllRooms(world);
room.doors.forEach((d) => { const roomsSet = new Set(rooms.map(x => x.id));
if (d) { for (const room of rooms) {
const id = generateRoomId(d.worldX, d.worldY, d.worldZ); 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); roomsSet.add(id);
} }
}); });
@ -219,4 +269,21 @@ export const getPossibleRoomsCount = () => {
return roomsSet.size; 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];
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
};