201 lines
7.0 KiB
TypeScript
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 →
|
|
</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>
|
|
);
|
|
}; |