TipsBlocks Web Technique Library

TB-0006

Interactive Mood & Time World

太陽や月をドラッグして時間を操作すると、空・昼夜の景色・星・光・パンダのムードが連動して変化するインタラクティブな世界表現です。TIMEとMOODを別軸で扱い、マウスパララックスや自動進行も組み合わせています。

Animation Background Effect UI Interaction Drag Interaction Interactive Control Pointer Follow
Interaction
中央の軌道上をドラッグす…
Runtime
HTML / CSS / Vanilla JavaScript

MOTION PREVIEW

太陽を動かすと、世界の時間も動き出す。

ドラッグ操作で昼から夕方、夜、朝へ時間を動かし、空、昼夜の景色、星、光、キャラクターのMoodまで一つの世界として連動させます。

PREVIEW MEDIA Preview MP4 / Poster を登録すると、ここへ自動表示されます。

LIVE DEMO

太陽と月をドラッグして、時間と世界を直接操作する。

中央の軌道上をドラッグすると時間帯が変化します。太陽と月は反対側へ連動して移動し、昼・夕方・夜・朝へ空色や背景が連続変化します。HAPPY / MOODY / ANGRY / SLEEPYでキャラクターと世界のMoodを切り替えられます。マウス移動では背景とキャラクターが軽くパララックス追従し、AUTO ONで時間を自動進行、NOONで正午へ戻せます。

HOW TO INTERACT

Drag Interaction

中央の軌道上をドラッグすると時間帯が変化します。太陽と月は反対側へ連動して移動し、昼・夕方・夜・朝へ空色や背景が連続変化します。HAPPY / MOODY / ANGRY / SLEEPYでキャラクターと世界のMoodを切り替えられます。マウス移動では背景とキャラクターが軽くパララックス追従し、AUTO ONで時間を自動進行、NOONで正午へ戻せます。

ABOUT THIS BLOCK

Interactive Mood & Time Worldとは

Interactive Mood & Time Worldは、太陽や月をドラッグして時間帯を直接操作できるインタラクティブな世界表現です。

時間は0〜1の循環値として扱い、正午、夕方、深夜、朝へ連続的に変化します。太陽と月は同じ時間値から反対位置へ配置され、空色、昼景と夜景のクロスフェード、星の表示量、光の雰囲気が一つの時間パラメータへ同期します。

さらにHAPPY、MOODY、ANGRY、SLEEPYの4種類のMoodを時間とは別軸で切り替えられます。同じ時間帯でもMoodによってパンダの状態や世界の色味が変わるため、TIMEとMOODという2つの状態を組み合わせて画面全体の印象を作れます。

マウス移動には軽いパララックスを加え、背景プレート、軌道、キャラクターが異なる量で追従します。AUTOでは時間が自動進行し、NOONで正午へ戻せます。

旅行・宿泊、教育コンテンツ、ブランドサイト、キャンペーンページなど、ユーザー操作によって世界観を変化させたいWeb表現へ応用できます。

Technology
CSS / HTML / JavaScript
Interaction
Drag Interaction / Interactive Control / Pointer Follow
External Library
None
Reduced Motion
Supported

HOW IT WORKS

TIMEとMOODを分離し、世界全体を一つの状態として同期する。

