hoi4-agent-tools 2.5.3 → 2.7.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 +13 -0
- package/README.md +5 -5
- 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/map/catalog.js +398 -0
- package/dist/hoi4_agent_tools/map/index.js +1 -0
- package/dist/hoi4_agent_tools/map/operations.js +337 -30
- package/dist/hoi4_agent_tools/map/render.js +40 -147
- package/dist/hoi4_agent_tools/map/service.js +1 -0
- package/dist/hoi4_agent_tools/mcp/tools/map.js +85 -9
- package/dist/hoi4_agent_tools/schemas/map.js +69 -2
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +5 -1
- package/docs/map.md +60 -23
- 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 +228 -74
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- 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 +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 2.7.0 - 2026-08-24
|
|
8
|
+
|
|
9
|
+
- Made scripted-GUI layout follow Clausewitz orientation, element-origin, center-position, relative-dimension, local-scale, container-clipping, and fixed text-bound behavior.
|
|
10
|
+
- Added deterministic nine-slice cornered tiles, filled/background progress bars, masked shields, and inline localisation GFX icons.
|
|
11
|
+
- Resolved ordinary and inline GUI assets case-insensitively through mod, dependency, and installed-game layers so vanilla graphics remain visible in mod renders.
|
|
12
|
+
- Removed synthetic state-color overlays from normal renders and retained actual sprite-frame evidence for the requested state.
|
|
13
|
+
|
|
14
|
+
## 2.6.0 - 2026-08-22
|
|
15
|
+
|
|
16
|
+
- Added a shared complete map catalog with resolved names, source locations, geometry, memberships, networks, positions, buildings, ports, adjacency, and localisation evidence.
|
|
17
|
+
- Made map inspection return a full-map overview and a searchable, clickable HTML navigator with exact province hit testing and pixel/map coordinate lookup.
|
|
18
|
+
- Added compact state and province creation defaults, rectangle province geometry, and one-call province, state, and strategic-region ID swapping with connected map-reference and standard localisation updates.
|
|
19
|
+
|
|
7
20
|
## 2.5.3 - 2026-08-22
|
|
8
21
|
|
|
9
22
|
- Resolved and embedded technology icons through the complete mod, dependency, and installed-game load order, including vanilla sprites used by mod content.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
|
|
|
6
6
|
|
|
7
7
|
- Focus trees: inspect structure and references, render layouts, create trees, and reorganize existing branches.
|
|
8
8
|
- Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
|
|
9
|
-
- Maps: inspect provinces, states, regions, adjacency, supply, and
|
|
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
12
|
- AI and MTTH: evaluate weighted choices and timing across explicit scenarios, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
|
|
@@ -40,9 +40,9 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
|
|
|
40
40
|
| `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
|
|
41
41
|
| `hoi4.gui_render` | Render GUI scenario variants, states, resolutions, hierarchy, and layout diagnostics. |
|
|
42
42
|
| `hoi4.gui_rewrite` | Create or update a GUI source package. |
|
|
43
|
-
| `hoi4.map_inspect` |
|
|
44
|
-
| `hoi4.map_render` | Render map layers and
|
|
45
|
-
| `hoi4.map_rewrite` | Create or update
|
|
43
|
+
| `hoi4.map_inspect` | Search, click, navigate, and inspect the complete rendered map and its linked data. |
|
|
44
|
+
| `hoi4.map_render` | Render full-map layers, overlays, names, IDs, coordinates, and source-linked catalogs. |
|
|
45
|
+
| `hoi4.map_rewrite` | Create or update states, provinces, IDs, networks, positions, and connected map data. |
|
|
46
46
|
| `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
|
|
47
47
|
| `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
|
|
48
48
|
| `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
|
|
@@ -71,7 +71,7 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
|
|
|
71
71
|
|
|
72
72
|
- Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
|
|
73
73
|
- Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render every value-driven version of this window and fix hidden controls, off-center button text, background alignment, clipping, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
|
|
74
|
-
- Maps: "Create a state from these
|
|
74
|
+
- Maps: "Render the whole map and find this state by name," "Create a state from these provinces," "Create a province inside this exact rectangle," or "Swap these state IDs and update connected references." See [Maps](docs/map.md).
|
|
75
75
|
- Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
|
|
76
76
|
- Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
|
|
77
77
|
- AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
|
|
@@ -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
|
}
|