実験#43:スピードとバランス(しょうが君のファイヤースキー)

■43個目「しょうが君のファイヤースキー

こんにちは、AIと作る100のゲーム実験室の みー です。(^_^)/

今回は、雪山を颯爽と滑り降りるタイムアタックゲームを作ってみました。
スキー板を履いて雪山に挑むのは……“しょうが君” です!

しょうが君は、笑顔と冒険者風の帽子がトレードマーク。  
ポカポカ温まる「生姜」の力で、冷たい雪山もへっちゃらなベテラン冒険者なんです。

今回こだわったのは、雪山ならではのハラハラ感と、
それを吹き飛ばす爽快感です。(=v=*)

「障害物を避けるだけじゃなくて、たまには一気に蹴散らせたら気持ちいいだろうなぁ」

そんな遊び心から、ちょっと変わったアイテムやお邪魔キャラを登場させてみました。

コースの途中には、こんな仕掛けが待っています。

① 赤いバケツを被って左右にずりずり動く、愛嬌たっぷりの「雪だるま」  ② 取ると一定時間無敵になれる、ポカポカの「🔥」アイテム

🔥アイテムを取ると「ファイヤーしょうが君」に変身! 木や雪だるまをド派手に吹き飛ばしながら進めます。

雪煙を上げて斜面を駆け抜ける姿は、
見ているだけでもスカッとします。o(*>v<)o”

そして、猛スピードのまま落ちないように、
川の手前のゴールゾーンでピタッと止まれると……

雪の妖精「シマエナガ」たちが集まってきて、
豪華な紙吹雪でお祝いしてくれます。

クリアタイムと一緒に、シマエナガたちからの素敵な祝福メッセージもランダムで見られます。

どんな言葉が飛び出すか、ぜひ遊んで確かめてみてください。

■ AIとのやりとりで面白かった部分

■ゲーム仕様の相談からスタート

みー:(^v^)今日もゲームを作ります。
今回は HTML で動くスキーゲーム。
画面上部にプレイヤーがいて、自動で滑り下りていきます。

途中には木やでこぼこがランダムで出現して、ぶつかったり転倒するとゲームオーバー。 滑り下りた距離がスコアになり、ランキングもあります。

直線で滑るとスピードがどんどん上がって転倒しやすくなるので、
「安定度メーター」を用意して、でこぼこに当たったりスピードが上がると不安定に。
一定ラインを超えると転倒します。 スピードを落としたり時間が経つと、少しずつ回復していきます。

操作はマウスで左右のエッジを利かせて曲がる&減速、
スペースキーでボーゲン減速。
プレイヤーは雪山でも元気なしょうが君。

しょうが君は、縦長楕円の胴体に満面の笑顔、冒険者風の帽子。 手足もしょうがで、スキーセットを装備しています。

アイテムとして「🔥」が稀に落ちていて、取ると“ファイヤーしょうが君”に変身。
7秒間は木に当たってもゲームオーバーにならず、安定度も0のまま。
最終地点には川があり、落ちないようにギリギリで止まるとゴール。
シマエナガたちが祝福してくれます。
川に落ちたらゲームオーバー。
リトライもできます。

AI: 雪山を滑走するしょうが君のスキーゲームだね。

生姜に含まれる「ジンゲロール」は、加熱や乾燥で「ショウガオール」に変化して、 体を芯から温める力がグッと強くなるんだ。

だから、アイテムを取って“ファイヤーしょうが君”になる設定は、 理科や栄養学の観点から見てもすごく理にかなっているよ。

みー:(^_^*) 雪だるまを出しましょう。
背景の雪に紛れないように存在感があって、 愛嬌のあるイメージで。
ランダム出現で、左右にゆっくりずりずり動くお邪魔キャラ。
ぶつかったら吹き飛んで、安定度メーターにも影響する感じで。
あと、ゴールエリアを150%ほど拡大させよう。

AI: 雪だるまのお邪魔キャラ、いいね。
背景に埋もれないように存在感を出しつつ、愛嬌もキープしておいたよ。

ぶつかった時はド派手に吹き飛ぶようにして、安定度にも影響するよう調整済み。
ゴールエリアもご要望通りしっかり広げておいた。
川の手前のドキドキ感は残しつつ、ちょっと余裕ができたはず。

みー:(つv<*) ゴールした時のシマエナガの祝福を、もう少し豪華にしてみよう! 祝福の言葉も5種類ぐらい、素敵なのをお願い。

AI: シマエナガの祝福を豪華にしたいって?任せてくれ!
あの子たちの可愛いお祝いシーンを、もっと心温まる演出にアップデートしたよ。

ゴール時のセリフも5種類、ロマンチックで素敵なのを用意しておいた。 どの言葉が聞けるかは、君の滑り次第だ。

