clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / juice / visuals.js
visuals.js 2.8K
1/**2 * Pure projection from an in-flight entity tween to the renderer's visual3 * snapshot. Keeping this outside createJuice makes the frame math reusable4 * without giving render code access to the mutable animation maps.5 */67const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));8const easeOut = value => 1 - ((1 - value) ** 3);9const easeBack = value => {10 const c1 = 1.70158;11 const c3 = c1 + 1;12 return 1 + c3 * ((value - 1) ** 3) + c1 * ((value - 1) ** 2);13};1415export const facingFor = (dx, dy) => (16 dx > 0 ? 'right' : dx < 0 ? 'left' : dy > 0 ? 'down' : dy < 0 ? 'up' : null17);1819/**20 * @param {object} entity21 * @param {{ at: number, blocked?: object|null, reducedMotion?: boolean, faceFlipDuration?: number }} options22 */23export function projectEntityVisual(entity, {24 at,25 blocked = null,26 reducedMotion = false,27 faceFlipDuration = 70,28} = {}) {29 const progress = entity.duration ? clamp((at - entity.start) / entity.duration) : 1;30 const eased = easeOut(progress);31 let x = entity.fromX + (entity.x - entity.fromX) * eased;32 let y = entity.fromY + (entity.y - entity.fromY) * eased;33 let shakeX = 0;34 let shakeY = 0;35 if (blocked && blocked.id === entity.id && blocked.duration) {36 const n = clamp((at - blocked.start) / blocked.duration);37 const amount = Math.sin(n * Math.PI) * 0.2;38 shakeX = blocked.dir?.[0] * amount || 0;39 shakeY = blocked.dir?.[1] * amount || 0;40 }41 const squash = reducedMotion || progress >= 1 ? 0 : Math.sin(progress * Math.PI) * (entity.pushed ? 0.05 : 0.10);42 // Only the board renderer applies this to word tiles. A pushed word skids43 // toward its destination while the pusher remains level. The angle is44 // signed by horizontal travel and returns to zero at the settled cell.45 const skid = !reducedMotion && entity.pushed && entity.dx && progress < 146 ? entity.dx * 0.09 * Math.sin(progress * Math.PI)47 : 0;48 const popProgress = entity.popDuration ? clamp((at - entity.popStart) / entity.popDuration) : 1;49 const bounceProgress = entity.bounceDuration ? clamp((at - entity.bounceStart) / entity.bounceDuration) : 1;50 const popScale = entity.popDuration ? 0.55 + 0.45 * easeBack(popProgress) : 1;51 const bounceScale = entity.bounceDuration ? 1 + Math.sin(bounceProgress * Math.PI) * 0.14 : 1;52 const faceFlipProgress = !reducedMotion && Number.isFinite(entity.faceFlipStart)53 ? clamp((at - entity.faceFlipStart) / faceFlipDuration)54 : 1;55 const glyphScaleX = reducedMotion ? 1 : 1 - 0.8 * Math.sin(Math.PI * faceFlipProgress);56 return {57 id: entity.id,58 x: x + shakeX,59 y: y + shakeY,60 scaleX: (entity.dx ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,61 scaleY: (entity.dy ? 1 + squash : 1 - squash * 0.6) * popScale * bounceScale,62 moving: progress < 1,63 pushed: entity.pushed,64 skid,65 facing: entity.facing,66 glyphScaleX,67 };68}