touch-controls.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. "use client";
  2. import { useEffect, useRef, useCallback } from "react";
  3. import { ArrowUp } from "lucide-react";
  4. import { touchMoveState } from "@/lib/touch-state";
  5. import { useEditorStore } from "@/lib/store";
  6. const JOYSTICK_SIZE = 120;
  7. const KNOB_SIZE = 48;
  8. const MAX_DIST = (JOYSTICK_SIZE - KNOB_SIZE) / 2;
  9. export function TouchControls() {
  10. const isPlaying = useEditorStore((s) => s.isPlaying);
  11. const health = useEditorStore((s) => s.health);
  12. const joystickRef = useRef<HTMLDivElement>(null);
  13. const knobRef = useRef<HTMLDivElement>(null);
  14. const joystickTouchId = useRef<number | null>(null);
  15. const joystickCenter = useRef<{ x: number; y: number } | null>(null);
  16. const lookTouchRef = useRef<{ x: number; y: number } | null>(null);
  17. const isGameOver = health <= 0;
  18. const resetJoystick = useCallback(() => {
  19. joystickTouchId.current = null;
  20. joystickCenter.current = null;
  21. touchMoveState.forward = 0;
  22. touchMoveState.right = 0;
  23. touchMoveState.sprint = false;
  24. if (knobRef.current) {
  25. knobRef.current.style.transform = "translate(-50%, -50%)";
  26. }
  27. }, []);
  28. useEffect(() => {
  29. if (!isPlaying || isGameOver) {
  30. resetJoystick();
  31. return;
  32. }
  33. const zone = joystickRef.current;
  34. if (!zone) return;
  35. const handleStart = (e: TouchEvent) => {
  36. if (joystickTouchId.current !== null) return;
  37. for (let i = 0; i < e.changedTouches.length; i++) {
  38. const t = e.changedTouches[i]!;
  39. const rect = zone.getBoundingClientRect();
  40. if (
  41. t.clientX >= rect.left &&
  42. t.clientX <= rect.right &&
  43. t.clientY >= rect.top &&
  44. t.clientY <= rect.bottom
  45. ) {
  46. joystickTouchId.current = t.identifier;
  47. joystickCenter.current = { x: t.clientX, y: t.clientY };
  48. break;
  49. }
  50. }
  51. };
  52. const handleMove = (e: TouchEvent) => {
  53. if (joystickTouchId.current === null || !joystickCenter.current) return;
  54. for (let i = 0; i < e.changedTouches.length; i++) {
  55. const t = e.changedTouches[i]!;
  56. if (t.identifier !== joystickTouchId.current) continue;
  57. let dx = t.clientX - joystickCenter.current.x;
  58. let dy = t.clientY - joystickCenter.current.y;
  59. const dist = Math.sqrt(dx * dx + dy * dy);
  60. const clamped = Math.min(dist, MAX_DIST);
  61. if (dist > 0) {
  62. dx = (dx / dist) * clamped;
  63. dy = (dy / dist) * clamped;
  64. }
  65. const normalized = dist > 0 ? Math.min(dist / MAX_DIST, 1) : 0;
  66. touchMoveState.right = (dx / MAX_DIST) * normalized;
  67. touchMoveState.forward = -(dy / MAX_DIST) * normalized;
  68. if (knobRef.current) {
  69. knobRef.current.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
  70. }
  71. break;
  72. }
  73. };
  74. const handleEnd = (e: TouchEvent) => {
  75. if (joystickTouchId.current === null) return;
  76. for (let i = 0; i < e.changedTouches.length; i++) {
  77. if (e.changedTouches[i]!.identifier === joystickTouchId.current) {
  78. resetJoystick();
  79. break;
  80. }
  81. }
  82. };
  83. zone.addEventListener("touchstart", handleStart, { passive: true });
  84. window.addEventListener("touchmove", handleMove, { passive: true });
  85. window.addEventListener("touchend", handleEnd, { passive: true });
  86. window.addEventListener("touchcancel", handleEnd, { passive: true });
  87. return () => {
  88. zone.removeEventListener("touchstart", handleStart);
  89. window.removeEventListener("touchmove", handleMove);
  90. window.removeEventListener("touchend", handleEnd);
  91. window.removeEventListener("touchcancel", handleEnd);
  92. resetJoystick();
  93. };
  94. }, [isPlaying, isGameOver, resetJoystick]);
  95. const handleLookStart = useCallback((e: React.TouchEvent) => {
  96. const touch = e.touches[0];
  97. if (touch) {
  98. lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
  99. }
  100. }, []);
  101. const handleLookMove = useCallback((e: React.TouchEvent) => {
  102. const touch = e.touches[0];
  103. if (!touch || !lookTouchRef.current) return;
  104. touchMoveState.lookDeltaX +=
  105. (touch.clientX - lookTouchRef.current.x) * 0.004;
  106. touchMoveState.lookDeltaY +=
  107. (touch.clientY - lookTouchRef.current.y) * 0.004;
  108. lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
  109. }, []);
  110. const handleLookEnd = useCallback(() => {
  111. lookTouchRef.current = null;
  112. }, []);
  113. const handleJump = useCallback(() => {
  114. touchMoveState.jump = true;
  115. setTimeout(() => {
  116. touchMoveState.jump = false;
  117. }, 150);
  118. }, []);
  119. if (!isPlaying || isGameOver) return null;
  120. return (
  121. <>
  122. {/* Joystick zone (left half, bottom) */}
  123. <div
  124. ref={joystickRef}
  125. className="fixed left-0 bottom-0 z-20 pointer-events-auto"
  126. style={{ width: "45vw", height: "40vh" }}
  127. >
  128. {/* Joystick base */}
  129. <div
  130. className="absolute rounded-full border border-white/20 bg-white/5"
  131. style={{
  132. width: JOYSTICK_SIZE,
  133. height: JOYSTICK_SIZE,
  134. left: 60 - JOYSTICK_SIZE / 2,
  135. bottom: 60 - JOYSTICK_SIZE / 2,
  136. }}
  137. >
  138. {/* Knob */}
  139. <div
  140. ref={knobRef}
  141. className="absolute left-1/2 top-1/2 rounded-full bg-white/25 border border-white/30"
  142. style={{
  143. width: KNOB_SIZE,
  144. height: KNOB_SIZE,
  145. transform: "translate(-50%, -50%)",
  146. }}
  147. />
  148. </div>
  149. </div>
  150. {/* Look zone (right half) */}
  151. <div
  152. className="fixed right-0 top-10 bottom-0 z-20 pointer-events-auto"
  153. style={{ width: "55vw" }}
  154. onTouchStart={handleLookStart}
  155. onTouchMove={handleLookMove}
  156. onTouchEnd={handleLookEnd}
  157. onTouchCancel={handleLookEnd}
  158. />
  159. {/* Jump button */}
  160. <button
  161. onTouchStart={handleJump}
  162. className="fixed bottom-6 right-6 z-30 w-14 h-14 rounded-full bg-white/15 backdrop-blur-sm border border-white/20 flex items-center justify-center active:bg-white/30 pointer-events-auto"
  163. >
  164. <ArrowUp size={24} className="text-white/70" />
  165. </button>
  166. </>
  167. );
  168. }