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_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 {
cc: string;
nm: 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 rtURL = 'https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/libclang_rt.builtins-wasm32-wasi-25.0.tar.gz';
const WASI_VERSION = '32.0';
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 wasiDir = path.resolve(import.meta.dir, '..', '..', 'dist', 'wasi');
const cc: CompilerWithFlags = {
cc: 'clang',
nm: 'nm',
flags: [
'--target=wasm32',
'--no-standard-libraries',
@ -28,7 +32,10 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
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);
if (!response.ok) {
@ -37,8 +44,10 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
const bytes = await response.bytes();
console.log(`Extracting WASI SDK...`);
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);
if (!rtResponse.ok) {
@ -47,12 +56,14 @@ const getCompiler = async (): Promise<CompilerWithFlags> => {
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}`;
}
cc.cc = `${path.resolve(wasiDir, 'bin', 'clang')}`;
cc.nm = `${path.resolve(wasiDir, 'bin', 'nm')}`;
cc.flags = [
'--target=wasm32-wasi',
'--target=wasm32-wasip1',
`--sysroot=${path.resolve(wasiDir, 'share', 'wasi-sysroot')}`,
];
@ -106,12 +117,11 @@ async function instantiate(url: string) {
const memory = new WebAssembly.Memory({
initial: 32,
});
const table = new WebAssembly.Table({ initial: 16, element: 'anyfunc' });
const decoder = new TextDecoder();
let buf = '';
let errBuf = '';
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {
env: { memory, __indirect_function_table: table },
env: { memory },
wasi_snapshot_preview1: new Proxy({
random_get: (ptr: number, length: number) => {
const data = new DataView(memory.buffer);
@ -164,9 +174,12 @@ async function instantiate(url: string) {
});
return {
...instance.exports,
...Object.fromEntries(
Object.entries(instance.exports)
.filter(([k]) => !k.startsWith('_'))
),
memory,
table,
table: instance.exports.__indirect_function_table,
get data() { return new DataView(memory.buffer); },
};
}
@ -227,13 +240,24 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
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 = [
'--strip-all',
'--lto-O3',
'--no-entry',
'--import-memory',
'--import-table',
'--export-dynamic',
'--export-table',
...exports.flatMap(e => ['--export', e]),
].map(f => `-Wl,${f}`);
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>({
idleTimeout: 0,
websocket: {
open(ws) {
const game = ws.data.game;

View File

@ -1,6 +1,6 @@
--target=wasm32-wasi
--target=wasm32-wasip1
--sysroot=dist/wasi/share/wasi-sysroot
-std=c++26
-std=gnu++26
-fno-exceptions
-I
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 {
this.applyModifier(-delta, field);
}
get current(): number { return this.value; }
}
@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 type { Point } from "@common/geometry";
import Input from "@common/input";
import { BSP } from "@common/level/bsp";
import { bresenhamCircleGen } from "@common/navigation/bresenham";
import { SeededRandom } from "@common/random";
import { Factions } from "@common/rpg/components/faction";
import { getPosition, move, Position } from "@common/rpg/components/position";
import { createViewport } from "@common/rpg/components/render/viewport";
import { Sprite } from "@common/rpg/components/sprite";
import { Serialization } from "@common/rpg/core/serialization";
import { World } from "@common/rpg/core/world";
import { TextDisplaySystem } from "@common/rpg/systems/render/text";
import { Resources } from "@common/rpg/utils/resources";
import { resolveVariables } from "@common/rpg/utils/variables";
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 WALL = '#';
const PLAYER = '@';
const createPlayer = (world: World, weapon: Entity): Entity => {
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) {
const MAP_SIZE = 100;
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');
inv.add(weapon);
inv.equip(weapon);
return player;
}
function createEnemy(world: World, x: number, y: number, sprite: string, ...factions: string[]) {
const enemy = world.createEntity('enemy_*');
enemy.add(new Position(x, y, 9));
enemy.add(new Sprite(sprite));
factions.forEach(faction => Factions.join(enemy, faction));
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;
}
function handleMovement() {
let dx = 0;
let dy = 0;
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' } });
const isReleasedOrRepeated = (...keys: Input.KeyCode[]) =>
keys.some(key => Input.isReleased(key) || Input.isRepeated(key));
weapon.add(new Damage({ value: damage, variance }));
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);
return weapon;
}
const upLeft = isReleasedOrRepeated(Input.KeyCode.Y);
const upRight = isReleasedOrRepeated(Input.KeyCode.U);
const downLeft = isReleasedOrRepeated(Input.KeyCode.B);
const downRight = isReleasedOrRepeated(Input.KeyCode.N);
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}`;
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;
const xp = entity.get(Experience);
if (xp) {
text += `\nLevel: ${xp.level}`;
text += `\nXP: ${xp.xp}/${xp.xpForNext}`;
}
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(() => {
Input.setRepeatInterval(50);
const world = new World();
const display = world.addSystem(new TextDisplaySystem(100, 25)).display;
const random = new SeededRandom();
const { map, mask } = createMap(world, random);
world.addSystem(new CombatSystem());
const display = new TextDisplay();
const mapData = Resources.get(TextRegion, map.get(Sprite)!.image)!;
const emptyCells: Point[] = [];
for (let x = 0; x < mapData.width; x++) {
for (let y = 0; y < mapData.height; y++) {
if (mapData.get(x, y)[0] === '.') {
emptyCells.push({ x, y });
}
}
}
const fists = createWeapon(world, 'fists', 7, 2);
const player = createPlayer(world, fists);
const enemy = createEnemy(world, 1);
const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!;
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 log: TextRegion[] = [];
const state = {
display,
world,
map, mapData,
mask, maskData, maskDirty: true,
random,
viewport,
display,
player,
enemy,
isWall: (x: number, y: number): boolean => {
const [ch] = state.mapData.get(x, y);
return ch === WALL || isVertical(ch) || isHorizontal(ch) || isCorner(ch);
},
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]);
needUpdate: true,
blocked: false,
log,
addLog(text: string, color?: ColorLike) {
log.push(new TextRegion(text, color));
if (log.length > 10) {
log.shift();
}
}
}
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;
state.needUpdate = true;
}
};
Factions.setReputationIn(player, 'monsters', 5);
console.log(Factions.getReputation(enemy, player));
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) => {
const {
world,
viewport,
player,
updateMask,
isWall,
} = 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;
const hasInput = Input.hasReleased() || Input.hasRepeated();
const playerPos = getPosition(player)!;
if (hasInput) {
if (Input.isHeld(Input.KeyCode.SHIFT)) {
setTimeout(() => {
if (state.enemy.destroyed) {
state.enemy = createEnemy(state.world, state.player.get(Experience)!.level);
} else {
let { dx, dy } = handleMovement();
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);
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.needUpdate = true;
}
if (dx || dy) {
move(player, dx, dy);
move(viewport, dx, dy);
if (state.needUpdate) {
state.world.update(1);
playerPos.x + dx;
playerPos.y + dy;
}
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);
}
// TODO environment step
state.maskDirty = true;
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);
}
updateMask();
world.update(dt);
state.needUpdate = false;
}
state.display.update();
});

View File

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

View File

@ -1,183 +1,5 @@
import { AlignHorizontal, TextDisplay, TextRegion, type ColorLike } from "@common/display/text";
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";
import awoo from './awoo.cpp';
const createPlayer = (world: World, weapon: Entity): Entity => {
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"));
inv.add(weapon);
inv.equip(weapon);
return player;
export default function main() {
console.log(awoo);
}
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();
});