アドレスバーで107秒の物語が上映されています

ブラウザのアドレスバーに絵文字の地図が並び、手紙が山へ向かっている様子 ライフワーク
この記事は約18分で読めます。
当ページはプロモーションが含まれています

スタジオおもち by HASH のサイト( https://hashgk.com/ )を開いて、上のアドレスバーを見てください。

# から先が動いています。街があって、ポストがあって、川があって、山があって、その向こうにおばあちゃんの家がある。そこを手紙が渡っていきます。107秒で一周する話です。

置いたのは私です。仕事として頼まれたわけではなく、勝手に作って勝手に置きました。この記事はその紹介と、作り方の記録です。

スポンサーリンク

何が映っているのか

12個のマスが横一列に並んでいます。
これがそのまま地図です。

 0    1    2    3    4    5    6    7    8    9   10   11
🏢   🏬   📮   🌳   🌊   🌊   🌊   🌲   🗻   🌾   📪   🏡

左端が街。2番目のマスが街のポスト。真ん中が川。8番目が山。右端がおばあちゃんの家で、その手前の 📪 が家のポストです。

物語は17の幕に分かれていて、絵の幕と字幕の幕が交互に来ます。

  • 子どもが手紙をポストに入れる
  • 手紙がトラックと船と汽車を乗り継いで山へ向かう
  • 山で止まる
  • おばあちゃんが毎日ポストを見にいく。ポストは空のまま
  • 秋のはじめに手紙が動きだして、届く
  • 返事が街へ向かってくる
  • 着いたのは手紙ではなかった
アドレスバーに12マスの地図が並び、左端の子どもと右端のおばあちゃんのあいだを鳥が横切っている

この距離が、この話の主題です

手紙が山のマスで止まったまま、左端の太陽と月が入れ替わり、右端のおばあちゃんが眠りにつくのを繰り返している

1秒で1日が過ぎます

子どもとおばあちゃんが隣り合ったマスに並び、あいだにハートが灯っている

12マスが1マスになったところ

最後の幕で、子どもとおばあちゃんが隣のマスに並びます。
字幕が「12マスは」と出て、「1マスになった」で終わります。

いちばん気に入っているのは、手紙が山で止まっている幕です。左端のマスが 🌞 と 🌙 で入れ替わって、右端のおばあちゃんが 👵 から 💤 になる。1秒で1日が過ぎる作りにしました。手紙は8番目のマスに置かれたまま、何日たっても動きません。

横一列しかない画面でも、時間が流れていることは表現できるらしい。作ってみるまで知りませんでした。

スポンサーリンク

なぜアドレスバーなのか

もとは2020年に Qiita で読んだ記事です。location.hash を書き換えると URL の # から先が変わるので、絵文字を順番に入れ替えれば月が満ち欠けする、という8行くらいの仕掛けでした。

読んだときに、これは画面だなと思いました。8個の絵文字が入るなら12個も入る。12個入るなら背景と主人公を重ねられる。重ねられるなら話が作れる。

そこから逆算して、107秒の無声映画になりました。

サイトにとって何の役にも立ちません。SEO にも売上にも関係がない。ただ、スタジオおもち by HASH は「できることを仕事に」を掲げているところで、サイトの隅で何かがずっと動いているのは、たぶん似合うと思ったのです。気づく人だけが気づけばいい。

どこでつまずいたか

置いたら動きませんでした。
エラーも出ない。

犯人は、自分で足した気づかいでした。

動きが苦手な人がいるので、パソコンの側で「アニメーションを減らす」設定が入っているときは再生しない、という条件を書いていました。そして自分のパソコンで、その設定が入っていた。いつ入れたのかも覚えていません。

条件はきちんと動いていました。壊れていたわけではない。ただ、止まった理由を何も言わずに黙っているので、こちらは壊れたと思い込んで、まったく別のところをずっと探すことになりました。

いまは、止めるときにその理由をブラウザのコンソールへ出すようにしてあります。動きが苦手な人の設定を、動くおもちゃのために踏み越えるわけにはいかないので、条件そのものは残したままです。

次につまずいたのが絵文字です。
動きだしたアドレスバーに、%EF%B8%8F という文字列が混ざっている。

絵文字の中には、実は2文字でできているものがあります。⛰️(山)は、山の記号と「これを絵文字として描いてください」という見えない指示子の2文字。アドレスバーは URL を組み直すときにこの2文字を切り離してしまうので、指示子だけが裸で出てきます。

私が使っていた中では ⛰️ ☁️ 🕊️ の3つが該当しました。それぞれ 🗻 ⛅ 🐦 に差し替えて解決です。❤️ ⚠️ ▶️ ✈️ も同じ仲間なので、URL に出そうとしている人は先に確かめたほうがいい。

3つめは空白でした。字幕に「あきの はじめ」と書いていたら %20 になった。空白を抜いて「あきのはじめ」にしました。ひらがなだけなら詰めても読めます。

どれも、置いてみるまで分からなかったことです。

自分のサイトにも置けます

WordPress なら、Simple Custom CSS and JS というプラグインで JavaScript を追加できます。私もそれで置きました。

まず、いちばん短い形から。
これを貼るだけで月が満ち欠けします。

const moons = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];

