refactor: sync hardened player surfaces and runtime atlas

This commit is contained in:
Admin 2026-09-19 22:37:02 +00:00
parent fd01d9aa51
commit 8a4efc758b
7 changed files with 260 additions and 298 deletions

View File

@ -1,9 +1,9 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN rm -f public/assets/terrain-sheet.png public/assets/flora-sheet.png public/assets/fauna-sheet.png public/assets/player-sheet.png && npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@ -1,23 +1,33 @@
# Squirrel Forest # Squirrel Forest
A small 2D adventure-platformer prototype about a squirrel exploring a living woodland full of unusual flora, fauna and mushrooms. A 2D Phaser adventure-platformer about a squirrel exploring a living woodland full of flora, fauna and unusual mushrooms.
## Prototype loop ## Current prototype
- Explore a continuous side-scrolling forest. - Side-scrolling exploration across five named woodland areas.
- Run and jump across roots and branches. - Animated squirrel with separate visual and physics bodies.
- Collect acorns while searching for five unusual mushrooms. - Keyboard and multi-touch movement/jumping.
- Pass through named woodland areas with ferns, flowers, frogs and butterflies. - One-way log platforms: jump through from below and land from above.
- Find all five forest caps to awaken the Moonlit Grove. - Acorn collection and five mushroom discoveries.
- Follow the fireflies to complete the prototype trail. - Ambient frogs, hedgehogs, butterflies and fireflies.
- Moonlit Grove completion sequence.
Everything in the first prototype uses Phaser-generated textures, so there are no external art dependencies yet.
## Controls ## Controls
- **Move:** A/D or Left/Right - **Move:** A/D or Left/Right
- **Jump:** W, Up or Space - **Jump:** W, Up or Space
- **Touch:** on-screen left/right/jump controls - **Touch:** independent left/right and jump controls
## Structure
- `src/main.js` — scene orchestration, world content, HUD and input.
- `src/PlayerController.js` — squirrel physics/visual synchronization and animation state.
- `src/worldConfig.js` — shared world/surface geometry and platform metadata.
- `public/assets/player-game-sheet.png` — normalized squirrel animation sheet.
- `public/assets/world-atlas.png/json` — optimized runtime atlas for terrain, flora and fauna.
- `source-assets/` — preserved original source art sheets.
The original large public source sheets are retained in Git for now, but the production Docker build removes them before Vite runs so they are not shipped to the browser.
## Run ## Run
@ -34,13 +44,13 @@ npm run build
## Direction ## Direction
The intended feel is a cosy exploratory woodland adventure: dense layers of forest, lots of discoverable species, small traversal secrets and environmental storytelling rather than combat-first progression. The intended feel is a cosy exploratory woodland adventure focused on traversal, discovery, environmental storytelling and small animal interactions rather than combat-first progression.
Good next slices: Good next slices:
1. replace generated graphics with a hand-painted squirrel animation set; 1. climbable trunks and branch-to-branch movement;
2. add climbable trunks and branch-to-branch movement; 2. field-guide journal for discovered plants, fungi and animals;
3. introduce a field-guide journal for discovered plants, fungi and animals; 3. richer biome pockets such as birch grove, damp hollow, old oak canopy and moonlit marsh;
4. split the forest into richer biome pockets such as birch grove, damp hollow, old oak canopy and moonlit marsh; 4. lightweight animal NPC quests;
5. add tiny NPC animals and lightweight quests; 5. acorn throwing / interaction using the existing player frames;
6. add ambient audio, leaf particles, weather and a day/night cycle. 6. re-enable audio behind a user gesture once the sound pass begins.

View File

