1
0
Fork 0

Text display viewport

This commit is contained in:
Pabloader 2026-05-05 09:11:40 +00:00
parent 7cc1d5f504
commit 5796f914e3
10 changed files with 225 additions and 21 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;
}

View File

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

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

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;