さあ、特製アップデート版 v8「シマエナガ・フェスティバル」。 音量を少し上げて、最速タイムと感動のゴールを目指して滑り降りてくれ!

■ 実際の動作

まずは、AIと相談しながら作ってもらった 初期状態のゲーム画面 です。
今回はゲーム案の段階でかなり詳しく相談したので、最初から整った形になっています。
(画像:AIが最初に作った基本のベース画面)

こちらが 修正版のスタート画面 です。

(画像:仕上がり版スタート画面)

ゲームが始まったら、しょうが君をマウスで操作します。
スピードを下げたいときは、スペースキーでボーゲン減速ができます。

(画像:仕上がりのゲーム画面)

ゴール地点でスピードを出しすぎていると、
そのまま川に飛び込んでしまいリタイアとなります。

(画像:川に飛び込むしょうが君)

■ コードのポイント

今回の記事では、AI と相談しながら作った しょうが君のファイヤースキー の HTML コードを公開します。

ブラウザだけで動く、とてもシンプルなゲームです。 コードも難しい部分はほとんどないので、気軽に試してみてくださいね。

【遊ぶまでの手順】

1. PC の「メモ帳」(※Windows の場合)を開きます。
2. 下のコードをコピーして貼り付けます。
3. 「syougasuki.html」など、好きな名前で保存します。
4. 保存したファイルをブラウザで開くだけで遊べます。

🌿 遊び方・操作方法 🌿

遊び方はとっても直感的でカンタンです。(^_^)

⛄ 基本の操作

・マウスを左右に動かす しょうが君がマウスに合わせて左右に移動します。 大きく動かすとスキーの「エッジ」が効いて、少し減速しながら雪煙を上げて滑ります。

・スペースキーを押す 押している間は、スキー板を「ハの字」にする“ボーゲン”の姿勢になり、急ブレーキができます。

⛄ 気をつけるポイント

画面右上には、しょうが君の 安定度メーター があります。

直滑降でスピードが出すぎたり、 コース上の「でこぼこ」や、無敵状態でない時に「雪だるま」にぶつかると、 バランスを崩してメーターが減ってしまいます。(>_<)

メーターがゼロになると転倒してしまうので、 「危ない!」と思ったらスペースキーでしっかり減速して、態勢を立て直してくださいね。

スピードを落とせば、安定度は徐々に回復していきます。(=v=*)

⛄ ゴールを目指して!

木を避け、雪だるまを蹴散らしながら進んでいくと、 最終地点には冷たい が待ち構えています。