時間を0〜1の循環値として管理し、その値から太陽と月の円軌道座標、昼夜の比率、空色、星の量、背景プレートの不透明度を計算します。Moodは時間とは別の状態として保持し、HAPPY、MOODY、ANGRY、SLEEPYのキャラクター差分と色調を切り替えます。Pointer EventsとMath.atan2()でドラッグ位置を時間へ変換し、CSS Custom Propertiesを介して複数の視覚要素を同期しています。

  1. 01

    時間を0〜1の循環値として管理する

    正午を0、夕方を0.25、深夜を0.5、朝を0.75として一周を0〜1で表現します。時間を一つの連続値に集約することで、太陽、月、空、背景、星を同じ基準から制御できます。

  2. 02

    ポインター位置を円軌道上の時間へ変換する

    Pointer Eventsでドラッグ位置を取得し、Math.atan2()から中心に対する角度を計算します。その角度を0〜1へ正規化して、ユーザーが軌道上をドラッグする動作を時間値へ変換します。

  3. 03

    太陽と月を同じ時間値から反対位置へ配置する

    時間値をラジアンへ変換し、sinとcosから太陽の座標を求めます。月は太陽と反対側の座標へ置くことで、ひとつの時間操作から昼夜の天体移動を同期できます。

  4. 04

    昼・夕方・夜・朝を連続補間する

    太陽の高さから昼夜比率と地平線付近の値を求め、空色、夕焼けレイヤー、星の不透明度を連続的に変更します。固定プリセットを瞬間切替するのではなく、ドラッグ量に応じて途中状態も表示します。

  5. 05

    昼景と夜景をクロスフェードする

    昼と夜の背景プレートを別レイヤーとして配置し、時間値から不透明度を計算します。太陽の高さに応じて昼景を強く、月が上がるにつれて夜景を強くし、世界全体の移り変わりを作ります。

  6. 06

    Moodを時間とは独立した状態として扱う

    HAPPY、MOODY、ANGRY、SLEEPYは時間とは別の状態として保持します。パンダの画像差分と色調をMoodから切り替えることで、同じ時間帯でも異なる世界観を作れる構造にしています。

  7. 07

    Pointer Parallaxで奥行きを追加する

    マウス位置を画面中心からの距離へ変換し、背景プレート、軌道、キャラクターへ異なる移動量をCSS Custom Propertiesで渡します。小さな移動差だけで、平面的な画面に奥行き感を加えます。

CODE

時間操作・Mood切替・パララックスを連動させるコードを見る。

HTMLは昼夜の背景プレート、太陽・月、星、4種類のパンダ、操作UIで構成します。CSSは背景レイヤー、軌道、パララックス、Mood別の演出を担当し、JavaScriptはPointer Events、Math.atan2()、時間値の補間、AUTO進行、Mood切替を制御します。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>TB-0006 Interactive Mood & Time World V8</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="scene" id="scene" data-mood="happy">
    <div class="sky sky-gradient"></div>
    <div class="sky sky-dusk"></div>
    <div class="sky sky-night"></div>
    <div class="vignette"></div>

    <div class="world-wrap">
      <div class="world" id="world">
        <div class="orbit-ring"></div>

        <div class="plate day-plate">
          <div class="plate-tint day-plate-tint"></div>
        </div>

        <div class="plate night-plate">
          <div class="plate-tint night-plate-tint"></div>
        </div>

        <div class="sun-system" id="sunHandle" aria-hidden="true">
          <span class="sun-ring ring-one"></span>
          <span class="sun-ring ring-two"></span>
          <span class="sun-core"></span>
        </div>

        <div class="moon-system" id="moonHandle" aria-hidden="true">
          <span class="moon-core"></span>
          <span class="moon-cut"></span>
        </div>

        <span class="star s1"></span>
        <span class="star s2"></span>
        <span class="star s3"></span>
        <span class="star s4"></span>
        <span class="star s5"></span>
        <span class="star s6"></span>
        <span class="star s7"></span>
      </div>

      <div class="drag-layer" id="dragLayer" aria-label="Drag the sun or moon to change time"></div>
    </div>

    <div class="character-stage">
      <div class="character-shadow"></div>
      <div class="character-stack">
        <img class="panda panda-happy is-active" src="./assets/panda-happy.png" alt="">
        <img class="panda panda-moody" src="./assets/panda-moody.png" alt="">
        <img class="panda panda-angry" src="./assets/panda-angry.png" alt="">
        <img class="panda panda-sleepy" src="./assets/panda-sleepy.png" alt="">
      </div>
      <div class="mood-mark mark-happy">✦</div>
      <div class="mood-mark mark-moody">•••</div>
      <div class="mood-mark mark-angry">⚡</div>
      <div class="mood-mark mark-sleepy">z z</div>
    </div>

    <div class="flash" id="flash"></div>

    <div class="ui">
      <div class="headline">
        <span class="eyebrow">TB-0006 / V8</span>
        <strong>INTERACTIVE MOOD & TIME WORLD</strong>
        <span class="sub" id="stateCopy">DRAG THE SUN / MOON TO CHANGE TIME</span>
      </div>

      <div class="panel">
        <div class="hint" id="hint">DRAG SUN / MOON</div>

        <div class="mood-buttons" id="moodButtons">
          <button type="button" class="active" data-mood="happy"><span>☀</span> HAPPY</button>
          <button type="button" data-mood="moody"><span>☁</span> MOODY</button>
          <button type="button" data-mood="angry"><span>◆</span> ANGRY</button>
          <button type="button" data-mood="sleepy"><span>☾</span> SLEEPY</button>
        </div>

        <div class="bottom-row">
          <div class="time-readout" id="timeReadout">12:00 / DAY</div>
          <div class="button-row">
            <button type="button" id="autoBtn">AUTO ON</button>
            <button type="button" id="resetBtn">NOON</button>
          </div>
        </div>
      </div>
    </div>
  </main>

  <script src="./script.js"></script>