const loop = (arr, count = 0, time = 75) => {
  location.hash = arr[count % arr.length];
  setTimeout(loop.bind(this, arr, count + 1, time), time);
};

loop(moons);

配列を差し替えれば何でも動きます。時計にするなら ["🕛","🕐","🕑","🕒","🕓","🕔","🕕","🕖","🕗","🕘","🕙","🕚"]。数字を並べればカウントダウンになります。

12マスの画面にしたい場合は、配列を1コマぶんの絵にします。

const W = 12;

function fill(c) {
  return Array.from({ length: W }, () => c);
}

function put(a, x, c) {
  const i = Math.round(x);
  if (i >= 0 && i < W) a[i] = c;
  return a;
}

// 青空を12マス敷いて、太陽を左から右へ動かす
let f = 0;
setInterval(() => {
  const a = fill("🟦");
  put(a, (f % 130) / 10, "🌞");
  location.hash = a.join("");
  f++;
}, 125);

背景を敷いて、上に何かを置いて、つなげる。これだけです。あとから描いたものが手前になるので、太陽のあとに雲を描けば雲が前を横切ります。

107秒の物語のほうは、幕ごとの描画関数を17個ぶん並べただけのものです。全文はこの記事の末尾に置いておきます。長いですが、上から読める形にしてあります。

置くときの注意が3つ。ひとつは、絵文字を選ぶときに ⛰️ のような2文字組を避けること。ふたつめは、パソコン側で「アニメーションを減らす」が入っている人には再生しないこと。上の全文にはその条件を入れてあります。みっつめは、ページを離れるときに # を消す処理を入れることです。12マスの絵文字は、URL の形にすると140文字を超えます。その状態のリンクを誰かに送ってしまうと、ただの文字化けに見えます。

window.addEventListener("beforeunload", () => {
  history.replaceState(null, "", location.pathname + location.search);
});

もう一度、アドレスバーを

hashのホームページ をもう一度開いてみてください。

タイトルが出て、街とおばあちゃんの家がいちばん離れたところに置かれます。子どもがポストまで歩いて、手紙が旅に出る。山で止まって、何日か過ぎて、また動きだす。

最後に「1マスになった」と出て、また最初に戻ります。

107秒です。
読み込みが終わってから、そのぶんだけ待ってもらえたら。


コード全文

url-letter.js として保存して、<script src="url-letter.js"></script> で読み込みます。Simple Custom CSS and JS を使う場合は、新規 JS スニペットを作って中身を貼り、linking type は External File にしてください。停止したいときはブラウザのコンソールで URLAnimation.stop() と打ちます。

