feat:语言只留中英并默认中文

去掉法语,首次打开用中文。README 按 bloub 的段落重写,补上嵌入用法。
This commit is contained in:
Edom
2026-08-21 01:26:05 +08:00
parent ba78973e51
commit d91c160138
7 changed files with 95 additions and 235 deletions
+82 -10
View File
@@ -3,7 +3,13 @@
Unofficial study of a spring-driven character engine.
非官方学习项目,与 xAI / Grok 无关。本仓库只开源机芯,不含角色几何、商标或第三方应用源码。
The **site** follows [bloub](https://github.com/jeremy-prt/bloub)’s presentation: a left rail, one character in the middle, Customise / Animations / Settings. The **motion** is ours — springs, 39 states, polygon eyes, overlays, login wrap. Not bloub’s measured 14-state engine.
One filled shape that morphs between **39 states**, **polygon eyes** that morph
on their own playlist, overlays on some states, springs on move / squash / blink /
spin. No animation library.
The **site** follows [bloub](https://github.com/jeremy-prt/bloub)’s presentation:
a left rail, one character in the middle, Customise / Animations / Settings.
The **motion** is ours — not bloub’s measured 14-state engine.
## Running it
@@ -14,21 +20,51 @@ pnpm dev
Then open http://localhost:5191.
Clone the repo and the chrome still loads. Without `replica/geometry-data.js` the character stays off; the layout does not fall over. Put a file that matches [`replica/geometry.schema.md`](./replica/geometry.schema.md) at `replica/geometry-data.js` (gitignored) to see the bot.
```bash
pnpm build # vue-tsc --noEmit && vite build
```
The original lab is still there:
Vue 3, Vite, TypeScript, Tailwind 4. No ESLint and no Prettier: `vue-tsc` is the
only gate, so run `pnpm build` before you call something done.
- [`replica/index.html`](./replica/index.html) — playground + HUD
- [`replica/showcase.html`](./replica/showcase.html) — recording layouts
- [`replica/embed.html`](./replica/embed.html) — `<grok-bot>`
Clone the repo and the chrome still loads. Without `replica/geometry-data.js`
the character stays off; the layout does not fall over. Put a file that matches
[`replica/geometry.schema.md`](./replica/geometry.schema.md) at
`replica/geometry-data.js` (gitignored) to see the bot.
Do not commit extracted geometry, icons, or a third-party app bundle.
## What's on the site
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 rail on the left switches between three views. **Customise** offers 8 body
shapes, 11 inks and 17 rest expressions, kept between visits, plus a manual pose
overlay (drag the body, tune an eye). **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**
holds the language (Chinese or English, Chinese by default) and the 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.
Anything on screen can be exported: the avatar as PNG, SVG or an animated GIF,
and a whole timeline as GIF or MP4. The still formats need no library at all,
and the video encoder is only fetched the first time you ask for one.
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.
Three URLs are worth knowing:
- [`replica/index.html`](./replica/index.html) — playground + HUD
- [`replica/showcase.html`](./replica/showcase.html) — recording layouts
- [`replica/embed.html`](./replica/embed.html) — `<grok-bot>`
## Why this isn't bloub
bloub measured 14 states off a video and plays exponential ease-outs with no
overshoot. This repo studies the spring-driven character: 39 states, polygon
eyes, overlays, login wrap. Rounding a spring or swapping a playlist changes
the feel, which is the thing this project is trying to keep.
Geometry is not in the tree on purpose. The engine runs against
`window.GROK_GEO`; without it the site still stands.
## What the engine does
@@ -40,11 +76,17 @@ The centre avatar is always `GrokCharacter` in `hold` mode. Tiles paint one fram
Login-disk defaults: `sizePx: 64`, `color: "black"`, `shape: "blob"`.
## Layout
## How it's put together
`replica/src/` is framework-free. `GrokCharacter` owns the clock; Vue only
mounts it, paints tiles, and (in Customise) overlays the pose editor. Missing
geometry keeps the chrome. `GROK.create` / `<grok-bot>` live in
`replica/src/api.js`.
| Path | What |
|---|---|
| `src/` | Vue site (bloub-like chrome) |
| `src/ui/pose/` | manual pose, head surfaces |
| `replica/index.html` | playground |
| `replica/geometry.schema.md` | `window.GROK_GEO` contract, no data |
| `replica/src/math.js` | springs, lerp, polygons |
@@ -63,9 +105,39 @@ This repo deliberately does not contain:
- unpacked app slices
- unpack scripts
## Using the component
```vue
<Hero state="idle" shape="blob" color="black" :size="120" />
```
```html
<grok-bot state="idle" shape="blob" color="black" size="64"></grok-bot>
```
```js
const { setState, setShape, setColor, setPaused, spin, bounce, destroy } = GROK.create("#host", {
sizePx: 64,
shape: "blob",
color: "black",
loginWrap: true,
mode: "hold",
});
```
`GROK.create` / `GrokCharacter` default to `mode: "hold"` and will not cycle on
their own. Pass `mode: "onboarding"` for the login beat. Public methods:
`setState` `setShape` `setColor` `setPaused` `spin` `bounce` `destroy`.
`<grok-bot>` attributes: `state` `shape` `color` `size` `paused`
`follow="true"` `mode` `emphasis`.
See [`src/components/Hero.vue`](src/components/Hero.vue) and
[`replica/src/api.js`](replica/src/api.js) for the details.
## License
[MIT](./LICENSE) covers the original engine, site and docs in this repository.
It does not grant rights to third-party character art, trademarks or application source; those are not in this repository.
Site chrome is arranged after bloub’s three-column scene. bloub is MIT; “Grok” and “x.ai” belong to their owners. Not affiliated with either.
Site chrome is arranged after bloub’s three-column scene. bloub is MIT; “Grok”
and “x.ai” belong to their owners. Not affiliated with either.
+2 -2
View File
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="zh-Hans">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>study — spring character</title>
<title>study — 弹簧角色</title>
<meta
name="description"
content="Unofficial study of a spring-driven character engine. Site layout after bloub; motion from the replica engine."
+3 -6
View File
@@ -2,23 +2,20 @@ import { computed, ref, watchEffect } from 'vue'
import { ecris, lis } from '@/ui/stockage'
import { formePlurielle, interpoler } from './format'
import { choisirLangue, estLangue, type Langue, tagDe } from './langues'
import fr from './locales/fr'
import en from './locales/en'
import zh from './locales/zh'
export { LANGUES, type Langue } from './langues'
const dictionnaires: Record<Langue, typeof fr> = { fr, en, zh }
const dictionnaires: Record<Langue, typeof zh> = { zh, en }
type Chemins<T, P extends string = ''> = {
[K in keyof T & string]: T[K] extends string ? `${P}${K}` : Chemins<T[K], `${P}${K}.`>
}[keyof T & string]
export type Cle = Chemins<typeof fr>
export type Cle = Chemins<typeof zh>
const courante = ref<Langue>(
choisirLangue(lis('langue'), typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]))
)
const courante = ref<Langue>(choisirLangue(lis('langue')))
export const langue = computed<Langue>({
get: () => courante.value,
+5 -16
View File
@@ -1,12 +1,11 @@
export const LANGUES = [
{ id: 'fr', tag: 'fr', emoji: '🇫🇷', nom: 'Français' },
{ id: 'en', tag: 'en', emoji: '🇬🇧', nom: 'English' },
{ id: 'zh', tag: 'zh-Hans', emoji: '🇨🇳', nom: '简体中文' }
{ id: 'zh', tag: 'zh-Hans', emoji: '🇨🇳', nom: '简体中文' },
{ id: 'en', tag: 'en', emoji: '🇬🇧', nom: 'English' }
] as const
export type Langue = (typeof LANGUES)[number]['id']
export const LANGUE_PAR_DEFAUT: Langue = 'en'
export const LANGUE_PAR_DEFAUT: Langue = 'zh'
export function estLangue(valeur: string | null | undefined): valeur is Langue {
return LANGUES.some((l) => l.id === valeur)
@@ -16,16 +15,6 @@ export function tagDe(langue: Langue): string {
return LANGUES.find((l) => l.id === langue)!.tag
}
export function choisirLangue(memorisee: string | null, preferences: readonly string[]): Langue {
if (estLangue(memorisee)) return memorisee
for (const tag of preferences) {
let base: string
try {
base = new Intl.Locale(tag).language
} catch {
continue
}
if (estLangue(base)) return base
}
return LANGUE_PAR_DEFAUT
export function choisirLangue(memorisee: string | null): Langue {
return estLangue(memorisee) ? memorisee : LANGUE_PAR_DEFAUT
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type fr from './fr'
import type zh from './zh'
const en: typeof fr = {
const en: typeof zh = {
app: {
name: 'study',
title: 'study — spring character',
-196
View File
@@ -1,196 +0,0 @@
const fr = {
app: {
name: 'study',
title: 'study — personnage à ressorts',
botAria: 'Personnage animé par ressorts',
missingGeo:
'Géométrie locale absente. L’interface reste là ; le personnage non. Voir replica/geometry.schema.md.'
},
rail: {
nav: 'Sections',
customize: 'Personnaliser',
animations: 'Animations',
settings: 'Réglages'
},
panel: {
animations: 'Animation',
shape: 'Forme',
expression: 'Expression',
color: 'Couleur',
group_lifecycle: 'Cycle',
group_reactions: 'Réactions',
group_agent: 'Agent',
group_product: 'Produit'
},
shapes: {
blob: 'Blob',
bean: 'Haricot',
egg: 'Œuf',
teardrop: 'Goutte',
cloud: 'Nuage',
leaf: 'Feuille',
squircle: 'Cube',
capsule: 'Capsule'
},
export: {
action: 'Exporter en PNG',
more: 'Autres formats',
png: 'Télécharger le PNG',
svg: 'Télécharger le SVG',
anime: "Télécharger l'animation SVG",
gif: 'Télécharger le GIF animé',
cycleDetail: 'La vidéo est plus légère et plus fluide ; le GIF passe partout.',
cycleFormat: 'Format',
cycle_mp4: 'Vidéo MP4',
cycle_mp4_aide: 'Léger et fluide, fond obligatoire',
cycle_gif: 'GIF animé',
cycle_gif_aide: 'Lu partout, plus lourd',
cycleProgress: 'Export en cours…',
cycleReessayer: 'Réessayer',
gifTitle: 'Télécharger le GIF animé',
gifDetail:
"Le GIF ne gère la transparence qu'en tout ou rien : sans fond, le contour de la boule est un peu dur.",
gifBackground: 'Fond',
fond_blanc: 'Fond blanc',
fond_blanc_aide: 'Contour lisse, à poser sur du clair',
fond_transparent: 'Fond transparent',
fond_transparent_aide: "S'adapte à tout fond, contour un peu dur",
gifConfirm: 'Télécharger',
copie: "Copier l'image",
copieSvg: 'Copier le SVG',
done: 'Exporté',
copied: 'Copié',
failed: "Échec de l'export"
},
preview: {
exit: 'Quitter l’aperçu',
open: 'Aperçu',
key: 'Échap'
},
timeline: {
play: 'Lancer la lecture',
pause: 'Arrêter la lecture',
addAnimation: 'Ajouter une animation',
preview: 'Aperçu',
export: "Exporter la séquence d'animation",
zoom: 'Zoom de la piste',
blockAria: '{state}, {duration}',
blockDurationAria: 'Durée de {state}, {duration}',
blockRemoveAria: 'Retirer {state}'
},
playback: {
details: 'Détails de l’animation',
show: 'Afficher les détails de l’animation',
hide: 'Masquer les détails de l’animation',
playing: 'En lecture',
paused: 'En pause',
stopped: 'Arrêté',
play: 'Lancer {name}',
pause: 'Mettre {name} en pause',
stop: 'Arrêter {name}',
mode: 'Mode de lecture',
loop: 'boucle',
clips: 'Poses',
current: 'Pose en cours',
stepOf: '{n} / {m}',
summary: 'Les poses d’œil de cette expression, en boucle.',
blinkFirst: 'Premier clignement',
blinkAfter: 'après le lancement',
blinkGap: 'Intervalle du clignement',
blinkRand: 'tirage aléatoire',
blinkOff: 'Désactivé'
},
dialog: {
cancel: 'Annuler',
nameCreateTitle: 'Nouveau cycle',
nameRenameTitle: 'Renommer le cycle',
nameField: 'Nom du cycle',
nameCreate: 'Créer',
nameRename: 'Renommer',
removeTitle: 'Supprimer « {name} » ?',
removeDetail:
'Ce montage sera perdu, avec son animation. | Ce montage sera perdu, avec ses {n} animations.',
removeConfirm: 'Supprimer'
},
cycles: {
defaultName: 'Cycle par défaut',
newName: 'Mon cycle',
menuNew: 'Nouveau cycle',
menuRenameAria: 'Renommer {name}',
menuRemoveAria: 'Supprimer {name}'
},
states: {
sleeping: 'Sommeil',
waking: 'Réveil',
idle: 'Repos',
listening: 'Écoute',
thinking: 'Réflexion',
searching: 'Recherche',
working: 'Travail',
excited: 'Excité',
surprised: 'Surpris',
suspicious: 'Méfiant',
angry: 'En colère',
drowsy: 'Somnolent',
happy: 'Heureux',
curious: 'Curieux',
confused: 'Confus',
bored: 'Blasé',
proud: 'Fier',
shy: 'Timide',
sad: 'Triste',
laughing: 'Rire',
scared: 'Effrayé',
playful: 'Joueur',
celebrate: 'Célébration',
orbit: 'Orbite',
radar: 'Radar',
progress: 'Progrès',
spawning: 'Apparition',
humming: 'Bourdonnement',
loading: 'Chargement',
dictating: 'Dictée',
writing: 'Écriture',
sending: 'Envoi',
receiving: 'Réception',
uploading: 'Téléversement',
notifying: 'Notification',
alerting: 'Alerte',
dragging: 'Glisser',
bouncing: 'Rebond',
'powering-down': 'Extinction'
},
settings: {
language: 'Langue',
about: 'À propos',
lab: 'Laboratoire',
labAria: 'Ouvrir le playground d’origine',
showcase: 'Showcase',
showcaseAria: 'Ouvrir le showcase d’enregistrement',
credits:
'Étude non officielle. Le mouvement vient du moteur replica ; la mise en page, de bloub. Sans lien avec xAI.',
missing: 'Pas de geometry-data.js — chrome seul, pas de personnage.'
},
units: {
seconds: '{n} s',
secondsShort: '{n}s'
},
manual: {
title: 'Réglage manuel',
help: 'Une fois activé, glisse le corps pour le tourner, clique un œil pour L/H/S/R/E, ou les anneaux en haut à droite.',
on: 'Activer le réglage manuel',
reset: 'Réinitialiser',
head: 'Rotation de la tête',
body: 'Corps',
spin: 'Roulis',
eyes: 'Yeux',
width: 'L',
height: 'H',
size: 'S',
spacing: 'Écart',
gizmo: 'Manipulateur de rotation',
plane: 'Déplacer dans le plan'
}
}
export default fr
+1 -3
View File
@@ -1,6 +1,4 @@
import type fr from './fr'
const zh: typeof fr = {
const zh = {
app: {
name: 'study',
title: 'study — 弹簧角色',