diff --git a/assets/backgrounds/campaign01_bg.png b/assets/backgrounds/campaign01_bg.png index 5923324..2d689e0 100644 Binary files a/assets/backgrounds/campaign01_bg.png and b/assets/backgrounds/campaign01_bg.png differ diff --git a/assets/ui/map_finish.png b/assets/ui/map_finish.png index 77ef608..f0290be 100644 Binary files a/assets/ui/map_finish.png and b/assets/ui/map_finish.png differ diff --git a/assets/ui/map_node.png b/assets/ui/map_node.png index b6bbbba..1b27154 100644 Binary files a/assets/ui/map_node.png and b/assets/ui/map_node.png differ diff --git a/assets/ui/map_node_current.png b/assets/ui/map_node_current.png index a5abec5..bdcbe43 100644 Binary files a/assets/ui/map_node_current.png and b/assets/ui/map_node_current.png differ diff --git a/assets/ui/map_node_locked.png b/assets/ui/map_node_locked.png index 671a000..ac2c5c9 100644 Binary files a/assets/ui/map_node_locked.png and b/assets/ui/map_node_locked.png differ diff --git a/sprites.md b/sprites.md index 1564256..b9e70f1 100644 --- a/sprites.md +++ b/sprites.md @@ -95,7 +95,7 @@ shifted. | `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 | | `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_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) | @@ -113,10 +113,17 @@ Per-campaign notes: (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 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, 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 game writes `" x/y cleared"` on top of it in dark ink (#1a1f29), so the middle should be a light, uncluttered fill. diff --git a/src/data/levels/index.js b/src/data/levels/index.js index 79ca342..3b130dc 100644 --- a/src/data/levels/index.js +++ b/src/data/levels/index.js @@ -15,7 +15,7 @@ import level06 from './level06.js'; export const CAMPAIGNS = [ { id: 'campaign01', - name: 'Sunny Suburbs', + name: 'Evergreen Lake', bgKey: 'campaign01_bg', levels: [level01, level02, level03], positions: [ @@ -26,7 +26,7 @@ export const CAMPAIGNS = [ }, { id: 'campaign02', - name: 'Dusk Junction', + name: 'Underprivileged Community', bgKey: 'campaign02_bg', levels: [level04, level05, level06], }, diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 382346b..27897ef 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -123,17 +123,18 @@ export default class LevelSelectScene extends Phaser.Scene { const flag = this.add .image(last.x + Math.cos(ang) * 95 * WORLD_SCALE, last.y + Math.sin(ang) * 95 * WORLD_SCALE, 'map_finish') .setOrigin(0.5) - .setDisplaySize(GAME_WIDTH * 0.55, GAME_HEIGHT * 0.55) + .setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18) .setDepth(DEPTH.flag); this._groups.path.push(flag); - // Nodes + labels. - const { width: dw, height: dh } = nodeDisplaySize(); + // Nodes + labels. Per-key display sizes: map_node_current ships as a tight + // 512x512 badge while the others are 960x540 frames (see mapArt.js). levels.forEach((level, i) => { const p = points[i]; 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 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 // 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 * // scale). The hover/entrance tweens only change the scale, so the hit // 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 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( 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' ) ) @@ -169,10 +170,10 @@ export default class LevelSelectScene extends Phaser.Scene { } else { node .setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest }) - .on('pointerover', () => this._tweenNodeSize(node, dw * NODE_HOVER, dh * NODE_HOVER)) - .on('pointerout', () => this._tweenNodeSize(node, dw, dh)) + .on('pointerover', () => this._tweenNodeSize(node, nw * NODE_HOVER, nh * NODE_HOVER)) + .on('pointerout', () => this._tweenNodeSize(node, nw, nh)) .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.scene.stop('LevelSelect'); 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!'; else if (state === 'current') label = level.name; 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.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' }); this._groups.nodes.forEach((node, i) => { - // Pop in to the resting DISPLAY size - never scale 1: the texture is a - // big mostly-transparent frame with the badge centered (the shipped 2x - // art is 1920x1080), so full scale renders it enormous. Animating - // display sizes keeps this correct for any frame size, like every other - // node tween in this scene. + // Pop in to the resting DISPLAY size - never scale 1 (see the per-key + // sizes in mapArt.js); animating display sizes keeps this correct for + // any texture frame size, like every other node tween in this scene. + const { width: dw, height: dh } = nodeDisplaySize(node.texture.key); node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0); this.tweens.add({ targets: node, @@ -227,7 +227,7 @@ export default class LevelSelectScene extends Phaser.Scene { if (this._pulseTween) this._pulseTween.stop(); const node = this._groups.nodes[index]; 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 // of whatever mid-animation scale the node happens to have when this is // added. diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index f2682eb..204c23e 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 }, // 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_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_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: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa }, ]; diff --git a/src/util/mapArt.js b/src/util/mapArt.js index 58673f8..20754a5 100644 --- a/src/util/mapArt.js +++ b/src/util/mapArt.js @@ -1,12 +1,9 @@ // Shared map geometry for the LevelSelect map nodes + path ribbon. // -// Placeholder art for the map UI is generated on 960x540 canvases with the -// sprite centered (the generator in /tmp, documented in sprites.md). The code -// below displays those textures full-frame at a node's position and reads the -// badge size back from the frame size, so it works unchanged when real art -// 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). +// Most map UI art is generated on 960x540 canvases with the sprite centered +// (documented in sprites.md). The node textures follow that convention EXCEPT +// map_node_current, which ships as a 512x512 badge image with no padding frame +// (see NODE_ART_FRAMES below). 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. @@ -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. export const BADGE_FRAC_OF_FRAME = 0.47; -// Fraction of the texture file's width spanned by the badge's ink. The node -// art is a ~450px badge centered in a 1920x1080 file (the 2x export of the -// 960x540 design canvas - see sprites.md); measured on the shipped -// map_node*.png, ink spans ~459-466 of the 1920 file width. Re-measure if -// the art changes. -export const BADGE_FILE_FRAC = 0.24; +// Fraction of the texture file's width spanned by the badge's ink, PER NODE +// STATE. map_node/map_node_locked are 1920x1080 frames with the ~450px badge +// centered, so ink spans ~0.24 of the file width (measured on the shipped +// art). map_node_current is a 512x512 image that IS the badge, so its ink +// spans the whole file. Re-measure an entry if that file's art changes. +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 // generous so clicks near the edge feel natural without reaching the road. @@ -34,9 +35,20 @@ export function frameAnchorDisplayWidth() { return GAME_WIDTH; } -export function nodeDisplaySize() { - const s = GAME_WIDTH * BADGE_FRAC_OF_FRAME; - return { width: s, height: s * (540 / 960) }; +// Source pixel dimensions of each node texture. The old 960x540 frames were +// 2x exports; the current map_node_current is a tight 512x512 badge. Used to +// 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) {