Newer
Older
PixelPaintWar / apps / client / src / app.tsx
import { useEffect, useState, useRef, useCallback } from "react";
import { io, Socket } from "socket.io-client";
// ✅ v7 (安定版) の正しいインポート
import { Stage, Container, Graphics } from "@pixi/react";

const MAP_SIZE = 2000;
const MAX_DIST = 60; // ジョイスティックの可動範囲

type Player = {
  id: string;
  x: number;
  y: number;
  color: string;
};

// 🎨 プレイヤー描画(v7対応)
const PlayerCharacter = ({
  x,
  y,
  color,
  isMe,
}: {
  x: number;
  y: number;
  color: string;
  isMe: boolean;
}) => {
  const draw = useCallback(
    (g: any) => {
      g.clear();
      const hexColor = parseInt(color.replace("#", "0x"), 16) || 0xff0000;

      // v7の書き方: beginFill を使う
      g.beginFill(hexColor);
      g.drawCircle(0, 0, 10);
      g.endFill();

      if (isMe) {
        g.lineStyle(3, 0xffff00);
        g.drawCircle(0, 0, 10);
      }
    },
    [color, isMe],
  );

  return <Graphics draw={draw} x={x} y={y} />;
};

// 🗺️ 背景マップ描画(v7対応)
const GameMap = () => {
  const draw = useCallback((g: any) => {
    g.clear();
    g.beginFill(0x111111);
    g.drawRect(0, 0, MAP_SIZE, MAP_SIZE);
    g.endFill();

    // グリッド線
    g.lineStyle(1, 0x333333);
    for (let i = 0; i <= MAP_SIZE; i += 100) {
      g.moveTo(i, 0).lineTo(i, MAP_SIZE);
      g.moveTo(0, i).lineTo(MAP_SIZE, i);
    }

    // 外枠
    g.lineStyle(5, 0xff4444);
    g.drawRect(0, 0, MAP_SIZE, MAP_SIZE);
  }, []);

  return <Graphics draw={draw} />;
};

