@@ -69,6 +69,7 @@
|
||||
"devDependencies": {
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/three": "^0.184.0",
|
||||
"vitest": "catalog:",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { onUnmounted } from 'vue'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { controlConfig, useL2dViewControl } from '../../stores'
|
||||
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
|
||||
|
||||
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
|
||||
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
switch (viewControlMode.value) {
|
||||
case 'x':
|
||||
return position.value.x
|
||||
case 'y':
|
||||
return position.value.y
|
||||
case 'scale':
|
||||
return scale.value
|
||||
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
setValue(viewControlMode.value, value)
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
return formatter[viewControlMode.value](controlledValue.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewControlsEnabled.value = false
|
||||
@@ -15,31 +36,13 @@ onUnmounted(() => {
|
||||
<Transition name="fade-side-pops-in">
|
||||
<div v-if="viewControlsEnabled">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.x" :min="controlConfig.x.min" :max="controlConfig.x.max" :step="controlConfig.x.step" handle-wheel
|
||||
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.x.format(position.x) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.y" :min="controlConfig.y.min" :max="controlConfig.y.max" :step="controlConfig.y.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.y.format(position.y) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="scale" :min="controlConfig.scale.min" :max="controlConfig.scale.max" :step="controlConfig.scale.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.scale.format(scale) }}
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -62,13 +62,13 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
|
||||
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
|
||||
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
|
||||
const { position, scale, reset: resetViewControl } = useL2dViewControl()
|
||||
const { position, scale, set: setViewControl } = useL2dViewControl()
|
||||
|
||||
// Live2D model parameters
|
||||
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
|
||||
|
||||
function resetState() {
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
supportedControl.forEach(c => setViewControl(c))
|
||||
currentMotion.reset()
|
||||
availableMotions.reset()
|
||||
motionMap.reset()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { defaultControlConfig, formatter, supportedControl, useL2dViewControl } from './view-control'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useL2dViewControl', () => {
|
||||
let composable: ReturnType<typeof useL2dViewControl>
|
||||
|
||||
beforeEach(() => {
|
||||
composable = useL2dViewControl()
|
||||
})
|
||||
|
||||
it('should initialize with defaults', () => {
|
||||
expect(composable.viewControlsEnabled.value).toBe(false)
|
||||
expect(composable.viewControlMode.value).toBe('scale')
|
||||
expect(composable.position.value).toEqual({ x: 0, y: 0 })
|
||||
expect(composable.scale.value).toBe(1)
|
||||
})
|
||||
|
||||
describe('set function', () => {
|
||||
it('should set model position x', () => {
|
||||
composable.set('x', 5)
|
||||
expect(composable.position.value.x).toBe(5)
|
||||
})
|
||||
|
||||
it('should set scale', () => {
|
||||
composable.set('scale', 2)
|
||||
expect(composable.scale.value).toBe(2)
|
||||
})
|
||||
|
||||
it('should clamp values to max', () => {
|
||||
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.max)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.max)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.max)
|
||||
})
|
||||
|
||||
it('should clamp values to min', () => {
|
||||
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.min)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.min)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.min)
|
||||
})
|
||||
|
||||
it('should reset to default when value is omitted', () => {
|
||||
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
|
||||
supportedControl.forEach(k => composable.set(k))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.default)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.default)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.default)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatter', () => {
|
||||
it('should format percentages with 1 decimals', () => {
|
||||
expect(formatter.x(123.44)).toBe('123.4%')
|
||||
expect(formatter.x(123.56)).toBe('123.6%')
|
||||
expect(formatter.y(11.22)).toBe('11.2%')
|
||||
})
|
||||
|
||||
it('should format to percentage with 0 decimal', () => {
|
||||
expect(formatter.scale(1.234)).toBe('123%')
|
||||
expect(formatter.scale(1.236)).toBe('124%')
|
||||
expect(formatter.scale(0.11)).toBe('11%')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,60 +3,68 @@ import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'scale'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
|
||||
|
||||
/** show or hide the control element(slider) on stage */
|
||||
const viewControlsEnabled = ref(false)
|
||||
/** what value to control for the control element */
|
||||
const viewControlMode = ref<SupportedControl>('scale')
|
||||
/** model position relative to the center of the screen, in pixels */
|
||||
/** model position relative to the center of the screen, in percentages */
|
||||
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
|
||||
/** model scaling. `1` means no scaling. */
|
||||
/** model scaling factor. `1` means no scaling. */
|
||||
const scale = useLocalStorage('settings/live2d/scale', 1)
|
||||
|
||||
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
|
||||
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: calculate the min and max value dynamically according to window height/width, or allow user to set it
|
||||
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: allow user to set preferred default
|
||||
x: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
buttonText: 'X',
|
||||
},
|
||||
y: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
buttonText: 'Y',
|
||||
},
|
||||
scale: {
|
||||
min: 0.01,
|
||||
max: 3,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
format: formatToPercent,
|
||||
buttonText: 'Scale',
|
||||
},
|
||||
}
|
||||
|
||||
export const formatter: Record<SupportedControl, (val: number) => string> = {
|
||||
x: formatPercentD1,
|
||||
y: formatPercentD1,
|
||||
scale: formatToPercent,
|
||||
}
|
||||
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
export function useL2dViewControl() {
|
||||
/**
|
||||
* reset the given control to its default value.
|
||||
* @param key the control to reset
|
||||
* @param value optional, will reset the value to its default if not provided
|
||||
*/
|
||||
function reset(key: SupportedControl) {
|
||||
function set(key: SupportedControl, value?: number) {
|
||||
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
|
||||
switch (key) {
|
||||
case 'x':
|
||||
position.value.x = controlConfig.x.default
|
||||
position.value.x = clamped ?? defaultControlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
position.value.y = controlConfig.y.default
|
||||
position.value.y = clamped ?? defaultControlConfig.y.default
|
||||
break
|
||||
case 'scale':
|
||||
scale.value = controlConfig.scale.default
|
||||
scale.value = clamped ?? defaultControlConfig.scale.default
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -67,7 +75,7 @@ export function useL2dViewControl() {
|
||||
/** model scaling in percentages. `100` means no scaling. */
|
||||
scale,
|
||||
/** reset the given control to its default value. */
|
||||
reset,
|
||||
set,
|
||||
/** show or hide the control element(slider) on stage */
|
||||
viewControlsEnabled,
|
||||
/** what value to control for the control element */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user