</body>
</html>
*{box-sizing:border-box}
html,body{margin:0;width:100%;min-height:100%;background:#0d1630}
body{min-height:100vh;overflow:hidden;font-family:Arial,Helvetica,sans-serif}
button{font:inherit}

:root{
  --scene-top:#97dcff;
  --scene-bottom:#e8fbff;
  --dusk-opacity:0;
  --night-opacity:0;
  --day-plate-opacity:1;
  --night-plate-opacity:.25;
  --day-tint-opacity:0;
  --night-tint-opacity:0;
  --day-tint:#ffffff;
  --night-tint:#ffffff;
  --sun-x:0px;
  --sun-y:-280px;
  --moon-x:0px;
  --moon-y:280px;
  --sun-opacity:1;
  --moon-opacity:.2;
  --star-opacity:.06;
  --mx:0px;
  --my:0px;
  --char-tilt:0deg;
  --char-lift:0px;
  --ui-bg:rgba(8,16,42,.25);
  --ui-border:rgba(255,255,255,.18);
  --t:240ms;
}

.scene{
  position:relative;
  width:100vw;height:100vh;overflow:hidden;
  background:linear-gradient(180deg,var(--scene-top),var(--scene-bottom));
  transition:background 380ms linear;
}

.sky{position:absolute;inset:0;pointer-events:none}
.sky-gradient{
  background:
    radial-gradient(circle at 24% 20%, rgba(255,255,255,.28), transparent 22%),
    linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,0) 36%);
}
.sky-dusk{
  opacity:var(--dusk-opacity);
  background:
    radial-gradient(circle at 50% 35%, rgba(255,222,154,.24), transparent 24%),
    linear-gradient(180deg, rgba(255,149,88,.16), rgba(255,149,88,0) 42%);
  transition:opacity 300ms linear;
}
.sky-night{
  opacity:var(--night-opacity);
  background:
    radial-gradient(circle at 70% 18%, rgba(125,160,255,.16), transparent 22%),
    linear-gradient(180deg, rgba(8,16,54,.28), rgba(7,14,40,.06) 44%);
  transition:opacity 300ms linear;
}

.vignette{
  position:absolute;inset:0;z-index:3;pointer-events:none;
  background:radial-gradient(circle at 50% 48%, transparent 36%, rgba(9,18,46,.22) 100%);
}

.world-wrap{
  position:absolute;inset:0;z-index:2;overflow:hidden;
}
.world{
  position:absolute;
  left:50%;top:54%;
  width:min(120vmin,1180px);height:min(120vmin,1180px);
  transform:translate(-50%,-50%);
  transform-origin:50% 50%;
}

.orbit-ring{
  position:absolute;inset:8%;
  border-radius:50%;
  border:2px solid rgba(255,255,255,.22);
  box-shadow:0 0 0 1px rgba(255,255,255,.04) inset;
  transform:translate(calc(var(--mx) * .06), calc(var(--my) * .06));
}

.plate{
  position:absolute;
  background-size:cover;
  background-position:center center;
  background-repeat:no-repeat;
  overflow:hidden;
  transition:opacity 240ms linear;
}
.day-plate{
  left:3%; top:17%;
  width:50%; height:42%;
  border-radius:58% 42% 44% 56% / 46% 42% 58% 54%;
  background-image:url("./assets/day-plate.png");
  opacity:var(--day-plate-opacity);
  transform:translate(calc(var(--mx) * .45), calc(var(--my) * .35)) rotate(-6deg);
  box-shadow:0 26px 90px rgba(255,212,94,.14);
}
.night-plate{
  right:4%; bottom:16%;
  width:50%; height:42%;
  border-radius:46% 54% 58% 42% / 50% 58% 42% 50%;
  background-image:url("./assets/night-plate.png");
  opacity:var(--night-plate-opacity);
  transform:translate(calc(var(--mx) * -.35), calc(var(--my) * -.30)) rotate(7deg);
  box-shadow:0 26px 96px rgba(75,101,205,.20);
}

