feat(midsummer): rain of flowers/frogs/Swedish flags, dots become frogs, drop jingle
Per request: remove the WebAudio jingle (+ its 🔊 toggle and sound state); replace the one-shot confetti with a continuous rain of 🌼🌸🐸🇸🇪🌿 over the screen (MidsummerRain, gated by the theme, reduced-motion aware, leak-free); and replace player-dot markers with frogs themselves (override the inline dot color/border) instead of a flower-crown on top. Still toggled by the 🐸 Midsommar switch. Includes rebuilt static bundle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7141a38c5c
commit
d86bc48862
24 changed files with 129 additions and 202 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useLiveData } from '../hooks/useLiveData';
|
||||
import { useMidsummerSound } from '../hooks/useMidsummerSound';
|
||||
import { PlayerDashboardContent } from './windows/PlayerDashboardWindow';
|
||||
import { MidsummerBanner } from './midsummer/MidsummerBanner';
|
||||
import { MidsummerRain } from './midsummer/MidsummerRain';
|
||||
|
||||
/**
|
||||
* Fullscreen "Player Dashboard" page — rendered when the React app loads
|
||||
|
|
@ -16,7 +16,6 @@ import { MidsummerBanner } from './midsummer/MidsummerBanner';
|
|||
*/
|
||||
export const PlayerDashboardFullPage: React.FC = () => {
|
||||
const data = useLiveData();
|
||||
useMidsummerSound();
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
// Set tab title.
|
||||
|
|
@ -41,6 +40,7 @@ export const PlayerDashboardFullPage: React.FC = () => {
|
|||
return (
|
||||
<div className="ml-dashboard-page">
|
||||
<MidsummerBanner />
|
||||
<MidsummerRain />
|
||||
<header className="ml-dashboard-header">
|
||||
<span className="ml-dashboard-title">👥 Player Dashboard</span>
|
||||
<span className="ml-dashboard-count">{count} online</span>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { WindowRenderer } from '../windows/WindowRenderer';
|
|||
import { RareNotification } from '../effects/RareNotification';
|
||||
import { DeathNotification } from '../effects/DeathNotification';
|
||||
import { usePlayerColors } from '../../hooks/usePlayerColors';
|
||||
import { useMidsummerSound } from '../../hooks/useMidsummerSound';
|
||||
import { MidsummerBanner } from '../midsummer/MidsummerBanner';
|
||||
import { MidsummerRain } from '../midsummer/MidsummerRain';
|
||||
import type { DashboardState } from '../../hooks/useLiveData';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -17,7 +17,6 @@ interface Props {
|
|||
|
||||
export const MapLayout: React.FC<Props> = ({ data }) => {
|
||||
const getColor = usePlayerColors();
|
||||
useMidsummerSound();
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
const [showPortals, setShowPortals] = useState(false);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string | null>(null);
|
||||
|
|
@ -45,6 +44,7 @@ export const MapLayout: React.FC<Props> = ({ data }) => {
|
|||
<WindowManagerProvider>
|
||||
<div className="ml-layout">
|
||||
<MidsummerBanner />
|
||||
<MidsummerRain />
|
||||
<Sidebar
|
||||
players={players}
|
||||
vitals={vitalsMap}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,17 @@
|
|||
import React from 'react';
|
||||
import { useMidsummer } from '../../hooks/useMidsummer';
|
||||
import { playSmaGrodorna } from '../../hooks/useMidsummerSound';
|
||||
|
||||
/** 🐸 theme toggle + 🔊 jingle toggle, rendered among the sidebar tool links. */
|
||||
/** 🐸 midsummer theme toggle, rendered among the sidebar tool links. */
|
||||
export const FrogToggle: React.FC = () => {
|
||||
const { enabled, toggle, soundOn, toggleSound } = useMidsummer();
|
||||
const { enabled, toggle } = useMidsummer();
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="ml-tool-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
|
||||
onClick={toggle}
|
||||
>
|
||||
🐸 Midsommar {enabled ? 'on' : 'off'}
|
||||
</span>
|
||||
{enabled && (
|
||||
<span
|
||||
className="ml-tool-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={soundOn ? 'Mute the Små grodorna jingle' : 'Unmute the Små grodorna jingle'}
|
||||
onClick={() => {
|
||||
const turningOn = !soundOn;
|
||||
toggleSound();
|
||||
if (turningOn) playSmaGrodorna(); // this click is a user gesture
|
||||
}}
|
||||
>
|
||||
{soundOn ? '🔊' : '🔇'} Jingle
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
<span
|
||||
className="ml-tool-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
|
||||
onClick={toggle}
|
||||
>
|
||||
🐸 Midsommar {enabled ? 'on' : 'off'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,9 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useMidsummer } from '../../hooks/useMidsummer';
|
||||
import { burstConfetti } from './confetti';
|
||||
|
||||
const CONFETTI_FLAG = 'mo-midsummer-confetti';
|
||||
|
||||
/** Festive top strip + a one-shot confetti burst on the first load of a
|
||||
* session while the theme is on. */
|
||||
/** Festive top strip shown while the theme is on. */
|
||||
export const MidsummerBanner: React.FC = () => {
|
||||
const { enabled } = useMidsummer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (sessionStorage.getItem(CONFETTI_FLAG)) return;
|
||||
sessionStorage.setItem(CONFETTI_FLAG, '1');
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
burstConfetti();
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
if (!enabled) return null;
|
||||
return (
|
||||
<div className="ms-banner" role="status">
|
||||
|
|
|
|||
44
frontend/src/components/midsummer/MidsummerRain.tsx
Normal file
44
frontend/src/components/midsummer/MidsummerRain.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useMidsummer } from '../../hooks/useMidsummer';
|
||||
|
||||
// Flowers, frogs and Swedish flags drifting down over the screen.
|
||||
const PIECES = ['🐸', '🌼', '🌸', '🇸🇪', '🌿'];
|
||||
|
||||
/**
|
||||
* Continuous gentle rain of flowers, frogs and Swedish flags while the theme
|
||||
* is on. Pure DOM + CSS (no React re-renders) so it doesn't fight the map's
|
||||
* high-frequency telemetry updates. Cleans up its layer, interval and any
|
||||
* in-flight pieces when the theme is toggled off or the component unmounts.
|
||||
* Skipped entirely under prefers-reduced-motion.
|
||||
*/
|
||||
export const MidsummerRain: React.FC = () => {
|
||||
const { enabled } = useMidsummer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'ms-rain';
|
||||
document.body.appendChild(layer);
|
||||
|
||||
const spawn = () => {
|
||||
const piece = document.createElement('span');
|
||||
piece.textContent = PIECES[Math.floor(Math.random() * PIECES.length)];
|
||||
piece.style.left = Math.floor(Math.random() * 100) + 'vw';
|
||||
const dur = 6 + Math.random() * 5; // 6–11s to fall
|
||||
piece.style.animationDuration = dur.toFixed(2) + 's';
|
||||
piece.style.fontSize = 14 + Math.floor(Math.random() * 16) + 'px';
|
||||
layer.appendChild(piece);
|
||||
window.setTimeout(() => piece.remove(), dur * 1000 + 250);
|
||||
};
|
||||
|
||||
const id = window.setInterval(spawn, 450);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
layer.remove();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
const EMOJIS = ['🐸', '🌼', '🌸', '🥂', '🌿'];
|
||||
|
||||
/** One-shot falling-emoji burst. Self-removes after the animation. */
|
||||
export function burstConfetti(count = 28): void {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'ms-confetti';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = document.createElement('span');
|
||||
p.textContent = EMOJIS[i % EMOJIS.length];
|
||||
p.style.left = Math.floor(Math.random() * 100) + 'vw';
|
||||
p.style.animationDelay = (Math.random() * 0.6).toFixed(2) + 's';
|
||||
p.style.fontSize = 12 + Math.floor(Math.random() * 14) + 'px';
|
||||
layer.appendChild(p);
|
||||
}
|
||||
document.body.appendChild(layer);
|
||||
window.setTimeout(() => layer.remove(), 4200);
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const KEY = 'mo-midsummer';
|
||||
const SOUND_KEY = 'mo-midsummer-sound';
|
||||
|
||||
interface MidsummerCtx {
|
||||
enabled: boolean;
|
||||
toggle: () => void;
|
||||
soundOn: boolean;
|
||||
toggleSound: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<MidsummerCtx | null>(null);
|
||||
|
|
@ -15,7 +12,6 @@ const Ctx = createContext<MidsummerCtx | null>(null);
|
|||
export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Default ON: only the literal "off" disables it.
|
||||
const [enabled, setEnabled] = useState<boolean>(() => localStorage.getItem(KEY) !== 'off');
|
||||
const [soundOn, setSoundOn] = useState<boolean>(() => localStorage.getItem(SOUND_KEY) !== 'off');
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
|
|
@ -24,15 +20,10 @@ export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
localStorage.setItem(KEY, enabled ? 'on' : 'off');
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SOUND_KEY, soundOn ? 'on' : 'off');
|
||||
}, [soundOn]);
|
||||
|
||||
const toggle = useCallback(() => setEnabled(e => !e), []);
|
||||
const toggleSound = useCallback(() => setSoundOn(s => !s), []);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ enabled, toggle, soundOn, toggleSound }}>
|
||||
<Ctx.Provider value={{ enabled, toggle }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useMidsummer } from './useMidsummer';
|
||||
|
||||
const JINGLE_FLAG = 'mo-midsummer-jingle';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
// Public-domain "Små grodorna" opening phrase (approximation), as
|
||||
// [frequencyHz, durationSeconds]. Cheerful major-key triangle tones.
|
||||
const MELODY: [number, number][] = [
|
||||
[392, 0.22], [392, 0.22], [392, 0.22], [440, 0.22], [494, 0.42],
|
||||
[494, 0.22], [440, 0.22], [494, 0.22], [523, 0.22], [587, 0.5],
|
||||
];
|
||||
|
||||
/** Play the jingle once. Safe to call from any user gesture. No-op if
|
||||
* WebAudio is unavailable. Reuses a single AudioContext (no leak). */
|
||||
export function playSmaGrodorna(): void {
|
||||
try {
|
||||
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
if (!ctx) ctx = new AC();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
let t = ctx.currentTime + 0.05;
|
||||
for (const [freq, dur] of MELODY) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.0001, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur);
|
||||
t += dur;
|
||||
}
|
||||
} catch {
|
||||
/* audio not available — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Plays the jingle once per session, on the first user gesture, when the
|
||||
* theme and sound are both on. Browsers block audio before a gesture, so
|
||||
* we wait for the first pointerdown/keydown. */
|
||||
export function useMidsummerSound(): void {
|
||||
const { enabled, soundOn } = useMidsummer();
|
||||
useEffect(() => {
|
||||
if (!enabled || !soundOn) return;
|
||||
if (sessionStorage.getItem(JINGLE_FLAG)) return;
|
||||
|
||||
const fire = () => {
|
||||
sessionStorage.setItem(JINGLE_FLAG, '1');
|
||||
playSmaGrodorna();
|
||||
cleanup();
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointerdown', fire);
|
||||
window.removeEventListener('keydown', fire);
|
||||
};
|
||||
window.addEventListener('pointerdown', fire);
|
||||
window.addEventListener('keydown', fire);
|
||||
return cleanup;
|
||||
}, [enabled, soundOn]);
|
||||
}
|
||||
|
|
@ -83,20 +83,15 @@
|
|||
.ms-maypole-ring { animation: none; }
|
||||
}
|
||||
|
||||
/* Replace each player marker with a frog (instead of the coloured dot). The
|
||||
inline backgroundColor + base border on .ml-dot are overridden so only the
|
||||
frog shows; the base blink animation on the selected dot still applies. */
|
||||
:root[data-midsummer] .ml-dot {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
overflow: visible;
|
||||
}
|
||||
:root[data-midsummer] .ml-dot::before {
|
||||
content: '🌸';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -9px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
:root[data-midsummer] .ml-dot.ml-dot-selected::after {
|
||||
content: '🐸';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
|
@ -106,6 +101,10 @@
|
|||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
:root[data-midsummer] .ml-dot.ml-dot-selected::before {
|
||||
font-size: 21px;
|
||||
filter: drop-shadow(0 0 3px #7ed957);
|
||||
}
|
||||
|
||||
.ms-banner {
|
||||
position: absolute;
|
||||
|
|
@ -123,21 +122,26 @@
|
|||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ms-confetti {
|
||||
/* Continuous rain of flowers / frogs / Swedish flags (MidsummerRain). Each
|
||||
piece sets its own animation-duration inline; pointer-events:none so it
|
||||
never blocks the UI underneath. */
|
||||
.ms-rain {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 999998;
|
||||
z-index: 9000;
|
||||
}
|
||||
.ms-confetti span {
|
||||
.ms-rain span {
|
||||
position: absolute;
|
||||
top: -32px;
|
||||
top: -40px;
|
||||
line-height: 1;
|
||||
animation: ms-fall 3.6s linear forwards;
|
||||
animation-name: ms-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
@keyframes ms-fall {
|
||||
to { transform: translateY(112vh) rotate(360deg); opacity: 0.25; }
|
||||
to { transform: translateY(112vh) rotate(360deg); opacity: 0.3; }
|
||||
}
|
||||
|
||||
.ml-layout.ms-hop {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue