Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
e68b299d0This file is pinned to the selected release. Download the original to open it in your design tool.
import { registerStackchanBlocks, TOOLBOX, generateModSource } from './blocks.mjs'
import { buildModArchive, isXsArchive, manifestForProjectAssets, xsArchiveVersion } from './mod-builder.mjs'
import {
createEsptoolLoader,
DEVICE_OPERATION_STATUS,
installModToDevice,
removeModFromDevice,
} from './esptool-installer.mjs'
import { createModStorage, formatByteSize } from '../simulator/mod-storage.mjs'
import { DEVICE_PROFILES, inspectDeploymentCompatibility, profileFor, toolboxForTarget } from './capabilities.mjs'
import {
createVisualProject,
assetBytes,
MAX_ASSET_BYTES,
MAX_ASSET_COUNT,
MAX_PROJECT_JSON_BYTES,
parseVisualProject,
projectFileName,
serializeVisualProject,
} from './project-format.mjs'
import { analyzeWorkspace } from './project-validator.mjs'
import { addFaceAssetToProject, FACE_ASSET_MEDIA_TYPE, applyFaceAssetToSource, parseFaceAsset } from './face-assets.mjs'
import { parseVisualTrace } from './runtime-diagnostics.mjs'
import {
createRecoveryRecord,
duplicateVisualProject,
parseProjectLibrary,
updateProjectLibrary,
} from './project-library.mjs'
import { createProjectStorage } from './project-storage.mjs'
import { VISUAL_SAMPLES, sampleById } from './samples.mjs'
import {
clearFaceEditContext,
clearStagedFaceTransfer,
loadStagedFaceTransfer,
saveFaceEditContext,
} from '../face-editor/face-editor-storage.mjs'
import createTools from './vendor/tools.js'
const WORKSPACE_STORAGE_KEY = 'stackchan-blockly-workspace'
const PROJECT_STORAGE_KEY = 'stackchan-visual-project-v1'
const PROJECT_LIBRARY_KEY = 'stackchan-visual-project-library-v1'
const PROJECT_RECOVERY_KEY = 'stackchan-visual-project-recovery-v1'
const METRICS_STORAGE_KEY = 'stackchan-visual-metrics-v1'
const Blockly = globalThis.Blockly
const javascriptGenerator = globalThis.javascript?.javascriptGenerator ?? Blockly?.JavaScript
const Order = globalThis.javascript?.Order ?? { NONE: 99, FUNCTION_CALL: 2 }
const workspaceHost = document.getElementById('blockly-workspace')
const codePreview = document.getElementById('code-preview')
const buildButton = document.getElementById('build-button')
const downloadButton = document.getElementById('download-button')
const installSimulatorButton = document.getElementById('install-simulator-button')
const installDeviceButton = document.getElementById('install-device-button')
const buildStatus = document.getElementById('build-status')
const installProgress = document.getElementById('install-progress')
const sampleButton = document.getElementById('sample-button')
const clearButton = document.getElementById('clear-button')
const logOutput = document.getElementById('log-output')
const outputTabs = [...document.querySelectorAll('.output-tabs [role="tab"]')]
const projectNameInput = document.getElementById('project-name')
const projectNameDisplay = document.getElementById('project-name-display')
const projectNameLabel = document.getElementById('project-name-label')
const targetDeviceSelect = document.getElementById('target-device')
const importButton = document.getElementById('import-button')
const exportButton = document.getElementById('export-button')
const projectFileInput = document.getElementById('project-file-input')
const capabilitySummary = document.getElementById('capability-summary')
const diagnosticsList = document.getElementById('diagnostics-list')
const diagnosticsCount = document.getElementById('diagnostics-count')
const assetButton = document.getElementById('asset-button')
const assetFileInput = document.getElementById('asset-file-input')
const assetSummary = document.getElementById('asset-summary')
const faceSelectionButton = document.getElementById('face-selection-button')
const faceSelectionLabel = document.getElementById('face-selection-label')
const faceSelectionMenu = document.getElementById('face-selection-menu')
const faceSelectionList = document.getElementById('face-selection-list')
const editFaceButton = document.getElementById('edit-face-button')
const faceEditorNavigationLink = document.querySelector('.tool-drawer-link[data-tool-id="face-editor"]')
const embedAssetsInput = document.getElementById('embed-assets')
const simulatorDialog = document.getElementById('simulator-dialog')
const simulatorFrame = document.getElementById('simulator-frame')
const simulatorRestart = document.getElementById('simulator-restart')
const simulatorStop = document.getElementById('simulator-stop')
const simulatorClose = document.getElementById('simulator-close')
const removeDeviceButton = document.getElementById('remove-device-button')
const newProjectButton = document.getElementById('new-project-button')
const duplicateProjectButton = document.getElementById('duplicate-project-button')
const recentProjectsControl = document.getElementById('recent-projects-control')
const recentProjectsButton = document.getElementById('recent-projects-button')
const recentProjectsSubmenu = document.getElementById('recent-projects-submenu')
const recentProjectsList = document.getElementById('recent-projects-list')
const recoveryButton = document.getElementById('recovery-button')
const sampleDialog = document.getElementById('sample-dialog')
const sampleList = document.getElementById('sample-list')
const sampleClose = document.getElementById('sample-close')
const projectMenuButton = document.getElementById('project-menu-button')
const projectMenu = document.getElementById('project-menu')
const mobileTargetDeviceSelect = document.getElementById('mobile-target-device')
const clearWorkspaceDialog = document.getElementById('clear-workspace-dialog')
const clearWorkspaceProjectName = document.getElementById('clear-workspace-project-name')
const clearWorkspaceConfirm = document.getElementById('clear-workspace-confirm')
function selectOutputTab(tab) {
for (const candidate of outputTabs) {
const selected = candidate === tab
candidate.setAttribute('aria-selected', String(selected))
candidate.tabIndex = selected ? 0 : -1
const panel = document.getElementById(candidate.getAttribute('aria-controls'))
if (panel != null) panel.hidden = !selected
}
}
for (const tab of outputTabs) {
tab.addEventListener('click', () => selectOutputTab(tab))
tab.addEventListener('keydown', (event) => {
const index = outputTabs.indexOf(tab)
let nextIndex
if (event.key === 'ArrowRight') nextIndex = (index + 1) % outputTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + outputTabs.length) % outputTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = outputTabs.length - 1
else return
event.preventDefault()
outputTabs[nextIndex].focus()
selectOutputTab(outputTabs[nextIndex])
})
}
selectOutputTab(outputTabs.find((tab) => tab.getAttribute('aria-selected') === 'true') ?? outputTabs[0])
const logLines = []
function log(text) {
logLines.push(String(text))
if (logLines.length > 400) logLines.splice(0, logLines.length - 400)
logOutput.textContent = logLines.join('\n')
logOutput.scrollTop = logOutput.scrollHeight
}
function setStatus(text) {
buildStatus.textContent = text
}
const SAMPLE_WORKSPACE = {
blocks: {
languageVersion: 0,
blocks: [
{
type: 'stackchan_on_start',
x: 24,
y: 24,
inputs: {
DO: {
block: {
type: 'stackchan_set_emotion',
fields: { EMOTION: 'HAPPY' },
next: {
block: {
type: 'stackchan_show_balloon',
inputs: { TEXT: { shadow: { type: 'text', fields: { TEXT: 'ブロックから こんにちは!' } } } },
},
},
},
},
},
},
{
type: 'stackchan_on_button',
x: 24,
y: 240,
fields: { BUTTON: 'a' },
inputs: {
DO: {
block: {
type: 'stackchan_say',
inputs: { TEXT: { shadow: { type: 'text', fields: { TEXT: 'ぼく スタックチャン!' } } } },
},
},
},
},
{
type: 'stackchan_every',
x: 320,
y: 24,
fields: { SECONDS: 10 },
inputs: {
DO: {
block: {
type: 'stackchan_look_at',
fields: { X: 1, Y: 0.5, Z: 0 },
next: {
block: {
type: 'stackchan_wait',
fields: { DURATION: 2000 },
next: { block: { type: 'stackchan_look_away' } },
},
},
},
},
},
},
],
},
}
for (const [value, profile] of Object.entries(DEVICE_PROFILES)) {
const option = document.createElement('option')
option.value = value
option.textContent = profile.label
targetDeviceSelect.append(option)
}
if (!Blockly || !javascriptGenerator) {
setStatus('Blocklyを読み込めませんでした。ネットワーク接続を確認してください。')
throw new Error('Blockly failed to load')
}
registerStackchanBlocks(Blockly, javascriptGenerator, Order)
const workspace = Blockly.inject(workspaceHost, {
toolbox: TOOLBOX,
renderer: 'zelos',
grid: { spacing: 24, length: 2, colour: '#e4ddcf', snap: true },
zoom: { controls: true, wheel: true, startScale: 0.9 },
trashcan: true,
})
function loadWorkspace(state) {
Blockly.serialization.workspaces.load(state, workspace)
}
let currentProject
const projectStorage = createProjectStorage()
let storedProjectState = null
try {
storedProjectState = await projectStorage.loadState()
} catch (error) {
log(`IndexedDBのプロジェクトを復元できませんでした: ${error.message}`)
}
let projectLibrary = storedProjectState?.projects ?? parseProjectLibrary(localStorage.getItem(PROJECT_LIBRARY_KEY))
let recoveryRecord = null
try {
recoveryRecord = await projectStorage.loadRecovery()
if (!recoveryRecord) recoveryRecord = JSON.parse(localStorage.getItem(PROJECT_RECOVERY_KEY) ?? 'null')
} catch (error) {
log(`復旧データを読み込めませんでした: ${error.message}`)
}
const savedProject = localStorage.getItem(PROJECT_STORAGE_KEY)
try {
const savedWorkspace = localStorage.getItem(WORKSPACE_STORAGE_KEY)
currentProject =
storedProjectState?.currentProject ??
(savedProject
? parseVisualProject(savedProject)
: createVisualProject({ workspace: savedWorkspace ? JSON.parse(savedWorkspace) : SAMPLE_WORKSPACE }))
loadWorkspace(currentProject.workspace)
} catch (error) {
log(`ワークスペースの復元に失敗しました: ${error.message}`)
if (savedProject) {
recoveryRecord = createRecoveryRecord(savedProject, error)
void projectStorage.saveRecovery(recoveryRecord).catch((storageError) => {
log(`復旧データを保存できませんでした: ${storageError.message}`)
})
}
currentProject = createVisualProject({ workspace: SAMPLE_WORKSPACE })
loadWorkspace(SAMPLE_WORKSPACE)
}
projectNameInput.value = currentProject.name
renderProjectName()
targetDeviceSelect.value = DEVICE_PROFILES[currentProject.target] ? currentProject.target : 'portable'
currentProject.target = targetDeviceSelect.value
embedAssetsInput.checked = currentProject.settings.embedAssets
workspace.updateToolbox(toolboxForTarget(TOOLBOX, targetDeviceSelect.value))
let currentSource = ''
let currentArchive = null
let currentAnalysis
let currentGenerationError = null
let simulatorMetricPending = false
let deviceOperationPending = false
function refreshDeviceActionButtons() {
const available =
!deviceOperationPending && 'serial' in navigator && profileFor(targetDeviceSelect.value).deviceInstall
installDeviceButton.disabled = !available || !currentArchive
removeDeviceButton.disabled = !available
}
function workspaceState() {
return Blockly.serialization.workspaces.save(workspace)
}
let projectSave = Promise.resolve(true)
function queueProjectSave() {
const snapshot = structuredClone({ currentProject, projects: projectLibrary })
projectSave = projectSave.then(async () => {
try {
await projectStorage.saveState(snapshot)
return true
} catch (error) {
log(`プロジェクトを自動保存できませんでした: ${error.message}`)
setStatus('自動保存に失敗しました。プロジェクトを書き出してください。')
return false
}
})
return projectSave
}
function persistProject(project = currentProject) {
const nextProject = createVisualProject({
...project,
name: projectNameInput.value.trim() || 'はじめてのMOD',
target: targetDeviceSelect.value,
workspace: workspaceState(),
settings: { ...project.settings, embedAssets: embedAssetsInput.checked },
updatedAt: new Date().toISOString(),
})
const nextProjectLibrary = updateProjectLibrary(projectLibrary, nextProject)
currentProject = nextProject
projectLibrary = nextProjectLibrary
projectNameInput.value = nextProject.name
queueProjectSave()
renderProjectName()
renderRecentProjects()
return currentProject
}
function renderRecentProjects() {
const projects = projectLibrary.some((project) => project.id === currentProject?.id)
? projectLibrary
: [currentProject, ...projectLibrary].filter(Boolean)
recentProjectsList.replaceChildren()
recentProjectsButton.disabled = projects.length === 0
if (projects.length === 0) {
const empty = document.createElement('p')
empty.className = 'recent-projects-empty'
empty.textContent = '最近開いたプロジェクトはありません'
recentProjectsList.append(empty)
setRecentProjectsSubmenuOpen(false)
return
}
for (const project of projects) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'recent-project-item'
button.dataset.projectId = project.id
button.setAttribute('role', 'menuitem')
if (project.id === currentProject.id) button.setAttribute('aria-current', 'true')
const copy = document.createElement('span')
copy.className = 'recent-project-item-copy'
const name = document.createElement('strong')
name.textContent = project.name
const detail = document.createElement('small')
const updatedAt = new Date(project.updatedAt).toLocaleString('ja-JP')
detail.textContent = project.id === currentProject.id ? `現在開いています · ${updatedAt}` : updatedAt
copy.append(name, detail)
button.append(copy)
button.addEventListener('click', () => {
setProjectMenuOpen(false)
activateProject(project, `「${project.name}」を開きました`)
})
recentProjectsList.append(button)
}
}
function copySelectOptions(source, target) {
target.replaceChildren(...[...source.options].map((option) => option.cloneNode(true)))
target.value = source.value
}
function renderProjectName() {
const name = projectNameInput.value.trim() || currentProject?.name || 'はじめてのMOD'
projectNameLabel.textContent = name
projectNameDisplay.title = name
projectNameDisplay.setAttribute('aria-label', `プロジェクト名「${name}」を編集`)
}
function startProjectNameEdit() {
projectNameInput.value = currentProject.name
projectNameDisplay.hidden = true
projectNameInput.hidden = false
projectNameInput.focus()
projectNameInput.select()
}
function finishProjectNameEdit({ cancel = false, focusDisplay = false } = {}) {
if (projectNameInput.hidden) return
projectNameInput.value = cancel ? currentProject.name : projectNameInput.value.trim() || 'はじめてのMOD'
if (!cancel) persistProject()
projectNameInput.hidden = true
projectNameDisplay.hidden = false
renderProjectName()
if (focusDisplay) projectNameDisplay.focus()
}
function visibleProjectMenuItems() {
return [recentProjectsButton, ...projectMenu.querySelectorAll('.project-menu-actions > [role="menuitem"]')].filter(
(item) => !item.hidden && !item.disabled && item.getClientRects().length > 0
)
}
function visibleRecentProjectItems() {
return [...recentProjectsList.querySelectorAll('[role="menuitem"]')].filter(
(item) => !item.hidden && item.getClientRects().length > 0
)
}
function setRecentProjectsSubmenuOpen(open, { focusItem = false } = {}) {
const nextOpen = Boolean(open && !recentProjectsButton.disabled && !projectMenu.hidden)
recentProjectsSubmenu.hidden = !nextOpen
recentProjectsButton.setAttribute('aria-expanded', String(nextOpen))
if (nextOpen && focusItem) requestAnimationFrame(() => visibleRecentProjectItems()[0]?.focus())
}
function setProjectMenuOpen(open, { focusItem = false } = {}) {
if (open) copySelectOptions(targetDeviceSelect, mobileTargetDeviceSelect)
else setRecentProjectsSubmenuOpen(false)
projectMenu.hidden = !open
projectMenuButton.setAttribute('aria-expanded', String(open))
if (open && focusItem) requestAnimationFrame(() => visibleProjectMenuItems()[0]?.focus())
}
projectNameDisplay.addEventListener('click', startProjectNameEdit)
projectNameInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
finishProjectNameEdit({ focusDisplay: true })
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
finishProjectNameEdit({ cancel: true, focusDisplay: true })
}
})
projectNameInput.addEventListener('blur', () => finishProjectNameEdit())
projectMenuButton.addEventListener('click', (event) => {
event.stopPropagation()
setProjectMenuOpen(projectMenu.hidden, { focusItem: projectMenu.hidden })
})
projectMenu.addEventListener('click', (event) => {
const menuItem = event.target.closest('[role="menuitem"]')
if (menuItem && menuItem !== recentProjectsButton) setProjectMenuOpen(false)
})
projectMenu.addEventListener('keydown', (event) => {
const items = visibleProjectMenuItems()
const index = items.indexOf(document.activeElement)
if (index < 0) return
let nextIndex
if (event.key === 'ArrowDown') nextIndex = (index + 1) % items.length
else if (event.key === 'ArrowUp') nextIndex = (index - 1 + items.length) % items.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = items.length - 1
else return
event.preventDefault()
items[nextIndex]?.focus()
})
recentProjectsControl.addEventListener('mouseenter', () => {
if (innerWidth > 760 && matchMedia('(hover: hover)').matches) setRecentProjectsSubmenuOpen(true)
})
recentProjectsControl.addEventListener('mouseleave', () => {
if (innerWidth > 760 && matchMedia('(hover: hover)').matches) setRecentProjectsSubmenuOpen(false)
})
recentProjectsControl.addEventListener('focusout', (event) => {
if (innerWidth > 760 && !recentProjectsControl.contains(event.relatedTarget)) {
setRecentProjectsSubmenuOpen(false)
}
})
recentProjectsButton.addEventListener('click', (event) => {
const shouldToggle = innerWidth <= 760 || event.detail === 0
setRecentProjectsSubmenuOpen(shouldToggle ? recentProjectsSubmenu.hidden : true)
})
recentProjectsButton.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
event.preventDefault()
event.stopPropagation()
setRecentProjectsSubmenuOpen(true, { focusItem: true })
})
recentProjectsSubmenu.addEventListener('keydown', (event) => {
const items = visibleRecentProjectItems()
const index = items.indexOf(document.activeElement)
if (['Escape', 'ArrowRight'].includes(event.key)) {
event.preventDefault()
event.stopPropagation()
setRecentProjectsSubmenuOpen(false)
recentProjectsButton.focus()
return
}
if (index < 0) return
let nextIndex
if (event.key === 'ArrowDown') nextIndex = (index + 1) % items.length
else if (event.key === 'ArrowUp') nextIndex = (index - 1 + items.length) % items.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = items.length - 1
else return
event.preventDefault()
event.stopPropagation()
items[nextIndex]?.focus()
})
document.addEventListener('click', (event) => {
if (!projectMenu.hidden && !event.target.closest('.project-menu-control')) setProjectMenuOpen(false)
})
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || projectMenu.hidden) return
setProjectMenuOpen(false)
projectMenuButton.focus()
})
mobileTargetDeviceSelect.addEventListener('change', () => {
targetDeviceSelect.value = mobileTargetDeviceSelect.value
targetDeviceSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
function activateProject(project, message) {
currentProject = project
projectNameInput.value = project.name
targetDeviceSelect.value = DEVICE_PROFILES[project.target] ? project.target : 'portable'
embedAssetsInput.checked = project.settings.embedAssets
workspace.updateToolbox(toolboxForTarget(TOOLBOX, targetDeviceSelect.value))
loadWorkspace(project.workspace)
currentArchive = null
downloadButton.disabled = true
installSimulatorButton.disabled = true
installDeviceButton.disabled = true
persistProject()
renderAssets()
refreshCode()
setStatus(message)
}
function faceAssetEntries() {
return currentProject.assets
.filter((asset) => asset.mediaType === FACE_ASSET_MEDIA_TYPE)
.map((asset) => {
let name = asset.path.replace(/^assets\//, '').replace(/\.stackchan-face\.json$/i, '')
try {
name = parseFaceAsset(new TextDecoder().decode(assetBytes(asset))).name
} catch {
// Invalid Face assets remain visible so the project can remove them.
}
return { asset, name }
})
}
function visibleFaceSelectionItems() {
return [...faceSelectionList.querySelectorAll('[role="menuitemradio"]')].filter(
(item) => !item.hidden && item.getClientRects().length > 0
)
}
function setFaceSelectionMenuOpen(open, { focusItem = false } = {}) {
faceSelectionMenu.hidden = !open
faceSelectionButton.setAttribute('aria-expanded', String(open))
if (open && focusItem) requestAnimationFrame(() => visibleFaceSelectionItems()[0]?.focus())
}
function renderFaceSelection() {
const entries = faceAssetEntries()
const selectedPath = currentProject.settings.faceAsset ?? ''
const selected = entries.find(({ asset }) => asset.path === selectedPath)
faceSelectionLabel.textContent = selected?.name ?? '標準Face'
faceSelectionButton.title = selected ? `使用中のFace: ${selected.name}` : '使用中のFace: 標準Face'
editFaceButton.disabled = !selected
editFaceButton.title = selected ? `「${selected.name}」を顔エディタで編集` : '標準Faceは編集できません'
faceSelectionList.replaceChildren()
const options = [
{ path: '', name: '標準Face', detail: 'ファームウェア標準' },
...entries.map(({ asset, name }) => ({ path: asset.path, name, detail: 'カスタムFace' })),
]
for (const option of options) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'face-selection-option'
button.dataset.faceAsset = option.path
button.setAttribute('role', 'menuitemradio')
button.setAttribute('aria-checked', String(option.path === selectedPath))
const marker = document.createElement('span')
marker.className = 'face-selection-option-marker'
marker.textContent = '✓'
marker.setAttribute('aria-hidden', 'true')
const copy = document.createElement('span')
copy.className = 'face-selection-option-copy'
const name = document.createElement('strong')
name.textContent = option.name
const detail = document.createElement('small')
detail.textContent = option.detail
copy.append(name, detail)
button.append(marker, copy)
button.addEventListener('click', () => {
setFaceSelectionMenuOpen(false)
currentProject.settings.faceAsset = option.path || null
persistProject()
renderFaceSelection()
refreshCode()
setStatus(`Faceを「${option.name}」へ切り替えました`)
})
faceSelectionList.append(button)
}
}
faceSelectionButton.addEventListener('click', (event) => {
event.stopPropagation()
setFaceSelectionMenuOpen(faceSelectionMenu.hidden, { focusItem: faceSelectionMenu.hidden })
})
async function openSelectedFaceEditor() {
const assetPath = currentProject.settings.faceAsset
const entry = currentProject.assets.find(
(asset) => asset.path === assetPath && asset.mediaType === FACE_ASSET_MEDIA_TYPE
)
if (!entry) {
setStatus('編集するカスタムFaceを選択してください')
return
}
editFaceButton.disabled = true
try {
const asset = parseFaceAsset(new TextDecoder().decode(assetBytes(entry)))
persistProject()
if (!(await projectSave)) throw new Error('MODプロジェクトを保存できませんでした')
saveFaceEditContext(asset, { projectId: currentProject.id, assetPath: entry.path })
location.href = '../face-editor/?face-edit=project'
} catch (error) {
log(`顔エディタを開けませんでした: ${error.message}`)
setStatus(`顔エディタを開けませんでした: ${error.message}`)
renderFaceSelection()
}
}
editFaceButton.addEventListener('click', openSelectedFaceEditor)
faceEditorNavigationLink?.addEventListener('click', (event) => {
const selectedFace = faceAssetEntries().some(({ asset }) => asset.path === currentProject.settings.faceAsset)
if (!selectedFace) return
event.preventDefault()
document.getElementById('tool-drawer')?.close()
void openSelectedFaceEditor()
})
window.addEventListener('pageshow', renderFaceSelection)
faceSelectionMenu.addEventListener('keydown', (event) => {
const items = visibleFaceSelectionItems()
const index = items.indexOf(document.activeElement)
if (items.length === 0) return
let nextIndex
if (event.key === 'ArrowDown') nextIndex = index < 0 ? 0 : (index + 1) % items.length
else if (event.key === 'ArrowUp') nextIndex = index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = items.length - 1
else return
event.preventDefault()
items[nextIndex]?.focus()
})
document.addEventListener('click', (event) => {
if (!faceSelectionMenu.hidden && !event.target.closest('.face-selection-control')) {
setFaceSelectionMenuOpen(false)
}
})
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || faceSelectionMenu.hidden) return
setFaceSelectionMenuOpen(false)
faceSelectionButton.focus()
})
function renderAssets() {
renderFaceSelection()
assetSummary.replaceChildren()
if (currentProject.assets.length === 0) {
assetSummary.textContent = 'アセットなし'
return
}
for (const asset of currentProject.assets) {
const chip = document.createElement('span')
chip.className = 'asset-chip'
const label = document.createElement('span')
label.className = 'asset-chip-label'
label.textContent = asset.path.replace(/^assets\//, '')
label.title = label.textContent
chip.append(label)
const remove = document.createElement('button')
remove.type = 'button'
remove.textContent = '×'
remove.title = `${asset.path}を削除`
remove.addEventListener('click', () => {
currentProject.assets = currentProject.assets.filter((candidate) => candidate.path !== asset.path)
if (currentProject.settings.faceAsset === asset.path) currentProject.settings.faceAsset = null
persistProject()
renderAssets()
refreshCode()
})
chip.append(remove)
assetSummary.append(chip)
}
}
function addFaceAsset(asset, options) {
return addFaceAssetToProject(currentProject, asset, options)
}
try {
if (new URLSearchParams(location.search).get('face-asset') === 'staging') {
const stagedTransfer = loadStagedFaceTransfer()
if (!stagedTransfer) throw new Error('顔エディタからの受け渡しデータがありません')
const edit = stagedTransfer.edit
if (edit && edit.projectId !== currentProject.id) {
throw new Error('編集元と現在のMODプロジェクトが一致しません')
}
const stagedProject = addFaceAsset(stagedTransfer.asset, edit ? { replacePath: edit.assetPath } : undefined)
persistProject(stagedProject)
if (!(await projectSave)) throw new Error('顔アセットを含むMODプロジェクトを保存できませんでした')
clearStagedFaceTransfer()
clearFaceEditContext()
history.replaceState(null, '', location.pathname)
setStatus(edit ? '顔エディタの変更を反映しました' : '顔エディタのアセットを追加しました')
}
} catch (error) {
log(`顔アセットを読み込めませんでした: ${error.message}`)
setStatus(`顔アセットを読み込めませんでした: ${error.message}`)
}
function recordMetric(event, detail = {}) {
try {
const metrics = JSON.parse(localStorage.getItem(METRICS_STORAGE_KEY) ?? '[]')
metrics.push({ event, at: new Date().toISOString(), project: currentProject.name, ...detail })
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(metrics.slice(-200)))
} catch {
// 計測は編集やビルドを妨げない。
}
}
recordMetric('editor_opened', { target: currentProject.target, browser: navigator.userAgent })
function renderAnalysis() {
const workspaceAnalysis = analyzeWorkspace(workspaceState(), { target: targetDeviceSelect.value })
currentAnalysis = currentGenerationError
? {
...workspaceAnalysis,
canBuild: false,
diagnostics: [
...workspaceAnalysis.diagnostics,
{
severity: 'error',
code: 'VP_CODE_GENERATION_FAILED',
message: `コードを生成できません: ${currentGenerationError}`,
},
],
}
: workspaceAnalysis
diagnosticsList.replaceChildren()
diagnosticsCount.textContent = String(currentAnalysis.diagnostics.length)
for (const block of workspace.getAllBlocks(false)) block.setWarningText(null, 'visual-validator')
for (const diagnostic of currentAnalysis.diagnostics) {
const item = document.createElement('li')
const button = document.createElement('button')
button.type = 'button'
button.className = 'diagnostic-button'
button.dataset.severity = diagnostic.severity
const code = document.createElement('span')
code.className = 'diagnostic-code'
code.textContent = `${diagnostic.severity === 'error' ? 'エラー' : '警告'} · ${diagnostic.code}`
button.append(code, document.createTextNode(diagnostic.message))
if (diagnostic.blockId) {
workspace.getBlockById(diagnostic.blockId)?.setWarningText(diagnostic.message, 'visual-validator')
button.addEventListener('click', () => {
const block = workspace.getBlockById(diagnostic.blockId)
if (!block) return
workspace.centerOnBlock(block.id)
block.select()
})
}
item.append(button)
diagnosticsList.append(item)
}
const profile = profileFor(targetDeviceSelect.value)
const xsLabel = profile.xsArchiveVersion ? ` · XS ${profile.xsArchiveVersion.join('.')}` : ' · XS版は書き込み前に確認'
capabilitySummary.textContent = currentAnalysis.requirements.length
? `${profile.label}${xsLabel} · 使用する能力: ${currentAnalysis.requirements.join(', ')}`
: `${profile.label}${xsLabel} · 追加のハードウェア能力は使いません`
capabilitySummary.dataset.severity = currentAnalysis.canBuild ? 'ok' : 'error'
buildButton.disabled = !currentAnalysis.canBuild
}
function refreshCode() {
currentArchive = null
currentGenerationError = null
downloadButton.disabled = true
installSimulatorButton.disabled = true
refreshDeviceActionButtons()
try {
const generatedSource = generateModSource(javascriptGenerator, workspace)
const selectedFaceAsset = currentProject.assets.find((asset) => asset.path === currentProject.settings.faceAsset)
const nextSource = selectedFaceAsset
? applyFaceAssetToSource(generatedSource, parseFaceAsset(new TextDecoder().decode(assetBytes(selectedFaceAsset))))
: generatedSource
currentSource = nextSource
codePreview.textContent = currentSource
} catch (error) {
currentSource = ''
currentGenerationError = String(error.message ?? error)
codePreview.textContent = `// コード生成エラー: ${error.message}`
}
renderAnalysis()
}
workspace.addChangeListener((event) => {
if (event.isUiEvent) return
refreshCode()
persistProject()
})
refreshCode()
renderAssets()
renderRecentProjects()
recoveryButton.hidden = !recoveryRecord
queueProjectSave()
for (const sample of VISUAL_SAMPLES) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'sample-card'
button.dataset.sampleId = sample.id
const title = document.createElement('strong')
title.textContent = sample.title
const description = document.createElement('span')
description.textContent = sample.description
button.append(title, description)
button.addEventListener('click', () => {
loadWorkspace(sample.workspace)
refreshCode()
setStatus(`サンプル「${sample.title}」を読み込みました`)
recordMetric('sample_loaded', { sample: sample.id })
sampleDialog.close()
})
sampleList.append(button)
}
sampleButton.addEventListener('click', () => sampleDialog.showModal())
sampleClose.addEventListener('click', () => sampleDialog.close())
clearButton.addEventListener('click', () => {
clearWorkspaceProjectName.textContent = currentProject.name
clearWorkspaceDialog.showModal()
})
clearWorkspaceConfirm.addEventListener('click', () => {
workspace.clear()
refreshCode()
persistProject()
setStatus(`「${currentProject.name}」のワークスペースを消去しました`)
recordMetric('workspace_cleared')
})
targetDeviceSelect.addEventListener('change', () => {
workspace.updateToolbox(toolboxForTarget(TOOLBOX, targetDeviceSelect.value))
persistProject()
refreshCode()
})
embedAssetsInput.addEventListener('change', () => {
currentProject.settings.embedAssets = embedAssetsInput.checked
persistProject()
refreshCode()
})
newProjectButton.addEventListener('click', () => {
persistProject()
const sample = sampleById('hello')
activateProject(createVisualProject({ workspace: sample.workspace }), '新しいプロジェクトを作成しました')
recordMetric('project_created')
})
duplicateProjectButton.addEventListener('click', () => {
persistProject()
activateProject(duplicateVisualProject(currentProject), 'プロジェクトを複製しました')
recordMetric('project_duplicated')
})
recoveryButton.addEventListener('click', () => {
if (!recoveryRecord) return
downloadBytes(
`${JSON.stringify(recoveryRecord, null, 2)}\n`,
`stackchan-project-recovery-${recoveryRecord.capturedAt.replace(/[:.]/g, '-')}.json`,
'application/json'
)
})
exportButton.addEventListener('click', () => {
persistProject()
const blob = new Blob([serializeVisualProject(currentProject)], { type: 'application/json' })
downloadBlob(blob, projectFileName(currentProject))
recordMetric('project_exported')
})
importButton.addEventListener('click', () => projectFileInput.click())
projectFileInput.addEventListener('change', async () => {
const [file] = projectFileInput.files ?? []
if (!file) return
let raw = ''
try {
if (file.size > MAX_PROJECT_JSON_BYTES) {
setStatus(`プロジェクトは${formatByteSize(MAX_PROJECT_JSON_BYTES)}以下にしてください`)
return
}
raw = await file.text()
const imported = parseVisualProject(raw)
activateProject(imported, `「${imported.name}」を読み込みました`)
recordMetric('project_imported')
} catch (error) {
recoveryRecord = createRecoveryRecord(raw, error)
void projectStorage.saveRecovery(recoveryRecord).catch((storageError) => {
log(`復旧データを保存できませんでした: ${storageError.message}`)
})
recoveryButton.hidden = false
setStatus('プロジェクトを読み込めませんでした')
log(String(error.message ?? error))
} finally {
projectFileInput.value = ''
}
})
assetButton.addEventListener('click', () => assetFileInput.click())
assetFileInput.addEventListener('change', async () => {
const files = [...(assetFileInput.files ?? [])]
try {
if (currentProject.assets.length + files.length > MAX_ASSET_COUNT) {
setStatus(`アセットは${MAX_ASSET_COUNT}個まで追加できます`)
return
}
let addedCount = 0
for (const file of files) {
if (file.size > MAX_ASSET_BYTES) {
setStatus(`${file.name}は${formatByteSize(MAX_ASSET_BYTES)}以下にしてください`)
continue
}
try {
const bytes = new Uint8Array(await file.arrayBuffer())
let binary = ''
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
}
let mediaType = file.type || 'application/octet-stream'
let assetData = btoa(binary)
let encoding = 'base64'
if (file.type === FACE_ASSET_MEDIA_TYPE || file.name.toLowerCase().endsWith('.stackchan-face.json')) {
const faceAsset = parseFaceAsset(new TextDecoder().decode(bytes))
mediaType = FACE_ASSET_MEDIA_TYPE
assetData = `${JSON.stringify(faceAsset, null, 2)}\n`
encoding = 'utf8'
}
const asset = {
path: `assets/${file.name.replace(/[^\p{L}\p{N}._-]/gu, '_')}`,
mediaType,
encoding,
data: assetData,
}
const nextAssets = [...currentProject.assets.filter((item) => item.path !== asset.path), asset]
const nextSettings = {
...currentProject.settings,
faceAsset: mediaType === FACE_ASSET_MEDIA_TYPE ? asset.path : currentProject.settings.faceAsset,
}
serializeVisualProject({ ...currentProject, assets: nextAssets, settings: nextSettings })
currentProject.assets = nextAssets
currentProject.settings = nextSettings
addedCount += 1
} catch (error) {
setStatus(`${file.name}をアセットとして読み込めませんでした`)
log(String(error.message ?? error))
recordMetric('asset_failed', { name: file.name, error: String(error.message ?? error) })
}
}
persistProject()
renderAssets()
refreshCode()
if (addedCount) recordMetric('assets_added', { count: addedCount })
} finally {
// Chromium may lazily read temporary File objects. Keep the selection
// alive until every arrayBuffer() call has settled.
assetFileInput.value = ''
}
})
buildButton.addEventListener('click', async () => {
refreshCode()
if (!currentAnalysis.canBuild) {
setStatus('ビルド前診断のエラーを修正してください')
selectOutputTab(document.getElementById('diagnostics-tab'))
return
}
buildButton.disabled = true
setStatus('ビルド中… (mcrun @ WebAssembly)')
currentArchive = null
downloadButton.disabled = true
installSimulatorButton.disabled = true
installDeviceButton.disabled = true
try {
const startedAt = performance.now()
const embeddedAssets = currentProject.settings.embedAssets ? currentProject.assets : []
const archive = await buildModArchive(createTools, {
modJs: currentSource,
name: currentProject.name,
manifest: manifestForProjectAssets(embeddedAssets),
files: embeddedAssets.map((asset) => ({ path: asset.path, bytes: assetBytes(asset) })),
onLog: log,
})
if (!isXsArchive(archive)) throw new Error('生成されたファイルがXSアーカイブではありません')
currentArchive = archive
const version = xsArchiveVersion(archive)
const compatibility = inspectDeploymentCompatibility(targetDeviceSelect.value, { xsVersion: version })
if (!compatibility.compatible) throw new Error(compatibility.diagnostics.map((item) => item.message).join('\n'))
const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1)
setStatus(`ビルド成功: ${formatByteSize(archive.length)} / XS ${version?.join('.')} (${elapsed}秒)`)
recordMetric('build_succeeded', { elapsedMs: Math.round(performance.now() - startedAt), size: archive.length })
downloadButton.disabled = false
installSimulatorButton.disabled = false
refreshDeviceActionButtons()
if (!('serial' in navigator)) log('このブラウザはWebSerialに対応していないため実機転送は使えません')
else if (!profileFor(targetDeviceSelect.value).deviceInstall) {
log(`${profileFor(targetDeviceSelect.value).label}はWebSerial実機書き込みの対象ではありません`)
}
} catch (error) {
console.error(error)
setStatus('ビルド失敗。ログを確認してください。')
log(String(error.message ?? error))
recordMetric('build_failed', { error: String(error.message ?? error) })
} finally {
buildButton.disabled = false
}
})
function downloadBlob(blob, name) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = name
document.body.append(anchor)
anchor.click()
anchor.remove()
// Chromium may resolve the Blob URL after click() returns. Revoking it in
// the same task races the download and can produce an empty/missing file.
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
function downloadBytes(bytes, name, type = 'application/octet-stream') {
downloadBlob(new Blob([bytes], { type }), name)
}
downloadButton.addEventListener('click', () => {
if (!currentArchive) return
downloadBytes(currentArchive, `${currentProject.name}.xsa`)
})
installSimulatorButton.addEventListener('click', async () => {
if (!currentArchive) return
try {
const storage = createModStorage()
const installed = await storage.saveInstalledMod({ name: `${currentProject.name}.xsa`, bytes: currentArchive })
setStatus(`シミュレーターを起動しています (${formatByteSize(installed.size)})`)
simulatorDialog.showModal()
simulatorFrame.dataset.runCount = '0'
simulatorFrame.src = `../simulator/?editor=${Date.now()}`
simulatorMetricPending = true
recordMetric('simulator_opened', { size: installed.size })
} catch (error) {
setStatus('シミュレータへの保存に失敗しました')
log(String(error.message ?? error))
}
})
function postSimulatorCommand(command, detail = {}) {
simulatorFrame.contentWindow?.postMessage({ type: 'stackchan-editor-command', command, ...detail }, location.origin)
}
simulatorRestart.addEventListener('click', () => postSimulatorCommand('restart'))
simulatorStop.addEventListener('click', () => {
simulatorMetricPending = false
simulatorFrame.src = 'about:blank'
simulatorFrame.dataset.runCount = '0'
setStatus('シミュレーターを停止しました')
})
simulatorClose.addEventListener('click', () => {
simulatorMetricPending = false
simulatorDialog.close()
simulatorFrame.src = 'about:blank'
})
for (const button of document.querySelectorAll('[data-simulator-button]')) {
button.addEventListener('click', () => postSimulatorCommand('button', { name: button.dataset.simulatorButton }))
}
window.addEventListener('message', (event) => {
if (event.origin !== location.origin || event.source !== simulatorFrame.contentWindow) return
if (!simulatorDialog.open) return
const message = event.data
if (message?.type === 'stackchan-simulator-ready') {
simulatorFrame.dataset.runCount = String(message.runCount ?? 1)
setStatus(
message.runCount > 1
? `シミュレーターでMODを再実行しました(${message.runCount}回目)`
: 'シミュレーターでMODを実行しています'
)
if (simulatorMetricPending) {
simulatorMetricPending = false
recordMetric('simulator_succeeded', { size: currentArchive?.length ?? null })
}
return
}
if (message?.type === 'stackchan-simulator-trace') {
const text = String(message.text ?? '')
log(`[simulator] ${text}`)
const runtimeDiagnostic = parseVisualTrace(text)
const blockId = runtimeDiagnostic?.block_id
const block = blockId ? workspace.getBlockById(blockId) : null
if (block) {
block.setWarningText(`${runtimeDiagnostic.error_code}: ${runtimeDiagnostic.message}`, 'visual-runtime')
workspace.centerOnBlock(block.id)
block.select()
selectOutputTab(document.getElementById('log-tab'))
}
}
if (message?.type === 'stackchan-simulator-status' && message.status === 'error') {
setStatus(`シミュレーターエラー: ${message.error}`)
}
})
async function writeArchiveToDevice(archive) {
if (!profileFor(targetDeviceSelect.value).deviceInstall) {
setStatus(`${profileFor(targetDeviceSelect.value).label}は実機書き込みの対象ではありません`)
return
}
if (!('serial' in navigator)) {
setStatus('このブラウザはWebSerialに対応していません (Chrome/Edgeを使ってください)')
return
}
deviceOperationPending = true
refreshDeviceActionButtons()
installProgress.hidden = false
installProgress.value = 0
let port
try {
port = await navigator.serial.requestPort()
setStatus('実機へ書き込み中…(ブートローダ経由でxsパーティションへ)')
const result = await installModToDevice(createEsptoolLoader, port, archive, {
onLog: log,
onProgress: (ratio) => {
installProgress.value = ratio
},
onPrompt: (message) => setStatus(message),
onPreflight: ({ chip, firmware }) => {
const archiveVersion = xsArchiveVersion(archive)
const compatibility = inspectDeploymentCompatibility(targetDeviceSelect.value, {
chip,
xsVersion: archiveVersion,
firmwareVersion: firmware.version,
requireFirmware: true,
requireArchive: true,
})
if (!compatibility.compatible) {
throw new Error(
`安全確認で不一致を検出したため書き込みません: ${compatibility.diagnostics
.map((item) => item.message)
.join(' / ')}`
)
}
log(
`[flash] 互換性を確認しました: ${chip} / ${firmware.projectName} ${firmware.version} / XS ${archiveVersion.join('.')}`
)
return true
},
})
if (result.status === DEVICE_OPERATION_STATUS.CANCELLED) {
setStatus('実機への書き込みをキャンセルしました')
return
}
setStatus(`実機への書き込みと検証が終了しました (${result.chip})`)
recordMetric('device_installed', { size: archive.length, target: targetDeviceSelect.value })
} catch (error) {
console.error(error)
setStatus('実機への書き込みに失敗しました。ログを確認してください。')
log(String(error.message ?? error))
recordMetric('device_failed', { error: String(error.message ?? error) })
} finally {
installProgress.hidden = true
deviceOperationPending = false
refreshDeviceActionButtons()
}
}
installDeviceButton.addEventListener('click', async () => {
if (currentArchive) await writeArchiveToDevice(currentArchive)
})
removeDeviceButton.addEventListener('click', async () => {
if (!profileFor(targetDeviceSelect.value).deviceInstall) {
setStatus(`${profileFor(targetDeviceSelect.value).label}は実機操作の対象ではありません`)
return
}
if (!('serial' in navigator)) {
setStatus('このブラウザはWebSerialに対応していません')
return
}
let port
try {
deviceOperationPending = true
refreshDeviceActionButtons()
port = await navigator.serial.requestPort()
const result = await removeModFromDevice(createEsptoolLoader, port, {
onLog: log,
onPrompt: (message) => setStatus(message),
onPreflight: ({ chip, partition, firmware }) => {
const compatibility = inspectDeploymentCompatibility(targetDeviceSelect.value, {
chip,
firmwareVersion: firmware.version,
requireFirmware: true,
})
if (!compatibility.compatible) {
throw new Error(
`安全確認で不一致を検出したため削除しません: ${compatibility.diagnostics
.map((item) => item.message)
.join(' / ')}`
)
}
return globalThis.confirm(
`${profileFor(targetDeviceSelect.value).label}(${chip} / ${firmware.version})のxsパーティション ` +
`(0x${partition.offset.toString(16)}) からMODを削除します。続行しますか?`
)
},
})
if (result.status === DEVICE_OPERATION_STATUS.CANCELLED) {
setStatus('実機のMOD削除をキャンセルしました')
return
}
setStatus('実機のMODを削除しました')
recordMetric('device_mod_removed')
} catch (error) {
setStatus('実機のMODを削除できませんでした')
log(String(error.message ?? error))
recordMetric('device_remove_failed', { error: String(error.message ?? error) })
} finally {
deviceOperationPending = false
refreshDeviceActionButtons()
}
})