diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 0595f87..cd9ac2e 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -88,6 +88,9 @@ "extra": { "router": { "origin": false + }, + "eas": { + "projectId": "69115b2d-38e8-4c5c-b0bd-02f1ce15f561" } } } diff --git a/apps/mobile/assets/rive/outhere-logo.riv b/apps/mobile/assets/rive/outhere-logo.riv new file mode 100644 index 0000000..e45d97a Binary files /dev/null and b/apps/mobile/assets/rive/outhere-logo.riv differ diff --git a/apps/mobile/assets/rive/outhere-versus.riv b/apps/mobile/assets/rive/outhere-versus.riv new file mode 100644 index 0000000..21ece7a Binary files /dev/null and b/apps/mobile/assets/rive/outhere-versus.riv differ diff --git a/apps/mobile/assets/rive/silhouette-female-blue.jpg b/apps/mobile/assets/rive/silhouette-female-blue.jpg new file mode 100644 index 0000000..0114320 Binary files /dev/null and b/apps/mobile/assets/rive/silhouette-female-blue.jpg differ diff --git a/apps/mobile/assets/rive/silhouette-female-red.jpg b/apps/mobile/assets/rive/silhouette-female-red.jpg new file mode 100644 index 0000000..0c73dc7 Binary files /dev/null and b/apps/mobile/assets/rive/silhouette-female-red.jpg differ diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 8a5e93a..4534dda 100755 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -4,6 +4,9 @@ const path = require('node:path'); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); +// Rive files (assets/rive) are bundled like images. +config.resolver.assetExts.push('riv'); + const WEB_ALIASES = { 'expo-secure-store': path.resolve(__dirname, './polyfills/web/secureStore.web.ts'), 'react-native-webview': path.resolve(__dirname, './polyfills/web/webview.web.tsx'), diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ee13f9c..c143739 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -25,6 +25,8 @@ "@react-native-masked-view/masked-view": "0.3.2", "@react-native-picker/picker": "2.11.4", "@react-three/fiber": "9.8.1", + "@rive-app/canvas": "2.43.1", + "@rive-app/react-native": "0.5.5", "@shopify/react-native-skia": "2.14.0", "@supabase/supabase-js": "^2.117.3", "@tanstack/react-query": "5.72.2", @@ -42,6 +44,7 @@ "expo-clipboard": "~58.0.3", "expo-constants": "~58.0.12", "expo-contacts": "~58.0.7", + "expo-dev-client": "~58.0.14", "expo-device": "~58.0.5", "expo-document-picker": "~58.0.4", "expo-file-system": "~58.0.9", @@ -81,6 +84,7 @@ "react-native-gesture-handler": "3.3.0", "react-native-graph": "1.1.0", "react-native-maps": "1.29.11", + "react-native-nitro-modules": "0.36.5", "react-native-qrcode-svg": "6.3.21", "react-native-reanimated": "4.7.1", "react-native-reanimated-carousel": "4.0.2", diff --git a/apps/mobile/public/rive.wasm b/apps/mobile/public/rive.wasm new file mode 100755 index 0000000..bfe037a Binary files /dev/null and b/apps/mobile/public/rive.wasm differ diff --git a/apps/mobile/src/app/battles/[battleId].jsx b/apps/mobile/src/app/battles/[battleId].jsx index aebe2fe..31217a9 100644 --- a/apps/mobile/src/app/battles/[battleId].jsx +++ b/apps/mobile/src/app/battles/[battleId].jsx @@ -22,6 +22,7 @@ import { EmoteBurst } from "@/components/arena/RaceScreens"; import ClassicBattle from "@/components/arena/ClassicBattle"; import { RaceScreen, RevealScreen, SealedScreen } from "@/components/arena/RaceScreens"; import Confetti from "@/components/arena/Confetti"; +import VersusReveal, { useFirstReveal } from "@/components/arena/VersusReveal"; import { durationLabel, formatScore, metricUnit, playerName, useNow } from "@/components/arena/shared"; import { BATTLE_EMOTES, BATTLE_LINES, emoteLabel } from "@/data/seedOutside"; import { PHOTOS } from "@/constants/photos"; @@ -328,11 +329,21 @@ function Result({ battle }) { Share.share({ message: `${won ? "Beat" : "Battled"} ${oppName} on Outhere: ${formatScore(metric, mine)} to ${formatScore(metric, theirs)}${metric === "steps" ? " steps" : ""}.` }).catch(() => {}); const objectSize = Math.min(width * 0.7, 280); const packPic = won ? battle.me.player?.fighter?.win : draw ? null : battle.me.player?.fighter?.respect; + // The first time, the result lands on the 1v1 card (VersusReveal) and + // the card's own cues play it in the hand. + const [reveal, endReveal] = useFirstReveal(battle); + const buzzed = useRef(false); // The result lands in the hand once: rising taps for a win, a soft drop for a loss. useEffect(() => { + if (reveal === "checking" || buzzed.current) return; + buzzed.current = true; + if (reveal === "show") return; if (won) haptic.win(); else if (!draw) haptic.lose(); - }, [won, draw]); + }, [won, draw, reveal]); + + if (reveal === "checking") return ; + if (reveal === "show") return ; return ( diff --git a/apps/mobile/src/app/battles/countdown/[battleId].jsx b/apps/mobile/src/app/battles/countdown/[battleId].jsx index db00ade..9eee26b 100644 --- a/apps/mobile/src/app/battles/countdown/[battleId].jsx +++ b/apps/mobile/src/app/battles/countdown/[battleId].jsx @@ -1,17 +1,21 @@ -// The versus screen before a 1v1, like a fighting game (the prototype's -// .vs9): both fighters slide in on either side of a slanted, glowing seam, -// the head to head sits up top, and VS turns into the count. Each beat -// flashes white and fires a ring from the middle while the number slams in -// (SpotlightNumber). When the battle is live it hands over to the battle -// screen; while it waits on the other side to accept, it says so. - -import React, { useEffect, useState } from "react"; +// The versus screen before a 1v1, like a fighting game. Where the build can +// play Rive it is the 1v1 card (rive/versus): the intro picked for the +// battle (seam strike, tale of the tape, cinematic or Let's Run), then the +// card holds a moment and the battle opens. In Expo Go it is the prototype's +// .vs9 in React Native: both fighters slide in on either side of a slanted, +// glowing seam, the head to head sits up top, and VS turns into the count. +// Each beat flashes white and fires a ring from the middle while the number +// slams in (SpotlightNumber). Either way, while it waits on the other side +// to accept, it says so. + +import React, { useEffect, useMemo, useRef, useState } from "react"; import { StyleSheet, Text, View, useWindowDimensions } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BlurView } from "expo-blur"; -import Animated, { Easing, FadeInLeft, FadeInRight, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated"; -import { useBattle, useHeadToHead, useProfile } from "@/data/hooks"; +import Animated, { Easing, FadeIn, FadeInLeft, FadeInRight, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated"; +import { useBattle, useHeadToHead, usePlayer, useProfile } from "@/data/hooks"; +import { INTRO_MS, VersusCard, pickIntro, riveAvailable, versusCard } from "@/components/rive"; import { fighterPhoto } from "@/utils/fighter"; import { Avatar, C, F, GlowButton, Grain, R, S, Skeleton, SpotlightNumber, VersusSplit } from "@/components/ui"; import { TopBar } from "@/components/arena/OutsideKit"; @@ -47,6 +51,117 @@ function Burst({ beat, x, y }) { } export default function BattleCountdown() { + return riveAvailable ? : ; +} + +// The card is 390 x 844 and covers the screen; its stake tiles start 736 down. +const CARD = { w: 390, h: 844, tiles: 736 }; +// How long the card holds once it is up before the battle opens. The tape +// holds longer so its numbers can be read. +const HOLD_MS = { tape: 2600, cinematic: 1400, letsRun: 1400, strike: 1400 }; + +/** The 1v1 card. */ +function CardCountdown() { + const router = useRouter(); + const { width, height } = useWindowDimensions(); + const { battleId } = useLocalSearchParams(); + const id = String(battleId || ""); + const { data: battle, refetch } = useBattle(id); + const me = useProfile().data; + const opp = battle?.opponent?.player; + const h2hQuery = useHeadToHead(opp?.id); + const h2h = h2hQuery.data; + + // The intro is picked once the battle and the head to head are in (or a + // moment has passed without the head to head), and then it stays. + const [waited, setWaited] = useState(false); + useEffect(() => { + const t = setTimeout(() => setWaited(true), 1500); + return () => clearTimeout(t); + }, []); + const picked = useRef(null); + if (!picked.current && battle && me && (h2hQuery.status !== "pending" || !opp?.id || waited)) picked.current = pickIntro(battle, h2h); + const intro = picked.current; + + const needStats = intro === "tape" || intro === "cinematic"; + const youStats = usePlayer(needStats ? me?.id : undefined).data; + const themStats = usePlayer(needStats ? opp?.id : undefined).data; + const card = useMemo( + () => (intro ? versusCard({ battle, me, opp, h2h, stats: { you: youStats, them: themStats }, intro }) : null), + [intro, battle, me, opp, h2h, youStats, themStats], + ); + + const live = battle?.status === "active"; + const waiting = battle?.status === "pending"; + + // Up once the intro has played; if the card never says it started, carry on anyway. + const [started, setStarted] = useState(false); + const [up, setUp] = useState(false); + useEffect(() => { + if (!intro) return undefined; + const t = setTimeout(() => setStarted(true), 2500); + return () => clearTimeout(t); + }, [intro]); + useEffect(() => { + if (!started) return undefined; + const t = setTimeout(() => setUp(true), INTRO_MS[intro] || INTRO_MS.strike); + return () => clearTimeout(t); + }, [started, intro]); + + // Live: hold, then GO and the battle opens. + useEffect(() => { + if (!live || !up) return undefined; + const t = setTimeout(() => { + haptic.go(); + router.replace(`/battles/${id}`); + }, HOLD_MS[intro] || HOLD_MS.strike); + return () => clearTimeout(t); + }, [live, up, intro, id, router]); + + // Waiting on them: check now and then whether they've said yes. + useEffect(() => { + if (!waiting) return undefined; + const t = setInterval(() => refetch(), 5000); + return () => clearInterval(t); + }, [waiting, refetch]); + + const onCue = (name) => { + if (name === "slam" || name === "letsRun") haptic.beat(); + else if (name === "cutIn") haptic.select(); + else if (name === "addPack") router.push("/pack"); + }; + + if (!card) { + return ( + + router.replace("/outside/arena")} /> + + ); + } + + const oppName = (opp?.name || "Rival").split(" ")[0]; + const scale = Math.max(width / CARD.w, height / CARD.h); + const tilesTop = (height - CARD.h * scale) / 2 + CARD.tiles * scale; + return ( + + setStarted(true)} style={StyleSheet.absoluteFill} /> + {up && !live ? ( + + {waiting ? Sent. The clock starts when {oppName} accepts. : null} + router.replace(waiting ? "/outside/arena" : `/battles/${id}`)} + /> + + ) : null} + + ); +} + +/** The prototype's .vs9 in React Native, for builds without Rive (Expo Go). */ +function SplitCountdown() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); @@ -233,4 +348,6 @@ const styles = StyleSheet.create({ stakeValue: { color: "#fff", fontFamily: F.display, fontSize: 22, lineHeight: 24, marginBottom: 2 }, stakeLabel: { color: "rgba(255,255,255,0.7)", fontFamily: F.body, fontSize: 10.5, textAlign: "center" }, wait: { color: C.textSoft, fontFamily: F.body, fontSize: 13, textAlign: "center", marginVertical: S.sm }, + panel: { position: "absolute", left: 18, right: 18, gap: S.sm, padding: S.md, borderRadius: 22, backgroundColor: "rgba(4,6,11,0.72)" }, + panelText: { color: "#fff", fontFamily: F.bodySemi, fontSize: 14, textAlign: "center" }, }); diff --git a/apps/mobile/src/components/AppTabBar.jsx b/apps/mobile/src/components/AppTabBar.jsx index 7aae609..80e7ceb 100644 --- a/apps/mobile/src/components/AppTabBar.jsx +++ b/apps/mobile/src/components/AppTabBar.jsx @@ -31,17 +31,21 @@ function useItems() { })); } -/** For . */ -export function TabsTabBar({ state, navigation, hidden }) { +/** + * For . Since expo-router 58 the tab bar gets `emitter` + * and `navigateToTab` rather than `navigation`; tapping a tab called the old + * navigation.emit and closed the app. + */ +export function TabsTabBar({ state, emitter, navigateToTab, hidden }) { const items = useItems(); const current = state.routes[state.index]?.name; const onSelect = (key) => { const route = state.routes.find((r) => r.name === key); if (!route) return; - const event = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true }); + const event = emitter.emit({ type: "tabPress", target: route.key, canPreventDefault: true }); if (key !== current && !event.defaultPrevented) { hapticSelection(); - navigation.navigate(route.name); + navigateToTab(route.key); } }; return state.routes.some((r) => r.name === i.key))} current={current} onSelect={onSelect} hidden={hidden} />; diff --git a/apps/mobile/src/components/arena/VersusReveal.jsx b/apps/mobile/src/components/arena/VersusReveal.jsx new file mode 100644 index 0000000..c43fa87 --- /dev/null +++ b/apps/mobile/src/components/arena/VersusReveal.jsx @@ -0,0 +1,104 @@ +// The first time you open a finished 1v1, the result lands on the 1v1 card +// (rive/versus) before the result screen: the card strikes in, VS bursts, +// YOU WIN! or YOU LOSE (with K.O. and the glass crack when the winner had at +// least double), then WINNER and LOSER on each side's result shot. A tap +// once the result is playing, or Continue at the end, opens the result +// screen. Only where the build can play Rive, and only once per battle on +// this phone, for battles that ended in the last week. + +import React, { useEffect, useMemo, useState } from "react"; +import { Pressable, StyleSheet, View, useWindowDimensions } from "react-native"; +import Animated, { FadeIn } from "react-native-reanimated"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { VersusCard, riveAvailable, versusCard, versusResult } from "@/components/rive"; +import { GlowButton, S } from "@/components/ui"; +import { haptic } from "@/services/haptics"; + +const SEEN_KEY = "outhere.vsReveal.v1"; +const WEEK_MS = 7 * 86_400_000; +// The card is 390 x 844 and covers the screen; its stake tiles start 736 down. +const CARD = { w: 390, h: 844, tiles: 736 }; + +/** + * "checking", then "show" the first time this battle's result is opened + * (and marks it seen) or "no". The second value ends the reveal. + */ +export function useFirstReveal(battle) { + const eligible = riveAvailable && !!versusResult(battle) && Date.now() - (battle?.completedAt || battle?.endAt || 0) < WEEK_MS; + const [state, setState] = useState(eligible ? "checking" : "no"); + const id = battle?.id; + useEffect(() => { + if (!eligible) return undefined; + let alive = true; + AsyncStorage.getItem(SEEN_KEY) + .then((raw) => { + const seen = JSON.parse(raw || "[]"); + if (!alive) return; + if (seen.includes(id)) return setState("no"); + setState("show"); + return AsyncStorage.setItem(SEEN_KEY, JSON.stringify([id, ...seen].slice(0, 200))); + }) + .catch(() => alive && setState("no")); + return () => { + alive = false; + }; + }, [eligible, id]); + return [state, () => setState("no")]; +} + +export default function VersusReveal({ battle, onDone }) { + const { width, height } = useWindowDimensions(); + const card = useMemo(() => versusCard({ battle, me: battle.me.player, opp: battle.opponent.player, intro: "strike" }), [battle]); + const outcome = useMemo(() => versusResult(battle), [battle]); + const [slammed, setSlammed] = useState(false); + const [result, setResult] = useState(null); + const [done, setDone] = useState(false); + + // VS slams, a beat, then the result. Timers stand in if a cue never comes. + useEffect(() => { + const t = setTimeout(() => setSlammed(true), 2600); + return () => clearTimeout(t); + }, []); + useEffect(() => { + if (!slammed) return undefined; + const t = setTimeout(() => setResult(outcome), 900); + return () => clearTimeout(t); + }, [slammed, outcome]); + useEffect(() => { + if (!result) return undefined; + const t = setTimeout(() => setDone(true), result.ko ? 7000 : 6000); + return () => clearTimeout(t); + }, [result]); + + const onCue = (name) => { + if (name === "slam") { + haptic.beat(); + setSlammed(true); + } else if (name === "youWin") haptic.win(); + else if (name === "youLose") haptic.lose(); + else if (name === "knockout" || name === "shatter") haptic.beat(); + else if (name === "prize") { + haptic.tap(); + setTimeout(() => setDone(true), 900); + } + }; + + const scale = Math.max(width / CARD.w, height / CARD.h); + const tilesTop = (height - CARD.h * scale) / 2 + CARD.tiles * scale; + return ( + + + {result ? : null} + {done ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#04060b" }, + panel: { position: "absolute", left: 18, right: 18, gap: S.sm }, +}); diff --git a/apps/mobile/src/components/rive/RiveLogo.jsx b/apps/mobile/src/components/rive/RiveLogo.jsx new file mode 100644 index 0000000..58aa3ab --- /dev/null +++ b/apps/mobile/src/components/rive/RiveLogo.jsx @@ -0,0 +1,50 @@ +// The animated mark (rive/logo) on a phone. `size` is the mark's own box, as +// for Logo; the artboard has a tenth of the size spare on every side for the +// glow, so the player overhangs the box by that much. Logo (96 px and up) +// lands the dot with a ping and a glow that then breathes; Small (under +// that) plays the same landing without them. onLanded fires as the dot lands. +// Only render it when riveAvailable is true (runtime.js). + +import React, { useEffect, useRef } from "react"; +import { View } from "react-native"; +import { listenForEvents, riveNative as R } from "./runtime"; +import { RIVE_FILES } from "./files"; + +const LANDED_MS = 1100; + +export default function RiveLogo({ size = 150, artboard = size >= 96 ? "Logo" : "Small", onLanded, style }) { + const { riveFile } = R.useRiveFile(RIVE_FILES.logo); + const { riveViewRef, setHybridRef } = R.useRive(); + const landed = useRef(onLanded); + landed.current = onLanded; + + useEffect(() => { + if (!riveViewRef) return undefined; + const stop = listenForEvents(riveViewRef, (event) => { + if (event.name === "landed") landed.current?.(); + }); + if (stop) return stop; + // No events on this player: the dot lands about 1.1 s into the intro. + const timer = setTimeout(() => landed.current?.(), LANDED_MS); + return () => clearTimeout(timer); + }, [riveViewRef]); + + const spare = size * 0.1; + return ( + + {riveFile ? ( + console.warn("Logo:", error.message)} + style={{ position: "absolute", left: -spare, top: -spare, width: size + spare * 2, height: size + spare * 2 }} + /> + ) : null} + + ); +} diff --git a/apps/mobile/src/components/rive/RiveLogo.web.jsx b/apps/mobile/src/components/rive/RiveLogo.web.jsx new file mode 100644 index 0000000..1c4c15a --- /dev/null +++ b/apps/mobile/src/components/rive/RiveLogo.web.jsx @@ -0,0 +1,38 @@ +// The animated mark on the web, through the canvas runtime. Same props as +// RiveLogo.jsx. + +import React, { useEffect, useRef } from "react"; +import { View } from "react-native"; +import { Asset } from "expo-asset"; +import { Alignment, EventType, Fit, Layout, Rive } from "@rive-app/canvas"; +import "./runtime"; +import { RIVE_FILES } from "./files"; + +export default function RiveLogo({ size = 150, artboard = size >= 96 ? "Logo" : "Small", onLanded, style }) { + const canvas = useRef(null); + const landed = useRef(onLanded); + landed.current = onLanded; + + useEffect(() => { + const rive = new Rive({ + src: Asset.fromModule(RIVE_FILES.logo).uri, + canvas: canvas.current, + artboard, + stateMachines: artboard, + autoplay: true, + layout: new Layout({ fit: Fit.Contain, alignment: Alignment.Center }), + onLoad: () => rive.resizeDrawingSurfaceToCanvas(), + }); + rive.on(EventType.RiveEvent, (event) => { + if (event.data?.name === "landed") landed.current?.(); + }); + return () => rive.cleanup(); + }, [artboard]); + + const spare = size * 0.1; + return ( + + + + ); +} diff --git a/apps/mobile/src/components/rive/VersusCard.jsx b/apps/mobile/src/components/rive/VersusCard.jsx new file mode 100644 index 0000000..a463069 --- /dev/null +++ b/apps/mobile/src/components/rive/VersusCard.jsx @@ -0,0 +1,108 @@ +// The 1v1 card (rive/versus) on a phone, through @rive-app/react-native. +// Only render it when riveAvailable is true (runtime.js); Expo Go can't. +// +// card versusCard(...) from versusData.js: intro, strings, numbers, +// booleans and the two photos. The intro is read once, as the card +// first plays; everything else follows the prop. +// result versusResult(...) or null. When it arrives the card loads the +// result shots and plays the win or the loss, once. +// onCue called with each event the file fires (slam, youWin, prize, +// addPack, ...; the list is in rive/versus/README.md). +// onReady called once the card is on screen and playing. + +import React, { useEffect, useRef } from "react"; +import { StyleSheet, View } from "react-native"; +import { listenForEvents, riveNative as R } from "./runtime"; +import { RIVE_FILES } from "./files"; +import { imageUrl } from "./images"; + +const IMAGE_PROPS = ["youPhoto", "themPhoto"]; + +function bindValues(vmi, card, withIntro) { + if (!vmi || !card) return; + if (withIntro) vmi.enumProperty("intro")?.set(card.intro); + for (const [key, value] of Object.entries(card.strings || {})) vmi.stringProperty(key)?.set(String(value ?? "")); + for (const [key, value] of Object.entries(card.numbers || {})) vmi.numberProperty(key)?.set(Number(value) || 0); + for (const [key, value] of Object.entries(card.booleans || {})) vmi.booleanProperty(key)?.set(!!value); +} + +async function bindImage(vmi, key, source) { + const url = await imageUrl(source); + if (!url) return; + const image = await R.RiveImages.loadFromURLAsync(url); + vmi.imageProperty(key)?.set(image); +} + +export default function VersusCard({ card, result, onCue, onReady, style }) { + const { riveFile } = R.useRiveFile(RIVE_FILES.versus); + const first = useRef(card); + // The intro and the first values are set before the card binds, so the + // state machine opens on the right intro. + const { instance } = R.useViewModelInstance(riveFile, { viewModelName: "Versus", onInit: (vmi) => bindValues(vmi, first.current, true) }); + const { riveViewRef, setHybridRef } = R.useRive(); + const cue = useRef(onCue); + cue.current = onCue; + const ready = useRef(onReady); + ready.current = onReady; + + useEffect(() => { + bindValues(instance, card, false); + }, [instance, card]); + + const youPhoto = card?.photos?.youPhoto; + const themPhoto = card?.photos?.themPhoto; + useEffect(() => { + if (!instance) return; + [youPhoto, themPhoto].forEach((source, i) => { + if (source != null) bindImage(instance, IMAGE_PROPS[i], source).catch(() => {}); + }); + }, [instance, youPhoto, themPhoto]); + + // Where the player has no events the screens' own timers stand in for the cues. + useEffect(() => { + if (!riveViewRef) return undefined; + const stop = listenForEvents(riveViewRef, (event) => cue.current?.(event.name)); + ready.current?.(); + return stop ?? undefined; + }, [riveViewRef]); + + // The result plays once: the shots load first so they're there under the flash. + const played = useRef(false); + useEffect(() => { + if (!instance || !riveViewRef || !result || played.current) return; + played.current = true; + (async () => { + instance.booleanProperty("ko")?.set(!!result.ko); + if (result.prize) instance.stringProperty("prize")?.set(result.prize); + await Promise.all([ + bindImage(instance, "youResult", result.youResult ?? youPhoto).catch(() => {}), + bindImage(instance, "themResult", result.themResult ?? themPhoto).catch(() => {}), + ]); + instance.triggerProperty(result.outcome === "win" ? "win" : "lose")?.trigger(); + riveViewRef.playIfNeeded(); + })(); + }, [instance, riveViewRef, result, youPhoto, themPhoto]); + + return ( + + {riveFile && instance ? ( + console.warn("VS card:", error.message)} + style={StyleSheet.absoluteFill} + /> + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { flex: 1, backgroundColor: "#04060b" }, +}); diff --git a/apps/mobile/src/components/rive/VersusCard.web.jsx b/apps/mobile/src/components/rive/VersusCard.web.jsx new file mode 100644 index 0000000..d98746c --- /dev/null +++ b/apps/mobile/src/components/rive/VersusCard.web.jsx @@ -0,0 +1,113 @@ +// The 1v1 card on the web, through the canvas runtime (@rive-app/canvas). +// Same props as VersusCard.jsx. + +import React, { useEffect, useRef, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { Asset } from "expo-asset"; +import { Alignment, EventType, Fit, Layout, Rive, decodeImage } from "@rive-app/canvas"; +import "./runtime"; +import { RIVE_FILES } from "./files"; +import { imageUrl } from "./images"; + +const IMAGE_PROPS = ["youPhoto", "themPhoto"]; + +function bindValues(vmi, card, withIntro) { + if (!vmi || !card) return; + const set = (prop, value) => { + if (prop) prop.value = value; + }; + if (withIntro) set(vmi.enum("intro"), card.intro); + for (const [key, value] of Object.entries(card.strings || {})) set(vmi.string(key), String(value ?? "")); + for (const [key, value] of Object.entries(card.numbers || {})) set(vmi.number(key), Number(value) || 0); + for (const [key, value] of Object.entries(card.booleans || {})) set(vmi.boolean(key), !!value); +} + +async function bindImage(vmi, key, source) { + const url = await imageUrl(source); + if (!url) return; + const response = await fetch(url); + const image = await decodeImage(new Uint8Array(await response.arrayBuffer())); + const prop = vmi.image(key); + if (prop) prop.value = image; + image.unref?.(); +} + +export default function VersusCard({ card, result, onCue, onReady, style }) { + const canvas = useRef(null); + const first = useRef(card); + const [vmi, setVmi] = useState(null); + const cue = useRef(onCue); + cue.current = onCue; + const ready = useRef(onReady); + ready.current = onReady; + + useEffect(() => { + const rive = new Rive({ + src: Asset.fromModule(RIVE_FILES.versus).uri, + canvas: canvas.current, + artboard: "Versus", + stateMachines: "Versus", + autoplay: false, + autoBind: true, + layout: new Layout({ fit: Fit.Cover, alignment: Alignment.Center }), + onLoad: () => { + rive.resizeDrawingSurfaceToCanvas(); + const instance = rive.viewModelInstance; + bindValues(instance, first.current, true); + // replay sends the state machine to the intro just set, whether or + // not it has already taken its first step. + instance?.trigger("replay")?.trigger(); + rive.play(); + setVmi(instance); + ready.current?.(); + }, + }); + rive.on(EventType.RiveEvent, (event) => cue.current?.(event.data?.name)); + const resize = () => rive.resizeDrawingSurfaceToCanvas(); + window.addEventListener("resize", resize); + return () => { + window.removeEventListener("resize", resize); + rive.cleanup(); + }; + }, []); + + useEffect(() => { + bindValues(vmi, card, false); + }, [vmi, card]); + + const youPhoto = card?.photos?.youPhoto; + const themPhoto = card?.photos?.themPhoto; + useEffect(() => { + if (!vmi) return; + [youPhoto, themPhoto].forEach((source, i) => { + if (source != null) bindImage(vmi, IMAGE_PROPS[i], source).catch(() => {}); + }); + }, [vmi, youPhoto, themPhoto]); + + const played = useRef(false); + useEffect(() => { + if (!vmi || !result || played.current) return; + played.current = true; + (async () => { + const ko = vmi.boolean("ko"); + if (ko) ko.value = !!result.ko; + const prize = vmi.string("prize"); + if (prize && result.prize) prize.value = result.prize; + await Promise.all([ + bindImage(vmi, "youResult", result.youResult ?? youPhoto).catch(() => {}), + bindImage(vmi, "themResult", result.themResult ?? themPhoto).catch(() => {}), + ]); + vmi.trigger(result.outcome === "win" ? "win" : "lose")?.trigger(); + })(); + }, [vmi, result, youPhoto, themPhoto]); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + card: { flex: 1, backgroundColor: "#04060b" }, +}); diff --git a/apps/mobile/src/components/rive/__tests__/runtime.test.js b/apps/mobile/src/components/rive/__tests__/runtime.test.js new file mode 100644 index 0000000..eb45cf0 --- /dev/null +++ b/apps/mobile/src/components/rive/__tests__/runtime.test.js @@ -0,0 +1,30 @@ +import { listenForEvents } from "../runtime"; + +describe("listenForEvents", () => { + it("listens and stops where the player has events", () => { + const view = { onEventListener: jest.fn(), removeEventListeners: jest.fn() }; + const onEvent = () => {}; + const stop = listenForEvents(view, onEvent); + expect(view.onEventListener).toHaveBeenCalledWith(onEvent); + stop(); + expect(view.removeEventListeners).toHaveBeenCalled(); + }); + + it("returns null instead of throwing where the player has no events", () => { + const fail = () => { + throw new Error("Events are not supported by the new iOS runtime"); + }; + const view = { onEventListener: fail, removeEventListeners: fail }; + expect(listenForEvents(view, () => {})).toBeNull(); + }); + + it("stops quietly when the view has already gone", () => { + const view = { + onEventListener: () => {}, + removeEventListeners: () => { + throw new Error("gone"); + }, + }; + expect(() => listenForEvents(view, () => {})()).not.toThrow(); + }); +}); diff --git a/apps/mobile/src/components/rive/__tests__/versusData.test.js b/apps/mobile/src/components/rive/__tests__/versusData.test.js new file mode 100644 index 0000000..e3dd774 --- /dev/null +++ b/apps/mobile/src/components/rive/__tests__/versusData.test.js @@ -0,0 +1,123 @@ +// The 1v1 card's data: which intro a battle gets, what the card binds, the +// tape rows and the result from your side. + +// fighter.js takes imageSource from the UI kit, whose animation libraries +// Jest doesn't load; this is the same function. +jest.mock("@/components/ui", () => ({ imageSource: (src) => (src == null ? null : typeof src === "string" ? { uri: src } : src) })); + +const { BIG_STAKE, cardPhoto, clockFor, pickIntro, stepsShort, tapeRows, versusCard, versusResult } = require("../versusData"); +const { SHADOW_RUNNERS } = require("../assets"); + +// Thursday 15 October 2026, 18:45 local: the fourth day of the week. +const NOW = new Date(2026, 9, 15, 18, 45).getTime(); + +const me = { id: "me", name: "Alex Mensah", area: "Peckham", runner: "man", fighter: null }; +const kemi = { id: "p_kemi", name: "Kemi Ade", area: "Brixton", runner: "woman", fighter: null }; +const battle = (extra = {}) => ({ + id: "b1", + mode: "most_steps", + metric: "steps", + modeName: "Most steps", + durationHours: 24, + stakeOut: 50, + status: "active", + me: { player: me, value: null }, + opponent: { player: kemi, value: null }, + ...extra, +}); + +describe("pickIntro", () => { + it("strikes for an everyday battle, including the usual 50 stake", () => { + expect(pickIntro(battle(), { played: 0, mine: 0, theirs: 0 })).toBe("strike"); + expect(pickIntro(battle(), undefined)).toBe("strike"); + }); + it("runs the tape for a rematch, someone you've met or a big stake", () => { + expect(pickIntro(battle({ rematchOf: "b0" }), null)).toBe("tape"); + expect(pickIntro(battle(), { played: 1, mine: 1, theirs: 0 })).toBe("tape"); + expect(pickIntro(battle({ stakeOut: BIG_STAKE }), null)).toBe("tape"); + }); + it("goes cinematic for a grudge match, a derby or the top of a leaderboard", () => { + expect(pickIntro(battle(), { played: 4, mine: 2, theirs: 2 })).toBe("cinematic"); + expect(pickIntro(battle(), { played: 4, mine: 3, theirs: 1 })).toBe("tape"); + expect(pickIntro(battle({ isDerby: true }), null)).toBe("cinematic"); + expect(pickIntro(battle({ isTopOfLeaderboard: true }), null)).toBe("cinematic"); + }); + it("lets run for an event battle, above everything else", () => { + expect(pickIntro(battle({ eventId: "e1", rematchOf: "b0" }), { played: 5, mine: 2, theirs: 3 })).toBe("letsRun"); + }); +}); + +describe("the numbers", () => { + it("shortens counts the way the tape shows them", () => { + expect(stepsShort(11_400)).toBe("11.4k"); + expect(stepsShort(9000)).toBe("9k"); + expect(stepsShort(950)).toBe("950"); + expect(stepsShort(1_250_000)).toBe("1.3m"); + }); + it("writes the clock tile in hours, then days", () => { + expect(clockFor(24)).toBe("24h"); + expect(clockFor(72)).toBe("3d"); + expect(clockFor(undefined)).toBe("24h"); + }); + it("makes each bar a share of the bigger number", () => { + const rows = tapeRows({ + you: { weekSteps: 40_000, todaySteps: 9000, streak: 6, xp: 1200 }, + them: { weekSteps: 32_000, todaySteps: 12_000, streak: 0, xp: 0 }, + h2h: { mine: 0, theirs: 0 }, + now: NOW, + }); + expect(rows.map((r) => r.label)).toEqual(["STEPS A DAY", "TODAY", "DAY STREAK", "XP", "HEAD TO HEAD"]); + expect(rows[0]).toEqual({ label: "STEPS A DAY", you: "10k", them: "8k", youBar: 1, themBar: 0.8 }); + expect(rows[1].youBar).toBe(0.75); + expect(rows[1].themBar).toBe(1); + expect(rows[4]).toMatchObject({ you: "0", them: "0", youBar: 0, themBar: 0 }); + }); +}); + +describe("versusCard", () => { + it("binds names, places, the rule and the tiles", () => { + const card = versusCard({ battle: battle({ rematchOf: "b0" }), me, opp: kemi, h2h: null, now: NOW }); + expect(card.intro).toBe("tape"); + expect(card.strings).toMatchObject({ youName: "Alex", youCity: "Peckham", themName: "Kemi", themCity: "Brixton", title: "1V1 · REMATCH", rule: "24h · most steps", stake: "50", prize: "100", clock: "24h" }); + expect(card.strings.tape1Label).toBe("STEPS A DAY"); + expect(card.numbers.tape5YouBar).toBe(0); + expect(card.booleans.youHasPack).toBe(false); + }); + it("leaves the tape alone for the other intros", () => { + const card = versusCard({ battle: battle({ isSurprise: true }), me, opp: kemi, h2h: null, now: NOW }); + expect(card.intro).toBe("strike"); + expect(card.strings.title).toBe("1V1 · MYSTERY"); + expect(card.strings.tape1Label).toBeUndefined(); + expect(card.numbers).toEqual({}); + }); + it("uses the photo, else the female shadow runner, else the card's own male one", () => { + expect(cardPhoto(me, "you")).toBeNull(); + expect(cardPhoto(kemi, "them")).toBe(SHADOW_RUNNERS.womanThem); + expect(cardPhoto({ runner: "either", runnerFigure: "woman" }, "you")).toBe(SHADOW_RUNNERS.womanYou); + expect(cardPhoto({ ...kemi, avatar: "https://x.test/k.jpg" }, "them")).toEqual({ uri: "https://x.test/k.jpg" }); + expect(cardPhoto({ ...kemi, fighter: { vs: "https://x.test/vs.png" } }, "them")).toEqual({ uri: "https://x.test/vs.png" }); + }); +}); + +describe("versusResult", () => { + const done = (extra) => battle({ status: "complete", ...extra }); + it("has no result before the end, or for a draw", () => { + expect(versusResult(battle())).toBeNull(); + expect(versusResult(done({ result: "draw" }))).toBeNull(); + }); + it("is a knockout when the winner had at least double", () => { + const won = done({ result: "won", me: { player: me, value: 20_000 }, opponent: { player: kemi, value: 9000 }, prize: { out: 115 } }); + expect(versusResult(won)).toMatchObject({ outcome: "win", ko: true, prize: "115" }); + const close = done({ result: "lost", me: { player: me, value: 9000 }, opponent: { player: kemi, value: 12_000 } }); + expect(versusResult(close)).toMatchObject({ outcome: "lose", ko: false, prize: null }); + }); + it("never knocks out in a timed race", () => { + expect(versusResult(done({ mode: "live_race", result: "won", me: { player: me, value: 1500 }, opponent: { player: kemi, value: 3100 } })).ko).toBe(false); + }); + it("shows the winner's win pose and the loser's respect pose, else their card photo", () => { + const packed = { ...me, fighter: { vs: "https://x.test/vs.png", win: "https://x.test/win.png", respect: "https://x.test/respect.png" } }; + const r = versusResult(done({ result: "lost", me: { player: packed, value: 1 }, opponent: { player: kemi, value: 2 } })); + expect(r.youResult).toEqual({ uri: "https://x.test/respect.png" }); + expect(r.themResult).toBe(SHADOW_RUNNERS.womanThem); + }); +}); diff --git a/apps/mobile/src/components/rive/assets.js b/apps/mobile/src/components/rive/assets.js new file mode 100644 index 0000000..874863b --- /dev/null +++ b/apps/mobile/src/components/rive/assets.js @@ -0,0 +1,8 @@ +// The shadow runners the 1v1 card swaps in for a runner with no photo. The +// card embeds the male ones (blue for you, red for them), so only the +// female ones ship with the app. They come from rive/versus/images. + +export const SHADOW_RUNNERS = { + womanYou: require("../../../assets/rive/silhouette-female-blue.jpg"), + womanThem: require("../../../assets/rive/silhouette-female-red.jpg"), +}; diff --git a/apps/mobile/src/components/rive/files.js b/apps/mobile/src/components/rive/files.js new file mode 100644 index 0000000..79a6d0d --- /dev/null +++ b/apps/mobile/src/components/rive/files.js @@ -0,0 +1,7 @@ +// The Rive files. Their sources live in rive/ at the repo root (build.py +// there writes them); copy each export here after a rebuild. + +export const RIVE_FILES = { + versus: require("../../../assets/rive/outhere-versus.riv"), + logo: require("../../../assets/rive/outhere-logo.riv"), +}; diff --git a/apps/mobile/src/components/rive/images.js b/apps/mobile/src/components/rive/images.js new file mode 100644 index 0000000..1c1002d --- /dev/null +++ b/apps/mobile/src/components/rive/images.js @@ -0,0 +1,18 @@ +// Turns an image source as the app keeps it (a require() number, { uri } or +// a plain URL) into a URL a Rive player can load. Bundled images go through +// expo-asset: on a phone it copies them to a local file, on the web it gives +// the exported file's hashed URL. + +import { Platform } from "react-native"; +import { Asset } from "expo-asset"; + +export async function imageUrl(source) { + if (source == null) return null; + if (typeof source === "string") return source; + if (typeof source === "object" && source.uri) return source.uri; + if (typeof source !== "number") return null; + const asset = Asset.fromModule(source); + if (Platform.OS === "web") return asset.uri; + await asset.downloadAsync(); + return asset.localUri || asset.uri; +} diff --git a/apps/mobile/src/components/rive/index.js b/apps/mobile/src/components/rive/index.js new file mode 100644 index 0000000..e2a84f9 --- /dev/null +++ b/apps/mobile/src/components/rive/index.js @@ -0,0 +1,8 @@ +// Rive in the app: the animated logo and the 1v1 card. riveAvailable says +// whether this build can play them (not in Expo Go); when it can't, screens +// draw their React Native versions. + +export { riveAvailable } from "./runtime"; +export { default as RiveLogo } from "./RiveLogo"; +export { default as VersusCard } from "./VersusCard"; +export * from "./versusData"; diff --git a/apps/mobile/src/components/rive/runtime.js b/apps/mobile/src/components/rive/runtime.js new file mode 100644 index 0000000..a167291 --- /dev/null +++ b/apps/mobile/src/components/rive/runtime.js @@ -0,0 +1,43 @@ +// Whether this build can play Rive, and the player when it can. +// +// Rive is native code (@rive-app/react-native on Nitro), so it plays in our +// own builds (the EAS development build and the store builds) but not in +// Expo Go, which only carries Expo's own native modules. There the import +// throws, `rive` stays null and every Rive screen draws its older React +// Native version instead. The web has its own player in runtime.web.js. + +import Constants, { ExecutionEnvironment } from "expo-constants"; + +let rive = null; +if (Constants.executionEnvironment !== ExecutionEnvironment.StoreClient) { + try { + rive = require("@rive-app/react-native"); + } catch { + rive = null; + } +} + +export const riveNative = rive; +export const riveAvailable = !!rive; + +/** + * Listens for the file's events (landed, slam, addPack, ...) and returns the + * function that stops listening, or null where the player has no events. + * The newer phone runtime, the default in @rive-app/react-native 0.5, throws + * "Events are not supported" from onEventListener; left uncaught that closed + * the app as soon as the logo came on screen. Callers stand in with timers. + */ +export function listenForEvents(view, onEvent) { + try { + view.onEventListener(onEvent); + } catch { + return null; + } + return () => { + try { + view.removeEventListeners(); + } catch { + // The view has gone already. + } + }; +} diff --git a/apps/mobile/src/components/rive/runtime.web.js b/apps/mobile/src/components/rive/runtime.web.js new file mode 100644 index 0000000..3c64a12 --- /dev/null +++ b/apps/mobile/src/components/rive/runtime.web.js @@ -0,0 +1,11 @@ +// On the web Rive is the canvas runtime (@rive-app/canvas). Its WebAssembly +// file ships in public/ (copied to the site root by the export), so the +// loader looks there rather than on a CDN. + +import { RuntimeLoader } from "@rive-app/canvas"; + +const base = process.env.EXPO_BASE_URL || ""; +RuntimeLoader.setWasmUrl(`${base}/rive.wasm`); + +export const riveNative = null; +export const riveAvailable = true; diff --git a/apps/mobile/src/components/rive/versusData.js b/apps/mobile/src/components/rive/versusData.js new file mode 100644 index 0000000..65ad2b3 --- /dev/null +++ b/apps/mobile/src/components/rive/versusData.js @@ -0,0 +1,177 @@ +// What the 1v1 card (rive/versus) shows for a battle: which intro opens it, +// the words and numbers it binds, each side's photo and, once the battle is +// over, the result. Plain functions over the data layer's battle and player +// views, so both players (native and web) and the tests share them. + +import { imageSource } from "@/components/ui/Cover"; +import { distanceLabel } from "@/constants/battleModes"; +import { startOfWeek } from "@/data/time"; +import { fighterPhoto, hasPack } from "@/utils/fighter"; +import { formatNumber } from "@/utils/format"; +import { SHADOW_RUNNERS } from "./assets"; + +// How long each intro runs before the card is up (rive/versus/README.md). +export const INTRO_MS = { strike: 1700, tape: 3200, cinematic: 3900, letsRun: 4100 }; + +// A stake at or over this is a big one and gets the tale of the tape. The +// create screen starts every challenge at 50, so a lower line would put +// nearly every battle on the tape and the strike would hardly show. +export const BIG_STAKE = 100; + +const DAY_MS = 86_400_000; + +const firstName = (p, fallback) => (p?.name || fallback).split(" ")[0]; +const placeOf = (p) => p?.area || p?.city || ""; + +/** 11400 as "11.4k", 950 as "950". */ +export const stepsShort = (n) => { + const v = Math.max(0, Math.round(Number(n) || 0)); + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; + if (v >= 1000) return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return String(v); +}; + +/** A grudge match: three or more meetings and never more than one win apart. */ +export const isGrudge = (h2h) => (h2h?.played || 0) >= 3 && Math.abs((h2h.mine || 0) - (h2h.theirs || 0)) <= 1; + +/** + * Which intro opens the card. Let's Run for event battles, the cinematic for + * the big ones (a club derby, the top of a leaderboard, a grudge match), the + * tale of the tape for a rematch, someone you've met before or a big stake, + * and the seam strike for everything else. + */ +export function pickIntro(battle, h2h) { + if (!battle) return "strike"; + if (battle.eventId || battle.isEvent) return "letsRun"; + if (battle.isDerby || battle.isTopOfLeaderboard || isGrudge(h2h)) return "cinematic"; + if (battle.rematchOf || (h2h?.played || 0) > 0 || (battle.stakeOut || 0) >= BIG_STAKE) return "tape"; + return "strike"; +} + +/** The top bar's rule: "24h · most steps", "Fastest 5 km · 5 km". */ +export function ruleFor(battle) { + if (battle.mode === "most_steps") { + const goal = battle.metric === "distance" ? "km" : battle.metric === "minutes" ? "minutes" : "steps"; + return `${clockFor(battle.durationHours)} · most ${goal}`; + } + return `${battle.modeName || "Battle"}${battle.distanceM ? ` · ${distanceLabel(battle.distanceM)}` : ""}`; +} + +/** The clock tile: "24h", "3d". */ +export const clockFor = (hours) => { + const h = Number(hours) || 24; + return h >= 48 ? `${Math.round(h / 24)}d` : `${h}h`; +}; + +/** Steps a day this week so far, from a player view with weekSteps. */ +export function stepsADay(player, now = Date.now()) { + if (player?.weekSteps == null) return null; + const days = Math.max(1, Math.ceil((now - startOfWeek(new Date(now)).getTime()) / DAY_MS)); + return Math.round(player.weekSteps / days); +} + +const bars = (a, b) => { + const top = Math.max(a, b); + return top > 0 ? [a / top, b / top] : [0, 0]; +}; + +/** + * The tale of the tape's five rows from the two player views (usePlayer: + * weekSteps, todaySteps, streak, xp) and the head to head. Each row has the + * label, both numbers as shown and each side's bar as a share of the bigger. + */ +export function tapeRows({ you, them, h2h, now = Date.now() }) { + const rows = [ + ["STEPS A DAY", stepsADay(you, now) || 0, stepsADay(them, now) || 0, stepsShort], + ["TODAY", you?.todaySteps || 0, them?.todaySteps || 0, stepsShort], + ["DAY STREAK", you?.streak || 0, them?.streak || 0, (n) => String(n)], + ["XP", you?.xp || 0, them?.xp || 0, stepsShort], + ["HEAD TO HEAD", h2h?.mine || 0, h2h?.theirs || 0, (n) => String(n)], + ]; + return rows.map(([label, a, b, show]) => { + const [youBar, themBar] = bars(a, b); + return { label, you: show(a), them: show(b), youBar, themBar }; + }); +} + +const lineFor = (player, stats, now) => { + const place = placeOf(player).toUpperCase(); + const perDay = stepsADay(stats, now); + return [place, perDay ? `${stepsShort(perDay).toUpperCase()} STEPS A DAY` : null].filter(Boolean).join(" · "); +}; + +/** + * The card's photo for one side: their Fighter Pack VS shot, else their own + * photo, else the shadow runner for the figure they chose. The male shadow + * runners are already in the file, so for those this returns null and the + * card keeps its own. + */ +export function cardPhoto(player, side) { + const { source, isSilhouette } = fighterPhoto(player, side === "you" ? "vs" : "vsMirror"); + if (!isSilhouette) return source; + const figure = player?.runner === "either" ? player?.runnerFigure : player?.runner; + if (figure !== "woman") return null; + return side === "you" ? SHADOW_RUNNERS.womanYou : SHADOW_RUNNERS.womanThem; +} + +/** + * Everything the card binds before it plays. stats is { you, them } from + * usePlayer, only needed for the tape and the cinematic lines. + */ +export function versusCard({ battle, me, opp, h2h, stats = {}, intro = pickIntro(battle, h2h), now = Date.now() }) { + const stake = battle.stakeOut || 0; + const strings = { + youName: firstName(me, "You"), + youCity: placeOf(me), + themName: firstName(opp, "Rival"), + themCity: placeOf(opp), + title: `1V1${battle.rematchOf ? " · REMATCH" : battle.isSurprise ? " · MYSTERY" : ""}`, + rule: ruleFor(battle), + stake: formatNumber(stake), + prize: formatNumber(stake * 2 || 15), + clock: clockFor(battle.durationHours), + youLine: lineFor(me, stats.you, now), + themLine: lineFor(opp, stats.them, now), + }; + const numbers = {}; + if (intro === "tape") { + tapeRows({ you: stats.you, them: stats.them, h2h, now }).forEach((row, i) => { + const n = i + 1; + strings[`tape${n}Label`] = row.label; + strings[`tape${n}You`] = row.you; + strings[`tape${n}Them`] = row.them; + numbers[`tape${n}YouBar`] = row.youBar; + numbers[`tape${n}ThemBar`] = row.themBar; + }); + } + return { + intro, + strings, + numbers, + booleans: { youHasPack: hasPack(me) }, + photos: { youPhoto: cardPhoto(me, "you"), themPhoto: cardPhoto(opp, "them") }, + }; +} + +/** + * The result, from your side, once a battle is over: "win" or "lose" (a draw + * has none), whether it's a knockout (in a most-steps battle the winner has + * at least double the loser's count), each side's result shot (the winner's + * win pose, the loser's respect pose, else their card photo) and the prize. + */ +export function versusResult(battle) { + if (battle?.status !== "complete" || (battle.result !== "won" && battle.result !== "lost")) return null; + const won = battle.result === "won"; + const mine = Number(battle.me?.value) || 0; + const theirs = Number(battle.opponent?.value) || 0; + const [top, bottom] = won ? [mine, theirs] : [theirs, mine]; + const ko = battle.mode === "most_steps" && top > 0 && top >= bottom * 2; + const shot = (player, side, pose) => imageSource(player?.fighter?.[pose]) || cardPhoto(player, side); + return { + outcome: won ? "win" : "lose", + ko, + youResult: shot(battle.me?.player, "you", won ? "win" : "respect"), + themResult: shot(battle.opponent?.player, "them", won ? "respect" : "win"), + prize: won && battle.prize?.out ? formatNumber(battle.prize.out) : null, + }; +} diff --git a/apps/mobile/src/components/ui/Logo.jsx b/apps/mobile/src/components/ui/Logo.jsx index 910ed39..c51c435 100644 --- a/apps/mobile/src/components/ui/Logo.jsx +++ b/apps/mobile/src/components/ui/Logo.jsx @@ -2,12 +2,18 @@ // out now dot sitting in the gap, the same as a photo ring with its dot. // Weight A (Edem's pick): ring 12, dot 9, space 5, round ends, on a 100 box. // With `animate`, the ring draws itself in and then the dot springs into place. +// Where the build can play Rive (not Expo Go), an animated mark in the house +// colours is the Rive one (rive/logo): the same landing with a ping, a glow +// that breathes and a light tap in the hand as the dot lands. import React, { useEffect } from "react"; import { Text, View } from "react-native"; import Svg, { Circle, Path } from "react-native-svg"; import Animated, { Easing, useAnimatedProps, useReducedMotion, useSharedValue, withDelay, withSpring, withTiming } from "react-native-reanimated"; import { C, F } from "./tokens"; +import RiveLogo from "../rive/RiveLogo"; +import { riveAvailable } from "../rive/runtime"; +import { haptic } from "@/services/haptics"; const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); @@ -42,6 +48,15 @@ export function Logo({ size = 40, ink = C.text, dotInk = C.out, w = 12, dot = 9, const ringProps = useAnimatedProps(() => ({ strokeDashoffset: length * (1 - drawn.value) })); const dotProps = useAnimatedProps(() => ({ r: dot * pop.value })); + const house = ink === C.text && dotInk === C.out && w === 12 && dot === 9 && space === 5; + if (play && house && riveAvailable) { + return ( + + = 96 ? haptic.tap : undefined} /> + + ); + } + return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ffbf4f..223795d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,12 @@ importers: '@react-three/fiber': specifier: 9.8.1 version: 9.8.1(4a452696a5bd70f5e9a79220c9cd5d81) + '@rive-app/canvas': + specifier: 2.43.1 + version: 2.43.1 + '@rive-app/react-native': + specifier: 0.5.5 + version: 0.5.5(@rive-app/canvas@2.43.1)(react-native-nitro-modules@0.36.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@shopify/react-native-skia': specifier: 2.14.0 version: 2.14.0(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) @@ -229,6 +235,9 @@ importers: expo-contacts: specifier: ~58.0.7 version: 58.0.7(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + expo-dev-client: + specifier: ~58.0.14 + version: 58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) expo-device: specifier: ~58.0.5 version: 58.0.5(expo@58.0.14) @@ -300,7 +309,7 @@ importers: version: 58.0.6(expo@58.0.14)(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) expo-updates: specifier: ~58.0.17 - version: 58.0.17(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 58.0.17(expo-dev-client@58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) expo-video: specifier: ~58.0.11 version: 58.0.11(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) @@ -346,6 +355,9 @@ importers: react-native-maps: specifier: 1.29.11 version: 1.29.11(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + react-native-nitro-modules: + specifier: 0.36.5 + version: 0.36.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) react-native-qrcode-svg: specifier: 6.3.21 version: 6.3.21(react-native-svg@15.16.0(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) @@ -2268,6 +2280,21 @@ packages: '@remix-run/route-pattern@0.22.1': resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} + '@rive-app/canvas@2.43.1': + resolution: {integrity: sha512-GqENtpSOK5KOnF+kmzDQ70tfEQYksSOb39jcPPAV5vj5XDJ3vekPdbqTWuQdEyOFMhJUir4iBo3On9Gy2hghtQ==} + + '@rive-app/react-native@0.5.5': + resolution: {integrity: sha512-juY6RfQPoZJLS/KMYd8mrYKtP7rvA2abUJzQhYT/Gnq4H14RJ7B7LWikJd5rOtwmrWz1GJUOMcZUWJ9Ecz0xgw==} + hasBin: true + peerDependencies: + '@rive-app/canvas': ^2.39.0 + react: 19.3.0 + react-native: '*' + react-native-nitro-modules: '>=0.35.10 <0.37' + peerDependenciesMeta: + '@rive-app/canvas': + optional: true + '@rolldown/binding-android-arm-eabi@1.2.14': resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3682,6 +3709,28 @@ packages: react: 19.3.0 react-native: '*' + expo-dev-client@58.0.14: + resolution: {integrity: sha512-gyzONNNS/SLZdxFdDshgzc0Uxxt2i+MjYwOQX3LNvr3pXFtYHYM9kza8MpdYOqhqGvWnA4BebeBWq3T1hjt9nQ==} + peerDependencies: + expo: '*' + + expo-dev-launcher@58.0.14: + resolution: {integrity: sha512-RFgP59Tesnr3wBPj4B82Qwx2k0355VkVmHuovzwfmuti22pWhJvNhdr2aR7BokQ5U6z2Vm4HAz6NjRE+xBIFPA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-menu-interface@58.0.1: + resolution: {integrity: sha512-XdjO3ZZhEq1dXm15GVtzQ0SXxSTPguJIDZib3Z8tAf2jLxfPUlQObgO/0hdnw7ssEBhTT2jQo2HAfnOSGUfj8w==} + peerDependencies: + expo: '*' + + expo-dev-menu@58.0.11: + resolution: {integrity: sha512-kOqJ2wGsmM+tYf96mEy06WSBbX/cOx2KgmaouuqQX24/zpBPKlcLUQGgV75iGBXIxS6sczBO7qBp/MifFOud3w==} + peerDependencies: + expo: '*' + react-native: '*' + expo-device@58.0.5: resolution: {integrity: sha512-GNyaHPwoiI/z2SGR14R6d9J7hFSW2JabZieJtC3l6/LO2mJYVCbhEsQ7/1ljGBeTUnFFWHcFLccdaWEhOWtaQQ==} peerDependencies: @@ -5390,6 +5439,12 @@ packages: react-native-web: optional: true + react-native-nitro-modules@0.36.5: + resolution: {integrity: sha512-SJby84+hovD70JmQ1fq+56tw3oapJeChehUqXA4Gqjm+ktJhMAcj8LGwoAjStSOUwRaK7czbzBPB0Widj+EDoQ==} + peerDependencies: + react: 19.3.0 + react-native: '*' + react-native-qrcode-svg@6.3.21: resolution: {integrity: sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==} peerDependencies: @@ -8691,6 +8746,16 @@ snapshots: '@remix-run/route-pattern@0.22.1': {} + '@rive-app/canvas@2.43.1': {} + + '@rive-app/react-native@0.5.5(@rive-app/canvas@2.43.1)(react-native-nitro-modules@0.36.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + dependencies: + react: 19.3.0 + react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) + react-native-nitro-modules: 0.36.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + optionalDependencies: + '@rive-app/canvas': 2.43.1 + '@rolldown/binding-android-arm-eabi@1.2.14': optional: true @@ -10126,6 +10191,35 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) + expo-dev-client@58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)): + dependencies: + expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + expo-dev-launcher: 58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) + expo-dev-menu: 58.0.11(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) + expo-dev-menu-interface: 58.0.1(expo@58.0.14) + expo-manifests: 58.0.1(expo@58.0.14) + expo-updates-interface: 58.0.1(expo@58.0.14) + transitivePeerDependencies: + - react-native + + expo-dev-launcher@58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)): + dependencies: + '@expo/schema-utils': 58.0.2 + expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + expo-dev-menu: 58.0.11(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) + expo-manifests: 58.0.1(expo@58.0.14) + react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) + + expo-dev-menu-interface@58.0.1(expo@58.0.14): + dependencies: + expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + + expo-dev-menu@58.0.11(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)): + dependencies: + expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + expo-dev-menu-interface: 58.0.1(expo@58.0.14) + react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) + expo-device@58.0.5(expo@58.0.14): dependencies: expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) @@ -10377,7 +10471,7 @@ snapshots: dependencies: expo: 58.0.14(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.14)(expo-router@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-web@0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-native-webview@14.0.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@5.9.3) - expo-updates@58.0.17(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + expo-updates@58.0.17(expo-dev-client@58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)))(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: '@expo/code-signing-certificates': 0.0.7 '@expo/env': 2.5.2 @@ -10399,6 +10493,8 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) resolve-from: 5.0.0 + optionalDependencies: + expo-dev-client: 58.0.14(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0)) transitivePeerDependencies: - supports-color @@ -12202,6 +12298,11 @@ snapshots: optionalDependencies: react-native-web: 0.21.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-native-nitro-modules@0.36.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + dependencies: + react: 19.3.0 + react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0) + react-native-qrcode-svg@6.3.21(react-native-svg@15.16.0(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: prop-types: 15.8.1 diff --git a/rive/logo/.gitignore b/rive/logo/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/rive/logo/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/rive/logo/README.md b/rive/logo/README.md new file mode 100644 index 0000000..2a55085 --- /dev/null +++ b/rive/logo/README.md @@ -0,0 +1,24 @@ +# Outhere logo (Rive) + +One file, `export/outhere-logo.riv`, with four artboards. Each has a state machine of the same name, a transparent background, and the design system's weight A mark (ring 12, dot 9, space 5, round ends). + +| Artboard | Use | Motion | +| --- | --- | --- | +| `Logo` | Large uses, about 96 px and up | The O draws in, the blue out now dot lands with a ping and a glow (2.2 s), then the glow breathes on a 2.8 s loop | +| `Splash` | The opening screen | The same landing, then UTHERE slides out from behind the O and its letters close up (2.5 s), then the glow breathes | +| `Small` | Under about 48 px: app bar, notifications | The same intro without the ping or glow (1.5 s), then still | +| `Loader` | Waiting | The stroke runs round the O and away again while the dot pulses, on a 1.4 s loop. The gap never moves | + +`Logo`, `Splash` and `Small` fire a `landed` event as the dot lands (1.1 s), so the app can play a light haptic. + +- `build.py` writes `scene.rml`. Edit `build.py`, not the RML. +- `fonts/BarlowCondensed-Bold-UTHERE.ttf` is Barlow Condensed Bold cut down to the letters U, T, H, E and R, embedded in the file (licence in `fonts/OFL.txt`). The whole file is 11 KB. + +```bash +python3 build.py +rive . --verify && rive inspect . --summary +rive . --once && cp build/outhere-logo.riv export/ +rive . --artboard=Splash --screenshot=frame.png --advance=80 # one frame, 80 frames in +``` + +The glow is a radial gradient, not a `Feather`, so it draws on every runtime including the web canvas one. No scripts, so the unsigned build plays everywhere. diff --git a/rive/logo/build.py b/rive/logo/build.py new file mode 100644 index 0000000..8d0ad10 --- /dev/null +++ b/rive/logo/build.py @@ -0,0 +1,291 @@ +"""Writes scene.rml for the animated Outhere logo, in four artboards: + + Logo the full intro: the O draws in, the dot lands with a ping and a glow, + then the glow breathes. For larger uses. + Small the same intro without ping or glow, for sizes under about 48 px. + Splash the opening screen: the O lands, then UTHERE slides out of it, with + OUT set larger than HERE. + Loader the O draws and undraws itself on a loop while the dot pulses. + +The geometry is Outhere.logo() from the design system, weight A: on a 100 grid +the ring is 12 wide with round ends, the dot has radius 9 and sits 5 clear of +the ring ends, in a gap centred on the bottom right (45 degrees). Every +artboard is transparent; every intro fires `landed` as the dot lands. + +Run `python3 build.py` after changing anything here, then `rive . --verify`. +Times are frames at 60fps. +""" +import itertools +import math +import pathlib + +INK, BLUE = "FFF3F5F8", "FF4F9BFF" + +# Easing, as in the design system tokens +EXPO = (0.16, 1, 0.3, 1) # --ease-out-expo +THUMB = (0.34, 1.56, 0.64, 1) # --ease-thumb, overshoots +GLIDE = (0.22, 1, 0.36, 1) # --ease-glide +INOUT = (0.45, 0, 0.55, 1) +OUT = (0, 0, 0.58, 1) + +X, Y, ROTATION, SX, SY, OPACITY = 13, 14, 15, 16, 17, 18 +TRIM_START, TRIM_END, LETTER_SPACING = 114, 115, 390 + +ARC = 300 / 360 # the gap is 60 degrees, so the ring is 300 +ROT = math.radians(165) # Rive's ellipse path starts at the top; turn it to start at 75 degrees + +_ids = itertools.count(10) + + +def nid(): + return f"0:{next(_ids)}" + + +def kf(value, frame, ease=None): + """A keyframe. `ease` shapes the segment that leaves it; None holds.""" + if ease is None: + return f'' + if ease == "linear": + return f'' + x1, y1, x2, y2 = ease + return (f'' + f'') + + +def prop(key, *frames): + return f'' + "".join(frames) + "" + + +def obj(oid, label, *props): + return f"\n \n " + "".join(props) + "" + + +def callback(event_id, frame): + return f'' + + +class Mark: + """The O and its dot, drawn `size` px across and centred on (cx, cy).""" + + def __init__(self, size, cx, cy, glow=True): + s = size / 100 + self.s, self.cx, self.cy, self.glow = s, cx, cy, glow + self.r, self.w, self.d = 40 * s, 12 * s, 9 * s + a = math.radians(45) + self.dx, self.dy = cx + self.r * math.cos(a), cy + self.r * math.sin(a) + self.ring, self.trim, self.dot, self.halo, self.ping = nid(), nid(), nid(), nid(), nid() + + def shapes(self): + """First declared paints on top: dot, glow, ping, ring.""" + halo = 22 * self.s + out = f''' + + + + ''' + if self.glow: + out += f''' + + + + + + + + + + + + + + + ''' + out += f''' + + + + + + + ''' + return out + + def intro(self, t=0, far=None): + """The draw-in and landing, starting at frame t. Lands at t + 66.""" + far = 20 * self.s if far is None else far + keys = [ + obj(self.trim, "ring draws on", prop(TRIM_END, kf(0, 0), kf(0, t, EXPO), kf(ARC, t + 90))), + obj(self.ring, "ring sweeps round as it draws", + prop(ROTATION, kf(ROT - math.radians(40), 0), kf(ROT - math.radians(40), t, EXPO), kf(ROT, t + 90)), + prop(OPACITY, kf(0, 0), kf(0, t, OUT), kf(1, t + 6))), + obj(self.dot, "dot arrives from out along the diagonal and settles in the gap", + prop(X, kf(self.dx + far, 0), kf(self.dx + far, t + 36, GLIDE), kf(self.dx, t + 70)), + prop(Y, kf(self.dy + far, 0), kf(self.dy + far, t + 36, GLIDE), kf(self.dy, t + 70)), + prop(SX, kf(0.3, 0), kf(0.3, t + 36, THUMB), kf(1, t + 76)), + prop(SY, kf(0.3, 0), kf(0.3, t + 36, THUMB), kf(1, t + 76)), + prop(OPACITY, kf(0, 0), kf(0, t + 36, OUT), kf(1, t + 46))), + ] + if self.glow: + keys += [ + obj(self.halo, "glow blooms on landing, then settles", + prop(OPACITY, kf(0, 0), kf(0, t + 60, OUT), kf(1, t + 76, INOUT), kf(0.7, t + 132)), + prop(SX, kf(0.55, 0), kf(0.55, t + 60, OUT), kf(1.3, t + 80, INOUT), kf(1, t + 132)), + prop(SY, kf(0.55, 0), kf(0.55, t + 60, OUT), kf(1.3, t + 80, INOUT), kf(1, t + 132))), + obj(self.ping, "ping ring on landing", + prop(SX, kf(1, 0), kf(1, t + 66, OUT), kf(2.6, t + 114)), + prop(SY, kf(1, 0), kf(1, t + 66, OUT), kf(2.6, t + 114)), + prop(OPACITY, kf(0, 0), kf(0.7, t + 66, OUT), kf(0, t + 114))), + ] + return keys + + def breathe(self): + return [ + obj(self.halo, "glow breathes", + prop(OPACITY, kf(0.7, 0, INOUT), kf(1, 84, INOUT), kf(0.7, 168)), + prop(SX, kf(1, 0, INOUT), kf(1.15, 84, INOUT), kf(1, 168)), + prop(SY, kf(1, 0, INOUT), kf(1.15, 84, INOUT), kf(1, 168))), + obj(self.dot, "dot breathes, barely", + prop(SX, kf(1, 0, INOUT), kf(1.04, 84, INOUT), kf(1, 168)), + prop(SY, kf(1, 0, INOUT), kf(1.04, 84, INOUT), kf(1, 168))), + ] + + +def animation(name, duration, loop, keys): + aid = nid() + return aid, (f'\n ' + + "".join(keys) + "\n ") + + +def machine(name, first, then=None): + """Plays `first`, then loops `then` if given.""" + sm, s1, s2 = nid(), nid(), nid() + after = (f'' + if then else "") + second = f'' if then else "" + return sm, f''' + + + + {after} + {second} + + ''' + + +def artboard(name, w, h, x, body): + sm, rest = body + return f''' + + {rest} + ''' + + +STYLES = {} + + +def nid_style(name): + STYLES[name] = nid() + return STYLES[name] + + +# ---------------------------------------------------------------- Logo: 400 px mark, glow and ping +def logo(): + m = Mark(400, 240, 240) + ev = nid() + intro_id, intro = animation("Intro", 132, "oneShot", m.intro() + [callback(ev, 66)]) + idle_id, idle = animation("Idle", 168, "loop", m.breathe()) + sm, sm_xml = machine("Logo", intro_id, idle_id) + return sm, f''' + + {m.shapes()}{sm_xml}{intro}{idle}''' + + +# ---------------------------------------------------------------- Small: no ping, no glow, no loop +def small(): + m = Mark(100, 60, 60, glow=False) + ev = nid() + intro_id, intro = animation("Intro", 90, "oneShot", m.intro(far=12) + [callback(ev, 66)]) + sm, sm_xml = machine("Small", intro_id) + return sm, f''' + {m.shapes()}{sm_xml}{intro}''' + + +# ---------------------------------------------------------------- Splash: the O lands, then UTHERE +F = 205 # font size of OUT +SMALL = 0.78 # HERE is set smaller, so OUT leads +MARK = 0.74 * F # the inline wordmark: the mark stands in for the O +GAP, TRACK = 0.06 * F, 0.06 # .oh-word.inline; tracking is a share of each size +UT_W = (0.479 + 0.468 + 2 * TRACK) * F # advance widths from the font +HERE_W = (0.48 + 0.438 + 0.471 + 0.438 + 4 * TRACK) * SMALL * F +WORD_W = UT_W + HERE_W +SPLASH_W, SPLASH_H = 800, 260 +LEFT = (SPLASH_W - (MARK + GAP + WORD_W)) / 2 + + +def splash(): + m = Mark(MARK, LEFT + MARK / 2, SPLASH_H / 2) + ev, word, style, small, clip = nid(), nid(), nid(), nid(), nid() + tx = LEFT + MARK + GAP + baseline = SPLASH_H / 2 + 0.35 * F # cap height is 0.7 em, centred on the mark + word_xml = f''' + + + + + + + + + + + + + + + + + ''' + keys = m.intro() + [callback(ev, 66), + obj(word, "UTHERE slides out of the O as the dot lands", + prop(X, kf(tx - 0.9 * WORD_W, 0), kf(tx - 0.9 * WORD_W, 70, EXPO), kf(tx, 130)), + prop(OPACITY, kf(0, 0), kf(0, 70, OUT), kf(1, 84))), + obj(style, "tracking settles as it arrives", + prop(LETTER_SPACING, kf(0.3 * F, 0), kf(0.3 * F, 70, EXPO), kf(TRACK * F, 140))), + obj(small, "HERE settles with it", + prop(LETTER_SPACING, kf(0.3 * SMALL * F, 0), kf(0.3 * SMALL * F, 70, EXPO), kf(TRACK * SMALL * F, 140)))] + intro_id, intro = animation("Intro", 150, "oneShot", keys) + idle_id, idle = animation("Idle", 168, "loop", m.breathe()) + sm, sm_xml = machine("Splash", intro_id, idle_id) + return sm, f''' + {word_xml}{m.shapes()}{sm_xml}{intro}{idle}''' + + +# ---------------------------------------------------------------- Loader: the O draws and undraws +def loader(): + m = Mark(100, 60, 60, glow=False) + keys = [ + # The ring stays still, so the gap and the dot never move; only the stroke runs round. + obj(m.trim, "the end runs ahead, the start chases it", + prop(TRIM_END, kf(0, 0, INOUT), kf(ARC, 54)), + prop(TRIM_START, kf(0, 0), kf(0, 30, INOUT), kf(ARC, 84))), + obj(m.dot, "the dot pulses while it waits", + prop(SX, kf(1, 0, INOUT), kf(0.8, 42, INOUT), kf(1, 84)), + prop(SY, kf(1, 0, INOUT), kf(0.8, 42, INOUT), kf(1, 84)), + prop(OPACITY, kf(1, 0, INOUT), kf(0.6, 42, INOUT), kf(1, 84))), + ] + spin_id, spin = animation("Spin", 84, "loop", keys) + sm, sm_xml = machine("Loader", spin_id) + return sm, f"{m.shapes()}{sm_xml}{spin}" + + +rml = f''' + {artboard("Logo", 480, 480, 0, logo())}{artboard("Small", 120, 120, 540, small())}{artboard("Splash", SPLASH_W, SPLASH_H, 720, splash())}{artboard("Loader", 120, 120, 1580, loader())} + + + +''' + +if __name__ == "__main__": + pathlib.Path(__file__).with_name("scene.rml").write_text(rml) diff --git a/rive/logo/export/outhere-logo.riv b/rive/logo/export/outhere-logo.riv new file mode 100644 index 0000000..e45d97a Binary files /dev/null and b/rive/logo/export/outhere-logo.riv differ diff --git a/rive/logo/fonts/BarlowCondensed-Bold-UTHERE.ttf b/rive/logo/fonts/BarlowCondensed-Bold-UTHERE.ttf new file mode 100644 index 0000000..5cf3a2b Binary files /dev/null and b/rive/logo/fonts/BarlowCondensed-Bold-UTHERE.ttf differ diff --git a/rive/logo/fonts/OFL.txt b/rive/logo/fonts/OFL.txt new file mode 100644 index 0000000..2f22ba6 --- /dev/null +++ b/rive/logo/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/rive/logo/rive.yaml b/rive/logo/rive.yaml new file mode 100644 index 0000000..f4e0f3e --- /dev/null +++ b/rive/logo/rive.yaml @@ -0,0 +1,7 @@ +name: outhere-logo +main: Logo +exclude: + - build.py + - README.md + - fonts/OFL.txt + - export diff --git a/rive/logo/scene.rml b/rive/logo/scene.rml new file mode 100644 index 0000000..e659544 --- /dev/null +++ b/rive/logo/scene.rml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rive/versus/.gitignore b/rive/versus/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/rive/versus/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/rive/versus/README.md b/rive/versus/README.md new file mode 100644 index 0000000..f3ccedd --- /dev/null +++ b/rive/versus/README.md @@ -0,0 +1,121 @@ +# Outhere 1v1 card (Rive) + +`export/outhere-versus.riv` is the versus screen from the prototype's countdown (.vs9): the screen split on a diagonal, you on the blue side, them on the red, a slammed VS in the middle and the stakes along the bottom. Artboard `Versus`, state machine `Versus`, 390 x 844. + +## What the app sets + +Everything goes through the `Versus` view model, so one file serves every battle. + +| Property | Type | What it is | +| --- | --- | --- | +| `youName`, `youCity`, `themName`, `themCity` | string | Names and areas; long names shrink to fit | +| `title`, `rule` | string | Top bar, for example `1V1 · REMATCH` and `24 hours · most steps` | +| `stake`, `prize`, `clock` | string | The three tiles | +| `youPhoto`, `themPhoto` | image | One photo per runner, any size | +| `youResult`, `themResult` | image | The shot each side swaps to when the result lands (see below) | +| `youHasPack` | boolean | Hides the Add your Fighter Pack pill | +| `ko` | boolean | Makes the next result a knockout (see below) | +| `intro` | enum `Intro` | Which intro opens the card: `strike` (the default), `tape`, `cinematic` or `letsRun` (see below) | +| `youLine`, `themLine` | string | The line under each name in the cinematic, for example `PECKHAM · 11.4K STEPS A DAY` | +| `tape1Label` … `tape5Label` | string | The tale of the tape's five row labels | +| `tape1You`, `tape1Them` … `tape5Them` | string | Each row's two numbers as shown, for example `11.4k` and `9.8k` | +| `tape1YouBar`, `tape1ThemBar` … `tape5ThemBar` | number | Each row's two bars, 0 to 1, as a share of the bigger number; the side at 1 leads | +| `win`, `lose`, `replay` | trigger | Play a result, or play the intro again; set the result shots first | + +The file fires `addPack` when the pill is tapped, so the app can open the Fighter Pack flow. + +## Which photo to set + +For each runner, in this order: + +1. Their Fighter Pack VS shot. +2. Their profile photo. +3. The shadow runner for the gender they chose in onboarding or settings, lightly tinted blue on your side and red on theirs: `images/silhouette-{male,female}-{blue,red}.jpg`. Ship these four with the app. + +The file embeds the male shadow runners (blue for you, red for them) as defaults, so it never shows an empty side. Each photo is scaled to cover its box whatever its size. The boxes are big, so each runner fills most of their side: yours fades out towards the bottom and theirs towards the top, melting into the wash colour so ordinary photos have no hard edges. `PHOTO_BOX` and `PHOTO_FADE` in `build.py` set both. Pack shots are cut out on black and drawn with a lighten blend, so their background disappears. + +## The intros + +The card has four intros, and the app picks one per battle with `intro`, so each tells you what kind of battle it is before the card lands. Set `intro` before the card first plays, or set it and fire `replay`. + +| `intro` | For | What plays | +| --- | --- | --- | +| `strike` | every everyday battle; the signature | `Strike In`, about 1.7 s | +| `tape` | a rematch, a battle with a stake, or a rival | `Tape In`, then the `Tape` layer, about 3.2 s | +| `cinematic` | the big ones: top of a leaderboard, a club derby, a grudge match | `Cinematic In`, about 3.9 s | +| `letsRun` | event battles, like launch nights and weekly city events | `Lets Run In`, about 4.1 s | + +**Seam Strike.** Speed lines race along the seam, blue down your side and red up theirs, while the two halves scissor in along the seam from opposite ends. The seam ignites in a flare that throws sparks, the screen flashes and shakes, and VS slams with its ring. The names, top bar and stake tiles follow, and the Add your Fighter Pack pill waits until the card is up. Each side's wash runs `REACH` points past its outer edge so the scissor never opens a gap at the screen edges. + +**Tale of the Tape.** The card comes up from black and VS slams first. At 1.5 s the names give way to the tape, which rises under VS row by row with its bars growing outwards, and stays on screen with the stake tiles; a shade darkens the lower half so it reads over the photos. Each row shows a label and both numbers, and each bar is the app's share of the bigger number; the side whose bar is full leads, its number bright in a glow that follows its digits, the other at half strength. The tape runs on its own state machine layer, `Tape`, and a result blends it away and brings the names back. + +**Cinematic.** A slow push in on each runner in turn, full screen on their photo, with their name, a glowing bar and `youLine` or `themLine`. At 2.8 s a white flash cuts to the card as VS slams. Pack shots look best here. + +**Let's Run.** Giant outlined names and areas stream across the screen behind two letterbox panels of the runners that wipe open, then the battle's `rule` shows and LET'S RUN slams with a shake. At 3 s a flash cuts to the card as VS slams. + +The tape, cinematic and Let's Run land the card the same way (`land()` in `build.py`): the sides settle in from 80 points out while their seam edges hold still, so no gap opens along the seam, then the seam lights, VS slams with a shake and the names, top bar and tiles follow. `Slam In`, the original slide-in, is still in the file but no state plays it. + +## The result + +`win` and `lose` play the result from your side of the battle, in three beats. + +1. **The hit.** VS winds up and bursts, the screen flashes and each side swaps to its result shot (below). +2. **Your outcome.** A win gets YOU WIN! in white with a blue shadow, over two sets of light rays, two shock rings, sparks and a bloom, while two confetti cannons fire from the bottom corners and a burst comes out of the headline. A loss gets YOU LOSE in grey: the letters drop one at a time like weights and the screen dims. A loss gets no confetti. +3. **The labels.** The headline clears and WINNER slams in on the winner's side (white with a blue or red shadow), the winner's name kicks and `+prize OUT` pops under it with a glint. LOSER drops in grey on the other side, the seam swings toward the loser and the winner's glow keeps breathing (`Win Glow` or `Lose Glow`). + +The confetti runs on its own state machine layer, `Confetti`, so it keeps falling while the labels land: about a hundred pieces in white, ice blue, cyan, blue, violet and silver foil, each turning so its darker back shows and the foil flashes. Pieces near the camera are bigger and faster. + +For a knockout, set `ko` to true before firing the trigger (our line: the winner has at least double the loser's steps). K.O. slams into the middle first with a red flash and a shake, and the loser's side of the screen cracks like glass: a white-hot impact point, uneven rays and rings, lit facets, two holes and shards falling out. K.O. then moves to the loser's corner in place of LOSER, and YOU WIN! or YOU LOSE follows as usual. Set `ko` back to false for an ordinary result. + +Under the flash each side swaps to its result shot. Before firing the trigger, set: + +- the winner's `…Result` to their pack's win shot (the celebration pose), +- the loser's `…Result` to their pack's respect shot, +- either one to the same image as its photo when the pack has no such shot, or there is no pack. + +## Cues for sound and haptics + +The file sends these events as an intro or a result plays. None of them changes the scene, so sounds and haptics can be added in the app later without touching the file. + +| Event | When it fires | +| --- | --- | +| `strike` | the seam ignites (Seam Strike) | +| `cutIn` | each runner's cut-in starts (Cinematic, twice) | +| `letsRun` | LET'S RUN slams (Let's Run) | +| `slam` | VS slams, in every intro | +| `tape` | the names hand over to the tape (Tale of the Tape) | +| `impact` | VS bursts, on every result | +| `knockout` | K. lands | +| `shatter` | O. lands and the glass cracks | +| `youWin` | YOU WIN! lands | +| `confetti` | the cannons fire | +| `youLose` | YOU LOSE starts | +| `thud` | each letter of YOU LOSE lands (seven) | +| `winner` | WINNER lands | +| `loser` | LOSER drops (not on a knockout) | +| `prize` | the prize chip pops | +| `addPack` | the Add your Fighter Pack pill is tapped | + +## Building + +- `build.py` writes `scene.rml`. Edit `build.py`, not the RML. +- The shadow runners come from the Fighter Pack plan's grey runners in `/mnt/project-files/outhere-assets/ai-avatar/silhouette-{male,female}.jpg`. `tools/shade.py SOURCE male|female blue|red OUT.jpg` turns one into a fallback; rerun it for all four when a source changes. +- Fonts are Barlow Condensed (licence in `fonts/OFL.txt`). The ExtraBold Italic draws names in the cinematic and Let's Run, so it is cut down to Latin rather than a few letters: `pyftsubset BarlowCondensed-ExtraBoldItalic.ttf --unicodes="U+0020-007E,U+00A0-017F,U+2010-2044,U+20AC,U+2122"`. + +```bash +python3 build.py +rive . --verify && rive inspect . --summary +rive . --once && cp build/outhere-versus.riv export/ +rive . --screenshot=frame.png --advance=90 +rive . --screenshot=tape.png --advance=200 --data=intro=tape # another intro +rive . --screenshot=win.png --advance=60 --data=win=true # a result +rive . --screenshot=ko.png --advance=40 --data=ko=true --data=lose=true # a knockout +``` + +## Runtime notes + +- A swapped image draws at its own pixel size unless it sits in a layout box, which is why each photo lives in a root-level `LayoutComponent` with `fit="cover"`. A layout box inside a node does not lay out, so the boxes are at the root and clipped by the same shapes as their sides. +- The fade inside a photo box does not follow a box that is moved or scaled, so results leave the photos where they are and move the seam instead. +- An image inside a layout box does scale when its own scale is keyed, which is how the cinematic and Let's Run push in on the photos. +- Rive has no text case, so names show as the app sends them. +- The web runtime ignores `fitFontSize`; the name boxes use `fit`. diff --git a/rive/versus/build.py b/rive/versus/build.py new file mode 100644 index 0000000..bf70929 --- /dev/null +++ b/rive/versus/build.py @@ -0,0 +1,2321 @@ +"""Writes scene.rml for the Outhere 1v1 versus screen. + +Layout follows the prototype's countdown screen (.vs9 in demo-v29): a phone +screen split on a diagonal, you on the blue side top left, them on the red +side bottom right, a slammed VS in the middle and the stakes along the bottom. + +The app drives it through the `Versus` view model: + youName, youCity, themName, themCity, title, rule strings + stake, prize, clock strings for the three tiles + youPhoto, themPhoto images, any size, drawn to + cover their side. The app sets + the Fighter Pack shot, else the + profile photo, else the + silhouette for the runner's + gender. The file ships the male + silhouettes, blue for you and + red for them. + youResult, themResult the shot that takes over when the + result lands: the pack's win shot + for the winner, its respect shot + for the loser. Without one, set + the same image as the photo. + youHasPack hides the Add your Fighter Pack pill + ko set before win or lose for a + knockout (the winner has at least + double the loser's steps): K.O. + lands first and shatters the + loser's side + intro which intro opens the card: strike + (the default), tape, cinematic or + letsRun; set it before the card + plays, or set it and fire replay + youLine, themLine the line under each name in the + cinematic cut-ins + tape1Label..tape5Label, tape1You..tape5Them the tale of the tape's rows: labels + and both numbers as text + tape1YouBar..tape5ThemBar each row's bars, 0 to 1, as a share + of the bigger number (the leader's is 1) + win, lose, replay triggers; set the result shots first +and listens for the `addPack` event (the pill was tapped). + +A result plays in beats. The VS bursts and the split swings toward the +loser. YOU WIN! slams in with a sunburst and confetti, or YOU LOSE drops in +letter by letter. Then it clears, and WINNER, LOSER and the prize land on +the two sides. A knockout puts K.O. and the broken glass first. + +The card opens with one of four intros, picked per battle. Seam Strike: speed +lines race along the seam while the two halves scissor in, the seam flares and +throws sparks, then VS slams. Tale of the Tape: VS slams, then the tape rises +under it and stays. Cinematic: a full-screen cut-in on each runner, then a +flash cuts to the card. Let's Run: names stream behind two runner panels, LET'S +RUN slams, then a flash cuts to the card. + +Cue events for sounds and haptics, in the order they fire: strike, cutIn, +letsRun, slam and tape in the intros; then impact, knockout, shatter, youWin, confetti, youLose, thud +(each YOU LOSE letter landing), winner, loser, prize. + +Run `python3 build.py`, then `rive . --verify && rive inspect . --summary`. +Times are frames at 60fps. +""" +import itertools +import math +import pathlib +import random + +W, H = 390, 844 +TOP, BOT = W * 0.66, W * 0.34 # where the split meets the top and bottom edges +SEAM_ANGLE = math.atan2(TOP - BOT, H) # radians, top leans right +MID = (TOP + BOT) / 2 + (TOP - BOT) * (0.5 - 388 / H), 388 # VS sits on the seam +SHIFT = 72 # how far the split moves toward the loser + +WHITE, OUT, BLUE_HI, ACT = "FFFFFFFF", "FF4F9BFF", "FF82B4FF", "FF2A6AF0" + +EASE_SIDE = (0.2, 0.9, 0.2, 1) +SLAM = (0.2, 1.4, 0.4, 1) +THUMB = (0.34, 1.56, 0.64, 1) +OUT_EASE = (0, 0, 0.58, 1) +INOUT = (0.45, 0, 0.55, 1) +SHOCK = (0.1, 0.7, 0.3, 1) +IN_EASE = (0.42, 0, 1, 1) +GRAV = (0.55, 0, 1, 1) # falls away, faster and faster +QUAD_IN = (0.333, 0, 0.667, 0.333) # a fall from rest that reaches full speed +DROP = (0.55, 0, 0.95, 0.6) # a heavy letter dropping +ZOOM = (0.5, 0, 0.85, 0.6) # rushing past the camera +LAUNCH_X = (0.25, 0.6, 0.4, 1) # confetti thrown out and slowed by the air +LAUNCH_Y = (0.1, 0.8, 0.25, 1) + +X, Y, ROT, SX, SY, OPACITY, VX = 13, 14, 15, 16, 17, 18, 24 +COLOUR, TRIM_END, FIRE = 37, 115, 395 + +_ids = itertools.count(100) + + +def nid(): + return f"0:{next(_ids)}" + + +# ---------------------------------------------------------------- keyframes + +def kf(value, frame, ease=None): + if ease is None: + return f'' + if ease == "linear": + return f'' + x1, y1, x2, y2 = ease + return (f'' + f'') + + +def kfc(colour, frame): + return f'' + + +def prop(key, *frames): + return f'' + "".join(frames) + "" + + +def keyed(oid, *props): + return f'' + "".join(props) + "" + + +ANIMS = {} +RESTS = [] # (object, property, value) for everything a result moves, filled in below + + +class Timeline: + """One animation: a track per object and property, the last write wins.""" + + def __init__(self, name, duration, loop="oneShot"): + self.name, self.duration, self.loop = name, duration, loop + self.tracks = {} + + def key(self, oid, key, *frames): + self.tracks[(oid, key)] = list(frames) + + def path(self, oid, key, points): + """(frame, value, ease to the next point) in order; the first value holds from frame 0.""" + f0, v0, _ = points[0] + frames = [kf(v0, 0)] if f0 > 0 else [] + frames += [kf(v, f, e) for f, v, e in points] + self.tracks[(oid, key)] = frames + + def tween(self, oid, key, a, b, f0, f1, ease): + self.path(oid, key, [(f0, a, ease), (f1, b, None)]) + + def fire(self, event, frame): + self.tracks.setdefault((event, FIRE), []).append((frame, f'')) + + def rest(self): + """Puts everything a result moves back where it starts, unless keyed here.""" + for oid, key, value in RESTS: + self.tracks.setdefault((oid, key), [kf(value, 0)]) + + def xml(self): + aid = nid() + ANIMS[self.name] = aid + objects = {} + for (oid, key), frames in self.tracks.items(): + if key == FIRE: + frames = [x for _, x in sorted(frames)] + objects.setdefault(oid, []).append(prop(key, *frames)) + body = "".join(keyed(oid, *ps) for oid, ps in objects.items()) + return (f'' + f'{body}') + + +# ---------------------------------------------------------------- view model + +VM = "0:40" +props = {} +instance_values = [] + + +def vm_prop(kind, name, value=None, attrs=""): + pid = nid() + props[name] = (kind, pid, attrs) + if kind == "String": + instance_values.append(f'') + elif kind == "Boolean": + instance_values.append(f'') + elif kind == "AssetImage": + instance_values.append(f'') + elif kind == "Trigger": + instance_values.append(f'') + elif kind == "Number": + instance_values.append(f'') + elif kind == "EnumCustom": + instance_values.append(f'') + return pid + + +SIL_YOU, SIL_THEM = "0:60", "0:64" +FONT_BOLD, FONT_VS, FONT_MED = "0:61", "0:62", "0:63" + +for name, val in [("youName", "Alex"), ("youCity", "Peckham"), ("themName", "Kemi"), ("themCity", "Brixton"), + ("title", "1V1 · REMATCH"), ("rule", "24 hours · most steps"), + ("stake", "50"), ("prize", "100"), ("clock", "24h")]: + vm_prop("String", name, val) +vm_prop("AssetImage", "youPhoto", SIL_YOU) +vm_prop("AssetImage", "themPhoto", SIL_THEM) +vm_prop("AssetImage", "youResult", SIL_YOU) +vm_prop("AssetImage", "themResult", SIL_THEM) +vm_prop("Boolean", "youHasPack", "false") +vm_prop("Boolean", "ko", "false") +for t in ("win", "lose", "replay"): + vm_prop("Trigger", t) + +# Which intro opens the card; the app picks it per battle (see the README). +INTRO_ENUM = nid() +INTROS = {key: nid() for key in ("strike", "tape", "cinematic", "letsRun")} +INTRO_NAMES = {"strike": "Seam Strike", "tape": "Tale of the Tape", "cinematic": "Cinematic", "letsRun": "Let's Run"} +vm_prop("EnumCustom", "intro", INTROS["strike"], f'enumId="{INTRO_ENUM}" ') +intro_enum_xml = (f'' + + "".join(f'' for k, v in INTROS.items()) + + "") + +# The line under each runner's name in the cinematic cut-ins. +vm_prop("String", "youLine", "PECKHAM · 11.4K STEPS A DAY") +vm_prop("String", "themLine", "BRIXTON · 9.8K STEPS A DAY") + +# The tale of the tape: five rows, each a label, the two numbers as text and +# each side's bar as a fraction of the larger number (the leader's bar is 1). +TAPE_ROWS = [("STEPS A DAY", "11.4k", "9.8k", 1, 0.86), ("BEST DAY", "21.2k", "24.6k", 0.862, 1), + ("WIN STREAK", "6", "9", 0.667, 1), ("1V1 RECORD", "14-5", "11-7", 1, 0.786), ("K.O.s", "3", "5", 0.6, 1)] +for n, (label, a, b, bar_a, bar_b) in enumerate(TAPE_ROWS, 1): + vm_prop("String", f"tape{n}Label", label) + vm_prop("String", f"tape{n}You", a) + vm_prop("String", f"tape{n}Them", b) + vm_prop("Number", f"tape{n}YouBar", bar_a) + vm_prop("Number", f"tape{n}ThemBar", bar_b) + + +def path(name): + return f"{VM}-{props[name][1]}" + + +def bind_text(name): + return f'' + + +def bind_num(name, key, converter=None): + """Binds a number property to an object's own property, through a converter if given.""" + conv = f' converterId="{converter}"' if converter else "" + return f'' + + +# A side leads a tape row when its bar is full: its number shows at full +# strength in a glow that follows its digits, the other side's at half. +LEAD_TEXT, LEAD_ON = nid(), nid() +converters_xml = f''' + + ''' + + +# ---------------------------------------------------------------- events +# Cues for the app's sounds and haptics; none of them changes the scene. + +EVENTS = {name: nid() for name in ("addPack", "strike", "cutIn", "letsRun", "slam", "tape", "impact", "knockout", "shatter", + "youWin", "confetti", "youLose", "thud", "winner", "loser", "prize")} + +# ---------------------------------------------------------------- drawing helpers + +def text(x, y, size, font, colour, value, bind=None, origin=(0, 0), spacing=0, oid=None, opacity=1, name="Text", fit=None, + extra=""): + sid = nid() + oid = oid or nid() + b = bind_text(bind) if bind else "" + family, style = {FONT_BOLD: ("Barlow Condensed", "Bold"), FONT_VS: ("Barlow Condensed", "ExtraBold Italic"), + FONT_MED: ("Barlow Condensed", "Medium")}[font] + if fit: # a fixed box the text shrinks to fit, aligned to the side it is anchored on + align = "right" if origin[0] == 1 else "left" + sizing = (f'sizingValue="fixed" width="{fit[0]}" height="{fit[1]}" overflowValue="fit" ' + f'alignValue="{align}" verticalAlignValue="bottom"') + else: + sizing = 'sizingValue="autoWidth"' + return (f'' + f'' + f'' + f'{b}{extra}') + + +def poly(points, oid, ids=None): + vs = [] + for i, (px, py) in enumerate(points): + vid = f' id="{ids[i]}"' if ids and ids[i] else "" + vs.append(f'') + return f'' + "".join(vs) + "" + + +def open_path(pts, name="Line"): + vs = "".join(f'' for x, y in pts) + return f'{vs}' + + +def closed_path(pts, name="Path"): + vs = "".join(f'' for x, y in pts) + return f'{vs}' + + +def glow(oid, size, rgb, a0="99", a1="33", opacity=0, scale_y=1, name="Glow", blend="srcOver"): + """A soft radial bloom that fades to nothing at its edge.""" + r = size / 2 + return f''' + + + + + + ''' + + +def soft_halo(x0, x1, half, rgb, alpha): + """A glow along a bar from x0 to x1 that fades to nothing above, below and past both ends. + + The middle fades straight up and down; each end is a half disc fading out + from the bar's end at the same rate, so the glow has no edge anywhere.""" + a, b = sorted((x0, x1)) + out = f''' + + + + + + ''' + for cx, o in ((a, 1), (b, 0)): + out += f''' + + + + + ''' + return out + + +def digit_glow(x, o, value, bind, rgb, extra=""): + """A number drawn again behind itself as soft stacked strokes, so its glow + follows its own digits however short or long it is.""" + sid = nid() + strokes = "".join(f'' + f'' for i in range(11)) + return (f'' + f'' + f'{strokes}{bind_text(bind)}{extra}') + + +def flash(oid, size, rgb, name="Flash"): + r = size / 2 + return f''' + + + + + + ''' + + +def ring(oid, size, crisp, halo_rgb=None, name="Ring"): + """A shock ring: a soft halo under a crisp line (within a shape the last paint is on top).""" + halo = f'' if halo_rgb else "" + return f''' + + + {halo} + + ''' + + +def streaks(n, rgb, long=40, short=26): + """Sparks: n streaks around a point, each in its own turned node, flying out along it.""" + xml, bars = "", [] + for i in range(n): + node, bar = nid(), nid() + bars.append(bar) + ang = 2 * math.pi * i / n + (0.12 if i % 2 else 0) + colour = WHITE if i % 2 else f"FF{rgb}" + length = short if i % 3 else long + xml += f''' + + + + + + + ''' + return xml, bars + + +def sunburst(oid, rays, half, radius, beam, core=None, name="Rays", offset=0.0): + """Beams of light from the centre that fade toward their tips, each with a + brighter core down its middle. They screen over the scene, so they add light.""" + def layer(width, stops, label): + paths = "" + for i in range(rays): + a = 2 * math.pi * i / rays + offset + paths += poly([(0, 0), (radius * math.cos(a - width), radius * math.sin(a - width)), + (radius * math.cos(a + width), radius * math.sin(a + width))], nid()) + st = "".join(f'' for p, c in stops) + return f''' + {paths} + {st} + ''' + return f''' + {layer(half * 0.35, core, "Core") if core else ""}{layer(half, beam, "Beam")} + ''' + + +def twinkle(oid, x, y, r): + """A four-point star with a small bloom, hidden at scale 0.""" + pts = [((r if k % 2 == 0 else r * 0.2) * math.cos(k * math.pi / 4 - math.pi / 2), + (r if k % 2 == 0 else r * 0.2) * math.sin(k * math.pi / 4 - math.pi / 2)) for k in range(8)] + return f''' + + {poly(pts, nid())} + + + + + + ''' + + +# Advance widths and kerning of the display face, in ems, from the font. +ADV = {"Y": .477, "O": .464, "U": .466, " ": .194, "W": .685, "I": .228, "N": .506, "!": .281, + "L": .423, "S": .441, "E": .428, "K": .49, ".": .228} +KERN = {("Y", "O"): -.027, ("L", "O"): -.004, ("S", "E"): -.001, ("K", "."): .003, ("O", "."): -.001} + + +def letters(word, size, tracking): + """Each letter of a line of text with its centre x and advance, the line centred on 0.""" + x, out = 0.0, [] + for i, ch in enumerate(word): + adv = ADV[ch] * size + out.append([ch, x + adv / 2, adv]) + if i + 1 < len(word): + x += adv + tracking + KERN.get((ch, word[i + 1]), 0) * size + else: + x += adv + for o in out: + o[1] -= x / 2 + return out + + +# ---------------------------------------------------------------- the sides + +WASH = { # each side's colour wash: start point, end point, start colour, end colour + "you": ((120, 0), (250, 600), "0B2A66", "050A16"), + "them": ((270, 844), (140, 244), "5A1218", "12060A"), +} + + +def wash_colour(who, x, y): + """The wash's colour at (x, y), so a photo's fade can melt into it.""" + (sx, sy), (ex, ey), c0, c1 = WASH[who] + dx, dy = ex - sx, ey - sy + t = min(1, max(0, ((x - sx) * dx + (y - sy) * dy) / (dx * dx + dy * dy))) + a, b = bytes.fromhex(c0), bytes.fromhex(c1) + return "".join(f"{round(p + (q - p) * t):02X}" for p, q in zip(a, b)) + + +# Each runner's photo box (x, y, width, height), about the shape of a pack shot. +# Big enough that the runner fills their side: yours runs from the top of the +# screen, theirs to the bottom, and they overlap behind the seam. +# Each runner is centred in the open part of their side, away from the seam. +PHOTO_BOX = {"you": (-78, 0, 372, 664), "them": (100, 180, 372, 664)} +REACH = 260 # how far each side's wash runs past its outer edge +# Where each photo melts into the wash (position down the box, opacity of the +# wash on top). Yours fades out a long way at the bottom; theirs only softens its top edge, so their head shows. +PHOTO_FADE = { + "you": [(0, 0.55), (0.12, 0), (0.34, 0), (0.68, 0.85), (0.82, 1), (1, 1)], + "them": [(0, 1), (0.03, 0.55), (0.1, 0), (0.86, 0), (1, 0.6)], +} + + +def side(who): + """One half of the split: its clip and colour wash, plus its photo box. + + The photo sits in a layout box at the root so Rive scales any image to + cover it; a layout box inside a node does not lay out. The box is clipped + by the same shape as the wash, and its x is keyed alongside the group's. + """ + you = who == "you" + group, clip, content, photo, style, dim, shot = nid(), nid(), nid(), nid(), nid(), nid(), nid() + v = [nid(), nid()] # the two vertices on the seam, animated for win and lose + # Each side reaches REACH past its outer screen edge, so the strike intro, + # which slides the sides along the seam, never opens a gap at the edges. + if you: + pts, vids = [(-REACH, 0), (TOP, 0), (BOT, H), (-REACH, H)], [None, v[0], v[1], None] + else: + pts, vids = [(TOP, 0), (W + REACH, 0), (W + REACH, H), (BOT, H)], [v[0], None, None, v[1]] + (sx, sy), (ex, ey), c0, c1 = WASH[who] + grad = (f'' + f'') + bx, by, bw, bh = PHOTO_BOX[who] + stops = "".join(f'' + for pos, a in PHOTO_FADE[who]) + xml = f''' + + + {poly(pts, nid(), vids)} + + + + + + {grad} + + + ''' + def box(oid, sid, bind, name, opacity=1): + return f''' + + + + + + + {stops} + + + + + + ''' + + who_name = "You" if you else "Them" + # The result shot sits over the photo, hidden until the result lands. + photo_xml = (box(shot, nid(), who + "Result", f"{who_name} Result", opacity=0) + + box(photo, style, who + "Photo", f"{who_name} Photo")) + # The loser's side darkens under this, photo and wash together, so the + # photo's faded edges still match the wash behind them. + photo_xml = f''' + + + + + + + ''' + photo_xml + return xml, photo_xml, dict(group=group, clip=clip, content=content, v=v, photo=photo, dim=dim, shot=shot) + + +you_xml, you_photo_xml, YOU = side("you") +them_xml, them_photo_xml, THEM = side("them") + +# ---------------------------------------------------------------- seam, VS, names, tiles + +SEAM, SEAM_GLOW = nid(), nid() +seam_xml = f''' + + + + + + + + + + + + + + + ''' + +VSG, FLASH, RING, VSTEXT, VSGLOW = nid(), nid(), nid(), nid(), nid() +vs_xml = f''' + + + {text(0, 0, 118, FONT_VS, WHITE, "VS", origin=(0.5, 0.5), spacing=-2, name="VS Face")} + {text(0, 6, 118, FONT_VS, "FF1F62FF", "VS", origin=(0.5, 0.5), spacing=-2, name="VS Shadow")} + + {glow(VSGLOW, 300, "82B4FF", a0="73", a1="26", opacity=1, name="VS Glow")} + {ring(RING, 120, (3, WHITE), "82B4FF", name="Shock Ring")} + {flash(FLASH, 340, "82B4FF")} + ''' + + +# ---------------------------------------------------------------- the strike intro +# Speed lines race along the seam, blue down your side and red up theirs, +# while the two halves scissor in from opposite ends. The seam ignites in a +# flare that throws sparks, the screen flashes and VS slams. All of it lives +# in a node turned to the seam: local y runs down the seam, local x toward them. + +SEAM_LEN = math.hypot(TOP - BOT, H) +DOWN = ((BOT - TOP) / SEAM_LEN, H / SEAM_LEN) # down the seam, in screen space +VS_ON_SEAM = MID[1] - H / 2 # where VS sits along the seam +STRIKE = dict(node=nid(), flare=nid(), a=nid(), b=nid(), lines=[], sparks=[]) +SCREEN_FLASH = nid() + + +def strike_parts(): + rnd = random.Random(4) + xml = "" + for i in range(14): # speed lines: (id, x, length, start frame, frames, from y, to y) + down = i % 2 == 0 + x, ln, w = (-1 if down else 1) * rnd.uniform(14, 200), rnd.uniform(110, 260), rnd.choice((2, 2, 3, 4)) + f0, n = round(0.06 * (60 + rnd.uniform(0, 250))), round(0.06 * rnd.uniform(200, 300)) + y0, y1 = (-470, 470 + ln) if down else (470, -470 - ln) + oid = nid() + STRIKE["lines"].append((oid, f0, n, y0, y1)) + rgb = "C8DEFF" if down else "FFC8CD" + a, b = (f"00{rgb}", f"F2{rgb}") if down else (f"F2{rgb}", f"00{rgb}") + xml += f''' + + + + + ''' + for i in range(22): # sparks thrown off the seam, mostly around VS: (id, start frame, frames, distance) + y = max(-380, min(380, rnd.gauss(VS_ON_SEAM, 130))) + right = rnd.random() < 0.5 + ang = math.radians((0 if right else 180) + rnd.uniform(-38, 38)) + ln, th, d = rnd.uniform(12, 34), rnd.choice((2, 2, 3)), rnd.uniform(50, 170) + f0, n = 28 + rnd.randint(0, 3), round(0.06 * rnd.uniform(240, 440)) + oid = nid() + STRIKE["sparks"].append((oid, f0, n, d)) + xml += f''' + + + + + + + ''' + + def streak(oid, down, halo): + o, (a, b) = (1, ("00FFFFFF", WHITE)) if down else (0, (WHITE, "00FFFFFF")) + g0, g1 = (-300, 0) if down else (0, 300) + return f''' + + + + + + + + + + + + ''' + flare = f''' + + + + + + + ''' + return f''' + {xml} + {streak(STRIKE["a"], True, "82B4FF")} + {streak(STRIKE["b"], False, "FF6670")} + {flare} + ''' + + +strike_xml = strike_parts() +screen_flash_xml = f''' + + + + + + + ''' + + +# ---------------------------------------------------------------- WINNER, LOSER and the prize +# After the headline clears, WINNER slams in on the winner's side over a +# flash, two shock rings and a burst of sparks, the prize pops under the +# winner's name, and LOSER drops in heavy and grey on the other side. + +HIT_SIZE, LOSER_SIZE = 84, 46 +HIT_W = 2.82 * HIT_SIZE # advance width of WINNER, from the font +LOSER_W = 2.223 * LOSER_SIZE +SIDE_INK = {"you": ("FF1F62FF", "82B4FF", "FF2A6AF0", "FF1A47C2"), # shadow, glow, chip top, chip bottom + "them": ("FFD7263A", "FF6670", "FFE5404C", "FFA0182A")} +SPARKS = 16 +CHIP_W = 3.189 * 26 + 28 +GLINT_FROM, GLINT_TO = -(CHIP_W / 2 + 24), CHIP_W / 2 + 24 + + +def hit(who): + """The winner's label on one side, and LOSER: ids for the animation, and the xml.""" + you = who == "you" + who_name = "You" if you else "Them" + shadow, glow_rgb, chip_top, chip_bot = SIDE_INK[who] + ids = dict(word=nid(), glow=nid(), flash=nid(), rings=(nid(), nid()), chip=nid(), loser=nid(), glint=nid()) + cx = 16 + HIT_W / 2 if you else W - 16 - HIT_W / 2 + # yours sits under your name and prize, clear of the face in a win shot; + # theirs sits above their name, over the chest + cy = 452 if you else 508 + sparks, ids["sparks"] = streaks(SPARKS, glow_rgb) + word = f''' + + + {text(0, 0, HIT_SIZE, FONT_VS, WHITE, "WINNER", origin=(0.5, 0.5), spacing=-1, name="Winner Face")} + {text(0, 5, HIT_SIZE, FONT_VS, shadow, "WINNER", origin=(0.5, 0.5), spacing=-1, name="Winner Shadow")} + + {sparks} + + {glow(ids["glow"], 440, glow_rgb, scale_y=0.5)} + {ring(ids["rings"][0], 160, (3, WHITE), glow_rgb)} + {ring(ids["rings"][1], 160, (2, f"CC{glow_rgb}"), name="Ring 2")} + {flash(ids["flash"], 460, glow_rgb)} + ''' + # The prize, under the winner's name and city, with a glint that sweeps it. + chip_x = 18 + CHIP_W / 2 if you else W - 18 - CHIP_W / 2 + chip_y = 372 if you else 628 + sid, chip_shape = nid(), nid() + chip = f''' + + + + + + {bind_text("prize")} + + + + + + + + + + + + + + + + + + ''' + # LOSER sits on this side when this side loses: above the name, grey and heavy. + lx = 18 + LOSER_W / 2 if you else W - 18 - LOSER_W / 2 + ly = 268 if you else 524 + loser = f''' + + {text(0, 0, LOSER_SIZE, FONT_VS, "B3FFFFFF", "LOSER", origin=(0.5, 0.5), spacing=0, name="Loser Face")} + {text(0, 3, LOSER_SIZE, FONT_VS, "CC000000", "LOSER", origin=(0.5, 0.5), spacing=0, name="Loser Shadow")} + ''' + ids["y"] = ly + ids["loser_x"] = lx + return ids, word, chip + loser + + +YOU_HIT, you_hit_xml, you_hit_labels = hit("you") +THEM_HIT, them_hit_xml, them_hit_labels = hit("them") +IMPACT = nid() +# A full-screen flash as the VS bursts, over the photos, under the words. +impact_xml = f''' + + + + ''' + +# ---------------------------------------------------------------- the headlines +# YOU WIN! slams into the middle over a turning sunburst, with rings, sparks +# and twinkles, then rushes past the camera. YOU LOSE drops in letter by +# letter and lands heavy, then the letters fall away off the screen. + +HEAD = (W / 2, 388) +HEAD_SIZE = 100 +HW = dict(word=nid(), bloom=nid(), flash=nid(), rings=(nid(), nid()), rays=(nid(), nid()), twinkles=[]) +head_sparks, HW["sparks"] = streaks(20, "82B4FF", long=46, short=30) +twinkles_xml = "" +for tx, ty, tr in [(-150, -46, 13), (-58, -54, 10), (40, -52, 14), (132, -44, 10), (-182, 16, 9), + (176, 20, 12), (-100, 44, 9), (92, 46, 11), (4, -74, 9)]: + HW["twinkles"].append(nid()) + twinkles_xml += twinkle(HW["twinkles"][-1], tx, ty, tr) +head_win_xml = f''' + + {twinkles_xml} + + + {text(0, 0, HEAD_SIZE, FONT_VS, WHITE, "YOU WIN!", origin=(0.5, 0.5), spacing=-1.5, name="Face")} + {text(0, 6, HEAD_SIZE, FONT_VS, "FF1F62FF", "YOU WIN!", origin=(0.5, 0.5), spacing=-1.5, name="Shadow")} + + {head_sparks} + + + {ring(HW["rings"][0], 220, (3, WHITE), "82B4FF")} + {ring(HW["rings"][1], 220, (2, "CC82B4FF"), name="Ring 2")} + + {flash(HW["flash"], 560, "82B4FF")} + + {glow(HW["bloom"], 560, "4F9BFF", a0="A6", a1="40", scale_y=0.5, name="Bloom", blend="screen")} + {sunburst(HW["rays"][0], 12, 0.075, 430, [(0, "6682B4FF"), (0.3, "2E82B4FF"), (0.7, "0A82B4FF"), (1, "0082B4FF")], + [(0, "B3FFFFFF"), (0.25, "66CFE0FF"), (0.6, "1A82B4FF"), (1, "0082B4FF")])} + {sunburst(HW["rays"][1], 8, 0.16, 360, [(0, "4082B4FF"), (0.4, "149B8CFF"), (1, "009B8CFF")], + name="Soft Rays", offset=math.pi / 8)} + ''' + +LETTER_BASE = 0.6 * HEAD_SIZE # each letter hangs from its foot, so it squashes as it lands +HL = dict(letters=[], gloom=nid()) +lose_letters = "" +for ch, lx, _ in letters("YOU LOSE", HEAD_SIZE, -1.5): + if ch == " ": + continue + HL["letters"].append(nid()) + lose_letters += f''' + + {text(0, 0, HEAD_SIZE, FONT_VS, "FFDDE1E8", ch, origin=(0.5, 1), name="Face")} + {text(0, 6, HEAD_SIZE, FONT_VS, "FF3E4450", ch, origin=(0.5, 1), name="Shadow")} + ''' +head_lose_xml = f''' + {lose_letters} + ''' +# The screen closes in around YOU LOSE. +gloom_xml = f''' + + + + + + ''' + +# ---------------------------------------------------------------- the knockout +# K. and O. slam into the middle one after the other, each with a red flash +# and a screen shake. The first cracks the loser's side of the glass, the +# second shatters it: rings of cracks, lit facets, holes where pieces fall +# out and shards flying at the camera. K.O. then flies to the loser's corner, +# where LOSER would have gone, and the broken glass stays. + +KO_SIZE = 132 +KO_SMALL = 0.62 +KO = dict(node=nid(), k=nid(), o=nid(), glow=nid()) +_ko = letters("K.O.", KO_SIZE, -2) +K_X = (_ko[0][1] - _ko[0][2] / 2 + _ko[1][1] + _ko[1][2] / 2) / 2 +O_X = (_ko[2][1] - _ko[2][2] / 2 + _ko[3][1] + _ko[3][2] / 2) / 2 +ko_xml = f''' + + + {text(0, 0, KO_SIZE, FONT_VS, WHITE, "K.", origin=(0.5, 0.5), name="Face")} + {text(0, 7, KO_SIZE, FONT_VS, "FFD7263A", "K.", origin=(0.5, 0.5), name="Shadow")} + + + {text(0, 0, KO_SIZE, FONT_VS, WHITE, "O.", origin=(0.5, 0.5), name="Face")} + {text(0, 7, KO_SIZE, FONT_VS, "FFD7263A", "O.", origin=(0.5, 0.5), name="Shadow")} + + {glow(KO["glow"], 380, "FF4A55", opacity=1, name="KO Glow")} + ''' +KO_IMPACT = nid() +ko_impact_xml = f''' + + + + ''' + +CRACK_AT = {"you": (86, 170), "them": (330, 410)} # where the hit lands on each side +RING_R = (18, 44, 80, 128) # the rings of the web +RAY_R = (0, 8, 18, 30, 44, 60, 80, 104, 128, 170, 230, 310, 400, 500) +REVEALS = [] # trims that draw the cracks on, kept at the artboard root + + +def reveal(name): + gid, tid = nid(), nid() + REVEALS.append(f'') + return gid, tid + + +def stroke(thickness, colour, effect, name="Line"): + return (f'' + f'') + + +def crack(who, clip): + """A spider web of cracks on one side, clipped to it, and the glass that flies off.""" + rng = random.Random(5 if who == "you" else 9) + who_name = "You" if who == "you" else "Them" + cx, cy = CRACK_AT[who] + n = 13 + base = rng.uniform(0, 2 * math.pi) + rays, ends = [], [] + for i in range(n): + a = base + 2 * math.pi * i / n + rng.uniform(-0.22, 0.22) + drift, pts = 0.0, [] + for r in RAY_R: + drift += rng.uniform(-0.05, 0.05) + rr = r * rng.uniform(0.82, 1.18) if r in RING_R else r + pts.append((rr * math.cos(a + drift), rr * math.sin(a + drift))) + # some cracks stop short instead of running to the edge + ends.append(len(RAY_R) if rng.random() < 0.7 else rng.randint(RAY_R.index(80), len(RAY_R) - 2)) + rays.append(pts[:ends[-1]]) + ring_at = [RAY_R.index(r) for r in RING_R] + joins, ring_paths = {}, [] + for k, vi in enumerate(ring_at): + for i in range(n): + j = (i + 1) % n + if vi < ends[i] and vi < ends[j] and rng.random() < (1, 0.75, 0.5, 0.3)[k]: + a, b = rays[i][vi], rays[j][vi] + # rings bow in toward the hit and kink twice on the way across + bow = rng.uniform(0.03, 0.12) + mids = [] + for f in (rng.uniform(0.28, 0.4), rng.uniform(0.6, 0.72)): + mx, my = a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f + mids.append((mx * (1 - bow) + rng.uniform(-2.5, 2.5), my * (1 - bow) + rng.uniform(-2.5, 2.5))) + joins[(i, k)] = mids + ring_paths.append([a, *mids, b]) + branches = [] + for ray in rays: + for vi, r in enumerate(RAY_R[:len(ray)]): + if r >= 60 and rng.random() < 0.3: + x0, y0 = ray[vi] + a = math.atan2(y0, x0) + rng.choice((-1, 1)) * rng.uniform(0.45, 0.9) + ln = rng.uniform(18, 50) + mid = (x0 + ln * 0.5 * math.cos(a) + rng.uniform(-3, 3), y0 + ln * 0.5 * math.sin(a) + rng.uniform(-3, 3)) + a2 = a + rng.uniform(-0.25, 0.25) + branches.append([(x0, y0), mid, (x0 + ln * math.cos(a2), y0 + ln * math.sin(a2))]) + # a few cracks that start from a ring rather than the hit + for (i, k), mids in list(joins.items()): + if k >= 1 and rng.random() < 0.35: + x0, y0 = mids[0] + a = math.atan2(y0, x0) + rng.uniform(-0.3, 0.3) + pts, r0 = [(x0, y0)], math.hypot(x0, y0) + for step in range(1, 4): + rr = r0 + step * rng.uniform(30, 60) + a += rng.uniform(-0.08, 0.08) + pts.append((rr * math.cos(a), rr * math.sin(a))) + branches.append(pts) + crush = [] + for _ in range(22): + r, u = rng.uniform(0, 15), rng.uniform(0, 2 * math.pi) + p = (r * math.cos(u), r * math.sin(u)) + ln, v = rng.uniform(4, 10), rng.uniform(0, 2 * math.pi) + crush.append([p, (p[0] + ln * math.cos(v), p[1] + ln * math.sin(v))]) + + def cell(i, k): + """The piece of glass between two rays and two rings.""" + j = (i + 1) % n + lo, hi = ring_at[k], ring_at[k + 1] + return ([rays[i][lo], *joins[(i, k)], rays[j][lo]] + rays[j][lo + 1:hi] + + [rays[j][hi], *joins[(i, k + 1)][::-1], rays[i][hi]] + rays[i][lo + 1:hi][::-1]) + + cells = [(i, k) for k in range(3) for i in range(n) if (i, k) in joins and (i, k + 1) in joins] + rng.shuffle(cells) + holes = [c for c in cells if c[1] >= 1][:2] + facets, holes_xml, loose = "", "", "" + ids = dict(node=nid(), flash=nid(), crush=nid(), powder=nid(), bruise=nid(), facets=nid(), holes=nid(), + glint=nid(), falls=[], flies=[], at=(cx, cy)) + for c in cells: + pts = cell(*c) + if c in holes: + # the piece falls out and leaves a dark hole with a bright edge + holes_xml += f''' + {closed_path(pts)} + + + ''' + mx = sum(p[0] for p in pts) / len(pts) + my = sum(p[1] for p in pts) / len(pts) + s = dict(node=nid(), x=cx + mx, y=cy + my, dx=rng.uniform(-30, 30), spin=rng.uniform(-1.6, 1.6)) + ids["falls"].append(s) + loose += f''' + + {closed_path([(px - mx, py - my) for px, py in pts])} + + + + + ''' + continue + roll = rng.random() + mx = sum(p[0] for p in pts) / len(pts) + my = sum(p[1] for p in pts) / len(pts) + if roll < 0.6: + # a facet tipped toward the light, which comes from the top left, so + # those on that side of the hit shine most; each catches it at its own angle + lit = 0.5 + 0.5 * math.cos(math.atan2(my, mx) + 2.36) + alpha = int(0x18 + lit * 0x60 * rng.uniform(0.6, 1)) + a = math.atan2(my, mx) + rng.uniform(-1, 1) + facets += f''' + {closed_path(pts)} + + + ''' + elif roll < 0.72: + facets += f''' + {closed_path(pts)} + + ''' + # shards that fly out at the camera + for _ in range(14): + a, dist, size = rng.uniform(0, 2 * math.pi), rng.uniform(70, 200), rng.uniform(5, 14) + t0 = rng.uniform(0, 2 * math.pi) + tri = [(size * math.cos(t0 + d) * rng.uniform(0.6, 1), size * math.sin(t0 + d) * rng.uniform(0.6, 1)) + for d in (0, 2.1 + rng.uniform(-0.4, 0.4), 4.2 + rng.uniform(-0.4, 0.4))] + s = dict(node=nid(), dx=dist * math.cos(a), dy=dist * math.sin(a), spin=rng.uniform(-3, 3)) + ids["flies"].append(s) + loose += f''' + + {closed_path(tri)} + + + + + ''' + ray_fx, ids["ray_trim"] = reveal(f"{who_name} Rays") + ring_fx, ids["ring_trim"] = reveal(f"{who_name} Rings") + branch_fx, ids["branch_trim"] = reveal(f"{who_name} Branches") + xml = f''' + + + + + + + + + + + + + + + + {"".join(open_path(p) for p in crush)} + + + + + + + + + + {"".join(open_path(r[:5]) for r in rays)} + {stroke(2.4, WHITE, ray_fx)} + + + {"".join(open_path(r) for i, r in enumerate(rays) if i % 3)} + {stroke(5, "12FFFFFF", ray_fx, "Shine")} + {stroke(3, "59000000", ray_fx, "Shadow")} + {stroke(1.4, "F2FFFFFF", ray_fx)} + + + {"".join(open_path(r) for i, r in enumerate(rays) if not i % 3)} + {stroke(2.6, "40000000", ray_fx, "Shadow")} + {stroke(1.1, "A6FFFFFF", ray_fx)} + + + {"".join(open_path(p) for p in ring_paths)} + {stroke(2.6, "40000000", ring_fx, "Shadow")} + {stroke(1.1, "D9FFFFFF", ring_fx)} + + + {"".join(open_path(p) for p in branches)} + {stroke(0.9, "B3FFFFFF", branch_fx)} + + {holes_xml} + + {facets} + + {glow(ids["bruise"], 360, "000000", a0="8C", a1="40", name="Bruise")} + ''' + return ids, xml, loose + + +CRACKS = {} +crack_xml, loose_glass_xml = "", "" +for who, sd in (("you", YOU), ("them", THEM)): + CRACKS[who], cx_, lx_ = crack(who, sd["clip"]) + crack_xml += cx_ + loose_glass_xml += lx_ + +# ---------------------------------------------------------------- confetti +# Only for your win. Two cannons fire from the bottom corners and a burst +# pops out of YOU WIN!. The pieces tumble, showing their darker backs, sway +# and flutter down; foil ones flash white as they turn to face you. A +# quarter sit in front of the headline, nearer the camera, so they are bigger +# and fall faster. The Confetti layer of the state machine plays them. + +CONF_T = 250 +PALETTE = [("FFFFFFFF", "FFAFBCD6"), ("FFDCE8FF", "FF8AA0CC"), ("FF5CE1FF", "FF1F8DB5"), + ("FF4F9BFF", "FF2356B5"), ("FF2A6AF0", "FF163A94"), ("FF9B8CFF", "FF5544BD")] # front, back +FOIL = ("FFD3DCEC", "FF7A8496") +BITS = [] + + +def confetti(): + rng = random.Random(21) + + def piece(start, apex, t0, t_up): + kind = rng.choices(("strip", "chip", "dot", "long", "streamer"), (46, 16, 12, 16, 10))[0] + near = rng.random() < 0.25 + foil = kind in ("strip", "chip") and rng.random() < 0.22 + front, back = FOIL if foil else rng.choice(PALETTE) + fall = {"strip": (1.6, 2.6), "chip": (1.9, 2.9), "dot": (1.9, 2.9), "long": (1.5, 2.3), "streamer": (1.0, 1.6)}[kind] + flip = {"strip": (14, 30), "chip": (18, 34), "dot": (20, 40), "long": (14, 28), "streamer": (30, 50)}[kind] + slow = 0.5 if kind == "streamer" else 1 + BITS.append(dict( + node=nid(), piece=nid(), colour=nid(), kind=kind, near=near, foil=foil, front=front, back=back, + start=start, apex=apex, t0=t0, t_up=t_up, + depth=rng.uniform(1.25, 1.55) if near else rng.uniform(0.7, 1.0), + v=rng.uniform(*fall) * (1.25 if near else 1), + sway=rng.uniform(14, 30) if kind == "streamer" else rng.uniform(8, 26), + half=rng.randint(30, 44) if kind == "streamer" else rng.randint(20, 36), + drift=rng.uniform(-4, 4), r0=rng.uniform(0, 2 * math.pi), + spin_up=rng.choice((-1, 1)) * rng.uniform(2, 5) * slow, + spin_fall=rng.choice((-1, 1)) * rng.uniform(1, 4) * slow, + flip=max(8, rng.randint(*flip) // 2), flip_from=rng.choice((1, 0.06)), + wobble=rng.randint(15, 30) if kind in ("chip", "dot", "streamer") else 0)) + + for left in (True, False): + for _ in range(36): + apex = (rng.uniform(24, 300) if left else rng.uniform(90, 366), 70 + 400 * rng.random() ** 1.3) + piece((-8 if left else W + 8, H - 96 + rng.uniform(-6, 6)), apex, rng.randint(0, 5), rng.randint(26, 42)) + for _ in range(28): + a = rng.uniform(-math.pi, 0) if rng.random() < 0.8 else rng.uniform(0, math.pi) + d = rng.uniform(70, 210) + x0, y0 = HEAD[0] + rng.uniform(-90, 90), HEAD[1] + rng.uniform(-14, 14) + piece((x0, y0), (x0 + d * math.cos(a), y0 + 0.8 * d * math.sin(a)), rng.randint(0, 3), rng.randint(18, 28)) + + front_xml, back_xml = "", "" + for i, b in enumerate(BITS): + paint = f'' + if b["kind"] == "streamer": + curl = [(6 * math.sin(k * math.pi / 2.5), -20 + k * 40 / 8) for k in range(9)] + body = open_path(curl, "Path") + f'{paint}' + elif b["kind"] == "dot": + body = f'{paint}' + else: + w, h = {"strip": (7, 16), "chip": (10, 10), "long": (5, 22)}[b["kind"]] + body = (f'' + f'{paint}') + x, y = b["start"] + xml = f''' + + {body} + ''' + if b["near"]: + front_xml += xml + else: + back_xml += xml + return (f'{front_xml}\n ', + f'{back_xml}\n ') + + +confetti_front_xml, confetti_back_xml = confetti() + +YOU_NAME, THEM_NAME = nid(), nid() +YOU_NAME_HOLD, THEM_NAME_HOLD = nid(), nid() # the tape hands over from the names through these +names_xml = f''' + + + {text(0, 0, 34, FONT_BOLD, WHITE, "Alex", bind="youName", name="Name", fit=(170, 38))} + {text(1, 38, 12, FONT_MED, "B3FFFFFF", "Peckham", bind="youCity", spacing=0.3, name="City")} + + + + + {text(0, 0, 34, FONT_BOLD, WHITE, "Kemi", bind="themName", origin=(1, 0), name="Name", fit=(170, 38))} + {text(-1, 38, 12, FONT_MED, "B3FFFFFF", "Brixton", bind="themCity", origin=(1, 0), spacing=0.3, name="City")} + + ''' + +TOPBAR = nid() +top_xml = f''' + + {text(18, 0, 12, FONT_BOLD, BLUE_HI, "1V1 · REMATCH", bind="title", spacing=1.6, name="Title")} + {text(W - 18, 0, 12, FONT_MED, "BFFFFFFF", "24 hours · most steps", bind="rule", origin=(1, 0), name="Rule")} + ''' + +TILE_W = (W - 36 - 16) / 3 +TILE_Y = H - 40 - 64 +TILES = [nid(), nid(), nid()] +tiles_xml = "" +for i, (bind, label) in enumerate([("stake", "OUT each"), ("prize", "to the winner"), ("clock", "on the clock")]): + cx = 18 + TILE_W / 2 + i * (TILE_W + 8) + tiles_xml += f''' + + {text(0, -7, 24, FONT_BOLD, WHITE, {"stake": "50", "prize": "100", "clock": "24h"}[bind], bind=bind, origin=(0.5, 0.5), name="Value")} + {text(0, 16, 11, FONT_MED, "B3FFFFFF", label, origin=(0.5, 0.5), spacing=0.2, name="Label")} + + + + + + + ''' + +PILL, PILL_HIT, PILL_HOLD = nid(), nid(), nid() +# The holder hides the pill while a result shows (the WINNER tag sits there); +# the Pack Prompt layer drives the pill itself from youHasPack. +pill_xml = f''' + + + {text(0, 0.5, 14, FONT_MED, WHITE, "+ Add your Fighter Pack", origin=(0.5, 0.5), spacing=0.2, name="Label")} + + + + + + + ''' + +# ---------------------------------------------------------------- the tale of the tape +# For a rematch, a stake or a rival. The card slams in, then the names hand +# over to the tape, which rises under VS and stays with the stake tiles: each +# row has the two numbers either side of its label and bars growing outwards. +# A shade darkens the lower half so the tape reads over the photos. + +TAPE = dict(hold=nid(), shade=nid(), head=nid(), rows=[], bars=[]) +TAPE_BAR = 77 # a bar's full width +TAPE_ROW_Y = [58 + 40 * r for r in range(5)] # row centres, down from the tape's top + + +def tape_bar(oid, n, you): + """One side's bar: its width set by the app, its growth animated inside.""" + rgb = ("82B4FF", "2A6AF0", "4F9BFF") if you else ("FF8A92", "D7263A", "FF5060") + end = -TAPE_BAR if you else TAPE_BAR + o = 1 if you else 0 + return f''' + + {bind_num(f"tape{n}{'You' if you else 'Them'}Bar", SX)} + + + + + + + + {soft_halo(end, 0, 15, rgb[2], "3D")} + + ''' + + +def tape_parts(): + rows = "" + for r, y in enumerate(TAPE_ROW_Y): + n, row, left, right = r + 1, nid(), nid(), nid() + TAPE["rows"].append(row) + TAPE["bars"] += [left, right] + label, a, b, _, _ = TAPE_ROWS[r] + lead = lambda side_: bind_num(f"tape{n}{side_}Bar", OPACITY, LEAD_TEXT) + on = lambda side_: bind_num(f"tape{n}{side_}Bar", OPACITY, LEAD_ON) + rows += f''' + + {text(0, 0, 20, FONT_BOLD, WHITE, a, bind=f"tape{n}You", origin=(0, 0.5), name="You Value", extra=lead("You"))} + {text(354, 0, 20, FONT_BOLD, WHITE, b, bind=f"tape{n}Them", origin=(1, 0.5), name="Them Value", extra=lead("Them"))} + {digit_glow(0, 0, a, f"tape{n}You", "4F9BFF", on("You"))} + {digit_glow(354, 1, b, f"tape{n}Them", "FF5060", on("Them"))} + {text(177, 0.5, 10.5, FONT_MED, "9EFFFFFF", label, bind=f"tape{n}Label", origin=(0.5, 0.5), spacing=1.4, name="Label")}{tape_bar(left, n, True)}{tape_bar(right, n, False)} + ''' + head = f''' + + {text(0, -12, 20, FONT_BOLD, "FF82B4FF", "Alex", bind="youName", name="You", fit=(104, 24))} + {text(177, 0.5, 10.5, FONT_MED, "99FFFFFF", "TALE OF THE TAPE", origin=(0.5, 0.5), spacing=2.6, name="Title")} + {text(354, -12, 20, FONT_BOLD, "FFFF8A92", "Kemi", bind="themName", origin=(1, 0), name="Them", fit=(104, 24))} + ''' + tape = f''' + {head}{rows} + ''' + shade = f''' + + + + + + + ''' + return tape, shade + + +tape_xml, shade_xml = tape_parts() + + +# ---------------------------------------------------------------- the cuts +# Full-screen black (the cinematic and Let's Run open from it) and the white +# flash that cuts from either to the card. Both sit over everything. + +BLACK, CUT_FLASH = nid(), nid() + + +def screen_rect(oid, colour, name): + return f''' + + + + ''' + + +cuts_xml = screen_rect(CUT_FLASH, "E6FFFFFF", "Cut Flash") + screen_rect(BLACK, "FF000000", "Black") + + +# ---------------------------------------------------------------- the cinematic +# For the big battles. A slow push in on each runner in turn, full screen, +# with a name strip under them; then a white flash cuts to the card as VS +# slams. The shots are layout boxes at the root, so any photo covers the screen. + +CINE = dict(front=nid(), shots={}, strips={}) +CINE_LINE_Y = 104 # the line under each name, down from the strip + + +def cine_parts(): + front = "" + for who in ("you", "them"): + you = who == "you" + bar, name, sub = nid(), nid(), nid() + CINE["strips"][who] = dict(bar=bar, name=name, sub=sub) + x, o = (26, 0) if you else (W - 26, 1) + ink, hi, glow_rgb = ("FF1F62FF", "FF82B4FF", "4F9BFF") if you else ("FFD7263A", "FFFF6670", "FF5060") + a, b = (ink, hi) if you else (hi, ink) + bar_x = 0 if you else -220 + front += f''' + + + + + + + + {soft_halo(bar_x, bar_x + 220, 15, glow_rgb, "66")} + + + {text(0, 6, 84, FONT_VS, WHITE, "Alex" if you else "Kemi", bind=who + "Name", origin=(o, 0), spacing=-1, name="Face", fit=(338, 104))} + {text(0, 12, 84, FONT_VS, ink, "Alex" if you else "Kemi", bind=who + "Name", origin=(o, 0), spacing=-1, name="Shadow", fit=(338, 104))} + + + {text(0, 0, 13, FONT_MED, "D1FFFFFF", "PECKHAM · 11.4K STEPS A DAY" if you else "BRIXTON · 9.8K STEPS A DAY", + bind=who + "Line", origin=(o, 0), spacing=1.6, name="Line")} + + ''' + front_xml = f''' + {front} + ''' + + shots = "" + for who in ("you", "them"): + you = who == "you" + box, sid, img = nid(), nid(), nid() + CINE["shots"][who] = dict(box=box, img=img) + # the wash runs at 170 degrees for you and 350 for them, as a CSS gradient over the screen + (sx, sy), (ex, ey) = ((117, -21), (273, 865)) if you else ((273, 865), (117, -21)) + c0, c1 = ("FF123C8F", "FF050A16") if you else ("FF7A1822", "FF12060A") + shots += f''' + + + + + + + + + + + + + + + + + + ''' + return front_xml, shots + + +cine_front_xml, cine_shots_xml = cine_parts() + + +# ---------------------------------------------------------------- Let's Run +# For event battles. Giant outlined names and towns stream across the screen +# behind two letterbox panels of the runners that wipe open, then the battle's +# rule and LET'S RUN slams. A flash cuts to the card as VS slams. + +LR = dict(back=nid(), front=nid(), rows=[], panels={}, tg1=nid(), tg2=nid()) +LR_WORDS = [("youName", "Alex", "you"), ("themName", "Kemi", "them"), ("youCity", "Peckham", "you"), ("themCity", "Brixton", "them")] +LR_ROW_H = H / 9 +LR_DRIFT = 270 # how far a row streams while the intro plays +LR_START = {"you": 0, "them": -700} +LR_NUDGE = {1: 12} # the second row sits a little lower, wholly behind the first panel + + +def lr_parts(): + rows = "" + for i in range(9): + bind, word, who = LR_WORDS[i % 4] + outer, inner, sid = nid(), nid(), nid() + LR["rows"].append((outer, inner, -1 if i % 2 == 0 else 1, who)) + rgb = "4F9BFF" if who == "you" else "FF5A68" + runs = "".join(f'{bind_text(bind)}' + f'' for _ in range(14)) + rows += f''' + + + + + {runs} + + ''' + + def soft(x, y, rx, ry, argb): + return f''' + + + + + ''' + + clips, edges, boxes = "", "", "" + for key, who, top in (("a", "you", 132), ("b", "them", 460)): + clip, edge, box, sid, img = nid(), nid(), nid(), nid(), nid() + LR["panels"][key] = dict(clip=clip, edge=edge, box=box, img=img) + left = key == "a" # A wipes open from the left, B from the right + x, o = (0, 0) if left else (W, 1) + clips += f''' + + + ''' + edges += f''' + + + + + + ''' + boxes += f''' + + + + + + + + + + + + ''' + + back = f''' + {clips}{rows} + {soft(70, 203, 191, 248, "4D1F62FF")} + {soft(320, 641, 191, 248, "47D7263A")} + + + + + ''' + front = f''' + + + {text(0, 0, 32, FONT_VS, WHITE, "24 hours · most steps", bind="rule", origin=(0.5, 0.5), spacing=0.5, name="Face")} + {text(0, 3, 32, FONT_VS, "FF1F62FF", "24 hours · most steps", bind="rule", origin=(0.5, 0.5), spacing=0.5, name="Shadow")} + + + {text(0, 0, 68, FONT_VS, WHITE, "LET'S RUN", origin=(0.5, 0.5), name="Face")} + {text(0, 5, 68, FONT_VS, "FFD7263A", "LET'S RUN", origin=(0.5, 0.5), name="Shadow")} + {glow(nid(), 380, "FF3B4A", a0="80", a1="2E", opacity=1, scale_y=0.32)} + {edges} + ''' + return front, boxes, back + + +lr_front_xml, lr_panels_xml, lr_back_xml = lr_parts() + + +# ---------------------------------------------------------------- what a result moves, at rest + +SHAKE_FRONT, SHAKE_BACK = nid(), nid() + + +def rests(): + out = [] + for s in (YOU, THEM): + out += [(s["v"][0], VX, TOP), (s["v"][1], VX, BOT), (s["dim"], OPACITY, 0), + (s["photo"], OPACITY, 1), (s["shot"], OPACITY, 0)] + out += [(oid, k, 0) for oid in (s["photo"], s["shot"]) for k in (X, Y)] + out += [(PILL_HOLD, OPACITY, 1), (SEAM, X, (TOP + BOT) / 2), (VSG, X, MID[0]), (VSG, OPACITY, 1), + (VSTEXT, SX, 1), (VSTEXT, SY, 1), (IMPACT, OPACITY, 0), (KO_IMPACT, OPACITY, 0)] + out += [(n, k, 0) for n in (SHAKE_FRONT, SHAKE_BACK) for k in (X, Y)] + for h in (YOU_HIT, THEM_HIT): + out += [(oid, OPACITY, 0) for oid in (h["word"], h["glow"], h["flash"], h["chip"], h["loser"], *h["rings"], *h["sparks"])] + out += [(h["word"], SX, 1), (h["word"], SY, 1), (h["glint"], X, GLINT_FROM)] + out += [(n, k, 1) for n in (YOU_NAME, THEM_NAME) for k in (SX, SY)] + out += [(KO["node"], OPACITY, 0), (KO["node"], X, MID[0]), (KO["node"], Y, MID[1]), (KO["node"], SX, 1), + (KO["node"], SY, 1), (KO["k"], OPACITY, 0), (KO["o"], OPACITY, 0), (KO["glow"], OPACITY, 1)] + for c in CRACKS.values(): + out += [(c[k], OPACITY, 0) for k in ("node", "flash", "crush", "powder", "bruise", "facets", "holes", "glint")] + out += [(c[k], TRIM_END, 0) for k in ("ray_trim", "ring_trim", "branch_trim")] + out += [(s["node"], OPACITY, 0) for s in c["falls"] + c["flies"]] + out += [(HW["word"], OPACITY, 0), (HW["word"], SX, 1), (HW["word"], SY, 1), (HW["bloom"], OPACITY, 0), (HW["flash"], OPACITY, 0)] + out += [(oid, OPACITY, 0) for oid in (*HW["rings"], *HW["sparks"], *HW["rays"])] + out += [(tw, k, 0) for tw in HW["twinkles"] for k in (SX, SY)] + out += [(lid, OPACITY, 0) for lid in HL["letters"]] + [(HL["gloom"], OPACITY, 0)] + # the intros' own parts, hidden, so a replay part way through one never leaves them up + out += [(s["group"], k, 0) for s in (YOU, THEM) for k in (X, Y)] + [(SEAM_GLOW, SX, 1)] + out += [(oid, OPACITY, 0) for oid in (SCREEN_FLASH, STRIKE["flare"], STRIKE["a"], STRIKE["b"])] + out += [(line[0], OPACITY, 0) for line in STRIKE["lines"] + STRIKE["sparks"]] + out += [(oid, OPACITY, 0) for oid in (BLACK, CUT_FLASH, CINE["front"], CINE["shots"]["you"]["box"], CINE["shots"]["them"]["box"], + LR["back"], LR["front"], LR["panels"]["a"]["box"], LR["panels"]["b"]["box"])] + return out + + +RESTS.extend(rests()) + +# ---------------------------------------------------------------- animations + +def ease_y(e, u): + """How far a cubic-bezier ease has got at time u, both 0 to 1.""" + if u <= 0 or u >= 1: + return min(1.0, max(0.0, u)) + x1, y1, x2, y2 = e + lo, hi = 0.0, 1.0 + for _ in range(48): + k = (lo + hi) / 2 + if 3 * (1 - k) ** 2 * k * x1 + 3 * (1 - k) * k ** 2 * x2 + k ** 3 < u: + lo = k + else: + hi = k + k = (lo + hi) / 2 + return 3 * (1 - k) ** 2 * k * y1 + 3 * (1 - k) * k ** 2 * y2 + k ** 3 + + +def vs_slam(t, v): + """VS slams at frame v under its flash and shock ring, and its glow comes up.""" + t.fire(EVENTS["slam"], v) + t.path(FLASH, OPACITY, [(0, 0, None), (v, 1, OUT_EASE), (v + 42, 0, None)]) + t.tween(FLASH, SX, 0.2, 1.4, v, v + 42, OUT_EASE) + t.tween(FLASH, SY, 0.2, 1.4, v, v + 42, OUT_EASE) + t.path(RING, OPACITY, [(0, 0, None), (v, 1, OUT_EASE), (v + 54, 0, None)]) + t.tween(RING, SX, 0.3, 2.6, v, v + 54, SHOCK) + t.tween(RING, SY, 0.3, 2.6, v, v + 54, SHOCK) + t.tween(VSTEXT, SX, 2.4, 1, v, v + 48, SLAM) + t.tween(VSTEXT, SY, 2.4, 1, v, v + 48, SLAM) + t.tween(VSTEXT, OPACITY, 0, 1, v, v + 17, OUT_EASE) + t.tween(VSGLOW, OPACITY, 0, 0.8, v + 8, v + 50, OUT_EASE) + + +def card_in(t, f): + """From frame f the names slide in, the top bar fades up and the stake tiles rise.""" + t.tween(YOU_NAME, X, -22, 18, f, f + 36, EASE_SIDE) + t.tween(YOU_NAME, OPACITY, 0, 1, f, f + 36, OUT_EASE) + t.tween(THEM_NAME, X, W + 22, W - 18, f + 9, f + 45, EASE_SIDE) + t.tween(THEM_NAME, OPACITY, 0, 1, f + 9, f + 45, OUT_EASE) + t.tween(TOPBAR, OPACITY, 0, 1, f + 2, f + 32, OUT_EASE) + for i in range(3): + t.tween(TILES[i], Y, TILE_Y + 52, TILE_Y + 32, f + 4 + 4 * i, f + 34 + 4 * i, EASE_SIDE) + t.tween(TILES[i], OPACITY, 0, 1, f + 4 + 4 * i, f + 34 + 4 * i, OUT_EASE) + + +def pill_hold(t, f): + """The pack pill waits for the card.""" + t.path(PILL_HOLD, OPACITY, [(0, 0, None), (f, 0, OUT_EASE), (f + 16, 1, None)]) + + +def slide_shake(x0, f0, n, hits): + """A photo box's x: it slides home from x0 over n frames from f0 and shakes with + its side, sampled every 2 frames (a box moves by itself, outside the shake nodes).""" + end = max(f0 + n, max(f for f, _, _ in hits) + 3 * (len(SHAKE) - 1)) + pts = [(f, x0 * (1 - ease_y(EASE_SIDE, (f - f0) / n)) + shake_value(hits, 0, f), "linear") + for f in range(f0, end + 1, 2)] + return pts + [(pts[-1][0] + 2, 0, None)] + + +def land(t, f0, shake_at): + """The card arriving under a cut: the sides settle in from 80 out, the seam lights, + VS slams and the screen shakes, then the names, the top bar and the tiles.""" + hits = [(shake_at, 0.8, 0.8)] + for side_, sign in ((YOU, -1), (THEM, 1)): + t.tween(side_["group"], X, sign * 80, 0, f0, f0 + 20, EASE_SIDE) + # the seam edge holds still while the side slides in, so no gap opens along it + t.tween(side_["v"][0], VX, TOP - sign * 80, TOP, f0, f0 + 20, EASE_SIDE) + t.tween(side_["v"][1], VX, BOT - sign * 80, BOT, f0, f0 + 20, EASE_SIDE) + t.path(side_["photo"], X, slide_shake(sign * 80, f0, 20, hits)) + t.path(side_["photo"], Y, shake_points(hits, 1)) + for oid in (SHAKE_FRONT, SHAKE_BACK): + t.path(oid, X, shake_points(hits, 0)) + t.path(oid, Y, shake_points(hits, 1)) + t.path(SEAM, OPACITY, [(0, 0, None), (f0 + 4, 0, OUT_EASE), (f0 + 14, 0.85, None)]) + t.path(SEAM, SY, [(0, 0.15, None), (f0 + 4, 0.15, OUT_EASE), (f0 + 14, 1, None)]) + vs_slam(t, f0 + 10) + card_in(t, f0 + 19) + pill_hold(t, f0 + 40) + + +def slam_in(): + t = Timeline("Slam In", 84) + t.tween(YOU["group"], X, -W, 0, 0, 42, EASE_SIDE) + t.tween(THEM["group"], X, W, 0, 0, 42, EASE_SIDE) + # a layout box's x is an offset from where layout puts it, so 0 is home + t.tween(YOU["photo"], X, -W, 0, 0, 42, EASE_SIDE) + t.tween(THEM["photo"], X, W, 0, 0, 42, EASE_SIDE) + t.tween(SEAM, OPACITY, 0, 0.85, 21, 75, OUT_EASE) + t.tween(SEAM, SY, 0, 1, 21, 75, EASE_SIDE) + t.path(FLASH, OPACITY, [(0, 0, None), (10, 1, OUT_EASE), (52, 0, None)]) + t.tween(FLASH, SX, 0.2, 1.4, 10, 52, OUT_EASE) + t.tween(FLASH, SY, 0.2, 1.4, 10, 52, OUT_EASE) + t.path(RING, OPACITY, [(0, 0, None), (10, 1, OUT_EASE), (64, 0, None)]) + t.tween(RING, SX, 0.3, 2.6, 10, 64, SHOCK) + t.tween(RING, SY, 0.3, 2.6, 10, 64, SHOCK) + t.tween(VSTEXT, SX, 2.4, 1, 10, 58, SLAM) + t.tween(VSTEXT, SY, 2.4, 1, 10, 58, SLAM) + t.tween(VSTEXT, OPACITY, 0, 1, 10, 27, OUT_EASE) + t.tween(VSGLOW, OPACITY, 0, 0.8, 18, 60, OUT_EASE) + t.tween(YOU_NAME, X, -22, 18, 18, 54, EASE_SIDE) + t.tween(YOU_NAME, OPACITY, 0, 1, 18, 54, OUT_EASE) + t.tween(THEM_NAME, X, W + 22, W - 18, 27, 63, EASE_SIDE) + t.tween(THEM_NAME, OPACITY, 0, 1, 27, 63, OUT_EASE) + t.tween(TOPBAR, OPACITY, 0, 1, 20, 50, OUT_EASE) + for i in range(3): + t.tween(TILES[i], Y, TILE_Y + 52, TILE_Y + 32, 33 + 4 * i, 63 + 4 * i, EASE_SIDE) + t.tween(TILES[i], OPACITY, 0, 1, 33 + 4 * i, 63 + 4 * i, OUT_EASE) + t.rest() + return t.xml() + + +def strike_in(): + """The opening: speed lines and a scissor along the seam, a flare with sparks, then VS slams.""" + t = Timeline("Strike In", 104) + dx, dy = DOWN[0] * 900, DOWN[1] * 900 # each side starts fully off screen along the seam + shake_x, shake_y = shake_points([(28, 1.1, 1.1)], 0), shake_points([(28, 1.1, 1.1)], 1) + for side_, sign in ((YOU, -1), (THEM, 1)): + t.tween(side_["group"], X, sign * dx, 0, 12, 28, EASE_SIDE) + t.tween(side_["group"], Y, sign * dy, 0, 12, 28, EASE_SIDE) + # a layout box's x and y are offsets from where layout puts it; it shakes with the rest + t.path(side_["photo"], X, [(12, sign * dx, EASE_SIDE)] + shake_x) + t.path(side_["photo"], Y, [(12, sign * dy, EASE_SIDE)] + shake_y) + for oid in (SHAKE_FRONT, SHAKE_BACK): + t.path(oid, X, shake_x) + t.path(oid, Y, shake_y) + for oid, f0, n, y0, y1 in STRIKE["lines"]: + t.path(oid, Y, [(f0, y0, "linear"), (f0 + n, y1, None)]) + t.path(oid, OPACITY, [(0, 0, None), (f0, 1, None), (f0 + n, 0, None)]) + for oid, y0, y1 in ((STRIKE["a"], -440, -20), (STRIKE["b"], 440, -48)): + t.path(oid, Y, [(4, y0, "linear"), (20, VS_ON_SEAM, OUT_EASE), (29, y1, None)]) + t.path(oid, OPACITY, [(0, 0, None), (4, 1, None), (20, 1, OUT_EASE), (29, 0, None)]) + t.path(SEAM, OPACITY, [(0, 0, None), (27, 0, OUT_EASE), (34, 0.85, None)]) + t.path(SEAM, SY, [(0, 0, None), (27, 0, OUT_EASE), (34, 1, None)]) + t.path(SEAM_GLOW, SX, [(27, 1, OUT_EASE), (31, 3.2, OUT_EASE), (66, 1, None)]) + t.path(STRIKE["flare"], OPACITY, [(0, 0, None), (28, 1, None), (34, 1, OUT_EASE), (59, 0, None)]) + t.path(STRIKE["flare"], SX, [(0, 0.15, None), (28, 0.5, SHOCK), (34, 1, OUT_EASE), (59, 1.5, None)]) + t.path(SCREEN_FLASH, OPACITY, [(0, 0, None), (28, 0.6, OUT_EASE), (41, 0, None)]) + for oid, f0, n, d in STRIKE["sparks"]: + t.path(oid, X, [(f0, 4, SHOCK), (f0 + n, d, None)]) + t.path(oid, SX, [(f0, 1, SHOCK), (f0 + n, 0.35, None)]) + t.path(oid, OPACITY, [(0, 0, None), (f0, 1, "linear"), (f0 + n, 0, None)]) + t.fire(EVENTS["strike"], 28) + t.path(PILL_HOLD, OPACITY, [(0, 0, None), (84, 0, OUT_EASE), (100, 1, None)]) # the pack pill waits for the card + vs_slam(t, 36) # VS slams as in the classic intro, a beat after the strike + card_in(t, 46) + t.rest() + return t.xml() + + +def idle(): + t = Timeline("Idle", 240, "loop") + t.path(VSGLOW, OPACITY, [(0, 0.8, INOUT), (120, 1, INOUT), (240, 0.8, None)]) + for k in (SX, SY): + t.path(VSGLOW, k, [(0, 1, INOUT), (120, 1.1, INOUT), (240, 1, None)]) + t.path(VSTEXT, k, [(0, 1, INOUT), (120, 1.025, INOUT), (240, 1, None)]) + t.path(SEAM_GLOW, OPACITY, [(0, 0.7, INOUT), (60, 1, INOUT), (120, 0.7, INOUT), (180, 1, INOUT), (240, 0.7, None)]) + return t.xml() + + +def tape_in(): + """Tale of the Tape on the Main layer: the card slams in from black. The Tape + layer then hands the names over to the tape (tape_hand_over).""" + t = Timeline("Tape In", 74) + t.path(BLACK, OPACITY, [(0, 1, OUT_EASE), (10, 0, None)]) + land(t, 6, 18) + t.rest() + return t.xml() + + +def cinematic_in(): + """The cinematic: a full-screen cut-in on each runner, then a flash cuts to the card.""" + f0 = 168 # the cut, 2.8 s in + t = Timeline("Cinematic In", f0 + 66) + t.path(BLACK, OPACITY, [(0, 1, OUT_EASE), (16, 0, None)]) + t.key(CINE["front"], OPACITY, kf(1, 0)) + you, them = CINE["shots"]["you"], CINE["shots"]["them"] + t.path(you["box"], OPACITY, [(0, 1, None), (84, 0, None)]) + t.path(them["box"], OPACITY, [(0, 0, None), (84, 1, None), (f0, 0, None)]) + for k in (SX, SY): # a slow push in on each runner + t.path(you["img"], k, [(0, 1.24, OUT_EASE), (84, 1.08, None)]) + t.path(them["img"], k, [(0, 1.24, None), (84, 1.24, OUT_EASE), (f0, 1.08, None)]) + for who, a, b, d in (("you", 16, 76, 1), ("them", 100, 160, -1)): + st = CINE["strips"][who] + t.path(st["bar"], SX, [(0, 0, None), (a, 0, EASE_SIDE), (a + 17, 1, None), (b, 1, IN_EASE), (b + 7, 0, None)]) + t.path(st["name"], OPACITY, [(0, 0, None), (a + 5, 0, EASE_SIDE), (a + 23, 1, None), (b, 1, IN_EASE), (b + 7, 0, None)]) + t.path(st["name"], X, [(0, -46 * d, None), (a + 5, -46 * d, EASE_SIDE), (a + 23, 0, None), (b, 0, IN_EASE), (b + 7, 30 * d, None)]) + t.path(st["sub"], OPACITY, [(0, 0, None), (a + 12, 0, EASE_SIDE), (a + 29, 1, None), (b, 1, IN_EASE), (b + 7, 0, None)]) + t.path(st["sub"], Y, [(0, CINE_LINE_Y + 8, None), (a + 12, CINE_LINE_Y + 8, EASE_SIDE), (a + 29, CINE_LINE_Y, None)]) + t.fire(EVENTS["cutIn"], 0) + t.fire(EVENTS["cutIn"], 84) + t.path(CUT_FLASH, OPACITY, [(0, 0, None), (f0, 0, None), (f0 + 1, 1, OUT_EASE), (f0 + 18, 0, None)]) + land(t, f0, f0 + 11) + t.rest() + return t.xml() + + +def lets_run_in(): + """Let's Run: names stream behind two runner panels, the rule, LET'S RUN, then a flash cuts to the card.""" + f0 = 180 # the cut, 3 s in + t = Timeline("Lets Run In", f0 + 66) + t.path(BLACK, OPACITY, [(0, 1, OUT_EASE), (10, 0, None)]) + p = LR["panels"] + layers = (LR["back"], LR["front"], p["a"]["box"], p["b"]["box"]) + hits = [(143, 0.6, 0.6)] # LET'S RUN lands + for oid in layers: + t.path(oid, OPACITY, [(0, 1, None), (f0 + 1, 0, None)]) + t.path(oid, X, shake_points(hits, 0)) + t.path(oid, Y, shake_points(hits, 1)) + for i, (outer, inner, d, who) in enumerate(LR["rows"]): + a, b = round(3.6 + 2.4 * i), round(33.6 + 2.4 * i) + t.path(outer, X, [(0, d * 220, None), (a, d * 220, EASE_SIDE), (b, 0, None)]) + t.path(outer, OPACITY, [(0, 0, None), (a, 0, EASE_SIDE), (b, 1, None)]) + x0 = LR_START[who] + t.tween(inner, X, x0, x0 + (-LR_DRIFT if who == "you" else LR_DRIFT), 0, f0, "linear") + for key, a in (("a", 25), ("b", 49)): + pn = p[key] + for oid in (pn["clip"], pn["edge"]): + t.path(oid, SX, [(0, 0.001, None), (a, 0.001, EASE_SIDE), (a + 21, 1, None)]) + for k in (SX, SY): + t.path(pn["img"], k, [(0, 1.16, None), (a, 1.16, OUT_EASE), (f0, 1.03, None)]) + t.path(LR["tg1"], OPACITY, [(0, 0, None), (78, 0, EASE_SIDE), (96, 1, None), (132, 1, IN_EASE), (141, 0, None)]) + t.path(LR["tg1"], Y, [(0, 58, None), (78, 58, EASE_SIDE), (96, 70, None), (132, 70, IN_EASE), (141, 62, None)]) + t.path(LR["tg2"], OPACITY, [(0, 0, None), (141, 0, "linear"), (143, 1, None)]) + for k in (SX, SY): + t.path(LR["tg2"], k, [(0, 2.4, None), (141, 2.4, "linear"), (143, 2.1, SLAM), (157, 1, None)]) + t.fire(EVENTS["letsRun"], 143) + t.path(CUT_FLASH, OPACITY, [(0, 0, None), (f0, 0, None), (f0 + 1, 1, OUT_EASE), (f0 + 18, 0, None)]) + land(t, f0, f0 + 11) + t.rest() + return t.xml() + + +TAPE_AT = 90 # the names hand over to the tape, 1.5 s in + + +def tape_hand_over(): + """The Tape layer: the names give way and the tape rises row by row, its bars growing out.""" + t = Timeline("Tape Hand Over", TAPE_AT + 100) + t.key(TAPE["hold"], OPACITY, kf(1, 0)) + t.path(TAPE["shade"], OPACITY, [(0, 0, None), (TAPE_AT - 3, 0, OUT_EASE), (TAPE_AT + 24, 1, None)]) + for oid, sign in ((YOU_NAME_HOLD, -1), (THEM_NAME_HOLD, 1)): + t.path(oid, OPACITY, [(0, 1, None), (TAPE_AT, 1, IN_EASE), (TAPE_AT + 12, 0, None)]) + t.path(oid, X, [(0, 0, None), (TAPE_AT, 0, IN_EASE), (TAPE_AT + 12, sign * 30, None)]) + t.path(TAPE["head"], OPACITY, [(0, 0, None), (TAPE_AT + 7, 0, EASE_SIDE), (TAPE_AT + 25, 1, None)]) + t.path(TAPE["head"], Y, [(0, 20, None), (TAPE_AT + 7, 20, EASE_SIDE), (TAPE_AT + 25, 10, None)]) + for r, row in enumerate(TAPE["rows"]): + f, y = TAPE_AT + 16 + 9 * r, TAPE_ROW_Y[r] + t.path(row, OPACITY, [(0, 0, None), (f, 0, EASE_SIDE), (f + 16, 1, None)]) + t.path(row, Y, [(0, y + 10, None), (f, y + 10, EASE_SIDE), (f + 16, y, None)]) + for bar in TAPE["bars"][2 * r:2 * r + 2]: + t.path(bar, SX, [(0, 0, None), (f + 9, 0, EASE_SIDE), (f + 45, 1, None)]) + t.fire(EVENTS["tape"], TAPE_AT) + return t.xml() + + +def tape_off(): + t = Timeline("Tape Off", 1) + for oid, v in ((TAPE["hold"], 0), (TAPE["shade"], 0), (YOU_NAME_HOLD, 1), (THEM_NAME_HOLD, 1)): + t.key(oid, OPACITY, kf(v, 0)) + for oid in (YOU_NAME_HOLD, THEM_NAME_HOLD): + t.key(oid, X, kf(0, 0)) + return t.xml() + + +HIT = 10 # the frame the result lands and the VS bursts +HEAD_GAP = 12 # then the headline, a beat later +HEAD_KO = 62 # or after the knockout has cleared the middle + +SHAKE = [(0, 0), (-11, 6), (10, -7), (-9, 5), (8, -4), (-6, 4), (5, -2), (-3, 2), (2, -1), (0, 0)] # every 3 frames + + +def shake_value(hits, axis, f): + """Decaying shakes added together at frame f; each hit is (frame, x strength, y strength).""" + v = 0.0 + for f0, *strength in hits: + u = (f - f0) / 3 + if 0 <= u <= len(SHAKE) - 1: + i = min(int(u), len(SHAKE) - 2) + a, b = SHAKE[i][axis], SHAKE[i + 1][axis] + v += (a + (b - a) * (u - i)) * strength[axis] + return v + + +def shake_points(hits, axis): + """Offsets for decaying shakes added together, every 2 frames.""" + start = min(f for f, _, _ in hits) + end = max(f for f, _, _ in hits) + 3 * (len(SHAKE) - 1) + pts = [(f, shake_value(hits, axis, f), "linear") for f in range(start, end + 1, 2)] + pts.append((pts[-1][0] + 2, 0, None)) + return pts + + +def split_keys(t, dx, f0, f1): + """Move both seam vertices of both clips, and the seam, by dx.""" + for s in (YOU, THEM): + t.tween(s["v"][0], VX, TOP, TOP + dx, f0, f1, EASE_SIDE) + t.tween(s["v"][1], VX, BOT, BOT + dx, f0, f1, EASE_SIDE) + t.tween(SEAM, X, (TOP + BOT) / 2, (TOP + BOT) / 2 + dx, f0, f1, EASE_SIDE) + + +def win_head(t, h0, x0): + """YOU WIN! slams in at h0, holds while the rays turn, and rushes past the camera at x0.""" + t.path(HW["word"], OPACITY, [(h0, 0, OUT_EASE), (h0 + 6, 1, None), (x0, 1, OUT_EASE), (x0 + 10, 0, None)]) + for k in (SX, SY): + t.path(HW["word"], k, [(h0, 2.6, SLAM), (h0 + 28, 1, INOUT), (h0 + 50, 1.035, INOUT), (x0, 1, ZOOM), (x0 + 10, 1.6, None)]) + t.path(HW["bloom"], OPACITY, [(h0 + 2, 0, OUT_EASE), (h0 + 24, 1, INOUT), (h0 + 48, 0.7, INOUT), (x0, 1, OUT_EASE), (x0 + 12, 0, None)]) + t.path(HW["flash"], OPACITY, [(h0 + 1, 0, OUT_EASE), (h0 + 4, 1, OUT_EASE), (h0 + 40, 0, None)]) + for k in (SX, SY): + t.path(HW["flash"], k, [(h0 + 4, 0.3, OUT_EASE), (h0 + 40, 1.5, None)]) + for i, r in enumerate(HW["rings"]): + f = h0 + 3 + 10 * i + t.path(r, OPACITY, [(f, 0, OUT_EASE), (f + 3, 1, OUT_EASE), (f + 48, 0, None)]) + for k in (SX, SY): + t.path(r, k, [(f + 3, 0.3, SHOCK), (f + 48, 3.2 - 0.6 * i, None)]) + for i, bar in enumerate(HW["sparks"]): + d = i % 3 + t.path(bar, OPACITY, [(h0 + 3 + d, 0, OUT_EASE), (h0 + 5 + d, 1, OUT_EASE), (h0 + 36 + 2 * d, 0, None)]) + t.path(bar, X, [(h0 + 3 + d, 40, OUT_EASE), (h0 + 36 + 2 * d, 150 + 20 * d, None)]) + end = x0 + 14 + for oid, spin in zip(HW["rays"], (0.012, -0.007)): + t.path(oid, OPACITY, [(h0, 0, OUT_EASE), (h0 + 18, 1, None), (x0, 1, OUT_EASE), (end, 0, None)]) + for k in (SX, SY): + t.path(oid, k, [(h0, 0.3, OUT_EASE), (h0 + 30, 1, None), (x0, 1, ZOOM), (end, 1.6, None)]) + t.path(oid, ROT, [(h0, 0, "linear"), (end, spin * (end - h0), None)]) + rng = random.Random(17) + for i, tw in enumerate(HW["twinkles"]): + f = h0 + 10 + i * (x0 - h0 - 30) // len(HW["twinkles"]) + rng.randint(0, 6) + for k in (SX, SY): + t.path(tw, k, [(f, 0, OUT_EASE), (f + 7, 1, INOUT), (f + 18, 0, None)]) + t.path(tw, ROT, [(f, 0, "linear"), (f + 18, 0.9, None)]) + + +def lose_head(t, h0, x0): + """YOU LOSE drops in a letter at a time from h0, then falls away from x0. Returns the landings.""" + rng = random.Random(4) + order = list(range(len(HL["letters"]))) + rng.shuffle(order) + lands = [] + for i, lid in enumerate(HL["letters"]): + s = h0 + 3 * i + land = s + 12 + lands.append(land) + out = x0 + 2 * order[i] + r0, r1 = rng.uniform(-0.5, 0.5), rng.uniform(-0.08, 0.08) + spin = rng.choice((-1, 1)) * rng.uniform(0.8, 1.8) + base = LETTER_BASE + t.path(lid, OPACITY, [(s, 0, OUT_EASE), (s + 3, 1, None), (out + 30, 0, None)]) + t.path(lid, Y, [(s, base - 480, DROP), (land, base, OUT_EASE), (land + 6, base - 16, IN_EASE), + (land + 12, base, OUT_EASE), (land + 15, base - 4, IN_EASE), (land + 18, base, None), + (out, base, OUT_EASE), (out + 6, base - 14, GRAV), (out + 30, base + 640, None)]) + t.path(lid, ROT, [(s, r0, OUT_EASE), (land + 12, r1, None), (out + 6, r1, IN_EASE), (out + 30, r1 + spin, None)]) + t.path(lid, SX, [(land - 1, 1, OUT_EASE), (land + 1, 1.16, INOUT), (land + 9, 1, None)]) + t.path(lid, SY, [(land - 1, 1, OUT_EASE), (land + 1, 0.8, INOUT), (land + 9, 1, None)]) + t.path(HL["gloom"], OPACITY, [(h0, 0, OUT_EASE), (h0 + 16, 0.9, INOUT), (h0 + 40, 0.7, None), (x0, 0.7, OUT_EASE), (x0 + 30, 0, None)]) + return lands + + +def label_keys(t, h, l0): + """WINNER slams in on its side at l0.""" + t.tween(h["word"], OPACITY, 0, 1, l0 + 2, l0 + 10, OUT_EASE) + for k in (SX, SY): + t.tween(h["word"], k, 2.4, 1, l0 + 2, l0 + 32, SLAM) + t.tween(h["glow"], OPACITY, 0, 1, l0 + 4, l0 + 28, OUT_EASE) + t.path(h["flash"], OPACITY, [(l0 + 1, 0, OUT_EASE), (l0 + 4, 1, OUT_EASE), (l0 + 40, 0, None)]) + for k in (SX, SY): + t.path(h["flash"], k, [(l0 + 4, 0.2, OUT_EASE), (l0 + 40, 1.5, None)]) + for i, r in enumerate(h["rings"]): + f = l0 + 3 + 10 * i + t.path(r, OPACITY, [(f, 0, OUT_EASE), (f + 3, 1, OUT_EASE), (f + 46, 0, None)]) + for k in (SX, SY): + t.path(r, k, [(f + 3, 0.3, SHOCK), (f + 46, 3 - 0.6 * i, None)]) + for i, bar in enumerate(h["sparks"]): + d = i % 3 + t.path(bar, OPACITY, [(l0 + 3 + d, 0, OUT_EASE), (l0 + 5 + d, 1, OUT_EASE), (l0 + 34 + 2 * d, 0, None)]) + t.path(bar, X, [(l0 + 3 + d, 30, OUT_EASE), (l0 + 34 + 2 * d, 120 + 18 * d, None)]) + + +def crack_keys(t, c, k, o): + """The glass cracks at k and shatters at o, and stays broken.""" + cx, cy = c["at"] + t.path(c["node"], OPACITY, [(k - 1, 0, None), (k, 1, None)]) + t.tween(c["bruise"], OPACITY, 0, 1, k, k + 6, OUT_EASE) + t.path(c["flash"], OPACITY, [(k, 0, OUT_EASE), (k + 1, 1, OUT_EASE), (k + 12, 0, None), + (o - 1, 0, OUT_EASE), (o, 0.8, OUT_EASE), (o + 14, 0, None)]) + for key in (SX, SY): + t.path(c["flash"], key, [(k, 0.4, OUT_EASE), (k + 12, 1.4, None), (o - 1, 0.5, OUT_EASE), (o + 14, 1.2, None)]) + t.path(c["crush"], OPACITY, [(k - 1, 0, None), (k, 1, None)]) + t.path(c["powder"], OPACITY, [(k - 1, 0, None), (k, 1, OUT_EASE), (k + 20, 0.75, None)]) + t.tween(c["ray_trim"], TRIM_END, 0, 1, k, k + 7, OUT_EASE) + t.tween(c["ring_trim"], TRIM_END, 0, 1, o, o + 6, OUT_EASE) + t.tween(c["branch_trim"], TRIM_END, 0, 1, o + 1, o + 9, OUT_EASE) + t.tween(c["facets"], OPACITY, 0, 1, o, o + 5, OUT_EASE) + t.path(c["holes"], OPACITY, [(o, 0, None), (o + 1, 1, None)]) + t.path(c["glint"], OPACITY, [(o + 20, 0, INOUT), (o + 30, 1, None), (o + 46, 1, INOUT), (o + 58, 0, None)]) + t.tween(c["glint"], X, -280, 280, o + 20, o + 58, INOUT) + for s in c["falls"]: + t.path(s["node"], OPACITY, [(o, 0, None), (o + 1, 1, None), (o + 26, 1, OUT_EASE), (o + 44, 0, None)]) + t.tween(s["node"], Y, s["y"], s["y"] + 460, o + 1, o + 44, GRAV) + t.tween(s["node"], X, s["x"], s["x"] + s["dx"], o + 1, o + 44, "linear") + t.tween(s["node"], ROT, 0, s["spin"], o + 1, o + 44, "linear") + for key in (SX, SY): + t.tween(s["node"], key, 1, 1.2, o + 1, o + 44, "linear") + for s in c["flies"]: + t.path(s["node"], OPACITY, [(o - 1, 0, None), (o, 1, None), (o + 10, 1, OUT_EASE), (o + 20, 0, None)]) + t.tween(s["node"], X, cx, cx + s["dx"], o, o + 20, OUT_EASE) + t.tween(s["node"], Y, cy, cy + s["dy"], o, o + 20, OUT_EASE) + for key in (SX, SY): + t.tween(s["node"], key, 0.6, 1.8, o, o + 20, OUT_EASE) + t.tween(s["node"], ROT, 0, s["spin"], o, o + 20, OUT_EASE) + + +def result(name, you_win, ko): + """The split swings toward the loser and VS bursts; K.O. first when ko; + then the headline, and last WINNER, LOSER and the prize.""" + t = Timeline(name, 0) + dx = SHIFT if you_win else -SHIFT + loser = THEM if you_win else YOU + wh, lh = (YOU_HIT, THEM_HIT) if you_win else (THEM_HIT, YOU_HIT) + h0 = HIT + (HEAD_KO if ko else HEAD_GAP) + x0 = h0 + (72 if you_win else 78) # the headline clears + l0 = x0 + (8 if you_win else 30) # WINNER lands once it has gone + shakes = [] + + split_keys(t, dx, HIT, HIT + 40) + for k in (SX, SY): + t.path(VSTEXT, k, [(0, 1, INOUT), (HIT, 0.86, OUT_EASE), (HIT + 22, 2.2, None)]) + t.tween(VSG, X, MID[0], MID[0] + dx, HIT, HIT + 40, EASE_SIDE) + if ko: + # Hard cut: the VS vanishes as the K. lands so no ghost sits behind it. + t.path(VSG, OPACITY, [(HIT, 1, None), (HIT + 1, 0, None)]) + else: + t.tween(VSG, OPACITY, 1, 0, HIT, HIT + 10, OUT_EASE) + t.tween(loser["dim"], OPACITY, 0, 0.62, HIT, HIT + 40, OUT_EASE) + t.tween(PILL_HOLD, OPACITY, 1, 0, 0, 12, OUT_EASE) + # Under the flash each side swaps to its result shot (win or respect). + for sd in (YOU, THEM): + t.tween(sd["shot"], OPACITY, 0, 1, HIT, HIT + 4, OUT_EASE) + t.tween(sd["photo"], OPACITY, 1, 0, HIT, HIT + 6, OUT_EASE) + t.fire(EVENTS["impact"], HIT) + + if ko: + k, o = HIT, HIT + 8 + t.path(KO_IMPACT, OPACITY, [(k, 0, OUT_EASE), (k + 3, 0.55, OUT_EASE), (o, 0.14, OUT_EASE), + (o + 3, 0.4, OUT_EASE), (o + 30, 0, None)]) + t.path(KO["node"], OPACITY, [(k - 1, 0, None), (k, 1, None)]) + for part, f in ((KO["k"], k), (KO["o"], o)): + t.tween(part, OPACITY, 0, 1, f, f + 4, OUT_EASE) + t.tween(part, SX, 3.2, 1, f, f + 16, SLAM) + t.tween(part, SY, 3.2, 1, f, f + 16, SLAM) + fly = HIT + 48 + t.path(KO["glow"], OPACITY, [(k, 0, OUT_EASE), (k + 6, 1, None), (fly - 4, 1, OUT_EASE), (fly + 14, 0, None)]) + t.tween(KO["node"], X, MID[0], lh["loser_x"], fly, fly + 22, EASE_SIDE) + t.tween(KO["node"], Y, MID[1], lh["y"], fly, fly + 22, EASE_SIDE) + t.tween(KO["node"], SX, 1, KO_SMALL, fly, fly + 22, EASE_SIDE) + t.tween(KO["node"], SY, 1, KO_SMALL, fly, fly + 22, EASE_SIDE) + crack_keys(t, CRACKS["them" if you_win else "you"], k + 2, o + 2) + shakes += [(k + 2, 1, 1), (o + 2, 1.2, 1.2)] + t.fire(EVENTS["knockout"], k + 2) + t.fire(EVENTS["shatter"], o + 2) + else: + t.path(IMPACT, OPACITY, [(HIT, 0, OUT_EASE), (HIT + 2, 0.5 if you_win else 0.3, OUT_EASE), (HIT + 26, 0, None)]) + + if you_win: + win_head(t, h0, x0) + shakes.append((h0 + 2, 0.4, 0.4)) + t.fire(EVENTS["youWin"], h0 + 2) + t.fire(EVENTS["confetti"], h0 + 2) + else: + lands = lose_head(t, h0, x0) + shakes.append((lands[0], 0.15, 0.6)) + t.fire(EVENTS["youLose"], lands[0]) + for f in lands: + t.fire(EVENTS["thud"], f) + + label_keys(t, wh, l0) + t.fire(EVENTS["winner"], l0 + 8) + # the winner's name kicks + for k in (SX, SY): + t.path(YOU_NAME if you_win else THEM_NAME, k, [(l0 + 10, 1, OUT_EASE), (l0 + 18, 1.14, INOUT), (l0 + 38, 1, None)]) + if not ko: + # LOSER drops in on the other side and lands heavy + f = l0 + 14 + t.tween(lh["loser"], OPACITY, 0, 1, f, f + 10, OUT_EASE) + t.tween(lh["loser"], Y, lh["y"] - 34, lh["y"], f, f + 18, (0.5, 0, 0.8, 1.25)) + t.tween(lh["loser"], ROT, -0.2, 0.04, f, f + 18, (0.5, 0, 0.8, 1.25)) + t.fire(EVENTS["loser"], f + 18) + # the prize pops under the winner's name and a glint runs across it + c = l0 + 28 + t.tween(wh["chip"], OPACITY, 0, 1, c, c + 10, OUT_EASE) + t.tween(wh["chip"], SX, 0.4, 1, c, c + 20, THUMB) + t.tween(wh["chip"], SY, 0.4, 1, c, c + 20, THUMB) + t.tween(wh["glint"], X, GLINT_FROM, GLINT_TO, c + 18, c + 46, INOUT) + t.fire(EVENTS["prize"], c + 2) + + xs, ys = shake_points(shakes, 0), shake_points(shakes, 1) + for oid in (SHAKE_FRONT, SHAKE_BACK, YOU["photo"], YOU["shot"], THEM["photo"], THEM["shot"]): + t.path(oid, X, xs) + t.path(oid, Y, ys) + t.duration = c + 64 + t.rest() + return t.xml() + + +def glow_loop(name, h): + """After the result the winner's glow keeps breathing and the prize glints now and then.""" + t = Timeline(name, 180, "loop") + t.path(h["glow"], OPACITY, [(0, 1, INOUT), (90, 0.65, INOUT), (180, 1, None)]) + for k in (SX, SY): + t.path(h["word"], k, [(0, 1, INOUT), (90, 1.03, INOUT), (180, 1, None)]) + t.path(h["glint"], X, [(0, GLINT_FROM, None), (60, GLINT_FROM, INOUT), (90, GLINT_TO, None)]) + return t.xml() + + +def confetti_burst(): + t = Timeline("Confetti", CONF_T) + for b in BITS: + t0 = b["t0"] + ta = t0 + b["t_up"] + (x0, y0), (x1, y1) = b["start"], b["apex"] + # thrown out, then swaying from side to side as it falls + xs, f, side, cx, step = [(t0, x0, LAUNCH_X), (ta, x1, INOUT)], ta, 1, x1, b["half"] // 2 + while f < CONF_T: + f = min(f + step, CONF_T) + cx += b["drift"] + xs.append((f, cx + side * b["sway"], INOUT)) + side, step = -side, b["half"] + t.path(b["node"], X, xs) + fall0 = ta + 20 + ym = y1 + b["v"] * 10 + t.path(b["node"], Y, [(t0, y0, LAUNCH_Y), (ta, y1, QUAD_IN), (fall0, ym, "linear"), + (CONF_T, ym + b["v"] * (CONF_T - fall0), None)]) + r1 = b["r0"] + b["spin_up"] + t.path(b["node"], ROT, [(t0, b["r0"], OUT_EASE), (ta, r1, "linear"), (CONF_T, r1 + b["spin_fall"], None)]) + # tumbling: edge on, then over to its back, and round again + v, sxs, cols, facing = b["flip_from"], [], [(t0, b["front"])], True + f = t0 + while f <= CONF_T: + sxs.append((f, v, INOUT)) + if f > t0 and v < 1: + facing = not facing + cols.append((f, b["front"] if facing else b["back"])) + elif f > t0 + 2 and b["foil"]: + cols += [(f - 2, WHITE), (f + 2, b["front"] if facing else b["back"])] + v = 0.06 if v == 1 else 1 + f += b["flip"] + t.path(b["piece"], SX, sxs) + if b["wobble"]: + f, v, sys_ = t0, 1, [] + while f <= CONF_T: + sys_.append((f, v, INOUT)) + v = 0.5 if v == 1 else 1 + f += b["wobble"] + t.path(b["piece"], SY, sys_) + shown = [(t0 - 1, 0, None), (t0, 1, None)] if t0 else [(0, 1, None)] + t.path(b["piece"], OPACITY, shown + [(CONF_T - 30, 1, "linear"), (CONF_T, 0, None)]) + t.key(b["colour"], COLOUR, *(kfc(col, f) for f, col in cols if f <= CONF_T)) + return t.xml() + + +def confetti_hidden(name, duration): + t = Timeline(name, duration) + for b in BITS: + t.key(b["piece"], OPACITY, kf(0, 0)) + return t.xml() + + +def prompt(name, a, b, duration): + t = Timeline(name, duration) + t.tween(PILL, OPACITY, a, b, 0, duration, OUT_EASE) + return t.xml() + + +animations = [ + slam_in(), strike_in(), tape_in(), cinematic_in(), lets_run_in(), idle(), + tape_hand_over(), tape_off(), + result("Win", True, False), result("Lose", False, False), + result("KO Win", True, True), result("KO Lose", False, True), + glow_loop("Win Glow", YOU_HIT), glow_loop("Lose Glow", THEM_HIT), + confetti_burst(), confetti_hidden("Confetti Off", 1), + confetti_hidden("Confetti Wait", HIT + HEAD_GAP + 2), confetti_hidden("Confetti Wait KO", HIT + HEAD_KO + 2), + prompt("Prompt Shown", 0, 1, 18), prompt("Prompt Hidden", 1, 0, 12), +] + + +# ---------------------------------------------------------------- state machine + +def on_trigger(name, to): + return f''' + + + + + + + ''' + + +def on_trigger_if(name, flag, value, to): + """A trigger that only counts while a boolean has a given value.""" + return f''' + + + + + + + + + + + + + ''' + + +def on_bool(name, value, to): + return f''' + + + + + + + ''' + + +def intro_is(key, op="equal"): + """A condition on which intro the app picked.""" + return f''' + + + + + + ''' + + +def fired(name): + return f''' + + + + + + ''' + + +def go(to, *conditions, attrs=""): + return f'' + "".join(conditions) + "" + + +def after(to): + return f'' + + +(S_STRIKE, S_IDLE, S_WIN, S_LOSE, S_ON, S_OFF, S_WIN_GLOW, S_LOSE_GLOW, S_KO_WIN, S_KO_LOSE, + S_C_OFF, S_C_WAIT, S_C_WAIT_KO, S_C_BURST) = (nid() for _ in range(14)) +S_TAPE, S_CINE, S_RUN, S_T_ON, S_T_OFF = (nid() for _ in range(5)) +S_INTRO = {"strike": S_STRIKE, "tape": S_TAPE, "cinematic": S_CINE, "letsRun": S_RUN} +machine = f''' + + + + {"".join(go(S_INTRO[k], fired("replay"), intro_is(k)) for k in INTROS)} + {on_trigger_if("win", "ko", "true", S_KO_WIN)} + {on_trigger_if("lose", "ko", "true", S_KO_LOSE)} + {on_trigger_if("win", "ko", "false", S_WIN)} + {on_trigger_if("lose", "ko", "false", S_LOSE)} + + + + {go(S_TAPE, intro_is("tape"))}{go(S_CINE, intro_is("cinematic"))}{go(S_RUN, intro_is("letsRun"))}{go(S_STRIKE)} + + {after(S_IDLE)} + {after(S_IDLE)} + {after(S_IDLE)} + {after(S_IDLE)} + + {after(S_WIN_GLOW)} + {after(S_LOSE_GLOW)} + {after(S_WIN_GLOW)} + {after(S_LOSE_GLOW)} + + + + + + + {go(S_T_ON, fired("replay"), intro_is("tape"))} + {go(S_T_OFF, fired("replay"), intro_is("tape", "notEqual"))} + + + {go(S_T_ON, intro_is("tape"))}{go(S_T_OFF)} + + {go(S_T_OFF, fired("win"), attrs=' duration="200"')} + {go(S_T_OFF, fired("lose"), attrs=' duration="200"')} + + + + + + {on_trigger("replay", S_C_OFF)} + {on_trigger("lose", S_C_OFF)} + {on_trigger_if("win", "ko", "false", S_C_WAIT)} + {on_trigger_if("win", "ko", "true", S_C_WAIT_KO)} + + + + + {after(S_C_BURST)} + {after(S_C_BURST)} + + + + + + + {on_bool("youHasPack", "false", S_ON)} + {on_bool("youHasPack", "true", S_OFF)} + + + {on_bool("youHasPack", "true", S_OFF)} + + + {on_bool("youHasPack", "false", S_ON)} + + + + + + ''' + +# ---------------------------------------------------------------- the file + +INSTANCE = nid() +rml = f''' + + + + + + {"".join(f'' for n, i in EVENTS.items())} + + {"".join(REVEALS)} + + + + {cuts_xml} + {cine_front_xml} + {cine_shots_xml} + {lr_front_xml} + {lr_panels_xml} + {lr_back_xml} + + + {screen_flash_xml} + {loose_glass_xml} + {confetti_front_xml} + {ko_xml} + {head_win_xml} + {head_lose_xml} + {gloom_xml} + {pill_xml} + {top_xml} + {tape_xml} + {tiles_xml} + {you_hit_xml} + {them_hit_xml} + {you_hit_labels} + {them_hit_labels} + {names_xml} + {vs_xml} + {strike_xml} + {confetti_back_xml} + {ko_impact_xml} + {impact_xml} + {crack_xml} + {shade_xml} + {seam_xml} + + + {you_photo_xml} + {them_photo_xml} + + {them_xml} + {you_xml} + + {machine} + {"".join(animations)} + + + + + + + + + {intro_enum_xml} + {converters_xml} + + + {"".join(f'' for n, (k, pid, a) in props.items())} + + {"".join(instance_values)} + + + +''' + +if __name__ == "__main__": + pathlib.Path(__file__).with_name("scene.rml").write_text(rml) diff --git a/rive/versus/export/outhere-versus.riv b/rive/versus/export/outhere-versus.riv new file mode 100644 index 0000000..21ece7a Binary files /dev/null and b/rive/versus/export/outhere-versus.riv differ diff --git a/rive/versus/fonts/BarlowCondensed-Bold.ttf b/rive/versus/fonts/BarlowCondensed-Bold.ttf new file mode 100644 index 0000000..baf7f91 Binary files /dev/null and b/rive/versus/fonts/BarlowCondensed-Bold.ttf differ diff --git a/rive/versus/fonts/BarlowCondensed-ExtraBoldItalic.ttf b/rive/versus/fonts/BarlowCondensed-ExtraBoldItalic.ttf new file mode 100644 index 0000000..b901a07 Binary files /dev/null and b/rive/versus/fonts/BarlowCondensed-ExtraBoldItalic.ttf differ diff --git a/rive/versus/fonts/BarlowCondensed-Medium.ttf b/rive/versus/fonts/BarlowCondensed-Medium.ttf new file mode 100644 index 0000000..b4b4e90 Binary files /dev/null and b/rive/versus/fonts/BarlowCondensed-Medium.ttf differ diff --git a/rive/versus/fonts/OFL.txt b/rive/versus/fonts/OFL.txt new file mode 100644 index 0000000..2f22ba6 --- /dev/null +++ b/rive/versus/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/rive/versus/images/silhouette-female-blue.jpg b/rive/versus/images/silhouette-female-blue.jpg new file mode 100644 index 0000000..0114320 Binary files /dev/null and b/rive/versus/images/silhouette-female-blue.jpg differ diff --git a/rive/versus/images/silhouette-female-red.jpg b/rive/versus/images/silhouette-female-red.jpg new file mode 100644 index 0000000..0c73dc7 Binary files /dev/null and b/rive/versus/images/silhouette-female-red.jpg differ diff --git a/rive/versus/images/silhouette-male-blue.jpg b/rive/versus/images/silhouette-male-blue.jpg new file mode 100644 index 0000000..52fa1bc Binary files /dev/null and b/rive/versus/images/silhouette-male-blue.jpg differ diff --git a/rive/versus/images/silhouette-male-red.jpg b/rive/versus/images/silhouette-male-red.jpg new file mode 100644 index 0000000..947c414 Binary files /dev/null and b/rive/versus/images/silhouette-male-red.jpg differ diff --git a/rive/versus/rive.yaml b/rive/versus/rive.yaml new file mode 100644 index 0000000..5769ab5 --- /dev/null +++ b/rive/versus/rive.yaml @@ -0,0 +1,7 @@ +name: outhere-versus +exclude: + - tools + - build.py + - README.md + - fonts/OFL.txt + - export diff --git a/rive/versus/scene.rml b/rive/versus/scene.rml new file mode 100644 index 0000000..c1a77f7 --- /dev/null +++ b/rive/versus/scene.rml @@ -0,0 +1,2857 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rive/versus/tools/shade.py b/rive/versus/tools/shade.py new file mode 100644 index 0000000..1e47a2c --- /dev/null +++ b/rive/versus/tools/shade.py @@ -0,0 +1,34 @@ +"""Turns a shadow-figure runner photo into the VS card's no-photo fallback. + + python3 tools/shade.py SOURCE.jpg male|female blue|red OUT.jpg + +The sources are the Fighter Pack shadow figures in +/mnt/project-files/outhere-assets/ai-avatar/silhouette-{male,female}.jpg. +Each is made grey the way the Fighter Pack mock shows it, then given a light +wash of its side's colour, blue for you and red for them. Rive scales the +result to cover the photo box, so the size only needs to be generous. +""" +import sys + +from PIL import Image, ImageEnhance, ImageOps + +# grey settings from the mock: male grayscale brightness .75; female brightness 1.1 contrast 1.4 +LOOK = {"male": (0.75, 1.0), "female": (0.75, 1.0)} # the sources share one look, so one setting +TINT = {"blue": (79, 155, 255), "red": (255, 77, 85)} +STRENGTH = 0.18 # how much of the side's colour the grey takes on; Edem wants tints light + + +def shade(src, gender, side, out): + im = ImageOps.grayscale(Image.open(src).convert("RGB")) + bright, contrast = LOOK[gender] + im = ImageEnhance.Contrast(ImageEnhance.Brightness(im).enhance(bright)).enhance(contrast) + tinted = ImageOps.colorize(im, black=(0, 0, 0), white=TINT[side]) + im = Image.blend(im.convert("RGB"), tinted, STRENGTH) + w, h = im.size + if w > 720: + im = im.resize((720, round(h * 720 / w)), Image.LANCZOS) + im.save(out, quality=84, optimize=True) + + +if __name__ == "__main__": + shade(*sys.argv[1:5])