203 lines
8.6 KiB
JavaScript
203 lines
8.6 KiB
JavaScript
"use strict";
|
|
|
|
// Layer: commands
|
|
// UI calls this facade instead of mutating cross-cutting world state directly.
|
|
// Keep this file small: commands normalize user intent, then delegate game rules to World/system modules.
|
|
(function (global) {
|
|
const DEFAULT_SPEEDS = Object.freeze([1, 2, 4]);
|
|
const DEFAULT_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
|
|
|
|
function emit(worldRef, type, payload) {
|
|
worldRef?.emit?.(type, payload);
|
|
return payload;
|
|
}
|
|
|
|
function ok(payload = {}) {
|
|
return { ok: true, ...payload };
|
|
}
|
|
|
|
function fail(reason, payload = {}) {
|
|
return { ok: false, reason, ...payload };
|
|
}
|
|
|
|
function selectTool(worldRef, toolId) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const id = String(toolId || "observe");
|
|
if (worldRef.tool === id) return ok({ toolId: id, changed: false });
|
|
worldRef.tool = id;
|
|
emit(worldRef, "tool:selected", { toolId: id });
|
|
return ok({ toolId: id, changed: true });
|
|
}
|
|
|
|
function setSelection(worldRef, target) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const next = target || null;
|
|
if (worldRef.selected === next) return ok({ selected: next, changed: false });
|
|
worldRef.selected = next;
|
|
emit(worldRef, "selection:changed", { selected: worldRef.selected });
|
|
return ok({ selected: worldRef.selected, changed: true });
|
|
}
|
|
|
|
function clearSelection(worldRef) {
|
|
return setSelection(worldRef, null);
|
|
}
|
|
|
|
function focusSelection(worldRef, target, options = {}) {
|
|
if (!worldRef) return fail("missing_world");
|
|
if (!target || target.dead) return fail("missing_target");
|
|
const result = setSelection(worldRef, target);
|
|
target.focusPulseTimer = Math.max(target.focusPulseTimer || 0, Number(options.pulse ?? 1.15) || 1.15);
|
|
if (options.selectObserve !== false) selectTool(worldRef, "observe");
|
|
if (options.center !== false && Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
|
const visibleW = worldRef.visibleWorldW ? worldRef.visibleWorldW() : (worldRef.viewportW || worldRef.w || 0);
|
|
const visibleH = worldRef.visibleWorldH ? worldRef.visibleWorldH() : (worldRef.viewportH || worldRef.h || 0);
|
|
worldRef.cameraX = target.x - visibleW / 2;
|
|
worldRef.cameraY = target.y - visibleH / 2;
|
|
worldRef.clampCamera?.();
|
|
}
|
|
return ok({ selected: result.selected || target, target });
|
|
}
|
|
|
|
function toggleSelectedFavorite(worldRef, target = worldRef?.selected || null) {
|
|
if (!worldRef) return fail("missing_world");
|
|
if (!target || target.dead) return fail("missing_target");
|
|
target.favorite = !target.favorite;
|
|
emit(worldRef, "selection:favorite-changed", { selected: target, favorite: Boolean(target.favorite) });
|
|
return ok({ selected: target, favorite: Boolean(target.favorite) });
|
|
}
|
|
|
|
function scalableToolSet() {
|
|
if (typeof global.scalableToolIds === "function") return new Set(global.scalableToolIds());
|
|
return new Set();
|
|
}
|
|
|
|
function setToolSize(worldRef, tool, size) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const id = String(tool || worldRef.tool || "");
|
|
const next = String(size || "medium");
|
|
if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id });
|
|
if (!worldRef.toolSizes) worldRef.toolSizes = {};
|
|
worldRef.toolSizes[id] = next;
|
|
worldRef.toolSize = next;
|
|
emit(worldRef, "tool:size-changed", { toolId: id, size: next });
|
|
return ok({ toolId: id, size: next });
|
|
}
|
|
|
|
function cycleToolSize(worldRef, tool, order = DEFAULT_SIZE_ORDER) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const id = String(tool || worldRef.tool || "");
|
|
if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id });
|
|
const sizes = Array.isArray(order) && order.length ? order.map(String) : Array.from(DEFAULT_SIZE_ORDER);
|
|
const current = typeof global.normalizedToolSizeFor === "function"
|
|
? global.normalizedToolSizeFor(worldRef, id)
|
|
: ((worldRef.toolSizes && worldRef.toolSizes[id]) || worldRef.toolSize || "medium");
|
|
const idx = sizes.indexOf(current);
|
|
const next = sizes[(idx + 1 + sizes.length) % sizes.length];
|
|
return setToolSize(worldRef, id, next);
|
|
}
|
|
|
|
function togglePaused(worldRef) {
|
|
if (!worldRef) return fail("missing_world");
|
|
worldRef.paused = !worldRef.paused;
|
|
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
|
|
return ok({ paused: Boolean(worldRef.paused) });
|
|
}
|
|
|
|
function setPaused(worldRef, paused) {
|
|
if (!worldRef) return fail("missing_world");
|
|
worldRef.paused = Boolean(paused);
|
|
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
|
|
return ok({ paused: Boolean(worldRef.paused) });
|
|
}
|
|
|
|
function cycleSpeed(worldRef, speeds = DEFAULT_SPEEDS) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const list = Array.isArray(speeds) && speeds.length ? speeds.map(Number).filter(Number.isFinite) : Array.from(DEFAULT_SPEEDS);
|
|
const i = list.indexOf(Number(worldRef.speed) || 1);
|
|
worldRef.speed = list[(i + 1 + list.length) % list.length];
|
|
emit(worldRef, "simulation:speed-changed", { speed: worldRef.speed });
|
|
return ok({ speed: worldRef.speed });
|
|
}
|
|
|
|
function primaryPointer(worldRef, x, y) {
|
|
if (!worldRef) return fail("missing_world");
|
|
const px = Number(x);
|
|
const py = Number(y);
|
|
if (!Number.isFinite(px) || !Number.isFinite(py)) return fail("invalid_point", { x, y });
|
|
if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler");
|
|
if (worldRef.tool && worldRef.tool !== "observe") global.TarinaiHistory?.capture?.(worldRef, `tool:${worldRef.tool}`);
|
|
worldRef.handleClick(px, py);
|
|
emit(worldRef, "pointer:primary", { x: px, y: py, toolId: worldRef.tool || "" });
|
|
return ok({ x: px, y: py, toolId: worldRef.tool || "" });
|
|
}
|
|
|
|
function dispatch(worldRef, command = {}) {
|
|
switch (command.type) {
|
|
case "tool.select":
|
|
return selectTool(worldRef, command.toolId);
|
|
case "tool.size.set":
|
|
return setToolSize(worldRef, command.toolId, command.size);
|
|
case "tool.size.cycle":
|
|
return cycleToolSize(worldRef, command.toolId, command.order);
|
|
case "selection.set":
|
|
return setSelection(worldRef, command.target || null);
|
|
case "selection.clear":
|
|
return clearSelection(worldRef);
|
|
case "selection.focus":
|
|
return focusSelection(worldRef, command.target || null, command.options || {});
|
|
case "selection.favorite.toggle":
|
|
return toggleSelectedFavorite(worldRef, command.target || worldRef?.selected || null);
|
|
case "simulation.paused.set":
|
|
return setPaused(worldRef, command.paused);
|
|
case "simulation.paused.toggle":
|
|
return togglePaused(worldRef);
|
|
case "simulation.speed.cycle":
|
|
return cycleSpeed(worldRef, command.speeds);
|
|
case "pointer.primary":
|
|
return primaryPointer(worldRef, command.x, command.y);
|
|
case "history.undo": {
|
|
const result = global.TarinaiHistory?.undo?.(worldRef) || { ok: false, reason: "missing_history" };
|
|
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
|
|
}
|
|
case "history.redo": {
|
|
const result = global.TarinaiHistory?.redo?.(worldRef) || { ok: false, reason: "missing_history" };
|
|
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
|
|
}
|
|
default:
|
|
return fail("unknown_command", { type: command.type || "" });
|
|
}
|
|
}
|
|
|
|
|
|
function installWorldFacade() {
|
|
const World = global.World;
|
|
if (!World?.prototype || World.prototype.dispatchCommand) return;
|
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
|
dispatchCommand(command = {}) { return dispatch(this, command); },
|
|
selectToolCommand(toolId) { return selectTool(this, toolId); },
|
|
setSelectionCommand(target) { return setSelection(this, target); },
|
|
clearSelectionCommand() { return clearSelection(this); },
|
|
focusSelectionCommand(target, options = {}) { return focusSelection(this, target, options); },
|
|
primaryPointerCommand(x, y) { return primaryPointer(this, x, y); },
|
|
}));
|
|
}
|
|
|
|
installWorldFacade();
|
|
|
|
global.TarinaiCommands = Object.freeze({
|
|
dispatch,
|
|
selectTool,
|
|
setSelection,
|
|
clearSelection,
|
|
focusSelection,
|
|
toggleSelectedFavorite,
|
|
setToolSize,
|
|
cycleToolSize,
|
|
setPaused,
|
|
togglePaused,
|
|
cycleSpeed,
|
|
primaryPointer,
|
|
undo(worldRef = global.world) { return global.TarinaiHistory?.undo?.(worldRef) || fail("missing_history"); },
|
|
redo(worldRef = global.world) { return global.TarinaiHistory?.redo?.(worldRef) || fail("missing_history"); },
|
|
});
|
|
})(typeof window !== "undefined" ? window : globalThis);
|