Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { PING_EMOJIS, type Role, type RoomState } from '@two-keys/shared';
|
|
import { socket } from '../lib/socket';
|
|
import { ROLE_TEXT } from '../lib/theme';
|
|
|
|
/** Partner's online dot, what they're looking at, and the ping buttons. */
|
|
export function PartnerStatus({ state }: { state: RoomState }) {
|
|
const [open, setOpen] = useState(false);
|
|
const partner = state.partner;
|
|
const name = partner?.seat ? state.game.roles[partner.seat].name : 'Partner';
|
|
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<button
|
|
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-expanded={open}
|
|
aria-label={`${name} is ${partner?.online ? 'online' : 'offline'}. Send a ping.`}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={`h-2 w-2 shrink-0 rounded-full ${partner?.online ? 'bg-emerald-400 shadow-[0_0_6px_#34d399]' : 'bg-stone-500'}`}
|
|
/>
|
|
<span className={`truncate font-medium ${partner?.seat ? ROLE_TEXT[partner.seat] : ''}`}>{name}</span>
|
|
<span aria-hidden>👋</span>
|
|
</button>
|
|
|
|
{open && (
|
|
// Anchored to the header bar (not the button) so it always fits a narrow screen.
|
|
<div className="absolute inset-x-4 top-full z-30 mt-2 max-w-xs rounded-2xl border border-white/15 bg-bg p-3 shadow-2xl">
|
|
<p className="text-xs text-stone-400">
|
|
{partner?.online ? 'Online' : 'Offline. They can rejoin with the same link.'}
|
|
{partner?.online && partner.viewing ? ` · looking at ${partner.viewing}` : ''}
|
|
</p>
|
|
<div className="mt-2 flex gap-2">
|
|
{PING_EMOJIS.map((emoji) => (
|
|
<button
|
|
key={emoji}
|
|
className="btn flex-1 px-0 text-xl"
|
|
disabled={!partner?.online}
|
|
aria-label={`Send ${emoji}`}
|
|
onClick={() => {
|
|
socket.emit('ping', { emoji });
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Ping {
|
|
id: number;
|
|
emoji: string;
|
|
from: Role;
|
|
x: number;
|
|
}
|
|
|
|
/** Reactions from the partner float up the screen. */
|
|
export function PingOverlay({ roles }: { roles: RoomState['game']['roles'] }) {
|
|
const [pings, setPings] = useState<Ping[]>([]);
|
|
|
|
useEffect(() => {
|
|
let nextId = 1;
|
|
const onPing = ({ from, emoji }: { from: Role; emoji: string }) => {
|
|
const id = nextId++;
|
|
setPings((p) => [...p, { id, emoji, from, x: 15 + Math.random() * 70 }]);
|
|
setTimeout(() => setPings((p) => p.filter((x) => x.id !== id)), 2600);
|
|
};
|
|
socket.on('ping', onPing);
|
|
return () => {
|
|
socket.off('ping', onPing);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden" aria-live="polite">
|
|
<AnimatePresence>
|
|
{pings.map((p) => (
|
|
<motion.div
|
|
key={p.id}
|
|
className="absolute bottom-24 flex flex-col items-center"
|
|
style={{ left: `${p.x}%` }}
|
|
initial={{ y: 0, opacity: 0, scale: 0.5 }}
|
|
animate={{ y: -260, opacity: 1, scale: 1.4 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 2.2, ease: 'easeOut' }}
|
|
>
|
|
<span className="text-5xl">{p.emoji}</span>
|
|
<span className={`mt-1 rounded-full bg-black/70 px-2 text-[10px] font-semibold ${ROLE_TEXT[p.from]}`}>
|
|
{roles[p.from].name}
|
|
</span>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|