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:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user