diff --git a/src/common/display/text.ts b/src/common/display/text.ts index 398a30f..a999e8d 100644 --- a/src/common/display/text.ts +++ b/src/common/display/text.ts @@ -1,6 +1,7 @@ import '@common/assets/fonts/vga.font.css'; import { randInt } from "@common/utils"; import { createCanvas } from './canvas'; +import type { Rect } from '@common/geometry'; export type IColorLike = string | number | Color; export type IChar = [string, IColorLike?, IColorLike?] | string; @@ -107,6 +108,10 @@ export class TextDisplay { private ctx: CanvasRenderingContext2D; private font = FALLBACK_FONT; private letterboxColor: string; + private clipLeft: number = 0; + private clipTop: number = 0; + private clipRight: number; + private clipBottom: number; constructor( public width = GAME_WIDTH, @@ -138,6 +143,9 @@ export class TextDisplay { this.font = loaded ? NATIVE_FONT : FALLBACK_FONT; this.redraw(); }); + + this.clipRight = width; + this.clipBottom = height; } private updateScale() { @@ -180,7 +188,7 @@ export class TextDisplay { } 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; + if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom || !char) return; const i = (y | 0) * this.width + (x | 0); let dirty = false; if (this.chars[i] !== char) { @@ -206,7 +214,7 @@ export class TextDisplay { getChar(x: number, y: number): IDefinedChar { x = x | 0; y = y | 0; - if (x < 0 || y < 0 || y >= this.height || x >= this.width) { + if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) { return [' ', DEFAULT_FG, DEFAULT_BG]; } return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]]; @@ -217,10 +225,10 @@ export class TextDisplay { y = y | 0; const { chars, fgs, bgs } = region[REGION_DATA]; const rw = region.width; - const x0 = Math.max(0, x); - const y0 = Math.max(0, y); - const x1 = Math.min(this.width, x + rw); - const y1 = Math.min(this.height, y + region.height); + const x0 = Math.max(this.clipLeft, x); + const y0 = Math.max(this.clipTop, y); + const x1 = Math.min(this.clipRight, x + rw); + const y1 = Math.min(this.clipBottom, y + region.height); const copyW = x1 - x0; if (copyW <= 0) return; @@ -252,7 +260,7 @@ export class TextDisplay { const bgRow: IColorLike[] = []; for (let col = 0; col < w; col++) { const dispCol = x + col; - if (dispRow < 0 || dispRow >= this.height || dispCol < 0 || dispCol >= this.width) { + if (dispCol < this.clipLeft || dispRow < this.clipTop || dispCol >= this.clipRight || dispRow >= this.clipBottom) { rowStr += ' '; fgRow.push(DEFAULT_FG); bgRow.push(DEFAULT_BG); @@ -277,7 +285,7 @@ export class TextDisplay { for (let row = 0; row < lines.length; row++) { const line = lines[row]; const ry = y + row; - if (ry < 0 || ry >= this.height) continue; + if (ry < this.clipTop || ry >= this.clipBottom) continue; for (let col = 0; col < line.length; col++) { this.setCharRaw(x + col, ry, line[col], fg, bg); } @@ -288,9 +296,9 @@ export class TextDisplay { x = x | 0; y1 = y1 | 0; y2 = y2 | 0; 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; + y1 = Math.max(this.clipTop, y1); + y2 = Math.min(this.clipBottom - 1, y2); + if (x < this.clipLeft || x >= this.clipRight) return; const ch = parseChar(char); for (let y = y1; y <= y2; y++) { @@ -301,9 +309,9 @@ export class TextDisplay { drawHLine(x1: number, x2: number, y: number, char: IChar = '─') { x1 = x1 | 0; x2 = x2 | 0; y = y | 0; 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; + x1 = Math.max(this.clipLeft, x1); + x2 = Math.min(this.clipRight - 1, x2); + if (y < this.clipTop || y >= this.clipBottom) return; const ch = parseChar(char); for (let x = x1; x <= x2; x++) { @@ -370,6 +378,22 @@ export class TextDisplay { this.drawHLine(x, x + width - 1, i, char); } } + + getClipRect(): Rect { + return { + x: this.clipLeft, + y: this.clipTop, + width: this.clipRight - this.clipLeft, + height: this.clipBottom - this.clipTop, + }; + } + + setClipRect(rect: Rect) { + this.clipLeft = Math.max(0, rect.x); + this.clipTop = Math.max(0, rect.y); + this.clipRight = Math.min(this.width, rect.x + rect.width); + this.clipBottom = Math.min(this.height, rect.y + rect.height); + } } export class TextRegion { diff --git a/src/common/game.ts b/src/common/game.ts index db8faf8..75af856 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -4,7 +4,7 @@ import { nextFrame } from "./utils"; type Setup = () => Promise | T; type Frame = (dt: number, state: T) => Promise | T | void; -type GameMain = () => void; +type GameMain = () => Promise; export function gameLoop(frame: Frame): GameMain; export function gameLoop(setup: Setup, frame: Frame): GameMain; diff --git a/src/common/geometry.ts b/src/common/geometry.ts new file mode 100644 index 0000000..8064a99 --- /dev/null +++ b/src/common/geometry.ts @@ -0,0 +1,15 @@ +export interface Point { + x: number; + y: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export const inRect = (x: number, y: number, rect: Rect) => { + return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height; +} diff --git a/src/common/level/bsp.ts b/src/common/level/bsp.ts index fac76de..5178272 100644 --- a/src/common/level/bsp.ts +++ b/src/common/level/bsp.ts @@ -1,3 +1,4 @@ +import { inRect } from "@common/geometry"; import { SeededRandom } from "@common/random"; export namespace BSP { @@ -168,9 +169,21 @@ export namespace BSP { const dy = Math.sign(by - ay); for (; ax !== bx; ax += dx) { + if (inRect(ax, ay, nodeA)) { + continue; + } + if (inRect(ax, ay, nodeB)) { + break; + } carver(ax, ay); } for (; ay != by; ay += dy) { + if (inRect(ax, ay, nodeA)) { + continue; + } + if (inRect(ax, ay, nodeB)) { + break; + } carver(ax, ay); } } diff --git a/src/common/rpg/components/render/viewport.ts b/src/common/rpg/components/render/viewport.ts new file mode 100644 index 0000000..76e5f3e --- /dev/null +++ b/src/common/rpg/components/render/viewport.ts @@ -0,0 +1,73 @@ +import { Component, World } from "@common/rpg/core/world"; +import { component } from "@common/rpg/utils/decorators"; +import { getPosition, Position } from "../position"; + +export interface ViewportData { + screenX: number; + screenY: number; + width: number; + height: number; + worldX: number; + worldY: number; +} + +@component +export class Viewport extends Component { + + get screenX(): number { + return Math.round(getPosition(this.entity, 'screen')?.x ?? 0); + } + + get screenY(): number { + return Math.round(getPosition(this.entity, 'screen')?.y ?? 0); + } + + get width(): number { + return Math.round(getPosition(this.entity, 'size')?.x ?? Infinity); + } + + get height(): number { + return Math.round(getPosition(this.entity, 'size')?.y ?? Infinity); + } + + get worldX(): number { + return Math.round(getPosition(this.entity)?.x ?? 0); + } + + get worldY(): number { + return Math.round(getPosition(this.entity)?.y ?? 0); + } + + move(dx: number, dy: number) { + const pos = getPosition(this.entity); + if (!pos) return; + + pos.x += dx; + pos.y += dy; + } +} + +export const createViewport = (world: World, viewportData: ViewportData) => { + const viewport = world.createEntity(); + viewport.add(new Viewport()); + viewport.add(new Position(viewportData.worldX, viewportData.worldY)); + viewport.add(new Position(viewportData.width, viewportData.height), 'size'); + viewport.add(new Position(viewportData.screenX, viewportData.screenY), 'screen'); + + return viewport; +} + +export const getViewport = (world: World): ViewportData | null => { + for (const [, , viewport] of world.query(Viewport)) { + return { + screenX: viewport.screenX, + screenY: viewport.screenY, + width: viewport.width, + height: viewport.height, + worldX: viewport.worldX, + worldY: viewport.worldY, + } + } + + return null; +} diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 12a8a14..d9bebf2 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -364,9 +364,10 @@ export class World { } } - addSystem(system: System): void { + addSystem(system: T): T { this.#systems.push(system); system.onAdd(this); + return system; } removeSystem(system: System): void { diff --git a/src/common/rpg/systems/render/text.ts b/src/common/rpg/systems/render/text.ts index 3f8911f..296fa93 100644 --- a/src/common/rpg/systems/render/text.ts +++ b/src/common/rpg/systems/render/text.ts @@ -1,5 +1,6 @@ import { TextRegion, TextDisplay } from "@common/display/text"; import { Position } from "@common/rpg/components/position"; +import { getViewport } from "@common/rpg/components/render/viewport"; import { Hidden, Sprite } from "@common/rpg/components/sprite"; import { System, World } from "@common/rpg/core/world"; import { Resources } from "@common/rpg/utils/resources"; @@ -13,6 +14,16 @@ export class TextDisplaySystem extends System { } override update(world: World) { + const viewport = getViewport(world); + const offset = viewport ? { + x: viewport.worldX - viewport.screenX, + y: viewport.worldY - viewport.screenY, + } : { x: 0, y: 0 }; + const clipRect = this.display.getClipRect(); + if (viewport) { + this.display.setClipRect({ x: viewport.screenX, y: viewport.screenY, width: viewport.width, height: viewport.height }); + } + 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; @@ -25,7 +36,9 @@ export class TextDisplaySystem extends System { ?? image; const region = data instanceof TextRegion ? data : new TextRegion(data); - this.display.setRegion(x, y, region); + this.display.setRegion(x - offset.x, y - offset.y, region); } + + this.display.setClipRect(clipRect); } } \ No newline at end of file diff --git a/src/common/rpg/utils/resources.ts b/src/common/rpg/utils/resources.ts index c5d3de8..ecec3a6 100644 --- a/src/common/rpg/utils/resources.ts +++ b/src/common/rpg/utils/resources.ts @@ -32,4 +32,12 @@ export namespace Resources { resources.set(ctor, namespace); return id; } -} \ No newline at end of file + + export function update(id: string, value: NonNullable): string { + const ctor = value.constructor; + const namespace: Map = resources.get(ctor) ?? new Map(); + namespace.set(id, value); + resources.set(ctor, namespace); + return id; + } +} diff --git a/src/games/crawler/index.ts b/src/games/crawler/index.ts new file mode 100644 index 0000000..73e97c0 --- /dev/null +++ b/src/games/crawler/index.ts @@ -0,0 +1,60 @@ +import { TextRegion } from "@common/display/text"; +import { gameLoop } from "@common/game"; +import Input from "@common/input"; +import { BSP } from "@common/level/bsp"; +import { SeededRandom } from "@common/random"; +import { Position } from "@common/rpg/components/position"; +import { createViewport, Viewport } from "@common/rpg/components/render/viewport"; +import { Sprite } from "@common/rpg/components/sprite"; +import { World } from "@common/rpg/core/world"; +import { TextDisplaySystem } from "@common/rpg/systems/render/text"; +import { Resources } from "@common/rpg/utils/resources"; + + +function createMap(world: World, random: SeededRandom) { + const mapSize = 100; + const mapData = new Array(mapSize * mapSize).fill('#'); + + BSP.generateLevel(mapSize, mapSize, (x, y) => { mapData[x + y * mapSize] = '.'; }, { + minWidth: 8, + minHeight: 8, + depth: 8, + random, + }); + + const map = world.createEntity('map'); + const mapDataString: string[] = []; + for (let i = 0; i < mapSize; i++) { + mapDataString.push(mapData.slice(i * mapSize, (i + 1) * mapSize).join('')); + } + Resources.add('map', new TextRegion(mapDataString)); + map.add(new Sprite('map')); + map.add(new Position(0, 0)); + + return map; +} + +export default gameLoop(() => { + const world = new World(); + const display = world.addSystem(new TextDisplaySystem()).display; + + const random = new SeededRandom('awoorwa'); + const map = createMap(world, random); + const viewportEntity = createViewport(world, { + width: display.width >> 1, + height: display.height, + worldX: 0, + worldY: 0, + screenX: display.width >> 1, + screenY: 0, + }); + const viewport = viewportEntity.get(Viewport)!; + return { world, map, random, viewport }; +}, (dt, { world, viewport }) => { + const dx = Input.getHorizontal(); + const dy = Input.getVertical(); + + viewport.move(dx * dt * 32, dy * dt * 16); + + world.update(dt); +}); diff --git a/src/types.d.ts b/src/types.d.ts index a3c809e..c4cbb2d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,6 +1,3 @@ -type Point = [number, number]; -type Rect = [number, number, number, number]; - type RunGame = () => Promise; declare const GAME: string;