Compare commits
No commits in common. "f6bb3f19df3e6c74879ca9b410235cb85bc40dbf" and "90bac08e0b5f1e861c6b358ef1c4f0232a58a5fe" have entirely different histories.
f6bb3f19df
...
90bac08e0b
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { render } from "preact";
|
||||||
|
import type { ReactElement } from "preact/compat";
|
||||||
import { clamp, range } from "@common/utils";
|
import { clamp, range } from "@common/utils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { render, type ComponentChildren } from "preact";
|
|
||||||
|
|
||||||
import styles from './assets/brick.module.css';
|
import styles from './assets/brick.module.css';
|
||||||
import "./assets/lcd.font.css";
|
import "./assets/lcd.font.css";
|
||||||
|
|
@ -11,16 +12,10 @@ const FIELD_HEIGHT = 20;
|
||||||
const MINI_FIELD_WIDTH = 4;
|
const MINI_FIELD_WIDTH = 4;
|
||||||
const MINI_FIELD_HEIGHT = 4;
|
const MINI_FIELD_HEIGHT = 4;
|
||||||
|
|
||||||
export class BrickDisplayImage {
|
export interface BrickDisplayImage {
|
||||||
public image: boolean[];
|
image: boolean[];
|
||||||
public width: number;
|
width: number;
|
||||||
public height: number;
|
height: number;
|
||||||
|
|
||||||
constructor(image: boolean[] = [], width: number = 0, height: number = 0) {
|
|
||||||
this.image = image;
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrickDisplay {
|
export class BrickDisplay {
|
||||||
|
|
@ -31,7 +26,7 @@ export class BrickDisplay {
|
||||||
#level: number = 1;
|
#level: number = 1;
|
||||||
public pause: boolean = false;
|
public pause: boolean = false;
|
||||||
public gameOver: boolean = false;
|
public gameOver: boolean = false;
|
||||||
public helpText: ComponentChildren = '';
|
public helpText: string | ReactElement = '';
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.update();
|
this.update();
|
||||||
|
|
@ -42,7 +37,7 @@ export class BrickDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
set score(value) {
|
set score(value) {
|
||||||
this.#score = Math.max(0, (value | 0) % 1_000_000_000);
|
this.#score = Math.max(0, (value | 0) % 1000000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
get speed() {
|
get speed() {
|
||||||
|
|
@ -248,7 +243,11 @@ export class BrickDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
static convertImage(image: HTMLImageElement): BrickDisplayImage {
|
static convertImage(image: HTMLImageElement): BrickDisplayImage {
|
||||||
const result = new BrickDisplayImage();
|
const result: BrickDisplayImage = {
|
||||||
|
image: [],
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
result.width = canvas.width = image.naturalWidth;
|
result.width = canvas.width = image.naturalWidth;
|
||||||
|
|
@ -268,7 +267,7 @@ export class BrickDisplay {
|
||||||
|
|
||||||
static extractSprite(image: BrickDisplayImage, x: number, y: number, w: number, h: number): BrickDisplayImage {
|
static extractSprite(image: BrickDisplayImage, x: number, y: number, w: number, h: number): BrickDisplayImage {
|
||||||
if (w <= 0 || h <= 0 || x >= image.width || y >= image.height) {
|
if (w <= 0 || h <= 0 || x >= image.width || y >= image.height) {
|
||||||
return new BrickDisplayImage();
|
return { image: [], width: 0, height: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
x = clamp(x | 0, 0, image.width);
|
x = clamp(x | 0, 0, image.width);
|
||||||
|
|
@ -277,7 +276,11 @@ export class BrickDisplay {
|
||||||
w = clamp(w | 0, 1, image.width - x);
|
w = clamp(w | 0, 1, image.width - x);
|
||||||
h = clamp(h | 0, 1, image.height - y);
|
h = clamp(h | 0, 1, image.height - y);
|
||||||
|
|
||||||
const result = new BrickDisplayImage(new Array(w * h), w, h);
|
const result: BrickDisplayImage = {
|
||||||
|
image: new Array(w * h),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
|
||||||
for (let j = 0; j < h; j++) {
|
for (let j = 0; j < h; j++) {
|
||||||
for (let i = 0; i < w; i++) {
|
for (let i = 0; i < w; i++) {
|
||||||
|
|
@ -317,11 +320,11 @@ export class BrickDisplay {
|
||||||
static rotateSprite(image: BrickDisplayImage, angle: 0 | 90 | 180 | 270): BrickDisplayImage {
|
static rotateSprite(image: BrickDisplayImage, angle: 0 | 90 | 180 | 270): BrickDisplayImage {
|
||||||
if (angle === 0) return this.copySprite(image);
|
if (angle === 0) return this.copySprite(image);
|
||||||
|
|
||||||
const newImage = new BrickDisplayImage(
|
const newImage: BrickDisplayImage = {
|
||||||
new Array(image.width * image.height),
|
image: new Array(image.width * image.height),
|
||||||
angle === 180 ? image.width : image.height,
|
width: angle === 180 ? image.width : image.height,
|
||||||
angle === 180 ? image.height : image.width,
|
height: angle === 180 ? image.height : image.width,
|
||||||
);
|
}
|
||||||
|
|
||||||
for (let j = 0; j < image.height; j++) {
|
for (let j = 0; j < image.height; j++) {
|
||||||
for (let i = 0; i < image.width; i++) {
|
for (let i = 0; i < image.width; i++) {
|
||||||
|
|
@ -348,4 +351,4 @@ export class BrickDisplay {
|
||||||
|
|
||||||
return newImage;
|
return newImage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
/**
|
|
||||||
* SeededRandom — deterministic PRNG for games and procedural generation.
|
|
||||||
*
|
|
||||||
* Algorithm: xoshiro128** (Blackman & Vigna, 2018)
|
|
||||||
* Period: 2^128 − 1
|
|
||||||
* Quality: passes PractRand, BigCrush; excellent for game use
|
|
||||||
* State: 4 × uint32 — compact, fast, fully serialisable
|
|
||||||
*
|
|
||||||
* Seeding: SplitMix32 is used to expand any integer or string seed into
|
|
||||||
* the four-word initial state, avoiding the all-zero trap.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const rng = new SeededRandom('world-seed');
|
|
||||||
* const roomRng = rng.clone(); // independent sub-stream
|
|
||||||
* const x = roomRng.randInt(0, 100);
|
|
||||||
* const saved = rng.getState(); // snapshot
|
|
||||||
* rng.setState(saved); // restore
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { stringHash } from "./utils";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Four-word xoshiro128** state. Serialisable to / from JSON. */
|
|
||||||
export type RNGState = [number, number, number, number];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers (module-private)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Left-rotate a uint32. */
|
|
||||||
function rotl(x: number, k: number): number {
|
|
||||||
return ((x << k) | (x >>> (32 - k))) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SplitMix32 — used only during seeding to expand a single uint32 into the
|
|
||||||
* four independent state words required by xoshiro128**.
|
|
||||||
*/
|
|
||||||
function splitMix32Step(h: number): [number, number] {
|
|
||||||
h = (h + 0x9e3779b9) >>> 0;
|
|
||||||
let v = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
|
|
||||||
v = Math.imul(v ^ (v >>> 13), 0xc2b2ae35) >>> 0;
|
|
||||||
return [(v ^ (v >>> 16)) >>> 0, h];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Expand a single integer (or hashed string) into a valid xoshiro128 state. */
|
|
||||||
function buildState(seed: number | string): RNGState {
|
|
||||||
let h = typeof seed === 'string' ? stringHash(seed) : seed >>> 0;
|
|
||||||
let s0: number, s1: number, s2: number, s3: number;
|
|
||||||
[s0, h] = splitMix32Step(h);
|
|
||||||
[s1, h] = splitMix32Step(h);
|
|
||||||
[s2, h] = splitMix32Step(h);
|
|
||||||
[s3] = splitMix32Step(h);
|
|
||||||
// Guard: xoshiro128** must never start in the all-zero state.
|
|
||||||
if ((s0 | s1 | s2 | s3) === 0) s0 = 1;
|
|
||||||
return [s0, s1, s2, s3];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main class
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class SeededRandom {
|
|
||||||
private s: RNGState;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param seed A number, string, or a previously saved RNGState array.
|
|
||||||
* Strings are hashed deterministically (same string → same sequence).
|
|
||||||
*/
|
|
||||||
constructor(seed: number | string | RNGState = Date.now()) {
|
|
||||||
this.s = Array.isArray(seed)
|
|
||||||
? ([...seed] as RNGState)
|
|
||||||
: buildState(seed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Core generator
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** xoshiro128** — advances state, returns a raw uint32. */
|
|
||||||
private next(): number {
|
|
||||||
const [s0, s1, s2, s3] = this.s;
|
|
||||||
|
|
||||||
const result = Math.imul(rotl(Math.imul(s1, 5) >>> 0, 7), 9) >>> 0;
|
|
||||||
|
|
||||||
const t = (s1 << 9) >>> 0;
|
|
||||||
this.s[2] = (s2 ^ s0) >>> 0;
|
|
||||||
this.s[3] = (s3 ^ s1) >>> 0;
|
|
||||||
this.s[1] = (s1 ^ this.s[2]) >>> 0;
|
|
||||||
this.s[0] = (s0 ^ this.s[3]) >>> 0;
|
|
||||||
this.s[2] = (this.s[2] ^ t) >>> 0;
|
|
||||||
this.s[3] = rotl(this.s[3], 11);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uniform float in [0, 1).
|
|
||||||
* Uses 32 bits of randomness → 2^32 distinct values spaced by ~2.3 × 10^−10.
|
|
||||||
*/
|
|
||||||
random(): number {
|
|
||||||
return this.next() / 0x1_0000_0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uniform integer in [min, max).
|
|
||||||
* Uses rejection sampling to eliminate modulo bias.
|
|
||||||
*
|
|
||||||
* @param min Inclusive lower bound (default 0).
|
|
||||||
* @param max Exclusive upper bound (required).
|
|
||||||
*/
|
|
||||||
randInt(min: number, max: number): number;
|
|
||||||
randInt(max: number): number;
|
|
||||||
randInt(minOrMax: number, max?: number): number {
|
|
||||||
const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max];
|
|
||||||
|
|
||||||
if (!Number.isInteger(min) || !Number.isInteger(hi)) {
|
|
||||||
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})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = hi - min;
|
|
||||||
// Rejection threshold that makes every output equally likely.
|
|
||||||
// threshold = (2^32 mod range), computed without overflow as:
|
|
||||||
const threshold = (0x1_0000_0000 - range) % range >>> 0;
|
|
||||||
|
|
||||||
let v: number;
|
|
||||||
do { v = this.next(); } while (v < threshold);
|
|
||||||
return min + (v % range);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uniform boolean — exactly 50 % probability. */
|
|
||||||
randBool(): boolean {
|
|
||||||
return (this.next() & 1) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uniform random element from any iterable.
|
|
||||||
* Arrays are read in O(1); other iterables are materialised first.
|
|
||||||
*
|
|
||||||
* @param iterable Source collection.
|
|
||||||
* @param k When provided, returns k distinct elements sampled
|
|
||||||
* **without replacement** using a partial Fisher-Yates
|
|
||||||
* sweep — O(k) time, O(n) space for the working copy.
|
|
||||||
*
|
|
||||||
* @throws RangeError if the collection is empty, k < 1, or k > collection size.
|
|
||||||
*/
|
|
||||||
choice<T>(iterable: Iterable<T>): T;
|
|
||||||
choice<T>(iterable: Iterable<T>, k: number): T[];
|
|
||||||
choice<T>(iterable: Iterable<T>, k?: number): T | T[] {
|
|
||||||
const arr = [...iterable];
|
|
||||||
if (arr.length === 0) return [];
|
|
||||||
|
|
||||||
if (k === undefined) {
|
|
||||||
return arr[this.randInt(0, arr.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (k === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isInteger(k) || k < 0) {
|
|
||||||
throw new RangeError(`choice: k must be a positive integer, got ${k}`);
|
|
||||||
}
|
|
||||||
if (k > arr.length) {
|
|
||||||
throw new RangeError(`choice: k (${k}) exceeds collection size (${arr.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partial Fisher-Yates — only iterate the first k swaps.
|
|
||||||
// The selected items accumulate at the front of the working copy.
|
|
||||||
for (let i = 0; i < k; i++) {
|
|
||||||
const j = this.randInt(i, arr.length);
|
|
||||||
const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
|
||||||
}
|
|
||||||
return arr.slice(0, k);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weighted random choice — weights need not be normalised.
|
|
||||||
*
|
|
||||||
* Runs in O(n) per draw. For hot paths with a large, static item list
|
|
||||||
* consider building an alias table (Vose's method) instead.
|
|
||||||
*
|
|
||||||
* @param items Source array.
|
|
||||||
* @param weights Parallel weight array; must be non-negative with total > 0.
|
|
||||||
* @param k When provided, returns k elements sampled independently
|
|
||||||
* **with replacement** — each draw is an independent weighted
|
|
||||||
* roll, so the same item can appear multiple times.
|
|
||||||
*
|
|
||||||
* @throws RangeError if arrays have different lengths, contain negative
|
|
||||||
* weights, the total weight is zero, or k < 1.
|
|
||||||
*/
|
|
||||||
weightedChoice<T>(items: readonly T[], weights: readonly number[]): T;
|
|
||||||
weightedChoice<T>(items: readonly T[], weights: readonly number[], k: number): T[];
|
|
||||||
weightedChoice<T>(items: readonly T[], weights: readonly number[], k?: number): T | T[] {
|
|
||||||
if (items.length === 0) {
|
|
||||||
throw new RangeError('weightedChoice: items is empty');
|
|
||||||
}
|
|
||||||
if (items.length !== weights.length) {
|
|
||||||
throw new RangeError(
|
|
||||||
`weightedChoice: items.length (${items.length}) !== weights.length (${weights.length})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (k === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (k !== undefined && (!Number.isInteger(k) || k < 1)) {
|
|
||||||
throw new RangeError(`weightedChoice: k must be a positive integer, got ${k}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
for (let i = 0; i < weights.length; i++) {
|
|
||||||
if (weights[i] < 0) throw new RangeError(`weightedChoice: negative weight at index ${i}`);
|
|
||||||
total += weights[i];
|
|
||||||
}
|
|
||||||
if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0');
|
|
||||||
|
|
||||||
const drawOne = (): T => {
|
|
||||||
let r = this.random() * total;
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
r -= weights[i];
|
|
||||||
if (r < 0) return items[i];
|
|
||||||
}
|
|
||||||
// Float rounding safety net — return last item with non-zero weight.
|
|
||||||
for (let i = items.length - 1; i >= 0; i--) {
|
|
||||||
if (weights[i] > 0) return items[i];
|
|
||||||
}
|
|
||||||
return items[items.length - 1];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (k === undefined) return drawOne();
|
|
||||||
|
|
||||||
const result: T[] = new Array(k);
|
|
||||||
for (let i = 0; i < k; i++) result[i] = drawOne();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fisher-Yates shuffle — mutates the array in place.
|
|
||||||
* Returns the same array for chaining.
|
|
||||||
*/
|
|
||||||
shuffle<T>(arr: T[]): T[] {
|
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = this.randInt(0, i + 1);
|
|
||||||
const tmp = arr[i];
|
|
||||||
arr[i] = arr[j];
|
|
||||||
arr[j] = tmp;
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new shuffled copy of the input.
|
|
||||||
* The original is not modified.
|
|
||||||
*/
|
|
||||||
toShuffled<T>(arr: readonly T[]): T[] {
|
|
||||||
return this.shuffle([...arr]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// State management
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot the current generator state.
|
|
||||||
* The returned value is a plain array — safe to JSON.stringify / store.
|
|
||||||
*/
|
|
||||||
getState(): RNGState {
|
|
||||||
return [...this.s] as RNGState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore the generator to a previously snapshotted state.
|
|
||||||
* After this call, the sequence is identical to what it was at the snapshot.
|
|
||||||
*/
|
|
||||||
setState(state: RNGState): void {
|
|
||||||
if (!Array.isArray(state) || state.length !== 4) {
|
|
||||||
throw new TypeError('setState: expected an array of 4 uint32s');
|
|
||||||
}
|
|
||||||
this.s = [...state] as RNGState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an independent child RNG for a sub-resource (room, NPC, particle
|
|
||||||
* system, …).
|
|
||||||
*
|
|
||||||
* The child starts from the *current* state. The parent then advances by
|
|
||||||
* one step so parent and child immediately diverge — neither will reproduce
|
|
||||||
* the other's sequence.
|
|
||||||
*
|
|
||||||
* Typical pattern:
|
|
||||||
* const worldRng = new SeededRandom('seed-42');
|
|
||||||
* const dungeonRng = worldRng.clone(); // independent dungeon stream
|
|
||||||
* const npcRng = worldRng.clone(); // independent NPC stream
|
|
||||||
*/
|
|
||||||
clone(): SeededRandom {
|
|
||||||
const child = new SeededRandom(this.getState());
|
|
||||||
this.next(); // diverge parent so the two streams never overlap
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Serialisation
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
toJSON(): { state: RNGState } {
|
|
||||||
return { state: this.getState() };
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJSON(json: { state: RNGState }): SeededRandom {
|
|
||||||
return new SeededRandom(json.state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,3 +8,9 @@ the attacker/source that CombatSystem folds into the final damage value.
|
||||||
|
|
||||||
### Crit / variance
|
### Crit / variance
|
||||||
RNG layer on top of damage calculation (crit chance, crit multiplier, random range).
|
RNG layer on top of damage calculation (crit chance, crit multiplier, random range).
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
- Different display systems
|
||||||
|
- [x] TextDisplay
|
||||||
|
- [ ] BrickDisplay
|
||||||
|
- [ ] Canvas
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,6 @@ export class Position extends Component<{ x: number, y: number, z: number }> {
|
||||||
constructor(x = 0, y = 0, z = 0) {
|
constructor(x = 0, y = 0, z = 0) {
|
||||||
super({ x, y, z });
|
super({ x, y, z });
|
||||||
}
|
}
|
||||||
|
|
||||||
get x() {
|
|
||||||
return this.state.x;
|
|
||||||
}
|
|
||||||
get y() {
|
|
||||||
return this.state.y;
|
|
||||||
}
|
|
||||||
get z() {
|
|
||||||
return this.state.z;
|
|
||||||
}
|
|
||||||
set x(x: number) {
|
|
||||||
this.state.x = x;
|
|
||||||
}
|
|
||||||
set y(y: number) {
|
|
||||||
this.state.y = y;
|
|
||||||
}
|
|
||||||
set z(z: number) {
|
|
||||||
this.state.z = z;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPosition = (entity?: Entity, key?: string) => entity?.get(Position, key)?.state;
|
export const getPosition = (entity?: Entity, key?: string) => entity?.get(Position, key)?.state;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Component } from "@common/rpg/core/world";
|
|
||||||
import { component } from "@common/rpg/utils/decorators";
|
|
||||||
|
|
||||||
@component
|
|
||||||
export class BrickSprite extends Component<{ xor: boolean, miniDisplay: boolean }> {
|
|
||||||
constructor(miniDisplay: boolean, xor: boolean) {
|
|
||||||
super({ xor, miniDisplay });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,10 +27,6 @@ export class Sprite extends Component<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get image(): string {
|
|
||||||
return this.state.frames[this.state.currentFrame];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@component export class Hidden extends Component<{}> {
|
@component export class Hidden extends Component<{}> {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { BrickDisplay, BrickDisplayImage } from "@common/display/brick";
|
|
||||||
import { Position } from "@common/rpg/components/position";
|
|
||||||
import { BrickSprite } from "@common/rpg/components/render/brick";
|
|
||||||
import { Hidden, Sprite } from "@common/rpg/components/sprite";
|
|
||||||
import { System, World } from "@common/rpg/core/world";
|
|
||||||
import { Resources } from "@common/rpg/utils/resources";
|
|
||||||
|
|
||||||
export class BrickDisplaySystem extends System {
|
|
||||||
public readonly display: BrickDisplay;
|
|
||||||
|
|
||||||
constructor(display?: BrickDisplay) {
|
|
||||||
super();
|
|
||||||
this.display = display ?? new BrickDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
override update(world: World) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
const { xor = false, miniDisplay = false } = e.get(BrickSprite)?.state ?? {};
|
|
||||||
|
|
||||||
const { x, y } = pos.state;
|
|
||||||
|
|
||||||
const data = Resources.get(BrickDisplayImage, sprite.image);
|
|
||||||
if (!data) {
|
|
||||||
throw new Error('No image data found for sprite');
|
|
||||||
}
|
|
||||||
this.display.drawImage(data, x, y, miniDisplay, xor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { BrickDisplay, BrickDisplayImage } from "@common/display/brick";
|
|
||||||
import { createCanvas } from "@common/display/canvas";
|
|
||||||
import { Position } from "@common/rpg/components/position";
|
|
||||||
import { BrickSprite } from "@common/rpg/components/render/brick";
|
|
||||||
import { Hidden, Sprite } from "@common/rpg/components/sprite";
|
|
||||||
import { System, World } from "@common/rpg/core/world";
|
|
||||||
import { Resources } from "@common/rpg/utils/resources";
|
|
||||||
|
|
||||||
export class CanvasDisplaySystem extends System {
|
|
||||||
public readonly canvas: HTMLCanvasElement;
|
|
||||||
public readonly ctx: CanvasRenderingContext2D;
|
|
||||||
|
|
||||||
constructor();
|
|
||||||
constructor(canvas: HTMLCanvasElement);
|
|
||||||
constructor(width: number, height: number);
|
|
||||||
constructor(canvasOrWidth?: HTMLCanvasElement | number, height?: number) {
|
|
||||||
super();
|
|
||||||
const width = typeof canvasOrWidth === 'number' ? canvasOrWidth : undefined;
|
|
||||||
let canvas = canvasOrWidth instanceof HTMLCanvasElement ? canvasOrWidth : undefined;
|
|
||||||
if (canvas == null) {
|
|
||||||
if (width == null || height == null) {
|
|
||||||
throw new Error('Canvas or width/height must be provided');
|
|
||||||
}
|
|
||||||
canvas = createCanvas(width, height);
|
|
||||||
}
|
|
||||||
this.canvas = canvas;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx == null) {
|
|
||||||
throw new Error('Could not create canvas context');
|
|
||||||
}
|
|
||||||
this.ctx = ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
override update(world: World) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
const { x, y } = pos.state;
|
|
||||||
const imageId = sprite.image;
|
|
||||||
|
|
||||||
const image: CanvasImageSource | undefined =
|
|
||||||
Resources.get(HTMLImageElement, imageId)
|
|
||||||
?? Resources.get(HTMLVideoElement, imageId)
|
|
||||||
?? Resources.get(HTMLCanvasElement, imageId)
|
|
||||||
?? Resources.get(SVGImageElement, imageId)
|
|
||||||
?? Resources.get(ImageBitmap, imageId)
|
|
||||||
?? Resources.get(OffscreenCanvas, imageId)
|
|
||||||
?? Resources.get(VideoFrame, imageId)
|
|
||||||
|
|
||||||
const data = Resources.get(ImageData, imageId);
|
|
||||||
if (image) {
|
|
||||||
this.ctx.drawImage(image, x, y);
|
|
||||||
} else if (data) {
|
|
||||||
this.ctx.putImageData(data, x, y);
|
|
||||||
} else {
|
|
||||||
throw new Error(`No image data found for id ${imageId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { System, World } from "@common/rpg/core/world";
|
||||||
import { Resources } from "@common/rpg/utils/resources";
|
import { Resources } from "@common/rpg/utils/resources";
|
||||||
|
|
||||||
export class TextDisplaySystem extends System {
|
export class TextDisplaySystem extends System {
|
||||||
public readonly display: TextDisplay;
|
private readonly display: TextDisplay;
|
||||||
|
|
||||||
constructor(display?: TextDisplay) {
|
constructor(display?: TextDisplay) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -16,16 +16,11 @@ export class TextDisplaySystem extends System {
|
||||||
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;
|
||||||
const image = sprite.image;
|
const { frames, currentFrame } = sprite.state;
|
||||||
const { x, y } = pos.state;
|
const { x, y } = pos.state;
|
||||||
|
|
||||||
const data =
|
const data = Resources.get(TextRegion, frames[currentFrame]) ?? new TextRegion(frames[currentFrame]);
|
||||||
Resources.get(TextRegion, image)
|
this.display.setRegion(x, y, data);
|
||||||
?? Resources.get(String, image)
|
|
||||||
?? image;
|
|
||||||
|
|
||||||
const region = data instanceof TextRegion ? data : new TextRegion(data);
|
|
||||||
this.display.setRegion(x, y, region);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,9 +4,6 @@ export namespace Resources {
|
||||||
const resources = new Map<Function, Map<string, any>>();
|
const resources = new Map<Function, Map<string, any>>();
|
||||||
|
|
||||||
export function get<T>(id: string): T | undefined;
|
export function get<T>(id: string): T | undefined;
|
||||||
export function get(ctor: StringConstructor, id: string): string | undefined;
|
|
||||||
export function get(ctor: NumberConstructor, id: string): number | undefined;
|
|
||||||
export function get(ctor: BooleanConstructor, id: string): boolean | undefined;
|
|
||||||
export function get<T>(ctor: Class<T>, id: string): T | undefined;
|
export function get<T>(ctor: Class<T>, id: string): T | undefined;
|
||||||
export function get<T>(ctorOrId: Class<T> | string, id?: string): T | undefined {
|
export function get<T>(ctorOrId: Class<T> | string, id?: string): T | undefined {
|
||||||
const ctor = typeof ctorOrId === 'string' ? undefined : ctorOrId;
|
const ctor = typeof ctorOrId === 'string' ? undefined : ctorOrId;
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,6 @@ export const intHash = (seed: number, ...parts: number[]) => {
|
||||||
return h1;
|
return h1;
|
||||||
};
|
};
|
||||||
export const sinHash = (...data: number[]) => data.reduce((hash, n) => Math.sin((hash * 123.12 + n) * 756.12), 0) / 2 + 0.5;
|
export const sinHash = (...data: number[]) => data.reduce((hash, n) => Math.sin((hash * 123.12 + n) * 756.12), 0) / 2 + 0.5;
|
||||||
/** FNV-1a 32-bit hash — turns a string into a uint32 seed. */
|
|
||||||
export const stringHash = (s: string): number => {
|
|
||||||
let h = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
|
|
||||||
}
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
type F<T, A extends unknown[], R> = (this: T, ...args: A) => R;
|
type F<T, A extends unknown[], R> = (this: T, ...args: A) => R;
|
||||||
export const throttle = function <T, A extends unknown[], R>(func: F<T, A, R>, ms: number, trailing = false): F<T, A, R> {
|
export const throttle = function <T, A extends unknown[], R>(func: F<T, A, R>, ms: number, trailing = false): F<T, A, R> {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import './assets/style.css';
|
||||||
import { Direction, getOppositeDirection, MAP_HEIGHT, MAP_WIDTH, MAP_X, MAP_Y, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from './const';
|
import { Direction, getOppositeDirection, MAP_HEIGHT, MAP_WIDTH, MAP_X, MAP_Y, ROOM_AREA_HEIGHT, ROOM_AREA_WIDTH, ROOM_AREA_X, ROOM_AREA_Y } from './const';
|
||||||
import { getPossibleRoomsCount, getRoom, getRoomsCount, getRoomsForLayer, getMapRoomChar, Room, Door } from './room';
|
import { getPossibleRoomsCount, getRoom, getRoomsCount, getRoomsForLayer, getMapRoomChar, Room, Door } from './room';
|
||||||
import { createPlayer, Player } from './player';
|
import { createPlayer, Player } from './player';
|
||||||
import { resolveActions, resolveVariables } from '@common/rpg/utils/variables';
|
|
||||||
import { createItems } from './item';
|
|
||||||
|
|
||||||
|
|
||||||
export default async function main() {
|
export default async function main() {
|
||||||
|
|
@ -170,8 +168,6 @@ export default async function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createItems(world);
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const dt = await nextFrame();
|
const dt = await nextFrame();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue