diff --git a/README.md b/README.md index 14de93e62..61534a53c 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ along with the property that takes the normal map: 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. A theme model keeps -the other properties of the object, like the size of the scattered copies. +the other properties of the object, like the density 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 @@ -147,6 +147,57 @@ only offered once its copies are there. A theme only re-skins what it has an asset for. Anything else keeps the starter's placeholder art. +A theme model takes the place of the placeholder as drawn in the starter, at +the size its slot means. Each model slot of the root `theme-slots.json` says +it with `fit`: + +- `height`: the model is as tall as the placeholder (characters, trees, + grass), so a player is never a giant or a dwarf next to the level. The + character slots also have a `scale` of 1.2: a person is much thinner than + the capsule standing for it, and looks smaller at the same height; +- `length`: the model is as long as the placeholder (vehicles), so a truck + fills the lane of a car; +- `volume`: the model takes the volume of the placeholder (projectiles, rocks), + for objects whose shape varies a lot: a ball in the place of a missile, a + boulder in the place of a flat rock; +- `contain`: the model is as large as it fits in the *room* of the object + (props, buildings...), so it never overlaps what stands next to it. Models + don't all face the same direction: a prop is fit whichever way it faces. + +This uses the real dimensions of the theme model, published with the themes, +and the size of the placeholder model (its `.glb`) as drawn in its object. The +same goes for an instance given its own size, and for the copies of scattered +models (their "Height of a copy" changes for a model not sized by its height). + +The room of an object is its size (width, height and depth). A model keeping +its proportions is drawn as large as it fits in it, and its object takes that +size in the game, so the room can be larger than the placeholder on an axis it +does not fill, without changing the starter. Give the room a theme model may +need: the market of the RTS is flat, but a house can stand where it is, so its +object is as deep as it is wide; the gun of the FPS is thin, but a blaster is +not. + +Slots are split by shape when objects of one role differ too much: a +`prop.obstacle` (a cone) and a `prop.barrier` (a long barrier), a +`building.house` and a `building.tower`. Their descriptions say the shape a +theme should give them. + +A model of another shape than its placeholder (a squarish house on a long +lot) ends up much smaller, and the build reports it, along with a vehicle much +broader than its placeholder (and the themes giving no real dimensions, whose +models are sized by the volume of the placeholder object): + +``` +⚠️ starting-expanded-3d-grand-theft-auto: scene:Game Scene/Building_K +(building.house, sized by contain) is drawn at a size far from its placeholder +with 26 themes: battle-royale (60% of the largest dimension of the placeholder), ... +``` + +It is not an error, but it says what is to fix: a placeholder with too little +room, a slot grouping objects of too different shapes, or a theme model of the +wrong shape. A theme can also give a model a `scale` (0.5 to 2, see the assets +repository) to draw it larger or smaller on purpose: that is not reported. + The copies are rebuilt on every deploy of this repository, and every night by the `nightly-rebuild` workflow of the CircleCI configuration, which is how they catch up with a theme or an asset published by the assets repository. A new diff --git a/examples/starting-expanded-3d-car-racing/theme-slots.json b/examples/starting-expanded-3d-car-racing/theme-slots.json index 5fb201fd0..b004c5e3c 100644 --- a/examples/starting-expanded-3d-car-racing/theme-slots.json +++ b/examples/starting-expanded-3d-car-racing/theme-slots.json @@ -10,8 +10,8 @@ "top": "env.finish", "bottom": "env.finish" }, - "scene:Game Scene/Barrier": "prop.obstacle", - "scene:Game Scene/BarrierRed": "prop.obstacle", + "scene:Game Scene/Barrier": "prop.barrier", + "scene:Game Scene/BarrierRed": "prop.barrier", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-3d-driving/theme-slots.json b/examples/starting-expanded-3d-driving/theme-slots.json index 55e57bb03..ebe49e8dd 100644 --- a/examples/starting-expanded-3d-driving/theme-slots.json +++ b/examples/starting-expanded-3d-driving/theme-slots.json @@ -4,11 +4,11 @@ "scene:Game Scene/Road_3D": "env.road", "scene:Game Scene/PlayerCar": "vehicle.car", "scene:Game Scene/TrafficCone": "prop.obstacle", - "scene:Game Scene/Barrier": "prop.obstacle", - "scene:Game Scene/House_A": "building.tower", - "scene:Game Scene/House_B": "building.tower", - "scene:Game Scene/House_C": "building.tower", - "scene:Game Scene/House_D": "building.tower", + "scene:Game Scene/Barrier": "prop.barrier", + "scene:Game Scene/House_A": "building.house", + "scene:Game Scene/House_B": "building.house", + "scene:Game Scene/House_C": "building.house", + "scene:Game Scene/House_D": "building.house", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-3d-flight-sim/theme-slots.json b/examples/starting-expanded-3d-flight-sim/theme-slots.json index fc6e1e417..1935cd239 100644 --- a/examples/starting-expanded-3d-flight-sim/theme-slots.json +++ b/examples/starting-expanded-3d-flight-sim/theme-slots.json @@ -8,7 +8,7 @@ "scene:Game Scene/Trees": { "Model": "nature.tree" }, - "scene:Game Scene/House": "building.tower", + "scene:Game Scene/House": "building.house", "scene:Game Scene/Terrain": { "Layer1Texture": { "slot": "terrain.ground", diff --git a/examples/starting-expanded-3d-grand-theft-auto/theme-slots.json b/examples/starting-expanded-3d-grand-theft-auto/theme-slots.json index 88da07b7f..8ae4ac1d4 100644 --- a/examples/starting-expanded-3d-grand-theft-auto/theme-slots.json +++ b/examples/starting-expanded-3d-grand-theft-auto/theme-slots.json @@ -5,21 +5,21 @@ "scene:Game Scene/Car": "vehicle.car", "scene:Game Scene/Driven_Car": "vehicle.car", "scene:Game Scene/PushableBox": "env.crate", - "scene:Game Scene/Building_A": "building.tower", - "scene:Game Scene/Building_C": "building.tower", - "scene:Game Scene/Building_E": "building.tower", + "scene:Game Scene/Building_A": "building.house", + "scene:Game Scene/Building_C": "building.house", + "scene:Game Scene/Building_E": "building.house", "scene:Game Scene/Building_G": "building.tower", - "scene:Game Scene/Building_H": "building.tower", - "scene:Game Scene/Building_K": "building.tower", + "scene:Game Scene/Building_H": "building.house", + "scene:Game Scene/Building_K": "building.house", "scene:Game Scene/Skyscraper_A": "building.tower", "scene:Game Scene/Skyscraper_C": "building.tower", "scene:Game Scene/Skyscraper_E": "building.tower", - "scene:Game Scene/House_A": "building.tower", - "scene:Game Scene/House_D": "building.tower", - "scene:Game Scene/House_N": "building.tower", - "scene:Game Scene/House_T": "building.tower", + "scene:Game Scene/House_A": "building.house", + "scene:Game Scene/House_D": "building.house", + "scene:Game Scene/House_N": "building.house", + "scene:Game Scene/House_T": "building.house", "scene:Game Scene/Cone": "prop.obstacle", - "scene:Game Scene/Barrier": "prop.obstacle", + "scene:Game Scene/Barrier": "prop.barrier", "scene:Game Scene/TownTree": "nature.tree", "scene:Game Scene/PineForest": { "Model": "nature.tree" diff --git a/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json b/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json index b8b358e0a..354b32a9a 100644 --- a/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json +++ b/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json @@ -1565,7 +1565,7 @@ "content": { "centerLocation": "BottomCenterZ", "crossfadeDuration": 0, - "depth": 102.1, + "depth": 225, "height": 225.4, "isCastingShadow": true, "isReceivingShadow": true, @@ -1598,7 +1598,7 @@ "content": { "centerLocation": "BottomCenterZ", "crossfadeDuration": 0, - "depth": 145.4, + "depth": 269, "height": 280, "isCastingShadow": true, "isReceivingShadow": true, diff --git a/examples/starting-expanded-3d-rts-unit-selection/theme-slots.json b/examples/starting-expanded-3d-rts-unit-selection/theme-slots.json index f3cc8a846..f399a569c 100644 --- a/examples/starting-expanded-3d-rts-unit-selection/theme-slots.json +++ b/examples/starting-expanded-3d-rts-unit-selection/theme-slots.json @@ -3,13 +3,13 @@ "objects": { "scene:Game Scene/RTSUnit": "character.player", "scene:Game Scene/Grass": "nature.grass", - "scene:Game Scene/TownCenter": "building.tower", - "scene:Game Scene/House": "building.tower", - "scene:Game Scene/HouseTall": "building.tower", - "scene:Game Scene/HouseSmall": "building.tower", - "scene:Game Scene/Barracks": "building.tower", - "scene:Game Scene/Market": "building.tower", - "scene:Game Scene/Storage": "building.tower", + "scene:Game Scene/TownCenter": "building.house", + "scene:Game Scene/House": "building.house", + "scene:Game Scene/HouseTall": "building.house", + "scene:Game Scene/HouseSmall": "building.house", + "scene:Game Scene/Barracks": "building.house", + "scene:Game Scene/Market": "building.house", + "scene:Game Scene/Storage": "building.house", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-3d-tank/theme-slots.json b/examples/starting-expanded-3d-tank/theme-slots.json index a769ecff1..ab62b3ea0 100644 --- a/examples/starting-expanded-3d-tank/theme-slots.json +++ b/examples/starting-expanded-3d-tank/theme-slots.json @@ -6,8 +6,8 @@ "object:TankConfiguration::CombinedTank/TankBase": "vehicle.tank.base", "object:TankConfiguration::TankTop/TankTop": "vehicle.tank.turret", "object:TankConfiguration::TankTop/TankCanon": "vehicle.tank.cannon", - "scene:Game Scene/SackTrench": "prop.obstacle", - "scene:Game Scene/Container": "prop.container", + "scene:Game Scene/SackTrench": "prop.barrier", + "scene:Game Scene/Container": "prop.barrier", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-3d-top-down-rpg/theme-slots.json b/examples/starting-expanded-3d-top-down-rpg/theme-slots.json index 51da1146c..d06274af7 100644 --- a/examples/starting-expanded-3d-top-down-rpg/theme-slots.json +++ b/examples/starting-expanded-3d-top-down-rpg/theme-slots.json @@ -7,10 +7,10 @@ "scene:Game Scene/GrassTufts": { "Model": "nature.grass" }, - "scene:Game Scene/House": "building.tower", - "scene:Game Scene/HouseLarge": "building.tower", - "scene:Game Scene/Inn": "building.tower", - "scene:Game Scene/Blacksmith": "building.tower", + "scene:Game Scene/House": "building.house", + "scene:Game Scene/HouseLarge": "building.house", + "scene:Game Scene/Inn": "building.house", + "scene:Game Scene/Blacksmith": "building.house", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-first-person-farming/theme-slots.json b/examples/starting-expanded-first-person-farming/theme-slots.json index 039606a98..d50af6be5 100644 --- a/examples/starting-expanded-first-person-farming/theme-slots.json +++ b/examples/starting-expanded-first-person-farming/theme-slots.json @@ -12,9 +12,9 @@ "scene:Game Scene/Beet": "crop.b", "scene:Game Scene/Harvest_Seed_Beet": "prop.container", "scene:Game Scene/Harvest_Seed_Carrot": "prop.container", - "scene:Game Scene/Barn": "building.tower", - "scene:Game Scene/SmallBarn": "building.tower", - "scene:Game Scene/ChickenCoop": "building.tower", + "scene:Game Scene/Barn": "building.house", + "scene:Game Scene/SmallBarn": "building.house", + "scene:Game Scene/ChickenCoop": "building.house", "scene:Game Scene/Trees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-first-person-horror/theme-slots.json b/examples/starting-expanded-first-person-horror/theme-slots.json index df2deccf5..b026675b7 100644 --- a/examples/starting-expanded-first-person-horror/theme-slots.json +++ b/examples/starting-expanded-first-person-horror/theme-slots.json @@ -3,7 +3,7 @@ "objects": { "scene:Game Scene/PushableBox": "env.crate", "scene:Game Scene/Monster": "character.monster", - "scene:Game Scene/Cabin": "building.tower", + "scene:Game Scene/Cabin": "building.house", "scene:Game Scene/PineTrees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json b/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json index 6a3acd48b..e5b7167e3 100644 --- a/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json +++ b/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json @@ -2192,7 +2192,7 @@ "centerLocation": "ModelOrigin", "crossfadeDuration": 0.10000000149011612, "depth": 415.9, - "height": 153.9, + "height": 600, "isCastingShadow": false, "isReceivingShadow": false, "keepAspectRatio": true, diff --git a/examples/starting-expanded-first-person-shooter-enemies/theme-slots.json b/examples/starting-expanded-first-person-shooter-enemies/theme-slots.json index cc4df3fb7..a006f1cc2 100644 --- a/examples/starting-expanded-first-person-shooter-enemies/theme-slots.json +++ b/examples/starting-expanded-first-person-shooter-enemies/theme-slots.json @@ -11,9 +11,9 @@ "scene:Game Scene/PushableBox": "env.crate", "scene:Game Scene/Enemy": "character.enemy", "scene:Game Scene/Gun": "weapon.handheld", - "scene:Game Scene/Sandbags": "prop.obstacle", - "scene:Game Scene/Barrier": "prop.obstacle", - "scene:Game Scene/Container": "prop.container", + "scene:Game Scene/Sandbags": "prop.barrier", + "scene:Game Scene/Barrier": "prop.barrier", + "scene:Game Scene/Container": "prop.barrier", "scene:Game Scene/Crate": "prop.container", "scene:Game Scene/Rock": "nature.rock.big", "scene:Game Scene/SmallRocks": { diff --git a/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json b/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json index 64e6a9aee..918e2a745 100644 --- a/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json +++ b/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json @@ -2337,7 +2337,7 @@ "centerLocation": "ModelOrigin", "crossfadeDuration": 0.10000000149011612, "depth": 415.9, - "height": 153.9, + "height": 600, "isCastingShadow": false, "isReceivingShadow": false, "keepAspectRatio": true, diff --git a/examples/starting-expanded-first-person-shooter-horror/theme-slots.json b/examples/starting-expanded-first-person-shooter-horror/theme-slots.json index 0b1c9d7d2..e28cfd5b7 100644 --- a/examples/starting-expanded-first-person-shooter-horror/theme-slots.json +++ b/examples/starting-expanded-first-person-shooter-horror/theme-slots.json @@ -4,8 +4,8 @@ "scene:Game Scene/PushableBox": "env.crate", "scene:Game Scene/Monster": "character.monster", "scene:Game Scene/Gun": "weapon.handheld", - "scene:Game Scene/Crypt": "building.tower", - "scene:Game Scene/CryptSmall": "building.tower", + "scene:Game Scene/Crypt": "building.house", + "scene:Game Scene/CryptSmall": "building.house", "scene:Game Scene/PineTrees": { "Model": "nature.tree" }, diff --git a/examples/starting-expanded-first-person-survival-crafting/theme-slots.json b/examples/starting-expanded-first-person-survival-crafting/theme-slots.json index d6b57b841..63ea94596 100644 --- a/examples/starting-expanded-first-person-survival-crafting/theme-slots.json +++ b/examples/starting-expanded-first-person-survival-crafting/theme-slots.json @@ -20,7 +20,7 @@ }, "scene:Game Scene/FallenLog": "resource.log", "scene:Game Scene/Stump": "nature.rock.small", - "scene:Game Scene/Tent": "building.tower", + "scene:Game Scene/Tent": "building.house", "scene:Game Scene/Terrain": { "Layer1Texture": { "slot": "terrain.ground", diff --git a/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json b/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json index 274e648ba..b1a82e18a 100644 --- a/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json +++ b/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json @@ -1579,7 +1579,7 @@ "centerLocation": "ModelOrigin", "crossfadeDuration": 0.10000000149011612, "depth": 415.9, - "height": 153.9, + "height": 600, "isCastingShadow": false, "isReceivingShadow": false, "keepAspectRatio": true, diff --git a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json index 3dad64afb..416c95936 100644 --- a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json +++ b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json @@ -1634,7 +1634,7 @@ "centerLocation": "ModelOrigin", "crossfadeDuration": 0.10000000149011612, "depth": 415.9, - "height": 153.9, + "height": 600, "isCastingShadow": false, "isReceivingShadow": false, "keepAspectRatio": true, diff --git a/examples/starting-first-person-shooter/starting-first-person-shooter.json b/examples/starting-first-person-shooter/starting-first-person-shooter.json index 0c1e6915a..c465d44d9 100644 --- a/examples/starting-first-person-shooter/starting-first-person-shooter.json +++ b/examples/starting-first-person-shooter/starting-first-person-shooter.json @@ -1364,7 +1364,7 @@ "centerLocation": "ModelOrigin", "crossfadeDuration": 0.10000000149011612, "depth": 415.95, - "height": 153.89, + "height": 600, "isCastingShadow": false, "isReceivingShadow": false, "keepAspectRatio": true, diff --git a/scripts/generate-database.js b/scripts/generate-database.js index afeeeb649..d5ddafe1d 100644 --- a/scripts/generate-database.js +++ b/scripts/generate-database.js @@ -26,6 +26,8 @@ const { getThemedStarterFileName, } = require('./lib/StarterThemer'); const { readPngBox } = require('./lib/PngBox'); +const { readGlbSize } = require('./lib/GlbBox'); +const { getSizeWarnings } = require('./lib/ThemeSizeCheck'); const { default: axios } = require('axios'); /** @typedef {import('./lib/FileTreeParser.js').DreeWithMetadata} DreeWithMetadata */ @@ -729,16 +731,48 @@ const fetchThemes = async () => { * (`.theme-.json`). They sit in the starter's folder so the resources * it refers to by a relative path still resolve, and are not examples of their * own: only `themedStarters.json` lists them. + * + * A theme model drawn at a size far from the placeholder it replaces is + * reported (not an error: the theme or the starter is to fix). + * @param {import('./lib/ThemeSlots').ThemeSlotsVocabulary} themeSlotsVocabulary * @param {Object.} themeSlotsByStarterSlug * @param {Object.} starterFilePathBySlug * @returns {Promise}>>} */ const generateThemedStarters = async ( + themeSlotsVocabulary, themeSlotsByStarterSlug, starterFilePathBySlug ) => { const themes = await fetchThemes(); const themedStarters = []; + /** @type {Object.} */ + const modelFitBySlot = {}; + /** @type {Object.} */ + const modelScaleBySlot = {}; + themeSlotsVocabulary.slots.forEach((slot) => { + if (slot.fit) modelFitBySlot[slot.id] = slot.fit; + if (slot.scale) modelScaleBySlot[slot.id] = slot.scale; + }); + /** @type {Map} */ + const starterModelSizeByPath = new Map(); + /** @param {string} filePath */ + const readStarterModelSize = (filePath) => { + if (!starterModelSizeByPath.has(filePath)) { + let size = null; + try { + size = readGlbSize(fsSync.readFileSync(filePath)); + } catch (error) {} + starterModelSizeByPath.set(filePath, size); + } + return starterModelSizeByPath.get(filePath) || null; + }; + // `: ()` -> ` ()`, so that a + // placeholder or a slot to fix shows on one line. + /** @type {Map>} */ + const sizeWarningsByObject = new Map(); + /** @type {Map} */ + const modelsSizedByVolumeCountByTheme = new Map(); for (const theme of themes) { // Starter slug -> URL of its copy re-skinned with the theme. @@ -751,27 +785,54 @@ const generateThemedStarters = async ( ); const starterThemeSlots = themeSlotsByStarterSlug[slug]; const starterFolderPath = path.dirname(starterFilePath); - const { changedObjectsCount, changedResourcesCount, missingSlots } = - applyThemeToStarter(projectObject, starterThemeSlots, theme, { - getStarterImageBox: (resourceFile) => { - try { - return readPngBox( - fsSync.readFileSync( - path.join( - starterFolderPath, - normalizePathSeparators(resourceFile) - ) + const { + changedObjectsCount, + changedResourcesCount, + missingSlots, + sizedModels, + } = applyThemeToStarter(projectObject, starterThemeSlots, theme, { + modelFitBySlot, + modelScaleBySlot, + getStarterModelSize: (resourceFile) => + readStarterModelSize( + path.join(starterFolderPath, normalizePathSeparators(resourceFile)) + ), + getStarterImageBox: (resourceFile) => { + try { + return readPngBox( + fsSync.readFileSync( + path.join( + starterFolderPath, + normalizePathSeparators(resourceFile) ) - ); - } catch (error) { - return null; - } - }, - }); + ) + ); + } catch (error) { + return null; + } + }, + }); if (!changedObjectsCount && !changedResourcesCount) continue; // A 2D starter with only part of its art themed looks broken. if (starterThemeSlots.requireAllSlots && missingSlots.length) continue; + sizedModels.forEach((sizedModel) => { + if (!sizedModel.fit) { + modelsSizedByVolumeCountByTheme.set( + theme.id, + (modelsSizedByVolumeCountByTheme.get(theme.id) || 0) + 1 + ); + return; + } + const warnings = getSizeWarnings(sizedModel); + if (!warnings.length) return; + const objectKey = `${slug}: ${sizedModel.objectPath} (${sizedModel.slotId}, sized by ${sizedModel.fit})`; + sizeWarningsByObject.set(objectKey, [ + ...(sizeWarningsByObject.get(objectKey) || []), + `${theme.id} (${warnings.join(', ')})`, + ]); + }); + const themedStarterFilePath = path.join( path.dirname(starterFilePath), getThemedStarterFileName(slug, theme.id) @@ -793,6 +854,18 @@ const generateThemedStarters = async ( starters, }); } + modelsSizedByVolumeCountByTheme.forEach((count, themeId) => + console.warn( + `⚠️ Theme "${themeId}": ${count} models sized by the volume of their placeholder, for lack of their real dimensions in the theme.` + ) + ); + sizeWarningsByObject.forEach((themeWarnings, objectKey) => + console.warn( + `⚠️ ${objectKey} is drawn at a size far from its placeholder with ${ + themeWarnings.length + } themes: ${themeWarnings.join(', ')}.` + ) + ); return themedStarters; }; @@ -911,6 +984,7 @@ const generateThemedStarters = async ( } const themedStarters = await generateThemedStarters( + themeSlotsVocabulary, themeSlotsByStarterSlug, starterFilePathBySlug ); diff --git a/scripts/lib/GlbBox.js b/scripts/lib/GlbBox.js new file mode 100644 index 000000000..3ab3880c0 --- /dev/null +++ b/scripts/lib/GlbBox.js @@ -0,0 +1,114 @@ +// @ts-check + +/** + * The size of the model of a .glb file along each axis of the file, before + * any rotation: most models stand on Y. + * @typedef {{x: number, y: number, z: number}} GlbSize + */ + +/** + * @param {Array} a + * @param {Array} b + * @returns {Array} The product of two 4x4 matrices, column-major. + */ +const multiply = (a, b) => { + const result = new Array(16).fill(0); + for (let column = 0; column < 4; column++) { + for (let row = 0; row < 4; row++) { + for (let k = 0; k < 4; k++) { + result[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k]; + } + } + } + return result; +}; + +/** + * @param {any} node A glTF node. + * @returns {Array} Its local matrix, column-major. + */ +const getNodeMatrix = (node) => { + if (node.matrix) return node.matrix; + const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; + const [sx, sy, sz] = node.scale || [1, 1, 1]; + const [tx, ty, tz] = node.translation || [0, 0, 0]; + return [ + (1 - 2 * (y * y + z * z)) * sx, + 2 * (x * y + z * w) * sx, + 2 * (x * z - y * w) * sx, + 0, + 2 * (x * y - z * w) * sy, + (1 - 2 * (x * x + z * z)) * sy, + 2 * (y * z + x * w) * sy, + 0, + 2 * (x * z + y * w) * sz, + 2 * (y * z - x * w) * sz, + (1 - 2 * (x * x + y * y)) * sz, + 0, + tx, + ty, + tz, + 1, + ]; +}; + +/** + * The size of the model of a .glb file, from the bounds of its meshes moved by + * their nodes. + * @param {Buffer} buffer The content of a .glb file. + * @returns {GlbSize | null} null if the file cannot be read. + */ +const readGlbSize = (buffer) => { + /** @type {any} */ + let gltf; + try { + if (buffer.toString('utf8', 0, 4) !== 'glTF') return null; + const jsonLength = buffer.readUInt32LE(12); + gltf = JSON.parse(buffer.toString('utf8', 20, 20 + jsonLength)); + } catch (error) { + return null; + } + + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + /** + * @param {number} nodeIndex + * @param {Array} parentMatrix + */ + const visit = (nodeIndex, parentMatrix) => { + const node = gltf.nodes[nodeIndex]; + const matrix = multiply(parentMatrix, getNodeMatrix(node)); + if (node.mesh !== undefined) { + for (const primitive of gltf.meshes[node.mesh].primitives) { + const accessor = gltf.accessors[primitive.attributes.POSITION]; + if (!accessor || !accessor.min || !accessor.max) continue; + for (const x of [accessor.min[0], accessor.max[0]]) { + for (const y of [accessor.min[1], accessor.max[1]]) { + for (const z of [accessor.min[2], accessor.max[2]]) { + for (let axis = 0; axis < 3; axis++) { + const value = + matrix[axis] * x + + matrix[4 + axis] * y + + matrix[8 + axis] * z + + matrix[12 + axis]; + min[axis] = Math.min(min[axis], value); + max[axis] = Math.max(max[axis], value); + } + } + } + } + } + } + for (const child of node.children || []) visit(child, matrix); + }; + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const scene = (gltf.scenes || [])[gltf.scene || 0]; + for (const nodeIndex of (scene && scene.nodes) || []) { + visit(nodeIndex, identity); + } + if (!(max[0] >= min[0])) return null; + + return { x: max[0] - min[0], y: max[1] - min[1], z: max[2] - min[2] }; +}; + +module.exports = { readGlbSize }; diff --git a/scripts/lib/StarterThemer.js b/scripts/lib/StarterThemer.js index 50293389f..120e044ef 100644 --- a/scripts/lib/StarterThemer.js +++ b/scripts/lib/StarterThemer.js @@ -1,11 +1,20 @@ // @ts-check /** @typedef {import('./ThemeSlots.js').StarterThemeSlots} StarterThemeSlots */ +/** @typedef {import('./ThemeSlots.js').ModelFit} ModelFit */ +/** @typedef {import('./GlbBox.js').GlbSize} GlbSize */ + +/** + * The size of a 3D model in a scene: `width` along X, `height` along Y and + * `depth` along Z, the up axis. + * @typedef {{width: number, height: number, depth: number}} ModelDimensions + */ /** * A theme, as published by the assets repository: for each slot, the file to - * use and, for a 3D model, the serialized content of the asset's object. A - * skybox slot has one file per face instead. + * use and, for a 3D model, the serialized content of the asset's object and + * the real dimensions of the model (its object is sized as a cube). A skybox + * slot has one file per face instead. * @typedef {{ * file: string, * resourceName: string, @@ -17,6 +26,8 @@ * @typedef {ThemeFile & { * kind: 'model' | 'texture', * objectContent?: any, + * dimensions?: ModelDimensions, + * scale?: number, * assetStoreId?: string, * } | ThemeFile & { * kind: 'material', @@ -55,15 +66,38 @@ * theme's art where the placeholder art was. * @typedef {import('./PngBox.js').ImageBox} ImageBox * + * `modelFitBySlot` says how a model slot sizes its model (see `ModelFit`), + * `modelScaleBySlot` the scale a slot gives its models on top of the fit, and + * `getStarterModelSize` gives the size of a placeholder model, to know how + * large it is drawn. Without them, a theme model takes the volume of the + * placeholder object. * @typedef {{ * getStarterImageBox?: (resourceFile: string) => ImageBox | null, + * getStarterModelSize?: (resourceFile: string) => GlbSize | null, + * modelFitBySlot?: Object., + * modelScaleBySlot?: Object., * }} ThemeApplicationOptions * + * How large a theme model is drawn compared to the placeholder it replaces, + * for the checks of the build. `fit` is null for a model sized by the volume + * of the placeholder object, for lack of the real dimensions of the model. + * `scale` is the scale the theme deliberately gives its model on top of the + * fit (1 for most). + * @typedef {{ + * objectPath: string, + * slotId: string, + * fit: ModelFit | null, + * scale: number, + * placeholder: ModelDimensions, + * themed: ModelDimensions, + * }} SizedModel + * * @typedef {{ * appliedSlots: Array, * missingSlots: Array, * changedObjectsCount: number, * changedResourcesCount: number, + * sizedModels: Array, * }} ThemeApplicationResult */ @@ -179,6 +213,166 @@ const getSizeRatio = (objectContent, themeContent) => { return Math.pow(objectVolume / themeVolume, 1 / 3); }; +/** + * The size of a model rotated like a 3D model object rotates it: in degrees, + * around the X axis of the scene, then its Y axis, then its Z axis. + * @param {GlbSize} size + * @param {number} rotationX + * @param {number} rotationY + * @param {number} rotationZ + * @returns {ModelDimensions} + */ +const getRotatedModelDimensions = (size, rotationX, rotationY, rotationZ) => { + const [cx, sx, cy, sy, cz, sz] = [rotationX, rotationY, rotationZ].flatMap( + (angle) => [ + Math.cos((angle * Math.PI) / 180), + Math.sin((angle * Math.PI) / 180), + ] + ); + const rotation = [ + [cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz], + [cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz], + [-sy, sx * cy, cx * cy], + ]; + const [width, height, depth] = rotation.map( + ([fromX, fromY, fromZ]) => + Math.abs(fromX) * size.x + + Math.abs(fromY) * size.y + + Math.abs(fromZ) * size.z + ); + return { width, height, depth }; +}; + +/** + * @param {ModelDimensions} dimensions + * @param {number} scale + * @returns {ModelDimensions} + */ +const scaleModelDimensions = ({ width, height, depth }, scale) => ({ + width: width * scale, + height: height * scale, + depth: depth * scale, +}); + +/** + * The largest scale fitting a model in a box, each size of the model facing + * the size of the box given with it. + * @param {Array<[number, number]>} boxAndModelSizes + * @returns {number} + */ +const getContainScale = (boxAndModelSizes) => + Math.min( + ...boxAndModelSizes + .filter(([, modelSize]) => modelSize > 0) + .map(([boxSize, modelSize]) => boxSize / modelSize) + ); + +/** + * The longest then the shortest of the sizes of a model across the ground. + * @param {ModelDimensions} dimensions + * @returns {[number, number]} + */ +const getGroundSizes = ({ width, height }) => [ + Math.max(width, height), + Math.min(width, height), +]; + +/** @param {ModelDimensions} dimensions */ +const getVolume = ({ width, height, depth }) => width * height * depth; + +/** + * The scale giving a model the size its slot means, in the place of a + * placeholder drawn at `placeholder` in an object of size `room`: its height + * for a character, its length for a vehicle, its volume for a projectile, or + * the largest size fitting in the room of the object for a prop. Models of a + * theme don't all face the direction of the placeholder: a prop is fit + * whichever way it faces, its longest size across the ground against the + * longest one of the room. + * @param {ModelFit} fit + * @param {ModelDimensions} placeholder + * @param {ModelDimensions} room + * @param {ModelDimensions} model + * @returns {number | null} null if the model has no size to fit. + */ +const getFitScale = (fit, placeholder, room, model) => { + const [roomLength, roomBreadth] = getGroundSizes(room); + const [modelLength, modelBreadth] = getGroundSizes(model); + const scale = + fit === 'height' + ? placeholder.depth / model.depth + : fit === 'length' + ? getGroundSizes(placeholder)[0] / modelLength + : fit === 'volume' + ? Math.cbrt(getVolume(placeholder) / getVolume(model)) + : getContainScale([ + [roomLength, modelLength], + [roomBreadth, modelBreadth], + [room.depth, model.depth], + ]); + return scale > 0 && Number.isFinite(scale) ? scale : null; +}; + +/** + * The size a 3D model object draws its model at in a box: stretched to the + * box, or as large as it fits in it when the object keeps the proportions of + * the model. + * @param {ModelDimensions} box + * @param {ModelDimensions | null} modelDimensions + * @param {boolean} keepAspectRatio + * @returns {ModelDimensions} + */ +const getDrawnModelDimensions = (box, modelDimensions, keepAspectRatio) => { + if (!keepAspectRatio || !modelDimensions) return box; + const scale = getContainScale([ + [box.width, modelDimensions.width], + [box.height, modelDimensions.height], + [box.depth, modelDimensions.depth], + ]); + return scale > 0 && Number.isFinite(scale) + ? scaleModelDimensions(modelDimensions, scale) + : box; +}; + +/** + * A body the 3D physics behavior sizes on its own takes the size of its + * object. For a model fit by its height, that is the arms of a character in its + * bind pose, or the canopy of a tree: much larger than the body of the + * placeholder the game was made with. Keep the body of the placeholder + * instead, scaled like the model, and its mass. + * @param {Array} behaviors + * @param {ModelDimensions} placeholder + * @param {number} scale How much taller the model is than the placeholder. + */ +const keepPlaceholderBodies = (behaviors, placeholder, scale) => { + const { width, height, depth } = scaleModelDimensions(placeholder, scale); + behaviors.forEach((behavior) => { + if ( + behavior.type !== 'Physics3D::Physics3DBehavior' || + (behavior.shapeOrientation || 'Z') !== 'Z' || + behavior.shapeDimensionA > 0 || + behavior.shapeDimensionB > 0 || + behavior.shapeDimensionC > 0 + ) { + return; + } + if (behavior.shape === 'Box') { + behavior.shapeDimensionA = width; + behavior.shapeDimensionB = height; + behavior.shapeDimensionC = depth; + } else if (behavior.shape === 'Capsule' || behavior.shape === 'Cylinder') { + behavior.shapeDimensionA = Math.sqrt(width * height) / 2; + behavior.shapeDimensionB = depth; + } else if (behavior.shape === 'Sphere') { + behavior.shapeDimensionA = Math.cbrt(width * height * depth) / 2; + } else { + return; + } + behavior.density = + (behavior.density === undefined ? 1 : behavior.density) / + Math.pow(scale, 3); + }); +}; + /** * The instances of an object, wherever its path lets it be placed. * @param {any} projectObject @@ -433,6 +627,99 @@ const applyThemeToStarter = ( /** @type {Set} */ const replacedResourceNames = new Set(); let changedObjectsCount = 0; + /** @type {Array} */ + const sizedModels = []; + + /** @param {string} slotId @returns {ModelFit | null} */ + const getModelFit = (slotId) => + (options && options.modelFitBySlot && options.modelFitBySlot[slotId]) || + null; + + /** + * The scale of a model on top of its fit: the one of its slot, times the + * one its theme deliberately gives it. + * @param {string} slotId + * @param {{scale?: number}} slot + * @returns {number} + */ + const getModelScale = (slotId, slot) => + ((options && + options.modelScaleBySlot && + options.modelScaleBySlot[slotId]) || + 1) * (slot.scale || 1); + + /** @param {string} resourceName @returns {GlbSize | null} */ + const getStarterModelSize = (resourceName) => { + const resource = resourceByName.get(resourceName); + return resource && options && options.getStarterModelSize + ? options.getStarterModelSize(resource.file) + : null; + }; + + /** + * Copies of a scattered model are given a height ("Size", when not sized by + * a scale factor): for a model fit otherwise than by its height, the height + * giving it the size its slot means where the placeholder copies were. + * @param {string} objectPath + * @param {string} slotId + * @param {{objectContent?: any, dimensions?: ModelDimensions, scale?: number}} slot + * @param {any} objectContent + * @param {string} placeholderResourceName + */ + const fitScatteredCopies = ( + objectPath, + slotId, + slot, + objectContent, + placeholderResourceName + ) => { + const themeModelUpAxis = + slot.objectContent && slot.objectContent.rotationX === 0 ? 'Z' : 'Y'; + const placeholderUpAxis = objectContent.ModelUpAxis || 'Y'; + if (themeModelUpAxis !== placeholderUpAxis) { + objectContent.ModelUpAxis = themeModelUpAxis; + } + + const placeholderCopyHeight = Number(objectContent.Size) || 0; + const fit = getModelFit(slotId); + const placeholderSize = getStarterModelSize(placeholderResourceName); + if ( + !placeholderCopyHeight || + !fit || + !slot.dimensions || + !placeholderSize + ) { + return; + } + const placeholderModel = + placeholderUpAxis === 'Z' + ? { + width: placeholderSize.x, + height: placeholderSize.y, + depth: placeholderSize.z, + } + : { + width: placeholderSize.x, + height: placeholderSize.z, + depth: placeholderSize.y, + }; + if (!(placeholderModel.depth > 0)) return; + const placeholder = scaleModelDimensions( + placeholderModel, + placeholderCopyHeight / placeholderModel.depth + ); + const fitScale = getFitScale( + fit, + placeholder, + placeholder, + slot.dimensions + ); + if (!fitScale) return; + const scale = getModelScale(slotId, slot); + const themed = scaleModelDimensions(slot.dimensions, fitScale * scale); + objectContent.Size = String(Math.round(themed.depth * 100) / 100); + sizedModels.push({ objectPath, slotId, fit, scale, placeholder, themed }); + }; /** * Add a file of a slot to the project as a resource configured like the @@ -491,11 +778,13 @@ const applyThemeToStarter = ( * texture of a texture or material slot, along with the normal map of the * 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 {string} objectPath + * @param {any} object * @param {Object.} mapping * @returns {boolean} Whether a property changed. */ - const themeCustomObjectProperties = (objectContent, mapping) => { + const themeCustomObjectProperties = (objectPath, object, mapping) => { + const objectContent = object.content; let changed = false; Object.entries(mapping).forEach(([property, propertyMapping]) => { const slotId = @@ -518,16 +807,24 @@ const applyThemeToStarter = ( 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 placeholderResourceName = String(objectContent[property] || ''); const themeModelResourceName = useThemeResource( - String(objectContent[property] || ''), + placeholderResourceName, slotId, slot, { kind: 'model3D', metadata: '', userAdded: false } ); if (!themeModelResourceName) return; objectContent[property] = themeModelResourceName; + if (object.type === 'Terrain3D::ScatteredModels') { + fitScatteredCopies( + objectPath, + slotId, + slot, + objectContent, + placeholderResourceName + ); + } changed = true; return; } @@ -978,7 +1275,110 @@ const applyThemeToStarter = ( if (!modelResourceName) return; const themeContent = slot.objectContent; - const sizeRatio = getSizeRatio(objectContent, themeContent); + const fit = getModelFit(mapping); + const placeholderSize = getStarterModelSize( + objectContent.modelResourceName + ); + const placeholderModel = placeholderSize + ? getRotatedModelDimensions( + placeholderSize, + objectContent.rotationX || 0, + objectContent.rotationY || 0, + objectContent.rotationZ || 0 + ) + : null; + /** @param {ModelDimensions} box @returns {ModelDimensions} */ + const getDrawnPlaceholder = (box) => + getDrawnModelDimensions( + box, + placeholderModel, + !!objectContent.keepAspectRatio + ); + const room = { + width: objectContent.width || 0, + height: objectContent.height || 0, + depth: objectContent.depth || 0, + }; + const placeholder = getDrawnPlaceholder(room); + const modelDimensions = slot.dimensions; + const scale = getModelScale(mapping, slot); + const fitScale = + fit && modelDimensions + ? getFitScale(fit, placeholder, room, modelDimensions) + : null; + const sizeRatio = getSizeRatio(objectContent, themeContent) * scale; + /** @type {SizedModel} */ + const sizedModel = { + objectPath, + slotId: mapping, + fit: null, + scale, + placeholder, + themed: { + width: (themeContent.width || 0) * sizeRatio, + height: (themeContent.height || 0) * sizeRatio, + depth: (themeContent.depth || 0) * sizeRatio, + }, + }; + if (fit && modelDimensions && fitScale) { + sizedModel.fit = fit; + sizedModel.themed = scaleModelDimensions( + modelDimensions, + fitScale * scale + ); + + // An instance given its own size is the placeholder at another + // size: the theme model takes the size its slot means there too. + let hasInstancesOfAnotherSize = false; + getInstancesOfObject(projectObject, objectPath).forEach( + (instance) => { + if (!instance.customSize) return; + const instanceRoom = { + width: instance.width, + height: instance.height, + depth: + instance.depth === undefined + ? objectContent.depth || 0 + : instance.depth, + }; + const instancePlaceholder = getDrawnPlaceholder(instanceRoom); + if ( + Math.abs(instancePlaceholder.depth - placeholder.depth) > + placeholder.depth / 100 + ) { + hasInstancesOfAnotherSize = true; + } + const instanceFitScale = getFitScale( + fit, + instancePlaceholder, + instanceRoom, + modelDimensions + ); + if (!instanceFitScale) return; + const instanceSize = scaleModelDimensions( + modelDimensions, + instanceFitScale * scale + ); + instance.width = instanceSize.width; + instance.height = instanceSize.height; + instance.depth = instanceSize.depth; + } + ); + if ( + fit === 'height' && + placeholder.depth > 0 && + !hasInstancesOfAnotherSize + ) { + keepPlaceholderBodies( + object.behaviors || [], + placeholder, + sizedModel.themed.depth / placeholder.depth + ); + } + } + sizedModels.push(sizedModel); + const { themed } = sizedModel; + object.content = { ...themeContent, modelResourceName, @@ -986,9 +1386,9 @@ const applyThemeToStarter = ( objectContent.animations || [], themeContent.animations || [] ), - width: (themeContent.width || 0) * sizeRatio, - height: (themeContent.height || 0) * sizeRatio, - depth: (themeContent.depth || 0) * sizeRatio, + width: themed.width, + height: themed.height, + depth: themed.depth, // The origin and center drive collisions and placement: keep the // starter's, which its events and instances were built around. originLocation: objectContent.originLocation, @@ -1028,7 +1428,7 @@ const applyThemeToStarter = ( if ( typeof mapping !== 'string' && - themeCustomObjectProperties(objectContent, mappingObject) + themeCustomObjectProperties(objectPath, object, mappingObject) ) { changedObjectsCount++; } @@ -1079,6 +1479,7 @@ const applyThemeToStarter = ( missingSlots: [...missingSlots].sort(), changedObjectsCount, changedResourcesCount: themeResourceNames.size, + sizedModels, }; }; diff --git a/scripts/lib/ThemeSizeCheck.js b/scripts/lib/ThemeSizeCheck.js new file mode 100644 index 000000000..7cf22aa2e --- /dev/null +++ b/scripts/lib/ThemeSizeCheck.js @@ -0,0 +1,87 @@ +// @ts-check + +/** @typedef {import('./StarterThemer.js').SizedModel} SizedModel */ +/** @typedef {import('./StarterThemer.js').ModelDimensions} ModelDimensions */ + +// How much larger or smaller than the placeholder a theme model can be drawn +// before it is reported. +const MAX_SIZE_RATIO = 1.25; + +/** @param {number} ratio */ +const toPercent = (ratio) => `${Math.round(ratio * 100)}%`; + +/** @param {ModelDimensions} dimensions */ +const getLargestDimension = ({ width, height, depth }) => + Math.max(width, height, depth); + +/** + * The size a model is fit by, and its name: its height, its length, its + * volume (compared as the side of a cube), or its largest dimension for a + * model fit in the placeholder (which is smaller when the model is not of the + * shape of the placeholder). + * @param {import('./ThemeSlots.js').ModelFit} fit + * @returns {{label: string, getSize: (dimensions: ModelDimensions) => number}} + */ +const getMainDimension = (fit) => + fit === 'height' + ? { label: 'height', getSize: (dimensions) => dimensions.depth } + : fit === 'length' + ? { + label: 'length', + getSize: (dimensions) => Math.max(dimensions.width, dimensions.height), + } + : fit === 'volume' + ? { + label: 'volume', + getSize: ({ width, height, depth }) => + Math.cbrt(width * height * depth), + } + : { label: 'largest dimension', getSize: getLargestDimension }; + +/** + * What looks wrong in the size of a theme model compared to the placeholder it + * replaces: a main dimension more than 25% off (like a prop of another shape + * than the placeholder, fit in it but much smaller), or a vehicle more than + * 25% broader than the placeholder. The scale a theme deliberately gives its + * model is not reported. A model sized by the volume of the placeholder + * object, for lack of its real dimensions, is not checked: the size it is + * drawn at is not known. + * + * A vehicle taller than the placeholder is not reported (a truck in the place + * of a car), nor a character wider than it: its model is in its bind pose, + * arms spread, wider than it is drawn while it moves. + * @param {SizedModel} sizedModel + * @returns {Array} + */ +const getSizeWarnings = ({ fit, scale, placeholder, themed: scaledThemed }) => { + if (!fit) return []; + const themed = { + width: scaledThemed.width / scale, + height: scaledThemed.height / scale, + depth: scaledThemed.depth / scale, + }; + + /** @type {Array} */ + const warnings = []; + const mainDimension = getMainDimension(fit); + const placeholderMainSize = mainDimension.getSize(placeholder); + if (placeholderMainSize > 0) { + const ratio = mainDimension.getSize(themed) / placeholderMainSize; + if (ratio > MAX_SIZE_RATIO || ratio < 1 / MAX_SIZE_RATIO) { + warnings.push( + `${toPercent(ratio)} of the ${mainDimension.label} of the placeholder` + ); + } + } + if (fit === 'length') { + const ratio = + Math.min(themed.width, themed.height) / + Math.min(placeholder.width, placeholder.height); + if (ratio > MAX_SIZE_RATIO) { + warnings.push(`${toPercent(ratio)} of the breadth of the placeholder`); + } + } + return warnings; +}; + +module.exports = { getSizeWarnings }; diff --git a/scripts/lib/ThemeSlots.js b/scripts/lib/ThemeSlots.js index db98b2b05..c2a286749 100644 --- a/scripts/lib/ThemeSlots.js +++ b/scripts/lib/ThemeSlots.js @@ -5,12 +5,20 @@ const fs = require('fs').promises; /** * The vocabulary of slots a theme can fill, shared by every starter. The * description of a slot is what the authors of themes read to choose its asset. + * A model slot says how its model is sized (`fit`), and can draw it larger or + * smaller than that (`scale`). * @typedef {{ * version: number, - * slots: Array<{id: string, kind: ThemeSlotKind, label: string, description: string}>, + * slots: Array<{id: string, kind: ThemeSlotKind, fit?: ModelFit, scale?: number, label: string, description: string}>, * }} ThemeSlotsVocabulary * * @typedef {'model' | 'texture' | 'material' | 'skybox' | 'color' | 'sprite' | 'tilemap'} ThemeSlotKind + * + * How a theme model takes the place of a placeholder: with its height (a + * character, a tree: a giant or a dwarf would break the game), its length (a + * vehicle), its volume (a projectile, a ball in the place of a missile), or as + * large as it fits in the room of the placeholder object (a prop, a building). + * @typedef {'height' | 'length' | 'volume' | 'contain'} ModelFit */ /** diff --git a/scripts/lib/__tests__/GlbBox.spec.js b/scripts/lib/__tests__/GlbBox.spec.js new file mode 100644 index 000000000..879a38461 --- /dev/null +++ b/scripts/lib/__tests__/GlbBox.spec.js @@ -0,0 +1,51 @@ +// @ts-check +const { readGlbSize } = require('../GlbBox'); + +/** + * A .glb file with only its JSON chunk: enough for the bounds of its meshes. + * @param {any} gltf + * @returns {Buffer} + */ +const makeGlb = (gltf) => { + const json = Buffer.from(JSON.stringify(gltf)); + const paddedJson = Buffer.concat([ + json, + Buffer.alloc((4 - (json.length % 4)) % 4, ' '), + ]); + const header = Buffer.alloc(20); + header.write('glTF', 0); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(20 + paddedJson.length, 8); + header.writeUInt32LE(paddedJson.length, 12); + header.write('JSON', 16); + return Buffer.concat([header, paddedJson]); +}; + +describe('readGlbSize', () => { + it('reads the size of the meshes moved by their nodes', () => { + const glb = makeGlb({ + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { children: [1], scale: [2, 2, 2] }, + { + mesh: 0, + // A quarter turn around X: Y becomes Z. + rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2], + translation: [10, 0, 0], + }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + accessors: [{ min: [0, 0, 0], max: [1, 2, 3] }], + }); + + const size = readGlbSize(glb); + expect(size && size.x).toBeCloseTo(2); + expect(size && size.y).toBeCloseTo(6); + expect(size && size.z).toBeCloseTo(4); + }); + + it('cannot read a file that is not a .glb', () => { + expect(readGlbSize(Buffer.from('not a model'))).toBeNull(); + }); +}); diff --git a/scripts/lib/__tests__/StarterThemer.spec.js b/scripts/lib/__tests__/StarterThemer.spec.js index 247cc826e..01e8a7e7c 100644 --- a/scripts/lib/__tests__/StarterThemer.spec.js +++ b/scripts/lib/__tests__/StarterThemer.spec.js @@ -722,6 +722,479 @@ describe('applyThemeToStarter on custom objects', () => { }); }); +describe('applyThemeToStarter sizing models by the meaning of their slot', () => { + /** @typedef {import('../StarterThemer.js').ModelDimensions} ModelDimensions */ + /** @typedef {import('../GlbBox.js').GlbSize} GlbSize */ + /** @typedef {import('../ThemeSlots.js').ModelFit} ModelFit */ + + /** + * A starter whose object `Thing` draws a placeholder model, standing on Y + * in its file, in a box keeping its proportions. + * @param {{box: ModelDimensions, instances?: Array}} options + * @returns {any} + */ + const makeSizedProject = ({ box, instances }) => ({ + resources: { + resources: [ + { + name: 'Placeholder.glb', + file: 'assets/Placeholder.glb', + kind: 'model3D', + }, + ], + }, + layouts: [ + { + name: 'Game Scene', + layers: [], + objects: [ + { + name: 'Thing', + type: 'Scene3D::Model3DObject', + content: { + ...makeModelContent('Placeholder.glb'), + ...box, + rotationX: 90, + keepAspectRatio: true, + }, + }, + ], + instances: instances || [], + }, + ], + }); + + /** + * @param {string} slotId + * @param {ModelDimensions=} dimensions + * @param {number=} rotationX + * @param {number=} scale + * @returns {Theme} + */ + const makeSizedTheme = (slotId, dimensions, rotationX = 90, scale) => { + const side = dimensions + ? Math.max(dimensions.width, dimensions.height, dimensions.depth) + : 100; + return { + id: 'city', + name: 'City', + description: 'A city.', + slots: { + [slotId]: { + kind: 'model', + file: 'https://asset-resources.gdevelop.io/public-resources/Theme.glb', + resourceName: 'Theme.glb', + dimensions, + ...(scale ? { scale } : {}), + objectContent: { + modelResourceName: 'Theme.glb', + width: side, + height: side, + depth: side, + rotationX, + keepAspectRatio: true, + }, + }, + }, + }; + }; + + /** + * @param {any} projectContent + * @param {string} objectPath + * @param {Theme} theme + * @param {ModelFit} fit + * @param {GlbSize} placeholderSize + * @param {number=} slotScale + */ + const applySizedTheme = ( + projectContent, + objectPath, + theme, + fit, + placeholderSize, + slotScale + ) => { + const [slotId] = Object.keys(theme.slots); + const slot = theme.slots[slotId]; + const isScattered = objectPath.endsWith('Copies'); + return applyThemeToStarter( + projectContent, + { + version: 1, + objects: { [objectPath]: isScattered ? { Model: slotId } : slotId }, + }, + theme, + { + modelFitBySlot: { [slotId]: fit }, + modelScaleBySlot: slotScale ? { [slotId]: slotScale } : {}, + getStarterModelSize: (resourceFile) => + resourceFile === 'assets/Placeholder.glb' ? placeholderSize : null, + } + ); + }; + + /** + * @param {any} object An object content or an instance. + * @param {ModelDimensions} dimensions + */ + const expectDimensions = (object, { width, height, depth }) => { + expect(object.width).toBeCloseTo(width); + expect(object.height).toBeCloseTo(height); + expect(object.depth).toBeCloseTo(depth); + }; + + it('gives a character the height of the placeholder', () => { + // A capsule 30 wide and 60 tall, and a character in its bind pose, arms + // spread along Y. + const projectContent = makeSizedProject({ + box: { width: 30, height: 30, depth: 60 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('character.player', { + width: 29, + height: 156, + depth: 145, + }), + 'height', + { x: 2, y: 4, z: 2 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: (29 * 60) / 145, + height: (156 * 60) / 145, + depth: 60, + }); + }); + + it('gives a vehicle the length of the placeholder, whichever way it faces', () => { + const projectContent = makeSizedProject({ + box: { width: 200, height: 100, depth: 50 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('vehicle.car', { width: 50, height: 100, depth: 60 }), + 'length', + { x: 4, y: 1, z: 2 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: 100, + height: 200, + depth: 120, + }); + }); + + it('fits a prop in the place of the placeholder, whichever way it faces', () => { + // A barrier 200 long, 40 thick and 60 tall. + const projectContent = makeSizedProject({ + box: { width: 200, height: 40, depth: 60 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('prop.obstacle', { width: 20, height: 100, depth: 30 }), + 'contain', + { x: 10, y: 3, z: 2 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: 40, + height: 200, + depth: 60, + }); + }); + + it('keeps a prop of another shape within the size of the placeholder', () => { + const projectContent = makeSizedProject({ + box: { width: 200, height: 40, depth: 60 }, + }); + const { sizedModels } = applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('prop.obstacle', { width: 20, height: 20, depth: 40 }), + 'contain', + { x: 10, y: 3, z: 2 } + ); + + expect(sizedModels).toEqual([ + { + objectPath: 'scene:Game Scene/Thing', + slotId: 'prop.obstacle', + fit: 'contain', + scale: 1, + placeholder: { width: 200, height: 40, depth: 60 }, + themed: { width: 30, height: 30, depth: 60 }, + }, + ]); + }); + + it('fits a prop in the room of the object, larger than its placeholder', () => { + // A thin bag drawn 20 wide in an object of 50 by 50: a crate takes the + // room of the object. + const projectContent = makeSizedProject({ + box: { width: 50, height: 50, depth: 50 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('prop.container', { width: 10, height: 10, depth: 10 }), + 'contain', + { x: 2, y: 5, z: 4 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: 50, + height: 50, + depth: 50, + }); + }); + + it('gives a projectile the volume of the placeholder', () => { + // A missile 80 long and 10 thick, and a ball. + const projectContent = makeSizedProject({ + box: { width: 80, height: 10, depth: 10 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('projectile.player', { width: 1, height: 1, depth: 1 }), + 'volume', + { x: 8, y: 1, z: 1 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: 20, + height: 20, + depth: 20, + }); + }); + + it('draws a model at the scale of its slot, times the one of its theme', () => { + const projectContent = makeSizedProject({ + box: { width: 30, height: 30, depth: 60 }, + }); + const { sizedModels } = applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme( + 'character.player', + { width: 30, height: 30, depth: 150 }, + 90, + 1.5 + ), + 'height', + { x: 1, y: 2, z: 1 }, + 1.2 + ); + + expect(projectContent.layouts[0].objects[0].content.depth).toBeCloseTo(108); + expect(sizedModels[0].scale).toBeCloseTo(1.8); + }); + + it('keeps the physics body and the mass of the placeholder for a character', () => { + // A capsule 30 wide and 60 tall, and a character in its bind pose, its + // arms spread along Y: its body stays the capsule, 20% taller. + const projectContent = makeSizedProject({ + box: { width: 30, height: 30, depth: 60 }, + }); + projectContent.layouts[0].objects[0].behaviors = [ + { + type: 'Physics3D::Physics3DBehavior', + shape: 'Capsule', + shapeOrientation: 'Z', + shapeDimensionA: 0, + shapeDimensionB: 0, + shapeDimensionC: 0, + density: 1.728, + }, + ]; + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('character.player', { + width: 29, + height: 156, + depth: 145, + }), + 'height', + { x: 2, y: 4, z: 2 }, + 1.2 + ); + + const [body] = projectContent.layouts[0].objects[0].behaviors; + expect(body.shapeDimensionA).toBeCloseTo(18); + expect(body.shapeDimensionB).toBeCloseTo(72); + expect(body.density).toBeCloseTo(1); + }); + + it('draws a model at the scale its theme deliberately gives it', () => { + const projectContent = makeSizedProject({ + box: { width: 30, height: 30, depth: 60 }, + }); + const theme = makeSizedTheme( + 'character.monster', + { width: 30, height: 30, depth: 150 }, + 90, + 1.5 + ); + const { sizedModels } = applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + theme, + 'height', + { x: 1, y: 2, z: 1 } + ); + + expect(projectContent.layouts[0].objects[0].content.depth).toBeCloseTo(90); + expect(sizedModels.map(({ scale }) => scale)).toEqual([1.5]); + }); + + it('measures the placeholder as drawn in its box, keeping its proportions', () => { + // The box is a cube, but the placeholder model is twice as tall as wide. + const projectContent = makeSizedProject({ + box: { width: 100, height: 100, depth: 100 }, + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('character.player', { width: 30, height: 30, depth: 150 }), + 'height', + { x: 1, y: 2, z: 1 } + ); + + expect(projectContent.layouts[0].objects[0].content.depth).toBeCloseTo(100); + }); + + it('gives an instance with its own size the size its slot means there', () => { + const projectContent = makeSizedProject({ + box: { width: 100, height: 100, depth: 100 }, + instances: [ + { name: 'Thing', customSize: true, width: 25, height: 25 }, + { name: 'Thing', customSize: false, width: 0, height: 0 }, + ], + }); + applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('character.enemy', { width: 30, height: 90, depth: 150 }), + 'height', + { x: 1, y: 2, z: 1 } + ); + + const [resizedInstance, instance] = projectContent.layouts[0].instances; + // The placeholder is drawn 25 wide and 50 tall in the box of the + // instance (25 by 25, as tall as the object). + expectDimensions(resizedInstance, { width: 10, height: 30, depth: 50 }); + expect(instance).toEqual({ + name: 'Thing', + customSize: false, + width: 0, + height: 0, + }); + }); + + it('sizes a model by the volume of the placeholder without its real dimensions', () => { + const projectContent = makeSizedProject({ + box: { width: 100, height: 100, depth: 100 }, + }); + const { sizedModels } = applySizedTheme( + projectContent, + 'scene:Game Scene/Thing', + makeSizedTheme('prop.obstacle'), + 'contain', + { x: 1, y: 1, z: 1 } + ); + + expectDimensions(projectContent.layouts[0].objects[0].content, { + width: 100, + height: 100, + depth: 100, + }); + expect(sizedModels.map(({ fit }) => fit)).toEqual([null]); + }); + + /** + * @param {number} size + * @returns {any} + */ + const makeScatteredProject = (size) => ({ + resources: { + resources: [ + { + name: 'Placeholder.glb', + file: 'assets/Placeholder.glb', + kind: 'model3D', + }, + ], + }, + layouts: [ + { + name: 'Game Scene', + layers: [], + objects: [ + { + name: 'Copies', + type: 'Terrain3D::ScatteredModels', + content: { Model: 'Placeholder.glb', Size: String(size) }, + }, + ], + }, + ], + }); + + it('gives scattered copies of a prop the height fitting it where the placeholder copies were', () => { + // Placeholder rocks 50 tall, 200 by 100 across the ground; theme rocks 50 + // tall, 400 by 200 across. + const projectContent = makeScatteredProject(50); + applySizedTheme( + projectContent, + 'scene:Game Scene/Copies', + makeSizedTheme('nature.rock.big', { width: 400, height: 200, depth: 50 }), + 'contain', + { x: 4, y: 1, z: 2 } + ); + + expect(projectContent.layouts[0].objects[0].content).toEqual({ + Model: 'Theme.glb', + Size: '25', + }); + }); + + it('keeps the height of scattered trees', () => { + const projectContent = makeScatteredProject(300); + applySizedTheme( + projectContent, + 'scene:Game Scene/Copies', + makeSizedTheme('nature.tree', { width: 100, height: 100, depth: 50 }), + 'height', + { x: 1, y: 3, z: 1 } + ); + + expect(projectContent.layouts[0].objects[0].content.Size).toBe('300'); + }); + + it('stands up the scattered copies of a model standing on Z in its file', () => { + const projectContent = makeScatteredProject(300); + applySizedTheme( + projectContent, + 'scene:Game Scene/Copies', + makeSizedTheme('nature.tree', { width: 100, height: 100, depth: 300 }, 0), + 'height', + { x: 1, y: 3, z: 1 } + ); + + expect(projectContent.layouts[0].objects[0].content).toEqual({ + Model: 'Theme.glb', + Size: '300', + ModelUpAxis: 'Z', + }); + }); +}); + describe('applyThemeToStarter on a 2D starter', () => { const makeProject = () => ({ resources: { diff --git a/scripts/lib/__tests__/ThemeSizeCheck.spec.js b/scripts/lib/__tests__/ThemeSizeCheck.spec.js new file mode 100644 index 000000000..3eaa9e74f --- /dev/null +++ b/scripts/lib/__tests__/ThemeSizeCheck.spec.js @@ -0,0 +1,121 @@ +// @ts-check +const { getSizeWarnings } = require('../ThemeSizeCheck'); + +/** @typedef {import('../StarterThemer.js').SizedModel} SizedModel */ +/** @typedef {import('../StarterThemer.js').ModelDimensions} ModelDimensions */ + +/** + * @param {SizedModel['fit']} fit + * @param {ModelDimensions} placeholder + * @param {ModelDimensions} themed + * @param {number=} scale + * @returns {SizedModel} + */ +const makeSizedModel = (fit, placeholder, themed, scale = 1) => ({ + objectPath: 'scene:Game Scene/Thing', + slotId: 'prop.obstacle', + fit, + scale, + placeholder, + themed, +}); + +describe('getSizeWarnings', () => { + it('accepts a model about the size of the placeholder', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'contain', + { width: 200, height: 40, depth: 60 }, + { width: 40, height: 180, depth: 60 } + ) + ) + ).toEqual([]); + }); + + it('reports a prop of another shape, much smaller than the placeholder', () => { + // A traffic cone in the place of a long barrier. + expect( + getSizeWarnings( + makeSizedModel( + 'contain', + { width: 200, height: 40, depth: 60 }, + { width: 30, height: 30, depth: 60 } + ) + ) + ).toEqual(['30% of the largest dimension of the placeholder']); + }); + + it('reports a vehicle much broader than the placeholder', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'length', + { width: 200, height: 40, depth: 50 }, + { width: 200, height: 120, depth: 50 } + ) + ) + ).toEqual(['300% of the breadth of the placeholder']); + }); + + it('accepts a vehicle taller than the placeholder', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'length', + { width: 200, height: 100, depth: 50 }, + { width: 200, height: 100, depth: 150 } + ) + ) + ).toEqual([]); + }); + + it('accepts a character with arms spread wider than the placeholder', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'height', + { width: 30, height: 30, depth: 60 }, + { width: 12, height: 65, depth: 60 } + ) + ) + ).toEqual([]); + }); + + it('does not report the scale a theme deliberately gives its model', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'height', + { width: 30, height: 30, depth: 60 }, + { width: 30, height: 30, depth: 120 }, + 2 + ) + ) + ).toEqual([]); + }); + + it('compares a projectile by its volume', () => { + expect( + getSizeWarnings( + makeSizedModel( + 'volume', + { width: 40, height: 10, depth: 10 }, + { width: 40, height: 40, depth: 40 } + ) + ) + ).toEqual(['252% of the volume of the placeholder']); + }); + + it('does not check a model sized by the volume of the placeholder object', () => { + expect( + getSizeWarnings( + makeSizedModel( + null, + { width: 100, height: 100, depth: 100 }, + { width: 100, height: 100, depth: 100 } + ) + ) + ).toEqual([]); + }); +}); diff --git a/theme-slots.json b/theme-slots.json index 82cf4d030..e58d576cd 100644 --- a/theme-slots.json +++ b/theme-slots.json @@ -4,152 +4,195 @@ { "id": "character.player", "kind": "model", + "fit": "height", + "scale": 1.2, "label": "Player character", "description": "The character the player controls: walks, runs and jumps in the platformer, GTA, endless runner, RPG and point-and-click starters, or a unit selected and moved in the RTS and tile starters. A rigged character with Idle, Walk, Run, Jump and Death animations." }, { "id": "character.enemy", "kind": "model", + "fit": "height", + "scale": 1.2, "label": "Enemy character", "description": "An opponent the player fights: it chases the player and is shot in the FPS with enemies and the vampire survivor, and it is the target blown away by the tank. A rigged character with Idle, Walk or Run and Death animations, recognizable as hostile." }, { "id": "character.npc", "kind": "model", + "fit": "height", + "scale": 1.2, "label": "NPC or ally character", "description": "A friendly character the player walks to and talks to (RPG, point-and-click): it stands and opens a dialog. A rigged character with an Idle animation, recognizable as friendly." }, { "id": "character.monster", "kind": "model", + "fit": "height", + "scale": 1.2, "label": "Monster or creature", "description": "The creature hunting the player in the horror starters: it roams, chases the player and ends the game when it catches them, and is shot in the FPS horror. A rigged creature with Idle and Walk or Run animations, scarier than character.enemy." }, { "id": "vehicle.car", "kind": "model", + "fit": "length", "label": "Drivable ground vehicle", "description": "The vehicle the player drives on the ground (driving, racing, GTA): a car, kart, truck, tractor or sled. Not a boat or an aircraft." }, { "id": "vehicle.aircraft.player", "kind": "model", + "fit": "length", "label": "Player aircraft", "description": "The flying vehicle the player pilots (flight simulator, shoot'em up, twin-stick shooter): a plane, spaceship or any flying vessel." }, { "id": "vehicle.aircraft.enemy", "kind": "model", + "fit": "length", "label": "Enemy aircraft", "description": "The flying vehicles the player shoots down in the shoot'em up and the twin-stick shooter." }, { "id": "vehicle.tank.base", "kind": "model", + "fit": "length", "label": "Turret vehicle base", "description": "The hull of the tank, which drives. Fill the three tank slots from the same model set." }, { "id": "vehicle.tank.turret", "kind": "model", + "fit": "length", "label": "Turret vehicle turret", "description": "The turret of the tank, which rotates on the hull. Fill the three tank slots from the same model set." }, { "id": "vehicle.tank.cannon", "kind": "model", + "fit": "length", "label": "Turret vehicle cannon", "description": "The barrel of the tank, which aims up and down and fires. Fill the three tank slots from the same model set." }, { "id": "weapon.handheld", "kind": "model", + "fit": "contain", "label": "First person weapon", "description": "The gun held in first-person view in the FPS starters: the player aims it with the crosshair and fires it, and its bullets hit what it points at. It must shoot: a pistol, rifle, shotgun, blaster, crossbow or bow. Never a melee weapon or a tool (sword, axe, shovel, candy cane)." }, { "id": "projectile.player", "kind": "model", + "fit": "volume", "label": "Player projectile", "description": "What the player fires: the bullets of the shoot'em up, twin-stick shooter and vampire survivor, and the tank shell, which explodes where it lands. Small and flying: a bullet, arrow, cannonball, fireball or snowball." }, { "id": "projectile.enemy", "kind": "model", + "fit": "volume", "label": "Enemy projectile", "description": "What the enemies fire at the player in the shoot'em up. Small and flying, and different from projectile.player." }, { "id": "pickup.coin", "kind": "model", + "fit": "contain", "label": "Coin or currency pickup", - "description": "A collectible the player picks up by walking into it, counted as a score (the coins of the platformer). Small and rewarding: a coin, gem, star or fruit." + "description": "A collectible the player picks up by walking into it, counted as a score (the coins of the platformer). Small and rewarding, about as tall as it is wide: a coin, gem, star or fruit." }, { "id": "hazard.spike", "kind": "model", + "fit": "contain", "label": "Hazard or trap", "description": "A trap the player must avoid in the endless runner: touching it fails the run. Clearly dangerous: spikes, a saw, fire or a mine." }, { "id": "nature.tree", "kind": "model", + "fit": "height", "label": "Tree", "description": "The trees: scattered by the thousands in the forests of the expanded starters, where the player and vehicles collide with their trunks, and placed on tiles in the tile placement starters. Something tall growing from the ground, in the style of the setting: a pine, palm, dead tree or alien plant." }, { "id": "nature.grass", "kind": "model", + "fit": "height", "label": "Grass or bush", "description": "Small vegetation scattered densely on the ground of the expanded starters, which the player walks through (it has no collision), and the decorative grass of the RPG, RTS and point-and-click starters. Low and small: grass, a bush, flowers or mushrooms. Not a tree or an object." }, { "id": "nature.rock.big", "kind": "model", + "fit": "volume", "label": "Large rock", - "description": "Large rocks scattered on the terrain of the expanded starters, where the player and vehicles collide with them: boulders that block the way." + "description": "Large rocks scattered on the terrain of the expanded starters, where the player and vehicles collide with them: boulders that block the way. A rock takes the volume of the placeholder rock, whatever its shape." }, { "id": "nature.rock.small", "kind": "model", + "fit": "volume", "label": "Small rock", - "description": "Small stones scattered densely on the ground of the expanded starters, walked over: pebbles and small rocks." + "description": "Small stones scattered densely on the ground of the expanded starters, walked over: pebbles and small rocks. A stone takes the volume of the placeholder stone, whatever its shape." }, { "id": "resource.log", "kind": "model", + "fit": "contain", "label": "Log or raw resource", - "description": "A raw material lying on the ground: the logs harvested and carried in the survival crafting starters, and the fallen logs of the horror forests. A log, branch, plank or bundle of material." + "description": "A raw material lying on the ground: the logs harvested and carried in the survival crafting starters, and the fallen logs of the horror forests. Long and lying: a log, branch, plank or bundle of material." }, { "id": "crop.a", "kind": "model", + "fit": "contain", "label": "First crop or food item", "description": "The first crop of the farming starters: it grows from a seed planted in a dirt plot, and is picked up when grown. Something grown from the soil: a vegetable, fruit, pumpkin or mushroom. Not cooked food or an object. Different from crop.b." }, { "id": "crop.b", "kind": "model", + "fit": "contain", "label": "Second crop or food item", "description": "The second crop of the farming starters: it grows from a seed planted in a dirt plot, and is picked up when grown. Something grown from the soil: a vegetable, fruit, pumpkin or mushroom. Not cooked food or an object. Different from crop.a." }, { "id": "prop.container", "kind": "model", + "fit": "contain", "label": "Bag, crate or chest", - "description": "A container: the seed bags the player clicks to get seeds in the farming starters, and the crates and shipping containers used as cover in the FPS with enemies and the tank. A bag, crate, barrel or chest." + "description": "A container about as tall as it is wide: the seed bags the player clicks to get seeds in the farming starters, and the crates used as cover in the FPS with enemies. A bag, crate, barrel or chest." }, { "id": "building.tower", "kind": "model", - "label": "Tower or placeable building", - "description": "A building: the tower placed on a tile in the tile placement starters, and the houses of the villages of the expanded starters. A building or tower of the setting, standing on its own." + "fit": "contain", + "label": "Tall building or tower", + "description": "A tall building, at least twice as tall as it is wide: the skyscrapers of the GTA starter and the tower placed on a tile in the tile placement starters. A skyscraper, tower, silo, lighthouse or obelisk. Not a house (building.house)." + }, + { + "id": "building.house", + "kind": "model", + "fit": "contain", + "label": "House or low building", + "description": "A house or a low building standing on its own, about as tall as it is wide or lower: the houses of the GTA, driving and RPG starters, the buildings of the RTS, the barns of the farming starters, the crypts and cabins of the horror starters. A house, shop, barn, cabin, hut, tent, crypt or base module. Not a tower (building.tower)." }, { "id": "prop.obstacle", "kind": "model", - "label": "Road or track obstacle", - "description": "A light obstacle on a road or track: the traffic cones the car knocks away, the barriers along the racing track. Something a vehicle hits and pushes: a cone, barrier, fence piece or crate." + "fit": "contain", + "label": "Small obstacle", + "description": "A small obstacle on a road or track, about as tall as it is wide: the traffic cones the car knocks away in the driving and racing starters. Something a vehicle hits and pushes: a cone, bollard, barrel, crate or fire hydrant. Not a long barrier (prop.barrier)." + }, + { + "id": "prop.barrier", + "kind": "model", + "fit": "contain", + "label": "Barrier or low wall", + "description": "A long, low obstacle lining a road or giving cover, about three times as long as it is tall: the barriers along the racing and driving tracks, the sandbags and containers the player hides behind in the FPS with enemies and the tank. A road barrier, sandbags, fence, low wall, hedge or log. Not a cone (prop.obstacle)." }, { "id": "env.ground",