Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/components/GameLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useDailyObjectiveTracking } from "../hooks/useDailyObjectiveTracking";
import { useGameLoop } from "../hooks/useGameLoop";
import { useKonamiCode } from "../hooks/useKonamiCode";
import { useReducedMotion } from "../hooks/useReducedMotion";
import { useSound } from "../hooks/useSound";
import { useGameStore } from "../store";
import { useDailyStore } from "../store/dailyStore";
import { AchievementsModal } from "./AchievementsModal";
Expand All @@ -38,6 +39,9 @@ export function GameLayout() {
const konamiTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unlockedCount = useGameStore((s) => s.unlockedAchievements.length);
const reducedMotion = useReducedMotion();
const { playWelcomeBack } = useSound();
const playWelcomeBackRef = useRef(playWelcomeBack);
playWelcomeBackRef.current = playWelcomeBack;

useEffect(() => {
const state = useGameStore.getState();
Expand All @@ -61,6 +65,7 @@ export function GameLayout() {
if (result) {
state.addTrainingData(result.earned);
setOfflineResult(result);
playWelcomeBackRef.current();
useDailyStore.getState().recordOfflineBonus();
}
}, []);
Expand Down
8 changes: 6 additions & 2 deletions src/components/PetDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useAsciiAnimation } from "../hooks/useAsciiAnimation";
import { useClickParticles } from "../hooks/useClickParticles";
import { useDialogue } from "../hooks/useDialogue";
import { useReducedMotion } from "../hooks/useReducedMotion";
import { useSound } from "../hooks/useSound";
import { useGameStore } from "../store";
import { useUIStore } from "../store/uiStore";
import { formatNumber } from "../utils/formatNumber";
Expand Down Expand Up @@ -82,6 +83,7 @@ export function PetDisplay() {
const prevStageRef = useRef(evolutionStage);
const containerRef = useRef<HTMLDivElement>(null);
const prefersReduced = useReducedMotion();
const { playClick, playEvolution } = useSound();

const currentFrame = useAsciiAnimation(artFrames, 2000, isGlitching);

Expand Down Expand Up @@ -156,6 +158,7 @@ export function PetDisplay() {
useEffect(() => {
if (evolutionStage !== prevStageRef.current) {
prevStageRef.current = evolutionStage;
playEvolution();
setIsFlashing(true);
const flashTimer = setTimeout(() => setIsFlashing(false), 600);

Expand All @@ -173,18 +176,19 @@ export function PetDisplay() {

return () => clearTimeout(flashTimer);
}
}, [evolutionStage, prefersReduced]);
}, [evolutionStage, prefersReduced, playEvolution]);

const handlePetClick = () => {
setMood(getClickMood());
};

const handleFeed = useCallback(() => {
playClick();
clickFeed();
if (containerRef.current) {
spawn(containerRef.current.getBoundingClientRect());
}
}, [clickFeed, spawn]);
}, [clickFeed, playClick, spawn]);

