Sign in
EN
games/satisfying-bounce / tools / site / build.py · commit 8bdd6e4
#!/usr/bin/env python3"""Writes the two pages of the game's web site (site/index.html in English, site/fr/index.html in French) and the sitemap from ONElist of videos, so that the two languages never drift apart. Run it from anywhere: python3 tools/site/build.py(the videos and posters come from tools/make-videos.sh; og.png and assets/thumbnail.png from tools/site/images.py)."""import html, json, osROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..'))SITE = os.path.join(ROOT, 'site')# id, section, {lang: (title, text)}VIDEOS = [    ('zoom', 'escape', {        'en': ('Escape, zoom, repeat', 'Rings inside rings, each with an opening that turns at its own speed. The ball bounces until it finds the exit, the ring it left bursts, the camera glides out to the next one, and new rings keep appearing: an infinite zoom.'),        'fr': ("S'échapper, zoomer, recommencer", "Des anneaux dans des anneaux, chacun avec une ouverture qui tourne à sa vitesse. La balle rebondit jusqu'à trouver la sortie, l'anneau quitté éclate, la caméra glisse vers le suivant et de nouveaux anneaux apparaissent : un zoom infini.")}),    ('gap', 'escape', {        'en': ('Can it find the exit?', 'A ball bounces in a circle closed on three quarters while the opening turns. Each bounce gives it energy: it climbs, then finds the exit. The next opening is narrower.'),        'fr': ('Peut-elle trouver la sortie ?', "Une balle rebondit dans un cercle fermé sur trois quarts pendant que l'ouverture tourne. Chaque rebond lui donne de l'énergie : elle monte, puis trouve la sortie. L'ouverture suivante est plus étroite.")}),    ('rings', 'escape', {        'en': ('Break all the rings', 'Seven rings inside each other. Each one breaks after three hits, from the inside, with a higher bell.'),        'fr': ('Brise tous les anneaux', 'Sept anneaux les uns dans les autres. Chacun se brise après trois chocs, en partant du centre, avec une cloche plus aiguë.')}),    ('multiply', 'escape', {        'en': ('It keeps multiplying', 'Every hit on the wall makes a new ball, ten at most: when the circle is full the oldest one fades away, so you can still follow every bounce and hear every note.'),        'fr': ('Ça ne s\'arrête pas de se multiplier', "Chaque choc sur la paroi fait naître une nouvelle balle, dix au plus : quand le cercle est plein, la plus ancienne s'efface. On suit chaque rebond et on entend chaque note.")}),    ('marble', 'falling', {        'en': ('Marble run', 'Marbles drop down a zig-zag of ramps. Every ramp is a note of a scale, so the falling marbles play a rippling arpeggio.'),        'fr': ('Course de billes', "Des billes dévalent un zig-zag de rampes. Chaque rampe est une note d'une gamme : les billes qui tombent jouent un arpège cascadant.")}),    ('plinko', 'falling', {        'en': ('Pegs that play music', 'Balls fall through a field of pegs. Every peg lights up and rings a note, the further right the higher, and a chime sounds at the bottom.'),        'fr': ('Des clous qui jouent de la musique', "Des balles tombent à travers un champ de clous. Chaque clou s'allume et sonne une note, plus aiguë vers la droite, et un carillon retentit en bas.")}),    ('dominos', 'falling', {        'en': ('The perfect chain reaction', 'Dominoes on a spiral topple one after the other, each ringing a higher note and turning from white to its colour. Then they all stand up again, backwards. A seamless loop.'),        'fr': ('La réaction en chaîne parfaite', "Des dominos en spirale tombent l'un après l'autre, chacun sonnant une note plus aiguë et passant du blanc à sa couleur. Puis ils se relèvent tous, à l'envers. Une boucle parfaite.")}),    ('pendulum', 'falling', {        'en': ('Pendulum wave', 'Twelve pendulums of slightly different lengths start together, drift into waves and tangles, and line up again after exactly one cycle. Each one rings its own note.'),        'fr': ('Onde de pendules', "Douze pendules de longueurs légèrement différentes partent ensemble, dérivent en vagues et en enchevêtrements, et se réalignent après exactement un cycle. Chacun sonne sa propre note.")}),    ('stairs', 'illusion', {        'en': ('It grows with every step', 'A shape hops down steps that get bigger and bigger, and it grows with them. The camera zooms out in step, so its size on screen never changes: it feels like it keeps growing while it does not.'),        'fr': ('Ça grossit à chaque marche', "Une forme descend des marches de plus en plus grandes et grossit avec elles. La caméra dézoome en même temps : sa taille à l'écran ne change jamais. On a l'impression qu'elle grossit sans cesse alors que non.")}),    ('tunnel', 'illusion', {        'en': ('Do not stare too long', 'An endless tunnel of twisted squares, each one a step further round the colour wheel. Nothing moves but the size of the squares, yet the picture seems to spin and pull you in.'),        'fr': ('Ne fixe pas trop longtemps', "Un tunnel sans fin de carrés vrillés, chacun un cran plus loin sur le cercle des couleurs. Seule la taille des carrés change, mais l'image semble tourner et vous aspirer.")}),    ('spiral', 'illusion', {        'en': ('Watch the centre', 'A spiral of dots that only circle, yet the arms seem to flow outwards. Each arm that passes the top or the bottom rings a note of a dreamy whole-tone scale.'),        'fr': ('Regarde le centre', "Une spirale de points qui ne font que tourner, et pourtant les bras semblent s'écouler vers l'extérieur. Chaque bras qui passe en haut ou en bas sonne une note d'une gamme par tons entiers, rêveuse.")}),    ('checker', 'illusion', {        'en': ('It is perfectly flat', 'A checkerboard whose tiles move and swell with a travelling wave: the flat grid looks curved and deep. A note and a wave of light on every beat.'),        'fr': ("C'est parfaitement plat", "Un damier dont les cases bougent et gonflent avec une vague : la grille plate paraît courbe et profonde. Une note et une vague de lumière à chaque temps.")}),    ('ripple', 'illusion', {        'en': ('None of the dots move', 'A grid of dots whose size ripples out from the middle, like a lens bulging towards you. The flat grid looks 3D and the dots seem to stream away.'),        'fr': ('Aucun point ne bouge', "Une grille de points dont la taille ondule depuis le centre, comme une lentille qui bombe vers vous. La grille plate paraît en 3D et les points semblent s'éloigner.")}),    ('cats', 'sound', {        'en': ('Cats bounce, cats meow', 'Three round cats bounce in a box and meow at every bounce. The meows are synthesized, each cat has its own pitch from a pentatonic scale so they sing together, and a purr rumbles underneath.'),        'fr': ('Des chats qui miaulent', "Trois chats ronds rebondissent dans une boîte et miaulent à chaque rebond. Les miaous sont synthétisés, chaque chat a sa hauteur dans une gamme pentatonique pour chanter ensemble, et un ronronnement gronde en dessous.")}),    ('rain', 'sound', {        'en': ('Rain on a quiet pond', 'An ASMR pond: slow drops, soft rings that spread and cross, and a round drop sound of a minor pentatonic scale. Nothing hurries.'),        'fr': ('Pluie sur une mare calme', "Une mare ASMR : des gouttes lentes, des cercles doux qui s'étendent et se croisent, et un son de goutte rond sur une gamme pentatonique mineure. Rien ne presse.")}),]SECTIONS = {    'en': {'escape': 'Escape and bounce', 'falling': 'Falling, rolling, toppling', 'illusion': 'Optical illusions', 'sound': 'Cats and rain: sounds to melt to'},    'fr': {'escape': "S'échapper et rebondir", 'falling': 'Tomber, rouler, basculer', 'illusion': "Illusions d'optique", 'sound': 'Chats et pluie : des sons doux'},}T = {    'en': {        'lang': 'en', 'name': 'English', 'prefix': '', 'up': '', 'other': ('fr/', 'Français', 'fr'),        'title': 'Satisfying Bounce - satisfying videos and optical illusions, play free online',        'desc': 'Fifteen satisfying vertical videos and animated optical illusions made in PHP: an infinite zoom of rings to escape, balls that multiply, stairs where it grows without growing, tunnels, dominoes, meowing cats, pendulum waves. Every bounce plays a note.',        'tag': 'Fifteen satisfying videos and optical illusions made in PHP. Every bounce plays a note.',        'play': '&#9654; Play', 'start': '&#9654; Start', 'h_videos': 'Fifteen videos to watch (sound on)',        'h_how': 'How it works',        'how': 'Each scene is a simulation or an illusion drawn in HD, played at a fixed step, so the same seed always gives the same video. Each bounce plays a note of a scale or a public-domain melody, with trails, sparks, squash and stretch and a soft flash. Camera zooms are a pure drawing transform: the physics never sees them. The game also renders every scene offline as a 1080 x 1920 MP4 with its sound, in English and in French, for TikTok, Shorts and Reels.',        'h_what': 'What makes them satisfying',        'what': ['A hook in the first seconds: the ball is already moving and a short caption says what to wait for.', 'Every bounce is a note, so sight and sound match exactly; soft envelopes, pentatonic scales, no harsh clicks.', 'Tension that builds: openings that narrow, rings that get closer, zooms that go further.', 'Clean loops: the illusions and the pendulums end exactly where they began.'],        'h_controls': 'Controls', 'controls': '<li><b>Menu</b>: arrows and Enter, or tap, to choose a scene; <b>Language</b> switches English / French.</li><li><b>R</b> or a tap: restart the scene. <b>Escape</b>: back to the menu.</li>',        'footer': 'Made in PHP with JumPHP.', 'langs': 'Languages', 'listen': 'Turn the sound on',    },    'fr': {        'lang': 'fr', 'name': 'Français', 'prefix': '../', 'up': '../', 'other': ('../', 'English', 'en'),        'title': "Satisfying Bounce - vidéos satisfaisantes et illusions d'optique, jouer gratuitement en ligne",        'desc': "Quinze vidéos verticales satisfaisantes et illusions d'optique animées faites en PHP : un zoom infini d'anneaux à franchir, des balles qui se multiplient, des escaliers où ça grossit sans grossir, des tunnels, des dominos, des chats qui miaulent, des ondes de pendules. Chaque rebond joue une note.",        'tag': "Quinze vidéos satisfaisantes et illusions d'optique faites en PHP. Chaque rebond joue une note.",        'play': '&#9654; Jouer', 'start': '&#9654; Commencer', 'h_videos': 'Quinze vidéos à regarder (avec le son)',        'h_how': 'Comment ça marche',        'how': "Chaque scène est une simulation ou une illusion dessinée en HD, jouée à pas fixe : la même graine donne toujours la même vidéo. Chaque rebond joue une note d'une gamme ou d'une mélodie du domaine public, avec traînées, étincelles, écrasement et étirement et un léger flash. Les zooms de caméra sont une simple transformation du dessin : la physique ne les voit jamais. Le jeu rend aussi chaque scène hors ligne en MP4 1080 x 1920 avec son son, en français et en anglais, pour TikTok, Shorts et Reels.",        'h_what': 'Ce qui les rend satisfaisantes',        'what': ["Un accroche dès les premières secondes : la balle bouge déjà et une courte légende dit quoi attendre.", "Chaque rebond est une note : l'image et le son concordent exactement ; enveloppes douces, gammes pentatoniques, aucun clic agressif.", "Une tension qui monte : des ouvertures qui se resserrent, des anneaux qui se rapprochent, des zooms qui vont plus loin.", "Des boucles propres : les illusions et les pendules finissent exactement où elles ont commencé."],        'h_controls': 'Commandes', 'controls': "<li><b>Menu</b> : flèches et Entrée, ou toucher, pour choisir une scène ; <b>Langue</b> bascule français / anglais.</li><li><b>R</b> ou un toucher : relancer la scène. <b>Échap</b> : retour au menu.</li>",        'footer': 'Fait en PHP avec JumPHP.', 'langs': 'Langues', 'listen': 'Mets le son',    },}T['fr']['what'][0] = "Une accroche dès les premières secondes : la balle bouge déjà et une courte légende dit quoi attendre."def esc(s):    return html.escape(s, quote=True)def page(lang):    t = T[lang]    p = t['prefix']    url = 'https://{{DOMAIN}}/' + ('fr/' if lang == 'fr' else '')    sections = []    for key in ['escape', 'falling', 'illusion', 'sound']:        cards = []        for vid, sec, texts in VIDEOS:            if sec != key:                continue            title, text = texts[lang]            cards.append(                f'      <article class="clip" id="{vid}"><video src="{p}videos/{vid}-{lang}.mp4" poster="{p}images/{vid}-{lang}.jpg" controls loop muted playsinline preload="none" width="270" height="480" aria-label="{esc(title)}"></video>\n'                f'      <h3>{esc(title)}</h3><p>{esc(text)}</p></article>')        sections.append(f'    <section>\n      <h2>{esc(SECTIONS[lang][key])}</h2>\n      <div class="gallery">\n' + '\n'.join(cards) + '\n      </div>\n    </section>')    shots = [f'https://{{{{DOMAIN}}}}/images/{v[0]}-{lang}.jpg' for v in VIDEOS[:6]]    ld = {        '@context': 'https://schema.org', '@type': 'VideoGame', 'name': 'Satisfying Bounce', 'description': t['desc'], 'url': url,        'genre': 'Simulation', 'gamePlatform': ['Web browser', 'PC', 'Mobile'], 'applicationCategory': 'Game', 'operatingSystem': 'Any',        'image': 'https://{{DOMAIN}}/images/og.png', 'screenshot': shots, 'isAccessibleForFree': True, 'inLanguage': lang,        'potentialAction': {'@type': 'PlayAction', 'target': 'https://{{DOMAIN}}/play/'},    }    other_href, other_name, other_code = t['other']    nav = (f'<b>{t["name"]}</b><a href="{other_href}" hreflang="{other_code}" lang="{other_code}">{other_name}</a>' if lang == 'en'           else f'<a href="{other_href}" hreflang="{other_code}" lang="{other_code}">{other_name}</a><b>{t["name"]}</b>')    what = ''.join(f'<li>{esc(x)}</li>' for x in t['what'])    return f'''<!doctype html><html lang="{lang}"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>{esc(t['title'])}</title>  <meta name="description" content="{esc(t['desc'])}">  <link rel="canonical" href="{url}">  <link rel="alternate" hreflang="en" href="https://{{{{DOMAIN}}}}/">  <link rel="alternate" hreflang="fr" href="https://{{{{DOMAIN}}}}/fr/">  <link rel="alternate" hreflang="x-default" href="https://{{{{DOMAIN}}}}/">  <link rel="icon" href="{p}images/favicon.png">  <meta property="og:type" content="website">  <meta property="og:title" content="Satisfying Bounce">  <meta property="og:description" content="{esc(t['desc'])}">  <meta property="og:url" content="{url}">  <meta property="og:image" content="https://{{{{DOMAIN}}}}/images/og.png">  <meta property="og:locale" content="{lang}">  <meta name="twitter:card" content="summary_large_image">  <meta name="twitter:title" content="Satisfying Bounce">  <meta name="twitter:description" content="{esc(t['desc'])}">  <meta name="twitter:image" content="https://{{{{DOMAIN}}}}/images/og.png">  <script type="application/ld+json">{json.dumps(ld, indent=4, ensure_ascii=False)}  </script>  <style>    :root {{ --bg: #0d0f1a; --panel: #171a2b; --text: #eef0ff; --muted: #a7acc9; --accent: #ffcc33; }}    * {{ box-sizing: border-box; }}    body {{ margin: 0; background: var(--bg); color: var(--text); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }}    main {{ max-width: 1100px; margin: 0 auto; padding: 32px 16px 64px; }}    header {{ text-align: center; padding: 24px 0 8px; }}    h1 {{ font-size: clamp(32px, 7vw, 56px); margin: 0 0 8px; letter-spacing: 2px; }}    h2 {{ margin: 48px 0 16px; font-size: 24px; }}    p, li {{ color: var(--muted); }}    .play {{ display: inline-block; margin: 20px 0; padding: 16px 40px; border-radius: 12px; background: var(--accent); color: #111;      font-weight: 800; font-size: 22px; text-decoration: none; box-shadow: 0 6px 0 #b38b00; }}    .play:active {{ transform: translateY(4px); box-shadow: 0 2px 0 #b38b00; }}    .gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 24px; }}    .clip video {{ width: 100%; height: auto; aspect-ratio: 9 / 16; border-radius: 12px; background: #000; display: block; }}    h3 {{ margin: 12px 0 4px; }}    .clip p {{ margin: 0; font-size: 15px; line-height: 1.5; }}    footer {{ text-align: center; color: var(--muted); font-size: 14px; margin-top: 56px; }}    a {{ color: var(--accent); }}    .langs {{ text-align: center; font-size: 14px; margin: 0; padding-top: 12px; }}    .langs a, .langs b {{ margin: 0 8px; }}    .listen {{ color: var(--accent); font-weight: 700; }}  </style></head><body>  <main>    <nav class="langs" aria-label="{esc(t['langs'])}">{nav}</nav>    <header>      <h1>Satisfying Bounce</h1>      <p>{esc(t['tag'])}</p>      <a class="play" href="{t['up']}play/">{t['play']}</a>    </header>    <p class="listen" style="text-align: center">{esc(t['listen'])} &#128266;</p>    <h2>{esc(t['h_videos'])}</h2>{chr(10).join(sections)}    <section>      <h2>{esc(t['h_what'])}</h2>      <ul>{what}</ul>    </section>    <section>      <h2>{esc(t['h_how'])}</h2>      <p>{esc(t['how'])}</p>    </section>    <section>      <h2>{esc(t['h_controls'])}</h2>      <ul>{t['controls']}</ul>    </section>    <p style="text-align: center"><a class="play" href="{t['up']}play/">{t['start']}</a></p>    <footer>{esc(t['footer'])}</footer>  </main></body></html>'''def sitemap():    alt = ('    <xhtml:link rel="alternate" hreflang="en" href="https://{{DOMAIN}}/"/>\n'           '    <xhtml:link rel="alternate" hreflang="fr" href="https://{{DOMAIN}}/fr/"/>\n'           '    <xhtml:link rel="alternate" hreflang="x-default" href="https://{{DOMAIN}}/"/>\n')    return ('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'            f'  <url><loc>https://{{{{DOMAIN}}}}/</loc>\n{alt}  </url>\n  <url><loc>https://{{{{DOMAIN}}}}/fr/</loc>\n{alt}  </url>\n'            '  <url><loc>https://{{DOMAIN}}/play/</loc></url>\n</urlset>\n')if __name__ == '__main__':    os.makedirs(os.path.join(SITE, 'fr'), exist_ok=True)    open(os.path.join(SITE, 'index.html'), 'w', encoding='utf-8').write(page('en'))    open(os.path.join(SITE, 'fr', 'index.html'), 'w', encoding='utf-8').write(page('fr'))    open(os.path.join(SITE, 'sitemap.xml'), 'w', encoding='utf-8').write(sitemap())    print('site written:', len(VIDEOS), 'videos x 2 languages')