1
0
Fork 0

Compare commits

..

No commits in common. "2a82c13fb4980a1b7db95b08ed02283df94dc469" and "2ace096907713ea76c5c3179a776173a8aa27429" have entirely different histories.

8 changed files with 84 additions and 258 deletions

View File

@ -1,5 +1,3 @@
import Input from "@common/input";
export function loadImageData(dataView: DataView, pointer: number) { export function loadImageData(dataView: DataView, pointer: number) {
const width = dataView.getUint16(pointer + 0, true); const width = dataView.getUint16(pointer + 0, true);
const height = dataView.getUint16(pointer + 2, true); const height = dataView.getUint16(pointer + 2, true);
@ -27,8 +25,6 @@ export function createCanvas(width: number, height: number) {
document.body.style.alignItems = 'center'; document.body.style.alignItems = 'center';
document.body.append(canvas); document.body.append(canvas);
Input.setMainCanvas(canvas);
return canvas; return canvas;
} }

View File

@ -1,9 +1,8 @@
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 { Point, Rect } from '@common/geometry'; import type { Rect } from '@common/geometry';
import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham'; import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham';
import Input from '@common/input';
export type ColorLike = string | number | Color; export type ColorLike = string | number | Color;
export type Char = [string, ColorLike?, ColorLike?] | string; export type Char = [string, ColorLike?, ColorLike?] | string;
@ -451,14 +450,6 @@ export class TextDisplay {
update() { update() {
this.redrawDirty(); this.redrawDirty();
} }
getMousePosition(): Point {
const pos = Input.getMousePosition();
return {
x: Math.floor(pos.x / CHAR_W),
y: Math.floor(pos.y / CHAR_H),
};
}
} }
function expandColors(chars: string[] | Char[][], colors: ColorLike | ColorLike[] | ColorLike[][]): ColorLike[][] { function expandColors(chars: string[] | Char[][], colors: ColorLike | ColorLike[] | ColorLike[][]): ColorLike[][] {
@ -493,8 +484,8 @@ export class TextRegion {
constructor( constructor(
chars: Char[][] | string | string[], chars: Char[][] | string | string[],
fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG, fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG,
bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG, bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG,
) { ) {
if (typeof chars === 'string') { if (typeof chars === 'string') {
chars = chars.split('\n'); chars = chars.split('\n');

View File

@ -5,11 +5,15 @@ export interface EventWithPoint {
export const getRealPoint = (canvas: HTMLCanvasElement, e: EventWithPoint): DOMPoint => { export const getRealPoint = (canvas: HTMLCanvasElement, e: EventWithPoint): DOMPoint => {
const matrix = new DOMMatrix(); const matrix = new DOMMatrix();
const scale = Math.min(canvas.clientWidth / canvas.width, canvas.clientHeight / canvas.height);
const realWidth = canvas.width * scale;
const realHeight = canvas.height * scale;
const box = canvas.getBoundingClientRect(); const offsetLeft = (canvas.clientWidth - realWidth) / 2;
const scale = Math.min(box.width / canvas.width, box.height / canvas.height); const offsetTop = (canvas.clientHeight - realHeight) / 2;
matrix.translateSelf(box.left, box.top); matrix.translateSelf(offsetLeft, offsetTop);
matrix.scaleSelf(scale); matrix.scaleSelf(scale);
matrix.invertSelf(); matrix.invertSelf();

View File

@ -4,7 +4,6 @@ import { nextFrame } from "./utils";
interface FrameMeta { interface FrameMeta {
fps: number; fps: number;
now: number;
} }
type Awaitable<T> = PromiseLike<T> | T; type Awaitable<T> = PromiseLike<T> | T;
@ -33,7 +32,7 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
let prevFrame = performance.now(); let prevFrame = performance.now();
let fpsCounter = 0; let fpsCounter = 0;
let fpsTimer = 0; let fpsTimer = 0;
const meta: FrameMeta = { fps: 0, now: 0 }; const meta: FrameMeta = { fps: 0 };
while (true) { while (true) {
await nextFrame(); await nextFrame();
Input.updateKeys(); Input.updateKeys();
@ -42,7 +41,6 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
const dt = (now - prevFrame) / 1000; const dt = (now - prevFrame) / 1000;
if (dt < 1) { // skip long pause to avoid blowing up values if (dt < 1) { // skip long pause to avoid blowing up values
meta.now += dt;
const newState = await frame(dt, state, meta); const newState = await frame(dt, state, meta);
if (newState) { if (newState) {
state = newState; state = newState;

View File

@ -1,18 +1,10 @@
import { getRealPoint } from "./dom";
import type { Point } from "./geometry";
interface IKeyState { interface IKeyState {
state: boolean; state: boolean;
prevState?: boolean; prevState?: boolean;
released?: boolean; released?: boolean;
pressed?: boolean; pressed?: boolean;
repeated?: boolean;
held?: boolean; held?: boolean;
changedAt: number;
eventAt: number;
repeatAt?: number;
} }
namespace Input { namespace Input {
export enum KeyCode { export enum KeyCode {
@ -24,46 +16,6 @@ namespace Input {
SHIFT = 'Shift', SHIFT = 'Shift',
SHIFT_LEFT = 'ShiftLeft', SHIFT_LEFT = 'ShiftLeft',
SHIFT_RIGHT = 'ShiftRight', SHIFT_RIGHT = 'ShiftRight',
A = 'KeyA',
B = 'KeyB',
C = 'KeyC',
D = 'KeyD',
E = 'KeyE',
F = 'KeyF',
G = 'KeyG',
H = 'KeyH',
I = 'KeyI',
J = 'KeyJ',
K = 'KeyK',
L = 'KeyL',
M = 'KeyM',
N = 'KeyN',
O = 'KeyO',
P = 'KeyP',
Q = 'KeyQ',
R = 'KeyR',
S = 'KeyS',
T = 'KeyT',
U = 'KeyU',
V = 'KeyV',
W = 'KeyW',
X = 'KeyX',
Y = 'KeyY',
Z = 'KeyZ',
NUM_0 = 'Digit0',
NUM_1 = 'Digit1',
NUM_2 = 'Digit2',
NUM_3 = 'Digit3',
NUM_4 = 'Digit4',
NUM_5 = 'Digit5',
NUM_6 = 'Digit6',
NUM_7 = 'Digit7',
NUM_8 = 'Digit8',
NUM_9 = 'Digit9',
MOUSE_LEFT = `Mouse0`,
MOUSE_MIDDLE = `Mouse1`,
MOUSE_RIGHT = `Mouse2`,
}; };
export enum GamepadAxis { export enum GamepadAxis {
@ -94,24 +46,14 @@ namespace Input {
} }
const DEAD_ZONE = 0.05; const DEAD_ZONE = 0.05;
const KEYS: Partial<Record<string, IKeyState>> = {}; const KEYS: Partial<Record<KeyCode, IKeyState>> = {};
const mousePosition: Point = { x: 0, y: 0 };
let mainCanvas: HTMLCanvasElement | null = null;
let repeatIntervalMs = 10;
let repeatDelayMs = 300;
const onStateChange = (keyId: string, state: boolean) => { const onStateChange = (keyId: KeyCode, state: boolean) => {
console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`); console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`);
const now = Date.now();
if (KEYS[keyId]) { if (KEYS[keyId]) {
if (state !== KEYS[keyId].state) {
KEYS[keyId].changedAt = now;
}
KEYS[keyId].state = state; KEYS[keyId].state = state;
KEYS[keyId].eventAt = now;
} else { } else {
KEYS[keyId] = { state, changedAt: now, eventAt: now }; KEYS[keyId] = { state };
} }
if (keyId === KeyCode.SHIFT_LEFT || keyId === KeyCode.SHIFT_RIGHT) { if (keyId === KeyCode.SHIFT_LEFT || keyId === KeyCode.SHIFT_RIGHT) {
@ -120,39 +62,12 @@ namespace Input {
} }
}; };
const onMouseChange = (e: MouseEvent) => {
if (mainCanvas) {
const point = getRealPoint(mainCanvas, e);
mousePosition.x = point.x;
mousePosition.y = point.y;
} else {
mousePosition.x = e.clientX;
mousePosition.y = e.clientY;
}
}
document.body.addEventListener('keydown', (e) => onStateChange(e.code as KeyCode, true)); document.body.addEventListener('keydown', (e) => onStateChange(e.code as KeyCode, true));
document.body.addEventListener('keyup', (e) => onStateChange(e.code as KeyCode, false)); document.body.addEventListener('keyup', (e) => onStateChange(e.code as KeyCode, false));
document.body.addEventListener('mousedown', (e) => {
onStateChange(`Mouse${e.button}`, true);
onMouseChange(e);
});
document.body.addEventListener('mouseup', (e) => {
onStateChange(`Mouse${e.button}`, false);
onMouseChange(e);
});
document.body.addEventListener('mousemove', (e) => onMouseChange(e));
document.body.addEventListener('contextmenu', (e) => e.preventDefault());
export const isPressed = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.pressed); export const isPressed = (key: KeyCode): boolean => KEYS[key]?.pressed ?? false;
export const isReleased = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.released); export const isReleased = (key: KeyCode): boolean => KEYS[key]?.released ?? false;
export const isRepeated = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.repeated); export const isHeld = (key: KeyCode): boolean => KEYS[key]?.held ?? false;
export const isHeld = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.held);
export const hasPressed = () => Object.values(KEYS).some(k => k?.pressed);
export const hasReleased = () => Object.values(KEYS).some(k => k?.released);
export const hasRepeated = () => Object.values(KEYS).some(k => k?.repeated);
export const hasHeld = () => Object.values(KEYS).some(k => k?.held);
export const getGamepad = () => navigator.getGamepads().find(g => g != null); export const getGamepad = () => navigator.getGamepads().find(g => g != null);
@ -178,24 +93,8 @@ namespace Input {
+ getGamepadAxis(GamepadAxis.LY) + getGamepadAxis(GamepadAxis.LY)
); );
export const getMousePosition = (): Point => ({ ...mousePosition });
export const setRepeatInterval = (intervalMs: number) => {
repeatIntervalMs = intervalMs;
}
export const setRepeatDelay = (delayMs: number) => {
repeatDelayMs = delayMs;
}
export const setMainCanvas = (canvas: HTMLCanvasElement | null) => {
mainCanvas = canvas;
}
export function updateKeys() { export function updateKeys() {
for (const key of Object.values(KEYS)) { for (const key of Object.values(KEYS)) {
if (!key) continue;
key.released = false; key.released = false;
key.pressed = false; key.pressed = false;
@ -203,15 +102,10 @@ namespace Input {
key.pressed = !key.held; key.pressed = !key.held;
key.held = true; key.held = true;
} else { } else {
key.released = key.held; key.released = true;
key.held = false; key.held = false;
} }
const now = Date.now();
key.repeated = key.held && (now - (key.repeatAt ?? 0) > repeatIntervalMs) && (now - key.changedAt > repeatDelayMs);
if (key.repeated) {
key.repeatAt = now;
}
key.prevState = key.state; key.prevState = key.state;
} }
} }

View File

@ -1,7 +1,6 @@
import { Component, World } from "@common/rpg/core/world"; import { Component, World } from "@common/rpg/core/world";
import { component } from "@common/rpg/utils/decorators"; import { component } from "@common/rpg/utils/decorators";
import { getPosition, Position } from "../position"; import { getPosition, Position } from "../position";
import type { Point } from "@common/geometry";
export interface ViewportData { export interface ViewportData {
screenX: number; screenX: number;
@ -14,6 +13,7 @@ export interface ViewportData {
@component @component
export class Viewport extends Component<void> { export class Viewport extends Component<void> {
get screenX(): number { get screenX(): number {
return Math.round(getPosition(this.entity, 'screen')?.x ?? 0); return Math.round(getPosition(this.entity, 'screen')?.x ?? 0);
} }
@ -37,20 +37,6 @@ export class Viewport extends Component<void> {
get worldY(): number { get worldY(): number {
return Math.round(getPosition(this.entity)?.y ?? 0); return Math.round(getPosition(this.entity)?.y ?? 0);
} }
screenToWorld = ({ x, y }: Point): Point => {
return {
x: x - this.screenX + this.worldX,
y: y - this.screenY + this.worldY,
};
}
worldToScreen = ({ x, y }: Point): Point => {
return {
x: x - this.worldX + this.screenX,
y: y - this.worldY + this.screenY,
};
}
} }
export const createViewport = (world: World, viewportData: ViewportData) => { export const createViewport = (world: World, viewportData: ViewportData) => {
@ -63,9 +49,16 @@ export const createViewport = (world: World, viewportData: ViewportData) => {
return viewport; return viewport;
} }
export const getViewport = (world: World): Viewport | null => { export const getViewport = (world: World): ViewportData | null => {
for (const [, , viewport] of world.query(Viewport)) { for (const [, , viewport] of world.query(Viewport)) {
return viewport; return {
screenX: viewport.screenX,
screenY: viewport.screenY,
width: viewport.width,
height: viewport.height,
worldX: viewport.worldX,
worldY: viewport.worldY,
}
} }
return null; return null;

View File

@ -17,20 +17,16 @@ export class TextDisplaySystem extends System {
override update(world: World) { override update(world: World) {
const viewport = getViewport(world); const viewport = getViewport(world);
const offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined; const offset = viewport ? {
const viewportClipRect = viewport ? { x: viewport.worldX - viewport.screenX,
x: viewport.screenX, y: viewport.worldY - viewport.screenY,
y: viewport.screenY, } : { x: 0, y: 0 };
width: viewport.width,
height: viewport.height,
} : undefined;
const sprites = Array.from(world.query(Sprite, Position)) const sprites = Array.from(world.query(Sprite, Position))
.sort((a, b) => Number(a[2].absolute) - Number(b[2].absolute) || a[2].z - b[2].z); .sort((a, b) => Number(a[2].absolute) - Number(b[2].absolute) || a[2].z - b[2].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;
const image = sprite.image; const image = sprite.image;
const { x, y, absolute } = pos; const { x, y, absolute } = pos;
@ -41,11 +37,13 @@ export class TextDisplaySystem extends System {
const region = data instanceof TextRegion ? data : new TextRegion(data); const region = data instanceof TextRegion ? data : new TextRegion(data);
if (absolute || !offset || !viewportClipRect) { if (absolute) {
this.display.setRegion(x, y, region); this.display.setRegion(x, y, region);
} else { } else {
const clipRect = this.display.getClipRect(); const clipRect = this.display.getClipRect();
this.display.setClipRect(viewportClipRect); if (viewport) {
this.display.setClipRect({ x: viewport.screenX, y: viewport.screenY, width: viewport.width, height: viewport.height });
}
this.display.setRegion(x - offset.x, y - offset.y, region); this.display.setRegion(x - offset.x, y - offset.y, region);
this.display.setClipRect(clipRect); this.display.setClipRect(clipRect);
} }

View File

@ -52,50 +52,7 @@ function createPlayer(world: World, x = 0, y = 0) {
return player; return player;
} }
function handleMovement() {
let dx = 0;
let dy = 0;
const isReleasedOrRepeated = (...keys: Input.KeyCode[]) =>
keys.some(key => Input.isReleased(key) || Input.isRepeated(key));
const left = isReleasedOrRepeated(Input.KeyCode.LEFT, Input.KeyCode.H);
const right = isReleasedOrRepeated(Input.KeyCode.RIGHT, Input.KeyCode.L);
const up = isReleasedOrRepeated(Input.KeyCode.UP, Input.KeyCode.K);
const down = isReleasedOrRepeated(Input.KeyCode.DOWN, Input.KeyCode.J);
const upLeft = isReleasedOrRepeated(Input.KeyCode.Y);
const upRight = isReleasedOrRepeated(Input.KeyCode.U);
const downLeft = isReleasedOrRepeated(Input.KeyCode.B);
const downRight = isReleasedOrRepeated(Input.KeyCode.N);
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
if (upLeft) {
dx -= 1;
dy -= 1;
}
if (upRight) {
dx += 1;
dy -= 1;
}
if (downLeft) {
dx -= 1;
dy += 1;
}
if (downRight) {
dx += 1;
dy += 1;
}
return { dx, dy };
}
export default gameLoop(() => { export default gameLoop(() => {
Input.setRepeatInterval(50);
const world = new World(); const world = new World();
const display = world.addSystem(new TextDisplaySystem(100, 25)).display; const display = world.addSystem(new TextDisplaySystem(100, 25)).display;
@ -125,7 +82,7 @@ export default gameLoop(() => {
}); });
const player = createPlayer(world, startCell.x, startCell.y); const player = createPlayer(world, startCell.x, startCell.y);
const state = { return {
display, display,
world, world,
map, mapData, map, mapData,
@ -133,74 +90,69 @@ export default gameLoop(() => {
random, random,
viewport, viewport,
player, player,
lastMove: 0, now: 0,
isWall: (x: number, y: number): boolean => { isWall: (x: number, y: number): boolean => {
const [ch] = state.mapData.get(x, y); const [ch] = mapData.get(x, y);
return ch === WALL; return ch === WALL;
}, },
updateMask: () => {
if (!state.maskDirty) return;
const { mapData, maskData, player, isWall } = state;
const playerPos = getPosition(player)!;
for (let x = 0; x < mapData.width; x++) {
for (let y = 0; y < mapData.height; y++) {
const [ch, fg] = maskData.get(x + mapData.width, y + mapData.height);
if (ch !== ' ' && fg !== Color.GRAY) {
maskData.set(x + mapData.width, y + mapData.height, [mapData.get(x, y)[0], Color.GRAY]);
}
}
}
for (const { x, y } of bresenhamCircleGen(playerPos.x, playerPos.y, 10, { fill: 'shadow', breaker: isWall })) {
maskData.set(x + mapData.width, y + mapData.height, '\0');
}
state.maskDirty = false;
}
}; };
return state;
}, (dt, state) => { }, (dt, state) => {
const { const {
world, world,
viewport, viewport,
player, player,
updateMask, lastMove, now,
mapData, maskData,
isWall, isWall,
} = state; } = state;
const hasInput = Input.hasReleased() || Input.hasRepeated(); let dx = -Math.sign(Input.getHorizontal());
let dy = -Math.sign(Input.getVertical());
const playerPos = getPosition(player)!; const playerPos = getPosition(player)!;
if (hasInput) { if (now - lastMove > 0.05) {
if (Input.isHeld(Input.KeyCode.SHIFT)) { if (isWall(playerPos.x + dx, playerPos.y)) {
} else { dx = 0;
let { dx, dy } = handleMovement();
if (isWall(playerPos.x + dx, playerPos.y)) {
dx = 0;
}
if (isWall(playerPos.x, playerPos.y + dy)) {
dy = 0;
}
if (isWall(playerPos.x + dx, playerPos.y + dy)) {
dx = 0
dy = 0;
}
if (dx || dy) {
move(player, dx, dy);
move(viewport, dx, dy);
playerPos.x + dx;
playerPos.y + dy;
}
} }
if (isWall(playerPos.x, playerPos.y + dy)) {
// TODO environment step dy = 0;
}
state.maskDirty = true; if (isWall(playerPos.x + dx, playerPos.y + dy)) {
dx = 0
dy = 0;
}
} else {
dx = 0;
dy = 0;
}
if (dx || dy) {
move(player, dx, dy);
move(viewport, dx, dy);
playerPos.x + dx;
playerPos.y + dy;
state.maskDirty = true;
state.lastMove = now;
}
if (state.maskDirty) {
for (let x = 0; x < mapData.width; x++) {
for (let y = 0; y < mapData.height; y++) {
const [ch, fg] = maskData.get(x + mapData.width, y + mapData.height);
if (ch !== ' ' && fg !== Color.GRAY) {
maskData.set(x + mapData.width, y + mapData.height, [mapData.get(x, y)[0], Color.GRAY]);
}
}
}
for (const { x, y } of bresenhamCircleGen(playerPos.x, playerPos.y, 10, { fill: 'shadow', breaker: isWall })) {
maskData.set(x + mapData.width, y + mapData.height, '\0');
}
state.maskDirty = false;
} }
updateMask();
world.update(dt); world.update(dt);
state.now += dt;
}); });