Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 19 additions & 29 deletions src/client/game/GameScene.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { stadiumScreen } from './backgrounds/screens/stadium';
import { CrowdAddon } from './backgrounds/addons/crowd';

export default class GameScene extends Phaser.Scene {
public declare add: Phaser.GameObjects.GameObjectFactory;
Expand All @@ -25,6 +27,8 @@ export default class GameScene extends Phaser.Scene {
private fireworksTriggered: boolean = false;
private lastDistractionScore: number = 0;
private dailyModifiers = { gravityMultiplier: 1, kickMultiplier: 1, windX: 0 };
private crowdAddon?: CrowdAddon;
private crowdCheerTriggered: boolean = false;

private onGameOverCallback?: (score: number) => void;
private onScoreUpdateCallback?: (score: number) => void;
Expand Down Expand Up @@ -56,6 +60,8 @@ export default class GameScene extends Phaser.Scene {
this.confettiTriggered = false;
this.fireworksTriggered = false;
this.lastDistractionScore = 0;
this.crowdCheerTriggered = false;
this.crowdAddon?.reset();
}

create() {
Expand Down Expand Up @@ -145,9 +151,10 @@ export default class GameScene extends Phaser.Scene {
}
}

this.cameras.main.setBackgroundColor('#1e293b');
this.cameras.main.setBackgroundColor(stadiumScreen.background);

this.createCrowdBackdrop(width, height);
this.crowdAddon = new CrowdAddon(this, stadiumScreen.crowd);
this.crowdAddon.create(width, height);

// Score BG
this.scoreText = this.add
Expand Down Expand Up @@ -196,6 +203,11 @@ export default class GameScene extends Phaser.Scene {
this.handleBallTap(pointer);
}
});
this.input.keyboard?.on('keydown-SPACE', () => {
if (this.isGameOver) return;
const syntheticPointer = { x: this.ball.x, y: this.ball.y } as Phaser.Input.Pointer;
this.handleBallTap(syntheticPointer);
});

