Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
a917e7c83This file is pinned to the selected release. Download the original to open it in your design tool.
import { FACE_ASSET_MEDIA_TYPE, createFaceAsset, parseFaceAsset, shapeFaceDefinition } from '../editor/face-assets.mjs'
const element = (id) => document.getElementById(id)
const elements = {
form: element('face-form'),
name: element('face-name'),
emotion: element('face-emotion'),
primary: element('primary-color'),
secondary: element('secondary-color'),
mouthOpen: element('mouth-open'),
mouthOpenOutput: element('mouth-open-output'),
mouthVisible: element('mouth-visible'),
mouthShapeControls: element('mouth-shape-controls'),
canvas: element('face-canvas'),
faceFrame: element('face-frame'),
frameBackground: element('face-frame-background'),
frameOutline: element('face-frame-outline'),
leftIris: element('left-eye-iris'),
leftLid: element('left-eye-lid'),
rightIris: element('right-eye-iris'),
rightLid: element('right-eye-lid'),
mouthPart: element('mouth-part'),
mouthPreview: element('mouth-preview'),
status: element('face-status'),
codePreview: element('shape-code-preview'),
fileInput: element('face-file-input'),
}
const controls = {
canvas: {
left: element('canvas-left'),
top: element('canvas-top'),
width: element('canvas-width'),
height: element('canvas-height'),
},
leftEye: {
shape: element('left-eye-shape'),
x: element('left-eye-x'),
y: element('left-eye-y'),
radius: element('left-eye-radius'),
width: element('left-eye-width'),
height: element('left-eye-height'),
r: element('left-eye-r'),
eyelidWidth: element('left-eyelid-width'),
eyelidHeight: element('left-eyelid-height'),
},
rightEye: {
shape: element('right-eye-shape'),
x: element('right-eye-x'),
y: element('right-eye-y'),
radius: element('right-eye-radius'),
width: element('right-eye-width'),
height: element('right-eye-height'),
r: element('right-eye-r'),
eyelidWidth: element('right-eyelid-width'),
eyelidHeight: element('right-eyelid-height'),
},
mouth: {
x: element('mouth-x'),
y: element('mouth-y'),
minWidth: element('mouth-min-width'),
maxWidth: element('mouth-max-width'),
minHeight: element('mouth-min-height'),
maxHeight: element('mouth-max-height'),
},
}
function values(group) {
return Object.fromEntries(Object.entries(group).map(([key, input]) => [key, Number(input.value)]))
}
function eyeValues(group) {
const eye = {
shape: group.shape.value,
x: Number(group.x.value),
y: Number(group.y.value),
eyelidWidth: Number(group.eyelidWidth.value),
eyelidHeight: Number(group.eyelidHeight.value),
}
if (eye.shape === 'roundRect') {
return {
...eye,
width: Number(group.width.value),
height: Number(group.height.value),
r: Number(group.r.value),
}
}
return { ...eye, radius: Number(group.radius.value) }
}
export function assetFromShapeEditorValues({
name,
emotion,
primary,
secondary,
mouth,
canvas,
leftEye,
rightEye,
mouthVisible,
mouthShape,
}) {
return createFaceAsset({
name,
emotion,
primary,
secondary,
mouth,
canvas,
shape: {
eyes: { left: leftEye, right: rightEye },
mouth: { ...mouthShape, visible: mouthVisible },
},
})
}
function currentAsset() {
return assetFromShapeEditorValues({
name: elements.name.value,
emotion: elements.emotion.value,
primary: elements.primary.value,
secondary: elements.secondary.value,
mouth: Number(elements.mouthOpen.value),
canvas: values(controls.canvas),
leftEye: eyeValues(controls.leftEye),
rightEye: eyeValues(controls.rightEye),
mouthVisible: elements.mouthVisible.checked,
mouthShape: values(controls.mouth),
})
}
function setValues(group, value) {
for (const [key, input] of Object.entries(group)) input.value = String(value[key])
}
function setEyeValues(group, eye) {
group.shape.value = eye.shape
group.x.value = String(eye.x)
group.y.value = String(eye.y)
if (eye.shape === 'roundRect') {
group.width.value = String(eye.width)
group.height.value = String(eye.height)
group.r.value = String(eye.r)
} else {
group.radius.value = String(eye.radius)
}
group.eyelidWidth.value = String(eye.eyelidWidth)
group.eyelidHeight.value = String(eye.eyelidHeight)
syncEyeControls(group)
}
function setShapeFieldVisibility(input, visible) {
input.disabled = !visible
input.closest('label').hidden = !visible
}
function syncEyeControls(group) {
const roundRect = group.shape.value === 'roundRect'
setShapeFieldVisibility(group.radius, !roundRect)
for (const input of [group.width, group.height, group.r]) setShapeFieldVisibility(input, roundRect)
let width
let height
if (roundRect) {
width = Number(group.width.value)
height = Number(group.height.value)
const maximumR = Math.max(0, Math.min(width, height) / 2)
group.r.max = String(maximumR)
group.r.value = String(Math.min(maximumR, Math.max(0, Number(group.r.value))))
} else {
width = Number(group.radius.value) * 2
height = width
}
group.eyelidWidth.value = String(width)
group.eyelidHeight.value = String(height)
}
function syncMouthControls() {
const visible = elements.mouthVisible.checked
elements.mouthOpen.disabled = !visible
elements.mouthShapeControls.dataset.disabled = String(!visible)
for (const input of Object.values(controls.mouth)) input.disabled = !visible
}
function applyAsset(asset) {
const normalized = createFaceAsset(asset)
elements.name.value = normalized.name
elements.emotion.value = normalized.emotion
elements.primary.value = normalized.colors.primary
elements.secondary.value = normalized.colors.secondary
elements.mouthOpen.value = String(normalized.mouth)
elements.mouthVisible.checked = normalized.shape.mouth.visible
setValues(controls.canvas, normalized.canvas)
setEyeValues(controls.leftEye, normalized.shape.eyes.left)
setEyeValues(controls.rightEye, normalized.shape.eyes.right)
setValues(controls.mouth, normalized.shape.mouth)
syncMouthControls()
render()
}
function setIris(rectangle, eye) {
const roundRect = eye.shape === 'roundRect'
const width = roundRect ? eye.width : eye.radius * 2
const height = roundRect ? eye.height : eye.radius * 2
const r = roundRect ? eye.r : eye.radius
rectangle.dataset.shape = eye.shape
rectangle.setAttribute('x', eye.x - width / 2)
rectangle.setAttribute('y', eye.y - height / 2)
rectangle.setAttribute('width', width)
rectangle.setAttribute('height', height)
rectangle.setAttribute('rx', r)
rectangle.setAttribute('ry', r)
}
function eyelidPath(eye, side, emotion) {
const left = eye.x - eye.eyelidWidth / 2
const top = eye.y - eye.eyelidHeight / 2
const right = left + eye.eyelidWidth
const bottom = top + eye.eyelidHeight
if (emotion === 'HAPPY') {
const y = top + eye.eyelidHeight * 0.6
return `M ${left} ${y} H ${right} V ${bottom} H ${left} Z`
}
if (emotion === 'SLEEPY') {
const y = top + eye.eyelidHeight * 0.5
return `M ${left} ${top} H ${right} V ${y} H ${left} Z`
}
if (emotion === 'ANGRY' || emotion === 'SAD') {
const leftIsCovered = (emotion === 'ANGRY' && side === 'right') || (emotion === 'SAD' && side === 'left')
const leftDepth = top + eye.eyelidHeight * (leftIsCovered ? 0.5 : 0)
const rightDepth = top + eye.eyelidHeight * (leftIsCovered ? 0 : 0.5)
return `M ${left} ${top} L ${left} ${leftDepth} L ${right} ${rightDepth} L ${right} ${top} Z`
}
return ''
}
function setLid(path, eye, side, emotion) {
path.setAttribute('d', eyelidPath(eye, side, emotion))
}
function render() {
const asset = currentAsset()
const { canvas, shape } = asset
elements.canvas.dataset.emotion = asset.emotion
elements.canvas.style.setProperty('--face-primary', asset.colors.primary)
elements.canvas.style.setProperty('--face-secondary', asset.colors.secondary)
elements.faceFrame.setAttribute('transform', `translate(${canvas.left} ${canvas.top})`)
for (const rectangle of [elements.frameBackground, elements.frameOutline]) {
rectangle.setAttribute('x', 0)
rectangle.setAttribute('y', 0)
rectangle.setAttribute('width', canvas.width)
rectangle.setAttribute('height', canvas.height)
}
setIris(elements.leftIris, shape.eyes.left)
setIris(elements.rightIris, shape.eyes.right)
setLid(elements.leftLid, shape.eyes.left, 'left', asset.emotion)
setLid(elements.rightLid, shape.eyes.right, 'right', asset.emotion)
elements.mouthPart.toggleAttribute('hidden', !shape.mouth.visible)
const open = asset.mouth
const mouthWidth = shape.mouth.minWidth + (shape.mouth.maxWidth - shape.mouth.minWidth) * (1 - open)
const mouthHeight = shape.mouth.minHeight + (shape.mouth.maxHeight - shape.mouth.minHeight) * open
elements.mouthPreview.setAttribute('x', shape.mouth.x - mouthWidth / 2)
elements.mouthPreview.setAttribute('y', shape.mouth.y - mouthHeight / 2)
elements.mouthPreview.setAttribute('width', mouthWidth)
elements.mouthPreview.setAttribute('height', mouthHeight)
elements.mouthPreview.removeAttribute('rx')
elements.mouthOpenOutput.value = open.toFixed(2)
elements.codePreview.textContent = `${shapeFaceDefinition(asset)}
export function onContextCreated(robot) {
robot.ui.setFace(new _StackchanVisualShapeFace({}))
}`
return asset
}
function setStatus(message, state = '') {
elements.status.textContent = message
elements.status.dataset.state = state
}
elements.form.addEventListener('input', () => {
for (const eyeControls of [controls.leftEye, controls.rightEye]) syncEyeControls(eyeControls)
syncMouthControls()
render()
setStatus('Shape型Faceを編集中です。')
})
elements.form.addEventListener('change', () => applyAsset(currentAsset()))
function pointInCanvas(event) {
const bounds = elements.canvas.getBoundingClientRect()
return {
x: ((event.clientX - bounds.left) / bounds.width) * 320,
y: ((event.clientY - bounds.top) / bounds.height) * 240,
}
}
const positionControls = {
'left-eye': controls.leftEye,
'right-eye': controls.rightEye,
mouth: controls.mouth,
}
let drag = null
elements.canvas.addEventListener('pointerdown', (event) => {
const part = event.target.closest('[data-part]')
if (!part) return
const asset = currentAsset()
const group = positionControls[part.dataset.part]
const point = pointInCanvas(event)
drag = {
pointerId: event.pointerId,
group,
offsetX: point.x - asset.canvas.left - Number(group.x.value),
offsetY: point.y - asset.canvas.top - Number(group.y.value),
}
part.setPointerCapture(event.pointerId)
part.focus()
event.preventDefault()
})
elements.canvas.addEventListener('pointermove', (event) => {
if (!drag || drag.pointerId !== event.pointerId) return
const asset = currentAsset()
const point = pointInCanvas(event)
drag.group.x.value = String(
Math.round(Math.min(asset.canvas.width, Math.max(0, point.x - asset.canvas.left - drag.offsetX)))
)
drag.group.y.value = String(
Math.round(Math.min(asset.canvas.height, Math.max(0, point.y - asset.canvas.top - drag.offsetY)))
)
render()
})
const endDrag = (event) => {
if (drag?.pointerId === event.pointerId) {
drag = null
applyAsset(currentAsset())
}
}
elements.canvas.addEventListener('pointerup', endDrag)
elements.canvas.addEventListener('pointercancel', endDrag)
for (const part of elements.canvas.querySelectorAll('[data-part]')) {
part.addEventListener('keydown', (event) => {
const movement = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}[event.key]
if (!movement) return
event.preventDefault()
const group = positionControls[part.dataset.part]
const step = event.shiftKey ? 5 : 1
group.x.value = String(Number(group.x.value) + movement[0] * step)
group.y.value = String(Number(group.y.value) + movement[1] * step)
applyAsset(currentAsset())
})
}
element('load-face').addEventListener('click', () => elements.fileInput.click())
elements.fileInput.addEventListener('change', async () => {
const [file] = elements.fileInput.files ?? []
if (!file) return
try {
const asset = parseFaceAsset(await file.text())
applyAsset(asset)
setStatus(`「${asset.name}」を読み込みました。`, 'success')
} catch (error) {
setStatus(`Shape顔を読み込めませんでした: ${error.message}`, 'error')
} finally {
elements.fileInput.value = ''
}
})
element('reset-face').addEventListener('click', () => {
applyAsset(createFaceAsset())
setStatus('標準のShape配置へ戻しました。', 'success')
})
element('download-face').addEventListener('click', () => {
const asset = render()
const blob = new Blob([`${JSON.stringify(asset, null, 2)}\n`], { type: FACE_ASSET_MEDIA_TYPE })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${asset.name.replace(/[^\p{L}\p{N}._-]/gu, '_')}.stackchan-face.json`
document.body.append(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
setStatus('Shape顔プロジェクトを保存しました。', 'success')
})
element('send-to-editor').addEventListener('click', () => {
const asset = render()
localStorage.setItem('stackchan-face-asset-staging', JSON.stringify(asset))
setStatus('Shape型Faceを保存しました。ブロックエディタを開きます。', 'success')
location.href = '../editor/?face-asset=staging'
})
applyAsset(createFaceAsset())