Text display viewport
This commit is contained in:
parent
7cc1d5f504
commit
5796f914e3
|
|
@ -1,6 +1,7 @@
|
||||||
import '@common/assets/fonts/vga.font.css';
|
import '@common/assets/fonts/vga.font.css';
|
||||||
import { randInt } from "@common/utils";
|
import { randInt } from "@common/utils";
|
||||||
import { createCanvas } from './canvas';
|
import { createCanvas } from './canvas';
|
||||||
|
import type { Rect } from '@common/geometry';
|
||||||
|
|
||||||
export type IColorLike = string | number | Color;
|
export type IColorLike = string | number | Color;
|
||||||
export type IChar = [string, IColorLike?, IColorLike?] | string;
|
export type IChar = [string, IColorLike?, IColorLike?] | string;
|
||||||
|
|
@ -107,6 +108,10 @@ export class TextDisplay {
|
||||||
private ctx: CanvasRenderingContext2D;
|
private ctx: CanvasRenderingContext2D;
|
||||||
private font = FALLBACK_FONT;
|
private font = FALLBACK_FONT;
|
||||||
private letterboxColor: string;
|
private letterboxColor: string;
|
||||||
|
private clipLeft: number = 0;
|
||||||
|
private clipTop: number = 0;
|
||||||
|
private clipRight: number;
|
||||||
|
private clipBottom: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public width = GAME_WIDTH,
|
public width = GAME_WIDTH,
|
||||||
|
|
@ -138,6 +143,9 @@ export class TextDisplay {
|
||||||
this.font = loaded ? NATIVE_FONT : FALLBACK_FONT;
|
this.font = loaded ? NATIVE_FONT : FALLBACK_FONT;
|
||||||
this.redraw();
|
this.redraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.clipRight = width;
|
||||||
|
this.clipBottom = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateScale() {
|
private updateScale() {
|
||||||
|
|
@ -180,7 +188,7 @@ export class TextDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) {
|
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);
|
const i = (y | 0) * this.width + (x | 0);
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
if (this.chars[i] !== char) {
|
if (this.chars[i] !== char) {
|
||||||
|
|
@ -206,7 +214,7 @@ export class TextDisplay {
|
||||||
|
|
||||||
getChar(x: number, y: number): IDefinedChar {
|
getChar(x: number, y: number): IDefinedChar {
|
||||||
x = x | 0; y = y | 0;
|
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 [' ', DEFAULT_FG, DEFAULT_BG];
|
||||||
}
|
}
|
||||||
return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]];
|
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;
|
y = y | 0;
|
||||||
const { chars, fgs, bgs } = region[REGION_DATA];
|
const { chars, fgs, bgs } = region[REGION_DATA];
|
||||||
const rw = region.width;
|
const rw = region.width;
|
||||||
const x0 = Math.max(0, x);
|
const x0 = Math.max(this.clipLeft, x);
|
||||||
const y0 = Math.max(0, y);
|
const y0 = Math.max(this.clipTop, y);
|
||||||
const x1 = Math.min(this.width, x + rw);
|
const x1 = Math.min(this.clipRight, x + rw);
|
||||||
const y1 = Math.min(this.height, y + region.height);
|
const y1 = Math.min(this.clipBottom, y + region.height);
|
||||||
const copyW = x1 - x0;
|
const copyW = x1 - x0;
|
||||||
if (copyW <= 0) return;
|
if (copyW <= 0) return;
|
||||||
|
|
||||||
|
|
@ -252,7 +260,7 @@ export class TextDisplay {
|
||||||
const bgRow: IColorLike[] = [];
|
const bgRow: IColorLike[] = [];
|
||||||
for (let col = 0; col < w; col++) {
|
for (let col = 0; col < w; col++) {
|
||||||
const dispCol = x + 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 += ' ';
|
rowStr += ' ';
|
||||||
fgRow.push(DEFAULT_FG);
|
fgRow.push(DEFAULT_FG);
|
||||||
bgRow.push(DEFAULT_BG);
|
bgRow.push(DEFAULT_BG);
|
||||||
|
|
@ -277,7 +285,7 @@ export class TextDisplay {
|
||||||
for (let row = 0; row < lines.length; row++) {
|
for (let row = 0; row < lines.length; row++) {
|
||||||
const line = lines[row];
|
const line = lines[row];
|
||||||
const ry = y + 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++) {
|
for (let col = 0; col < line.length; col++) {
|
||||||
this.setCharRaw(x + col, ry, line[col], fg, bg);
|
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;
|
x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
|
||||||
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
|
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
|
||||||
|
|
||||||
y1 = Math.max(0, y1);
|
y1 = Math.max(this.clipTop, y1);
|
||||||
y2 = Math.min(this.height - 1, y2);
|
y2 = Math.min(this.clipBottom - 1, y2);
|
||||||
if (x < 0 || x >= this.width) return;
|
if (x < this.clipLeft || x >= this.clipRight) return;
|
||||||
|
|
||||||
const ch = parseChar(char);
|
const ch = parseChar(char);
|
||||||
for (let y = y1; y <= y2; y++) {
|
for (let y = y1; y <= y2; y++) {
|
||||||
|
|
@ -301,9 +309,9 @@ export class TextDisplay {
|
||||||
drawHLine(x1: number, x2: number, y: number, char: IChar = '─') {
|
drawHLine(x1: number, x2: number, y: number, char: IChar = '─') {
|
||||||
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
|
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
|
||||||
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
|
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
|
||||||
x1 = Math.max(0, x1);
|
x1 = Math.max(this.clipLeft, x1);
|
||||||
x2 = Math.min(this.width - 1, x2);
|
x2 = Math.min(this.clipRight - 1, x2);
|
||||||
if (y < 0 || y >= this.height) return;
|
if (y < this.clipTop || y >= this.clipBottom) return;
|
||||||
|
|
||||||
const ch = parseChar(char);
|
const ch = parseChar(char);
|
||||||
for (let x = x1; x <= x2; x++) {
|
for (let x = x1; x <= x2; x++) {
|
||||||
|
|
@ -370,6 +378,22 @@ export class TextDisplay {
|
||||||
this.drawHLine(x, x + width - 1, i, char);
|
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 {
|
export class TextRegion {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { nextFrame } from "./utils";
|
||||||
|
|
||||||
type Setup<T> = () => Promise<T> | T;
|
type Setup<T> = () => Promise<T> | T;
|
||||||
type Frame<T> = (dt: number, state: T) => Promise<T | void> | T | void;
|
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>(frame: Frame<T>): GameMain;
|
||||||
export function gameLoop<T>(setup: Setup<T>, frame: Frame<T>): GameMain;
|
export function gameLoop<T>(setup: Setup<T>, frame: Frame<T>): GameMain;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { inRect } from "@common/geometry";
|
||||||
import { SeededRandom } from "@common/random";
|
import { SeededRandom } from "@common/random";
|
||||||
|
|
||||||
export namespace BSP {
|
export namespace BSP {
|
||||||
|
|
@ -168,9 +169,21 @@ export namespace BSP {
|
||||||
const dy = Math.sign(by - ay);
|
const dy = Math.sign(by - ay);
|
||||||
|
|
||||||
for (; ax !== bx; ax += dx) {
|
for (; ax !== bx; ax += dx) {
|
||||||
|
if (inRect(ax, ay, nodeA)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inRect(ax, ay, nodeB)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
carver(ax, ay);
|
carver(ax, ay);
|
||||||
}
|
}
|
||||||
for (; ay != by; ay += dy) {
|
for (; ay != by; ay += dy) {
|
||||||
|
if (inRect(ax, ay, nodeA)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inRect(ax, ay, nodeB)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
carver(ax, ay);
|
carver(ax, ay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -364,9 +364,10 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addSystem(system: System): void {
|
addSystem<T extends System>(system: T): T {
|
||||||
this.#systems.push(system);
|
this.#systems.push(system);
|
||||||
system.onAdd(this);
|
system.onAdd(this);
|
||||||
|
return system;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSystem(system: System): void {
|
removeSystem(system: System): void {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { TextRegion, TextDisplay } from "@common/display/text";
|
import { TextRegion, TextDisplay } from "@common/display/text";
|
||||||
import { Position } from "@common/rpg/components/position";
|
import { Position } from "@common/rpg/components/position";
|
||||||
|
import { getViewport } from "@common/rpg/components/render/viewport";
|
||||||
import { Hidden, Sprite } from "@common/rpg/components/sprite";
|
import { Hidden, Sprite } from "@common/rpg/components/sprite";
|
||||||
import { System, World } from "@common/rpg/core/world";
|
import { System, World } from "@common/rpg/core/world";
|
||||||
import { Resources } from "@common/rpg/utils/resources";
|
import { Resources } from "@common/rpg/utils/resources";
|
||||||
|
|
@ -13,6 +14,16 @@ export class TextDisplaySystem extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
override update(world: World) {
|
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);
|
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) {
|
for (const [e, sprite, pos] of sprites) {
|
||||||
if (e.has(Hidden)) continue;
|
if (e.has(Hidden)) continue;
|
||||||
|
|
@ -25,7 +36,9 @@ export class TextDisplaySystem extends System {
|
||||||
?? image;
|
?? image;
|
||||||
|
|
||||||
const region = data instanceof TextRegion ? data : new TextRegion(data);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32,4 +32,12 @@ export namespace Resources {
|
||||||
resources.set(ctor, namespace);
|
resources.set(ctor, namespace);
|
||||||
return id;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
type Point = [number, number];
|
|
||||||
type Rect = [number, number, number, number];
|
|
||||||
|
|
||||||
type RunGame = () => Promise<void>;
|
type RunGame = () => Promise<void>;
|
||||||
|
|
||||||
declare const GAME: string;
|
declare const GAME: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue