私はZEN大学生のsamoyed130-zenです。主に情報系分野を履修しています。
「ZEN Study 動くWebページコンテスト2025 夏」で拙作の Hanabee が優秀賞をいただくことができました。今回は制作過程と工夫点を3日分の記事でまとめました。
-
Day1:基本設計(この記事)
なるべく「なぜそう設計したか」を言語化し、実コードも引用しながら説明します。
制約と方針(最初に決めたこと)
今回はいくつか制約があったため、最初に「やらないこと」と「優先順位」を決めて進めました。
- 制作期間は2日程度と定め、早い段階で HTML5(Canvas)で表現できる範囲に絞りました。
- WebGL / WebGPU は環境差が出やすく、動作確認に時間が掛かるため今回は見送りました。
- オンラインのリーダーボードは、差別化になりそうだと感じたことに加えて、授業「デジタルツールの使い方」で学んだ GAS を応用できる題材だったため実装しました。
また、コンテストの規約として「外部ライブラリの利用制限」がありました。
HTML / CSS / JavaScript で作成された動きのある Web ページであること
Bootstrap以外の外部ライブラリ (jQuery, anime.js, Pixi.js など) の利用は、完成度に差がついてしまうため禁止とします。
ただし、外部ライブラリを参考にしたオリジナルのプログラムであれば使用可能です。※ 今回からTailWind CSSもOKとしました。
このため、入力判定のゲームロジックや花火の演出などリアルタイムが必要なものは JavaScript で自作し、スタイルは Tailwind CSS(許可範囲)と最小限のCSSで構成しました。
1. 何を作る?(ゲームの目的と体験)
Hanabee はブラウザ上で動く「花火連鎖アクション」です。
参考にしたゲームは「ファンタビジョン」です。
- 制限時間(60秒)の間にスコアを稼ぐ
- 花火(ターゲット)をクリックすると爆発し、近くの花火が誘爆して「連鎖」する
- 連鎖が続くとコンボ倍率が上がる
- 最大コンボを一定時間維持すると「フィーバータイム」になり、難易度(出現頻度・速度)が一気に上がる
- 終了後にスコアを保存し、オンラインランキングに登録される
ゲームとしての気持ちよさは「1クリックで画面が派手になる」こと。
設計段階で意識したのは、破綻しにくいルールにすることでした。
2. 実装を進めやすくする設計方針
方針A:MVC 設計
有名な設計に MVC があります。
Hanabee では以下の様な形でルール化・想定アサインしています。
プロダクトなどでは厳密に検討するところですが、今回のゲームなどはスクラップアンドビルドするのでざっくり決めます。
M (Model):DOMとJSの連携を目的とする変数。UIのプロパティに反映するもの。あとは定数とかもこっち。
V (View):Canvasで使う描画の制御変数・関数。HTML/CSSも対象。
C (Controller):ゲーム状態を制御する変数・関数。M の条件を満たすものは M とする。今回はビジネスロジックなど明確化していないので、M の変更も C で行うことにする。
scripts/main.js の変数・関数として定義しています。
// scripts/main.js より(状態の一部)
let targets = [];
const GAME_TIME_TOTAL = 60.0;
let gameTime = GAME_TIME_TOTAL;
let score = 0;
let combo = 0;
let maxCombo = 0;
let playing = false;
// 演出
const particles = [];
本格的なプロジェクトならクラスなどを作って分けるべきでしょうか。
- C:
targets[]:画面の花火ターゲット - M:
GAME_TIME_TOTAL:最大残り時間 - M:
gameTime:現在の残り時間 - M:
score:スコア - M:
combo/maxCombo:現在のコンボと最大コンボ - C:
playing:ゲーム中かどうか - V:
particles[]:花火の粒(演出用)
MVC を定めるメリットとして、どのタイミングで初期化・更新・リセットなどするべきか
ある程度グルーピングして管理しやすくなるのでアプリケーションの設計として破綻しにくいです。
方針B:ゲームの中心は “1つのループ” に集約する
ゲームは毎フレーム(1秒に60回など)更新します。
この “ループ” を中心にすると、プログラムの見通しが良くなります。
Hanabee のメインループは requestAnimationFrame(loop) です。
function loop(){
const t = now();
const dt = clamp((t - lastTime)/1000, 0, 0.05);
lastTime = t;
if (playing){
gameTime = Math.max(0, gameTime - dt);
if (gameTime <= 0) { endGame(); }
stepTargets(dt);
}
updateHUD();
drawTargets();
stepParticles(dt);
drawParticlesOnFx();
if (!stopRAF) requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
「クリックした瞬間に色々処理する」よりも、
ループで毎フレーム整える方がバグが減ります。
3. 画面(UI)設計:まず最小のHUDを決める
設計段階で HUD(表示する情報)を固定しました。
- TIME:残り秒数
- SCORE:スコア
- COMBO:コンボ
- COMBOゲージ:次のヒット受付までの残り時間
HTML側は「Canvas(ゲーム画面)+HUD(DOM)+Overlay(結果画面)」に分けています。
<!-- index.html より(構造の考え方) -->
<div id="gameWrap" class="relative ...">
<canvas id="stage" class="w-full bg-slate-900"></canvas>
<!-- HUD(時間・スコア・コンボ) -->
<span id="uiTime">060</span>
<span id="uiScore">00000000</span>
<span id="uiCombo">00</span>
<div id="comboGaugeFill" style="width:0%"></div>
<!-- Overlay(タイトル/結果/入力) -->
<div id="overlay" class="hidden ...">
<input id="playerName" ... />
<button id="ovStart">Restart</button>
</div>
</div>
「Canvasは描画が得意」「DOMは文字入力やボタンが得意」。
得意な場所に役割分担させるのが設計のコツです。
4. ルール設計:連鎖・コンボ・フィーバーをどう決めたか
連鎖(誘爆)の設計:BFSで「近いものから順に爆発」
連鎖は、開始地点から「半径内にあるターゲット」を次々巻き込む処理です。
ここはアルゴリズムっぽく見えるけど、やっていることはシンプルで
キュー(queue)に入れて順に処理しているだけです(幅優先探索/BFS)。
// 連鎖爆発の中核(queueで回す)
function chainExplode(startIndex){
const queue = [];
queue.push(targets[startIndex]);
while (queue.length) {
const current = queue.shift();
const idx = targets.indexOf(current);
if (idx === -1) continue;
// 爆発演出
addFirework(current.x, current.y, current.hue);
// 近いターゲットを追加
for (const other of targets) {
if (other === current) continue;
const dist = Math.hypot(other.x - current.x, other.y - current.y);
if (dist <= (CHAIN_RADIUS_MULT * ...) * current.r) {
queue.push(other);
}
}
targets.splice(idx, 1);
}
}
「配列のindexがズレる」事故を避けるため、キューに indexではなくオブジェクト参照 を入れるのもポイントです。
コンボ設計:受付時間を2秒に固定し、ゲージで見える化
遊びやすさのために、コンボは「2秒以内に次を爆発できたら継続」にしました。
const COMBO_WINDOW = 2.0; // seconds
// ヒットがないと自然にコンボが切れる
if (combo > 0 && (nowSecForCombo - lastHitAt) > COMBO_WINDOW) {
combo = 0;
lastHitAt = -Infinity;
}
さらに、HUDに「残り時間ゲージ」を置くことで、
プレイヤーが“次を急ぐべきか”を直感で判断できます。
const remain = Math.max(0, COMBO_WINDOW - (nowSec - lastHitAt));
const pct = Math.max(0, Math.min(1, remain / COMBO_WINDOW));
comboGaugeFill.style.width = (pct * 100).toFixed(1) + '%';
フィーバー設計:「MAXコンボ維持」をトリガーにする
単に“ランダムでフィーバー”にすると納得感が弱いので、
上手いプレイのご褒美として発動する設計にしました。
-
MAX_COMBOを維持して -
FEVER_REQUIRE_DURATION秒が経つと発動
const MAX_COMBO = 16;
const FEVER_REQUIRE_DURATION = 10.0;
const FEVER_DURATION = 10.0;
if (!feverTriggered && comboMaxStartAt && (nowSec - comboMaxStartAt >= FEVER_REQUIRE_DURATION)) {
feverActive = true;
feverTriggered = true;
feverStartAt = nowSec;
}
設計として「強化(フィーバー)」は、
見た目だけではなく 出現頻度と速度を上げる ことで体験が変わるようにしました。
5. データ設計:ランキングは “最小の4項目”
ランキングの保存項目はこれだけに絞りました。
-
t:時刻(並び替え・同点処理用) -
name:プレイヤー名 -
score:スコア -
maxCombo:最大コンボ(同点の比較用)
GAS側でも同じヘッダを固定しています(Day3で詳しく解説します)。
const HEADERS = ['t','name','score','maxCombo'];
6. 全体アーキテクチャ(超ざっくり図)
ブラウザ(GitHub Pages)
- index.html(UI枠・入力)
- main.js(ゲーム本体)
- Canvas描画(花火/ターゲット)
|
| fetch(POST/GET)
v
GAS(Webアプリ)
- leaderboard.gs
|
v
Google スプレッドシート(ランキング保存)
ポイントは「サーバは最小」「ゲームは全部ブラウザ」という点です。
コンテストの要件や、運用しやすいことも意識しました。
7. この段階で決めてよかった工夫点(まとめ)
- MVC設計:アプリケーションの品質が担保しやすくなる
- CanvasとDOMの役割分担:入力やHUDはDOM、派手な演出はCanvas
- ルールは見える化:コンボゲージで理解しやすい
- 連鎖はキューで実装:複雑に見える処理をシンプルに保てる
次回(Day2)
Day2ではフロントエンド実装として、
- 画面サイズ調整(16:9の維持、スマホ対応)
- 縦持ちブロック(事故りやすいポイントの対策)
- 演出(残像・発光)
- 入力(pointer/touch)
を、コード引用しながら解説します。
シリーズとリンク
この投稿は「Hanabee制作記」シリーズのDay1です。続きはDay2/Day3へ。