148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { PROFILE_SAMPLES } from './profiles'
|
|
import {
|
|
hullOfCircles,
|
|
profileFromPolygon,
|
|
regularPolygonProfile,
|
|
superellipseProfile,
|
|
unionOfCirclesProfile
|
|
} from './shape'
|
|
|
|
/**
|
|
* Formes et couleurs proposees par le personnalisateur du bot.
|
|
*
|
|
* A la difference des silhouettes d'animation (`profiles.ts`), celles-ci ne sont
|
|
* PAS relevees sur la video : elles sont construites analytiquement d'apres la
|
|
* grille du personnalisateur d'origine. Deux sources distinctes, donc, et c'est
|
|
* volontaire — les etats animes doivent rester fideles a la video, les formes de
|
|
* base sont un choix d'utilisateur.
|
|
*/
|
|
|
|
/**
|
|
* Les identifiants sont enumeres plutot que deduits du tableau : c'est ce qui
|
|
* permet a la couche i18n de verifier A LA COMPILATION que chaque forme a bien
|
|
* sa traduction dans les trois langues (`t(\`shapes.${id}\`)` ne compile que si
|
|
* la cle existe). Un `as const` sur le tableau aurait le meme effet mais
|
|
* rendrait `radii` en lecture seule, alors que le moteur le passe tel quel.
|
|
*/
|
|
export type ShapeId =
|
|
| 'cercle'
|
|
| 'galet'
|
|
| 'squircle'
|
|
| 'capsule'
|
|
| 'triangle'
|
|
| 'hexagone'
|
|
| 'nuage'
|
|
| 'goutte'
|
|
|
|
export interface BotShape {
|
|
id: ShapeId
|
|
radii: number[]
|
|
}
|
|
|
|
/** Ramene le rayon maximal a `max` pour que toutes les formes pesent pareil a l'oeil. */
|
|
function normalize(radii: number[], max = 1): number[] {
|
|
const peak = Math.max(...radii)
|
|
if (peak <= 0) return radii
|
|
const k = max / peak
|
|
return radii.map((r) => r * k)
|
|
}
|
|
|
|
const ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => (i / PROFILE_SAMPLES) * Math.PI * 2)
|
|
|
|
/** Galet : cercle deforme par deux harmoniques basses, donc irregulier mais lisse. */
|
|
const pebble = normalize(
|
|
ANGLES.map((a) => 1 + 0.075 * Math.cos(2 * a + 0.5) + 0.035 * Math.cos(3 * a + 2.1)),
|
|
1.02
|
|
)
|
|
|
|
/** Nuage : union de bosses, large en bas, deux lobes en haut. */
|
|
const cloud = normalize(
|
|
unionOfCirclesProfile([
|
|
{ x: -0.44, y: 0.2, r: 0.54 },
|
|
{ x: 0.46, y: 0.2, r: 0.5 },
|
|
{ x: 0.02, y: 0.3, r: 0.6 },
|
|
{ x: -0.24, y: -0.3, r: 0.48 },
|
|
{ x: 0.3, y: -0.24, r: 0.44 }
|
|
]),
|
|
1.02
|
|
)
|
|
|
|
/** Goutte : gros disque en bas, pointe effilee en haut. */
|
|
const droplet = normalize(
|
|
profileFromPolygon(hullOfCircles(0, 0.28, 0.66, 0, -0.96, 0.05), 0, 0),
|
|
1.04
|
|
)
|
|
|
|
/** Capsule couchee : enveloppe de deux disques cote a cote. */
|
|
const capsule = profileFromPolygon(hullOfCircles(-0.42, 0, 0.62, 0.42, 0, 0.62), 0, 0)
|
|
|
|
export const SHAPES: BotShape[] = [
|
|
{ id: 'cercle', radii: new Array(PROFILE_SAMPLES).fill(1) },
|
|
{ id: 'galet', radii: pebble },
|
|
// 1.15 et pas 1.02 : sur une superellipse le rayon maximal est la diagonale,
|
|
// donc normaliser dessus donne une forme qui parait plus petite que le cercle.
|
|
{ id: 'squircle', radii: normalize(superellipseProfile(4.2), 1.15) },
|
|
{ id: 'capsule', radii: capsule },
|
|
// -90deg : un sommet vers le haut de l'ecran (y est oriente vers le bas)
|
|
{ id: 'triangle', radii: regularPolygonProfile(3, 1.12, 0.34, -90) },
|
|
// 0deg : sommets a gauche et a droite, donc aretes du haut et du bas plates
|
|
{ id: 'hexagone', radii: regularPolygonProfile(6, 1.04, 0.26, 0) },
|
|
{ id: 'nuage', radii: cloud },
|
|
{ id: 'goutte', radii: droplet }
|
|
]
|
|
|
|
// Map indexee par `string` et non par `ShapeId` : les appelants interrogent avec
|
|
// une valeur relue du localStorage ou d'une prop, donc non validee.
|
|
export const SHAPE_BY_ID = new Map<string, BotShape>(SHAPES.map((s) => [s.id, s]))
|
|
export const DEFAULT_SHAPE = 'cercle'
|
|
|
|
export type ColorId =
|
|
| 'encre'
|
|
| 'creme'
|
|
| 'brun'
|
|
| 'rouge'
|
|
| 'orange'
|
|
| 'ambre'
|
|
| 'vert'
|
|
| 'turquoise'
|
|
| 'bleu'
|
|
| 'violet'
|
|
| 'rose'
|
|
| 'gris'
|
|
|
|
export interface BotColor {
|
|
id: ColorId
|
|
hex: string
|
|
}
|
|
|
|
/** Palette du personnalisateur d'origine. */
|
|
export const COLORS: BotColor[] = [
|
|
{ id: 'encre', hex: '#0a0a0c' },
|
|
{ id: 'brun', hex: '#8b5e3c' },
|
|
{ id: 'rouge', hex: '#e8483f' },
|
|
{ id: 'orange', hex: '#f08a24' },
|
|
{ id: 'ambre', hex: '#f0b429' },
|
|
{ id: 'vert', hex: '#3ecf8e' },
|
|
{ id: 'turquoise', hex: '#2fbfa0' },
|
|
{ id: 'bleu', hex: '#3b93f0' },
|
|
{ id: 'violet', hex: '#8b5cf6' },
|
|
{ id: 'rose', hex: '#e152b0' },
|
|
{ id: 'gris', hex: '#a3a3a3' },
|
|
{ id: 'creme', hex: '#f1efe9' }
|
|
]
|
|
|
|
export const COLOR_BY_ID = new Map<string, BotColor>(COLORS.map((c) => [c.id, c]))
|
|
export const DEFAULT_COLOR = 'encre'
|
|
|
|
/** Melange deux couleurs hex. Sert a la brume de profondeur des particules. */
|
|
export function mixHex(from: string, to: string, t: number): string {
|
|
const parse = (h: string) => {
|
|
const v = parseInt(h.slice(1), 16)
|
|
return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
|
|
}
|
|
const a = parse(from)
|
|
const b = parse(to)
|
|
const c = a.map((x, i) => Math.round(x + (b[i]! - x) * t))
|
|
return `#${c.map((x) => x.toString(16).padStart(2, '0')).join('')}`
|
|
}
|