This commit is contained in:
2025-09-18 18:32:48 +02:00
parent ddae4ad4b0
commit 1abe31b3c8
7 changed files with 700 additions and 293 deletions
+335 -80
View File
@@ -1,13 +1,15 @@
// W&G Voidships Builder — Voidship Actor Sheet (vehicle+flag emulation)
// v1.0.0 (safe MVP) — works on Foundry v11v13
// v1.0.1 — works on Foundry v11v13
/* eslint-env es2021 */
const VB_MOD = "wg-voidships-builder";
const VB_DEBUG = true; // debug; później zrobimy game.settings
function dbg(...args) { if (VB_DEBUG) console.debug("[VB]", ...args); }
class VoidshipSheet extends ActorSheet {
static get defaultOptions() {
const opts = super.defaultOptions;
return foundry.utils.mergeObject(opts, {
classes: ["wg-voidships", "sheet", "actor", "voidship"],
classes: ["wg-voidships", "sheet", "actor", "voidship", "themed", "theme-light"],
template: `modules/${VB_MOD}/templates/voidship-sheet.hbs`,
width: 720,
height: 680,
@@ -15,32 +17,27 @@ class VoidshipSheet extends ActorSheet {
});
}
/** Prefer to use this sheet only for vehicles marked flagiem voidship */
_canUserView(user) {
return !!user;
}
get isVoidship() {
return this.actor?.getFlag(VB_MOD, "isVoidship") === true;
}
_canUserView(user) { return !!user; }
get isVoidship() { return this.actor?.getFlag(VB_MOD, "isVoidship") === true; }
/** Aggregate base + mods from items into derived summary (safe, optional) */
/** Prostszą agregację zostawiamy do walidacji ręcznej etc. */
_computeDerived() {
const a = this.actor;
const b = a.getFlag(VB_MOD, "base") || {};
const base = {
armour: Number(b.armour) || 0,
integrity: Number(b.integrity) || 0,
manoeuvre: Number(b.manoeuvre) || 0,
detection: Number(b.detection) || 0,
speed: Number(b.speed) || 0,
space: Number(b.space) || 0,
power: Number(b.power) || 0
};
const b = a.getFlag(VB_MOD, "base") || {};
const base = {
armour: Number(b.armour) || 0,
integrity: Number(b.integrity) || 0,
manoeuvre: Number(b.manoeuvre) || 0,
detection: Number(b.detection) || 0,
speed: Number(b.speed) || 0,
space: Number(b.space) || 0,
power: Number(b.power) || 0
};
const mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 };
const slots = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0, other: 0 };
const items = a?.items ?? [];
for (const it of items) {
const eq = (it.system?.equipped ?? true); // treat as mounted unless explicitly off
const eq = (it.system?.equipped ?? true);
const v = it.system?.voidship ?? {};
const m = v.mods ?? {};
if (eq) {
@@ -50,7 +47,7 @@ const base = {
}
}
const slot = String((v.slot || it.getFlag(VB_MOD, "slot") || "")).toLowerCase();
if (slot && slots.hasOwnProperty(slot)) slots[slot]++; else slots.other++;
if (slot && Object.prototype.hasOwnProperty.call(slots, slot)) slots[slot]++; else slots.other++;
}
const total = {
armour: base.armour + mods.armour,
@@ -61,62 +58,153 @@ const base = {
space: base.space + mods.space,
power: base.power + mods.power
};
// Headroom (positive = free)
total.spaceFree = total.space;
total.powerFree = total.power;
// If components encode their usage (voidship.use.{space|power})
for (const it of items) {
const use = it.system?.voidship?.use ?? {};
if (Number.isFinite(Number(use.space))) total.spaceFree -= Number(use.space);
if (Number.isFinite(Number(use.power))) total.powerFree -= Number(use.power);
}
dbg("computeDerived: base=", base, "mods=", mods, "total=", total, "slots=", slots);
return { base, mods, total, slots };
}
async getData(options = {}) {
async getData(options) {
const data = await super.getData(options);
data.isVoidship = this.isVoidship;
data.voidship = this._computeDerived();
const actor = this.actor;
const hull = this.actor.getFlag(VB_MOD, "hull") || {};
data.hull = {
class: hull.class || this.actor.name,
size: hull.size || "Cruiser",
slotsAllowed: hull.slots || { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }
};
data.crew = this.actor.getFlag(VB_MOD, "crew") || {
command: 1, pilot: 1, sensors: 1, engineering: 2,
gunnery: 2, security: 2, flightDeck: 0, passengers: 0
};
data.rtRaw = this.actor.getFlag(VB_MOD, "rtRaw") || {};
try {
data.rtRawString = JSON.stringify(data.rtRaw, null, 2);
} catch (e) {
data.rtRawString = "{}";
}
// ——— FLAGS (cały namespace)
const ns = actor.flags?.[VB_MOD] || {};
const hull = ns.hull || { class: actor.name, size: "Cruiser", slots: { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 } };
const base = ns.base || { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 };
const crew = ns.crew || { command: 1, pilot: 1, sensors: 1, engineering: 2, gunnery: 2, security: 2, flightDeck: 0, passengers: 0 };
const rtRaw = ns.rtRaw || {};
const rt = rtRaw.rt || {};
// expose a minimal config (labels)
data.cfg = {
labels: {
armour: "Armour",
integrity: "Integrity",
manoeuvre: "Manoeuvre",
detection: "Detection",
speed: "Speed",
space: "Space",
power: "Power",
spaceFree: "Space (free)",
powerFree: "Power (free)"
}
// ——— RT snapshot do karty
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 ?? ""
};
// ——— KOMPONENTY (tylko nasze)
const components = actor.items.contents
.filter(i => i.flags?.[VB_MOD]?.component?.kind)
.map(i => {
const vs = i.system?.voidship ?? i.flags?.[VB_MOD]?.voidship ?? {};
const v = foundry.utils.deepClone(vs);
v.itemId = i.id;
v.name = i.name;
v.equipped = (i.system?.equipped ?? true);
v.slot = (v.slot || "Internal");
v.use = Object.assign({ space: 0, power: 0 }, v.use);
v.mods = Object.assign({ armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }, v.mods);
v.kind = i.flags?.[VB_MOD]?.component?.kind;
v.tags = i.flags?.[VB_MOD]?.component?.tags ?? [];
return v;
});
// ——— SUMY MODYFIKATORÓW (equipped)
const modsSum = components.reduce((acc, c) => {
if (!c.equipped) return acc;
acc.armour += Number(c.mods.armour || 0);
acc.integrity += Number(c.mods.integrity || 0);
acc.manoeuvre += Number(c.mods.manoeuvre || 0);
acc.detection += Number(c.mods.detection || 0);
acc.speed += Number(c.mods.speed || 0);
acc.space += Number(c.mods.space || 0);
acc.power += Number(c.mods.power || 0);
return acc;
}, { armour:0, integrity:0, manoeuvre:0, detection:0, speed:0, space:0, power:0 });
// ——— CAPACITY (use) dla equipped
const useTotal = 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 });
// ——— TOTALS = BASE + MODS
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 - useTotal.space;
totals.powerFree = totals.power - useTotal.power;
// ——— SLOTY (użyte vs limit)
const slotsUsed = components.reduce((acc, c) => {
if (!c.equipped) return acc;
const k = (c.slot || "Internal").toLowerCase();
if (["prow","dorsal","port","starboard","keel"].includes(k)) acc[k] = (acc[k]||0)+1;
return acc;
}, { prow:0, dorsal:0, port:0, starboard:0, keel:0 });
// ——— Ostrzeżenia + wymagania
const warnings = {};
const anySlotOver =
(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 (anySlotOver) warnings.slots = "One or more weapon slot groups exceed hull limits.";
if (totals.spaceFree < 0) warnings.capacity = "Not enough Space for installed components.";
if (totals.powerFree < 0) warnings.capacity = (warnings.capacity ? warnings.capacity + " " : "") + "Not enough Power for installed components.";
const req = (hull.requirements ?? []).map(r => ({ tag: r.tag, min: Number(r.min||0), label: r.label || r.tag }));
const haveByTag = components.reduce((acc, c) => {
if (!c.equipped) return acc;
const tags = Array.isArray(c.tags) ? c.tags : [];
for (const t of tags) acc[t] = (acc[t]||0) + 1;
return acc;
}, {});
const missing = req.filter(r => (haveByTag[r.tag]||0) < r.min);
if (missing.length) {
const list = missing.map(m => `${m.label}: ${haveByTag[m.tag]||0}/${m.min}`).join("; ");
warnings.requirements = `Missing required components → ${list}`;
}
// ——— Dane do template
data.hull = hull;
data.base = base;
data.crew = crew;
data.rtCard = rtCard;
data.components = components;
data.modsSum = modsSum;
data.totals = totals;
data.slotsUsed = slotsUsed;
data.warnings = warnings;
data.flags = actor.flags;
return data;
}
activateListeners(html) {
super.activateListeners(html);
// Guard: allow sheet UI even for non-voidship, but prompt to mark
// Mark/Unmark
html.find("[data-action='mark-voidship']").on("click", async () => {
await this.actor.setFlag(VB_MOD, "isVoidship", true);
ui.notifications.info("Marked as Voidship.");
@@ -127,17 +215,20 @@ try {
ui.notifications.info("Unmarked Voidship.");
this.render(true);
});
// Base config dialog
html.find("[data-action='configure-base']").on("click", async () => {
const cur = this._computeDerived().base;
const content = `
<div class="form-group"><label>Armour</label><input type="number" name="armour" value="${cur.armour}"/></div>
<div class="form-group"><label>Integrity</label><input type="number" name="integrity" value="${cur.integrity}"/></div>
<div class="form-group"><label>Manoeuvre</label><input type="number" name="manoeuvre" value="${cur.manoeuvre}"/></div>
<div class="form-group"><label>Detection</label><input type="number" name="detection" value="${cur.detection}"/></div>
<div class="form-group"><label>Speed</label><input type="number" name="speed" value="${cur.speed}"/></div>
<div class="form-group"><label>Space</label><input type="number" name="space" value="${cur.space}"/></div>
<div class="form-group"><label>Power</label><input type="number" name="power" value="${cur.power}"/></div>
`;
<form>
<div class="form-group"><label>Armour</label><input type="number" name="armour" value="${cur.armour}"/></div>
<div class="form-group"><label>Integrity</label><input type="number" name="integrity" value="${cur.integrity}"/></div>
<div class="form-group"><label>Manoeuvre</label><input type="number" name="manoeuvre" value="${cur.manoeuvre}"/></div>
<div class="form-group"><label>Detection</label><input type="number" name="detection" value="${cur.detection}"/></div>
<div class="form-group"><label>Speed</label><input type="number" name="speed" value="${cur.speed}"/></div>
<div class="form-group"><label>Space</label><input type="number" name="space" value="${cur.space}"/></div>
<div class="form-group"><label>Power</label><input type="number" name="power" value="${cur.power}"/></div>
</form>`;
new Dialog({
title: "Voidship — Base Stats",
content, buttons: {
@@ -146,17 +237,17 @@ try {
callback: async (html) => {
const form = html[0].querySelector("form") || html[0];
const get = (k) => Number(form.querySelector(`[name="${k}"]`)?.value || 0);
const patchBase = {
armour: get("armour"),
integrity: get("integrity"),
manoeuvre: get("manoeuvre"),
detection: get("detection"),
speed: get("speed"),
space: get("space"),
power: get("power")
};
await this.actor.setFlag(VB_MOD, "base", patchBase);
this.render(true);
const patchBase = {
armour: get("armour"),
integrity: get("integrity"),
manoeuvre: get("manoeuvre"),
detection: get("detection"),
speed: get("speed"),
space: get("space"),
power: get("power")
};
await this.actor.setFlag(VB_MOD, "base", patchBase);
this.render(true);
}
},
cancel: { label: "Cancel" }
@@ -164,17 +255,142 @@ this.render(true);
default: "ok"
}, { jQuery: true }).render(true);
});
// Toggle equipped
html.on("click", "[data-action='comp-toggle']", async (ev) => {
const tr = ev.currentTarget.closest("tr[data-item-id]");
const itemId = tr?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
const cur = !!(item.system?.equipped ?? true);
await item.update({ "system.equipped": !cur });
this.render(true);
});
// Edit component
html.on("click", "[data-action='comp-edit']", async (ev) => {
const tr = ev.currentTarget.closest("tr[data-item-id]");
const itemId = tr?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
const v = foundry.utils.deepClone(item.system?.voidship || {});
v.slot = v.slot || "Internal";
v.use = Object.assign({ space: 0, power: 0 }, v.use);
v.mods = Object.assign({ armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }, v.mods);
const eq = (item.system?.equipped ?? true);
const slotOptions = ["Prow", "Dorsal", "Port", "Starboard", "Keel", "Internal", "Aetherics"]
.map(s => `<option value="${s}" ${v.slot === s ? "selected" : ""}>${s}</option>`).join("");
const content = `
<form>
<div class="form-group"><label>Equipped</label><input type="checkbox" name="equipped" ${eq ? "checked" : ""}/></div>
<div class="form-group"><label>Slot</label><select name="slot">${slotOptions}</select></div>
<fieldset style="margin-top:.5rem;"><legend>Use</legend>
<div class="form-group"><label>Space</label><input type="number" step="1" name="use.space" value="${v.use.space}"/></div>
<div class="form-group"><label>Power</label><input type="number" step="1" name="use.power" value="${v.use.power}"/></div>
</fieldset>
<fieldset style="margin-top:.5rem;"><legend>Mods</legend>
${["armour", "integrity", "manoeuvre", "detection", "speed", "space", "power"].map(k => `
<div class="form-group"><label>${k[0].toUpperCase() + k.slice(1)}</label>
<input type="number" step="1" name="mods.${k}" value="${v.mods[k]}"/>
</div>`).join("")}
</fieldset>
</form>`;
new Dialog({
title: `Edit Component — ${item.name}`,
content,
buttons: {
ok: {
label: "Save",
callback: async (dlg) => {
const f = dlg[0].querySelector("form");
const read = (sel, num = false) => {
const el = f.querySelector(`[name="${sel}"]`);
if (!el) return num ? 0 : "";
if (el.type === "checkbox") return el.checked;
const val = el.value ?? (el.textContent ?? "");
return num ? Number(val || 0) : val;
};
const patch = {
"system.voidship.slot": read("slot"),
"system.voidship.use.space": read("use.space", true),
"system.voidship.use.power": read("use.power", true),
"system.voidship.mods.armour": read("mods.armour", true),
"system.voidship.mods.integrity": read("mods.integrity", true),
"system.voidship.mods.manoeuvre": read("mods.manoeuvre", true),
"system.voidship.mods.detection": read("mods.detection", true),
"system.voidship.mods.speed": read("mods.speed", true),
"system.voidship.mods.space": read("mods.space", true),
"system.voidship.mods.power": read("mods.power", true),
"system.equipped": !!read("equipped")
};
await item.update(patch);
// miękka walidacja
const { total, slots } = this._computeDerived();
const flags = this.actor.getFlag(VB_MOD, "") || {};
const hull = flags.hull || { slots: { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 } };
const overSlots = ["prow", "dorsal", "port", "starboard", "keel"].filter(k => slots[k] > (hull.slots?.[k] || 0));
if (overSlots.length) ui.notifications.warn(`Slot limit exceeded: ${overSlots.join(", ")}`);
if (total.spaceFree < 0) ui.notifications.warn(`Not enough Space (over by ${Math.abs(total.spaceFree)})`);
if (total.powerFree < 0) ui.notifications.warn(`Not enough Power (over by ${Math.abs(total.powerFree)})`);
this.render(true);
}
},
cancel: { label: "Cancel" }
},
default: "ok"
}, { jQuery: true }).render(true);
});
// Remove
html.on("click", "[data-action='comp-remove']", async (ev) => {
const tr = ev.currentTarget.closest("tr[data-item-id]");
const itemId = tr?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
new Dialog({
title: "Remove Component",
content: `<p>Remove <b>${foundry.utils.escapeHTML(item.name)}</b> from this voidship?</p>`,
buttons: {
ok: { label: "Remove", callback: () => this.actor.deleteEmbeddedDocuments("Item", [itemId]) },
cancel: { label: "Cancel" }
},
default: "ok"
}).render(true);
});
// Auto-refresh po zmianach itemów tego aktora
const refreshIfMine = (doc) => { try { if (doc?.parent?.id === this.actor?.id) this.render(false); } catch(_) {} };
Hooks.on('createItem', refreshIfMine);
Hooks.on('updateItem', refreshIfMine);
Hooks.on('deleteItem', refreshIfMine);
this.once("close", () => {
Hooks.off('createItem', refreshIfMine);
Hooks.off('updateItem', refreshIfMine);
Hooks.off('deleteItem', refreshIfMine);
});
}
}
// ————————————————————————————————————————————————————————————————
// INIT: helper + rejestracja sheetu + domyślki/sanitizacja
Hooks.once("init", () => {
// Register the sheet for the 'vehicle' type (W&G Vehicles via Church of Steel)
if (!Handlebars.helpers.signed) {
Handlebars.registerHelper("signed", function (value) {
const n = Number(value || 0);
if (Number.isNaN(n) || n === 0) return "±0";
return (n > 0 ? "+" : "") + Math.abs(n);
});
}
Actors.registerSheet(VB_MOD, VoidshipSheet, {
types: ["vehicle"],
makeDefault: false,
label: "Voidship (W&G)"
});
// Optional: a convenient factory on game namespace
game.wgVoidships = game.wgVoidships || {};
game.wgVoidships.createVoidship = async function (name = "New Voidship") {
const actor = await Actor.create({ name, type: "vehicle", folder: null });
@@ -182,5 +398,44 @@ Hooks.once("init", () => {
ui.notifications.info(`Voidship created: ${name}`);
return actor;
};
// Auto-domyślki dla itemów dodawanych do voidshipów
Hooks.on("preCreateItem", (item) => {
const parent = item?.parent;
if (!(parent instanceof Actor)) return;
if (parent.getFlag(VB_MOD, "isVoidship") !== true) return;
const vs = item.system?.voidship;
if (!vs) {
item.updateSource({
"system.voidship": {
slot: "Internal",
use: { space: 0, power: 0 },
mods: { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }
}
});
}
if (item.system?.equipped === undefined) item.updateSource({ "system.equipped": true });
});
// Sanitizacja liczb przy update (żeby nie trafiały NaN)
Hooks.on("preUpdateItem", (item, change) => {
const parent = item?.parent;
if (!(parent instanceof Actor)) return;
if (parent.getFlag(VB_MOD, "isVoidship") !== true) return;
const fixNum = (path) => {
const v = foundry.utils.getProperty(change, path);
if (v === undefined) return;
const n = Number(v);
foundry.utils.setProperty(change, path, Number.isFinite(n) ? n : 0);
};
[
"system.voidship.use.space", "system.voidship.use.power",
"system.voidship.mods.armour", "system.voidship.mods.integrity", "system.voidship.mods.manoeuvre",
"system.voidship.mods.detection", "system.voidship.mods.speed", "system.voidship.mods.space", "system.voidship.mods.power"
].forEach(fixNum);
});
console.log(`${VB_MOD}: VoidshipSheet registered`);
});