Skip to content

Commit

Permalink
feat: キャラ編集画面のAPI連携 (#49)
Browse files Browse the repository at this point in the history
  • Loading branch information
shun-shobon authored Nov 5, 2024
2 parents 95c44fc + a41859d commit 8e7dbfa
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 181 deletions.
126 changes: 70 additions & 56 deletions app/components/ModelConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,77 @@
import { useEffect, useState } from "react";
import { ACCESSORY_LIST, MODEL_LIST } from "~/features/profile/Profile";
import type {
Accessory,
CharacterSetting,
Model,
} from "~/features/profile/Profile";

interface ModelListProps {
onModelSelect: (modelPath: string) => void;
updateVisibility: (key: string, value: boolean) => void;
updateColor: (key: string, value: string) => void;
interface ModelConfigProps {
characterSetting: CharacterSetting;
onModelSelect?: (model: Model) => void;
onAccessorySelect?: (accessory: Accessory) => void;
onHairColorChange?: (color: string) => void;
}

export function ModelConfig({
characterSetting,
onModelSelect,
updateVisibility,
updateColor,
}: ModelListProps) {
const [models, setModels] = useState<string[]>([]);
const [containerWidth, setContainerWidth] = useState(0);
const [selectedColors, setSelectedColors] = useState<Record<string, string>>(
{},
);

useEffect(() => {
const fetchModels = async () => {
const modelFiles = [
"/models/web_asuka.glb",
"/models/web_jiraichan.glb",
"/models/web_kaiju.glb",
"/models/web_necochan.glb",
"/models/web_sushong.glb",
];
setModels(modelFiles);
};

fetchModels();

const updateContainerWidth = () => {
const containerRef = document.getElementById("model-list-container");
if (containerRef) {
setContainerWidth(containerRef.offsetWidth);
}
};

updateContainerWidth();
window.addEventListener("resize", updateContainerWidth);
onAccessorySelect,
onHairColorChange,
}: ModelConfigProps) {
return (
<div className="mx-auto grid w-full max-w-screen-sm gap-y-4 px-4">
<div className="flex gap-4">
<span className="flex-shrink-0">モデル:</span>
<div className="flex flex-grow flex-wrap justify-between gap-2">
{MODEL_LIST.map((model) => (
<label key={model}>
<input
type="radio"
name="model"
value={model}
checked={model === characterSetting.character}
onChange={() => onModelSelect?.(model)}
/>
<span>{model}</span>
</label>
))}
</div>
</div>

return () => window.removeEventListener("resize", updateContainerWidth);
}, []);
<div className="flex gap-4">
<span className="flex-shrink-0">アクセサリー:</span>
<div className="flex flex-grow flex-wrap justify-between gap-2">
{ACCESSORY_LIST.map((accessory) => (
<label key={accessory}>
<input
type="radio"
name="accessory"
value={accessory}
checked={accessory === characterSetting.accessory}
onChange={() => onAccessorySelect?.(accessory)}
/>
<span>{accessory}</span>
</label>
))}
</div>
</div>

// ボタンの幅を計算
const buttonWidth = `${Math.floor(containerWidth / models.length) - 16}px`;
// 表示非表示のキー
const visibilityKeys = [
"accessoryeyepatch",
"accessoryglasses",
"goggle",
"accessoryhalo",
];
<div>
<label className="flex gap-4">
<span className="flex-shrink-0">髪の色:</span>
<input
type="color"
value={characterSetting.hair}
onChange={(e) => {
// TODO: 色の更新処理が頻繁に呼び出されてしまうため、debounceする
onHairColorChange?.(e.target.value);
}}
/>
</label>
</div>

return (
<div>
{/* モデル選択ボタン */}
<div
{/* <div
id="model-list-container"
className="mb-4 flex flex-wrap justify-center space-x-2"
>
Expand All @@ -72,9 +86,9 @@ export function ModelConfig({
{model.split("/").pop()?.split("_")[1].split(".")[0]}
</button>
))}
</div>
</div> */}
{/* 表示非表示ボタン */}
<div className="flex flex-col items-center">
{/* <div className="flex flex-col items-center">
{visibilityKeys.map((key) => (
<div key={key} className="flex items-center space-x-2">
<span>{key}</span>
Expand All @@ -94,9 +108,9 @@ export function ModelConfig({
</button>
</div>
))}
</div>
</div> */}
{/* 色変更ボタン */}
<div className="flex items-center justify-center space-x-2">
{/* <div className="flex items-center justify-center space-x-2">
<span className="font-medium">Hair Color:</span>
<input
type="color"
Expand All @@ -108,7 +122,7 @@ export function ModelConfig({
className="h-8 w-8 cursor-pointer rounded border border-gray-300"
title="髪の色を変更"
/>
</div>
</div> */}
</div>
);
}
180 changes: 110 additions & 70 deletions app/components/ModelLoad.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,136 @@
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
import { OrbitControls, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { Bloom, EffectComposer } from "@react-three/postprocessing";
import { type ReactNode, useEffect, useRef } from "react";
import type { Group, MeshStandardMaterial } from "three";
import type * as THREE from "three";
import * as THREE from "three";
import type { Accessory, CharacterSetting } from "~/features/profile/Profile";

function Model({
path,
colorMap,
meshVisibility,
}: {
path: string;
colorMap: { [key: string]: string };
meshVisibility: { [key: string]: boolean };
}): ReactNode {
const { scene } = useGLTF(path);
// モデルのグループ(オブジェクト全体)にアクセスするための参照を作成
const groupRef = useRef<Group>(null);
const HAIR_MESH_NAMES = [
"hairear",
"hairback",
"hairfront",
"hairtail",
"hairside",
"hairsid",
"hair",
] as const;
type HairMeshName = (typeof HAIR_MESH_NAMES)[number];

const ACCESSORY_METH_NAMES = [
"accessoryeyepatch",
"accessoryglasses",
"goggle",
"goggle_1",
"accessoryhalo",
] as const;
type AccessoryMeshName = (typeof ACCESSORY_METH_NAMES)[number];

const ACCESSORY_MAP: Record<Accessory, AccessoryMeshName[]> = {
none: [],
eyepatch: ["accessoryeyepatch"],
glasses: ["accessoryglasses"],
goggle: ["goggle", "goggle_1"],
halo: ["accessoryhalo"],
};

function useCharacterSetting(setting: CharacterSetting) {
const modelPath = `/models/web_${setting.character}.glb`;

const { scene } = useGLTF(modelPath);

useEffect(() => {
scene.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
const threeTone = new THREE.TextureLoader().load("/assets/threeTone.jpg");
threeTone.minFilter = THREE.NearestFilter;
threeTone.magFilter = THREE.NearestFilter;

// メッシュ名をコンソールに出力
// console.log("Mesh name:", mesh.name);
scene.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) return;
const mesh = child as THREE.Mesh;
const oldMaterial = mesh.material as MeshStandardMaterial;
const newMaterial = new THREE.MeshToonMaterial({
name: oldMaterial.name,
color: oldMaterial.color,
gradientMap: threeTone,
map: oldMaterial.map,
emissive: oldMaterial.emissive,
emissiveIntensity: oldMaterial.emissiveIntensity,
emissiveMap: oldMaterial.emissiveMap,
});
mesh.material = newMaterial;
});
}, [scene]);

// 部位名がmeshVisibilityのキーに一致する場合に表示非表示を設定
if (
Object.hasOwn(meshVisibility, mesh.name) &&
!meshVisibility[mesh.name]
) {
mesh.visible = false;
return;
}
useEffect(() => {
scene.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) return;
const mesh = child as THREE.Mesh;

// アクセサリーのmeshは基本非表示
if (ACCESSORY_METH_NAMES.includes(mesh.name as AccessoryMeshName)) {
mesh.visible = false;
}
// アクセサリーのmeshのうち、設定されたアクセサリーに対応するものだけ表示
const visibleAccessoryMeshNames = ACCESSORY_MAP[setting.accessory];
if (visibleAccessoryMeshNames.includes(mesh.name as AccessoryMeshName)) {
mesh.visible = true;
}

// 髪のmeshの場合は色を設定
if (HAIR_MESH_NAMES.includes(mesh.name as HairMeshName)) {
const material = mesh.material as MeshStandardMaterial;

// 部位名がcolorMapのキーに一致する場合に色を設定
if (Object.hasOwn(colorMap, mesh.name)) {
material.color.set(colorMap[mesh.name]);
}
material.color.set(setting.hair);
}
});
}, [scene, colorMap, meshVisibility]);
}, [scene, setting]);

