flutterflight source

App.tsx

import { useEffect, useState } from 'react';
import { StatusBar, useWindowDimensions } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import { Block, Button, PlocksProvider, SafeArea, Text } from '@plocks/ui';

const COLORS = {
  sky: '#83D5EF',
  ink: '#12334B',
  pipe: '#55A947',
  pipeBorder: '#327E39',
} as const;
const BIRD_X = 54;
const BIRD_SIZE = 28;
const PIPE_WIDTH = 52;
const GAP_HEIGHT = 120;
const TICK_MS = 40;

type Size = { width: number; height: number };
type Game = {
  bird: number;
  velocity: number;
  pipe: number;
  gap: number;
  score: number;
  running: boolean;
  over: boolean;
  passed: boolean;
};

const gapHeight = (height: number) => Math.min(GAP_HEIGHT, Math.max(80, height * 0.24));
const randomGap = (height: number) => 40 + Math.random() * Math.max(0, height - gapHeight(height) - 80);
const fresh = ({ width, height }: Size): Game => ({
  bird: Math.max(0, height / 2 - BIRD_SIZE / 2),
  velocity: 0,
  pipe: width,
  gap: randomGap(height),
  score: 0,
  running: false,
  over: false,
  passed: false,
});

export default function App() {
  const window = useWindowDimensions();
  const [size, setSize] = useState<Size>(() => ({ width: window.width, height: window.height }));
  const [game, setGame] = useState<Game>(() => fresh({ width: window.width, height: window.height }));

  const flap = () =>
    setGame((old) =>
      old.over
        ? { ...fresh(size), running: true, velocity: -6 }
        : { ...old, running: true, velocity: -6 },
    );

  useEffect(() => {
    if (!game.running || game.over) return;
    const timer = setInterval(() => {
      setGame((old) => {
        if (!old.running || old.over) return old;
        const velocity = Math.min(8, old.velocity + 0.45);
        const bird = old.bird + velocity;
        const movedPipe = old.pipe - Math.max(3, Math.min(8, size.width / 120));
        const wrapped = movedPipe < -PIPE_WIDTH;
        const pipe = wrapped ? size.width : movedPipe;
        const gap = wrapped ? randomGap(size.height) : old.gap;
        const hitPipe = pipe < BIRD_X + BIRD_SIZE && pipe + PIPE_WIDTH > BIRD_X;
        const collision =
          bird < 0 ||
          bird > size.height - BIRD_SIZE ||
          (hitPipe && (bird < gap || bird + BIRD_SIZE > gap + gapHeight(size.height)));
        const passed = pipe + PIPE_WIDTH < BIRD_X;
        return {
          ...old,
          bird,
          velocity,
          pipe,
          gap,
          over: collision,
          running: !collision,
          score: old.score + (passed && !old.passed ? 1 : 0),
          passed: wrapped ? false : passed,
        };
      });
    }, TICK_MS);
    return () => clearInterval(timer);
  }, [game.running, game.over, size]);

  return (
    <SafeAreaProvider>
      <PlocksProvider theme={{ colorScheme: 'light' }}>
        <SafeArea flex={1} gap={0} bg={COLORS.sky}>
          <StatusBar barStyle="dark-content" />
          <Block
            flex={1}
            gap={0}
            overflow="hidden"
            bg={COLORS.sky}
            onLayout={({ nativeEvent: { layout } }) => {
              const next = { width: layout.width, height: layout.height };
              if (
                next.width > 0 &&
                next.height > 0 &&
                (Math.abs(next.width - size.width) > 1 || Math.abs(next.height - size.height) > 1)
              ) {
                setSize(next);
                setGame(fresh(next));
              }
            }}
          >
            <Block
              gap={0}
              pointerEvents="none"
              position="absolute"
              left={game.pipe}
              top={0}
              w={PIPE_WIDTH}
              h={game.gap}
              bg={COLORS.pipe}
              borderWidth={3}
              borderColor={COLORS.pipeBorder}
            />
            <Block
              gap={0}
              pointerEvents="none"
              position="absolute"
              left={game.pipe}
              top={game.gap + gapHeight(size.height)}
              w={PIPE_WIDTH}
              h={size.height}
              bg={COLORS.pipe}
              borderWidth={3}
              borderColor={COLORS.pipeBorder}
            />
            <Block
              gap={0}
              pointerEvents="none"
              position="absolute"
              left={BIRD_X}
              top={game.bird}
              w={BIRD_SIZE}
              h={BIRD_SIZE}
              radius={BIRD_SIZE / 2}
              bg="#FFD34F"
              borderWidth={2}
              borderColor="#B87519"
            />
            <Block
              gap={0}
              position="absolute"
              inset={0}
              touchAction="none"
              onStartShouldSetResponder={() => true}
              onResponderRelease={flap}
              accessibilityRole="button"
              accessibilityLabel="Flap"
            />
            <Block
              gap={0}
              pointerEvents="box-none"
              position="absolute"
              top={16}
              left={20}
              right={20}
              direction="row"
              align="center"
              justify="space-between"
            >
              <Block gap={0} pointerEvents="none">
                <Text c={COLORS.ink} size={11} fw="bold" lts={1}>SCORE</Text>
                <Text c={COLORS.ink} size={30} fw="bold">{game.score}</Text>
              </Block>
              <Button title="Restart" color="#ED8B21" onPress={() => setGame(fresh(size))} />
            </Block>
            {(!game.running || game.over) && (
              <Block
                gap={0}
                pointerEvents="none"
                position="absolute"
                top="45%"
                alignSelf="center"
                bg="#FFFFFFE6"
                p={14}
                radius={12}
              >
                <Text c={COLORS.ink} fw="bold">
                  {game.over ? 'Game over ยท tap to retry' : 'Tap to start'}
                </Text>
              </Block>
            )}
          </Block>
        </SafeArea>
      </PlocksProvider>
    </SafeAreaProvider>
  );
}

index.ts

import { registerRootComponent } from 'expo';

import App from './App';

registerRootComponent(App);