(function (global) {
  'use strict';

  var doc = global.document;
  if (!doc) { return; }

  var cp = String.fromCodePoint;

  var E = {
    office: cp(0x1F3E2), store: cp(0x1F3EC), post: cp(0x1F4EE),
    tree:   cp(0x1F332), tree2: cp(0x1F333), water: cp(0x1F30A),
    mount:  cp(0x1F5FB), rice:  cp(0x1F33E),
    boxOff: cp(0x1F4EA), boxOn: cp(0x1F4EB), home: cp(0x1F3E1),
    kid:    cp(0x1F9D2), gran:  cp(0x1F475), letter: cp(0x1F48C),
    truck:  cp(0x1F69A), boat:  cp(0x26F5),  train:  cp(0x1F682),
    boom:   cp(0x1F4A5), swirl: cp(0x1F300),
    sun:    cp(0x1F31E), moon:  cp(0x1F319), zzz: cp(0x1F4A4),
    spark:  cp(0x2728),  heart: cp(0x1F497), bird: cp(0x1F426),
    what:   cp(0x2753),  caret: '\u258D'
  };

  var W = 12;
  var MAP = [E.office, E.store, E.post, E.tree2, E.water, E.water,
             E.water, E.tree, E.mount, E.rice, E.boxOff, E.home];

  function map() { return MAP.slice(); }

  function put(a, x, c) {
    var i = Math.round(x);
    if (i >= 0 && i < W) { a[i] = c; }
    return a;
  }

  function ride(i) {
    if (i >= 4 && i <= 6) { return E.boat; }
    if (i >= 7 && i <= 8) { return E.train; }
    return E.truck;
  }

  function card(text, sec) {
    return {
      no: '間', ttl: text, sec: sec || 3, fps: 5, text: true,
      render: function (f) {
        var a = text.split('');
        return a.slice(0, Math.min(f + 1, a.length)).join('') +
               (f % 2 ? E.caret : '');
      }
    };
  }

  var scenes = [
    card('12マスのてがみ', 3),

    { no: '一', ttl: 'おばあちゃんはとおい', sec: 6, fps: 8,
      render: function (f) {
        var a = map();
        put(a, 0, E.kid);
        put(a, 11, E.gran);
        if (f > 8) { put(a, 1 + (f - 8) * 0.28, E.bird); }
        return a.join('');
      } },

    card('とおいね', 3),

    { no: '二', ttl: 'ポストまで', sec: 6, fps: 8,
      render: function (f, F) {
        var a = map();
        put(a, 11, E.gran);
        if (f < 18) {
          put(a, (f / 17) * 2, (f % 4 < 2) ? E.letter : E.kid);
        } else if (f < 26) {
          put(a, 0, E.kid);
          put(a, 2, (f % 4 < 2) ? E.letter : E.post);
        } else {
          put(a, 2 - ((f - 26) / (F - 27)) * 2, E.kid);
        }
        return a.join('');
      } },

    { no: '三', ttl: 'たびだち', sec: 14, fps: 8,
      render: function (f, F) {
        var a = map();
        put(a, 0, E.kid);
        put(a, 11, E.gran);
        var x = 2 + (f / F) * 6.4;
        put(a, x, (f % 8 < 2) ? E.letter : ride(Math.round(x)));
        return a.join('');
      } },

    card('ところが', 3),

    { no: '四', ttl: 'やまでとまる', sec: 8, fps: 8,
      render: function (f, F) {
        var a = map();
        put(a, 11, E.gran);
        var p = f / F;
        if (p < 0.22) { put(a, 8, E.train); }
        else if (p < 0.34) { put(a, 8, E.boom); }
        else {
          put(a, 8, (f % 6 < 3) ? E.train : E.swirl);
          if (f % 10 < 4) { put(a, 7, E.swirl); }
        }
        return a.join('');
      } },

    { no: '五', ttl: 'まいにちポストをみる', sec: 10, fps: 8,
      render: function (f) {
        var a = map();
        put(a, 8, E.train);
        var night = (f % 8) >= 4;
        put(a, 0, night ? E.moon : E.sun);
        put(a, 10, E.boxOff);
        put(a, 11, night ? E.zzz : E.gran);
        return a.join('');
      } },

    card('あきのはじめ', 3),

    { no: '六', ttl: 'ふたたびうごく', sec: 10, fps: 8,
      render: function (f, F) {
        var a = map();
        var p = f / F;
        put(a, 11, E.gran);
        if (p < 0.85) {
          var x = 8 + (p / 0.85) * 2;
          put(a, x, (f % 8 < 2) ? E.letter : ride(Math.round(x)));
        }
        put(a, 10, p > 0.75 ? E.boxOn : E.boxOff);
        return a.join('');
      } },

    { no: '七', ttl: 'よむ', sec: 6, fps: 8,
      render: function (f) {
        var a = map();
        put(a, 10, E.boxOn);
        put(a, 11, (f % 8 < 4) ? E.gran : E.letter);
        if (f > 16) { put(a, 10, (f % 6 < 3) ? E.spark : E.boxOn); }
        if (f > 30) { put(a, 9, E.heart); }
        return a.join('');
      } },

    card('おへんじは', 3),

    { no: '八', ttl: 'かえりみち', sec: 10, fps: 8,
      render: function (f, F) {
        var a = map();
        put(a, 0, E.kid);
        put(a, 10, E.boxOn);
        var x = 11 - (f / F) * 8;
        put(a, x, (f % 16 < 2) ? E.what : ride(Math.round(x)));
        return a.join('');
      } },

    { no: '九', ttl: 'とうちゃく', sec: 8, fps: 8,
      render: function (f, F) {
        var a = map();
        var p = f / F;
        put(a, 0, E.kid);
        if (p < 0.30) { put(a, 3 - p * 6.6, E.truck); }
        else if (p < 0.42) { put(a, 1, E.truck); }
        else { put(a, 1, E.gran); }
        if (p > 0.55) { put(a, 2, (f % 6 < 3) ? E.spark : E.post); }
        if (p > 0.80) { put(a, 2, E.heart); }
        return a.join('');
      } },

    card('12マスは', 3),

    { no: '十', ttl: 'ならんで', sec: 6, fps: 8,
      render: function (f) {
        var a = map();
        put(a, 0, E.kid);
        put(a, 1, E.gran);
        put(a, 3 + (f * 0.16) % 9, E.bird);
        if (f % 14 < 7) { put(a, 2, E.heart); }
        return a.join('');
      } },

    card('1マスになった', 5)
  ];

  scenes.forEach(function (s) { s.F = Math.round(s.sec * s.fps); });

  var mode = 'hash';

  function write(text) {
    if (mode === 'replace') {
      try { global.location.replace('#' + text); return; }
      catch (e) { mode = 'hash'; }
    }
    global.location.hash = text;
  }

  function restore() {
    try {
      global.history.replaceState(null, '',
        global.location.pathname + global.location.search);
    } catch (e) { global.location.hash = ''; }
  }

  var si = 0, fi = 0, timer = null, playing = false, speed = 1, resume = false;

  function step() {
    var s = scenes[si];
    write(s.render(fi, s.F));
    if (++fi >= s.F) { fi = 0; si = (si + 1) % scenes.length; }
    timer = global.setTimeout(step, (1000 / s.fps) / speed);
  }

  var api = {
    scenes: scenes,
    play:  function () { if (!playing) { playing = true; step(); } return api; },
    pause: function () { playing = false; global.clearTimeout(timer); return api; },
    stop:  function () { api.pause(); restore(); return api; },
    seek:  function (n) { si = ((n % scenes.length) + scenes.length) % scenes.length; fi = 0; return api; },
    speed: function (x) { speed = x || 1; return api; },
    mode:  function (m) { mode = (m === 'replace') ? 'replace' : 'hash'; return api; }
  };

  doc.addEventListener('visibilitychange', function () {
    if (doc.hidden) { resume = playing; api.pause(); }
    else if (resume) { resume = false; api.play(); }
  });

  global.addEventListener('beforeunload', restore);
  global.URLAnimation = api;

  var reduced = global.matchMedia &&
                global.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (reduced) {
    console.log('動きを減らす設定が有効なので再生していません。' +
                'URLAnimation.play() で手動再生できます。');
  } else {
    api.play();
  }

})(window);

地図を変えたいときは MAP の12個を書き換えるだけです。幕を足したいときは scenes に1つ足す。字幕なら card('すきなことば', 3) と書けば、それだけで1枚増えます。

タイトルとURLをコピーしました