diff --git a/README.md b/README.md index fa95d0df4..14de93e62 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,52 @@ to end without merging anything: Staging holds the result of the last branch that was pushed, whoever pushed it. +#### 2D starters + +A 2D starter can have a `theme-slots.json` too. It is optional, and maps only +the art of the game: the interface (buttons, joysticks, texts) is left out. + +```json +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { "slot": "2d.side.player", "anchor": "bottom" }, + "scene:Game Scene/Coins": "2d.side.coin", + "scene:Game Scene/Platform_Ground": "2d.side.ground", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.overhead.tilemap", + "tiles": { "0": "ground", "1": "ground.detail1", "3": "area" } + } + } +} +``` + +- A **sprite** maps to a sprite slot. The theme art is drawn where the + placeholder was (same origin, center and points, so events and instances are + untouched), covering the same area and never enlarged. `"anchor": "bottom"` + makes it stand on the bottom of the placeholder instead of being centered on + it, and `"fit": "width"` or `"height"` makes it take that dimension of the + placeholder. +- A **tiled sprite** or a **panel sprite** maps to a texture slot. +- A **tile map** maps to a tile map slot, with the role each of its tiles + plays: the theme gives its own atlas and the tile playing each role. +- A sprite whose animations need precise art of the theme names the + animations of the theme each one can be played by, the first found being + used: `"animations": { "Walk0": ["Walk Right", "Right"] }`. +- A sprite showing different things in its animations (the icons of an + inventory) maps each animation to its own slot instead of `slot`: + `"animationSlots": { "Rock": "2d.rpg.resource.rock", "Log": "2d.rpg.resource.log" }`. +- `"tileSize"` (64 by default, next to `"version"`) is the size of the tiles + of the starter. Pixel art, drawn for smaller tiles, is scaled by a whole + number so its tiles become these. +- Keep `requireAllSlots`: a theme then re-skins the starter only if it fills + every slot it maps, as 2D theme art next to placeholder art looks broken. + +The 2D slots are named after the point of view of the starters using them +(`2d.side.*`, `2d.overhead.*`, `2d.driving.*`...), because 2D art only works +from the point of view it was drawn for. + ### Gameplay tests A game can contain _gameplay tests_: scripts that play the game like a player diff --git a/examples/starting-2d-car-racing/theme-slots.json b/examples/starting-2d-car-racing/theme-slots.json new file mode 100644 index 000000000..1f2eb4683 --- /dev/null +++ b/examples/starting-2d-car-racing/theme-slots.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:GameScene/PlayerCar": "2d.driving.car", + "scene:GameScene/BushObstacle": "2d.driving.obstacle", + "scene:GameScene/Road": { + "slot": "2d.driving.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-2d-driving/theme-slots.json b/examples/starting-2d-driving/theme-slots.json new file mode 100644 index 000000000..1f2eb4683 --- /dev/null +++ b/examples/starting-2d-driving/theme-slots.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:GameScene/PlayerCar": "2d.driving.car", + "scene:GameScene/BushObstacle": "2d.driving.obstacle", + "scene:GameScene/Road": { + "slot": "2d.driving.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-2d-platformer-shooter/theme-slots.json b/examples/starting-2d-platformer-shooter/theme-slots.json new file mode 100644 index 000000000..d445597d6 --- /dev/null +++ b/examples/starting-2d-platformer-shooter/theme-slots.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.side.player", + "anchor": "bottom" + }, + "scene:Game Scene/Target": "2d.side.target", + "scene:Game Scene/Bullet": "2d.side.projectile", + "scene:Game Scene/Platform_Ground": "2d.side.ground", + "scene:Game Scene/Background": "2d.side.background" + } +} diff --git a/examples/starting-beatemup/theme-slots.json b/examples/starting-beatemup/theme-slots.json new file mode 100644 index 000000000..f53508de3 --- /dev/null +++ b/examples/starting-beatemup/theme-slots.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.brawler.player", + "anchor": "bottom", + "animations": { + "Idle": [ + "Idle" + ], + "AttackBuildUp": [ + "Punch Windup" + ], + "AttackStrike": [ + "Kick", + "Punch" + ], + "Hurt": [ + "Hurt" + ] + } + }, + "scene:Game Scene/Enemy": { + "slot": "2d.brawler.enemy", + "anchor": "bottom", + "animations": { + "Idle": [ + "Idle" + ], + "AttackBuildUp": [ + "Punch Windup" + ], + "AttackStrike": [ + "Kick", + "Punch" + ], + "Hurt": [ + "Hurt" + ] + } + }, + "scene:Game Scene/Movement_Boundary": "2d.brawler.wall", + "scene:Game Scene/GroundTilemap": { + "slot": "2d.overhead.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2" + } + } + } +} diff --git a/examples/starting-draggable-tiles/theme-slots.json b/examples/starting-draggable-tiles/theme-slots.json new file mode 100644 index 000000000..019f383e4 --- /dev/null +++ b/examples/starting-draggable-tiles/theme-slots.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Unit": "2d.board.piece.a", + "scene:Game Scene/Tower": "2d.board.piece.b", + "scene:Game Scene/Tree": "2d.board.piece.c", + "scene:Game Scene/Board_Tilemap": { + "slot": "2d.board.tilemap", + "tiles": { + "0": "ground", + "1": "cell.a", + "2": "cell.b" + } + } + } +} diff --git a/examples/starting-endless-runner/theme-slots.json b/examples/starting-endless-runner/theme-slots.json new file mode 100644 index 000000000..622b41399 --- /dev/null +++ b/examples/starting-endless-runner/theme-slots.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.side.player", + "anchor": "bottom" + }, + "scene:Game Scene/Hazard": { + "slot": "2d.side.hazard", + "anchor": "bottom" + }, + "scene:Game Scene/Platform_Ground": "2d.side.ground", + "scene:Game Scene/Platform_Floating": "2d.side.ground", + "scene:Game Scene/Background": "2d.side.background" + } +} diff --git a/examples/starting-flappy-bird/theme-slots.json b/examples/starting-flappy-bird/theme-slots.json new file mode 100644 index 000000000..fa051599d --- /dev/null +++ b/examples/starting-flappy-bird/theme-slots.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.flappy.player", + "scene:Game Scene/SpikeObstacle": { + "slot": "2d.flappy.obstacle", + "fit": "height" + }, + "scene:Game Scene/Boundary": "2d.flappy.ground", + "scene:Game Scene/Background": "2d.flappy.background" + } +} diff --git a/examples/starting-physics/theme-slots.json b/examples/starting-physics/theme-slots.json new file mode 100644 index 000000000..6e6e7cb9a --- /dev/null +++ b/examples/starting-physics/theme-slots.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Background": "2d.physics.background", + "scene:Game Scene/Ground": "2d.physics.ground", + "scene:Game Scene/Ball": "2d.physics.ball", + "scene:Game Scene/Block_Light": "2d.physics.block.light", + "scene:Game Scene/Block_Heavy": "2d.physics.block.heavy" + } +} diff --git a/examples/starting-platformer-pixel/theme-slots.json b/examples/starting-platformer-pixel/theme-slots.json new file mode 100644 index 000000000..e2483935a --- /dev/null +++ b/examples/starting-platformer-pixel/theme-slots.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "requireAllSlots": true, + "tileSize": 32, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.side.player", + "anchor": "bottom" + }, + "scene:Game Scene/Coins": "2d.side.coin", + "scene:Game Scene/Platform_Ground": "2d.side.ground", + "scene:Game Scene/Background": "2d.side.background" + } +} diff --git a/examples/starting-platformer/theme-slots.json b/examples/starting-platformer/theme-slots.json new file mode 100644 index 000000000..4dcdc69ae --- /dev/null +++ b/examples/starting-platformer/theme-slots.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.side.player", + "anchor": "bottom" + }, + "scene:Game Scene/Coins": "2d.side.coin", + "scene:Game Scene/Platform_Ground": "2d.side.ground", + "scene:Game Scene/Background": "2d.side.background" + } +} diff --git a/examples/starting-point-and-click-pixel/theme-slots.json b/examples/starting-point-and-click-pixel/theme-slots.json new file mode 100644 index 000000000..1b3bc75ed --- /dev/null +++ b/examples/starting-point-and-click-pixel/theme-slots.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "requireAllSlots": true, + "tileSize": 32, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.rpg.player", + "anchor": "bottom" + }, + "scene:Game Scene/Obstacle_Impassable": { + "slot": "2d.rpg.building", + "anchor": "bottom" + }, + "scene:Game Scene/Water_Passable": "2d.rpg.water", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rpg.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-point-and-click/theme-slots.json b/examples/starting-point-and-click/theme-slots.json new file mode 100644 index 000000000..21b4efe84 --- /dev/null +++ b/examples/starting-point-and-click/theme-slots.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.overhead.player", + "scene:Game Scene/Obstacle_Impassable": "2d.overhead.wall", + "scene:Game Scene/Water_Passable": "2d.overhead.water", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.overhead.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-rts-unit-selection/theme-slots.json b/examples/starting-rts-unit-selection/theme-slots.json new file mode 100644 index 000000000..152253d34 --- /dev/null +++ b/examples/starting-rts-unit-selection/theme-slots.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/RTSUnit": { + "slot": "2d.rts.unit", + "anchor": "bottom" + }, + "scene:Game Scene/Building": { + "slot": "2d.rts.building", + "anchor": "bottom" + }, + "scene:Game Scene/Water_Passable": "2d.rts.water", + "scene:Game Scene/Collision_Impassable": "2d.rts.blocked", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rts.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-shootemup/theme-slots.json b/examples/starting-shootemup/theme-slots.json new file mode 100644 index 000000000..746c6ee68 --- /dev/null +++ b/examples/starting-shootemup/theme-slots.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.shooter.player", + "scene:Game Scene/Enemy": "2d.shooter.enemy", + "scene:Game Scene/PlayerBullet": "2d.shooter.projectile.player", + "scene:Game Scene/EnemyBullet": "2d.shooter.projectile.enemy", + "scene:Game Scene/RepeatingBackground": "2d.shooter.background" + } +} diff --git a/examples/starting-tile-placement/theme-slots.json b/examples/starting-tile-placement/theme-slots.json new file mode 100644 index 000000000..1bae27e75 --- /dev/null +++ b/examples/starting-tile-placement/theme-slots.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/TilePlacement_Indicator": { + "animationSlots": { + "Unit": "2d.board.piece.a", + "Tower": "2d.board.piece.b", + "Tree": "2d.board.piece.c" + } + }, + "scene:Game Scene/TileType_Button": { + "animationSlots": { + "Unit": "2d.board.piece.a", + "Tower": "2d.board.piece.b", + "Tree": "2d.board.piece.c" + } + }, + "scene:Game Scene/Unit": "2d.board.piece.a", + "scene:Game Scene/Tower": "2d.board.piece.b", + "scene:Game Scene/Tree": "2d.board.piece.c", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rts.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-top-down-farming/theme-slots.json b/examples/starting-top-down-farming/theme-slots.json new file mode 100644 index 000000000..5b27c7f72 --- /dev/null +++ b/examples/starting-top-down-farming/theme-slots.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.rpg.player", + "anchor": "bottom", + "animations": { + "Walk0": [ + "Walk Right", + "Right" + ], + "Walk1": [ + "Walk Down", + "Down" + ], + "Walk2": [ + "Walk Left", + "Left" + ], + "Walk3": [ + "Walk Up", + "Up" + ] + } + }, + "scene:Game Scene/Building_Obstacle": { + "slot": "2d.rpg.building", + "anchor": "bottom" + }, + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rpg.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + }, + "scene:Game Scene/Carrot": { + "animationSlots": { + "Food": "2d.rpg.crop.a.food", + "Planted": "2d.rpg.crop.a.planted" + } + }, + "scene:Game Scene/Beet": { + "animationSlots": { + "Food": "2d.rpg.crop.b.food", + "Planted": "2d.rpg.crop.b.planted" + } + }, + "scene:Game Scene/Seed_Carrot": "2d.rpg.crop.a.seed", + "scene:Game Scene/Seed_Beet": "2d.rpg.crop.b.seed", + "scene:Game Scene/Harvest_Seed_Carrot": { + "slot": "2d.rpg.crop.a.source", + "anchor": "bottom" + }, + "scene:Game Scene/Harvest_Seed_Beet": { + "slot": "2d.rpg.crop.b.source", + "anchor": "bottom" + }, + "scene:Game Scene/InventoryIcons": { + "animationSlots": { + "Carrot": "2d.rpg.crop.a.food", + "Seed_Carrot": "2d.rpg.crop.a.seed", + "Beet": "2d.rpg.crop.b.food", + "Seed_Beet": "2d.rpg.crop.b.seed" + } + } + } +} diff --git a/examples/starting-top-down-pixel/theme-slots.json b/examples/starting-top-down-pixel/theme-slots.json new file mode 100644 index 000000000..8f8212102 --- /dev/null +++ b/examples/starting-top-down-pixel/theme-slots.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "requireAllSlots": true, + "tileSize": 32, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.rpg.player", + "anchor": "bottom" + }, + "scene:Game Scene/Plant_Obstacle": "2d.rpg.bush", + "scene:Game Scene/Wall_Obstacle": "2d.rpg.wall", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rpg.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-top-down-rpg/theme-slots.json b/examples/starting-top-down-rpg/theme-slots.json new file mode 100644 index 000000000..89eac2953 --- /dev/null +++ b/examples/starting-top-down-rpg/theme-slots.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": { + "slot": "2d.rpg.player", + "anchor": "bottom", + "animations": { + "Walk0": [ + "Walk Right", + "Right" + ], + "Walk1": [ + "Walk Down", + "Down" + ], + "Walk2": [ + "Walk Left", + "Left" + ], + "Walk3": [ + "Walk Up", + "Up" + ] + } + }, + "scene:Game Scene/NPC": { + "slot": "2d.rpg.npc", + "anchor": "bottom" + }, + "scene:Game Scene/Building_Obstacle": { + "slot": "2d.rpg.building", + "anchor": "bottom" + }, + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rpg.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-top-down-survival-crafting/theme-slots.json b/examples/starting-top-down-survival-crafting/theme-slots.json new file mode 100644 index 000000000..d1d9c8f2b --- /dev/null +++ b/examples/starting-top-down-survival-crafting/theme-slots.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/TopDown_Character": { + "slot": "2d.rpg.player", + "anchor": "bottom", + "animations": { + "Walk0": [ + "Walk Right", + "Right" + ], + "Walk1": [ + "Walk Down", + "Down" + ], + "Walk2": [ + "Walk Left", + "Left" + ], + "Walk3": [ + "Walk Up", + "Up" + ] + } + }, + "scene:Game Scene/Building_Obstacle": { + "slot": "2d.rpg.building", + "anchor": "bottom" + }, + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.rpg.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + }, + "scene:Game Scene/Rock": "2d.rpg.resource.rock", + "scene:Game Scene/Log": "2d.rpg.resource.log", + "scene:Game Scene/Harvest_Boulder": { + "slot": "2d.rpg.harvest.rock", + "anchor": "bottom" + }, + "scene:Game Scene/Harvest_Tree": { + "slot": "2d.rpg.harvest.tree", + "anchor": "bottom" + }, + "scene:Game Scene/InventoryIcons": { + "animationSlots": { + "Rock": "2d.rpg.resource.rock", + "Log": "2d.rpg.resource.log" + } + } + } +} diff --git a/examples/starting-top-down/theme-slots.json b/examples/starting-top-down/theme-slots.json new file mode 100644 index 000000000..a92e07108 --- /dev/null +++ b/examples/starting-top-down/theme-slots.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.overhead.player", + "scene:Game Scene/Plant_Obstacle": "2d.overhead.obstacle", + "scene:Game Scene/Wall_Obstacle": "2d.overhead.wall", + "scene:Game Scene/Ground_Tilemap": { + "slot": "2d.overhead.tilemap", + "tiles": { + "0": "ground", + "1": "ground.detail1", + "2": "ground.detail2", + "3": "area", + "4": "area", + "5": "area", + "6": "area", + "7": "area", + "8": "area" + } + } + } +} diff --git a/examples/starting-twin-stick-shooter/theme-slots.json b/examples/starting-twin-stick-shooter/theme-slots.json new file mode 100644 index 000000000..127d1b3c1 --- /dev/null +++ b/examples/starting-twin-stick-shooter/theme-slots.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.overhead.player", + "scene:Game Scene/Enemy": "2d.overhead.enemy", + "scene:Game Scene/PlayerBullet": "2d.overhead.projectile", + "scene:Game Scene/RepeatingBackground": "2d.overhead.ground" + } +} diff --git a/examples/starting-vampire-survivor/theme-slots.json b/examples/starting-vampire-survivor/theme-slots.json new file mode 100644 index 000000000..127d1b3c1 --- /dev/null +++ b/examples/starting-vampire-survivor/theme-slots.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "requireAllSlots": true, + "objects": { + "scene:Game Scene/Player": "2d.overhead.player", + "scene:Game Scene/Enemy": "2d.overhead.enemy", + "scene:Game Scene/PlayerBullet": "2d.overhead.projectile", + "scene:Game Scene/RepeatingBackground": "2d.overhead.ground" + } +} diff --git a/scripts/generate-database.js b/scripts/generate-database.js index dd0359f79..afeeeb649 100644 --- a/scripts/generate-database.js +++ b/scripts/generate-database.js @@ -25,6 +25,7 @@ const { applyThemeToStarter, getThemedStarterFileName, } = require('./lib/StarterThemer'); +const { readPngBox } = require('./lib/PngBox'); const { default: axios } = require('axios'); /** @typedef {import('./lib/FileTreeParser.js').DreeWithMetadata} DreeWithMetadata */ @@ -748,13 +749,28 @@ const generateThemedStarters = async ( const projectObject = JSON.parse( await fs.readFile(starterFilePath, 'utf8') ); - const { changedObjectsCount, changedResourcesCount } = - applyThemeToStarter( - projectObject, - themeSlotsByStarterSlug[slug], - theme - ); + 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) + ) + ) + ); + } 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; const themedStarterFilePath = path.join( path.dirname(starterFilePath), diff --git a/scripts/lib/PngBox.js b/scripts/lib/PngBox.js new file mode 100644 index 000000000..9c005b5ef --- /dev/null +++ b/scripts/lib/PngBox.js @@ -0,0 +1,53 @@ +// @ts-check +const { PNG } = require('pngjs'); + +/** + * The size of an image and the box of its visible (not transparent) pixels. + * @typedef {{ + * width: number, + * height: number, + * box: {left: number, top: number, right: number, bottom: number}, + * }} ImageBox + */ + +// Fainter pixels (soft shadows, antialiasing) are not part of the shape. +const VISIBLE_ALPHA = 32; + +/** + * @param {Buffer} buffer The content of a PNG file. + * @returns {ImageBox | null} null if the image cannot be read. + */ +const readPngBox = (buffer) => { + /** @type {PNG} */ + let png; + try { + png = PNG.sync.read(buffer); + } catch (error) { + return null; + } + + const { width, height, data } = png; + let left = width; + let top = height; + let right = 0; + let bottom = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * 4 + 3] < VISIBLE_ALPHA) continue; + if (x < left) left = x; + if (x + 1 > right) right = x + 1; + if (y < top) top = y; + if (y + 1 > bottom) bottom = y + 1; + } + } + if (right <= left || bottom <= top) { + return { + width, + height, + box: { left: 0, top: 0, right: width, bottom: height }, + }; + } + return { width, height, box: { left, top, right, bottom } }; +}; + +module.exports = { readPngBox }; diff --git a/scripts/lib/StarterThemer.js b/scripts/lib/StarterThemer.js index 7a8e53cc3..20f93d5dd 100644 --- a/scripts/lib/StarterThemer.js +++ b/scripts/lib/StarterThemer.js @@ -28,6 +28,20 @@ * kind: 'skybox', * faces: Object., * assetStoreId?: string, + * } | { + * kind: 'sprite', + * objectContent: any, + * resources: Array, + * width: number, + * height: number, + * tileSize?: number, + * assetStoreId?: string, + * } | ThemeFile & { + * kind: 'tilemap', + * tileSize: number, + * columnCount: number, + * rowCount: number, + * tiles: Object., * }} ThemeSlot * * @typedef {{ @@ -37,6 +51,14 @@ * slots: Object., * }} Theme * + * What the themer needs to know about an image of the starter, to fit the + * theme's art where the placeholder art was. + * @typedef {import('./PngBox.js').ImageBox} ImageBox + * + * @typedef {{ + * getStarterImageBox?: (resourceFile: string) => ImageBox | null, + * }} ThemeApplicationOptions + * * @typedef {{ * appliedSlots: Array, * missingSlots: Array, @@ -157,6 +179,209 @@ const getSizeRatio = (objectContent, themeContent) => { return Math.pow(objectVolume / themeVolume, 1 / 3); }; +/** + * The instances of an object, wherever its path lets it be placed. + * @param {any} projectObject + * @param {string} objectPath + * @returns {Array} + */ +const getInstancesOfObject = (projectObject, objectPath) => { + const separatorIndex = objectPath.lastIndexOf('/'); + const scope = objectPath.slice(0, separatorIndex); + const objectName = objectPath.slice(separatorIndex + 1); + const sceneName = scope.startsWith('scene:') ? scope.slice(6) : null; + if (scope !== 'global' && sceneName === null) return []; + + /** @type {Array} */ + const instances = []; + (projectObject.layouts || []).forEach( + /** @param {any} layout */ (layout) => { + if (sceneName !== null && layout.name !== sceneName) return; + instances.push(...(layout.instances || [])); + } + ); + (projectObject.externalLayouts || []).forEach( + /** @param {any} externalLayout */ (externalLayout) => { + if (sceneName !== null && externalLayout.associatedLayout !== sceneName) { + return; + } + instances.push(...(externalLayout.instances || [])); + } + ); + return instances.filter((instance) => instance.name === objectName); +}; + +/** + * The box around the collision mask of a sprite frame, or null without one. + * @param {any} frame + * @returns {{left: number, top: number, right: number, bottom: number} | null} + */ +const getFrameMaskBox = (frame) => { + if (!frame.hasCustomCollisionMask) return null; + /** @type {Array<{x: number, y: number}>} */ + const vertices = (frame.customCollisionMask || []).flat(); + if (!vertices.length) return null; + return { + left: Math.min(...vertices.map((vertex) => vertex.x)), + top: Math.min(...vertices.map((vertex) => vertex.y)), + right: Math.max(...vertices.map((vertex) => vertex.x)), + bottom: Math.max(...vertices.map((vertex) => vertex.y)), + }; +}; + +/** @param {any} object @returns {any | null} */ +const getFirstFrame = (object) => { + for (const animation of object.animations || []) { + for (const direction of animation.directions || []) { + if ((direction.sprites || []).length) return direction.sprites[0]; + } + } + return null; +}; + +/** + * How the art of a theme sprite is drawn where a placeholder image was: its + * scale, and the position on the theme image of a position on the placeholder. + * @typedef {{ + * scale: number, + * toThemeImage: (x: number, y: number) => {x: number, y: number}, + * }} SpritePlacement + */ + +/** + * Place the art of a theme sprite where the placeholder art was: scaled to + * cover what the placeholder covered, centered on it (or standing on its + * bottom). + * @param {ImageBox} placeholderImage + * @param {any} themeFrame The first frame of the theme art. + * @param {'center' | 'bottom'} anchor + * @param {'area' | 'width' | 'height'} fit Whether the art covers the same + * area as the placeholder, or takes its width or its height. + * @param {number=} forcedScale The scale to use instead of fitting the art. + * @returns {SpritePlacement | null} + */ +const placeThemeSprite = ( + placeholderImage, + themeFrame, + anchor, + fit, + forcedScale +) => { + const themeBox = getFrameMaskBox(themeFrame); + if (!themeBox) return null; + const themeBoxWidth = themeBox.right - themeBox.left; + const themeBoxHeight = themeBox.bottom - themeBox.top; + const box = placeholderImage.box; + if (themeBoxWidth <= 0 || themeBoxHeight <= 0) return null; + + const widthScale = (box.right - box.left) / themeBoxWidth; + const heightScale = (box.bottom - box.top) / themeBoxHeight; + // By default, the art covers as much of the scene as the placeholder did, + // without growing out of the image of the placeholder: art of another shape + // (a wide tank for a tall pawn) is then not shrunk to the narrow side. + const scale = + forcedScale || + (fit === 'width' + ? widthScale + : fit === 'height' + ? heightScale + : Math.min( + Math.sqrt(widthScale * heightScale), + placeholderImage.width / themeBoxWidth, + placeholderImage.height / themeBoxHeight, + // Never enlarged: small art of a pack stays as sharp, and as small + // compared to the rest of the pack, as its author drew it. + 1 + )); + if (!(scale > 0)) return null; + + return { + scale, + toThemeImage: (x, y) => ({ + x: + (themeBox.left + themeBox.right) / 2 + + (x - (box.left + box.right) / 2) / scale, + y: + anchor === 'bottom' + ? themeBox.bottom + (y - box.bottom) / scale + : (themeBox.top + themeBox.bottom) / 2 + + (y - (box.top + box.bottom) / 2) / scale, + }), + }; +}; + +/** + * Give an animation of a starter sprite the frames of a theme animation. The + * origin, center and points of the placeholder are kept where they were in + * the scene, so the events and the instances of the starter behave as before. + * The collision mask is the one of the theme art: it follows what is drawn. + * @param {any} themeAnimation + * @param {string} name The name the animation keeps in the starter. + * @param {any} placeholderFrame + * @param {ImageBox} placeholderImage + * @param {SpritePlacement} placement + * @param {(imageName: string) => string | null} useThemeImage + * @returns {any} + */ +const makeThemedAnimation = ( + themeAnimation, + name, + placeholderFrame, + placeholderImage, + placement, + useThemeImage +) => { + const { toThemeImage } = placement; + const placeholderCenter = placeholderFrame.centerPoint.automatic + ? { x: placeholderImage.width / 2, y: placeholderImage.height / 2 } + : placeholderFrame.centerPoint; + const frameGeometry = { + originPoint: { + ...placeholderFrame.originPoint, + ...toThemeImage( + placeholderFrame.originPoint.x, + placeholderFrame.originPoint.y + ), + }, + centerPoint: { + ...placeholderFrame.centerPoint, + automatic: false, + ...toThemeImage(placeholderCenter.x, placeholderCenter.y), + }, + points: (placeholderFrame.points || []).map( + /** @param {any} point */ (point) => ({ + ...point, + ...toThemeImage(point.x, point.y), + }) + ), + }; + + return { + ...themeAnimation, + name, + directions: (themeAnimation.directions || []).map( + /** @param {any} direction */ (direction) => ({ + ...direction, + sprites: (direction.sprites || []).map( + /** @param {any} frame */ (frame) => ({ + ...frame, + ...frameGeometry, + image: useThemeImage(frame.image) || frame.image, + }) + ), + }) + ), + }; +}; + +/** @param {any} animation @returns {any | null} */ +const getAnimationFirstFrame = (animation) => { + for (const direction of (animation && animation.directions) || []) { + if ((direction.sprites || []).length) return direction.sprites[0]; + } + return null; +}; + /** * Re-skin a starter with a theme by rewriting its serialized project, in place. * @@ -173,10 +398,23 @@ const getSizeRatio = (objectContent, themeContent) => { * * @param {any} projectObject * @param {StarterThemeSlots} starterThemeSlots + * A sprite takes the animations of the theme sprite, drawn where its + * placeholder art was; a tiled sprite or a panel sprite takes the theme image + * as its texture; a tile map takes the theme atlas, each of its tiles being + * replaced by the tile of the atlas playing the same role. + * + * @param {any} projectObject + * @param {StarterThemeSlots} starterThemeSlots * @param {Theme} theme + * @param {ThemeApplicationOptions=} options * @returns {ThemeApplicationResult} */ -const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { +const applyThemeToStarter = ( + projectObject, + starterThemeSlots, + theme, + options +) => { /** @type {Array} */ const resources = (projectObject.resources || {}).resources || []; /** @type {Map} */ @@ -332,10 +570,387 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { return changed; }; + const starterTileSize = starterThemeSlots.tileSize || 64; + /** + * The assets of a theme for a slot: usually one, or several when the theme + * has one for each size of tiles. + * @param {string} slotId + * @returns {Array} + */ + const getSlotCandidates = (slotId) => { + /** @type {any} */ + const slot = theme.slots[slotId]; + return slot ? [slot, ...(slot.variants || [])] : []; + }; + Object.entries(starterThemeSlots.objects || {}).forEach( ([objectPath, mapping]) => { const object = objectsByPath.get(objectPath); - if (!object || !object.content) return; + if (!object) return; + /** @type {any} */ + const mappingObject = typeof mapping === 'string' ? {} : mapping; + + if (object.type === 'Sprite') { + // Each animation of the sprite is played by a theme sprite: the same + // one for all of them, or one per animation (`animationSlots`, for a + // sprite showing different things, like the icons of an inventory). + /** @type {Array} */ + const objectAnimations = object.animations || []; + /** @type {Object.} */ + const slotIdByAnimation = mappingObject.animationSlots || {}; + const mainSlotId = + typeof mapping === 'string' ? mapping : mappingObject.slot; + const usedSlotIds = [ + ...new Set( + objectAnimations + .map( + (animation) => slotIdByAnimation[animation.name] || mainSlotId + ) + .filter((slotId) => typeof slotId === 'string') + ), + ]; + if (!usedSlotIds.length) return; + + /** @param {string} imageName @returns {ImageBox | null} */ + const getPlaceholderImage = (imageName) => { + const resource = resourceByName.get(imageName); + return resource && options && options.getStarterImageBox + ? options.getStarterImageBox(resource.file) + : null; + }; + const anchor = mappingObject.anchor === 'bottom' ? 'bottom' : 'center'; + const fit = + mappingObject.fit === 'width' || mappingObject.fit === 'height' + ? mappingObject.fit + : 'area'; + + // First find how each animation would be drawn, as the sprite has a + // single scale: the smallest one, so nothing grows out of its place. + /** @type {Array<{animation: any, slotId: string, slot: any, placeholderFrame: any, placeholderImage: ImageBox, themeAnimation: any} | null>} */ + const themedAnimations = objectAnimations.map((animation) => { + const slotId = slotIdByAnimation[animation.name] || mainSlotId; + // A theme can have pixel art for the starters built on small tiles + // next to its other art. + const candidates = + typeof slotId === 'string' ? getSlotCandidates(slotId) : []; + const slot = + (starterTileSize !== 64 + ? candidates.find((candidate) => !!candidate.tileSize) + : candidates.find((candidate) => !candidate.tileSize)) || + candidates[0]; + const placeholderFrame = getAnimationFirstFrame(animation); + const placeholderImage = + placeholderFrame && getPlaceholderImage(placeholderFrame.image); + if (!slot || slot.kind !== 'sprite' || !placeholderImage) return null; + // A starter built on smaller tiles is pixel art: only art drawn on + // a grid can be scaled to its pixels. + if (starterTileSize !== 64 && !slot.tileSize) return null; + + /** @type {Array} */ + const themeAnimations = slot.objectContent.animations || []; + // The theme animation is the one the starter asks for by name + // (`animations`), or the one named like the animation of the + // starter, or the first one. + /** @type {Array} */ + const wantedNames = [ + ...((mappingObject.animations || {})[animation.name] || []), + animation.name, + ]; + const themeAnimation = + wantedNames + .map((wantedName) => + themeAnimations.find( + (themeAnimation) => + !!wantedName && + (themeAnimation.name || '').toLowerCase() === + wantedName.toLowerCase() + ) + ) + .find((themeAnimation) => !!themeAnimation) || themeAnimations[0]; + if (!getAnimationFirstFrame(themeAnimation)) return null; + return { + animation, + slotId, + slot, + placeholderFrame, + placeholderImage, + themeAnimation, + }; + }); + if (themedAnimations.some((themedAnimation) => !themedAnimation)) { + usedSlotIds.forEach((slotId) => { + const slot = theme.slots[slotId]; + if (!slot || slot.kind !== 'sprite') missingSlots.add(slotId); + }); + if (!usedSlotIds.some((slotId) => missingSlots.has(slotId))) { + usedSlotIds.forEach((slotId) => missingSlots.add(slotId)); + } + return; + } + + /** @param {any} themedAnimation @param {number=} forcedScale */ + const place = (themedAnimation, forcedScale) => + placeThemeSprite( + themedAnimation.placeholderImage, + getAnimationFirstFrame(themedAnimation.themeAnimation), + anchor, + fit, + // Art drawn on a grid of tiles (pixel art) is scaled so its grid + // becomes the one of the starter: the same scale for the whole + // theme, whatever the size of each placeholder. + forcedScale || + (themedAnimation.slot.tileSize + ? starterTileSize / themedAnimation.slot.tileSize + : undefined) + ); + const placements = themedAnimations.map((themedAnimation) => + place(themedAnimation) + ); + if (placements.some((placement) => !placement)) { + usedSlotIds.forEach((slotId) => missingSlots.add(slotId)); + return; + } + const scale = Math.min( + ...placements.map((placement) => (placement ? placement.scale : 1)) + ); + + /** @type {Array} */ + const placeholderImageNames = objectAnimations.flatMap( + /** @param {any} animation */ (animation) => + (animation.directions || []).flatMap( + /** @param {any} direction */ (direction) => + (direction.sprites || []).map( + /** @param {any} frame */ (frame) => frame.image + ) + ) + ); + /** @param {any} slot @param {string} slotId */ + const makeUseThemeImage = + (slot, slotId) => + /** @param {string} imageName */ + (imageName) => { + const themeResource = slot.resources.find( + /** @param {any} resource */ (resource) => + resource.name === imageName + ); + return themeResource + ? useThemeResource( + '', + slotId, + { + file: themeResource.file, + resourceName: themeResource.name, + origin: themeResource.origin, + }, + themeResource + ) + : null; + }; + + const animations = themedAnimations.map((themedAnimation) => { + // @ts-ignore - checked above: every animation is themed and placed. + const { animation, slot, slotId, themeAnimation } = themedAnimation; + return makeThemedAnimation( + themeAnimation, + animation.name, + // @ts-ignore + themedAnimation.placeholderFrame, + // @ts-ignore + themedAnimation.placeholderImage, + // @ts-ignore + place(themedAnimation, scale), + makeUseThemeImage(slot, slotId) + ); + }); + // A sprite played by a single theme sprite also gets the other + // animations of that sprite, ready to be used. + const firstThemed = themedAnimations[0]; + if (usedSlotIds.length === 1 && firstThemed) { + const usedNames = new Set( + themedAnimations.map((themedAnimation) => + themedAnimation + ? (themedAnimation.themeAnimation.name || '').toLowerCase() + : '' + ) + ); + (firstThemed.slot.objectContent.animations || []).forEach( + /** @param {any} themeAnimation */ (themeAnimation) => { + const name = themeAnimation.name || ''; + const isUsed = + !name || + usedNames.has(name.toLowerCase()) || + objectAnimations.some( + (animation) => + (animation.name || '').toLowerCase() === name.toLowerCase() + ); + if (isUsed) return; + animations.push( + makeThemedAnimation( + themeAnimation, + name, + firstThemed.placeholderFrame, + firstThemed.placeholderImage, + // @ts-ignore + place(firstThemed, scale), + makeUseThemeImage(firstThemed.slot, firstThemed.slotId) + ) + ); + } + ); + } + + object.animations = animations; + object.preScale = (object.preScale || 1) * scale; + placeholderImageNames.forEach((imageName) => + replacedResourceNames.add(imageName) + ); + + // An instance with a custom size was sized for the placeholder image. + if (firstThemed) { + const widthRatio = + (firstThemed.slot.width * scale) / + firstThemed.placeholderImage.width; + const heightRatio = + (firstThemed.slot.height * scale) / + firstThemed.placeholderImage.height; + const themedOrigin = getAnimationFirstFrame( + animations[0] + ).originPoint; + getInstancesOfObject(projectObject, objectPath).forEach( + (instance) => { + if (!instance.customSize) return; + if (!firstThemed.slot.tileSize) { + instance.width *= widthRatio; + instance.height *= heightRatio; + return; + } + + // Art drawn on a grid keeps the scale of its grid: the instance + // takes the size of the art, placed where the instance was. + const { placeholderFrame, placeholderImage } = firstThemed; + const left = + instance.x - + (placeholderFrame.originPoint.x * instance.width) / + placeholderImage.width; + const top = + instance.y - + (placeholderFrame.originPoint.y * instance.height) / + placeholderImage.height; + const width = firstThemed.slot.width * scale; + const height = firstThemed.slot.height * scale; + instance.x = + left + (instance.width - width) / 2 + themedOrigin.x * scale; + instance.y = + top + + (anchor === 'bottom' + ? instance.height - height + : (instance.height - height) / 2) + + themedOrigin.y * scale; + instance.customSize = false; + } + ); + if (usedSlotIds.length === 1 && firstThemed.slot.assetStoreId) { + object.assetStoreId = firstThemed.slot.assetStoreId; + } + } + changedObjectsCount++; + return; + } + + if ( + object.type === 'TiledSpriteObject::TiledSprite' || + object.type === 'PanelSpriteObject::PanelSprite' + ) { + if (typeof mapping !== 'string') return; + const content = object.content || object; + // An image is made for a size of tiles: 64 pixels unless it says so. + const slot = getSlotCandidates(mapping).find( + (candidate) => + candidate.kind === 'texture' && + (candidate.tileSize || 64) === starterTileSize + ); + if (!slot || slot.kind !== 'texture') { + missingSlots.add(mapping); + return; + } + const textureResourceName = useThemeResource( + content.texture, + mapping, + slot + ); + if (!textureResourceName) return; + content.texture = textureResourceName; + changedObjectsCount++; + return; + } + + if (object.type === 'TileMap::SimpleTileMap') { + const slotId = mappingObject.slot; + if (typeof slotId !== 'string') return; + const content = object.content; + const slot = getSlotCandidates(slotId).find( + (candidate) => + candidate.kind === 'tilemap' && + candidate.tileSize === content.tileSize + ); + /** @type {Object.} */ + const roleByTileId = mappingObject.tiles || {}; + if ( + !slot || + slot.kind !== 'tilemap' || + slot.tileSize !== content.tileSize || + Object.values(roleByTileId).some( + (role) => slot.tiles[role] === undefined + ) + ) { + missingSlots.add(slotId); + return; + } + const atlasResourceName = useThemeResource( + content.atlasImage, + slotId, + slot + ); + if (!atlasResourceName) return; + + /** @param {number} tileId */ + const toThemeTileId = (tileId) => { + const role = roleByTileId[String(tileId)]; + return role === undefined ? -1 : slot.tiles[role]; + }; + content.atlasImage = atlasResourceName; + content.columnCount = slot.columnCount; + content.rowCount = slot.rowCount; + content.tilesWithHitBox = String(content.tilesWithHitBox || '') + .split(',') + .filter((tileId) => tileId !== '') + .map((tileId) => toThemeTileId(Number(tileId))) + .filter((tileId) => tileId >= 0) + .join(','); + getInstancesOfObject(projectObject, objectPath).forEach((instance) => + (instance.stringProperties || []).forEach( + /** @param {any} property */ (property) => { + if (property.name !== 'tilemap' || !property.value) return; + const tileMap = JSON.parse(property.value); + (tileMap.layers || []).forEach( + /** @param {any} layer */ (layer) => { + layer.tiles = (layer.tiles || []).map( + /** @param {Array} row */ (row) => + row.map((tileId) => + tileId < 0 ? tileId : toThemeTileId(tileId) + ) + ); + } + ); + property.value = JSON.stringify(tileMap); + } + ) + ); + changedObjectsCount++; + return; + } + + if (!object.content) return; const objectContent = object.content; if (object.type === 'Scene3D::Model3DObject') { @@ -378,7 +993,7 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { let changed = false; Object.keys(cubeFaceProperties).forEach((face) => { const faceMapping = - typeof mapping === 'string' ? mapping : mapping[face]; + typeof mapping === 'string' ? mapping : mappingObject[face]; const slotId = typeof faceMapping === 'string' ? faceMapping : null; const faceProperty = cubeFaceProperties[face]; const resourceName = objectContent[faceProperty]; @@ -403,7 +1018,7 @@ const applyThemeToStarter = (projectObject, starterThemeSlots, theme) => { if ( typeof mapping !== 'string' && - themeCustomObjectProperties(objectContent, mapping) + themeCustomObjectProperties(objectContent, mappingObject) ) { changedObjectsCount++; } diff --git a/scripts/lib/ThemeSlots.js b/scripts/lib/ThemeSlots.js index 42c427aa7..db98b2b05 100644 --- a/scripts/lib/ThemeSlots.js +++ b/scripts/lib/ThemeSlots.js @@ -10,7 +10,7 @@ const fs = require('fs').promises; * slots: Array<{id: string, kind: ThemeSlotKind, label: string, description: string}>, * }} ThemeSlotsVocabulary * - * @typedef {'model' | 'texture' | 'material' | 'skybox' | 'color'} ThemeSlotKind + * @typedef {'model' | 'texture' | 'material' | 'skybox' | 'color' | 'sprite' | 'tilemap'} ThemeSlotKind */ /** @@ -24,14 +24,29 @@ const fs = require('fs').promises; * to a texture slot, a color to a color slot, or an image resource to a * material slot along with the property taking the normal map of the material * (`{"slot": "terrain.ground", "normalMap": "Layer1NormalMap"}`). + * + * In a 2D starter, a sprite maps to a sprite slot, or to + * `{"slot": "2d.side.player", "anchor": "bottom", "fit": "height"}` to say how + * the theme art is placed where the placeholder art was: standing on its + * bottom instead of centered, taking its width or its height instead of + * covering the same area. A tiled sprite or a panel sprite maps to a texture + * slot. A tile map maps to a tile map slot along with the role each of its + * tiles plays (`{"slot": "2d.overhead.tilemap", "tiles": {"0": "ground"}}`). + * With `requireAllSlots`, a theme re-skins the starter only if it fills every + * slot the starter maps: 2D art of a theme next to placeholder art looks + * broken, where an unthemed 3D model is hardly noticed. * @typedef {{ * version: number, - * objects: Object.>, + * requireAllSlots?: boolean, + * tileSize?: number, + * objects: Object.>, * effects?: Object., * ignoredObjects?: Array, * }} StarterThemeSlots * * @typedef {{slot: string, normalMap: string}} MaterialMapping + * @typedef {{slot?: string, anchor?: 'center' | 'bottom', fit?: 'area' | 'width' | 'height', animations?: Object.>, animationSlots?: Object.}} SpriteMapping + * @typedef {{slot: string, tiles: Object.}} TileMapMapping */ const vocabularyPath = path.join(__dirname, '../../theme-slots.json'); @@ -40,6 +55,124 @@ const vocabularyPath = path.join(__dirname, '../../theme-slots.json'); const loadThemeSlotsVocabulary = async () => JSON.parse(await fs.readFile(vocabularyPath, 'utf8')); +/** The kind of slot each type of 2D object maps to. */ +const slotKindBy2DObjectType = { + Sprite: 'sprite', + 'TiledSpriteObject::TiledSprite': 'texture', + 'PanelSpriteObject::PanelSprite': 'texture', + 'TileMap::SimpleTileMap': 'tilemap', +}; + +/** + * The 2D objects of a project a theme can re-skin, by the path a mapping + * refers to them by. + * @param {any} projectObject + * @returns {Map} The type of each object. + */ +const get2DThemeableObjects = (projectObject) => { + /** @type {Map} */ + const typeByPath = new Map(); + /** @param {string} prefix @param {Array} list */ + const collect = (prefix, list) => + (list || []).forEach((object) => { + if (object.type in slotKindBy2DObjectType) { + typeByPath.set(`${prefix}/${object.name}`, object.type); + } + }); + collect('global', projectObject.objects); + (projectObject.layouts || []).forEach( + /** @param {any} layout */ (layout) => + collect(`scene:${layout.name}`, layout.objects) + ); + return typeByPath; +}; + +/** + * The 2D objects a starter maps must exist and map to a slot of their kind. + * Unlike 3D objects, the 2D objects left unmapped are not an error: they are + * the interface of the starter (buttons, joysticks, texts). + * @param {ThemeSlotsVocabulary} vocabulary + * @param {string} slug + * @param {any} projectObject + * @param {StarterThemeSlots} starterThemeSlots + * @param {boolean} is3D + * @returns {Error[]} + */ +const check2DObjectsThemeSlots = ( + vocabulary, + slug, + projectObject, + starterThemeSlots, + is3D +) => { + /** @type {Error[]} */ + const errors = []; + const slotKindById = new Map( + vocabulary.slots.map((slot) => [slot.id, slot.kind]) + ); + const typeByPath = get2DThemeableObjects(projectObject); + + Object.entries(starterThemeSlots.objects || {}).forEach( + ([objectPath, mapping]) => { + const type = typeByPath.get(objectPath); + if (!type) { + // The objects of a 3D starter are checked with its 3D objects. + if (!is3D) { + errors.push( + new Error( + `Starter "${slug}": theme-slots.json maps "${objectPath}", which is not a sprite, tiled sprite, panel sprite or tile map of the starter.` + ) + ); + } + return; + } + + // @ts-ignore - the type is one of the keys. + const expectedKind = slotKindBy2DObjectType[type]; + /** @type {any} */ + const mappingObject = typeof mapping === 'string' ? {} : mapping; + // A sprite can map each of its animations to its own slot. + /** @type {Array} */ + const slotIds = [ + ...(typeof mapping === 'string' + ? [mapping] + : mappingObject.slot !== undefined || !mappingObject.animationSlots + ? [mappingObject.slot] + : []), + ...Object.values(mappingObject.animationSlots || {}), + ]; + let hasError = false; + slotIds.forEach((slotId) => { + const declaredKind = slotKindById.get(slotId); + if (typeof slotId !== 'string' || !declaredKind) { + hasError = true; + errors.push( + new Error( + `Starter "${slug}": "${objectPath}" maps to "${slotId}", which is not a slot of theme-slots.json.` + ) + ); + } else if (declaredKind !== expectedKind) { + hasError = true; + errors.push( + new Error( + `Starter "${slug}": "${objectPath}" maps to "${slotId}", a ${declaredKind} slot, but needs a ${expectedKind} slot.` + ) + ); + } + }); + if (hasError) return; + if (expectedKind === 'tilemap' && !mappingObject.tiles) { + errors.push( + new Error( + `Starter "${slug}": the tile map "${objectPath}" must map the role of each of its tiles ("tiles").` + ) + ); + } + } + ); + return errors; +}; + const cubeFaceProperties = [ 'frontFaceResourceName', 'backFaceResourceName', @@ -187,6 +320,17 @@ const checkStarterThemeSlots = ( const errors = []; const { objects, customObjects, effects, is3D } = getThemeableObjects(projectObject); + if (starterThemeSlots) { + errors.push( + ...check2DObjectsThemeSlots( + vocabulary, + slug, + projectObject, + starterThemeSlots, + is3D + ) + ); + } if (!is3D) return errors; if (!starterThemeSlots) { @@ -317,7 +461,10 @@ const checkStarterThemeSlots = ( ); return; } - Object.entries(mapping).forEach(([property, propertyMapping]) => { + /** @type {Object.} */ + // @ts-ignore - the mapping of a custom object maps its properties. + const propertiesMapping = mapping; + Object.entries(propertiesMapping).forEach(([property, propertyMapping]) => { const where = `"${objectPath}" property "${property}"`; if (typeof propertyMapping !== 'string') { if ( diff --git a/scripts/lib/__tests__/StarterThemer.spec.js b/scripts/lib/__tests__/StarterThemer.spec.js index 72ab62e49..b0189aa0d 100644 --- a/scripts/lib/__tests__/StarterThemer.spec.js +++ b/scripts/lib/__tests__/StarterThemer.spec.js @@ -722,6 +722,449 @@ describe('applyThemeToStarter on custom objects', () => { }); }); +describe('applyThemeToStarter on a 2D starter', () => { + const makeProject = () => ({ + resources: { + resources: [ + { name: 'Player.png', kind: 'image', file: 'assets/Player.png' }, + { name: 'Ground.png', kind: 'image', file: 'assets/Ground.png' }, + { name: 'Tiles.png', kind: 'image', file: 'assets/Tiles.png' }, + ], + }, + layouts: [ + { + name: 'Game Scene', + objects: [ + { + name: 'Player', + type: 'Sprite', + behaviors: [{ name: 'Platformer' }], + animations: [ + { + name: '', + directions: [ + { + sprites: [ + { + image: 'Player.png', + originPoint: { name: 'origine', x: 0, y: 0 }, + centerPoint: { name: 'centre', automatic: true }, + points: [{ name: 'Hand', x: 64, y: 32 }], + hasCustomCollisionMask: false, + }, + ], + }, + ], + }, + ], + }, + { + name: 'Ground', + type: 'TiledSpriteObject::TiledSprite', + content: { texture: 'Ground.png', width: 64, height: 64 }, + }, + { + name: 'Tiles', + type: 'TileMap::SimpleTileMap', + content: { + atlasImage: 'Tiles.png', + tileSize: 64, + columnCount: 3, + rowCount: 1, + tilesWithHitBox: '2', + }, + }, + ], + instances: [ + { name: 'Player', customSize: false, width: 0, height: 0 }, + { name: 'Player', customSize: true, width: 128, height: 128 }, + { + name: 'Tiles', + stringProperties: [ + { + name: 'tilemap', + value: JSON.stringify({ + layers: [{ id: 0, tiles: [[0, 1, 2, -1]] }], + }), + }, + ], + }, + ], + }, + ], + }); + + /** @returns {StarterThemeSlots} */ + const makeThemeSlots = () => ({ + version: 1, + requireAllSlots: true, + objects: { + 'scene:Game Scene/Player': { slot: '2d.side.player', anchor: 'bottom' }, + 'scene:Game Scene/Ground': '2d.side.ground', + 'scene:Game Scene/Tiles': { + slot: '2d.overhead.tilemap', + tiles: { 0: 'ground', 1: 'ground', 2: 'area' }, + }, + }, + }); + + /** @param {string} image */ + const makeThemeFrame = (image) => ({ + image, + originPoint: { name: 'origine', x: 0, y: 0 }, + centerPoint: { name: 'centre', automatic: true }, + points: [], + hasCustomCollisionMask: true, + customCollisionMask: [ + [ + { x: 28, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 128 }, + { x: 28, y: 128 }, + ], + ], + }); + + /** @returns {Theme} */ + const makeTheme = () => ({ + id: 'platformer', + name: 'Colorful platformer', + description: 'A cheerful platformer world.', + slots: { + '2d.side.player': { + kind: 'sprite', + assetStoreId: 'hero123', + width: 128, + height: 128, + objectContent: { + type: 'Sprite', + animations: [ + { + name: 'idle', + directions: [{ sprites: [makeThemeFrame('Hero_idle.png')] }], + }, + { + name: 'walk', + directions: [{ sprites: [makeThemeFrame('Hero_walk.png')] }], + }, + ], + }, + resources: [ + { name: 'Hero_idle.png', kind: 'image', file: 'https://a/idle.png' }, + { name: 'Hero_walk.png', kind: 'image', file: 'https://a/walk.png' }, + ], + }, + '2d.side.ground': { + kind: 'texture', + file: 'https://a/grass.png', + resourceName: 'tiled_Grass.png', + }, + '2d.overhead.tilemap': { + kind: 'tilemap', + file: 'https://a/atlas.png', + resourceName: 'Terrain atlas.png', + tileSize: 64, + columnCount: 10, + rowCount: 4, + tiles: { ground: 12, area: 30 }, + }, + }, + }); + + // The placeholder is a 64x64 image entirely visible. + const options = { + getStarterImageBox: () => ({ + width: 64, + height: 64, + box: { left: 0, top: 0, right: 64, bottom: 64 }, + }), + }; + + it('draws the theme sprite where the placeholder was, standing on its bottom', () => { + const project = makeProject(); + const result = applyThemeToStarter( + project, + makeThemeSlots(), + makeTheme(), + options + ); + /** @type {any} */ + const player = project.layouts[0].objects[0]; + + expect(result.missingSlots).toEqual([]); + // The 72x128 art is as tall as the 64 pixels placeholder. + expect(player.preScale).toBe(0.5); + expect(player.behaviors).toEqual([{ name: 'Platformer' }]); + expect(player.assetStoreId).toBe('hero123'); + // The animation of the starter is played by the first one of the theme, + // and the other animations of the theme are added. + expect(player.animations.map((/** @type {any} */ a) => a.name)).toEqual([ + '', + 'walk', + ]); + const frame = player.animations[0].directions[0].sprites[0]; + expect(frame.image).toBe('Hero_idle.png'); + // The top-left corner of the placeholder is 32 pixels left of the art. + expect(frame.originPoint).toMatchObject({ x: 0, y: 0 }); + expect(frame.centerPoint).toMatchObject({ automatic: false, x: 64, y: 64 }); + expect(frame.points).toEqual([{ name: 'Hand', x: 128, y: 64 }]); + // The collision mask is the one of the theme art. + expect(frame.customCollisionMask[0][0]).toEqual({ x: 28, y: 0 }); + }); + + it('keeps the instances with a custom size as large as they were', () => { + const project = makeProject(); + applyThemeToStarter(project, makeThemeSlots(), makeTheme(), options); + // 128 pixels for a 64 pixels placeholder: twice the theme art at its scale. + expect(project.layouts[0].instances[1]).toMatchObject({ + width: 128, + height: 128, + }); + }); + + it('replaces the placeholder images by the images of the theme', () => { + const project = makeProject(); + applyThemeToStarter(project, makeThemeSlots(), makeTheme(), options); + expect( + project.resources.resources.map((resource) => resource.name).sort() + ).toEqual([ + 'Hero_idle.png', + 'Hero_walk.png', + 'Terrain atlas.png', + 'tiled_Grass.png', + ]); + /** @type {any} */ + const ground = project.layouts[0].objects[1]; + expect(ground.content.texture).toBe('tiled_Grass.png'); + }); + + it('paints a tile map with the tiles of the theme playing the same roles', () => { + const project = makeProject(); + applyThemeToStarter(project, makeThemeSlots(), makeTheme(), options); + /** @type {any} */ + const tiles = project.layouts[0].objects[2]; + expect(tiles.content).toEqual({ + atlasImage: 'Terrain atlas.png', + tileSize: 64, + columnCount: 10, + rowCount: 4, + tilesWithHitBox: '30', + }); + /** @type {any} */ + const instance = project.layouts[0].instances[2]; + expect(JSON.parse(instance.stringProperties[0].value)).toEqual({ + layers: [{ id: 0, tiles: [[12, 12, 30, -1]] }], + }); + }); + + it('reports the slots the theme does not fill', () => { + const theme = makeTheme(); + delete theme.slots['2d.side.ground']; + // A tile map of another tile size cannot replace the tiles of the starter. + // @ts-ignore - this slot is a tile map. + theme.slots['2d.overhead.tilemap'].tileSize = 16; + const project = makeProject(); + const result = applyThemeToStarter( + project, + makeThemeSlots(), + theme, + options + ); + expect(result.missingSlots).toEqual([ + '2d.overhead.tilemap', + '2d.side.ground', + ]); + /** @type {any} */ + const tiles = project.layouts[0].objects[2]; + expect(tiles.content.atlasImage).toBe('Tiles.png'); + }); + + it('scales art drawn on a grid of tiles to the grid of the starter', () => { + const theme = makeTheme(); + /** @type {any} */ + const slot = theme.slots['2d.side.player']; + // 16 pixels tiles, for a starter built on 64 pixels tiles. + slot.tileSize = 16; + const project = makeProject(); + applyThemeToStarter(project, makeThemeSlots(), theme, options); + /** @type {any} */ + const player = project.layouts[0].objects[0]; + expect(player.preScale).toBe(4); + // An instance with a custom size takes the size of the art at that scale. + expect(project.layouts[0].instances[1]).toMatchObject({ + customSize: false, + }); + + const pixelProject = makeProject(); + applyThemeToStarter( + pixelProject, + { ...makeThemeSlots(), tileSize: 32 }, + theme, + options + ); + /** @type {any} */ + const pixelPlayer = pixelProject.layouts[0].objects[0]; + expect(pixelPlayer.preScale).toBe(2); + }); + + it('plays an animation with the theme animation the starter asks for', () => { + const project = makeProject(); + /** @type {any} */ + const player = project.layouts[0].objects[0]; + player.animations[0].name = 'Walk0'; + const themeSlots = makeThemeSlots(); + themeSlots.objects['scene:Game Scene/Player'] = { + slot: '2d.side.player', + animations: { Walk0: ['Walk Right', 'walk'] }, + }; + applyThemeToStarter(project, themeSlots, makeTheme(), options); + expect(player.animations[0].name).toBe('Walk0'); + expect(player.animations[0].directions[0].sprites[0].image).toBe( + 'Hero_walk.png' + ); + // The other animation of the theme is still added. + expect(player.animations.map((/** @type {any} */ a) => a.name)).toEqual([ + 'Walk0', + 'idle', + ]); + }); + + it('gives each animation of a sprite the art of its own slot', () => { + const project = makeProject(); + /** @type {any} */ + const player = project.layouts[0].objects[0]; + player.animations = [ + { ...player.animations[0], name: 'Unit' }, + { ...player.animations[0], name: 'Tower' }, + ]; + const theme = makeTheme(); + /** @type {any} */ + const heroSlot = theme.slots['2d.side.player']; + theme.slots['2d.board.tower'] = { + ...heroSlot, + assetStoreId: 'tower123', + objectContent: { + type: 'Sprite', + animations: [ + { + name: '', + directions: [{ sprites: [makeThemeFrame('Tower.png')] }], + }, + ], + }, + resources: [ + { name: 'Tower.png', kind: 'image', file: 'https://a/t.png' }, + ], + }; + const themeSlots = makeThemeSlots(); + themeSlots.objects['scene:Game Scene/Player'] = { + animationSlots: { Unit: '2d.side.player', Tower: '2d.board.tower' }, + }; + const result = applyThemeToStarter(project, themeSlots, theme, options); + + expect(result.missingSlots).toEqual([]); + expect( + player.animations.map( + (/** @type {any} */ a) => + a.name + ':' + a.directions[0].sprites[0].image + ) + ).toEqual(['Unit:Hero_idle.png', 'Tower:Tower.png']); + + // Without one of the slots, the sprite is left as it was. + delete theme.slots['2d.board.tower']; + const untouchedProject = makeProject(); + /** @type {any} */ + const untouchedPlayer = untouchedProject.layouts[0].objects[0]; + untouchedPlayer.animations = [ + { ...untouchedPlayer.animations[0], name: 'Unit' }, + { ...untouchedPlayer.animations[0], name: 'Tower' }, + ]; + const missing = applyThemeToStarter( + untouchedProject, + themeSlots, + theme, + options + ); + expect(missing.missingSlots).toContain('2d.board.tower'); + expect(untouchedPlayer.animations[0].directions[0].sprites[0].image).toBe( + 'Player.png' + ); + }); + + it('takes the image and the tile map a theme made for the tiles of the starter', () => { + const theme = makeTheme(); + /** @type {any} */ + const slots = theme.slots; + slots['2d.side.player'].tileSize = 16; + slots['2d.side.ground'].variants = [ + { + kind: 'texture', + file: 'https://a/grass32.png', + resourceName: 'tiled_Grass 32.png', + tileSize: 32, + }, + ]; + slots['2d.overhead.tilemap'].variants = [ + { + ...slots['2d.overhead.tilemap'], + file: 'https://a/atlas32.png', + resourceName: 'Terrain 32 atlas.png', + tileSize: 32, + }, + ]; + const project = makeProject(); + /** @type {any} */ + const tiles = project.layouts[0].objects[2]; + tiles.content.tileSize = 32; + const result = applyThemeToStarter( + project, + { ...makeThemeSlots(), tileSize: 32 }, + theme, + options + ); + + expect(result.missingSlots).toEqual([]); + /** @type {any} */ + const ground = project.layouts[0].objects[1]; + expect(ground.content.texture).toBe('tiled_Grass 32.png'); + expect(tiles.content.atlasImage).toBe('Terrain 32 atlas.png'); + }); + + it('does not use art that is not pixel art in a starter built on small tiles', () => { + const project = makeProject(); + const result = applyThemeToStarter( + project, + { ...makeThemeSlots(), tileSize: 32 }, + makeTheme(), + options + ); + // The sprite is not drawn on a grid, and the image is made for 64 pixels + // tiles. + expect(result.missingSlots).toEqual(['2d.side.ground', '2d.side.player']); + }); + + it('does not enlarge art smaller than the placeholder', () => { + const theme = makeTheme(); + /** @type {any} */ + const slot = theme.slots['2d.side.player']; + slot.objectContent.animations.forEach((/** @type {any} */ animation) => { + animation.directions[0].sprites[0].customCollisionMask = [ + [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 4 }, + { x: 0, y: 4 }, + ], + ]; + }); + const project = makeProject(); + applyThemeToStarter(project, makeThemeSlots(), theme, options); + /** @type {any} */ + const player = project.layouts[0].objects[0]; + expect(player.preScale).toBe(1); + }); +}); + describe('getThemedStarterFileName', () => { it('names the themed copy after the starter, to sit next to it', () => { expect(getThemedStarterFileName('starting-3d-tank', 'pirate')).toBe( diff --git a/scripts/lib/__tests__/ThemeSlots.spec.js b/scripts/lib/__tests__/ThemeSlots.spec.js index f5d520e89..c583d346d 100644 --- a/scripts/lib/__tests__/ThemeSlots.spec.js +++ b/scripts/lib/__tests__/ThemeSlots.spec.js @@ -455,3 +455,71 @@ describe('checkStarterThemeSlots', () => { expect(errors[0].message).toContain('must map its properties to slots'); }); }); + +describe('checkStarterThemeSlots on a 2D starter', () => { + /** @type {ThemeSlotsVocabulary} */ + const vocabulary2D = { + version: 1, + slots: [ + { id: '2d.side.player', kind: 'sprite', label: '', description: '' }, + { id: '2d.side.ground', kind: 'texture', label: '', description: '' }, + { id: '2d.tilemap', kind: 'tilemap', label: '', description: '' }, + ], + }; + const project = { + layouts: [ + { + name: 'Game Scene', + objects: [ + { name: 'Player', type: 'Sprite' }, + { name: 'Ground', type: 'TiledSpriteObject::TiledSprite' }, + { name: 'Tiles', type: 'TileMap::SimpleTileMap' }, + { name: 'JumpButton', type: 'Sprite' }, + ], + }, + ], + }; + + it('accepts sprites, tiled sprites and tile maps mapped to slots of their kind', () => { + expect( + checkStarterThemeSlots(vocabulary2D, 'starting-platformer', project, { + version: 1, + requireAllSlots: true, + objects: { + 'scene:Game Scene/Player': { + slot: '2d.side.player', + anchor: 'bottom', + }, + 'scene:Game Scene/Ground': '2d.side.ground', + 'scene:Game Scene/Tiles': { + slot: '2d.tilemap', + tiles: { 0: 'ground' }, + }, + }, + }) + ).toEqual([]); + }); + + it('reports an object the starter does not have, and a slot of the wrong kind', () => { + const errors = checkStarterThemeSlots( + vocabulary2D, + 'starting-platformer', + project, + { + version: 1, + objects: { + 'scene:Game Scene/Hero': '2d.side.player', + 'scene:Game Scene/Player': '2d.side.ground', + 'scene:Game Scene/Ground': '2d.side.unknown', + 'scene:Game Scene/Tiles': { slot: '2d.tilemap' }, + }, + } + ).map((error) => error.message); + + expect(errors).toHaveLength(4); + expect(errors[0]).toContain('"scene:Game Scene/Hero", which is not'); + expect(errors[1]).toContain('a texture slot, but needs a sprite slot'); + expect(errors[2]).toContain('which is not a slot of theme-slots.json'); + expect(errors[3]).toContain('must map the role of each of its tiles'); + }); +}); diff --git a/theme-slots.json b/theme-slots.json index a5e011451..82cf4d030 100644 --- a/theme-slots.json +++ b/theme-slots.json @@ -264,6 +264,384 @@ "kind": "skybox", "label": "Night or gloomy sky", "description": "The night or gloomy sky of the horror starters." + }, + { + "id": "2d.side.player", + "kind": "sprite", + "label": "Side view player character", + "description": "The character the player controls in the 2D platformer, platformer shooter and endless runner starters, seen from the side and facing right. It stands on platforms about as tall as itself: a character close to a square, with idle, walk and jump animations when the pack has them." + }, + { + "id": "2d.side.ground", + "kind": "texture", + "label": "Side view platform", + "description": "The image repeated along the platforms of the side view starters, which are one tile (64 pixels) tall: a ground tile that repeats seamlessly from left to right, with its surface at the top." + }, + { + "id": "2d.side.background", + "kind": "texture", + "label": "Side view background", + "description": "The image repeated behind the whole scene of the side view starters: a plain or evenly patterned sky or wall that repeats seamlessly in both directions." + }, + { + "id": "2d.side.coin", + "kind": "sprite", + "label": "Side view collectible", + "description": "What the player collects in the 2D platformer: a coin, gem or star, half the size of the player." + }, + { + "id": "2d.side.hazard", + "kind": "sprite", + "label": "Side view hazard", + "description": "What the player must jump over in the endless runner: spikes, a saw or a hostile creature, about as large as the player." + }, + { + "id": "2d.side.target", + "kind": "sprite", + "label": "Side view target", + "description": "What the player shoots in the platformer shooter: a target or an enemy, floating in the air, about as large as the player." + }, + { + "id": "2d.side.projectile", + "kind": "sprite", + "label": "Side view projectile", + "description": "What the player fires in the platformer shooter: a bullet, fireball or arrow flying to the right." + }, + { + "id": "2d.flappy.player", + "kind": "sprite", + "label": "Flappy flyer", + "description": "What the player keeps in the air in the flappy bird starter, seen from the side and facing right: a bird, plane or any flying thing." + }, + { + "id": "2d.flappy.obstacle", + "kind": "sprite", + "label": "Flappy obstacle", + "description": "The tall obstacles the flyer passes between in the flappy bird starter, rising from the ground: a rock, pillar, pipe or tree, much taller than wide." + }, + { + "id": "2d.flappy.ground", + "kind": "texture", + "label": "Flappy ground and ceiling", + "description": "The image repeated along the floor and the ceiling of the flappy bird starter, two tiles (128 pixels) tall." + }, + { + "id": "2d.flappy.background", + "kind": "texture", + "label": "Flappy background", + "description": "The image repeated behind the whole scene of the flappy bird starter: a plain or evenly patterned sky." + }, + { + "id": "2d.overhead.player", + "kind": "sprite", + "label": "Top view player", + "description": "The character or vehicle the player moves in the top-down, twin-stick shooter and vampire survivor starters, seen from straight above and facing right. It turns to face where it goes or aims." + }, + { + "id": "2d.overhead.enemy", + "kind": "sprite", + "label": "Top view enemy", + "description": "What chases the player in the twin-stick shooter and the vampire survivor, seen from straight above and facing right." + }, + { + "id": "2d.overhead.projectile", + "kind": "sprite", + "label": "Top view projectile", + "description": "What the player fires in the twin-stick shooter and the vampire survivor, seen from above and flying to the right." + }, + { + "id": "2d.overhead.obstacle", + "kind": "sprite", + "label": "Top view obstacle", + "description": "The round obstacles scattered in the top-down starter, one tile wide: a bush, rock or barrel seen from above." + }, + { + "id": "2d.overhead.wall", + "kind": "sprite", + "label": "Top view large obstacle", + "description": "The large blocks the player walks around in the top-down starter, two tiles wide: a tree, building or crate stack seen from above." + }, + { + "id": "2d.overhead.ground", + "kind": "texture", + "label": "Top view ground", + "description": "The image repeated under the whole scene of the twin-stick shooter and the vampire survivor: a ground seen from above that repeats seamlessly." + }, + { + "id": "2d.overhead.tilemap", + "kind": "tilemap", + "label": "Top view ground tiles", + "description": "The 64 pixels tiles painting the ground of the top-down starter. Its tiles play these roles: `ground`, `ground.detail1` and `ground.detail2` (the main ground and two variations of it), and `area` (the fill of a path or zone drawn on the ground)." + }, + { + "id": "2d.shooter.player", + "kind": "sprite", + "label": "Shoot'em up player", + "description": "The ship or plane the player flies in the side-scrolling shoot'em up, seen from above and facing right." + }, + { + "id": "2d.shooter.enemy", + "kind": "sprite", + "label": "Shoot'em up enemy", + "description": "The ships or planes flying at the player in the shoot'em up, seen from above. They are drawn facing right and the starter turns them around." + }, + { + "id": "2d.shooter.projectile.player", + "kind": "sprite", + "label": "Shoot'em up player projectile", + "description": "What the player fires in the shoot'em up, flying to the right." + }, + { + "id": "2d.shooter.projectile.enemy", + "kind": "sprite", + "label": "Shoot'em up enemy projectile", + "description": "What the enemies fire in the shoot'em up. Drawn flying to the right, and different from the player's." + }, + { + "id": "2d.shooter.background", + "kind": "texture", + "label": "Shoot'em up background", + "description": "The image scrolling under the shoot'em up: a ground, sea or space seen from above that repeats seamlessly." + }, + { + "id": "2d.driving.car", + "kind": "sprite", + "label": "Top view car", + "description": "The car the player drives in the 2D driving and racing starters, seen from above and facing right, about twice as long as wide." + }, + { + "id": "2d.driving.obstacle", + "kind": "sprite", + "label": "Top view track obstacle", + "description": "The round obstacles the car bumps into and pushes: a tire stack, bush, cone or barrel seen from above." + }, + { + "id": "2d.driving.tilemap", + "kind": "tilemap", + "label": "Driving ground tiles", + "description": "The 64 pixels tiles painting the ground of the 2D driving and racing starters. Its tiles play these roles: `ground`, `ground.detail1` and `ground.detail2` (what is beside the road, and two variations of it), and `area` (the surface of the road)." + }, + { + "id": "2d.board.piece.a", + "kind": "sprite", + "label": "Board piece", + "description": "The main piece moved on the board of the draggable tiles starter, fitting in one tile: a unit, pawn or character." + }, + { + "id": "2d.board.piece.b", + "kind": "sprite", + "label": "Second board piece", + "description": "Another kind of piece on the board, fitting in one tile: a tower, building or vehicle." + }, + { + "id": "2d.board.piece.c", + "kind": "sprite", + "label": "Board scenery piece", + "description": "A piece of scenery on the board, fitting in one tile: a tree, bush or rock." + }, + { + "id": "2d.board.tilemap", + "kind": "tilemap", + "label": "Board tiles", + "description": "The 64 pixels tiles painting the board of the draggable tiles starter. Its tiles play these roles: `ground` (what surrounds the board), `cell.a` and `cell.b` (the two alternating cells of the board)." + }, + { + "id": "2d.overhead.water", + "kind": "texture", + "label": "Top view water", + "description": "A 192 pixels image of water (3 by 3 tiles) repeated over the ponds of the point and click and unit selection starters." + }, + { + "id": "2d.rpg.player", + "kind": "sprite", + "label": "Top-down RPG player", + "description": "The character the player walks in the top-down RPG, farming and survival starters, seen from the front like in a classic RPG. It needs an animation for each direction: walking right, down, left and up." + }, + { + "id": "2d.rpg.npc", + "kind": "sprite", + "label": "Top-down RPG character", + "description": "A character standing in the top-down RPG starter, who the player talks to, seen from the front." + }, + { + "id": "2d.rpg.building", + "kind": "sprite", + "label": "Top-down RPG building", + "description": "The buildings and large obstacles the player walks around in the top-down RPG, farming and survival starters: a house, hut, statue or large tree seen from the front." + }, + { + "id": "2d.rpg.tilemap", + "kind": "tilemap", + "label": "Top-down RPG ground tiles", + "description": "The 64 pixels tiles painting the ground of the top-down RPG, farming and survival starters. Its tiles play these roles: `ground`, `ground.detail1` and `ground.detail2` (the main ground and two variations of it), and `area` (the fill of a path, a field or a zone drawn on the ground)." + }, + { + "id": "2d.rpg.crop.a.food", + "kind": "sprite", + "label": "First crop", + "description": "The first thing grown in the farming starter, once harvested: a vegetable, fruit or nut, one tile large." + }, + { + "id": "2d.rpg.crop.a.seed", + "kind": "sprite", + "label": "Seed of the first crop", + "description": "The seed of the first crop, one tile large." + }, + { + "id": "2d.rpg.crop.a.planted", + "kind": "sprite", + "label": "First crop, planted", + "description": "The first crop growing in the ground, one tile large: a sprout or small plant." + }, + { + "id": "2d.rpg.crop.a.source", + "kind": "sprite", + "label": "Wild plant of the first crop", + "description": "The wild plant the player harvests to get seeds of the first crop." + }, + { + "id": "2d.rpg.crop.b.food", + "kind": "sprite", + "label": "Second crop", + "description": "The second thing grown in the farming starter, once harvested." + }, + { + "id": "2d.rpg.crop.b.seed", + "kind": "sprite", + "label": "Seed of the second crop", + "description": "The seed of the second crop." + }, + { + "id": "2d.rpg.crop.b.planted", + "kind": "sprite", + "label": "Second crop, planted", + "description": "The second crop growing in the ground." + }, + { + "id": "2d.rpg.crop.b.source", + "kind": "sprite", + "label": "Wild plant of the second crop", + "description": "The wild plant the player harvests to get seeds of the second crop." + }, + { + "id": "2d.rpg.resource.rock", + "kind": "sprite", + "label": "Rock resource", + "description": "The rock the player collects and carries in the survival starter, one tile large." + }, + { + "id": "2d.rpg.resource.log", + "kind": "sprite", + "label": "Wood resource", + "description": "The wood the player collects and carries in the survival starter, one tile large." + }, + { + "id": "2d.rpg.harvest.rock", + "kind": "sprite", + "label": "Boulder to mine", + "description": "The boulder the player mines for rocks in the survival starter, two tiles large." + }, + { + "id": "2d.rpg.harvest.tree", + "kind": "sprite", + "label": "Tree to cut", + "description": "The tree the player cuts for wood in the survival starter, two tiles large." + }, + { + "id": "2d.rts.unit", + "kind": "sprite", + "label": "Strategy unit", + "description": "The units the player selects and moves in the unit selection starter, smaller than a tile: a soldier, worker or vehicle." + }, + { + "id": "2d.rts.building", + "kind": "sprite", + "label": "Strategy building", + "description": "The buildings the units walk around in the unit selection starter: a house, tower or base." + }, + { + "id": "2d.rts.blocked", + "kind": "texture", + "label": "Impassable ground", + "description": "The image repeated over the ground units cannot cross in the unit selection starter: rocks, a forest or a wall seen from above, one tile large." + }, + { + "id": "2d.brawler.player", + "kind": "sprite", + "label": "Beat'em up player", + "description": "The fighter the player controls in the beat'em up, seen from the side and facing right, with a pose to stand, to prepare a hit, to hit, and to be hurt." + }, + { + "id": "2d.brawler.enemy", + "kind": "sprite", + "label": "Beat'em up enemy", + "description": "The fighter the player fights in the beat'em up, with the same poses as the player, drawn facing right." + }, + { + "id": "2d.brawler.wall", + "kind": "texture", + "label": "Beat'em up wall", + "description": "The image repeated over the walls closing the street of the beat'em up." + }, + { + "id": "2d.physics.background", + "kind": "texture", + "label": "Physics playground background", + "description": "The image repeated behind the physics starter: a sky or landscape that repeats seamlessly from left to right." + }, + { + "id": "2d.physics.ground", + "kind": "texture", + "label": "Physics playground ground", + "description": "The image repeated over the ground and walls of the physics starter." + }, + { + "id": "2d.physics.ball", + "kind": "sprite", + "label": "Physics ball", + "description": "The round thing thrown or rolling in the physics starter." + }, + { + "id": "2d.physics.block.light", + "kind": "sprite", + "label": "Light block", + "description": "The light blocks stacked in the physics starter, three times taller than wide: a wooden plank." + }, + { + "id": "2d.physics.block.heavy", + "kind": "sprite", + "label": "Heavy block", + "description": "The heavy square blocks of the physics starter: a stone or metal block." + }, + { + "id": "2d.rpg.bush", + "kind": "sprite", + "label": "Top-down RPG small obstacle", + "description": "The small obstacles scattered in the pixel art top-down starter, one tile large: a bush, rock or stump." + }, + { + "id": "2d.rpg.wall", + "kind": "texture", + "label": "Top-down RPG wall", + "description": "The image repeated over the walls of the pixel art top-down starter: a hedge, wall or rocks, one tile large." + }, + { + "id": "2d.rpg.water", + "kind": "texture", + "label": "Top-down RPG pond", + "description": "An image of a pond, 3 by 3 tiles, with its shore on the outer tiles: the pixel art point and click starter stretches its middle to draw ponds of any size." + }, + { + "id": "2d.rts.tilemap", + "kind": "tilemap", + "label": "Strategy ground tiles", + "description": "The 64 pixels tiles painting the ground of the unit selection and tile placement starters. Its tiles play these roles: `ground`, `ground.detail1` and `ground.detail2` (the main ground and two variations of it), and `area` (the fill of a path or zone drawn on the ground)." + }, + { + "id": "2d.rts.water", + "kind": "texture", + "label": "Strategy pond", + "description": "A 192 pixels image of water (3 by 3 tiles) repeated over the pond of the unit selection starter." } ] }