Newer
Older
PixelPaintWar / apps / client / src / app.tsx
import { useEffect, useState, useRef } from "react";
import { io, Socket } from "socket.io-client";

// 設定:マップの広さ(全員共通)
const MAP_SIZE = 2000;
// ジョイスティックの設定(Joystick.ts のロジックを参考)
const MAX_DIST = 60; 

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

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 initializedRef = useRef(false);

  // 1. タッチ/クリック開始:ジョイスティックの拠点を決める
  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);
  };

  // 2. 移動中:スティックを動かし、プレイヤーを移動させる
  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);
    
    // スティックが外枠からはみ出さないように制限 (Joystick.ts のロジック)
    const limitedDist = Math.min(dist, MAX_DIST);
    const moveX = Math.cos(angle) * limitedDist;
    const moveY = Math.sin(angle) * limitedDist;

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

    // 入力値の正規化 (-1.0 ~ 1.0)
    const inputX = moveX / MAX_DIST;
    const inputY = moveY / MAX_DIST;

    if (!socketRef.current || !myId) return;

    // 移動の実行
    const speed = 5.0; 
    let nextX = myPosRef.current.x + (inputX * speed);
    let nextY = myPosRef.current.y + (inputY * speed);

    // 壁判定
    if (nextX < 20) nextX = 20;
    if (nextX > MAP_SIZE - 20) nextX = MAP_SIZE - 20;
    if (nextY < 20) nextY = 20;
    if (nextY > MAP_SIZE - 20) nextY = MAP_SIZE - 20;

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

    // 自分の画面を即座に更新
    setPlayers(prev => {
      if (!prev[myId]) return prev;
      return { ...prev, [myId]: { ...prev[myId], x: nextX, y: nextY } };
    });

    // サーバーへ送信
    socketRef.current.emit("move", { x: nextX, y: nextY });
  };

  // 3. 終了:ジョイスティックをリセット
  const handleEnd = () => {
    setIsMoving(false);
    setStickPos({ x: 0, y: 0 });
  };

  // 初期位置同期
  useEffect(() => {
    if (!myId || initializedRef.current) return;
    const me = players[myId];
    if (me) {
      myPosRef.current = { x: me.x, y: me.y };
      initializedRef.current = true;
    }
  }, [players, myId]);

  // Socket通信設定
  useEffect(() => {
    const handleResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener("resize", handleResize);

    socketRef.current = io();
    const socket = socketRef.current;

    socket.on("connect", () => setMyId(socket.id || null));
    socket.on("current_players", (serverPlayers: Player[]) => {
      const pMap: Record<string, Player> = {};
      serverPlayers.forEach(p => pMap[p.id] = p);
      setPlayers(pMap);
    });
    socket.on("new_player", (p: Player) => setPlayers(prev => ({ ...prev, [p.id]: p })));
    socket.on("update_player", (d: { id: string; x: number; y: number }) => {
      if (d.id === socket.id) return; 
      setPlayers(prev => {
        const target = prev[d.id];
        if (!target) return prev; 
        return { ...prev, [d.id]: { ...target, x: d.x, y: d.y } };
      });
    });
    socket.on("remove_player", (id: string) => {
      setPlayers(prev => {
        const next = { ...prev };
        delete next[id];
        return next;
      });
    });

    return () => {
      socket.disconnect();
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  // カメラ計算
  let cameraX = 0;
  let cameraY = 0;
  if (myId && players[myId]) {
    const me = players[myId];
    cameraX = me.x - viewport.w / 2;
    cameraY = me.y - 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", 
        background: "#111", position: "relative", touchAction: "none"
      }}
    >
      
      {/* UIレイヤー */}
      <div style={{ position: "fixed", top: 10, left: 10, zIndex: 1000, background: "rgba(0,0,0,0.6)", padding: "10px", borderRadius: "8px", color: "white", pointerEvents: "none" }}>
        <div style={{ color: "#4ade80", fontWeight: "bold" }}>● Online: {Object.keys(players).length}</div>
        <div style={{ fontSize: "12px", color: "#ccc" }}>ID: {myId?.slice(0, 4)}</div>
      </div>

      {/* ゲームワールド */}
      <div style={{
        position: "absolute",
        width: `${MAP_SIZE}px`, height: `${MAP_SIZE}px`,
        transform: `translate(${-cameraX}px, ${-cameraY}px)`,
        backgroundImage: "linear-gradient(#333 1px, transparent 1px), linear-gradient(90deg, #333 1px, transparent 1px)",
        backgroundSize: "100px 100px",
        backgroundColor: "#222",
        border: "5px solid #ff4444"
      }}>
        {Object.values(players).map((p) => (
          <div
            key={p.id}
            style={{
              position: "absolute",
              left: 0, top: 0,
              transform: `translate(${p.x}px, ${p.y}px)`, 
              width: "20px", height: "20px",
              background: p.color || "red",
              borderRadius: "50%",
              border: p.id === myId ? "2px solid yellow" : "none",
              boxShadow: p.id === myId ? "0 0 15px rgba(255,255,0,0.5)" : "none",
              zIndex: p.id === myId ? 100 : 1,
              marginTop: "-10px", marginLeft: "-10px",
              // 自分は即時反映、他人は滑らかに
              transition: p.id === myId ? "none" : "transform 0.1s linear",
            }}
          >
             <div style={{ position: "absolute", top: -25, left: "50%", transform: "translateX(-50%)", color: "white", fontSize: "10px", whiteSpace: "nowrap", pointerEvents: "none" }}>
              {p.id.slice(0,4)}
            </div>
          </div>
        ))}
      </div>

      {/* 自作ジョイスティックの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.2)",
          pointerEvents: "none",
          zIndex: 2000
        }}>
          {/* 内側の動くスティック部分 */}
          <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>
  );
}

export default App;