Skip to content

Commit bb1638e

Browse files
committed
feat: implement displacement system for dash begin
1 parent 2b859f9 commit bb1638e

5 files changed

Lines changed: 388 additions & 0 deletions

File tree

src/GameScene.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Phaser from "phaser";
22
import type { AirDashAssist } from "./assists";
33
import { COLORS, PLAYER_CONFIG, VIEWPORT, WORLD } from "./constants";
4+
import { DisplacementSystem } from "./displacement/DisplacementSystem";
45
import { EntityWorld, spikeTriangles } from "./entities/EntityWorld";
56
import { Grid } from "./entities/core/Grid";
67
import { Hitbox } from "./entities/core/Hitbox";
@@ -187,6 +188,7 @@ export class GameScene extends Phaser.Scene {
187188
private refills: RefillView[] = [];
188189
private refillEmitter!: Phaser.GameObjects.Particles.ParticleEmitter;
189190
private lighting!: LightingSystem;
191+
private displacement!: DisplacementSystem;
190192
private deathRespawnSequence: DeathRespawnSequenceState | null = null;
191193
private forceCameraUpdate = false;
192194
private forceCameraSnapNextFrame = true;
@@ -213,6 +215,7 @@ export class GameScene extends Phaser.Scene {
213215
this.spawnWipe = this.add.graphics().setDepth(20).setScrollFactor(0);
214216
this.drawTiles();
215217
this.lighting = new LightingSystem(this, this.world);
218+
this.displacement = new DisplacementSystem(this);
216219

217220
this.player = new Player(this.spawnX, this.spawnY, this.world, PLAYER_CONFIG);
218221
this.player.setAssistOptions(this.gameOptions);
@@ -271,6 +274,7 @@ export class GameScene extends Phaser.Scene {
271274

272275
update(_time: number, delta: number): void {
273276
const rawFrameDt = toFloat(Math.min(delta / 1000, 0.1));
277+
this.displacement.update(rawFrameDt);
274278
this.gameplayEdgesConsumed = false;
275279
if (this.confirmBufferedFrames > 0) {
276280
this.confirmBufferedFrames--;
@@ -405,6 +409,7 @@ export class GameScene extends Phaser.Scene {
405409
if (spike) {
406410
if (this.beginSpikeDeathRespawn(stepSnapshot, spike.dir)) {
407411
this.playerView.tick(stepSnapshot, stepEffects, this.fixedDt);
412+
this.applyDisplacementEffects(stepSnapshot, stepEffects);
408413
effects.push(...stepEffects);
409414
this.accumulator = 0;
410415
steps++;
@@ -414,6 +419,7 @@ export class GameScene extends Phaser.Scene {
414419

415420
let snapshot = this.player.getSnapshot();
416421
this.playerView.tick(snapshot, stepEffects, this.fixedDt);
422+
this.applyDisplacementEffects(snapshot, stepEffects);
417423
effects.push(...stepEffects);
418424

419425
if (this.tryStartRoomTransition(snapshot)) {
@@ -543,6 +549,7 @@ export class GameScene extends Phaser.Scene {
543549
this.spawnWipe?.destroy();
544550
this.refillEmitter?.destroy();
545551
this.lighting?.destroy();
552+
this.displacement?.destroy();
546553
for (const refill of this.refills) {
547554
refill.glow.destroy();
548555
refill.body.destroy();
@@ -913,9 +920,21 @@ export class GameScene extends Phaser.Scene {
913920
const stepSnapshot = this.player.getSnapshot();
914921
const stepEffects = this.player.consumeEffects();
915922
this.playerView.tick(stepSnapshot, stepEffects, dt);
923+
this.applyDisplacementEffects(stepSnapshot, stepEffects);
916924
effects.push(...stepEffects);
917925
}
918926

927+
private applyDisplacementEffects(
928+
snapshot: ReturnType<Player["getSnapshot"]>,
929+
effects: readonly PlayerEffect[],
930+
): void {
931+
for (const effect of effects) {
932+
if (effect.type === "dash_begin") {
933+
this.displacement.addBurst(snapshot.centerX, snapshot.centerY);
934+
}
935+
}
936+
}
937+
919938
private tryStartRoomTransition(snapshot: ReturnType<Player["getSnapshot"]>): boolean {
920939
if (this.roomTransition !== null) {
921940
return true;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Phaser from "phaser";
2+
import {
3+
DisplacementBurstModel,
4+
type ProjectedDisplacementBurst,
5+
projectBurstToPostFx,
6+
} from "./ripple";
7+
8+
const DISPLACEMENT_PIPELINE_KEY = "DashDisplacementPipeline";
9+
const MAX_SHADER_BURSTS = 4;
10+
const NEAREST_RENDER_TARGET_FILTER = 1;
11+
12+
const DASH_DISPLACEMENT_FRAG = `
13+
precision mediump float;
14+
15+
uniform sampler2D uMainSampler;
16+
uniform int uBurstCount;
17+
uniform vec2 uResolution;
18+
uniform vec2 uCenters[${MAX_SHADER_BURSTS}];
19+
uniform float uRadii[${MAX_SHADER_BURSTS}];
20+
uniform float uRingWidths[${MAX_SHADER_BURSTS}];
21+
uniform float uAmplitudes[${MAX_SHADER_BURSTS}];
22+
uniform float uStrengths[${MAX_SHADER_BURSTS}];
23+
24+
varying vec2 outTexCoord;
25+
26+
void main() {
27+
vec2 pixel = outTexCoord * uResolution;
28+
vec2 offset = vec2(0.0);
29+
30+
for (int i = 0; i < ${MAX_SHADER_BURSTS}; i++) {
31+
if (i >= uBurstCount) {
32+
break;
33+
}
34+
35+
vec2 delta = pixel - uCenters[i];
36+
float dist = length(delta);
37+
float ringDistance = abs(dist - uRadii[i]);
38+
float envelope = 1.0 - smoothstep(0.0, uRingWidths[i], ringDistance);
39+
float wave = sin((dist - uRadii[i]) * 1.8) * envelope;
40+
vec2 dir = dist > 0.001 ? delta / dist : vec2(0.0);
41+
offset += dir * wave * uAmplitudes[i] * uStrengths[i];
42+
}
43+
44+
vec2 displacedCoord = clamp(outTexCoord - offset / uResolution, vec2(0.001), vec2(0.999));
45+
gl_FragColor = texture2D(uMainSampler, displacedCoord);
46+
}
47+
`;
48+
49+
export class DashDisplacementPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
50+
private burstCount = 0;
51+
private resolutionX = 1;
52+
private resolutionY = 1;
53+
private readonly centers = new Float32Array(MAX_SHADER_BURSTS * 2);
54+
private readonly radii = new Float32Array(MAX_SHADER_BURSTS);
55+
private readonly ringWidths = new Float32Array(MAX_SHADER_BURSTS);
56+
private readonly amplitudes = new Float32Array(MAX_SHADER_BURSTS);
57+
private readonly strengths = new Float32Array(MAX_SHADER_BURSTS);
58+
59+
constructor(game: Phaser.Game) {
60+
super({
61+
game,
62+
fragShader: DASH_DISPLACEMENT_FRAG,
63+
renderTarget: [{ minFilter: NEAREST_RENDER_TARGET_FILTER, autoResize: true }],
64+
});
65+
}
66+
67+
setBursts(bursts: readonly ProjectedDisplacementBurst[], width: number, height: number): void {
68+
this.burstCount = Math.min(bursts.length, MAX_SHADER_BURSTS);
69+
this.resolutionX = Math.max(1, width);
70+
this.resolutionY = Math.max(1, height);
71+
this.centers.fill(0);
72+
this.radii.fill(0);
73+
this.ringWidths.fill(1);
74+
this.amplitudes.fill(0);
75+
this.strengths.fill(0);
76+
77+
for (let i = 0; i < this.burstCount; i++) {
78+
const burst = bursts[i];
79+
this.centers[i * 2] = burst.screenX;
80+
this.centers[i * 2 + 1] = burst.screenY;
81+
this.radii[i] = burst.radius;
82+
this.ringWidths[i] = burst.ringWidth;
83+
this.amplitudes[i] = burst.amplitude;
84+
this.strengths[i] = burst.strength;
85+
}
86+
87+
// Keep the whole camera on one render path so dash start/end does not
88+
// toggle full-viewport framebuffer sampling and visibly change sharp edges.
89+
this.active = true;
90+
}
91+
92+
onDraw(renderTarget: Phaser.Renderer.WebGL.RenderTarget): void {
93+
this.set1i("uBurstCount", this.burstCount);
94+
this.set2f("uResolution", this.resolutionX, this.resolutionY);
95+
this.set2fv("uCenters", this.centers);
96+
this.set1fv("uRadii", this.radii);
97+
this.set1fv("uRingWidths", this.ringWidths);
98+
this.set1fv("uAmplitudes", this.amplitudes);
99+
this.set1fv("uStrengths", this.strengths);
100+
this.bindAndDraw(renderTarget);
101+
}
102+
}
103+
104+
export class DisplacementSystem {
105+
private readonly model = new DisplacementBurstModel();
106+
private readonly camera: Phaser.Cameras.Scene2D.Camera;
107+
private readonly pipeline: DashDisplacementPipeline | null;
108+
109+
constructor(private readonly scene: Phaser.Scene, camera = scene.cameras.main) {
110+
this.camera = camera;
111+
this.pipeline = this.installPipeline();
112+
this.syncPipeline();
113+
}
114+
115+
addBurst(x: number, y: number): void {
116+
if (this.pipeline === null) return;
117+
118+
this.model.addBurst(x, y);
119+
this.syncPipeline();
120+
}
121+
122+
update(dt: number): void {
123+
if (this.pipeline === null) return;
124+
125+
this.model.update(dt);
126+
this.syncPipeline();
127+
}
128+
129+
clear(): void {
130+
this.model.clear();
131+
this.syncPipeline();
132+
}
133+
134+
destroy(): void {
135+
this.clear();
136+
if (this.pipeline !== null) {
137+
this.camera.removePostPipeline(DISPLACEMENT_PIPELINE_KEY);
138+
}
139+
}
140+
141+
private installPipeline(): DashDisplacementPipeline | null {
142+
const renderer = this.scene.game.renderer;
143+
if (renderer.type !== Phaser.WEBGL || !("pipelines" in renderer)) {
144+
return null;
145+
}
146+
147+
renderer.pipelines.addPostPipeline(DISPLACEMENT_PIPELINE_KEY, DashDisplacementPipeline);
148+
this.camera.setPostPipeline(DISPLACEMENT_PIPELINE_KEY);
149+
const pipeline = this.camera.getPostPipeline(DISPLACEMENT_PIPELINE_KEY);
150+
return Array.isArray(pipeline)
151+
? (pipeline[0] as DashDisplacementPipeline | undefined) ?? null
152+
: (pipeline as DashDisplacementPipeline | null);
153+
}
154+
155+
private syncPipeline(): void {
156+
if (this.pipeline === null) return;
157+
158+
const bursts = this.model.shaderBursts().map((burst) => projectBurstToPostFx(burst, this.camera));
159+
160+
this.pipeline.setBursts(bursts, this.camera.width, this.camera.height);
161+
}
162+
}

src/displacement/ripple.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
export interface DisplacementBurstConfig {
2+
duration: number;
3+
startRadius: number;
4+
endRadius: number;
5+
ringWidth: number;
6+
amplitude: number;
7+
strength: number;
8+
maxBursts: number;
9+
}
10+
11+
export interface DisplacementBurst {
12+
x: number;
13+
y: number;
14+
age: number;
15+
}
16+
17+
export interface DisplacementShaderBurst {
18+
x: number;
19+
y: number;
20+
radius: number;
21+
ringWidth: number;
22+
amplitude: number;
23+
strength: number;
24+
}
25+
26+
export interface ProjectedDisplacementBurst extends DisplacementShaderBurst {
27+
screenX: number;
28+
screenY: number;
29+
}
30+
31+
export interface DisplacementCameraProjection {
32+
scrollX: number;
33+
scrollY: number;
34+
height: number;
35+
}
36+
37+
export const DASH_DISPLACEMENT_CONFIG: Readonly<DisplacementBurstConfig> = Object.freeze({
38+
duration: 0.4,
39+
startRadius: 2,
40+
endRadius: 16,
41+
ringWidth: 2,
42+
amplitude: 4,
43+
strength: 0.5,
44+
maxBursts: 4,
45+
});
46+
47+
export function easeQuadOut(t: number): number {
48+
const clamped = clamp01(t);
49+
return 1 - (1 - clamped) * (1 - clamped);
50+
}
51+
52+
export function projectBurstToPostFx(
53+
burst: DisplacementShaderBurst,
54+
camera: DisplacementCameraProjection,
55+
): ProjectedDisplacementBurst {
56+
const screenY = burst.y - camera.scrollY;
57+
58+
return {
59+
...burst,
60+
screenX: burst.x - camera.scrollX,
61+
screenY: camera.height - screenY,
62+
};
63+
}
64+
65+
export class DisplacementBurstModel {
66+
private readonly bursts: DisplacementBurst[] = [];
67+
68+
constructor(private readonly config: Readonly<DisplacementBurstConfig> = DASH_DISPLACEMENT_CONFIG) {}
69+
70+
addBurst(x: number, y: number): void {
71+
this.bursts.push({ x, y, age: 0 });
72+
while (this.bursts.length > this.config.maxBursts) {
73+
this.bursts.shift();
74+
}
75+
}
76+
77+
update(dt: number): void {
78+
if (dt <= 0) return;
79+
80+
for (const burst of this.bursts) {
81+
burst.age += dt;
82+
}
83+
84+
for (let i = this.bursts.length - 1; i >= 0; i--) {
85+
if (this.bursts[i].age >= this.config.duration) {
86+
this.bursts.splice(i, 1);
87+
}
88+
}
89+
}
90+
91+
clear(): void {
92+
this.bursts.length = 0;
93+
}
94+
95+
hasActiveBursts(): boolean {
96+
return this.bursts.length > 0;
97+
}
98+
99+
shaderBursts(): DisplacementShaderBurst[] {
100+
return this.bursts.map((burst) => {
101+
const progress = clamp01(burst.age / this.config.duration);
102+
const easedRadius = easeQuadOut(progress);
103+
const radius = lerp(this.config.startRadius, this.config.endRadius, easedRadius);
104+
const fade = 1 - easedRadius;
105+
106+
return {
107+
x: burst.x,
108+
y: burst.y,
109+
radius,
110+
ringWidth: this.config.ringWidth,
111+
amplitude: this.config.amplitude,
112+
strength: this.config.strength * fade,
113+
};
114+
});
115+
}
116+
}
117+
118+
function lerp(from: number, to: number, t: number): number {
119+
return from + (to - from) * t;
120+
}
121+
122+
function clamp01(value: number): number {
123+
return Math.max(0, Math.min(1, value));
124+
}

0 commit comments

Comments
 (0)