1
0
Fork 0

Upgrade wasi

This commit is contained in:
Pabloader 2026-05-12 16:27:12 +00:00
parent ec8cff15ac
commit e22d3b7c6f
8 changed files with 184 additions and 394 deletions

View File

@ -2,4 +2,4 @@
#define JS_IMPORT(name) __attribute__((import_module("env"), import_name(#name))) #define JS_IMPORT(name) __attribute__((import_module("env"), import_name(#name)))
#define JS_EXPORT_AS(name) __attribute__((export_name(#name))) #define JS_EXPORT_AS(name) __attribute__((export_name(#name)))
#define JS_EXPORT __attribute__((visibility("default"))) #define JS_EXPORT extern "C" __attribute__((visibility("default")))

View File

@ -9,16 +9,20 @@ interface WasmLoaderConfig {
interface CompilerWithFlags { interface CompilerWithFlags {
cc: string; cc: string;
nm: string;
flags: string[]; flags: string[];
} }
const wasiArchiveURL = 'https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-linux.tar.gz'; const WASI_VERSION = '32.0';
const rtURL = 'https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/libclang_rt.builtins-wasm32-wasi-25.0.tar.gz'; const WASI_MAJOR = WASI_VERSION.split('.')[0];
const wasiArchiveURL = `https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_MAJOR}/wasi-sdk-${WASI_VERSION}-x86_64-linux.tar.gz`;
const rtURL = `https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_MAJOR}/libclang_rt-${WASI_VERSION}.tar.gz`;
const getCompiler = async (): Promise<CompilerWithFlags> => { const getCompiler = async (): Promise<CompilerWithFlags> => {
const wasiDir = path.resolve(import.meta.dir, '..', '..', 'dist', 'wasi'); const wasiDir = path.resolve(import.meta.dir, '..', '..', 'dist', 'wasi');
const cc: CompilerWithFlags = { const cc: CompilerWithFlags = {
cc: 'clang', cc: 'clang',
nm: 'nm',
flags: [ flags: [
'--target=wasm32', '--target=wasm32',
'--no-standard-libraries', '--no-standard-libraries',
@ -28,7 +32,10 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
await fs.mkdir(wasiDir, { recursive: true }); await fs.mkdir(wasiDir, { recursive: true });
if (!await Bun.file(path.resolve(wasiDir, 'VERSION')).exists()) { const installedVersion = (await Bun.file(path.resolve(wasiDir, 'VERSION')).text().catch(() => '')).slice(0, WASI_VERSION.length);
if (installedVersion !== WASI_VERSION) {
console.log(`WASI version mismatch. Downloading WASI SDK ${wasiArchiveURL}...`)
const response = await fetch(wasiArchiveURL); const response = await fetch(wasiArchiveURL);
if (!response.ok) { if (!response.ok) {
@ -37,8 +44,10 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
const bytes = await response.bytes(); const bytes = await response.bytes();
console.log(`Extracting WASI SDK...`);
await $`tar -xzv -C ${wasiDir} --strip-components=1 < ${bytes}`; await $`tar -xzv -C ${wasiDir} --strip-components=1 < ${bytes}`;
console.log(`Downloading libclang_rt.builtins-wasm32-wasi-${WASI_VERSION}.0...`);
const rtResponse = await fetch(rtURL); const rtResponse = await fetch(rtURL);
if (!rtResponse.ok) { if (!rtResponse.ok) {
@ -47,12 +56,14 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
const rtBytes = await rtResponse.bytes(); const rtBytes = await rtResponse.bytes();
console.log(`Extracting libclang_rt.builtins-wasm32-wasi-${WASI_VERSION}.0...`);
await $`tar -xzv -C ${wasiDir} --strip-components=1 < ${rtBytes}`; await $`tar -xzv -C ${wasiDir} --strip-components=1 < ${rtBytes}`;
} }
cc.cc = `${path.resolve(wasiDir, 'bin', 'clang')}`; cc.cc = `${path.resolve(wasiDir, 'bin', 'clang')}`;
cc.nm = `${path.resolve(wasiDir, 'bin', 'nm')}`;
cc.flags = [ cc.flags = [
'--target=wasm32-wasi', '--target=wasm32-wasip1',
`--sysroot=${path.resolve(wasiDir, 'share', 'wasi-sysroot')}`, `--sysroot=${path.resolve(wasiDir, 'share', 'wasi-sysroot')}`,
]; ];
@ -106,12 +117,11 @@ async function instantiate(url: string) {
const memory = new WebAssembly.Memory({ const memory = new WebAssembly.Memory({
initial: 32, initial: 32,
}); });
const table = new WebAssembly.Table({ initial: 16, element: 'anyfunc' });
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
let errBuf = ''; let errBuf = '';
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), { const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {
env: { memory, __indirect_function_table: table }, env: { memory },
wasi_snapshot_preview1: new Proxy({ wasi_snapshot_preview1: new Proxy({
random_get: (ptr: number, length: number) => { random_get: (ptr: number, length: number) => {
const data = new DataView(memory.buffer); const data = new DataView(memory.buffer);
@ -164,9 +174,12 @@ async function instantiate(url: string) {
}); });
return { return {
...instance.exports, ...Object.fromEntries(
Object.entries(instance.exports)
.filter(([k]) => !k.startsWith('_'))
),
memory, memory,
table, table: instance.exports.__indirect_function_table,
get data() { return new DataView(memory.buffer); }, get data() { return new DataView(memory.buffer); },
}; };
} }
@ -227,13 +240,24 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
throw new Error('Compile failed, check output'); throw new Error('Compile failed, check output');
} }
const symbolsResult = await $`${cc.nm} ${objPath}`.quiet();
if (symbolsResult.exitCode !== 0) {
throw new Error('nm failed, check output');
}
const exports = symbolsResult.text().split('\n')
.map(l => l.match(/^-+ T ([a-z][a-z0-9_]*)/i))
.filter(m => m != null)
.map(m => m[1]);
const linkFlags = [ const linkFlags = [
'--strip-all', '--strip-all',
'--lto-O3', '--lto-O3',
'--no-entry', '--no-entry',
'--import-memory', '--import-memory',
'--import-table', '--export-table',
'--export-dynamic', ...exports.flatMap(e => ['--export', e]),
].map(f => `-Wl,${f}`); ].map(f => `-Wl,${f}`);
const linkResult = await $`${cc.cc} ${flags} -std=gnu23 ${linkFlags} -lstdc++ -nostartfiles -o ${wasmPath} ${objPath} ${stdlib}`; const linkResult = await $`${cc.cc} ${flags} -std=gnu23 ${linkFlags} -lstdc++ -nostartfiles -o ${wasmPath} ${objPath} ${stdlib}`;

View File

@ -45,6 +45,7 @@ watcher.on('change', async (_, filename) => {
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
Bun.serve<ClientData>({ Bun.serve<ClientData>({
idleTimeout: 0,
websocket: { websocket: {
open(ws) { open(ws) {
const game = ws.data.game; const game = ws.data.game;

View File

@ -1,6 +1,6 @@
--target=wasm32-wasi --target=wasm32-wasip1
--sysroot=dist/wasi/share/wasi-sysroot --sysroot=dist/wasi/share/wasi-sysroot
-std=c++26 -std=gnu++26
-fno-exceptions -fno-exceptions
-I -I
build/assets/include build/assets/include

View File

@ -52,8 +52,6 @@ export class Stat<T = {}> extends Component<StatState & T> {
removeModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void { removeModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void {
this.applyModifier(-delta, field); this.applyModifier(-delta, field);
} }
get current(): number { return this.value; }
} }
@component @component

View File

@ -1,237 +1,183 @@
import { Color, Glyphs, isCorner, isHorizontal, isVertical, TextRegion } from "@common/display/text"; import { AlignHorizontal, TextDisplay, TextRegion, type ColorLike } from "@common/display/text";
import { gameLoop } from "@common/game"; import { gameLoop } from "@common/game";
import type { Point } from "@common/geometry";
import Input from "@common/input"; import Input from "@common/input";
import { BSP } from "@common/level/bsp"; import { Attacked, Damage } from "@common/rpg/components/combat";
import { bresenhamCircleGen } from "@common/navigation/bresenham"; import { Equipment } from "@common/rpg/components/equipment";
import { SeededRandom } from "@common/random"; import { Experience, type LevelUpEvent } from "@common/rpg/components/experience";
import { Factions } from "@common/rpg/components/faction"; import { Inventory } from "@common/rpg/components/inventory";
import { getPosition, move, Position } from "@common/rpg/components/position"; import { Item, Items } from "@common/rpg/components/item";
import { createViewport } from "@common/rpg/components/render/viewport"; import { Name } from "@common/rpg/components/name";
import { Sprite } from "@common/rpg/components/sprite"; import { getWorldRandom } from "@common/rpg/components/random";
import { Serialization } from "@common/rpg/core/serialization"; import { Health } from "@common/rpg/components/stat";
import { World } from "@common/rpg/core/world"; import { Entity, World } from "@common/rpg/core/world";
import { TextDisplaySystem } from "@common/rpg/systems/render/text"; import { CombatSystem, type HitEvent } from "@common/rpg/systems/combat";
import { Resources } from "@common/rpg/utils/resources"; import { capitalize } from "@common/utils";
import { resolveVariables } from "@common/rpg/utils/variables";
const WALL = '#'; const createPlayer = (world: World, weapon: Entity): Entity => {
const PLAYER = '@'; const player = world.createEntity();
player.add(new Health({ value: 100, max: 100 }));
const inv = player.add(new Inventory());
player.add(new Equipment('weapon'));
player.add(new Experience({ base: 50, factor: 1.5 }));
player.add(new Name("Player"));
function createMap(world: World, random: SeededRandom) { inv.add(weapon);
const MAP_SIZE = 100; inv.equip(weapon);
const mapData = new Array(MAP_SIZE * MAP_SIZE).fill(WALL);
BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, {
minWidth: 10,
minHeight: 8,
depth: 10,
random,
});
const mapDataString: string[] = [];
const maskDataString: string[] = [];
for (let i = 0; i < MAP_SIZE; i++) {
mapDataString.push(mapData.slice(i * MAP_SIZE, (i + 1) * MAP_SIZE).join(''));
}
for (let i = 0; i < MAP_SIZE * 3; i++) {
maskDataString.push(Array.from({ length: MAP_SIZE * 3 }, () => ' ').join(''));
}
const map = world.createEntity('map');
map.add(new Sprite(Resources.add('map', new TextRegion(mapDataString))));
map.add(new Position(0, 0, -2));
const mask = world.createEntity('mask');
mask.add(new Sprite(Resources.add('mask', new TextRegion(maskDataString))));
mask.add(new Position(-MAP_SIZE, -MAP_SIZE, 100));
return { map, mask };
}
function createPlayer(world: World, x: number, y: number) {
const player = world.createEntity('player');
player.add(new Position(x, y, 10));
player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW))));
Factions.join(player, 'players');
return player; return player;
} }
function createEnemy(world: World, x: number, y: number, sprite: string, ...factions: string[]) { const createEnemy = (world: World, level: number): Entity => {
const enemy = world.createEntity('enemy_*'); const enemy = world.createEntity();
enemy.add(new Position(x, y, 9)); const inv = enemy.add(new Inventory());
enemy.add(new Sprite(sprite)); enemy.add(new Equipment('weapon'));
factions.forEach(faction => Factions.join(enemy, faction));
const random = getWorldRandom(world);
enemy.add(new Name(random.nextName({ maxLength: 7 }) + ' The Goblin'));
const hp = random.nextInt(level * 10, level * 20);
enemy.add(new Health({ value: hp, max: hp }));
const attack = random.nextInt(level * 3, level * 6);
const weaponName = random.choice(['sword', 'axe', 'dagger', 'mace']);
const weapon = createWeapon(world, weaponName, attack, level);
inv.add(weapon);
inv.equip(weapon);
return enemy; return enemy;
} }
function handleMovement() { const createWeapon = (world: World, id: string, damage: number, variance: number): Entity => {
let dx = 0; const name = id.split(/[-_ ]+/).map(capitalize).join(' ');
let dy = 0; const weapon = Items.register(world, `${id}_*`, name, { equippable: { slotType: 'weapon' } });
const isReleasedOrRepeated = (...keys: Input.KeyCode[]) => weapon.add(new Damage({ value: damage, variance }));
keys.some(key => Input.isReleased(key) || Input.isRepeated(key));
const left = isReleasedOrRepeated(Input.KeyCode.LEFT, Input.KeyCode.H); return weapon;
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 drawEntity = (display: TextDisplay, entity: Entity, x: number, y: number, anchorH: AlignHorizontal) => {
const upRight = isReleasedOrRepeated(Input.KeyCode.U); const { value: hp, max: maxHp } = entity.get(Health)!;
const downLeft = isReleasedOrRepeated(Input.KeyCode.B); let text = `HP: ${hp}/${maxHp}`;
const downRight = isReleasedOrRepeated(Input.KeyCode.N);
if (left) dx -= 1; const xp = entity.get(Experience);
if (right) dx += 1; if (xp) {
if (up) dy -= 1; text += `\nLevel: ${xp.level}`;
if (down) dy += 1; text += `\nXP: ${xp.xp}/${xp.xpForNext}`;
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 }; const title = entity.get(Name)?.state ?? entity.id;
display.drawStringInBox(text, x, y, { title, anchorH });
const weapon = entity.get(Equipment)!.getItem('weapon');
if (weapon) {
const { value: damage, variance } = weapon.get(Damage)!;
const minDamage = damage - variance;
const maxDamage = damage + variance;
display.drawStringInBox(`Damage: ${minDamage}-${maxDamage}`, x, y + (xp ? 5 : 3), { title: weapon.get(Item)!.name, anchorH });
}
} }
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 random = new SeededRandom(); world.addSystem(new CombatSystem());
const { map, mask } = createMap(world, random); const display = new TextDisplay();
const mapData = Resources.get(TextRegion, map.get(Sprite)!.image)!; const fists = createWeapon(world, 'fists', 7, 2);
const emptyCells: Point[] = []; const player = createPlayer(world, fists);
for (let x = 0; x < mapData.width; x++) { const enemy = createEnemy(world, 1);
for (let y = 0; y < mapData.height; y++) {
if (mapData.get(x, y)[0] === '.') {
emptyCells.push({ x, y });
}
}
}
const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!; const log: TextRegion[] = [];
const startCell = random.choice(emptyCells);
const enemyCell = random.choice(emptyCells.filter(c => c !== startCell));
const viewport = createViewport(world, {
width: display.width,
height: display.height,
worldX: startCell.x - (display.width >> 1),
worldY: startCell.y - (display.height >> 1),
screenX: 0,
screenY: 0,
});
const player = createPlayer(world, startCell.x, startCell.y);
Resources.add('goblin', new TextRegion('g', Color.DARK_GREEN));
const goblins = Factions.getFaction(world, 'goblins');
const monsters = Factions.getFaction(world, 'monsters');
Factions.setReputationOf(goblins.entity, 'players', -100);
Factions.setReputationOf(monsters.entity, 'players', -100);
Factions.setReputationOf(goblins.entity, 'monsters', 100);
Factions.setReputationOf(monsters.entity, 'goblins', 100);
const enemy = createEnemy(world, enemyCell.x, enemyCell.y, 'goblin', 'goblins', 'monsters');
const state = { const state = {
display,
world, world,
map, mapData, display,
mask, maskData, maskDirty: true,
random,
viewport,
player, player,
enemy, enemy,
isWall: (x: number, y: number): boolean => { needUpdate: true,
const [ch] = state.mapData.get(x, y); blocked: false,
return ch === WALL || isVertical(ch) || isHorizontal(ch) || isCorner(ch); log,
}, addLog(text: string, color?: ColorLike) {
updateMask: () => { log.push(new TextRegion(text, color));
if (!state.maskDirty) return; if (log.length > 10) {
log.shift();
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 })) { state.needUpdate = true;
maskData.set(x + mapData.width, y + mapData.height, '\0');
}
state.maskDirty = false;
} }
}; };
Factions.setReputationIn(player, 'monsters', 5); world.on<HitEvent>('Combat.hit', ({ target, data }) => {
console.log(Factions.getReputation(enemy, player)); if (target instanceof Entity) {
const targetName = target === player ? "Player" : target.get(Name)?.state ?? target.id;
state.addLog(`${targetName} hit by ${data.amount}`);
}
});
world.on('Combat.killed', ({ target }) => {
if (target instanceof Entity) {
target.destroy();
player.get(Experience)!.award(10);
state.addLog("Enemy killed!");
}
});
player.on<LevelUpEvent>('Experience.levelup', ({ data }) => {
const health = player.get(Health)!;
health.applyModifier(Math.round(health.max! * 0.1), 'max');
health.set(health.max!);
state.addLog(`Player leveled up to level ${data.level}`);
})
return state; return state;
}, (dt, state) => { }, (_dt, state) => {
const { if (Input.isReleased(Input.KeyCode.SPACE, Input.KeyCode.MOUSE_LEFT) && !state.blocked) {
world, const weapon = state.player.get(Equipment)!.getItem('weapon')!;
viewport, state.enemy.add(new Attacked({ attackerId: state.player.id, sourceId: weapon.id }));
player, state.blocked = true;
updateMask,
isWall,
} = state;
const hasInput = Input.hasReleased() || Input.hasRepeated(); setTimeout(() => {
const playerPos = getPosition(player)!; if (state.enemy.destroyed) {
state.enemy = createEnemy(state.world, state.player.get(Experience)!.level);
if (hasInput) { } else {
if (Input.isHeld(Input.KeyCode.SHIFT)) { const enemyWeapon = state.enemy.get(Equipment)!.getItem('weapon')!;
} else { state.player.add(new Attacked({ attackerId: state.enemy.id, sourceId: enemyWeapon.id }));
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;
} }
state.blocked = false;
state.needUpdate = true;
}, 500);
if (dx || dy) { state.needUpdate = true;
move(player, dx, dy);
move(viewport, dx, dy);
playerPos.x + dx;
playerPos.y + dy;
}
}
// TODO environment step
state.maskDirty = true;
} }
updateMask(); if (state.needUpdate) {
world.update(dt); state.world.update(1);
state.display.fillBox(
0,
0,
state.display.width,
state.display.height,
' ',
);
drawEntity(state.display, state.player, 0, 0, AlignHorizontal.LEFT);
if (!state.enemy.destroyed) {
drawEntity(state.display, state.enemy, state.display.width - 1, 0, AlignHorizontal.RIGHT);
}
for (let i = 0; i < state.log.length; i++) {
const entry = state.log[i];
state.display.setRegion(entry, 0, state.display.height - state.log.length + i);
}
state.needUpdate = false;
}
state.display.update();
}); });

View File

@ -4,7 +4,6 @@
#include <format> #include <format>
#include <iostream> #include <iostream>
#include <js.h> #include <js.h>
#include <stdlib.h>
#include <string> #include <string>
#include <utility> #include <utility>

View File

@ -1,183 +1,5 @@
import { AlignHorizontal, TextDisplay, TextRegion, type ColorLike } from "@common/display/text"; import awoo from './awoo.cpp';
import { gameLoop } from "@common/game";
import Input from "@common/input";
import { Attacked, Damage } from "@common/rpg/components/combat";
import { Equipment } from "@common/rpg/components/equipment";
import { Experience, type LevelUpEvent } from "@common/rpg/components/experience";
import { Inventory } from "@common/rpg/components/inventory";
import { Item, Items } from "@common/rpg/components/item";
import { Name } from "@common/rpg/components/name";
import { getWorldRandom } from "@common/rpg/components/random";
import { Health } from "@common/rpg/components/stat";
import { Entity, World } from "@common/rpg/core/world";
import { CombatSystem, type HitEvent } from "@common/rpg/systems/combat";
import { capitalize } from "@common/utils";
const createPlayer = (world: World, weapon: Entity): Entity => { export default function main() {
const player = world.createEntity(); console.log(awoo);
player.add(new Health({ value: 100, max: 100 }));
const inv = player.add(new Inventory());
player.add(new Equipment('weapon'));
player.add(new Experience({ base: 50, factor: 1.5 }));
player.add(new Name("Player"));
inv.add(weapon);
inv.equip(weapon);
return player;
} }
const createEnemy = (world: World, level: number): Entity => {
const enemy = world.createEntity();
const inv = enemy.add(new Inventory());
enemy.add(new Equipment('weapon'));
const random = getWorldRandom(world);
enemy.add(new Name(random.nextName({ maxLength: 7 }) + ' The Goblin'));
const hp = random.nextInt(level * 10, level * 20);
enemy.add(new Health({ value: hp, max: hp }));
const attack = random.nextInt(level * 3, level * 6);
const weaponName = random.choice(['sword', 'axe', 'dagger', 'mace']);
const weapon = createWeapon(world, weaponName, attack, level);
inv.add(weapon);
inv.equip(weapon);
return enemy;
}
const createWeapon = (world: World, id: string, damage: number, variance: number): Entity => {
const name = id.split(/[-_ ]+/).map(capitalize).join(' ');
const weapon = Items.register(world, `${id}_*`, name, { equippable: { slotType: 'weapon' } });
weapon.add(new Damage({ value: damage, variance }));
return weapon;
}
const drawEntity = (display: TextDisplay, entity: Entity, x: number, y: number, anchorH: AlignHorizontal) => {
const { value: hp, max: maxHp } = entity.get(Health)!;
let text = `HP: ${hp}/${maxHp}`;
const xp = entity.get(Experience);
if (xp) {
text += `\nLevel: ${xp.level}`;
text += `\nXP: ${xp.xp}/${xp.xpForNext}`;
}
const title = entity.get(Name)?.state ?? entity.id;
display.drawStringInBox(text, x, y, { title, anchorH });
const weapon = entity.get(Equipment)!.getItem('weapon');
if (weapon) {
const { value: damage, variance } = weapon.get(Damage)!;
const minDamage = damage - variance;
const maxDamage = damage + variance;
display.drawStringInBox(`Damage: ${minDamage}-${maxDamage}`, x, y + (xp ? 5 : 3), { title: weapon.get(Item)!.name, anchorH });
}
}
export default gameLoop(() => {
const world = new World();
world.addSystem(new CombatSystem());
const display = new TextDisplay();
const fists = createWeapon(world, 'fists', 7, 2);
const player = createPlayer(world, fists);
const enemy = createEnemy(world, 1);
const log: TextRegion[] = [];
const state = {
world,
display,
player,
enemy,
needUpdate: true,
blocked: false,
log,
addLog(text: string, color?: ColorLike) {
log.push(new TextRegion(text, color));
if (log.length > 10) {
log.shift();
}
state.needUpdate = true;
}
};
world.on<HitEvent>('Combat.hit', ({ target, data }) => {
if (target instanceof Entity) {
const targetName = target === player ? "Player" : target.get(Name)?.state ?? target.id;
state.addLog(`${targetName} hit by ${data.amount}`);
}
});
world.on('Combat.killed', ({ target }) => {
if (target instanceof Entity) {
target.destroy();
player.get(Experience)!.award(10);
state.addLog("Enemy killed!");
}
});
player.on<LevelUpEvent>('Experience.levelup', ({ data }) => {
const health = player.get(Health)!;
health.applyModifier(Math.round(health.max! * 0.1), 'max');
health.set(health.max!);
state.addLog(`Player leveled up to level ${data.level}`);
})
return state;
}, (_dt, state) => {
if (Input.isReleased(Input.KeyCode.SPACE, Input.KeyCode.MOUSE_LEFT) && !state.blocked) {
const weapon = state.player.get(Equipment)!.getItem('weapon')!;
state.enemy.add(new Attacked({ attackerId: state.player.id, sourceId: weapon.id }));
state.blocked = true;
setTimeout(() => {
if (state.enemy.destroyed) {
state.enemy = createEnemy(state.world, state.player.get(Experience)!.level);
} else {
const enemyWeapon = state.enemy.get(Equipment)!.getItem('weapon')!;
state.player.add(new Attacked({ attackerId: state.enemy.id, sourceId: enemyWeapon.id }));
}
state.blocked = false;
state.needUpdate = true;
}, 500);
state.needUpdate = true;
}
if (state.needUpdate) {
state.world.update(1);
state.display.fillBox(
0,
0,
state.display.width,
state.display.height,
' ',
);
drawEntity(state.display, state.player, 0, 0, AlignHorizontal.LEFT);
if (!state.enemy.destroyed) {
drawEntity(state.display, state.enemy, state.display.width - 1, 0, AlignHorizontal.RIGHT);
}
for (let i = 0; i < state.log.length; i++) {
const entry = state.log[i];
state.display.setRegion(entry, 0, state.display.height - state.log.length + i);
}
state.needUpdate = false;
}
state.display.update();
});