fix:修导出时钟与切页卡顿
导出与播放共用模拟时钟;换循环、重排播放头、<grok-bot> 重挂和构建产物对齐。 自定义/动画面板留下不重画,角色高度对齐,表情走弹簧。
This commit is contained in:
@@ -26,7 +26,7 @@ Do not commit extracted geometry, icons, or a third-party app bundle.
|
|||||||
|
|
||||||
## What's on the site
|
## What's on the site
|
||||||
|
|
||||||
The rail switches three views. **Customise** is 18 body shapes, 11 inks and the rest-face playlist. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits. GIF/MP4 export is not in this tree.
|
The rail switches three views. **Customise** is 8 body shapes, 11 inks and the rest-face playlist; stills export as PNG / SVG / GIF. **Animations** is bloub’s timeline editor on our 39 states: drag to reorder, pull a card to set duration, save named cycles in `localStorage`, export the sequence as MP4 or GIF. The right-hand tiles append a block. **Settings** is language (French, English, Chinese) and credits.
|
||||||
|
|
||||||
The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one frame and stop. Nothing from bloub’s `src/bot/` is in this tree.
|
The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one frame and stop. Nothing from bloub’s `src/bot/` is in this tree.
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,17 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
box-shadow: inset 0 -8px 18px rgba(40, 30, 10, 0.08), 0 12px 28px rgba(0,0,0,.3);
|
||||||
}
|
}
|
||||||
|
.missing {
|
||||||
|
width: min(100%, 280px);
|
||||||
|
margin: 12px auto 0;
|
||||||
|
padding: 22px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #2a2823;
|
||||||
|
color: #d8d0c0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.missing strong { display: block; margin-bottom: 8px; color: var(--accent); font-weight: 560; }
|
||||||
|
.missing a { color: var(--accent); }
|
||||||
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
.disk-login svg { width: 64px; height: 64px; overflow: visible; color-scheme: light; --bg: var(--disk); --fg: #111; }
|
||||||
.disk-login span {
|
.disk-login span {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -181,6 +192,12 @@
|
|||||||
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="missing" id="missing-geo" hidden>
|
||||||
|
<strong>几何未加载</strong>
|
||||||
|
本仓库不包含 <code>geometry-data.js</code>。把符合
|
||||||
|
<a href="geometry.schema.md">geometry.schema.md</a>
|
||||||
|
的数据放到同目录后刷新,即可本地预览。不要提交或再分发从商业应用抽出的造型。
|
||||||
|
</div>
|
||||||
<div class="modes">
|
<div class="modes">
|
||||||
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
|
||||||
<button id="mode-hold" aria-pressed="false">锁定状态</button>
|
<button id="mode-hold" aria-pressed="false">锁定状态</button>
|
||||||
@@ -296,6 +313,10 @@
|
|||||||
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
document.getElementById("mode-hold").setAttribute("aria-pressed", mode === "hold");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!window.GROK_GEO?.shapes || typeof window.GROK?.create !== "function") {
|
||||||
|
document.getElementById("missing-geo").hidden = false;
|
||||||
|
} else {
|
||||||
|
|
||||||
GROK_META.groups.forEach((group) => {
|
GROK_META.groups.forEach((group) => {
|
||||||
const h = document.createElement("h2");
|
const h = document.createElement("h2");
|
||||||
h.textContent = group.label;
|
h.textContent = group.label;
|
||||||
@@ -387,6 +408,7 @@
|
|||||||
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
|
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
|
||||||
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
|
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
|
||||||
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
|
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-1
@@ -56,7 +56,8 @@
|
|||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (this._handle) return;
|
if (this._handle) return;
|
||||||
const root = this.attachShadow({ mode: "open" });
|
const root = this.shadowRoot || this.attachShadow({ mode: "open" });
|
||||||
|
root.replaceChildren();
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.setAttribute("part", "disk");
|
box.setAttribute("part", "disk");
|
||||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
|||||||
@@ -276,10 +276,20 @@
|
|||||||
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
|
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_clock() {
|
||||||
|
return this.driven ? this.last : performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
setState(name, { resetEyes = false } = {}) {
|
setState(name, { resetEyes = false } = {}) {
|
||||||
if (!this.playlists[name]) return;
|
if (!this.playlists[name]) return;
|
||||||
this.state = name;
|
this.state = name;
|
||||||
this.stateAt = performance.now();
|
this.stateAt = this._clock();
|
||||||
|
if (this.driven) {
|
||||||
|
this.trick = null;
|
||||||
|
this.spinTurn = null;
|
||||||
|
this.hopAt = -1;
|
||||||
|
this.wildWide = false;
|
||||||
|
}
|
||||||
const list = this.playlists[name];
|
const list = this.playlists[name];
|
||||||
this.eyeIdx = 0;
|
this.eyeIdx = 0;
|
||||||
if (resetEyes) {
|
if (resetEyes) {
|
||||||
@@ -407,6 +417,11 @@
|
|||||||
return this.snapshot();
|
return this.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSize(px) {
|
||||||
|
this.sizePx = px > 0 ? px : null;
|
||||||
|
this._applyPoseScale();
|
||||||
|
}
|
||||||
|
|
||||||
_applyPoseScale() {
|
_applyPoseScale() {
|
||||||
const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1);
|
const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1);
|
||||||
this.pose.scale = sc;
|
this.pose.scale = sc;
|
||||||
@@ -529,7 +544,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_cycleShapeTrick() {
|
_cycleShapeTrick() {
|
||||||
if (this.reduceMotion || this.paused) return;
|
if (this.reduceMotion || this.paused || this.driven) return;
|
||||||
this.trickCycle = (this.trickCycle + 1) % 5;
|
this.trickCycle = (this.trickCycle + 1) % 5;
|
||||||
this.wildWide = false;
|
this.wildWide = false;
|
||||||
if (this.trickCycle === 0) this._pn(1);
|
if (this.trickCycle === 0) this._pn(1);
|
||||||
@@ -686,7 +701,7 @@
|
|||||||
this.celebrateAt = now + 6200;
|
this.celebrateAt = now + 6200;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now >= this.trickAt) {
|
if (!this.driven && now >= this.trickAt) {
|
||||||
if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
|
if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
|
||||||
const z = Math.random();
|
const z = Math.random();
|
||||||
if (V_T.has(this.state)) {
|
if (V_T.has(this.state)) {
|
||||||
|
|||||||
+19
-3
@@ -112,6 +112,7 @@ watch(activeId, (v) => {
|
|||||||
ecris('cycle', v)
|
ecris('cycle', v)
|
||||||
block.value = 0
|
block.value = 0
|
||||||
elapsed.value = 0
|
elapsed.value = 0
|
||||||
|
state.value = cycle.value.blocks[0]?.state ?? 'idle'
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(block, (i) => {
|
watch(block, (i) => {
|
||||||
@@ -144,9 +145,12 @@ function onSeek(t: number) {
|
|||||||
state.value = cycle.value.blocks[index]?.state ?? 'idle'
|
state.value = cycle.value.blocks[index]?.state ?? 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gardeAnim = ref(false)
|
||||||
|
|
||||||
watch(view, (now) => {
|
watch(view, (now) => {
|
||||||
intro.value = false
|
intro.value = false
|
||||||
if (now === 'animations') {
|
if (now === 'animations') {
|
||||||
|
gardeAnim.value = true
|
||||||
playing.value = true
|
playing.value = true
|
||||||
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
|
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
|
||||||
return
|
return
|
||||||
@@ -248,6 +252,11 @@ function annuleCycle() {
|
|||||||
|
|
||||||
watch(dialogueCycle, (ouverte) => {
|
watch(dialogueCycle, (ouverte) => {
|
||||||
if (ouverte) erreurCycle.value = false
|
if (ouverte) erreurCycle.value = false
|
||||||
|
else annuleCycle()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([view, preview], () => {
|
||||||
|
if (view.value !== 'animations' || preview.value) annuleCycle()
|
||||||
})
|
})
|
||||||
|
|
||||||
const CONFIRMATION_MS = 1800
|
const CONFIRMATION_MS = 1800
|
||||||
@@ -330,6 +339,7 @@ onUnmounted(() => {
|
|||||||
clearTimeout(pending)
|
clearTimeout(pending)
|
||||||
clearTimeout(minuteurBarre)
|
clearTimeout(minuteurBarre)
|
||||||
clearTimeout(confirmation)
|
clearTimeout(confirmation)
|
||||||
|
annuleCycle()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -350,7 +360,8 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="scene min-h-full items-stretch justify-center p-8 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
|
class="scene min-h-full items-stretch justify-center p-8 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
|
||||||
:class="[
|
:class="[
|
||||||
!preview && view === 'animations' && 'scene--timeline pb-[calc(var(--timeline)_+_1rem)]',
|
!preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
|
||||||
|
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
|
||||||
!preview && 'max-lg:pt-20',
|
!preview && 'max-lg:pt-20',
|
||||||
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
|
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
|
||||||
]"
|
]"
|
||||||
@@ -427,23 +438,27 @@ onUnmounted(() => {
|
|||||||
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
|
||||||
>
|
>
|
||||||
<Customizer
|
<Customizer
|
||||||
v-if="view === 'personnaliser'"
|
v-show="view === 'personnaliser'"
|
||||||
v-model:shape="shape"
|
v-model:shape="shape"
|
||||||
v-model:color="color"
|
v-model:color="color"
|
||||||
v-model:expression="expression"
|
v-model:expression="expression"
|
||||||
:ready="ready"
|
:ready="ready"
|
||||||
|
:active="view === 'personnaliser'"
|
||||||
/>
|
/>
|
||||||
<AnimPanel
|
<AnimPanel
|
||||||
v-else-if="view === 'animations'"
|
v-if="gardeAnim"
|
||||||
|
v-show="view === 'animations'"
|
||||||
v-model="state"
|
v-model="state"
|
||||||
:shape="shape"
|
:shape="shape"
|
||||||
:color="color"
|
:color="color"
|
||||||
:ready="ready"
|
:ready="ready"
|
||||||
|
:active="view === 'animations'"
|
||||||
@pick="addBlock"
|
@pick="addBlock"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Transition name="fondu">
|
||||||
<Timeline
|
<Timeline
|
||||||
v-if="view === 'animations' && !preview"
|
v-if="view === 'animations' && !preview"
|
||||||
v-model:cycles="cycles"
|
v-model:cycles="cycles"
|
||||||
@@ -458,6 +473,7 @@ onUnmounted(() => {
|
|||||||
@preview="preview = true"
|
@preview="preview = true"
|
||||||
@exporter="dialogueCycle = true"
|
@exporter="dialogueCycle = true"
|
||||||
/>
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
|
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
shape: string
|
shape: string
|
||||||
color: string
|
color: string
|
||||||
ready?: boolean
|
ready?: boolean
|
||||||
|
active?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
@@ -43,6 +44,7 @@ const groups = computed(() => {
|
|||||||
:state="id"
|
:state="id"
|
||||||
:shape="shape"
|
:shape="shape"
|
||||||
:color="color"
|
:color="color"
|
||||||
|
:active="active"
|
||||||
@click="emit('pick', id)"
|
@click="emit('pick', id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ const props = withDefaults(
|
|||||||
size?: number
|
size?: number
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
|
active?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ interactive: true, showLabel: true }
|
{ interactive: true, showLabel: true, active: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const svg = ref<SVGSVGElement | null>(null)
|
const svg = ref<SVGSVGElement | null>(null)
|
||||||
@@ -22,7 +23,6 @@ const painted = ref(false)
|
|||||||
async function paint() {
|
async function paint() {
|
||||||
const node = svg.value
|
const node = svg.value
|
||||||
if (!node) return
|
if (!node) return
|
||||||
node.innerHTML = ''
|
|
||||||
const mount = await paintTile(node, {
|
const mount = await paintTile(node, {
|
||||||
state: props.state ?? 'idle',
|
state: props.state ?? 'idle',
|
||||||
shape: props.shape ?? 'blob',
|
shape: props.shape ?? 'blob',
|
||||||
@@ -34,12 +34,27 @@ async function paint() {
|
|||||||
painted.value = !!mount
|
painted.value = !!mount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dirty = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void paint()
|
void paint()
|
||||||
})
|
})
|
||||||
watch(() => [props.state, props.shape, props.color], () => {
|
watch(() => [props.state, props.shape, props.color], () => {
|
||||||
|
if (!props.active) {
|
||||||
|
dirty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
void paint()
|
void paint()
|
||||||
})
|
})
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(on) => {
|
||||||
|
if (on && dirty.value) {
|
||||||
|
dirty.value = false
|
||||||
|
void paint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import BotTile from '@/components/BotTile.vue'
|
|||||||
import { shapeName, stateName, t } from '@/i18n'
|
import { shapeName, stateName, t } from '@/i18n'
|
||||||
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
|
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
|
||||||
|
|
||||||
const props = defineProps<{ ready?: boolean }>()
|
const props = defineProps<{ ready?: boolean; active?: boolean }>()
|
||||||
const shape = defineModel<string>('shape', { required: true })
|
const shape = defineModel<string>('shape', { required: true })
|
||||||
const color = defineModel<string>('color', { required: true })
|
const color = defineModel<string>('color', { required: true })
|
||||||
const expression = defineModel<string>('expression', { required: true })
|
const expression = defineModel<string>('expression', { required: true })
|
||||||
@@ -26,6 +26,7 @@ const shapes = computed(() => {
|
|||||||
:shape="id"
|
:shape="id"
|
||||||
:color="color"
|
:color="color"
|
||||||
:state="expression"
|
:state="expression"
|
||||||
|
:active="active"
|
||||||
@click="shape = id"
|
@click="shape = id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +41,7 @@ const shapes = computed(() => {
|
|||||||
:shape="shape"
|
:shape="shape"
|
||||||
:color="color"
|
:color="color"
|
||||||
:state="id"
|
:state="id"
|
||||||
|
:active="active"
|
||||||
@click="expression = id"
|
@click="expression = id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import type { Cycle } from '@/editor/cycles'
|
import { MAX_CYCLES, type Cycle } from '@/editor/cycles'
|
||||||
import { nomDeCycle, t } from '@/i18n'
|
import { nomDeCycle, t } from '@/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,7 +8,7 @@ import { nomDeCycle, t } from '@/i18n'
|
|||||||
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut,
|
* ni la suppression d'une entree, d'ou ce menu — et il s'ouvre vers le haut,
|
||||||
* la barre etant en bas de l'ecran.
|
* la barre etant en bas de l'ecran.
|
||||||
*/
|
*/
|
||||||
defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
const props = defineProps<{ cycles: Cycle[]; current: Cycle }>()
|
||||||
const activeId = defineModel<string>('activeId', { required: true })
|
const activeId = defineModel<string>('activeId', { required: true })
|
||||||
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
|
const emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ function choose(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function create() {
|
function create() {
|
||||||
|
if (props.cycles.length >= MAX_CYCLES) return
|
||||||
open.value = false
|
open.value = false
|
||||||
emit('create')
|
emit('create')
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5"
|
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:disabled="cycles.length >= MAX_CYCLES"
|
||||||
@click="create"
|
@click="create"
|
||||||
>
|
>
|
||||||
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
|
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ZoomSlider from '@/components/ZoomSlider.vue'
|
|||||||
import {
|
import {
|
||||||
blocksWith,
|
blocksWith,
|
||||||
makeBlock,
|
makeBlock,
|
||||||
|
MAX_CYCLES,
|
||||||
nextCycleId,
|
nextCycleId,
|
||||||
offsetOf,
|
offsetOf,
|
||||||
totalDuration,
|
totalDuration,
|
||||||
@@ -77,6 +78,7 @@ function select(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askCreate() {
|
function askCreate() {
|
||||||
|
if (cycles.value.length >= MAX_CYCLES) return
|
||||||
naming.value = { mode: 'create' }
|
naming.value = { mode: 'create' }
|
||||||
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
|
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
|
||||||
nameOpen.value = true
|
nameOpen.value = true
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { t } from '@/i18n'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
playing: boolean
|
|
||||||
state: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
toggle: []
|
|
||||||
preview: []
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="fixed right-0 bottom-0 left-0 z-20 border-t border-[var(--line)] bg-[var(--paper)]/90 px-4 py-3 backdrop-blur max-lg:px-5 lg:left-[4.5rem] lg:right-[24.5rem]"
|
|
||||||
>
|
|
||||||
<div class="mx-auto flex max-w-[460px] items-center justify-between gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
|
||||||
:aria-label="playing ? t('timeline.pause') : t('timeline.play')"
|
|
||||||
@click="emit('toggle')"
|
|
||||||
>
|
|
||||||
{{ playing ? t('timeline.pause') : t('timeline.play') }}
|
|
||||||
</button>
|
|
||||||
<span class="tronque text-sm font-medium">{{ state }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl border border-[var(--line)] px-3 py-1.5 text-sm transition hover:border-[var(--muted)]"
|
|
||||||
:aria-label="t('preview.open')"
|
|
||||||
@click="emit('preview')"
|
|
||||||
>
|
|
||||||
{{ t('preview.open') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -4,6 +4,7 @@ import BlockPicker from '@/components/BlockPicker.vue'
|
|||||||
import BotTile from '@/components/BotTile.vue'
|
import BotTile from '@/components/BotTile.vue'
|
||||||
import {
|
import {
|
||||||
clampDuration,
|
clampDuration,
|
||||||
|
followMove,
|
||||||
moveBlock,
|
moveBlock,
|
||||||
offsetOf,
|
offsetOf,
|
||||||
STEP,
|
STEP,
|
||||||
@@ -11,8 +12,8 @@ import {
|
|||||||
type Block,
|
type Block,
|
||||||
type StateId
|
type StateId
|
||||||
} from '@/editor/cycles'
|
} from '@/editor/cycles'
|
||||||
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
|
||||||
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
|
||||||
|
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont
|
* La piste : une regle graduee, les cartes du montage, et les gestes qui vont
|
||||||
@@ -240,10 +241,8 @@ function onBlockUp(index: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (d.to === d.from) return
|
if (d.to === d.from) return
|
||||||
// le curseur suit la carte qu'on deplace, sinon la lecture sauterait ailleurs
|
|
||||||
const suivi = block.value === d.from ? d.to : block.value
|
|
||||||
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
emit('update:blocks', moveBlock(props.blocks, d.from, d.to))
|
||||||
block.value = suivi
|
block.value = followMove(block.value, d.from, d.to)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResizeDown(index: number, e: PointerEvent) {
|
function onResizeDown(index: number, e: PointerEvent) {
|
||||||
@@ -298,7 +297,7 @@ async function onCardKey(index: number, e: KeyboardEvent) {
|
|||||||
const cible = index + sens
|
const cible = index + sens
|
||||||
if (cible < 0 || cible >= props.blocks.length) return
|
if (cible < 0 || cible >= props.blocks.length) return
|
||||||
emit('update:blocks', moveBlock(props.blocks, index, cible))
|
emit('update:blocks', moveBlock(props.blocks, index, cible))
|
||||||
block.value = cible
|
block.value = followMove(block.value, index, cible)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
|
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
|
||||||
liste?.[cible]?.focus()
|
liste?.[cible]?.focus()
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Index of the clip that was current, after `moveBlock(from, to)`. */
|
||||||
|
export function followMove(current: number, from: number, to: number): number {
|
||||||
|
if (current === from) return to
|
||||||
|
if (from < to && current > from && current <= to) return current - 1
|
||||||
|
if (from > to && current >= to && current < from) return current + 1
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
export function uniqueName(base: string, cycles: Cycle[]): string {
|
export function uniqueName(base: string, cycles: Cycle[]): string {
|
||||||
const taken = new Set(cycles.map((c) => c.name))
|
const taken = new Set(cycles.map((c) => c.name))
|
||||||
if (!taken.has(base)) return base
|
if (!taken.has(base)) return base
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ReplicaBot = {
|
|||||||
orbitGaze: (ms?: number) => void
|
orbitGaze: (ms?: number) => void
|
||||||
holdFrame: (at?: number) => void
|
holdFrame: (at?: number) => void
|
||||||
step: (dt: number) => void
|
step: (dt: number) => void
|
||||||
|
setSize: (px: number) => void
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-10
@@ -18,16 +18,39 @@ export type ReplicaInput = {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let tileQueue: Promise<unknown> = Promise.resolve()
|
const TILE_MAX = 6
|
||||||
|
let tileActive = 0
|
||||||
|
const tileWait: Array<() => void> = []
|
||||||
|
|
||||||
/** One-frame tiles share a queue so first paint does not spawn 30 engines at once. */
|
function lockTile(): Promise<void> {
|
||||||
export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise<ReplicaMount | null> {
|
if (tileActive < TILE_MAX) {
|
||||||
const job = tileQueue.then(() => mountReplica(svg, input, false))
|
tileActive++
|
||||||
tileQueue = job.then(
|
return Promise.resolve()
|
||||||
() => undefined,
|
}
|
||||||
() => undefined
|
return new Promise((resolve) => {
|
||||||
)
|
tileWait.push(() => {
|
||||||
return job
|
tileActive++
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockTile() {
|
||||||
|
tileActive--
|
||||||
|
tileWait.shift()?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function paintTile(
|
||||||
|
svg: SVGSVGElement,
|
||||||
|
input: ReplicaInput
|
||||||
|
): Promise<ReplicaMount | null> {
|
||||||
|
await lockTile()
|
||||||
|
try {
|
||||||
|
svg.innerHTML = ''
|
||||||
|
return await mountReplica(svg, input, false)
|
||||||
|
} finally {
|
||||||
|
unlockTile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mountReplica(
|
export async function mountReplica(
|
||||||
@@ -70,11 +93,12 @@ export async function mountReplica(
|
|||||||
let last = { ...input }
|
let last = { ...input }
|
||||||
return {
|
return {
|
||||||
apply(next) {
|
apply(next) {
|
||||||
if (next.state !== last.state) bot.setState(next.state, { resetEyes: true })
|
if (next.state !== last.state) bot.setState(next.state, { resetEyes: false })
|
||||||
if (next.shape !== last.shape) bot.setShape(next.shape)
|
if (next.shape !== last.shape) bot.setShape(next.shape)
|
||||||
if (next.color !== last.color) bot.setColor(next.color)
|
if (next.color !== last.color) bot.setColor(next.color)
|
||||||
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
|
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
|
||||||
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
|
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
|
||||||
|
if (next.size !== last.size) bot.setSize(next.size)
|
||||||
last = { ...next }
|
last = { ...next }
|
||||||
},
|
},
|
||||||
spin: () => bot.spinOnce(1),
|
spin: () => bot.spinOnce(1),
|
||||||
|
|||||||
+13
-1
@@ -593,11 +593,23 @@ html::-webkit-scrollbar {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fondu-enter-active,
|
||||||
|
.fondu-leave-active {
|
||||||
|
transition: opacity 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fondu-enter-from,
|
||||||
|
.fondu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.scene,
|
.scene,
|
||||||
.panneau,
|
.panneau,
|
||||||
.panneau--ouvert,
|
.panneau--ouvert,
|
||||||
.avatar {
|
.avatar,
|
||||||
|
.fondu-enter-active,
|
||||||
|
.fondu-leave-active {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-15
@@ -13,24 +13,37 @@ function serveReplica(): Plugin {
|
|||||||
'.svg': 'image/svg+xml',
|
'.svg': 'image/svg+xml',
|
||||||
'.md': 'text/markdown; charset=utf-8'
|
'.md': 'text/markdown; charset=utf-8'
|
||||||
}
|
}
|
||||||
|
function middleware(
|
||||||
|
req: { url?: string },
|
||||||
|
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (b?: Buffer) => void },
|
||||||
|
next: () => void
|
||||||
|
) {
|
||||||
|
if (!req.url?.startsWith('/replica/')) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '')
|
||||||
|
const file = path.resolve(root, rel)
|
||||||
|
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
|
||||||
|
res.end(fs.readFileSync(file))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'serve-replica',
|
name: 'serve-replica',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use(middleware)
|
||||||
if (!req.url?.startsWith('/replica/')) {
|
},
|
||||||
next()
|
configurePreviewServer(server) {
|
||||||
return
|
server.middlewares.use(middleware)
|
||||||
}
|
},
|
||||||
const rel = decodeURIComponent(req.url.slice('/replica/'.length).split('?')[0] || '')
|
writeBundle(options) {
|
||||||
const file = path.resolve(root, rel)
|
const dest = path.resolve(options.dir ?? 'dist', 'replica')
|
||||||
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
fs.cpSync(root, dest, { recursive: true })
|
||||||
res.statusCode = 404
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream')
|
|
||||||
res.end(fs.readFileSync(file))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user