export default function App() {
  const [myId, setMyId] = useState<string | null>(null);
  const [players, setPlayers] = useState<Record<string, Player>>({});
  const [viewport, setViewport] = useState({
    w: window.innerWidth,
    h: window.innerHeight,
  });

  // 自作ジョイスティックの状態
  const [isMoving, setIsMoving] = useState(false);
  const [basePos, setBasePos] = useState({ x: 0, y: 0 });
  const [stickPos, setStickPos] = useState({ x: 0, y: 0 });

  const myPosRef = useRef({ x: MAP_SIZE / 2, y: MAP_SIZE / 2 });
  const socketRef = useRef<Socket | null>(null);

  // 🕹️ 操作開始
  const handleStart = (e: React.TouchEvent | React.MouseEvent) => {
    const clientX =
      "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
    const clientY =
      "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
    setBasePos({ x: clientX, y: clientY });
    setStickPos({ x: 0, y: 0 });
    setIsMoving(true);
  };

  // 🕹️ 操作中
  const handleMove = (e: React.TouchEvent | React.MouseEvent) => {
    if (!isMoving) return;
    const clientX =
      "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
    const clientY =
      "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;

    const dx = clientX - basePos.x;
    const dy = clientY - basePos.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const angle = Math.atan2(dy, dx);

    const limitedDist = Math.min(dist, MAX_DIST);
    const moveX = Math.cos(angle) * limitedDist;
    const moveY = Math.sin(angle) * limitedDist;

    setStickPos({ x: moveX, y: moveY });

    if (socketRef.current && myId) {
      const speed = 5.0;
      let nextX = myPosRef.current.x + (moveX / MAX_DIST) * speed;
      let nextY = myPosRef.current.y + (moveY / MAX_DIST) * speed;

      nextX = Math.max(20, Math.min(MAP_SIZE - 20, nextX));
      nextY = Math.max(20, Math.min(MAP_SIZE - 20, nextY));

      myPosRef.current = { x: nextX, y: nextY };

      socketRef.current.emit("move", { x: nextX, y: nextY });
      setPlayers((prev) => {
        if (!prev[myId]) return prev;
        return { ...prev, [myId]: { ...prev[myId], x: nextX, y: nextY } };
      });
    }
  };

  const handleEnd = () => {
    setIsMoving(false);
    setStickPos({ x: 0, y: 0 });
  };

  useEffect(() => {
    const socket = io();
    socketRef.current = socket;

    socket.on("connect", () => setMyId(socket.id || null));
    socket.on("current_players", (serverPlayers: any) => {
      const pMap: Record<string, Player> = {};
      if (Array.isArray(serverPlayers))
        serverPlayers.forEach((p) => (pMap[p.id] = p));
      else Object.assign(pMap, serverPlayers);
      setPlayers(pMap);
    });
    socket.on("new_player", (p: Player) =>
      setPlayers((prev) => ({ ...prev, [p.id]: p })),
    );
    socket.on("update_player", (data: any) => {
      if (data.id === socket.id) return;
      setPlayers((prev) => {
        if (!prev[data.id]) return prev;
        return { ...prev, [data.id]: { ...prev[data.id], ...data } };
      });
    });
    socket.on("remove_player", (id: string) => {
      setPlayers((prev) => {
        const next = { ...prev };
        delete next[id];
        return next;
      });
    });

    const handleResize = () =>
      setViewport({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener("resize", handleResize);
    return () => {
      socket.disconnect();
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  const me = myId ? players[myId] : null;
  const camX = (me?.x || MAP_SIZE / 2) - viewport.w / 2;
  const camY = (me?.y || MAP_SIZE / 2) - viewport.h / 2;

  return (
    <div
      onMouseDown={handleStart}
      onMouseMove={handleMove}
      onMouseUp={handleEnd}
      onMouseLeave={handleEnd}
      onTouchStart={handleStart}
      onTouchMove={handleMove}
      onTouchEnd={handleEnd}
      style={{
        width: "100vw",
        height: "100vh",
        overflow: "hidden",
        backgroundColor: "#000",
        position: "relative",
        touchAction: "none",
      }}
    >
      {/* 🚀 Layer 1: PixiJS描画 (v7) */}
      <div style={{ position: "absolute", top: 0, left: 0, zIndex: 1 }}>
        <Stage
          width={viewport.w}
          height={viewport.h}
          options={{ backgroundColor: 0x000000, antialias: true }}
        >
          <Container position={[-camX, -camY]}>
            <GameMap />
            {Object.values(players).map((p) => (
              <PlayerCharacter
                key={p.id}
                x={p.x}
                y={p.y}
                color={p.color}
                isMe={p.id === myId}
              />
            ))}
          </Container>
        </Stage>
      </div>

      {/* 🕹️ Layer 2: ジョイスティックUI */}
      {isMoving && (
        <div
          style={{
            position: "fixed",
            left: basePos.x - MAX_DIST,
            top: basePos.y - MAX_DIST,
            width: MAX_DIST * 2,
            height: MAX_DIST * 2,
            background: "rgba(255, 255, 255, 0.1)",
            borderRadius: "50%",
            border: "2px solid rgba(255, 255, 255, 0.3)",
            pointerEvents: "none",
            zIndex: 9999,
          }}
        >
          <div
            style={{
              position: "absolute",
              left: "50%",
              top: "50%",
              width: 40,
              height: 40,
              background: "rgba(255, 255, 255, 0.8)",
              borderRadius: "50%",
              transform: `translate(calc(-50% + ${stickPos.x}px), calc(-50% + ${stickPos.y}px))`,
              boxShadow: "0 0 10px rgba(0,0,0,0.5)",
            }}
          />
        </div>
      )}
      <div
        style={{
          position: "absolute",
          top: 10,
          left: 10,
          color: "#4ade80",
          zIndex: 100,
          pointerEvents: "none",
          fontWeight: "bold",
        }}
      >
        ● Online: {Object.keys(players).length}
      </div>
    </div>
  );
}