return scene;
}

interface ModelProps {
characterSetting: CharacterSetting;
}

function Model({ characterSetting }: ModelProps): ReactNode {
const scene = useCharacterSetting(characterSetting);

return (
// グループとしてシーンをレンダリング
<group ref={groupRef} position={[0, -0.5, 0]}>
<group position={[0, -0.5, 0]}>
{/* モデルのプリミティブ(生のオブジェクト)を表示 */}
<primitive object={scene} />
</group>
);
}

// 3Dモデルビューアーコンポーネント
// モデルのパスを受け取って、それを表示する
type ModelViewerProps = {
modelPath: string; // モデルのパス
colorMap: { [key: string]: string }; // 部位ごとの色マップ
meshVisibility: { [key: string]: boolean }; // 部位ごとの表示非表示
};
export function ModelViewer({
modelPath,
colorMap,
meshVisibility,
}: ModelViewerProps): ReactNode {
export function ModelViewer({ characterSetting }: ModelProps): ReactNode {
return (
<div>
{/* 3Dモデルを表示するためのCanvasエリア */}
<div style={{ height: "60vh" }}>
<Canvas
camera={{
position: [0, 0, 4],
fov: 30,
}} // カメラの初期位置と視野角を設定
>
{/* 環境を設定*/}
<Environment preset="lobby" />
{/* 環境光を追加(全体的に均一な光を当てる) */}
{/* <ambientLight intensity={0.5} /> */}
{/* GLBモデルの読み込みと表示 */}
<Model
path={modelPath}
colorMap={colorMap}
meshVisibility={meshVisibility}
/>
{/* カメラコントロールの追加(ユーザーが自由にカメラを操作できるようにする) */}
<OrbitControls makeDefault />
</Canvas>
</div>
</div>
<Canvas
className="aspect-square h-auto w-full sm:max-h-[50dvh]"
scene={{
background: new THREE.Color("#000000"),
}}
camera={{
position: [0, 0, 2],
fov: 30,
}} // カメラの初期位置と視野角を設定
>
{/* ライトを設定 */}
<ambientLight />
<directionalLight position={[6, 5, 5]} intensity={1} />
{/* ポストプロセッシング */}
<EffectComposer>
<Bloom intensity={1} luminanceThreshold={1} radius={0.8} mipmapBlur />
</EffectComposer>
{/* GLBモデルの読み込みと表示 */}
<Model characterSetting={characterSetting} />
{/* カメラコントロールの追加(ユーザーが自由にカメラを操作できるようにする) */}
<OrbitControls
enableZoom={false}
enablePan={false}
minPolarAngle={(Math.PI / 5) * 2}
maxPolarAngle={(Math.PI / 5) * 2}
/>
</Canvas>
);
}
4 changes: 0 additions & 4 deletions app/features/profile/Profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,3 @@ export interface Profile {
rank: number | null;
characterSetting: CharacterSetting;
}

export type UpdateProfileBody = {
profile: Partial<Pick<Profile, "displayName" | "characterSetting">>;
};
Loading

0 comments on commit 8e7dbfa

Please sign in to comment.