const instr = this.add
.text(width / 2, height - 100, 'TAP BALL TO KICK OFF', {
Expand Down Expand Up @@ -272,6 +284,10 @@ export default class GameScene extends Phaser.Scene {
if (this.score >= 10 && this.score <= 100 && this.score % 10 === 0) {
this.blastConfetti();
}
if (this.score === 10 && !this.crowdCheerTriggered) {
this.crowdCheerTriggered = true;
this.crowdAddon?.cheer(3000);
}
if (this.score >= 5 && !this.fireworksTriggered) {
this.fireworksTriggered = true;
this.launchFireworks();
Expand All @@ -291,33 +307,6 @@ export default class GameScene extends Phaser.Scene {
});
}

private createCrowdBackdrop(width: number, height: number) {
const rows = 3;
const rowHeight = Math.max(40, height * 0.07);
const baseY = height * 0.12;
const colors = [0x0b1220, 0x111827, 0x1f2937];

for (let row = 0; row < rows; row++) {
const y = baseY + row * (rowHeight + 8);
const headCount = Math.max(10, Math.floor(width / 60));
const headWidth = width / headCount;

for (let i = 0; i < headCount; i++) {
const x = i * headWidth + headWidth / 2;
const bodyHeight = Phaser.Math.Between(18, 28);
const bodyWidth = Phaser.Math.Between(18, 30);
const color = colors[row % colors.length];

const body = this.add.rectangle(x, y + rowHeight / 2, bodyWidth, bodyHeight, color, 0.9);
body.setDepth(1);
}
}

// Soft overlay to blend the crowd into the background
const overlay = this.add.rectangle(width / 2, baseY + rowHeight, width, rowHeight * rows, 0x0f172a, 0.35);
overlay.setDepth(2);
}

private shouldTriggerDistraction() {
return (
this.score >= 25 &&
Expand Down Expand Up @@ -614,4 +603,5 @@ export default class GameScene extends Phaser.Scene {
delayMs += clusterGapMs + Phaser.Math.Between(-clusterJitterMs, clusterJitterMs);
}
}

}
3 changes: 2 additions & 1 deletion src/client/game/PhaserGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import Phaser from 'phaser';
import GameScene from './GameScene';
import type { DailyModifiers } from '../services/daily';
import { stadiumScreen } from './backgrounds/screens/stadium';

interface PhaserGameProps {
onGameOver: (score: number) => void;
Expand Down Expand Up @@ -45,7 +46,7 @@ const PhaserGame: React.FC<PhaserGameProps> = ({ onGameOver, onScoreUpdate, dail
},
},
scene: GameScene,
backgroundColor: '#1e293b',
backgroundColor: stadiumScreen.background,
transparent: true,
antialias: true,
scale: {
Expand Down
213 changes: 213 additions & 0 deletions src/client/game/backgrounds/addons/crowd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import Phaser from 'phaser';

export type CrowdColors = {
band: number;
frontBand: number;
haze: number;
bodyPalette: number[];
head: number;
};

type CrowdPerson = {
container: Phaser.GameObjects.Container;
leftArm: Phaser.GameObjects.Rectangle;
rightArm: Phaser.GameObjects.Rectangle;
seatedY: number;
standingY: number;
idleLeftRot: number;
idleRightRot: number;
};

type CrowdOptions = {
groundOffset?: number;
vDepth?: number;
bandHeight?: number;
count?: number;
};

export class CrowdAddon {
private scene: Phaser.Scene;
private colors: CrowdColors;
private people: CrowdPerson[] = [];
private clapTweens: Phaser.Tweens.Tween[] = [];
private backgroundObjects: Phaser.GameObjects.GameObject[] = [];

constructor(scene: Phaser.Scene, colors: CrowdColors) {
this.scene = scene;
this.colors = colors;
}

create(width: number, height: number, options: CrowdOptions = {}) {
this.reset();
this.people = [];

const bandHeight = options.bandHeight ?? Math.max(60, height * 0.16);
const groundOffset = options.groundOffset ?? 40;
const baseY = height - groundOffset;
const vDepth = options.vDepth ?? Math.max(18, bandHeight * 0.35);
const count = options.count ?? Math.max(14, Math.floor(width / 44));
const standOffset = Math.max(16, bandHeight * 0.25);

const bandBottom = baseY + bandHeight * 0.2;
const backBand = this.scene.add.rectangle(
width / 2,
bandBottom - bandHeight / 2,
width,
bandHeight,
this.colors.band,
0.7
);
backBand.setDepth(1);

const frontBand = this.scene.add.rectangle(
width / 2,
bandBottom - bandHeight * 0.1,
width,
bandHeight * 0.45,
this.colors.frontBand,
0.8
);
frontBand.setDepth(2);

const haze = this.scene.add.rectangle(
width / 2,
bandBottom - bandHeight * 0.8,
width,
bandHeight,
this.colors.haze,
0.25
);
haze.setDepth(4);

this.backgroundObjects.push(backBand, frontBand, haze);

const gap = width / count;
const center = (count - 1) / 2;

for (let i = 0; i < count; i++) {
const x = i * gap + gap / 2;
const t = center > 0 ? Math.abs(i - center) / center : 0;
const seatY = baseY - vDepth * t;

const torsoWidth = Phaser.Math.Between(14, 18);
const torsoHeight = Phaser.Math.Between(14, 18);
const armLength = Phaser.Math.Between(10, 14);
const armWidth = 4;
const legLength = Phaser.Math.Between(10, 12);
const legWidth = 4;
const headRadius = Phaser.Math.Between(5, 7);
const bodyColor = this.colors.bodyPalette[i % this.colors.bodyPalette.length];

const container = this.scene.add.container(x, seatY);
container.setDepth(3);

const head = this.scene.add.circle(0, -20, headRadius, this.colors.head, 1);
const torso = this.scene.add.rectangle(0, -6, torsoWidth, torsoHeight, bodyColor, 0.95);
const leftArm = this.scene.add.rectangle(-torsoWidth / 2, -10, armLength, armWidth, bodyColor, 0.95);
leftArm.setOrigin(1, 0.5);
const rightArm = this.scene.add.rectangle(torsoWidth / 2, -10, armLength, armWidth, bodyColor, 0.95);
rightArm.setOrigin(0, 0.5);

const leftLeg = this.scene.add.rectangle(
-torsoWidth / 4,
torsoHeight / 2 + 2,
legWidth,
legLength,
bodyColor,
0.95
);
leftLeg.setOrigin(0.5, 0);
leftLeg.setRotation(0.9);
const rightLeg = this.scene.add.rectangle(
torsoWidth / 4,
torsoHeight / 2 + 2,
legWidth,
legLength,
bodyColor,
0.95
);
rightLeg.setOrigin(0.5, 0);
rightLeg.setRotation(-0.9);

const idleLeftRot = -0.7;
const idleRightRot = 0.7;
leftArm.setRotation(idleLeftRot);
rightArm.setRotation(idleRightRot);

container.add([head, torso, leftArm, rightArm, leftLeg, rightLeg]);

this.people.push({
container,
leftArm,
rightArm,
seatedY: seatY,
standingY: seatY - standOffset,
idleLeftRot,
idleRightRot,
});
}
}

cheer(durationMs: number = 3000) {
if (this.people.length === 0) return;

this.clapTweens.forEach((tween) => tween.stop());
this.clapTweens = [];

this.people.forEach((person, index) => {
this.scene.tweens.add({
targets: person.container,
y: person.standingY,
duration: 260,
ease: 'Back.easeOut',
delay: index * 10,
});

const clapLeft = this.scene.tweens.add({
targets: person.leftArm,
rotation: { from: person.idleLeftRot, to: -0.2 },
duration: 140,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: Phaser.Math.Between(0, 220),
});
const clapRight = this.scene.tweens.add({
targets: person.rightArm,
rotation: { from: person.idleRightRot, to: 0.2 },
duration: 140,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: Phaser.Math.Between(0, 220),
});
this.clapTweens.push(clapLeft, clapRight);
});

this.scene.time.delayedCall(durationMs, () => {
this.clapTweens.forEach((tween) => tween.stop());
this.clapTweens = [];

this.people.forEach((person, index) => {
person.leftArm.setRotation(person.idleLeftRot);
person.rightArm.setRotation(person.idleRightRot);
this.scene.tweens.add({
targets: person.container,
y: person.seatedY,
duration: 240,
ease: 'Sine.easeInOut',
delay: index * 8,
});
});
});
}

reset() {
this.clapTweens.forEach((tween) => tween.stop());
this.clapTweens = [];
this.people.forEach((person) => person.container.destroy(true));
this.people = [];
this.backgroundObjects.forEach((obj) => obj.destroy());
this.backgroundObjects = [];
}
}
10 changes: 10 additions & 0 deletions src/client/game/backgrounds/screens/stadium.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const stadiumScreen = {
background: '#1e293b',
crowd: {
band: 0x0f172a,
frontBand: 0x111827,
haze: 0x0b1220,
bodyPalette: [0x0b1220, 0x111827, 0x1f2937, 0x0f172a],
head: 0x1f2937,
},
};