.plate-tint{
  position:absolute;inset:0;border-radius:inherit;
  background:var(--day-tint);
  opacity:var(--day-tint-opacity);
  mix-blend-mode:multiply;
}
.night-plate-tint{
  background:var(--night-tint);
  opacity:var(--night-tint-opacity);
}

.sun-system,.moon-system{
  position:absolute;
  left:50%;top:50%;
  width:118px;height:118px;
  margin-left:-59px;margin-top:-59px;
  pointer-events:none;
}
.sun-system{
  transform:translate(calc(var(--sun-x) + var(--mx) * .1), calc(var(--sun-y) + var(--my) * .1));
  opacity:var(--sun-opacity);
}
.moon-system{
  transform:translate(calc(var(--moon-x) + var(--mx) * .08), calc(var(--moon-y) + var(--my) * .08));
  opacity:var(--moon-opacity);
}

.sun-core{
  position:absolute;inset:26%;
  border-radius:50%;
  background:#ffd24c;
  animation:sunBob 3.3s ease-in-out infinite;
}
.sun-ring{
  position:absolute;border-radius:50%;
  border:8px solid rgba(255,210,76,.20);
}
.ring-one{inset:12%;animation:ringOne 3.2s ease-in-out infinite}
.ring-two{inset:0;border-color:rgba(255,210,76,.10);animation:ringTwo 4s ease-in-out infinite}

.moon-core{
  position:absolute;inset:18%;
  border-radius:50%;
  background:#f4f6ff;
  box-shadow:0 0 28px rgba(255,255,255,.16);
}
.moon-cut{
  position:absolute;
  width:48%;height:48%;
  right:18%;top:18%;
  border-radius:50%;
  background:#1d2d60;
}

.star{
  position:absolute;
  width:1%;aspect-ratio:1/1;
  border-radius:50%;
  background:white;
  opacity:var(--star-opacity);
}
.s1{left:18%;top:15%;animation:twinkle 2.1s ease-in-out infinite}
.s2{left:34%;top:22%;transform:scale(.72);animation:twinkle 2.8s ease-in-out .3s infinite}
.s3{right:22%;top:18%;transform:scale(.54);animation:twinkle 2.5s ease-in-out .6s infinite}
.s4{right:30%;top:32%;transform:scale(.82);animation:twinkle 3.2s ease-in-out .2s infinite}
.s5{right:13%;top:24%;transform:scale(.48);animation:twinkle 2.4s ease-in-out .9s infinite}
.s6{left:56%;top:13%;transform:scale(.66);animation:twinkle 3s ease-in-out .4s infinite}
.s7{left:68%;top:29%;transform:scale(.5);animation:twinkle 2.2s ease-in-out .5s infinite}

.drag-layer{
  position:absolute;left:50%;top:54%;
  width:min(92vmin,910px);height:min(92vmin,910px);
  transform:translate(-50%,-50%);
  cursor:grab;
  z-index:4;
}
.drag-layer.dragging{cursor:grabbing}

.character-stage{
  position:absolute;left:50%;top:54%;
  width:min(35vw,380px);height:min(44vw,430px);
  transform:translate(-50%,-50%) translateY(var(--char-lift));
  z-index:5;pointer-events:none;
  transition:transform 320ms ease;
}
.character-stack{
  position:absolute;inset:0;
  transform:translate(calc(var(--mx) * .55), calc(var(--my) * .50)) rotate(var(--char-tilt));
  animation:floatBase 4.2s ease-in-out infinite;
  transition:transform 300ms ease;
}
.character-shadow{
  position:absolute;left:50%;bottom:6%;
  width:48%;height:7%;
  transform:translateX(-50%);
  border-radius:50%;
  background:rgba(20,25,48,.18);
  filter:blur(8px);
  animation:shadowBreath 4s ease-in-out infinite;
}
.panda{
  position:absolute;left:50%;top:50%;
  max-width:100%;max-height:100%;width:auto;height:auto;
  transform:translate(-50%,-50%) scale(.92);
  opacity:0;
  filter:drop-shadow(0 20px 16px rgba(24,27,51,.18));
  transition:opacity 260ms ease, transform 420ms cubic-bezier(.18,.86,.25,1);
}
.panda.is-active{opacity:1;transform:translate(-50%,-50%) scale(1)}
.panda-happy.is-active{animation:happyPose 2.8s ease-in-out infinite}
.panda-moody.is-active{animation:moodyPose 3.8s ease-in-out infinite}
.panda-angry.is-active{animation:angryPose 1.7s ease-in-out infinite}
.panda-sleepy.is-active{animation:sleepyPose 4.8s ease-in-out infinite}