@ -10,7 +10,10 @@
<div id="game"></div> <div id="game"></div>
<script> <script>
(() => { (() => {
window.__squirrelBooted = false;
const showBootError = (message) => { const showBootError = (message) => {
if (window.__squirrelBooted) return;
if (document.getElementById('boot-error')) return; if (document.getElementById('boot-error')) return;
const box = document.createElement('div'); const box = document.createElement('div');
box.id = 'boot-error'; box.id = 'boot-error';

View File

@ -9,9 +9,9 @@
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"
}, },
"dependencies": { "dependencies": {
"phaser": "^3.90.0" "phaser": "3.90.0"
}, },
"devDependencies": { "devDependencies": {
"vite": "^7.1.7" "vite": "7.3.6"
} }
} }

99
src/PlayerController.js Normal file
View File

@ -0,0 +1,99 @@
import Phaser from 'phaser';
export class PlayerController {
constructor(scene) {
this.scene = scene;
this.bodySprite = null;
this.visual = null;
this.wasGrounded = false;
this.landing = false;
}
create(x, y) {
this.bodySprite = this.scene.physics.add.sprite(x, y, 'player-body');
this.bodySprite.setAlpha(0);
this.bodySprite.setCollideWorldBounds(true);
this.bodySprite.setDragX(900);
this.bodySprite.setMaxVelocity(330, 760);
this.bodySprite.body.setSize(40, 68, true);
this.visual = this.scene.add.sprite(x, y, 'player-game', 0);
this.visual.setScale(0.42);
this.visual.setOrigin(0.5, 209 / 220);
this.visual.setDepth(12);
this.visual.play('squirrel-idle');
this.syncVisual();
return this.bodySprite;
}
setFacing(direction) {
if (!this.visual) return;
this.visual.setFlipX(direction < 0);
}
tryJump() {
if (!this.bodySprite?.body) return;
if (this.bodySprite.body.blocked.down || this.bodySprite.body.touching.down) {
this.bodySprite.setVelocityY(-520);
this.landing = false;
this.visual.play('squirrel-jump', true);
}
}
updateAnimation(grounded) {
if (!this.bodySprite?.body || !this.visual) return;
if (grounded && !this.wasGrounded) {
this.landing = true;
this.visual.play('squirrel-land', true);
this.visual.once('animationcomplete-squirrel-land', () => {
this.landing = false;
});
}
if (grounded) {
this.visual.setAngle(0);
if (!this.landing) {
if (Math.abs(this.bodySprite.body.velocity.x) > 35) {
this.visual.play('squirrel-run', true);
} else {
this.visual.play('squirrel-idle', true);
}
}
} else if (this.bodySprite.body.velocity.y < 0) {
this.landing = false;
this.visual.play('squirrel-jump', true);
this.visual.setAngle(
Phaser.Math.Clamp(this.bodySprite.body.velocity.x * 0.012, -5, 5)
);
} else {
this.landing = false;
this.visual.play('squirrel-fall', true);
this.visual.setAngle(
Phaser.Math.Clamp(this.bodySprite.body.velocity.x * 0.012, -5, 5)
);
}
this.wasGrounded = grounded;
this.syncVisual();
}
syncVisual() {
if (!this.bodySprite?.body || !this.visual) return;
this.visual.setPosition(this.bodySprite.x, this.bodySprite.body.bottom);
}
finish() {
if (!this.bodySprite?.body) return;
this.syncVisual();
this.bodySprite.setAcceleration(0, 0);
this.bodySprite.setVelocity(0, 0);
this.bodySprite.body.allowGravity = false;
this.bodySprite.body.enable = false;
this.visual.setAngle(0);
this.visual.setTint(0xfff2bd);
}
}

View File

@ -1,18 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import './style.css'; import './style.css';
import { PlayerController } from './PlayerController.js';
const WORLD_WIDTH = 5600; import { WORLD, GROUND_ART, BRANCH_POSITIONS, getLogGeometry } from './worldConfig.js';
const WORLD_HEIGHT = 720;
const GROUND_Y = 628;
const GROUND_SURFACE_Y = GROUND_Y - 4;
const GROUND_ART_SCALE = 0.98;
const GROUND_ART_SURFACE_OFFSET = 20;
const LOG_FRAME_HEIGHT = 79;
const LOG_ART_SURFACE_OFFSET = 17;
const LOG_COLLIDER_HEIGHT = 16;
const LOG_SURFACE_LEFT = 15;
const LOG_SURFACE_RIGHT = 266;
const LOG_FRAME_WIDTH = 269;
class ForestScene extends Phaser.Scene { class ForestScene extends Phaser.Scene {
constructor() { constructor() {
@ -22,8 +11,6 @@ class ForestScene extends Phaser.Scene {
this.totalMushrooms = 5; this.totalMushrooms = 5;
this.touch = { left: false, right: false }; this.touch = { left: false, right: false };
this.touchPointers = { left: new Set(), right: new Set() }; this.touchPointers = { left: new Set(), right: new Set() };
this.wasGrounded = false;
this.landing = false;
} }
preload() { preload() {
@ -54,18 +41,18 @@ class ForestScene extends Phaser.Scene {
frameWidth: 280, frameWidth: 280,
frameHeight: 220 frameHeight: 220
}); });
this.load.image('flora-sheet', '/assets/flora-sheet.png'); this.load.atlas('world-atlas', '/assets/world-atlas.png', '/assets/world-atlas.json');
this.load.image('fauna-sheet', '/assets/fauna-sheet.png');
this.load.image('terrain-sheet', '/assets/terrain-sheet.png');
} }
create() { create() {
this.physics.world.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); window.__squirrelBooted = true;
this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); document.getElementById('boot-error')?.remove();
this.physics.world.setBounds(0, 0, WORLD.width, WORLD.height);
this.cameras.main.setBounds(0, 0, WORLD.width, WORLD.height);
this.cameras.main.setBackgroundColor('#8fc7a2'); this.cameras.main.setBackgroundColor('#8fc7a2');
this.createTextures(); this.createTextures();
this.createArtFrames();
this.createAnimations(); this.createAnimations();
this.createBackdrop(); this.createBackdrop();
this.createWorld(); this.createWorld();
@ -79,9 +66,19 @@ class ForestScene extends Phaser.Scene {
this.cameras.main.setDeadzone(180, 110); this.cameras.main.setDeadzone(180, 110);
this.cameras.main.fadeIn(700, 16, 37, 29); this.cameras.main.fadeIn(700, 16, 37, 29);
this.physics.add.collider(this.player, this.platforms); this.physics.add.collider(this.player, this.groundPlatforms);
this.physics.add.collider(this.acornGroup, this.platforms); this.physics.add.collider(
this.physics.add.collider(this.mushroomGroup, this.platforms); this.player,
this.oneWayPlatforms,
undefined,
this.shouldCollideWithOneWayPlatform,
this
);
this.physics.add.collider(this.acornGroup, this.groundPlatforms);
this.physics.add.collider(this.acornGroup, this.oneWayPlatforms);
this.physics.add.collider(this.mushroomGroup, this.groundPlatforms);
this.physics.add.collider(this.mushroomGroup, this.oneWayPlatforms);
this.physics.add.overlap(this.player, this.acornGroup, (_, acorn) => { this.physics.add.overlap(this.player, this.acornGroup, (_, acorn) => {
acorn.disableBody(true, true); acorn.disableBody(true, true);
@ -110,49 +107,12 @@ class ForestScene extends Phaser.Scene {
this.physics.add.overlap(this.player, this.groveTrigger, () => { this.physics.add.overlap(this.player, this.groveTrigger, () => {
if (this.mushrooms < this.totalMushrooms || this.finished) return; if (this.mushrooms < this.totalMushrooms || this.finished) return;
this.finished = true; this.finished = true;
this.player.setVelocityX(0); this.playerController.finish();
this.playerVisual.setTint(0xfff2bd);
this.showEnding(); this.showEnding();
}); });
} }
createArtFrames() {
const addFrame = (textureKey, name, x, y, width, height) => {
this.textures.get(textureKey).add(name, 0, x, y, width, height);
};
addFrame('terrain-sheet', 'ground-main', 24, 32, 341, 107);
// Interior-only repeat crop: removes the standalone tile's visible
// left/right border so adjacent ground pieces join cleanly.
addFrame('terrain-sheet', 'ground-repeat', 52, 32, 285, 107);
addFrame('terrain-sheet', 'log-platform', 21, 327, 269, 79);
addFrame('terrain-sheet', 'root-platform', 1023, 672, 402, 168);
addFrame('flora-sheet', 'bush', 16, 28, 244, 149);
addFrame('flora-sheet', 'fern-cluster', 659, 19, 169, 154);
addFrame('flora-sheet', 'bluebells', 15, 180, 100, 131);
addFrame('flora-sheet', 'mush-red', 14, 321, 168, 200);
addFrame('flora-sheet', 'mush-brown', 388, 328, 195, 195);
addFrame('flora-sheet', 'mush-orange', 560, 335, 117, 184);
addFrame('flora-sheet', 'mush-purple', 685, 331, 114, 188);
addFrame('flora-sheet', 'mush-blue', 1088, 336, 149, 186);
addFrame('flora-sheet', 'acorn-art', 178, 567, 58, 71);
const frogFrames = [
[72, 538, 99, 88], [231, 537, 105, 89], [422, 535, 155, 96],
[594, 505, 169, 95], [845, 547, 118, 79]
];
frogFrames.forEach(([x, y, w, h], i) => addFrame('fauna-sheet', `frog${i}`, x, y, w, h));
const hedgehogFrames = [
[18, 66, 139, 106], [169, 64, 131, 108], [310, 63, 131, 105],
[449, 66, 129, 107], [592, 57, 120, 115], [729, 67, 105, 105]
];
hedgehogFrames.forEach(([x, y, w, h], i) => addFrame('fauna-sheet', `hedgehog${i}`, x, y, w, h));
}
createAnimations() { createAnimations() {
const frames = (texture, names) => names.map(frame => ({ key: texture, frame })); const frames = (texture, names) => names.map(frame => ({ key: texture, frame }));
@ -193,7 +153,7 @@ class ForestScene extends Phaser.Scene {
this.anims.create({ this.anims.create({
key: 'frog-hop', key: 'frog-hop',
frames: frames('fauna-sheet', ['frog0', 'frog1', 'frog2', 'frog3', 'frog4']), frames: frames('world-atlas', ['frog0', 'frog1', 'frog2', 'frog3', 'frog4']),
frameRate: 7, frameRate: 7,
repeat: -1, repeat: -1,
repeatDelay: 1400 repeatDelay: 1400
@ -201,7 +161,7 @@ class ForestScene extends Phaser.Scene {
this.anims.create({ this.anims.create({
key: 'hedgehog-walk', key: 'hedgehog-walk',
frames: frames('fauna-sheet', ['hedgehog0', 'hedgehog1', 'hedgehog2', 'hedgehog3', 'hedgehog4', 'hedgehog5']), frames: frames('world-atlas', ['hedgehog0', 'hedgehog1', 'hedgehog2', 'hedgehog3', 'hedgehog4', 'hedgehog5']),
frameRate: 8, frameRate: 8,
repeat: -1 repeat: -1
}); });
@ -210,95 +170,10 @@ class ForestScene extends Phaser.Scene {
createTextures() { createTextures() {
const g = this.make.graphics({ add: false }); const g = this.make.graphics({ add: false });
g.clear();
g.fillStyle(0xffffff, 1); g.fillStyle(0xffffff, 1);
g.fillRect(0, 0, 44, 72); g.fillRect(0, 0, 44, 72);
g.generateTexture('player-body', 44, 72); g.generateTexture('player-body', 44, 72);
g.clear();
g.fillStyle(0x6e482b, 1);
g.fillEllipse(24, 30, 35, 24);
g.fillStyle(0x9c6840, 1);
g.fillEllipse(30, 25, 28, 20);
g.fillStyle(0x6e482b, 1);
g.fillCircle(42, 20, 10);
g.fillTriangle(39, 13, 43, 3, 47, 14);
g.fillStyle(0xf3d5a0, 1);
g.fillCircle(46, 21, 3);
g.fillStyle(0x2c2018, 1);
g.fillCircle(48, 19, 1.5);
g.fillStyle(0x8c5b35, 1);
g.fillEllipse(9, 25, 22, 38);
g.lineStyle(5, 0x8c5b35, 1);
g.beginPath();
g.arc(7, 20, 16, Math.PI * 0.6, Math.PI * 1.65);
g.strokePath();
g.fillStyle(0x4d321f, 1);
g.fillRect(17, 38, 7, 12);
g.fillRect(33, 38, 7, 12);
g.generateTexture('squirrel', 58, 56);
g.clear();
g.fillStyle(0x8f5a2b, 1);
g.fillEllipse(10, 13, 15, 13);
g.fillStyle(0x4f341c, 1);
g.fillRect(6, 2, 8, 5);
g.generateTexture('acorn', 20, 22);
g.clear();
g.fillStyle(0xf4ddba, 1);
g.fillRoundedRect(12, 14, 7, 18, 3);
g.fillStyle(0xb94846, 1);
g.fillEllipse(16, 14, 28, 17);
g.fillStyle(0xffe8c8, 1);
g.fillCircle(10, 10, 2);
g.fillCircle(18, 8, 2);
g.fillCircle(23, 13, 2);
g.generateTexture('mushroom-red', 32, 34);
g.clear();
g.fillStyle(0xe8dbba, 1);
g.fillRoundedRect(12, 14, 7, 18, 3);
g.fillStyle(0x76528f, 1);
g.fillEllipse(16, 14, 28, 17);
g.fillStyle(0xdcccf0, 1);
g.fillCircle(10, 10, 2);
g.fillCircle(20, 9, 2);
g.generateTexture('mushroom-purple', 32, 34);
g.clear();
g.fillStyle(0xe0cfaa, 1);
g.fillRoundedRect(12, 13, 7, 19, 3);
g.fillStyle(0xe1a53a, 1);
g.fillEllipse(16, 13, 28, 16);
g.fillStyle(0xffefaa, 1);
g.fillCircle(10, 10, 2);
g.fillCircle(21, 10, 2);
g.generateTexture('mushroom-gold', 32, 34);
g.clear();
g.fillStyle(0x35684b, 1);
for (let i = 0; i < 5; i++) {
g.fillEllipse(17 + i * 2, 22 - i * 4, 9, 25);
}
g.fillStyle(0x6aa56d, 1);
for (let i = 0; i < 4; i++) {
g.fillEllipse(12 + i * 6, 25 - (i % 2) * 6, 7, 19);
}
g.generateTexture('fern', 42, 44);
g.clear();
g.fillStyle(0xe8b7c7, 1);
for (let i = 0; i < 5; i++) {
const a = (Math.PI * 2 * i) / 5;
g.fillCircle(9 + Math.cos(a) * 5, 9 + Math.sin(a) * 5, 4);
}
g.fillStyle(0xf5d067, 1);
g.fillCircle(9, 9, 3);
g.fillStyle(0x477654, 1);
g.fillRect(8, 14, 2, 14);
g.generateTexture('flower', 18, 30);
g.clear(); g.clear();
g.fillStyle(0x6c4429, 1); g.fillStyle(0x6c4429, 1);
g.fillRoundedRect(32, 0, 28, 120, 10); g.fillRoundedRect(32, 0, 28, 120, 10);
@ -312,13 +187,6 @@ class ForestScene extends Phaser.Scene {
g.fillCircle(55, 50, 28); g.fillCircle(55, 50, 28);
g.generateTexture('tree', 92, 120); g.generateTexture('tree', 92, 120);
g.clear();
g.fillStyle(0x393024, 1);
g.fillRoundedRect(0, 0, 140, 22, 10);
g.fillStyle(0x567645, 1);
g.fillRect(0, 0, 140, 7);
g.generateTexture('branch', 140, 22);
g.clear(); g.clear();
g.fillStyle(0xb4d9f2, 0.9); g.fillStyle(0xb4d9f2, 0.9);
g.fillEllipse(8, 8, 12, 7); g.fillEllipse(8, 8, 12, 7);
@ -334,117 +202,87 @@ class ForestScene extends Phaser.Scene {
g.fillCircle(6, 6, 6); g.fillCircle(6, 6, 6);
g.generateTexture('firefly', 12, 12); g.generateTexture('firefly', 12, 12);
g.clear();
g.fillStyle(0x517a4e, 1);
g.fillEllipse(15, 16, 24, 16);
g.fillStyle(0x75995b, 1);
g.fillCircle(18, 10, 8);
g.fillStyle(0x1e2c22, 1);
g.fillCircle(15, 8, 1.5);
g.fillCircle(21, 8, 1.5);
g.generateTexture('frog', 30, 24);
g.destroy(); g.destroy();
} }
createBackdrop() { createBackdrop() {
this.add.rectangle(WORLD_WIDTH / 2, 270, WORLD_WIDTH, 540, 0x8fc7a2).setScrollFactor(0.05); this.add.rectangle(WORLD.width / 2, 270, WORLD.width, 540, 0x8fc7a2).setScrollFactor(0.05);
const far = this.add.graphics().setScrollFactor(0.12); const far = this.add.graphics().setScrollFactor(0.12);
far.fillStyle(0x719f78, 1); far.fillStyle(0x719f78, 1);
for (let x = -100; x < WORLD_WIDTH + 300; x += 170) { for (let x = -100; x < WORLD.width + 300; x += 170) {
far.fillCircle(x, 350 + (x % 3) * 8, 180); far.fillCircle(x, 350 + (x % 3) * 8, 180);
} }
const mist = this.add.graphics().setScrollFactor(0.22); const mist = this.add.graphics().setScrollFactor(0.22);
mist.fillStyle(0xd9ead6, 0.28); mist.fillStyle(0xd9ead6, 0.28);
for (let x = 0; x < WORLD_WIDTH; x += 500) { for (let x = 0; x < WORLD.width; x += 500) {
mist.fillEllipse(x, 480 + (x % 2) * 20, 520, 120); mist.fillEllipse(x, 480 + (x % 2) * 20, 520, 120);
} }
const canopy = this.add.graphics().setScrollFactor(0.35); const canopy = this.add.graphics().setScrollFactor(0.35);
canopy.fillStyle(0x315f45, 0.95); canopy.fillStyle(0x315f45, 0.95);
for (let x = -120; x < WORLD_WIDTH + 300; x += 150) { for (let x = -120; x < WORLD.width + 300; x += 150) {
canopy.fillCircle(x, 140 + ((x / 150) % 3) * 28, 110); canopy.fillCircle(x, 140 + ((x / 150) % 3) * 28, 110);
} }
for (let x = 200; x < WORLD_WIDTH; x += 720) { for (let x = 200; x < WORLD.width; x += 720) {
this.add.image(x, 455, 'tree').setScale(2.5).setAlpha(0.58).setScrollFactor(0.55).setOrigin(0.5, 1); this.add.image(x, 455, 'tree').setScale(2.5).setAlpha(0.58).setScrollFactor(0.55).setOrigin(0.5, 1);
} }
} }
createWorld() { createWorld() {
this.platforms = this.physics.add.staticGroup(); this.groundPlatforms = this.physics.add.staticGroup();
this.oneWayPlatforms = this.physics.add.staticGroup();
// Keep the physics plane and the illustrated grass line on the exact same Y. // Keep the physics plane and the illustrated grass line on the exact same Y.
// In ground-main the continuous walkable grass line is 20 source pixels // In ground-main the continuous walkable grass line is 20 source pixels
// below the cropped frame top. // below the cropped frame top.
const ground = this.add.rectangle( const ground = this.add.rectangle(
WORLD_WIDTH / 2, WORLD.width / 2,
GROUND_SURFACE_Y + 50, WORLD.groundSurfaceY + 50,
WORLD_WIDTH, WORLD.width,
100, 100,
0x263c2d, 0x263c2d,
0 0
); );
this.physics.add.existing(ground, true); this.physics.add.existing(ground, true);
this.platforms.add(ground); this.groundPlatforms.add(ground);
const groundArtY = GROUND_SURFACE_Y - (GROUND_ART_SURFACE_OFFSET * GROUND_ART_SCALE); const groundArtY = WORLD.groundSurfaceY - (GROUND_ART.surfaceOffset * GROUND_ART.scale);
const groundTileWidth = 285 * GROUND_ART_SCALE; const groundTileWidth = GROUND_ART.repeatFrameWidth * GROUND_ART.scale;
const groundTileStep = groundTileWidth - 3; // tiny overlap avoids antialias hairlines const groundTileStep = groundTileWidth - GROUND_ART.overlap; // tiny overlap avoids antialias hairlines
for (let x = groundTileWidth / 2; x < WORLD_WIDTH + groundTileWidth; x += groundTileStep) { for (let x = groundTileWidth / 2; x < WORLD.width + groundTileWidth; x += groundTileStep) {
this.add.image(x, groundArtY, 'terrain-sheet', 'ground-repeat') this.add.image(x, groundArtY, 'world-atlas', 'ground-repeat')
.setOrigin(0.5, 0) .setOrigin(0.5, 0)
.setScale(GROUND_ART_SCALE) .setScale(GROUND_ART.scale)
.setDepth(3); .setDepth(3);
} }
const branchPositions = [ BRANCH_POSITIONS.forEach(([x, surfaceY], index) => {
[500, 545], [720, 505], [900, 465],
[1260, 545], [1480, 505], [1680, 465],
[2050, 545], [2260, 505], [2470, 465],
[2900, 545], [3140, 505], [3370, 465],
[3800, 545], [4010, 505], [4230, 465],
[4680, 545], [4890, 505], [5100, 465]
];
// branchPositions store the intended walkable surface Y, not the
// collider centre. The illustrated log's usable top edge is 17 source
// pixels below its cropped frame top, so derive art + physics from the
// same surface coordinate.
branchPositions.forEach(([x, surfaceY], index) => {
const scale = index % 3 === 0 ? 0.62 : 0.55; const scale = index % 3 === 0 ? 0.62 : 0.55;
const visualY = surfaceY + ((LOG_FRAME_HEIGHT / 2 - LOG_ART_SURFACE_OFFSET) * scale); const geometry = getLogGeometry(x, surfaceY, scale);
const visual = this.add.image(x, visualY, 'terrain-sheet', 'log-platform') this.add.image(x, geometry.visualY, 'world-atlas', 'log-platform')
.setScale(scale) .setScale(scale)
.setDepth(6); .setDepth(6);
const surfaceWidth = (LOG_SURFACE_RIGHT - LOG_SURFACE_LEFT) * scale;
// The visible moss cap is biased left relative to the cropped log frame.
// Keep the corrected surface width, but mirror the centre correction so
// the collider follows the artwork rather than drifting right.
const surfaceCenterOffset = -(
((LOG_SURFACE_LEFT + LOG_SURFACE_RIGHT) / 2) - (LOG_FRAME_WIDTH / 2)
) * scale;
const collider = this.add.rectangle( const collider = this.add.rectangle(
x + surfaceCenterOffset, geometry.colliderX,
surfaceY + (LOG_COLLIDER_HEIGHT / 2), geometry.colliderY,
surfaceWidth, geometry.colliderWidth,
LOG_COLLIDER_HEIGHT, geometry.colliderHeight,
0x000000, 0x000000,
0 0
); );
this.physics.add.existing(collider, true); this.physics.add.existing(collider, true);
this.platforms.add(collider); this.oneWayPlatforms.add(collider);
}); });
for (let x = 120; x < WORLD_WIDTH - 120; x += 180) { for (let x = 120; x < WORLD.width - 120; x += 180) {
const n = Math.sin(x * 0.013) + Math.cos(x * 0.021); const n = Math.sin(x * 0.013) + Math.cos(x * 0.021);
if (n > -0.3) { if (n > -0.3) {
this.add.image(x, GROUND_Y - 2, 'flora-sheet', 'fern-cluster') this.add.image(x, WORLD.groundSurfaceY - 2, 'world-atlas', 'fern-cluster')
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(0.2 + ((x % 90) / 900)) .setScale(0.2 + ((x % 90) / 900))
.setAlpha(0.96) .setAlpha(0.96)
@ -452,14 +290,14 @@ class ForestScene extends Phaser.Scene {
} }
if (x % 540 < 190) { if (x % 540 < 190) {
this.add.image(x + 65, GROUND_Y - 2, 'flora-sheet', 'bluebells') this.add.image(x + 65, WORLD.groundSurfaceY - 2, 'world-atlas', 'bluebells')
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(0.2) .setScale(0.2)
.setDepth(5); .setDepth(5);
} }
if (x % 900 < 190) { if (x % 900 < 190) {
this.add.image(x - 65, GROUND_Y - 3, 'flora-sheet', 'bush') this.add.image(x - 65, WORLD.groundSurfaceY - 3, 'world-atlas', 'bush')
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(0.34) .setScale(0.34)
.setDepth(4); .setDepth(4);
@ -491,28 +329,20 @@ class ForestScene extends Phaser.Scene {
} }
createPlayer() { createPlayer() {
// Keep physics independent from animation artwork. The body is a simple, this.playerController = new PlayerController(this);
// stable capsule-like rectangle while the squirrel art follows it. this.player = this.playerController.create(160, 520);
this.player = this.physics.add.sprite(160, 520, 'player-body'); this.playerVisual = this.playerController.visual;
this.player.setAlpha(0);
this.player.setCollideWorldBounds(true);
this.player.setDragX(900);
this.player.setMaxVelocity(330, 760);
this.player.body.setSize(40, 68, true);
this.playerVisual = this.add.sprite(160, 520, 'player-game', 0);
this.playerVisual.setScale(0.42);
// Every normalized player frame has its paw baseline at source y=209.
// Pin that exact baseline to the physics body's bottom.
this.playerVisual.setOrigin(0.5, 209 / 220);
this.playerVisual.setDepth(12);
this.playerVisual.play('squirrel-idle');
this.syncPlayerVisual();
} }
syncPlayerVisual() { shouldCollideWithOneWayPlatform(player, platform) {
if (!this.player?.body || !this.playerVisual) return; if (!player?.body || !platform?.body) return false;
this.playerVisual.setPosition(this.player.x, this.player.body.bottom); if (player.body.velocity.y < 0) return false;
const previousBottom = player.body.prev?.y !== undefined
? player.body.prev.y + player.body.height
: player.body.bottom;
return previousBottom <= platform.body.top + 8;
} }
createCollectibles() { createCollectibles() {
@ -524,7 +354,7 @@ class ForestScene extends Phaser.Scene {
[3370, 345], [3610, 555], [3800, 440], [4050, 555], [4230, 365], [3370, 345], [3610, 555], [3800, 440], [4050, 555], [4230, 365],
[4470, 555], [4680, 460], [4910, 555], [5100, 370] [4470, 555], [4680, 460], [4910, 555], [5100, 370]
].forEach(([x, y], i) => { ].forEach(([x, y], i) => {
const acorn = this.acornGroup.create(x, y, 'flora-sheet', 'acorn-art'); const acorn = this.acornGroup.create(x, y, 'world-atlas', 'acorn-art');
acorn.setScale(0.62); acorn.setScale(0.62);
acorn.setBounce(0.2); acorn.setBounce(0.2);
acorn.body.setSize(38, 48, true); acorn.body.setSize(38, 48, true);
@ -541,7 +371,7 @@ class ForestScene extends Phaser.Scene {
]; ];
mushrooms.forEach(([x, y, frame, name], index) => { mushrooms.forEach(([x, y, frame, name], index) => {
const mushroom = this.mushroomGroup.create(x, y, 'flora-sheet', frame); const mushroom = this.mushroomGroup.create(x, y, 'world-atlas', frame);
const scale = frame === 'mush-blue' ? 0.3 : 0.28; const scale = frame === 'mush-blue' ? 0.3 : 0.28;
mushroom.setScale(scale); mushroom.setScale(scale);
mushroom.setData('name', name); mushroom.setData('name', name);
@ -578,7 +408,7 @@ class ForestScene extends Phaser.Scene {
}); });
[1120, 2600, 3980].forEach((x, i) => { [1120, 2600, 3980].forEach((x, i) => {
const frog = this.add.sprite(x, GROUND_Y - 2, 'fauna-sheet', 'frog0') const frog = this.add.sprite(x, WORLD.groundSurfaceY - 2, 'world-atlas', 'frog0')
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(0.32) .setScale(0.32)
.setDepth(7); .setDepth(7);
@ -587,7 +417,7 @@ class ForestScene extends Phaser.Scene {
}); });
[1450, 3450].forEach((x, i) => { [1450, 3450].forEach((x, i) => {
const hedgehog = this.add.sprite(x, GROUND_Y - 3, 'fauna-sheet', 'hedgehog0') const hedgehog = this.add.sprite(x, WORLD.groundSurfaceY - 3, 'world-atlas', 'hedgehog0')
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(0.34) .setScale(0.34)
.setDepth(7) .setDepth(7)
@ -736,11 +566,7 @@ class ForestScene extends Phaser.Scene {
} }
tryJump() { tryJump() {
if (!this.player?.body) return; this.playerController?.tryJump();
if (this.player.body.blocked.down || this.player.body.touching.down) {
this.player.setVelocityY(-520);
this.playerVisual.play('squirrel-jump', true);
}
} }
showDiscovery(name) { showDiscovery(name) {
@ -808,45 +634,16 @@ class ForestScene extends Phaser.Scene {
if (left && !right) { if (left && !right) {
this.player.setAccelerationX(-1050); this.player.setAccelerationX(-1050);
this.playerVisual.setFlipX(true); this.playerController.setFacing(-1);
} else if (right && !left) { } else if (right && !left) {
this.player.setAccelerationX(1050); this.player.setAccelerationX(1050);
this.playerVisual.setFlipX(false); this.playerController.setFacing(1);
} else { } else {
this.player.setAccelerationX(0); this.player.setAccelerationX(0);
} }
const grounded = this.player.body.blocked.down || this.player.body.touching.down; const grounded = this.player.body.blocked.down || this.player.body.touching.down;
this.playerController.updateAnimation(grounded);
if (grounded && !this.wasGrounded) {
this.landing = true;
this.playerVisual.play('squirrel-land', true);
this.playerVisual.once('animationcomplete-squirrel-land', () => {
this.landing = false;
});
}
if (grounded) {
this.playerVisual.setAngle(0);
if (!this.landing) {
if (Math.abs(this.player.body.velocity.x) > 35) {
this.playerVisual.play('squirrel-run', true);
} else {
this.playerVisual.play('squirrel-idle', true);
}
}
} else if (this.player.body.velocity.y < 0) {
this.landing = false;
this.player.play('squirrel-jump', true);
this.playerVisual.setAngle(Phaser.Math.Clamp(this.player.body.velocity.x * 0.012, -5, 5));
} else {
this.landing = false;
this.playerVisual.play('squirrel-fall', true);
this.playerVisual.setAngle(Phaser.Math.Clamp(this.player.body.velocity.x * 0.012, -5, 5));
}
this.wasGrounded = grounded;
this.syncPlayerVisual();
this.acornGroup.children.iterate(acorn => { this.acornGroup.children.iterate(acorn => {
if (!acorn?.active) return; if (!acorn?.active) return;

53
src/worldConfig.js Normal file
View File

@ -0,0 +1,53 @@
export const WORLD = {
width: 5600,
height: 720,
groundSurfaceY: 624
};
export const GROUND_ART = {
scale: 0.98,
surfaceOffset: 20,
repeatFrameWidth: 285,
overlap: 3
};
export const LOG_PLATFORM = {
frameWidth: 269,
frameHeight: 79,
surfaceOffsetY: 17,
colliderHeight: 16,
surfaceLeft: 15,
surfaceRight: 266
};
export const BRANCH_POSITIONS = [
[500, 545], [720, 505], [900, 465],
[1260, 545], [1480, 505], [1680, 465],
[2050, 545], [2260, 505], [2470, 465],
[2900, 545], [3140, 505], [3370, 465],
[3800, 545], [4010, 505], [4230, 465],
[4680, 545], [4890, 505], [5100, 465]
];
export function getLogGeometry(x, surfaceY, scale) {
const visualY = surfaceY
+ ((LOG_PLATFORM.frameHeight / 2 - LOG_PLATFORM.surfaceOffsetY) * scale);
const colliderWidth = (
LOG_PLATFORM.surfaceRight - LOG_PLATFORM.surfaceLeft
) * scale;
const surfaceCenterOffset = -(
(
(LOG_PLATFORM.surfaceLeft + LOG_PLATFORM.surfaceRight) / 2
) - (LOG_PLATFORM.frameWidth / 2)
) * scale;
return {
visualY,
colliderX: x + surfaceCenterOffset,
colliderY: surfaceY + (LOG_PLATFORM.colliderHeight / 2),
colliderWidth,
colliderHeight: LOG_PLATFORM.colliderHeight
};
}