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
export class Item extends Component<ItemState> {
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) {

View File

@ -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 });
}
}
}
export const getPosition = (entity: Entity) => entity.get(Position)?.state;

View File

@ -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());
}
}
}
}
}
@component export class Hidden extends Component<{}> {
constructor() {
super({});
}
}

View File

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

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 { 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");
}

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_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,

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 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),
],
],
}
export const ITEM_SPRITE = [
new TextRegion('ъ', Color.MAGENTA),
new TextRegion('ъ', Color.YELLOW),
new TextRegion('ъ', Color.CYAN),
new TextRegion('ъ', Color.GREEN),
].map(Resources.add);

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 { 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);
}
}

View File

@ -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<number, Figure>();
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;
}

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

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 { 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<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 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<number>();
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];
}

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