このページでは、ひなテックGamesで実際に動いている「謎の書斎からの脱出」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直したものではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。
このゲームは、5つのシーンを行き来して暗号を解き、書斎から脱出するポイント&クリック型のアドベンチャーです。コードの見どころは、5つの部屋とその中の調べられるものをすべてデータとして書き出している設計と、「アイテムを拾って別のものに使う」というインベントリのしくみ。画像を1枚も使わず、背景をすべてSVGで描いているのも面白いところです。
コードはコピーして自分のパソコンで自由に動かせます。暗号や謎を自分で考えて差しかえれば、オリジナルの脱出ゲームが作れます。
謎の書斎からの脱出は、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。
| ファイル | 役割 |
|---|---|
index.html | ゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する |
style.css | 見た目のデザイン。色・大きさ・レイアウト・アニメーション |
game.js | ゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など |
この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。
index.htmlは、ブラウザが最初に読み込むファイルです。ここにはゲームの「枠」をHTMLで用意しているだけで、部屋の中身や謎の動きは書かれていません。
大きく分けると、(1)部屋の絵を映すscene-area、(2)状況を伝えるmessage-bar、(3)拾ったアイテムを並べるinventory-bar、(4)となりの部屋へ移動するnav-buttons、(5)番号錠などを表示するmodal-overlay、でできています。
部屋の背景や調べられるオブジェクト、アイテムのマスは、ここには1つも書かれていません。すべて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="謎の書斎からの脱出!部屋に散らばるヒントを組み合わせて暗号を解読し、5つのシーンを攻略して脱出せよ。PC・スマホ対応の無料ブラウザゲーム。">
<link rel="canonical" href="https://hinata-ya.tech/games/games/escape/">
<!-- OGP -->
<meta property="og:title" content="謎の書斎からの脱出|無料ブラウザゲーム|ひなテックGames">
<meta property="og:description" content="謎の書斎からの脱出!部屋に散らばるヒントを組み合わせて暗号を解読し、5つのシーンを攻略して脱出せよ。PC・スマホ対応の無料ブラウザゲーム。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://hinata-ya.tech/games/games/escape/">
<meta property="og:site_name" content="ひなテックGames">
<meta property="og:locale" content="ja_JP">
<!-- 構造化データ -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Game",
"name": "謎の書斎からの脱出",
"description": "書斎に閉じ込められた主人公が、部屋に隠されたヒントを組み合わせて謎を解き、脱出を目指すポイント&クリック型アドベンチャーゲーム",
"url": "https://hinata-ya.tech/games/games/escape/",
"genre": ["アドベンチャー", "謎解き", "脱出"],
"gamePlatform": "Web Browser",
"publisher": {
"@type": "Organization",
"name": "ひなテック",
"url": "https://hinata-ya.tech"
}
}
</script>
<link rel="stylesheet" href="../../css/style.css">
<link rel="stylesheet" href="style.css">
</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="escape-wrapper" id="escape-wrapper">
<!-- シーン表示エリア -->
<div class="scene-area" id="scene-area">
<!-- シーン背景とオブジェクトはJSで動的生成 -->
</div>
<!-- メッセージバー -->
<div class="message-bar" id="message-bar">
<span id="message-text">クリックして調べよう</span>
</div>
<!-- インベントリ -->
<div class="inventory-bar" id="inventory-bar">
<div class="inventory-label">所持品</div>
<div class="inventory-slots" id="inventory-slots">
<!-- アイテムスロットはJSで生成 -->
</div>
</div>
<!-- 移動ボタン -->
<div class="nav-buttons" id="nav-buttons">
<button class="nav-btn" id="btn-left" title="左へ">←</button>
<div class="scene-indicator" id="scene-indicator">1 / 5</div>
<button class="nav-btn" id="btn-right" title="右へ">→</button>
</div>
<!-- モーダル(番号錠・テキスト調査など) -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal-box" id="modal-box">
<button class="modal-close" id="modal-close">✕</button>
<div class="modal-content" id="modal-content"></div>
</div>
</div>
<!-- スタートオーバーレイ -->
<div class="game-overlay active" id="game-start-overlay">
<div class="overlay-content">
<h2>🔒 謎の書斎からの脱出</h2>
<p>古びた書斎に閉じ込められた。<br>部屋に隠されたヒントを組み合わせて<br>謎を解き、脱出せよ!</p>
<p class="hint-note">💡 ヒント:シーン間を行き来して情報を集めよう</p>
<button class="btn-primary" id="btn-start">ゲームスタート</button>
</div>
</div>
<!-- クリアオーバーレイ -->
<div class="game-overlay" id="game-clear-overlay">
<div class="overlay-content">
<h2>🎉 脱出成功!</h2>
<p id="clear-time-text"></p>
<p class="clear-msg">謎を全て解いて書斎から脱出した!</p>
<button class="btn-primary" id="btn-retry">もう一度遊ぶ</button>
</div>
</div>
</div><!-- /.escape-wrapper -->
</div><!-- /.game-container -->
<div class="game-instructions">
<h3>遊び方</h3>
<ul>
<li>画面内のものをクリック(タップ)して調べよう</li>
<li>アイテムを拾ったら下部のインベントリに表示される</li>
<li>アイテムを選択した状態で別のものをクリックすると「使う」</li>
<li>← → ボタンで隣のシーンに移動できる</li>
<li>5つのシーンに隠されたヒントを組み合わせて謎を解こう</li>
<li>一部のシーンは特定アイテムがないと入れない</li>
</ul>
</div>
<div class="game-promo">
<h3>このゲーム、自分でも作れるよ!</h3>
<p>
脱出ゲームはDOM操作と<br>
ゲーム状態管理の良い練習になります。<br><br>
ひなテックでは、こうしたゲームの<br>
作り方を楽しく学べます!
</p>
<div class="promo-links">
<a href="https://hinata-ya.tech/contact/" class="btn-primary">無料体験に申し込む →</a>
<a href="https://github.com/mooosung/hinatech-games/tree/main/games/escape" 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=20260404" data-base="../../"></script>
<script src="game.js"></script>
</body>
</html>
style.cssは、古びた書斎の重く落ちついた雰囲気を作っているファイルです。茶色や暗いオレンジを基調にして、薄暗い部屋の空気を表現しています。
注目してほしいのは.scene-objectの指定です。調べられるものにマウスを乗せると:hoverでfilter: brightnessが効いて明るく光り、黄色いふちが付くので「これはクリックできる」とプレイヤーに伝わります。アイテムを選んでいるときは.selected-item-targetで青いふちに変わります。
暗い廊下の.flashlight-overlayはまっ黒な半透明の板で、懐中電灯を使うとJavaScriptがこれを取り除いて「照らした」演出を作ります。番号錠の数字は.lock-digitにfont-family: monospaceを指定し、デジタル表示らしい見た目にしています。
/* ============================================
謎の書斎からの脱出 - 固有スタイル
============================================ */
/* ゲームラッパー */
.escape-wrapper {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto;
background: #1a1208;
border-radius: 8px;
overflow: hidden;
user-select: none;
}
/* シーンエリア */
.scene-area {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
cursor: default;
}
/* シーン背景 */
.scene-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* クリッカブルオブジェクト */
.scene-object {
position: absolute;
cursor: pointer;
border-radius: 4px;
transition: filter 0.15s, outline 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.scene-object:hover {
filter: brightness(1.35);
outline: 2px solid rgba(255, 220, 100, 0.7);
}
.scene-object.selected-item-target {
outline: 2px solid rgba(100, 220, 255, 0.85);
filter: brightness(1.2);
}
/* オブジェクトラベル(デバッグ用には非表示) */
.scene-object .obj-icon {
font-size: 1.6em;
pointer-events: none;
line-height: 1;
}
/* メッセージバー */
.message-bar {
background: #2a1e0e;
border-top: 2px solid #5a3e1e;
padding: 0.55rem 1rem;
min-height: 2.4rem;
display: flex;
align-items: center;
}
#message-text {
font-size: 0.88rem;
color: #e8d5a0;
line-height: 1.4;
}
/* インベントリ */
.inventory-bar {
background: #231808;
border-top: 2px solid #5a3e1e;
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.6rem;
min-height: 3.5rem;
}
.inventory-label {
font-size: 0.72rem;
color: #8a7050;
font-weight: 600;
letter-spacing: 0.05em;
white-space: nowrap;
writing-mode: initial;
}
.inventory-slots {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.inventory-slot {
width: 44px;
height: 44px;
background: #3a2810;
border: 2px solid #5a3e1e;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
position: relative;
}
.inventory-slot:hover {
border-color: #c8a050;
background: #4a3418;
}
.inventory-slot.active {
border-color: #64dcff;
background: #1a3040;
box-shadow: 0 0 8px rgba(100, 220, 255, 0.4);
}
.inventory-slot .slot-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #1a1208;
color: #e8d5a0;
font-size: 0.7rem;
padding: 3px 7px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
border: 1px solid #5a3e1e;
transition: opacity 0.15s;
z-index: 20;
}
.inventory-slot:hover .slot-tooltip {
opacity: 1;
}
/* 移動ボタン */
.nav-buttons {
background: #1a1208;
border-top: 2px solid #3a2810;
padding: 0.4rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-btn {
background: #3a2810;
color: #c8a050;
border: 2px solid #5a3e1e;
border-radius: 6px;
width: 40px;
height: 36px;
font-size: 1.1rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.nav-btn:hover:not(:disabled) {
background: #4a3820;
border-color: #c8a050;
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.scene-indicator {
font-size: 0.8rem;
color: #8a7050;
}
/* モーダル */
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(10, 6, 2, 0.82);
display: none;
align-items: center;
justify-content: center;
z-index: 30;
}
.modal-overlay.active {
display: flex;
}
.modal-box {
background: #2a1e0e;
border: 2px solid #8a6030;
border-radius: 10px;
padding: 1.25rem 1.5rem 1.5rem;
width: 88%;
max-width: 320px;
position: relative;
}
.modal-close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
background: none;
border: none;
color: #8a7050;
font-size: 1rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.modal-close:hover {
color: #e8d5a0;
}
.modal-content h3 {
font-size: 1rem;
color: #e8d5a0;
margin-bottom: 0.75rem;
}
.modal-content p {
font-size: 0.85rem;
color: #c8b080;
line-height: 1.6;
margin-bottom: 0.75rem;
}
/* 番号錠UI */
.lock-display {
display: flex;
justify-content: center;
gap: 0.4rem;
margin: 0.75rem 0;
}
.lock-digit-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.lock-digit-btn {
background: #3a2810;
color: #e8d5a0;
border: 1px solid #5a3e1e;
border-radius: 4px;
width: 28px;
height: 22px;
font-size: 0.75rem;
cursor: pointer;
line-height: 1;
padding: 0;
}
.lock-digit-btn:hover {
background: #4a3820;
border-color: #c8a050;
}
.lock-digit {
background: #1a1208;
color: #ffdc64;
border: 2px solid #5a3e1e;
border-radius: 6px;
width: 38px;
height: 48px;
font-size: 1.6rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: monospace;
}
.lock-submit {
display: block;
width: 100%;
margin-top: 1rem;
padding: 0.5rem;
background: #6a4820;
color: #e8d5a0;
border: 2px solid #c8a050;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.lock-submit:hover {
background: #8a6030;
}
/* テキスト入力(英単語錠) */
.word-input-area {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.75rem 0;
}
.word-input {
background: #1a1208;
color: #ffdc64;
border: 2px solid #5a3e1e;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 1.1rem;
font-family: monospace;
text-align: center;
letter-spacing: 0.2em;
text-transform: uppercase;
width: 100%;
}
.word-input:focus {
outline: none;
border-color: #c8a050;
}
/* 懐中電灯演出(暗い廊下) */
.flashlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
}
/* ゲームオーバーレイ */
.game-overlay {
position: absolute;
inset: 0;
background: rgba(10, 6, 2, 0.92);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
animation: overlay-appear 0.3s ease;
}
.game-overlay.active {
display: flex;
}
@keyframes overlay-appear {
from { opacity: 0; }
to { opacity: 1; }
}
.overlay-content {
text-align: center;
padding: 1.5rem;
max-width: 300px;
}
.overlay-content h2 {
font-size: 1.6rem;
color: #e8d5a0;
margin-bottom: 0.75rem;
}
.overlay-content p {
font-size: 0.95rem;
color: #c8b080;
line-height: 1.6;
margin-bottom: 0.75rem;
}
.overlay-content .hint-note {
font-size: 0.82rem;
color: #8a7050;
margin-bottom: 1.25rem;
}
.overlay-content .clear-msg {
color: #ffdc64;
font-weight: 600;
}
/* レスポンシブ */
@media (max-width: 480px) {
.inventory-slot {
width: 38px;
height: 38px;
font-size: 1.2rem;
}
.lock-digit {
width: 32px;
height: 40px;
font-size: 1.3rem;
}
.lock-digit-btn {
width: 24px;
height: 20px;
font-size: 0.7rem;
}
.modal-box {
padding: 1rem 1.1rem 1.25rem;
}
}
game.jsがこのゲームの心臓部です。全体が(function () { ... })();という「即時実行関数」で囲まれ、ゲームの変数が他のコードとぶつからないようになっています。
このゲームの設計の中心はSCENESという配列です。5つの部屋が1つずつオブジェクトになっていて、その中のobjectsにはその部屋で調べられるもの(本棚・机・金庫など)が、位置(x, y, w, h)とクリックしたときの動き(onExamine)をセットにして並んでいます。renderScene()は、今いる部屋のデータを読んで画面に部品を並べ直す関数。部屋を移動するたびにこれが呼ばれて、画面がまるごと作り直されます。
ゲームの進み具合はstateというオブジェクトが覚えています。今いる部屋(scene)、持ち物(inventory)、そしてflagsという「引き出しを開けたか」「金庫を開けたか」といったオン・オフの記録です。このflagsのおかげで、一度解いた謎は解けたままになり、まだ解いていない場所には進めない、といった判定ができます。
このゲームならではの工夫がアイテムを「使う」しくみです。handleObjectClick()は、アイテムを選んでいるかどうかで動きを変えます。何も選んでいなければ「調べる」(onExamine)、アイテムを選んでいれば「使う」(onUseWith)。たとえば暗い廊下の壁に懐中電灯を使うと、onUseWithが反応して隠し文字が浮かび上がります。
番号錠はshowSafeLock()やshowDoorLock()が作ります。▲▼ボタンで数字を回す部分は(current[i] + dir + 10) % 10という計算がポイント。+ 10と% 10を組み合わせることで、9の次は0、0の前は9というふうに数字がくるりとループします。入力した数字をjoin('')でつなげ、正解のSAFE_CODEなどと比べて開錠を判定します。
背景の絵に画像ファイルは1枚も使っていません。drawStudy()やdrawFireplace()といった関数がdocument.createElementNSを使ってSVGの図形(四角や丸)をプログラムで描いているのです。クリアまでの時間はDate.now()でスタートからの差を計算しています。
/* ============================================
謎の書斎からの脱出 - ゲームロジック
============================================
【謎解きの流れ】
シーン1(書斎全体): 本棚の5冊を厚さ順に並べると英単語 "LIGHT" が浮かぶ
シーン2(机クローズアップ): "LIGHT" で引き出し開錠 → 星座図とページメモ入手
シーン3(暖炉・壁): 本棚の該当ページ(p.47)を調べる → 星座記号→数字変換 → 金庫(4桁)開錠 → 懐中電灯入手
シーン4(廊下・暗い): 懐中電灯で壁の隠し文字を照らす → 6桁番号を得る
シーン5(玄関ドア): 6桁番号を逆から入力 → 脱出!
============================================ */
(function () {
'use strict';
// ---- 定数 ----
var CORRECT_WORD = 'LIGHT'; // 机の引き出しの合言葉
var SAFE_CODE = '4729'; // 暖炉の金庫4桁
var HIDDEN_NUMBERS = '839156'; // 廊下の隠し文字(懐中電灯で照らす)
var DOOR_CODE = '651938'; // 玄関ドア6桁(HIDDEN_NUMBERS を逆順)
// ---- ゲーム状態 ----
var state = {
scene: 0, // 現在シーン (0〜4)
inventory: [], // 所持アイテム ID リスト
selectedItem: null, // 選択中アイテム ID
flags: {
drawerOpen: false, // 引き出し開いた
safeOpen: false, // 金庫開いた
hallLit: false, // 廊下を照らした
doorUnlocked: false, // ドア解錠済み
},
startTime: null,
cleared: false,
};
// ---- シーン定義 ----
// bg: 背景SVGのインライン文字列か色
// objects: クリッカブルオブジェクト配列
// { id, icon, label, x, y, w, h, onExamine, onUseWith }
var SCENES = [
/* ---- シーン0: 書斎全体 ---- */
{
name: '書斎',
bgColor: '#2b1d0e',
bgDeco: drawStudy,
objects: [
{
id: 'bookshelf',
icon: '📚',
label: '本棚',
x: 5, y: 10, w: 30, h: 65,
onExamine: function () {
showModal(
'本棚',
'5冊の本が並んでいる。<br>' +
'それぞれの背表紙に1文字ずつ刻まれている。<br><br>' +
'背の厚さは左から:<strong>薄・厚・中・薄・中</strong><br><br>' +
'薄い順に並べると……<br>' +
' [<span style="color:#ffdc64">薄1</span>] [<span style="color:#ffdc64">薄2</span>] [<span style="color:#aaffaa">中1</span>] [<span style="color:#aaffaa">中2</span>] [<span style="color:#ff9977">厚</span>]<br><br>' +
'背表紙の文字(薄い順):<strong style="color:#ffdc64;font-size:1.1em;letter-spacing:0.15em">L I G H T</strong><br><br>' +
'<small style="color:#8a7050">「机の引き出しに使えそうだ」</small>',
null
);
}
},
{
id: 'desk_far',
icon: '🪑',
label: '机(遠景)',
x: 38, y: 40, w: 35, h: 45,
onExamine: function () {
setMessage('机に近づいてみよう。→ボタンで次のシーンへ。');
}
},
{
id: 'window',
icon: '🪟',
label: '窓',
x: 68, y: 10, w: 26, h: 35,
onExamine: function () {
setMessage('窓は固く閉ざされている。鍵もかかっている。');
}
},
{
id: 'portrait',
icon: '🖼️',
label: '肖像画',
x: 68, y: 50, w: 26, h: 30,
onExamine: function () {
setMessage('古い肖像画。目が合う気がする。裏に何か書いてあるが届かない。');
}
}
]
},
/* ---- シーン1: 机クローズアップ ---- */
{
name: '机(クローズアップ)',
bgColor: '#1e1408',
bgDeco: drawDesk,
objects: [
{
id: 'drawer_lock',
icon: '🔒',
label: '引き出し(錠付き)',
x: 20, y: 35, w: 60, h: 40,
onExamine: function () {
if (state.flags.drawerOpen) {
showDrawerOpen();
} else {
showWordLock();
}
},
onUseWith: null
},
{
id: 'desk_memo',
icon: '📝',
label: 'メモ用紙(机上)',
x: 65, y: 20, w: 20, h: 20,
onExamine: function () {
showModal(
'メモ用紙',
'ボールペンで走り書きがある。<br><br>' +
'「暖炉の上の本棚に<br>' +
' 天文学書がある。<br>' +
' <strong>p.47</strong> を見よ」<br><br>' +
'<small style="color:#8a7050">(ページメモを覚えた)</small>',
null
);
setFlag('readDeskMemo');
}
},
{
id: 'ink_bottle',
icon: '🖊️',
label: 'インク瓶',
x: 10, y: 15, w: 15, h: 22,
onExamine: function () {
setMessage('インク瓶。もうほとんど空だ。');
}
}
]
},
/* ---- シーン2: 暖炉・壁 ---- */
{
name: '暖炉と壁',
bgColor: '#1a1208',
bgDeco: drawFireplace,
objects: [
{
id: 'astro_book',
icon: '📖',
label: '天文学書(p.47)',
x: 62, y: 8, w: 14, h: 30,
onExamine: function () {
showModal(
'天文学書 p.47',
'星座と数字の対応表が載っている。<br><br>' +
'<span style="font-family:monospace;color:#aaffaa">' +
'♈ オリオン = <strong>4</strong><br>' +
'♉ カシオペア = <strong>7</strong><br>' +
'♊ おおぐま = <strong>2</strong><br>' +
'♋ さそり = <strong>9</strong><br>' +
'</span><br>' +
'<small style="color:#8a7050">(星座の数字対応を覚えた)</small>',
null
);
}
},
{
id: 'fireplace_safe',
icon: '🔐',
label: '暖炉横の金庫',
x: 10, y: 50, w: 28, h: 35,
onExamine: function () {
if (state.flags.safeOpen) {
setMessage('金庫は開いている。懐中電灯はもう持っている。');
} else {
showSafeLock();
}
}
},
{
id: 'star_chart',
icon: '🌟',
label: '星座図(壁)',
x: 65, y: 42, w: 28, h: 40,
onExamine: function () {
// 引き出しを開けていれば星座図を持っているはず
if (!state.flags.drawerOpen) {
setMessage('壁に貼られた古い星座図。引き出しの中に詳しいものがありそうだ。');
return;
}
showModal(
'星座図',
'引き出しで見つけた星座図だ。<br>' +
'4つの星座が描かれ、矢印の順に並んでいる。<br><br>' +
'<span style="color:#ffdc64;font-size:1.1em">' +
'① ♈ オリオン<br>' +
'② ♉ カシオペア<br>' +
'③ ♊ おおぐま<br>' +
'④ ♋ さそり' +
'</span><br><br>' +
'<small style="color:#8a7050">p.47 の対応表と照らし合わせよう</small>',
null
);
}
},
{
id: 'fireplace',
icon: '🔥',
label: '暖炉',
x: 10, y: 30, w: 50, h: 20,
onExamine: function () {
setMessage('暖炉は消えている。灰が残っているが、何か燃やされたようだ。');
}
}
]
},
/* ---- シーン3: 廊下(暗い) ---- */
{
name: '廊下',
bgColor: '#08060a',
bgDeco: drawHallway,
objects: [
{
id: 'hallway_wall',
icon: '🧱',
label: '壁',
x: 5, y: 10, w: 90, h: 70,
onExamine: function () {
if (!hasItem('flashlight')) {
setMessage('暗くて何も見えない。光が必要だ。');
} else if (!state.flags.hallLit) {
// 懐中電灯を自動的に使った演出
state.flags.hallLit = true;
showModal(
'壁の隠し文字',
'懐中電灯で照らすと…<br>' +
'壁に蛍光塗料で書かれた文字が浮かび上がった!<br><br>' +
'<span style="font-size:2rem;letter-spacing:0.3em;color:#aaffaa">839156</span><br><br>' +
'<small style="color:#8a7050">(6桁の番号を覚えた)</small>',
null
);
} else {
showModal(
'壁の隠し文字',
'蛍光塗料で書かれた番号。<br><br>' +
'<span style="font-size:2rem;letter-spacing:0.3em;color:#aaffaa">839156</span>',
null
);
}
},
onUseWith: function (itemId) {
if (itemId === 'flashlight') {
state.flags.hallLit = true;
showModal(
'壁の隠し文字',
'懐中電灯で照らすと…<br>' +
'壁に蛍光塗料で書かれた文字が浮かび上がった!<br><br>' +
'<span style="font-size:2rem;letter-spacing:0.3em;color:#aaffaa">839156</span><br><br>' +
'<small style="color:#8a7050">(6桁の番号を覚えた)</small>',
null
);
return true;
}
return false;
}
},
{
id: 'hallway_door_back',
icon: '🚪',
label: '書斎へ戻るドア',
x: 75, y: 20, w: 20, h: 65,
onExamine: function () {
setMessage('書斎へ戻るドアだ。');
}
}
]
},
/* ---- シーン4: 玄関ドア ---- */
{
name: '玄関ドア',
bgColor: '#0e0a06',
bgDeco: drawEntrance,
objects: [
{
id: 'front_door',
icon: '🚪',
label: '玄関ドア(番号錠)',
x: 20, y: 10, w: 60, h: 80,
onExamine: function () {
if (state.flags.doorUnlocked) {
triggerClear();
} else {
showDoorLock();
}
}
},
{
id: 'door_note',
icon: '📋',
label: 'ドアの貼り紙',
x: 68, y: 20, w: 12, h: 18,
onExamine: function () {
showModal(
'ドアの貼り紙',
'<span style="color:#ffaaaa;font-size:1.05em">「逆から読め」</span><br><br>' +
'<small style="color:#8a7050">廊下で見つけた番号を逆順に入力するらしい…</small>',
null
);
}
}
]
}
];
// ---- DOM参照 ----
var sceneArea = document.getElementById('scene-area');
var messageText = document.getElementById('message-text');
var invSlots = document.getElementById('inventory-slots');
var sceneInd = document.getElementById('scene-indicator');
var btnLeft = document.getElementById('btn-left');
var btnRight = document.getElementById('btn-right');
var modalOverlay = document.getElementById('modal-overlay');
var modalContent = document.getElementById('modal-content');
var modalClose = document.getElementById('modal-close');
var startOverlay = document.getElementById('game-start-overlay');
var clearOverlay = document.getElementById('game-clear-overlay');
var btnStart = document.getElementById('btn-start');
var btnRetry = document.getElementById('btn-retry');
// ---- 初期化 ----
btnStart.addEventListener('click', startGame);
btnRetry.addEventListener('click', function () {
clearOverlay.classList.remove('active');
startGame();
});
modalClose.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', function (e) {
if (e.target === modalOverlay) closeModal();
});
btnLeft.addEventListener('click', function () { navigateScene(-1); });
btnRight.addEventListener('click', function () { navigateScene(1); });
function startGame() {
state.scene = 0;
state.inventory = [];
state.selectedItem = null;
state.flags = { drawerOpen: false, safeOpen: false, hallLit: false, doorUnlocked: false };
state.startTime = Date.now();
state.cleared = false;
startOverlay.classList.remove('active');
renderScene();
renderInventory();
setMessage('書斎に閉じ込められた。手がかりを探そう。');
}
// ---- シーン描画 ----
function renderScene() {
var scene = SCENES[state.scene];
sceneArea.innerHTML = '';
sceneArea.style.background = scene.bgColor;
// SVG背景デコレーション
if (scene.bgDeco) {
var svg = scene.bgDeco();
if (svg) sceneArea.appendChild(svg);
}
// 懐中電灯シーン(廊下)の暗闇オーバーレイ
if (state.scene === 3 && !state.flags.hallLit) {
var dark = document.createElement('div');
dark.className = 'flashlight-overlay';
dark.style.background = 'rgba(0,0,0,0.88)';
sceneArea.appendChild(dark);
}
// オブジェクト配置
scene.objects.forEach(function (obj) {
// 引き出しが開いた後はアイコン変更
var icon = obj.icon;
if (obj.id === 'drawer_lock' && state.flags.drawerOpen) icon = '📂';
if (obj.id === 'fireplace_safe' && state.flags.safeOpen) icon = '🔓';
var el = document.createElement('div');
el.className = 'scene-object';
el.style.left = obj.x + '%';
el.style.top = obj.y + '%';
el.style.width = obj.w + '%';
el.style.height = obj.h + '%';
el.dataset.id = obj.id;
var iconEl = document.createElement('span');
iconEl.className = 'obj-icon';
iconEl.textContent = icon;
el.appendChild(iconEl);
el.addEventListener('click', function () { handleObjectClick(obj); });
sceneArea.appendChild(el);
});
// ナビゲーション更新
sceneInd.textContent = (state.scene + 1) + ' / ' + SCENES.length;
btnLeft.disabled = (state.scene === 0);
// 廊下(シーン3)は懐中電灯なしで入れない
var nextBlocked = (state.scene === 2 && !state.flags.safeOpen);
btnRight.disabled = (state.scene >= SCENES.length - 1) || nextBlocked;
updateObjectHighlights();
}
// ---- オブジェクトクリック ----
function handleObjectClick(obj) {
if (state.selectedItem) {
// アイテムを使う
var used = false;
if (obj.onUseWith) {
used = obj.onUseWith(state.selectedItem);
}
if (!used) {
setMessage('それはここでは使えないようだ。');
}
state.selectedItem = null;
renderInventory();
updateObjectHighlights();
renderScene();
} else {
// 調べる
if (obj.onExamine) obj.onExamine();
}
}
// ---- ナビゲーション ----
function navigateScene(dir) {
var next = state.scene + dir;
if (next < 0 || next >= SCENES.length) return;
// 廊下は懐中電灯がないと入れない
if (next === 3 && !hasItem('flashlight')) {
setMessage('暗くて進めない。何か光るものが必要だ。');
return;
}
state.scene = next;
state.selectedItem = null;
closeModal();
renderScene();
setMessage(SCENES[next].name + 'を調べよう。');
}
// ---- インベントリ ----
var ITEMS = {
flashlight: { icon: '🔦', name: '懐中電灯' },
star_map: { icon: '🗺️', name: '星座図' },
key: { icon: '🗝️', name: '引き出しの鍵' },
};
function addItem(id) {
if (!hasItem(id)) {
state.inventory.push(id);
renderInventory();
setMessage('「' + ITEMS[id].name + '」を手に入れた!');
}
}
function hasItem(id) {
return state.inventory.indexOf(id) !== -1;
}
function renderInventory() {
invSlots.innerHTML = '';
state.inventory.forEach(function (id) {
var item = ITEMS[id];
if (!item) return;
var slot = document.createElement('div');
slot.className = 'inventory-slot' + (state.selectedItem === id ? ' active' : '');
slot.textContent = item.icon;
var tip = document.createElement('span');
tip.className = 'slot-tooltip';
tip.textContent = item.name;
slot.appendChild(tip);
slot.addEventListener('click', function () {
state.selectedItem = (state.selectedItem === id) ? null : id;
renderInventory();
updateObjectHighlights();
if (state.selectedItem) {
setMessage('「' + item.name + '」を選択中。使いたいものをクリック。');
} else {
setMessage('選択を解除した。');
}
});
invSlots.appendChild(slot);
});
}
function updateObjectHighlights() {
document.querySelectorAll('.scene-object').forEach(function (el) {
el.classList.toggle('selected-item-target', !!state.selectedItem);
});
}
// ---- フラグ ----
function setFlag(key) {
state.flags[key] = true;
}
// ---- メッセージ ----
function setMessage(msg) {
messageText.innerHTML = msg;
}
// ---- モーダル ----
function showModal(title, bodyHtml, extraHtml) {
var html = '<h3>' + title + '</h3><p>' + bodyHtml + '</p>';
if (extraHtml) html += extraHtml;
modalContent.innerHTML = html;
modalOverlay.classList.add('active');
}
function closeModal() {
modalOverlay.classList.remove('active');
}
// ---- 錠前UI ----
/* 英単語錠(引き出し) */
function showWordLock() {
var html = '<h3>引き出しの錠</h3>' +
'<p>5文字の英単語を入力して開けよう。</p>' +
'<div class="word-input-area">' +
'<input class="word-input" id="word-input" type="text" maxlength="5" placeholder="?????" autocomplete="off">' +
'</div>' +
'<button class="lock-submit" id="word-submit">入力</button>';
modalContent.innerHTML = html;
modalOverlay.classList.add('active');
document.getElementById('word-submit').addEventListener('click', function () {
var val = document.getElementById('word-input').value.toUpperCase().trim();
if (val === CORRECT_WORD) {
state.flags.drawerOpen = true;
// 星座図を入手
closeModal();
renderScene();
setMessage('引き出しが開いた!');
setTimeout(function () {
showModal(
'引き出しの中身',
'引き出しの中に <strong>星座図</strong> があった。<br>' +
'4つの星座が描かれ、番号が振られている。<br><br>' +
'<small style="color:#8a7050">(星座図を入手した)</small>',
null
);
addItem('star_map');
}, 300);
} else {
document.getElementById('word-input').style.borderColor = '#ff6644';
setMessage('違う。もう一度考えよう。');
setTimeout(function () {
var el = document.getElementById('word-input');
if (el) el.style.borderColor = '';
}, 600);
}
});
// Enterキーでも送信
document.getElementById('word-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') document.getElementById('word-submit').click();
});
setTimeout(function () {
var el = document.getElementById('word-input');
if (el) el.focus();
}, 100);
}
/* 引き出しオープン状態 */
function showDrawerOpen() {
showModal(
'引き出し(開)',
'引き出しは開いている。<br>' +
'中には <strong>星座図</strong> が入っていた。<br><br>' +
'<small style="color:#8a7050">(すでに持っている)</small>',
null
);
}
/* 金庫4桁錠 */
function showSafeLock() {
var digits = [4, 7, 2, 9].map(function (d) { return d; }); // 正解はSAFE_CODE
var current = [0, 0, 0, 0];
function buildSafeHTML() {
var html = '<h3>金庫の番号錠</h3><p>4桁の数字を入力しよう。</p>';
html += '<div class="lock-display">';
for (var i = 0; i < 4; i++) {
html += '<div class="lock-digit-group">' +
'<button class="lock-digit-btn" data-i="' + i + '" data-dir="1">▲</button>' +
'<div class="lock-digit" id="safe-d-' + i + '">' + current[i] + '</div>' +
'<button class="lock-digit-btn" data-i="' + i + '" data-dir="-1">▼</button>' +
'</div>';
}
html += '</div>';
html += '<button class="lock-submit" id="safe-submit">解錠する</button>';
return html;
}
modalContent.innerHTML = buildSafeHTML();
modalOverlay.classList.add('active');
function bindSafeEvents() {
document.querySelectorAll('.lock-digit-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var i = parseInt(this.dataset.i);
var dir = parseInt(this.dataset.dir);
current[i] = (current[i] + dir + 10) % 10;
document.getElementById('safe-d-' + i).textContent = current[i];
});
});
document.getElementById('safe-submit').addEventListener('click', function () {
var entered = current.join('');
if (entered === SAFE_CODE) {
state.flags.safeOpen = true;
closeModal();
renderScene();
setMessage('金庫が開いた!');
setTimeout(function () {
showModal(
'金庫の中身',
'金庫の中に <strong>懐中電灯</strong> があった!<br>' +
'バッテリーはまだ残っているようだ。<br><br>' +
'<small style="color:#8a7050">(懐中電灯を入手した)</small>',
null
);
addItem('flashlight');
// 廊下へのナビを有効化
renderScene();
}, 300);
} else {
setMessage('違う番号だ。もう一度。');
document.querySelectorAll('.lock-digit').forEach(function (el) {
el.style.color = '#ff6644';
});
setTimeout(function () {
document.querySelectorAll('.lock-digit').forEach(function (el) {
el.style.color = '#ffdc64';
});
}, 600);
}
});
}
bindSafeEvents();
}
/* 玄関ドア6桁錠 */
function showDoorLock() {
var current = [0, 0, 0, 0, 0, 0];
function buildDoorHTML() {
var html = '<h3>玄関ドアの番号錠</h3>' +
'<p>6桁の番号を入力しよう。</p>';
html += '<div class="lock-display">';
for (var i = 0; i < 6; i++) {
html += '<div class="lock-digit-group">' +
'<button class="lock-digit-btn" data-i="' + i + '" data-dir="1">▲</button>' +
'<div class="lock-digit" id="door-d-' + i + '" style="width:30px;height:40px;font-size:1.3rem">' + current[i] + '</div>' +
'<button class="lock-digit-btn" data-i="' + i + '" data-dir="-1">▼</button>' +
'</div>';
}
html += '</div>';
html += '<button class="lock-submit" id="door-submit">解錠する</button>';
return html;
}
modalContent.innerHTML = buildDoorHTML();
modalOverlay.classList.add('active');
document.querySelectorAll('.lock-digit-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var i = parseInt(this.dataset.i);
var dir = parseInt(this.dataset.dir);
current[i] = (current[i] + dir + 10) % 10;
document.getElementById('door-d-' + i).textContent = current[i];
});
});
document.getElementById('door-submit').addEventListener('click', function () {
var entered = current.join('');
if (entered === DOOR_CODE) {
state.flags.doorUnlocked = true;
closeModal();
triggerClear();
} else {
setMessage('違う。貼り紙のヒントをもう一度確認しよう。');
document.querySelectorAll('#modal-content .lock-digit').forEach(function (el) {
el.style.color = '#ff6644';
});
setTimeout(function () {
document.querySelectorAll('#modal-content .lock-digit').forEach(function (el) {
el.style.color = '#ffdc64';
});
}, 700);
}
});
}
// ---- クリア ----
function triggerClear() {
if (state.cleared) return;
state.cleared = true;
var elapsed = Math.floor((Date.now() - state.startTime) / 1000);
var min = Math.floor(elapsed / 60);
var sec = elapsed % 60;
var timeStr = (min > 0 ? min + '分' : '') + sec + '秒';
document.getElementById('clear-time-text').textContent =
'クリアタイム:' + timeStr;
clearOverlay.classList.add('active');
}
// ---- 背景SVG描画関数 ----
function makeSVG(w, h) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.style.position = 'absolute';
svg.style.inset = '0';
svg.style.width = '100%';
svg.style.height = '100%';
return svg;
}
function rect(svg, x, y, w, h, fill, rx) {
var el = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
el.setAttribute('x', x); el.setAttribute('y', y);
el.setAttribute('width', w); el.setAttribute('height', h);
el.setAttribute('fill', fill);
if (rx) el.setAttribute('rx', rx);
svg.appendChild(el);
}
function drawStudy() {
var svg = makeSVG(400, 300);
// 床
rect(svg, 0, 220, 400, 80, '#3d2510');
// 壁
rect(svg, 0, 0, 400, 220, '#4a3020');
// 腰板
rect(svg, 0, 170, 400, 12, '#6a4828');
// 本棚(左)
rect(svg, 0, 20, 120, 185, '#5a3818');
for (var i = 0; i < 5; i++) {
rect(svg, 4, 30 + i * 32, 112, 28,
['#8b3a3a','#3a7a5a','#4a5a9a','#9a7a2a','#6a3a7a'][i], 2);
}
// 棚板
for (var j = 0; j < 4; j++) {
rect(svg, 0, 58 + j * 32, 120, 4, '#3a2510');
}
// 窓(右上)
rect(svg, 280, 20, 110, 120, '#8ab4d4', 4);
rect(svg, 280, 20, 110, 120, 'none');
// 窓枠
rect(svg, 278, 18, 114, 124, '#5a3818', 4);
rect(svg, 280, 20, 110, 2, '#6a4828');
rect(svg, 280, 78, 110, 2, '#6a4828');
rect(svg, 334, 20, 2, 122, '#6a4828');
// 机(右下)
rect(svg, 140, 160, 180, 12, '#7a5028');
rect(svg, 145, 172, 12, 55, '#6a4020');
rect(svg, 303, 172, 12, 55, '#6a4020');
// 肖像画
rect(svg, 280, 155, 110, 75, '#5a3010', 3);
rect(svg, 285, 160, 100, 65, '#7a6040', 2);
return svg;
}
function drawDesk() {
var svg = makeSVG(400, 300);
rect(svg, 0, 0, 400, 300, '#2a1808');
// 机面
rect(svg, 20, 80, 360, 180, '#6a4a28', 6);
rect(svg, 20, 80, 360, 10, '#8a6a3a', 6);
// 引き出し
rect(svg, 80, 130, 240, 100, '#4a3018', 4);
rect(svg, 82, 132, 236, 96, '#5a3e22', 4);
// 引き出しの取っ手
rect(svg, 185, 175, 30, 8, '#c8a050', 3);
// インク瓶エリア(左上)
rect(svg, 30, 90, 50, 70, '#2a1808', 4);
// メモ用紙エリア(右上)
rect(svg, 290, 88, 80, 60, '#e8dcc0', 3);
return svg;
}
function drawFireplace() {
var svg = makeSVG(400, 300);
rect(svg, 0, 0, 400, 300, '#1e160a');
// 壁
rect(svg, 0, 0, 400, 240, '#3a2810');
// 床
rect(svg, 0, 240, 400, 60, '#2a1c0c');
// 暖炉本体
rect(svg, 30, 110, 200, 130, '#2e2010', 4);
rect(svg, 35, 120, 190, 100, '#1a1008', 4);
// 暖炉上の棚
rect(svg, 20, 100, 220, 14, '#5a3e1e', 3);
// 天文学書(棚の上)
rect(svg, 250, 20, 55, 110, '#3a2a5a', 3);
rect(svg, 252, 22, 51, 106, '#4a3a7a', 3);
// 金庫(暖炉横)
rect(svg, 30, 120, 110, 100, '#1a1208', 3);
rect(svg, 35, 125, 100, 90, '#3a2a18', 3);
rect(svg, 68, 165, 28, 28, '#2a2010', 4);
// 星座図(右壁)
rect(svg, 258, 165, 115, 120, '#2a2010', 3);
rect(svg, 263, 170, 105, 110, '#1a1808', 2);
// 星座の点
var stars = [[280,195],[320,210],[300,240],[340,250],[285,265],[350,230]];
stars.forEach(function(s) {
var c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('cx',s[0]); c.setAttribute('cy',s[1]); c.setAttribute('r','3');
c.setAttribute('fill','#ffdc64');
svg.appendChild(c);
});
return svg;
}
function drawHallway() {
var svg = makeSVG(400, 300);
rect(svg, 0, 0, 400, 300, '#0a0810');
// 遠近感のある廊下
rect(svg, 0, 0, 400, 300, '#0e0c16');
rect(svg, 60, 60, 280, 180, '#12101a', 0);
rect(svg, 120, 100, 160, 120, '#16141e', 0);
// 壁ライン(遠近)
var lines = [[0,0,60,60],[400,0,340,60],[0,300,60,240],[400,300,340,240]];
lines.forEach(function(l) {
var line = document.createElementNS('http://www.w3.org/2000/svg','line');
line.setAttribute('x1',l[0]); line.setAttribute('y1',l[1]);
line.setAttribute('x2',l[2]); line.setAttribute('y2',l[3]);
line.setAttribute('stroke','#2a2030'); line.setAttribute('stroke-width','2');
svg.appendChild(line);
});
// ドア(奥)
rect(svg, 300, 40, 80, 220, '#1e1828', 3);
rect(svg, 302, 42, 76, 216, '#2a2230', 3);
return svg;
}
function drawEntrance() {
var svg = makeSVG(400, 300);
rect(svg, 0, 0, 400, 300, '#0e0a06');
// 壁
rect(svg, 0, 0, 400, 260, '#2a1e10');
// 床
rect(svg, 0, 260, 400, 40, '#1e1408');
// ドア
rect(svg, 80, 20, 240, 270, '#3a2810', 4);
rect(svg, 85, 25, 230, 255, '#4a3818', 4);
// ドアパネル装飾
rect(svg, 95, 40, 95, 100, '#5a4828', 4);
rect(svg, 210, 40, 95, 100, '#5a4828', 4);
rect(svg, 95, 155, 95, 100, '#5a4828', 4);
rect(svg, 210, 155, 95, 100, '#5a4828', 4);
// ドアノブ
rect(svg, 285, 148, 18, 8, '#c8a050', 4);
// 番号錠パネル
rect(svg, 95, 120, 60, 30, '#1a1208', 4);
// 貼り紙(右)
rect(svg, 275, 80, 45, 60, '#e8dcc0', 3);
return svg;
}
})();
SCENES配列にまとめ、コードと中身を分ける% 10で番号錠の数字を0〜9でぐるぐる回すcreateElementNSで背景を図形から組み立てる上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。
このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。
慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。
このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。
ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。
コードを読んで「自分でも作ってみたい!」と思ったら、謎の書斎からの脱出の作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。
ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、無料体験にぜひ来てみてください。