Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
aa1dafa
加速床の型定義を追加
Rn86222 Apr 30, 2026
872ff23
加速床コンポーネントの実装
Rn86222 Apr 30, 2026
9634b0c
加速床の物理演算ロジック(速度強制上書き)をBallに実装
Rn86222 Apr 30, 2026
fdce50a
加速床の連鎖を利用したLevel 5を追加
Rn86222 Apr 30, 2026
2b44604
レビューへの対応
Rn86222 Apr 30, 2026
d0532b7
Merge branch 'main' of https://github.com/ut-code/billiards-game into…
Rn86222 May 6, 2026
cf6093c
試しに作成したlevel6を削除してlevel8(爆弾+加速床の罠)を作成
Rn86222 May 6, 2026
06fbb50
加速床のSEを追加
Rn86222 May 6, 2026
e0598fe
加速床の型定義を見直し
Rn86222 May 6, 2026
cf0443c
level2, level3のボールの配置を変更
faithia-anastasia May 6, 2026
b3ec45b
マグネットコントロールアイコンと入力方向のUIを作成
faithia-anastasia May 6, 2026
2b474b5
遊び方説明を追加
faithia-anastasia May 6, 2026
eafcbb3
ステージ開始時のモーダルを作成
faithia-anastasia May 6, 2026
2e3886d
ギミックの説明プロパティをlevelに追加
faithia-anastasia May 6, 2026
5fe6531
levelsのdescriptionとgimmicの文章の内容を修正
faithia-anastasia May 6, 2026
311a96b
Merge branch 'main' of https://github.com/ut-code/billiards-game into…
Rn86222 May 6, 2026
021dab0
merge conflict解消時のミスを修正
Rn86222 May 6, 2026
d0b9a25
levelsのdescriptionのボール数を修正
faithia-anastasia May 6, 2026
862468c
Merge pull request #19 from ut-code/feat/acceleration-floor
Rn86222 May 6, 2026
0f8f169
level2, level3のボールの配置を変更
faithia-anastasia May 6, 2026
07369bb
マグネットコントロールアイコンと入力方向のUIを作成
faithia-anastasia May 6, 2026
838689d
遊び方説明を追加
faithia-anastasia May 6, 2026
7ec96ed
ステージ開始時のモーダルを作成
faithia-anastasia May 6, 2026
1d038ef
ギミックの説明プロパティをlevelに追加
faithia-anastasia May 6, 2026
6d3e70f
levelsのdescriptionとgimmicの文章の内容を修正
faithia-anastasia May 6, 2026
9e67170
levelsのdescriptionのボール数を修正
faithia-anastasia May 6, 2026
c20db9d
磁石とインフォメーションのアイコンを変更
faithia-anastasia May 6, 2026
8e45833
Merge branch 'fix/stage-adjustment' of github.com:ut-code/billiards-g…
faithia-anastasia May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
"@react-three/cannon": "^6.6.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"lucide-react": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.1",
"three": "^0.183.2"
},
Expand Down
Binary file added public/acceleration_floor_se.mp3
Binary file not shown.
91 changes: 91 additions & 0 deletions src/gamescene/components/AccelerationFloor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { AccelerationFloorConfig } from "../constants/levels";

type AccelerationFloorProps = {
config: AccelerationFloorConfig;
};

export function AccelerationFloor({ config }: AccelerationFloorProps) {
// 矢印の流れるテクスチャを Canvas API で生成
const texture = useMemo(() => {
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (ctx) {
// 背景を少し暗く
ctx.fillStyle = "rgba(0, 0, 0, 0.4)";
ctx.fillRect(0, 0, 256, 256);

// 矢印の描画設定
ctx.strokeStyle = "#ffaa00";
ctx.lineWidth = 20;
ctx.lineCap = "round";
ctx.lineJoin = "round";

// 矢印を描画
const drawArrow = (yOffset: number) => {
ctx.beginPath();
ctx.moveTo(16, yOffset + 112);
ctx.lineTo(128, yOffset);
ctx.lineTo(240, yOffset + 112);
ctx.stroke();
};

// y軸マイナス方向が進行方向になるように描画
// 切れ目なくループするように配置 (16と144でちょうど128px間隔)
drawArrow(16);
drawArrow(144);
}

const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
// 幅に対して1回、高さはアスペクト比を維持してリピート
tex.repeat.set(1, config.size[1] / config.size[0]);
return tex;
}, [config.size]);

// メモリリークを防ぐため、コンポーネントのアンマウント時またはテクスチャ再生成時に古いテクスチャを破棄
useEffect(() => {
return () => {
texture.dispose();
};
}, [texture]);

const materialRef = useRef<THREE.MeshStandardMaterial>(null);

// 毎フレーム、テクスチャをずらして流れるアニメーションを実現
useFrame((_, delta) => {
const map = materialRef.current?.map;
if (map) {
// テクスチャを上方向(V軸マイナス方向)へスクロールして、矢印の向きに流れるようにする
// RepeatWrapping を使っているため、オフセットは 0..1 の範囲に正規化して精度低下を防ぐ
map.offset.y = (((map.offset.y - delta * 2.5) % 1) + 1) % 1;
}
});

// direction ベクトルからY軸の回転角度を計算
// Z軸(0, 0, 1) を基準とした角度
const angle = Math.atan2(config.direction[0], config.direction[2]);
// 矢印(テクスチャの上部)が指定された方向を向くように補正 (+ Math.PI)
const visualAngle = angle + Math.PI;

return (
<group position={config.position} rotation={[0, visualAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.005, 0]}>
<planeGeometry args={[config.size[0], config.size[1]]} />
<meshStandardMaterial
ref={materialRef}
map={texture}
transparent
opacity={0.8}
emissive="#ffaa00"
emissiveIntensity={0.2}
/>
</mesh>
</group>
);
}
77 changes: 75 additions & 2 deletions src/gamescene/components/Ball.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useSphere } from "@react-three/cannon";
import { useTexture } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { PortalConfig } from "../constants/levels";
import type {
AccelerationFloorConfig,
PortalConfig,
} from "../constants/levels";
import { BALL_RADIUS } from "../constants/physics";
import { POCKET_Y_THRESHOLD } from "./billiardTable";
export type ShootFn = (power: number) => boolean;
Expand All @@ -25,6 +28,7 @@ type BallProps = {
portal?: PortalConfig;
portals?: PortalConfig[];
allowMagnet?: boolean;
accelerationFloors?: AccelerationFloorConfig[];
};

