refactor: support per-node map art sizes and rebrand campaigns
- Refactor level select scene to handle variable node texture dimensions instead of assuming all node art is centered on a 960×540 canvas - Change BADGE_FILE_FRAC and nodeDisplaySize to accept per-state keys, allowing map_node_current (512×512 badge) to differ from the other nodes (960×540 frames) - Update map_finish to use its new 512×512 tight export; reduce its display scale accordingly - Rename campaigns: "Sunny Suburbs" → "Evergreen Lake", "Dusk Junction" → "Underprivileged Community" - Update asset manifest dimensions and sprites.md documentation
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 235 KiB |
13
sprites.md
|
|
@ -95,7 +95,7 @@ shifted.
|
||||||
| `campaign01_bg` | `assets/backgrounds/campaign01_bg.png` | full frame 960x540 | Campaign 1 "Sunny Suburbs" background |
|
| `campaign01_bg` | `assets/backgrounds/campaign01_bg.png` | full frame 960x540 | Campaign 1 "Sunny Suburbs" background |
|
||||||
| `campaign02_bg` | `assets/backgrounds/campaign02_bg.png` | full frame 960x540 | Campaign 2 "Dusk Junction" background |
|
| `campaign02_bg` | `assets/backgrounds/campaign02_bg.png` | full frame 960x540 | Campaign 2 "Dusk Junction" background |
|
||||||
| `map_node` | `assets/ui/map_node.png` | ~450x450 badge centered | completed level node (number badge) |
|
| `map_node` | `assets/ui/map_node.png` | ~450x450 badge centered | completed level node (number badge) |
|
||||||
| `map_node_current` | `assets/ui/map_node_current.png` | ~450x450 badge centered | the next level to play ("start here" node) |
|
| `map_node_current` | `assets/ui/map_node_current.png` | **512x512 badge, no padding frame** (exception to the centered-frame rule) | the next level to play ("start here" node) |
|
||||||
| `map_node_locked` | `assets/ui/map_node_locked.png` | ~450x450 badge centered | locked level node |
|
| `map_node_locked` | `assets/ui/map_node_locked.png` | ~450x450 badge centered | locked level node |
|
||||||
| `map_finish` | `assets/ui/map_finish.png` | ~440x560 centered | flag past the last node |
|
| `map_finish` | `assets/ui/map_finish.png` | ~440x560 centered | flag past the last node |
|
||||||
| `map_campaign_tag` | `assets/ui/map_campaign_tag.png` | ~1440x300 plate centered near top | banner behind the campaign title text (title is drawn on top at y≈82/540) |
|
| `map_campaign_tag` | `assets/ui/map_campaign_tag.png` | ~1440x300 plate centered near top | banner behind the campaign title text (title is drawn on top at y≈82/540) |
|
||||||
|
|
@ -113,10 +113,17 @@ Per-campaign notes:
|
||||||
(the one to play next, e.g. gold - this one gets a gentle idle pulse),
|
(the one to play next, e.g. gold - this one gets a gentle idle pulse),
|
||||||
`map_node_locked` (greyed out, e.g. a padlock). If you want the badge to
|
`map_node_locked` (greyed out, e.g. a padlock). If you want the badge to
|
||||||
be a different size than ~450 units, also adjust `BADGE_FRAC_OF_FRAME`
|
be a different size than ~450 units, also adjust `BADGE_FRAC_OF_FRAME`
|
||||||
in `src/util/mapArt.js`.
|
in `src/util/mapArt.js`. Note `map_node_current` currently ships as a
|
||||||
|
tight 512x512 image (no 960x540 padding frame); its size/hit area are
|
||||||
|
driven by `NODE_ART_FRAMES` + `BADGE_FILE_FRAC` per-key in `mapArt.js`,
|
||||||
|
so a different frame size there is fine as long as those tables are
|
||||||
|
updated.
|
||||||
- **`map_finish.png`** - the flag sits just past the last node, on the road,
|
- **`map_finish.png`** - the flag sits just past the last node, on the road,
|
||||||
so a base/mound that grounds it looks best; don't rely on the bottom of
|
so a base/mound that grounds it looks best; don't rely on the bottom of
|
||||||
the 960x540 canvas being the ground line.
|
the 960x540 canvas being the ground line. Currently ships as a tight
|
||||||
|
512x512 image (flag ink is ~60% of the width, full height); the scene
|
||||||
|
displays it at 0.28x the game screen, so keep the flag roughly centered
|
||||||
|
in a square frame if you re-export it.
|
||||||
- **`map_campaign_tag.png`** - a plate/banner with empty center space; the
|
- **`map_campaign_tag.png`** - a plate/banner with empty center space; the
|
||||||
game writes `"<name> x/y cleared"` on top of it in dark ink (#1a1f29),
|
game writes `"<name> x/y cleared"` on top of it in dark ink (#1a1f29),
|
||||||
so the middle should be a light, uncluttered fill.
|
so the middle should be a light, uncluttered fill.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import level06 from './level06.js';
|
||||||
export const CAMPAIGNS = [
|
export const CAMPAIGNS = [
|
||||||
{
|
{
|
||||||
id: 'campaign01',
|
id: 'campaign01',
|
||||||
name: 'Sunny Suburbs',
|
name: 'Evergreen Lake',
|
||||||
bgKey: 'campaign01_bg',
|
bgKey: 'campaign01_bg',
|
||||||
levels: [level01, level02, level03],
|
levels: [level01, level02, level03],
|
||||||
positions: [
|
positions: [
|
||||||
|
|
@ -26,7 +26,7 @@ export const CAMPAIGNS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'campaign02',
|
id: 'campaign02',
|
||||||
name: 'Dusk Junction',
|
name: 'Underprivileged Community',
|
||||||
bgKey: 'campaign02_bg',
|
bgKey: 'campaign02_bg',
|
||||||
levels: [level04, level05, level06],
|
levels: [level04, level05, level06],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -123,17 +123,18 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
const flag = this.add
|
const flag = this.add
|
||||||
.image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish')
|
.image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish')
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setDisplaySize(GAME_WIDTH * 0.55, GAME_HEIGHT * 0.55)
|
.setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18)
|
||||||
.setDepth(DEPTH.flag);
|
.setDepth(DEPTH.flag);
|
||||||
this._groups.path.push(flag);
|
this._groups.path.push(flag);
|
||||||
|
|
||||||
// Nodes + labels.
|
// Nodes + labels. Per-key display sizes: map_node_current ships as a tight
|
||||||
const { width: dw, height: dh } = nodeDisplaySize();
|
// 512x512 badge while the others are 960x540 frames (see mapArt.js).
|
||||||
levels.forEach((level, i) => {
|
levels.forEach((level, i) => {
|
||||||
const p = points[i];
|
const p = points[i];
|
||||||
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
|
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
|
||||||
const key = state === 'current' ? 'map_node_current' : state === 'locked' ? 'map_node_locked' : 'map_node';
|
const key = state === 'current' ? 'map_node_current' : state === 'locked' ? 'map_node_locked' : 'map_node';
|
||||||
const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(dw, dh).setDepth(DEPTH.node);
|
const { width: nw, height: nh } = nodeDisplaySize(key);
|
||||||
|
const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(nw, nh).setDepth(DEPTH.node);
|
||||||
|
|
||||||
// The badge is a small centered circle inside a big mostly-transparent
|
// The badge is a small centered circle inside a big mostly-transparent
|
||||||
// texture frame, so the interactive area must be a circle around the
|
// texture frame, so the interactive area must be a circle around the
|
||||||
|
|
@ -149,7 +150,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
// and radii must be given in frame pixels too (world radius = radius *
|
// and radii must be given in frame pixels too (world radius = radius *
|
||||||
// scale). The hover/entrance tweens only change the scale, so the hit
|
// scale). The hover/entrance tweens only change the scale, so the hit
|
||||||
// circle stays glued to the visible badge at all times.
|
// circle stays glued to the visible badge at all times.
|
||||||
const hitRadius = ((node.width * BADGE_FILE_FRAC) / 2) * BADGE_HIT_RADIUS_MULT;
|
const hitRadius = ((node.width * BADGE_FILE_FRAC[key]) / 2) * BADGE_HIT_RADIUS_MULT;
|
||||||
const hitArea = new Phaser.Geom.Circle(node.displayOriginX, node.displayOriginY, hitRadius);
|
const hitArea = new Phaser.Geom.Circle(node.displayOriginX, node.displayOriginY, hitRadius);
|
||||||
const hitTest = (shape, x, y) => (x - shape.x) * (x - shape.x) + (y - shape.y) * (y - shape.y) <= shape.radius * shape.radius;
|
const hitTest = (shape, x, y) => (x - shape.x) * (x - shape.x) + (y - shape.y) * (y - shape.y) <= shape.radius * shape.radius;
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
() =>
|
() =>
|
||||||
this._tooltip(
|
this._tooltip(
|
||||||
p.x,
|
p.x,
|
||||||
p.y - dh * 0.75,
|
p.y - nh * 0.75,
|
||||||
i === 0 ? 'Clear the previous campaign to unlock' : 'Finish the previous level to unlock'
|
i === 0 ? 'Clear the previous campaign to unlock' : 'Finish the previous level to unlock'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -169,10 +170,10 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
} else {
|
} else {
|
||||||
node
|
node
|
||||||
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
||||||
.on('pointerover', () => this._tweenNodeSize(node, dw * NODE_HOVER, dh * NODE_HOVER))
|
.on('pointerover', () => this._tweenNodeSize(node, nw * NODE_HOVER, nh * NODE_HOVER))
|
||||||
.on('pointerout', () => this._tweenNodeSize(node, dw, dh))
|
.on('pointerout', () => this._tweenNodeSize(node, nw, nh))
|
||||||
.on('pointerdown', () => {
|
.on('pointerdown', () => {
|
||||||
this._tweenNodeSize(node, dw * 0.92, dh * 0.92, 70);
|
this._tweenNodeSize(node, nw * 0.92, nh * 0.92, 70);
|
||||||
this.time.delayedCall(80, () => {
|
this.time.delayedCall(80, () => {
|
||||||
this.scene.stop('LevelSelect');
|
this.scene.stop('LevelSelect');
|
||||||
this.scene.start('Play', { levelId: level.id });
|
this.scene.start('Play', { levelId: level.id });
|
||||||
|
|
@ -186,7 +187,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!';
|
if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!';
|
||||||
else if (state === 'current') label = level.name;
|
else if (state === 'current') label = level.name;
|
||||||
if (label) {
|
if (label) {
|
||||||
this._groups.tags.push(this.add.text(p.x, p.y + dh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag));
|
this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -199,11 +200,10 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
[...this._groups.bg, ...this._groups.path].forEach((o) => o.setAlpha(0));
|
[...this._groups.bg, ...this._groups.path].forEach((o) => o.setAlpha(0));
|
||||||
this.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' });
|
this.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' });
|
||||||
this._groups.nodes.forEach((node, i) => {
|
this._groups.nodes.forEach((node, i) => {
|
||||||
// Pop in to the resting DISPLAY size - never scale 1: the texture is a
|
// Pop in to the resting DISPLAY size - never scale 1 (see the per-key
|
||||||
// big mostly-transparent frame with the badge centered (the shipped 2x
|
// sizes in mapArt.js); animating display sizes keeps this correct for
|
||||||
// art is 1920x1080), so full scale renders it enormous. Animating
|
// any texture frame size, like every other node tween in this scene.
|
||||||
// display sizes keeps this correct for any frame size, like every other
|
const { width: dw, height: dh } = nodeDisplaySize(node.texture.key);
|
||||||
// node tween in this scene.
|
|
||||||
node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0);
|
node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: node,
|
targets: node,
|
||||||
|
|
@ -227,7 +227,7 @@ export default class LevelSelectScene extends Phaser.Scene {
|
||||||
if (this._pulseTween) this._pulseTween.stop();
|
if (this._pulseTween) this._pulseTween.stop();
|
||||||
const node = this._groups.nodes[index];
|
const node = this._groups.nodes[index];
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const { width: dw, height: dh } = nodeDisplaySize();
|
const { width: dw, height: dh } = nodeDisplaySize(node.texture.key);
|
||||||
// Explicit from/to pins the oscillation to the resting size, independent
|
// Explicit from/to pins the oscillation to the resting size, independent
|
||||||
// of whatever mid-animation scale the node happens to have when this is
|
// of whatever mid-animation scale the node happens to have when this is
|
||||||
// added.
|
// added.
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,11 @@ export const ASSET_MANIFEST = [
|
||||||
{ key: 'campaign02_bg', path: 'assets/backgrounds/campaign02_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x3a346e },
|
{ key: 'campaign02_bg', path: 'assets/backgrounds/campaign02_bg.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x3a346e },
|
||||||
// 960x540 canvas, badge centered (see the LEVEL SELECT section of sprites.md).
|
// 960x540 canvas, badge centered (see the LEVEL SELECT section of sprites.md).
|
||||||
{ key: 'map_node', path: 'assets/ui/map_node.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x2255aa },
|
{ key: 'map_node', path: 'assets/ui/map_node.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x2255aa },
|
||||||
{ key: 'map_node_current', path: 'assets/ui/map_node_current.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0xdea834 },
|
// 512x512 badge art (NOT the 960x540 centered-frame convention of the other
|
||||||
|
// node sprites) - see NODE_ART_FRAMES + nodeDisplaySize() in mapArt.js.
|
||||||
|
{ key: 'map_node_current', path: 'assets/ui/map_node_current.png', width: 512, height: 512, kind: 'circle', color: 0xdea834 },
|
||||||
{ key: 'map_node_locked', path: 'assets/ui/map_node_locked.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x606876 },
|
{ key: 'map_node_locked', path: 'assets/ui/map_node_locked.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'circle', color: 0x606876 },
|
||||||
{ key: 'map_finish', path: 'assets/ui/map_finish.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0xde5046 },
|
{ key: 'map_finish', path: 'assets/ui/map_finish.png', width: 512, height: 512, kind: 'rect', color: 0xde5046 },
|
||||||
{ key: 'map_campaign_tag', path: 'assets/ui/map_campaign_tag.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0xf4f0e6 },
|
{ key: 'map_campaign_tag', path: 'assets/ui/map_campaign_tag.png', width: 960 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0xf4f0e6 },
|
||||||
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },
|
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
// Shared map geometry for the LevelSelect map nodes + path ribbon.
|
// Shared map geometry for the LevelSelect map nodes + path ribbon.
|
||||||
//
|
//
|
||||||
// Placeholder art for the map UI is generated on 960x540 canvases with the
|
// Most map UI art is generated on 960x540 canvases with the sprite centered
|
||||||
// sprite centered (the generator in /tmp, documented in sprites.md). The code
|
// (documented in sprites.md). The node textures follow that convention EXCEPT
|
||||||
// below displays those textures full-frame at a node's position and reads the
|
// map_node_current, which ships as a 512x512 badge image with no padding frame
|
||||||
// badge size back from the frame size, so it works unchanged when real art
|
// (see NODE_ART_FRAMES below).
|
||||||
// replaces the placeholders - as long as the same "sprite centered on a
|
|
||||||
// 960x540 canvas" convention is kept (or the texture frame size is kept
|
|
||||||
// close to the badge's own aspect).
|
|
||||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||||
|
|
||||||
// How much of a game screen wide each node's texture FRAME is displayed at.
|
// How much of a game screen wide each node's texture FRAME is displayed at.
|
||||||
|
|
@ -14,12 +11,16 @@ import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
||||||
// the badge-to-frame ratio (see BADGE_FILE_FRAC) - it only sets display size.
|
// the badge-to-frame ratio (see BADGE_FILE_FRAC) - it only sets display size.
|
||||||
export const BADGE_FRAC_OF_FRAME = 0.47;
|
export const BADGE_FRAC_OF_FRAME = 0.47;
|
||||||
|
|
||||||
// Fraction of the texture file's width spanned by the badge's ink. The node
|
// Fraction of the texture file's width spanned by the badge's ink, PER NODE
|
||||||
// art is a ~450px badge centered in a 1920x1080 file (the 2x export of the
|
// STATE. map_node/map_node_locked are 1920x1080 frames with the ~450px badge
|
||||||
// 960x540 design canvas - see sprites.md); measured on the shipped
|
// centered, so ink spans ~0.24 of the file width (measured on the shipped
|
||||||
// map_node*.png, ink spans ~459-466 of the 1920 file width. Re-measure if
|
// art). map_node_current is a 512x512 image that IS the badge, so its ink
|
||||||
// the art changes.
|
// spans the whole file. Re-measure an entry if that file's art changes.
|
||||||
export const BADGE_FILE_FRAC = 0.24;
|
export const BADGE_FILE_FRAC = {
|
||||||
|
map_node: 0.24,
|
||||||
|
map_node_current: 0.30,
|
||||||
|
map_node_locked: 0.24,
|
||||||
|
};
|
||||||
|
|
||||||
// Hit-circle radius as a multiple of the VISIBLE badge's radius - slightly
|
// Hit-circle radius as a multiple of the VISIBLE badge's radius - slightly
|
||||||
// generous so clicks near the edge feel natural without reaching the road.
|
// generous so clicks near the edge feel natural without reaching the road.
|
||||||
|
|
@ -34,9 +35,20 @@ export function frameAnchorDisplayWidth() {
|
||||||
return GAME_WIDTH;
|
return GAME_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeDisplaySize() {
|
// Source pixel dimensions of each node texture. The old 960x540 frames were
|
||||||
const s = GAME_WIDTH * BADGE_FRAC_OF_FRAME;
|
// 2x exports; the current map_node_current is a tight 512x512 badge. Used to
|
||||||
return { width: s, height: s * (540 / 960) };
|
// normalize display size + hit radius regardless of frame size.
|
||||||
|
export const NODE_ART_FRAMES = {
|
||||||
|
map_node: { w: 512, h: 512 },
|
||||||
|
map_node_current: { w: 512, h: 512 },
|
||||||
|
map_node_locked: { w: 512, h: 512 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function nodeDisplaySize(key = 'map_node') {
|
||||||
|
const { w, h } = NODE_ART_FRAMES[key] || NODE_ART_FRAMES.map_node;
|
||||||
|
const badgeDesign = GAME_WIDTH * BADGE_FRAC_OF_FRAME * BADGE_FILE_FRAC[key];
|
||||||
|
const s = badgeDesign * (w / h); // display size of the square badge
|
||||||
|
return { width: s, height: s * (h / w) }; // for non-square art this keeps aspect
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scalePos(p) {
|
export function scalePos(p) {
|
||||||
|
|
|
||||||