1
0
Fork 0

Compare commits

..

3 Commits

Author SHA1 Message Date
Pabloader 5796f914e3 Text display viewport 2026-05-05 09:11:40 +00:00
Pabloader 7cc1d5f504 BSP dungeon generator 2026-05-05 07:19:34 +00:00
Pabloader 925b3aabaa Obstacle is visible in the fov circles 2026-05-04 18:16:33 +00:00
15 changed files with 445 additions and 47 deletions

View File

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

View File

@ -4,7 +4,7 @@ import { nextFrame } from "./utils";
type Setup<T> = () => Promise<T> | T;
type Frame<T> = (dt: number, state: T) => Promise<T | void> | T | void;
type GameMain = () => void;
type GameMain = () => Promise<void>;
export function gameLoop<T>(frame: Frame<T>): GameMain;
export function gameLoop<T>(setup: Setup<T>, frame: Frame<T>): GameMain;

15
src/common/geometry.ts Normal file
View File

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

216
src/common/level/bsp.ts Normal file
View File

@ -0,0 +1,216 @@
import { inRect } from "@common/geometry";
import { SeededRandom } from "@common/random";
export namespace BSP {
export interface BSPOptions {
depth?: number;
minWidth?: number;
minHeight?: number;
seed?: string | number;
random?: SeededRandom;
}
interface Node {
id: string;
x: number;
y: number;
width: number;
height: number;
depth: number;
children: Node[];
}
type Carver = (x: number, y: number, node?: Node) => void;
export function generateLevel(
width: number,
height: number,
carver: Carver,
options?: BSPOptions,
) {
const minWidth = options?.minWidth ?? 4;
const minHeight = options?.minHeight ?? 4;
const depth = options?.depth ?? 4;
const random = options?.random ?? new SeededRandom(options?.seed);
const root: Node = {
id: '/',
x: 0,
y: 0,
width: width,
height: height,
depth: 0,
children: []
};
const carved = new Set<string>();
const dedupCarver = (x: number, y: number, node?: Node) => {
if (carved.has(`${x},${y}`)) return;
carved.add(`${x},${y}`);
carver(x, y, node);
}
const stack = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.depth >= depth) continue;
const splitHorizontally = node.width > node.height;
if (splitHorizontally && node.width > minWidth * 2) {
const splitX = random.randInt(minWidth, node.width - minWidth);
const nodeA: Node = {
id: node.id + '/A',
x: node.x,
y: node.y,
width: splitX,
height: node.height,
depth: node.depth + 1,
children: []
};
const nodeB: Node = {
id: node.id + '/B',
x: node.x + splitX,
y: node.y,
width: node.width - splitX,
height: node.height,
depth: node.depth + 1,
children: []
};
node.children = [nodeA, nodeB];
stack.push(nodeA, nodeB);
} else if (!splitHorizontally && node.height > minHeight * 2) {
const splitY = random.randInt(minHeight, node.height - minHeight);
const nodeA: Node = {
id: node.id + '/A',
x: node.x,
y: node.y,
width: node.width,
height: splitY,
depth: node.depth + 1,
children: []
};
const nodeB: Node = {
id: node.id + '/B',
x: node.x,
y: node.y + splitY,
width: node.width,
height: node.height - splitY,
depth: node.depth + 1,
children: []
};
node.children = [nodeA, nodeB];
stack.push(nodeA, nodeB);
} else {
// Leaf node
node.children = [];
}
}
// Carve rooms
stack.push(root);
while (stack.length > 0) {
const node = stack.pop()!;
if (node.children.length === 0) {
const width = random.randInt(minWidth, node.width) - 2;
const height = random.randInt(minHeight, node.height) - 2;
const x = random.randInt(node.x + 1, node.x + node.width - width - 1);
const y = random.randInt(node.y + 1, node.y + node.height - height - 1);
node.x = x;
node.y = y;
node.width = width;
node.height = height;
carveRoom(x, y, width, height, node, dedupCarver);
} else {
stack.push(...node.children);
}
}
stack.push(root);
// Carve corridors
while (stack.length > 0) {
const node = stack.pop()!;
if (node.children.length === 0) continue;
const [nodeA, nodeB] = node.children;
const leafA = findLeaf(nodeA, nodeB);
const leafB = findLeaf(nodeB, nodeA);
carveCorridor(leafA, leafB, dedupCarver, random);
stack.push(...node.children);
}
}
function carveRoom(x: number, y: number, width: number, height: number, node: Node, carver: Carver) {
for (let i = x; i < x + width; i++) {
for (let j = y; j < y + height; j++) {
carver(i, j, node);
}
}
}
function carveCorridor(nodeA: Node, nodeB: Node, carver: Carver, random: SeededRandom) {
let ax = random.randInt(nodeA.x, nodeA.x + nodeA.width);
let ay = random.randInt(nodeA.y, nodeA.y + nodeA.height);
const bx = random.randInt(nodeB.x, nodeB.x + nodeB.width);
const by = random.randInt(nodeB.y, nodeB.y + nodeB.height);
const dx = Math.sign(bx - ax);
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);
}
}
const center = (node: Node) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2
});
function findLeaf(node: Node, closestNode: Node): Node {
if (node.children.length === 0) {
return node;
}
let minDist = Infinity;
let closest = null;
const closestCenter = center(closestNode);
for (const child of node.children.map(child => findLeaf(child, closestNode))) {
const childCenter = center(child);
const dist = Math.abs(childCenter.x - closestCenter.x) + Math.abs(childCenter.y - closestCenter.y);
if (dist < minDist) {
minDist = dist;
closest = child;
}
}
return closest ?? node;
}
}

