Skip to content

Commit 92cacec

Browse files
committed
Add snow precipitation to WeatherLayer
- Add _startSnow / _makeSnowflake methods with wobble animation - Export WanderBehavior and RenderableItem publicly - Bump version to 0.1.2
1 parent bfc8919 commit 92cacec

5 files changed

Lines changed: 191 additions & 4 deletions

File tree

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@caper/engine",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"license": "MIT",
55
"nodeModulesDir": "auto",
66
"exports": {

mod.d.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ export class WeatherLayer {
882882
tint: Phaser.GameObjects.Rectangle | null;
883883
/** @type {Drop[]} */
884884
drops: Drop[];
885+
_snow: boolean;
885886
_heavy: boolean;
886887
/** @type {AmbientMode} */
887888
ambientMode: AmbientMode;
@@ -897,12 +898,20 @@ export class WeatherLayer {
897898
_ensureUpdateLoop(): void;
898899
/** @param {boolean} heavy */
899900
_startRain(heavy: boolean): void;
901+
/** @param {boolean} heavy */
902+
_startSnow(heavy: boolean): void;
900903
/**
901904
* @param {boolean} initial - if true, seed positions across the full
902905
* screen so rain doesn't pop in from the top all at once.
903906
* @returns {Drop}
904907
*/
905908
_makeDrop(initial: boolean): Drop;
909+
/**
910+
* @param {boolean} initial - seed flakes across the screen so snow is
911+
* already falling when the scene appears.
912+
* @returns {Drop}
913+
*/
914+
_makeSnowflake(initial: boolean): Drop;
906915
_startLeaves(): void;
907916
/**
908917
* @param {boolean} initial - seed across the full screen on first build
@@ -923,7 +932,8 @@ export class WeatherLayer {
923932
export type PrecipitationMode = "none" | "light-rain" | "heavy-rain" | "snow" | "heavy-snow";
924933
export type AmbientMode = "none" | "falling-leaves";
925934
/**
926-
* One falling raindrop drawn as a line each frame.
935+
* One falling precipitation particle drawn each frame.
936+
* Rain uses short slanted lines; snow uses small soft dots.
927937
*/
928938
export type Drop = {
929939
x: number;
@@ -932,6 +942,10 @@ export type Drop = {
932942
vy: number;
933943
length: number;
934944
alpha: number;
945+
radius?: number;
946+
wobbleAmp?: number;
947+
wobbleFreq?: number;
948+
wobblePhase?: number;
935949
};
936950
/**
937951
* One falling leaf sprite, sway + spin animated per-frame.
@@ -1603,7 +1617,33 @@ export type WanderHost = Walker & {
16031617
despawn: () => void;
16041618
isSpawned: () => boolean;
16051619
};
1606-
declare class WanderBehavior {
1620+
/**
1621+
* @typedef {object} WanderOptions
1622+
* @property {boolean} [startPresent] - force the initial present/absent roll.
1623+
* @property {{x: number, y: number} | null} [startPos] - when present-on-entry
1624+
* (not from an exit), stand here instead of a random walkable point.
1625+
* @property {number} [presentChance] - chance of being present on attach. Default 1 (always present).
1626+
* @property {[number, number] | null} [walksRange] - wanders before leaving the scene. `null` = perpetual (never leaves).
1627+
* @property {[number, number]} [wanderDelayRange] - ms paused between wanders. Default [3000, 6000].
1628+
* @property {number} [returnInterval] - ms between return-check rolls while absent. Default 18000.
1629+
* @property {number} [returnChance] - chance to return on each check. Default 0.33.
1630+
* @property {{x: number, y: number, w: number, h: number}} [area] - explicit roam bounds. When set, random
1631+
* destinations are picked inside this rect (no walkable snap) — use it when the character's roam zone differs
1632+
* from the player walkable polygon (e.g. a foreground shore strip). Defaults to the scene walkable.
1633+
* @property {boolean} [startAtExit] - spawn from a random scene exit. Default = can-leave.
1634+
* @property {boolean} [walkInOnSpawn] - walk to a random point immediately on spawn (vs pausing first). Default false.
1635+
* @property {string | null} [idleFrame] - texture shown while paused between wanders. Default = host still frame.
1636+
* @property {number} [interruptResumeMs] - ms before the routine resumes after a click greeting. Default 2600.
1637+
* @property {boolean} [autoStart] - run the state machine on construction. Default true.
1638+
*/
1639+
/**
1640+
* The come-and-go wander machine, generalized out of the six controllers that
1641+
* each hand-rolled it. Drives a {@link import("./walker.js").WanderHost} through
1642+
* `wandering` → `leaving` → `absent`, picking random walkable points, and
1643+
* (optionally) leaving the scene after a few walks before checking back on a
1644+
* timer. Knows nothing about sprites except through the host.
1645+
*/
1646+
export class WanderBehavior {
16071647
/**
16081648
* @param {import("./walker.js").WanderHost} host
16091649
* @param {WanderOptions} [opts]
@@ -3555,6 +3595,73 @@ export function buildCutsceneContext(scene: any, cs: Cutscene, present: Map<stri
35553595
* @return {number}
35563596
*/
35573597
export function randomInt(...args: number[]): number;
3598+
/**
3599+
* Shared "renderable item" shape used across a game's item collections (props,
3600+
* equipment, scene-specific objects). Every collection extends this with its own
3601+
* positional / domain
3602+
* fields, but the visual fields are uniform: pick a frame, set a scale,
3603+
* optionally rotate.
3604+
*
3605+
* Pattern for a new collection — declare a typedef that intersects this
3606+
* with the scene-specific fields, e.g.:
3607+
* `import("./itemDef.js").RenderableItem & { x: number, y: number }`
3608+
*/
3609+
export type RenderableItem = {
3610+
/**
3611+
* - registry key
3612+
*/
3613+
id: string;
3614+
/**
3615+
* - atlas frame name. Defaults to `id` if omitted.
3616+
*/
3617+
frame?: string;
3618+
/**
3619+
* - display scale, default 1
3620+
*/
3621+
scale?: number;
3622+
/**
3623+
* - rotation in DEGREES (Phaser setAngle), default 0
3624+
*/
3625+
rotation?: number;
3626+
};
3627+
/**
3628+
* Shared "renderable item" shape used across a game's item collections (props,
3629+
* equipment, scene-specific objects). Every collection extends this with its own
3630+
* positional / domain
3631+
* fields, but the visual fields are uniform: pick a frame, set a scale,
3632+
* optionally rotate.
3633+
*
3634+
* Pattern for a new collection — declare a typedef that intersects this
3635+
* with the scene-specific fields, e.g.:
3636+
* `import("./itemDef.js").RenderableItem & { x: number, y: number }`
3637+
*
3638+
* @typedef {object} RenderableItem
3639+
* @property {string} id - registry key
3640+
* @property {string} [frame] - atlas frame name. Defaults to `id` if omitted.
3641+
* @property {number} [scale] - display scale, default 1
3642+
* @property {number} [rotation] - rotation in DEGREES (Phaser setAngle), default 0
3643+
*/
3644+
/**
3645+
* A `RenderableItem` placed in a scene at a specific position. Add a list
3646+
* of these to `AdventureSceneConfig.propItems` and the base scene renders
3647+
* them automatically (no per-scene loop needed). Subclasses can grab the
3648+
* resulting sprite via `this.propSprites.get(id)` for later manipulation
3649+
* (destroy on pickup, toggle visibility, etc.).
3650+
*
3651+
* @typedef {RenderableItem & {
3652+
* atlas: string,
3653+
* x: number,
3654+
* y: number,
3655+
* depth?: number,
3656+
* flipX?: boolean,
3657+
* origin?: { x?: number, y?: number },
3658+
* shouldRender?: () => boolean,
3659+
* seasons?: string[],
3660+
* hideIfPickedUp?: boolean,
3661+
* }} PropItem
3662+
*/
3663+
/** @type {RenderableItem} */
3664+
export const RenderableItem: RenderableItem;
35583665
/**
35593666
* Engine — content registry (ADR 0005).
35603667
*

mod.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { EngineAssetRegistry, engineAssets } from "./src/assets/EngineAssets.js"
1313
export { FullscreenButton } from "./src/ui/FullscreenButton.js";
1414
export { HotspotManager } from "./src/interaction/HotspotManager.js";
1515
export { IdleCharacter } from "./src/movement/IdleCharacter.js";
16+
export { WanderBehavior } from "./src/movement/behaviors/WanderBehavior.js";
1617
export { InventoryLayer } from "./src/inventory/InventoryLayer.js";
1718
export { NPC } from "./src/cast/NPC.js";
1819
export { NightLayer } from "./src/environment/NightLayer.js";
@@ -52,4 +53,5 @@ export { createAdventureGame } from "./src/scene/createAdventureGame.js";
5253
export { buildCutsceneContext } from "./src/cutscene/cutsceneActor.js";
5354
export { bakeCircularCrop, resolveCharacterPortrait } from "./src/characters/portraits.js";
5455
export { randomInt } from "./src/core/random.js";
56+
export { RenderableItem } from "./src/inventory/itemDef.js";
5557
export { transitionIn, TRANSITIONS, transitionTo } from "./src/scene/transitions.js";

src/environment/WeatherLayer.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { engineAssets } from "../assets/EngineAssets.js";
55
/** @typedef {"none" | "falling-leaves"} AmbientMode */
66

77
/**
8-
* One falling raindrop drawn as a line each frame.
8+
* One falling precipitation particle drawn each frame.
9+
* Rain uses short slanted lines; snow uses small soft dots.
910
* @typedef {Object} Drop
1011
* @property {number} x
1112
* @property {number} y
1213
* @property {number} vx
1314
* @property {number} vy
1415
* @property {number} length
1516
* @property {number} alpha
17+
* @property {number} [radius]
18+
* @property {number} [wobbleAmp]
19+
* @property {number} [wobbleFreq]
20+
* @property {number} [wobblePhase]
1621
*/
1722

1823
/**
@@ -32,6 +37,8 @@ import { engineAssets } from "../assets/EngineAssets.js";
3237

3338
const LIGHT_DROP_COUNT = 110;
3439
const HEAVY_DROP_COUNT = 320;
40+
const LIGHT_SNOW_COUNT = 150;
41+
const HEAVY_SNOW_COUNT = 420;
3542
const LEAF_COUNT = 18;
3643

3744
const WEATHER_DEPTH = 5000;
@@ -64,6 +71,7 @@ export class WeatherLayer {
6471
this.tint = null;
6572
/** @type {Drop[]} */
6673
this.drops = [];
74+
this._snow = false;
6775
this._heavy = false;
6876

6977
// ── Ambient state (falling leaves) ────────────────────────────────
@@ -90,6 +98,8 @@ export class WeatherLayer {
9098
this.weatherMode = mode;
9199
if (mode === "light-rain" || mode === "heavy-rain") {
92100
this._startRain(mode === "heavy-rain");
101+
} else if (mode === "snow" || mode === "heavy-snow") {
102+
this._startSnow(mode === "heavy-snow");
93103
}
94104
this._ensureUpdateLoop();
95105
}
@@ -141,6 +151,28 @@ export class WeatherLayer {
141151
}
142152
}
143153

154+
/** @param {boolean} heavy */
155+
_startSnow(heavy) {
156+
this._heavy = heavy;
157+
this._snow = true;
158+
const count = heavy ? HEAVY_SNOW_COUNT : LIGHT_SNOW_COUNT;
159+
160+
const tintAlpha = heavy ? 0.18 : 0.08;
161+
this.tint = this.scene.add.rectangle(0, 0, this.scene.scale.width, this.scene.scale.height, 0xb8d3ff, tintAlpha)
162+
.setOrigin(0, 0)
163+
.setDepth(WEATHER_DEPTH)
164+
.setScrollFactor(0);
165+
166+
this.gfx = this.scene.add.graphics()
167+
.setDepth(WEATHER_DEPTH + 1)
168+
.setScrollFactor(0);
169+
170+
this.drops = new Array(count);
171+
for (let i = 0; i < count; i++) {
172+
this.drops[i] = this._makeSnowflake(true);
173+
}
174+
}
175+
144176
/**
145177
* @param {boolean} initial - if true, seed positions across the full
146178
* screen so rain doesn't pop in from the top all at once.
@@ -158,6 +190,29 @@ export class WeatherLayer {
158190
return { x, y, vx: speedX, vy: speedY, length, alpha };
159191
}
160192

193+
/**
194+
* @param {boolean} initial - seed flakes across the screen so snow is
195+
* already falling when the scene appears.
196+
* @returns {Drop}
197+
*/
198+
_makeSnowflake(initial) {
199+
const heavy = this._heavy;
200+
const x = Phaser.Math.Between(-80, this.scene.scale.width + 80);
201+
const y = initial ? Phaser.Math.Between(-40, this.scene.scale.height) : Phaser.Math.Between(-160, -20);
202+
return {
203+
x,
204+
y,
205+
vx: Phaser.Math.Between(heavy ? -55 : -35, heavy ? 35 : 25),
206+
vy: Phaser.Math.Between(heavy ? 120 : 65, heavy ? 240 : 130),
207+
length: 0,
208+
alpha: Phaser.Math.FloatBetween(heavy ? 0.65 : 0.45, heavy ? 0.95 : 0.75),
209+
radius: Phaser.Math.FloatBetween(heavy ? 1.5 : 1, heavy ? 3.3 : 2.3),
210+
wobbleAmp: Phaser.Math.FloatBetween(heavy ? 8 : 5, heavy ? 24 : 16),
211+
wobbleFreq: Phaser.Math.FloatBetween(0.8, 1.8),
212+
wobblePhase: Phaser.Math.FloatBetween(0, Math.PI * 2),
213+
};
214+
}
215+
161216
// ─── Falling leaves ──────────────────────────────────────────────────
162217

163218
_startLeaves() {
@@ -230,6 +285,24 @@ export class WeatherLayer {
230285
g.lineTo(drop.x - ux * len, drop.y - uy * len);
231286
g.strokePath();
232287
}
288+
} else if (this.gfx && (this.weatherMode === "snow" || this.weatherMode === "heavy-snow")) {
289+
const g = this.gfx;
290+
g.clear();
291+
for (const flake of this.drops) {
292+
flake.wobblePhase = (flake.wobblePhase ?? 0) + (flake.wobbleFreq ?? 1) * seconds;
293+
flake.x += flake.vx * seconds;
294+
flake.y += flake.vy * seconds;
295+
const wobble = Math.sin(flake.wobblePhase) * (flake.wobbleAmp ?? 0);
296+
if (
297+
flake.y > this.scene.scale.height + 20 ||
298+
flake.x + wobble < -100 ||
299+
flake.x + wobble > this.scene.scale.width + 100
300+
) {
301+
Object.assign(flake, this._makeSnowflake(false));
302+
}
303+
g.fillStyle(0xffffff, flake.alpha);
304+
g.fillCircle(flake.x + wobble, flake.y, flake.radius ?? 2);
305+
}
233306
}
234307

235308
// ── Ambient (falling leaves) ──────────────────────────────────
@@ -267,6 +340,7 @@ export class WeatherLayer {
267340
this.tint = null;
268341
}
269342
this.drops = [];
343+
this._snow = false;
270344
}
271345

272346
/** Tear down ambient only. */

src/inventory/itemDef.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@
3535
* hideIfPickedUp?: boolean,
3636
* }} PropItem
3737
*/
38+
39+
/** @type {RenderableItem} */
40+
export const RenderableItem = null;
41+

0 commit comments

Comments
 (0)