.mood-mark{
  position:absolute;right:4%;top:10%;
  color:white;font-weight:900;opacity:0;
  text-shadow:0 4px 18px rgba(0,0,0,.18);
  transition:opacity 220ms ease;
}
.mood-mark.show{opacity:.9}
.mark-happy{font-size:38px}
.mark-moody{font-size:28px;letter-spacing:7px;top:16%}
.mark-angry{font-size:40px}
.mark-sleepy{font-size:28px;letter-spacing:6px;top:15%}

.flash{
  position:absolute;inset:0;z-index:8;pointer-events:none;opacity:0;
  background:radial-gradient(circle at 50% 46%, rgba(255,255,255,.34), rgba(255,255,255,0) 34%);
}
.flash.burst{animation:flash .72s ease-out}

.ui{
  position:absolute;left:24px;right:24px;bottom:22px;z-index:10;
  display:flex;align-items:flex-end;justify-content:space-between;gap:24px;
  color:white;
}
.headline{display:grid;gap:5px;max-width:480px}
.eyebrow{font-size:9px;font-weight:800;letter-spacing:.18em;color:rgba(255,255,255,.44)}
.headline strong{font-size:12px;letter-spacing:.14em}
.sub{font-size:11px;color:rgba(255,255,255,.74);letter-spacing:.08em;line-height:1.4}
.panel{display:grid;gap:10px;justify-items:end}
.hint{
  font-size:10px;font-weight:800;letter-spacing:.18em;
  color:rgba(255,255,255,.72);
}
.mood-buttons,.button-row{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
.bottom-row{display:flex;gap:18px;align-items:center;justify-content:flex-end;flex-wrap:wrap}
.time-readout{
  font-size:11px;font-weight:800;letter-spacing:.10em;color:rgba(255,255,255,.82);
}
.ui button{
  border:1px solid var(--ui-border);
  background:var(--ui-bg);
  color:#fff;border-radius:999px;
  padding:10px 14px;font-size:10px;font-weight:800;letter-spacing:.10em;
  cursor:pointer;backdrop-filter:blur(8px);
}
.ui button span{margin-right:4px}
.ui button.active{background:rgba(255,255,255,.20);border-color:rgba(255,255,255,.38)}
.ui button:hover{background:rgba(255,255,255,.15)}

/* mood-specific character framing */
.scene[data-mood="happy"]{--char-tilt:0deg;--char-lift:0px}
.scene[data-mood="moody"]{--char-tilt:-1deg;--char-lift:-2px}
.scene[data-mood="angry"]{--char-tilt:1.5deg;--char-lift:1px}
.scene[data-mood="sleepy"]{--char-tilt:-2deg;--char-lift:7px}

@keyframes sunBob{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
@keyframes ringOne{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.12);opacity:.28}}
@keyframes ringTwo{0%,100%{transform:scale(1);opacity:.46}50%{transform:scale(1.18);opacity:.10}}
@keyframes twinkle{0%,100%{opacity:var(--star-opacity)}50%{opacity:calc(var(--star-opacity) * .25)}}
@keyframes floatBase{0%,100%{translate:0 0}50%{translate:0 -8px}}
@keyframes shadowBreath{0%,100%{transform:translateX(-50%) scaleX(1);opacity:.20}50%{transform:translateX(-50%) scaleX(.78);opacity:.10}}
@keyframes happyPose{0%,100%{transform:translate(-50%,-50%) rotate(-2deg) scale(1)}50%{transform:translate(-50%,-53%) rotate(2deg) scale(1.03)}}
@keyframes moodyPose{0%,100%{transform:translate(-50%,-50%) rotate(-1deg) scale(.98)}50%{transform:translate(-50%,-49%) rotate(1deg) scale(1)}}
@keyframes angryPose{0%,100%{transform:translate(-50%,-50%) rotate(-1.5deg) scale(1.01)}40%{transform:translate(-50%,-51%) rotate(1.5deg) scale(1.035)}}
@keyframes sleepyPose{0%,100%{transform:translate(-50%,-50%) rotate(-2deg) scale(.98)}50%{transform:translate(-50%,-48.5%) rotate(1deg) scale(1)}}
@keyframes flash{0%{opacity:0}28%{opacity:.28}100%{opacity:0}}

