Files

672 lines
29 KiB
JavaScript
Raw Permalink 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.
"use strict";
// UI resources and drawing for the window editor, following the client (io3DEngine ioGUIManager,
// ioUIRenderImage, ioUIRenderFrame, ioUITitle, ioButton, ioStringManager):
// UIRes imagesets (xml/uiimageset.xml), frame templates (xml/defaultuiframe.xml), textures,
// the UI texts (text/text.txt) and the game font (font/lostsaga.ttf)
// UIText STR(n) → text ("xml_<file>_<n>"), CP949 encoding
// UIDraw one window: frames, images, title / texts, button states, as the client draws them
// UIProps inputs for attributes (colours, alignments…) and the quick form of a window
const UIRes = (() => {
let src = null;
let sets = new Map(); // imageset name (lower) → { file, images: Map(name lower → {x,y,w,h,ox,oy}) }
let frames = new Map(); // template name (lower) → { name, parts: [{ type, w, h, set, image }] }
const tex = new Map(); // texture file → Promise<ImageBitmap | null>
const imgCache = new Map();
let texts = null; // { encoding, xml, source }
let fontFamily = "";
const toLatin1 = (u8) => {
let s = "";
for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));
return s;
};
function reset() {
src = null;
sets = new Map();
frames = new Map();
tex.clear();
imgCache.clear();
texts = null;
}
async function load(s) {
if (src && JSON.stringify(src) === JSON.stringify(s)) return;
reset();
src = s;
if (!s) return;
const [is, fr, tx, font] = await Promise.all([
call("mdl:readPath", s, "xml/uiimageset.xml").catch(() => null),
call("mdl:readPath", s, "xml/defaultuiframe.xml").catch(() => null),
call("wnd:texts", s).catch(() => null),
fontFamily ? null : call("wnd:font", s).catch(() => null),
]);
if (is) {
const t = toLatin1(is.bytes);
for (const m of t.matchAll(/<Imageset\b([^>]*)>([\s\S]*?)<\/Imageset>/gi)) {
const a = attrs(m[1]);
const images = new Map();
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
const b = attrs(im[1]);
images.set((b.name || "").toLowerCase(), { x: +b.x || 0, y: +b.y || 0, w: +b.width || 0, h: +b.height || 0, ox: +b.offsetx || 0, oy: +b.offsety || 0 });
}
sets.set((a.name || "").toLowerCase(), { file: a.file || "", images });
}
}
if (fr) {
const t = toLatin1(fr.bytes);
for (const m of t.matchAll(/<FrameComponent\b([^>]*)>([\s\S]*?)<\/FrameComponent>/gi)) {
const a = attrs(m[1]);
const parts = [];
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
const b = attrs(im[1]);
const [w, h] = String(b.size || "").split(".").map(Number);
parts.push({ type: (b.type || "").toLowerCase(), w: w || 0, h: h || 0, set: b.imageset || "", image: b.image || "" });
}
frames.set((a.name || "").toLowerCase(), { name: a.name, parts });
}
}
texts = tx;
if (font && !fontFamily) {
try {
const face = new FontFace("LostSagaUI", font.bytes);
await face.load();
document.fonts.add(face);
fontFamily = "LostSagaUI";
} catch (err) {
console.warn("[wnd] font", err);
}
}
}
function attrs(s) {
const o = {};
for (const m of s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) o[m[1].toLowerCase()] = m[2];
return o;
}
function texture(file) {
const key = file.toLowerCase();
if (!tex.has(key)) {
tex.set(
key,
(async () => {
if (!src) return null;
const t = await call("mdl:texture", src, file);
if (!t) return null;
try {
return await TexDecode.decode(t.bytes, t.ext);
} catch {
return null;
}
})(),
);
}
return tex.get(key);
}
/** An imageset image (or a whole texture with File=): { bmp, x, y, w, h, ox, oy }. */
async function image(set, name, file) {
const key = file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase();
if (imgCache.has(key)) return imgCache.get(key);
if (file) {
const bmp = await texture(file);
const v = bmp ? { bmp, x: 0, y: 0, w: bmp.width, h: bmp.height, ox: 0, oy: 0 } : null;
imgCache.set(key, v);
return v;
}
const s = sets.get((set || "").toLowerCase());
const r = s?.images.get((name || "").toLowerCase());
if (!s || !r) {
imgCache.set(key, null);
return null;
}
const bmp = await texture(s.file);
const v = bmp ? { bmp, ...r } : null;
imgCache.set(key, v);
return v;
}
const imageSync = (set, name, file) => imgCache.get(file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase());
const frame = (name) => frames.get((name || "").toLowerCase()) || null;
const imagesets = () => [...sets.keys()];
const imagesOf = (set) => [...(sets.get((set || "").toLowerCase())?.images.keys() || [])];
const frameNames = () => [...frames.values()].map((f) => f.name);
return { reset, load, image, imageSync, frame, imagesets, imagesOf, frameNames, texts: () => texts, font: () => fontFamily };
})();
window.UIRes = UIRes;
const UIText = (() => {
// CP949 encoder: the reverse of the browser's euc-kr decoder, built once
let table = null;
function build() {
table = new Map();
const dec = new TextDecoder("euc-kr");
const b = new Uint8Array(2);
for (let hi = 0x81; hi <= 0xfe; hi++) {
for (let lo = 0x41; lo <= 0xfe; lo++) {
b[0] = hi;
b[1] = lo;
const ch = dec.decode(b);
if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, [hi, lo]);
}
}
}
function encodeCp949(str) {
if (!table) build();
const out = [];
for (const ch of str) {
const c = ch.codePointAt(0);
if (c < 0x80) out.push(c);
else {
const t = table.get(ch);
if (!t) throw new Error(`“${ch}” can't be written in CP949 (Korean encoding of the client files).`);
out.push(t[0], t[1]);
}
}
return Uint8Array.from(out);
}
let fileKey = ""; // xml file name without folder / extension, lowercase (ioGUIManager::AddWnd)
const setFile = (name) => (fileKey = (name || "").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "").toLowerCase());
const key = (n) => `xml_${fileKey}_${n}`;
/** A Title / Text / Tooltip value as the client shows it: STR(n) → text.txt "xml_<file>_<n>". */
function resolve(doc, el, text) {
const t = (text || "").trim();
const m = /STR\((\d+)/i.exec(t);
const decoded = LSF.decode(Uint8Array.from(t, (c) => c.charCodeAt(0) & 255));
if (!m) return decoded;
const tx = UIRes.texts();
return tx?.xml[key(m[1])] ?? key(m[1]); // a missing key shows the key, like the client
}
function titleOf(doc, w) {
const title = XmlDoc.child(w, "Title");
const te = title && XmlDoc.child(title, "Text");
return te ? resolve(doc, te, XmlDoc.text(doc, te)) : "";
}
return { encodeCp949, resolve, titleOf, setFile, key };
})();
window.UIText = UIText;
const UIDraw = (() => {
const X = XmlDoc;
const low = (s) => (s || "").toLowerCase();
/** AARRGGBB like ioStringConverter: missing → FF000000, a value of 0 → FFFFFFFF. */
function colorOf(s, dflt = "FF000000") {
if (s === undefined || s === null || s === "") s = dflt;
let v = parseInt(String(s).replace(/^0x/i, ""), 16);
if (!isFinite(v)) v = parseInt(dflt, 16);
if (v === 0) v = 0xffffffff;
if (String(s).replace(/^0x/i, "").length <= 6) v = (0xff000000 | v) >>> 0;
return { a: ((v >>> 24) & 255) / 255, r: (v >>> 16) & 255, g: (v >>> 8) & 255, b: v & 255 };
}
const css = (c, alpha = 1) => `rgba(${c.r},${c.g},${c.b},${(c.a * alpha).toFixed(3)})`;
const BLEND = { add: "lighter", addalpha: "lighter", addcolor: "lighter", screen: "screen", multiply: "multiply", lighten: "lighten", darken: "darken" };
function withBlend(ctx, renderType, fn) {
const rt = low(renderType);
ctx.save();
if (BLEND[rt]) ctx.globalCompositeOperation = BLEND[rt];
if (rt === "gray" || rt === "graylight") ctx.filter = "grayscale(1)";
fn();
ctx.restore();
}
// tinted copies (Color on an image / frame multiplies RGB, keeps alpha)
const tintCache = new Map();
function tinted(im, color) {
if (!color || /^f{6}$/i.test(color.slice(-6))) return im;
const k = `${im.x},${im.y},${im.w},${im.h},${color}`;
let t = tintCache.get(im.bmp)?.get(k);
if (t) return t;
const c = document.createElement("canvas");
c.width = im.w;
c.height = im.h;
const g = c.getContext("2d");
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
g.globalCompositeOperation = "multiply";
const col = colorOf(color, "FFFFFFFF");
g.fillStyle = `rgb(${col.r},${col.g},${col.b})`;
g.fillRect(0, 0, im.w, im.h);
g.globalCompositeOperation = "destination-in";
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
t = { ...im, bmp: c, x: 0, y: 0 };
if (!tintCache.has(im.bmp)) tintCache.set(im.bmp, new Map());
tintCache.get(im.bmp).set(k, t);
return t;
}
function blit(ctx, im, x, y, w, h, flipH, flipV) {
if (!im || !im.bmp || !im.w || !im.h || w <= 0 || h <= 0) return;
if (!flipH && !flipV) return ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, x, y, w, h);
ctx.save();
ctx.translate(x + (flipH ? w : 0), y + (flipV ? h : 0));
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, w, h);
ctx.restore();
}
/**
* A window <Image> (ioUIRenderImage): Area gives the render rect relative to the window (only
* the position when W or H is 0); x = win + Area.X − imageset OffsetX (+ −w/2 Center, −w Right);
* natural size except Stretch; Tile repeats; Reverse flips; Scale scales; Color tints.
*/
function drawImageEl(ctx, e, r) {
const im0 = UIRes.imageSync(X.attr(e, "Imageset"), X.attr(e, "Image"), X.attr(e, "File"));
if (!im0) return;
const im = tinted(im0, X.attr(e, "Color"));
const area = X.child(e, "Area");
const scale = X.num(e, "Scale", 1) || 1;
const aw = X.num(area, "Width"), ah = X.num(area, "Height");
const hasRect = aw > 0 && ah > 0;
const ha = low(X.attr(area, "HorzAlign") || "left"), va = low(X.attr(area, "VertAlign") || "top");
const nw = im.w * scale, nh = im.h * scale;
const bx = r.x + X.num(area, "X") - im.ox * scale, by = r.y + X.num(area, "Y") - im.oy * scale;
const rev = low(X.attr(area, "Reverse"));
const fh = rev.includes("horz"), fv = rev.includes("vert");
const w = hasRect && ha === "stretch" ? aw : nw;
const h = hasRect && va === "stretch" ? ah : nh;
let x = bx + (ha === "center" ? -w / 2 : ha === "right" ? -w : 0);
let y = by + (va === "center" ? -h / 2 : va === "bottom" ? -h : 0);
withBlend(ctx, X.attr(area, "RenderType"), () => {
const tx = hasRect && ha === "tile" ? Math.max(1, Math.ceil(aw / nw)) : 1;
const ty = hasRect && va === "tile" ? Math.max(1, Math.ceil(ah / nh)) : 1;
for (let i = 0; i < tx; i++) for (let j = 0; j < ty; j++) blit(ctx, im, x + i * w, y + j * h, w, h, fh, fv);
});
}
/**
* A frame template (ioUIRenderFrame) inside a rect: corners at their image size, the part Size
* ("w.h") sets the layout thickness, edges stretched along, centre stretched both ways.
*/
function drawFrame(ctx, tpl, r, color, renderType) {
const f = UIRes.frame(tpl);
if (!f) return false;
const p = Object.fromEntries(f.parts.map((x) => [x.type, x]));
const img = (x) => {
const im = x && UIRes.imageSync(x.set, x.image);
return im ? tinted(im, color) : null;
};
const W = (x) => (x ? x.w || img(x)?.w || 0 : 0);
const H = (x) => (x ? x.h || img(x)?.h || 0 : 0);
const nat = (x) => img(x) || { w: 0, h: 0 };
const fw = r.w, fh = r.h;
withBlend(ctx, renderType, () => {
const put = (x, px, py, pw, ph) => {
const im = img(x);
if (im && pw > 0 && ph > 0) blit(ctx, im, r.x + px - (im.ox || 0), r.y + py - (im.oy || 0), pw, ph);
};
const tl = p.topleft, tr = p.topright, bl = p.bottomleft, br = p.bottomright;
put(tl, 0, 0, nat(tl).w, nat(tl).h);
put(p.topedge, W(tl), 0, fw - W(tl) - W(tr), nat(p.topedge).h);
put(tr, fw - W(tr), 0, nat(tr).w, nat(tr).h);
put(p.leftedge, 0, H(tl), nat(p.leftedge).w, fh - H(tl) - H(bl));
put(p.center, W(p.leftedge), H(p.topedge), fw - W(p.leftedge) - W(p.rightedge), fh - H(p.topedge) - H(p.bottomedge));
put(p.rightedge, fw - W(p.rightedge), H(tr), nat(p.rightedge).w, fh - H(tr) - H(br));
put(bl, 0, fh - H(bl), nat(bl).w, nat(bl).h);
put(p.bottomedge, W(bl), fh - H(p.bottomedge), fw - W(bl) - W(br), nat(p.bottomedge).h);
put(br, fw - W(br), fh - H(br), nat(br).w, nat(br).h);
});
return true;
}
function drawFrameEl(ctx, e, r) {
const area = X.child(e, "Area");
// no Width / Height: to the window's right / bottom edge (e.g. a BottomBar at Y=193)
const ax = X.num(area, "X"), ay = X.num(area, "Y");
const ar = area ? { x: r.x + ax, y: r.y + ay, w: X.num(area, "Width") || Math.max(0, r.w - ax), h: X.num(area, "Height") || Math.max(0, r.h - ay) } : r;
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
// "…multiply…" templates / MultiplyFrm are the darkening strips the client draws with Multiply
const rt = X.attr(area, "RenderType") || (/multiply/i.test(tpl || "") || /multiply/i.test(X.attr(e, "Type") || "") ? "Multiply" : null);
drawFrame(ctx, tpl, ar, X.attr(e, "Color"), rt);
}
// ---------- text (ioUITitle + ioFontManager) ----------
function fontCss(size, bold) {
const fam = UIRes.font();
return `${bold ? "bold " : ""}${Math.max(1, size)}px ${fam ? `"${fam}", ` : ""}"Microsoft JhengHei", "Malgun Gothic", "Leelawadee UI", sans-serif`;
}
/** Draws one text with the client's styles (outline / shadow in the Back colour). */
function drawText(ctx, str, { x, y, size = 12, style = "Normal", halign = "left", valign = "top", gap = 0, text, back, alpha = 1, widthCut = 0 }) {
if (!str) return;
const st = low(style);
const bold = st.startsWith("bold");
ctx.save();
ctx.font = fontCss(size, bold);
ctx.textAlign = halign === "center" ? "center" : halign === "right" ? "right" : "left";
ctx.textBaseline = "top";
const lines = String(str).split(/#|\n/);
const pitch = gap || 0; // VertGap is 0 when missing (the client overlaps un-gapped lines)
const totalH = size + pitch * (lines.length - 1);
let y0 = valign === "center" ? y - totalH / 2 : valign === "bottom" ? y - totalH : y;
const m = /outline(_full)?(?:_(\d)x)?/.exec(st);
const ring = m ? (m[1] ? Number(m[2] || 1) : 1) : 0;
lines.forEach((line, i) => {
let s = line;
if (widthCut > 0) {
while (s.length > 1 && ctx.measureText(s).width > widthCut) s = s.slice(0, -1);
}
const ly = y0 + i * pitch;
if (ring) {
ctx.lineJoin = "round";
ctx.lineWidth = ring * 2;
ctx.strokeStyle = css(back, alpha);
ctx.strokeText(s, x, ly);
} else if (st.includes("shadow")) {
ctx.fillStyle = css(back, alpha);
ctx.fillText(s, x + 1, ly + 1);
}
ctx.fillStyle = css(text, alpha);
ctx.fillText(s, x, ly);
});
ctx.restore();
}
function drawTitle(ctx, doc, w, r, state) {
const title = X.child(w, "Title");
if (!title) return;
const fmt = X.child(title, "Format");
const colors = X.childrenOf(title, "Color");
const disabled = state === "Disable";
const col = colors.find((c) => low(X.attr(c, "Type")) === low(state)) || colors.find((c) => low(X.attr(c, "Type")) === "normal") || colors[0];
const te = X.child(title, "Text");
const str = te ? UIText.resolve(doc, te, X.text(doc, te)) : "";
let style = X.attr(fmt, "Style") || "Normal";
let dx = 0, dy = 0;
if (disabled && X.attr(fmt, "DisableStyle")) {
const ds = X.attr(fmt, "DisableStyle");
if (low(ds) !== low(style) && low(ds) === "normal" && low(X.attr(fmt, "VertAlign") || "top") === "top") (dx = 2), (dy = 2);
style = ds;
}
drawText(ctx, str, {
x: r.x + X.num(fmt, "X") + dx,
y: r.y + X.num(fmt, "Y") + dy,
size: X.num(fmt, "Size", 12),
style,
halign: low(X.attr(fmt, "HorzAlign") || "left"),
valign: low(X.attr(fmt, "VertAlign") || "top"),
gap: X.num(fmt, "VertGap", 0),
text: colorOf(X.attr(col, "Text")),
back: colorOf(X.attr(col, "Back")),
});
}
/** Window-level <Text> list entries: Format fields as attributes or a <Format> child, one Color. */
function drawTexts(ctx, doc, w, r) {
for (const t of X.childrenOf(w, "Text")) {
const fmt = X.child(t, "Format") || t;
const col = X.child(t, "Color") || t;
const body = X.child(t, "Text") || (t.children.length ? null : t);
const raw = body ? X.text(doc, body) : "";
if (!raw.trim()) continue;
drawText(ctx, UIText.resolve(doc, body, raw), {
x: r.x + X.num(fmt, "X"),
y: r.y + X.num(fmt, "Y"),
size: X.num(fmt, "Size", 12),
style: X.attr(fmt, "Style") || "Normal",
halign: low(X.attr(fmt, "HorzAlign") || "left"),
valign: low(X.attr(fmt, "VertAlign") || "top"),
gap: X.num(fmt, "VertGap", 0),
text: colorOf(X.attr(col, "Text") || X.attr(col, "Color")),
back: colorOf(X.attr(col, "Back")),
widthCut: X.num(fmt, "WidthCut", 0),
});
}
}
// ioRichLabel: <ExtraInfo><TextBlock Text Size Color Style/> … laid out one after another
function drawTextBlocks(ctx, doc, w, r) {
const extra = X.child(w, "ExtraInfo");
const blocks = X.childrenOf(extra, "TextBlock");
if (!blocks.length) return;
let x = r.x, y = r.y;
for (const b of blocks) {
const raw = X.attr(b, "Text") || "";
const str = /STR\(/i.test(raw) ? UIText.resolve(doc, b, raw) : LSF.decode(Uint8Array.from(raw, (c) => c.charCodeAt(0) & 255));
const size = X.num(b, "Size", 12);
const parts = str.split("#");
parts.forEach((p, i) => {
if (i) {
x = r.x;
y += size + 2;
}
const col = colorOf(X.attr(b, "Color") || "FFFFFFFF");
drawText(ctx, p, { x, y, size, style: X.attr(b, "Style") || "Normal", text: col, back: colorOf("FF000000") });
ctx.font = fontCss(size);
x += ctx.measureText(p).width;
});
}
}
// EX classes (ioLabelWndEX / ioButtonWndEX / ioFrameWndEX): ExtraInfo children that fill the window
const INT_H = ["left", "center", "right"];
const INT_V = ["top", "center", "bottom"];
function drawEx(ctx, doc, w, r, state) {
const extra = X.child(w, "ExtraInfo");
if (!extra) return;
const want = { Normal: ["NormalFrame", "NormalImage"], Over: ["OverFrame", "OverImage"], Push: ["PushFrame", "PushImage"], Disable: ["DisableFrame", "NormalImage"] }[state] || ["NormalFrame", "NormalImage"];
for (const c of extra.children) {
const tag = c.tag;
if (tag === "FrameInfo" || (tag.endsWith("Frame") && want.includes(tag)) || (tag === "OverAddFrame" && state === "Over")) {
drawFrame(ctx, X.attr(c, "Template") || X.attr(c, "Templete"), r, X.attr(c, "Color"), tag === "OverAddFrame" ? "Add" : null);
} else if (tag === "ImageInfo" || (tag.endsWith("Image") && want.includes(tag))) {
const im = UIRes.imageSync(X.attr(c, "Imageset"), X.attr(c, "Image"), X.attr(c, "File"));
if (im) blit(ctx, tinted(im, X.attr(c, "Color")), r.x, r.y, r.w || im.w, r.h || im.h);
} else if (tag === "LabelInfo") {
const h = INT_H[X.num(c, "HorzAlign", 0)] || "left";
const v = INT_V[X.num(c, "VertAlign", 0)] || "top";
const size = X.num(c, "Size", 12);
const raw = X.attr(c, "Text") || "";
drawText(ctx, LSF.decode(Uint8Array.from(raw, (ch) => ch.charCodeAt(0) & 255)), {
x: r.x + (h === "center" ? r.w / 2 : h === "right" ? r.w : 0) + X.num(extra, "LabelOffsetX"),
y: r.y + (v === "center" ? r.h / 2 : v === "bottom" ? r.h : 0) + X.num(extra, "LabelOffsetY"),
size,
halign: h,
valign: v,
text: colorOf(X.attr(c, state === "Over" ? "OverColor" : "NormalColor") || X.attr(c, "Color") || "FFFFFFFF"),
back: colorOf(X.attr(c, "BKColor")),
alpha: X.num(c, "Alpha", 255) / 255,
});
}
}
}
const FIRST = ["selected", "multiplyfrm", "selectedtitle"];
// engine classes (ioGUIManager::CreateWnd): they draw every image Type in their list; client
// classes claim their own Types and draw them in code, so an Area-less image with such a Type
// isn't at the window origin in the game
const ENGINE = new Set("ioWnd ioButton ioScreenButton ioConvexButton ioScaleButton ioCheckButton ioRadioButton ioStretchButton ioActiveScreenBtn ioEdit ioProgressBar ioScroll ioSlide ioToggleButton ioWndEX ioFrameWndEX ioImageWndEX ioLabelWndEX ioFrameButtonWndEX ioImageButtonWndEX ioCheckBoxEX ioFlashPlayer ioTabFrameButtonEX ioTabWndEX ioScrollBarEX ioRichLabel ioRadioWndEX ioRadioFrameButtonEX ioMovingWnd ioMovingBtn ioMovingNormalBtn ioCustomWnd".toLowerCase().split(" "));
const GENERIC_TYPES = new Set(["", "normal", "back", "background", "bg"]);
/** An image a client class draws itself (so the generic list skips it). */
const classDrawn = (w, e) => {
if (e.tag !== "Image" || X.child(e, "Area")) return false;
const cls = low(X.attr(w, "ClassName"));
if (ENGINE.has(cls)) return false;
const t = low(X.attr(e, "Type"));
return !GENERIC_TYPES.has(t) && !t.includes("#");
};
const BUTTON_STATES = ["normal", "over", "overadd", "push", "disable", "exdisablenor", "exdisablepush", "back", "normallist", "disablelist", "check"];
/** Draws one window (not its child windows) at r. opts: { state, text, images, preview } */
function window(ctx, doc, w, r, { state = "Normal", text = true, images = true, preview = false } = {}) {
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
const inactive = /IWS_INACTIVE/.test(style);
const st = inactive ? "Disable" : state;
const items = X.childrenOf(w, "Imagery").flatMap((im) => im.children);
const cls = X.attr(w, "ClassName") || "";
const isButton = /button|btn/i.test(cls) || items.some((e) => ["over", "push"].includes(low(X.attr(e, "Type"))));
const isCheck = /check/i.test(cls);
const draw = (e) => {
if (e.tag === "DefaultFrameImagery" || e.tag === "FrameImagery") return drawFrameEl(ctx, e, r);
if (e.tag !== "Image") return;
if (classDrawn(w, e)) {
// placed by the class's code: hidden in the preview, faint at the origin while editing
if (preview) return;
ctx.save();
ctx.globalAlpha *= 0.35;
drawImageEl(ctx, e, r);
ctx.restore();
return;
}
drawImageEl(ctx, e, r);
};
ctx.save();
if (inactive) ctx.filter = "grayscale(1)";
if (images) {
// Selected / MultiplyFrm / SelectedTitle frames first (ioWnd::OnRender)
for (const e of items) if (FIRST.includes(low(X.attr(e, "Type")))) draw(e);
if (isButton) {
const of = (t) => items.filter((e) => low(X.attr(e, "Type")) === t);
of("back").forEach(draw);
const s = low(st);
const stateItems = of(s === "disable" ? "disable" : s);
(stateItems.length ? stateItems : of("normal")).forEach(draw);
if (s === "over") of("overadd").forEach((e) => withBlend(ctx, "Add", () => draw(e)));
if (isCheck) of("check").forEach(draw);
// NormalList / DisableList images (the "x" of a close button…) come after the state art
(s === "disable" && of("disablelist").length ? of("disablelist") : of("normallist")).forEach(draw);
for (const e of items) {
const t = low(X.attr(e, "Type"));
if (!BUTTON_STATES.includes(t) && !FIRST.includes(t)) draw(e);
}
} else {
for (const e of items) {
const t = low(X.attr(e, "Type"));
if (FIRST.includes(t) || ["over", "overadd", "push", "disable"].includes(t)) continue;
draw(e);
}
}
drawEx(ctx, doc, w, r, st);
}
if (text) {
drawTitle(ctx, doc, w, r, st);
drawTexts(ctx, doc, w, r);
drawTextBlocks(ctx, doc, w, r);
}
ctx.restore();
}
/** IWS_MODAL: a full-screen BaseImage001/strip_black behind the window (×3 for MODAL_EXTEND). */
function modalBack(ctx, doc, w, screen) {
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
if (!/IWS_MODAL/.test(style)) return;
const im = UIRes.imageSync("BaseImage001", "strip_black");
const n = /IWS_MODAL_EXTEND/.test(style) ? 3 : 1;
for (let i = 0; i < n; i++) {
if (im) blit(ctx, im, 0, 0, screen[0], screen[1]);
else {
ctx.fillStyle = "rgba(0,0,0,.5)";
ctx.fillRect(0, 0, screen[0], screen[1]);
}
}
}
/** Every image name a document can draw (to load them before drawing). */
function wanted(doc) {
const out = [];
for (const e of doc.elements) {
const set = X.attr(e, "Imageset"), img = X.attr(e, "Image"), file = X.attr(e, "File");
if ((set && img) || (file && /\.(dds|png|tga|bmp|jpg)$/i.test(file))) out.push([set, img, file]);
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
if (tpl) for (const p of UIRes.frame(tpl)?.parts || []) out.push([p.set, p.image]);
}
out.push(["BaseImage001", "strip_black"]);
return out;
}
return { window, modalBack, drawFrame, drawText, colorOf, wanted };
})();
window.UIDraw = UIDraw;
const UIProps = (() => {
const X = XmlDoc;
const ENUMS = {
horzalign: ["Left", "Center", "Right", "Stretch", "Tile"],
vertalign: ["Top", "Center", "Bottom", "Stretch", "Tile"],
xalign: ["", "Left", "Center", "Right", "Rate"],
yalign: ["", "Top", "Center", "Bottom", "Rate"],
autoresize: ["True", "False"],
rendertype: ["", "Normal", "Screen", "Gray", "Add", "AddColor", "ColorAlpha", "Lighten", "Darken", "Multiply", "GrayLight", "AddAlpha"],
reverse: ["", "Horz", "Vert", "HorzVert"],
style: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Outline_full_4x", "Bold", "Bold_shadow", "Bold_outline", "Bold_outline_full", "Bold_outline_full_2x", "Bold_outline_full_3x", "Bold_outline_full_4x"],
disablestyle: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Bold", "Bold_shadow"],
};
/** An input for an attribute: colours get a picker, known enums a list, the rest text. */
function input(el, name, value, onChange) {
const n = name.toLowerCase();
const enumOk = ENUMS[n] && !(n === "style" && el.tag !== "Format");
if (enumOk) {
const sel = document.createElement("select");
for (const v of new Set([...ENUMS[n], value])) {
const o = document.createElement("option");
o.value = v;
o.textContent = v || "(none)";
o.selected = v === value;
sel.append(o);
}
sel.onchange = () => onChange(sel.value);
return sel;
}
if ((n === "templete" || n === "template") && UIRes.frameNames().length) return withList(value, onChange, "wnd-dl-frames", UIRes.frameNames());
if (n === "imageset" && UIRes.imagesets().length) return withList(value, onChange, "wnd-dl-sets", UIRes.imagesets());
const t = document.createElement("input");
t.type = "text";
t.value = value;
t.spellcheck = false;
t.addEventListener("change", () => onChange(t.value));
t.addEventListener("keydown", (e) => e.key === "Enter" && t.blur());
if (/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) && /color|^text$|^back$|bkcolor/i.test(name)) {
const pick = document.createElement("input");
pick.type = "color";
pick.value = `#${value.slice(-6)}`;
pick.onchange = () => onChange((value.length === 8 ? value.slice(0, 2) : "") + pick.value.slice(1).toUpperCase());
const wrap = document.createElement("div");
wrap.className = "wnd-color";
wrap.append(pick, t);
return wrap;
}
return t;
}
function withList(value, onChange, id, names) {
if (!document.getElementById(id)) {
const dl = document.createElement("datalist");
dl.id = id;
for (const n of names) {
const o = document.createElement("option");
o.value = n;
dl.append(o);
}
document.body.append(dl);
}
const t = document.createElement("input");
t.type = "text";
t.value = value;
t.spellcheck = false;
t.setAttribute("list", id);
t.addEventListener("change", () => onChange(t.value));
return t;
}
/** The quick form of a window: position, size, alignment. */
function quick(doc, w, { edit }) {
const s = X.child(w, "Size");
const box = document.createElement("div");
box.className = "wnd-quick";
if (!s) return box;
for (const k of ["X", "Y", "Width", "Height", "XAlign", "YAlign"]) {
const lab = document.createElement("label");
lab.className = "fld";
const sp = document.createElement("span");
sp.textContent = k;
lab.append(sp, input(s, k, X.attr(s, k) ?? "", (v) => edit(s, (d, x) => (v === "" ? X.removeAttr(d, x, k) : X.setAttr(d, x, k, v)))));
box.append(lab);
}
return box;
}
return { input, quick };
})();
window.UIProps = UIProps;