風呂キャンメーター
設定難易度

最後のお風呂から汚れLv.が育っていくウィジェット

お風呂に入らなかった時間を、「汚れLv.」としてゲーム感覚で表示するScriptableウィジェットです。

最後にお風呂に入ってからの時間に応じて、汚れLv.やEXP、清潔度などが少しずつ変化します。

「今日はもうお風呂いいかな……」を繰り返していると、いつの間にかとんでもないLv.になっているかも。

ウィジェットの表示内容

最後にリセットした時刻から経過時間を計算します。

サイズごとの表示

  • 小:現在のレベルやEXPを表示します。
  • 中:経過時間と次のレベルまでの目安も確認できます。
  • 大:清潔度・称号・汚染エリアや自己記録など、遊びの情報を詳しく表示します。

設定手順

上から順に進めてください。コードの保存と設定のコピーに必要な操作欄は、それぞれの手順の中にあります。

  1. Scriptableを用意する

    App StoreでScriptableを開き、iPhoneにインストールします。すでに入っている場合は次へ進んでください。

  2. コードをコピーして保存する

    下のコード欄の「コピー」を押します。Scriptableへ移動し、「+」から新しいスクリプトを作ってコード全文を貼り付け、風呂キャンメーターなどの名前で保存してください。

    保存したらこのページに戻り、次の手順へ進みます。

    // Variables used by Scriptable.
    // These must be at the very top of the file. Do not edit.
    // icon-color: blue; icon-glyph: bath;
    // Version: 1.0.0
    
    // =====================================================
    // 風呂キャンメーター - Scriptable Widget V5
    // =====================================================
    //
    // 最後にお風呂に入ってからの時間を計測して
    // 「汚れLv」を表示するジョーク系ウィジェット。
    //
    // ・小 / 中 / 大サイズ対応
    // ・汚れLv.0〜MAX
    // ・EXP 0〜99 / 100
    // ・EXPバー=現在Lv内の進行度
    // ・6〜12時間ごとに状態・例えが変化
    // ・清潔度
    // ・人類度
    // ・称号
    // ・推定・汚染エリア
    // ・次のLvまでの時間
    // ・1時間ごとに変わる一言
    // ・風呂キャン自己ベスト
    // ・ウィジェットタップ → 入浴記録をリセット
    //
    // APIキー不要
    // サーバー不要
    // Keychainへ端末内保存
    //
    // ※汚れLv・EXP・清潔度・人類度・汚染エリア・例えなどは
    //   科学的な測定値ではなくジョーク表現です。
    // =====================================================
    
    
    // =====================================================
    // 設定
    // =====================================================
    
    const CONFIG = {
    
      KEY_LAST_BATH:
        "geboku_bath_cancel_last_bath",
    
      KEY_BEST:
        "geboku_bath_cancel_best",
    
      BEST_MIN_HOURS: 24,
    
      LEVELS: [
    
        {
          hours: 12,
          level: "0",
          emoji: "✨",
          title: "清潔人間"
        },
    
        {
          hours: 24,
          level: "1",
          emoji: "🙂",
          title: "風呂キャン見習い"
        },
    
        {
          hours: 36,
          level: "2",
          emoji: "😐",
          title: "風呂キャン初心者"
        },
    
        {
          hours: 48,
          level: "3",
          emoji: "😨",
          title: "風呂キャン中級者"
        },
    
        {
          hours: 72,
          level: "4",
          emoji: "🤢",
          title: "風呂キャン上級者"
        },
    
        {
          hours: 96,
          level: "5",
          emoji: "☠️",
          title: "風呂キャンマスター"
        },
    
        {
          hours: Infinity,
          level: "MAX",
          emoji: "☣️",
          title: "風呂という概念を失った者"
        }
      ],
    
    
      // -------------------------------------------------
      // 6〜12時間ごとの状態変化
      // -------------------------------------------------
    
      STAGES: [
    
        {
          hours: 6,
          text: "✨ 風呂上がり"
        },
    
        {
          hours: 12,
          text: "😊 まだ清潔"
        },
    
        {
          hours: 18,
          text: "🙂 生活感が出てきた"
        },
    
        {
          hours: 24,
          text: "😐 そろそろ風呂の時間"
        },
    
        {
          hours: 30,
          text: "😶 1日突破"
        },
    
        {
          hours: 36,
          text: "😬 頭皮が気になり始める"
        },
    
        {
          hours: 42,
          text: "😨 枕カバー警戒区域"
        },
    
        {
          hours: 48,
          text: "🫠 人との距離を取りたい"
        },
    
        {
          hours: 60,
          text: "🤢 2日目突入"
        },
    
        {
          hours: 72,
          text: "🧦 履き込んだ靴下級"
        },
    
        {
          hours: 84,
          text: "☠️ 公共交通機関は慎重に"
        },
    
        {
          hours: 96,
          text: "☣️ 公衆トイレの床級"
        },
    
        {
          hours: Infinity,
          text: "🧟 文明から離脱"
        }
      ]
    };
    
    
    // =====================================================
    // カラー
    // =====================================================
    
    const COLORS = {
    
      bg:
        new Color("#111318"),
    
      white:
        new Color("#FFFFFF"),
    
      sub:
        new Color("#AEB4BF"),
    
      muted:
        new Color("#727985"),
    
      green:
        new Color("#4CD964"),
    
      yellow:
        new Color("#FFD60A"),
    
      orange:
        new Color("#FF9F0A"),
    
      red:
        new Color("#FF453A"),
    
      purple:
        new Color("#BF5AF2"),
    
      track:
        new Color("#30343C")
    };
    
    
    // =====================================================
    // 初期化
    // =====================================================
    
    const now =
      new Date();
    
    let lastBath =
      loadLastBath();
    
    let elapsedMs =
      now.getTime() -
      lastBath.getTime();
    
    if (elapsedMs < 0) {
      elapsedMs = 0;
    }
    
    const elapsedHours =
      elapsedMs / 3600000;
    
    
    // =====================================================
    // アプリ起動
    // =====================================================
    
    if (!config.runsInWidget) {
    
      await showMenu();
    
      Script.complete();
    
      return;
    }
    
    
    // =====================================================
    // 自己ベスト更新
    // =====================================================
    
    updateBest(elapsedMs);
    
    
    // =====================================================
    // ウィジェット
    // =====================================================
    
    const widget =
      await createWidget();
    
    Script.setWidget(widget);
    
    Script.complete();
    
    
    // =====================================================
    // 最終入浴日時
    // =====================================================
    
    function loadLastBath() {
    
      if (
        Keychain.contains(
          CONFIG.KEY_LAST_BATH
        )
      ) {
    
        const value =
          Number(
            Keychain.get(
              CONFIG.KEY_LAST_BATH
            )
          );
    
        if (
          Number.isFinite(value) &&
          value > 0
        ) {
    
          return new Date(value);
        }
      }
    
    
      Keychain.set(
        CONFIG.KEY_LAST_BATH,
        String(now.getTime())
      );
    
      return now;
    }
    
    
    // =====================================================
    // 自己ベスト
    // =====================================================
    
    function loadBest() {
    
      if (
        !Keychain.contains(
          CONFIG.KEY_BEST
        )
      ) {
        return 0;
      }
    
    
      const value =
        Number(
          Keychain.get(
            CONFIG.KEY_BEST
          )
        );
    
    
      if (!Number.isFinite(value)) {
        return 0;
      }
    
    
      if (
        value <
        CONFIG.BEST_MIN_HOURS *
        3600000
      ) {
    
        Keychain.remove(
          CONFIG.KEY_BEST
        );
    
        return 0;
      }
    
    
      return value;
    }
    
    
    function updateBest(ms) {
    
      const minMs =
        CONFIG.BEST_MIN_HOURS *
        3600000;
    
    
      if (ms < minMs) {
        return;
      }
    
    
      const best =
        loadBest();
    
    
      if (ms > best) {
    
        Keychain.set(
          CONFIG.KEY_BEST,
          String(
            Math.floor(ms)
          )
        );
      }
    }
    
    
    // =====================================================
    // メニュー
    // =====================================================
    
    async function showMenu() {
    
      const alert =
        new Alert();
    
    
      alert.title =
        "🛁 風呂キャンメーター";
    
    
      alert.message =
        "現在の風呂キャン歴\n" +
        formatDuration(elapsedMs);
    
    
      alert.addAction(
        "🛁 風呂入った!"
      );
    
    
      alert.addAction(
        "現在の記録を見る"
      );
    
    
      alert.addCancelAction(
        "キャンセル"
      );
    
    
      const result =
        await alert.presentSheet();
    
    
      // -------------------------------------------------
      // 入浴
      // -------------------------------------------------
    
      if (result === 0) {
    
        updateBest(elapsedMs);
    
    
        Keychain.set(
          CONFIG.KEY_LAST_BATH,
          String(Date.now())
        );
    
    
        const done =
          new Alert();
    
    
        done.title =
          "✨ おかえり人類";
    
    
        done.message =
          "風呂キャンメーターを\nLv.0 / EXP 0 にリセットしました。";
    
    
        done.addAction("OK");
    
    
        await done.presentAlert();
      }
    
    
      // -------------------------------------------------
      // 記録
      // -------------------------------------------------
    
      if (result === 1) {
    
        const best =
          loadBest();
    
    
        const info =
          new Alert();
    
    
        info.title =
          "🏆 風呂キャン記録";
    
    
        const bestText =
          best > 0
            ? formatDuration(best)
            : "まだ記録なし";
    
    
        info.message =
          "現在\n" +
          formatDuration(elapsedMs) +
          "\n\n自己ベスト\n" +
          bestText;
    
    
        info.addAction("OK");
    
    
        await info.presentAlert();
      }
    }
    
    
    // =====================================================
    // ウィジェット作成
    // =====================================================
    
    async function createWidget() {
    
      const widget =
        new ListWidget();
    
    
      widget.backgroundColor =
        COLORS.bg;
    
    
      widget.url =
        URLScheme.forRunningScript();
    
    
      // iOS側へ更新希望
      widget.refreshAfterDate =
        new Date(
          Date.now() +
          15 * 60 * 1000
        );
    
    
      const family =
        config.widgetFamily ||
        "small";
    
    
      if (family === "small") {
    
        buildSmall(widget);
    
      } else if (
        family === "medium"
      ) {
    
        buildMedium(widget);
    
      } else {
    
        buildLarge(widget);
      }
    
    
      return widget;
    }
    
    
    // =====================================================
    // 小サイズ
    // =====================================================
    
    function buildSmall(widget) {
    
      widget.setPadding(
        14,
        14,
        13,
        14
      );
    
    
      const data =
        getLevelData();
    
    
      // TITLE
    
      const title =
        widget.addText(
          "🛁 風呂キャンメーター"
        );
    
      title.font =
        Font.boldSystemFont(12);
    
      title.textColor =
        COLORS.white;
    
    
      widget.addSpacer(9);
    
    
      // LEVEL
    
      const level =
        widget.addText(
          `汚れ Lv.${data.level} ${data.emoji}`
        );
    
      level.font =
        Font.boldSystemFont(20);
    
      level.textColor =
        getLevelColor(data);
    
    
      widget.addSpacer(6);
    
    
      // EXP BAR
    
      addProgressBar(
        widget,
        getCurrentLevelProgress(),
        getLevelColor(data),
        130
      );
    
    
      widget.addSpacer(4);
    
    
      // EXP
    
      const exp =
        widget.addText(
          getExpText()
        );
    
      exp.font =
        Font.boldSystemFont(10);
    
      exp.textColor =
        COLORS.sub;
    
    
      widget.addSpacer(7);
    
    
      // TIME
    
      const time =
        widget.addText(
          formatDurationShort(
            elapsedMs
          )
        );
    
      time.font =
        Font.boldSystemFont(16);
    
      time.textColor =
        COLORS.white;
    
    
      widget.addSpacer(4);
    
    
      // STAGE
    
      const stage =
        widget.addText(
          getStageText()
        );
    
      stage.font =
        Font.systemFont(10);
    
      stage.textColor =
        COLORS.sub;
    
      stage.lineLimit = 1;
    }
    
    
    // =====================================================
    // 中サイズ
    // =====================================================
    
    function buildMedium(widget) {
    
      widget.setPadding(
        14,
        16,
        13,
        16
      );
    
    
      const data =
        getLevelData();
    
    
      // TITLE
    
      const title =
        widget.addText(
          "🛁 風呂キャンメーター"
        );
    
      title.font =
        Font.boldSystemFont(12);
    
      title.textColor =
        COLORS.white;
    
    
      widget.addSpacer(8);
    
    
      // =================================================
      // MAIN
      // =================================================
    
      const row =
        widget.addStack();
    
      row.layoutHorizontally();
    
    
      // =================================================
      // LEFT
      // =================================================
    
      const left =
        row.addStack();
    
      left.layoutVertically();
    
    
      // LEVEL
    
      const level =
        left.addText(
          `汚れ Lv.${data.level} ${data.emoji}`
        );
    
      level.font =
        Font.boldSystemFont(20);
    
      level.textColor =
        getLevelColor(data);
    
    
      left.addSpacer(6);
    
    
      // EXP BAR
    
      addProgressBar(
        left,
        getCurrentLevelProgress(),
        getLevelColor(data),
        120
      );
    
    
      left.addSpacer(9);
    
    
      // CLEAN
    
      const clean =
        left.addText(
          `清潔度 ${getCleanliness()}%`
        );
    
      clean.font =
        Font.boldSystemFont(11);
    
      clean.textColor =
        COLORS.white;
    
    
      left.addSpacer(4);
    
    
      // STAGE
    
      const stage =
        left.addText(
          getStageText()
        );
    
      stage.font =
        Font.systemFont(11);
    
      stage.textColor =
        COLORS.sub;
    
      stage.lineLimit = 1;
    
    
      // =================================================
      // SPACE
      // =================================================
    
      row.addSpacer();
    
    
      // =================================================
      // RIGHT
      // =================================================
    
      const right =
        row.addStack();
    
      right.layoutVertically();
    
    
      // -------------------------------------------------
      // 風呂キャン歴
      // -------------------------------------------------
    
      const historyLabel =
        right.addText(
          "風呂キャン歴"
        );
    
      historyLabel.font =
        Font.systemFont(9);
    
      historyLabel.textColor =
        COLORS.sub;
    
    
      right.addSpacer(1);
    
    
      const history =
        right.addText(
          formatDurationShort(
            elapsedMs
          )
        );
    
      history.font =
        Font.boldSystemFont(13);
    
      history.textColor =
        COLORS.white;
    
    
      right.addSpacer(6);
    
    
      // -------------------------------------------------
      // EXP
      // -------------------------------------------------
    
      const expLabel =
        right.addText(
          "EXP"
        );
    
      expLabel.font =
        Font.systemFont(9);
    
      expLabel.textColor =
        COLORS.sub;
    
    
      right.addSpacer(1);
    
    
      const exp =
        right.addText(
          getExpValueText()
        );
    
      exp.font =
        Font.boldSystemFont(13);
    
      exp.textColor =
        getLevelColor(data);
    
    
      right.addSpacer(6);
    
    
      // -------------------------------------------------
      // 次のLvまで
      // -------------------------------------------------
    
      const nextLabel =
        right.addText(
          "次のLvまで"
        );
    
      nextLabel.font =
        Font.systemFont(9);
    
      nextLabel.textColor =
        COLORS.sub;
    
    
      right.addSpacer(1);
    
    
      const next =
        right.addText(
          getNextLevelTime()
        );
    
      next.font =
        Font.boldSystemFont(12);
    
      next.textColor =
        COLORS.white;
    }
    
    
    // =====================================================
    // 大サイズ
    // =====================================================
    
    function buildLarge(widget) {
    
      widget.setPadding(
        16,
        17,
        15,
        17
      );
    
    
      const data =
        getLevelData();
    
    
      // TITLE
    
      const title =
        widget.addText(
          "🛁 風呂キャンメーター"
        );
    
      title.font =
        Font.boldSystemFont(13);
    
      title.textColor =
        COLORS.white;
    
    
      widget.addSpacer(9);
    
    
      // =================================================
      // LEVEL
      // =================================================
    
      const level =
        widget.addText(
          `汚れ Lv.${data.level} ${data.emoji}`
        );
    
      level.font =
        Font.boldSystemFont(25);
    
      level.textColor =
        getLevelColor(data);
    
    
      widget.addSpacer(6);
    
    
      // =================================================
      // EXP BAR
      // =================================================
    
      addProgressBar(
        widget,
        getCurrentLevelProgress(),
        getLevelColor(data),
        210
      );
    
    
      widget.addSpacer(4);
    
    
      // =================================================
      // EXP + NEXT LEVEL
      // =================================================
    
      const expRow =
        widget.addStack();
    
      expRow.layoutHorizontally();
    
      expRow.centerAlignContent();
    
    
      const exp =
        expRow.addText(
          getExpText()
        );
    
      exp.font =
        Font.boldSystemFont(10);
    
      exp.textColor =
        getLevelColor(data);
    
    
      expRow.addSpacer();
    
    
      const next =
        expRow.addText(
          `次のLvまで ${getNextLevelTime()}`
        );
    
      next.font =
        Font.systemFont(10);
    
      next.textColor =
        COLORS.sub;
    
    
      widget.addSpacer(9);
    
    
      // =================================================
      // SUMMARY
      // =================================================
    
      const summary =
        widget.addStack();
    
      summary.layoutHorizontally();
    
    
      addValueBlock(
        summary,
        "風呂キャン歴",
        formatDurationShort(
          elapsedMs
        )
      );
    
    
      summary.addSpacer();
    
    
      addValueBlock(
        summary,
        "清潔度",
        `${getCleanliness()}%`
      );
    
    
      widget.addSpacer(8);
    
    
      // =================================================
      // 人類度
      // =================================================
    
      const humanRow =
        widget.addStack();
    
      humanRow.layoutHorizontally();
    
      humanRow.centerAlignContent();
    
    
      const humanLabel =
        humanRow.addText(
          `${getHumanEmoji()} 人類度`
        );
    
      humanLabel.font =
        Font.boldSystemFont(11);
    
      humanLabel.textColor =
        COLORS.sub;
    
    
      humanRow.addSpacer(8);
    
    
      addProgressBar(
        humanRow,
        getHumanity() / 100,
        getHumanColor(),
        115
      );
    
    
      humanRow.addSpacer(7);
    
    
      const humanValue =
        humanRow.addText(
          `${getHumanity()}%`
        );
    
      humanValue.font =
        Font.boldSystemFont(11);
    
      humanValue.textColor =
        getHumanColor();
    
    
      widget.addSpacer(8);
    
    
      // =================================================
      // 現在の状態
      // =================================================
    
      const stateLabel =
        widget.addText(
          "現在の状態"
        );
    
      stateLabel.font =
        Font.systemFont(10);
    
      stateLabel.textColor =
        COLORS.sub;
    
    
      widget.addSpacer(2);
    
    
      const stateValue =
        widget.addText(
          getStageText()
        );
    
      stateValue.font =
        Font.boldSystemFont(14);
    
      stateValue.textColor =
        COLORS.white;
    
    
      widget.addSpacer(6);
    
    
      // =================================================
      // 称号
      // =================================================
    
      const titleRow =
        widget.addStack();
    
      titleRow.layoutHorizontally();
    
      titleRow.centerAlignContent();
    
    
      const medal =
        titleRow.addText(
          "🏅 "
        );
    
      medal.font =
        Font.systemFont(11);
    
    
      const titleValue =
        titleRow.addText(
          data.title
        );
    
      titleValue.font =
        Font.boldSystemFont(11);
    
      titleValue.textColor =
        COLORS.sub;
    
    
      widget.addSpacer(7);
    
    
      // =================================================
      // 汚染エリア
      // =================================================
    
      const pollutionTitle =
        widget.addText(
          "推定・汚染エリア"
        );
    
      pollutionTitle.font =
        Font.boldSystemFont(11);
    
      pollutionTitle.textColor =
        COLORS.sub;
    
    
      widget.addSpacer(5);
    
    
      const pollution =
        getPollutionValues();
    
    
      addPollutionRow(
        widget,
        "頭髪",
        pollution.hair
      );
    
    
      addPollutionRow(
        widget,
        "顔",
        pollution.face
      );
    
    
      addPollutionRow(
        widget,
        "上半身",
        pollution.body
      );
    
    
      addPollutionRow(
        widget,
        "足",
        pollution.feet
      );
    
    
      widget.addSpacer(7);
    
    
      // =================================================
      // COMMENT
      // =================================================
    
      const comment =
        widget.addText(
          `💬「${getComment()}」`
        );
    
      comment.font =
        Font.systemFont(11);
    
      comment.textColor =
        COLORS.sub;
    
      comment.lineLimit = 1;
    
    
      widget.addSpacer();
    
    
      // =================================================
      // BEST
      // =================================================
    
      const best =
        loadBest();
    
    
      const bestValue =
        best > 0
          ? formatDurationShort(best)
          : "まだ記録なし";
    
    
      const bestText =
        widget.addText(
          `🏆 自己ベスト  ${bestValue}`
        );
    
      bestText.font =
        Font.systemFont(10);
    
      bestText.textColor =
        COLORS.muted;
    }
    
    
    // =====================================================
    // VALUE BLOCK
    // =====================================================
    
    function addValueBlock(
      parent,
      label,
      value
    ) {
    
      const stack =
        parent.addStack();
    
      stack.layoutVertically();
    
    
      const l =
        stack.addText(
          label
        );
    
      l.font =
        Font.systemFont(10);
    
      l.textColor =
        COLORS.sub;
    
    
      stack.addSpacer(2);
    
    
      const v =
        stack.addText(
          value
        );
    
      v.font =
        Font.boldSystemFont(17);
    
      v.textColor =
        COLORS.white;
    }
    
    
    // =====================================================
    // 現在Lv
    // =====================================================
    
    function getLevelData() {
    
      for (
        const level of
        CONFIG.LEVELS
      ) {
    
        if (
          elapsedHours <
          level.hours
        ) {
    
          return level;
        }
      }
    
    
      return CONFIG.LEVELS[
        CONFIG.LEVELS.length - 1
      ];
    }
    
    
    // =====================================================
    // 現在Lv内の進行率
    // =====================================================
    
    function getCurrentLevelProgress() {
    
      const levels =
        CONFIG.LEVELS;
    
    
      for (
        let i = 0;
        i < levels.length;
        i++
      ) {
    
        const current =
          levels[i];
    
    
        if (
          elapsedHours <
          current.hours
        ) {
    
          // MAX到達後
          if (
            !Number.isFinite(
              current.hours
            )
          ) {
    
            return 1;
          }
    
    
          const start =
            i === 0
              ? 0
              : levels[
                  i - 1
                ].hours;
    
    
          const end =
            current.hours;
    
    
          return clamp(
            (
              elapsedHours -
              start
            ) /
            (
              end -
              start
            ),
            0,
            1
          );
        }
      }
    
    
      return 1;
    }
    
    
    // =====================================================
    // EXP
    // =====================================================
    
    function getExp() {
    
      const data =
        getLevelData();
    
    
      // MAX
      if (
        data.level === "MAX"
      ) {
        return 100;
      }
    
    
      const progress =
        getCurrentLevelProgress();
    
    
      return Math.min(
        99,
        Math.floor(
          progress * 100
        )
      );
    }
    
    
    function getExpText() {
    
      if (
        getLevelData().level ===
        "MAX"
      ) {
    
        return "EXP MAX";
      }
    
    
      return (
        `EXP ${getExp()} / 100`
      );
    }
    
    
    function getExpValueText() {
    
      if (
        getLevelData().level ===
        "MAX"
      ) {
    
        return "MAX";
      }
    
    
      return (
        `${getExp()} / 100`
      );
    }
    
    
    // =====================================================
    // 状態・例え
    // =====================================================
    
    function getStageText() {
    
      for (
        const stage of
        CONFIG.STAGES
      ) {
    
        if (
          elapsedHours <
          stage.hours
        ) {
    
          return stage.text;
        }
      }
    
    
      return "🧟 文明から離脱";
    }
    
    
    function getStageIndex() {
    
      for (
        let i = 0;
        i < CONFIG.STAGES.length;
        i++
      ) {
    
        if (
          elapsedHours <
          CONFIG.STAGES[i].hours
        ) {
    
          return i;
        }
      }
    
    
      return (
        CONFIG.STAGES.length -
        1
      );
    }
    
    
    // =====================================================
    // 清潔度
    // =====================================================
    
    function getCleanliness() {
    
      const value =
        100 -
        (
          elapsedHours /
          96
        ) *
        100;
    
    
      return clamp(
        Math.round(value),
        0,
        100
      );
    }
    
    
    // =====================================================
    // 人類度
    // =====================================================
    
    function getHumanity() {
    
      let value;
    
    
      if (
        elapsedHours <= 12
      ) {
    
        value = 100;
    
      } else {
    
        value =
          100 -
          (
            (
              elapsedHours -
              12
            ) /
            108
          ) *
          100;
      }
    
    
      return clamp(
        Math.round(value),
        0,
        100
      );
    }
    
    
    function getHumanEmoji() {
    
      const value =
        getHumanity();
    
    
      if (value >= 80) {
        return "👤";
      }
    
      if (value >= 60) {
        return "😐";
      }
    
      if (value >= 40) {
        return "😵";
      }
    
      if (value >= 20) {
        return "🧌";
      }
    
      return "☣️";
    }
    
    
    function getHumanColor() {
    
      const value =
        getHumanity();
    
    
      if (value >= 80) {
        return COLORS.green;
      }
    
      if (value >= 60) {
        return COLORS.yellow;
      }
    
      if (value >= 40) {
        return COLORS.orange;
      }
    
      return COLORS.red;
    }
    
    
    // =====================================================
    // 汚染エリア
    // =====================================================
    
    function getPollutionValues() {
    
      const h =
        elapsedHours;
    
    
      return {
    
        hair:
          clamp(
            Math.round(
              h * 1.65
            ),
            2,
            100
          ),
    
        face:
          clamp(
            Math.round(
              h * 1.25
            ),
            2,
            100
          ),
    
        body:
          clamp(
            Math.round(
              h * 1.45
            ),
            2,
            100
          ),
    
        feet:
          clamp(
            Math.round(
              h * 2.05
            ),
            3,
            100
          )
      };
    }
    
    
    function addPollutionRow(
      widget,
      name,
      value
    ) {
    
      const row =
        widget.addStack();
    
      row.layoutHorizontally();
    
      row.centerAlignContent();
    
    
      const label =
        row.addText(
          name
        );
    
      label.font =
        Font.systemFont(10);
    
      label.textColor =
        COLORS.sub;
    
      label.size =
        new Size(
          42,
          0
        );
    
    
      row.addSpacer(4);
    
    
      addProgressBar(
        row,
        value / 100,
        pollutionColor(value),
        110
      );
    
    
      row.addSpacer(7);
    
    
      const percent =
        row.addText(
          `${value}%`
        );
    
      percent.font =
        Font.mediumSystemFont(10);
    
      percent.textColor =
        COLORS.white;
    
    
      if (value >= 90) {
    
        row.addSpacer(3);
    
    
        const danger =
          row.addText(
            "☠️"
          );
    
        danger.font =
          Font.systemFont(9);
      }
    
    
      widget.addSpacer(3);
    }
    
    
    // =====================================================
    // 次Lv
    // =====================================================
    
    function getNextLevelTime() {
    
      for (
        const level of
        CONFIG.LEVELS
      ) {
    
        if (
          Number.isFinite(
            level.hours
          ) &&
          elapsedHours <
          level.hours
        ) {
    
          return formatHoursRemaining(
            level.hours -
            elapsedHours
          );
        }
      }
    
    
      return "MAX";
    }
    
    
    // =====================================================
    // レベルカラー
    // =====================================================
    
    function getLevelColor(data) {
    
      if (
        data.level === "0"
      ) {
        return COLORS.green;
      }
    
    
      if (
        data.level === "1" ||
        data.level === "2"
      ) {
        return COLORS.yellow;
      }
    
    
      if (
        data.level === "3"
      ) {
        return COLORS.orange;
      }
    
    
      if (
        data.level === "4" ||
        data.level === "5"
      ) {
        return COLORS.red;
      }
    
    
      return COLORS.purple;
    }
    
    
    // =====================================================
    // 汚染カラー
    // =====================================================
    
    function pollutionColor(value) {
    
      if (value < 35) {
        return COLORS.green;
      }
    
    
      if (value < 60) {
        return COLORS.yellow;
      }
    
    
      if (value < 80) {
        return COLORS.orange;
      }
    
    
      return COLORS.red;
    }
    
    
    // =====================================================
    // 一言
    // =====================================================
    
    function getComment() {
    
      const stageIndex =
        getStageIndex();
    
    
      const comments = [
    
        [
          "文明に完全復帰しました。",
          "今なら誰に会っても大丈夫。",
          "清潔度、文句なし。",
          "この状態を覚えておこう。"
        ],
    
        [
          "まだ余裕です。",
          "現在、人類判定は余裕で合格。",
          "まだ文明側の人間です。",
          "問題なし。今のところは。"
        ],
    
        [
          "少しずつ生活感が出てきた。",
          "まあ、まだバレない。",
          "ここまでは普通。",
          "風呂キャン界ではまだ素人。"
        ],
    
        [
          "そろそろ風呂の時間です。",
          "シャワーだけでもええんやで。",
          "まだ引き返せる。",
          "今日は入ろう。今日は。"
        ],
    
        [
          "24時間の壁を突破しました。",
          "風呂キャン認定です。",
          "昨日から入ってませんね。",
          "ここからが本番。いや入ろう。"
        ],
    
        [
          "頭皮が自己主張を始める頃。",
          "枕がすべてを知っている。",
          "そろそろ髪が気になる。",
          "文明との距離が開き始めました。"
        ],
    
        [
          "枕カバーが心配です。",
          "髪型ではなく髪質が変わってきた。",
          "そろそろ文明に戻ろう。",
          "まだ間に合う。たぶん。"
        ],
    
        [
          "近距離戦は避けたい。",
          "人との距離は少し広めに。",
          "風呂場の場所、覚えてる?",
          "そろそろシャワーが恋しくない?"
        ],
    
        [
          "2日目に入りました。",
          "清潔という言葉が懐かしい。",
          "風呂キャン上級者の入口です。",
          "お湯はまだ君を待っている。"
        ],
    
        [
          "靴下に例えられる領域です。",
          "そろそろ本気で入りません?",
          "人類度を確認してください。",
          "記録更新を狙うゲームではありません。"
        ],
    
        [
          "公共の場では慎重に。",
          "人類判定が怪しくなってきた。",
          "風呂キャン界ではかなりの実力者。",
          "もう十分記録は残せました。"
        ],
    
        [
          "例えがトイレになりました。",
          "ここまで来る予定でした?",
          "人類への復帰を推奨します。",
          "もうシャワーだけでは済まないかも。"
        ],
    
        [
          "文明から離脱しました。",
          "ここから先は未知の領域です。",
          "風呂という文明を思い出してください。",
          "記録は十分です。入ってください。"
        ]
      ];
    
    
      const list =
        comments[
          Math.min(
            stageIndex,
            comments.length - 1
          )
        ];
    
    
      // 1時間ごとに変更
      const seed =
        Math.floor(
          Date.now() /
          3600000
        );
    
    
      return list[
        seed %
        list.length
      ];
    }
    
    
    // =====================================================
    // PROGRESS BAR
    // =====================================================
    
    function addProgressBar(
      parent,
      progress,
      color,
      width
    ) {
    
      const barWidth =
        typeof width === "number"
          ? width
          : 100;
    
    
      const height =
        6;
    
    
      const context =
        new DrawContext();
    
    
      context.size =
        new Size(
          barWidth,
          height
        );
    
    
      context.opaque =
        false;
    
      context.respectScreenScale =
        true;
    
    
      // TRACK
    
      const trackPath =
        new Path();
    
    
      trackPath.addRoundedRect(
        new Rect(
          0,
          0,
          barWidth,
          height
        ),
        height / 2,
        height / 2
      );
    
    
      context.addPath(
        trackPath
      );
    
    
      context.setFillColor(
        COLORS.track
      );
    
    
      context.fillPath();
    
    
      // VALUE
    
      const rawWidth =
        barWidth *
        clamp(
          progress,
          0,
          1
        );
    
    
      if (rawWidth > 0) {
    
        const fillWidth =
          Math.max(
            height,
            rawWidth
          );
    
    
        const valuePath =
          new Path();
    
    
        valuePath.addRoundedRect(
          new Rect(
            0,
            0,
            Math.min(
              fillWidth,
              barWidth
            ),
            height
          ),
          height / 2,
          height / 2
        );
    
    
        context.addPath(
          valuePath
        );
    
    
        context.setFillColor(
          color
        );
    
    
        context.fillPath();
      }
    
    
      const image =
        parent.addImage(
          context.getImage()
        );
    
    
      image.imageSize =
        new Size(
          barWidth,
          height
        );
    }
    
    
    // =====================================================
    // 時間表示
    // =====================================================
    
    function formatDuration(ms) {
    
      const totalMinutes =
        Math.floor(
          ms / 60000
        );
    
    
      const days =
        Math.floor(
          totalMinutes /
          1440
        );
    
    
      const hours =
        Math.floor(
          (
            totalMinutes %
            1440
          ) /
          60
        );
    
    
      const minutes =
        totalMinutes %
        60;
    
    
      if (days > 0) {
    
        return (
          `${days}日 ${hours}時間 ${minutes}分`
        );
      }
    
    
      return (
        `${hours}時間 ${minutes}分`
      );
    }
    
    
    function formatDurationShort(ms) {
    
      const totalMinutes =
        Math.floor(
          ms / 60000
        );
    
    
      const days =
        Math.floor(
          totalMinutes /
          1440
        );
    
    
      const hours =
        Math.floor(
          (
            totalMinutes %
            1440
          ) /
          60
        );
    
    
      const minutes =
        totalMinutes %
        60;
    
    
      if (days > 0) {
    
        return (
          `${days}日 ${hours}時間`
        );
      }
    
    
      return (
        `${hours}時間 ${minutes}分`
      );
    }
    
    
    function formatHoursRemaining(hours) {
    
      const totalMinutes =
        Math.ceil(
          hours * 60
        );
    
    
      const h =
        Math.floor(
          totalMinutes / 60
        );
    
    
      const m =
        totalMinutes % 60;
    
    
      if (h <= 0) {
    
        return `${m}分`;
      }
    
    
      return (
        `${h}時間${m}分`
      );
    }
    
    
    // =====================================================
    // CLAMP
    // =====================================================
    
    function clamp(
      value,
      min,
      max
    ) {
    
      return Math.min(
        Math.max(
          value,
          min
        ),
        max
      );
    }
  3. ホーム画面に追加して設定する

    iPhoneのホーム画面を長押しし、ウィジェット追加画面からScriptableを選びます。この記事の対応サイズの説明に合わせてサイズを選んで追加してください。

    追加したウィジェットを長押しして「ウィジェットを編集」を開き、「Script」に保存したスクリプトを選びます。

    「Parameter」は空欄で構いません。

    編集画面を閉じて表示を確認します。ホーム画面への反映には少し時間がかかる場合があります。

  4. 風呂に入ったらリセット

    ウィジェットをタップするとScriptableが開きます。

    表示されたメニューから、

    「🛁 風呂入った!」

    をタップしてください。

    最後に入浴した時間が現在時刻に更新され、

    汚れ Lv.0 / EXP 0

    から再スタートします。

    データは端末内に保存されるため、アカウント登録や外部サーバーへの送信はありません。

  5. 風呂に入ったらリセット

    ウィジェットをタップするとScriptableが開きます。

    表示されたメニューから、

    「🛁 風呂入った!」

    をタップしてください。

    最後に入浴した時間が現在時刻に更新され、

    汚れ Lv.0 / EXP 0

    から再スタートします。

    データは端末内に保存されるため、アカウント登録や外部サーバーへの送信はありません。

