Files
vtt_work/wg-voidships-builder/scripts/voidship-actor.js
T

443 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// W&G Voidships Builder — Voidship Actor Sheet (vehicle+flag emulation)
// v1.1.0 — Foundry V11V13 safe
/* eslint-env es2021 */
const VB_MOD = "wg-voidships-builder";
const VB_DEBUG = true; // Debug on
function dbg(...args) { if (VB_DEBUG) console.debug("[VB]", ...args); }
// --- SLOT HELPERS: normalizacja i przydział "pierwszy wolny pasujący" ---
const VB_WEAPON_SLOTS = ["prow", "dorsal", "port", "starboard", "keel"];
function normalizeSlotName(s) {
const v = String(s || "").trim().toLowerCase();
return VB_WEAPON_SLOTS.includes(v) ? v : null;
}
function normalizeAllowedSlots(allowed) {
if (!allowed) return [];
if (Array.isArray(allowed)) return allowed.map(normalizeSlotName).filter(Boolean);
// string "prow|dorsal|port" albo "prow,dorsal"
return String(allowed)
.split(/[|,; ]+/g)
.map(normalizeSlotName)
.filter(Boolean);
}
function countUsedWeaponSlots(actor) {
const used = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 };
for (const it of actor.items ?? []) {
const vs = it.system?.voidship;
if (!vs) continue;
const slot = normalizeSlotName(vs.slot);
const eq = (it.system?.equipped ?? true);
if (eq && slot && (slot in used)) used[slot] += 1;
}
return used;
}
function pickFirstFreeSlot(allowed, hullSlots, used) {
// Kolejność zgodnie z wymaganiem: "pierwszy wolny pasujący"
const order = ["prow", "dorsal", "port", "starboard", "keel"];
for (const s of order) {
if (!allowed.includes(s)) continue;
const cap = Number(hullSlots?.[s] ?? 0);
const u = Number(used?.[s] ?? 0);
if (u < cap) return s;
}
return null;
}
// ---------------- Handlebars helpers ----------------
Hooks.once("init", () => {
if (!Handlebars.helpers.signed) {
Handlebars.registerHelper("signed", (v) => {
const n = Number(v || 0);
if (!Number.isFinite(n) || n === 0) return "±0";
return (n > 0 ? "+" : "") + Math.abs(n);
});
}
if (!Handlebars.helpers.eq) {
Handlebars.registerHelper("eq", (a, b) => a === b);
}
if (!Handlebars.helpers.json) {
Handlebars.registerHelper("json", (ctx) => {
try { return JSON.stringify(ctx, null, 2); }
catch { return String(ctx); }
});
}
});
class VoidshipSheet extends ActorSheet {
static get defaultOptions() {
const opts = super.defaultOptions;
return foundry.utils.mergeObject(opts, {
classes: ["wg-voidships", "sheet", "actor", "voidship", "themed", "theme-light"],
template: `modules/${VB_MOD}/templates/voidship-sheet.hbs`,
width: 720,
height: 680,
tabs: [{ navSelector: ".tabs", contentSelector: ".sheet-body", initial: "wg" }],
dragDrop: [{ dragSelector: ".voidship-sheet", dropSelector: ".voidship-sheet" }]
});
}
/** Allow viewing for everyone (adjust if you need perms) */
_canUserView(user) { return !!user; }
get isVoidship() { return this.actor?.getFlag(VB_MOD, "isVoidship") === true; }
// ---- math helpers ----
_sumModsFromItems(items) {
const mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 };
let useSpace = 0, usePower = 0;
const slotsUsed = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 };
for (const it of items) {
const vs = it.system?.voidship;
if (!vs) continue;
const equipped = (it.system?.equipped ?? true);
const slotKey = String(vs.slot || "").toLowerCase();
if (equipped && ["prow", "dorsal", "port", "starboard", "keel"].includes(slotKey)) {
slotsUsed[slotKey] = (slotsUsed[slotKey] || 0) + 1;
}
// capacity usage (always counts, bo to realne zużycie jeśli wolisz tylko equipped, to obejmij warunkiem)
const use = vs.use || {};
if (Number.isFinite(Number(use.space))) useSpace += Number(use.space);
if (Number.isFinite(Number(use.power))) usePower += Number(use.power);
// mods liczą się tylko jeśli equipped
if (!equipped) continue;
const m = vs.mods || {};
for (const k of Object.keys(mods)) {
const val = Number(m[k]);
if (Number.isFinite(val)) mods[k] += val;
}
}
return { mods, use: { space: useSpace, power: usePower }, slotsUsed };
}
// ---- data aggregation for template ----
async getData(options) {
const data = await super.getData(options);
const actor = this.actor;
const VB_MOD = "wg-voidships-builder";
// Flagowe źródła (przeżywają migracje W&G)
// czytaj flagi wprost z actor.flags[namespace] (albo pojedyncze klucze)
const flagsNs = actor.flags?.[VB_MOD] || {};
const defaultHull = { class: actor.name, size: "Cruiser", slots: { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 } };
const defaultBase = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 };
const defaultCrew = { command: 0, pilot: 0, sensors: 0, engineering: 0, gunnery: 0, security: 0, flightDeck: 0, passengers: 0 };
const hull = actor.getFlag(VB_MOD, "hull") ?? flagsNs.hull ?? defaultHull;
const base = actor.getFlag(VB_MOD, "base") ?? flagsNs.base ?? defaultBase;
const crew = actor.getFlag(VB_MOD, "crew") ?? flagsNs.crew ?? defaultCrew;
const rtRaw = actor.getFlag(VB_MOD, "rtRaw") ?? flagsNs.rtRaw ?? {};
const rt = rtRaw.rt || {};
const rtCard = {
speed: rt.speed ?? rtRaw.combat?.speed ?? 0,
manoeuvrability: rt.manoeuvrability ?? rtRaw.mnvr ?? 0,
detection: rt.detection ?? 0,
hullIntegrity: rt.hullIntegrity ?? rtRaw.combat?.wounds?.max ?? 0,
armour: rt.armour ?? 0,
space: rt.space ?? 0,
power: rt.power ?? 0,
weapons: {
prow: rt.weapons?.prow ?? 0,
dorsal: rt.weapons?.dorsal ?? 0,
port: rt.weapons?.port ?? 0,
starboard: rt.weapons?.starboard ?? 0,
keel: rt.weapons?.keel ?? 0
},
turret: rt.turret ?? 0,
notes: rtRaw.notes ?? "",
source: rt.source ?? rtRaw.source ?? ""
};
// ===== KOMPONENTY: czytamy Z FLAG =====
const items = actor.items.contents;
const inferSlotFromTags = (tags=[]) => {
const low = (Array.isArray(tags)?tags:[]).map(t => String(t).toLowerCase());
const pref = ["prow","dorsal","port","starboard","keel","aetherics","internal"];
const hit = pref.find(p => low.includes(p));
return hit ? (hit[0].toUpperCase()+hit.slice(1)) : "Internal";
};
const components = [];
for (const i of items) {
const fmod = i.flags?.[VB_MOD] || {};
const vflag = fmod.voidship || null; // <-- TU trzymamy liczby
const meta = fmod.component || {}; // kind/subtype/tags
// Dopuszczamy też stare itemy z system.voidship (jeśli coś przetrwa)
const vsys = i.system?.voidship || null;
// komponent, jeśli ma vflag LUB meta.tags LUB vsys
const isComponent = !!(vflag || vsys || (Array.isArray(meta.tags) && meta.tags.length));
if (!isComponent) continue;
const vf = vflag || vsys || {};
const use = {
space: Number(vf?.use?.space ?? 0),
power: Number(vf?.use?.power ?? 0)
};
const mods = {
armour: Number(vf?.mods?.armour ?? 0),
integrity: Number(vf?.mods?.integrity ?? 0),
manoeuvre: Number(vf?.mods?.manoeuvre ?? 0),
detection: Number(vf?.mods?.detection ?? 0),
speed: Number(vf?.mods?.speed ?? 0),
space: Number(vf?.mods?.space ?? 0),
power: Number(vf?.mods?.power ?? 0)
};
let slot = vf.slot || meta.slot || inferSlotFromTags(meta.tags);
const equipped = (i.system?.equipped ?? true) && (vf.disabled !== true);
components.push({
itemId: i.id,
name: i.name,
slot,
equipped,
use, mods,
tags: meta.tags || []
});
}
// Zliczanie slotów (tylko equipped)
const slotsUsed = components.reduce((acc, c) => {
if (!c.equipped) return acc;
const key = String(c.slot || "Internal").toLowerCase();
if (["prow","dorsal","port","starboard","keel"].includes(key)) acc[key] = (acc[key] || 0) + 1;
return acc;
}, { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 });
// Capacity: bazowe + mody z komponentów (equipped) vs użycie (equipped)
const modsSum = components.reduce((acc, c) => {
if (!c.equipped) return acc;
for (const k of Object.keys(acc)) acc[k] += Number(c.mods[k] || 0);
return acc;
}, { armour:0, integrity:0, manoeuvre:0, detection:0, speed:0, space:0, power:0 });
const useSum = components.reduce((acc, c) => {
if (!c.equipped) return acc;
acc.space += Number(c.use.space || 0);
acc.power += Number(c.use.power || 0);
return acc;
}, { space: 0, power: 0 });
const totals = {
armour: (Number(base.armour)||0) + modsSum.armour,
integrity: (Number(base.integrity)||0) + modsSum.integrity,
manoeuvre: (Number(base.manoeuvre)||0) + modsSum.manoeuvre,
detection: (Number(base.detection)||0) + modsSum.detection,
speed: (Number(base.speed)||0) + modsSum.speed,
space: (Number(base.space)||0) + modsSum.space,
power: (Number(base.power)||0) + modsSum.power
};
totals.spaceFree = totals.space - useSum.space;
totals.powerFree = totals.power - useSum.power;
// Ostrzeżenia
const warnings = {};
const overSlots =
(slotsUsed.prow > (hull.slots?.prow || 0)) ||
(slotsUsed.dorsal > (hull.slots?.dorsal || 0)) ||
(slotsUsed.port > (hull.slots?.port || 0)) ||
(slotsUsed.starboard > (hull.slots?.starboard || 0)) ||
(slotsUsed.keel > (hull.slots?.keel || 0));
if (overSlots) warnings.slots = "One or more weapon slot groups exceed hull limits.";
if (totals.spaceFree < 0 || totals.powerFree < 0)
warnings.capacity = "Not enough Space/Power for installed components.";
// wstrzyknij do template
data.hull = hull;
data.base = base;
data.crew = crew;
data.rtCard = rtCard;
data.components = components;
data.slotsUsed = slotsUsed;
data.totals = totals;
data.mods = modsSum; // ← DODANE: żeby HBS miał liczby modów
data.warnings = warnings;
dbg("sheet.getData", { base, hull, totals, slotsUsed, componentsCount: components.length });
return data;
}
// ---- listeners & UI ----
activateListeners(html) {
super.activateListeners(html);
html.find("[data-action='mark-voidship']").on("click", async () => {
await this.actor.setFlag(VB_MOD, "isVoidship", true);
ui.notifications.info("Marked as Voidship.");
this.render(true);
});
html.find("[data-action='unmark-voidship']").on("click", async () => {
await this.actor.unsetFlag(VB_MOD, "isVoidship");
ui.notifications.info("Unmarked Voidship.");
this.render(true);
});
html.find("[data-action='configure-base']").on("click", async () => {
const cur = this.actor.getFlag(VB_MOD, "base") || {};
const content = `
<form>
<div class="form-group"><label>Armour</label><input type="number" name="armour" value="${Number(cur.armour || 0)}"/></div>
<div class="form-group"><label>Integrity</label><input type="number" name="integrity" value="${Number(cur.integrity || 0)}"/></div>
<div class="form-group"><label>Manoeuvre</label><input type="number" name="manoeuvre" value="${Number(cur.manoeuvre || 0)}"/></div>
<div class="form-group"><label>Detection</label><input type="number" name="detection" value="${Number(cur.detection || 0)}"/></div>
<div class="form-group"><label>Speed</label><input type="number" name="speed" value="${Number(cur.speed || 0)}"/></div>
<div class="form-group"><label>Space</label><input type="number" name="space" value="${Number(cur.space || 0)}"/></div>
<div class="form-group"><label>Power</label><input type="number" name="power" value="${Number(cur.power || 0)}"/></div>
</form>`;
new Dialog({
title: "Voidship — Base Stats",
content, buttons: {
ok: {
label: "Save",
callback: async (dlg) => {
const form = dlg[0].querySelector("form");
const num = (n) => Number(form.querySelector(`[name="${n}"]`)?.value || 0);
await this.actor.setFlag(VB_MOD, "base", {
armour: num("armour"), integrity: num("integrity"),
manoeuvre: num("manoeuvre"), detection: num("detection"),
speed: num("speed"), space: num("space"), power: num("power")
});
this.render(true);
}
},
cancel: { label: "Cancel" }
}, default: "ok"
}, { jQuery: true }).render(true);
});
// Components controls
html.find("[data-action='comp-toggle']").on("click", async (ev) => {
const row = ev.currentTarget.closest("tr");
const id = row?.dataset?.itemId;
const item = this.actor.items.get(id);
if (!item) return;
const equipped = !(item.system?.equipped ?? true);
await item.update({ "system.equipped": equipped });
this.render(true);
});
html.find("[data-action='comp-edit']").on("click", async (ev) => {
const row = ev.currentTarget.closest("tr");
const id = row?.dataset?.itemId;
const item = this.actor.items.get(id);
if (item) item.sheet?.render(true);
});
html.find("[data-action='comp-remove']").on("click", async (ev) => {
const row = ev.currentTarget.closest("tr");
const id = row?.dataset?.itemId;
if (!id) return;
await this.actor.deleteEmbeddedDocuments("Item", [id]);
this.render(true);
});
}
// --- robust drop handlers: flag-only storage (survives W&G) ---
async _onDrop(event) {
event.preventDefault();
let raw = {};
try { raw = JSON.parse(event.dataTransfer?.getData("text/plain") || "{}"); }
catch { ui.notifications.error("Drop failed: cannot parse drop payload."); return; }
if (raw?.type === "Item") return this._onDropItem(event, raw);
dbg("drop.ignored", raw);
}
/** Drop itemów na arkusz: tylko komponenty voidship.
* - Jeżeli item ma allowedSlots (broń): przydziel "pierwszy wolny pasujący" i zapisz do system.voidship.slot
* - Jeżeli nie ma allowedSlots: zachowaj istniejący system.voidship.slot (albo slot "Internal" dla nie-broni)
*/
async _onDropItem(event, data) {
event.preventDefault();
// 1) Rozwiąż źródło dropu (UUID/pack/data) możliwie bezpiecznie
let doc = null;
try {
if (data?.uuid) doc = await fromUuid(data.uuid);
else if (data?.pack && data?._id) {
const pack = game.packs.get(data.pack);
if (pack) doc = await pack.getDocument(data._id);
} else if (data?.data || data?.type) {
// Foundry potrafi zrzucać raw "data"
doc = await Item.implementation.fromDropData(data);
}
} catch (e) { /* fall-through */ }
// 2) Wyprowadź "raw" do stworzenia osadzonego itemu
const raw = doc?.toObject?.(false) ?? data?.data ?? data;
if (!raw?.system) {
ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA).");
return false;
}
// 3) Sprawdź, czy to komponent voidship (wymagamy system.voidship)
const isComponent = !!raw.system?.voidship || !!raw.flags?.["wg-voidships-builder"]?.component;
if (!isComponent) {
ui.notifications.warn("This item is not a voidship component.");
return false;
}
// 4) Upewnij się, że istnieje sekcja voidship
raw.system.voidship = raw.system.voidship || {};
const vsh = raw.system.voidship;
// 5) Wyciągnij allowedSlots (preferuj z system.voidship.allowedSlots; ewentualnie z flags.component.allowedSlots)
const fromFlags = foundry.utils.getProperty(raw, "flags.wg-voidships-builder.component.allowedSlots");
const allowed = normalizeAllowedSlots(vsh.allowedSlots ?? fromFlags);
// 6) Jeżeli to broń (ma ograniczenia slotów), wybierz pierwszy wolny pasujący i wpisz do vsh.slot.
if (allowed.length > 0) {
const hull = this.actor.getFlag("wg-voidships-builder", "hull") || {};
const hullSlots = hull.slots || {};
const used = countUsedWeaponSlots(this.actor);
const chosen = pickFirstFreeSlot(allowed, hullSlots, used);
if (!chosen) {
ui.notifications.warn("No free matching weapon slot for this component.");
return false;
}
vsh.slot = chosen; // przypisz ostateczny slot montażu
} else {
// Nie-broń: zostaw istniejący slot albo nadaj "Internal"
vsh.slot = vsh.slot || "Internal";
}
// 7) Domyślnie traktuj jako "equipped" (zamontowane), jeśli schema na to pozwala
if (raw.system.equipped === undefined) raw.system.equipped = true;
// 8) Utwórz item na aktorze
await this.actor.createEmbeddedDocuments("Item", [raw]);
// 9) Przelicz i odśwież
this.render(true);
return true;
}
}
// Register the sheet
Hooks.once("init", () => {
Actors.registerSheet(VB_MOD, VoidshipSheet, {
types: ["vehicle"],
makeDefault: false,
label: "Voidship (W&G)"
});
game.wgVoidships = game.wgVoidships || {};
game.wgVoidships.createVoidship = async function (name = "New Voidship") {
const actor = await Actor.create({ name, type: "vehicle", folder: null });
await actor.setFlag(VB_MOD, "isVoidship", true);
ui.notifications.info(`Voidship created: ${name}`);
return actor;
};
console.log(`${VB_MOD}: VoidshipSheet registered`);
});