return (
<div
Expand Down
2 changes: 1 addition & 1 deletion src/components/RebirthProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Badge, Progress, Stack, Text } from "@mantine/core";
import { getEvolutionThresholdMultiplier } from "../data/prestigeShop";
import {
REBIRTH_MIN_STAGE,
getRebirthProgress,
getRebirthThresholdTd,
REBIRTH_MIN_STAGE,
} from "../engine/rebirthEngine";
import { useReducedMotion } from "../hooks/useReducedMotion";
import { useGameStore } from "../store";
Expand Down
9 changes: 9 additions & 0 deletions src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function SettingsPanel({
(s) => s.setAnimationsDisabled,
);
const setNumberFormat = useSettingsStore((s) => s.setNumberFormat);
const soundEnabled = useSettingsStore((s) => s.soundEnabled);
const setSoundEnabled = useSettingsStore((s) => s.setSoundEnabled);

const [resetStage, setResetStage] = useState(0);
const [importError, setImportError] = useState<string | null>(null);
Expand Down Expand Up @@ -188,6 +190,13 @@ export function SettingsPanel({
}
styles={{ label: { fontFamily: "monospace" } }}
/>
<Switch
label="Sound Effects"
description="Play synthesised audio feedback for clicks, purchases and events"
checked={soundEnabled}
onChange={(e) => setSoundEnabled(e.currentTarget.checked)}
styles={{ label: { fontFamily: "monospace" } }}
/>

<Divider />

Expand Down
31 changes: 28 additions & 3 deletions src/components/UpgradesSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import {
Title,
UnstyledButton,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { CLICK_UPGRADES } from "../data/clickUpgrades";
import { getGeneratorCostMultiplier } from "../data/prestigeShop";
import type { Upgrade } from "../data/upgrades";
import { UPGRADES } from "../data/upgrades";
import { useSound } from "../hooks/useSound";
import { useGameStore } from "../store";
import type { BuyMode } from "../store/settingsStore";
import { useSettingsStore } from "../store/settingsStore";
Expand Down Expand Up @@ -101,6 +102,30 @@ export function UpgradesSidebar() {
const buyMode = useSettingsStore((s) => s.buyMode);
const setBuyMode = useSettingsStore((s) => s.setBuyMode);

const { playPurchase } = useSound();

const handleBulkPurchase = useCallback(
(id: string, qty: number) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 playPurchase() fires before the purchase action executes. If the player can't afford the upgrade, they hear the chime but nothing happens — confusing audio feedback.

Move playPurchase() after the purchase call, or gate it on an affordability check.

const dataBefore = useGameStore.getState().trainingData;
purchaseBulkUpgrade(id, qty);
if (useGameStore.getState().trainingData !== dataBefore) {
playPurchase();
}
},
[purchaseBulkUpgrade, playPurchase],
);

const handleClickUpgradePurchase = useCallback(
(id: string) => {
const dataBefore = useGameStore.getState().trainingData;
purchaseClickUpgrade(id);
if (useGameStore.getState().trainingData !== dataBefore) {
playPurchase();
}
},
[purchaseClickUpgrade, playPurchase],
);

// Collapsible state — all open by default
const [openCategories, setOpenCategories] = useState<Record<string, boolean>>(
{
Expand Down Expand Up @@ -213,7 +238,7 @@ export function UpgradesSidebar() {
purchased={purchased}
trainingData={trainingData}
evolutionStage={evolutionStage}
onPurchase={purchaseClickUpgrade}
onPurchase={handleClickUpgradePurchase}
/>
</div>
);
Expand Down Expand Up @@ -246,7 +271,7 @@ export function UpgradesSidebar() {
allOwned={upgradeOwned}
trainingData={trainingData}
buyMode={buyMode}
onPurchase={purchaseBulkUpgrade}
onPurchase={handleBulkPurchase}
costMultiplier={costMultiplier}
/>
))}
Expand Down
9 changes: 3 additions & 6 deletions src/engine/rebirthEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,17 +227,15 @@ describe("pacing: time-to-Stage-4 simulation (Issue #108)", () => {
genTdPerSec +=
generators[i].baseTdPerSec * owned[i] * milestoneMult(owned[i]);
}
const earned =
(genTdPerSec + clicksPerSecond * baseClickPower) * DT;
const earned = (genTdPerSec + clicksPerSecond * baseClickPower) * DT;
td += earned;
totalTdEarned += earned;

// Buy the generator with the best TD/s per marginal-cost ratio
let bestIdx = -1;
let bestRatio = -1;
for (let i = 0; i < generators.length; i++) {
const marginalCost =
generators[i].baseCost * Math.pow(1.15, owned[i]);
const marginalCost = generators[i].baseCost * 1.15 ** owned[i];
if (td >= marginalCost) {
const ratio = generators[i].baseTdPerSec / marginalCost;
if (ratio > bestRatio) {
Expand All @@ -247,8 +245,7 @@ describe("pacing: time-to-Stage-4 simulation (Issue #108)", () => {
}
}
if (bestIdx >= 0) {
const cost =
generators[bestIdx].baseCost * Math.pow(1.15, owned[bestIdx]);
const cost = generators[bestIdx].baseCost * 1.15 ** owned[bestIdx];
td -= cost;
owned[bestIdx]++;
}
Expand Down
92 changes: 92 additions & 0 deletions src/hooks/useSound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initialSettings, useSettingsStore } from "../store/settingsStore";

// synthSounds uses the Web Audio API which is not available in the node test
// environment; mock the module so useSound can be tested without a real AudioContext.
vi.mock("../utils/synthSounds", () => ({
getAudioContext: vi.fn(() => null),
synthClick: vi.fn(),
synthEvolution: vi.fn(),
synthPurchase: vi.fn(),
synthWelcomeBack: vi.fn(),
}));

// useReducedMotion depends on window.matchMedia — stub it out
const mockUseReducedMotion = vi.fn(() => false);
vi.mock("./useReducedMotion", () => ({
get useReducedMotion() {
return mockUseReducedMotion;
},
}));

import * as synthSounds from "../utils/synthSounds";
import { useSound } from "./useSound";

// useSound is a React hook; we call it directly because all its inner
// hook dependencies are stubbed above — no React render is required.
function callHook() {
return renderHook(() => useSound()).result.current;
}

beforeEach(() => {
useSettingsStore.setState(initialSettings);
vi.clearAllMocks();
mockUseReducedMotion.mockReturnValue(false);
});

describe("useSound", () => {
it("returns four play functions", () => {
const sound = callHook();
expect(typeof sound.playClick).toBe("function");
expect(typeof sound.playPurchase).toBe("function");
expect(typeof sound.playEvolution).toBe("function");
expect(typeof sound.playWelcomeBack).toBe("function");
});

it("does not call getAudioContext when soundEnabled is false", () => {
useSettingsStore.setState({ soundEnabled: false });
const sound = callHook();
sound.playClick();
expect(synthSounds.getAudioContext).not.toHaveBeenCalled();
});

it("calls getAudioContext when soundEnabled is true", () => {
useSettingsStore.setState({ soundEnabled: true });
const sound = callHook();
sound.playClick();
expect(synthSounds.getAudioContext).toHaveBeenCalled();
});

it("does not call synthPurchase when audio context is null", () => {
useSettingsStore.setState({ soundEnabled: true });
vi.mocked(synthSounds.getAudioContext).mockReturnValue(null);
const sound = callHook();
sound.playPurchase();
expect(synthSounds.getAudioContext).toHaveBeenCalled();
expect(synthSounds.synthPurchase).not.toHaveBeenCalled();
});

it("does not call getAudioContext for playEvolution when muted", () => {
useSettingsStore.setState({ soundEnabled: false });
const sound = callHook();
sound.playEvolution();
expect(synthSounds.getAudioContext).not.toHaveBeenCalled();
});

it("does not call getAudioContext for playWelcomeBack when muted", () => {
useSettingsStore.setState({ soundEnabled: false });
const sound = callHook();
sound.playWelcomeBack();
expect(synthSounds.getAudioContext).not.toHaveBeenCalled();
});

it("silences all sounds when prefers-reduced-motion is true", () => {
mockUseReducedMotion.mockReturnValue(true);
useSettingsStore.setState({ soundEnabled: true });
const { result } = renderHook(() => useSound());
result.current.playClick();
expect(synthSounds.getAudioContext).not.toHaveBeenCalled();
});
});
45 changes: 45 additions & 0 deletions src/hooks/useSound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useCallback } from "react";
import { useSettingsStore } from "../store/settingsStore";
import {
getAudioContext,
synthClick,
synthEvolution,
synthPurchase,
synthWelcomeBack,
} from "../utils/synthSounds";
import { useReducedMotion } from "./useReducedMotion";

type SynthFn = (ctx: AudioContext) => void;

/**
* Returns stable callbacks for each in-game sound.
* Sounds are silenced when the user has muted audio or enabled
* prefers-reduced-motion (which implies reducing sensory stimulation).
*/
export function useSound() {
const soundEnabled = useSettingsStore((s) => s.soundEnabled);
const prefersReduced = useReducedMotion();

const play = useCallback(
(fn: SynthFn) => {
if (!soundEnabled || prefersReduced) return;
const ctx = getAudioContext();
if (!ctx) return;
const resume =
ctx.state === "suspended" ? ctx.resume() : Promise.resolve();
resume
.then(() => fn(ctx))
.catch(() => {
// AudioContext blocked by browser policy — ignore silently
});
},
[soundEnabled, prefersReduced],
);

const playClick = useCallback(() => play(synthClick), [play]);
const playPurchase = useCallback(() => play(synthPurchase), [play]);
const playEvolution = useCallback(() => play(synthEvolution), [play]);
const playWelcomeBack = useCallback(() => play(synthWelcomeBack), [play]);

return { playClick, playPurchase, playEvolution, playWelcomeBack };
}
22 changes: 22 additions & 0 deletions src/store/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,26 @@ describe("useSettingsStore", () => {
expect(useSettingsStore.getState().crtEnabled).toBe(false);
expect(useSettingsStore.getState().animationsDisabled).toBe(false);
});

it("has soundEnabled defaulting to true", () => {
const state = useSettingsStore.getState();
expect(state.soundEnabled).toBe(true);
});

it("setSoundEnabled sets soundEnabled to false", () => {
useSettingsStore.getState().setSoundEnabled(false);
expect(useSettingsStore.getState().soundEnabled).toBe(false);
});

it("setSoundEnabled sets soundEnabled back to true", () => {
useSettingsStore.setState({ soundEnabled: false });
useSettingsStore.getState().setSoundEnabled(true);
expect(useSettingsStore.getState().soundEnabled).toBe(true);
});

it("setSoundEnabled does not affect other settings", () => {
useSettingsStore.getState().setSoundEnabled(false);
expect(useSettingsStore.getState().crtEnabled).toBe(false);
expect(useSettingsStore.getState().animationsDisabled).toBe(false);
});
});
4 changes: 4 additions & 0 deletions src/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export interface SettingsState {
animationsDisabled: boolean;
buyMode: BuyMode;
numberFormat: NumberFormat;
soundEnabled: boolean;
}

interface SettingsActions {
setCrtEnabled: (enabled: boolean) => void;
setAnimationsDisabled: (disabled: boolean) => void;
setBuyMode: (mode: BuyMode) => void;
setNumberFormat: (format: NumberFormat) => void;
setSoundEnabled: (enabled: boolean) => void;
}

export type SettingsStore = SettingsState & SettingsActions;
Expand All @@ -26,6 +28,7 @@ export const initialSettings: SettingsState = {
animationsDisabled: false,
buyMode: 1,
numberFormat: "full",
soundEnabled: true,
};

export const useSettingsStore = create<SettingsStore>()(
Expand All @@ -37,6 +40,7 @@ export const useSettingsStore = create<SettingsStore>()(
set({ animationsDisabled }),
setBuyMode: (buyMode) => set({ buyMode }),
setNumberFormat: (numberFormat) => set({ numberFormat }),
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
}),
{ name: "glorp-settings" },
),
Expand Down
Loading
Loading