Skip to content

Commit 49a896c

Browse files
committed
Let themes replace the 3D model of custom objects, like scattered models
A custom object property holding a 3D model (like the model of the "Scattered models" object of the 3D terrain extension) can be mapped to a model slot: the theme model replaces it, and the other properties (like the size of the copies) are kept. Custom objects drawing a 3D model must be mapped or ignored in theme-slots.json, like 3D model objects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wa8uKpYuQrYqGYnqGCsquj
1 parent d536f20 commit 49a896c

5 files changed

Lines changed: 210 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ as `effect:<scene>/<layer>/<effect>`, maps to one skybox slot: the theme's
7171
skybox replaces its six faces.
7272

7373
A custom object, like the 3D terrain, maps some of its properties. An image
74-
property maps to a texture slot, a color property to a color slot, and the
74+
property maps to a texture slot, a 3D model property (like the model of
75+
scattered models) to a model slot, a color property to a color slot, and the
7576
texture of a terrain layer to a *material* slot, a texture with its normal map,
7677
along with the property that takes the normal map:
7778

@@ -83,14 +84,19 @@ along with the property that takes the normal map:
8384
"scene:Game Scene/Grass": {
8485
"BaseColor": "terrain.grass.base",
8586
"TipColor": "terrain.grass.tip"
87+
},
88+
"scene:Game Scene/PineForest": {
89+
"Model": "nature.tree"
8690
}
8791
```
8892

8993
A theme whose material has no normal map empties the normal map property: the
90-
bumps of the starter's texture would not match the theme's. Custom objects
91-
don't have to be mapped, but the children of a custom object that are 3D models
92-
or cubes do (list the placeholders of the terrain and the grass in
93-
`ignoredObjects`).
94+
bumps of the starter's texture would not match the theme's. A theme model keeps
95+
the other properties of the object, like the size of the scattered copies.
96+
Custom objects don't have to be mapped, except those drawing a 3D model (like
97+
scattered models), which are mapped or listed in `ignoredObjects` like 3D
98+
models. The children of a custom object that are 3D models or cubes are mapped
99+
too (list the placeholders of the terrain and the grass in `ignoredObjects`).
94100

95101
**When you add a 3D starter, add its `theme-slots.json`.** The build refuses to
96102
publish a 3D starter without one, or one where a 3D model or cube is neither

‎scripts/lib/StarterThemer.js‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const getSizeRatio = (objectContent, themeContent) => {
166166
* its name, behaviors, variables, instances, origin and center; a cube face
167167
* points at the theme image; a skybox effect points at the six theme images;
168168
* a property of a custom object points at the theme image (and the normal map
169-
* of a material) or takes the theme color.
169+
* of a material) or model, or takes the theme color.
170170
*
171171
* Each theme file becomes a resource named like in the asset store, and the
172172
* resources of the starter that nothing uses anymore are removed.
@@ -251,7 +251,8 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => {
251251
/**
252252
* Theme the properties of a custom object: an image resource takes the
253253
* texture of a texture or material slot, along with the normal map of the
254-
* material if the starter maps one; a color takes the color of a color slot.
254+
* material if the starter maps one; a 3D model resource takes the model of
255+
* a model slot; a color takes the color of a color slot.
255256
* @param {any} objectContent
256257
* @param {Object.<string, string | import('./ThemeSlots.js').MaterialMapping>} mapping
257258
* @returns {boolean} Whether a property changed.
@@ -274,6 +275,24 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => {
274275
changed = true;
275276
return;
276277
}
278+
if (
279+
slot &&
280+
slot.kind === 'model' &&
281+
typeof propertyMapping === 'string'
282+
) {
283+
// The custom object keeps its own settings for the model (like the
284+
// size of scattered copies): only the model changes.
285+
const themeModelResourceName = useThemeResource(
286+
String(objectContent[property] || ''),
287+
slotId,
288+
slot,
289+
{ kind: 'model3D', metadata: '', userAdded: false }
290+
);
291+
if (!themeModelResourceName) return;
292+
objectContent[property] = themeModelResourceName;
293+
changed = true;
294+
return;
295+
}
277296
if (!slot || (slot.kind !== 'texture' && slot.kind !== 'material')) {
278297
missingSlots.add(slotId);
279298
return;

‎scripts/lib/ThemeSlots.js‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,19 @@ const cubeFaceProperties = [
4848
'bottomFaceResourceName',
4949
];
5050

51+
/**
52+
* The type of a property of a custom object, and for a resource, its kind
53+
* (like "image" or "model3D").
54+
* @typedef {{type: string, resourceKind: string}} PropertyType
55+
*/
56+
5157
/**
5258
* The properties of each custom object type of a project, by type.
5359
* @param {any} projectObject
54-
* @returns {Map<string, Map<string, string>>}
60+
* @returns {Map<string, Map<string, PropertyType>>}
5561
*/
5662
const getCustomObjectPropertyTypes = (projectObject) => {
57-
/** @type {Map<string, Map<string, string>>} */
63+
/** @type {Map<string, Map<string, PropertyType>>} */
5864
const propertyTypesByObjectType = new Map();
5965
(projectObject.eventsFunctionsExtensions || []).forEach(
6066
/** @param {any} extension */ (extension) =>
@@ -66,7 +72,13 @@ const getCustomObjectPropertyTypes = (projectObject) => {
6672
(eventsBasedObject.propertyDescriptors || []).map(
6773
/** @param {any} property */ (property) => [
6874
property.name,
69-
property.type,
75+
{
76+
type: property.type,
77+
resourceKind:
78+
property.type === 'Resource'
79+
? (property.extraInformation || [])[0] || ''
80+
: '',
81+
},
7082
]
7183
)
7284
)
@@ -83,15 +95,15 @@ const getCustomObjectPropertyTypes = (projectObject) => {
8395
* @param {any} projectObject
8496
* @returns {{
8597
* objects: Array<{path: string, type: string}>,
86-
* customObjects: Array<{path: string, type: string, propertyTypes: Map<string, string>}>,
98+
* customObjects: Array<{path: string, type: string, propertyTypes: Map<string, PropertyType>}>,
8799
* effects: Array<string>,
88100
* is3D: boolean,
89101
* }}
90102
*/
91103
const getThemeableObjects = (projectObject) => {
92104
/** @type {Array<{path: string, type: string}>} */
93105
const objects = [];
94-
/** @type {Array<{path: string, type: string, propertyTypes: Map<string, string>}>} */
106+
/** @type {Array<{path: string, type: string, propertyTypes: Map<string, PropertyType>}>} */
95107
const customObjects = [];
96108
/** @type {Array<string>} */
97109
const effects = [];
@@ -260,27 +272,42 @@ const checkStarterThemeSlots = (
260272

261273
/**
262274
* @param {string} objectPath
263-
* @param {Map<string, string>} propertyTypes
275+
* @param {Map<string, PropertyType>} propertyTypes
264276
* @param {string} property
265277
* @param {string} expectedType
266278
* @returns {boolean}
267279
*/
268280
const checkProperty = (objectPath, propertyTypes, property, expectedType) => {
269281
const propertyType = propertyTypes.get(property);
270-
if (propertyType === expectedType) return true;
282+
if (propertyType && propertyType.type === expectedType) return true;
271283
errors.push(
272284
new Error(
273285
propertyType
274-
? `Starter "${slug}": "${objectPath}" maps its property "${property}", a ${propertyType}, but only resources and colors can be themed.`
286+
? `Starter "${slug}": "${objectPath}" maps its property "${property}", a ${propertyType.type}, but only resources and colors can be themed.`
275287
: `Starter "${slug}": "${objectPath}" maps the property "${property}", which its object does not have.`
276288
)
277289
);
278290
return false;
279291
};
280292

281-
customObjects.forEach(({ path: objectPath, propertyTypes }) => {
293+
customObjects.forEach(({ path: objectPath, type, propertyTypes }) => {
294+
if (ignored.has(objectPath)) return;
282295
const mapping = mapped[objectPath];
283-
if (mapping === undefined) return;
296+
if (mapping === undefined) {
297+
// Like 3D model objects, the models drawn by a custom object (like
298+
// scattered trees) must follow the theme or be explicitly kept.
299+
const hasModel = [...propertyTypes.values()].some(
300+
(propertyType) => propertyType.resourceKind === 'model3D'
301+
);
302+
if (hasModel) {
303+
errors.push(
304+
new Error(
305+
`Starter "${slug}": the ${type} "${objectPath}" draws a 3D model but is neither mapped to slots nor listed in ignoredObjects in theme-slots.json.`
306+
)
307+
);
308+
}
309+
return;
310+
}
284311
if (typeof mapping === 'string') {
285312
errors.push(
286313
new Error(
@@ -306,12 +333,18 @@ const checkStarterThemeSlots = (
306333
return;
307334
}
308335
const propertyType = propertyTypes.get(property);
309-
if (propertyType === 'Color') {
336+
if (propertyType && propertyType.type === 'Color') {
310337
checkSlot(where, propertyMapping, 'color');
311338
} else if (
312339
checkProperty(objectPath, propertyTypes, property, 'Resource')
313340
) {
314-
checkSlot(where, propertyMapping, 'texture');
341+
checkSlot(
342+
where,
343+
propertyMapping,
344+
propertyType && propertyType.resourceKind === 'model3D'
345+
? 'model'
346+
: 'texture'
347+
);
315348
}
316349
});
317350
});
@@ -326,7 +359,7 @@ const checkStarterThemeSlots = (
326359
}
327360
});
328361
ignored.forEach((objectPath) => {
329-
if (!typeByPath.has(objectPath)) {
362+
if (!typeByPath.has(objectPath) && !customObjectByPath.has(objectPath)) {
330363
errors.push(
331364
new Error(
332365
`Starter "${slug}": theme-slots.json ignores "${objectPath}", which is not a 3D object of the starter anymore.`

‎scripts/lib/__tests__/StarterThemer.spec.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,56 @@ describe('applyThemeToStarter on custom objects', () => {
659659
expect(terrainContent.Layer2NormalMap).toBe('');
660660
});
661661

662+
it('gives scattered models the model of a model slot, keeping their size', () => {
663+
/** @type {any} */
664+
const projectContent = {
665+
resources: {
666+
resources: [
667+
{ name: 'Pine.glb', file: 'assets/Pine.glb', kind: 'model3D' },
668+
],
669+
},
670+
layouts: [
671+
{
672+
name: 'Game Scene',
673+
layers: [],
674+
objects: [
675+
{
676+
name: 'Forest',
677+
type: 'Terrain3D::ScatteredModels',
678+
content: { Model: 'Pine.glb', Size: 300 },
679+
},
680+
],
681+
},
682+
],
683+
};
684+
const result = applyThemeToStarter(
685+
projectContent,
686+
{
687+
version: 1,
688+
objects: { 'scene:Game Scene/Forest': { Model: 'nature.tree' } },
689+
},
690+
{
691+
...makeTerrainTheme(),
692+
slots: {
693+
'nature.tree': {
694+
kind: 'model',
695+
file: 'https://asset-resources.gdevelop.io/public-resources/Palm.glb',
696+
resourceName: 'Palm.glb',
697+
objectContent: { modelResourceName: 'Palm.glb', rotationX: 90 },
698+
},
699+
},
700+
}
701+
);
702+
703+
expect(projectContent.layouts[0].objects[0].content).toEqual({
704+
Model: 'Palm.glb',
705+
Size: 300,
706+
});
707+
expect(getResource(projectContent, 'Palm.glb').kind).toBe('model3D');
708+
expect(getResource(projectContent, 'Pine.glb')).toBeUndefined();
709+
expect(result.appliedSlots).toEqual(['nature.tree']);
710+
});
711+
662712
it('gives a cube face the texture of a material', () => {
663713
const projectContent = makeProjectContent();
664714
applyThemeToStarter(projectContent, makeStarterThemeSlots(), {

‎scripts/lib/__tests__/ThemeSlots.spec.js‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const vocabulary = {
1212
version: 1,
1313
slots: [
1414
{ id: 'character.player', kind: 'model', label: 'Player character' },
15+
{ id: 'nature.tree', kind: 'model', label: 'Tree' },
1516
{ id: 'env.ground', kind: 'texture', label: 'Ground' },
1617
{ id: 'env.wall', kind: 'texture', label: 'Wall' },
1718
{ id: 'sky.day', kind: 'skybox', label: 'Daytime sky' },
@@ -91,6 +92,30 @@ const createFakeTerrainProjectObject = () => {
9192
return projectObject;
9293
};
9394

95+
/** @returns {any} */
96+
const createFakeForestProjectObject = () => {
97+
/** @type {any} */
98+
const projectObject = createFakeProjectObject();
99+
projectObject.layouts[0].objects.push({
100+
name: 'Forest',
101+
type: 'Terrain3D::ScatteredModels',
102+
});
103+
projectObject.eventsFunctionsExtensions.push({
104+
name: 'Terrain3D',
105+
eventsBasedObjects: [
106+
{
107+
name: 'ScatteredModels',
108+
objects: [],
109+
propertyDescriptors: [
110+
{ name: 'Model', type: 'Resource', extraInformation: ['model3D'] },
111+
{ name: 'Size', type: 'Number' },
112+
],
113+
},
114+
],
115+
});
116+
return projectObject;
117+
};
118+
94119
/**
95120
* @param {StarterThemeSlots['objects'][string]} terrainMapping
96121
* @returns {StarterThemeSlots}
@@ -326,6 +351,63 @@ describe('checkStarterThemeSlots', () => {
326351
]);
327352
});
328353

354+
it('accepts a custom object mapping its 3D model to a model slot', () => {
355+
const starterThemeSlots = createFakeStarterThemeSlots();
356+
starterThemeSlots.objects['scene:Game Scene/Forest'] = {
357+
Model: 'nature.tree',
358+
};
359+
360+
expect(
361+
checkStarterThemeSlots(
362+
vocabulary,
363+
'starting-3d-test',
364+
createFakeForestProjectObject(),
365+
starterThemeSlots
366+
)
367+
).toEqual([]);
368+
});
369+
370+
it('requires a model slot for the 3D model of a custom object', () => {
371+
const starterThemeSlots = createFakeStarterThemeSlots();
372+
starterThemeSlots.objects['scene:Game Scene/Forest'] = {
373+
Model: 'env.ground',
374+
};
375+
376+
const errors = checkStarterThemeSlots(
377+
vocabulary,
378+
'starting-3d-test',
379+
createFakeForestProjectObject(),
380+
starterThemeSlots
381+
);
382+
383+
expect(errors.map((error) => error.message)).toEqual([
384+
'Starter "starting-3d-test": "scene:Game Scene/Forest" property "Model" maps to "env.ground", a texture slot, but needs a model slot.',
385+
]);
386+
});
387+
388+
it('requires a custom object drawing a 3D model to be mapped or ignored', () => {
389+
const errors = checkStarterThemeSlots(
390+
vocabulary,
391+
'starting-3d-test',
392+
createFakeForestProjectObject(),
393+
createFakeStarterThemeSlots()
394+
);
395+
const starterThemeSlots = createFakeStarterThemeSlots();
396+
(starterThemeSlots.ignoredObjects || []).push('scene:Game Scene/Forest');
397+
398+
expect(errors.map((error) => error.message)).toEqual([
399+
'Starter "starting-3d-test": the Terrain3D::ScatteredModels "scene:Game Scene/Forest" draws a 3D model but is neither mapped to slots nor listed in ignoredObjects in theme-slots.json.',
400+
]);
401+
expect(
402+
checkStarterThemeSlots(
403+
vocabulary,
404+
'starting-3d-test',
405+
createFakeForestProjectObject(),
406+
starterThemeSlots
407+
)
408+
).toEqual([]);
409+
});
410+
329411
it('requires a custom object to map its properties', () => {
330412
const errors = checkStarterThemeSlots(
331413
vocabulary,

0 commit comments

Comments
 (0)