<!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>猫なでアドベンチャー</title>
<style>
/* ベーススタイル */
body { margin: 0; padding: 0; font-family: sans-serif; overflow: hidden; background: #333; color: #fff; touch-action: none; }
#app { width: 100vw; height: 100vh; position: relative; display: flex; flex-direction: column; }
/* 共通コンポーネント */
button { padding: 10px; margin: 2px; border: none; border-radius: 4px; background: #ddd; cursor: pointer; }
button:active { background: #bbb; }
.hidden { display: none !important; }
/* 画面レイアウト */
.screen { flex: 1; display: flex; flex-direction: column; width: 100%; height: 100%; }
/* スタート画面 */
#start-screen { justify-content: center; align-items: center; gap: 20px; }
#start-screen h1 { margin-bottom: 20px; }
/* 編集・再生共通:画像エリア */
.image-area {
position: relative;
width: 100%;
height: 50vh;
background: #222;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
.image-area img { max-width: 100%; max-height: 100%; object-fit: contain; user-select: none; pointer-events: none; }
/* ヒットエリア(ナデナデポイント) */
.hit-box {
position: absolute;
width: 60px; height: 60px;
display: flex; justify-content: center; align-items: center;
font-weight: bold; color: white;
user-select: none;
touch-action: none;
}
.hit-box.red { background: rgba(255, 0, 0, 0.5); border: 2px solid red; }
.hit-box.blue { background: rgba(0, 0, 255, 0.5); border: 2px solid blue; }
.hit-box.playing { opacity: 0; } /* 再生中は見えないが判定はある */
/* デバッグ用: 再生中でも位置確認したい場合はここを opacity: 0.3 とかにする */
/* 編集モード下部 */
.edit-bottom { flex: 1; display: flex; height: 50vh; }
.edit-text-panel { flex: 1; display: flex; flex-direction: column; padding: 5px; background: #444; }
.edit-text-panel textarea { flex: 1; width: 95%; resize: none; font-size: 14px; }
.edit-control-panel { flex: 1; background: #555; padding: 5px; display: flex; flex-direction: column; gap: 5px; align-items: center; }
.palette-area { display: flex; gap: 10px; margin-bottom: 10px; }
.palette-item { width: 50px; height: 50px; display: flex; justify-content: center; align-items: center; color: white; cursor: grab; }
.img-selector { display: flex; align-items: center; gap: 5px; margin-bottom: 5px; }
/* 再生モード下部 */
.play-bottom { flex: 1; padding: 20px; background: #222; border-top: 2px solid #555; font-size: 18px; line-height: 1.6; overflow-y: auto; }
/* 戻るボタン配置 */
.back-btn-area { position: absolute; top: 10px; left: 10px; z-index: 100; }
</style>
</head>
<body>
<div id="app">
<div id="start-screen" class="screen">
<h1>猫なでADV</h1>
<button onclick="startEditMode()">編集モード</button>
<button onclick="startPlayMode()">再生モード</button>
</div>
<div id="edit-screen" class="screen hidden">
<div class="image-area" id="edit-image-container">
<img id="edit-img" src="" alt="Cat Image">
<div id="edit-overlay" style="position:absolute; top:0; left:0; width:100%; height:100%;"></div>
</div>
<div class="edit-bottom">
<div class="edit-text-panel">
<textarea id="scenario-input" placeholder="ここにシナリオを入力..."></textarea>
</div>
<div class="edit-control-panel">
<div class="img-selector">
<button onclick="changeEditImage(-1)">←</button>
<span id="current-img-name">a1.png</span>
<button onclick="changeEditImage(1)">→</button>
</div>
<div class="palette-area">
<div class="palette-item red" style="background:red;" draggable="true" ondragstart="dragStart(event, 'red')">赤</div>
<div class="palette-item blue" style="background:blue;" draggable="true" ondragstart="dragStart(event, 'blue')">青</div>
</div>
<button onclick="downloadData()">保存 (DL)</button>
<button onclick="returnToTitle()">戻る</button>
<div style="font-size:10px; color:#aaa;">赤:ドラッグで配置<br>配置後移動可能</div>
</div>
</div>
</div>
<div id="play-screen" class="screen hidden">
<div class="image-area" id="play-image-container">
<img id="play-img" src="" alt="Cat Image">
<div class="back-btn-area">
<button onclick="returnToTitle()">Exit</button>
</div>
</div>
<div class="play-bottom" id="message-area">
</div>
</div>
</div>
<script>
/* ================= 定数・状態管理 ================= */
// 画像は本来 picフォルダ ですが、デモ用にプレースホルダーを使用
// 実装時はここを実際のパスにしてください ('pic/a1.png' 等)
const MAX_IMAGES = 10;
const getImgPath = (name) => {
// デモ用: 色付きの画像を生成するAPIを使用
const num = parseInt(name.replace('a', '').replace('.png', ''));
const hue = (num * 30) % 360;
return `https://via.placeholder.com/600x400/333/fff?text=${name}`;
// 本番用: return `pic/${name}`;
};
let appState = {
mode: 'title', // title, edit, play
currentImgIndex: 1, // a1 ~ a10
// アクション情報: { "a1.png": [ {type:'red', id:1, x:10, y:20}, ... ] }
actionData: {},
scenarioText: `:start\n猫がいる。\na1.png\n赤1-3 happy\n青1-5 sleep\n\n:happy\n気持ちよさそうだ。\na2.png\n\n:sleep\n眠ってしまった。\na3.png`,
};
// 実行時の一時変数
let playState = {
currentLabel: null,
touchCounts: {}, // { "red1": 0, "blue2": 0 }
activeTriggers: [] // 現在有効なトリガーリスト
};
/* ================= 画面遷移 ================= */
function showScreen(id) {
document.querySelectorAll('.screen').forEach(el => el.classList.add('hidden'));
document.getElementById(id).classList.remove('hidden');
}
function startEditMode() {
appState.mode = 'edit';
document.getElementById('scenario-input').value = appState.scenarioText;
updateEditImageDisplay();
showScreen('edit-screen');
}
function startPlayMode() {
appState.mode = 'play';
// シナリオを読み込んで保存
appState.scenarioText = document.getElementById('scenario-input').value || appState.scenarioText;
// パースして実行開始
showScreen('play-screen');
runScenario(':start'); // 初期ラベル(指定がなければ最初から)
}
function returnToTitle() {
appState.mode = 'title';
showScreen('start-screen');
}
/* ================= 編集モードロジック ================= */
function changeEditImage(dir) {
appState.currentImgIndex += dir;
if (appState.currentImgIndex < 1) appState.currentImgIndex = MAX_IMAGES;
if (appState.currentImgIndex > MAX_IMAGES) appState.currentImgIndex = 1;
updateEditImageDisplay();
}
function updateEditImageDisplay() {
const name = `a${appState.currentImgIndex}.png`;
document.getElementById('current-img-name').innerText = name;
document.getElementById('edit-img').src = getImgPath(name);
renderEditHitBoxes(name);
}
// ヒットボックスの描画(編集用)
function renderEditHitBoxes(imgName) {
const container = document.getElementById('edit-overlay');
container.innerHTML = '';
const items = appState.actionData[imgName] || [];
items.forEach((item, index) => {
const el = document.createElement('div');
el.className = `hit-box ${item.type}`;
el.innerText = item.id;
el.style.left = item.x + '%';
el.style.top = item.y + '%';
// 編集モードでのドラッグ移動
el.draggable = true;
el.ondragend = (e) => updateBoxPosition(e, imgName, index);
// スマホ用タッチ移動ロジック(簡易版)
addTouchDrag(el, imgName, index);
container.appendChild(el);
});
}
// パレットからのドラッグ開始
let draggingType = null;
function dragStart(e, type) {
draggingType = type;
}
// ドロップエリア(オーバーレイ)
const overlay = document.getElementById('edit-overlay');
overlay.ondragover = (e) => e.preventDefault();
overlay.ondrop = (e) => {
e.preventDefault();
if (!draggingType) return;
const rect = overlay.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
addHitBox(x, y, draggingType);
draggingType = null;
};
function addHitBox(x, y, type) {
const imgName = `a${appState.currentImgIndex}.png`;
if (!appState.actionData[imgName]) appState.actionData[imgName] = [];
// 番号の自動採番
const existing = appState.actionData[imgName].filter(i => i.type === type);
const nextId = existing.length > 0 ? Math.max(...existing.map(i => i.id)) + 1 : 1;
appState.actionData[imgName].push({
type: type,
id: nextId,
x: x.toFixed(1), // %
y: y.toFixed(1) // %
});
renderEditHitBoxes(imgName);
}
function updateBoxPosition(e, imgName, index) {
const rect = overlay.getBoundingClientRect();
// マウス位置から計算(簡易実装)
let clientX = e.clientX;
let clientY = e.clientY;
// 0の場合は非表示等の可能性があるので無視
if (clientX === 0 && clientY === 0) return;
const x = ((clientX - rect.left) / rect.width) * 100;
const y = ((clientY - rect.top) / rect.height) * 100;
appState.actionData[imgName][index].x = Math.max(0, Math.min(90, x)).toFixed(1);
appState.actionData[imgName][index].y = Math.max(0, Math.min(90, y)).toFixed(1);
renderEditHitBoxes(imgName);
}
// スマホタッチでの配置編集用(簡易実装)
function addTouchDrag(el, imgName, index) {
let startX, startY;
el.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
e.stopPropagation(); // 親への伝播を止める
}, {passive: false});
el.addEventListener('touchmove', (e) => {
e.preventDefault(); // スクロール防止
const touch = e.touches[0];
const rect = overlay.getBoundingClientRect();
const x = ((touch.clientX - rect.left) / rect.width) * 100;
const y = ((touch.clientY - rect.top) / rect.height) * 100;
// 即時反映すると重いかもしれないが、簡単な実装として
el.style.left = x + '%';
el.style.top = y + '%';
}, {passive: false});
el.addEventListener('touchend', (e) => {
// 最終位置を保存
const touch = e.changedTouches[0];
const rect = overlay.getBoundingClientRect();
const x = ((touch.clientX - rect.left) / rect.width) * 100;
const y = ((touch.clientY - rect.top) / rect.height) * 100;
appState.actionData[imgName][index].x = Math.max(0, Math.min(90, x)).toFixed(1);
appState.actionData[imgName][index].y = Math.max(0, Math.min(90, y)).toFixed(1);
renderEditHitBoxes(imgName);
});
}
function downloadData() {
appState.scenarioText = document.getElementById('scenario-input').value;
const data = {
scenario: appState.scenarioText,
actions: appState.actionData
};
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cat_game_data.json';
a.click();
}
/* ================= 再生エンジンのコアロジック ================= */
// シナリオ解析と実行
function runScenario(startLabel) {
const lines = appState.scenarioText.split('\n').map(l => l.trim());
let startIndex = 0;
// ラベル検索(:abc または startLabelが指定なしなら0行目から)
if (startLabel) {
const labelKey = startLabel.startsWith(':') ? startLabel : ':' + startLabel;
startIndex = lines.findIndex(l => l === labelKey);
if (startIndex === -1) startIndex = 0; // 見つからなければ最初から
}
// 実行状態のリセット
playState.activeTriggers = [];
playState.touchCounts = {};
const messageArea = document.getElementById('message-area');
messageArea.innerHTML = ''; // 画面切り替え時にクリアするかは仕様次第(ここではクリア)
let currentImg = null;
// 1行ずつ処理(非同期処理なしでここまで一気に読む)
// ループで回し、次のラベル(:xxx)が来たら「待ち」状態に入る
for (let i = startIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
// 次のラベルが来たら、ここまでの設定で「待機」に入る
if (line.startsWith(':')) {
break;
}
// 画像指定 (xxx.png)
if (line.match(/^[a-zA-Z0-9]+\.png$/)) {
currentImg = line;
document.getElementById('play-img').src = getImgPath(currentImg);
setupPlayHitBoxes(currentImg); // 画像が変わったらヒットエリアも再配置
}
// トリガー指定 (赤1-5 ccc)
else if (line.match(/^(赤|青)(\d+)-(\d+)\s+(.+)$/)) {
const m = line.match(/^(赤|青)(\d+)-(\d+)\s+(.+)$/);
const type = m[1] === '赤' ? 'red' : 'blue';
const id = parseInt(m[2]);
const count = parseInt(m[3]);
const jumpTo = m[4];
// トリガー登録
playState.activeTriggers.push({ type, id, count, jumpTo });
// カウンター初期化
playState.touchCounts[`${type}${id}`] = 0;
}
// テキスト表示(それ以外)
else {
const p = document.createElement('p');
p.innerText = line;
messageArea.appendChild(p);
messageArea.scrollTop = messageArea.scrollHeight;
}
}
// ループを抜けたら「ユーザー入力待ち」状態になっている
console.log("Wait for input...", playState.activeTriggers);
}
// 再生用ヒットボックス配置
function setupPlayHitBoxes(imgName) {
const container = document.getElementById('play-image-container');
// 画像と戻るボタン以外(=古いヒットボックス)を削除
Array.from(container.children).forEach(c => {
if (c.tagName === 'DIV' && c.classList.contains('hit-box')) {
container.removeChild(c);
}
});
const items = appState.actionData[imgName] || [];
items.forEach(item => {
const el = document.createElement('div');
el.className = `hit-box ${item.type} playing`; // 透明クラス付与
el.style.left = item.x + '%';
el.style.top = item.y + '%';
// インタラクション登録
if (item.type === 'red') {
// 赤=タップ(クリック)
el.addEventListener('pointerdown', () => handleInteraction('red', item.id));
} else {
// 青=スライド(なでなで)
// 連続した動きを検知する必要がある
let slideCount = 0;
let lastX = 0;
el.addEventListener('touchmove', (e) => {
// 簡単な実装:イベント発火回数、または移動量でカウント
// ここでは「撫でられた」感を出すため、一定のイベント発火ごとにカウントアップ
slideCount++;
if (slideCount % 5 === 0) { // 感度調整
handleInteraction('blue', item.id);
}
}, {passive: true});
// PCのマウス用
el.addEventListener('mousemove', (e) => {
if (e.buttons === 1) { // クリックしながら
slideCount++;
if (slideCount % 5 === 0) {
handleInteraction('blue', item.id);
}
}
});
}
container.appendChild(el);
});
}
// 判定処理
function handleInteraction(type, id) {
const key = `${type}${id}`;
if (playState.touchCounts[key] !== undefined) {
playState.touchCounts[key]++;
console.log(`Hit: ${key}, Count: ${playState.touchCounts[key]}`);
// 条件を満たすトリガーがあるかチェック
const trigger = playState.activeTriggers.find(t => t.type === type && t.id === id);
if (trigger) {
if (playState.touchCounts[key] >= trigger.count) {
// 条件達成!ジャンプ実行
console.log(`Trigger fired! Jump to ${trigger.jumpTo}`);
runScenario(trigger.jumpTo);
}
}
}
}
/* ================= Android/スマホ用 パレットドラッグ対応 ================= */
// ページ読み込み完了時、またはスクリプトの最後で実行
(function setupPaletteTouch() {
const paletteItems = document.querySelectorAll('.palette-item');
const overlay = document.getElementById('edit-overlay');
paletteItems.forEach(item => {
// タッチ開始時:スクロール防止など
item.addEventListener('touchstart', (e) => {
// ドラッグ操作とみなすためデフォルト動作(スクロール等)を止める
if(e.cancelable) e.preventDefault();
}, { passive: false });
// タッチ終了時:場所を判定して配置
item.addEventListener('touchend', (e) => {
// 指を離した位置を取得
const touch = e.changedTouches[0];
const clientX = touch.clientX;
const clientY = touch.clientY;
// 画像エリア(オーバーレイ)の範囲内かチェック
const rect = overlay.getBoundingClientRect();
const isInX = clientX >= rect.left && clientX <= rect.right;
const isInY = clientY >= rect.top && clientY <= rect.bottom;
if (isInX && isInY) {
// 画像内の相対座標(%)に変換
const x = ((clientX - rect.left) / rect.width) * 100;
const y = ((clientY - rect.top) / rect.height) * 100;
// 赤か青か判定
let type = 'blue';
if (item.classList.contains('red') || item.innerText.includes('赤')) {
type = 'red';
}
// ヒットボックス追加関数を呼び出し
addHitBox(x, y, type);
}
});
});
})();
</script>
</body>
</html>
使用変数
| * | |
| 5 | |
| a | |
| activeTriggers | |
| addHitBox -------( Function ) | |
| addTouchDrag -------( Function ) | |
| alt | |
| appState | |
| blob | |
| buttons | |
| c | |
| changeEditImage -------( Function ) | |
| charset | |
| class | |
| className | |
| clientX | |
| clientY | |
| container | |
| content | |
| count | |
| currentImg | |
| currentImgIndex | |
| data | |
| download | |
| downloadData -------( Function ) | |
| draggable | |
| draggingType | |
| dragStart -------( Function ) | |
| el | |
| existing | |
| getImgPath | |
| handleInteraction -------( Function ) | |
| href | |
| hue | |
| i | |
| id | |
| imgName | |
| innerHTML | |
| innerText | |
| isInX | |
| isInY | |
| item | |
| items | |
| jumpTo | |
| key | |
| l | |
| labelKey | |
| lang | |
| lastX | |
| left | |
| line | |
| lines | |
| m | |
| MAX_IMAGES | |
| messageArea | |
| mode | |
| name | |
| nextId | |
| num | |
| onclick | |
| ondragend | |
| ondragover | |
| ondragstart | |
| ondrop | |
| overlay | |
| p | |
| paletteItems | |
| placeholder | |
| playState | |
| rect | |
| renderEditHitBoxes -------( Function ) | |
| returnToTitle -------( Function ) | |
| runScenario -------( Function ) | |
| scalable | |
| scale | |
| scenarioText | |
| scrollTop | |
| setupPaletteTouch -------( Function ) | |
| setupPlayHitBoxes -------( Function ) | |
| showScreen -------( Function ) | |
| slideCount | |
| src | |
| startEditMode -------( Function ) | |
| startIndex | |
| startPlayMode -------( Function ) | |
| startX | |
| startY | |
| style | |
| t | |
| tagName | |
| text | |
| top | |
| touch | |
| touchCounts | |
| trigger | |
| type | |
| updateBoxPosition -------( Function ) | |
| updateEditImageDisplay -------( Function ) | |
| url | |
| value | |
| width | |
| x | |
| y | |
| パレットドラッグ対応 | |
| 再生エンジンのコアロジック | |
| 定数・状態管理 | |
| 画面遷移 | |
| 編集モードロジック |