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:
Erik 2026-06-19 09:47:39 +02:00
parent 7141a38c5c
commit d86bc48862
24 changed files with 129 additions and 202 deletions

View file

@ -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>

View file

@ -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}

View file

@ -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>
);
};

View file

@ -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">

View 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; // 611s 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;
};

View file

@ -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);
}