Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
export const FACE_ASSET_FORMAT = 'tech.stackchan.face'
export const FACE_ASSET_VERSION = 1
export const FACE_ASSET_MEDIA_TYPE = 'application/vnd.stackchan.face+json'
export const FACE_ASSET_KIND_SHAPE = 'shape'
export const FACE_ASSET_EYE_SHAPES = Object.freeze(['circle', 'roundRect'])
export const FACE_ASSET_EMOTIONS = Object.freeze([
'NEUTRAL',
'HAPPY',
'ANGRY',
'SAD',
'SLEEPY',
'DOUBTFUL',
'COLD',
'HOT',
])
const EMOTIONS = new Set(FACE_ASSET_EMOTIONS)
const EYE_SHAPES = new Set(FACE_ASSET_EYE_SHAPES)
const ROOT_FIELDS = new Set(['format', 'version', 'kind', 'name', 'emotion', 'colors', 'mouth', 'canvas', 'shape'])
const COLOR_FIELDS = new Set(['primary', 'secondary'])
const CANVAS_FIELDS = new Set(['left', 'top', 'width', 'height'])
const SHAPE_FIELDS = new Set(['eyes', 'mouth'])
const EYES_FIELDS = new Set(['left', 'right'])
const CIRCLE_EYE_FIELDS = new Set(['x', 'y', 'shape', 'radius', 'eyelidWidth', 'eyelidHeight'])
const ROUND_RECT_EYE_FIELDS = new Set(['x', 'y', 'shape', 'width', 'height', 'r', 'eyelidWidth', 'eyelidHeight'])
const MOUTH_FIELDS = new Set(['visible', 'x', 'y', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight'])
const DEFAULT_ROUND_RECT_EYE = Object.freeze({ width: 16, height: 16, r: 4 })
export const DEFAULT_SHAPE_FACE = Object.freeze({
canvas: Object.freeze({ left: 60, top: 60, width: 200, height: 120 }),
shape: Object.freeze({
eyes: Object.freeze({
left: Object.freeze({ x: 30, y: 33, shape: 'circle', radius: 8, eyelidWidth: 16, eyelidHeight: 16 }),
right: Object.freeze({ x: 170, y: 36, shape: 'circle', radius: 8, eyelidWidth: 16, eyelidHeight: 16 }),
}),
mouth: Object.freeze({ visible: true, x: 100, y: 88, minWidth: 50, maxWidth: 90, minHeight: 8, maxHeight: 58 }),
}),
})
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function color(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(String(value)) ? String(value).toLowerCase() : fallback
}
function finite(value, fallback) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}
function clamp(value, minimum, maximum, fallback) {
return Math.min(maximum, Math.max(minimum, finite(value, fallback)))
}
function exactFields(value, fields) {
return isRecord(value) && Object.keys(value).every((key) => fields.has(key))
}
function validNumber(value, minimum, maximum) {
return typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum
}
function normalizeEye(value, fallback, canvas) {
const eye = isRecord(value) ? value : {}
const shape = eye.shape === 'roundRect' ? 'roundRect' : 'circle'
const maximumEyelidWidth = Math.min(120, canvas.width)
const maximumEyelidHeight = Math.min(120, canvas.height)
let irisWidth
let irisHeight
let geometry
if (shape === 'roundRect') {
const width = clamp(eye.width, 4, maximumEyelidWidth, DEFAULT_ROUND_RECT_EYE.width)
const height = clamp(eye.height, 4, maximumEyelidHeight, DEFAULT_ROUND_RECT_EYE.height)
const r = clamp(eye.r, 0, Math.min(width, height) / 2, DEFAULT_ROUND_RECT_EYE.r)
irisWidth = width
irisHeight = height
geometry = { width, height, r }
} else {
const maximumRadius = Math.min(40, canvas.width / 2, canvas.height / 2)
const radius = clamp(eye.radius, 2, maximumRadius, fallback.radius)
irisWidth = radius * 2
irisHeight = radius * 2
geometry = { radius }
}
const eyelidWidth = clamp(eye.eyelidWidth, irisWidth, maximumEyelidWidth, Math.max(irisWidth, fallback.eyelidWidth))
const eyelidHeight = clamp(
eye.eyelidHeight,
irisHeight,
maximumEyelidHeight,
Math.max(irisHeight, fallback.eyelidHeight)
)
return {
x: clamp(eye.x, eyelidWidth / 2, canvas.width - eyelidWidth / 2, fallback.x),
y: clamp(eye.y, eyelidHeight / 2, canvas.height - eyelidHeight / 2, fallback.y),
shape,
...geometry,
eyelidWidth,
eyelidHeight,
}
}
function normalizeMouth(value, fallback, canvas) {
const mouth = isRecord(value) ? value : {}
const minWidth = clamp(mouth.minWidth, 1, canvas.width, fallback.minWidth)
const maxWidth = clamp(mouth.maxWidth, minWidth, canvas.width, fallback.maxWidth)
const minHeight = clamp(mouth.minHeight, 1, canvas.height, fallback.minHeight)
const maxHeight = clamp(mouth.maxHeight, minHeight, canvas.height, fallback.maxHeight)
return {
visible: mouth.visible !== false,
x: clamp(mouth.x, 0, canvas.width, fallback.x),
y: clamp(mouth.y, 0, canvas.height, fallback.y),
minWidth,
maxWidth,
minHeight,
maxHeight,
}
}
export function createFaceAsset({
name = 'マイShapeフェイス',
emotion = 'NEUTRAL',
primary,
secondary,
colors = {},
mouth = 0,
canvas: canvasInput = {},
shape: shapeInput = {},
} = {}) {
const safeColors = isRecord(colors) ? colors : {}
const safeCanvasInput = isRecord(canvasInput) ? canvasInput : {}
const safeShapeInput = isRecord(shapeInput) ? shapeInput : {}
const width = clamp(safeCanvasInput.width, 40, 320, DEFAULT_SHAPE_FACE.canvas.width)
const height = clamp(safeCanvasInput.height, 40, 240, DEFAULT_SHAPE_FACE.canvas.height)
const canvas = {
left: clamp(safeCanvasInput.left, 0, 320 - width, DEFAULT_SHAPE_FACE.canvas.left),
top: clamp(safeCanvasInput.top, 0, 240 - height, DEFAULT_SHAPE_FACE.canvas.top),
width,
height,
}
const eyesInput = isRecord(safeShapeInput.eyes) ? safeShapeInput.eyes : {}
return {
format: FACE_ASSET_FORMAT,
version: FACE_ASSET_VERSION,
kind: FACE_ASSET_KIND_SHAPE,
name: String(name).trim().slice(0, 64) || 'マイShapeフェイス',
emotion: EMOTIONS.has(emotion) ? emotion : 'NEUTRAL',
colors: {
primary: color(primary ?? safeColors.primary, '#ffffff'),
secondary: color(secondary ?? safeColors.secondary, '#202020'),
},
mouth: clamp(mouth, 0, 1, 0),
canvas,
shape: {
eyes: {
left: normalizeEye(eyesInput.left, DEFAULT_SHAPE_FACE.shape.eyes.left, canvas),
right: normalizeEye(eyesInput.right, DEFAULT_SHAPE_FACE.shape.eyes.right, canvas),
},
mouth: normalizeMouth(safeShapeInput.mouth, DEFAULT_SHAPE_FACE.shape.mouth, canvas),
},
}
}
function validateEye(eye, canvas) {
if (!isRecord(eye)) return false
const shape = eye.shape ?? 'circle'
if (!EYE_SHAPES.has(shape)) return false
let irisWidth
let irisHeight
if (shape === 'roundRect') {
const maximumWidth = Math.min(120, canvas.width)
const maximumHeight = Math.min(120, canvas.height)
if (
!exactFields(eye, ROUND_RECT_EYE_FIELDS) ||
!validNumber(eye.width, 4, maximumWidth) ||
!validNumber(eye.height, 4, maximumHeight) ||
!validNumber(eye.r, 0, Math.min(eye.width, eye.height) / 2)
) {
return false
}
irisWidth = eye.width
irisHeight = eye.height
} else {
const maximumRadius = Math.min(40, canvas.width / 2, canvas.height / 2)
if (!exactFields(eye, CIRCLE_EYE_FIELDS) || !validNumber(eye.radius, 2, maximumRadius)) return false
irisWidth = eye.radius * 2
irisHeight = eye.radius * 2
}
return (
validNumber(eye.eyelidWidth, irisWidth, Math.min(120, canvas.width)) &&
validNumber(eye.eyelidHeight, irisHeight, Math.min(120, canvas.height)) &&
validNumber(eye.x, eye.eyelidWidth / 2, canvas.width - eye.eyelidWidth / 2) &&
validNumber(eye.y, eye.eyelidHeight / 2, canvas.height - eye.eyelidHeight / 2)
)
}
function validateShapeFaceAsset(value) {
if (!exactFields(value, ROOT_FIELDS)) return false
if (
value.kind !== FACE_ASSET_KIND_SHAPE ||
typeof value.name !== 'string' ||
!value.name.trim() ||
[...value.name].length > 64 ||
!EMOTIONS.has(value.emotion) ||
!exactFields(value.colors, COLOR_FIELDS) ||
!/^#[0-9a-f]{6}$/i.test(value.colors.primary) ||
!/^#[0-9a-f]{6}$/i.test(value.colors.secondary) ||
!validNumber(value.mouth, 0, 1) ||
!exactFields(value.canvas, CANVAS_FIELDS)
) {
return false
}
const canvas = value.canvas
if (
!validNumber(canvas.left, 0, 280) ||
!validNumber(canvas.top, 0, 200) ||
!validNumber(canvas.width, 40, 320) ||
!validNumber(canvas.height, 40, 240) ||
canvas.left + canvas.width > 320 ||
canvas.top + canvas.height > 240 ||
!exactFields(value.shape, SHAPE_FIELDS) ||
!exactFields(value.shape.eyes, EYES_FIELDS) ||
!validateEye(value.shape.eyes.left, canvas) ||
!validateEye(value.shape.eyes.right, canvas)
) {
return false
}
const mouth = value.shape.mouth
return (
exactFields(mouth, MOUTH_FIELDS) &&
(mouth.visible === undefined || typeof mouth.visible === 'boolean') &&
validNumber(mouth.x, 0, canvas.width) &&
validNumber(mouth.y, 0, canvas.height) &&
validNumber(mouth.minWidth, 1, canvas.width) &&
validNumber(mouth.maxWidth, mouth.minWidth, canvas.width) &&
validNumber(mouth.minHeight, 1, canvas.height) &&
validNumber(mouth.maxHeight, mouth.minHeight, canvas.height)
)
}
export function parseFaceAsset(text) {
let value
try {
value = JSON.parse(String(text))
} catch (error) {
throw new TypeError(`顔アセットのJSONを解析できません: ${error.message}`)
}
if (value?.format !== FACE_ASSET_FORMAT) throw new TypeError('未対応の顔アセット形式です')
if (value.version !== FACE_ASSET_VERSION || !validateShapeFaceAsset(value)) {
throw new TypeError('Shape顔アセットの形式または値が不正です')
}
return createFaceAsset(value)
}
export function addFaceAssetToProject(project, asset, { replacePath = null } = {}) {
const normalized = parseFaceAsset(JSON.stringify(asset))
const generatedPath = `assets/${normalized.name.replace(/[^\p{L}\p{N}._-]/gu, '_')}.stackchan-face.json`
const path = replacePath == null ? generatedPath : String(replacePath)
if (replacePath != null) {
const replaced = project.assets.find((item) => item.path === path)
if (!replaced || replaced.mediaType !== FACE_ASSET_MEDIA_TYPE) {
throw new TypeError('更新対象の顔アセットがプロジェクトにありません')
}
}
const entry = {
path,
mediaType: FACE_ASSET_MEDIA_TYPE,
encoding: 'utf8',
data: `${JSON.stringify(normalized, null, 2)}\n`,
}
return {
...project,
assets: [...project.assets.filter((item) => item.path !== path), entry],
settings: { ...project.settings, faceAsset: path },
}
}
function rgb(hex) {
const value = Number.parseInt(hex.slice(1), 16)
return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
}
function number(value) {
return Number(value.toFixed(3)).toString()
}
export function shapeFaceDefinition(asset) {
const normalized = createFaceAsset(asset)
const { canvas, shape } = normalized
const eye = (value, side) =>
value.shape === 'roundRect'
? `new Eye({ cx: ${number(value.x)}, cy: ${number(value.y)}, shape: 'roundRect', width: ${number(value.width)}, height: ${number(value.height)}, r: ${number(value.r)}, side: '${side}', eyelidWidth: ${number(value.eyelidWidth)}, eyelidHeight: ${number(value.eyelidHeight)} })`
: `new Eye({ cx: ${number(value.x)}, cy: ${number(value.y)}, radius: ${number(value.radius)}, side: '${side}', eyelidWidth: ${number(value.eyelidWidth)}, eyelidHeight: ${number(value.eyelidHeight)} })`
const contents = [eye(shape.eyes.left, 'left'), eye(shape.eyes.right, 'right')]
if (shape.mouth.visible) {
contents.push(
`new Mouth({ cx: ${number(shape.mouth.x)}, cy: ${number(shape.mouth.y)}, minWidth: ${number(shape.mouth.minWidth)}, maxWidth: ${number(shape.mouth.maxWidth)}, minHeight: ${number(shape.mouth.minHeight)}, maxHeight: ${number(shape.mouth.maxHeight)} })`
)
}
return `const _StackchanVisualShapeFace = FaceBase.template(($ = {}) => ({
left: $.left ?? ${number(canvas.left)},
top: $.top ?? ${number(canvas.top)},
width: $.width ?? ${number(canvas.width)},
height: $.height ?? ${number(canvas.height)},
contents: [
${contents.join(',\n ')},
],
}))`
}
export function faceAssetStatements(asset) {
const normalized = createFaceAsset(asset)
const statements = [
'robot.ui.setFace(new _StackchanVisualShapeFace({}))',
`robot.face.setEmotion(Emotion.${normalized.emotion})`,
`robot.face.setColor('primary', ${rgb(normalized.colors.primary).join(', ')})`,
`robot.face.setColor('secondary', ${rgb(normalized.colors.secondary).join(', ')})`,
]
if (normalized.shape.mouth.visible) statements.push(`robot.face.setMouthOpen(${number(normalized.mouth)})`)
return statements.join('\n')
}
function prependImport(source, statement, moduleName) {
return source.includes(`from '${moduleName}'`) ? source : `${statement}\n${source}`
}
export function applyFaceAssetToSource(source, asset) {
const normalized = createFaceAsset(asset)
const marker = ' const runtime = createVisualRuntime(robot)'
const entrypoint = 'export async function onContextCreated(robot)'
if (!source.includes(marker) || !source.includes(entrypoint)) {
throw new TypeError('生成コードにVisual Programmingのエントリポイントがありません')
}
let result = source.replace(entrypoint, `${shapeFaceDefinition(normalized)}\n\n${entrypoint}`)
const statements = faceAssetStatements(normalized)
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
result = result.replace(marker, `${marker}\n${statements}`)
if (normalized.shape.mouth.visible) {
result = prependImport(result, "import { Mouth } from 'parts/mouth'", 'parts/mouth')
}
result = prependImport(result, "import { Eye } from 'parts/eye'", 'parts/eye')
result = prependImport(result, "import { FaceBase } from 'behaviors/face'", 'behaviors/face')
result = prependImport(result, "import { Emotion } from 'face-state'", 'face-state')
return result
}
442a3eef4This file is pinned to the selected release. Download the original to open it in your design tool.