EXPについて

汚れLv.には**EXP(経験値)**があります。

汚れ Lv.2 😐
████████░░░░
EXP
45 / 100

最後にお風呂に入ってから時間が経過するとEXPが増えていき、100に到達すると次の汚れLv.へ進みます。

つまり、何もしなくても経験値が貯まります。
まったく嬉しくない育成ゲームです(笑)

動作確認・使用結果

確認した端末 iPhone 17 ProMAX
iOS iOS27
Scriptable 1.7.19
確認したサイズ 小・中・大
バージョン 1.0.0
動作確認日 2026-09-29

表示・使用時のポイント

最後にリセットした時刻から経過時間を計算します。お風呂に入ったらメニューのリセット操作を選び、カウントを始め直します。

更新履歴

1.0.0

  • 初回リリース

よくあるエラーと対処

確認すること
表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
お風呂に入ったのに数字が変わらない
自動検知しません。タップしてリセット操作を選んでください。
リセット後に表示が戻らない
保存後にホーム画面の更新を待ってください。
記録が消えた
端末内の保存データを削除した場合などは記録を引き継げません。

注意事項・制限

  • EXPや清潔度は遊びのための数値です。実際の衛生状態を測定しません。
  • 入浴を控えることを勧めるものではありません。
  • ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。

データ提供・参考資料

このウィジェットにはデータ提供や参考資料は特にありません。

SHARE

この記事をシェア