クラフトアイテムでNPC作りに挑戦した
こんにちは。いえもんと申します。
わたしは現在clusterというメタバースプラットフォームで色々なものを作ることに挑戦しています。
ワールド、アイテム、アクセサリー、イベント、アバター。
手を出し過ぎて回しきれていないけがありますが試行錯誤を繰り返しています。
今回は8月にclusterのクラフトアイテムストアに出品したクラフトアイテムでのNPC作りについて書こうと思います。
最近はAIエージェントの開発が告知されたり、β機能でテキスト入力が開放されたりしていて、今後NPC制作のハードルは格段に下がるでしょう。
この記事は2023年8月当時のワールドクラフトでNPCを導入するために試行錯誤した記録みたいなものです。
メッシュについて
クラフトアイテムの制限
クラフトアイテムには「メッシュは5000ポリゴンまで」などのいくらかの制限があります。
まずはこれを把握して何ができるか考えます。
公式ページに詳細な情報が載っています。
Unityでできる小技
クラフトアイテムの制限をもとにNPCを作るにあたって小技として使用できることを考えます。
同一メッシュを共通使用することで使用ポリゴン数を削減(≒ ポリゴン上限5000の突破)
Unityでオブジェクトのスケール値を変更したメッシュの変形であれば同一メッシュの使い回しができるのでポリゴン節約の幅が増える(≒ 使用可能なメッシュの種類の数 8種類の上限突破)
できたメッシュは画像の通り。
メッシュが共通のオブジェクトには同じ色の「●」をつけています。「●」がないオブジェクトはメッシュのないGameObjectです。
つまり、メッシュは
Body
Eye_L, Eye_R, Blink_L, Blink_R
LArm, RArm
LHand, RHand
LFoot, RFoot
Morning
Hi
Evening
の8種類を使用していることになり、これはクラフトアイテムのメッシュの種類の上限8種類にギリギリ収まっています。
Eye_Rなど目のメッシュは同じものを使用していますが、Blinkはまばたきのためにスケールを使った変形を行っています。
スクリプトについて
スクリプト全文
スクリプト全文は以下の通りです。これでNPCの行動と部位ごとの動きを制御しています。
// NPC Uploaded ver1.0
/* 【アイテム構造】
NPCTake
┣Root
┃ ┗Body
┃ ┣Eyes
┃ ┃ ┣Eye_L
┃ ┃ ┗Eye_R
┃ ┣Blink
┃ ┃ ┣Blink_L
┃ ┃ ┗Blink_R
┃ ┣LArm
┃ ┃ ┗LHand
┃ ┣RArm
┃ ┃ ┗RHand
┃ ┣LFoot
┃ ┗RFoot
┗Greeting
┣Morning
┣Hi
┗Evening
*/
// 子オブジェクトを取得
const root = $.subNode("Root");
const body = $.subNode("Body");
const eyes = $.subNode("Eyes");
const eyeL = $.subNode("Eye_L");
const eyeR = $.subNode("Eye_R");
const blink = $.subNode("Blink");
const blinkL = $.subNode("Blink_L");
const blinkR = $.subNode("Blink_R");
const armL = $.subNode("LArm");
const handL = $.subNode("LHand");
const armR = $.subNode("RArm");
const handR = $.subNode("RHand");
const footL = $.subNode("LFoot");
const footR = $.subNode("RFoot");
const greeting = $.subNode("Greeting");
const morning = $.subNode("Morning");
const hi = $.subNode("Hi");
const evening = $.subNode("Evening");
const se = $.audio("Audio1");
// 定数などの設定
const actions = ["rest", "sleep", "greet", "move", "rotate"];
let currentTime = 0;
//let elapsedTime = 0;
let greetingTime = 0;
let desire = 0;
const axes =[
new Vector3(1.0, 0.0, 0.0).normalize(),
new Vector3(0.0, 1.0, 0.0).normalize(),
new Vector3(0.0, 0.0, 1.0).normalize()
];
const vecZ = new Vector3(0.0, 0.0, 0.005);// 進行時の加算
$.state.startPos ??= $.getPosition(); // 初期位置座標
const centerX = $.state.startPos.x; // 中心の x 座標
const centerZ = $.state.startPos.z; // 中心の z 座標
const radius = 1.25; // 円の半径
let wakeUpCount = 0;
let wakeUp = Math.floor(Math.random() * 11); // 0~10のランダムな値を生成
const greetFinishTime = 3.0;
let now = new Date();
let h = now.getHours();
let startTime = h;
let isBlinking = false;
const speed = 144.0;
let rotationTime = 0;
let rotationLimit = 0.3;
// 関数の設定
function setInit(){
root.setPosition($.state.rootPivot);
body.setPosition($.state.bodyPivot);
armL.setRotation($.state.armLPivot);
armR.setRotation($.state.armRPivot);
footL.setRotation($.state.footLPivotRot);
footR.setRotation($.state.footLPivotRot);
elapsedBodyAnimeTime = 0;
elapsedRootAnimeTime = 0;
if($.state.status == actions[1]){
blink.setEnabled(true);
eyes.setEnabled(false);
}else{
blink.setEnabled(false);
eyes.setEnabled(true);
}
}
let blinkingTime = 0;
function blinking(minBlinkInterval = 2.0, maxBlinkInterval = 10.0, doubleBlinkInterval = 0.3, minBlinkingTime = 0.1, maxBlinkingTime = 0.3){
$.state.blinkThreshold ??= 2.0; // 瞬き間隔の閾値(秒)
if (blinkingTime >= $.state.blinkThreshold) {
isBlinking = !isBlinking;
blinkingTime = 0;
// blinkとeyesの表示を切り替え
blink.setEnabled(isBlinking);
eyes.setEnabled(!isBlinking);
// 次の瞬きまでの間隔をランダムに設定
if(!isBlinking){
let rand = Math.floor(Math.random() * 5); // 0~4のランダムな値を生成
if(rand == 0){
$.state.blinkThreshold = doubleBlinkInterval;
}else{
let nextBlinkInterval = Math.random() * (maxBlinkInterval - minBlinkInterval) + minBlinkInterval;
$.state.blinkThreshold = nextBlinkInterval;
}
}else{
$.state.blinkThreshold = Math.random() * (maxBlinkingTime - minBlinkingTime);
}
}
}
let elapsedBodyAnimeTime = 0;
function bodyAction(bodyAnimeTime = 3.0, range = 0.003){
const nowBodyAnimeTime = elapsedBodyAnimeTime / bodyAnimeTime; // 1周の進行度(0〜1)
const newY = Math.sin(nowBodyAnimeTime * Math.PI * 2) * range; // 1周分のsin波を生成
body.setPosition($.state.bodyPivot.clone().add(axes[1].clone().multiplyScalar(newY).applyQuaternion(body.getRotation())));
}
let elapsedArmAnimeTime = 0;
function armAction(armAnimeTime = 1.5, minLRot = -30, maxLRot = -40, minRRot = 30, maxRRot = 40){
let nowArmAnimeTime = elapsedArmAnimeTime / armAnimeTime;
let minR = $.state.armRPivot.clone().setFromAxisAngle(axes[2], $.state.isOpen ? minRRot : maxRRot);
let maxR = $.state.armRPivot.clone().setFromAxisAngle(axes[2], !$.state.isOpen ? minRRot : maxRRot);
let minL = $.state.armLPivot.clone().setFromAxisAngle(axes[2], $.state.isOpen ? minLRot : maxLRot);
let maxL = $.state.armLPivot.clone().setFromAxisAngle(axes[2], !$.state.isOpen ? minLRot : maxLRot);
armR.setRotation(minR.clone().slerp(maxR, nowArmAnimeTime));
armL.setRotation(minL.clone().slerp(maxL, nowArmAnimeTime));
if(nowArmAnimeTime >= 1){
$.state.isOpen = !$.state.isOpen;
elapsedArmAnimeTime = 0;
}
}
let elapsedRootAnimeTime = 0;
function rootAction(RootAnimeTime = 0.5, range = 0.005){
const nowRootAnimeTime = elapsedRootAnimeTime / RootAnimeTime; // 1周の進行度(0〜1)
const newY = Math.sin(nowRootAnimeTime * Math.PI * 2) * range; // 1周分のsin波を生成
root.setPosition($.state.rootPivot.clone().add(axes[1].clone().multiplyScalar(newY).applyQuaternion(root.getRotation())));
}
let elapsedFootAnimeTime = 0;
function footAction(footAnimeTime = 0.5, minLRot = -35, maxLRot = 20, minRRot = -35, maxRRot = 20){
let nowFootAnimeTime = elapsedFootAnimeTime / footAnimeTime;
let minR = $.state.footRPivotRot.clone().setFromAxisAngle(axes[0], $.state.isOpen ? minRRot : maxRRot);
let maxR = $.state.footRPivotRot.clone().setFromAxisAngle(axes[0], !$.state.isOpen ? minRRot : maxRRot);
let minL = $.state.footLPivotRot.clone().setFromAxisAngle(axes[0], $.state.isOpen ? maxLRot : minLRot);
let maxL = $.state.footLPivotRot.clone().setFromAxisAngle(axes[0], !$.state.isOpen ? maxLRot : minLRot);
footR.setRotation(minR.clone().slerp(maxR, nowFootAnimeTime));
footL.setRotation(minL.clone().slerp(maxL, nowFootAnimeTime));
if(nowFootAnimeTime >= 1){
$.state.isOpen = !$.state.isOpen;
elapsedFootAnimeTime = 0;
}
}
function greetingMessage(){
now = new Date();
h = now.getHours();
se.play();
if(h >= 6 && h < 11){
morning.setEnabled(true);
$.state.sleepy =false;
}else if(h >= 11 && h < 18){
hi.setEnabled(true);
$.state.sleepy =false;
}else if(h >= 18){
evening.setEnabled(true);
$.state.sleepy =false;
}else {
evening.setEnabled(true);
$.state.sleepy = true;
};
}
//初期化
const initialize = () => {
$.state.inisialized = true;
$.state.startPos = $.getPosition();
$.state.startRot = $.getRotation();
$.state.nowPos ??= $.getPosition();
$.state.nowRot ??= $.getRotation();
$.state.rootPivot = root.getPosition();
$.state.bodyPivot = body.getPosition();
$.state.armRPivot = armR.getRotation();
$.state.armLPivot = armL.getRotation();
$.state.footRPivotPos = footR.getPosition();
$.state.footLPivotPos = footL.getPosition();
$.state.footRPivotRot = footR.getRotation();
$.state.footLPivotRot = footL.getRotation();
now = new Date();
h = now.getHours();
startTime = h;
if(startTime <= 5){
$.state.status = actions[1];
$.state.sleepy = true;
blink.setEnabled(true);
eyes.setEnabled(false);
}else{
$.state.status = actions[0];
$.state.sleepy = false;
blink.setEnabled(false);
eyes.setEnabled(true);
}
$.state.isOpen = false;
$.log(`起きるまでのカウント${wakeUp}`);
$.log(h);
$.log("生えました。");
}
// インタラクト時の処理
$.onInteract(() => {
if($.state.status == actions[2]){
$.state.status = actions[0];
morning.setEnabled(false);
hi.setEnabled(false);
evening.setEnabled(false);
greetingTime = 0;
}else if($.state.status == actions[1]){
wakeUpCount ++;
if(wakeUpCount > wakeUp){
wakeUpCount = 0;
wakeUp = Math.floor(Math.random() * 11);
$.state.status = actions[2];
greetingMessage();
$.log(`次起こすときのインタラクト回数${wakeUp}`);
}
}else{
$.state.status = actions[2];
greetingMessage();
}
setInit();
})
// 時間処理
$.onUpdate(deltaTime => {
if(!$.state.inisialized) initialize();
// $.log($.state.status);
currentTime += deltaTime;
switch ($.state.status) {
case actions[0]:
blinkingTime += deltaTime;
blinking();
elapsedArmAnimeTime += deltaTime;
armAction();
elapsedBodyAnimeTime += deltaTime;
bodyAction();
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else if($.state.sleepy || h < 6){
desire = 0;
$.state.sleepy = true;
$.state.status = actions[1];
setInit();
}else{
desire = 0;
now = new Date();
h = now.getHours();
$.state.status = actions[3];
setInit();
}
break;
case actions[1]:
elapsedArmAnimeTime += deltaTime;
armAction(2.0, -30, -35, 30, 35);
bodyAction(4);
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else if(h >= 6){
desire = 0;
$.state.sleepy = false;
$.state.status = actions[0];
setInit();
}else{
desire = 0;
now = new Date();
h = now.getHours();
}
break;
case actions[2]:
blinkingTime += deltaTime;
blinking();
greetingTime += deltaTime;
elapsedArmAnimeTime += deltaTime;
armAction(0.15 + Math.random() * 0.05, -30, -120, 30, 120);
bodyAction();
if(greetingTime >= greetFinishTime){
$.state.status = actions[0];
morning.setEnabled(false);
hi.setEnabled(false);
evening.setEnabled(false);
greetingTime = 0;
setInit();
}
break;
case actions[3]:
blinkingTime += deltaTime;
blinking();
elapsedRootAnimeTime += deltaTime;
rootAction();
elapsedFootAnimeTime += deltaTime;
footAction();
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else{
desire = 0;
$.state.status = actions[0];
setInit();
}
$.setPosition($.getPosition().add(vecZ.clone().applyQuaternion($.getRotation())));
const distanceToCenter = Math.sqrt(Math.pow($.getPosition().x - centerX, 2) + Math.pow($.getPosition().z - centerZ, 2));
if (distanceToCenter >= radius) {
rotationLimit = 0.3 + Math.random() * 0.6;
$.state.status = actions[4];
}
break;
case actions[4]:
blinkingTime += deltaTime;
blinking();
elapsedRootAnimeTime += deltaTime;
rootAction();
elapsedFootAnimeTime += deltaTime;
footAction();
rotationTime += deltaTime;
$.setRotation($.getRotation().multiply(new Quaternion().setFromAxisAngle(axes[1], speed * deltaTime)));
if(rotationTime >= rotationLimit){
rotationTime = 0;
$.state.nowRot = $.getRotation();
$.state.status = actions[3];
}
break;
default:
$.log("想定外の状態です。");
}
});
スクリプトの全体的な要約
オブジェクトの設定: NPCの身体部分(例: 目、手、足)のサブオブジェクトを定義し、後のアニメーションや状態変更に使用します。
定数と状態の設定: NPCの動作やアニメーションのための定数や状態を設定します。これには、動作の種類、初期位置、時間管理などが含まれます。
関数の定義: NPCの瞬き、腕の動き、体の動きなどを制御する関数が定義されています。
挨拶メッセージの処理: 現在の時間に基づいて、異なる挨拶メッセージを表示する処理を行います。
初期化関数: NPCが生成された際に初期状態を設定する関数です。
インタラクト時の処理: ユーザーがNPCとインタラクトした際の処理を定義します。これにより、NPCはユーザーの行動に応じて異なる反応を示します。
時間による処理: NPCの状態を時間経過に応じて更新する処理です。動作や位置の変更が行われます。
このスクリプトによって、NPCは時間帯やユーザーのインタラクションに応じて様々な反応を示します。
それぞれ見てみます。
1. オブジェクトの設定
// 子オブジェクトを取得
const root = $.subNode("Root");
const body = $.subNode("Body");
const eyes = $.subNode("Eyes");
const eyeL = $.subNode("Eye_L");
const eyeR = $.subNode("Eye_R");
const blink = $.subNode("Blink");
const blinkL = $.subNode("Blink_L");
const blinkR = $.subNode("Blink_R");
const armL = $.subNode("LArm");
const handL = $.subNode("LHand");
const armR = $.subNode("RArm");
const handR = $.subNode("RHand");
const footL = $.subNode("LFoot");
const footR = $.subNode("RFoot");
const greeting = $.subNode("Greeting");
const morning = $.subNode("Morning");
const hi = $.subNode("Hi");
const evening = $.subNode("Evening");
const se = $.audio("Audio1");
このスクリプトの最初の部分では、NPCの身体の各部位に相当するオブジェクトを定義しています。これらのオブジェクトは、後にアニメーションや状態変更のために使用されます。
root, body, eyesなどは、NPCの体の各部分を指しています。
$.subNode("部位名") は、特定の部位に対応するサブオブジェクトを取得するための関数です。
例えば、const eyeL = $.subNode("Eye_L") は、NPCの左目に対応するオブジェクトを取得しています。
これらのオブジェクトは、後のスクリプトで位置や回転の設定、アニメーションの実行などに使用されます。
2. 定数と状態の設定
// 定数などの設定
const actions = ["rest", "sleep", "greet", "move", "rotate"];
let currentTime = 0;
//let elapsedTime = 0;
let greetingTime = 0;
let desire = 0;
const axes =[
new Vector3(1.0, 0.0, 0.0).normalize(),
new Vector3(0.0, 1.0, 0.0).normalize(),
new Vector3(0.0, 0.0, 1.0).normalize()
];
const vecZ = new Vector3(0.0, 0.0, 0.005);// 進行時の加算
$.state.startPos ??= $.getPosition(); // 初期位置座標
const centerX = $.state.startPos.x; // 中心の x 座標
const centerZ = $.state.startPos.z; // 中心の z 座標
const radius = 1.25; // 円の半径
let wakeUpCount = 0;
let wakeUp = Math.floor(Math.random() * 11); // 0~10のランダムな値を生成
const greetFinishTime = 3.0;
let now = new Date();
let h = now.getHours();
let startTime = h;
let isBlinking = false;
const speed = 144.0;
let rotationTime = 0;
let rotationLimit = 0.3;
このセクションでは、NPCの動作や状態を制御するための定数や状態が定義されています。
actions: これはNPCが取りうる動作の種類を表す配列です。例えば "rest", "sleep", "greet" などが含まれます。
currentTime, greetingTime, desire: これらは時間管理やNPCの内部状態を管理するための変数です。
axes: これは3次元空間内での回転軸を定義しています。例えば、new Vector3(1.0, 0.0, 0.0) はX軸を表します。
vecZ: NPCが移動する際のベクトルです。これはNPCが前進するために使用されます。
startPos, centerX, centerZ, radius: これらはNPCの初期位置や動きの範囲を定義しています。
wakeUpCount, wakeUp: これらはNPCが目覚めるためのランダムなタイマーを管理する変数です。
greetFinishTime: 挨拶動作の持続時間を定義しています。
これらの変数や定数は、NPCの動作や状態を管理するための基盤を提供します。スクリプトの後半部分では、これらの変数を使用してNPCの動作を制御します。
3. 関数の定義
// 関数の設定
function setInit(){
root.setPosition($.state.rootPivot);
body.setPosition($.state.bodyPivot);
armL.setRotation($.state.armLPivot);
armR.setRotation($.state.armRPivot);
footL.setRotation($.state.footLPivotRot);
footR.setRotation($.state.footLPivotRot);
elapsedBodyAnimeTime = 0;
elapsedRootAnimeTime = 0;
if($.state.status == actions[1]){
blink.setEnabled(true);
eyes.setEnabled(false);
}else{
blink.setEnabled(false);
eyes.setEnabled(true);
}
}
let blinkingTime = 0;
function blinking(minBlinkInterval = 2.0, maxBlinkInterval = 10.0, doubleBlinkInterval = 0.3, minBlinkingTime = 0.1, maxBlinkingTime = 0.3){
$.state.blinkThreshold ??= 2.0; // 瞬き間隔の閾値(秒)
if (blinkingTime >= $.state.blinkThreshold) {
isBlinking = !isBlinking;
blinkingTime = 0;
// blinkとeyesの表示を切り替え
blink.setEnabled(isBlinking);
eyes.setEnabled(!isBlinking);
// 次の瞬きまでの間隔をランダムに設定
if(!isBlinking){
let rand = Math.floor(Math.random() * 5); // 0~4のランダムな値を生成
if(rand == 0){
$.state.blinkThreshold = doubleBlinkInterval;
}else{
let nextBlinkInterval = Math.random() * (maxBlinkInterval - minBlinkInterval) + minBlinkInterval;
$.state.blinkThreshold = nextBlinkInterval;
}
}else{
$.state.blinkThreshold = Math.random() * (maxBlinkingTime - minBlinkingTime);
}
}
}
let elapsedBodyAnimeTime = 0;
function bodyAction(bodyAnimeTime = 3.0, range = 0.003){
const nowBodyAnimeTime = elapsedBodyAnimeTime / bodyAnimeTime; // 1周の進行度(0〜1)
const newY = Math.sin(nowBodyAnimeTime * Math.PI * 2) * range; // 1周分のsin波を生成
body.setPosition($.state.bodyPivot.clone().add(axes[1].clone().multiplyScalar(newY).applyQuaternion(body.getRotation())));
}
let elapsedArmAnimeTime = 0;
function armAction(armAnimeTime = 1.5, minLRot = -30, maxLRot = -40, minRRot = 30, maxRRot = 40){
let nowArmAnimeTime = elapsedArmAnimeTime / armAnimeTime;
let minR = $.state.armRPivot.clone().setFromAxisAngle(axes[2], $.state.isOpen ? minRRot : maxRRot);
let maxR = $.state.armRPivot.clone().setFromAxisAngle(axes[2], !$.state.isOpen ? minRRot : maxRRot);
let minL = $.state.armLPivot.clone().setFromAxisAngle(axes[2], $.state.isOpen ? minLRot : maxLRot);
let maxL = $.state.armLPivot.clone().setFromAxisAngle(axes[2], !$.state.isOpen ? minLRot : maxLRot);
armR.setRotation(minR.clone().slerp(maxR, nowArmAnimeTime));
armL.setRotation(minL.clone().slerp(maxL, nowArmAnimeTime));
if(nowArmAnimeTime >= 1){
$.state.isOpen = !$.state.isOpen;
elapsedArmAnimeTime = 0;
}
}
let elapsedRootAnimeTime = 0;
function rootAction(RootAnimeTime = 0.5, range = 0.005){
const nowRootAnimeTime = elapsedRootAnimeTime / RootAnimeTime; // 1周の進行度(0〜1)
const newY = Math.sin(nowRootAnimeTime * Math.PI * 2) * range; // 1周分のsin波を生成
root.setPosition($.state.rootPivot.clone().add(axes[1].clone().multiplyScalar(newY).applyQuaternion(root.getRotation())));
}
let elapsedFootAnimeTime = 0;
function footAction(footAnimeTime = 0.5, minLRot = -35, maxLRot = 20, minRRot = -35, maxRRot = 20){
let nowFootAnimeTime = elapsedFootAnimeTime / footAnimeTime;
let minR = $.state.footRPivotRot.clone().setFromAxisAngle(axes[0], $.state.isOpen ? minRRot : maxRRot);
let maxR = $.state.footRPivotRot.clone().setFromAxisAngle(axes[0], !$.state.isOpen ? minRRot : maxRRot);
let minL = $.state.footLPivotRot.clone().setFromAxisAngle(axes[0], $.state.isOpen ? maxLRot : minLRot);
let maxL = $.state.footLPivotRot.clone().setFromAxisAngle(axes[0], !$.state.isOpen ? maxLRot : minLRot);
footR.setRotation(minR.clone().slerp(maxR, nowFootAnimeTime));
footL.setRotation(minL.clone().slerp(maxL, nowFootAnimeTime));
if(nowFootAnimeTime >= 1){
$.state.isOpen = !$.state.isOpen;
elapsedFootAnimeTime = 0;
}
}
このセクションでは、NPCの様々な動きや状態を制御するための関数が定義されています。
setInit(): この関数はNPCの初期設定を行います。各部位の位置や回転を設定し、NPCが眠っているかどうかに基づいて目を開けるか閉じるかを決定します。
blinking(): この関数はNPCの瞬きのアニメーションを制御します。瞬きの間隔や瞬きの速度をランダムに設定します。
bodyAction(): この関数はNPCの体の動きを制御します。sin波を使用して自然な動きを作成します。
armAction(): 腕のアニメーションを制御する関数です。腕の回転を周期的に変更します。
rootAction(): NPCの根元(root)の動きを制御します。これもsin波を使用しています。
footAction(): 足のアニメーションを制御します。歩行や立ち姿勢などの動作に使用されます。
greetingMessage(): 時間帯に応じた挨拶メッセージを表示する関数です。時間によって異なる挨拶を有効にします。
これらの関数で、NPCの動きをよりリアルで自然にし、瞬きや手足の動きなど、細かいアニメーションがNPCに生命を吹き込むことを目指します。
4. 挨拶メッセージの処理
function greetingMessage(){
now = new Date();
h = now.getHours();
se.play();
if(h >= 6 && h < 11){
morning.setEnabled(true);
$.state.sleepy =false;
}else if(h >= 11 && h < 18){
hi.setEnabled(true);
$.state.sleepy =false;
}else if(h >= 18){
evening.setEnabled(true);
$.state.sleepy =false;
}else {
evening.setEnabled(true);
$.state.sleepy = true;
};
}
この部分のスクリプトでは、NPCが時間帯に応じて異なる挨拶メッセージを出すための処理が記述されています。
greetingMessage(): この関数は現在の時間を取得し、その時間帯に応じて異なる挨拶メッセージを有効にします。例えば、朝の時間帯では「morning」メッセージが、夜の時間帯では「evening」メッセージが表示されます。
時間帯は、現在の時間を元に決定されます(例: if(h >= 6 && h < 11) は朝の時間帯を表しています)。
挨拶メッセージは、morning.setEnabled(true); のようにして有効化されます。
この機能により、NPCは現実の時間に合わせて異なる反応を示すことができ、ユーザーにとってよりリアルで没入感のある体験を提供します。
5. 初期化関数
//初期化
const initialize = () => {
$.state.inisialized = true;
$.state.startPos = $.getPosition();
$.state.startRot = $.getRotation();
$.state.nowPos ??= $.getPosition();
$.state.nowRot ??= $.getRotation();
$.state.rootPivot = root.getPosition();
$.state.bodyPivot = body.getPosition();
$.state.armRPivot = armR.getRotation();
$.state.armLPivot = armL.getRotation();
$.state.footRPivotPos = footR.getPosition();
$.state.footLPivotPos = footL.getPosition();
$.state.footRPivotRot = footR.getRotation();
$.state.footLPivotRot = footL.getRotation();
now = new Date();
h = now.getHours();
startTime = h;
if(startTime <= 5){
$.state.status = actions[1];
$.state.sleepy = true;
blink.setEnabled(true);
eyes.setEnabled(false);
}else{
$.state.status = actions[0];
$.state.sleepy = false;
blink.setEnabled(false);
eyes.setEnabled(true);
}
$.state.isOpen = false;
$.log(`起きるまでのカウント${wakeUp}`);
$.log(h);
$.log("生えました。");
}
このセクションでは、NPCが最初に生成された時に呼び出される初期化関数が定義されています。
initialize(): この関数はNPCの初期状態を設定します。NPCの位置や回転、身体の各部位の初期位置や回転を設定し、初期の動作状態を決定します。
この関数内で、時間に応じてNPCが睡眠状態になるかどうかが決定されます(例: 夜間は actions[1](睡眠)状態に設定)。
さらに、状態変数(例: $.state.sleepy)が初期化され、NPCの目が開いているか閉じているかが設定されます。
この初期化関数によって、NPCは適切な初期状態でユーザーの前に現れることができます。これは、NPCの振る舞いや反応の一貫性を保つために重要です。
6. インタラクト時の処理
// インタラクト時の処理
$.onInteract(() => {
if($.state.status == actions[2]){
$.state.status = actions[0];
morning.setEnabled(false);
hi.setEnabled(false);
evening.setEnabled(false);
greetingTime = 0;
}else if($.state.status == actions[1]){
wakeUpCount ++;
if(wakeUpCount > wakeUp){
wakeUpCount = 0;
wakeUp = Math.floor(Math.random() * 11);
$.state.status = actions[2];
greetingMessage();
$.log(`次起こすときのインタラクト回数${wakeUp}`);
}
}else{
$.state.status = actions[2];
greetingMessage();
}
setInit();
})
この部分では、ユーザーがNPCとインタラクション(たとえば、NPCをクリックまたはタッチするなど)した際の処理が記述されています。
$.onInteract(): この関数は、ユーザーがNPCとインタラクトしたときに呼び出されます。
スクリプト内で、ユーザーのインタラクションに基づいてNPCの状態が変更されます。たとえば、NPCが睡眠状態にある場合、インタラクト回数に応じて目覚めるかどうかが決定されます。
NPCが目覚めた場合、greetingMessage()関数が呼び出され、適切な挨拶メッセージが表示されます。
また、setInit()関数が呼び出され、NPCの各部位の位置や回転がリセットされます。
このインタラクト時の処理により、ユーザーはNPCと対話することができ、NPCはユーザーの行動に応じて異なる反応を示すことができます。
7. 時間による処理
// 時間処理
$.onUpdate(deltaTime => {
if(!$.state.inisialized) initialize();
// $.log($.state.status);
currentTime += deltaTime;
switch ($.state.status) {
case actions[0]:
blinkingTime += deltaTime;
blinking();
elapsedArmAnimeTime += deltaTime;
armAction();
elapsedBodyAnimeTime += deltaTime;
bodyAction();
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else if($.state.sleepy || h < 6){
desire = 0;
$.state.sleepy = true;
$.state.status = actions[1];
setInit();
}else{
desire = 0;
now = new Date();
h = now.getHours();
$.state.status = actions[3];
setInit();
}
break;
case actions[1]:
elapsedArmAnimeTime += deltaTime;
armAction(2.0, -30, -35, 30, 35);
bodyAction(4);
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else if(h >= 6){
desire = 0;
$.state.sleepy = false;
$.state.status = actions[0];
setInit();
}else{
desire = 0;
now = new Date();
h = now.getHours();
}
break;
case actions[2]:
blinkingTime += deltaTime;
blinking();
greetingTime += deltaTime;
elapsedArmAnimeTime += deltaTime;
armAction(0.15 + Math.random() * 0.05, -30, -120, 30, 120);
bodyAction();
if(greetingTime >= greetFinishTime){
$.state.status = actions[0];
morning.setEnabled(false);
hi.setEnabled(false);
evening.setEnabled(false);
greetingTime = 0;
setInit();
}
break;
case actions[3]:
blinkingTime += deltaTime;
blinking();
elapsedRootAnimeTime += deltaTime;
rootAction();
elapsedFootAnimeTime += deltaTime;
footAction();
if(desire < 100){
desire += (Math.floor(Math.random() * 6.5))*0.1;
}else{
desire = 0;
$.state.status = actions[0];
setInit();
}
$.setPosition($.getPosition().add(vecZ.clone().applyQuaternion($.getRotation())));
const distanceToCenter = Math.sqrt(Math.pow($.getPosition().x - centerX, 2) + Math.pow($.getPosition().z - centerZ, 2));
if (distanceToCenter >= radius) {
rotationLimit = 0.3 + Math.random() * 0.6;
$.state.status = actions[4];
}
break;
case actions[4]:
blinkingTime += deltaTime;
blinking();
elapsedRootAnimeTime += deltaTime;
rootAction();
elapsedFootAnimeTime += deltaTime;
footAction();
rotationTime += deltaTime;
$.setRotation($.getRotation().multiply(new Quaternion().setFromAxisAngle(axes[1], speed * deltaTime)));
if(rotationTime >= rotationLimit){
rotationTime = 0;
$.state.nowRot = $.getRotation();
$.state.status = actions[3];
}
break;
default:
$.log("想定外の状態です。");
}
});
このスクリプトの最後の部分では、時間の経過に応じてNPCの状態を更新する処理が記述されています。
$.onUpdate(deltaTime): この関数は、ゲームのフレームごとに呼び出され、deltaTime(前回のフレームからの経過時間)を引数に取ります。
switch ($.state.status): ここでは、NPCの現在の状態に基づいて異なるアクションを実行します。
例えば、actions[0](休息)の場合、瞬きや腕の動きなどのアニメーションを実行し、一定の「欲求」レベルが達成されたら、状態を変更します。
actions[1](睡眠)の場合、よりゆっくりとした腕の動きや体の動きを実行します。
actions[2](挨拶)では、挨拶のアニメーションが一定時間続きます。
actions[3](移動)とactions[4](回転)では、NPCが移動や回転を行います。
これらの動作は、NPCの状態や時間に応じて連続的に更新されます。
この時間による処理は、NPCがダイナミックに動くことを可能にし、ユーザーにリアルなインタラクションを提供します。
サンプルやリンクなど
ナプコタケくんのサンプルはいえもんくんの住処に住んでいます。ワールド内のどこかにいるのでよかったら会いにきてみてください。
ナプコタケのClusterScript以外にもいくつかのコードをGitHubで公開しています。
https://github.com/iemon-kun/cluster_obj/blob/main/craft_item/NPCTakeUploadedV1js
この記事はCluster Creator #2 Advent Calendar 2023に投稿しました。
https://adventar.org/calendars/9184
NPCがいるとなんだか癒やされますよね。
この記事が誰かの創作の手助けになれば幸いです。
この記事が気に入ったらサポートをしてみませんか?