mirror of
https://github.com/migatu/vtt_work.git
synced 2026-07-14 13:34:42 +00:00
402 lines
15 KiB
JavaScript
402 lines
15 KiB
JavaScript
// W&G Voidships Builder — Voidship Actor Sheet (vehicle+flag emulation)
|
||
// v1.1.0 — Foundry V11–V13 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);
|
||
return String(allowed).split(/[|,; ]+/g).map(normalizeSlotName).filter(Boolean);
|
||
}
|
||
function getVSH(it) {
|
||
// canonical: flags; fallback: legacy system.voidship (skopiujemy do flags przy dropie)
|
||
return it.getFlag(VB_MOD, "voidship") || it.system?.voidship || null;
|
||
}
|
||
function countUsedWeaponSlots(actor) {
|
||
const used = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 };
|
||
for (const it of actor.items ?? []) {
|
||
const v = getVSH(it);
|
||
const eq = (it.system?.equipped ?? true);
|
||
const slot = normalizeSlotName(v?.slot);
|
||
if (eq && slot && (slot in used)) used[slot] += 1;
|
||
}
|
||
return used;
|
||
}
|
||
function pickFirstFreeSlot(allowed, hullSlots, used) {
|
||
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 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 z itemów (flags canonical) ---
|
||
const items = actor.items.contents || [];
|
||
const components = [];
|
||
const slotsUsed = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 };
|
||
const mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 };
|
||
const useTotal = { space: 0, power: 0 };
|
||
|
||
for (const it of items) {
|
||
const v = getVSH(it);
|
||
if (!v) continue;
|
||
const c = foundry.utils.duplicate(v);
|
||
c.name = it.name;
|
||
c.itemId = it.id;
|
||
c.equipped = (it.system?.equipped ?? true);
|
||
c.slot = normalizeSlotName(c.slot) || "Internal";
|
||
c.allowedSlots = normalizeAllowedSlots(c.allowedSlots || foundry.utils.getProperty(it.flags, `${VB_MOD}.component.allowedSlots`));
|
||
|
||
// defaults
|
||
c.use = { space: 0, power: 0, ...(c.use || {}) };
|
||
c.mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0, ...(c.mods || {}) };
|
||
|
||
components.push(c);
|
||
|
||
if (c.equipped) {
|
||
// sumuj mody
|
||
for (const k of Object.keys(mods)) {
|
||
const val = Number(c.mods[k] || 0);
|
||
if (Number.isFinite(val)) mods[k] += val;
|
||
}
|
||
// sumuj zużycie/produkcję
|
||
const us = Number(c.use.space || 0);
|
||
const up = Number(c.use.power || 0);
|
||
if (Number.isFinite(us)) useTotal.space += us;
|
||
if (Number.isFinite(up)) useTotal.power += up;
|
||
|
||
// slot usage (tylko jeśli to broń z legalnym slotem)
|
||
if (VB_WEAPON_SLOTS.includes(c.slot)) {
|
||
slotsUsed[c.slot] = (slotsUsed[c.slot] || 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
const base = actor.getFlag(VB_MOD, "base") || { armour:0, integrity:0, manoeuvre:0, detection:0, speed:0, space:0, power:0 };
|
||
|
||
// total = base + mods
|
||
const totals = {
|
||
armour: (Number(base.armour)||0) + mods.armour,
|
||
integrity: (Number(base.integrity)||0) + mods.integrity,
|
||
manoeuvre: (Number(base.manoeuvre)||0) + mods.manoeuvre,
|
||
detection: (Number(base.detection)||0) + mods.detection,
|
||
speed: (Number(base.speed)||0) + mods.speed,
|
||
space: (Number(base.space)||0) + mods.space,
|
||
power: (Number(base.power)||0) + mods.power
|
||
};
|
||
totals.spaceFree = totals.space - useTotal.space;
|
||
totals.powerFree = totals.power - useTotal.power;
|
||
|
||
// warnings
|
||
const hull = actor.getFlag(VB_MOD, "hull") || { slots: {} };
|
||
const warnings = {};
|
||
for (const s of VB_WEAPON_SLOTS) {
|
||
if ((slotsUsed[s]||0) > (hull.slots?.[s]||0)) { warnings.slots = "One or more weapon slot groups exceed hull limits."; break; }
|
||
}
|
||
if (totals.spaceFree < 0) warnings.capacity = "Not enough Space for installed components.";
|
||
if (totals.powerFree < 0) warnings.capacity = "Not enough Power for installed components.";
|
||
|
||
// zaszyj
|
||
data.hull = hull;
|
||
data.base = base;
|
||
data.components = components;
|
||
data.slotsUsed = slotsUsed;
|
||
data.totals = totals;
|
||
data.mods = mods;
|
||
data.useTotal = useTotal;
|
||
|
||
// extra debug
|
||
data.debug = {
|
||
usedWeaponSlots: slotsUsed,
|
||
itemsCount: items.length,
|
||
componentsCount: components.length,
|
||
itemsNoVSH: items.filter(i => !getVSH(i)).map(i => ({id:i.id, name:i.name}))
|
||
};
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
async _onDropItem(event, data) {
|
||
event.preventDefault();
|
||
|
||
// 1) Rozwiąż źródło (uuid/pack/data)
|
||
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) {
|
||
doc = await Item.implementation.fromDropData(data);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
const raw = doc?.toObject?.(false) ?? data?.data ?? data;
|
||
if (!raw?.type) { ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); return false; }
|
||
|
||
// 2) Czy to komponent?
|
||
const flagsIn = foundry.utils.getProperty(raw, `flags.${VB_MOD}`) || {};
|
||
const legacySys = foundry.utils.getProperty(raw, "system.voidship") || null;
|
||
const hasCompTag = !!flagsIn.component;
|
||
const hasLegacy = !!legacySys;
|
||
if (!hasCompTag && !hasLegacy) {
|
||
ui.notifications.warn("This item is not a voidship component.");
|
||
return false;
|
||
}
|
||
|
||
// 3) Skonstruuj flags.${VB_MOD}.voidship (kanoniczne)
|
||
const vshIn = flagsIn.voidship || legacySys || {};
|
||
const flagsOut = foundry.utils.duplicate(flagsIn);
|
||
flagsOut.voidship = foundry.utils.mergeObject({
|
||
slot: vshIn.slot || null,
|
||
use: { space: 0, power: 0, ...(vshIn.use || {}) },
|
||
mods: { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0, ...(vshIn.mods || {}) },
|
||
allowedSlots: normalizeAllowedSlots(vshIn.allowedSlots || flagsIn?.component?.allowedSlots || [])
|
||
}, {}, { inplace: false });
|
||
|
||
// 4) Auto-przydział slotu dla broni (jeśli allowedSlots != pusty)
|
||
const allowed = flagsOut.voidship.allowedSlots || [];
|
||
if (allowed.length > 0) {
|
||
const hull = this.actor.getFlag(VB_MOD, "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;
|
||
}
|
||
flagsOut.voidship.slot = chosen;
|
||
} else {
|
||
// nie-broń
|
||
flagsOut.voidship.slot = flagsOut.voidship.slot || "Internal";
|
||
}
|
||
|
||
// 5) wpisz flags i domyślne "equipped"
|
||
raw.flags = raw.flags || {};
|
||
raw.flags[VB_MOD] = flagsOut;
|
||
if (raw.system?.equipped === undefined) {
|
||
raw.system = raw.system || {};
|
||
raw.system.equipped = true;
|
||
}
|
||
|
||
// 6) Utwórz na aktorze
|
||
await this.actor.createEmbeddedDocuments("Item", [raw]);
|
||
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`);
|
||
});
|