0 ビジュアルと演出:UI・SFX・FXを使いこなす
―― 伝わる→迷わない→気持ちいい、の順番で
- 伝える(短いメッセージ/WorldIconの切替)
- 導く(“ここへ行け”が一目で分かる配置と更新)
- 感じさせる(SFX・FXで手応えを足す。ただし“鳴らしすぎない”)
- 乱れない(スパム防止・距離/回数制限・クールダウン)
- 見直せる(デバッグHUDで“今なにが起きたか”が見える)
合言葉は 「ことば → 目印 → 効果」。
まず短い文で要件を出し、次に WorldIcon で方向を示し、最後に SFX/FX で手応えを重ねます。
1 メッセージ:短い文で“次の一手”だけを出す
なぜ
プレイヤーは数秒で判断します。長文は読まれません。「次に何をしてほしいか」 だけを5〜12文字程度で出すと、迷いが消えます。
どう書く(型)
- 命令形+目的語:
例)「入口へ向かえ」「端末Aを起動」「10秒防衛せよ」
- 時間/距離を入れると良い:
例)「10秒防衛せよ」「あと120m」
実装の型
画面に表示する文字は、コードへ直接書くのではなく Strings.json に登録してから使います。
通知、WorldIcon、UI Textの textLabel など、プレイヤーに見える文字はすべて同じ考え方です。
流れは、次の三段階です。
Strings.jsonに、表示したい文のキーと本文を登録する。- TypeScript側で
mod.Message(mod.stringkeys.キー名, 追加値...)を作る。 modlib.ShowNotificationMessage()など、表示用の関数へMessageを渡す。
Strings.json は、画面に出す文の辞書です。
TypeScript側は、その辞書のキーを指定し、必要なら {} に入れる値だけを追加で渡します。
この分け方にすると、表示文を増やしたときに「コードに直書きした文字がPortalで壊れる」事故を避けられます。
{
"goEntrance": "go entrance",
"defendSeconds": "defend:{}s",
"testName": "test name:{}"
}
コード側では mod.Message で表示用の Message を作ります。
{} の位置には、第二引数以降に渡した値が入ります。
modlib.ShowEventGameModeMessage(mod.Message(mod.stringkeys.goEntrance));
modlib.ShowEventGameModeMessage(mod.Message(mod.stringkeys.defendSeconds, 10));
modlib.ShowNotificationMessage(mod.Message(mod.stringkeys.testName, "player1"));
最後の例は、画面では test name:player1 のように表示されます。
mod.Message の追加引数は最大3つまで使えるので、残り秒数、スコア、プレイヤー名のような変わる値だけをコードから渡します。
// Important message
ui.say(mod.Message(mod.stringkeys.goEntrance));
// Updating message
ui.say(mod.Message(mod.stringkeys.defendSeconds, t));
つまずき対策
- 画面に出す文字を追加したら、
Strings.jsonにキーがあるか確認する。 - 同時に複数出さない(最後に出したものだけが残る設計に)。
- 通知頻度を絞る(毎秒新規通知は疲れます。上書きにしましょう)。
- 個別 vs 全体:個別の注意は“押した本人だけ”、合図は“全員”。最初に決めて統一。
2 WorldIcon:導線は“少し手前”に置き、段階で切り替える
なぜ
目的地そのものに置くと、近づいた瞬間に壁や角で見失います。 入口や角の“少し手前” に置くと、曲がり角でも迷いません。
どう置く/どう切り替える
- 段階分け:入口(ICON_ENTRANCE)→目的地(ICON_TARGET)→次の目的(ICON_NEXT…)
- 到達でOFF、次をON: “二重に光らせない” のが迷わないコツ。
実装の型
// 案内の基本(6章の guide を利用)
ui.guide(ICON_ENTRANCE, ICON_TARGET); // 入口OFF → 目的地ON
// 到達時
ui.guide(ICON_TARGET, undefined); // 目的地OFF(次があるならここでON)
つまずき対策
- ONだけ増える事故:到達時に必ず前のICONをOFF。
- チーム別表示が必要な場合は、ui.guideForTeam(teamId, hide, show) のように関数を分けておくと、表示範囲ミスを防げます。
3 SFX:鳴らし過ぎは“疲れ”になる(クールダウンを必ず置く)
なぜ
- 達成音は快感ですが、連続再生は疲労を生みます。クールダウン(一定時間は再生しない)で密度を抑えます。
実装の型:SFXクールダウン
const sfxCooldownMs = 1500;
let lastSfxAt = 0;
function playSfxCooled(id: number) {
const now = Date.now();
if (now - lastSfxAt < sfxCooldownMs) return;
lastSfxAt = now;
api.playSfx(id);
}
つまずき対策
- イベントの多重発火と組み合わせると地獄に。6章の onceIn とセットで使う。
- 距離で音量を変えるAPIがあるなら、遠距離は鳴らさない設定を。なければ“遠距離イベントではそもそも鳴らさない”判断を。
4 FX:遠目の“灯台”、近場の“ご褒美”
なぜ
FXは遠くから気づき、近くで納得が理想。遠距離には点滅・柱・矢印など視認性重視、近距離は爆発・火花・火柱など手応え重視。
実装の型:FXワンショット/ループ
function celebrate() {
api.playFX(FX_GOAL); // ワンショット想定
playSfxCooled(SFX_GOAL); // 7.3のクールダウン版
}
// ループ物は必ず停止側も
onEnterArea(AREA_TARGET, () => api.playFX(FX_GOAL));
onLeaveArea(AREA_TARGET, () => api.stopFX(FX_GOAL));
つまずき対策
- 止まらない煙:退出イベントで確実に停止を書く。
- 屋内で見えない:設置位置を少し手前にずらす。上方にオフセットを入れると解決することが多い。
5 距離と方向:案内を“あと◯◯m”で実感に変える
なぜ
距離が見えると、「今進んでいる」の手応えが生まれます。数秒に一度の更新で十分です(毎フレーム更新は不要)。
実装の型(距離UIを上書き)
const updateDistance = debounce(500, (playerPos: Vector3, targetPos: Vector3) => {
const d = Math.round(distance(playerPos, targetPos));
ui.say(mod.Message(mod.stringkeys.distanceLeft, d));
});
この場合、Strings.json には "distanceLeft": "{}m left" のような文言を用意しておきます。
つまずき対策
- 更新しすぎで通知がうるさい → debounceで間引く。
- 距離0mにならない → 目標位置はWorldIconと同じく少し手前に。
6 優先度:大事な音・光・文言から鳴らす/出す
なぜ
同時に複数の演出を重ねると、弱い方が消えます。優先度を付け、高→中→低の順に処理し、低優先度は抑制します。
実装の型(優先度キューのイメージ)
type Prio = "high"|"mid"|"low";
function playSfxPrio(id: number, prio: Prio) {
if (prio === "low" && Date.now() - lastSfxAt < 2000) return; // 直近に鳴ってたら抑制
playSfxCooled(id);
}
コツ
- 勝利・失敗のジングルは必ず high。
- 足音・環境音など地の音はゲーム側に任せ、独自SFXは節目だけ。
7 “やりすぎ”を防ぐ設計:1シーン1効果、1段落1メッセージ
- 1シーン1効果:同一イベントで FX を二つ三つ重ねない。主役をひとつ決める。
- 1段落1メッセージ:同時に「目的」「注意」「ヒント」を出さない。目的だけに絞る。
- 終了処理を必ず書く:ループFX/SFXの停止、メッセージの上書き、WorldIconのOFF。
8 デバッグHUD:自分だけに見える“耳と目”を持つ
なぜ
演出は“感じるもの”ですが、設計は数値と状態です。自分にだけ見える小さなHUDで、phase・残り秒・直近イベントを出すと、直しが速い。
実装の型(例)
const debug = { on: true };
function dbg(line: string) { if (!debug.on) return; /* 画面端に小さく */ }
function dump() { dbg(`phase=${Phase[state.phase]} time=${remainSec}`); }
onInteract(IP_START, () => dbg("Interact:Start"));
onEnterArea(AREA_TARGET, () => dbg("Enter:Target"));
onLeaveArea(AREA_TARGET, () => dbg("Leave:Target"));
コツ
- 本番公開時は debug.on=false に。
- 通知のスパム対策と同じく、HUDもデバウンスする(見やすさ維持)。
9 パフォーマンスと安定性:やらない勇気
- 毎フレーム判定は避ける(距離・方向は0.5〜1秒に1回で十分)。
- 無限ループ+短い待機は封印。イベントとタイマーで待つ。
- 同時再生数を制限(同時にSFX 3つまで、など自分ルールで上限)。
- 演出は“見える人だけ”に:APIがあれば可聴圏/視認圏チェックを入れる。
公式SDKのTipsでも、負荷に直結するものとして車両数、Player走査、UI Widget管理が挙げられています。演出を増やす前に、次の3つを守ってください。
- 車両は同時に40台を超えないようにする。常設車両とイベント車両を足した合計で見る。
- 全プレイヤーを毎フレーム走査しない。
OnPlayerEnterCapturePointやOnPlayerExitCapturePointなどのイベントで状態を記録し、必要なときだけ読む。 - UI Widgetは毎回作り直さない。作成済みのWidgetを変数に保持し、表示内容の更新で済ませる。
派手な演出ほど、重くなる前に上限を決めます。見た目の量ではなく、プレイヤーが理解できる量を基準にしてください。
10 レシピ集(そのまま使える小部品)
A)到達でカメラを揺らし、短い歓声を一度だけ
let cheered = false;
function celebrateOnce() {
if (cheered) return; cheered = true;
ui.celebrate(FX_GOAL, SFX_GOAL); // 光と音
api.shakeCameraAll?.(0.4, 600); // APIがあれば:強さ0.4/600ms
setTimeout(()=> cheered = false, 3000); // 3秒は再発しない
}
B)段階メッセージ(短文3つで一本の物語に)
ui.say(mod.Message(mod.stringkeys.start));
ui.guide(ICON_ENTRANCE, ICON_TARGET);
ui.say(mod.Message(mod.stringkeys.goTerminalA));
// On reached
ui.say(mod.Message(mod.stringkeys.goodJob));
C)“点滅アイコン”を擬似的に(ON/OFFを交互に)
let blinkOn = false, blinkH: any;
function startBlinkIcon(id: number, ms = 600) {
stopBlinkIcon();
blinkH = setInterval(()=> { blinkOn = !blinkOn; api.showIcon(id, blinkOn); }, ms);
}
function stopBlinkIcon() { if (blinkH) clearInterval(blinkH); api.showIcon(ICON_TARGET, true); }
使いすぎ注意。最初の“呼び込み”だけ点滅→到達が近づいたら常灯、が上品です。
結論
- ことば → 目印 → 効果の順を守るだけで、伝わり方が見違えます。
- WorldIconは“少し手前”、SFX/FXはクールダウン、UIは上書きで“うるささ”を防ぎます。
- デバッグHUDで“今”を見える化。直しが早く、演出の質も上がります。
次節への案内
続く 第9章「公開・ホスティング・運営」 では、ここまでの体験を “遊ばれる状態” にする実務へ進みます。
- 共有コード・256文字以内の説明文・サムネの書き方(目的/推奨人数/所要時間を短く伝える)
- サーバー運用(常設/イベント)と告知のテンプレ
- 更新頻度と“壊さず改良する”手順
- XP周りは状況により制限の可能性がある前提での、穏当な運営のコツ