feat: add idle yawn animation when user is inactive (F-4)

After 30s without user input on Elements tab, producer sprites cycle a
subtle yawn (translateY + scale) every 8s. Resets immediately on any
pointer/touch/key/scroll event. Implemented via wrapper div so
CharacterSprite internals are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 03:53:20 +09:00
parent c638679502
commit 2b752e9e1f
2 changed files with 66 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ import { useFloatingItems } from '../../hooks/useFloatingItems';
import elementsData from '../../data/elements.json';
import recipesData from '../../data/recipes.json';
import { CharacterSprite } from '../CharacterSprite';
import { useIdleMood, type Mood } from '../../lib/useIdleMood';
import {
calcResonanceBonuses,
calcTierAutomationStatus,
@@ -515,6 +516,22 @@ const producerSpriteStyle = (active: boolean) => css`
animation: ${active ? `${scenePulse} 1.8s ease-in-out infinite` : 'none'};
`;
const idleYawnKeyframes = keyframes`
0%, 70%, 100% { transform: translateY(0) scale(1); opacity: 1; }
78% { transform: translateY(-1.5px) scale(1.04, 0.96); opacity: 0.94; }
88% { transform: translateY(-2.5px) scale(1.06, 0.94); opacity: 0.88; }
96% { transform: translateY(-0.5px) scale(1.02, 0.99); opacity: 0.97; }
`;
const idleSpriteWrapperStyle = (mood: Mood) => css`
display: inline-block;
width: 100%;
height: 100%;
${mood === 'idle'
? `animation: ${idleYawnKeyframes.toString()} 8s ease-in-out infinite;`
: ''}
`;
const producerInfoStyle = css`
min-width: 0;
`;
@@ -1325,6 +1342,7 @@ export function ElementsScreen() {
const claimDailyBonus = useGameStore((s) => s.claimDailyBonus);
const claimDailyMission = useGameStore((s) => s.claimDailyMission);
const harvestElement = useGameStore((s) => s.harvestElement);
const idleMood = useIdleMood();
const [selectedEl, setSelectedEl] = useState<ElementData | null>(null);
const [activityLog, setActivityLog] = useState<ActivityEntry[]>([]);
const { items: floatItems, add: addFloat } = useFloatingItems(1100);
@@ -1651,13 +1669,15 @@ export function ElementsScreen() {
}}
>
<div css={producerSpriteStyle(active)}>
<CharacterSprite
elementId={el.id}
elementColor={el.color}
tier={el.tier}
size={42}
state={(elements[el.id] ?? 0) > 0 ? 'obtained' : 'locked'}
/>
<div css={idleSpriteWrapperStyle(idleMood)}>
<CharacterSprite
elementId={el.id}
elementColor={el.color}
tier={el.tier}
size={42}
state={(elements[el.id] ?? 0) > 0 ? 'obtained' : 'locked'}
/>
</div>
</div>
<div css={producerInfoStyle}>
<div css={producerNameStyle}>

39
src/lib/useIdleMood.ts Normal file
View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
const IDLE_AFTER_MS = 30_000;
const TICK_MS = 2_000;
export type Mood = 'awake' | 'idle';
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
'pointerdown',
'touchstart',
'keydown',
'scroll',
];
export function useIdleMood(idleAfterMs: number = IDLE_AFTER_MS): Mood {
const [mood, setMood] = useState<Mood>('awake');
useEffect(() => {
let lastActivity = Date.now();
const onActivity = () => {
lastActivity = Date.now();
setMood((prev) => (prev === 'awake' ? prev : 'awake'));
};
ACTIVITY_EVENTS.forEach((evt) =>
window.addEventListener(evt, onActivity, { passive: true })
);
const tick = window.setInterval(() => {
if (Date.now() - lastActivity >= idleAfterMs) {
setMood((prev) => (prev === 'idle' ? prev : 'idle'));
}
}, TICK_MS);
return () => {
ACTIVITY_EVENTS.forEach((evt) => window.removeEventListener(evt, onActivity));
window.clearInterval(tick);
};
}, [idleAfterMs]);
return mood;
}