View File

@ -263,7 +263,7 @@ export const bresenhamLine = (
* to that point and yields every cell along the ray. You can pass `breaker`
* to signal an obstacle the rest of that ray is then
* skipped and the next outline point's ray begins. The obstacle cell itself
* is not yielded (obstacle is hidden).
* is yielded (obstacle is visible).
*
* **Bounds:** The outline is always generated without clipping so that rays
* extend to the full circle perimeter. The individual rays are clipped to
@ -299,8 +299,8 @@ export function* bresenhamCircleGen(
};
for (const { x: ox, y: oy } of bresenhamCircleGen(x, y, r, { fill: false })) {
for (const linePoint of bresenhamLineGen(x, y, ox, oy, lineBounds)) {
if (options?.breaker?.(linePoint.x, linePoint.y)) break;
yield linePoint;
if (options?.breaker?.(linePoint.x, linePoint.y)) break;
}
}
return;

View File

@ -10,11 +10,9 @@ export function* shadowCast(
const seen = new Set<string>();
const keyOf = (px: number, py: number) => `${px},${py}`;
// Yield the source tile itself if it is not blocked.
if (!breaker(x, y)) {
seen.add(keyOf(x, y));
yield { x, y };
}
if (breaker(x, y)) return;
type Frame = {
octant: number;
@ -68,7 +66,7 @@ export function* shadowCast(
const opaque = breaker(mapX, mapY);
if (dx * dx + dy * dy <= radiusSq && !opaque) {
if (dx * dx + dy * dy <= radiusSq) {
const key = keyOf(mapX, mapY);
if (!seen.has(key)) {
seen.add(key);

View File

@ -125,7 +125,7 @@ export class SeededRandom {
throw new RangeError(`randInt: bounds must be integers, got (${min}, ${hi})`);
}
if (min >= hi) {
throw new RangeError(`randInt: min (${min}) must be strictly less than max (${hi})`);
return hi;
}
const range = hi - min;

View File

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

View File

@ -364,9 +364,10 @@ export class World {
}
}
addSystem(system: System): void {
addSystem<T extends System>(system: T): T {
this.#systems.push(system);
system.onAdd(this);
return system;
}
removeSystem(system: System): void {

View File

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

View File

@ -32,4 +32,12 @@ export namespace Resources {
resources.set(ctor, namespace);
return id;
}
export function update<T>(id: string, value: NonNullable<T>): string {
const ctor = value.constructor;
const namespace: Map<string, T> = resources.get(ctor) ?? new Map();
namespace.set(id, value);
resources.set(ctor, namespace);
return id;
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { createCanvas } from "@common/display/canvas";
import { gameLoop } from "@common/game";
import { circleGen } from "@common/navigation/bresenham";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import Physics from "@common/physics";
import { mapNumber } from "@common/utils";
@ -162,7 +162,7 @@ const frame = (dt: number, state: State) => {
function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) {
// because default circle fill is antialiased
for (const { x, y } of circleGen(xc, yc, r, { fill: true })) {
for (const { x, y } of bresenhamCircleGen(xc, yc, r, { fill: true })) {
ctx.fillRect(x, y, 1, 1);
}
}

View File

@ -1,9 +1,8 @@
import { createCanvas } from "@common/display/canvas";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import { BSP } from "@common/level/bsp";
import { stringHash } from "@common/utils";
const S = 20;
const breaker = (x: number, y: number) => Math.hypot(x - S * 0.3, y - S * 0.3) <= S / 16;
const S = 512;
export default async function main() {
const canvas = createCanvas(S, S);
@ -11,16 +10,10 @@ export default async function main() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = 'black';
for (const fill of ['fov', 'shadow'] as const) {
let i = 0;
console.time(`fill=${fill}`);
for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) {
i++;
BSP.generateLevel(S, S, (x, y, node) => {
ctx.fillStyle = node?.id ? `hsl(${stringHash(node.id) % 360}, 100%, 20%)`: 'black';
ctx.fillRect(x, y, 1, 1);
}
console.timeEnd(`fill=${fill}`);
console.log(`i=${i}`)
}
}, { depth: 16, minWidth: 8, minHeight: 8 });
console.log('done');
}

3
src/types.d.ts vendored
View File

@ -1,6 +1,3 @@
type Point = [number, number];
type Rect = [number, number, number, number];
type RunGame = () => Promise<void>;
declare const GAME: string;