fix:修导出时钟与切页卡顿

导出与播放共用模拟时钟;换循环、重排播放头、<grok-bot> 重挂和构建产物对齐。
自定义/动画面板留下不重画,角色高度对齐,表情走弹簧。
This commit is contained in:
Edom
2026-08-19 02:01:29 +08:00
parent 1ba126c92b
commit 851f2b8c6c
17 changed files with 179 additions and 84 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Do not commit extracted geometry, icons, or a third-party app bundle.
## 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.
+22
View File
@@ -72,6 +72,17 @@
place-items: center;
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 span {
display: block;
@@ -181,6 +192,12 @@
<span style="display:block;margin-top:8px;font-size:10px;color:#8d877b;text-align:center">登录 sizePx: 64</span>
</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">
<button id="mode-onboard" aria-pressed="true">登录轮换</button>
<button id="mode-hold" aria-pressed="false">锁定状态</button>
@@ -296,6 +313,10 @@
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) => {
const h = document.createElement("h2");
h.textContent = group.label;
@@ -387,6 +408,7 @@
document.getElementById("act-spin").onclick = () => bots.forEach((el) => el.spinOnce(1));
document.getElementById("act-bounce").onclick = () => bots.forEach((el) => el.bounceOnce());
document.getElementById("act-burst").onclick = () => bots.forEach((el) => el.burstOnce());
}
</script>
</body>
</html>
+2 -1
View File
@@ -56,7 +56,8 @@
connectedCallback() {
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");
box.setAttribute("part", "disk");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+18 -3
View File
@@ -276,10 +276,20 @@
this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG);
}
_clock() {
return this.driven ? this.last : performance.now();
}
setState(name, { resetEyes = false } = {}) {
if (!this.playlists[name]) return;
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];
this.eyeIdx = 0;
if (resetEyes) {
@@ -407,6 +417,11 @@
return this.snapshot();
}
setSize(px) {
this.sizePx = px > 0 ? px : null;
this._applyPoseScale();
}
_applyPoseScale() {
const sc = this.loginWrap ? poseScale(this.shapeName) : (this.pose.scale || 1);
this.pose.scale = sc;
@@ -529,7 +544,7 @@
}
_cycleShapeTrick() {
if (this.reduceMotion || this.paused) return;
if (this.reduceMotion || this.paused || this.driven) return;
this.trickCycle = (this.trickCycle + 1) % 5;
this.wildWide = false;
if (this.trickCycle === 0) this._pn(1);
@@ -686,7 +701,7 @@
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) {
const z = Math.random();
if (V_T.has(this.state)) {
+19 -3
View File
@@ -112,6 +112,7 @@ watch(activeId, (v) => {
ecris('cycle', v)
block.value = 0
elapsed.value = 0
state.value = cycle.value.blocks[0]?.state ?? 'idle'
})
watch(block, (i) => {
@@ -144,9 +145,12 @@ function onSeek(t: number) {
state.value = cycle.value.blocks[index]?.state ?? 'idle'
}
const gardeAnim = ref(false)
watch(view, (now) => {
intro.value = false
if (now === 'animations') {
gardeAnim.value = true
playing.value = true
state.value = cycle.value.blocks[block.value]?.state ?? 'idle'
return
@@ -248,6 +252,11 @@ function annuleCycle() {
watch(dialogueCycle, (ouverte) => {
if (ouverte) erreurCycle.value = false
else annuleCycle()
})
watch([view, preview], () => {
if (view.value !== 'animations' || preview.value) annuleCycle()
})
const CONFIRMATION_MS = 1800
@@ -330,6 +339,7 @@ onUnmounted(() => {
clearTimeout(pending)
clearTimeout(minuteurBarre)
clearTimeout(confirmation)
annuleCycle()
})
</script>
@@ -350,7 +360,8 @@ onUnmounted(() => {
<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="[
!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',
nue || preview ? 'scene--seule' : view === 'reglages' && 'scene--gauche'
]"
@@ -427,23 +438,27 @@ onUnmounted(() => {
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
>
<Customizer
v-if="view === 'personnaliser'"
v-show="view === 'personnaliser'"
v-model:shape="shape"
v-model:color="color"
v-model:expression="expression"
:ready="ready"
:active="view === 'personnaliser'"
/>
<AnimPanel
v-else-if="view === 'animations'"
v-if="gardeAnim"
v-show="view === 'animations'"
v-model="state"
:shape="shape"
:color="color"
:ready="ready"
:active="view === 'animations'"
@pick="addBlock"
/>
</aside>
</div>
<Transition name="fondu">
<Timeline
v-if="view === 'animations' && !preview"
v-model:cycles="cycles"
@@ -458,6 +473,7 @@ onUnmounted(() => {
@preview="preview = true"
@exporter="dialogueCycle = true"
/>
</Transition>
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
</template>
+2
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
shape: string
color: string
ready?: boolean
active?: boolean
}>()
const groups = computed(() => {
@@ -43,6 +44,7 @@ const groups = computed(() => {
:state="id"
:shape="shape"
:color="color"
:active="active"
@click="emit('pick', id)"
/>
</div>
+17 -2
View File
@@ -12,8 +12,9 @@ const props = withDefaults(
size?: number
interactive?: boolean
showLabel?: boolean
active?: boolean
}>(),
{ interactive: true, showLabel: true }
{ interactive: true, showLabel: true, active: true }
)
const svg = ref<SVGSVGElement | null>(null)
@@ -22,7 +23,6 @@ const painted = ref(false)
async function paint() {
const node = svg.value
if (!node) return
node.innerHTML = ''
const mount = await paintTile(node, {
state: props.state ?? 'idle',
shape: props.shape ?? 'blob',
@@ -34,12 +34,27 @@ async function paint() {
painted.value = !!mount
}
const dirty = ref(false)
onMounted(() => {
void paint()
})
watch(() => [props.state, props.shape, props.color], () => {
if (!props.active) {
dirty.value = true
return
}
void paint()
})
watch(
() => props.active,
(on) => {
if (on && dirty.value) {
dirty.value = false
void paint()
}
}
)
</script>
<template>
+3 -1
View File
@@ -4,7 +4,7 @@ import BotTile from '@/components/BotTile.vue'
import { shapeName, stateName, t } from '@/i18n'
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 color = defineModel<string>('color', { required: true })
const expression = defineModel<string>('expression', { required: true })
@@ -26,6 +26,7 @@ const shapes = computed(() => {
:shape="id"
:color="color"
:state="expression"
:active="active"
@click="shape = id"
/>
</div>
@@ -40,6 +41,7 @@ const shapes = computed(() => {
:shape="shape"
:color="color"
:state="id"
:active="active"
@click="expression = id"
/>
</div>
+5 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
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'
/**
@@ -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,
* 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 emit = defineEmits<{ create: []; remove: [id: string]; rename: [id: string] }>()
@@ -21,6 +21,7 @@ function choose(id: string) {
}
function create() {
if (props.cycles.length >= MAX_CYCLES) return
open.value = false
emit('create')
}
@@ -135,7 +136,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onOutside))
<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"
>
<span class="w-3 shrink-0 text-[var(--muted)]">+</span>
+2
View File
@@ -8,6 +8,7 @@ import ZoomSlider from '@/components/ZoomSlider.vue'
import {
blocksWith,
makeBlock,
MAX_CYCLES,
nextCycleId,
offsetOf,
totalDuration,
@@ -77,6 +78,7 @@ function select(id: string) {
}
function askCreate() {
if (cycles.value.length >= MAX_CYCLES) return
naming.value = { mode: 'create' }
nameDraft.value = uniqueName(t('cycles.newName'), cycles.value)
nameOpen.value = true
-39
View File
@@ -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 -5
View File
@@ -4,6 +4,7 @@ import BlockPicker from '@/components/BlockPicker.vue'
import BotTile from '@/components/BotTile.vue'
import {
clampDuration,
followMove,
moveBlock,
offsetOf,
STEP,
@@ -11,8 +12,8 @@ import {
type Block,
type StateId
} from '@/editor/cycles'
import { BASE_SCALE, clampZoom, ticksFor } from '@/ui/timeline'
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
@@ -240,10 +241,8 @@ function onBlockUp(index: number) {
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))
block.value = suivi
block.value = followMove(block.value, d.from, d.to)
}
function onResizeDown(index: number, e: PointerEvent) {
@@ -298,7 +297,7 @@ async function onCardKey(index: number, e: KeyboardEvent) {
const cible = index + sens
if (cible < 0 || cible >= props.blocks.length) return
emit('update:blocks', moveBlock(props.blocks, index, cible))
block.value = cible
block.value = followMove(block.value, index, cible)
await nextTick()
const liste = track.value?.querySelectorAll<HTMLButtonElement>('[data-carte]')
liste?.[cible]?.focus()
+8
View File
@@ -93,6 +93,14 @@ export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
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 {
const taken = new Set(cycles.map((c) => c.name))
if (!taken.has(base)) return base
+1
View File
@@ -13,6 +13,7 @@ export type ReplicaBot = {
orbitGaze: (ms?: number) => void
holdFrame: (at?: number) => void
step: (dt: number) => void
setSize: (px: number) => void
destroy: () => void
}
+34 -10
View File
@@ -18,16 +18,39 @@ export type ReplicaInput = {
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. */
export function paintTile(svg: SVGSVGElement, input: ReplicaInput): Promise<ReplicaMount | null> {
const job = tileQueue.then(() => mountReplica(svg, input, false))
tileQueue = job.then(
() => undefined,
() => undefined
)
return job
function lockTile(): Promise<void> {
if (tileActive < TILE_MAX) {
tileActive++
return Promise.resolve()
}
return new Promise((resolve) => {
tileWait.push(() => {
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(
@@ -70,11 +93,12 @@ export async function mountReplica(
let last = { ...input }
return {
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.color !== last.color) bot.setColor(next.color)
if (next.follow !== last.follow) bot.setFollowPointer(next.follow)
if (next.paper !== last.paper) bot.setEyeColor(next.paper)
if (next.size !== last.size) bot.setSize(next.size)
last = { ...next }
},
spin: () => bot.spinOnce(1),
+13 -1
View File
@@ -593,11 +593,23 @@ html::-webkit-scrollbar {
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) {
.scene,
.panneau,
.panneau--ouvert,
.avatar {
.avatar,
.fondu-enter-active,
.fondu-leave-active {
transition: none;
}
+28 -15
View File
@@ -13,24 +13,37 @@ function serveReplica(): Plugin {
'.svg': 'image/svg+xml',
'.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 {
name: 'serve-replica',
configureServer(server) {
server.middlewares.use((req, res, next) => {
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))
})
server.middlewares.use(middleware)
},
configurePreviewServer(server) {
server.middlewares.use(middleware)
},
writeBundle(options) {
const dest = path.resolve(options.dir ?? 'dist', 'replica')
fs.cpSync(root, dest, { recursive: true })
}
}
}