cffy.in/src/apps/bridge-builder-math/components/GameScene.tsx

201 lines
7.0 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { GameState } from '../types';
import { GAME_CONFIG, COLORS } from '../constants';
import { HardHat, Mountain } from 'lucide-react';
interface GameSceneProps {
gameState: GameState;
feedback?: { type: 'correct' | 'wrong', id: number } | null;
}
interface FloatingText {
id: number;
x: number;
y: number;
text: string;
color: string;
}
export const GameScene: React.FC<GameSceneProps> = ({ gameState, feedback }) => {
const { characterX, bridgeEndX } = gameState;
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(GAME_CONFIG.CANVAS_WIDTH);
const [containerHeight, setContainerHeight] = useState(GAME_CONFIG.CANVAS_HEIGHT);
const [isShaking, setIsShaking] = useState(false);
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
setContainerHeight(entry.contentRect.height);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Handle Feedback
useEffect(() => {
if (!feedback) return;
// Shake on error
if (feedback.type === 'wrong') {
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
}
// Add Floating Text
const newText: FloatingText = {
id: feedback.id,
x: characterX,
y: (containerHeight * 0.65) - 60, // Above character
text: feedback.type === 'correct' ? '+10' : '❌',
color: feedback.type === 'correct' ? 'text-green-500' : 'text-red-500'
};
setFloatingTexts(prev => [...prev, newText]);
// Cleanup text after animation
setTimeout(() => {
setFloatingTexts(prev => prev.filter(ft => ft.id !== feedback.id));
}, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [feedback]);
// Dynamic positioning
const viewOffset = Math.max(0, characterX - containerWidth * 0.3);
// Place bridge at ~65% of screen height, but clamp it reasonable
const bridgeY = containerHeight * 0.65;
return (
<div ref={containerRef} className={`w-full h-full relative overflow-hidden bg-sky-200 border-b-8 border-sky-800/20 shadow-inner rounded-xl ${isShaking ? 'animate-shake' : ''}`}>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.animate-shake {
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes floatUp {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-60px) scale(1.5); opacity: 0; }
}
.animate-float-up {
animation: floatUp 0.8s ease-out forwards;
}
`}</style>
{/* Background Decor */}
<div className="absolute top-10 left-10 opacity-50 text-white">
<Mountain size={64} />
</div>
<div className="absolute top-20 right-20 opacity-50 text-white">
<Mountain size={96} />
</div>
{/* Game World Container with transform for scrolling */}
<div
className="absolute inset-0 will-change-transform"
style={{
transform: `translateX(-${viewOffset}px)`,
transition: 'transform 0.1s linear'
}}
>
{/* Ground (Starting Platform) */}
<div
className="absolute border-r-4 border-amber-900"
style={{
left: 0,
top: bridgeY,
width: GAME_CONFIG.GAP_START,
height: '200vh', // Extend deep to cover tall screens (foldables)
backgroundColor: COLORS.grass,
backgroundImage: `linear-gradient(135deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(225deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(45deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(315deg, ${COLORS.grass} 25%, ${COLORS.dirt} 25%)`,
backgroundPosition: '10px 0, 10px 0, 0 0, 0 0',
backgroundSize: '20px 20px',
backgroundRepeat: 'repeat'
}}
/>
{/* The Abyss (Visual only, behind bridge) */}
<div
className="absolute w-[9999px]"
style={{
left: GAME_CONFIG.GAP_START,
top: bridgeY,
height: '200vh', // Extend deep
backgroundColor: 'transparent',
}}
/>
{/* Bridge Segments */}
<div
className="absolute h-4 shadow-lg border-y-2 border-amber-900"
style={{
left: GAME_CONFIG.GAP_START,
top: bridgeY,
width: Math.max(0, bridgeEndX - GAME_CONFIG.GAP_START),
backgroundColor: COLORS.bridge,
backgroundImage: `repeating-linear-gradient(90deg, transparent, transparent 18px, ${COLORS.bridgeDark} 18px, ${COLORS.bridgeDark} 20px)`,
}}
/>
{/* Character */}
<div
className="absolute transition-transform duration-75 flex flex-col items-center justify-center"
style={{
left: characterX,
top: bridgeY - GAME_CONFIG.CHARACTER_SIZE,
width: GAME_CONFIG.CHARACTER_SIZE,
height: GAME_CONFIG.CHARACTER_SIZE,
transform: `translateY(${gameState.status === 'GAME_OVER' && characterX > bridgeEndX ? '200px' : '0px'}) rotate(${gameState.status === 'GAME_OVER' && characterX > bridgeEndX ? '90deg' : '0deg'})`,
transition: 'transform 0.5s ease-in'
}}
>
<HardHat size={32} color={COLORS.character} fill="orange" />
<div className="w-4 h-8 bg-blue-600 rounded-b-lg -mt-1"></div>
{/* Legs animation simulation */}
<div className="flex w-full justify-between px-1">
<div className="w-1 h-3 bg-black animate-pulse"></div>
<div className="w-1 h-3 bg-black animate-pulse delay-75"></div>
</div>
</div>
{/* Next Platform Marker (Visual Goal) */}
<div
className="absolute border-l-4 border-amber-900 flex items-center justify-center text-white/50 font-bold text-4xl"
style={{
left: bridgeEndX + 300, // Always ahead
top: bridgeY + 50,
width: 100,
height: '200vh', // Extend deep
backgroundColor: COLORS.grass,
opacity: 0.5
}}
>
GOAL &rarr;
</div>
{/* Floating Texts */}
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`absolute font-black text-4xl ${ft.color} animate-float-up pointer-events-none`}
style={{ left: ft.x, top: ft.y }}
>
{ft.text}
</div>
))}
</div>
</div>
);
};