Compare commits
2 Commits
2ace096907
...
2a82c13fb4
| Author | SHA1 | Date |
|---|---|---|
|
|
2a82c13fb4 | |
|
|
d2003bc639 |
|
|
@ -1,3 +1,5 @@
|
|||
import Input from "@common/input";
|
||||
|
||||
export function loadImageData(dataView: DataView, pointer: number) {
|
||||
const width = dataView.getUint16(pointer + 0, true);
|
||||
const height = dataView.getUint16(pointer + 2, true);
|
||||
|
|
@ -25,6 +27,8 @@ export function createCanvas(width: number, height: number) {
|
|||
document.body.style.alignItems = 'center';
|
||||
document.body.append(canvas);
|
||||
|
||||
Input.setMainCanvas(canvas);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import '@common/assets/fonts/vga.font.css';
|
||||
import { randInt } from "@common/utils";
|
||||
import { createCanvas } from './canvas';
|
||||
import type { Rect } from '@common/geometry';
|
||||
import type { Point, Rect } from '@common/geometry';
|
||||
import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham';
|
||||
import Input from '@common/input';
|
||||
|
||||
export type ColorLike = string | number | Color;
|
||||
export type Char = [string, ColorLike?, ColorLike?] | string;
|
||||
|
|
@ -450,6 +451,14 @@ export class TextDisplay {
|
|||
update() {
|
||||
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[][] {
|
||||
|
|
@ -484,8 +493,8 @@ export class TextRegion {
|
|||
|
||||
constructor(
|
||||
chars: Char[][] | string | string[],
|
||||
fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG,
|
||||
bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG,
|
||||
fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG,
|
||||
bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG,
|
||||
) {
|
||||
if (typeof chars === 'string') {
|
||||
chars = chars.split('\n');
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ export interface EventWithPoint {
|
|||
|
||||
export const getRealPoint = (canvas: HTMLCanvasElement, e: EventWithPoint): DOMPoint => {
|
||||
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 offsetLeft = (canvas.clientWidth - realWidth) / 2;
|
||||
const offsetTop = (canvas.clientHeight - realHeight) / 2;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
const scale = Math.min(box.width / canvas.width, box.height / canvas.height);
|
||||
|
||||
matrix.translateSelf(offsetLeft, offsetTop);
|
||||
matrix.translateSelf(box.left, box.top);
|
||||
matrix.scaleSelf(scale);
|
||||
matrix.invertSelf();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { nextFrame } from "./utils";
|
|||
|
||||
interface FrameMeta {
|
||||
fps: number;
|
||||
now: number;
|
||||
}
|
||||
type Awaitable<T> = PromiseLike<T> | T;
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
|
|||
let prevFrame = performance.now();
|
||||
let fpsCounter = 0;
|
||||
let fpsTimer = 0;
|
||||
const meta: FrameMeta = { fps: 0 };
|
||||
const meta: FrameMeta = { fps: 0, now: 0 };
|
||||
while (true) {
|
||||
await nextFrame();
|
||||
Input.updateKeys();
|
||||
|
|
@ -41,6 +42,7 @@ export function gameLoop<T>(setupOrFrame: Setup<T> | Frame<T>, frame?: Frame<T>)
|
|||
const dt = (now - prevFrame) / 1000;
|
||||
|
||||
if (dt < 1) { // skip long pause to avoid blowing up values
|
||||
meta.now += dt;
|
||||
const newState = await frame(dt, state, meta);
|
||||
if (newState) {
|
||||
state = newState;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { getRealPoint } from "./dom";
|
||||
import type { Point } from "./geometry";
|
||||
|
||||
interface IKeyState {
|
||||
state: boolean;
|
||||
prevState?: boolean;
|
||||
|
||||
released?: boolean;
|
||||
pressed?: boolean;
|
||||
repeated?: boolean;
|
||||
held?: boolean;
|
||||
|
||||
changedAt: number;
|
||||
eventAt: number;
|
||||
repeatAt?: number;
|
||||
}
|
||||
namespace Input {
|
||||
export enum KeyCode {
|
||||
|
|
@ -16,6 +24,46 @@ namespace Input {
|
|||
SHIFT = 'Shift',
|
||||
SHIFT_LEFT = 'ShiftLeft',
|
||||
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 {
|
||||
|
|
@ -46,14 +94,24 @@ namespace Input {
|
|||
}
|
||||
|
||||
const DEAD_ZONE = 0.05;
|
||||
const KEYS: Partial<Record<KeyCode, IKeyState>> = {};
|
||||
const KEYS: Partial<Record<string, IKeyState>> = {};
|
||||
const mousePosition: Point = { x: 0, y: 0 };
|
||||
let mainCanvas: HTMLCanvasElement | null = null;
|
||||
let repeatIntervalMs = 10;
|
||||
let repeatDelayMs = 300;
|
||||
|
||||
const onStateChange = (keyId: KeyCode, state: boolean) => {
|
||||
const onStateChange = (keyId: string, state: boolean) => {
|
||||
console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`);
|
||||
const now = Date.now();
|
||||
|
||||
if (KEYS[keyId]) {
|
||||
if (state !== KEYS[keyId].state) {
|
||||
KEYS[keyId].changedAt = now;
|
||||
}
|
||||
KEYS[keyId].state = state;
|
||||
KEYS[keyId].eventAt = now;
|
||||
} else {
|
||||
KEYS[keyId] = { state };
|
||||
KEYS[keyId] = { state, changedAt: now, eventAt: now };
|
||||
}
|
||||
|
||||
if (keyId === KeyCode.SHIFT_LEFT || keyId === KeyCode.SHIFT_RIGHT) {
|
||||
|
|
@ -62,12 +120,39 @@ 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('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 = (key: KeyCode): boolean => KEYS[key]?.pressed ?? false;
|
||||
export const isReleased = (key: KeyCode): boolean => KEYS[key]?.released ?? false;
|
||||
export const isHeld = (key: KeyCode): boolean => KEYS[key]?.held ?? false;
|
||||
export const isPressed = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.pressed);
|
||||
export const isReleased = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.released);
|
||||
export const isRepeated = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.repeated);
|
||||
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);
|
||||
|
||||
|
|
@ -93,8 +178,24 @@ namespace Input {
|
|||
+ 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() {
|
||||
for (const key of Object.values(KEYS)) {
|
||||
if (!key) continue;
|
||||
|
||||
key.released = false;
|
||||
key.pressed = false;
|
||||
|
||||
|
|
@ -102,10 +203,15 @@ namespace Input {
|
|||
key.pressed = !key.held;
|
||||
key.held = true;
|
||||
} else {
|
||||
key.released = true;
|
||||
key.released = key.held;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Component, World } from "@common/rpg/core/world";
|
||||
import { component } from "@common/rpg/utils/decorators";
|
||||
import { getPosition, Position } from "../position";
|
||||
import type { Point } from "@common/geometry";
|
||||
|
||||
export interface ViewportData {
|
||||
screenX: number;
|
||||
|
|
@ -13,7 +14,6 @@ export interface ViewportData {
|
|||
|
||||
@component
|
||||
export class Viewport extends Component<void> {
|
||||
|
||||
get screenX(): number {
|
||||
return Math.round(getPosition(this.entity, 'screen')?.x ?? 0);
|
||||
}
|
||||
|
|
@ -37,6 +37,20 @@ export class Viewport extends Component<void> {
|
|||
get worldY(): number {
|
||||
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) => {
|
||||
|
|
@ -49,16 +63,9 @@ export const createViewport = (world: World, viewportData: ViewportData) => {
|
|||
return viewport;
|
||||
}
|
||||
|
||||
export const getViewport = (world: World): ViewportData | null => {
|
||||
export const getViewport = (world: World): Viewport | 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 viewport;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -17,16 +17,20 @@ 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 offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined;
|
||||
const viewportClipRect = viewport ? {
|
||||
x: viewport.screenX,
|
||||
y: viewport.screenY,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
} : undefined;
|
||||
|
||||
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);
|
||||
|
||||
for (const [e, sprite, pos] of sprites) {
|
||||
if (e.has(Hidden)) continue;
|
||||
|
||||
const image = sprite.image;
|
||||
const { x, y, absolute } = pos;
|
||||
|
||||
|
|
@ -37,13 +41,11 @@ export class TextDisplaySystem extends System {
|
|||
|
||||
const region = data instanceof TextRegion ? data : new TextRegion(data);
|
||||
|
||||
if (absolute) {
|
||||
if (absolute || !offset || !viewportClipRect) {
|
||||
this.display.setRegion(x, y, region);
|
||||
} else {
|
||||
const clipRect = this.display.getClipRect();
|
||||
if (viewport) {
|
||||
this.display.setClipRect({ x: viewport.screenX, y: viewport.screenY, width: viewport.width, height: viewport.height });
|
||||
}
|
||||
this.display.setClipRect(viewportClipRect);
|
||||
this.display.setRegion(x - offset.x, y - offset.y, region);
|
||||
this.display.setClipRect(clipRect);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,50 @@ function createPlayer(world: World, x = 0, y = 0) {
|
|||
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(() => {
|
||||
Input.setRepeatInterval(50);
|
||||
const world = new World();
|
||||
const display = world.addSystem(new TextDisplaySystem(100, 25)).display;
|
||||
|
||||
|
|
@ -82,7 +125,7 @@ export default gameLoop(() => {
|
|||
});
|
||||
const player = createPlayer(world, startCell.x, startCell.y);
|
||||
|
||||
return {
|
||||
const state = {
|
||||
display,
|
||||
world,
|
||||
map, mapData,
|
||||
|
|
@ -90,69 +133,74 @@ export default gameLoop(() => {
|
|||
random,
|
||||
viewport,
|
||||
player,
|
||||
lastMove: 0, now: 0,
|
||||
isWall: (x: number, y: number): boolean => {
|
||||
const [ch] = mapData.get(x, y);
|
||||
const [ch] = state.mapData.get(x, y);
|
||||
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) => {
|
||||
const {
|
||||
world,
|
||||
viewport,
|
||||
player,
|
||||
lastMove, now,
|
||||
mapData, maskData,
|
||||
updateMask,
|
||||
isWall,
|
||||
} = state;
|
||||
|
||||
let dx = -Math.sign(Input.getHorizontal());
|
||||
let dy = -Math.sign(Input.getVertical());
|
||||
const hasInput = Input.hasReleased() || Input.hasRepeated();
|
||||
const playerPos = getPosition(player)!;
|
||||
|
||||
if (now - lastMove > 0.05) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
dx = 0;
|
||||
dy = 0;
|
||||
}
|
||||
if (hasInput) {
|
||||
if (Input.isHeld(Input.KeyCode.SHIFT)) {
|
||||
} else {
|
||||
let { dx, dy } = handleMovement();
|
||||
|
||||
if (dx || dy) {
|
||||
move(player, dx, dy);
|
||||
move(viewport, dx, dy);
|
||||
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;
|
||||
}
|
||||
|
||||
playerPos.x + dx;
|
||||
playerPos.y + dy;
|
||||
if (dx || dy) {
|
||||
move(player, dx, dy);
|
||||
move(viewport, dx, 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]);
|
||||
}
|
||||
playerPos.x + dx;
|
||||
playerPos.y + dy;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// TODO environment step
|
||||
|
||||
state.maskDirty = true;
|
||||
}
|
||||
|
||||
updateMask();
|
||||
world.update(dt);
|
||||
|
||||
state.now += dt;
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue