From b549193a3b244141a955eba3cd5553b2fd6fc6fc Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 30 Apr 2026 15:25:22 +0000 Subject: [PATCH] Add TextDisplay with canvas --- src/common/display/text.ts | 305 +++++++++++++++++++++++++ src/common/input.ts | 2 +- src/common/rpg/components/equipment.ts | 4 +- src/games/olc-2025-shapes/index.ts | 26 --- src/games/text-dungeon/const.ts | 8 +- src/games/text-dungeon/display.ts | 230 ------------------- src/games/text-dungeon/drawable.ts | 3 + src/games/text-dungeon/figure.ts | 7 +- src/games/text-dungeon/game.ts | 141 ------------ src/games/text-dungeon/index.ts | 151 +++++++++++- src/games/text-dungeon/item.ts | 8 +- src/games/text-dungeon/map.ts | 10 +- src/games/text-dungeon/player.ts | 4 +- src/games/text-dungeon/room.ts | 32 +-- src/games/text-dungeon/sprite.ts | 11 +- src/games/text-dungeon/types.d.ts | 2 +- test/common/rpg/inventory.test.ts | 2 +- 17 files changed, 497 insertions(+), 449 deletions(-) create mode 100644 src/common/display/text.ts delete mode 100644 src/games/olc-2025-shapes/index.ts delete mode 100644 src/games/text-dungeon/display.ts delete mode 100644 src/games/text-dungeon/game.ts diff --git a/src/common/display/text.ts b/src/common/display/text.ts new file mode 100644 index 0000000..31f2184 --- /dev/null +++ b/src/common/display/text.ts @@ -0,0 +1,305 @@ +import '@common/assets/vga.font.css'; +import { randInt } from "@common/utils"; +import { createCanvas } from './canvas'; + +export type IColorLike = string | number | Color; +export type IChar = [string, IColorLike?, IColorLike?]; +export type IRegion = IChar[][]; + +export interface ISpriteDefinition { + frames: IRegion[]; + animationPeriod?: number; +} + +export const randChar = (min = ' ', max = '~') => + String.fromCharCode(randInt( + min.charCodeAt(0), + max.charCodeAt(0) + 1, + )); + +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; +}; + +const COLORS = generateColors(); + +export const GAME_WIDTH = 80; +export const GAME_HEIGHT = 25; + +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, + SOUTH = 2, + WEST = 3, + UP = 4, + DOWN = 5, +} + +export const isVertical = (char: string) => char === '│'; +export const isHorizontal = (char: string) => char === '─'; +export const isCorner = (char: string) => '┌┐└┘'.includes(char); + +interface IBoxOptions { + vertical?: string; + horizontal?: string; + topLeft?: string; + topRight?: string; + bottomLeft?: string; + bottomRight?: string; + fill?: IChar; + fg?: IColorLike; + bg?: IColorLike; + title?: string; +} + +const CHAR_W = 8; +const CHAR_H = 16; +const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`; +const FALLBACK_FONT = `${CHAR_H}px monospace`; + +const colorToCSS = (c: IColorLike): string => + typeof c === 'number' ? COLORS[c] : c as string; + +export class TextDisplay { + private chars: string[]; + private fgs: IColorLike[]; + private bgs: IColorLike[]; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private font = FALLBACK_FONT; + private letterboxColor: string; + + constructor( + public width = GAME_WIDTH, + public height = GAME_HEIGHT, + parent?: HTMLCanvasElement, + letterboxColor: IColorLike = Color.BLACK, + ) { + this.letterboxColor = colorToCSS(letterboxColor); + const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H); + canvas.width = width * CHAR_W; + canvas.height = height * CHAR_H; + canvas.style.imageRendering = 'pixelated'; + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.ctx.textBaseline = 'top'; + + const size = width * height; + this.chars = Array.from({ length: size }, () => randChar('!')); + this.fgs = new Array(size).fill(COLORS[7]); + this.bgs = new Array(size).fill(COLORS[0]); + + window.addEventListener('resize', () => this.updateScale()); + this.updateScale(); + + Promise.race([ + document.fonts.load(NATIVE_FONT).then(() => true).catch(() => false), + new Promise(resolve => setTimeout(() => resolve(false), 1000)), + ]).then(loaded => { + this.font = loaded ? NATIVE_FONT : FALLBACK_FONT; + this.redraw(); + }); + } + + private updateScale() { + const scale = Math.max(1, Math.min( + Math.floor(window.innerWidth / (this.width * CHAR_W)), + Math.floor(window.innerHeight / (this.height * CHAR_H)), + )); + this.canvas.style.width = `${this.width * CHAR_W * scale}px`; + this.canvas.style.height = `${this.height * CHAR_H * scale}px`; + document.body.style.background = this.letterboxColor; + } + + private redraw() { + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + this.drawCell(x, y); + } + } + } + + private drawCell(x: number, y: number) { + const i = y * this.width + x; + const px = x * CHAR_W; + const py = y * CHAR_H; + + this.ctx.fillStyle = colorToCSS(this.bgs[i]); + this.ctx.fillRect(px, py, CHAR_W, CHAR_H); + + const char = this.chars[i]; + if (char !== ' ') { + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(px, py, CHAR_W, CHAR_H); + this.ctx.clip(); + this.ctx.font = this.font; + this.ctx.fillStyle = colorToCSS(this.fgs[i]); + this.ctx.fillText(char, px, py); + this.ctx.restore(); + } + } + + private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) { + if (x < 0 || y < 0 || y >= this.height || x >= this.width || !char) return; + const i = (y | 0) * this.width + (x | 0); + if (this.chars[i] === char && this.fgs[i] === fg && this.bgs[i] === bg) return; + this.chars[i] = char; + this.fgs[i] = fg; + this.bgs[i] = bg; + this.drawCell(x | 0, y | 0); + } + + setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) { + this.setCharRaw(x, y, char, fg, bg); + } + + getChar(x: number, y: number): IChar { + if (x < 0 || y < 0 || y >= this.height || x >= this.width) { + return [' ', COLORS[0], COLORS[1]]; + } + const i = (y | 0) * this.width + (x | 0); + return [this.chars[i], this.fgs[i], this.bgs[i]]; + } + + setRegion(x: number, y: number, w: number, h: number, region: IRegion) { + for (let row = 0; row < h; row++) { + for (let col = 0; col < w; col++) { + const [char, fg = 'white', bg = 'black'] = region[row][col]; + this.setCharRaw(x + col, y + row, char, fg, bg); + } + } + } + + getRegion(x: number, y: number, w: number, h: number) { + const region: IRegion = []; + for (let row = 0; row < h; row++) { + const line: IChar[] = []; + for (let col = 0; col < w; col++) { + line.push(this.getChar(x + col, y + row)); + } + region.push(line); + } + return region; + } + + drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) { + const lines = text.split('\n'); + for (let row = 0; row < lines.length; row++) { + const line = lines[row]; + const ry = y + row; + if (ry < 0 || ry >= this.height) continue; + for (let col = 0; col < line.length; col++) { + this.setCharRaw(x + col, ry, line[col], fg, bg); + } + } + } + + drawVLine(x: number, y1: number, y2: number, [char, fg = 'white', bg = 'black']: IChar = ['│']) { + if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } + y1 = Math.max(0, y1); + y2 = Math.min(this.height - 1, y2); + if (x < 0 || x >= this.width) return; + for (let y = y1; y <= y2; y++) { + this.setCharRaw(x, y, char, fg, bg); + } + } + + drawHLine(x1: number, x2: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['─']) { + if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } + x1 = Math.max(0, x1); + x2 = Math.min(this.width - 1, x2); + if (y < 0 || y >= this.height) return; + for (let x = x1; x <= x2; x++) { + this.setCharRaw(x, y, char, fg, bg); + } + } + + drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) { + const { + vertical = '│', + horizontal = '─', + topLeft = '┌', + topRight = '┐', + bottomLeft = '└', + bottomRight = '┘', + fg = Color.WHITE, + bg = Color.BLACK, + fill, + title, + } = options; + + this.setCharRaw(x, y, topLeft, fg, bg); + this.setCharRaw(x + width + 1, y, topRight, fg, bg); + this.setCharRaw(x, y + height + 1, bottomLeft, fg, bg); + this.setCharRaw(x + width + 1, y + height + 1, bottomRight, fg, bg); + + if (fill) { + this.fillBox(x + 1, y + 1, width, height, fill); + } + + this.drawHLine(x + 1, x + width, y, [horizontal, fg, bg]); + this.drawHLine(x + 1, x + width, y + height + 1, [horizontal, fg, bg]); + + this.drawVLine(x, y + 1, y + height, [vertical, fg, bg]); + this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]); + + if (title) { + this.drawText(x + 1, y, title, fg, bg); + } + } + + drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) { + const { + fg = Color.WHITE, + bg = Color.BLACK, + } = options; + let width = 0; + const lines = text.split('\n'); + const height = lines.length; + + for (const line of lines) { + if (line.length > width) width = line.length; + } + + this.drawBox(x, y, width, height, { ...options, fill: [' '] }); + this.drawText(x + 1, y + 1, text, fg, bg); + } + + fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) { + for (let i = y; i < y + height; i++) { + this.drawHLine(x, x + width - 1, i, char); + } + } +} + diff --git a/src/common/input.ts b/src/common/input.ts index 7e059df..9412a79 100644 --- a/src/common/input.ts +++ b/src/common/input.ts @@ -49,7 +49,7 @@ namespace Input { const KEYS: Partial> = {}; const onStateChange = (keyId: KeyCode, state: boolean) => { - console.debug(`[Input] Pressed ${keyId}`); + console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`); if (KEYS[keyId]) { KEYS[keyId].state = state; } else { diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index 5c24958..26f0182 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -37,10 +37,12 @@ interface EquipmentState { slots: Record; } -export type SlotInput = +type SlotDefinition = | string // generic slot | { slotName: string; type?: string }; // typed slot +export type SlotInput = SlotDefinition | SlotDefinition[]; + @component export class Equipment extends Component { #cachedVars: RPGVariables | null = null; diff --git a/src/games/olc-2025-shapes/index.ts b/src/games/olc-2025-shapes/index.ts deleted file mode 100644 index 07248f4..0000000 --- a/src/games/olc-2025-shapes/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createCanvas } from "@common/display/canvas"; -import { gameLoop } from "@common/game"; - -type State = ReturnType; - -const setup = () => { - const canvas = createCanvas(800, 600); - const ctx = canvas.getContext("2d"); - - if (!ctx) { - throw new Error("Failed to get canvas context"); - } - - return { - canvas, - ctx, - }; -}; - -const frame = (dt: number, state: State) => { - const { ctx } = state; - ctx.fillStyle = "blue"; - ctx.fillRect(0, 0, 800, 600); -}; - -export default gameLoop(setup, frame); \ No newline at end of file diff --git a/src/games/text-dungeon/const.ts b/src/games/text-dungeon/const.ts index a9e5833..74a663d 100644 --- a/src/games/text-dungeon/const.ts +++ b/src/games/text-dungeon/const.ts @@ -65,10 +65,10 @@ export const getOppositeDirection = (d: Direction): Direction => { export const MAP_ROOM_CHARS: Record = { [0b0000]: ' ', - [0b0001]: '█', - [0b0010]: '█', - [0b0100]: '█', - [0b1000]: '█', + [0b0001]: '╨', + [0b0010]: '╞', + [0b0100]: '╥', + [0b1000]: '╡', [0b0011]: '╚', [0b0110]: '╔', [0b1100]: '╗', diff --git a/src/games/text-dungeon/display.ts b/src/games/text-dungeon/display.ts deleted file mode 100644 index ac8208d..0000000 --- a/src/games/text-dungeon/display.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { nextFrame } from "@common/utils"; -import { Color, GAME_HEIGHT, GAME_WIDTH } from "./const"; -import type { IChar, IColorLike, IRegion } from "./types"; -import { generateColors, randChar } from "./utils"; - -const ROOT_NODE = document.getElementById('root'); -const FPS_NODE = document.getElementById('fps'); - -const COLORS = generateColors(); -const GAME_FIELD = generateField(); - -function handleResize() { - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; - - const charWidth = windowWidth / GAME_WIDTH; - const charHeight = windowHeight / GAME_HEIGHT; - - // TODO -} - -function generateField(width = GAME_WIDTH, height = GAME_HEIGHT, parent = ROOT_NODE): HTMLSpanElement[][] { - if (!parent) return []; - - let field: HTMLSpanElement[][] = []; - parent.textContent = ''; - - for (let row = 0; row < height; row++) { - const line = document.createElement('div'); - field[row] = []; - - for (let column = 0; column < width; column++) { - const span = document.createElement('span'); - span.innerHTML = randChar('!'); - span.style.color = COLORS[7]; - line.append(span); - - field[row][column] = span; - } - - parent.append(line); - } - - return field; -} - -export function setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) { - if (x < 0 || y < 0 || y >= GAME_HEIGHT || x >= GAME_WIDTH || !char) { - return; - } - const span = GAME_FIELD[y | 0][x | 0]; - - if (typeof fg === 'number') fg = COLORS[fg]; - if (typeof bg === 'number') bg = COLORS[bg]; - - if (char === ' ') { - span.innerHTML = ' '; - } else { - span.textContent = char.toString(); - span.style.color = fg; - span.style.backgroundColor = bg; - } -} - -export function getChar(x: number, y: number): IChar { - if (x < 0 || y < 0 || y >= GAME_HEIGHT || x >= GAME_WIDTH) { - return [' ', COLORS[0], COLORS[1]]; - } - - const span = GAME_FIELD[y | 0][x | 0]; - - return [ - span.textContent?.[0] ?? ' ', - span.style.color, - span.style.backgroundColor, - ]; -} - -export function setRegion(x: number, y: number, w: number, h: number, region: IRegion) { - for (let screenY = y; screenY < y + h; screenY++) { - for (let screenX = x; screenX < x + w; screenX++) { - const char = region[screenY - y][screenX - x] - setChar(screenX, screenY, char); - } - } -} - -export function getRegion(x: number, y: number, w: number, h: number) { - const region: IRegion = []; - for (let screenY = y; screenY < y + h; screenY++) { - const line: IChar[] = [] - for (let screenX = x; screenX < x + w; screenX++) { - line.push(getChar(screenX, screenY)); - } - region.push(line); - } - - return region; -} - -export function drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) { - if (text.includes('\n')) { - const lines = text.split('\n'); - for (let line = 0; line < lines.length; line++) { - drawText(x, y + line, lines[line], fg, bg); - } - } else { - for (let i = 0; i < text.length; i++) { - setChar(x + i, y, [text[i], fg, bg]); - } - } -} - -export function drawVLine(x: number, y1: number, y2: number, char: IChar = ['│']) { - if (y2 < y1) { - const t = y2; - y2 = y1; - y1 = t; - } - for (let y = y1; y <= y2; y++) { - setChar(x, y, char); - } -} - -export function drawHLine(x1: number, x2: number, y: number, char: IChar = ['─']) { - if (x2 < x1) { - const t = x2; - x2 = x1; - x1 = t; - } - for (let x = x1; x <= x2; x++) { - setChar(x, y, char); - } -} - -export const isVertical = (char: string) => char === '│'; -export const isHorizontal = (char: string) => char === '─'; -export const isCorner = (char: string) => '┌┐└┘'.includes(char); - -interface IBoxOptions { - vertical?: string; - horizontal?: string; - topLeft?: string; - topRight?: string; - bottomLeft?: string; - bottomRight?: string; - fill?: IChar; - fg?: IColorLike; - bg?: IColorLike; - title?: string; -} -export function drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) { - const { - vertical = '│', - horizontal = '─', - topLeft = '┌', - topRight = '┐', - bottomLeft = '└', - bottomRight = '┘', - fg = Color.WHITE, - bg = Color.BLACK, - fill, - title, - } = options; - - setChar(x, y, [topLeft, fg, bg]); - setChar(x + width + 1, y, [topRight, fg, bg]); - setChar(x, y + height + 1, [bottomLeft, fg, bg]); - setChar(x + width + 1, y + height + 1, [bottomRight, fg, bg]); - - if (fill) { - fillBox(x + 1, y + 1, width, height, fill); - } - - drawHLine(x + 1, x + width, y, [horizontal, fg, bg]); - drawHLine(x + 1, x + width, y + height + 1, [horizontal, fg, bg]); - - drawVLine(x, y + 1, y + height, [vertical, fg, bg]); - drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]); - - if (title) { - drawText(x + 1, y, title, fg, bg); - } -} - -export function drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) { - const { - fg = Color.WHITE, - bg = Color.BLACK, - } = options; - let width = 0; - const lines = text.split('\n'); - const height = lines.length; - - for (const line of lines) { - if (line.length > width) { - width = line.length; - } - } - - drawBox(x, y, width, height, { ...options, fill: [' '] }); - drawText(x + 1, y + 1, text, fg, bg); -} - -export function fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) { - for (let i = y; i < y + height; i++) { - drawHLine(x, x + width - 1, i, char); - } -} - -let prevTime = 0; -let dt = 0; - -export async function tick(desiredFPS = 60) { - const time = await nextFrame(); - dt = time - prevTime; - prevTime = time; - if (FPS_NODE) { - FPS_NODE.textContent = `${Math.round(1000 / dt)}`; - } - - const totalDelay = 1000 / desiredFPS; - const remainingDelay = totalDelay - dt; - if (remainingDelay > 4) { - await Bun.sleep(remainingDelay); - } -} - -window.addEventListener('resize', handleResize); -handleResize(); diff --git a/src/games/text-dungeon/drawable.ts b/src/games/text-dungeon/drawable.ts index bcc0e9b..cd67b86 100644 --- a/src/games/text-dungeon/drawable.ts +++ b/src/games/text-dungeon/drawable.ts @@ -1,6 +1,9 @@ +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) { diff --git a/src/games/text-dungeon/figure.ts b/src/games/text-dungeon/figure.ts index a59e1d2..6f93de7 100644 --- a/src/games/text-dungeon/figure.ts +++ b/src/games/text-dungeon/figure.ts @@ -1,4 +1,4 @@ -import { setRegion } from "./display"; +import type { TextDisplay } from "@common/display/text"; import { Drawable } from "./drawable"; import type { IRegion, ISpriteDefinition } from "./types"; @@ -8,12 +8,13 @@ export class Figure extends Drawable { private animationPeriod; constructor( + display: TextDisplay, public x: number, public y: number, definition: ISpriteDefinition, public frame: number = 0, ) { - super(); + super(display); this.x = x | 0; this.y = y | 0; this.frames = definition.frames; @@ -28,7 +29,7 @@ export class Figure extends Drawable { this.frame = (this.frame + 1) % this.numFrames; } - setRegion(this.x, this.y, this.width, this.height, this.image); + this.display.setRegion(this.x, this.y, this.width, this.height, this.image); } get width() { diff --git a/src/games/text-dungeon/game.ts b/src/games/text-dungeon/game.ts deleted file mode 100644 index b5faebf..0000000 --- a/src/games/text-dungeon/game.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Color, Direction, getOppositeDirection } from './const'; -import { drawTextInBox, tick } from './display'; -import Input from '@common/input'; -import { createItems, getItemsCount } from './item'; -import { GameMap } from './map'; -import { Player } from './player'; -import { getPossibleRoomsCount, getRoom, getRoomsCount } from './room'; - -let currentRoom = getRoom(0, 0, 0); -const map = new GameMap(currentRoom); -currentRoom.draw(); -const player = new Player(currentRoom.x + currentRoom.width / 2, currentRoom.y + currentRoom.height / 2); - -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; - - 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 = currentRoom.getActivatedDoor(newX, newY); - if (activatedDoor) { - const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed; - - if (shouldTravel) { - currentRoom = getRoom(activatedDoor.worldX, activatedDoor.worldY, activatedDoor.worldZ); - currentRoom.invalidate(); - map.currentRoom = currentRoom; - map.invalidate(); - player.skipNextBackgroundRestore = true; - const oppositeDoor = currentRoom.doors[getOppositeDirection(activatedDoor.direction)]; - - 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 = currentRoom.x + currentRoom.width / 2; - newY = currentRoom.y + currentRoom.height / 2; - } - } - } 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); - } - - player.x = newX; - 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); - drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' }); -} - -function draw() { - currentRoom.draw(); - map.draw(); - player.draw(); - - drawInfo(); -} - -export async function main() { - createItems(); - currentRoom.invalidate(); - player.invalidate(); - drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN }); - - while (true) { - Input.updateKeys(); - handleInput(); - handleLogic(); - draw(); - - await tick(60); - } -} diff --git a/src/games/text-dungeon/index.ts b/src/games/text-dungeon/index.ts index 56ecfb2..e0df26f 100644 --- a/src/games/text-dungeon/index.ts +++ b/src/games/text-dungeon/index.ts @@ -1,15 +1,144 @@ -import '@common/assets/vga.font.css'; +import { TextDisplay } from '@common/display/text'; +import Input from '@common/input'; +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'; -export default async function run() { - const root = document.createElement('div'); - root.id = 'root'; - document.body.append(root); +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); - const fps = document.createElement('div'); - fps.id = 'fps'; - document.body.append(fps); +let lastMove = Date.now(); +function handleInput() { + const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE); - const { main } = await import('./game'); - await main(); -} \ No newline at end of file + 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 = currentRoom.getActivatedDoor(newX, newY); + if (activatedDoor) { + const shouldTravel = ![Direction.UP, Direction.DOWN].includes(activatedDoor.direction) || isSpacePressed; + + 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)]; + + 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 = currentRoom.x + currentRoom.width / 2; + newY = currentRoom.y + currentRoom.height / 2; + } + } + } 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); + } + + player.x = newX; + 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() { + 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 }); + + while (true) { + Input.updateKeys(); + handleInput(); + handleLogic(); + draw(); + + await nextFrame(); + } +} diff --git a/src/games/text-dungeon/item.ts b/src/games/text-dungeon/item.ts index 0497694..958e16c 100644 --- a/src/games/text-dungeon/item.ts +++ b/src/games/text-dungeon/item.ts @@ -1,3 +1,4 @@ +import type { TextDisplay } from "@common/display/text"; import { Drawable } from "./drawable"; import { Figure } from "./figure"; import { ITEM_KEY_SPRITE } from "./images"; @@ -7,23 +8,24 @@ const idMap = new Map(); export const getItemsCount = () => idMap.size; -export function createItems() { +export function createItems(display: TextDisplay) { for (let frame = 0; frame < ITEM_KEY_SPRITE.frames.length; frame++) { idMap.set( globalItemId++, - new Figure(0, 0, ITEM_KEY_SPRITE, frame), + new Figure(display, 0, 0, ITEM_KEY_SPRITE, frame), ); } } export class Item extends Drawable { constructor( + display: TextDisplay, public id: number, public count: number = 1, public x: number = -1, public y: number = -1, ) { - super(); + super(display); } override doDraw() { diff --git a/src/games/text-dungeon/map.ts b/src/games/text-dungeon/map.ts index 8d6dc7c..24ed162 100644 --- a/src/games/text-dungeon/map.ts +++ b/src/games/text-dungeon/map.ts @@ -1,15 +1,15 @@ +import type { TextDisplay } from "@common/display/text"; import { Color, MAP_HEIGHT, MAP_ROOM_CHARS, MAP_WIDTH, MAP_X, MAP_Y } from "./const"; -import { drawBox, setChar } from "./display"; import { Drawable } from "./drawable"; import { getRoomsForLayer, Room } from "./room"; export class GameMap extends Drawable { - constructor(public currentRoom: Room) { - super(); + constructor(display: TextDisplay, public currentRoom: Room) { + super(display); } override doDraw() { - drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' }); + 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; @@ -19,7 +19,7 @@ export class GameMap extends Drawable { if (x > MAP_X && x < MAP_X + MAP_WIDTH - 1 && y > MAP_Y && y < MAP_Y + MAP_HEIGHT - 1) { const char = getMapRoomChar(room); - setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]); + this.display.setChar(x, y, [char, room === this.currentRoom ? Color.YELLOW : Color.WHITE]); } } } diff --git a/src/games/text-dungeon/player.ts b/src/games/text-dungeon/player.ts index 62e73cf..bb5e32f 100644 --- a/src/games/text-dungeon/player.ts +++ b/src/games/text-dungeon/player.ts @@ -1,5 +1,4 @@ import { INVENTORY_X, INVENTORY_Y } from "./const"; -import { drawBox } from "./display"; import { getItemsCount, Item } from "./item"; import { Sprite } from "./sprite"; @@ -14,6 +13,7 @@ export class Player extends Sprite { } } this.inventory.push(new Item( + this.display, item.id, item.count, INVENTORY_X + 1 + this.inventory.length, @@ -37,7 +37,7 @@ export class Player extends Sprite { override doDraw() { super.doDraw(); - drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' }); + this.display.drawBox(INVENTORY_X, INVENTORY_Y, getItemsCount(), 1, { fill: [' '], title: 'Inv' }); this.inventory.forEach((item) => { if (item.count > 0) { item.doDraw(); diff --git a/src/games/text-dungeon/room.ts b/src/games/text-dungeon/room.ts index 833b692..f49fb68 100644 --- a/src/games/text-dungeon/room.ts +++ b/src/games/text-dungeon/room.ts @@ -1,15 +1,16 @@ 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 { drawBox } from "./display"; 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"; type IDoors = [Door | null, Door | null, Door | null, Door | null, Door | null, Door | null]; export class Door extends Figure { constructor( + display: TextDisplay, x: number, y: number, direction: Direction, @@ -17,7 +18,7 @@ export class Door extends Figure { public worldY: number, public worldZ: number, ) { - super(x, y, DOOR_SPRITE); + super(display, x, y, DOOR_SPRITE); this.frame = direction; } @@ -35,6 +36,7 @@ export class Door extends Figure { export class Room extends Drawable { constructor( + display: TextDisplay, public readonly x: number, public readonly y: number, public readonly width: number, @@ -45,12 +47,12 @@ export class Room extends Drawable { public readonly doors: IDoors = [null, null, null, null, null, null], public readonly items: Item[] = [], ) { - super(); + super(display); } override doDraw() { - drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: [' '] }); - drawBox(this.x, this.y, this.width, this.height, { fill: ['.', Color.GRAY] }); + 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] }); this.doors.forEach((door) => { if (door) { @@ -84,7 +86,7 @@ const rooms = new Map(); const generateRoomId = (worldX: number, worldY: number, worldZ: number) => `${worldX},${worldY},${worldZ}`; -const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => { +const generateDoors = (display: TextDisplay, { x, y, width, height, worldX, worldY, worldZ }: IRoomBlank): IDoors => { const doors: IDoors = [null, null, null, null, null, null]; if (typeof worldX === 'undefined' || typeof worldY === 'undefined' || typeof worldZ === 'undefined') { console.error('World coordinates not defined for doors generation'); @@ -116,7 +118,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla case Direction.EAST: dx = x + width + 1; dy = doorY; break; case Direction.WEST: dx = x; dy = doorY; break; } - doors[i] = new Door(dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ); + doors[i] = new Door(display, dx, dy, i as Direction, doorWorldX, doorWorldY, worldZ); } } @@ -124,7 +126,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla 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(doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff); + doors[Direction.DOWN] = new Door(display, doorX, doorY, Direction.DOWN, 0, 0, worldZ + zoff); if (worldZ !== 0) { let upDoorX: number; @@ -134,7 +136,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla upDoorY = randInt(y + 1, y + height - 2); } while (doorX === upDoorX && doorY === upDoorY); const [, , upZoff] = DIRECTION_OFFSETS[Direction.UP]; - doors[Direction.UP] = new Door(upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff); + doors[Direction.UP] = new Door(display, upDoorX, upDoorY, Direction.UP, 0, 0, worldZ + upZoff); } } @@ -143,7 +145,7 @@ const generateDoors = ({ x, y, width, height, worldX, worldY, worldZ }: IRoomBla const generatedItems = new Set(); -const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => { +const generateItems = (display: TextDisplay, { x, y, width, height, doors }: IRoomBlank): Item[] => { const items: Item[] = []; if (typeof x === 'undefined' || typeof y === 'undefined' || typeof width === 'undefined' || typeof height === 'undefined') { console.error('Screen coordinates not defined for items generation'); @@ -170,7 +172,7 @@ const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => { newItemX = randInt(x + 1, x + width - 2); newItemY = randInt(y + 1, y + height - 2); } while (hasObjectAt(newItemX, newItemY)); - const item = new Item(id, 1, newItemX, newItemY); + const item = new Item(display, id, 1, newItemX, newItemY); items.push(item); generatedItems.add(id); @@ -179,7 +181,7 @@ const generateItems = ({ x, y, width, height, doors }: IRoomBlank): Item[] => { return items; }; -export function getRoom(worldX: number, worldY: number, worldZ: number) { +export function getRoom(display: TextDisplay, worldX: number, worldY: number, worldZ: number) { const id = generateRoomId(worldX, worldY, worldZ); const existingRoom = rooms.get(id); if (existingRoom) { @@ -193,10 +195,10 @@ export function getRoom(worldX: number, worldY: number, worldZ: number) { const roomBlank: IRoomBlank = { x, y, width, height, worldX, worldY, worldZ }; - roomBlank.doors = generateDoors(roomBlank); - roomBlank.items = generateItems(roomBlank); + roomBlank.doors = generateDoors(display, roomBlank); + roomBlank.items = generateItems(display, roomBlank); - const room = new Room(x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items); + const room = new Room(display, x, y, width, height, worldX, worldY, worldZ, roomBlank.doors, roomBlank.items); rooms.set(id, room); return room; diff --git a/src/games/text-dungeon/sprite.ts b/src/games/text-dungeon/sprite.ts index 8392cd1..56fd2de 100644 --- a/src/games/text-dungeon/sprite.ts +++ b/src/games/text-dungeon/sprite.ts @@ -1,4 +1,4 @@ -import { getRegion, setRegion } from "./display"; +import type { TextDisplay } from "@common/display/text"; import { Figure } from "./figure"; import { PLAYER_SPRITE } from "./images"; import type { IRegion } from "./types"; @@ -11,23 +11,24 @@ export class Sprite extends Figure { public skipNextBackgroundRestore = false; constructor( + display: TextDisplay, x: number, y: number, ) { - super(x, y, PLAYER_SPRITE); + super(display, x, y, PLAYER_SPRITE); this.prevX = x | 0; this.prevY = y | 0; - this.prevImage = getRegion(x, y, this.width, this.height); + this.prevImage = this.display.getRegion(x, y, this.width, this.height); } override doDraw() { if (this.skipNextBackgroundRestore) { this.skipNextBackgroundRestore = false; } else { - setRegion(this.prevX, this.prevY, this.width, this.height, this.prevImage); + this.display.setRegion(this.prevX, this.prevY, this.width, this.height, this.prevImage); } - this.prevImage = getRegion(this.x, this.y, this.width, this.height); + this.prevImage = this.display.getRegion(this.x, this.y, this.width, this.height); super.doDraw(); diff --git a/src/games/text-dungeon/types.d.ts b/src/games/text-dungeon/types.d.ts index cdf3479..0c8697f 100644 --- a/src/games/text-dungeon/types.d.ts +++ b/src/games/text-dungeon/types.d.ts @@ -1,4 +1,4 @@ -import { Color } from "const"; +import { Color } from "./const"; export type IColorLike = string | number | Color; export type IChar = [string, IColorLike?, IColorLike?]; diff --git a/test/common/rpg/inventory.test.ts b/test/common/rpg/inventory.test.ts index 2497f8a..e40403b 100644 --- a/test/common/rpg/inventory.test.ts +++ b/test/common/rpg/inventory.test.ts @@ -225,7 +225,7 @@ describe('Inventory — equip', () => { sword.add('equippable', new Equippable('weapon')); const player = w.createEntity('player'); player.add('str', new Stat({ value: 10 })); - player.add('equipment', new Equipment([{ slotName: 'weapon', type: 'weapon' }])); + player.add('equipment', new Equipment({ slotName: 'weapon', type: 'weapon' })); player.add('inv', new Inventory()); const inv = player.get(Inventory)!; inv.add({ itemId: 'sword', amount: 1 });