@media(max-width:900px){
  .world{width:min(132vmin,1180px);height:min(132vmin,1180px)}
  .drag-layer{width:min(106vmin,930px);height:min(106vmin,930px)}
  .character-stage{width:min(52vw,390px);height:min(60vw,430px)}
  .ui{flex-direction:column;align-items:flex-start}
  .panel{justify-items:start}
  .mood-buttons,.button-row,.bottom-row{justify-content:flex-start}
}
@media(max-width:620px){
  .ui{left:14px;right:14px;bottom:14px}
  .ui button{padding:8px 10px;font-size:9px}
  .sub{display:none}
  .hint,.time-readout{font-size:10px}
}
(() => {
  const scene = document.getElementById("scene");
  const dragLayer = document.getElementById("dragLayer");
  const moodButtons = document.getElementById("moodButtons");
  const autoBtn = document.getElementById("autoBtn");
  const resetBtn = document.getElementById("resetBtn");
  const flash = document.getElementById("flash");
  const stateCopy = document.getElementById("stateCopy");
  const timeReadout = document.getElementById("timeReadout");

  const moods = ["happy", "moody", "angry", "sleepy"];
  const moodLabels = {
    happy: "HAPPY",
    moody: "MOODY",
    angry: "ANGRY",
    sleepy: "SLEEPY"
  };
  const moodTints = {
    happy: { day: [255, 255, 255], dayAlpha: 0, night: [255, 255, 255], nightAlpha: 0 },
    moody: { day: [149, 164, 216], dayAlpha: 0.20, night: [120, 132, 214], nightAlpha: 0.14 },
    angry: { day: [255, 111, 57], dayAlpha: 0.18, night: [255, 94, 70], nightAlpha: 0.10 },
    sleepy: { day: [120, 148, 220], dayAlpha: 0.14, night: [74, 104, 190], nightAlpha: 0.20 }
  };

  const state = {
    mood: "happy",
    time: 0,      // 0 = noon, .25 = sunset, .5 = midnight, .75 = sunrise
    auto: true,
    dragging: false,
    timer: null
  };

  function clamp(value, min, max) {
    return Math.min(max, Math.max(min, value));
  }

  function mix(a, b, t) {
    return Math.round(a + (b - a) * t);
  }

  function mixColor(c1, c2, t) {
    return `rgb(${mix(c1[0], c2[0], t)}, ${mix(c1[1], c2[1], t)}, ${mix(c1[2], c2[2], t)})`;
  }

  function polarTimeToAngle(t) {
    return t * Math.PI * 2 - Math.PI / 2;
  }

  function timeFromPointer(clientX, clientY) {
    const rect = dragLayer.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    const angle = Math.atan2(clientY - cy, clientX - cx);
    let t = (angle + Math.PI / 2) / (Math.PI * 2);
    if (t < 0) t += 1;
    return t;
  }

  function burst() {
    flash.classList.remove("burst");
    void flash.offsetWidth;
    flash.classList.add("burst");
  }

  function setMood(mood, shouldBurst = true) {
    state.mood = mood;
    scene.dataset.mood = mood;

    document.querySelectorAll(".panda").forEach((el) => {
      el.classList.toggle("is-active", el.classList.contains(`panda-${mood}`));
    });

    document.querySelectorAll(".mood-mark").forEach((el) => {
      const expected = `mark-${mood}`;
      el.classList.toggle("show", el.classList.contains(expected));
    });

    moodButtons.querySelectorAll("button").forEach((btn) => {
      btn.classList.toggle("active", btn.dataset.mood === mood);
    });

    if (shouldBurst) burst();
    render();
  }

  function describeTime(t) {
    const hours = (12 + t * 24) % 24;
    const hh = String(Math.floor(hours)).padStart(2, "0");
    const mm = String(Math.floor((hours % 1) * 60)).padStart(2, "0");

    let label = "DAY";
    if (t > 0.18 && t < 0.32) label = "SUNSET";
    else if (t >= 0.32 && t < 0.68) label = "NIGHT";
    else if (t >= 0.68 && t < 0.82) label = "SUNRISE";

    return { clock: `${hh}:${mm}`, label };
  }

  function render() {
    const angle = polarTimeToAngle(state.time);
    const r = Math.min(dragLayer.clientWidth, dragLayer.clientHeight) * 0.355;
    const sunX = Math.cos(angle) * r;
    const sunY = Math.sin(angle) * r;
    const moonX = -sunX;
    const moonY = -sunY;

    // daylight = 1 at noon, 0 at midnight
    const day = clamp((-sunY / r + 0.15) / 1.15, 0, 1);
    const night = 1 - day;
    const horizon = clamp(1 - Math.abs(sunY) / (r * 0.58), 0, 1);

    // sky colors
    const dayTop = [151, 220, 255];
    const dayBottom = [232, 251, 255];
    const duskTop = [255, 159, 108];
    const duskBottom = [255, 214, 164];
    const nightTop = [17, 39, 92];
    const nightBottom = [8, 19, 46];

    const upperBlend = (day > 0.25) ? mixColor(duskTop, dayTop, clamp((day - 0.25) / 0.75, 0, 1))
                                     : mixColor(nightTop, duskTop, clamp(day / 0.25, 0, 1));
    const lowerBlend = (day > 0.25) ? mixColor(duskBottom, dayBottom, clamp((day - 0.25) / 0.75, 0, 1))
                                     : mixColor(nightBottom, duskBottom, clamp(day / 0.25, 0, 1));

    scene.style.setProperty("--scene-top", upperBlend);
    scene.style.setProperty("--scene-bottom", lowerBlend);
    scene.style.setProperty("--dusk-opacity", horizon.toFixed(3));
    scene.style.setProperty("--night-opacity", (night * 0.92).toFixed(3));

    scene.style.setProperty("--day-plate-opacity", (0.22 + day * 0.90).toFixed(3));
    scene.style.setProperty("--night-plate-opacity", (0.18 + night * 0.95).toFixed(3));

    scene.style.setProperty("--sun-x", `${sunX.toFixed(1)}px`);
    scene.style.setProperty("--sun-y", `${sunY.toFixed(1)}px`);
    scene.style.setProperty("--moon-x", `${moonX.toFixed(1)}px`);
    scene.style.setProperty("--moon-y", `${moonY.toFixed(1)}px`);

    scene.style.setProperty("--sun-opacity", (0.20 + day * 0.95).toFixed(3));
    scene.style.setProperty("--moon-opacity", (0.15 + night * 0.95).toFixed(3));
    scene.style.setProperty("--star-opacity", (night * 0.95).toFixed(3));

    const tint = moodTints[state.mood];
    scene.style.setProperty("--day-tint", `rgb(${tint.day[0]}, ${tint.day[1]}, ${tint.day[2]})`);
    scene.style.setProperty("--day-tint-opacity", (tint.dayAlpha * (0.4 + horizon * 0.6)).toFixed(3));
    scene.style.setProperty("--night-tint", `rgb(${tint.night[0]}, ${tint.night[1]}, ${tint.night[2]})`);
    scene.style.setProperty("--night-tint-opacity", (tint.nightAlpha * (0.45 + night * 0.55)).toFixed(3));

    const { clock, label } = describeTime(state.time);
    timeReadout.textContent = `${clock} / ${label}`;
    stateCopy.textContent = `${label} / ${moodLabels[state.mood]} — DRAG THE SUN OR MOON`;

    const uiBgAlpha = state.mood === "sleepy" ? 0.30 : 0.25;
    scene.style.setProperty("--ui-bg", `rgba(8,16,42,${uiBgAlpha})`);
  }

  function restartAuto() {
    if (state.timer) clearInterval(state.timer);
    state.timer = null;
    if (state.auto) {
      state.timer = setInterval(() => {
        state.time = (state.time + 0.0028) % 1;
        render();
      }, 16);
    }
    autoBtn.textContent = state.auto ? "AUTO ON" : "AUTO OFF";
  }

  function pointerMove(event) {
    const rect = scene.getBoundingClientRect();
    const px = (event.clientX - rect.width / 2) / rect.width;
    const py = (event.clientY - rect.height / 2) / rect.height;
    scene.style.setProperty("--mx", `${(px * 30).toFixed(2)}px`);
    scene.style.setProperty("--my", `${(py * 22).toFixed(2)}px`);

    if (state.dragging) {
      state.time = timeFromPointer(event.clientX, event.clientY);
      render();
    }
  }

  dragLayer.addEventListener("pointerdown", (event) => {
    state.dragging = true;
    dragLayer.classList.add("dragging");
    state.auto = false;
    restartAuto();
    state.time = timeFromPointer(event.clientX, event.clientY);
    render();
    dragLayer.setPointerCapture(event.pointerId);
  });

  dragLayer.addEventListener("pointermove", pointerMove);
  scene.addEventListener("pointermove", pointerMove);

  function stopDragging() {
    state.dragging = false;
    dragLayer.classList.remove("dragging");
  }

  dragLayer.addEventListener("pointerup", stopDragging);
  dragLayer.addEventListener("pointercancel", stopDragging);
  dragLayer.addEventListener("pointerleave", stopDragging);

  moodButtons.addEventListener("click", (event) => {
    const btn = event.target.closest("button[data-mood]");
    if (!btn) return;
    setMood(btn.dataset.mood);
  });

  autoBtn.addEventListener("click", () => {
    state.auto = !state.auto;
    restartAuto();
  });

  resetBtn.addEventListener("click", () => {
    state.time = 0;
    render();
    burst();
  });

  setMood("happy", false);
  render();
  restartAuto();
})();

