Add dice-roll animation and tighten attack-roll prompt rules

Broadcast a "rolling" WS event when the DM invokes roll_dice, and show
a cycling dice-emoji animation with the roll notation in the chat while
waiting for the result, instead of just the generic typing indicator.

Also split the dice-roll prompt rule into skill/save checks (DC shown
to the player) vs. attack rolls (AC stays secret but must still be
labeled as a roll), and made the "always roll yourself, never ask the
player to roll" instruction unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-01 17:28:37 +02:00
parent 624644619c
commit 2b99451fdf
6 changed files with 62 additions and 11 deletions
+35 -6
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Message } from "../api/games";
import MarkdownContent from "./MarkdownContent";
const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];
function senderLabel(message: Message): string {
if (message.sender_type === "dm") return "Dungeon Master";
if (message.sender_type === "system") return "System";
@@ -10,18 +12,41 @@ function senderLabel(message: Message): string {
return message.player_name ?? "Spieler";
}
function DiceRollAnimation({ notation }: { notation: string }) {
const [faceIndex, setFaceIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setFaceIndex((i) => (i + 1) % DICE_FACES.length);
}, 120);
return () => clearInterval(interval);
}, []);
return (
<div className="text-slate-100">
<span className="font-semibold text-amber-400">Dungeon Master würfelt</span>{" "}
<span className="inline-block text-xl" aria-hidden="true">
{DICE_FACES[faceIndex]}
</span>{" "}
<span className="text-sm text-slate-400">{notation && `(${notation})`}</span>
</div>
);
}
export default function TranscriptPane({
messages,
typing,
rolling,
}: {
messages: Message[];
typing?: boolean;
rolling?: string | null;
}) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages, typing]);
}, [messages, typing, rolling]);
return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
@@ -41,10 +66,14 @@ export default function TranscriptPane({
<MarkdownContent content={m.content} />
</div>
))}
{typing && (
<div className="text-slate-100">
<span className="animate-pulse font-semibold text-amber-400">Dungeon Master schreibt …</span>
</div>
{rolling != null ? (
<DiceRollAnimation notation={rolling} />
) : (
typing && (
<div className="text-slate-100">
<span className="animate-pulse font-semibold text-amber-400">Dungeon Master schreibt …</span>
</div>
)
)}
<div ref={bottomRef} />
</div>
+4
View File
@@ -8,6 +8,7 @@ export function useGameSocket(
onMessage: (message: Message) => void,
onError: (detail: string) => void,
onTyping: () => void,
onRolling: (notation: string) => void,
) {
const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
@@ -16,6 +17,8 @@ export function useGameSocket(
onErrorRef.current = onError;
const onTypingRef = useRef(onTyping);
onTypingRef.current = onTyping;
const onRollingRef = useRef(onRolling);
onRollingRef.current = onRolling;
useEffect(() => {
if (!gameId) return;
@@ -35,6 +38,7 @@ export function useGameSocket(
if (data.type === "message") onMessageRef.current(data.message);
else if (data.type === "error") onErrorRef.current(data.detail);
else if (data.type === "typing") onTypingRef.current();
else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
};
ws.onclose = () => {
if (!intentionalClose) onErrorRef.current("connection_lost");
+5 -1
View File
@@ -20,6 +20,7 @@ export default function GameSession() {
const [selectedCharacterId, setSelectedCharacterId] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
const [isTyping, setIsTyping] = useState(false);
const [rollingNotation, setRollingNotation] = useState<string | null>(null);
async function loadGame() {
if (!gameId) return;
@@ -71,6 +72,7 @@ export default function GameSession() {
game?.is_participant ? gameId : undefined,
(incoming) => {
setIsTyping(false);
setRollingNotation(null);
setMessages((prev) => {
const next = [...prev, incoming];
return showingFull ? next : next.slice(-5);
@@ -78,6 +80,7 @@ export default function GameSession() {
},
(detail) => {
setIsTyping(false);
setRollingNotation(null);
const text =
detail === "connection_lost"
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
@@ -98,6 +101,7 @@ export default function GameSession() {
});
},
() => setIsTyping(true),
(notation) => setRollingNotation(notation),
);
async function handleSend(content: string) {
@@ -177,7 +181,7 @@ export default function GameSession() {
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
<TranscriptPane messages={messages} typing={isTyping} />
<TranscriptPane messages={messages} typing={isTyping} rolling={rollingNotation} />
<SendMessageForm onSend={handleSend} />
</div>
</div>