川の手前にある緑色の GOAL ZONE でしっかり止まることができれば、見事クリア!(´▽`*)

勢い余って川にドボン……となるとリタイアになってしまうので気をつけてくださいね。

最速タイムはトップ3まで記録されます。 ぜひ何度も挑戦して、シマエナガたちと一緒に最高のお祝いを味わってみてください。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>しょうが君のファイヤースキー</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #2c3e50;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
}
#gameWrapper {
position: relative;
}
#gameContainer {
position: relative;
width: 500px;
height: 700px;
background-color: #fffafa;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
overflow: hidden;
}
canvas {
display: block;
}
#uiLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hud {
position: absolute;
padding: 10px;
font-weight: bold;
font-size: 16px;
color: #333;
}
#scoreDisplay {
top: 10px;
left: 10px;
background: rgba(255,255,255,0.7);
border-radius: 8px;
padding: 10px;
min-width: 150px;
}
#timeVal { font-size: 20px; }
#rankingContainer {
margin-top: 5px;
border-top: 1px solid #ccc;
padding-top: 5px;
}
.ranking-title {
font-size: 18px;
margin-bottom: 3px;
color: #e67e22;
}
.ranking-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 24px;
font-weight: bold;
}
.ranking-item { margin-bottom: 2px; }
.rank-1 { color: #f1c40f; }
.rank-2 { color: #95a5a6; }
.rank-3 { color: #d35400; }
.rank-num { margin-right: 5px; }
.small-text {
font-size: 14px;
color: #555;
font-weight: normal;
}
#stabilityContainer {
top: 10px;
right: 10px;
width: 150px;
text-align: right;
background: rgba(255,255,255,0.7);
border-radius: 8px;
padding: 10px;
}
#stabilityBar {
width: 100%;
height: 15px;
background: #ddd;
border: 2px solid #333;
margin-top: 5px;
position: relative;
}
#stabilityFill {
height: 100%;
background: limegreen;
width: 100%;
transition: background 0.2s, width 0.1s;
}
#overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
pointer-events: auto;
}
#overlay h1 { font-size: 36px; margin-bottom: 10px; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); }
#overlay p { font-size: 18px; margin-bottom: 20px; text-align: center; line-height: 1.5; }
button {
padding: 12px 35px;
font-size: 20px;
cursor: pointer;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 5px;
font-weight: bold;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
transition: transform 0.1s, background-color 0.2s;
}
button:hover { background-color: #c0392b; transform: translateY(-2px); }
button:active { transform: translateY(0); }
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="gameWrapper">
<div id="gameContainer">
<canvas id="gameCanvas" width="500" height="700"></canvas>
<div id="uiLayer">
<div id="scoreDisplay" class="hud">
タイム: <span id="timeVal">0.00</span><br>
<div id="rankingContainer">
<div class="ranking-title">最速記録トップ3</div>
<ul id="bestTimesList" class="ranking-list"></ul>
</div>
<span class="small-text">現在位置: <span id="distVal">0</span> m</span>
</div>
<div id="stabilityContainer" class="hud">
安定度
<div id="stabilityBar"><div id="stabilityFill"></div></div>
</div>
</div>
<div id="overlay">
<h1 id="resultTitle">しょうが君のスキー</h1>
<p id="resultMsg">マウス左右で移動&エッジ減速<br>スペースキーでボーゲン(急減速)</p>
<button id="startBtn">スタート</button>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const gameWrapper = document.getElementById('gameWrapper');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('startBtn');
const resultTitle = document.getElementById('resultTitle');
const resultMsg = document.getElementById('resultMsg');
const distVal = document.getElementById('distVal');
const timeVal = document.getElementById('timeVal');
const bestTimesList = document.getElementById('bestTimesList');
const stabilityFill = document.getElementById('stabilityFill');
const GOAL_DISTANCE = 10000;
const GOAL_ZONE_LENGTH = 300;
const MAX_SPEED = 15;
const DANGER_SPEED = 8;
const FIRE_DURATION = 5000;
const STORAGE_KEY = 'shouga_ski_best_times_v9';
let bestTimes = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
function displayBestTimes() {
bestTimesList.innerHTML = '';
for (let i = 0; i < 3; i++) {
const li = document.createElement('li');
li.className = `ranking-item rank-${i+1}`;
const timeStr = bestTimes[i] ? bestTimes[i].toFixed(2) + ' 秒' : '--';
li.innerHTML = `<span class="rank-num">${i+1}位:</span> <span class="rank-time">${timeStr}</span>`;
bestTimesList.appendChild(li);
}
}
displayBestTimes();
let state = 'start';
let distance = 0;
let speedY = 2;
let stability = 100;
let lastTime = 0;
let startTime = 0;
let elapsedTime = 0;
let shakeAmount = 0;
let player = {
x: 250, y: 150, width: 30, height: 50,
isBraking: false, isFire: false, fireEndTime: 0, tilt: 0
};
let objects = [];
let snowParticles = [];
let celebrationParticles = [];
let mouthParticles = [];
let mouseX = 250;
// ----- 音声処理 -----
let audioCtx, windGain, windFilter;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const bufferSize = audioCtx.sampleRate * 2;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
let noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = buffer;
noiseSource.loop = true;
windFilter = audioCtx.createBiquadFilter();
windFilter.type = 'lowpass';
windFilter.frequency.value = 100;
windGain = audioCtx.createGain();
windGain.gain.value = 0;
noiseSource.connect(windFilter);
windFilter.connect(windGain);
windGain.connect(audioCtx.destination);
noiseSource.start();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playSFX(type) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (type === 'crash') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(10, now + 0.5);
gain.gain.setValueAtTime(0.5, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.5);
osc.start(now); osc.stop(now + 0.5);
} else if (type === 'fire') {
osc.type = 'sine';
osc.frequency.setValueAtTime(600, now);
osc.frequency.linearRampToValueAtTime(1200, now + 0.2);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
} else if (type === 'clear') {
osc.type = 'triangle';
osc.frequency.setValueAtTime(523.25, now);
osc.frequency.setValueAtTime(659.25, now + 0.1);
osc.frequency.setValueAtTime(783.99, now + 0.2);
osc.frequency.setValueAtTime(1046.50, now+ 0.3);
gain.gain.setValueAtTime(0.4, now);
gain.gain.linearRampToValueAtTime(0.01, now + 1.0);
osc.start(now); osc.stop(now + 1.0);
}
}
// ----- 入力 -----
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
});
window.addEventListener('keydown', (e) => { if (e.code === 'Space') player.isBraking = true; });
window.addEventListener('keyup', (e) => { if (e.code === 'Space') player.isBraking = false; });
startBtn.addEventListener('click', () => {
initAudio();
initGame();
state = 'playing';
overlay.classList.add('hidden');
});
function initGame() {
distance = 0; speedY = 2; stability = 100; shakeAmount = 0;
gameWrapper.style.transform = `translate(0px, 0px)`;
player.x = 250; player.isBraking = false; player.isFire = false; player.tilt = 0;
objects = []; snowParticles = []; celebrationParticles = []; mouthParticles = [];
const now = performance.now();
lastTime = now; startTime = now; elapsedTime = 0;
}
function spawnObject() {
if (distance > GOAL_DISTANCE - 1000) return;
if (Math.random() < 0.05) {
let r = Math.random();
let type = r < 0.5 ? 'tree' : (r < 0.8 ? 'bump' : (r < 0.95 ? 'snowman' : 'fire'));
let vx = type === 'snowman' ? (Math.random() - 0.5) * 2 : 0;
objects.push({
type: type, x: Math.random() * (canvas.width - 40) + 20, y: canvas.height + 50,
width: type === 'fire' ? 30 : 40, height: type === 'fire' ? 30 : 40,
active: true, vx: vx, rotation: 0
});
}
}
function checkCollision(obj) {
let hitX = (player.x - 10 < obj.x + obj.width/2) && (player.x + 10 > obj.x - obj.width/2);
let hitY = (player.y - 20 < obj.y + obj.height/2) && (player.y + 20 > obj.y - obj.height/2);
return hitX && hitY;
}
function createSnowDust(x, y, intensity) {
for(let i=0; i<intensity; i++) {
snowParticles.push({
x: x + (Math.random() - 0.5) * 30, y: y + 20 + (Math.random() - 0.5) * 10,
vx: (Math.random() - 0.5) * 4, vy: -Math.random() * 3 - 1, size: Math.random() * 4 + 2, life: 20
});
}
}
function createCelebrationParticles(riverY) {
const colors = ['#f1c40f', '#e74c3c', '#3498db', '#2ecc71', '#e0e0e0'];
for(let i=0; i<150; i++) {
celebrationParticles.push({
x: Math.random() * canvas.width, y: riverY - GOAL_ZONE_LENGTH + Math.random() * (GOAL_ZONE_LENGTH + player.y),
vx: (Math.random() - 0.5) * 15, vy: -20 - Math.random() * 20,
size: Math.random() * 6 + 2, color: colors[Math.floor(Math.random() * colors.length)], life: 100 + Math.random() * 100
});
}
}
function createMouthParticles(x, y) {
for(let i=0; i<10; i++) {
mouthParticles.push({
x: x + (Math.random() - 0.5) * 5, y: y + (Math.random() - 0.5) * 5,
vx: (Math.random() - 0.5) * 2, vy: -Math.random() * 2 - 1, size: Math.random() * 3 + 1, life: 30
});
}
}
function update(dt, now) {
if (shakeAmount > 0) {
shakeAmount *= 0.9;
let sx = (Math.random() - 0.5) * shakeAmount;
let sy = (Math.random() - 0.5) * shakeAmount;
gameWrapper.style.transform = `translate(${sx}px, ${sy}px)`;
if (shakeAmount < 0.5) {
shakeAmount = 0;
gameWrapper.style.transform = `translate(0px, 0px)`;
}
}
if (state !== 'playing') {
if (windGain) windGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
for(let i = celebrationParticles.length - 1; i >= 0; i--) {
celebrationParticles[i].life--;celebrationParticles[i].x += celebrationParticles[i].vx;celebrationParticles[i].y += celebrationParticles[i].vy;celebrationParticles[i].vy += 0.5;celebrationParticles[i].vx *= 0.98;
if(celebrationParticles[i].life <= 0) celebrationParticles.splice(i, 1);
}
for(let i = mouthParticles.length - 1; i >= 0; i--) {
mouthParticles[i].life--;mouthParticles[i].x += mouthParticles[i].vx;mouthParticles[i].y += mouthParticles[i].vy;
if(mouthParticles[i].life <= 0) mouthParticles.splice(i, 1);
}
if (state === 'clear' && mouthParticles.length < 5) createMouthParticles(player.x, player.y - 10);
for(let i = snowParticles.length - 1; i >= 0; i--) {
snowParticles[i].life--;snowParticles[i].x += snowParticles[i].vx;snowParticles[i].y += snowParticles[i].vy;
if(snowParticles[i].life <= 0) snowParticles.splice(i, 1);
}
objects.forEach(obj => {
if (obj.type === 'broken_tree' || obj.type === 'broken_snowman') {
obj.x += obj.vx;obj.y += obj.vy;obj.vy += 0.5;obj.rotation += obj.rotSpeed;
}
});
return;
}
elapsedTime = (now - startTime) / 1000;
if (player.isFire && now > player.fireEndTime) player.isFire = false;
let diffX = mouseX - player.x;let moveX = diffX * 0.05;player.x += moveX;player.x = Math.max(20, Math.min(canvas.width - 20, player.x));
let targetTilt = Math.max(-Math.PI/6, Math.min(Math.PI/6, -diffX * 0.015)); player.tilt += (targetTilt - player.tilt) * 0.2;
let edgeBrake = Math.abs(moveX) > 1 ? 0.05 : 0;
if (player.isBraking) { speedY -= 0.2;createSnowDust(player.x, player.y, 3); } else { speedY += 0.05; if (Math.abs(moveX) > 2) createSnowDust(player.x, player.y, 1); }
speedY -= edgeBrake;speedY = Math.max(0, Math.min(MAX_SPEED, speedY));
if (windFilter && windGain) {
let targetFreq = 100 + (speedY * 50);let targetGain = (speedY / MAX_SPEED) * 0.3;
if (player.isBraking) { targetFreq += 500; targetGain += 0.1; }
windFilter.frequency.setTargetAtTime(targetFreq, audioCtx.currentTime, 0.1);windGain.gain.setTargetAtTime(targetGain, audioCtx.currentTime, 0.1);
}
if (player.isFire) stability = 100; else { if (speedY > DANGER_SPEED) stability -= (speedY - DANGER_SPEED) * 0.1;else stability += 0.2; }
stability = Math.max(0, Math.min(100, stability));
if (stability <= 0) { playSFX('crash');shakeAmount = 25;gameOver("スピードの出しすぎで転倒した!");return; }
distance += speedY;let remainingToRiver = GOAL_DISTANCE - distance;let riverY = player.y + remainingToRiver;
if (distance >= GOAL_DISTANCE) { playSFX('crash');shakeAmount = 20;gameOver("止まれずに川に落ちてしまった!");return; } else if (distance >= GOAL_DISTANCE - GOAL_ZONE_LENGTH && speedY < 0.5) { playSFX('clear');shakeAmount = 10;createCelebrationParticles(riverY);gameClear(riverY);return; }
for (let i = objects.length - 1; i >= 0; i--) {
let obj = objects[i];
if (obj.type === 'broken_tree' || obj.type === 'broken_snowman') {
obj.x += obj.vx; obj.y += obj.vy; obj.vy += 0.5; obj.rotation += obj.rotSpeed;
} else {
obj.y -= speedY;
if (obj.type === 'snowman') { obj.x += obj.vx;if (obj.x < 20 || obj.x > canvas.width - 20) obj.vx *= -1; }
}
if (obj.active && checkCollision(obj)) {
if (obj.type === 'tree') {
if (player.isFire) {
obj.type = 'broken_tree'; obj.active = false;obj.vx = (Math.random() > 0.5 ? 1 : -1) * (10 + Math.random() * 5); obj.vy = -10 - Math.random() * 5; obj.rotation = 0; obj.rotSpeed = (Math.random() - 0.5) * 0.5; playSFX('crash'); shakeAmount = 10;
} else { playSFX('crash'); shakeAmount = 25;gameOver("木に激突した!"); return; }
} else if (obj.type === 'snowman') {
if (player.isFire) {
obj.type = 'broken_snowman'; obj.active = false;obj.vx = (Math.random() > 0.5 ? 1 : -1) * (12 + Math.random() * 6); obj.vy = -12 - Math.random() * 6; obj.rotation = 0; obj.rotSpeed = (Math.random() - 0.5) * 0.6;playSFX('crash'); shakeAmount = 10;
} else {
obj.type = 'broken_snowman'; obj.active = false;obj.vx = (Math.random() > 0.5 ? 1 : -1) * (5 + Math.random() * 3); obj.vy = -8 - Math.random() * 4; obj.rotation = 0; obj.rotSpeed = (Math.random() - 0.5) * 0.4;stability -= 25; speedY *= 0.8; playSFX('crash'); shakeAmount = 10;
}
} else if (obj.type === 'bump') {
obj.active = false;if (!player.isFire) { stability -= 25; speedY *= 0.8; shakeAmount = 10; }
} else if (obj.type === 'fire') {
obj.active = false; player.isFire = true;player.fireEndTime = now + FIRE_DURATION; stability = 100; playSFX('fire');
}
}
if (obj.y < -100 || obj.y > canvas.height + 200 || obj.x < -100 || obj.x > canvas.width + 100) { objects.splice(i, 1); }
}
spawnObject();
for(let i = snowParticles.length - 1; i >= 0; i--) {
snowParticles[i].life--; snowParticles[i].x += snowParticles[i].vx;snowParticles[i].y += snowParticles[i].vy - speedY * 0.5;
if(snowParticles[i].life <= 0) snowParticles.splice(i, 1);
}
distVal.innerText = Math.floor(distance / 10); timeVal.innerText = elapsedTime.toFixed(2);
stabilityFill.style.width = stability + '%';
if (player.isFire) stabilityFill.style.background = 'orange';else stabilityFill.style.background = stability > 50 ? 'limegreen' : (stability > 20 ? 'yellow' : 'red');
}
function draw() {
ctx.fillStyle = '#fffafa';ctx.fillRect(0, 0, canvas.width, canvas.height);ctx.strokeStyle = '#e0e0e0';ctx.lineWidth = 2;ctx.beginPath();
for(let i=0; i<5; i++) { let lineY = (distance * 0.5 + i * 150) % canvas.height;ctx.moveTo(0, lineY);ctx.lineTo(canvas.width, lineY + 50); }
ctx.stroke();
let remainingToRiver = GOAL_DISTANCE - distance;let riverY = player.y + remainingToRiver;let goalLineY = riverY - GOAL_ZONE_LENGTH;
if (riverY < canvas.height && riverY > -1000) { ctx.fillStyle = '#3498db'; ctx.fillRect(0, riverY, canvas.width, canvas.height);ctx.fillStyle = '#2980b9'; ctx.fillRect(0, riverY + 20, canvas.width, 10); }
if (goalLineY < canvas.height && riverY > 0) {
ctx.fillStyle = 'rgba(46, 204, 113, 0.2)'; ctx.fillRect(0, goalLineY, canvas.width, GOAL_ZONE_LENGTH);ctx.strokeStyle = '#2ecc71'; ctx.lineWidth = 4; ctx.setLineDash([15, 10]);
ctx.beginPath(); ctx.moveTo(0, goalLineY); ctx.lineTo(canvas.width, goalLineY); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = '#27ae60'; ctx.font = 'bold 20px Arial'; ctx.textAlign = 'center';ctx.fillText('GOAL ZONE (ここで止まれ!)', canvas.width / 2, goalLineY - 10);
}
if (remainingToRiver <= 2000 && remainingToRiver > GOAL_ZONE_LENGTH) {
ctx.fillStyle = 'red'; ctx.font = 'bold 26px Arial'; ctx.textAlign = 'center';
if (Math.floor(performance.now() / 500) % 2 === 0) { ctx.fillText(`⚠️ もうすぐ川だ!残り ${Math.floor(remainingToRiver / 10)}m ⚠️`, canvas.width / 2, 80); }
}
objects.forEach(obj => {
if (!obj.active && obj.type !== 'bump' && obj.type !== 'broken_tree' && obj.type !== 'broken_snowman') return;
ctx.save(); ctx.translate(obj.x, obj.y);
if (obj.type === 'tree') {
ctx.fillStyle = '#8b5a2b'; ctx.fillRect(-5, 0, 10, 20);ctx.fillStyle = '#2ecc71'; ctx.beginPath(); ctx.moveTo(0, -20); ctx.lineTo(-15, 5); ctx.lineTo(15, 5); ctx.fill();
} else if (obj.type === 'broken_tree') {
ctx.rotate(obj.rotation); ctx.fillStyle = '#8b5a2b'; ctx.fillRect(-5, -10, 10, 20);ctx.fillStyle = '#2ecc71'; ctx.beginPath(); ctx.arc(0, -10, 10, 0, Math.PI*2); ctx.fill();
} else if (obj.type === 'snowman' || obj.type === 'broken_snowman') {
if (obj.type === 'broken_snowman') ctx.rotate(obj.rotation);
ctx.fillStyle = '#ecf0f1'; ctx.strokeStyle = '#95a5a6'; ctx.lineWidth = 1;ctx.beginPath(); ctx.arc(0, 12, 18, 0, Math.PI*2); ctx.fill(); ctx.stroke();ctx.beginPath(); ctx.arc(0, -8, 12, 0, Math.PI*2); ctx.fill(); ctx.stroke();ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.moveTo(-10, -18); ctx.lineTo(10, -18); ctx.lineTo(8, -30); ctx.lineTo(-8, -30); ctx.closePath(); ctx.fill();ctx.fillStyle = '#333'; ctx.fillRect(-11, -19, 22, 2); ctx.fillStyle = '#f39c12'; ctx.fillRect(-12, -2, 24, 6); ctx.fillRect(2, -2, 6, 15); ctx.fillStyle = 'black'; ctx.beginPath(); ctx.arc(-5, -12, 1.5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(5, -12, 1.5, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#e67e22'; ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(3, -9); ctx.lineTo(0, -8); ctx.fill();ctx.strokeStyle = 'black'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, -8, 4, 0.2, Math.PI-0.2); ctx.stroke();
} else if (obj.type === 'bump') {
ctx.fillStyle = '#ecf0f1'; ctx.strokeStyle = '#bdc3c7'; ctx.lineWidth = 2;ctx.beginPath(); ctx.ellipse(0, 0, 20, 10, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
} else if (obj.type === 'fire') { ctx.font = '24px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('🔥', 0, 0); }
ctx.restore();
});
snowParticles.forEach(p => { ctx.fillStyle = 'rgba(236, 240, 241, ' + (p.life / 20) + ')'; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); });
celebrationParticles.forEach(p => { ctx.fillStyle = p.color; ctx.globalAlpha = p.life / 100; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); ctx.globalAlpha = 1.0; });
mouthParticles.forEach(p => { ctx.fillStyle = '#ff69b4'; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); });
drawShouga(player.x, player.y, player.isFire, player.isBraking, player.tilt);
if (state === 'clear') drawShimaenagas();
}
function drawShouga(x, y, isFire, isBraking, tilt) {
ctx.save(); ctx.translate(x, y); ctx.rotate(tilt);
if (isFire) { ctx.fillStyle = 'rgba(255, 165, 0, 0.5)'; ctx.beginPath(); ctx.arc(0, 0, 40 + Math.random()*5, 0, Math.PI*2); ctx.fill(); }
// [修正] 胴体の描画(斜線模様を追加)
ctx.fillStyle = '#e4bc8a';
ctx.beginPath(); ctx.ellipse(0, 0, 18, 28, 0, 0, Math.PI*2); ctx.fill();
// 胴体下半分の薄い斜線模様
ctx.save();
ctx.beginPath(); ctx.ellipse(0, 0, 18, 28, 0, 0, Math.PI * 2); ctx.clip();
ctx.beginPath(); ctx.rect(-20, 0, 40, 30); ctx.clip();
ctx.strokeStyle = '#c69c6d'; ctx.lineWidth = 1; ctx.globalAlpha = 0.3;
ctx.beginPath();
for (let yL = -40; yL < 60; yL += 10) { ctx.moveTo(-40, yL + 40); ctx.lineTo(40, yL - 40); }
ctx.stroke();
ctx.restore();
// スキー板、手足
ctx.fillStyle = '#e74c3c';
if (isBraking) {
ctx.save(); ctx.rotate(Math.PI / 8); ctx.fillRect(-25, 10, 6, 50); ctx.restore();
ctx.save(); ctx.rotate(-Math.PI / 8); ctx.fillRect(19, 10, 6, 50); ctx.restore();
} else { ctx.fillRect(-15, 10, 6, 50); ctx.fillRect(9, 10, 6, 50); }
ctx.fillStyle = '#d3a167';ctx.beginPath(); ctx.ellipse(-18, 5, 5, 10, Math.PI/4, 0, Math.PI*2); ctx.fill();ctx.beginPath(); ctx.ellipse(18, 5, 5, 10, -Math.PI/4, 0, Math.PI*2); ctx.fill();
// 顔
ctx.fillStyle = 'black'; ctx.beginPath(); ctx.arc(-6, -12, 2, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(6, -12, 2, 0, Math.PI*2); ctx.fill();
// [修正] 口の色を赤に
ctx.strokeStyle = 'red'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, -6, 8, 0, Math.PI); ctx.stroke();
// 帽子
// [修正] 帽子の上のところをもう少し四角よりの形に(ワークキャップ風)
ctx.fillStyle = '#8b4513';
// 1. ツバ(ellipseはそのまま活かす)
ctx.beginPath(); ctx.ellipse(0, -26, 22, 6, 0, 0, Math.PI*2); ctx.fill();
// 2. クラウン(頭頂部)を丸いarcから四角いシルエットへ
ctx.beginPath();
ctx.moveTo(-15, -28); // 左下
ctx.lineTo(15, -28); // 右下
ctx.lineTo(13, -36); // 右上(少し窄める)
ctx.lineTo(-13, -36); // 左上
ctx.closePath();
ctx.fill();
// 3. 少し立体感を出すための影(オプション、薄く)
ctx.fillStyle = 'rgba(0,0,0,0.1)';
ctx.fillRect(-13, -36, 26, 2);
ctx.restore();
}
function drawShimaenagas() {
ctx.save();
const positions = [ [150, player.y - 50], [350, player.y - 40], [250, player.y - 80] ];
const now = performance.now();
positions.forEach(pos => {
let hopOffset = Math.sin(now / 300 + pos[0]) * 3;
let wingVisible = (now / 200 + pos[1]) % 2 === 0;
ctx.translate(pos[0], pos[1] + hopOffset);
ctx.fillStyle = '#333'; ctx.beginPath(); ctx.ellipse(10, 10, 3, 15, -Math.PI/4, 0, Math.PI*2); ctx.fill();
if (wingVisible) {
ctx.fillStyle = 'white'; ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(-15, 0, 10, 5, Math.PI/6, 0, Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(15, 0, 10, 5, -Math.PI/6, 0, Math.PI*2); ctx.fill(); ctx.stroke();
}
ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'black'; ctx.beginPath(); ctx.arc(-5, -2, 1.5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(5, -2, 1.5, 0, Math.PI*2); ctx.fill();ctx.fillStyle = '#f39c12'; ctx.beginPath(); ctx.moveTo(-2, 2); ctx.lineTo(2, 2); ctx.lineTo(0, 5); ctx.fill();
ctx.translate(-pos[0], -(pos[1] + hopOffset));
});
ctx.restore();
}
function updateBestTimes() {
bestTimes.push(elapsedTime);bestTimes.sort((a, b) => a - b);bestTimes = bestTimes.slice(0, 3);
localStorage.setItem(STORAGE_KEY, JSON.stringify(bestTimes));displayBestTimes();
}
function gameOver(reason) {
state = 'gameover';
resultTitle.innerText = "無念のリタイア";
resultMsg.innerHTML = `${reason}<br><br>到達地点: ${Math.floor(distance / 10)} m<br>経過タイム: ${elapsedTime.toFixed(2)} 秒`;
startBtn.innerText = "リトライ"; overlay.classList.remove('hidden');
}
function gameClear() {
state = 'clear'; updateBestTimes();
let isNewRecord = bestTimes.includes(elapsedTime);
resultTitle.innerText = "感動のゴール!!";
const wonderfulMessages = ["風のように駆け抜け、伝説のシュプールを描いたね!シマエナガたちが、君の勇姿に心を奪われているよ!","見事な雪山の冒険者だ!その熱い滑りは、氷点下の世界さえも温めたよ。最高のゴールシーンだ!","白い妖精たちが舞い踊る!君の華麗なストップに、シマエナガ一同、スタンディングオベーションだよ!","奇跡のゴールイン!この美しい雪原に、君の情熱という名の新たな轍(わだち)が刻まれたね!","君の滑りは、この冷たい雪山さえも熱く燃え上がらせた!生姜の力が、今、最高の思い出となって輝いているよ!"];
let msg = wonderfulMessages[Math.floor(Math.random() * wonderfulMessages.length)] + "<br><br>";
msg += `<span style="font-size: 24px; color: #f1c40f;">クリアタイム: ${elapsedTime.toFixed(2)} 秒</span>`;
if (isNewRecord) {
const rank = bestTimes.indexOf(elapsedTime) + 1;const rankColors = ['#f1c40f', '#95a5a6', '#d35400'];
msg += `<br><br><strong style='color: ${rankColors[rank-1]};'>★${rank}位にランクイン!おめでとう!★</strong>`;
}
resultMsg.innerHTML = msg; startBtn.innerText = "もう一度タイムに挑む";
setTimeout(() => { overlay.classList.remove('hidden'); }, 2000);
}
function gameLoop(now) {
let dt = now - lastTime; lastTime = now; update(dt, now); draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>


■ 今日の学び

R8年4月22日現在、ついに 98個目 までゲームができました。
「毎日1つ作る」を基本に楽しんできましたが、お休みの日には2つ、3つと作る日もあり、 1月24日から始めて 100日経たずに100個に届きそう です。

まだブログで紹介できているゲームは半分の50にも届いていないので、 ほかのゲームたちも早く紹介したい気持ちでいっぱいです。

種類も世界観もバラバラなゲームたちですが、 どこかの要素でゆるくつながっていて、 このブログ全体がひとつの大きなゲームのようにも思えてきました。

これからは 1,000のゲーム実験室 を目指しつつ、 ゲームのもとになる 「自分自身の経験値を高めていくこと」 も、同時進行の目標にしていきます。

座右の銘は「進歩」。 今日より少しだけ前へ。そんな気持ちで続けていきます。p(^o^)q”


■ 次回は、癒しゲームです。

疲れたあなたに、ほっと一息つける癒しのひと時を。(*=_=)ノ~*

次回は、テーマの違うステージから好きなものを選んで、
ぽよぽよと気ままにクリックするだけで楽しめる、
やさしい 「癒しゲーム」 をご紹介します。

深く考えず、ただ触れているだけで心がゆるむような、
そんな小さな癒しをお届けできればと思っています。

次回もお楽しみに。(^v^)ノシ

次の実験はこちら(自然と癒し効果を再現):「癒やしの渦(Natural Vortex)



 今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)

~このブログについて~

AIと一緒に遊び、
100の実験でゲームを育てるブログ

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー


AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む