Another refactor
This commit is contained in:
98
src/engine/EntityManager.ts
Normal file
98
src/engine/EntityManager.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type World, type EntityId, type Actor, type Vec2 } from "../core/types";
|
||||
import { idx } from "./world/world-logic";
|
||||
|
||||
export class EntityManager {
|
||||
private grid: Map<number, EntityId[]> = new Map();
|
||||
private actors: Map<EntityId, Actor>;
|
||||
private world: World;
|
||||
|
||||
constructor(world: World) {
|
||||
this.world = world;
|
||||
this.actors = world.actors;
|
||||
this.rebuildGrid();
|
||||
}
|
||||
|
||||
rebuildGrid() {
|
||||
this.grid.clear();
|
||||
for (const actor of this.actors.values()) {
|
||||
this.addToGrid(actor);
|
||||
}
|
||||
}
|
||||
|
||||
private addToGrid(actor: Actor) {
|
||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
||||
if (!this.grid.has(i)) {
|
||||
this.grid.set(i, []);
|
||||
}
|
||||
this.grid.get(i)!.push(actor.id);
|
||||
}
|
||||
|
||||
private removeFromGrid(actor: Actor) {
|
||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
||||
const ids = this.grid.get(i);
|
||||
if (ids) {
|
||||
const index = ids.indexOf(actor.id);
|
||||
if (index !== -1) {
|
||||
ids.splice(index, 1);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
this.grid.delete(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveActor(actorId: EntityId, from: Vec2, to: Vec2) {
|
||||
const actor = this.actors.get(actorId);
|
||||
if (!actor) return;
|
||||
|
||||
// Remove from old position
|
||||
const oldIdx = idx(this.world, from.x, from.y);
|
||||
const ids = this.grid.get(oldIdx);
|
||||
if (ids) {
|
||||
const index = ids.indexOf(actorId);
|
||||
if (index !== -1) ids.splice(index, 1);
|
||||
if (ids.length === 0) this.grid.delete(oldIdx);
|
||||
}
|
||||
|
||||
// Update position
|
||||
actor.pos.x = to.x;
|
||||
actor.pos.y = to.y;
|
||||
|
||||
// Add to new position
|
||||
const newIdx = idx(this.world, to.x, to.y);
|
||||
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
|
||||
this.grid.get(newIdx)!.push(actorId);
|
||||
}
|
||||
|
||||
addActor(actor: Actor) {
|
||||
this.actors.set(actor.id, actor);
|
||||
this.addToGrid(actor);
|
||||
}
|
||||
|
||||
removeActor(actorId: EntityId) {
|
||||
const actor = this.actors.get(actorId);
|
||||
if (actor) {
|
||||
this.removeFromGrid(actor);
|
||||
this.actors.delete(actorId);
|
||||
}
|
||||
}
|
||||
|
||||
getActorsAt(x: number, y: number): Actor[] {
|
||||
const i = idx(this.world, x, y);
|
||||
const ids = this.grid.get(i);
|
||||
if (!ids) return [];
|
||||
return ids.map(id => this.actors.get(id)!).filter(Boolean);
|
||||
}
|
||||
|
||||
isOccupied(x: number, y: number, ignoreType?: string): boolean {
|
||||
const actors = this.getActorsAt(x, y);
|
||||
if (ignoreType) {
|
||||
return actors.some(a => a.type !== ignoreType);
|
||||
}
|
||||
return actors.length > 0;
|
||||
}
|
||||
|
||||
getNextId(): EntityId {
|
||||
return Math.max(0, ...this.actors.keys()) + 1;
|
||||
}
|
||||
}
|
||||
59
src/engine/ProgressionManager.ts
Normal file
59
src/engine/ProgressionManager.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { type CombatantActor, type Stats } from "../core/types";
|
||||
|
||||
export class ProgressionManager {
|
||||
allocateStat(player: CombatantActor, statName: string) {
|
||||
if (!player.stats || player.stats.statPoints <= 0) return;
|
||||
|
||||
player.stats.statPoints--;
|
||||
if (statName === "strength") {
|
||||
player.stats.strength++;
|
||||
player.stats.maxHp += 2;
|
||||
player.stats.hp += 2;
|
||||
player.stats.attack += 0.2;
|
||||
} else if (statName === "dexterity") {
|
||||
player.stats.dexterity++;
|
||||
player.speed += 1;
|
||||
} else if (statName === "intelligence") {
|
||||
player.stats.intelligence++;
|
||||
if (player.stats.intelligence % 5 === 0) {
|
||||
player.stats.defense++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allocatePassive(player: CombatantActor, nodeId: string) {
|
||||
if (!player.stats || player.stats.skillPoints <= 0) return;
|
||||
if (player.stats.passiveNodes.includes(nodeId)) return;
|
||||
|
||||
player.stats.skillPoints--;
|
||||
player.stats.passiveNodes.push(nodeId);
|
||||
|
||||
// Apply bonuses
|
||||
switch (nodeId) {
|
||||
case "off_1":
|
||||
player.stats.attack += 2;
|
||||
break;
|
||||
case "off_2":
|
||||
player.stats.attack += 4;
|
||||
break;
|
||||
case "def_1":
|
||||
player.stats.maxHp += 10;
|
||||
player.stats.hp += 10;
|
||||
break;
|
||||
case "def_2":
|
||||
player.stats.defense += 2;
|
||||
break;
|
||||
case "util_1":
|
||||
player.speed += 5;
|
||||
break;
|
||||
case "util_2":
|
||||
player.stats.expToNextLevel = Math.floor(player.stats.expToNextLevel * 0.9);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
calculateStats(baseStats: Stats): Stats {
|
||||
return baseStats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||
|
||||
import { isBlocked } from "../world/world-logic";
|
||||
import { findPathAStar } from "../world/pathfinding";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
|
||||
|
||||
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
||||
const actor = w.actors.get(actorId);
|
||||
if (!actor) return [];
|
||||
|
||||
@@ -12,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
||||
|
||||
switch (action.type) {
|
||||
case "move":
|
||||
events.push(...handleMove(w, actor, action));
|
||||
events.push(...handleMove(w, actor, action, em));
|
||||
break;
|
||||
case "attack":
|
||||
events.push(...handleAttack(w, actor, action));
|
||||
events.push(...handleAttack(w, actor, action, em));
|
||||
break;
|
||||
case "wait":
|
||||
default:
|
||||
@@ -31,7 +33,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
||||
return events;
|
||||
}
|
||||
|
||||
function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
||||
function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) {
|
||||
if (player.category !== "combatant") return;
|
||||
|
||||
const orbs = [...w.actors.values()].filter(a =>
|
||||
@@ -53,7 +55,8 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
|
||||
});
|
||||
|
||||
checkLevelUp(player, events);
|
||||
w.actors.delete(orb.id);
|
||||
if (em) em.removeActor(orb.id);
|
||||
else w.actors.delete(orb.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +89,23 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
||||
}
|
||||
|
||||
|
||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
|
||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): SimEvent[] {
|
||||
const from = { ...actor.pos };
|
||||
const nx = actor.pos.x + action.dx;
|
||||
const ny = actor.pos.y + action.dy;
|
||||
|
||||
if (!isBlocked(w, nx, ny)) {
|
||||
actor.pos.x = nx;
|
||||
actor.pos.y = ny;
|
||||
if (!isBlocked(w, nx, ny, em)) {
|
||||
if (em) {
|
||||
em.moveActor(actor.id, from, { x: nx, y: ny });
|
||||
} else {
|
||||
actor.pos.x = nx;
|
||||
actor.pos.y = ny;
|
||||
}
|
||||
const to = { ...actor.pos };
|
||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||
|
||||
if (actor.category === "combatant" && actor.isPlayer) {
|
||||
handleExpCollection(w, actor, events);
|
||||
handleExpCollection(w, actor, events, em);
|
||||
}
|
||||
|
||||
return events;
|
||||
@@ -108,7 +115,8 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }):
|
||||
}
|
||||
|
||||
|
||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
|
||||
|
||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] {
|
||||
const target = w.actors.get(action.targetId);
|
||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||
@@ -183,19 +191,25 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
|
||||
y: target.pos.y,
|
||||
victimType: target.type as ActorType
|
||||
});
|
||||
w.actors.delete(target.id);
|
||||
if (em) em.removeActor(target.id);
|
||||
else w.actors.delete(target.id);
|
||||
|
||||
// Spawn EXP Orb
|
||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||
const expAmount = enemyDef?.expValue || 0;
|
||||
const orbId = Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||
w.actors.set(orbId, {
|
||||
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||
|
||||
const orb: CollectibleActor = {
|
||||
id: orbId,
|
||||
category: "collectible",
|
||||
type: "exp_orb",
|
||||
pos: { ...target.pos },
|
||||
expAmount // Explicit member in CollectibleActor
|
||||
});
|
||||
expAmount
|
||||
};
|
||||
|
||||
if (em) em.addActor(orb);
|
||||
else w.actors.set(orbId, orb);
|
||||
|
||||
|
||||
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||
}
|
||||
@@ -210,7 +224,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
|
||||
* - if adjacent to player, attack
|
||||
* - else step toward player using greedy Manhattan
|
||||
*/
|
||||
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor): Action {
|
||||
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action {
|
||||
const dx = player.pos.x - enemy.pos.x;
|
||||
const dy = player.pos.y - enemy.pos.y;
|
||||
const dist = Math.abs(dx) + Math.abs(dy);
|
||||
@@ -219,7 +233,21 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
return { type: "attack", targetId: player.id };
|
||||
}
|
||||
|
||||
// Use A* for smarter pathfinding
|
||||
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
|
||||
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
||||
|
||||
|
||||
if (path.length >= 2) {
|
||||
const next = path[1];
|
||||
const adx = next.x - enemy.pos.x;
|
||||
const ady = next.y - enemy.pos.y;
|
||||
return { type: "move", dx: adx, dy: ady };
|
||||
}
|
||||
|
||||
// Fallback to greedy if no path found
|
||||
const options: { dx: number; dy: number }[] = [];
|
||||
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
||||
@@ -243,7 +271,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
* Energy/speed scheduler: runs until it's the player's turn and the game needs input.
|
||||
* Returns enemy events accumulated along the way.
|
||||
*/
|
||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||
const player = w.actors.get(playerId) as CombatantActor;
|
||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
||||
|
||||
@@ -269,8 +297,8 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
|
||||
return { awaitingPlayerId: actor.id, events };
|
||||
}
|
||||
|
||||
const action = decideEnemyAction(w, actor, player);
|
||||
events.push(...applyAction(w, actor.id, action));
|
||||
const action = decideEnemyAction(w, actor, player, em);
|
||||
events.push(...applyAction(w, actor.id, action, em));
|
||||
|
||||
// Check if player was killed by this action
|
||||
if (!w.actors.has(playerId)) {
|
||||
|
||||
@@ -214,54 +214,68 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||
|
||||
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
||||
const occupiedPositions = new Set<string>();
|
||||
|
||||
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
|
||||
for (let i = 0; i < numEnemies; i++) {
|
||||
// Pick a random room (not the starting room 0)
|
||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||
const room = rooms[roomIdx];
|
||||
|
||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||
|
||||
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
||||
const enemyDef = GAME_CONFIG.enemies[type];
|
||||
// Try to find an empty spot in the room
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
|
||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||
|
||||
actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
type,
|
||||
pos: { x: enemyX, y: enemyY },
|
||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||
energy: 0,
|
||||
stats: {
|
||||
maxHp: scaledHp + Math.floor(random() * 4),
|
||||
hp: scaledHp + Math.floor(random() * 4),
|
||||
attack: scaledAttack + Math.floor(random() * 2),
|
||||
defense: enemyDef.baseDefense,
|
||||
level: 0,
|
||||
exp: 0,
|
||||
expToNextLevel: 0,
|
||||
statPoints: 0,
|
||||
skillPoints: 0,
|
||||
strength: 0,
|
||||
dexterity: 0,
|
||||
intelligence: 0,
|
||||
critChance: 0,
|
||||
critMultiplier: 100,
|
||||
accuracy: 80,
|
||||
lifesteal: 0,
|
||||
evasion: 0,
|
||||
blockChance: 0,
|
||||
luck: 0,
|
||||
passiveNodes: []
|
||||
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||
const k = `${ex},${ey}`;
|
||||
|
||||
if (!occupiedPositions.has(k)) {
|
||||
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
||||
const enemyDef = GAME_CONFIG.enemies[type];
|
||||
|
||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||
|
||||
actors.set(enemyId, {
|
||||
id: enemyId,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
type,
|
||||
pos: { x: ex, y: ey },
|
||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||
energy: 0,
|
||||
stats: {
|
||||
maxHp: scaledHp + Math.floor(random() * 4),
|
||||
hp: scaledHp + Math.floor(random() * 4),
|
||||
attack: scaledAttack + Math.floor(random() * 2),
|
||||
defense: enemyDef.baseDefense,
|
||||
level: 0,
|
||||
exp: 0,
|
||||
expToNextLevel: 0,
|
||||
statPoints: 0,
|
||||
skillPoints: 0,
|
||||
strength: 0,
|
||||
dexterity: 0,
|
||||
intelligence: 0,
|
||||
critChance: 0,
|
||||
critMultiplier: 100,
|
||||
accuracy: 80,
|
||||
lifesteal: 0,
|
||||
evasion: 0,
|
||||
blockChance: 0,
|
||||
luck: 0,
|
||||
passiveNodes: []
|
||||
}
|
||||
});
|
||||
|
||||
occupiedPositions.add(k);
|
||||
enemyId++;
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
enemyId++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const makeTestWorld = generateWorld;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { World, Vec2 } from "../../core/types";
|
||||
import { key } from "../../core/utils";
|
||||
import { manhattan } from "../../core/math";
|
||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
|
||||
/**
|
||||
* Simple 4-dir A* pathfinding.
|
||||
@@ -11,14 +12,14 @@ import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||
* - You cannot path THROUGH unseen tiles.
|
||||
* - You cannot path TO an unseen target tile.
|
||||
*/
|
||||
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] {
|
||||
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] {
|
||||
if (!inBounds(w, end.x, end.y)) return [];
|
||||
if (isWall(w, end.x, end.y)) return [];
|
||||
|
||||
// If not ignoring target block, fail if blocked
|
||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return [];
|
||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
||||
|
||||
if (seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||
|
||||
const open: Vec2[] = [start];
|
||||
const cameFrom = new Map<string, string>();
|
||||
@@ -76,12 +77,12 @@ export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2
|
||||
if (!inBounds(w, nx, ny)) continue;
|
||||
if (isWall(w, nx, ny)) continue;
|
||||
|
||||
// Exploration rule: cannot path through unseen (except start)
|
||||
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
|
||||
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
|
||||
if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
|
||||
|
||||
// Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block)
|
||||
const isTarget = nx === end.x && ny === end.y;
|
||||
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue;
|
||||
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue;
|
||||
|
||||
const nK = key(nx, ny);
|
||||
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { World, EntityId } from "../../core/types";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
|
||||
export function inBounds(w: World, x: number, y: number): boolean {
|
||||
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
||||
}
|
||||
@@ -14,10 +16,14 @@ export function isWall(w: World, x: number, y: number): boolean {
|
||||
return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco;
|
||||
}
|
||||
|
||||
export function isBlocked(w: World, x: number, y: number): boolean {
|
||||
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
|
||||
if (!inBounds(w, x, y)) return true;
|
||||
if (isWall(w, x, y)) return true;
|
||||
|
||||
if (em) {
|
||||
return em.isOccupied(x, y, "exp_orb");
|
||||
}
|
||||
|
||||
for (const a of w.actors.values()) {
|
||||
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
|
||||
}
|
||||
@@ -25,6 +31,7 @@ export function isBlocked(w: World, x: number, y: number): boolean {
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||
const p = w.actors.get(playerId);
|
||||
if (!p) return false;
|
||||
|
||||
Reference in New Issue
Block a user