junkerstock
 ss-test01.03 

<!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</title>
<style>
/* ベーススタイル */
body {
margin: 0; padding: 0; font-family: sans-serif; overflow: hidden; background: #333; color: #fff;
/* iPhoneダブルタップ拡大防止のキモ */
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</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 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 MAX_IMAGES = 10;
const getImgPath = (name) => {
// デモ用プレースホルダー
return `https://via.placeholder.com/600x400/333/fff?text=${name}`;
};

let appState = {
mode: 'title',
currentImgIndex: 1,
actionData: {},
// デフォルトシナリオ(新機能のデモ含む)
scenarioText:
`:start
猫がいる。
a1.png
^skip_intro

:intro_dummy
ここは飛ばされるはず。

:skip_intro
p 1
(1秒経過)
頭をなでてみよう()か、鼻を触ろう()
赤1-1 happy
青1-3 sleep
p
(アクション待ち...)

:happy
気持ちよさそうだ。
a2.png
p 2
メニューに戻るよ。
p
^start

:sleep
眠ってしまった。
a3.png
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}.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 + '%';

// 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}.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 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() {
// モードが変わっていたら停止(Exit時など)
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. ラベル定義 (:xxx) -> 通過するだけ(無視して次へ)
if (line.startsWith(':')) {
nextStep();
return;
}

// 2. ジャンプ (^label)
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 {
console.warn('Label not found:', targetLabel);
nextStep();
}
return;
}

// 3. 待機コマンド (p または p 秒数)
if (line.startsWith('p')) {
const parts = line.split(/\s+/);
const arg = parts[1];

if (arg && !isNaN(arg)) {
// 時間待ち (p 3 -> 3秒)
const sec = parseFloat(arg);
addMessage(`(waiting ${sec}s...)`); // デバッグ的に表示(不要なら削除)
playState.waitingForTimer = true;
playState.timerId = setTimeout(() => {
playState.waitingForTimer = false;
nextStep();
}, sec * 1000);
} else {
// クリック待ち (p)
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);
}
// ここで処理を中断(次のnextStepは呼ばない)
return;
}

// 4. 画像変更 (xxx.png)
if (line.match(/^[a-zA-Z0-9]+\.png$/)) {
document.getElementById('play-img').src = getImgPath(line);
setupPlayHitBoxes(line);
nextStep(); // 即次へ
return;
}

// 5. トリガー設定 (赤1-3 happy)
// トリガーが見つかった時点で「ユーザーのアクション待ち」モードに入るため、
// コマンド解析は続けるが、自動進行はここでストップする
// → いや、仕様としては「テキスト表示しつつ、アクション待ちは裏で行う」のが一般的。
// ただしADVなので、アクション待ちの間もテキストは進んでいいのか?
// 今回は「p」コマンドが明示されたので、「トリガー設定は即時反映して次へ進む」とする。
// 待機が必要ならシナリオ側で `p` を書くはず。
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');
// 古いBOX削除(画像と戻るボタン以外)
Array.from(container.children).forEach(c => {
if (c.classList.contains('hit-box')) container.removeChild(c);
});

// 現在有効なトリガーをリセットするか?
// 画像が変わったら古いトリガーは無効にするのが自然。
playState.activeTriggers = [];
// ※注意: シナリオの書き方として「a1.png -> トリガー定義 -> p」の順である必要がある

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(); // 背景のp待ちクリックに反応させないため
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);
}
});
// 青にもstopPropagation入れたいが、moveイベントなので難しい。
// startで止める
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
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
imgName
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
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
スマホパレット操作(新規配置)
再生時のインタラクション
定数・状態管理
新・再生エンジン(ステップ実行型)
画面遷移
編集モードロジック