このページでは、ひなテックGamesで実際に動いている「おさかなサバイバル」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直した特別なコードではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。
おさかなサバイバルは、自分より小さい魚を食べて大きくなり、大きい魚から逃げるアクションゲームです。魚はHTMLの<canvas>に1ぴき1ぴきプログラムで描いていて、自分が大きくなると画面がだんだんズームアウトしていく「カメラ」のしくみも入っています。見た目以上に中身がぎゅっと詰まった題材です。
コードはコピーして自分のパソコンで自由に動かせます。「魚の動きはどう作っているんだろう?」と思ったら、解説を読みながらコードを追いかけてみてください。
おさかなサバイバルは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。
| ファイル | 役割 |
|---|---|
index.html | ゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する |
style.css | 見た目のデザイン。色・大きさ・レイアウト・アニメーション |
game.js | ゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など |
この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。
index.htmlは、ブラウザが最初に読み込むファイルです。ここにはゲームの「部品」をHTMLで並べているだけで、魚の動きそのものは書かれていません。
大きく分けると、(1)スコアとベスト記録、「New Game」ボタンを置くscore-area、(2)ゲーム画面のboard-wrapper、(3)スタート画面とゲームオーバー画面の2つのオーバーレイ、でできています。魚の絵が描かれる場所は、たった1行の<canvas id="game-canvas">です。ここは「絵を描くためのまっさらなキャンバス」で、何を描くかはすべてJavaScriptが決めます。
※ 下のコードでは、ゲームの動きと関係のない広告・アクセス解析用のタグ(ページ先頭の数行)を省いています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>おさかなサバイバル|無料ブラウザゲーム|ひなテックGames</title>
<meta name="description" content="池の中で小さな魚を食べて大きくなろう!大きな魚に食べられたらゲームオーバー。PC・スマホ対応の無料ブラウザゲーム。">
<link rel="canonical" href="https://hinata-ya.tech/games/games/absorb/">
<!-- I18N:hreflang -->
<link rel="alternate" hreflang="ja" href="https://hinata-ya.tech/games/games/absorb/">
<link rel="alternate" hreflang="en" href="https://hinata-ya.tech/games/en/games/absorb/">
<link rel="alternate" hreflang="x-default" href="https://hinata-ya.tech/games/games/absorb/">
<!-- /I18N:hreflang -->
<!-- OGP -->
<meta property="og:title" content="おさかなサバイバル|無料ブラウザゲーム|ひなテックGames">
<meta property="og:description" content="池の中で小さな魚を食べて大きくなろう!大きな魚に食べられたらゲームオーバー。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://hinata-ya.tech/games/games/absorb/">
<meta property="og:site_name" content="ひなテックGames">
<meta property="og:locale" content="ja_JP">
<!-- SEO:meta-extra -->
<meta property="og:image" content="https://hinata-ya.tech/games/images/og/absorb.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="おさかなサバイバル">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="おさかなサバイバル|無料ブラウザゲーム|ひなテックGames">
<meta name="twitter:description" content="池の中で小さな魚を食べて大きくなろう!大きな魚に食べられたらゲームオーバー。PC・スマホ対応の無料ブラウザゲーム。">
<meta name="twitter:image" content="https://hinata-ya.tech/games/images/og/absorb.png">
<!-- /SEO:meta-extra -->
<link rel="stylesheet" href="../../css/style.css">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../../css/leaderboard.css">
<!-- SEO:ld+json -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoGame",
"name": "おさかなサバイバル",
"description": "池の中で小さな魚を食べて大きくなろう!大きな魚に食べられたらゲームオーバー。PC・スマホ対応の無料ブラウザゲーム。",
"url": "https://hinata-ya.tech/games/games/absorb/",
"image": "https://hinata-ya.tech/games/images/og/absorb.png",
"inLanguage": "ja",
"genre": [
"アクション",
"サバイバル"
],
"gamePlatform": [
"Web Browser"
],
"applicationCategory": "GameApplication",
"operatingSystem": [
"Windows",
"macOS",
"iOS",
"Android",
"ChromeOS"
],
"browserRequirements": "Requires JavaScript. Requires HTML5.",
"isAccessibleForFree": true,
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "JPY",
"availability": "https://schema.org/InStock"
},
"publisher": {
"@type": "Organization",
"name": "ひなテック",
"url": "https://hinata-ya.tech"
},
"author": {
"@type": "Organization",
"name": "ひなテック",
"url": "https://hinata-ya.tech"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "ホーム",
"item": "https://hinata-ya.tech/games/"
},
{
"@type": "ListItem",
"position": 2,
"name": "おさかなサバイバル",
"item": "https://hinata-ya.tech/games/games/absorb/"
}
]
}
</script>
<!-- /SEO:ld+json -->
</head>
<body>
<div id="header"></div>
<div class="game-page">
<h1 class="game-page-title">おさかなサバイバル</h1>
<div class="game-page-tags">
<span class="tag">アクション</span>
<span class="tag">サバイバル</span>
</div>
<div class="game-container">
<div class="score-area">
<div class="score-box">
<span class="score-label">スコア</span>
<span class="score-value" id="score">0</span>
</div>
<div class="score-box">
<span class="score-label">ベスト</span>
<span class="score-value" id="best-score">0</span>
</div>
<button class="btn-new-game" id="btn-new-game">New Game</button>
</div>
<div class="board-wrapper">
<canvas id="game-canvas"></canvas>
<div class="game-overlay" id="game-over-overlay">
<div class="overlay-content">
<h2>たべられた!</h2>
<p>スコア: <span id="final-score">0</span></p>
<p class="best-result" id="best-result"></p>
<button class="btn-primary" id="btn-retry">もう一度遊ぶ</button>
</div>
</div>
<div class="game-overlay active" id="game-start-overlay">
<div class="overlay-content">
<h2>おさかなサバイバル</h2>
<p>小さな魚を食べて<br>池で一番大きくなろう!</p>
<button class="btn-primary" id="btn-start">ゲームスタート</button>
</div>
</div>
</div>
</div>
<div class="game-instructions">
<h3>遊び方</h3>
<ul>
<li><strong>PC</strong>: マウスを動かして魚を操作</li>
<li><strong>スマホ</strong>: 画面をタッチして操作</li>
<li>自分より<strong>小さい魚</strong>を食べると大きくなる</li>
<li>自分より<strong>大きい魚</strong>に触れると食べられてしまう</li>
<li>どんどん食べてハイスコアを目指そう!</li>
</ul>
</div>
<div class="game-promo">
<h3>このゲーム、自分でも作れるよ!</h3>
<p>
Canvas APIを使った魚の描画、当たり判定、AI移動など<br>
ゲーム開発の基本が詰まっています。<br><br>
ひなテックでは、こうしたゲームの<br>
作り方を楽しく学べます!
</p>
<div class="promo-links">
<a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
<a href="https://hinata-ya.tech/contact/" class="btn-primary">体験レッスンに申し込む →</a>
<a href="source/" class="btn-secondary">ソースコードを見る →</a>
</div>
</div>
<div class="other-games">
<a href="../../index.html">← 他のゲームも遊ぶ</a>
</div>
</div>
<div id="footer"></div>
<script src="../../js/common.js?v=20260613c" data-base="../../"></script>
<script src="../../js/leaderboard.js?v=20260610a"></script>
<script src="game.js?v=20260610"></script>
</body>
</html>
style.cssは、おさかなサバイバルの「画面まわり」を整えているファイルです。スコア表示のボックスや「New Game」ボタンの色、ゲーム画面を画面のまん中に置くレイアウトなどを決めています。
注目してほしいのは.board-wrapperのaspect-ratio: 1 / 1です。これは「横と縦の比を1対1にする」という指定で、スマホでもPCでもゲーム画面がきれいな正方形になります。touch-action: noneは、画面を指でなぞったときにページがスクロールしてしまわないようにするおまじないです。
魚や海の絵はすべてJavaScriptがcanvasに描くので、CSSはあくまで「枠」を整える役割です。ファイル下のほうの@mediaは、画面が小さいときに余白や文字を小さくするレスポンシブ指定です。
/* ============================================
おさかなサバイバル - 固有スタイル
============================================ */
/* スコアエリア */
.score-area {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.score-box {
background: #1a3a4a;
border-radius: 8px;
padding: 0.5rem 1rem;
text-align: center;
min-width: 70px;
}
.score-label {
display: block;
font-size: 0.7rem;
color: #7ecbdb;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.score-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: #fff;
}
.btn-new-game {
margin-left: auto;
background: #2980B9;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5rem 1.25rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-new-game:hover {
background: #1f6da0;
}
/* ゲームボード */
.board-wrapper {
position: relative;
width: 100%;
max-width: 500px;
margin: 0 auto;
aspect-ratio: 1 / 1;
touch-action: none;
}
#game-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: #145a73;
border-radius: 8px;
display: block;
}
/* ゲームオーバーレイ */
.game-overlay {
position: absolute;
inset: 0;
background: rgba(13, 33, 55, 0.88);
border-radius: 8px;
display: none;
align-items: center;
justify-content: center;
z-index: 10;
animation: overlay-appear 0.3s ease;
}
.game-overlay.active {
display: flex;
}
@keyframes overlay-appear {
0% { opacity: 0; }
100% { opacity: 1; }
}
.overlay-content {
text-align: center;
padding: 1.5rem;
}
.overlay-content h2 {
font-size: 1.75rem;
color: #7ecbdb;
margin-bottom: 0.5rem;
}
.overlay-content p {
font-size: 1.1rem;
color: #c0dde5;
margin-bottom: 0.75rem;
}
.overlay-content .best-result {
font-size: 0.95rem;
color: #3498DB;
font-weight: 600;
margin-bottom: 1rem;
}
.overlay-content .btn-primary {
margin-bottom: 0.5rem;
}
.overlay-content .btn-new-game {
margin-left: 0;
display: block;
margin: 0.5rem auto 0;
}
/* レスポンシブ */
@media (max-width: 500px) {
.board-wrapper {
border-radius: 6px;
}
#game-canvas {
border-radius: 6px;
}
}
@media (max-width: 360px) {
.score-area {
gap: 0.5rem;
}
.score-box {
padding: 0.4rem 0.75rem;
min-width: 55px;
}
.score-value {
font-size: 1rem;
}
.score-label {
font-size: 0.6rem;
}
}
game.jsがこのゲームの心臓部です。少し長いですが、やっていることを順番に追えば必ず理解できます。全体は(function () { ... })();という「即時実行関数」で囲まれていて、ゲームの変数がページの他の部分とぶつからないようになっています。
魚は1ぴきずつ「ただのデータ」です。createFish()を見ると、位置(x・y)、大きさ(radius)、色、速さ(vx・vy)などをまとめたオブジェクトを作っているだけです。プレイヤーはplayer、敵の魚はnpcsという配列に入っていて、毎フレームupdate()がこの数字を少しずつ書きかえ、draw()がその数字をもとに絵を描きます。
このゲームでいちばん面白いのがカメラとズームのしくみです。魚たちの本当の位置は「ワールド座標」という広い世界の座標で持っていて、画面に映すときだけcameraX・cameraY・zoomを使って変換します。draw()の中のctx.translate()とctx.scale()がその変換です。プレイヤーが大きくなるとtargetZoomが小さくなり、画面がじわっとズームアウトしていきます。Math.sqrt(平方根)を使っているのは、ズームを急にではなく、ゆっくり気持ちよく変化させるための工夫です。
当たり判定はとてもシンプルです。dist()で2ひきの魚の中心どうしの距離を測り、それが2つの半径の合計より近ければ「触れた」と判定します。触れたとき、自分のほうが大きければ相手を食べてスコアが増え(+5などのfloatingTextsがふわっと出ます)、小さければgameOver()です。
敵の魚の動きにも工夫があります。changeTimerが0になるたびに進む向きを変えますが、Math.atan2でプレイヤーのほうを向く角度を計算し、4割の確率でプレイヤーをねらってくるようにしています。これだけの簡単なルールで「魚が寄ってくる」AIらしさが生まれます。createNPC()では、プレイヤーの大きさに合わせて「食べやすい小さな餌」と「あぶない大きな魚」をバランスよく出しているのもポイントです。
ベストスコアはlocalStorageに保存しているので、ブラウザを閉じても記録が残ります。海底の岩・サンゴ・貝殻や泡・水草も、すべて数字から計算してcanvasに描いています。
// ============================================
// おさかなサバイバル ゲームロジック
// ============================================
(function () {
'use strict';
// 言語別テキスト(英語版ページが window.GAME_TEXT を定義して上書きする。docs/i18n.md 参照)
var TEXT = window.GAME_TEXT || {};
// ============================================
// 定数
// ============================================
var INITIAL_PLAYER_RADIUS = 12;
var PLAYER_SPEED = 3.5;
var NPC_MIN_RADIUS = 5;
var NPC_MAX_RADIUS = 40;
var INITIAL_NPC_COUNT = 30;
var MAX_NPC_COUNT = 50;
var SPAWN_INTERVAL = 1500;
var NPC_SPEED_MIN = 0.3;
var NPC_SPEED_MAX = 1.8;
var ABSORB_GROWTH = 0.4;
var PLAYER_LERP = 0.08;
var BUBBLE_COUNT = 15;
// ズーム/カメラ定数
var ZOOM_THRESHOLD_RATIO = 0.03;
var ZOOM_MIN = 0.15;
var ZOOM_LERP = 0.03;
var OCEAN_FLOOR_RATIO = 0.15;
// 魚のカラーパレット
var FISH_COLORS = [
'#E74C3C', '#E67E22', '#F1C40F', '#2ECC71',
'#1ABC9C', '#9B59B6', '#E91E63',
'#FF5722', '#8BC34A', '#FF9800'
];
// ============================================
// 状態変数
// ============================================
var canvas, ctx;
var canvasW, canvasH;
var player;
var npcs = [];
var bubbles = [];
var score = 0;
var bestScore = parseInt(localStorage.getItem('bestAbsorb') || '0', 10);
var gameRunning = false;
var gameOverFlag = false;
var animFrameId = null;
var lastSpawnTime = 0;
var targetX, targetY;
var mouseActive = false;
var frameCount = 0;
var floatingTexts = [];
// ズーム/カメラ状態
var zoom = 1.0;
var targetZoom = 1.0;
var cameraX = 0, cameraY = 0;
var oceanFloorY = 0;
var oceanFloorDecos = [];
var seaweeds = [];
// ============================================
// DOM要素
// ============================================
var scoreEl = document.getElementById('score');
var bestScoreEl = document.getElementById('best-score');
var gameOverOverlay = document.getElementById('game-over-overlay');
var gameStartOverlay = document.getElementById('game-start-overlay');
var finalScoreEl = document.getElementById('final-score');
var bestResultEl = document.getElementById('best-result');
canvas = document.getElementById('game-canvas');
ctx = canvas.getContext('2d');
// ============================================
// キャンバスの初期化
// ============================================
function resizeCanvas() {
var wrapper = canvas.parentElement;
var rect = wrapper.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvasW = rect.width;
canvasH = rect.height;
canvas.width = canvasW * dpr;
canvas.height = canvasH * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
oceanFloorY = canvasH * OCEAN_FLOOR_RATIO;
}
// ============================================
// ユーティリティ
// ============================================
function rand(min, max) {
return Math.random() * (max - min) + min;
}
function dist(x1, y1, x2, y2) {
var dx = x1 - x2;
var dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy);
}
function screenToWorld(sx, sy) {
return {
x: (sx - canvasW / 2) / zoom + cameraX,
y: (sy - canvasH / 2) / zoom + cameraY
};
}
// ============================================
// 泡の生成(ワールド空間)
// ============================================
function createBubble() {
var halfViewW = canvasW / (2 * zoom);
var halfViewH = canvasH / (2 * zoom);
return {
x: cameraX + rand(-halfViewW, halfViewW),
y: cameraY + halfViewH + rand(0, 20),
radius: rand(2, 6),
speed: rand(0.2, 0.8),
wobble: rand(0, Math.PI * 2),
wobbleSpeed: rand(0.01, 0.03)
};
}
function initBubbles() {
bubbles = [];
var halfViewH = canvasH / (2 * zoom);
for (var i = 0; i < BUBBLE_COUNT; i++) {
var b = createBubble();
b.y = cameraY + rand(-halfViewH, halfViewH);
bubbles.push(b);
}
}
// ============================================
// 水草の初期化(ワールド空間)
// ============================================
function initSeaweed() {
seaweeds = [];
var maxHalfW = canvasW / (2 * ZOOM_MIN);
var count = 30;
for (var i = 0; i < count; i++) {
seaweeds.push({
x: rand(-maxHalfW, maxHalfW),
baseY: oceanFloorY + rand(-10, 10),
h: 40 + (i % 3) * 25,
phase: rand(0, Math.PI * 2)
});
}
}
// ============================================
// 海底装飾の初期化(ワールド空間)
// ============================================
function initOceanFloor() {
oceanFloorDecos = [];
var maxHalfW = canvasW / (2 * ZOOM_MIN);
var nearDepth = canvasH * 0.6;
var farDepth = canvasH / ZOOM_MIN;
// 岩(半分は近くに、半分は遠くに配置)
for (var i = 0; i < 25; i++) {
var depth = i < 15
? rand(5, nearDepth)
: rand(nearDepth, farDepth * 0.5);
oceanFloorDecos.push({
type: 'rock',
x: rand(-maxHalfW, maxHalfW),
y: oceanFloorY + depth,
w: rand(30, 80),
h: rand(20, 55),
color: ['#3d4f5f', '#4a5568', '#2d3748', '#5a6577'][i % 4],
seed: rand(0, 100)
});
}
// サンゴ(多くを近くに配置)
for (var i = 0; i < 12; i++) {
var depth = i < 8
? rand(0, nearDepth)
: rand(nearDepth, farDepth * 0.4);
oceanFloorDecos.push({
type: 'coral',
x: rand(-maxHalfW, maxHalfW),
y: oceanFloorY + depth,
h: rand(35, 70),
color: ['#e84393', '#fd79a8', '#e17055', '#fab1a0', '#ff7675'][i % 5],
branches: Math.floor(rand(2, 5))
});
}
// 貝殻(多くを近くに配置)
for (var i = 0; i < 15; i++) {
var depth = i < 10
? rand(5, nearDepth)
: rand(nearDepth, farDepth * 0.3);
oceanFloorDecos.push({
type: 'shell',
x: rand(-maxHalfW, maxHalfW),
y: oceanFloorY + depth,
size: rand(8, 18),
color: ['#ffeaa7', '#dfe6e9', '#fab1a0'][i % 3],
rotation: rand(0, Math.PI * 2)
});
}
}
// ============================================
// NPC(魚)生成
// ============================================
function createFish(radius, x, y) {
var angle = rand(0, Math.PI * 2);
var sizeFactor = Math.max(0.4, 1 - (radius - NPC_MIN_RADIUS) / 200);
var speed = rand(NPC_SPEED_MIN, NPC_SPEED_MAX) * sizeFactor;
return {
x: x,
y: y,
radius: radius,
score: Math.ceil(radius),
color: FISH_COLORS[Math.floor(Math.random() * FISH_COLORS.length)],
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
changeTimer: rand(60, 180),
tailPhase: rand(0, Math.PI * 2)
};
}
function createNPC() {
var radius = rand(NPC_MIN_RADIUS, NPC_MAX_RADIUS);
if (player) {
var maxR = Math.max(NPC_MAX_RADIUS, player.radius * 3);
var r = Math.random();
if (r < 0.50) {
// 小さい餌
radius = rand(NPC_MIN_RADIUS, Math.max(player.radius * 0.7, NPC_MIN_RADIUS + 1));
} else if (r < 0.70) {
// 同程度
radius = rand(player.radius * 0.7, player.radius * 1.0);
} else if (r < 0.88) {
// やや強い
radius = rand(player.radius * 1.1, player.radius * 1.8);
} else {
// かなり強い
radius = rand(player.radius * 1.8, player.radius * 3.0);
}
radius = Math.max(NPC_MIN_RADIUS, Math.min(radius, maxR));
}
// ビューポート端からスポーン(ワールド座標)
var halfViewW = canvasW / (2 * zoom);
var halfViewH = canvasH / (2 * zoom);
var edge = Math.floor(Math.random() * 4);
var x, y;
if (edge === 0) {
x = cameraX + rand(-halfViewW, halfViewW);
y = cameraY - halfViewH - radius * 3;
} else if (edge === 1) {
x = cameraX + rand(-halfViewW, halfViewW);
y = cameraY + halfViewH + radius * 3;
} else if (edge === 2) {
x = cameraX - halfViewW - radius * 3;
y = cameraY + rand(-halfViewH, halfViewH);
} else {
x = cameraX + halfViewW + radius * 3;
y = cameraY + rand(-halfViewH, halfViewH);
}
var fish = createFish(radius, x, y);
// プレイヤーに向かう方向
var angle = Math.atan2(player.y - y + rand(-100, 100), player.x - x + rand(-100, 100));
var sizeFactor = Math.max(0.4, 1 - (radius - NPC_MIN_RADIUS) / 200);
var speed = rand(NPC_SPEED_MIN, NPC_SPEED_MAX) * sizeFactor;
fish.vx = Math.cos(angle) * speed;
fish.vy = Math.sin(angle) * speed;
return fish;
}
function createInitialNPC() {
var radius = rand(NPC_MIN_RADIUS, NPC_MAX_RADIUS);
var r = Math.random();
if (r < 0.65) {
radius = rand(NPC_MIN_RADIUS, INITIAL_PLAYER_RADIUS * 0.9);
} else if (r < 0.85) {
radius = rand(INITIAL_PLAYER_RADIUS * 0.7, INITIAL_PLAYER_RADIUS * 1.3);
} else {
radius = rand(INITIAL_PLAYER_RADIUS * 1.2, INITIAL_PLAYER_RADIUS * 2.5);
}
radius = Math.max(NPC_MIN_RADIUS, Math.min(radius, NPC_MAX_RADIUS));
// 初期ビューポート内に配置(プレイヤーは0,0)
var halfW = canvasW / 2;
var halfH = canvasH / 2;
var x = rand(-halfW + radius + 10, halfW - radius - 10);
var y = rand(-halfH + radius + 10, halfH - radius - 10);
if (dist(x, y, 0, 0) < INITIAL_PLAYER_RADIUS + radius + 30) {
x = rand(-halfW + radius + 10, halfW - radius - 10);
y = rand(-halfH + radius + 10, halfH - radius - 10);
}
return createFish(radius, x, y);
}
// ============================================
// プレイヤー初期化
// ============================================
function initPlayer() {
player = {
x: 0,
y: 0,
radius: INITIAL_PLAYER_RADIUS,
score: Math.ceil(INITIAL_PLAYER_RADIUS),
color: '#3498DB',
vx: 0,
vy: 0,
tailPhase: 0
};
targetX = 0;
targetY = 0;
}
// ============================================
// 更新
// ============================================
function update() {
if (!gameRunning || gameOverFlag) return;
frameCount++;
// プレイヤー移動
if (mouseActive) {
var dx = targetX - player.x;
var dy = targetY - player.y;
var d = Math.sqrt(dx * dx + dy * dy);
if (d > 1) {
var speedScale = Math.max(1, 1 / Math.sqrt(zoom));
var speed = Math.min(
(PLAYER_SPEED + player.radius * 0.02) * speedScale,
d * PLAYER_LERP + PLAYER_SPEED * 0.5 * speedScale
);
player.vx = (dx / d) * speed;
player.vy = (dy / d) * speed;
player.x += player.vx;
player.y += player.vy;
} else {
player.vx = 0;
player.vy = 0;
}
}
player.tailPhase += 0.15;
// ズーム計算(平方根で緩やかにズームアウト→成長が見える)
var thresholdRadius = Math.min(canvasW, canvasH) * ZOOM_THRESHOLD_RATIO;
if (player.radius > thresholdRadius) {
targetZoom = Math.sqrt(thresholdRadius / player.radius);
} else {
targetZoom = 1.0;
}
targetZoom = Math.max(ZOOM_MIN, targetZoom);
zoom += (targetZoom - zoom) * ZOOM_LERP;
// カメラはプレイヤー追従
cameraX = player.x;
cameraY = player.y;
// ビューポート情報(カリング用)
var halfViewW = canvasW / (2 * zoom);
var halfViewH = canvasH / (2 * zoom);
var cullDist = Math.max(halfViewW, halfViewH) * 3;
// NPC更新
for (var i = npcs.length - 1; i >= 0; i--) {
var npc = npcs[i];
npc.tailPhase += 0.12;
npc.changeTimer--;
if (npc.changeTimer <= 0) {
// プレイヤー方向にバイアスをかけた方向転換
var toPlayerAngle = Math.atan2(player.y - npc.y, player.x - npc.x);
var randomAngle = rand(0, Math.PI * 2);
var angle = Math.random() < 0.4
? toPlayerAngle + rand(-0.5, 0.5)
: randomAngle;
var sizeFactor = Math.max(0.4, 1 - (npc.radius - NPC_MIN_RADIUS) / 200);
var npcSpeed = rand(NPC_SPEED_MIN, NPC_SPEED_MAX) * sizeFactor;
npc.vx = Math.cos(angle) * npcSpeed;
npc.vy = Math.sin(angle) * npcSpeed;
npc.changeTimer = rand(60, 180);
}
npc.x += npc.vx;
npc.y += npc.vy;
// ビューポートから離れすぎたNPCを除去
if (dist(npc.x, npc.y, player.x, player.y) > cullDist) {
npcs.splice(i, 1);
continue;
}
// プレイヤーとの衝突判定
var dd = dist(player.x, player.y, npc.x, npc.y);
var touchDist = player.radius + npc.radius;
if (dd < touchDist * 0.7) {
if (player.radius > npc.radius) {
score += npc.score;
player.score += npc.score;
floatingTexts.push({
x: npc.x, y: npc.y,
text: '+' + npc.score,
life: 1
});
player.radius += npc.radius * ABSORB_GROWTH / player.radius * 5;
npcs.splice(i, 1);
updateScore();
} else {
gameOver();
return;
}
}
}
// フローティングテキスト更新
var ftSpeed = 1.2 / zoom;
for (var fi = floatingTexts.length - 1; fi >= 0; fi--) {
var ft = floatingTexts[fi];
ft.y -= ftSpeed;
ft.life -= 0.025;
if (ft.life <= 0) floatingTexts.splice(fi, 1);
}
// 泡の更新(ワールド空間)
for (var j = 0; j < bubbles.length; j++) {
var b = bubbles[j];
b.y -= b.speed;
b.wobble += b.wobbleSpeed;
b.x += Math.sin(b.wobble) * 0.3;
if (b.y < cameraY - halfViewH - 10 ||
b.x < cameraX - halfViewW - 20 ||
b.x > cameraX + halfViewW + 20) {
b.x = cameraX + rand(-halfViewW, halfViewW);
b.y = cameraY + halfViewH + rand(0, 20);
b.radius = rand(2, 6);
b.speed = rand(0.2, 0.8);
}
}
// NPC定期生成(ズームに応じてスケーリング)
var now = Date.now();
var dynamicMaxNPC = Math.floor(MAX_NPC_COUNT / Math.max(zoom, 0.3));
var dynamicSpawnInterval = SPAWN_INTERVAL * Math.max(zoom, 0.5);
if (now - lastSpawnTime > dynamicSpawnInterval && npcs.length < dynamicMaxNPC) {
npcs.push(createNPC());
lastSpawnTime = now;
}
}
// ============================================
// 描画
// ============================================
function draw() {
// 背景グラデーション(スクリーン空間)
var grad = ctx.createLinearGradient(0, 0, 0, canvasH);
grad.addColorStop(0, '#1a6b8a');
grad.addColorStop(0.5, '#145a73');
grad.addColorStop(1, '#0d3d52');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvasW, canvasH);
// 水面の波紋(スクリーン空間)
drawWaterSurface();
// カメラ変換開始
ctx.save();
ctx.translate(canvasW / 2, canvasH / 2);
ctx.scale(zoom, zoom);
ctx.translate(-cameraX, -cameraY);
// 海底(ワールド空間)
drawOceanFloor();
// 水草(ワールド空間)
drawSeaweed();
// 泡(ワールド空間)
drawBubbles();
// NPC描画(小さい順)
var sorted = npcs.slice().sort(function (a, b) { return a.radius - b.radius; });
for (var i = 0; i < sorted.length; i++) {
drawFish(sorted[i], false);
}
// プレイヤー描画
if (player) {
drawFish(player, true);
}
// フローティングテキスト描画(ワールド空間、フォントはズーム補正)
for (var fi = 0; fi < floatingTexts.length; fi++) {
var ft = floatingTexts[fi];
ctx.save();
ctx.globalAlpha = ft.life;
var fontSize = 16 / zoom;
ctx.font = 'bold ' + fontSize + 'px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#FFD700';
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 3 / zoom;
ctx.strokeText(ft.text, ft.x, ft.y);
ctx.fillText(ft.text, ft.x, ft.y);
ctx.restore();
}
// カメラ変換終了
ctx.restore();
}
function drawWaterSurface() {
ctx.save();
ctx.globalAlpha = 0.08;
for (var i = 0; i < 5; i++) {
var yy = 10 + i * 8;
ctx.beginPath();
ctx.moveTo(0, yy);
for (var x = 0; x < canvasW; x += 20) {
var wave = Math.sin((x + frameCount * 0.5 + i * 30) * 0.03) * 4;
ctx.lineTo(x, yy + wave);
}
ctx.lineTo(canvasW, yy);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function drawBubbles() {
ctx.save();
for (var i = 0; i < bubbles.length; i++) {
var b = bubbles[i];
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x - b.radius * 0.3, b.y - b.radius * 0.3, b.radius * 0.25, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
ctx.restore();
}
function drawSeaweed() {
ctx.save();
ctx.globalAlpha = 0.25;
var halfViewW = canvasW / (2 * zoom);
for (var i = 0; i < seaweeds.length; i++) {
var sw = seaweeds[i];
// ビューポートカリング
if (sw.x < cameraX - halfViewW - 50 || sw.x > cameraX + halfViewW + 50) continue;
var baseX = sw.x;
var baseY = sw.baseY;
var h = sw.h;
ctx.beginPath();
ctx.moveTo(baseX, baseY);
var sway = Math.sin(frameCount * 0.02 + sw.phase) * 8;
ctx.quadraticCurveTo(baseX + sway, baseY - h * 0.5, baseX + sway * 1.2, baseY - h);
ctx.strokeStyle = '#2ECC71';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.stroke();
}
ctx.restore();
}
function drawOceanFloor() {
var halfViewW = canvasW / (2 * zoom);
var halfViewH = canvasH / (2 * zoom);
var viewLeft = cameraX - halfViewW;
var viewRight = cameraX + halfViewW;
var viewTop = cameraY - halfViewH;
var viewBottom = cameraY + halfViewH;
// 海底が見える場合のみ描画
if (viewBottom < oceanFloorY - 20) return;
// 砂地グラデーション
ctx.save();
var sandTop = oceanFloorY;
var sandBottom = viewBottom + 100;
var sandGrad = ctx.createLinearGradient(0, sandTop, 0, sandTop + 200);
sandGrad.addColorStop(0, 'rgba(194, 178, 128, 0.25)');
sandGrad.addColorStop(0.3, 'rgba(194, 178, 128, 0.4)');
sandGrad.addColorStop(1, 'rgba(139, 119, 74, 0.5)');
ctx.fillStyle = sandGrad;
ctx.fillRect(viewLeft, sandTop, viewRight - viewLeft, sandBottom - sandTop);
// 砂の波紋
ctx.globalAlpha = 0.08;
ctx.strokeStyle = '#c2b280';
ctx.lineWidth = 1;
for (var ri = 0; ri < 8; ri++) {
var rippleY = oceanFloorY + 20 + ri * 30;
if (rippleY < viewTop || rippleY > viewBottom) continue;
ctx.beginPath();
for (var rx = viewLeft; rx < viewRight; rx += 15) {
var ry = rippleY + Math.sin((rx + frameCount * 0.2 + ri * 50) * 0.02) * 5;
if (rx === viewLeft) ctx.moveTo(rx, ry);
else ctx.lineTo(rx, ry);
}
ctx.stroke();
}
ctx.restore();
// 装飾の描画(ビューポートカリング)
for (var i = 0; i < oceanFloorDecos.length; i++) {
var deco = oceanFloorDecos[i];
if (deco.x < viewLeft - 80 || deco.x > viewRight + 80) continue;
if (deco.y < viewTop - 80 || deco.y > viewBottom + 80) continue;
if (deco.type === 'rock') {
drawRock(deco);
} else if (deco.type === 'coral') {
drawCoral(deco);
} else if (deco.type === 'shell') {
drawShell(deco);
}
}
}
function drawRock(rock) {
ctx.save();
ctx.translate(rock.x, rock.y);
ctx.globalAlpha = 0.5;
ctx.fillStyle = rock.color;
ctx.beginPath();
var w = rock.w;
var h = rock.h;
var s = rock.seed;
// 不規則な丸い岩
ctx.moveTo(-w * 0.4, 0);
ctx.quadraticCurveTo(-w * 0.5, -h * (0.6 + Math.sin(s) * 0.2), -w * 0.1, -h);
ctx.quadraticCurveTo(w * (0.1 + Math.cos(s) * 0.1), -h * (1.1 + Math.sin(s * 2) * 0.1), w * 0.4, -h * 0.5);
ctx.quadraticCurveTo(w * 0.5, -h * 0.1, w * 0.3, 0);
ctx.closePath();
ctx.fill();
// ハイライト
ctx.globalAlpha = 0.1;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(-w * 0.1, -h * 0.6, w * 0.15, h * 0.2, -0.3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawCoral(coral) {
ctx.save();
ctx.translate(coral.x, coral.y);
ctx.globalAlpha = 0.45;
ctx.strokeStyle = coral.color;
ctx.fillStyle = coral.color;
ctx.lineCap = 'round';
ctx.lineWidth = 4;
for (var b = 0; b < coral.branches; b++) {
var branchAngle = -Math.PI / 2 + (b - (coral.branches - 1) / 2) * 0.5;
var branchH = coral.h * (0.6 + b * 0.15);
var tipX = Math.cos(branchAngle) * branchH * 0.3;
var tipY = Math.sin(branchAngle) * branchH;
var sway = Math.sin(frameCount * 0.015 + coral.x * 0.1 + b) * 5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(tipX * 0.5 + sway, tipY * 0.5, tipX + sway, tipY);
ctx.stroke();
// ポリプ(先端の丸)
ctx.beginPath();
ctx.arc(tipX + sway, tipY, 4, 0, Math.PI * 2);
ctx.globalAlpha = 0.35;
ctx.fill();
ctx.globalAlpha = 0.35;
}
ctx.restore();
}
function drawShell(shell) {
ctx.save();
ctx.translate(shell.x, shell.y);
ctx.rotate(shell.rotation);
ctx.globalAlpha = 0.4;
ctx.fillStyle = shell.color;
ctx.strokeStyle = shell.color;
ctx.lineWidth = 1.5;
// 巻貝の螺旋
var s = shell.size;
ctx.beginPath();
for (var t = 0; t < Math.PI * 3; t += 0.2) {
var r = s * (1 - t / (Math.PI * 3)) * 0.8;
var sx = Math.cos(t) * r;
var sy = Math.sin(t) * r;
if (t === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// 中心の丸
ctx.beginPath();
ctx.arc(0, 0, s * 0.25, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawFish(fish, isPlayer) {
ctx.save();
var r = fish.radius;
var facing = (fish.vx >= 0) ? 1 : -1;
var tailSwing = Math.sin(fish.tailPhase) * 0.3;
ctx.translate(fish.x, fish.y);
ctx.scale(facing, 1);
ctx.globalAlpha = isPlayer ? 0.92 : 0.78;
// 魚体(楕円)
ctx.beginPath();
ctx.ellipse(0, 0, r * 1.4, r * 0.85, 0, 0, Math.PI * 2);
ctx.fillStyle = fish.color;
ctx.fill();
// 光沢
ctx.beginPath();
ctx.ellipse(-r * 0.2, -r * 0.25, r * 0.6, r * 0.3, -0.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
// 尾びれ
ctx.beginPath();
var tailX = -r * 1.3;
var tailTip = tailSwing * r * 0.8;
ctx.moveTo(tailX, 0);
ctx.lineTo(tailX - r * 0.7, -r * 0.6 + tailTip);
ctx.lineTo(tailX - r * 0.1, 0);
ctx.lineTo(tailX - r * 0.7, r * 0.6 + tailTip);
ctx.closePath();
ctx.fillStyle = fish.color;
ctx.fill();
// 背びれ
ctx.beginPath();
ctx.moveTo(r * 0.2, -r * 0.8);
ctx.quadraticCurveTo(-r * 0.1, -r * 1.1, -r * 0.5, -r * 0.75);
ctx.lineTo(-r * 0.1, -r * 0.6);
ctx.closePath();
ctx.fillStyle = fish.color;
ctx.globalAlpha = isPlayer ? 0.7 : 0.5;
ctx.fill();
ctx.globalAlpha = isPlayer ? 0.92 : 0.78;
// 目
var eyeX = r * 0.6;
var eyeY = -r * 0.15;
var eyeR = Math.max(2, r * 0.2);
ctx.beginPath();
ctx.arc(eyeX, eyeY, eyeR, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.globalAlpha = 1;
ctx.fill();
ctx.beginPath();
ctx.arc(eyeX + eyeR * 0.25, eyeY, eyeR * 0.55, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
// プレイヤーの縁取り
if (isPlayer) {
ctx.beginPath();
ctx.ellipse(0, 0, r * 1.4, r * 0.85, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = Math.max(2, 2 / zoom);
ctx.stroke();
}
// サイズ数字(反転を打ち消してから描画)
ctx.scale(facing, 1);
var sizeNum = Math.ceil(fish.radius);
var fontSize = Math.max(7, r * 0.45);
ctx.fillStyle = '#fff';
ctx.font = 'bold ' + fontSize + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.globalAlpha = 0.75;
ctx.fillText(sizeNum, 0, r * 0.2);
ctx.restore();
}
// ============================================
// スコア
// ============================================
function updateScore() {
scoreEl.textContent = score;
if (score > bestScore) {
bestScore = score;
bestScoreEl.textContent = bestScore;
localStorage.setItem('bestAbsorb', bestScore.toString());
}
}
// ============================================
// ゲーム開始
// ============================================
function startGame() {
resizeCanvas();
// ズーム/カメラ初期化
zoom = 1.0;
targetZoom = 1.0;
cameraX = 0;
cameraY = 0;
initPlayer();
npcs = [];
score = 0;
gameOverFlag = false;
mouseActive = false;
frameCount = 0;
floatingTexts = [];
lastSpawnTime = Date.now();
initBubbles();
initSeaweed();
initOceanFloor();
for (var i = 0; i < INITIAL_NPC_COUNT; i++) {
npcs.push(createInitialNPC());
}
updateScore();
gameOverOverlay.classList.remove('active');
gameStartOverlay.classList.remove('active');
gameRunning = true;
if (animFrameId) cancelAnimationFrame(animFrameId);
gameLoop();
}
// ============================================
// ゲームオーバー
// ============================================
function gameOver() {
gameRunning = false;
gameOverFlag = true;
if (animFrameId) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
finalScoreEl.textContent = score;
if (score >= bestScore && score > 0) {
bestResultEl.textContent = TEXT.newBest || 'ハイスコア更新!';
} else {
bestResultEl.textContent = (TEXT.best || 'ベスト: ') + bestScore;
}
setTimeout(function () {
gameOverOverlay.classList.add('active');
if (window.Leaderboard) Leaderboard.show('absorb', score);
}, 300);
}
// ============================================
// ゲームループ
// ============================================
function gameLoop() {
update();
draw();
if (gameRunning && !gameOverFlag) {
animFrameId = requestAnimationFrame(gameLoop);
}
}
// ============================================
// イベントリスナー
// ============================================
canvas.addEventListener('mousemove', function (e) {
if (!gameRunning) return;
var rect = canvas.getBoundingClientRect();
var screenX = e.clientX - rect.left;
var screenY = e.clientY - rect.top;
var world = screenToWorld(screenX, screenY);
targetX = world.x;
targetY = world.y;
mouseActive = true;
});
canvas.addEventListener('touchstart', function (e) {
e.preventDefault();
if (!gameRunning) return;
var rect = canvas.getBoundingClientRect();
var screenX = e.touches[0].clientX - rect.left;
var screenY = e.touches[0].clientY - rect.top;
var world = screenToWorld(screenX, screenY);
targetX = world.x;
targetY = world.y;
mouseActive = true;
}, { passive: false });
canvas.addEventListener('touchmove', function (e) {
e.preventDefault();
if (!gameRunning) return;
var rect = canvas.getBoundingClientRect();
var screenX = e.touches[0].clientX - rect.left;
var screenY = e.touches[0].clientY - rect.top;
var world = screenToWorld(screenX, screenY);
targetX = world.x;
targetY = world.y;
}, { passive: false });
canvas.addEventListener('touchend', function (e) {
e.preventDefault();
}, { passive: false });
document.getElementById('btn-new-game').addEventListener('click', function () {
startGame();
});
document.getElementById('btn-retry').addEventListener('click', function () {
startGame();
});
document.getElementById('btn-start').addEventListener('click', function () {
startGame();
});
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
resizeCanvas();
initSeaweed();
initOceanFloor();
initBubbles();
if (!gameRunning) {
draw();
}
}, 100);
});
document.addEventListener('visibilitychange', function () {
if (document.hidden && gameRunning) {
if (animFrameId) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
gameRunning = false;
}
});
// ============================================
// 初期化
// ============================================
bestScoreEl.textContent = bestScore;
resizeCanvas();
initBubbles();
initSeaweed();
initOceanFloor();
draw();
})();
Math.sqrtを使い、成長に合わせて画面をなめらかにズームアウトするMath.atan2でプレイヤーの方向を計算し、敵を寄せてくる上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。
このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。
慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。
このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。
ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。
コードを読んで「自分でも作ってみたい!」と思ったら、おさかなサバイバルの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。
ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、体験レッスンにぜひ来てみてください。