function isInsidePortal(
Expand All @@ -51,6 +55,7 @@ export function Ball({
portal,
portals,
allowMagnet,
accelerationFloors,
}: BallProps) {
const texture = useTexture(textureUrl);

Expand Down Expand Up @@ -79,6 +84,7 @@ export function Ball({
const hasPocketed = useRef(false);
const lastVelocityRef = useRef<[number, number, number]>([0, 0, 0]);
const lastTeleportAtRef = useRef(0);
const floorsOnRef = useRef<Set<string>>(new Set());
const portalWarpAudioRef = useRef<HTMLAudioElement | null>(null);
const keys = useRef<Record<string, boolean>>({});

Expand Down Expand Up @@ -138,13 +144,38 @@ export function Ball({
);
}
});
const dashAudioRef = useRef<HTMLAudioElement | null>(null);

// 加速床の判定に必要な計算(角度、三角関数、半サイズ、正規化ベクトル)を事前計算
const processedFloors = useMemo(() => {
if (!accelerationFloors) return null;
return accelerationFloors.map((floor) => {
const angle = Math.atan2(floor.direction[0], floor.direction[2]);
return {
...floor,
sinAngle: Math.sin(-angle),
cosAngle: Math.cos(-angle),
halfWidth: floor.size[0] / 2,
halfLength: floor.size[1] / 2,
normalizedDir: new THREE.Vector3(
floor.direction[0],
floor.direction[1],
floor.direction[2],
).normalize(),
};
});
}, [accelerationFloors]);

useEffect(() => {
portalWarpAudioRef.current = new Audio(PORTAL_WARP_SOUND_URL);
portalWarpAudioRef.current.volume = 0.35;

dashAudioRef.current = new Audio("/acceleration_floor_se.mp3");
dashAudioRef.current.volume = 0.5;

return () => {
portalWarpAudioRef.current = null;
dashAudioRef.current = null;
};
}, []);

Expand Down Expand Up @@ -179,6 +210,10 @@ export function Ball({
}, [api.velocity, api.angularVelocity, id, onMovingChange]);

useEffect(() => {
// 物理サブスクリプションが再生成される(床などの環境が更新された)タイミングで
// 過去に乗っていた床の履歴をクリアする
floorsOnRef.current.clear();

const unsubscribe = api.position.subscribe((p) => {
onPositionChange?.(id, [p[0], p[1], p[2]]);

Expand Down Expand Up @@ -207,6 +242,42 @@ export function Ball({
}
}

if (processedFloors) {
processedFloors.forEach((floor) => {
const dx = p[0] - floor.position[0];
const dz = p[2] - floor.position[2];

// 事前計算済みの値を使ってローカル座標に変換
const localX = dx * floor.cosAngle - dz * floor.sinAngle;
const localZ = dx * floor.sinAngle + dz * floor.cosAngle;

const isInside =
Math.abs(localX) <= floor.halfWidth &&
Math.abs(localZ) <= floor.halfLength;
const wasInside = floorsOnRef.current.has(floor.id);

if (isInside && !wasInside) {
// 床に入った瞬間、速度とスピンを完全に上書きする
const dir = floor.normalizedDir;

// 速度(velocity)を強制上書き。strength を直接のスピードとして扱う
api.velocity.set(dir.x * floor.strength, 0, dir.z * floor.strength);

// 直進後に変なカーブを描かないように、ボールの回転(スピン)をリセット
api.angularVelocity.set(0, 0, 0);
floorsOnRef.current.add(floor.id);

if (dashAudioRef.current) {
dashAudioRef.current.currentTime = 0;
void dashAudioRef.current.play();
}
} else if (!isInside && wasInside) {
// 床から出た
floorsOnRef.current.delete(floor.id);
}
});
}

if (hasPocketed.current) return;

if (p[1] <= POCKET_Y_THRESHOLD) {
Expand All @@ -226,6 +297,8 @@ export function Ball({
onPositionChange,
portal,
portals,
processedFloors,
api.angularVelocity,
]);

useEffect(() => {
Expand Down
Loading