<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>猫なでアドベンチャー v2.1</title>
<style>
/* ベーススタイル */
body {
margin: 0; padding: 0; font-family: sans-serif; overflow: hidden; background: #333; color: #fff;
touch-action: manipulation;
}
#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; touch-action: manipulation; }
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;
z-index: 10;
}
.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; }
/* 編集モード下部 */
.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; background: #222; color: #fff; border: 1px solid #666; }
.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; align-items: center; }
.palette-item { width: 50px; height: 50px; display: flex; justify-content: center; align-items: center; color: white; cursor: grab; border-radius: 4px; touch-action: none; }
/* ゴミ箱 */
#trash-box {
background: #444; border: 2px dashed #999; font-size: 24px;
}
#trash-box.drag-over { background: #600; border-color: red; }
.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; position: relative; }
/* 待機中のインジケーター */
.wait-indicator {
display: inline-block; width: 10px; height: 10px; background: #fff; border-radius: 50%;
animation: blink 1s infinite; margin-left: 5px; vertical-align: middle;
}
@keyframes blink { 0%,100% {opacity:0;} 50% {opacity:1;} }
/* クリック待ちオーバーレイ */
#click-wait-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 999; display: none;
}
.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 v2.1</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.jpg</span>
<button onclick="changeEditImage(1)">→</button>
</div>
<div class="palette-area">
<div id="trash-box" class="palette-item">🗑️</div>
<div class="palette-item red" style="background:red;" draggable="true" ondragstart="paletteDragStart(event, 'red')">赤</div>
<div class="palette-item blue" style="background:blue;" draggable="true" ondragstart="paletteDragStart(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 id="click-wait-overlay" onclick="onUserClick()"></div>
</div>
</div>
<script>
/* ================= 【ここを設定変更してください】 ================= */
// 画像フォルダのパス(末尾に / をつけてください)
const BASE_PATH = './pic4/';
// 画像の拡張子(.jpg, .png など)
const IMG_EXT = '.jpg';
// 編集モードで切り替えられる画像の最大数(a1~a10 まで等)
const MAX_IMAGES = 10;
/* ============================================================= */
/* ================= 定数・状態管理 ================= */
const getImgPath = (name) => {
// 画像パスを生成して返す
return `${BASE_PATH}${name}`;
};
let appState = {
mode: 'title',
currentImgIndex: 1,
actionData: {},
// デフォルトシナリオ(拡張子を jpg に変更済み)
scenarioText:
`:start
猫がいる。
a1${IMG_EXT}
^skip_intro
:intro_dummy
ここは飛ばされるはず。
:skip_intro
p 1
(1秒経過)
頭をなでてみよう(青)か、鼻を触ろう(赤)。
赤1-1 happy
青1-3 sleep
p
(アクション待ち...)
:happy
気持ちよさそうだ。
a2${IMG_EXT}
p 2
メニューに戻るよ。
p
^start
:sleep
眠ってしまった。
a3${IMG_EXT}
p
おやすみ。
^start`,
};
let playState = {
lines: [],
cursor: 0,
touchCounts: {},
activeTriggers: [],
waitingForClick: false,
waitingForTimer: false
};
/* ================= 画面遷移 ================= */
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';
if(playState.timerId) clearTimeout(playState.timerId);
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}${IMG_EXT}`;
document.getElementById('current-img-name').innerText = name;
document.getElementById('edit-img').src = getImgPath(name);
// 画像読み込みエラー時のフォールバック(デバッグ用: 不要なら削除可)
document.getElementById('edit-img').onerror = function() {
// 画像がない場合はプレースホルダーを表示
this.src = `https://via.placeholder.com/600x400/333/fff?text=${name}`;
this.onerror = null;
};
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 + '%';
// PCドラッグ用
el.draggable = true;
el.ondragstart = (e) => {
e.dataTransfer.setData('text/plain', JSON.stringify({index: index, imgName: imgName}));
document.getElementById('trash-box').classList.add('active');
};
// スマホタッチ移動&削除用
addTouchDrag(el, imgName, index);
container.appendChild(el);
});
}
/* パレットからの新規追加(PC) */
let draggingNewType = null;
function paletteDragStart(e, type) {
draggingNewType = type;
}
const overlay = document.getElementById('edit-overlay');
overlay.ondragover = (e) => e.preventDefault();
overlay.ondrop = (e) => {
e.preventDefault();
// 新規追加
if (draggingNewType) {
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, draggingNewType);
draggingNewType = null;
}
};
/* ゴミ箱へのドロップ(PC) */
const trashBox = document.getElementById('trash-box');
trashBox.ondragover = (e) => {
e.preventDefault();
trashBox.classList.add('drag-over');
};
trashBox.ondragleave = () => trashBox.classList.remove('drag-over');
trashBox.ondrop = (e) => {
e.preventDefault();
trashBox.classList.remove('drag-over');
const data = e.dataTransfer.getData('text/plain');
if (data) {
try {
const parsed = JSON.parse(data);
if (parsed.imgName) {
deleteHitBox(parsed.imgName, parsed.index);
}
} catch(err){}
}
};
function addHitBox(x, y, type) {
// 設定された拡張子を使用
const imgName = `a${appState.currentImgIndex}${IMG_EXT}`;
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 deleteHitBox(imgName, index) {
if (appState.actionData[imgName]) {
appState.actionData[imgName].splice(index, 1);
renderEditHitBoxes(imgName);
}
}
/* スマホタッチロジック(移動 + ゴミ箱削除) */
function addTouchDrag(el, imgName, index) {
let isDragging = false;
el.addEventListener('touchstart', (e) => {
isDragging = true;
e.stopPropagation();
}, {passive: false});
el.addEventListener('touchmove', (e) => {
if (!isDragging) return;
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 + '%';
// ゴミ箱の上に来ているか判定して色を変える
const trashRect = trashBox.getBoundingClientRect();
if (touch.clientX >= trashRect.left && touch.clientX <= trashRect.right &&
touch.clientY >= trashRect.top && touch.clientY <= trashRect.bottom) {
trashBox.classList.add('drag-over');
} else {
trashBox.classList.remove('drag-over');
}
}, {passive: false});
el.addEventListener('touchend', (e) => {
if (!isDragging) return;
isDragging = false;
const touch = e.changedTouches[0];
// ゴミ箱エリア判定
const trashRect = trashBox.getBoundingClientRect();
if (touch.clientX >= trashRect.left && touch.clientX <= trashRect.right &&
touch.clientY >= trashRect.top && touch.clientY <= trashRect.bottom) {
// 削除実行
trashBox.classList.remove('drag-over');
deleteHitBox(imgName, index);
return;
}
// 位置確定
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(95, x)).toFixed(1);
appState.actionData[imgName][index].y = Math.max(0, Math.min(95, 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 a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'cat_game_data.json';
a.click();
}
/* ================= スマホパレット操作(新規配置) ================= */
(function setupPaletteTouch() {
const paletteItems = document.querySelectorAll('.palette-item');
paletteItems.forEach(item => {
if (item.id === 'trash-box') return; // ゴミ箱は除外
item.addEventListener('touchstart', (e) => { if(e.cancelable) e.preventDefault(); }, { passive: false });
item.addEventListener('touchend', (e) => {
const touch = e.changedTouches[0];
const rect = overlay.getBoundingClientRect();
// 画像エリア内なら配置
if (touch.clientX >= rect.left && touch.clientX <= rect.right &&
touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
const x = ((touch.clientX - rect.left) / rect.width) * 100;
const y = ((touch.clientY - rect.top) / rect.height) * 100;
const type = item.classList.contains('red') ? 'red' : 'blue';
addHitBox(x, y, type);
}
});
});
})();
/* ================= 新・再生エンジン(ステップ実行型) ================= */
function runScenario(startLabel) {
playState.lines = appState.scenarioText.split('\n').map(l => l.trim());
playState.touchCounts = {};
playState.activeTriggers = [];
const messageArea = document.getElementById('message-area');
messageArea.innerHTML = '';
let startIndex = 0;
if (startLabel) {
const labelKey = startLabel.startsWith(':') ? startLabel : ':' + startLabel;
startIndex = playState.lines.findIndex(l => l === labelKey);
if (startIndex === -1) startIndex = 0;
}
playState.cursor = startIndex + 1;
nextStep();
}
function nextStep() {
if (appState.mode !== 'play') return;
if (playState.cursor >= playState.lines.length) return;
const line = playState.lines[playState.cursor];
playState.cursor++;
if (!line) { nextStep(); return; }
// 1. ラベル
if (line.startsWith(':')) { nextStep(); return; }
// 2. ジャンプ
if (line.startsWith('^')) {
const targetLabel = line.substring(1).trim();
const labelKey = targetLabel.startsWith(':') ? targetLabel : ':' + targetLabel;
const newIndex = playState.lines.findIndex(l => l === labelKey);
if (newIndex !== -1) {
playState.cursor = newIndex + 1;
nextStep();
} else {
nextStep();
}
return;
}
// 3. 待機コマンド
if (line.startsWith('p')) {
const parts = line.split(/\s+/);
const arg = parts[1];
if (arg && !isNaN(arg)) {
const sec = parseFloat(arg);
playState.waitingForTimer = true;
playState.timerId = setTimeout(() => {
playState.waitingForTimer = false;
nextStep();
}, sec * 1000);
} else {
playState.waitingForClick = true;
const overlay = document.getElementById('click-wait-overlay');
overlay.style.display = 'block';
const msgArea = document.getElementById('message-area');
const indicator = document.createElement('span');
indicator.className = 'wait-indicator';
msgArea.appendChild(indicator);
}
return;
}
// 4. 画像変更 (拡張子判定を少し緩くして jpg/png 両対応、または指定されたもの)
// ここでは単純に「.」を含み、末尾が画像っぽいものとして判定
if (line.match(/\.(png|jpg|jpeg|gif)$/i)) {
const imgEl = document.getElementById('play-img');
imgEl.src = getImgPath(line);
// 再生時もエラーならプレースホルダー
imgEl.onerror = function() {
this.src = `https://via.placeholder.com/600x400/333/fff?text=${line}`;
this.onerror = null;
};
setupPlayHitBoxes(line);
nextStep();
return;
}
// 5. トリガー設定
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;
nextStep();
return;
}
// 6. テキスト表示
addMessage(line);
nextStep();
}
function addMessage(text) {
const messageArea = document.getElementById('message-area');
const p = document.createElement('p');
p.innerText = text;
messageArea.appendChild(p);
messageArea.scrollTop = messageArea.scrollHeight;
}
function onUserClick() {
if (playState.waitingForClick) {
playState.waitingForClick = false;
document.getElementById('click-wait-overlay').style.display = 'none';
const indicators = document.querySelectorAll('.wait-indicator');
indicators.forEach(el => el.remove());
nextStep();
}
}
/* ================= 再生時のインタラクション ================= */
function setupPlayHitBoxes(imgName) {
const container = document.getElementById('play-image-container');
Array.from(container.children).forEach(c => {
if (c.classList.contains('hit-box')) container.removeChild(c);
});
playState.activeTriggers = [];
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', (e) => {
e.stopPropagation();
handleInteraction('red', item.id);
});
} else {
let slideCount = 0;
el.addEventListener('touchmove', (e) => {
slideCount++;
if (slideCount % 5 === 0) handleInteraction('blue', item.id);
}, {passive: true});
el.addEventListener('mousemove', (e) => {
if (e.buttons === 1) {
slideCount++;
if (slideCount % 5 === 0) handleInteraction('blue', item.id);
}
});
el.addEventListener('touchstart', (e)=>e.stopPropagation(), {passive:true});
el.addEventListener('mousedown', (e)=>e.stopPropagation());
}
container.appendChild(el);
});
}
function handleInteraction(type, id) {
const key = `${type}${id}`;
if (playState.touchCounts[key] === undefined) playState.touchCounts[key] = 0;
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) {
playState.waitingForClick = false;
playState.waitingForTimer = false;
if(playState.timerId) clearTimeout(playState.timerId);
document.getElementById('click-wait-overlay').style.display = 'none';
runScenario(trigger.jumpTo);
}
}
}
</script>
</body>
</html>
使用変数
| * | |
| 5 | |
| a | |
| activeTriggers | |
| addHitBox -------( Function ) | |
| addMessage -------( Function ) | |
| addTouchDrag -------( Function ) | |
| alt | |
| appState | |
| arg | |
| BASE_PATH | |
| blob | |
| buttons | |
| c | |
| changeEditImage -------( Function ) | |
| charset | |
| class | |
| className | |
| container | |
| content | |
| count | |
| currentImgIndex | |
| cursor | |
| data | |
| deleteHitBox -------( Function ) | |
| display | |
| download | |
| downloadData -------( Function ) | |
| draggable | |
| draggingNewType | |
| el | |
| existing | |
| getImgPath | |
| handleInteraction -------( Function ) | |
| href | |
| i | |
| id | |
| imgEl | |
| imgName | |
| IMG_EXT | |
| indicator | |
| indicators | |
| innerHTML | |
| innerText | |
| isDragging | |
| item | |
| items | |
| jumpTo | |
| key | |
| l | |
| labelKey | |
| lang | |
| left | |
| line | |
| lines | |
| m | |
| MAX_IMAGES | |
| messageArea | |
| mode | |
| msgArea | |
| name | |
| newIndex | |
| nextId | |
| nextStep -------( Function ) | |
| onclick | |
| ondragleave | |
| ondragover | |
| ondragstart | |
| ondrop | |
| onerror | |
| onUserClick -------( Function ) | |
| overlay | |
| p | |
| paletteDragStart -------( Function ) | |
| paletteItems | |
| parsed | |
| parts | |
| placeholder | |
| playState | |
| rect | |
| renderEditHitBoxes -------( Function ) | |
| returnToTitle -------( Function ) | |
| runScenario -------( Function ) | |
| scalable | |
| scale | |
| scenarioText | |
| scrollTop | |
| sec | |
| setupPaletteTouch -------( Function ) | |
| setupPlayHitBoxes -------( Function ) | |
| showScreen -------( Function ) | |
| slideCount | |
| src | |
| startEditMode -------( Function ) | |
| startIndex | |
| startPlayMode -------( Function ) | |
| style | |
| t | |
| targetLabel | |
| text | |
| timerId | |
| top | |
| touch | |
| touchCounts | |
| trashBox | |
| trashRect | |
| trigger | |
| type | |
| updateEditImageDisplay -------( Function ) | |
| value | |
| waitingForClick | |
| waitingForTimer | |
| width | |
| x | |
| y | |
| 【ここを設定変更してください】 | |
| スマホパレット操作(新規配置) | |
| 再生時のインタラクション | |
| 定数・状態管理 | |
| 新・再生エンジン(ステップ実行型) | |
| 画面遷移 | |
| 編集モードロジック |