<!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>Mobile Ready TODO List v3 + Neko Script</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<style>
/* --- 既存のスタイル (変更なし部分は省略しつつ維持) --- */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f0f2f5;
display: flex;
justify-content: center;
padding: 20px 10px;
margin: 0;
-webkit-tap-highlight-color: transparent;
overflow: hidden; /* 猫が画面外に行ってもスクロールバーを出さない */
}
.container {
width: 100%;
max-width: 500px;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: relative;
z-index: 10; /* 猫より手前 */
height: 85vh; /* 画面内に収める */
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
h2 { margin: 0; color: #333; font-size: 1.5rem; }
.header-btn {
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
color: #333;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.header-btn:active { opacity: 0.8; }
#edit-toggle-btn { background-color: #6c757d; color: white; font-size: 14px; }
#edit-toggle-btn.active { background-color: #28a745; }
#random-btn { background-color: #ffc107; }
#history-btn { background-color: #6f42c1; color: white; }
/* 新設:動作ボタン */
#action-btn {
background-color: #e83e8c;
color: white;
display: none; /* 初期は非表示 */
}
.input-group {
display: flex;
margin-bottom: 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
#task-input {
flex-grow: 1;
padding: 12px 15px;
border: 1px solid #ddd;
border-right: none;
outline: none;
font-size: 16px;
border-radius: 8px 0 0 8px;
}
#add-btn {
padding: 0 20px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
font-weight: bold;
font-size: 16px;
border-radius: 0 8px 8px 0;
min-width: 60px;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
flex-grow: 1;
overflow-y: auto;
}
.todo-item {
background-color: #fff;
border-bottom: 1px solid #eee;
padding: 8px 10px;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
touch-action: pan-y;
min-height: 30px;
}
.todo-text {
flex-grow: 1;
font-size: 16px;
padding-right: 10px;
word-break: break-all;
line-height: 1.3;
}
.edit-input {
flex-grow: 1;
font-size: 16px;
padding: 4px 8px;
margin-right: 10px;
border: 2px solid #17a2b8;
border-radius: 4px;
outline: none;
width: 100px;
}
.handle, .delete-btn, .modify-btn { display: none; }
.edit-mode .handle {
display: inline-block;
color: #ccc;
margin-right: 10px;
cursor: grab;
font-size: 20px;
padding: 0 5px;
}
.btn-group { display: flex; gap: 5px; align-items: center; }
.edit-mode .modify-btn,
.edit-mode .delete-btn {
display: block;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
flex-shrink: 0;
}
.edit-mode .modify-btn { background-color: #17a2b8; }
.edit-mode .modify-btn.saving { background-color: #007bff; }
.edit-mode .delete-btn { background-color: #ff4d4d; }
.sortable-ghost { opacity: 0.4; background-color: #e3f2fd; }
.sortable-drag { background: #fff; box-shadow: 0 5px 15px rgba(0,0,0,0.15); opacity: 1; }
/* モーダル共通 */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
backdrop-filter: blur(2px);
}
.modal-content {
background-color: #fff;
padding: 20px;
border-radius: 12px;
width: 90%;
max-width: 450px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 5px 20px rgba(0,0,0,0.25);
animation: popIn 0.2s ease-out;
}
@keyframes popIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.modal-header h3 { margin: 0; font-size: 1.2rem; color: #333; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0 5px; }
/* 履歴リストなど (省略せず最低限保持) */
#history-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex-grow: 1; }
.history-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
.history-header { display: flex; justify-content: space-between; margin-bottom: 2px; }
.history-date { font-size: 11px; color: #888; }
.history-symbol { font-weight: bold; color: #6f42c1; margin-left: 10px; }
.history-text { color: #333; font-weight: 500; word-break: break-all; margin-bottom: 2px; }
.history-memo { font-size: 12px; color: #666; background: #f8f9fa; padding: 2px 6px; border-radius: 4px; display: inline-block; }
/* 削除確認モーダル用 */
.confirm-task-preview { font-weight: bold; color: #007bff; display: block; margin-bottom: 15px; background: #f8f9fa; padding: 10px; border-radius: 6px; word-break: break-all; text-align: center; }
.assessment-group { display: flex; justify-content: center; gap: 15px; margin-bottom: 15px; }
.assess-btn { width: 50px; height: 50px; border-radius: 50%; border: 2px solid #ddd; background: #fff; font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #ccc; }
.assess-btn.selected { border-color: #6f42c1; color: #6f42c1; background-color: #f3eefc; transform: scale(1.1); font-weight: bold; }
#confirm-memo-input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; margin-bottom: 20px; box-sizing: border-box; }
.confirm-main-buttons { display: flex; gap: 10px; margin-bottom: 10px; }
.confirm-btn { padding: 12px; border: none; border-radius: 8px; font-size: 14px; font-weight: bold; cursor: pointer; flex: 1; }
#btn-record-delete { background-color: #6f42c1; color: white; }
#btn-just-delete { background-color: #ff4d4d; color: white; }
#btn-cancel-delete { background-color: #e9ecef; color: #333; width: 100%; }
/* --- アクションエディタ用スタイル --- */
#action-editor-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: #f8f9fa;
z-index: 3000;
flex-direction: column;
}
.editor-section {
flex: 1;
border-bottom: 2px solid #ddd;
padding: 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.editor-section:last-child { border-bottom: none; }
.editor-label {
font-size: 12px;
color: #666;
font-weight: bold;
margin-bottom: 5px;
background: rgba(255,255,255,0.8);
padding: 2px 5px;
border-radius: 4px;
position: absolute;
top: 5px; left: 5px;
z-index: 5;
}
/* キャラ表示画面 (上段) */
#char-view-container {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
background: #e9ecef;
border-radius: 8px;
position: relative;
}
#preview-img {
width: 96px; height: 96px;
image-rendering: pixelated;
}
.char-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-left: 20px;
}
.circle-btn {
width: 40px; height: 40px;
border-radius: 50%;
border: 1px solid #999;
background: white;
font-size: 18px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.circle-btn:active { background: #ddd; }
.insert-btn { background: #fd7e14; color: white; border: none; font-weight: bold; }
/* アクション入力画面 (中段) */
.action-header-row {
display: flex;
gap: 5px;
margin-bottom: 5px;
margin-top: 20px; /* label避け */
}
#act-name-input {
flex: 1;
padding: 5px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
}
#act-content-area {
flex: 1;
resize: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px;
font-family: monospace;
font-size: 14px;
white-space: pre;
}
/* メインループ画面 (下段) */
#loop-script-area {
flex: 1;
resize: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px;
font-family: monospace;
font-size: 12px;
white-space: pre;
margin-top: 20px;
background: #2d2d2d;
color: #50fa7b; /* 簡易シンタックスハイライト風 */
}
.editor-footer {
display: flex;
gap: 10px;
margin-top: 5px;
}
.editor-cmd-btn {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 14px;
}
#btn-save-script { background: #28a745; color: white; flex: 1;}
#btn-close-editor { background: #6c757d; color: white; }
#btn-add-goto { background: #17a2b8; color: white; }
/* Neko Styling */
#neko-container {
position: fixed;
z-index: 100; /* コンテナより上、モーダルより下 */
pointer-events: auto; /* タッチ判定のため */
transition: top 0.1s linear, left 0.1s linear; /* JS制御にするためCSS遷移は短く */
}
#neko-img {
width: 100%; height: auto; display: block;
image-rendering: pixelated;
}
</style>
</head>
<body>
<div class="container" id="main-container">
<div class="header">
<div class="header-left">
<h2>My Tasks</h2>
<button id="random-btn" class="header-btn" title="ランダム">🎲</button>
<button id="history-btn" class="header-btn" title="履歴">📖</button>
<button id="action-btn" class="header-btn" title="動作設定">⚙️ 動作</button>
</div>
<button id="edit-toggle-btn" class="header-btn">編集</button>
</div>
<div class="input-group">
<input type="text" id="task-input" placeholder="タスクを入力..." autocomplete="off">
<button id="add-btn">追加</button>
</div>
<ul id="todo-list" class="todo-list"></ul>
</div>
<div id="history-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>完了・削除履歴</h3>
<button class="close-btn" id="close-history">×</button>
</div>
<ul id="history-list"></ul>
</div>
</div>
<div id="confirm-modal" class="modal">
<div class="modal-content" style="max-width: 380px;">
<div class="modal-header" style="border:none; padding-bottom:0; margin-bottom:10px;">
<h3 style="font-size:1rem; color:#666;">タスクを削除・記録</h3>
</div>
<span id="confirm-task-name" class="confirm-task-preview"></span>
<div class="assessment-group">
<button class="assess-btn" data-val="○">○</button>
<button class="assess-btn" data-val="△">△</button>
<button class="assess-btn" data-val="✕">✕</button>
</div>
<input type="text" id="confirm-memo-input" placeholder="ひとことメモ(任意)" autocomplete="off">
<div class="confirm-actions">
<div class="confirm-main-buttons">
<button id="btn-record-delete" class="confirm-btn">📖 記録して削除</button>
<button id="btn-just-delete" class="confirm-btn">🗑️ 記録せず削除</button>
</div>
<button id="btn-cancel-delete" class="confirm-btn">キャンセル</button>
</div>
</div>
</div>
<div id="action-editor-overlay">
<div class="editor-section">
<span class="editor-label">キャラクタ表示</span>
<div id="char-view-container">
<img id="preview-img" src="./pic3/neko-rstop4.png" alt="preview">
<div class="char-controls">
<button class="circle-btn" id="btn-char-up">▲</button>
<button class="circle-btn insert-btn" id="btn-insert-char">↓</button>
<button class="circle-btn" id="btn-char-down">▼</button>
</div>
</div>
</div>
<div class="editor-section">
<span class="editor-label">アクション入力</span>
<div class="action-header-row">
<input type="text" id="act-name-input" placeholder="アクション名 (例: running)">
<button class="circle-btn" id="btn-act-prev">◀</button>
<button class="circle-btn" id="btn-act-next">▶</button>
<button class="circle-btn insert-btn" id="btn-insert-act">↓</button>
</div>
<textarea id="act-content-area" placeholder="例:
stop1 10
stop2 10"></textarea>
</div>
<div class="editor-section">
<span class="editor-label">メインループ入力</span>
<textarea id="loop-script-area" spellcheck="false"></textarea>
<div class="editor-footer">
<button id="btn-add-goto" class="editor-cmd-btn">GOTO</button>
<button id="btn-save-script" class="editor-cmd-btn">保存 & 適用</button>
<button id="btn-close-editor" class="editor-cmd-btn">閉じる</button>
</div>
</div>
</div>
<div id="neko-container">
<img id="neko-img" src="./pic3/neko-rstop4.png" alt="neko">
</div>
<script>
// --- 既存のTODOアプリロジック (最小限の修正で維持) ---
const input = document.getElementById('task-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');
const editToggleBtn = document.getElementById('edit-toggle-btn');
const randomBtn = document.getElementById('random-btn');
const historyBtn = document.getElementById('history-btn');
const actionBtn = document.getElementById('action-btn'); // 追加
const container = document.getElementById('main-container');
const historyModal = document.getElementById('history-modal');
const closeHistoryBtn = document.getElementById('close-history');
const historyList = document.getElementById('history-list');
const confirmModal = document.getElementById('confirm-modal');
const confirmTaskName = document.getElementById('confirm-task-name');
const confirmMemoInput = document.getElementById('confirm-memo-input');
const assessBtns = document.querySelectorAll('.assess-btn');
const btnRecordDelete = document.getElementById('btn-record-delete');
const btnJustDelete = document.getElementById('btn-just-delete');
const btnCancelDelete = document.getElementById('btn-cancel-delete');
let pendingDeleteTarget = null;
let selectedSymbol = '';
// アプリ状態変数 (Neko Scriptからも参照される)
let appMode = 1; // 1:通常, 2:編集, 3:履歴
// --- 日時取得 ---
function getCurrentTime() {
const now = new Date();
return `${now.getFullYear()}/${(now.getMonth()+1).toString().padStart(2,'0')}/${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}
// --- 履歴関連 ---
function saveToHistory(text, symbol, memo) {
const historyData = JSON.parse(localStorage.getItem('myTodoHistory') || '[]');
historyData.unshift({ text, date: getCurrentTime(), symbol: symbol||'', memo: memo||'' });
localStorage.setItem('myTodoHistory', JSON.stringify(historyData));
}
function renderHistory() {
const historyData = JSON.parse(localStorage.getItem('myTodoHistory') || '[]');
historyList.innerHTML = historyData.length ? '' : '<li style="text-align:center;padding:20px;color:#999">履歴なし</li>';
historyData.forEach(item => {
const li = document.createElement('li');
li.className = 'history-item';
const sym = item.symbol ? `<span class="history-symbol">${item.symbol}</span>` : '';
const mem = item.memo ? `<br><span class="history-memo">${item.memo}</span>` : '';
li.innerHTML = `<div class="history-header"><span class="history-date">${item.date}</span>${sym}</div><div class="history-text">${item.text}</div>${mem}`;
historyList.appendChild(li);
});
}
historyBtn.addEventListener('click', () => {
appMode = 3;
renderHistory();
historyModal.style.display = 'flex';
});
closeHistoryBtn.addEventListener('click', () => {
appMode = 1;
historyModal.style.display = 'none';
});
// --- 削除確認関連 ---
assessBtns.forEach(btn => btn.addEventListener('click', () => {
assessBtns.forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedSymbol = btn.dataset.val;
}));
function executeDelete(record) {
if(!pendingDeleteTarget) return;
if(record) saveToHistory(confirmTaskName.textContent, selectedSymbol, confirmMemoInput.value);
pendingDeleteTarget.remove();
saveTodos();
confirmModal.style.display = 'none';
pendingDeleteTarget = null;
}
btnRecordDelete.addEventListener('click', () => executeDelete(true));
btnJustDelete.addEventListener('click', () => executeDelete(false));
btnCancelDelete.addEventListener('click', () => { confirmModal.style.display='none'; pendingDeleteTarget=null; });
// --- TODOリスト制御 ---
function saveTodos() {
const todos = [];
document.querySelectorAll('.todo-item').forEach(li => {
const val = li.querySelector('.edit-input')?.value || li.querySelector('.todo-text').textContent;
todos.push(val);
});
localStorage.setItem('myTodoList', JSON.stringify(todos));
}
function loadTodos() {
const saved = localStorage.getItem('myTodoList');
const todos = saved ? JSON.parse(saved) : ['タスク1', 'タスク2'];
list.innerHTML = '';
todos.forEach(t => addTodo(t, false));
}
let sortable = new Sortable(list, { animation: 150, handle: '.handle', disabled: true, onEnd: saveTodos });
// --- モード切替 ---
editToggleBtn.addEventListener('click', () => {
const isEdit = container.classList.toggle('edit-mode');
appMode = isEdit ? 2 : 1;
if (isEdit) {
editToggleBtn.textContent = '完了';
editToggleBtn.classList.add('active');
sortable.option('disabled', false);
// ボタンの出し分け
randomBtn.style.display = 'none';
historyBtn.style.display = 'none';
actionBtn.style.display = 'flex';
} else {
document.querySelectorAll('.modify-btn.saving').forEach(b => b.click());
editToggleBtn.textContent = '編集';
editToggleBtn.classList.remove('active');
sortable.option('disabled', true);
// ボタン戻し
randomBtn.style.display = 'flex';
historyBtn.style.display = 'flex';
actionBtn.style.display = 'none';
}
});
addBtn.addEventListener('click', () => { if(input.value.trim()){ addTodo(input.value.trim()); input.value=''; } });
input.addEventListener('keypress', (e) => { if(e.key==='Enter') addBtn.click(); });
randomBtn.addEventListener('click', () => {
const items = Array.from(list.children);
if(items.length < 2) return;
for(let i=items.length-1; i>0; i--){
const j=Math.floor(Math.random()*(i+1));
list.appendChild(items[j]); // append moves element
}
saveTodos();
});
function addTodo(text, save=true) {
const li = document.createElement('li');
li.className = 'todo-item';
li.innerHTML = `
<span class="handle">≡</span><span class="todo-text">${text}</span>
<div class="btn-group"><button class="modify-btn">修正</button><button class="delete-btn">削除</button></div>`;
const textSpan = li.querySelector('.todo-text');
const modBtn = li.querySelector('.modify-btn');
modBtn.addEventListener('click', () => {
if(modBtn.classList.contains('saving')) {
const inp = li.querySelector('.edit-input');
textSpan.textContent = inp.value;
li.replaceChild(textSpan, inp);
modBtn.textContent = '修正';
modBtn.classList.remove('saving');
saveTodos();
} else {
const inp = document.createElement('input');
inp.className = 'edit-input';
inp.value = textSpan.textContent;
li.replaceChild(inp, textSpan);
modBtn.textContent = '保存';
modBtn.classList.add('saving');
}
});
li.querySelector('.delete-btn').addEventListener('click', (e) => {
e.stopPropagation();
pendingDeleteTarget = li;
confirmTaskName.textContent = li.querySelector('.edit-input')?.value || textSpan.textContent;
confirmMemoInput.value = '';
assessBtns.forEach(b => b.classList.remove('selected'));
selectedSymbol='';
confirmModal.style.display = 'flex';
});
list.appendChild(li);
if(save) saveTodos();
}
loadTodos();
/* =================================================================
★★★ Neko Script Engine & Editor Logic ★★★
================================================================= */
// --- 定数・初期データ ---
const NEKO_PATH = './pic3/';
// 画像ファイルの候補 (R/Lは自動付与)
const NEKO_SPRITES = [
'stop1', 'stop2', 'stop3', 'stop4',
'run1', 'run2', 'run3', 'run4',
'sit1', 'sit2',
'jump1', 'jump2',
'sleep1', 'sleep2',
'kaki1', 'kaki2'
];
// デフォルトのアクションデータ
const DEFAULT_ACTIONS = {
"stop": "stop1 20\nstop2 20\nstop3 20\nstop4 20",
"run": "run1 10\nrun2 10\nrun3 10\nrun4 10",
"sit": "sit1 30\nsit2 30",
"sleep": "sleep1 50\nsleep2 50",
"jump": "jump1 10\njump2 10"
};
// デフォルトのスクリプト
const DEFAULT_SCRIPT = `
:MAIN
RANDOM 3
R=1
neko-stop 5
GOTO MOVE
R=2
neko-sit 4
GOTO MAIN
R=3
neko-sleep 5
GOTO MAIN
:MOVE
neko-run 4
GOTO CHECK
:CHECK
R=1 AND T=1
neko-jump 2
TC
GOTO MAIN
S>0
neko-run 2
GOTO MAIN
GOTO MAIN
`.trim();
// --- 変数管理 ---
let actions = JSON.parse(localStorage.getItem('neko_actions')) || JSON.parse(JSON.stringify(DEFAULT_ACTIONS));
let mainScript = localStorage.getItem('neko_script') || DEFAULT_SCRIPT;
// --- エディタUI制御 ---
const editorOverlay = document.getElementById('action-editor-overlay');
const previewImg = document.getElementById('preview-img');
const actNameInput = document.getElementById('act-name-input');
const actContentArea = document.getElementById('act-content-area');
const loopScriptArea = document.getElementById('loop-script-area');
let spriteIdx = 0;
// 動作ボタンでエディタを開く
actionBtn.addEventListener('click', () => {
editorOverlay.style.display = 'flex';
// ロード
actions = JSON.parse(localStorage.getItem('neko_actions')) || actions;
mainScript = localStorage.getItem('neko_script') || mainScript;
// 初期表示セット
updatePreview();
const firstAct = Object.keys(actions)[0] || "new_action";
actNameInput.value = firstAct;
actContentArea.value = actions[firstAct] || "";
loopScriptArea.value = mainScript;
// エディタ起動時は猫エンジンを一時停止しても良いが、プレビュー用にそのまま動かす
});
document.getElementById('btn-close-editor').addEventListener('click', () => {
editorOverlay.style.display = 'none';
// 閉じるだけで保存しない(保存ボタンを押させる)
});
// キャラクタ表示部
function updatePreview() {
const name = NEKO_SPRITES[spriteIdx];
previewImg.src = `${NEKO_PATH}neko-r${name}.png`;
}
document.getElementById('btn-char-up').addEventListener('click', () => {
spriteIdx = (spriteIdx - 1 + NEKO_SPRITES.length) % NEKO_SPRITES.length;
updatePreview();
});
document.getElementById('btn-char-down').addEventListener('click', () => {
spriteIdx = (spriteIdx + 1) % NEKO_SPRITES.length;
updatePreview();
});
document.getElementById('btn-insert-char').addEventListener('click', () => {
// 現在の画像をアクション入力へ挿入
const txt = NEKO_SPRITES[spriteIdx] + " 10"; // デフォルト時間10
insertAtCursor(actContentArea, txt + "\n");
});
// アクション入力部
document.getElementById('btn-act-prev').addEventListener('click', () => cycleAction(-1));
document.getElementById('btn-act-next').addEventListener('click', () => cycleAction(1));
function cycleAction(dir) {
const keys = Object.keys(actions);
if(keys.length === 0) return;
let curr = keys.indexOf(actNameInput.value);
if(curr === -1) curr = 0;
else curr = (curr + dir + keys.length) % keys.length;
// 現在の内容を保存してから切り替えるか? (今回は保存ボタン依存にする)
// actions[actNameInput.value] = actContentArea.value; // 自動保存する場合
actNameInput.value = keys[curr];
actContentArea.value = actions[keys[curr]];
}
document.getElementById('btn-insert-act').addEventListener('click', () => {
// 現在のアクション名をメインループへ挿入
const name = "neko-" + actNameInput.value.trim();
insertAtCursor(loopScriptArea, name + " 3\n"); // デフォルト秒数3
});
// メインループ部
document.getElementById('btn-add-goto').addEventListener('click', () => {
insertAtCursor(loopScriptArea, "GOTO LABEL\n");
});
document.getElementById('btn-save-script').addEventListener('click', () => {
// アクションの保存(現在編集中のものも含める)
if(actNameInput.value) {
actions[actNameInput.value] = actContentArea.value;
}
mainScript = loopScriptArea.value;
localStorage.setItem('neko_actions', JSON.stringify(actions));
localStorage.setItem('neko_script', mainScript);
alert('保存しました。リロードします。');
nekoVM.restart();
});
// テキスト挿入ヘルパー
function insertAtCursor(field, text) {
const start = field.selectionStart || field.value.length;
const end = field.selectionEnd || field.value.length;
field.value = field.value.substring(0, start) + text + field.value.substring(end);
field.focus();
field.selectionStart = field.selectionEnd = start + text.length;
}
/* --- Neko Virtual Machine (VM) --- */
class NekoVM {
constructor() {
this.nekoImg = document.getElementById('neko-img');
this.container = document.getElementById('neko-container');
this.size = 96;
this.running = false;
// VM State
this.vars = {
R: 0, // Random
S: 0, // Todos count
M: 1, // Mode
T: 0 // Touch
};
this.currentLine = 0;
this.lines = [];
this.direction = 'r'; // 'r' or 'l'
this.posX = window.innerWidth/2;
this.posY = window.innerHeight/2;
this.targetX = this.posX;
this.targetY = this.posY;
// タッチイベント
this.container.addEventListener('click', () => {
this.vars.T = 1;
console.log("Neko Touched!");
});
this.init();
}
init() {
this.container.style.width = this.size + 'px';
this.container.style.height = this.size + 'px';
this.updatePos();
this.restart();
}
updatePos() {
this.container.style.left = this.posX + 'px';
this.container.style.top = this.posY + 'px';
}
restart() {
this.running = false;
// 変数リロード
this.lines = mainScript.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//'));
// ラベルマップ作成
this.labels = {};
this.lines.forEach((line, idx) => {
if(line.startsWith(':')) {
this.labels[line.substring(1)] = idx;
}
});
this.currentLine = 0;
this.vars.T = 0;
setTimeout(() => {
this.running = true;
this.loop();
}, 500);
}
async loop() {
if(!this.running) return;
// 外部変数の更新
this.vars.S = document.querySelectorAll('.todo-item').length;
this.vars.M = appMode;
// スクリプト終了
if(this.currentLine >= this.lines.length) {
this.currentLine = 0; // ループの先頭へ戻るか、停止するか。ここでは先頭へ。
}
const line = this.lines[this.currentLine];
this.currentLine++; // 先に進めておく
// 解析
if(line.startsWith(':')) {
// Label: Do nothing, already handled
this.loop();
return;
}
// GOTO
if(line.startsWith('GOTO')) {
const label = line.split(/\s+/)[1];
if(this.labels[label] !== undefined) {
this.currentLine = this.labels[label];
}
this.loop();
return;
}
// RANDOM
if(line.startsWith('RANDOM')) {
const max = parseInt(line.split(/\s+/)[1]) || 1;
this.vars.R = Math.floor(Math.random() * max) + 1;
this.loop();
return;
}
// TC (Touch Clear)
if(line === 'TC') {
this.vars.T = 0;
this.loop();
return;
}
// Conditions (R=1, etc)
// 単純化のため、行に "=" や ">" "<" "AND" が含まれていたら条件式とみなす
if(line.includes('=') || line.includes('>') || line.includes('<')) {
if(this.evaluateCondition(line)) {
// True: 次の行へ (何もしなくていい)
this.loop();
} else {
// False: 次の GOTO または Label または 別の条件式 までスキップ
this.skipBlock();
this.loop();
}
return;
}
// Action (neko-xxx duration)
if(line.startsWith('neko-')) {
const parts = line.split(/\s+/);
const actName = parts[0].replace('neko-', '');
const durationSec = parseFloat(parts[1]) || 1;
await this.playAction(actName, durationSec);
this.loop();
return;
}
// 未知のコマンドは無視
this.loop();
}
skipBlock() {
// 条件不成立時、次の有効なブロック(Label)または 別の条件式が始まるまで読み飛ばす
// プロンプトの仕様上、"R=1 ... GOTO" のセットを一塊とするなら、
// 次の条件式かラベルが見つかるまで飛ばすのが妥当。
while(this.currentLine < this.lines.length) {
const next = this.lines[this.currentLine];
if(next.startsWith(':')) break;
// 次の行も条件式ならそこで止まる(ELSIF的な挙動)
if(next.includes('=') || next.includes('>') || next.includes('<')) break;
this.currentLine++;
}
}
evaluateCondition(condStr) {
// "R=1 AND S=0" -> JSの評価式に変換
// 注意: 安全性のため eval は使わず簡易パーサを作るべきだが、
// 今回はデモのため replace で JS構文にして Function で実行する
let jsExpr = condStr
.replace(/AND/g, '&&')
.replace(/OR/g, '||')
.replace(/=/g, '===') // "=" -> "==="
.replace(/<>/g, '!==');
// 変数置換
const v = this.vars;
// 変数名を値に置換 (R, S, M, T)
jsExpr = jsExpr.replace(/\bR\b/g, v.R);
jsExpr = jsExpr.replace(/\bS\b/g, v.S);
jsExpr = jsExpr.replace(/\bM\b/g, v.M);
jsExpr = jsExpr.replace(/\bT\b/g, v.T);
try {
return new Function('return ' + jsExpr)();
} catch(e) {
console.error("Cond Error:", condStr, e);
return false;
}
}
async playAction(actName, durationSec) {
// アクション定義を取得
const defRaw = actions[actName];
if(!defRaw) {
console.warn("No action def:", actName);
return; // 定義なし即終了
}
// 定義パース (line: "stop1 10")
const frames = defRaw.split('\n').map(l => {
const p = l.trim().split(/\s+/);
return { sprite: p[0], time: parseInt(p[1]) || 10 };
}).filter(f => f.sprite);
if(frames.length === 0) return;
// 移動計算 (run系のアクションなら座標を動かすなど)
// 簡易的に "run" が名前に含まれていたらランダム移動させる
let isMoving = actName.includes('run') || actName.includes('walk');
let moveStepX = 0;
let moveStepY = 0;
if(isMoving) {
const maxX = window.innerWidth - this.size;
const maxY = window.innerHeight - this.size;
const destX = Math.random() * maxX;
const destY = Math.random() * maxY;
// 方向決定
this.direction = destX > this.posX ? 'r' : 'l';
// Duration秒で移動するための1フレームあたりのステップなど計算が必要だが
// 簡易実装として CSS transition を使う手もあるが、ここではJSで刻む
// 総所要時間(ms)
const totalTime = durationSec * 1000;
moveStepX = (destX - this.posX) / (totalTime / 100); // 100ms更新と仮定
moveStepY = (destY - this.posY) / (totalTime / 100);
}
// アニメーションループ
const startTime = Date.now();
const endTime = startTime + (durationSec * 1000);
let fIdx = 0;
let nextFrameTime = 0;
return new Promise(resolve => {
const frameLoop = () => {
if(!this.running) { resolve(); return; }
const now = Date.now();
if(now >= endTime) {
resolve();
return;
}
// フレーム更新
if(now >= nextFrameTime) {
const frame = frames[fIdx];
// 画像更新
this.nekoImg.src = `${NEKO_PATH}neko-${this.direction}${frame.sprite}.png`;
// 次のフレームタイミング設定 (frame.time は多分 10ms単位などを想定しているが、ここでは 10ms単位とする)
nextFrameTime = now + (frame.time * 10);
fIdx = (fIdx + 1) % frames.length;
}
// 移動更新
if(isMoving) {
this.posX += moveStepX * 1.5; // 速度調整
this.posY += moveStepY * 1.5;
// 画面端制限
this.posX = Math.max(0, Math.min(window.innerWidth - this.size, this.posX));
this.posY = Math.max(0, Math.min(window.innerHeight - this.size, this.posY));
this.updatePos();
}
requestAnimationFrame(frameLoop);
};
frameLoop();
});
}
}
// VM起動
const nekoVM = new NekoVM();
</script>
</body>
</html>
使用変数
| ' | |
| * | |
| actContentArea | |
| actionBtn | |
| actions | |
| actName | |
| actNameInput | |
| addBtn | |
| addTodo -------( Function ) | |
| alt | |
| appMode | |
| assessBtns | |
| autocomplete | |
| b | |
| btn | |
| btnCancelDelete | |
| btnJustDelete | |
| btnRecordDelete | |
| charset | |
| class | |
| className | |
| closeHistoryBtn | |
| confirmMemoInput | |
| confirmModal | |
| confirmTaskName | |
| container | |
| content | |
| curr | |
| currentLine | |
| cycleAction -------( Function ) | |
| DEFAULT_ACTIONS | |
| DEFAULT_SCRIPT | |
| defRaw | |
| destX | |
| destY | |
| direction | |
| display | |
| durationSec | |
| editorOverlay | |
| editToggleBtn | |
| end | |
| endingDeleteTarget | |
| endTime | |
| executeDelete -------( Function ) | |
| f | |
| fIdx | |
| firstAct | |
| frame | |
| frameLoop | |
| frames | |
| getCurrentTime -------( Function ) | |
| height | |
| historyBtn | |
| historyData | |
| historyList | |
| historyModal | |
| i | |
| id | |
| innerHTML | |
| inp | |
| input | |
| insertAtCursor -------( Function ) | |
| isEdit | |
| isMoving | |
| item | |
| items | |
| j | |
| jsExpr | |
| key | |
| keys | |
| l | |
| label | |
| labels | |
| lang | |
| left | |
| length | |
| li | |
| line | |
| lines | |
| list | |
| loadTodos -------( Function ) | |
| loopScriptArea | |
| M | |
| mainScript | |
| max | |
| maxX | |
| maxY | |
| mem | |
| modBtn | |
| moveStepX | |
| moveStepY | |
| name | |
| nekoImg | |
| nekoVM | |
| NEKO_PATH | |
| NEKO_SPRITES | |
| next | |
| nextFrameTime | |
| now | |
| p | |
| parts | |
| pendingDeleteTarget | |
| placeholder | |
| posX | |
| posY | |
| previewImg | |
| R | |
| randomBtn | |
| renderHistory -------( Function ) | |
| resolve | |
| running | |
| S | |
| save | |
| saved | |
| saveTodos -------( Function ) | |
| saveToHistory -------( Function ) | |
| scalable | |
| scale | |
| selectedSymbol | |
| selectionEnd | |
| selectionStart | |
| size | |
| sortable | |
| spellcheck | |
| spriteIdx | |
| src | |
| start | |
| startTime | |
| style | |
| sym | |
| t | |
| T | |
| targetX | |
| targetY | |
| textContent | |
| textSpan | |
| title | |
| todos | |
| top | |
| totalTime | |
| txt | |
| type | |
| updatePreview -------( Function ) | |
| v | |
| val | |
| value | |
| vars | |
| width |