Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
2f15cdf
feat(functions-go): 参拝レスポンスに next_time(クールダウン秒数)を追加 (#198)
ShinoharaTa Jul 23, 2026
fe75035
fix(web): 参拝成功結果をローカル保存しリロード時に完了画面を復元 (#198)
ShinoharaTa Jul 23, 2026
211dba4
fix(web): 参拝APIエラーで一律ログアウトせず再試行UIを表示 (#198)
ShinoharaTa Jul 23, 2026
2bf8901
fix(web): 儀式中〜参拝API実行中はSW自動リロードを保留 (#198)
ShinoharaTa Jul 23, 2026
40dad6b
fix(web): ローディングの「…」を固定幅+明度アニメーションに変更 (#201)
ShinoharaTa Jul 23, 2026
6f072fc
docs: Issue #201 のドットアニメーション修正前後の比較スクリーンショットを追加
ShinoharaTa Jul 23, 2026
f90e259
test(web): おみくじ装置のヘッドレス掃引・微振動計測スクリプトを追加 (#199)
ShinoharaTa Jul 23, 2026
4838592
fix(web): おみくじ装置の常時微振動(震え)を解消 (#199)
ShinoharaTa Jul 23, 2026
7028dc6
feat(web): ドミノ連鎖の確実始動アシストを追加 (#199)
ShinoharaTa Jul 23, 2026
18e4ed9
docs: Issue #199 の装置レンダリング確認スクリーンショットを追加
ShinoharaTa Jul 23, 2026
f5fba99
Merge pull request #202 from 428lab/feature/issue-198-sanpai-restore
ShinoharaTa Jul 24, 2026
f6bbd5f
Merge pull request #203 from 428lab/feature/issue-201-loading-dots
ShinoharaTa Jul 24, 2026
edbe3c8
Merge pull request #204 from 428lab/feature/issue-199-omikuji-physics
ShinoharaTa Jul 24, 2026
fbb9b78
Merge pull request #206 from 428lab/main
ShinoharaTa Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/functions-go/sanpai.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli
// Node版は Firestore Timestamp の整数秒(.seconds、ナノ秒切り捨て)同士を比較するため、
// Go側も time.Time の丸め込みではなく Unix()(整数秒)で揃える。
if userData.LastSanpai.Unix()+int64(cfg.NextTime) > time.Now().Unix() {
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "expire", "add_exp": 0})
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "expire", "add_exp": 0, "next_time": cfg.NextTime})
return nil
}
}
Expand Down Expand Up @@ -477,6 +477,7 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli
"level_after": formatted.Level,
"updated_repo_count": len(updatedRepos),
"action_count": len(splited),
"next_time": cfg.NextTime,
})
return nil
}
Binary file added docs/screenshots/issue-199-final.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/issue-199-mid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/issue-201-dots-comparison.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 16 additions & 16 deletions web/components/Loading.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<!-- 巡回するメッセージ -->
<transition name="msg" mode="out-in">
<div class="mt-4 loading-message" :key="currentMessage">
{{ currentMessage }}<span class="loading-dots"></span>
{{ currentMessage }}<span class="loading-dots" aria-hidden="true">…</span>
</div>
</transition>
</div>
Expand Down Expand Up @@ -227,25 +227,25 @@ export default {
font-size: clamp(1.1rem, 4.8vw, 2rem);
line-height: 1.2;
}
.loading-dots::after {
content: "";
animation: dots 1.2s steps(4, end) infinite;
/* 末尾の「…」はドット数を変えず(=幅固定)、明度だけをゆっくり変える。
ドット数が増減すると行幅が変わり、中央寄せの支点がずれて本文が
横に動いたり狭幅端末で改行したりするため(#201)。 */
.loading-dots {
animation: dots-glow 1.6s ease-in-out infinite;
}
@keyframes dots {
0% {
content: "";
}
25% {
content: "・";
@keyframes dots-glow {
0%,
100% {
opacity: 0.25;
}
50% {
content: "・・";
}
75% {
content: "・・・";
opacity: 1;
}
100% {
content: "";
}
@media (prefers-reduced-motion: reduce) {
.loading-dots {
animation: none;
opacity: 1;
}
}

Expand Down
2 changes: 2 additions & 0 deletions web/components/OmikujiScene.vue
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ export default {
initScene() {
if (this.destroyed || !this.$refs.canvasWrap) return;
const { engine, world, tassel, relayBall } = machine.buildMachineWorld(Matter);
// 弱い当たりでもドミノ連鎖が完走するよう最低転倒速度を保証(#199)
machine.installChainAssist(Matter, engine);
this.engine = engine;
this.tassel = tassel;
this.relayBall = relayBall;
Expand Down
65 changes: 56 additions & 9 deletions web/components/omikujiMachine.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const GEO = {
// import)の両対応のため。返り値の relayBall(玉2)はシーン側のフォールバック
// (詰まった時にそっと突く)に使う。
function buildMachineWorld(Matter) {
const { Engine, World, Bodies, Body, Composites, Composite, Constraint } = Matter;
const { Engine, World, Bodies, Body, Composites, Composite, Constraint, Events } = Matter;
const engine = Engine.create({ enableSleeping: true });
engine.gravity.scale = 0.001;
const world = engine.world;
Expand Down Expand Up @@ -130,14 +130,16 @@ function buildMachineWorld(Matter) {
render: { fillStyle: "#b23a48" },
})
);
Composites.chain(rope, 0, 0.5, 0, -0.5, { stiffness: 0.9, length: 0, render: { strokeStyle: "#b23a48", lineWidth: 3 } });
// 拘束は stiffness 1(剛結)にする。1未満のソフト拘束は重力との釣り合いで
// 残留速度が乗り続け、スリープできずに永久に微振動する(#199)。
Composites.chain(rope, 0, 0.5, 0, -0.5, { stiffness: 1, length: 0, render: { strokeStyle: "#b23a48", lineWidth: 3 } });
Composite.add(
rope,
Constraint.create({
pointA: { x: GEO.BELL.x, y: ropeTopY },
bodyB: rope.bodies[0],
pointB: { x: 0, y: -GEO.ROPE.segH / 2 },
stiffness: 0.95,
stiffness: 1,
length: 0,
render: { strokeStyle: "#b23a48", lineWidth: 3 },
})
Expand All @@ -155,7 +157,7 @@ function buildMachineWorld(Matter) {
pointA: { x: 0, y: GEO.ROPE.segH / 2 },
bodyB: tassel,
pointB: { x: 0, y: -GEO.ROPE.tasselR },
stiffness: 0.95,
stiffness: 1,
length: 0,
render: { strokeStyle: "#b23a48", lineWidth: 3 },
})
Expand Down Expand Up @@ -184,7 +186,9 @@ function buildMachineWorld(Matter) {
bodyB: plaque,
pointB: { x: 0, y: -GEO.EMA.h / 2 },
length: GEO.EMA.hang,
stiffness: 0.9,
// 剛結(1)にしないと吊り下げのソフト拘束が微振動し続け、絵馬が
// 玉の通過前から震えてスリープもできない(#199)
stiffness: 1,
render: { strokeStyle: "#caa96a", lineWidth: 2 },
}));
}
Expand All @@ -205,10 +209,27 @@ function buildMachineWorld(Matter) {
frictionAir: GEO.WHEEL.frictionAir,
label: "wheel",
});
wheel.sleepThreshold = Infinity; // 眠って止まらないように(静止角は水平のまま)
// スリープを許可する(眠り=完全静止)。眠らせないと軸まわりの解の
// ノイズで常時±0.08rad前後ロッキングし続け、目に見えて震える(#199)。
// 玉が当たれば衝突で目を覚まして回る(ドミノと同じ仕組み)。玉は1回の
// 演出で1個しか通らないので、通過後にどの角度で眠っても支障はない。
add(wheel);
add(Constraint.create({ pointA: { x: wx, y: wy }, bodyB: wheel, pointB: { x: 0, y: 0 }, length: 0, stiffness: 0.9, render: { visible: false } }));
add(Bodies.circle(wx, wy, 6, { isStatic: true, render: { fillStyle: "#7a2a12" } }));
// 軸ピンも剛結(1)。0.9 だと軸が重力でたわみ、水車が常時±0.1rad
// 前後で揺れ続ける(#199)
add(Constraint.create({ pointA: { x: wx, y: wy }, bodyB: wheel, pointB: { x: 0, y: 0 }, length: 0, stiffness: 1, render: { visible: false } }));
// 水車は左右対称でCOM=軸のため、自重は回転に寄与しない。それでも重力を
// 掛けたままだと軸ピンが毎ステップ重力と押し合い、その拘束インパルスが
// スリープを妨げて±0.08radのロッキングが永久に続く(#199)。自重だけ
// 打ち消して静止→スリープさせる(玉の重みは玉自身の重力で伝わるので、
// 「玉が乗ると回る」演出は変わらない)。
Events.on(engine, "beforeUpdate", () => {
if (!wheel.isSleeping) {
wheel.force.y -= wheel.mass * engine.gravity.y * engine.gravity.scale;
}
});
// 軸キャップは装飾。バーと重なっているので衝突を切らないと毎ステップ
// 押し出し解決が走り、水車が永久にロッキングして眠れない(#199)
add(Bodies.circle(wx, wy, 6, { isStatic: true, collisionFilter: { mask: 0 }, render: { fillStyle: "#7a2a12" } }));

// 斜面B
add(Bodies.rectangle(GEO.RAMP_B.x, GEO.RAMP_B.y, GEO.RAMP_B.w, GEO.RAMP_B.h, { isStatic: true, angle: GEO.RAMP_B.angle, chamfer: { radius: 5 }, render: wood }));
Expand Down Expand Up @@ -281,6 +302,32 @@ function buildMachineWorld(Matter) {
return { engine, world, rope, tassel, relayBall };
}

// ドミノ連鎖の確実始動(#199)。
// 事前傾斜+スリープ凍結のドミノでも、当たりが弱いと接触減衰で連鎖が
// 止まることが稀にある(実機はブラウザごとの浮動小数点差で掃引済み軌道から
// 僅かにずれる)。玉またはドミノに触られた立ち姿勢のドミノに、転倒方向(左)の
// 最低角速度を保証して連鎖を完走させる。すでに転倒閾値の8割まで傾いている
// ため、後押しは僅かで見た目は自然なまま。
const CHAIN_ASSIST = {
MIN_TIP_ANGULAR_VELOCITY: -0.02,
STANDING_ANGLE: -0.6, // これより起きていれば「まだ立っている」
};
function installChainAssist(Matter, engine) {
Matter.Events.on(engine, "collisionStart", (e) => {
for (const p of e.pairs) {
for (const [self, other] of [[p.bodyA, p.bodyB], [p.bodyB, p.bodyA]]) {
if (self.label !== "domino") continue;
if (other.label !== "ball" && other.label !== "domino") continue;
if (self.angle < CHAIN_ASSIST.STANDING_ANGLE) continue; // 倒れ済みは触らない
Matter.Sleeping.set(self, false);
if (self.angularVelocity > CHAIN_ASSIST.MIN_TIP_ANGULAR_VELOCITY) {
Matter.Body.setAngularVelocity(self, CHAIN_ASSIST.MIN_TIP_ANGULAR_VELOCITY);
}
}
}
});
}

// 御神玉(玉1)を生成して投入する(鈴が鳴った時に呼ぶ)。
function spawnBall(Matter, world, vx) {
const b = Matter.Bodies.circle(GEO.BALL.spawnX, GEO.BALL.spawnY, GEO.BALL.r, {
Expand All @@ -297,4 +344,4 @@ function spawnBall(Matter, world, vx) {
return b;
}

module.exports = { GEO, buildMachineWorld, spawnBall };
module.exports = { GEO, buildMachineWorld, spawnBall, installChainAssist };
117 changes: 86 additions & 31 deletions web/pages/sanpai.vue
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,22 @@
</div>
</transition>
</div>
<!-- 参拝前(儀式中)は共有UIや導線を出さない -->
<template v-if="!ritual">
<!-- 通信失敗(ネットワーク瞬断・サーバーエラー)。儀式はやり直させない -->
<div class="container py-5" v-if="isError && !ritual">
<div class="fs-1">「むむ、うまく声が届かなかったようじゃ。」</div>
<div class="fs-4 mt-4">
通信に失敗しました。少し待ってからもう一度お試しください。
</div>
<button
type="button"
class="btn btn-lg btn-accent mt-4"
@click="onRetry"
>
もう一度参拝する
</button>
</div>
<!-- 参拝前(儀式中)・結果未確定のうちは共有UIや導線を出さない -->
<template v-if="!ritual && result">
<div class="my-5">
<Share
title="参拝したことをSNSで報告しよう"
Expand All @@ -164,6 +178,10 @@
<script>
import { getAuth, onAuthStateChanged } from "firebase/auth";
import { mapGetters } from "vuex";
import {
saveSanpaiResult,
loadRestorableSanpaiResult,
} from "~/utils/sanpaiSession";

// 認証状態の復元は非同期のため、最初に確定したユーザーを待ち受ける
function resolveCurrentUser(auth) {
Expand Down Expand Up @@ -213,8 +231,23 @@ export default {
},
};
},
mounted() {
// SWの自動リロード・PWA再起動・タブ退避復帰などで再マウントされた場合、
// クールダウン内なら儀式をやり直させず完了画面を復元する。儀式画面に
// 戻すと二拍手→サーバー判定でexpireの悪循環になる(#198)
const saved = loadRestorableSanpaiResult(Date.now());
if (saved) {
this.ritual = false;
this.result = "success";
this.status = { ...this.status, ...saved };
return;
}
// 儀式〜参拝完了まではSWの自動リロードを保留させる(#198)
this.setSwReloadBlocked(true);
},
beforeDestroy() {
if (this.clapTimerId) clearTimeout(this.clapTimerId);
this.setSwReloadBlocked(false);
},
methods: {
// 二拍手の判定。dblclick はモバイルで不安定なため click を自前でカウントする。
Expand Down Expand Up @@ -242,6 +275,19 @@ export default {
this.claps = 0; // 時間切れ:改めて2回必要
}, 700);
},
// 通信失敗後の再試行。儀式(二拍手)は済んでいるのでAPIだけやり直す
onRetry() {
this.isError = false;
this.isLoading = true;
this.setSwReloadBlocked(true);
this.doSanpai();
},
// sw-update.client.js が公開するガード。儀式〜参拝API実行中の
// SW自動リロードを保留する(#198)
setSwReloadBlocked(blocked) {
const guard = typeof window !== "undefined" && window.$swReloadGuard;
if (guard) guard.blocked = blocked;
},
// 拍手のフィードバック(バウンス再生+波紋リング追加)
pop() {
this.popKey += 1;
Expand All @@ -268,36 +314,45 @@ export default {
};
// Go版(sanpaiGo)はコールドスタートが短く参拝処理が速くなるため使用する
// (Node版のsanpaiとレスポンス形式は同一。docs/backend.md参照)
let response = await this.$axios.post("sanpaiGo",
payload,
{
headers: {
Authorization: `Bearer ${token}`
}
})
.catch(e=>{
this.$store.dispatch('logout');
this.isLoading = false;
})
if (response) {
const d = response.data;
this.status.level = d.level;
this.status.get = d.add_exp;
this.status.point = d.exp;
this.status.msg = d.msg;
this.status.pointsBefore = d.points_before != null ? d.points_before : 0;
this.status.pointsAfter = d.points_after != null ? d.points_after : d.exp;
this.status.powerBefore = d.power_before != null ? d.power_before : 0;
this.status.powerAfter = d.power_after != null ? d.power_after : 0;
this.status.levelBefore = d.level_before != null ? d.level_before : 0;
this.status.levelAfter = d.level_after != null ? d.level_after : d.level;
this.status.updatedRepoCount = d.updated_repo_count != null ? d.updated_repo_count : 0;
this.status.actionCount = d.action_count != null ? d.action_count : 0;
this.result = d.status;
this.isLoading = false;
} else {
this.isError = true;
let response;
try {
response = await this.$axios.post("sanpaiGo",
payload,
{
headers: {
Authorization: `Bearer ${token}`
}
});
} catch (e) {
// ログアウトは認証エラー(401/403)のときだけ。ネットワーク瞬断や5xxで
// ログアウトさせると、再サインイン→再参拝→expireの悪循環になる(#198)
const statusCode = e.response && e.response.status;
if (statusCode === 401 || statusCode === 403) {
this.$store.dispatch("logout");
} else {
this.isError = true;
}
this.isLoading = false;
return;
}
const d = response.data;
this.status.level = d.level;
this.status.get = d.add_exp;
this.status.point = d.exp;
this.status.msg = d.msg;
this.status.pointsBefore = d.points_before != null ? d.points_before : 0;
this.status.pointsAfter = d.points_after != null ? d.points_after : d.exp;
this.status.powerBefore = d.power_before != null ? d.power_before : 0;
this.status.powerAfter = d.power_after != null ? d.power_after : 0;
this.status.levelBefore = d.level_before != null ? d.level_before : 0;
this.status.levelAfter = d.level_after != null ? d.level_after : d.level;
this.status.updatedRepoCount = d.updated_repo_count != null ? d.updated_repo_count : 0;
this.status.actionCount = d.action_count != null ? d.action_count : 0;
this.result = d.status;
this.isLoading = false;
if (d.status === "success") {
// クールダウン内の再マウントで完了画面を復元できるよう保存(#198)
saveSanpaiResult({ ...this.status }, d.next_time, Date.now());
}
},
},
Expand Down
Loading
Loading