refactor: sync hardened player surfaces and runtime atlas
This commit is contained in:
parent
fd01d9aa51
commit
8a4efc758b
@ -1,9 +1,9 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm install --no-audit --no-fund
|
||||
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
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
46
README.md
46
README.md
@ -1,23 +1,33 @@
|
||||
# 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.
|
||||
- Run and jump across roots and branches.
|
||||
- Collect acorns while searching for five unusual mushrooms.
|
||||
- Pass through named woodland areas with ferns, flowers, frogs and butterflies.
|
||||
- Find all five forest caps to awaken the Moonlit Grove.
|
||||
- Follow the fireflies to complete the prototype trail.
|
||||
|
||||
Everything in the first prototype uses Phaser-generated textures, so there are no external art dependencies yet.
|
||||
- Side-scrolling exploration across five named woodland areas.
|
||||
- Animated squirrel with separate visual and physics bodies.
|
||||
- Keyboard and multi-touch movement/jumping.
|
||||
- One-way log platforms: jump through from below and land from above.
|
||||
- Acorn collection and five mushroom discoveries.
|
||||
- Ambient frogs, hedgehogs, butterflies and fireflies.
|
||||
- Moonlit Grove completion sequence.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Move:** A/D or Left/Right
|
||||
- **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
|
||||
|
||||
@ -34,13 +44,13 @@ npm run build
|
||||
|
||||
## 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:
|
||||
|
||||
1. replace generated graphics with a hand-painted squirrel animation set;
|
||||
2. add climbable trunks and branch-to-branch movement;
|
||||
3. introduce a field-guide journal for discovered plants, fungi and animals;
|
||||
4. split the forest into richer biome pockets such as birch grove, damp hollow, old oak canopy and moonlit marsh;
|
||||
5. add tiny NPC animals and lightweight quests;
|
||||
6. add ambient audio, leaf particles, weather and a day/night cycle.
|
||||
1. climbable trunks and branch-to-branch movement;
|
||||
2. 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. lightweight animal NPC quests;
|
||||
5. acorn throwing / interaction using the existing player frames;
|
||||
6. re-enable audio behind a user gesture once the sound pass begins.
|
||||
|
||||
@ -10,7 +10,10 @@
|
||||
<div id="game"></div>
|
||||
<script>
|
||||
(() => {
|
||||
window.__squirrelBooted = false;
|
||||
|
||||
const showBootError = (message) => {
|
||||
if (window.__squirrelBooted) return;
|
||||
if (document.getElementById('boot-error')) return;
|
||||
const box = document.createElement('div');
|
||||
box.id = 'boot-error';
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.90.0"
|
||||
"phaser": "3.90.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.1.7"
|
||||
"vite": "7.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
99
src/PlayerController.js
Normal file
99
src/PlayerController.js
Normal 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);
|
||||
}
|
||||
}
|
||||
349
src/main.js
349
src/main.js
@ -1,18 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import './style.css';
|
||||
|
||||
const WORLD_WIDTH = 5600;
|
||||
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;
|
||||
import { PlayerController } from './PlayerController.js';
|
||||
import { WORLD, GROUND_ART, BRANCH_POSITIONS, getLogGeometry } from './worldConfig.js';
|
||||
|
||||
class ForestScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
@ -22,8 +11,6 @@ class ForestScene extends Phaser.Scene {
|
||||
this.totalMushrooms = 5;
|
||||
this.touch = { left: false, right: false };
|
||||
this.touchPointers = { left: new Set(), right: new Set() };
|
||||
this.wasGrounded = false;
|
||||
this.landing = false;
|
||||
}
|
||||
|
||||
preload() {
|
||||
@ -54,18 +41,18 @@ class ForestScene extends Phaser.Scene {
|
||||
frameWidth: 280,
|
||||
frameHeight: 220
|
||||
});
|
||||
this.load.image('flora-sheet', '/assets/flora-sheet.png');
|
||||
this.load.image('fauna-sheet', '/assets/fauna-sheet.png');
|
||||
this.load.image('terrain-sheet', '/assets/terrain-sheet.png');
|
||||
this.load.atlas('world-atlas', '/assets/world-atlas.png', '/assets/world-atlas.json');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.physics.world.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
|
||||
this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
|
||||
window.__squirrelBooted = true;
|
||||
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.createTextures();
|
||||
this.createArtFrames();
|
||||
this.createAnimations();
|
||||
this.createBackdrop();
|
||||
this.createWorld();
|
||||
@ -79,9 +66,19 @@ class ForestScene extends Phaser.Scene {
|
||||
this.cameras.main.setDeadzone(180, 110);
|
||||
this.cameras.main.fadeIn(700, 16, 37, 29);
|
||||
|
||||
this.physics.add.collider(this.player, this.platforms);
|
||||
this.physics.add.collider(this.acornGroup, this.platforms);
|
||||
this.physics.add.collider(this.mushroomGroup, this.platforms);
|
||||
this.physics.add.collider(this.player, this.groundPlatforms);
|
||||
this.physics.add.collider(
|
||||
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) => {
|
||||
acorn.disableBody(true, true);
|
||||
@ -110,49 +107,12 @@ class ForestScene extends Phaser.Scene {
|
||||
this.physics.add.overlap(this.player, this.groveTrigger, () => {
|
||||
if (this.mushrooms < this.totalMushrooms || this.finished) return;
|
||||
this.finished = true;
|
||||
this.player.setVelocityX(0);
|
||||
this.playerVisual.setTint(0xfff2bd);
|
||||
this.playerController.finish();
|
||||
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() {
|
||||
const frames = (texture, names) => names.map(frame => ({ key: texture, frame }));
|
||||
|
||||
@ -193,7 +153,7 @@ class ForestScene extends Phaser.Scene {
|
||||
|
||||
this.anims.create({
|
||||
key: 'frog-hop',
|
||||
frames: frames('fauna-sheet', ['frog0', 'frog1', 'frog2', 'frog3', 'frog4']),
|
||||
frames: frames('world-atlas', ['frog0', 'frog1', 'frog2', 'frog3', 'frog4']),
|
||||
frameRate: 7,
|
||||
repeat: -1,
|
||||
repeatDelay: 1400
|
||||
@ -201,7 +161,7 @@ class ForestScene extends Phaser.Scene {
|
||||
|
||||
this.anims.create({
|
||||
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,
|
||||
repeat: -1
|
||||
});
|
||||
@ -210,95 +170,10 @@ class ForestScene extends Phaser.Scene {
|
||||
createTextures() {
|
||||
const g = this.make.graphics({ add: false });
|
||||
|
||||
g.clear();
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillRect(0, 0, 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.fillStyle(0x6c4429, 1);
|
||||
g.fillRoundedRect(32, 0, 28, 120, 10);
|
||||
@ -312,13 +187,6 @@ class ForestScene extends Phaser.Scene {
|
||||
g.fillCircle(55, 50, 28);
|
||||
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.fillStyle(0xb4d9f2, 0.9);
|
||||
g.fillEllipse(8, 8, 12, 7);
|
||||
@ -334,117 +202,87 @@ class ForestScene extends Phaser.Scene {
|
||||
g.fillCircle(6, 6, 6);
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
const mist = this.add.graphics().setScrollFactor(0.22);
|
||||
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);
|
||||
}
|
||||
|
||||
const canopy = this.add.graphics().setScrollFactor(0.35);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// In ground-main the continuous walkable grass line is 20 source pixels
|
||||
// below the cropped frame top.
|
||||
const ground = this.add.rectangle(
|
||||
WORLD_WIDTH / 2,
|
||||
GROUND_SURFACE_Y + 50,
|
||||
WORLD_WIDTH,
|
||||
WORLD.width / 2,
|
||||
WORLD.groundSurfaceY + 50,
|
||||
WORLD.width,
|
||||
100,
|
||||
0x263c2d,
|
||||
0
|
||||
);
|
||||
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 groundTileWidth = 285 * GROUND_ART_SCALE;
|
||||
const groundTileStep = groundTileWidth - 3; // tiny overlap avoids antialias hairlines
|
||||
for (let x = groundTileWidth / 2; x < WORLD_WIDTH + groundTileWidth; x += groundTileStep) {
|
||||
this.add.image(x, groundArtY, 'terrain-sheet', 'ground-repeat')
|
||||
const groundArtY = WORLD.groundSurfaceY - (GROUND_ART.surfaceOffset * GROUND_ART.scale);
|
||||
const groundTileWidth = GROUND_ART.repeatFrameWidth * GROUND_ART.scale;
|
||||
const groundTileStep = groundTileWidth - GROUND_ART.overlap; // tiny overlap avoids antialias hairlines
|
||||
for (let x = groundTileWidth / 2; x < WORLD.width + groundTileWidth; x += groundTileStep) {
|
||||
this.add.image(x, groundArtY, 'world-atlas', 'ground-repeat')
|
||||
.setOrigin(0.5, 0)
|
||||
.setScale(GROUND_ART_SCALE)
|
||||
.setScale(GROUND_ART.scale)
|
||||
.setDepth(3);
|
||||
}
|
||||
|
||||
const branchPositions = [
|
||||
[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) => {
|
||||
BRANCH_POSITIONS.forEach(([x, surfaceY], index) => {
|
||||
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)
|
||||
.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(
|
||||
x + surfaceCenterOffset,
|
||||
surfaceY + (LOG_COLLIDER_HEIGHT / 2),
|
||||
surfaceWidth,
|
||||
LOG_COLLIDER_HEIGHT,
|
||||
geometry.colliderX,
|
||||
geometry.colliderY,
|
||||
geometry.colliderWidth,
|
||||
geometry.colliderHeight,
|
||||
0x000000,
|
||||
0
|
||||
);
|
||||
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);
|
||||
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)
|
||||
.setScale(0.2 + ((x % 90) / 900))
|
||||
.setAlpha(0.96)
|
||||
@ -452,14 +290,14 @@ class ForestScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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)
|
||||
.setScale(0.2)
|
||||
.setDepth(5);
|
||||
}
|
||||
|
||||
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)
|
||||
.setScale(0.34)
|
||||
.setDepth(4);
|
||||
@ -491,28 +329,20 @@ class ForestScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
createPlayer() {
|
||||
// Keep physics independent from animation artwork. The body is a simple,
|
||||
// stable capsule-like rectangle while the squirrel art follows it.
|
||||
this.player = this.physics.add.sprite(160, 520, 'player-body');
|
||||
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();
|
||||
this.playerController = new PlayerController(this);
|
||||
this.player = this.playerController.create(160, 520);
|
||||
this.playerVisual = this.playerController.visual;
|
||||
}
|
||||
|
||||
syncPlayerVisual() {
|
||||
if (!this.player?.body || !this.playerVisual) return;
|
||||
this.playerVisual.setPosition(this.player.x, this.player.body.bottom);
|
||||
shouldCollideWithOneWayPlatform(player, platform) {
|
||||
if (!player?.body || !platform?.body) return false;
|
||||
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() {
|
||||
@ -524,7 +354,7 @@ class ForestScene extends Phaser.Scene {
|
||||
[3370, 345], [3610, 555], [3800, 440], [4050, 555], [4230, 365],
|
||||
[4470, 555], [4680, 460], [4910, 555], [5100, 370]
|
||||
].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.setBounce(0.2);
|
||||
acorn.body.setSize(38, 48, true);
|
||||
@ -541,7 +371,7 @@ class ForestScene extends Phaser.Scene {
|
||||
];
|
||||
|
||||
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;
|
||||
mushroom.setScale(scale);
|
||||
mushroom.setData('name', name);
|
||||
@ -578,7 +408,7 @@ class ForestScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
[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)
|
||||
.setScale(0.32)
|
||||
.setDepth(7);
|
||||
@ -587,7 +417,7 @@ class ForestScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
[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)
|
||||
.setScale(0.34)
|
||||
.setDepth(7)
|
||||
@ -736,11 +566,7 @@ class ForestScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
tryJump() {
|
||||
if (!this.player?.body) return;
|
||||
if (this.player.body.blocked.down || this.player.body.touching.down) {
|
||||
this.player.setVelocityY(-520);
|
||||
this.playerVisual.play('squirrel-jump', true);
|
||||
}
|
||||
this.playerController?.tryJump();
|
||||
}
|
||||
|
||||
showDiscovery(name) {
|
||||
@ -808,45 +634,16 @@ class ForestScene extends Phaser.Scene {
|
||||
|
||||
if (left && !right) {
|
||||
this.player.setAccelerationX(-1050);
|
||||
this.playerVisual.setFlipX(true);
|
||||
this.playerController.setFacing(-1);
|
||||
} else if (right && !left) {
|
||||
this.player.setAccelerationX(1050);
|
||||
this.playerVisual.setFlipX(false);
|
||||
this.playerController.setFacing(1);
|
||||
} else {
|
||||
this.player.setAccelerationX(0);
|
||||
}
|
||||
|
||||
const grounded = this.player.body.blocked.down || this.player.body.touching.down;
|
||||
|
||||
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.playerController.updateAnimation(grounded);
|
||||
|
||||
this.acornGroup.children.iterate(acorn => {
|
||||
if (!acorn?.active) return;
|
||||
|
||||
53
src/worldConfig.js
Normal file
53
src/worldConfig.js
Normal 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
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user