diff --git a/.circleci/config.yml b/.circleci/config.yml index a36a2c3bd..4887a5051 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -56,7 +56,14 @@ jobs: - checkout - attach_workspace: at: . - - run: npm run build -- --gdevelop-root-path GDevelop + # Off `main`, the build is deployed to staging: it uses the themes of the + # staging assets for the starters re-skinned with each theme. + - run: | + if [ "$CIRCLE_BRANCH" = "main" ]; then + npm run build -- --gdevelop-root-path GDevelop + else + npm run build -- --gdevelop-root-path GDevelop --staging + fi - run: npm run check-post-build - persist_to_workspace: root: . @@ -71,6 +78,23 @@ jobs: at: . - aws-cli/setup - run: npm run deploy -- --cf-zoneid $CLOUDFLARE_ZONE_ID --cf-token $CLOUDFLARE_TOKEN + deploy-staging: + docker: + - image: cimg/node:20.13.1 + steps: + - run: + # A pull request from a fork has no access to the credentials. + name: Skip if the credentials are not available + command: | + if [ -z "$AWS_ACCESS_KEY_ID" ]; then + echo "No AWS credentials (pull request from a fork?): not deploying to staging." + circleci-agent step halt + fi + - checkout + - attach_workspace: + at: . + - aws-cli/setup + - run: npm run deploy -- --staging --cf-zoneid $CLOUDFLARE_ZONE_ID --cf-token $CLOUDFLARE_TOKEN # Run the gameplay tests of the example games with a real GDevelop: the # latest Linux build published on S3 by GDevelop's own CI is downloaded and @@ -201,6 +225,38 @@ workflows: filters: branches: only: main + - deploy-staging: + requires: + - build + - tests + filters: + branches: + ignore: main + + # Every night (03:00 UTC): rebuild and redeploy `main`. The starters re-skinned + # with each theme are generated by the build from the themes and assets the + # assets repository publishes, so this is how they catch up with it. Only this + # workflow runs on the schedule: the gameplay tests are not run again, as the + # games did not change. + nightly-rebuild: + triggers: + - schedule: + cron: '0 3 * * *' + filters: + branches: + only: main + jobs: + - install + - build: + requires: + - install + - tests: + requires: + - install + - deploy: + requires: + - build + - tests # On `main` (or when explicitly asked for): check that every gameplay test # of the repository still passes. diff --git a/README.md b/README.md index 218a0058e..cb4f96276 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,107 @@ If you know how to create _Pull Requests_, you can also clone this repository an To add a game to the homepage the game have to be listed in the `scripts/generate-database.js` file. +### Theme slots + +A *theme* replaces the placeholder art of a 3D starter with a coherent set of +assets, so a game created by the AI looks like the setting the user asked for. +Themes and starters never refer to each other: both point at *slots*, roles +such as `character.player` or `env.ground`. + +- `theme-slots.json`, at the root, is the list of slots. It is the vocabulary + shared by every starter and every theme. +- `examples//theme-slots.json` says which objects of that starter play + which slot: + +```json +{ + "version": 1, + "objects": { + "scene:Game Scene/Player": "character.player", + "scene:Game Scene/Ground": "env.ground", + "scene:Game Scene/Crate": { "top": "env.ground", "front": "env.wall" }, + "object:TankConfiguration::CombinedTank/TankBase": "vehicle.tank.base" + }, + "effects": { + "effect:Game Scene//SkyBox": { "frontFaceResourceName": "sky.day.front" } + }, + "ignoredObjects": ["scene:Game Scene/Camera"] +} +``` + +An object is referred to as `scene:/`, `global/`, or +`object:::/` for the children of a custom +object. A 3D model maps to one model slot. A 3D cube maps either to one texture +slot for all its faces, or to one slot per face. A skybox effect maps each of +its texture parameters. + +**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: + +``` +Starter "starting-3d-sailing": the Scene3D::Model3DObject "scene:Game Scene/Boat" +is neither mapped to a slot nor listed in ignoredObjects in theme-slots.json. +``` + +- **Reuse an existing slot whenever the object plays an existing role.** A new + starter's hero is `character.player`. Every theme then covers it already. +- **Add a slot to the root `theme-slots.json` only for a genuinely new role.** + Every theme is missing it until someone fills it in the assets repository: + those objects keep their placeholder art in the meantime, which is not an + error. +- **List in `ignoredObjects`** what is not meant to be seen as art: the camera + anchor cube, an invisible collision helper, the faceless player of a first + person game. + +Renaming or removing an object makes the build fail the same way on the entry +that no longer matches: fix or delete the line it names. HUD sprites, fonts and +sounds are not part of this. + +#### Themed starters + +The build uses these mappings to write, next to each 3D starter, a copy of it +re-skinned with each theme published by the assets repository: + +``` +examples/starting-3d-tank/starting-3d-tank.json # the starter +examples/starting-3d-tank/starting-3d-tank.theme-pirate.json # its pirate copy +``` + +When the AI picks a theme, GDevelop opens the copy instead of the starter, so +the game shows the themed assets from the first frame. The copies live in the +starter's folder because a starter refers to its images, sounds and fonts by a +relative path. They are not examples of their own and are never listed: +`themedStarters.json`, in the database, says which themes exist and which +starters each one covers. It is also what the AI prompts read, so a theme is +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. + +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 +theme is therefore available the day after it is merged there (or right away, +by pushing to `main` here or rerunning the last `main` pipeline in CircleCI). + +#### Trying a theme before merging + +Any branch but `main` is built with `--staging` and deployed next to the live +examples, under `staging/examples` and `staging/examples-database`, like the +assets repository does. A staging build uses the themes of the **staging** +assets, so a theme pushed on a branch of the assets repository can be tried end +to end without merging anything: + +1. push the theme on a branch of the assets repository (deployed to staging); +2. push (or rerun the pipeline of) a branch here, so the starters re-skinned + with it are built and deployed to staging; +3. push a branch of the AI prompts, whose dev prompts list the staging themes; +4. in a development build of GDevelop, turn on "Show staging assets" in the + asset store, then create a game with the AI. + +Staging holds the result of the last branch that was pushed, whoever pushed it. + ### Gameplay tests A game can contain _gameplay tests_: scripts that play the game like a player diff --git a/examples/starting-3D-platformer/theme-slots.json b/examples/starting-3D-platformer/theme-slots.json new file mode 100644 index 000000000..c788044f7 --- /dev/null +++ b/examples/starting-3D-platformer/theme-slots.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Player": "character.player", + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Coin": "pickup.coin", + "scene:Game Scene/PushableBox": { + "front": "env.crate", + "left": "env.crate", + "right": "env.crate", + "top": "env.crate", + "bottom": "env.crate" + } + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + } +} \ No newline at end of file diff --git a/examples/starting-3d-car-racing/theme-slots.json b/examples/starting-3d-car-racing/theme-slots.json new file mode 100644 index 000000000..a56a7542a --- /dev/null +++ b/examples/starting-3d-car-racing/theme-slots.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Road_3D": "env.road", + "scene:Game Scene/Grass_3D": "env.ground", + "scene:Game Scene/PlayerCar": "vehicle.car", + "scene:Game Scene/TrafficCone": "prop.obstacle", + "scene:Game Scene/FinishLine_3D": { + "front": "env.finish", + "left": "env.finish", + "right": "env.finish", + "top": "env.finish", + "bottom": "env.finish" + }, + "scene:Game Scene/Walls_3D": "env.wall" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + } +} \ No newline at end of file diff --git a/examples/starting-3d-draggable-tiles/theme-slots.json b/examples/starting-3d-draggable-tiles/theme-slots.json new file mode 100644 index 000000000..1773e84c5 --- /dev/null +++ b/examples/starting-3d-draggable-tiles/theme-slots.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Unit": "character.player", + "scene:Game Scene/Tower": "building.tower", + "scene:Game Scene/Tree": "nature.tree", + "scene:Game Scene/PlacementGrid": { + "front": "env.tiles" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-driving/theme-slots.json b/examples/starting-3d-driving/theme-slots.json new file mode 100644 index 000000000..af6a0aa79 --- /dev/null +++ b/examples/starting-3d-driving/theme-slots.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Road_3D": "env.road", + "scene:Game Scene/Grass_3D": "env.ground", + "scene:Game Scene/PlayerCar": "vehicle.car", + "scene:Game Scene/TrafficCone": "prop.obstacle", + "scene:Game Scene/Walls_3D": "env.wall" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + } +} \ No newline at end of file diff --git a/examples/starting-3d-endless-runner/theme-slots.json b/examples/starting-3d-endless-runner/theme-slots.json new file mode 100644 index 000000000..a9c686c67 --- /dev/null +++ b/examples/starting-3d-endless-runner/theme-slots.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Platform_Ground": { + "front": "env.wall", + "left": "env.ground", + "right": "env.ground", + "top": "env.ground" + }, + "scene:Game Scene/Hazard": "hazard.spike", + "scene:Game Scene/Platform_Floating": { + "front": "env.ground", + "left": "env.ground", + "right": "env.ground", + "top": "env.ground" + }, + "scene:Game Scene/Player": "character.player" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-flight-sim/theme-slots.json b/examples/starting-3d-flight-sim/theme-slots.json new file mode 100644 index 000000000..dc6b2a517 --- /dev/null +++ b/examples/starting-3d-flight-sim/theme-slots.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/PlayerAircraft": "vehicle.aircraft.player" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + } +} \ No newline at end of file diff --git a/examples/starting-3d-point-and-click-adventure/theme-slots.json b/examples/starting-3d-point-and-click-adventure/theme-slots.json new file mode 100644 index 000000000..8d798ec8d --- /dev/null +++ b/examples/starting-3d-point-and-click-adventure/theme-slots.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Wall": "env.wall", + "scene:Game Scene/Water": { + "front": "env.water" + }, + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Player": "character.player", + "scene:Game Scene/NPC": "character.npc", + "scene:Game Scene/Grass": "nature.grass" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-rts-unit-selection/theme-slots.json b/examples/starting-3d-rts-unit-selection/theme-slots.json new file mode 100644 index 000000000..ece943ca4 --- /dev/null +++ b/examples/starting-3d-rts-unit-selection/theme-slots.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/RTSUnit": "character.player", + "scene:Game Scene/Building_Impassable": "env.wall", + "scene:Game Scene/Water_Passable": { + "front": "env.water" + }, + "scene:Game Scene/Grass": "nature.grass" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-shootemup/theme-slots.json b/examples/starting-3d-shootemup/theme-slots.json new file mode 100644 index 000000000..8c51275f9 --- /dev/null +++ b/examples/starting-3d-shootemup/theme-slots.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/RepeatingBackground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Player": "vehicle.aircraft.player", + "scene:Game Scene/Enemy": "vehicle.aircraft.enemy", + "scene:Game Scene/PlayerBullet": "projectile.player", + "scene:Game Scene/EnemyBullet": "projectile.enemy" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-tank/theme-slots.json b/examples/starting-3d-tank/theme-slots.json new file mode 100644 index 000000000..422896d5c --- /dev/null +++ b/examples/starting-3d-tank/theme-slots.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Road_3D": "env.road", + "scene:Game Scene/Grass_3D": "env.ground", + "scene:Game Scene/Target": "character.enemy", + "scene:Game Scene/Bullet": "projectile.player", + "scene:Game Scene/Walls_3D": "env.wall", + "object:TankConfiguration::CombinedTank/TankBase": "vehicle.tank.base", + "object:TankConfiguration::TankTop/TankTop": "vehicle.tank.turret", + "object:TankConfiguration::TankTop/TankCanon": "vehicle.tank.cannon" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + } +} \ No newline at end of file diff --git a/examples/starting-3d-tile-placement/theme-slots.json b/examples/starting-3d-tile-placement/theme-slots.json new file mode 100644 index 000000000..1773e84c5 --- /dev/null +++ b/examples/starting-3d-tile-placement/theme-slots.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Unit": "character.player", + "scene:Game Scene/Tower": "building.tower", + "scene:Game Scene/Tree": "nature.tree", + "scene:Game Scene/PlacementGrid": { + "front": "env.tiles" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-top-down-rpg/theme-slots.json b/examples/starting-3d-top-down-rpg/theme-slots.json new file mode 100644 index 000000000..1850f26c7 --- /dev/null +++ b/examples/starting-3d-top-down-rpg/theme-slots.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Player": "character.player", + "scene:Game Scene/NPC": "character.npc", + "scene:Game Scene/Grass": "nature.grass", + "scene:Game Scene/Building_Obstacle": "env.wall" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-twin-stick-shooter/theme-slots.json b/examples/starting-3d-twin-stick-shooter/theme-slots.json new file mode 100644 index 000000000..d5d92c417 --- /dev/null +++ b/examples/starting-3d-twin-stick-shooter/theme-slots.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/RepeatingBackground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Player": "vehicle.aircraft.player", + "scene:Game Scene/Enemy": "vehicle.aircraft.enemy", + "scene:Game Scene/PlayerBullet": "projectile.player" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-3d-vampire-survivor/theme-slots.json b/examples/starting-3d-vampire-survivor/theme-slots.json new file mode 100644 index 000000000..0c414f804 --- /dev/null +++ b/examples/starting-3d-vampire-survivor/theme-slots.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/RepeatingBackground": { + "front": "env.ground.tiled" + }, + "scene:Game Scene/Player": "character.player", + "scene:Game Scene/Enemy": "character.enemy", + "scene:Game Scene/PlayerBullet": "projectile.player" + }, + "ignoredObjects": [ + "scene:Game Scene/Camera" + ] +} \ No newline at end of file diff --git a/examples/starting-first-person-farming/theme-slots.json b/examples/starting-first-person-farming/theme-slots.json new file mode 100644 index 000000000..362317849 --- /dev/null +++ b/examples/starting-first-person-farming/theme-slots.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground" + }, + "scene:Game Scene/DirtPlot": { + "front": "env.dirt", + "left": "env.dirt", + "right": "env.dirt", + "top": "env.dirt", + "bottom": "env.dirt" + }, + "scene:Game Scene/Carrot": "crop.a", + "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/Seed_Beet": "nature.rock.small", + "scene:Game Scene/Seed_Carrot": "nature.rock.small", + "scene:Game Scene/Walls": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + } + }, + "effects": { + "effect:Game Scene//Effect2": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Player" + ] +} diff --git a/examples/starting-first-person-horror/theme-slots.json b/examples/starting-first-person-horror/theme-slots.json new file mode 100644 index 000000000..924eb36f6 --- /dev/null +++ b/examples/starting-first-person-horror/theme-slots.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/PushableBox": "env.crate", + "scene:Game Scene/Monster": "character.monster" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.dark.back", + "bottomFaceResourceName": "sky.dark.bottom", + "frontFaceResourceName": "sky.dark.front", + "leftFaceResourceName": "sky.dark.left", + "rightFaceResourceName": "sky.dark.right", + "topFaceResourceName": "sky.dark.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/PathfindingObstacle", + "scene:Game Scene/Player", + "object:Light3D::SpotLight3D/Placeholder", + "object:Light3D::PointLight3D/Placeholder" + ] +} diff --git a/examples/starting-first-person-shooter-horror/theme-slots.json b/examples/starting-first-person-shooter-horror/theme-slots.json new file mode 100644 index 000000000..fe2281f83 --- /dev/null +++ b/examples/starting-first-person-shooter-horror/theme-slots.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/PushableBox": "env.crate", + "scene:Game Scene/Monster": "character.monster", + "scene:Game Scene/Gun": "weapon.handheld" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.dark.back", + "bottomFaceResourceName": "sky.dark.bottom", + "frontFaceResourceName": "sky.dark.front", + "leftFaceResourceName": "sky.dark.left", + "rightFaceResourceName": "sky.dark.right", + "topFaceResourceName": "sky.dark.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/PathfindingObstacle", + "scene:Game Scene/Player", + "object:Light3D::SpotLight3D/Placeholder", + "object:Light3D::PointLight3D/Placeholder" + ] +} diff --git a/examples/starting-first-person-shooter/theme-slots.json b/examples/starting-first-person-shooter/theme-slots.json new file mode 100644 index 000000000..d4f52dd2d --- /dev/null +++ b/examples/starting-first-person-shooter/theme-slots.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/PushableBox": "env.crate", + "scene:Game Scene/Gun": "weapon.handheld", + "scene:Game Scene/Target": "character.player" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Player" + ] +} diff --git a/examples/starting-first-person-survival-crafting/theme-slots.json b/examples/starting-first-person-survival-crafting/theme-slots.json new file mode 100644 index 000000000..216e28b1a --- /dev/null +++ b/examples/starting-first-person-survival-crafting/theme-slots.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Log": "resource.log", + "scene:Game Scene/Rock": "nature.rock.small", + "scene:Game Scene/Harvest_Rock": "nature.rock.big", + "scene:Game Scene/Harvest_Tree": "nature.tree" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Player" + ] +} diff --git a/examples/starting-first-person/theme-slots.json b/examples/starting-first-person/theme-slots.json new file mode 100644 index 000000000..ba95df2b7 --- /dev/null +++ b/examples/starting-first-person/theme-slots.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "objects": { + "scene:Game Scene/Ground": { + "front": "env.ground", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/Obstacle": { + "front": "env.wall", + "left": "env.wall", + "right": "env.wall", + "top": "env.wall", + "bottom": "env.wall" + }, + "scene:Game Scene/PushableBox": "env.crate" + }, + "effects": { + "effect:Game Scene//SkyBox": { + "backFaceResourceName": "sky.day.back", + "bottomFaceResourceName": "sky.day.bottom", + "frontFaceResourceName": "sky.day.front", + "leftFaceResourceName": "sky.day.left", + "rightFaceResourceName": "sky.day.right", + "topFaceResourceName": "sky.day.top" + } + }, + "ignoredObjects": [ + "scene:Game Scene/Player" + ] +} diff --git a/scripts/deploy.js b/scripts/deploy.js index a629efe65..71d7bea41 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -5,8 +5,12 @@ const args = require('minimist')(process.argv.slice(2)); const databasePath = path.join(__dirname, '../dist/database'); const examplesPath = path.join(__dirname, '../dist/examples'); -const databaseDestination = `s3://resources.gdevelop-app.com/examples-database`; -const examplesDestination = `s3://resources.gdevelop-app.com/examples`; +// With `--staging`, deploy next to the live examples without touching them, +// like the assets repository does. Made from any branch but `main`. +const staging = args['staging'] !== undefined; +const prefix = staging ? 'staging/' : ''; +const databaseDestination = `s3://resources.gdevelop-app.com/${prefix}examples-database`; +const examplesDestination = `s3://resources.gdevelop-app.com/${prefix}examples`; if (!args['cf-zoneid'] || !args['cf-token']) { shell.echo( @@ -60,8 +64,10 @@ axios { files: [ // Update the "database" - 'https://resources.gdevelop-app.com/examples-database/exampleShortHeaders.json', - 'https://resources.gdevelop-app.com/examples-database/filters.json', + `https://resources.gdevelop-app.com/${prefix}examples-database/exampleShortHeaders.json`, + `https://resources.gdevelop-app.com/${prefix}examples-database/filters.json`, + `https://resources.gdevelop-app.com/${prefix}examples-database/themeSlots.json`, + `https://resources.gdevelop-app.com/${prefix}examples-database/themedStarters.json`, ], }, { diff --git a/scripts/generate-database.js b/scripts/generate-database.js index ceb781ea1..0e4a46e60 100644 --- a/scripts/generate-database.js +++ b/scripts/generate-database.js @@ -2,6 +2,7 @@ const shell = require('shelljs'); const path = require('path'); const fs = require('fs').promises; +const fsSync = require('fs'); const { constants } = require('fs'); const crypto = require('crypto'); const { @@ -15,6 +16,15 @@ const allLicenses = require('./lib/licenses.json'); const args = require('minimist')(process.argv.slice(2)); const { loadSerializedProject } = require('./lib/LocalProjectOpener'); const { writeProjectJSONFile } = require('./lib/LocalProjectWriter'); +const { + loadThemeSlotsVocabulary, + checkStarterThemeSlots, +} = require('./lib/ThemeSlots'); +const { + applyThemeToStarter, + getThemedStarterFileName, +} = require('./lib/StarterThemer'); +const { default: axios } = require('axios'); /** @typedef {import('./lib/FileTreeParser.js').DreeWithMetadata} DreeWithMetadata */ /** @typedef {import('./types').libGDevelop} libGDevelop */ @@ -672,6 +682,103 @@ const createPlatformExtensionsMap = (gd) => { return platformExtensionsMap; }; +// With `--staging`, the build is meant for the staging deployment (made from +// any branch but `main`): it uses the themes of the staging assets, so a theme +// and the starters re-skinned with it can be tried before anything is merged. +const staging = args['staging'] !== undefined; +const publicBaseUrl = staging + ? 'https://resources.gdevelop-app.com/staging' + : 'https://resources.gdevelop-app.com'; + +// The themes are built and published by the assets repository. +const THEMES_BASE_URL = `${publicBaseUrl}/assets-database/themes`; + +/** + * The published themes. No theme published yet is a valid answer (the file is + * then missing); any other failure aborts the build, as publishing an empty + * list would silently turn the themes off. + * @returns {Promise>} + */ +const fetchThemes = async () => { + /** @type {Array<{id: string}>} */ + let themeShortHeaders = []; + try { + themeShortHeaders = (await axios.get(`${THEMES_BASE_URL}/themes.json`)) + .data; + } catch (error) { + const status = error.response && error.response.status; + if (status === 403 || status === 404) { + console.info('ℹ️ No theme is published yet: no themed starter to build.'); + return []; + } + throw error; + } + + return Promise.all( + themeShortHeaders.map( + async ({ id }) => (await axios.get(`${THEMES_BASE_URL}/${id}.json`)).data + ) + ); +}; + +/** + * Write, next to each 3D starter, a copy of it re-skinned with each theme + * (`.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. + * @param {Object.} themeSlotsByStarterSlug + * @param {Object.} starterFilePathBySlug + * @returns {Promise}>>} + */ +const generateThemedStarters = async ( + themeSlotsByStarterSlug, + starterFilePathBySlug +) => { + const themes = await fetchThemes(); + const themedStarters = []; + + for (const theme of themes) { + // Starter slug -> URL of its copy re-skinned with the theme. + /** @type {Object.} */ + const starters = {}; + for (const slug of Object.keys(themeSlotsByStarterSlug)) { + const starterFilePath = starterFilePathBySlug[slug]; + const projectObject = JSON.parse( + await fs.readFile(starterFilePath, 'utf8') + ); + const { changedObjectsCount, changedResourcesCount } = + applyThemeToStarter( + projectObject, + themeSlotsByStarterSlug[slug], + theme + ); + if (!changedObjectsCount && !changedResourcesCount) continue; + + const themedStarterFilePath = path.join( + path.dirname(starterFilePath), + getThemedStarterFileName(slug, theme.id) + ); + await fs.writeFile(themedStarterFilePath, JSON.stringify(projectObject)); + starters[slug] = `${publicBaseUrl}/examples/${normalizePathSeparators( + path.relative(examplesRootPath, themedStarterFilePath) + )}`; + } + console.info( + `ℹ️ Theme "${theme.id}": ${ + Object.keys(starters).length + } themed starters written.` + ); + themedStarters.push({ + id: theme.id, + name: theme.name, + description: theme.description, + starters, + }); + } + + return themedStarters; +}; + /** * Discover all examples and extract information from them. */ @@ -741,6 +848,55 @@ const createPlatformExtensionsMap = (gd) => { shell.exit(1); } + // Which objects of each 3D starter a theme re-skins. Checked against the + // starter itself so an unmapped object can never ship, then published in + // one file for the editor. + const themeSlotsVocabulary = await loadThemeSlotsVocabulary(); + /** @type {Object.} */ + const themeSlotsByStarterSlug = {}; + /** @type {Object.} */ + const starterFilePathBySlug = {}; + /** @type {Error[]} */ + const themeSlotsErrors = []; + for (const fileWithMetadata of allExampleFiles) { + const slug = path.basename(path.dirname(fileWithMetadata.path)); + if (!slug.startsWith('starting-') || !fileWithMetadata.parsedContent) { + continue; + } + const themeSlotsPath = path.join( + path.dirname(fileWithMetadata.path), + 'theme-slots.json' + ); + const starterThemeSlots = fsSync.existsSync(themeSlotsPath) + ? JSON.parse(await fs.readFile(themeSlotsPath, 'utf8')) + : null; + themeSlotsErrors.push( + ...checkStarterThemeSlots( + themeSlotsVocabulary, + slug, + fileWithMetadata.parsedContent, + starterThemeSlots + ) + ); + if (starterThemeSlots) { + themeSlotsByStarterSlug[slug] = starterThemeSlots; + starterFilePathBySlug[slug] = fileWithMetadata.path; + } + } + if (themeSlotsErrors.length) { + console.error( + 'There were errors while checking the starter theme slots:', + themeSlotsErrors + ); + console.info('Aborting because of these errors.'); + shell.exit(1); + } + + const themedStarters = await generateThemedStarters( + themeSlotsByStarterSlug, + starterFilePathBySlug + ); + try { shell.mkdir('-p', databaseRootPath); shell.mkdir('-p', path.join(databaseRootPath, 'examples')); @@ -760,6 +916,20 @@ const createPlatformExtensionsMap = (gd) => { JSON.stringify(exampleShortHeaders) ); + await fs.writeFile( + path.join(databaseRootPath, 'themedStarters.json'), + JSON.stringify({ version: 1, themes: themedStarters }) + ); + + await fs.writeFile( + path.join(databaseRootPath, 'themeSlots.json'), + JSON.stringify({ + version: themeSlotsVocabulary.version, + slots: themeSlotsVocabulary.slots, + starters: themeSlotsByStarterSlug, + }) + ); + await fs.writeFile( path.join(databaseRootPath, 'filters.json'), JSON.stringify( diff --git a/scripts/lib/StarterThemer.js b/scripts/lib/StarterThemer.js new file mode 100644 index 000000000..676b3f3b7 --- /dev/null +++ b/scripts/lib/StarterThemer.js @@ -0,0 +1,320 @@ +// @ts-check + +/** @typedef {import('./ThemeSlots.js').StarterThemeSlots} StarterThemeSlots */ + +/** + * 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. + * @typedef {{ + * kind: 'model' | 'texture', + * file: string, + * objectContent?: any, + * assetStoreId?: string, + * origin?: {name: string, identifier: string}, + * }} ThemeSlot + * + * @typedef {{ + * id: string, + * name: string, + * description: string, + * slots: Object., + * }} Theme + * + * @typedef {{ + * appliedSlots: Array, + * missingSlots: Array, + * changedObjectsCount: number, + * changedResourcesCount: number, + * }} ThemeApplicationResult + */ + +/** @type {Object.} */ +const cubeFaceProperties = { + front: 'frontFaceResourceName', + back: 'backFaceResourceName', + left: 'leftFaceResourceName', + right: 'rightFaceResourceName', + top: 'topFaceResourceName', + bottom: 'bottomFaceResourceName', +}; + +/** + * Every object of a serialized project, by the path a starter's + * theme-slots.json refers to it by. + * @param {any} projectObject + * @returns {Map} + */ +const getObjectsByPath = (projectObject) => { + /** @type {Map} */ + const objectsByPath = new Map(); + /** @param {string} prefix @param {Array | undefined} objects */ + const collect = (prefix, objects) => + (objects || []).forEach((object) => + objectsByPath.set(`${prefix}/${object.name}`, object) + ); + + collect('global', projectObject.objects); + (projectObject.layouts || []).forEach( + /** @param {any} layout */ (layout) => + collect(`scene:${layout.name}`, layout.objects) + ); + (projectObject.eventsFunctionsExtensions || []).forEach( + /** @param {any} extension */ (extension) => + (extension.eventsBasedObjects || []).forEach( + /** @param {any} eventsBasedObject */ (eventsBasedObject) => + collect( + `object:${extension.name}::${eventsBasedObject.name}`, + eventsBasedObject.objects + ) + ) + ); + return objectsByPath; +}; + +/** + * @param {any} projectObject + * @returns {Map} + */ +const getEffectsByPath = (projectObject) => { + /** @type {Map} */ + const effectsByPath = new Map(); + (projectObject.layouts || []).forEach( + /** @param {any} layout */ (layout) => + (layout.layers || []).forEach( + /** @param {any} layer */ (layer) => + (layer.effects || []).forEach( + /** @param {any} effect */ (effect) => + effectsByPath.set( + `effect:${layout.name}/${layer.name}/${effect.name}`, + effect + ) + ) + ) + ); + return effectsByPath; +}; + +/** + * Same merge as the asset swapper of the editor: the object keeps every + * animation name it had, played by the theme's animation of the same name (or + * its first one), and the theme's extra animations are appended. + * @param {Array} objectAnimations + * @param {Array} themeAnimations + * @returns {Array} + */ +const mergeModel3DAnimations = (objectAnimations, themeAnimations) => { + if (!themeAnimations.length) return objectAnimations; + + const animations = objectAnimations.map( + (objectAnimation) => + themeAnimations.find( + (themeAnimation) => themeAnimation.name === objectAnimation.name + ) || { ...themeAnimations[0], name: objectAnimation.name } + ); + themeAnimations.forEach((themeAnimation) => { + const isAlreadyAdded = objectAnimations.some( + (objectAnimation) => objectAnimation.name === themeAnimation.name + ); + if (!isAlreadyAdded) animations.push(themeAnimation); + }); + return animations; +}; + +/** + * Scale the theme model to the volume the placeholder occupied, so instances + * keep their footprint in the scene. + * @param {any} objectContent + * @param {any} themeContent + * @returns {number} + */ +const getSizeRatio = (objectContent, themeContent) => { + const objectVolume = + (objectContent.width || 0) * + (objectContent.height || 0) * + (objectContent.depth || 0); + const themeVolume = + (themeContent.width || 0) * + (themeContent.height || 0) * + (themeContent.depth || 0); + if (objectVolume <= 0 || themeVolume <= 0) return 1; + return Math.pow(objectVolume / themeVolume, 1 / 3); +}; + +/** + * Re-skin a starter with a theme by rewriting its serialized project, in place. + * + * The starter's theme-slots.json says which of its objects play which slot. + * 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 or + * a skybox face points at the theme image. + * + * Resources are repointed in place, keeping their names, so nothing else in + * the project has to be renamed. + * + * @param {any} projectObject + * @param {StarterThemeSlots} starterThemeSlots + * @param {Theme} theme + * @returns {ThemeApplicationResult} + */ +const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { + /** @type {Array} */ + const resources = (projectObject.resources || {}).resources || []; + /** @type {Map} */ + const resourceByName = new Map( + resources.map((resource) => [resource.name, resource]) + ); + const objectsByPath = getObjectsByPath(projectObject); + const effectsByPath = getEffectsByPath(projectObject); + + /** @type {Set} */ + const appliedSlots = new Set(); + /** @type {Set} */ + const missingSlots = new Set(); + /** @type {Set} */ + const changedResourceNames = new Set(); + /** @type {Map} */ + const slotIdByResourceName = new Map(); + let changedObjectsCount = 0; + + /** + * Point the resource of that name at the theme file and return the name to + * reference. Objects sharing a resource but playing different slots each get + * their own copy, so one does not overwrite the other. + * @param {string} resourceName + * @param {string} slotId + * @returns {string | null} + */ + const repointResource = (resourceName, slotId) => { + const slot = theme.slots[slotId]; + if (!slot) { + missingSlots.add(slotId); + return null; + } + const resource = resourceByName.get(resourceName); + if (!resource) return null; + + const alreadyAppliedSlotId = slotIdByResourceName.get(resourceName); + let themedResource = resource; + if (alreadyAppliedSlotId && alreadyAppliedSlotId !== slotId) { + const copyName = `${resourceName} (${slotId})`; + themedResource = resourceByName.get(copyName) || { + ...resource, + name: copyName, + }; + if (!resourceByName.has(copyName)) { + resources.push(themedResource); + resourceByName.set(copyName, themedResource); + } + } + + themedResource.file = slot.file; + themedResource.origin = slot.origin || { + name: 'gdevelop-asset-store', + identifier: slot.file, + }; + slotIdByResourceName.set(themedResource.name, slotId); + appliedSlots.add(slotId); + changedResourceNames.add(themedResource.name); + return themedResource.name; + }; + + Object.entries(starterThemeSlots.objects || {}).forEach( + ([objectPath, mapping]) => { + const object = objectsByPath.get(objectPath); + if (!object || !object.content) return; + const objectContent = object.content; + + if (object.type === 'Scene3D::Model3DObject') { + if (typeof mapping !== 'string') return; + const slot = theme.slots[mapping]; + if (!slot || slot.kind !== 'model' || !slot.objectContent) { + missingSlots.add(mapping); + return; + } + const modelResourceName = repointResource( + objectContent.modelResourceName, + mapping + ); + if (!modelResourceName) return; + + const themeContent = slot.objectContent; + const sizeRatio = getSizeRatio(objectContent, themeContent); + object.content = { + ...themeContent, + // Keep pointing at the project's own resource, now holding the theme model. + modelResourceName, + animations: mergeModel3DAnimations( + objectContent.animations || [], + themeContent.animations || [] + ), + width: (themeContent.width || 0) * sizeRatio, + height: (themeContent.height || 0) * sizeRatio, + depth: (themeContent.depth || 0) * sizeRatio, + // The origin and center drive collisions and placement: keep the + // starter's, which its events and instances were built around. + originLocation: objectContent.originLocation, + centerLocation: objectContent.centerLocation, + }; + if (slot.assetStoreId) object.assetStoreId = slot.assetStoreId; + changedObjectsCount++; + return; + } + + if (object.type === 'Scene3D::Cube3DObject') { + let changed = false; + Object.keys(cubeFaceProperties).forEach((face) => { + const slotId = typeof mapping === 'string' ? mapping : mapping[face]; + const faceProperty = cubeFaceProperties[face]; + const resourceName = objectContent[faceProperty]; + if (!slotId || !resourceName) return; + const themedResourceName = repointResource(resourceName, slotId); + if (!themedResourceName) return; + objectContent[faceProperty] = themedResourceName; + changed = true; + }); + if (changed) changedObjectsCount++; + } + } + ); + + Object.entries(starterThemeSlots.effects || {}).forEach( + ([effectPath, parameters]) => { + const effect = effectsByPath.get(effectPath); + if (!effect || !effect.stringParameters) return; + Object.entries(parameters).forEach(([parameter, slotId]) => { + const resourceName = effect.stringParameters[parameter]; + if (!resourceName) return; + const themedResourceName = repointResource( + String(resourceName), + slotId + ); + if (themedResourceName) { + effect.stringParameters[parameter] = themedResourceName; + } + }); + } + ); + + return { + appliedSlots: [...appliedSlots].sort(), + missingSlots: [...missingSlots].sort(), + changedObjectsCount, + changedResourcesCount: changedResourceNames.size, + }; +}; + +/** + * The file a themed copy of a starter is written to: next to the starter, so + * the resources it refers to by a relative path still resolve. + * @param {string} slug + * @param {string} themeId + * @returns {string} + */ +const getThemedStarterFileName = (slug, themeId) => + `${slug}.theme-${themeId}.json`; + +module.exports = { + applyThemeToStarter, + getThemedStarterFileName, +}; diff --git a/scripts/lib/ThemeSlots.js b/scripts/lib/ThemeSlots.js new file mode 100644 index 000000000..c424778a5 --- /dev/null +++ b/scripts/lib/ThemeSlots.js @@ -0,0 +1,236 @@ +// @ts-check +const path = require('path'); +const fs = require('fs').promises; + +/** + * The vocabulary of slots a theme can fill, shared by every starter. + * @typedef {{ + * version: number, + * slots: Array<{id: string, kind: 'model' | 'texture', label: string}>, + * }} ThemeSlotsVocabulary + */ + +/** + * A starter's own mapping, next to its game file: which of its objects a theme + * re-skins, and as what. Object paths are `scene:/`, + * `global/` or `object:::/`. A cube + * maps either to one texture slot for all six faces, or to one slot per face. + * @typedef {{ + * version: number, + * objects: Object.>, + * effects?: Object.>, + * ignoredObjects?: Array, + * }} StarterThemeSlots + */ + +const vocabularyPath = path.join(__dirname, '../../theme-slots.json'); + +/** @returns {Promise} */ +const loadThemeSlotsVocabulary = async () => + JSON.parse(await fs.readFile(vocabularyPath, 'utf8')); + +const cubeFaceProperties = [ + 'frontFaceResourceName', + 'backFaceResourceName', + 'leftFaceResourceName', + 'rightFaceResourceName', + 'topFaceResourceName', + 'bottomFaceResourceName', +]; + +/** + * Every object of a project a theme could re-skin, with the path a mapping + * refers to it by, plus every skybox effect. + * @param {any} projectObject + * @returns {{objects: Array<{path: string, type: string}>, effects: Array, is3D: boolean}} + */ +const getThemeableObjects = (projectObject) => { + /** @type {Array<{path: string, type: string}>} */ + const objects = []; + /** @type {Array} */ + const effects = []; + const themeableTypes = ['Scene3D::Model3DObject', 'Scene3D::Cube3DObject']; + + /** @param {string} prefix @param {Array} list */ + const collect = (prefix, list) => + (list || []).forEach((object) => { + if (themeableTypes.includes(object.type)) { + objects.push({ path: `${prefix}/${object.name}`, type: object.type }); + } + }); + + collect('global', projectObject.objects); + (projectObject.layouts || []).forEach( + /** @param {any} layout */ (layout) => { + collect(`scene:${layout.name}`, layout.objects); + (layout.layers || []).forEach( + /** @param {any} layer */ (layer) => + (layer.effects || []).forEach( + /** @param {any} effect */ (effect) => { + if ((effect.effectType || '').includes('Skybox')) { + effects.push( + `effect:${layout.name}/${layer.name}/${effect.name}` + ); + } + } + ) + ); + } + ); + (projectObject.eventsFunctionsExtensions || []).forEach( + /** @param {any} extension */ (extension) => + (extension.eventsBasedObjects || []).forEach( + /** @param {any} eventsBasedObject */ (eventsBasedObject) => + collect( + `object:${extension.name}::${eventsBasedObject.name}`, + eventsBasedObject.objects + ) + ) + ); + + return { objects, effects, is3D: objects.length > 0 || effects.length > 0 }; +}; + +/** + * A 3D starter must say, for each object a theme could re-skin, which slot it + * plays or that it is deliberately left alone; and must not refer to objects + * or slots that do not exist. Enforced by the build so a starter cannot ship + * art the themes don't know how to replace. + * @param {ThemeSlotsVocabulary} vocabulary + * @param {string} slug + * @param {any} projectObject + * @param {StarterThemeSlots | null} starterThemeSlots + * @returns {Error[]} + */ +const checkStarterThemeSlots = ( + vocabulary, + slug, + projectObject, + starterThemeSlots +) => { + /** @type {Error[]} */ + const errors = []; + const { objects, effects, is3D } = getThemeableObjects(projectObject); + if (!is3D) return errors; + + if (!starterThemeSlots) { + errors.push( + new Error( + `Starter "${slug}" is a 3D starter but has no theme-slots.json: add one mapping its objects to theme slots (see the README).` + ) + ); + return errors; + } + + const slotKindById = new Map( + vocabulary.slots.map((slot) => [slot.id, slot.kind]) + ); + const typeByPath = new Map( + objects.map((object) => [object.path, object.type]) + ); + const effectPaths = new Set(effects); + const mapped = starterThemeSlots.objects || {}; + const ignored = new Set(starterThemeSlots.ignoredObjects || []); + const mappedEffects = starterThemeSlots.effects || {}; + + /** @param {string} where @param {string} slotId @param {'model'|'texture'} kind */ + const checkSlot = (where, slotId, kind) => { + const declaredKind = slotKindById.get(slotId); + if (!declaredKind) { + errors.push( + new Error( + `Starter "${slug}": ${where} maps to "${slotId}", which is not a slot of theme-slots.json.` + ) + ); + } else if (declaredKind !== kind) { + errors.push( + new Error( + `Starter "${slug}": ${where} maps to "${slotId}", a ${declaredKind} slot, but needs a ${kind} slot.` + ) + ); + } + }; + + objects.forEach(({ path: objectPath, type }) => { + if (ignored.has(objectPath)) return; + const mapping = mapped[objectPath]; + if (mapping === undefined) { + errors.push( + new Error( + `Starter "${slug}": the ${type} "${objectPath}" is neither mapped to a slot nor listed in ignoredObjects in theme-slots.json.` + ) + ); + return; + } + if (type === 'Scene3D::Model3DObject') { + if (typeof mapping !== 'string') { + errors.push( + new Error( + `Starter "${slug}": "${objectPath}" is a 3D model and must map to a single model slot.` + ) + ); + return; + } + checkSlot(`"${objectPath}"`, mapping, 'model'); + } else if (typeof mapping === 'string') { + checkSlot(`"${objectPath}"`, mapping, 'texture'); + } else { + Object.entries(mapping).forEach(([face, slotId]) => { + if (!cubeFaceProperties.some((property) => property.startsWith(face))) { + errors.push( + new Error( + `Starter "${slug}": "${objectPath}" maps the unknown cube face "${face}".` + ) + ); + return; + } + checkSlot(`"${objectPath}" face "${face}"`, slotId, 'texture'); + }); + } + }); + + Object.keys(mapped).forEach((objectPath) => { + if (!typeByPath.has(objectPath)) { + errors.push( + new Error( + `Starter "${slug}": theme-slots.json maps "${objectPath}", which is not a 3D object of the starter anymore.` + ) + ); + } + }); + ignored.forEach((objectPath) => { + if (!typeByPath.has(objectPath)) { + errors.push( + new Error( + `Starter "${slug}": theme-slots.json ignores "${objectPath}", which is not a 3D object of the starter anymore.` + ) + ); + } + }); + Object.entries(mappedEffects).forEach(([effectPath, faces]) => { + if (!effectPaths.has(effectPath)) { + errors.push( + new Error( + `Starter "${slug}": theme-slots.json maps the effect "${effectPath}", which is not a skybox of the starter anymore.` + ) + ); + return; + } + Object.entries(faces).forEach(([parameter, slotId]) => + checkSlot( + `skybox "${effectPath}" parameter "${parameter}"`, + slotId, + 'texture' + ) + ); + }); + + return errors; +}; + +module.exports = { + loadThemeSlotsVocabulary, + getThemeableObjects, + checkStarterThemeSlots, + cubeFaceProperties, +}; diff --git a/scripts/lib/__tests__/StarterThemer.spec.js b/scripts/lib/__tests__/StarterThemer.spec.js new file mode 100644 index 000000000..e20b6901c --- /dev/null +++ b/scripts/lib/__tests__/StarterThemer.spec.js @@ -0,0 +1,412 @@ +// @ts-check +const { + applyThemeToStarter, + getThemedStarterFileName, +} = require('../StarterThemer'); + +/** @typedef {import('../ThemeSlots.js').StarterThemeSlots} StarterThemeSlots */ +/** @typedef {import('../StarterThemer.js').Theme} Theme */ + +/** @returns {StarterThemeSlots} */ +const makeStarterThemeSlots = () => ({ + version: 1, + objects: { + 'scene:Game Scene/Player': 'character.player', + 'scene:Game Scene/Enemy': 'character.enemy', + 'scene:Game Scene/Ground': 'env.ground', + 'scene:Game Scene/Crate': { top: 'env.ground', front: 'env.wall' }, + 'object:TankConfiguration::CombinedTank/TankBase': 'character.player', + }, + effects: { + 'effect:Game Scene//SkyBox': { frontFaceResourceName: 'sky.day.front' }, + }, + ignoredObjects: ['scene:Game Scene/Camera'], +}); + +/** @returns {Theme} */ +const makeStarterTheme = () => ({ + id: 'pirate', + name: 'Pirate islands', + description: 'Tropical islands.', + slots: { + 'character.player': { + kind: 'model', + file: 'https://asset-resources.gdevelop.io/public-resources/Henry.glb', + assetStoreId: 'abc123', + objectContent: { + modelResourceName: 'Henry.glb', + width: 200, + height: 200, + depth: 200, + rotationX: 90, + rotationY: 0, + rotationZ: 90, + materialType: 'StandardWithoutMetalness', + originLocation: 'ModelOrigin', + centerLocation: 'CenteredOnZ', + animations: [ + { name: 'Idle', source: 'Henry_Idle', loop: true }, + { name: 'Run', source: 'Henry_Run', loop: true }, + ], + }, + }, + 'env.ground': { + kind: 'texture', + file: 'https://asset-resources.gdevelop.io/public-resources/Sand.png', + }, + 'env.wall': { + kind: 'texture', + file: 'https://asset-resources.gdevelop.io/public-resources/Planks.png', + }, + 'sky.day.front': { + kind: 'texture', + file: 'https://asset-resources.gdevelop.io/public-resources/Tropical.png', + origin: { name: 'gdevelop-asset-store', identifier: 'tropical-front' }, + }, + }, +}); + +/** @param {string} modelResourceName */ +const makeModelContent = (modelResourceName) => ({ + modelResourceName, + width: 100, + height: 100, + depth: 100, + originLocation: 'ModelOrigin', + centerLocation: 'ModelOrigin', + animations: [], +}); + +/** @returns {any} */ +const makeProjectContent = () => ({ + resources: { + resources: [ + { + name: 'unit_orange.glb', + file: 'assets/unit_orange.glb', + kind: 'model3D', + }, + { name: 'unit_red.glb', file: 'assets/unit_red.glb', kind: 'model3D' }, + { name: 'Ground.png', file: 'assets/Ground.png', kind: 'image' }, + { name: 'Wall.png', file: 'assets/Wall.png', kind: 'image' }, + { name: 'Sky_Front.png', file: 'assets/Sky_Front.png', kind: 'image' }, + { name: 'Camera.png', file: 'assets/Camera.png', kind: 'image' }, + ], + }, + layouts: [ + { + name: 'Game Scene', + layers: [ + { + name: '', + effects: [ + { + name: 'SkyBox', + effectType: 'Scene3D::Skybox', + stringParameters: { frontFaceResourceName: 'Sky_Front.png' }, + }, + ], + }, + ], + objects: [ + { + name: 'Player', + type: 'Scene3D::Model3DObject', + behaviors: [ + { name: 'Physics3D', type: 'Physics3D::Physics3DBehavior' }, + ], + variables: [{ name: 'Health', value: 3 }], + content: makeModelContent('unit_orange.glb'), + }, + { + name: 'Enemy', + type: 'Scene3D::Model3DObject', + content: makeModelContent('unit_red.glb'), + }, + { + name: 'Ground', + type: 'Scene3D::Cube3DObject', + content: { + frontFaceResourceName: 'Ground.png', + topFaceResourceName: 'Ground.png', + }, + }, + { + name: 'Crate', + type: 'Scene3D::Cube3DObject', + content: { + frontFaceResourceName: 'Wall.png', + topFaceResourceName: 'Ground.png', + }, + }, + { + name: 'Camera', + type: 'Scene3D::Cube3DObject', + content: { frontFaceResourceName: 'Camera.png' }, + }, + ], + }, + ], + eventsFunctionsExtensions: [ + { + name: 'TankConfiguration', + eventsBasedObjects: [ + { + name: 'CombinedTank', + objects: [ + { + name: 'TankBase', + type: 'Scene3D::Model3DObject', + content: makeModelContent('unit_orange.glb'), + }, + ], + }, + ], + }, + ], +}); + +/** @param {any} projectContent */ +const applyPirateTheme = (projectContent) => + applyThemeToStarter( + projectContent, + makeStarterThemeSlots(), + makeStarterTheme() + ); + +describe('applyThemeToStarter', () => { + it('repoints the resources of mapped objects at the theme files', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + const resources = projectContent.resources.resources; + expect(resources[0].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Henry.glb' + ); + expect(resources[2].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Sand.png' + ); + expect(resources[4].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Tropical.png' + ); + expect(resources[4].origin).toEqual({ + name: 'gdevelop-asset-store', + identifier: 'tropical-front', + }); + }); + + it('does not touch the resources of ignored or unmapped objects', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + expect(projectContent.resources.resources[5].file).toBe( + 'assets/Camera.png' + ); + }); + + it('keeps resource names so every reference follows', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + expect(projectContent.resources.resources[2].name).toBe('Ground.png'); + expect( + projectContent.layouts[0].objects[2].content.frontFaceResourceName + ).toBe('Ground.png'); + }); + + it('takes the theme model content while keeping the object identity', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + const player = projectContent.layouts[0].objects[0]; + expect(player.name).toBe('Player'); + expect(player.behaviors).toEqual([ + { name: 'Physics3D', type: 'Physics3D::Physics3DBehavior' }, + ]); + expect(player.variables).toEqual([{ name: 'Health', value: 3 }]); + expect(player.assetStoreId).toBe('abc123'); + expect(player.content.materialType).toBe('StandardWithoutMetalness'); + expect(player.content.modelResourceName).toBe('unit_orange.glb'); + expect(player.content.originLocation).toBe('ModelOrigin'); + expect(player.content.centerLocation).toBe('ModelOrigin'); + }); + + it('scales the theme model to the volume the placeholder occupied', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + const player = projectContent.layouts[0].objects[0]; + expect(player.content.width).toBeCloseTo(100); + expect(player.content.height).toBeCloseTo(100); + expect(player.content.depth).toBeCloseTo(100); + }); + + it('gives the object the theme animations when it had none', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + expect(projectContent.layouts[0].objects[0].content.animations).toEqual([ + { name: 'Idle', source: 'Henry_Idle', loop: true }, + { name: 'Run', source: 'Henry_Run', loop: true }, + ]); + }); + + it('keeps the animation names the object already had', () => { + const projectContent = makeProjectContent(); + projectContent.layouts[0].objects[0].content.animations = [ + { name: 'Run', source: 'Placeholder_Run', loop: true }, + { name: 'Jump', source: 'Placeholder_Jump', loop: false }, + ]; + applyPirateTheme(projectContent); + + const animations = projectContent.layouts[0].objects[0].content.animations; + expect( + animations.map( + /** @param {any} animation */ (animation) => animation.name + ) + ).toEqual(['Run', 'Jump', 'Idle']); + expect(animations[0].source).toBe('Henry_Run'); + // An animation the theme does not have falls back to its first one. + expect(animations[1].source).toBe('Henry_Idle'); + }); + + it('lets a cube map each face to a different slot', () => { + const projectContent = makeProjectContent(); + const result = applyPirateTheme(projectContent); + + expect(projectContent.resources.resources[3].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Planks.png' + ); + expect(result.appliedSlots).toContain('env.wall'); + }); + + it('themes the 3D objects held by events-based objects', () => { + const projectContent = makeProjectContent(); + const result = applyPirateTheme(projectContent); + + const tankBase = + projectContent.eventsFunctionsExtensions[0].eventsBasedObjects[0] + .objects[0]; + expect(tankBase.content.materialType).toBe('StandardWithoutMetalness'); + // Player, Ground, Crate and TankBase: the theme has no enemy. + expect(result.changedObjectsCount).toBe(4); + }); + + it('reports the slots the theme does not fill and leaves them untouched', () => { + const projectContent = makeProjectContent(); + const result = applyPirateTheme(projectContent); + + expect(result.missingSlots).toEqual(['character.enemy']); + expect(projectContent.resources.resources[1].file).toBe( + 'assets/unit_red.glb' + ); + }); + + it('gives objects sharing a resource their own copy when they play different slots', () => { + const projectContent = makeProjectContent(); + // The enemy uses the same model file as the player. + projectContent.layouts[0].objects[1].content.modelResourceName = + 'unit_orange.glb'; + const starterTheme = makeStarterTheme(); + starterTheme.slots['character.enemy'] = { + kind: 'model', + file: 'https://asset-resources.gdevelop.io/public-resources/Skeleton.glb', + objectContent: { + modelResourceName: 'Skeleton.glb', + width: 100, + height: 100, + depth: 100, + animations: [], + }, + }; + + applyThemeToStarter(projectContent, makeStarterThemeSlots(), starterTheme); + + const [player, enemy] = projectContent.layouts[0].objects; + /** @type {Object.} */ + const resourceByName = {}; + projectContent.resources.resources.forEach( + /** @param {any} resource */ (resource) => { + resourceByName[resource.name] = resource; + } + ); + expect(player.content.modelResourceName).toBe('unit_orange.glb'); + expect(resourceByName['unit_orange.glb'].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Henry.glb' + ); + expect(enemy.content.modelResourceName).toBe( + 'unit_orange.glb (character.enemy)' + ); + expect(resourceByName['unit_orange.glb (character.enemy)'].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Skeleton.glb' + ); + }); + + it('skips a mapped object the project does not have', () => { + const projectContent = makeProjectContent(); + const starterThemeSlots = makeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Ghost'] = 'character.player'; + + const result = applyThemeToStarter( + projectContent, + starterThemeSlots, + makeStarterTheme() + ); + + expect(result.changedObjectsCount).toBe(4); + }); + + it('can be applied again to change the theme of an already themed project', () => { + const projectContent = makeProjectContent(); + applyPirateTheme(projectContent); + + const secondTheme = makeStarterTheme(); + secondTheme.id = 'medieval'; + secondTheme.slots['character.player'] = { + kind: 'model', + file: 'https://asset-resources.gdevelop.io/public-resources/Knight.glb', + objectContent: { + modelResourceName: 'Knight.glb', + width: 150, + height: 150, + depth: 150, + animations: [{ name: 'Idle', source: 'Knight_Idle', loop: true }], + }, + }; + const result = applyThemeToStarter( + projectContent, + makeStarterThemeSlots(), + secondTheme + ); + + expect(projectContent.resources.resources[0].file).toBe( + 'https://asset-resources.gdevelop.io/public-resources/Knight.glb' + ); + expect( + projectContent.layouts[0].objects[0].content.animations[0].source + ).toBe('Knight_Idle'); + }); + + it('changes nothing when the theme fills no slot of the starter', () => { + const projectContent = makeProjectContent(); + const result = applyThemeToStarter( + projectContent, + makeStarterThemeSlots(), + { id: 'empty', name: 'Empty', description: '', slots: {} } + ); + + expect(result.changedObjectsCount).toBe(0); + expect(result.changedResourcesCount).toBe(0); + expect(projectContent.resources.resources[0].file).toBe( + 'assets/unit_orange.glb' + ); + }); +}); + +describe('getThemedStarterFileName', () => { + it('names the themed copy after the starter, to sit next to it', () => { + expect(getThemedStarterFileName('starting-3d-tank', 'pirate')).toBe( + 'starting-3d-tank.theme-pirate.json' + ); + }); +}); diff --git a/scripts/lib/__tests__/ThemeSlots.spec.js b/scripts/lib/__tests__/ThemeSlots.spec.js new file mode 100644 index 000000000..aa87165f3 --- /dev/null +++ b/scripts/lib/__tests__/ThemeSlots.spec.js @@ -0,0 +1,211 @@ +// @ts-check +const { + getThemeableObjects, + checkStarterThemeSlots, +} = require('../ThemeSlots'); + +/** @typedef {import('../ThemeSlots.js').ThemeSlotsVocabulary} ThemeSlotsVocabulary */ +/** @typedef {import('../ThemeSlots.js').StarterThemeSlots} StarterThemeSlots */ + +/** @type {ThemeSlotsVocabulary} */ +const vocabulary = { + version: 1, + slots: [ + { id: 'character.player', kind: 'model', label: 'Player character' }, + { id: 'env.ground', kind: 'texture', label: 'Ground' }, + { id: 'env.wall', kind: 'texture', label: 'Wall' }, + { id: 'sky.day.front', kind: 'texture', label: 'Day skybox, front' }, + ], +}; + +const createFakeProjectObject = () => ({ + layouts: [ + { + name: 'Game Scene', + layers: [ + { + name: '', + effects: [{ name: 'SkyBox', effectType: 'Scene3D::Skybox' }], + }, + ], + objects: [ + { name: 'Player', type: 'Scene3D::Model3DObject' }, + { name: 'Ground', type: 'Scene3D::Cube3DObject' }, + { name: 'Camera', type: 'Scene3D::Cube3DObject' }, + { name: 'Joystick', type: 'Sprite' }, + ], + }, + ], + eventsFunctionsExtensions: [ + { + name: 'TankConfiguration', + eventsBasedObjects: [ + { + name: 'CombinedTank', + objects: [{ name: 'TankBase', type: 'Scene3D::Model3DObject' }], + }, + ], + }, + ], +}); + +/** @returns {StarterThemeSlots} */ +const createFakeStarterThemeSlots = () => ({ + version: 1, + objects: { + 'scene:Game Scene/Player': 'character.player', + 'scene:Game Scene/Ground': 'env.ground', + 'object:TankConfiguration::CombinedTank/TankBase': 'character.player', + }, + effects: { + 'effect:Game Scene//SkyBox': { frontFaceResourceName: 'sky.day.front' }, + }, + ignoredObjects: ['scene:Game Scene/Camera'], +}); + +describe('getThemeableObjects', () => { + it('lists the 3D objects of scenes and custom objects, and the skyboxes', () => { + const { objects, effects, is3D } = getThemeableObjects( + createFakeProjectObject() + ); + + expect(is3D).toBe(true); + expect(objects.map((object) => object.path)).toEqual([ + 'scene:Game Scene/Player', + 'scene:Game Scene/Ground', + 'scene:Game Scene/Camera', + 'object:TankConfiguration::CombinedTank/TankBase', + ]); + expect(effects).toEqual(['effect:Game Scene//SkyBox']); + }); + + it('does not consider a project without 3D objects as 3D', () => { + expect( + getThemeableObjects({ + layouts: [{ name: 'Scene', objects: [{ name: 'A', type: 'Sprite' }] }], + }).is3D + ).toBe(false); + }); +}); + +describe('checkStarterThemeSlots', () => { + it('accepts a starter whose 3D objects are all mapped or ignored', () => { + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + createFakeStarterThemeSlots() + ) + ).toEqual([]); + }); + + it('asks for a theme-slots.json when a 3D starter has none', () => { + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + null + ); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('has no theme-slots.json'); + }); + + it('asks nothing of a starter without 3D objects', () => { + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-2d-test', + { + layouts: [ + { name: 'Scene', objects: [{ name: 'A', type: 'Sprite' }] }, + ], + }, + null + ) + ).toEqual([]); + }); + + it('reports a 3D object that is neither mapped nor ignored', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + delete starterThemeSlots.objects['scene:Game Scene/Ground']; + + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + starterThemeSlots + ); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('"scene:Game Scene/Ground"'); + }); + + it('reports a mapping to an object the starter does not have anymore', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Ghost'] = 'character.player'; + + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + starterThemeSlots + ); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('"scene:Game Scene/Ghost"'); + }); + + it('reports a slot that does not exist, or of the wrong kind', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Player'] = 'character.hero'; + starterThemeSlots.objects['scene:Game Scene/Ground'] = 'character.player'; + + const messages = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + starterThemeSlots + ).map((error) => error.message); + + expect(messages).toEqual([ + expect.stringContaining('"character.hero", which is not a slot'), + expect.stringContaining('a model slot, but needs a texture slot'), + ]); + }); + + it('lets a cube map each face to its own slot', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.objects['scene:Game Scene/Ground'] = { + top: 'env.ground', + front: 'env.wall', + }; + + expect( + checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + starterThemeSlots + ) + ).toEqual([]); + }); + + it('reports a skybox mapping to an effect the starter does not have', () => { + const starterThemeSlots = createFakeStarterThemeSlots(); + starterThemeSlots.effects = { + 'effect:Game Scene//Gone': { frontFaceResourceName: 'sky.day.front' }, + }; + + const errors = checkStarterThemeSlots( + vocabulary, + 'starting-3d-test', + createFakeProjectObject(), + starterThemeSlots + ); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('"effect:Game Scene//Gone"'); + }); +}); diff --git a/theme-slots.json b/theme-slots.json new file mode 100644 index 000000000..cba69eb29 --- /dev/null +++ b/theme-slots.json @@ -0,0 +1,235 @@ +{ + "version": 1, + "slots": [ + { + "id": "character.player", + "kind": "model", + "label": "Player character" + }, + { + "id": "character.enemy", + "kind": "model", + "label": "Enemy character" + }, + { + "id": "character.npc", + "kind": "model", + "label": "NPC or ally character" + }, + { + "id": "character.monster", + "kind": "model", + "label": "Monster or creature" + }, + { + "id": "vehicle.car", + "kind": "model", + "label": "Drivable ground vehicle" + }, + { + "id": "vehicle.aircraft.player", + "kind": "model", + "label": "Player aircraft" + }, + { + "id": "vehicle.aircraft.enemy", + "kind": "model", + "label": "Enemy aircraft" + }, + { + "id": "vehicle.tank.base", + "kind": "model", + "label": "Turret vehicle base" + }, + { + "id": "vehicle.tank.turret", + "kind": "model", + "label": "Turret vehicle turret" + }, + { + "id": "vehicle.tank.cannon", + "kind": "model", + "label": "Turret vehicle cannon" + }, + { + "id": "weapon.handheld", + "kind": "model", + "label": "First person weapon" + }, + { + "id": "projectile.player", + "kind": "model", + "label": "Player projectile" + }, + { + "id": "projectile.enemy", + "kind": "model", + "label": "Enemy projectile" + }, + { + "id": "pickup.coin", + "kind": "model", + "label": "Coin or currency pickup" + }, + { + "id": "hazard.spike", + "kind": "model", + "label": "Hazard or trap" + }, + { + "id": "nature.tree", + "kind": "model", + "label": "Tree" + }, + { + "id": "nature.grass", + "kind": "model", + "label": "Grass or bush" + }, + { + "id": "nature.rock.big", + "kind": "model", + "label": "Large rock" + }, + { + "id": "nature.rock.small", + "kind": "model", + "label": "Small rock" + }, + { + "id": "resource.log", + "kind": "model", + "label": "Log or raw resource" + }, + { + "id": "crop.a", + "kind": "model", + "label": "First crop or food item" + }, + { + "id": "crop.b", + "kind": "model", + "label": "Second crop or food item" + }, + { + "id": "prop.container", + "kind": "model", + "label": "Bag, crate or chest" + }, + { + "id": "building.tower", + "kind": "model", + "label": "Tower or placeable building" + }, + { + "id": "prop.obstacle", + "kind": "model", + "label": "Road or track obstacle" + }, + { + "id": "env.ground", + "kind": "texture", + "label": "Ground" + }, + { + "id": "env.ground.tiled", + "kind": "texture", + "label": "Ground with a tile grid" + }, + { + "id": "env.wall", + "kind": "texture", + "label": "Wall and building faces" + }, + { + "id": "env.road", + "kind": "texture", + "label": "Road or track surface" + }, + { + "id": "env.water", + "kind": "texture", + "label": "Water or lava" + }, + { + "id": "env.tiles", + "kind": "texture", + "label": "Placement grid overlay" + }, + { + "id": "env.crate", + "kind": "texture", + "label": "Pushable box faces" + }, + { + "id": "env.finish", + "kind": "texture", + "label": "Finish line" + }, + { + "id": "env.dirt", + "kind": "texture", + "label": "Farm plot or diggable ground" + }, + { + "id": "sky.day.front", + "kind": "texture", + "label": "Day skybox, front face" + }, + { + "id": "sky.day.back", + "kind": "texture", + "label": "Day skybox, back face" + }, + { + "id": "sky.day.left", + "kind": "texture", + "label": "Day skybox, left face" + }, + { + "id": "sky.day.right", + "kind": "texture", + "label": "Day skybox, right face" + }, + { + "id": "sky.day.top", + "kind": "texture", + "label": "Day skybox, top face" + }, + { + "id": "sky.day.bottom", + "kind": "texture", + "label": "Day skybox, bottom face" + }, + { + "id": "sky.dark.front", + "kind": "texture", + "label": "Dark skybox, front face" + }, + { + "id": "sky.dark.back", + "kind": "texture", + "label": "Dark skybox, back face" + }, + { + "id": "sky.dark.left", + "kind": "texture", + "label": "Dark skybox, left face" + }, + { + "id": "sky.dark.right", + "kind": "texture", + "label": "Dark skybox, right face" + }, + { + "id": "sky.dark.top", + "kind": "texture", + "label": "Dark skybox, top face" + }, + { + "id": "sky.dark.bottom", + "kind": "texture", + "label": "Dark skybox, bottom face" + } + ] +} \ No newline at end of file