From d44d02e002d9fc60a77b96172e3dce6b176ea3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:38:09 +0000 Subject: [PATCH 1/2] Let themes fill the properties of custom objects, like the 3D terrain - New slot kinds: "material" (a texture with its normal map) and "color". - A starter maps the properties of a custom object in theme-slots.json: an image to a texture slot, a color to a color slot, or a layer texture to a material slot along with the property taking its normal map. A theme without a normal map for it empties that property. - New slots for the terrain of the starters with a map: terrain.ground, terrain.dirt, terrain.road, terrain.rock, terrain.sand, terrain.pavement, and the colors of the grass blades. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wa8uKpYuQrYqGYnqGCsquj --- README.md | 22 +++ scripts/lib/StarterThemer.js | 108 ++++++++++- scripts/lib/ThemeSlots.js | 142 +++++++++++++- scripts/lib/__tests__/StarterThemer.spec.js | 196 ++++++++++++++++++++ scripts/lib/__tests__/ThemeSlots.spec.js | 114 ++++++++++++ theme-slots.json | 40 ++++ 6 files changed, 606 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8255ba5c1..41e5f5afd 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,28 @@ slot for all its faces, or to one slot per face. A skybox effect, referred to as `effect://`, maps to one skybox slot: the theme's skybox replaces its six faces. +A custom object, like the 3D terrain, maps some of its properties. An image +property maps to a texture slot, a color property to a color slot, and the +texture of a terrain layer to a *material* slot, a texture with its normal map, +along with the property that takes the normal map: + +```json +"scene:Game Scene/Terrain": { + "Layer1Texture": { "slot": "terrain.ground", "normalMap": "Layer1NormalMap" }, + "Layer2Texture": { "slot": "terrain.road", "normalMap": "Layer2NormalMap" } +}, +"scene:Game Scene/Grass": { + "BaseColor": "terrain.grass.base", + "TipColor": "terrain.grass.tip" +} +``` + +A theme whose material has no normal map empties the normal map property: the +bumps of the starter's texture would not match the theme's. Custom objects +don't have to be mapped, but the children of a custom object that are 3D models +or cubes do (list the placeholders of the terrain and the grass in +`ignoredObjects`). + **When you add a 3D starter, add its `theme-slots.json`.** The build refuses to publish a 3D starter without one, or one where a 3D model or cube is neither mapped nor listed in `ignoredObjects`, and it names the object: diff --git a/scripts/lib/StarterThemer.js b/scripts/lib/StarterThemer.js index 11d1c40fc..ddc963926 100644 --- a/scripts/lib/StarterThemer.js +++ b/scripts/lib/StarterThemer.js @@ -12,10 +12,18 @@ * origin?: {name: string, identifier: string}, * }} ThemeFile * + * A material slot is a texture with, if the theme has one, its normal map. A + * color slot is a color, like "52;104;36". * @typedef {ThemeFile & { * kind: 'model' | 'texture', * objectContent?: any, * assetStoreId?: string, + * } | ThemeFile & { + * kind: 'material', + * normalMap?: ThemeFile, + * } | { + * kind: 'color', + * color: string, * } | { * kind: 'skybox', * faces: Object., @@ -156,7 +164,9 @@ const getSizeRatio = (objectContent, themeContent) => { * Each such object gets the theme's asset for that slot: a 3D model takes the * theme model's dimensions, rotation, material and animations while keeping * its name, behaviors, variables, instances, origin and center; a cube face - * points at the theme image; a skybox effect points at the six theme images. + * points at the theme image; a skybox effect points at the six theme images; + * a property of a custom object points at the theme image (and the normal map + * of a material) or takes the theme color. * * Each theme file becomes a resource named like in the asset store, and the * resources of the starter that nothing uses anymore are removed. @@ -188,14 +198,23 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { /** * Add a file of a slot to the project as a resource configured like the - * resource it replaces, and return its name. + * resource it replaces, and return its name. Without a resource to replace + * (an empty property of a custom object), it is configured like + * `defaultResource`, if given. * @param {string} replacedResourceName * @param {string} slotId * @param {ThemeFile} themeFile + * @param {any=} defaultResource * @returns {string | null} */ - const useThemeResource = (replacedResourceName, slotId, themeFile) => { - const replacedResource = resourceByName.get(replacedResourceName); + const useThemeResource = ( + replacedResourceName, + slotId, + themeFile, + defaultResource + ) => { + const replacedResource = + resourceByName.get(replacedResourceName) || defaultResource; if (!replacedResource) return null; // A resource of the starter can already have that name: number the new one. @@ -225,10 +244,75 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { appliedSlots.add(slotId); themeResourceNames.add(name); - replacedResourceNames.add(replacedResourceName); + if (replacedResourceName) replacedResourceNames.add(replacedResourceName); return name; }; + /** + * Theme the properties of a custom object: an image resource takes the + * texture of a texture or material slot, along with the normal map of the + * material if the starter maps one; a color takes the color of a color slot. + * @param {any} objectContent + * @param {Object.} mapping + * @returns {boolean} Whether a property changed. + */ + const themeCustomObjectProperties = (objectContent, mapping) => { + let changed = false; + Object.entries(mapping).forEach(([property, propertyMapping]) => { + const slotId = + typeof propertyMapping === 'string' + ? propertyMapping + : propertyMapping.slot; + const slot = theme.slots[slotId]; + if ( + slot && + slot.kind === 'color' && + typeof propertyMapping === 'string' + ) { + objectContent[property] = slot.color; + appliedSlots.add(slotId); + changed = true; + return; + } + if (!slot || (slot.kind !== 'texture' && slot.kind !== 'material')) { + missingSlots.add(slotId); + return; + } + + const themeTextureResourceName = useThemeResource( + String(objectContent[property] || ''), + slotId, + slot, + { kind: 'image', metadata: '', smoothed: true, userAdded: false } + ); + if (!themeTextureResourceName) return; + objectContent[property] = themeTextureResourceName; + changed = true; + if (typeof propertyMapping === 'string') return; + + const normalMapProperty = propertyMapping.normalMap; + const normalMapResourceName = String( + objectContent[normalMapProperty] || '' + ); + if (slot.kind === 'material' && slot.normalMap) { + objectContent[normalMapProperty] = + useThemeResource( + normalMapResourceName, + slotId, + slot.normalMap, + resourceByName.get(themeTextureResourceName) + ) || ''; + } else { + // The bumps of another texture would not match the theme texture. + objectContent[normalMapProperty] = ''; + if (normalMapResourceName) { + replacedResourceNames.add(normalMapResourceName); + } + } + }); + return changed; + }; + Object.entries(starterThemeSlots.objects || {}).forEach( ([objectPath, mapping]) => { const object = objectsByPath.get(objectPath); @@ -274,12 +358,14 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { if (object.type === 'Scene3D::Cube3DObject') { let changed = false; Object.keys(cubeFaceProperties).forEach((face) => { - const slotId = typeof mapping === 'string' ? mapping : mapping[face]; + const faceMapping = + typeof mapping === 'string' ? mapping : mapping[face]; + const slotId = typeof faceMapping === 'string' ? faceMapping : null; const faceProperty = cubeFaceProperties[face]; const resourceName = objectContent[faceProperty]; if (!slotId || !resourceName) return; const slot = theme.slots[slotId]; - if (!slot || slot.kind !== 'texture') { + if (!slot || (slot.kind !== 'texture' && slot.kind !== 'material')) { missingSlots.add(slotId); return; } @@ -293,6 +379,14 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { changed = true; }); if (changed) changedObjectsCount++; + return; + } + + if ( + typeof mapping !== 'string' && + themeCustomObjectProperties(objectContent, mapping) + ) { + changedObjectsCount++; } } ); diff --git a/scripts/lib/ThemeSlots.js b/scripts/lib/ThemeSlots.js index c6f179d05..8e7892d40 100644 --- a/scripts/lib/ThemeSlots.js +++ b/scripts/lib/ThemeSlots.js @@ -9,7 +9,7 @@ const fs = require('fs').promises; * slots: Array<{id: string, kind: ThemeSlotKind, label: string}>, * }} ThemeSlotsVocabulary * - * @typedef {'model' | 'texture' | 'skybox'} ThemeSlotKind + * @typedef {'model' | 'texture' | 'material' | 'skybox' | 'color'} ThemeSlotKind */ /** @@ -18,12 +18,19 @@ const fs = require('fs').promises; * `global/` or `object:::/`. A cube * maps either to one texture slot for all six faces, or to one slot per face. * A skybox effect (`effect://`) maps to one skybox slot. + * + * A custom object (like a 3D terrain) maps its properties: an image resource + * to a texture slot, a color to a color slot, or an image resource to a + * material slot along with the property taking the normal map of the material + * (`{"slot": "terrain.ground", "normalMap": "Layer1NormalMap"}`). * @typedef {{ * version: number, - * objects: Object.>, + * objects: Object.>, * effects?: Object., * ignoredObjects?: Array, * }} StarterThemeSlots + * + * @typedef {{slot: string, normalMap: string}} MaterialMapping */ const vocabularyPath = path.join(__dirname, '../../theme-slots.json'); @@ -41,24 +48,71 @@ const cubeFaceProperties = [ 'bottomFaceResourceName', ]; +/** + * The properties of each custom object type of a project, by type. + * @param {any} projectObject + * @returns {Map>} + */ +const getCustomObjectPropertyTypes = (projectObject) => { + /** @type {Map>} */ + const propertyTypesByObjectType = new Map(); + (projectObject.eventsFunctionsExtensions || []).forEach( + /** @param {any} extension */ (extension) => + (extension.eventsBasedObjects || []).forEach( + /** @param {any} eventsBasedObject */ (eventsBasedObject) => + propertyTypesByObjectType.set( + `${extension.name}::${eventsBasedObject.name}`, + new Map( + (eventsBasedObject.propertyDescriptors || []).map( + /** @param {any} property */ (property) => [ + property.name, + property.type, + ] + ) + ) + ) + ) + ); + return propertyTypesByObjectType; +}; + /** * Every object of a project a theme could re-skin, with the path a mapping - * refers to it by, plus every skybox effect. + * refers to it by, plus every skybox effect. Custom objects are listed apart: + * a starter maps those of their properties it wants themed, if any. * @param {any} projectObject - * @returns {{objects: Array<{path: string, type: string}>, effects: Array, is3D: boolean}} + * @returns {{ + * objects: Array<{path: string, type: string}>, + * customObjects: Array<{path: string, type: string, propertyTypes: Map}>, + * effects: Array, + * is3D: boolean, + * }} */ const getThemeableObjects = (projectObject) => { /** @type {Array<{path: string, type: string}>} */ const objects = []; + /** @type {Array<{path: string, type: string, propertyTypes: Map}>} */ + const customObjects = []; /** @type {Array} */ const effects = []; const themeableTypes = ['Scene3D::Model3DObject', 'Scene3D::Cube3DObject']; + const propertyTypesByObjectType = getCustomObjectPropertyTypes(projectObject); /** @param {string} prefix @param {Array} list */ const collect = (prefix, list) => (list || []).forEach((object) => { + const objectPath = `${prefix}/${object.name}`; if (themeableTypes.includes(object.type)) { - objects.push({ path: `${prefix}/${object.name}`, type: object.type }); + objects.push({ path: objectPath, type: object.type }); + return; + } + const propertyTypes = propertyTypesByObjectType.get(object.type); + if (propertyTypes) { + customObjects.push({ + path: objectPath, + type: object.type, + propertyTypes, + }); } }); @@ -91,7 +145,12 @@ const getThemeableObjects = (projectObject) => { ) ); - return { objects, effects, is3D: objects.length > 0 || effects.length > 0 }; + return { + objects, + customObjects, + effects, + is3D: objects.length > 0 || effects.length > 0, + }; }; /** @@ -113,7 +172,8 @@ const checkStarterThemeSlots = ( ) => { /** @type {Error[]} */ const errors = []; - const { objects, effects, is3D } = getThemeableObjects(projectObject); + const { objects, customObjects, effects, is3D } = + getThemeableObjects(projectObject); if (!is3D) return errors; if (!starterThemeSlots) { @@ -131,6 +191,9 @@ const checkStarterThemeSlots = ( const typeByPath = new Map( objects.map((object) => [object.path, object.type]) ); + const customObjectByPath = new Map( + customObjects.map((customObject) => [customObject.path, customObject]) + ); const effectPaths = new Set(effects); const mapped = starterThemeSlots.objects || {}; const ignored = new Set(starterThemeSlots.ignoredObjects || []); @@ -179,7 +242,10 @@ const checkStarterThemeSlots = ( checkSlot(`"${objectPath}"`, mapping, 'texture'); } else { Object.entries(mapping).forEach(([face, slotId]) => { - if (!cubeFaceProperties.some((property) => property.startsWith(face))) { + if ( + typeof slotId !== 'string' || + !cubeFaceProperties.some((property) => property.startsWith(face)) + ) { errors.push( new Error( `Starter "${slug}": "${objectPath}" maps the unknown cube face "${face}".` @@ -192,8 +258,66 @@ const checkStarterThemeSlots = ( } }); + /** + * @param {string} objectPath + * @param {Map} propertyTypes + * @param {string} property + * @param {string} expectedType + * @returns {boolean} + */ + const checkProperty = (objectPath, propertyTypes, property, expectedType) => { + const propertyType = propertyTypes.get(property); + if (propertyType === expectedType) return true; + errors.push( + new Error( + propertyType + ? `Starter "${slug}": "${objectPath}" maps its property "${property}", a ${propertyType}, but only resources and colors can be themed.` + : `Starter "${slug}": "${objectPath}" maps the property "${property}", which its object does not have.` + ) + ); + return false; + }; + + customObjects.forEach(({ path: objectPath, propertyTypes }) => { + const mapping = mapped[objectPath]; + if (mapping === undefined) return; + if (typeof mapping === 'string') { + errors.push( + new Error( + `Starter "${slug}": "${objectPath}" is a custom object and must map its properties to slots.` + ) + ); + return; + } + Object.entries(mapping).forEach(([property, propertyMapping]) => { + const where = `"${objectPath}" property "${property}"`; + if (typeof propertyMapping !== 'string') { + if ( + checkProperty(objectPath, propertyTypes, property, 'Resource') && + checkProperty( + objectPath, + propertyTypes, + propertyMapping.normalMap, + 'Resource' + ) + ) { + checkSlot(where, propertyMapping.slot, 'material'); + } + return; + } + const propertyType = propertyTypes.get(property); + if (propertyType === 'Color') { + checkSlot(where, propertyMapping, 'color'); + } else if ( + checkProperty(objectPath, propertyTypes, property, 'Resource') + ) { + checkSlot(where, propertyMapping, 'texture'); + } + }); + }); + Object.keys(mapped).forEach((objectPath) => { - if (!typeByPath.has(objectPath)) { + if (!typeByPath.has(objectPath) && !customObjectByPath.has(objectPath)) { errors.push( new Error( `Starter "${slug}": theme-slots.json maps "${objectPath}", which is not a 3D object of the starter anymore.` diff --git a/scripts/lib/__tests__/StarterThemer.spec.js b/scripts/lib/__tests__/StarterThemer.spec.js index e0f58ccd5..57b08ab9a 100644 --- a/scripts/lib/__tests__/StarterThemer.spec.js +++ b/scripts/lib/__tests__/StarterThemer.spec.js @@ -476,6 +476,202 @@ describe('applyThemeToStarter', () => { }); }); +/** @returns {StarterThemeSlots} */ +const makeTerrainThemeSlots = () => ({ + version: 1, + objects: { + 'scene:Game Scene/Terrain': { + Layer1Texture: { slot: 'terrain.ground', normalMap: 'Layer1NormalMap' }, + Layer2Texture: { slot: 'terrain.road', normalMap: 'Layer2NormalMap' }, + Layer3Texture: { slot: 'terrain.rock', normalMap: 'Layer3NormalMap' }, + }, + 'scene:Game Scene/Grass': { + BaseColor: 'terrain.grass.base', + TipColor: 'terrain.grass.tip', + }, + }, +}); + +/** @returns {Theme} */ +const makeTerrainTheme = () => ({ + id: 'winter', + name: 'Winter', + description: 'Snowy mountains.', + slots: { + 'terrain.ground': { + kind: 'material', + file: 'https://asset-resources.gdevelop.io/public-resources/Snow_Color.jpg', + resourceName: 'Snow_Color.jpg', + normalMap: { + file: 'https://asset-resources.gdevelop.io/public-resources/Snow_Normal.jpg', + resourceName: 'Snow_Normal.jpg', + }, + }, + 'terrain.road': { + kind: 'material', + file: 'https://asset-resources.gdevelop.io/public-resources/Ice.jpg', + resourceName: 'Ice.jpg', + }, + 'terrain.grass.base': { kind: 'color', color: '120;130;140' }, + 'terrain.grass.tip': { kind: 'color', color: '230;235;240' }, + }, +}); + +/** @returns {any} */ +const makeTerrainProjectContent = () => ({ + resources: { + resources: [ + { name: 'Grass.jpg', file: 'assets/Grass.jpg', kind: 'image' }, + { + name: 'GrassNormal.jpg', + file: 'assets/GrassNormal.jpg', + kind: 'image', + smoothed: true, + }, + { name: 'Asphalt.jpg', file: 'assets/Asphalt.jpg', kind: 'image' }, + { + name: 'AsphaltNormal.jpg', + file: 'assets/AsphaltNormal.jpg', + kind: 'image', + }, + ], + }, + layouts: [ + { + name: 'Game Scene', + layers: [], + objects: [ + { + name: 'Terrain', + type: 'Terrain3D::Terrain3D', + content: { + Layer1Texture: 'Grass.jpg', + Layer1NormalMap: '', + Layer2Texture: 'Asphalt.jpg', + Layer2NormalMap: 'AsphaltNormal.jpg', + Layer3Texture: '', + Layer3NormalMap: '', + }, + }, + { + name: 'Grass', + type: 'Terrain3D::TerrainGrass', + content: { BaseColor: '40;62;22', TipColor: '112;130;52' }, + }, + ], + }, + ], +}); + +describe('applyThemeToStarter on custom objects', () => { + /** @param {any} projectContent */ + const applyWinterTheme = (projectContent) => + applyThemeToStarter( + projectContent, + makeTerrainThemeSlots(), + makeTerrainTheme() + ); + + it('gives a terrain layer the texture and the normal map of a material', () => { + const projectContent = makeTerrainProjectContent(); + applyWinterTheme(projectContent); + + const terrainContent = projectContent.layouts[0].objects[0].content; + expect(terrainContent.Layer1Texture).toBe('Snow_Color.jpg'); + expect(terrainContent.Layer1NormalMap).toBe('Snow_Normal.jpg'); + expect(getResource(projectContent, 'Snow_Normal.jpg')).toEqual({ + name: 'Snow_Normal.jpg', + file: 'https://asset-resources.gdevelop.io/public-resources/Snow_Normal.jpg', + kind: 'image', + origin: { + name: 'gdevelop-asset-store', + identifier: + 'https://asset-resources.gdevelop.io/public-resources/Snow_Normal.jpg', + }, + }); + expect(getResource(projectContent, 'Grass.jpg')).toBeUndefined(); + }); + + it('removes the normal map of a layer whose material has none', () => { + const projectContent = makeTerrainProjectContent(); + applyWinterTheme(projectContent); + + const terrainContent = projectContent.layouts[0].objects[0].content; + expect(terrainContent.Layer2Texture).toBe('Ice.jpg'); + expect(terrainContent.Layer2NormalMap).toBe(''); + expect(getResource(projectContent, 'AsphaltNormal.jpg')).toBeUndefined(); + }); + + it('leaves a layer untouched when the theme does not fill its slot', () => { + const projectContent = makeTerrainProjectContent(); + const result = applyWinterTheme(projectContent); + + const terrainContent = projectContent.layouts[0].objects[0].content; + expect(terrainContent.Layer3Texture).toBe(''); + expect(result.missingSlots).toEqual(['terrain.rock']); + }); + + it('gives a color property the color of a color slot', () => { + const projectContent = makeTerrainProjectContent(); + const result = applyWinterTheme(projectContent); + + expect(projectContent.layouts[0].objects[1].content).toEqual({ + BaseColor: '120;130;140', + TipColor: '230;235;240', + }); + expect(result.appliedSlots).toEqual([ + 'terrain.grass.base', + 'terrain.grass.tip', + 'terrain.ground', + 'terrain.road', + ]); + expect(result.changedObjectsCount).toBe(2); + }); + + it('fills a layer that had no texture', () => { + const projectContent = makeTerrainProjectContent(); + applyThemeToStarter(projectContent, makeTerrainThemeSlots(), { + ...makeTerrainTheme(), + slots: { 'terrain.rock': makeTerrainTheme().slots['terrain.ground'] }, + }); + + const terrainContent = projectContent.layouts[0].objects[0].content; + expect(terrainContent.Layer3Texture).toBe('Snow_Color.jpg'); + expect(terrainContent.Layer3NormalMap).toBe('Snow_Normal.jpg'); + expect(getResource(projectContent, 'Snow_Color.jpg').kind).toBe('image'); + }); + + it('gives a layer a texture without its normal map', () => { + const projectContent = makeTerrainProjectContent(); + applyThemeToStarter(projectContent, makeTerrainThemeSlots(), { + ...makeTerrainTheme(), + slots: { + 'terrain.road': { + kind: 'texture', + file: 'https://asset-resources.gdevelop.io/public-resources/Sand.png', + resourceName: 'Sand.png', + }, + }, + }); + + const terrainContent = projectContent.layouts[0].objects[0].content; + expect(terrainContent.Layer2Texture).toBe('Sand.png'); + expect(terrainContent.Layer2NormalMap).toBe(''); + }); + + it('gives a cube face the texture of a material', () => { + const projectContent = makeProjectContent(); + applyThemeToStarter(projectContent, makeStarterThemeSlots(), { + ...makeStarterTheme(), + slots: { 'env.ground': makeTerrainTheme().slots['terrain.ground'] }, + }); + + expect( + projectContent.layouts[0].objects[2].content.topFaceResourceName + ).toBe('Snow_Color.jpg'); + }); +}); + describe('getThemedStarterFileName', () => { it('names the themed copy after the starter, to sit next to it', () => { expect(getThemedStarterFileName('starting-3d-tank', 'pirate')).toBe( diff --git a/scripts/lib/__tests__/ThemeSlots.spec.js b/scripts/lib/__tests__/ThemeSlots.spec.js index 52cada748..5efa443d5 100644 --- a/scripts/lib/__tests__/ThemeSlots.spec.js +++ b/scripts/lib/__tests__/ThemeSlots.spec.js @@ -15,6 +15,8 @@ const vocabulary = { { id: 'env.ground', kind: 'texture', label: 'Ground' }, { id: 'env.wall', kind: 'texture', label: 'Wall' }, { id: 'sky.day', kind: 'skybox', label: 'Daytime sky' }, + { id: 'terrain.ground', kind: 'material', label: 'Terrain ground' }, + { id: 'terrain.grass.tip', kind: 'color', label: 'Grass tip color' }, ], }; @@ -63,6 +65,42 @@ const createFakeStarterThemeSlots = () => ({ ignoredObjects: ['scene:Game Scene/Camera'], }); +/** @returns {any} */ +const createFakeTerrainProjectObject = () => { + /** @type {any} */ + const projectObject = createFakeProjectObject(); + projectObject.layouts[0].objects.push({ + name: 'Terrain', + type: 'Terrain3D::Terrain3D', + }); + projectObject.eventsFunctionsExtensions.push({ + name: 'Terrain3D', + eventsBasedObjects: [ + { + name: 'Terrain3D', + objects: [], + propertyDescriptors: [ + { name: 'Layer1Texture', type: 'Resource' }, + { name: 'Layer1NormalMap', type: 'Resource' }, + { name: 'Layer1Color', type: 'Color' }, + { name: 'TextureSize', type: 'Number' }, + ], + }, + ], + }); + return projectObject; +}; + +/** + * @param {StarterThemeSlots['objects'][string]} terrainMapping + * @returns {StarterThemeSlots} + */ +const createFakeTerrainThemeSlots = (terrainMapping) => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Terrain'] = terrainMapping; + return starterThemeSlots; +}; + describe('getThemeableObjects', () => { it('lists the 3D objects of scenes and custom objects, and the skyboxes', () => { const { objects, effects, is3D } = getThemeableObjects( @@ -223,4 +261,80 @@ describe('checkStarterThemeSlots', () => { 'a texture slot, but needs a skybox slot' ); }); + + it('accepts a custom object mapping its resources and colors', () => { + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeTerrainProjectObject(), + createFakeTerrainThemeSlots({ + Layer1Texture: { + slot: 'terrain.ground', + normalMap: 'Layer1NormalMap', + }, + Layer1Color: 'terrain.grass.tip', + }) + ) + ).toEqual([]); + }); + + it('does not require custom objects to be mapped', () => { + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeTerrainProjectObject(), + createFakeStarterThemeSlots() + ) + ).toEqual([]); + }); + + it('reports a custom object property that does not exist or cannot be themed', () => { + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeTerrainProjectObject(), + createFakeTerrainThemeSlots({ + Layer9Texture: 'env.ground', + TextureSize: 'env.ground', + Layer1Texture: { slot: 'terrain.ground', normalMap: 'Missing' }, + }) + ); + + expect(errors.map((error) => error.message)).toEqual([ + 'Starter "starting-3d-test": "scene:Game Scene/Terrain" maps the property "Layer9Texture", which its object does not have.', + 'Starter "starting-3d-test": "scene:Game Scene/Terrain" maps its property "TextureSize", a Number, but only resources and colors can be themed.', + 'Starter "starting-3d-test": "scene:Game Scene/Terrain" maps the property "Missing", which its object does not have.', + ]); + }); + + it('requires a material slot for a texture with its normal map', () => { + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeTerrainProjectObject(), + createFakeTerrainThemeSlots({ + Layer1Texture: { slot: 'env.ground', normalMap: 'Layer1NormalMap' }, + Layer1Color: 'env.ground', + }) + ); + + expect(errors.map((error) => error.message)).toEqual([ + 'Starter "starting-3d-test": "scene:Game Scene/Terrain" property "Layer1Texture" maps to "env.ground", a texture slot, but needs a material slot.', + 'Starter "starting-3d-test": "scene:Game Scene/Terrain" property "Layer1Color" maps to "env.ground", a texture slot, but needs a color slot.', + ]); + }); + + it('requires a custom object to map its properties', () => { + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeTerrainProjectObject(), + createFakeTerrainThemeSlots('terrain.ground') + ); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('must map its properties to slots'); + }); }); diff --git a/theme-slots.json b/theme-slots.json index cf70338d5..0835de675 100644 --- a/theme-slots.json +++ b/theme-slots.json @@ -171,6 +171,46 @@ "kind": "texture", "label": "Farm plot or diggable ground" }, + { + "id": "terrain.ground", + "kind": "material", + "label": "Main ground of a terrain (grass, sand, snow...)" + }, + { + "id": "terrain.dirt", + "kind": "material", + "label": "Paths, trails and fields of a terrain" + }, + { + "id": "terrain.road", + "kind": "material", + "label": "Paved roads of a terrain" + }, + { + "id": "terrain.rock", + "kind": "material", + "label": "Cliffs and steep slopes of a terrain" + }, + { + "id": "terrain.sand", + "kind": "material", + "label": "Shores and beaches of a terrain" + }, + { + "id": "terrain.pavement", + "kind": "material", + "label": "Sidewalks and squares of a terrain" + }, + { + "id": "terrain.grass.base", + "kind": "color", + "label": "Color of the grass blades at their base" + }, + { + "id": "terrain.grass.tip", + "kind": "color", + "label": "Color of the grass blades at their tip" + }, { "id": "sky.day", "kind": "skybox", From 6541de0e9e7964108d8bc7102aeefd32ec9d2a7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:43:37 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01Wa8uKpYuQrYqGYnqGCsquj --- README.md | 16 ++-- scripts/lib/StarterThemer.js | 23 +++++- scripts/lib/ThemeSlots.js | 59 +++++++++++---- scripts/lib/__tests__/StarterThemer.spec.js | 50 +++++++++++++ scripts/lib/__tests__/ThemeSlots.spec.js | 82 +++++++++++++++++++++ 5 files changed, 210 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 41e5f5afd..4df4527ab 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ as `effect://`, maps to one skybox slot: the theme's skybox replaces its six faces. A custom object, like the 3D terrain, maps some of its properties. An image -property maps to a texture slot, a color property to a color slot, and the +property maps to a texture slot, a 3D model property (like the model of +scattered models) to a model slot, a color property to a color slot, and the texture of a terrain layer to a *material* slot, a texture with its normal map, along with the property that takes the normal map: @@ -83,14 +84,19 @@ along with the property that takes the normal map: "scene:Game Scene/Grass": { "BaseColor": "terrain.grass.base", "TipColor": "terrain.grass.tip" +}, +"scene:Game Scene/PineForest": { + "Model": "nature.tree" } ``` A theme whose material has no normal map empties the normal map property: the -bumps of the starter's texture would not match the theme's. Custom objects -don't have to be mapped, but the children of a custom object that are 3D models -or cubes do (list the placeholders of the terrain and the grass in -`ignoredObjects`). +bumps of the starter's texture would not match the theme's. A theme model keeps +the other properties of the object, like the size of the scattered copies. +Custom objects don't have to be mapped, except those drawing a 3D model (like +scattered models), which are mapped or listed in `ignoredObjects` like 3D +models. The children of a custom object that are 3D models or cubes are mapped +too (list the placeholders of the terrain and the grass in `ignoredObjects`). **When you add a 3D starter, add its `theme-slots.json`.** The build refuses to publish a 3D starter without one, or one where a 3D model or cube is neither diff --git a/scripts/lib/StarterThemer.js b/scripts/lib/StarterThemer.js index ddc963926..7a8e53cc3 100644 --- a/scripts/lib/StarterThemer.js +++ b/scripts/lib/StarterThemer.js @@ -166,7 +166,7 @@ const getSizeRatio = (objectContent, themeContent) => { * its name, behaviors, variables, instances, origin and center; a cube face * points at the theme image; a skybox effect points at the six theme images; * a property of a custom object points at the theme image (and the normal map - * of a material) or takes the theme color. + * of a material) or model, or takes the theme color. * * Each theme file becomes a resource named like in the asset store, and the * resources of the starter that nothing uses anymore are removed. @@ -251,7 +251,8 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { /** * Theme the properties of a custom object: an image resource takes the * texture of a texture or material slot, along with the normal map of the - * material if the starter maps one; a color takes the color of a color slot. + * material if the starter maps one; a 3D model resource takes the model of + * a model slot; a color takes the color of a color slot. * @param {any} objectContent * @param {Object.} mapping * @returns {boolean} Whether a property changed. @@ -274,6 +275,24 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { changed = true; return; } + if ( + slot && + slot.kind === 'model' && + typeof propertyMapping === 'string' + ) { + // The custom object keeps its own settings for the model (like the + // size of scattered copies): only the model changes. + const themeModelResourceName = useThemeResource( + String(objectContent[property] || ''), + slotId, + slot, + { kind: 'model3D', metadata: '', userAdded: false } + ); + if (!themeModelResourceName) return; + objectContent[property] = themeModelResourceName; + changed = true; + return; + } if (!slot || (slot.kind !== 'texture' && slot.kind !== 'material')) { missingSlots.add(slotId); return; diff --git a/scripts/lib/ThemeSlots.js b/scripts/lib/ThemeSlots.js index 8e7892d40..e752cb1bc 100644 --- a/scripts/lib/ThemeSlots.js +++ b/scripts/lib/ThemeSlots.js @@ -48,13 +48,19 @@ const cubeFaceProperties = [ 'bottomFaceResourceName', ]; +/** + * The type of a property of a custom object, and for a resource, its kind + * (like "image" or "model3D"). + * @typedef {{type: string, resourceKind: string}} PropertyType + */ + /** * The properties of each custom object type of a project, by type. * @param {any} projectObject - * @returns {Map>} + * @returns {Map>} */ const getCustomObjectPropertyTypes = (projectObject) => { - /** @type {Map>} */ + /** @type {Map>} */ const propertyTypesByObjectType = new Map(); (projectObject.eventsFunctionsExtensions || []).forEach( /** @param {any} extension */ (extension) => @@ -66,7 +72,13 @@ const getCustomObjectPropertyTypes = (projectObject) => { (eventsBasedObject.propertyDescriptors || []).map( /** @param {any} property */ (property) => [ property.name, - property.type, + { + type: property.type, + resourceKind: + property.type === 'Resource' + ? (property.extraInformation || [])[0] || '' + : '', + }, ] ) ) @@ -83,7 +95,7 @@ const getCustomObjectPropertyTypes = (projectObject) => { * @param {any} projectObject * @returns {{ * objects: Array<{path: string, type: string}>, - * customObjects: Array<{path: string, type: string, propertyTypes: Map}>, + * customObjects: Array<{path: string, type: string, propertyTypes: Map}>, * effects: Array, * is3D: boolean, * }} @@ -91,7 +103,7 @@ const getCustomObjectPropertyTypes = (projectObject) => { const getThemeableObjects = (projectObject) => { /** @type {Array<{path: string, type: string}>} */ const objects = []; - /** @type {Array<{path: string, type: string, propertyTypes: Map}>} */ + /** @type {Array<{path: string, type: string, propertyTypes: Map}>} */ const customObjects = []; /** @type {Array} */ const effects = []; @@ -260,27 +272,42 @@ const checkStarterThemeSlots = ( /** * @param {string} objectPath - * @param {Map} propertyTypes + * @param {Map} propertyTypes * @param {string} property * @param {string} expectedType * @returns {boolean} */ const checkProperty = (objectPath, propertyTypes, property, expectedType) => { const propertyType = propertyTypes.get(property); - if (propertyType === expectedType) return true; + if (propertyType && propertyType.type === expectedType) return true; errors.push( new Error( propertyType - ? `Starter "${slug}": "${objectPath}" maps its property "${property}", a ${propertyType}, but only resources and colors can be themed.` + ? `Starter "${slug}": "${objectPath}" maps its property "${property}", a ${propertyType.type}, but only resources and colors can be themed.` : `Starter "${slug}": "${objectPath}" maps the property "${property}", which its object does not have.` ) ); return false; }; - customObjects.forEach(({ path: objectPath, propertyTypes }) => { + customObjects.forEach(({ path: objectPath, type, propertyTypes }) => { + if (ignored.has(objectPath)) return; const mapping = mapped[objectPath]; - if (mapping === undefined) return; + if (mapping === undefined) { + // Like 3D model objects, the models drawn by a custom object (like + // scattered trees) must follow the theme or be explicitly kept. + const hasModel = [...propertyTypes.values()].some( + (propertyType) => propertyType.resourceKind === 'model3D' + ); + if (hasModel) { + errors.push( + new Error( + `Starter "${slug}": the ${type} "${objectPath}" draws a 3D model but is neither mapped to slots nor listed in ignoredObjects in theme-slots.json.` + ) + ); + } + return; + } if (typeof mapping === 'string') { errors.push( new Error( @@ -306,12 +333,18 @@ const checkStarterThemeSlots = ( return; } const propertyType = propertyTypes.get(property); - if (propertyType === 'Color') { + if (propertyType && propertyType.type === 'Color') { checkSlot(where, propertyMapping, 'color'); } else if ( checkProperty(objectPath, propertyTypes, property, 'Resource') ) { - checkSlot(where, propertyMapping, 'texture'); + checkSlot( + where, + propertyMapping, + propertyType && propertyType.resourceKind === 'model3D' + ? 'model' + : 'texture' + ); } }); }); @@ -326,7 +359,7 @@ const checkStarterThemeSlots = ( } }); ignored.forEach((objectPath) => { - if (!typeByPath.has(objectPath)) { + if (!typeByPath.has(objectPath) && !customObjectByPath.has(objectPath)) { errors.push( new Error( `Starter "${slug}": theme-slots.json ignores "${objectPath}", which is not a 3D object of the starter anymore.` diff --git a/scripts/lib/__tests__/StarterThemer.spec.js b/scripts/lib/__tests__/StarterThemer.spec.js index 57b08ab9a..72ab62e49 100644 --- a/scripts/lib/__tests__/StarterThemer.spec.js +++ b/scripts/lib/__tests__/StarterThemer.spec.js @@ -659,6 +659,56 @@ describe('applyThemeToStarter on custom objects', () => { expect(terrainContent.Layer2NormalMap).toBe(''); }); + it('gives scattered models the model of a model slot, keeping their size', () => { + /** @type {any} */ + const projectContent = { + resources: { + resources: [ + { name: 'Pine.glb', file: 'assets/Pine.glb', kind: 'model3D' }, + ], + }, + layouts: [ + { + name: 'Game Scene', + layers: [], + objects: [ + { + name: 'Forest', + type: 'Terrain3D::ScatteredModels', + content: { Model: 'Pine.glb', Size: 300 }, + }, + ], + }, + ], + }; + const result = applyThemeToStarter( + projectContent, + { + version: 1, + objects: { 'scene:Game Scene/Forest': { Model: 'nature.tree' } }, + }, + { + ...makeTerrainTheme(), + slots: { + 'nature.tree': { + kind: 'model', + file: 'https://asset-resources.gdevelop.io/public-resources/Palm.glb', + resourceName: 'Palm.glb', + objectContent: { modelResourceName: 'Palm.glb', rotationX: 90 }, + }, + }, + } + ); + + expect(projectContent.layouts[0].objects[0].content).toEqual({ + Model: 'Palm.glb', + Size: 300, + }); + expect(getResource(projectContent, 'Palm.glb').kind).toBe('model3D'); + expect(getResource(projectContent, 'Pine.glb')).toBeUndefined(); + expect(result.appliedSlots).toEqual(['nature.tree']); + }); + it('gives a cube face the texture of a material', () => { const projectContent = makeProjectContent(); applyThemeToStarter(projectContent, makeStarterThemeSlots(), { diff --git a/scripts/lib/__tests__/ThemeSlots.spec.js b/scripts/lib/__tests__/ThemeSlots.spec.js index 5efa443d5..14134a2e7 100644 --- a/scripts/lib/__tests__/ThemeSlots.spec.js +++ b/scripts/lib/__tests__/ThemeSlots.spec.js @@ -12,6 +12,7 @@ const vocabulary = { version: 1, slots: [ { id: 'character.player', kind: 'model', label: 'Player character' }, + { id: 'nature.tree', kind: 'model', label: 'Tree' }, { id: 'env.ground', kind: 'texture', label: 'Ground' }, { id: 'env.wall', kind: 'texture', label: 'Wall' }, { id: 'sky.day', kind: 'skybox', label: 'Daytime sky' }, @@ -91,6 +92,30 @@ const createFakeTerrainProjectObject = () => { return projectObject; }; +/** @returns {any} */ +const createFakeForestProjectObject = () => { + /** @type {any} */ + const projectObject = createFakeProjectObject(); + projectObject.layouts[0].objects.push({ + name: 'Forest', + type: 'Terrain3D::ScatteredModels', + }); + projectObject.eventsFunctionsExtensions.push({ + name: 'Terrain3D', + eventsBasedObjects: [ + { + name: 'ScatteredModels', + objects: [], + propertyDescriptors: [ + { name: 'Model', type: 'Resource', extraInformation: ['model3D'] }, + { name: 'Size', type: 'Number' }, + ], + }, + ], + }); + return projectObject; +}; + /** * @param {StarterThemeSlots['objects'][string]} terrainMapping * @returns {StarterThemeSlots} @@ -326,6 +351,63 @@ describe('checkStarterThemeSlots', () => { ]); }); + it('accepts a custom object mapping its 3D model to a model slot', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Forest'] = { + Model: 'nature.tree', + }; + + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeForestProjectObject(), + starterThemeSlots + ) + ).toEqual([]); + }); + + it('requires a model slot for the 3D model of a custom object', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Forest'] = { + Model: 'env.ground', + }; + + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeForestProjectObject(), + starterThemeSlots + ); + + expect(errors.map((error) => error.message)).toEqual([ + 'Starter "starting-3d-test": "scene:Game Scene/Forest" property "Model" maps to "env.ground", a texture slot, but needs a model slot.', + ]); + }); + + it('requires a custom object drawing a 3D model to be mapped or ignored', () => { + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeForestProjectObject(), + createFakeStarterThemeSlots() + ); + const starterThemeSlots = createFakeStarterThemeSlots(); + (starterThemeSlots.ignoredObjects || []).push('scene:Game Scene/Forest'); + + expect(errors.map((error) => error.message)).toEqual([ + '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.', + ]); + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeForestProjectObject(), + starterThemeSlots + ) + ).toEqual([]); + }); + it('requires a custom object to map its properties', () => { const errors = checkStarterThemeSlots( vocabulary,