hoi4-agent-tools 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/hoi4_agent_tools/gui/assets.js +15 -3
- package/dist/hoi4_agent_tools/gui/layout.js +270 -75
- package/dist/hoi4_agent_tools/gui/renderer.js +185 -22
- package/dist/hoi4_agent_tools/gui/source-graph.js +40 -11
- package/dist/hoi4_agent_tools/gui/studio.js +35 -7
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -0
- package/dist/hoi4_agent_tools/probability/adapters.js +5 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
- package/dist/hoi4_agent_tools/probability/scenario-state.js +165 -0
- package/dist/hoi4_agent_tools/probability/scope-pools.js +199 -0
- package/dist/hoi4_agent_tools/probability/simulation.js +4 -3
- package/dist/hoi4_agent_tools/probability/sweep.js +6 -2
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +35 -28
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +4 -0
- package/dist/hoi4_agent_tools/schemas/probability.js +90 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +5 -1
- package/docs/probability.md +46 -0
- package/docs/research/probability-adapter-evidence.md +2 -1
- package/examples/probability/scoped-dynamic-pools-evaluate-input.json +50 -0
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +175 -66
- package/schemas/probability-compare-input.schema.json +251 -66
- package/schemas/probability-evaluate-input.schema.json +252 -67
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +231 -46
- package/schemas/probability-sequence-input.schema.json +273 -88
- package/schemas/probability-simulate-input.schema.json +252 -67
- package/schemas/probability-sweep-input.schema.json +254 -69
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 2.8.0 - 2026-08-26
|
|
8
|
+
|
|
9
|
+
- Added structured `ROOT`, `THIS`, `PREV`, `FROM` chain, saved-scope, and event-target bindings to probability scenarios.
|
|
10
|
+
- Added deterministic enumeration and exact complete-pool shares for declared dynamic destination, opposition, donor, and other runtime scope pools.
|
|
11
|
+
- Extended exact branching, sweeps, and simulation to update scoped and pool-candidate state without flattening it into the root scenario.
|
|
12
|
+
- Added MCP resource counts, a published callable example, 2,048-candidate coverage, and source-linked trigger traces for dynamic scope pools.
|
|
13
|
+
|
|
14
|
+
## 2.7.0 - 2026-08-24
|
|
15
|
+
|
|
16
|
+
- Made scripted-GUI layout follow Clausewitz orientation, element-origin, center-position, relative-dimension, local-scale, container-clipping, and fixed text-bound behavior.
|
|
17
|
+
- Added deterministic nine-slice cornered tiles, filled/background progress bars, masked shields, and inline localisation GFX icons.
|
|
18
|
+
- Resolved ordinary and inline GUI assets case-insensitively through mod, dependency, and installed-game layers so vanilla graphics remain visible in mod renders.
|
|
19
|
+
- Removed synthetic state-color overlays from normal renders and retained actual sprite-frame evidence for the requested state.
|
|
20
|
+
|
|
7
21
|
## 2.6.0 - 2026-08-22
|
|
8
22
|
|
|
9
23
|
- Added a shared complete map catalog with resolved names, source locations, geometry, memberships, networks, positions, buildings, ports, adjacency, and localisation evidence.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
|
|
|
9
9
|
- Maps: navigate the complete rendered map by ID or localised name; inspect provinces, states, regions, adjacency, supply, railways, and positions; create states and provinces; change IDs; and repair connected map data.
|
|
10
10
|
- Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
|
|
11
11
|
- Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
|
|
12
|
-
- AI and MTTH: evaluate weighted choices and timing across explicit scenarios, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
|
|
12
|
+
- AI and MTTH: evaluate weighted choices and timing across explicit scenarios, bind special scope chains, enumerate dynamic target pools, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
|
|
13
13
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
@@ -12,6 +12,7 @@ function normalizeAssetPath(value) {
|
|
|
12
12
|
return value
|
|
13
13
|
.replaceAll('\\', '/')
|
|
14
14
|
.replace(/^\/+|^\.\//u, '')
|
|
15
|
+
.replace(/\/+/gu, '/')
|
|
15
16
|
.toLowerCase();
|
|
16
17
|
}
|
|
17
18
|
function parseFields(line) {
|
|
@@ -214,7 +215,12 @@ export class GuiAssetCatalog {
|
|
|
214
215
|
return promise;
|
|
215
216
|
}
|
|
216
217
|
loadSpriteFrame(sprite, requestedFrame) {
|
|
217
|
-
|
|
218
|
+
return this.loadSpriteTextureFrame(sprite, sprite.texturePath, requestedFrame, 'primary');
|
|
219
|
+
}
|
|
220
|
+
loadSecondarySpriteFrame(sprite, requestedFrame) {
|
|
221
|
+
return this.loadSpriteTextureFrame(sprite, sprite.texturePath2, requestedFrame, 'secondary');
|
|
222
|
+
}
|
|
223
|
+
loadSpriteTextureFrame(sprite, texturePath, requestedFrame, layer) {
|
|
218
224
|
if (texturePath === undefined)
|
|
219
225
|
return Promise.resolve(undefined);
|
|
220
226
|
const frameCount = Math.max(1, sprite.frameCount);
|
|
@@ -226,13 +232,19 @@ export class GuiAssetCatalog {
|
|
|
226
232
|
const textureIdentity = textureFile === undefined
|
|
227
233
|
? `${sprite.sourcePath}:${texturePath}`
|
|
228
234
|
: `${textureFile.displayPath}:${textureFile.sha256}`;
|
|
229
|
-
const key = `${textureIdentity}:${frameCount}:${frame}`;
|
|
235
|
+
const key = `${textureIdentity}:${frameCount}:${frame}:${layer}`;
|
|
230
236
|
let promise = this.frames.get(key);
|
|
231
237
|
if (promise === undefined) {
|
|
232
238
|
promise = this.decodeSpriteFrame(sprite, texturePath, frameCount, frame, key);
|
|
233
239
|
this.frames.set(key, promise);
|
|
234
240
|
}
|
|
235
|
-
return promise.then((decoded) => decoded === undefined
|
|
241
|
+
return promise.then((decoded) => decoded === undefined
|
|
242
|
+
? undefined
|
|
243
|
+
: {
|
|
244
|
+
...decoded,
|
|
245
|
+
spriteName: layer === 'primary' ? sprite.name : `${sprite.name}#secondary`,
|
|
246
|
+
texturePath,
|
|
247
|
+
});
|
|
236
248
|
}
|
|
237
249
|
async decodeSpriteFrame(sprite, texturePath, frameCount, frame, operationKey) {
|
|
238
250
|
const raster = await this.loadRaster(texturePath, sprite.sourcePath);
|
|
@@ -9,24 +9,6 @@ import { emptyFidelityReport } from './types.js';
|
|
|
9
9
|
const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
|
|
10
10
|
const numericDynamicPlaceholder = '[X]';
|
|
11
11
|
const textDynamicPlaceholder = '[dynamic_loc]';
|
|
12
|
-
const partialSpriteSemantics = {
|
|
13
|
-
textSpriteType: {
|
|
14
|
-
field: 'text_sprite_semantics',
|
|
15
|
-
detail: 'Engine text-sprite generation is not modelled; only the resolved primary texture is shown.',
|
|
16
|
-
},
|
|
17
|
-
corneredTileSpriteType: {
|
|
18
|
-
field: 'cornered_tile_semantics',
|
|
19
|
-
detail: 'Corner and edge tiling is not modelled; the primary texture is stretched as one frame.',
|
|
20
|
-
},
|
|
21
|
-
progressbarType: {
|
|
22
|
-
field: 'progressbar_sprite_semantics',
|
|
23
|
-
detail: 'Progressbar sprite composition is not modelled; only the resolved primary texture is shown.',
|
|
24
|
-
},
|
|
25
|
-
maskedShieldType: {
|
|
26
|
-
field: 'masked_shield_semantics',
|
|
27
|
-
detail: 'Mask, shield, and secondary-texture composition is not modelled; only the resolved primary texture is shown.',
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
12
|
function property(attributes, ...names) {
|
|
31
13
|
const lowered = new Set(names.map((name) => name.toLowerCase()));
|
|
32
14
|
const entry = Object.entries(attributes).find(([name]) => lowered.has(name.toLowerCase()));
|
|
@@ -58,9 +40,12 @@ const hoi4TextColours = {
|
|
|
58
40
|
b: '#4aa3df',
|
|
59
41
|
g: '#87939e',
|
|
60
42
|
};
|
|
43
|
+
const inlineIconMarkerStart = 0xe000;
|
|
44
|
+
const inlineIconMarkerEnd = 0xf8ff;
|
|
61
45
|
function visibleHoiText(value) {
|
|
62
46
|
const characters = [];
|
|
63
47
|
const colours = [];
|
|
48
|
+
const inlineIcons = [];
|
|
64
49
|
let colour;
|
|
65
50
|
let hasColourMarkup = false;
|
|
66
51
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -76,8 +61,18 @@ function visibleHoiText(value) {
|
|
|
76
61
|
let cursor = index + 1;
|
|
77
62
|
while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
|
|
78
63
|
cursor += 1;
|
|
79
|
-
|
|
80
|
-
|
|
64
|
+
const token = value.slice(index + 1, cursor);
|
|
65
|
+
const markerCodePoint = inlineIconMarkerStart + inlineIcons.length;
|
|
66
|
+
if (token.length === 0 || markerCodePoint > inlineIconMarkerEnd) {
|
|
67
|
+
characters.push('\u25c6');
|
|
68
|
+
colours.push(colour);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const marker = String.fromCodePoint(markerCodePoint);
|
|
72
|
+
inlineIcons.push({ marker, token });
|
|
73
|
+
characters.push(marker);
|
|
74
|
+
colours.push(colour);
|
|
75
|
+
}
|
|
81
76
|
index = cursor - 1;
|
|
82
77
|
continue;
|
|
83
78
|
}
|
|
@@ -90,20 +85,26 @@ function visibleHoiText(value) {
|
|
|
90
85
|
characters.push(character);
|
|
91
86
|
colours.push(colour);
|
|
92
87
|
}
|
|
93
|
-
return { text: characters.join(''), colours, hasColourMarkup };
|
|
88
|
+
return { text: characters.join(''), colours, hasColourMarkup, inlineIcons };
|
|
94
89
|
}
|
|
95
90
|
function scalarNumber(value, reference) {
|
|
96
91
|
if (typeof value === 'number')
|
|
97
92
|
return value;
|
|
98
93
|
if (typeof value !== 'string')
|
|
99
94
|
return undefined;
|
|
100
|
-
if (
|
|
101
|
-
const percent = Number(value.
|
|
95
|
+
if (/%%?$/u.test(value)) {
|
|
96
|
+
const percent = Number(value.replace(/%%?$/u, ''));
|
|
102
97
|
return Number.isFinite(percent) ? (reference * percent) / 100 : undefined;
|
|
103
98
|
}
|
|
104
99
|
const parsed = Number(value);
|
|
105
100
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
106
101
|
}
|
|
102
|
+
function scaledNumber(value, reference, scale) {
|
|
103
|
+
const resolved = scalarNumber(value, reference);
|
|
104
|
+
if (resolved === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
return typeof value === 'string' && /%%?$/u.test(value) ? resolved : resolved * scale;
|
|
107
|
+
}
|
|
107
108
|
function scalarBoolean(value) {
|
|
108
109
|
if (typeof value === 'boolean')
|
|
109
110
|
return value;
|
|
@@ -240,7 +241,100 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
240
241
|
}, 'GUI scope substitution');
|
|
241
242
|
return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
|
|
242
243
|
}
|
|
243
|
-
function
|
|
244
|
+
function measureTextWithInlineIcons(catalog, fontName, text, fontSize, inlineIcons) {
|
|
245
|
+
if (inlineIcons.size === 0 || ![...inlineIcons.keys()].some((marker) => text.includes(marker)))
|
|
246
|
+
return catalog.measureText(fontName, text, fontSize);
|
|
247
|
+
let width = 0;
|
|
248
|
+
let lineHeight = fontSize * 1.2;
|
|
249
|
+
let source = 'approximation';
|
|
250
|
+
const missingGlyphs = new Set();
|
|
251
|
+
let segment = '';
|
|
252
|
+
const commit = () => {
|
|
253
|
+
if (segment.length === 0)
|
|
254
|
+
return;
|
|
255
|
+
const measured = catalog.measureText(fontName, segment, fontSize);
|
|
256
|
+
width += measured.width;
|
|
257
|
+
lineHeight = Math.max(lineHeight, measured.lineHeight);
|
|
258
|
+
source = measured.source;
|
|
259
|
+
for (const codePoint of measured.missingGlyphs)
|
|
260
|
+
missingGlyphs.add(codePoint);
|
|
261
|
+
segment = '';
|
|
262
|
+
};
|
|
263
|
+
for (const character of text) {
|
|
264
|
+
const icon = inlineIcons.get(character);
|
|
265
|
+
if (icon === undefined)
|
|
266
|
+
segment += character;
|
|
267
|
+
else {
|
|
268
|
+
commit();
|
|
269
|
+
width += icon.width;
|
|
270
|
+
lineHeight = Math.max(lineHeight, icon.height);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
commit();
|
|
274
|
+
return { width, lineHeight, source, missingGlyphs: [...missingGlyphs] };
|
|
275
|
+
}
|
|
276
|
+
async function shapeTextWithInlineIcons(catalog, fontName, text, fontSize, lineIndex, inlineIcons) {
|
|
277
|
+
if (inlineIcons.size === 0 || ![...inlineIcons.keys()].some((marker) => text.includes(marker)))
|
|
278
|
+
return { glyphLine: await catalog.shapeText(fontName, text, fontSize), icons: [] };
|
|
279
|
+
const base = await catalog.shapeText(fontName, Array.from(text)
|
|
280
|
+
.filter((character) => !inlineIcons.has(character))
|
|
281
|
+
.join(''), fontSize);
|
|
282
|
+
const glyphs = [];
|
|
283
|
+
const missingGlyphs = new Set();
|
|
284
|
+
const sourceHashes = new Set([base.sourceHash]);
|
|
285
|
+
const sources = new Set([base.source]);
|
|
286
|
+
let baseline = base.baseline;
|
|
287
|
+
let baselineModelled = base.baselineModelled;
|
|
288
|
+
let penX = 0;
|
|
289
|
+
let segment = '';
|
|
290
|
+
const icons = [];
|
|
291
|
+
const commit = async () => {
|
|
292
|
+
if (segment.length === 0)
|
|
293
|
+
return;
|
|
294
|
+
const shaped = await catalog.shapeText(fontName, segment, fontSize);
|
|
295
|
+
sourceHashes.add(shaped.sourceHash);
|
|
296
|
+
sources.add(shaped.source);
|
|
297
|
+
baseline = Math.max(baseline, shaped.baseline);
|
|
298
|
+
baselineModelled &&= shaped.baselineModelled;
|
|
299
|
+
for (const codePoint of shaped.missingGlyphs)
|
|
300
|
+
missingGlyphs.add(codePoint);
|
|
301
|
+
glyphs.push(...shaped.glyphs.map((glyph) => ({ ...glyph, x: glyph.x + penX })));
|
|
302
|
+
penX += shaped.width;
|
|
303
|
+
segment = '';
|
|
304
|
+
};
|
|
305
|
+
for (const character of text) {
|
|
306
|
+
const icon = inlineIcons.get(character);
|
|
307
|
+
if (icon === undefined)
|
|
308
|
+
segment += character;
|
|
309
|
+
else {
|
|
310
|
+
await commit();
|
|
311
|
+
icons.push({
|
|
312
|
+
token: icon.token,
|
|
313
|
+
spriteName: icon.spriteName,
|
|
314
|
+
lineIndex,
|
|
315
|
+
offsetX: penX,
|
|
316
|
+
width: icon.width,
|
|
317
|
+
height: icon.height,
|
|
318
|
+
...(icon.sprite === undefined ? {} : { sprite: icon.sprite }),
|
|
319
|
+
});
|
|
320
|
+
penX += icon.width;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await commit();
|
|
324
|
+
return {
|
|
325
|
+
glyphLine: {
|
|
326
|
+
source: sources.size === 1 ? [...sources][0] : 'deterministic-fallback',
|
|
327
|
+
sourceHash: sourceHashes.size === 1 ? [...sourceHashes][0] : hashCanonical([...sourceHashes].sort()),
|
|
328
|
+
width: penX,
|
|
329
|
+
baseline,
|
|
330
|
+
baselineModelled,
|
|
331
|
+
glyphs,
|
|
332
|
+
missingGlyphs: [...missingGlyphs].sort((left, right) => left - right),
|
|
333
|
+
},
|
|
334
|
+
icons,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work, inlineIcons = new Map()) {
|
|
244
338
|
const lines = [];
|
|
245
339
|
const widths = [];
|
|
246
340
|
const colourLines = [];
|
|
@@ -267,7 +361,7 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
|
|
|
267
361
|
}
|
|
268
362
|
const committedText = characters.join('');
|
|
269
363
|
work.spendTextLayout('committed text line measurement');
|
|
270
|
-
const committed = catalog
|
|
364
|
+
const committed = measureTextWithInlineIcons(catalog, fontName, committedText, fontSize, inlineIcons);
|
|
271
365
|
lines.push(committedText);
|
|
272
366
|
widths.push(committed.width);
|
|
273
367
|
colourLines.push(lineColours);
|
|
@@ -300,12 +394,12 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
|
|
|
300
394
|
let previousWordWidth = 0;
|
|
301
395
|
for (const word of words) {
|
|
302
396
|
work.spendTextLayout('text word measurement');
|
|
303
|
-
const measuredWord = catalog
|
|
397
|
+
const measuredWord = measureTextWithInlineIcons(catalog, fontName, word.text, fontSize, inlineIcons);
|
|
304
398
|
retainMissingGlyphs(measuredWord.missingGlyphs);
|
|
305
399
|
let candidateWidth = measuredWord.width;
|
|
306
400
|
if (previousWord !== undefined) {
|
|
307
401
|
work.spendTextLayout('text word-boundary measurement');
|
|
308
|
-
const boundary = catalog
|
|
402
|
+
const boundary = measureTextWithInlineIcons(catalog, fontName, `${previousWord} ${word.text}`, fontSize, inlineIcons);
|
|
309
403
|
retainMissingGlyphs(boundary.missingGlyphs);
|
|
310
404
|
candidateWidth = currentWidth + boundary.width - previousWordWidth;
|
|
311
405
|
}
|
|
@@ -332,20 +426,28 @@ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work
|
|
|
332
426
|
missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
|
|
333
427
|
};
|
|
334
428
|
}
|
|
335
|
-
function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
|
|
429
|
+
function colourRunsForLine(catalog, fontName, line, colours, fontSize, work, inlineIcons = new Map()) {
|
|
336
430
|
if (!colours.some((colour) => colour !== undefined) || line.length === 0)
|
|
337
431
|
return [];
|
|
338
432
|
const runs = [];
|
|
339
433
|
let start = 0;
|
|
340
434
|
let offsetX = 0;
|
|
341
435
|
while (start < line.length) {
|
|
436
|
+
const inlineIcon = inlineIcons.get(line[start] ?? '');
|
|
437
|
+
if (inlineIcon !== undefined) {
|
|
438
|
+
offsetX += inlineIcon.width;
|
|
439
|
+
start += 1;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
342
442
|
const colour = colours[start] ?? defaultTextColour;
|
|
343
443
|
let end = start + 1;
|
|
344
|
-
while (end < line.length &&
|
|
444
|
+
while (end < line.length &&
|
|
445
|
+
!inlineIcons.has(line[end] ?? '') &&
|
|
446
|
+
(colours[end] ?? defaultTextColour) === colour)
|
|
345
447
|
end += 1;
|
|
346
448
|
const text = line.slice(start, end);
|
|
347
449
|
work.spendTextLayout('localisation colour-run measurement');
|
|
348
|
-
const width = catalog
|
|
450
|
+
const width = measureTextWithInlineIcons(catalog, fontName, text, fontSize, inlineIcons).width;
|
|
349
451
|
runs.push({ text, colour, offsetX, width });
|
|
350
452
|
offsetX += width;
|
|
351
453
|
start = end;
|
|
@@ -384,6 +486,10 @@ function frameFor(element, sprite, scenario) {
|
|
|
384
486
|
: 0;
|
|
385
487
|
return Math.min(frameCount - 1, mapped);
|
|
386
488
|
}
|
|
489
|
+
function textIconSpriteCandidates(token) {
|
|
490
|
+
const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
|
|
491
|
+
return [normalized, `${normalized}_texticon`, `${normalized}_text_icon`];
|
|
492
|
+
}
|
|
387
493
|
function alignment(attributes, buttonText) {
|
|
388
494
|
const explicitFormat = scalarString(property(attributes, 'format'))?.toLowerCase();
|
|
389
495
|
const format = explicitFormat ?? '';
|
|
@@ -403,6 +509,33 @@ function alignment(attributes, buttonText) {
|
|
|
403
509
|
: 'top';
|
|
404
510
|
return { horizontalAlignment, verticalAlignment };
|
|
405
511
|
}
|
|
512
|
+
function anchorAxes(value) {
|
|
513
|
+
const normalized = value.toLowerCase().replaceAll('-', '_');
|
|
514
|
+
if (normalized === 'center_left')
|
|
515
|
+
return { horizontal: 'left', vertical: 'center' };
|
|
516
|
+
if (normalized === 'center_right')
|
|
517
|
+
return { horizontal: 'right', vertical: 'center' };
|
|
518
|
+
if (normalized === 'center_up' || normalized === 'center_upper')
|
|
519
|
+
return { horizontal: 'center', vertical: 'top' };
|
|
520
|
+
if (normalized === 'center_down' || normalized === 'center_lower')
|
|
521
|
+
return { horizontal: 'center', vertical: 'bottom' };
|
|
522
|
+
if (normalized === 'upper_right')
|
|
523
|
+
return { horizontal: 'right', vertical: 'top' };
|
|
524
|
+
if (normalized === 'lower_left')
|
|
525
|
+
return { horizontal: 'left', vertical: 'bottom' };
|
|
526
|
+
if (normalized === 'lower_right')
|
|
527
|
+
return { horizontal: 'right', vertical: 'bottom' };
|
|
528
|
+
if (normalized === 'center')
|
|
529
|
+
return { horizontal: 'center', vertical: 'center' };
|
|
530
|
+
return { horizontal: 'left', vertical: 'top' };
|
|
531
|
+
}
|
|
532
|
+
function anchoredCoordinate(start, span, offset, anchor) {
|
|
533
|
+
return anchor === 'right' || anchor === 'bottom'
|
|
534
|
+
? start + span + offset
|
|
535
|
+
: anchor === 'center'
|
|
536
|
+
? start + span / 2 + offset
|
|
537
|
+
: start + offset;
|
|
538
|
+
}
|
|
406
539
|
class GuiSceneWorkBudget {
|
|
407
540
|
work = 0;
|
|
408
541
|
elements = 0;
|
|
@@ -450,7 +583,7 @@ class GuiSceneWorkBudget {
|
|
|
450
583
|
this.textLayoutOperations += amount;
|
|
451
584
|
}
|
|
452
585
|
}
|
|
453
|
-
async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId) {
|
|
586
|
+
async function layoutElement(definition, parentRect, inheritedClip, parentScale, depth, context, instanceSuffix = '', rowIndex, rowValues, parentInstanceId, inheritedOrientation = 'upper_left') {
|
|
454
587
|
context.work.admitElement(depth);
|
|
455
588
|
const { scenario, catalog, fidelity, diagnostics } = context;
|
|
456
589
|
const instanceId = instanceSuffix.length === 0 ? definition.id : `${definition.id}${instanceSuffix}`;
|
|
@@ -458,12 +591,10 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
458
591
|
const scale = parentScale * localScale;
|
|
459
592
|
const position = objectProperty(property(definition.attributes, 'position'));
|
|
460
593
|
const size = objectProperty(property(definition.attributes, 'size'));
|
|
461
|
-
const localX =
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
let width = (scalarNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width) ?? 0) * scale;
|
|
466
|
-
let height = (scalarNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height) ?? 0) * scale;
|
|
594
|
+
const localX = scaledNumber(position === undefined ? undefined : property(position, 'x'), parentRect.width, parentScale) ?? 0;
|
|
595
|
+
const localY = scaledNumber(position === undefined ? undefined : property(position, 'y'), parentRect.height, parentScale) ?? 0;
|
|
596
|
+
let width = scaledNumber(size === undefined ? undefined : property(size, 'width', 'x'), parentRect.width, scale) ?? 0;
|
|
597
|
+
let height = scaledNumber(size === undefined ? undefined : property(size, 'height', 'y'), parentRect.height, scale) ?? 0;
|
|
467
598
|
const background = objectProperty(property(definition.attributes, 'background'));
|
|
468
599
|
const spriteName = scalarString(property(definition.attributes, 'spriteType', 'quadTextureSprite')) ??
|
|
469
600
|
scalarString(background === undefined
|
|
@@ -471,6 +602,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
471
602
|
: property(background, 'spriteType', 'quadTextureSprite'));
|
|
472
603
|
const spriteDefinition = spriteName === undefined ? undefined : context.spritesByName.get(spriteName.toLowerCase());
|
|
473
604
|
let sprite;
|
|
605
|
+
let secondarySprite;
|
|
606
|
+
let spriteRenderMode;
|
|
474
607
|
if (spriteName !== undefined && spriteDefinition === undefined) {
|
|
475
608
|
const partialInventory = context.graph.edges.some((edge) => edge.kind === 'uses_sprite' &&
|
|
476
609
|
edge.from === definition.id &&
|
|
@@ -483,16 +616,15 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
483
616
|
}
|
|
484
617
|
else if (spriteDefinition !== undefined) {
|
|
485
618
|
const partialAppearance = [];
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
619
|
+
const spriteType = spriteDefinition.spriteType.toLowerCase();
|
|
620
|
+
spriteRenderMode =
|
|
621
|
+
spriteType === 'corneredtilespritetype'
|
|
622
|
+
? 'cornered-tile'
|
|
623
|
+
: spriteType === 'progressbartype'
|
|
624
|
+
? 'progressbar'
|
|
625
|
+
: spriteType === 'maskedshieldtype'
|
|
626
|
+
? 'masked-shield'
|
|
627
|
+
: 'stretch';
|
|
496
628
|
if (spriteDefinition.effectFile !== undefined) {
|
|
497
629
|
const detail = `Effect ${spriteDefinition.effectFile} is retained in the source graph but is not executed by the offline renderer.`;
|
|
498
630
|
addFidelity(fidelity, 'unsupported', 'effectFile', detail, definition);
|
|
@@ -502,6 +634,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
502
634
|
diagnostics.push(diagnostic('GUI_SPRITE_RENDER_PARTIAL', 'warning', `Element ${definition.name} has a partial sprite appearance: ${partialAppearance.join(' ')}`, definition));
|
|
503
635
|
const frame = frameFor(definition, spriteDefinition, scenario);
|
|
504
636
|
sprite = await catalog.loadSpriteFrame(spriteDefinition, frame);
|
|
637
|
+
if (spriteDefinition.texturePath2 !== undefined)
|
|
638
|
+
secondarySprite = await catalog.loadSecondarySpriteFrame(spriteDefinition, frame);
|
|
505
639
|
if (!sprite?.supported) {
|
|
506
640
|
addFidelity(fidelity, 'unsupported', 'texture', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition);
|
|
507
641
|
diagnostics.push(diagnostic('GUI_TEXTURE_UNSUPPORTED', 'warning', sprite?.reason ?? `Sprite ${spriteDefinition.name} has no texture.`, definition));
|
|
@@ -514,6 +648,20 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
514
648
|
addFidelity(fidelity, partialAppearance.length === 0 ? 'modelled' : 'approximated', 'sprite_frame', partialAppearance.length === 0
|
|
515
649
|
? `${spriteDefinition.name} frame ${sprite.frame + 1}/${sprite.frameCount}`
|
|
516
650
|
: `${spriteDefinition.name} frame ${sprite.frame + 1}/${sprite.frameCount} shows only the primary resolved texture; additional sprite semantics are omitted.`, definition);
|
|
651
|
+
if (spriteRenderMode === 'cornered-tile')
|
|
652
|
+
addFidelity(fidelity, spriteDefinition.borderSize === undefined ? 'approximated' : 'modelled', 'cornered_tile_composition', spriteDefinition.borderSize === undefined
|
|
653
|
+
? `${spriteDefinition.name} has no borderSize, so the full texture fills the element.`
|
|
654
|
+
: `${spriteDefinition.name} uses fixed corners and edges with a ${spriteDefinition.borderSize.width}x${spriteDefinition.borderSize.height} source border${spriteDefinition.tilingCenter === true ? ' and a tiled center' : ''}.`, definition);
|
|
655
|
+
if (spriteRenderMode === 'progressbar' || spriteRenderMode === 'masked-shield') {
|
|
656
|
+
const detail = secondarySprite?.supported === true
|
|
657
|
+
? `Primary and secondary textures for ${spriteDefinition.name} are composited.`
|
|
658
|
+
: `Secondary texture for ${spriteDefinition.name} could not be rendered.`;
|
|
659
|
+
addFidelity(fidelity, secondarySprite?.supported === true ? 'modelled' : 'missing', spriteRenderMode === 'progressbar'
|
|
660
|
+
? 'progressbar_composition'
|
|
661
|
+
: 'masked_shield_composition', detail, definition);
|
|
662
|
+
if (secondarySprite !== undefined && !secondarySprite.supported)
|
|
663
|
+
diagnostics.push(diagnostic('GUI_SECONDARY_TEXTURE_UNSUPPORTED', 'warning', secondarySprite.reason ?? detail, definition));
|
|
664
|
+
}
|
|
517
665
|
if (spriteDefinition.frameAnimated) {
|
|
518
666
|
addFidelity(fidelity, spriteDefinition.looping === undefined ? 'approximated' : 'modelled', 'animation_looping', spriteDefinition.looping === false
|
|
519
667
|
? 'Animation stops on its final frame.'
|
|
@@ -549,14 +697,50 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
549
697
|
resolvedFontMetrics.nativeSize ??
|
|
550
698
|
context.catalog.fontDefinition(fontName ?? '')?.size ??
|
|
551
699
|
16;
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
700
|
+
const resolvedInlineIcons = new Map();
|
|
701
|
+
for (const inlineIcon of visibleText.inlineIcons) {
|
|
702
|
+
const candidates = textIconSpriteCandidates(inlineIcon.token);
|
|
703
|
+
const iconDefinition = candidates
|
|
704
|
+
.map((candidate) => context.spritesByName.get(candidate.toLowerCase()))
|
|
705
|
+
.find((candidate) => candidate !== undefined);
|
|
706
|
+
const iconHeight = fontSize * scale;
|
|
707
|
+
const iconSprite = iconDefinition === undefined ? undefined : await catalog.loadSpriteFrame(iconDefinition, 0);
|
|
708
|
+
const iconWidth = iconSprite?.supported === true && iconSprite.height > 0
|
|
709
|
+
? iconHeight * (iconSprite.width / iconSprite.height)
|
|
710
|
+
: iconHeight;
|
|
711
|
+
const spriteName = iconDefinition?.name ?? candidates[0];
|
|
712
|
+
resolvedInlineIcons.set(inlineIcon.marker, {
|
|
713
|
+
marker: inlineIcon.marker,
|
|
714
|
+
token: inlineIcon.token,
|
|
715
|
+
spriteName,
|
|
716
|
+
width: iconWidth,
|
|
717
|
+
height: iconHeight,
|
|
718
|
+
...(iconSprite === undefined ? {} : { sprite: iconSprite }),
|
|
719
|
+
});
|
|
720
|
+
if (iconSprite?.supported === true)
|
|
721
|
+
addFidelity(fidelity, 'modelled', 'inline_text_icon', `Localisation icon £${inlineIcon.token} uses ${spriteName}.`, definition);
|
|
722
|
+
else {
|
|
723
|
+
addFidelity(fidelity, 'missing', 'inline_text_icon', iconSprite?.reason ?? `No sprite resolves localisation icon £${inlineIcon.token}.`, definition);
|
|
724
|
+
diagnostics.push(diagnostic('GUI_TEXT_ICON_MISSING', 'warning', iconSprite?.reason ?? `No sprite resolves localisation icon £${inlineIcon.token}.`, definition));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const declaredMaxWidth = scaledNumber(property(definition.attributes, 'maxWidth'), parentRect.width, scale);
|
|
728
|
+
const declaredMaxHeight = scaledNumber(property(definition.attributes, 'maxHeight'), parentRect.height, scale);
|
|
729
|
+
if (width === 0 && declaredMaxWidth !== undefined)
|
|
730
|
+
width = declaredMaxWidth;
|
|
731
|
+
if (height === 0 && declaredMaxHeight !== undefined)
|
|
732
|
+
height = declaredMaxHeight;
|
|
733
|
+
const maxWidth = declaredMaxWidth ?? width;
|
|
734
|
+
const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work, resolvedInlineIcons);
|
|
555
735
|
const glyphLines = [];
|
|
556
|
-
|
|
557
|
-
|
|
736
|
+
const inlineIcons = [];
|
|
737
|
+
for (const [lineIndex, line] of wrapped.lines.entries()) {
|
|
738
|
+
const shaped = await shapeTextWithInlineIcons(catalog, fontName, line, fontSize * scale, lineIndex, resolvedInlineIcons);
|
|
739
|
+
glyphLines.push(shaped.glyphLine);
|
|
740
|
+
inlineIcons.push(...shaped.icons);
|
|
741
|
+
}
|
|
558
742
|
const colourRuns = visibleText.hasColourMarkup
|
|
559
|
-
? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
|
|
743
|
+
? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work, resolvedInlineIcons))
|
|
560
744
|
: undefined;
|
|
561
745
|
const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
|
|
562
746
|
if (width === 0)
|
|
@@ -579,8 +763,10 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
579
763
|
glyphLines,
|
|
580
764
|
overflowX: width > 0 && measuredWidth > width + 0.01,
|
|
581
765
|
overflowY: height > 0 && measuredHeight > height + 0.01,
|
|
766
|
+
fixedSize: scalarBoolean(property(definition.attributes, 'fixedsize')) ?? false,
|
|
582
767
|
unresolvedTokens: resolved.unresolved,
|
|
583
768
|
...(colourRuns === undefined ? {} : { colourRuns }),
|
|
769
|
+
...(inlineIcons.length === 0 ? {} : { inlineIcons }),
|
|
584
770
|
};
|
|
585
771
|
if (wrapped.metricSource === 'approximation')
|
|
586
772
|
addFidelity(fidelity, 'approximated', 'font_metrics', `No supplied font metrics for ${fontName ?? '<default>'}.`, definition);
|
|
@@ -604,31 +790,29 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
604
790
|
if (resolved.unresolved.length > 0)
|
|
605
791
|
addFidelity(fidelity, 'unresolved', 'dynamic_text', `Unresolved tokens: ${resolved.unresolved.join(', ')}.`, definition);
|
|
606
792
|
}
|
|
607
|
-
const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ??
|
|
793
|
+
const orientation = scalarString(property(definition.attributes, 'orientation'))?.toLowerCase() ??
|
|
794
|
+
inheritedOrientation;
|
|
608
795
|
const origo = scalarString(property(definition.attributes, 'origo'))?.toLowerCase() ?? 'upper_left';
|
|
609
|
-
|
|
610
|
-
let
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
else if (orientation.includes('center'))
|
|
618
|
-
y = parentRect.y + parentRect.height / 2 + localY * scale;
|
|
619
|
-
if (origo.includes('center')) {
|
|
796
|
+
const orientationAxes = anchorAxes(orientation);
|
|
797
|
+
let x = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
|
|
798
|
+
let y = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
|
|
799
|
+
const centerPosition = scalarBoolean(property(definition.attributes, 'centerposition')) ?? false;
|
|
800
|
+
const origoAxes = centerPosition
|
|
801
|
+
? { horizontal: 'center', vertical: 'center' }
|
|
802
|
+
: anchorAxes(origo);
|
|
803
|
+
if (origoAxes.horizontal === 'center') {
|
|
620
804
|
x -= width / 2;
|
|
621
|
-
y -= height / 2;
|
|
622
|
-
}
|
|
623
|
-
else {
|
|
624
|
-
if (origo.includes('right'))
|
|
625
|
-
x -= width;
|
|
626
|
-
if (origo.includes('lower') || origo.includes('bottom'))
|
|
627
|
-
y -= height;
|
|
628
805
|
}
|
|
806
|
+
else if (origoAxes.horizontal === 'right')
|
|
807
|
+
x -= width;
|
|
808
|
+
if (origoAxes.vertical === 'center')
|
|
809
|
+
y -= height / 2;
|
|
810
|
+
else if (origoAxes.vertical === 'bottom')
|
|
811
|
+
y -= height;
|
|
629
812
|
const scrollOffset = (scenario.scrollOffsets[definition.name] ?? scenario.scrollOffsets[definition.id] ?? 0) * scale;
|
|
630
813
|
const unclippedRect = { x, y, width: Math.max(0, width), height: Math.max(0, height) };
|
|
631
|
-
const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
|
|
814
|
+
const ownClipping = scalarBoolean(property(definition.attributes, 'clipping')) ??
|
|
815
|
+
(background !== undefined && /(?:containerwindow|windowtype)/iu.test(definition.elementType));
|
|
632
816
|
const availableClip = inheritedClip === undefined ? unclippedRect : rectIntersection(inheritedClip, unclippedRect);
|
|
633
817
|
const clipRect = inheritedClip;
|
|
634
818
|
const clipped = clipRect !== undefined &&
|
|
@@ -681,6 +865,17 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
681
865
|
state,
|
|
682
866
|
...(progressRatio === undefined ? {} : { progressRatio }),
|
|
683
867
|
...(sprite === undefined ? {} : { sprite }),
|
|
868
|
+
...(secondarySprite === undefined ? {} : { secondarySprite }),
|
|
869
|
+
...(spriteRenderMode === undefined ? {} : { spriteRenderMode }),
|
|
870
|
+
...(spriteDefinition?.borderSize === undefined
|
|
871
|
+
? {}
|
|
872
|
+
: { spriteBorderSize: spriteDefinition.borderSize }),
|
|
873
|
+
...(spriteDefinition?.tilingCenter === undefined
|
|
874
|
+
? {}
|
|
875
|
+
: { spriteTilingCenter: spriteDefinition.tilingCenter }),
|
|
876
|
+
...(spriteDefinition === undefined
|
|
877
|
+
? {}
|
|
878
|
+
: { progressHorizontal: spriteDefinition.horizontal ?? true }),
|
|
684
879
|
...(text === undefined ? {} : { text }),
|
|
685
880
|
sourcePath: definition.sourcePath,
|
|
686
881
|
...(definition.location === undefined ? {} : { location: definition.location }),
|
|
@@ -735,7 +930,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
735
930
|
...childParentRect,
|
|
736
931
|
y: childParentRect.y + rowY - originalY * scale,
|
|
737
932
|
};
|
|
738
|
-
await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId);
|
|
933
|
+
await layoutElement(child, shiftedParent, childClip, scale, depth + 1, context, `${instanceSuffix}#row-${index}`, index, row, instanceId, origo);
|
|
739
934
|
const rendered = context.instancesById.get(`${child.id}${instanceSuffix}#row-${index}`);
|
|
740
935
|
rowHeight = Math.max(rowHeight, rendered?.unclippedRect.height ?? 0);
|
|
741
936
|
}
|
|
@@ -746,7 +941,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
746
941
|
else {
|
|
747
942
|
for (const child of childDefinitions) {
|
|
748
943
|
context.work.spend('nested child expansion');
|
|
749
|
-
await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId);
|
|
944
|
+
await layoutElement(child, childParentRect, childClip, scale, depth + 1, context, instanceSuffix, rowIndex, rowValues, instanceId, origo);
|
|
750
945
|
}
|
|
751
946
|
}
|
|
752
947
|
}
|