USE CASE

時間と感情が連動する世界を、Webサイトへどう活用できるか。

旅行・宿泊施設のHero、昼夜を学ぶ教育コンテンツ、ゲームやブランドの世界観演出、キャンペーンページ、インタラクティブなポートフォリオなど、ユーザー操作によって画面全体の状態を変化させたい場面に利用できます。

Travel / Resort Hero

Travel / Resort Hero

ホテル、リゾート、観光地などで、昼と夜の景色をユーザーが直接切り替えながら体験できるHeroや施設紹介に向いています。

Education / Interactive Content

Education / Interactive Content

昼夜の移り変わり、太陽と月の位置関係、時間の循環などを、ドラッグ操作を使って体験的に見せる教育コンテンツへ応用できます。

Creative Brand / Campaign

Creative Brand / Campaign

ブランドサイトやキャンペーンページで、ユーザー操作に合わせて世界観、色、キャラクターのMoodを変化させ、印象に残るインタラクティブなキービジュアルとして利用できます。

TECHNICAL NOTES

実装前に確認しておきたいこと。

背景には比較的大きな画像アセットを使用しています。実サイトではWebPまたはAVIFへの変換、表示サイズに合わせたリサイズ、遅延読込を検討してください。Pointer Eventsでマウスとタッチの両方を扱えますが、モバイルではドラッグ領域とページスクロールの競合を実機で確認してください。

Browser

Pointer Events、CSS Custom Properties、CSS transition、transform、filter、mix-blend-mode、Math.atan2()を使用します。現行の主要ブラウザを対象とし、公開前にドラッグ操作、タッチ操作、画像描画を対象端末で確認してください。

Performance

CanvasやWebGLを使わず、DOM/CSSと画像レイヤーで構成しています。主な負荷は背景画像と複数レイヤーのtransform・opacityです。実サイトでは背景画像をWebP/AVIF化し、必要に応じて画像解像度と同時レイヤー数を調整してください。

Mobile

Pointer Eventsのためタッチ操作にも対応できます。モバイルではドラッグ領域が大きいため、ページ内へ組み込む場合はtouch-actionやスクロールとの競合を確認し、必要に応じて操作エリアを限定してください。

Reduced Motion

実サイトではprefers-reduced-motionに応じてAUTO進行、キャラクターの浮遊、太陽リング、星の点滅、マウスパララックスを停止または弱める対応を推奨します。ドラッグによる時間操作そのものは静的な状態切替として残すことができます。