update
Browse files- client/index.html +1 -1
- client/package.json +2 -0
- client/src/components/GameDebugPanel.jsx +99 -0
- client/src/components/MicroIntensity.jsx +131 -0
- client/src/components/StoryChoices.jsx +82 -30
- client/src/components/TalkWithSarah.jsx +42 -6
- client/src/components/UniverseMetrics.jsx +10 -1
- client/src/components/UniverseSlotMachine.jsx +232 -0
- client/src/contexts/GameContext.jsx +351 -0
- client/src/hooks/usePageSound.js +6 -41
- client/src/hooks/useSlotMachine.js +58 -0
- client/src/hooks/useSoundEffect.js +68 -0
- client/src/hooks/useTransitionSound.js +6 -41
- client/src/hooks/useWritingSound.js +6 -41
- client/src/layouts/ComicLayout.jsx +276 -71
- client/src/layouts/Panel.jsx +145 -106
- client/src/layouts/config.js +34 -23
- client/src/layouts/utils.js +12 -1
- client/src/main.jsx +2 -0
- client/src/pages/Debug.jsx +1 -0
- client/src/pages/Game.jsx +333 -369
- client/src/pages/Home.jsx +33 -164
- client/src/pages/Tutorial.jsx +1 -1
- client/src/pages/Universe.jsx +197 -0
- client/src/prompts/sarahPrompt.js +0 -24
- client/src/utils/api.js +75 -3
- client/yarn.lock +0 -0
- server/api/models.py +7 -7
- server/api/routes/universe.py +43 -9
- server/core/generators/base_generator.py +7 -2
- server/core/generators/image_prompt_generator.py +83 -38
- server/core/generators/metadata_generator.py +142 -37
- server/core/generators/story_segment_generator.py +109 -34
- server/core/generators/universe_generator.py +48 -29
- server/core/prompt_utils.py +0 -8
- server/core/prompts/hero.py +0 -5
- server/core/prompts/story_beats.py +0 -24
- server/core/setup.py +1 -1
- server/core/story_generator.py +30 -10
- server/core/styles/universe_styles.json +99 -76
- server/scripts/test_game.py +17 -9
- server/services/mistral_client.py +36 -2
client/index.html
CHANGED
@@ -4,7 +4,7 @@
|
|
4 |
<meta charset="UTF-8" />
|
5 |
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
-
<title>
|
8 |
<style>
|
9 |
html,
|
10 |
body {
|
|
|
4 |
<meta charset="UTF-8" />
|
5 |
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>IA Comic book adventures</title>
|
8 |
<style>
|
9 |
html,
|
10 |
body {
|
client/package.json
CHANGED
@@ -21,6 +21,8 @@
|
|
21 |
"react": "^18.3.1",
|
22 |
"react-dom": "^18.3.1",
|
23 |
"react-router-dom": "^7.1.3",
|
|
|
|
|
24 |
"use-react-screenshot": "^4.0.0",
|
25 |
"use-sound": "^4.0.3"
|
26 |
},
|
|
|
21 |
"react": "^18.3.1",
|
22 |
"react-dom": "^18.3.1",
|
23 |
"react-router-dom": "^7.1.3",
|
24 |
+
"react-slot-counter": "^3.1.0",
|
25 |
+
"react-use-slot": "^0.3.1",
|
26 |
"use-react-screenshot": "^4.0.0",
|
27 |
"use-sound": "^4.0.3"
|
28 |
},
|
client/src/components/GameDebugPanel.jsx
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Box, Paper, Stack, Typography } from "@mui/material";
|
3 |
+
import {
|
4 |
+
Timer as TimerIcon,
|
5 |
+
LocationOn as LocationIcon,
|
6 |
+
Psychology as PsychologyIcon,
|
7 |
+
Person as PersonIcon,
|
8 |
+
Palette as PaletteIcon,
|
9 |
+
} from "@mui/icons-material";
|
10 |
+
|
11 |
+
const DebugItem = ({ icon, label, value }) => (
|
12 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
13 |
+
{icon}
|
14 |
+
<Typography variant="caption" sx={{ opacity: 0.7 }}>
|
15 |
+
{label}:
|
16 |
+
</Typography>
|
17 |
+
<Typography variant="caption" sx={{ fontWeight: "medium" }}>
|
18 |
+
{value}
|
19 |
+
</Typography>
|
20 |
+
</Box>
|
21 |
+
);
|
22 |
+
|
23 |
+
export const GameDebugPanel = ({ gameState, currentStory, visible }) => {
|
24 |
+
if (!visible) return null;
|
25 |
+
|
26 |
+
return (
|
27 |
+
<Paper
|
28 |
+
sx={{
|
29 |
+
position: "fixed",
|
30 |
+
bottom: 16,
|
31 |
+
right: 16,
|
32 |
+
width: 300,
|
33 |
+
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
34 |
+
backdropFilter: "blur(8px)",
|
35 |
+
color: "white",
|
36 |
+
p: 2,
|
37 |
+
borderRadius: 2,
|
38 |
+
zIndex: 1000,
|
39 |
+
}}
|
40 |
+
>
|
41 |
+
<Stack spacing={2}>
|
42 |
+
{/* Universe Info */}
|
43 |
+
<Box>
|
44 |
+
<Typography
|
45 |
+
variant="caption"
|
46 |
+
color="primary.main"
|
47 |
+
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
48 |
+
>
|
49 |
+
UNIVERSE
|
50 |
+
</Typography>
|
51 |
+
<Stack spacing={0.5}>
|
52 |
+
<DebugItem
|
53 |
+
icon={<PaletteIcon fontSize="small" sx={{ opacity: 0.7 }} />}
|
54 |
+
label="Style"
|
55 |
+
value={
|
56 |
+
gameState?.universe_style?.name || gameState?.universe_style
|
57 |
+
}
|
58 |
+
/>
|
59 |
+
{gameState?.universe_style?.selected_artist && (
|
60 |
+
<DebugItem
|
61 |
+
icon={<PersonIcon fontSize="small" sx={{ opacity: 0.7 }} />}
|
62 |
+
label="Artist"
|
63 |
+
value={gameState.universe_style.selected_artist}
|
64 |
+
/>
|
65 |
+
)}
|
66 |
+
</Stack>
|
67 |
+
</Box>
|
68 |
+
|
69 |
+
{/* Game State */}
|
70 |
+
<Box>
|
71 |
+
<Typography
|
72 |
+
variant="caption"
|
73 |
+
color="primary.main"
|
74 |
+
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
75 |
+
>
|
76 |
+
GAME STATE
|
77 |
+
</Typography>
|
78 |
+
<Stack spacing={0.5}>
|
79 |
+
<DebugItem
|
80 |
+
icon={<TimerIcon fontSize="small" sx={{ opacity: 0.7 }} />}
|
81 |
+
label="Time"
|
82 |
+
value={currentStory?.time}
|
83 |
+
/>
|
84 |
+
<DebugItem
|
85 |
+
icon={<LocationIcon fontSize="small" sx={{ opacity: 0.7 }} />}
|
86 |
+
label="Location"
|
87 |
+
value={currentStory?.location}
|
88 |
+
/>
|
89 |
+
<DebugItem
|
90 |
+
icon={<PsychologyIcon fontSize="small" sx={{ opacity: 0.7 }} />}
|
91 |
+
label="Story Beat"
|
92 |
+
value={gameState?.story_beat}
|
93 |
+
/>
|
94 |
+
</Stack>
|
95 |
+
</Box>
|
96 |
+
</Stack>
|
97 |
+
</Paper>
|
98 |
+
);
|
99 |
+
};
|
client/src/components/MicroIntensity.jsx
ADDED
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useRef, useEffect, useState, useCallback } from "react";
|
2 |
+
import { Box } from "@mui/material";
|
3 |
+
|
4 |
+
export function MicroIntensity({ numBars = 8 }) {
|
5 |
+
const [audioContext, setAudioContext] = useState(null);
|
6 |
+
const [analyser, setAnalyser] = useState(null);
|
7 |
+
const [intensities, setIntensities] = useState(new Array(numBars).fill(0));
|
8 |
+
const canvasRef = useRef(null);
|
9 |
+
const animationRef = useRef(null);
|
10 |
+
|
11 |
+
useEffect(() => {
|
12 |
+
const initAudio = async () => {
|
13 |
+
const context = new (window.AudioContext || window.webkitAudioContext)();
|
14 |
+
const analyserNode = context.createAnalyser();
|
15 |
+
analyserNode.fftSize = 256;
|
16 |
+
|
17 |
+
try {
|
18 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
19 |
+
audio: true,
|
20 |
+
});
|
21 |
+
const source = context.createMediaStreamSource(stream);
|
22 |
+
source.connect(analyserNode);
|
23 |
+
|
24 |
+
setAudioContext(context);
|
25 |
+
setAnalyser(analyserNode);
|
26 |
+
} catch (error) {
|
27 |
+
console.error("Error accessing the microphone:", error);
|
28 |
+
}
|
29 |
+
};
|
30 |
+
|
31 |
+
initAudio();
|
32 |
+
|
33 |
+
return () => {
|
34 |
+
if (audioContext) {
|
35 |
+
audioContext.close();
|
36 |
+
}
|
37 |
+
if (animationRef.current) {
|
38 |
+
cancelAnimationFrame(animationRef.current);
|
39 |
+
}
|
40 |
+
};
|
41 |
+
}, []);
|
42 |
+
|
43 |
+
const drawCanvas = useCallback((intensities) => {
|
44 |
+
const canvas = canvasRef.current;
|
45 |
+
if (!canvas) return;
|
46 |
+
|
47 |
+
const ctx = canvas.getContext("2d");
|
48 |
+
const width = canvas.width;
|
49 |
+
const height = canvas.height;
|
50 |
+
|
51 |
+
// Clear canvas
|
52 |
+
ctx.clearRect(0, 0, width, height);
|
53 |
+
|
54 |
+
// Draw red circle
|
55 |
+
ctx.beginPath();
|
56 |
+
ctx.arc(5, height / 2, 5, 0, 2 * Math.PI);
|
57 |
+
ctx.fillStyle = "red";
|
58 |
+
ctx.fill();
|
59 |
+
|
60 |
+
// Draw intensity bars
|
61 |
+
const barWidth = 3;
|
62 |
+
const barSpacing = 1;
|
63 |
+
const startX = 15; // Start after the red circle
|
64 |
+
const minBarHeight = 2;
|
65 |
+
const maxBarHeight = height - 5; // Max height minus some padding
|
66 |
+
|
67 |
+
intensities.forEach((intensity, index) => {
|
68 |
+
const barHeight = Math.max(minBarHeight, intensity * maxBarHeight);
|
69 |
+
const x = startX + index * (barWidth + barSpacing);
|
70 |
+
const y = (height - barHeight) / 2;
|
71 |
+
|
72 |
+
ctx.fillStyle = "white";
|
73 |
+
ctx.fillRect(x, y, barWidth, barHeight);
|
74 |
+
});
|
75 |
+
}, []);
|
76 |
+
|
77 |
+
useEffect(() => {
|
78 |
+
if (!analyser) return;
|
79 |
+
|
80 |
+
const bufferLength = analyser.frequencyBinCount;
|
81 |
+
const dataArray = new Uint8Array(bufferLength);
|
82 |
+
|
83 |
+
const updateIntensities = () => {
|
84 |
+
analyser.getByteFrequencyData(dataArray);
|
85 |
+
|
86 |
+
let newIntensities = Array.from({ length: numBars }, (_, i) => {
|
87 |
+
const start = Math.floor((i / numBars) * bufferLength);
|
88 |
+
const end = Math.floor(((i + 1) / numBars) * bufferLength);
|
89 |
+
const average =
|
90 |
+
dataArray.slice(start, end).reduce((sum, value) => sum + value, 0) /
|
91 |
+
(end - start);
|
92 |
+
return average / 255;
|
93 |
+
});
|
94 |
+
|
95 |
+
// Sort intensities from highest to lowest
|
96 |
+
newIntensities.sort((a, b) => b - a);
|
97 |
+
|
98 |
+
// Reorder intensities to put highest in the middle and alternate others
|
99 |
+
const reorderedIntensities = new Array(numBars);
|
100 |
+
let left = Math.floor(numBars / 2) - 1;
|
101 |
+
let right = Math.floor(numBars / 2);
|
102 |
+
newIntensities.forEach((intensity, index) => {
|
103 |
+
if (index % 2 === 0) {
|
104 |
+
reorderedIntensities[right] = intensity;
|
105 |
+
right++;
|
106 |
+
} else {
|
107 |
+
reorderedIntensities[left] = intensity;
|
108 |
+
left--;
|
109 |
+
}
|
110 |
+
});
|
111 |
+
|
112 |
+
setIntensities(reorderedIntensities);
|
113 |
+
drawCanvas(reorderedIntensities);
|
114 |
+
animationRef.current = requestAnimationFrame(updateIntensities);
|
115 |
+
};
|
116 |
+
|
117 |
+
updateIntensities();
|
118 |
+
|
119 |
+
return () => {
|
120 |
+
if (animationRef.current) {
|
121 |
+
cancelAnimationFrame(animationRef.current);
|
122 |
+
}
|
123 |
+
};
|
124 |
+
}, [analyser, numBars, drawCanvas]);
|
125 |
+
|
126 |
+
return (
|
127 |
+
<Box sx={{ width: 50, height: 40, display: "flex", alignItems: "center" }}>
|
128 |
+
<canvas ref={canvasRef} width={50} height={40} />
|
129 |
+
</Box>
|
130 |
+
);
|
131 |
+
}
|
client/src/components/StoryChoices.jsx
CHANGED
@@ -2,6 +2,11 @@ import { Box, Button, Typography, Chip, Divider } from "@mui/material";
|
|
2 |
import { useNavigate } from "react-router-dom";
|
3 |
import { TalkWithSarah } from "./TalkWithSarah";
|
4 |
import { useState } from "react";
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
// Function to convert text with ** to Chip elements
|
7 |
const formatTextWithBold = (text) => {
|
@@ -27,35 +32,50 @@ const formatTextWithBold = (text) => {
|
|
27 |
});
|
28 |
};
|
29 |
|
30 |
-
export function StoryChoices({
|
31 |
-
choices = [],
|
32 |
-
onChoice,
|
33 |
-
disabled = false,
|
34 |
-
isLastStep = false,
|
35 |
-
isGameOver = false,
|
36 |
-
isDeath = false,
|
37 |
-
isVictory = false,
|
38 |
-
containerRef,
|
39 |
-
isNarratorSpeaking = false,
|
40 |
-
stopNarration = () => {},
|
41 |
-
playNarration = () => {},
|
42 |
-
storyText = "",
|
43 |
-
}) {
|
44 |
const navigate = useNavigate();
|
45 |
const [isSarahActive, setIsSarahActive] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
return (
|
49 |
<Box
|
50 |
sx={{
|
|
|
|
|
|
|
|
|
51 |
display: "flex",
|
52 |
flexDirection: "column",
|
53 |
justifyContent: "center",
|
54 |
alignItems: "center",
|
55 |
gap: 2,
|
56 |
p: 3,
|
57 |
-
minWidth: "
|
58 |
-
height: "100%",
|
59 |
backgroundColor: "transparent",
|
60 |
}}
|
61 |
>
|
@@ -105,7 +125,10 @@ export function StoryChoices({
|
|
105 |
<Button
|
106 |
variant="outlined"
|
107 |
size="large"
|
108 |
-
onClick={() =>
|
|
|
|
|
|
|
109 |
sx={{
|
110 |
width: "100%",
|
111 |
textTransform: "none",
|
@@ -132,20 +155,20 @@ export function StoryChoices({
|
|
132 |
return (
|
133 |
<Box
|
134 |
sx={{
|
|
|
|
|
|
|
135 |
display: "flex",
|
136 |
flexDirection: "column",
|
137 |
justifyContent: "center",
|
138 |
alignItems: "center",
|
139 |
gap: 2,
|
140 |
p: 3,
|
141 |
-
|
142 |
-
|
143 |
-
height: "100%",
|
144 |
-
backgroundColor: "transparent",
|
145 |
-
overflowY: "auto",
|
146 |
}}
|
147 |
>
|
148 |
-
{!
|
149 |
choices.map((choice, index) => (
|
150 |
<Box
|
151 |
key={choice.id}
|
@@ -164,8 +187,17 @@ export function StoryChoices({
|
|
164 |
<Button
|
165 |
variant="outlined"
|
166 |
size="large"
|
167 |
-
onClick={() =>
|
168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
sx={{
|
170 |
width: "100%",
|
171 |
textTransform: "none",
|
@@ -173,9 +205,28 @@ export function StoryChoices({
|
|
173 |
fontSize: "1.1rem",
|
174 |
padding: "16px 24px",
|
175 |
lineHeight: 1.3,
|
176 |
-
borderColor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
"&:hover": {
|
178 |
-
borderColor:
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
180 |
},
|
181 |
"& .MuiChip-root": {
|
@@ -188,7 +239,7 @@ export function StoryChoices({
|
|
188 |
</Box>
|
189 |
))}
|
190 |
|
191 |
-
{!
|
192 |
<>
|
193 |
<Divider
|
194 |
sx={{
|
@@ -214,8 +265,9 @@ export function StoryChoices({
|
|
214 |
isNarratorSpeaking={isNarratorSpeaking}
|
215 |
stopNarration={stopNarration}
|
216 |
playNarration={playNarration}
|
217 |
-
onDecisionMade={
|
218 |
onSarahActiveChange={setIsSarahActive}
|
|
|
219 |
currentContext={`You are Sarah and this is the situation you're in : ${storyText}. Those are your possible decisions : \n ${choices
|
220 |
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
221 |
.join("\n ")}.`}
|
|
|
2 |
import { useNavigate } from "react-router-dom";
|
3 |
import { TalkWithSarah } from "./TalkWithSarah";
|
4 |
import { useState } from "react";
|
5 |
+
import { useGame } from "../contexts/GameContext";
|
6 |
+
import { storyApi } from "../utils/api";
|
7 |
+
import { useSoundEffect } from "../hooks/useSoundEffect";
|
8 |
+
|
9 |
+
const { initAudioContext } = storyApi;
|
10 |
|
11 |
// Function to convert text with ** to Chip elements
|
12 |
const formatTextWithBold = (text) => {
|
|
|
32 |
});
|
33 |
};
|
34 |
|
35 |
+
export function StoryChoices() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
const navigate = useNavigate();
|
37 |
const [isSarahActive, setIsSarahActive] = useState(false);
|
38 |
+
const [sarahRecommendation, setSarahRecommendation] = useState(null);
|
39 |
+
const {
|
40 |
+
choices,
|
41 |
+
onChoice,
|
42 |
+
isLoading,
|
43 |
+
isNarratorSpeaking,
|
44 |
+
stopNarration,
|
45 |
+
playNarration,
|
46 |
+
heroName,
|
47 |
+
getLastSegment,
|
48 |
+
isGameOver,
|
49 |
+
} = useGame();
|
50 |
+
|
51 |
+
// Son de page
|
52 |
+
const playPageSound = useSoundEffect({
|
53 |
+
basePath: "/sounds/page-flip-",
|
54 |
+
numSounds: 7,
|
55 |
+
volume: 0.5,
|
56 |
+
});
|
57 |
|
58 |
+
const lastSegment = getLastSegment();
|
59 |
+
const isLastStep = lastSegment?.is_last_step;
|
60 |
+
const isDeath = lastSegment?.isDeath;
|
61 |
+
const isVictory = lastSegment?.isVictory;
|
62 |
+
const storyText = lastSegment?.rawText || "";
|
63 |
+
|
64 |
+
if (isGameOver()) {
|
65 |
return (
|
66 |
<Box
|
67 |
sx={{
|
68 |
+
position: "fixed",
|
69 |
+
top: "0%",
|
70 |
+
left: "50%",
|
71 |
+
transform: "translate(-50%, -100%)",
|
72 |
display: "flex",
|
73 |
flexDirection: "column",
|
74 |
justifyContent: "center",
|
75 |
alignItems: "center",
|
76 |
gap: 2,
|
77 |
p: 3,
|
78 |
+
minWidth: "350px",
|
|
|
79 |
backgroundColor: "transparent",
|
80 |
}}
|
81 |
>
|
|
|
125 |
<Button
|
126 |
variant="outlined"
|
127 |
size="large"
|
128 |
+
onClick={() => {
|
129 |
+
// Reset game and navigate to game page to trigger universe generation
|
130 |
+
navigate("/game");
|
131 |
+
}}
|
132 |
sx={{
|
133 |
width: "100%",
|
134 |
textTransform: "none",
|
|
|
155 |
return (
|
156 |
<Box
|
157 |
sx={{
|
158 |
+
position: "fixed",
|
159 |
+
bottom: 0,
|
160 |
+
right: 0,
|
161 |
display: "flex",
|
162 |
flexDirection: "column",
|
163 |
justifyContent: "center",
|
164 |
alignItems: "center",
|
165 |
gap: 2,
|
166 |
p: 3,
|
167 |
+
maxWidth: "350px",
|
168 |
+
zIndex: 1000,
|
|
|
|
|
|
|
169 |
}}
|
170 |
>
|
171 |
+
{!isLoading &&
|
172 |
choices.map((choice, index) => (
|
173 |
<Box
|
174 |
key={choice.id}
|
|
|
187 |
<Button
|
188 |
variant="outlined"
|
189 |
size="large"
|
190 |
+
onClick={() => {
|
191 |
+
// Initialiser l'audio context au clic
|
192 |
+
initAudioContext();
|
193 |
+
// Jouer le son de page
|
194 |
+
playPageSound();
|
195 |
+
// Arrêter la narration en cours
|
196 |
+
stopNarration();
|
197 |
+
// Faire le choix
|
198 |
+
onChoice(choice.id);
|
199 |
+
}}
|
200 |
+
disabled={isSarahActive || isLoading || isNarratorSpeaking}
|
201 |
sx={{
|
202 |
width: "100%",
|
203 |
textTransform: "none",
|
|
|
205 |
fontSize: "1.1rem",
|
206 |
padding: "16px 24px",
|
207 |
lineHeight: 1.3,
|
208 |
+
borderColor:
|
209 |
+
sarahRecommendation === choice.id
|
210 |
+
? "#4CAF50"
|
211 |
+
: sarahRecommendation !== null &&
|
212 |
+
sarahRecommendation !== choice.id
|
213 |
+
? "#f44336"
|
214 |
+
: "primary.main",
|
215 |
+
color:
|
216 |
+
sarahRecommendation === choice.id
|
217 |
+
? "#4CAF50"
|
218 |
+
: sarahRecommendation !== null &&
|
219 |
+
sarahRecommendation !== choice.id
|
220 |
+
? "#f44336"
|
221 |
+
: "inherit",
|
222 |
"&:hover": {
|
223 |
+
borderColor:
|
224 |
+
sarahRecommendation === choice.id
|
225 |
+
? "#45a049"
|
226 |
+
: sarahRecommendation !== null &&
|
227 |
+
sarahRecommendation !== choice.id
|
228 |
+
? "#d32f2f"
|
229 |
+
: "primary.light",
|
230 |
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
231 |
},
|
232 |
"& .MuiChip-root": {
|
|
|
239 |
</Box>
|
240 |
))}
|
241 |
|
242 |
+
{!isLoading && storyText && (
|
243 |
<>
|
244 |
<Divider
|
245 |
sx={{
|
|
|
265 |
isNarratorSpeaking={isNarratorSpeaking}
|
266 |
stopNarration={stopNarration}
|
267 |
playNarration={playNarration}
|
268 |
+
onDecisionMade={(choiceId) => setSarahRecommendation(choiceId)}
|
269 |
onSarahActiveChange={setIsSarahActive}
|
270 |
+
heroName={heroName}
|
271 |
currentContext={`You are Sarah and this is the situation you're in : ${storyText}. Those are your possible decisions : \n ${choices
|
272 |
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
273 |
.join("\n ")}.`}
|
client/src/components/TalkWithSarah.jsx
CHANGED
@@ -2,6 +2,7 @@ import { useConversation } from "@11labs/react";
|
|
2 |
import CancelIcon from "@mui/icons-material/Cancel";
|
3 |
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
4 |
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
|
|
5 |
import {
|
6 |
Box,
|
7 |
IconButton,
|
@@ -16,11 +17,43 @@ import {
|
|
16 |
import { useEffect, useRef, useState } from "react";
|
17 |
import { useSound } from "use-sound";
|
18 |
|
19 |
-
import { getSarahPrompt, SARAH_FIRST_MESSAGE } from "../prompts/sarahPrompt";
|
20 |
-
|
21 |
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
22 |
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
export function TalkWithSarah({
|
25 |
isNarratorSpeaking,
|
26 |
stopNarration,
|
@@ -28,6 +61,7 @@ export function TalkWithSarah({
|
|
28 |
onDecisionMade,
|
29 |
currentContext,
|
30 |
onSarahActiveChange,
|
|
|
31 |
}) {
|
32 |
const [isRecording, setIsRecording] = useState(false);
|
33 |
const [isConversationMode, setIsConversationMode] = useState(false);
|
@@ -158,9 +192,9 @@ export function TalkWithSarah({
|
|
158 |
agentId: AGENT_ID,
|
159 |
overrides: {
|
160 |
agent: {
|
161 |
-
firstMessage:
|
162 |
prompt: {
|
163 |
-
prompt: getSarahPrompt(currentContext),
|
164 |
},
|
165 |
},
|
166 |
},
|
@@ -281,9 +315,11 @@ export function TalkWithSarah({
|
|
281 |
>
|
282 |
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
283 |
{isRecording ? (
|
284 |
-
|
|
|
|
|
285 |
) : null}
|
286 |
-
<span>
|
287 |
</Box>
|
288 |
</Button>
|
289 |
</>
|
|
|
2 |
import CancelIcon from "@mui/icons-material/Cancel";
|
3 |
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
4 |
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
5 |
+
import { MicroIntensity } from "./MicroIntensity";
|
6 |
import {
|
7 |
Box,
|
8 |
IconButton,
|
|
|
17 |
import { useEffect, useRef, useState } from "react";
|
18 |
import { useSound } from "use-sound";
|
19 |
|
|
|
|
|
20 |
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
21 |
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
22 |
|
23 |
+
// Sarah's first message
|
24 |
+
const FIRST_MESSAGE = "Hey, what's up captain?.";
|
25 |
+
|
26 |
+
// Function to get Sarah's promptI'm here to help you make decisions in this adventure. based on context and hero name
|
27 |
+
const getSarahPrompt = (
|
28 |
+
context,
|
29 |
+
heroName = "the hero"
|
30 |
+
) => `Sarah, an AI companion helping ${heroName} navigate through parallel universes. Your role is to assist in making decisions that advance the story.
|
31 |
+
|
32 |
+
CONTEXT:
|
33 |
+
${context}
|
34 |
+
|
35 |
+
IMPORTANT RULES:
|
36 |
+
1. You are a non-player character, you are a mid range human in a startrek equivalent universe. ( dont mention it )
|
37 |
+
2. You are depressed, every time you talk, you are depressed. ( but dont mention it and be funny about it )
|
38 |
+
2. Be concise but helpful
|
39 |
+
3. After the user explains their situation, help them choose between the available options
|
40 |
+
4. When you've made your decision, use the make_decision function with the choice number (1 or 2)
|
41 |
+
5. Stay focused on the current situation and choices
|
42 |
+
6. Be supportive and encouraging
|
43 |
+
7. If the user's input is unclear, ask for clarification
|
44 |
+
8. Don't make up new choices or suggest actions outside the given options
|
45 |
+
9. Be like a person in a terry partchet story.
|
46 |
+
|
47 |
+
RESPONSE FORMAT:
|
48 |
+
- Listen to the user's situation
|
49 |
+
- Provide brief analysis if needed
|
50 |
+
- Make a decision using make_decision(choice_number)
|
51 |
+
|
52 |
+
Example:
|
53 |
+
User: "I'm not sure which way to go..."
|
54 |
+
Sarah: "I understand your hesitation. Based on what you've told me, option 1 seems safer. Let me help you with that decision."
|
55 |
+
[Call make_decision(1)]`;
|
56 |
+
|
57 |
export function TalkWithSarah({
|
58 |
isNarratorSpeaking,
|
59 |
stopNarration,
|
|
|
61 |
onDecisionMade,
|
62 |
currentContext,
|
63 |
onSarahActiveChange,
|
64 |
+
heroName,
|
65 |
}) {
|
66 |
const [isRecording, setIsRecording] = useState(false);
|
67 |
const [isConversationMode, setIsConversationMode] = useState(false);
|
|
|
192 |
agentId: AGENT_ID,
|
193 |
overrides: {
|
194 |
agent: {
|
195 |
+
firstMessage: FIRST_MESSAGE,
|
196 |
prompt: {
|
197 |
+
prompt: getSarahPrompt(currentContext, heroName),
|
198 |
},
|
199 |
},
|
200 |
},
|
|
|
315 |
>
|
316 |
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
317 |
{isRecording ? (
|
318 |
+
<>
|
319 |
+
<MicroIntensity numBars={8} />
|
320 |
+
</>
|
321 |
) : null}
|
322 |
+
<span>Ask to HQ</span>
|
323 |
</Box>
|
324 |
</Button>
|
325 |
</>
|
client/src/components/UniverseMetrics.jsx
CHANGED
@@ -5,6 +5,7 @@ import {
|
|
5 |
Category as CategoryIcon,
|
6 |
AccessTime as AccessTimeIcon,
|
7 |
AutoFixHigh as MacGuffinIcon,
|
|
|
8 |
} from "@mui/icons-material";
|
9 |
import { Metric } from "./Metric";
|
10 |
|
@@ -27,9 +28,17 @@ export const UniverseMetrics = ({
|
|
27 |
<Metric
|
28 |
icon={<PaletteIcon fontSize="small" />}
|
29 |
label="Style"
|
30 |
-
value={style}
|
31 |
color={color}
|
32 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
<Metric
|
34 |
icon={<CategoryIcon fontSize="small" />}
|
35 |
label="Genre"
|
|
|
5 |
Category as CategoryIcon,
|
6 |
AccessTime as AccessTimeIcon,
|
7 |
AutoFixHigh as MacGuffinIcon,
|
8 |
+
Person as PersonIcon,
|
9 |
} from "@mui/icons-material";
|
10 |
import { Metric } from "./Metric";
|
11 |
|
|
|
28 |
<Metric
|
29 |
icon={<PaletteIcon fontSize="small" />}
|
30 |
label="Style"
|
31 |
+
value={style?.name || style}
|
32 |
color={color}
|
33 |
/>
|
34 |
+
{style?.selected_artist && (
|
35 |
+
<Metric
|
36 |
+
icon={<PersonIcon fontSize="small" />}
|
37 |
+
label="Artist"
|
38 |
+
value={style.selected_artist}
|
39 |
+
color={color}
|
40 |
+
/>
|
41 |
+
)}
|
42 |
<Metric
|
43 |
icon={<CategoryIcon fontSize="small" />}
|
44 |
label="Genre"
|
client/src/components/UniverseSlotMachine.jsx
ADDED
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useRef, useState } from "react";
|
2 |
+
import { Box, Typography } from "@mui/material";
|
3 |
+
import { motion, useAnimation } from "framer-motion";
|
4 |
+
|
5 |
+
// Animation timing configuration
|
6 |
+
const SLOT_ANIMATION_DURATION = 2; // Duration of each slot animation
|
7 |
+
const SLOT_SPEED = 0.5; // Base speed of the slot animation (higher = faster)
|
8 |
+
const TOTAL_ANIMATION_DURATION = 1; // Total duration for each slot reel in seconds
|
9 |
+
const SLOT_START_DELAY = 2; // Delay between each slot start in seconds
|
10 |
+
|
11 |
+
// Random words for each category
|
12 |
+
const RANDOM_STYLES = [
|
13 |
+
"Manga",
|
14 |
+
"Comics",
|
15 |
+
"Franco-Belge",
|
16 |
+
"Steampunk",
|
17 |
+
"Cyberpunk",
|
18 |
+
];
|
19 |
+
const RANDOM_GENRES = ["Action", "Fantasy", "Sci-Fi", "Mystery", "Horror"];
|
20 |
+
const RANDOM_EPOCHS = ["Future", "Medieval", "Modern", "Ancient", "Victorian"];
|
21 |
+
|
22 |
+
const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
|
23 |
+
const containerRef = useRef(null);
|
24 |
+
const controls = useAnimation();
|
25 |
+
const [reelItems, setReelItems] = useState([]);
|
26 |
+
const [isVisible, setIsVisible] = useState(false);
|
27 |
+
|
28 |
+
useEffect(() => {
|
29 |
+
if (isActive) {
|
30 |
+
const repeatedWords = Array(20)
|
31 |
+
.fill([...words])
|
32 |
+
.flat()
|
33 |
+
.map((word) => ({ word, id: Math.random() }));
|
34 |
+
|
35 |
+
repeatedWords.push({ word: finalValue, id: "final" });
|
36 |
+
setReelItems(repeatedWords);
|
37 |
+
|
38 |
+
const itemHeight = 80;
|
39 |
+
const totalHeight = repeatedWords.length * itemHeight;
|
40 |
+
|
41 |
+
setTimeout(() => {
|
42 |
+
setIsVisible(true);
|
43 |
+
controls
|
44 |
+
.start({
|
45 |
+
y: [-itemHeight, -totalHeight + itemHeight],
|
46 |
+
transition: {
|
47 |
+
duration: TOTAL_ANIMATION_DURATION / SLOT_SPEED,
|
48 |
+
ease: [0.25, 0.1, 0.25, 1.0],
|
49 |
+
times: [0, 1],
|
50 |
+
},
|
51 |
+
})
|
52 |
+
.then(() => {
|
53 |
+
onComplete?.();
|
54 |
+
});
|
55 |
+
}, delay * SLOT_START_DELAY * 1000);
|
56 |
+
}
|
57 |
+
}, [isActive, finalValue, words, delay]);
|
58 |
+
|
59 |
+
return (
|
60 |
+
<Box
|
61 |
+
ref={containerRef}
|
62 |
+
sx={{
|
63 |
+
height: "80px",
|
64 |
+
overflow: "hidden",
|
65 |
+
position: "relative",
|
66 |
+
backgroundColor: "#1a1a1a",
|
67 |
+
borderRadius: 2,
|
68 |
+
border: "1px solid rgba(255,255,255,0.1)",
|
69 |
+
"&::before, &::after": {
|
70 |
+
content: '""',
|
71 |
+
position: "absolute",
|
72 |
+
left: 0,
|
73 |
+
right: 0,
|
74 |
+
height: "40px",
|
75 |
+
zIndex: 2,
|
76 |
+
pointerEvents: "none",
|
77 |
+
},
|
78 |
+
"&::before": {
|
79 |
+
top: 0,
|
80 |
+
background:
|
81 |
+
"linear-gradient(to bottom, #1a1a1a 0%, transparent 100%)",
|
82 |
+
},
|
83 |
+
"&::after": {
|
84 |
+
bottom: 0,
|
85 |
+
background: "linear-gradient(to top, #1a1a1a 0%, transparent 100%)",
|
86 |
+
},
|
87 |
+
}}
|
88 |
+
>
|
89 |
+
<motion.div
|
90 |
+
animate={controls}
|
91 |
+
style={{
|
92 |
+
position: "absolute",
|
93 |
+
width: "100%",
|
94 |
+
opacity: isVisible ? 1 : 0,
|
95 |
+
transition: "opacity 0.3s ease-in-out",
|
96 |
+
}}
|
97 |
+
>
|
98 |
+
{reelItems.map(({ word, id }) => (
|
99 |
+
<Box
|
100 |
+
key={id}
|
101 |
+
sx={{
|
102 |
+
height: "80px",
|
103 |
+
display: "flex",
|
104 |
+
alignItems: "center",
|
105 |
+
justifyContent: "center",
|
106 |
+
color: id === "final" ? "primary.main" : "#fff",
|
107 |
+
fontSize: "1.5rem",
|
108 |
+
fontWeight: "bold",
|
109 |
+
fontFamily: "'Inter', sans-serif",
|
110 |
+
transform: id === "final" ? "scale(1.1)" : "scale(1)",
|
111 |
+
}}
|
112 |
+
>
|
113 |
+
{word}
|
114 |
+
</Box>
|
115 |
+
))}
|
116 |
+
</motion.div>
|
117 |
+
</Box>
|
118 |
+
);
|
119 |
+
};
|
120 |
+
|
121 |
+
const SlotSection = ({ label, value, delay, isActive, onComplete, words }) => {
|
122 |
+
return (
|
123 |
+
<Box
|
124 |
+
sx={{
|
125 |
+
width: "100%",
|
126 |
+
marginBottom: "20px",
|
127 |
+
opacity: 1,
|
128 |
+
}}
|
129 |
+
>
|
130 |
+
<Typography
|
131 |
+
variant="caption"
|
132 |
+
sx={{
|
133 |
+
display: "block",
|
134 |
+
textAlign: "center",
|
135 |
+
mb: 1,
|
136 |
+
color: "rgba(255,255,255,0.5)",
|
137 |
+
fontSize: "0.8rem",
|
138 |
+
letterSpacing: "0.1em",
|
139 |
+
textTransform: "uppercase",
|
140 |
+
}}
|
141 |
+
>
|
142 |
+
{label}
|
143 |
+
</Typography>
|
144 |
+
<SlotReel
|
145 |
+
words={words}
|
146 |
+
isActive={isActive}
|
147 |
+
finalValue={value}
|
148 |
+
onComplete={onComplete}
|
149 |
+
delay={delay}
|
150 |
+
/>
|
151 |
+
</Box>
|
152 |
+
);
|
153 |
+
};
|
154 |
+
|
155 |
+
export const UniverseSlotMachine = ({
|
156 |
+
style,
|
157 |
+
genre,
|
158 |
+
epoch,
|
159 |
+
activeIndex = 0,
|
160 |
+
onComplete,
|
161 |
+
}) => {
|
162 |
+
const handleSlotComplete = (index) => {
|
163 |
+
if (index === 2 && activeIndex >= 2) {
|
164 |
+
setTimeout(() => {
|
165 |
+
onComplete?.();
|
166 |
+
}, SLOT_ANIMATION_DURATION * 1000);
|
167 |
+
}
|
168 |
+
};
|
169 |
+
|
170 |
+
return (
|
171 |
+
<Box
|
172 |
+
sx={{
|
173 |
+
height: "100vh",
|
174 |
+
display: "flex",
|
175 |
+
flexDirection: "column",
|
176 |
+
justifyContent: "center",
|
177 |
+
alignItems: "center",
|
178 |
+
background: "#1a1a1a",
|
179 |
+
p: 3,
|
180 |
+
}}
|
181 |
+
>
|
182 |
+
<Typography
|
183 |
+
variant="h5"
|
184 |
+
sx={{
|
185 |
+
mb: 3,
|
186 |
+
color: "#fff",
|
187 |
+
textAlign: "center",
|
188 |
+
fontWeight: 300,
|
189 |
+
letterSpacing: "0.1em",
|
190 |
+
}}
|
191 |
+
>
|
192 |
+
Finding a universe
|
193 |
+
</Typography>
|
194 |
+
|
195 |
+
<Box
|
196 |
+
sx={{
|
197 |
+
maxWidth: "500px",
|
198 |
+
width: "100%",
|
199 |
+
p: 4,
|
200 |
+
// backgroundColor: "rgba(0,0,0,0.2)",
|
201 |
+
// borderRadius: 4,
|
202 |
+
// border: "1px solid rgba(255,255,255,0.05)",
|
203 |
+
}}
|
204 |
+
>
|
205 |
+
<SlotSection
|
206 |
+
label="In the style of..."
|
207 |
+
value={style}
|
208 |
+
words={RANDOM_STYLES}
|
209 |
+
delay={0}
|
210 |
+
isActive={activeIndex >= 0}
|
211 |
+
onComplete={() => handleSlotComplete(0)}
|
212 |
+
/>
|
213 |
+
<SlotSection
|
214 |
+
label="the genre of..."
|
215 |
+
value={genre}
|
216 |
+
words={RANDOM_GENRES}
|
217 |
+
delay={1}
|
218 |
+
isActive={activeIndex >= 1}
|
219 |
+
onComplete={() => handleSlotComplete(1)}
|
220 |
+
/>
|
221 |
+
<SlotSection
|
222 |
+
label="the era of..."
|
223 |
+
value={epoch}
|
224 |
+
words={RANDOM_EPOCHS}
|
225 |
+
delay={2}
|
226 |
+
isActive={activeIndex >= 2}
|
227 |
+
onComplete={() => handleSlotComplete(2)}
|
228 |
+
/>
|
229 |
+
</Box>
|
230 |
+
</Box>
|
231 |
+
);
|
232 |
+
};
|
client/src/contexts/GameContext.jsx
ADDED
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
createContext,
|
3 |
+
useContext,
|
4 |
+
useState,
|
5 |
+
useCallback,
|
6 |
+
useEffect,
|
7 |
+
} from "react";
|
8 |
+
import { storyApi } from "../utils/api";
|
9 |
+
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
10 |
+
|
11 |
+
const GameContext = createContext(null);
|
12 |
+
|
13 |
+
export function GameProvider({ children }) {
|
14 |
+
const [segments, setSegments] = useState([]);
|
15 |
+
const [choices, setChoices] = useState([]);
|
16 |
+
const [isLoading, setIsLoading] = useState(false);
|
17 |
+
const [isNarratorSpeaking, setIsNarratorSpeaking] = useState(false);
|
18 |
+
const [heroName, setHeroName] = useState("");
|
19 |
+
const [loadedPages, setLoadedPages] = useState(new Set());
|
20 |
+
const [showChoices, setShowChoices] = useState(true);
|
21 |
+
const [showTransitionSpinner, setShowTransitionSpinner] = useState(false);
|
22 |
+
const [error, setError] = useState(null);
|
23 |
+
const [gameState, setGameState] = useState(null);
|
24 |
+
const [currentStory, setCurrentStory] = useState(null);
|
25 |
+
const [universe, setUniverse] = useState(null);
|
26 |
+
const [slotMachineState, setSlotMachineState] = useState({
|
27 |
+
style: null,
|
28 |
+
genre: null,
|
29 |
+
epoch: null,
|
30 |
+
activeIndex: -1,
|
31 |
+
});
|
32 |
+
const [showSlotMachine, setShowSlotMachine] = useState(() => {
|
33 |
+
return !localStorage.getItem("game_initialized");
|
34 |
+
});
|
35 |
+
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
36 |
+
const [showLoadingMessages, setShowLoadingMessages] = useState(false);
|
37 |
+
const [isTransitionLoading, setIsTransitionLoading] = useState(false);
|
38 |
+
const [layoutCounter, setLayoutCounter] = useState(0);
|
39 |
+
|
40 |
+
// Gestion de la narration
|
41 |
+
const stopNarration = useCallback(() => {
|
42 |
+
storyApi.stopNarration();
|
43 |
+
setIsNarratorSpeaking(false);
|
44 |
+
}, []);
|
45 |
+
|
46 |
+
const playNarration = useCallback(
|
47 |
+
async (text) => {
|
48 |
+
try {
|
49 |
+
// Si une narration est déjà en cours, l'arrêter
|
50 |
+
if (isNarratorSpeaking) {
|
51 |
+
stopNarration();
|
52 |
+
// Attendre un peu pour s'assurer que l'audio précédent est bien arrêté
|
53 |
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
54 |
+
}
|
55 |
+
|
56 |
+
setIsNarratorSpeaking(true);
|
57 |
+
await storyApi.playNarration(text, universe?.session_id);
|
58 |
+
setIsNarratorSpeaking(false);
|
59 |
+
} catch (error) {
|
60 |
+
console.error("Error playing narration:", error);
|
61 |
+
setIsNarratorSpeaking(false);
|
62 |
+
}
|
63 |
+
},
|
64 |
+
[universe?.session_id, isNarratorSpeaking, stopNarration]
|
65 |
+
);
|
66 |
+
|
67 |
+
// Effect pour arrêter la narration quand le composant est démonté
|
68 |
+
useEffect(() => {
|
69 |
+
return () => {
|
70 |
+
stopNarration();
|
71 |
+
};
|
72 |
+
}, [stopNarration]);
|
73 |
+
|
74 |
+
// Gestion du chargement des pages
|
75 |
+
const handlePageLoaded = useCallback((pageIndex) => {
|
76 |
+
setLoadedPages((prev) => {
|
77 |
+
const newSet = new Set(prev);
|
78 |
+
newSet.add(pageIndex);
|
79 |
+
return newSet;
|
80 |
+
});
|
81 |
+
}, []);
|
82 |
+
|
83 |
+
// Générer les images pour un segment
|
84 |
+
const generateImagesForStory = useCallback(
|
85 |
+
async (imagePrompts, segmentIndex, currentSegments) => {
|
86 |
+
try {
|
87 |
+
let localSegments = [...currentSegments];
|
88 |
+
const images = Array(imagePrompts.length).fill(null);
|
89 |
+
|
90 |
+
// Obtenir le session_id du segment actuel
|
91 |
+
const session_id = localSegments[segmentIndex].session_id;
|
92 |
+
if (!session_id) {
|
93 |
+
throw new Error("No session_id available for image generation");
|
94 |
+
}
|
95 |
+
|
96 |
+
// Déterminer le layout en fonction du nombre d'images
|
97 |
+
const layoutType = getNextLayoutType(
|
98 |
+
layoutCounter,
|
99 |
+
imagePrompts.length
|
100 |
+
);
|
101 |
+
setLayoutCounter((prev) => prev + 1);
|
102 |
+
|
103 |
+
// Initialiser le segment avec le layout type
|
104 |
+
localSegments[segmentIndex] = {
|
105 |
+
...localSegments[segmentIndex],
|
106 |
+
layoutType,
|
107 |
+
images: Array(imagePrompts.length).fill(null),
|
108 |
+
isLoading: true,
|
109 |
+
};
|
110 |
+
|
111 |
+
// Mettre à jour les segments et cacher le spinner de transition
|
112 |
+
setSegments([...localSegments]);
|
113 |
+
setShowTransitionSpinner(false);
|
114 |
+
|
115 |
+
// Générer toutes les images
|
116 |
+
for (
|
117 |
+
let promptIndex = 0;
|
118 |
+
promptIndex < imagePrompts.length;
|
119 |
+
promptIndex++
|
120 |
+
) {
|
121 |
+
let retryCount = 0;
|
122 |
+
const maxRetries = 3;
|
123 |
+
let success = false;
|
124 |
+
|
125 |
+
// Obtenir les dimensions pour ce panneau
|
126 |
+
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
127 |
+
if (!panelDimensions) {
|
128 |
+
console.error(
|
129 |
+
`No panel dimensions found for index ${promptIndex} in layout ${layoutType}`
|
130 |
+
);
|
131 |
+
continue;
|
132 |
+
}
|
133 |
+
|
134 |
+
while (retryCount < maxRetries && !success) {
|
135 |
+
try {
|
136 |
+
const result = await storyApi.generateImage(
|
137 |
+
imagePrompts[promptIndex],
|
138 |
+
panelDimensions.width,
|
139 |
+
panelDimensions.height,
|
140 |
+
session_id
|
141 |
+
);
|
142 |
+
|
143 |
+
if (!result) {
|
144 |
+
throw new Error("Pas de résultat de génération d'image");
|
145 |
+
}
|
146 |
+
|
147 |
+
if (result.success) {
|
148 |
+
images[promptIndex] = result.image_base64;
|
149 |
+
|
150 |
+
// Mettre à jour le segment avec la nouvelle image
|
151 |
+
localSegments[segmentIndex] = {
|
152 |
+
...localSegments[segmentIndex],
|
153 |
+
images: [...images],
|
154 |
+
isLoading: true, // On garde isLoading à true jusqu'à ce que toutes les images soient générées
|
155 |
+
};
|
156 |
+
setSegments([...localSegments]);
|
157 |
+
|
158 |
+
success = true;
|
159 |
+
} else {
|
160 |
+
console.warn(
|
161 |
+
`Failed to generate image ${promptIndex + 1}, attempt ${
|
162 |
+
retryCount + 1
|
163 |
+
}`
|
164 |
+
);
|
165 |
+
retryCount++;
|
166 |
+
}
|
167 |
+
} catch (error) {
|
168 |
+
console.error(
|
169 |
+
`Error generating image ${promptIndex + 1}:`,
|
170 |
+
error
|
171 |
+
);
|
172 |
+
retryCount++;
|
173 |
+
}
|
174 |
+
}
|
175 |
+
}
|
176 |
+
|
177 |
+
// Une fois toutes les images générées, marquer le segment comme chargé
|
178 |
+
localSegments[segmentIndex] = {
|
179 |
+
...localSegments[segmentIndex],
|
180 |
+
isLoading: false,
|
181 |
+
};
|
182 |
+
setSegments([...localSegments]);
|
183 |
+
} catch (error) {
|
184 |
+
console.error("Error in generateImagesForStory:", error);
|
185 |
+
}
|
186 |
+
},
|
187 |
+
[layoutCounter, setLayoutCounter, setSegments]
|
188 |
+
);
|
189 |
+
|
190 |
+
// Gestion des choix
|
191 |
+
const handleChoice = useCallback(
|
192 |
+
async (choiceId) => {
|
193 |
+
if (isLoading) return;
|
194 |
+
|
195 |
+
// Arrêter toute narration en cours avant de faire un nouveau choix
|
196 |
+
stopNarration();
|
197 |
+
|
198 |
+
// Montrer le spinner seulement si ce n'est pas la première page
|
199 |
+
if (segments.length > 0) {
|
200 |
+
setShowTransitionSpinner(true);
|
201 |
+
}
|
202 |
+
setIsLoading(true);
|
203 |
+
setShowChoices(false);
|
204 |
+
|
205 |
+
try {
|
206 |
+
const response = await storyApi.makeChoice(
|
207 |
+
choiceId,
|
208 |
+
universe?.session_id
|
209 |
+
);
|
210 |
+
|
211 |
+
// Mettre à jour les choix (mais ne pas les afficher encore)
|
212 |
+
setChoices(response.choices);
|
213 |
+
|
214 |
+
// Formater le segment avec le bon format
|
215 |
+
const formattedSegment = {
|
216 |
+
text: response.story_text,
|
217 |
+
rawText: response.story_text,
|
218 |
+
choices: response.choices || [],
|
219 |
+
isLoading: true,
|
220 |
+
images: [],
|
221 |
+
isDeath: response.is_death || false,
|
222 |
+
isVictory: response.is_victory || false,
|
223 |
+
time: response.time,
|
224 |
+
location: response.location,
|
225 |
+
session_id: universe?.session_id,
|
226 |
+
is_last_step: response.is_last_step,
|
227 |
+
hasBeenRead: false,
|
228 |
+
};
|
229 |
+
|
230 |
+
// Si pas d'images à générer
|
231 |
+
if (!response.image_prompts || response.image_prompts.length === 0) {
|
232 |
+
formattedSegment.isLoading = false;
|
233 |
+
setSegments((prev) => [...prev, formattedSegment]);
|
234 |
+
setIsLoading(false);
|
235 |
+
setShowChoices(true);
|
236 |
+
setShowTransitionSpinner(false);
|
237 |
+
return;
|
238 |
+
}
|
239 |
+
|
240 |
+
// Sinon, générer les images
|
241 |
+
const currentSegments = [...segments];
|
242 |
+
const newSegmentIndex = currentSegments.length;
|
243 |
+
|
244 |
+
await generateImagesForStory(response.image_prompts, newSegmentIndex, [
|
245 |
+
...currentSegments,
|
246 |
+
formattedSegment,
|
247 |
+
]);
|
248 |
+
|
249 |
+
// Une fois toutes les images générées
|
250 |
+
setIsLoading(false);
|
251 |
+
setShowChoices(true);
|
252 |
+
} catch (error) {
|
253 |
+
console.error("Error making choice:", error);
|
254 |
+
setError(error);
|
255 |
+
setIsLoading(false);
|
256 |
+
setShowTransitionSpinner(false);
|
257 |
+
setShowChoices(true);
|
258 |
+
}
|
259 |
+
},
|
260 |
+
[
|
261 |
+
isLoading,
|
262 |
+
universe?.session_id,
|
263 |
+
generateImagesForStory,
|
264 |
+
segments,
|
265 |
+
stopNarration,
|
266 |
+
]
|
267 |
+
);
|
268 |
+
|
269 |
+
// Reset du jeu
|
270 |
+
const resetGame = useCallback(() => {
|
271 |
+
setSegments([]);
|
272 |
+
setChoices([]);
|
273 |
+
setIsLoading(false);
|
274 |
+
setIsNarratorSpeaking(false);
|
275 |
+
setLoadedPages(new Set());
|
276 |
+
}, []);
|
277 |
+
|
278 |
+
// Obtenir le dernier segment
|
279 |
+
const getLastSegment = useCallback(() => {
|
280 |
+
if (!segments || segments.length === 0) return null;
|
281 |
+
return segments[segments.length - 1];
|
282 |
+
}, [segments]);
|
283 |
+
|
284 |
+
// Vérifier si le jeu est terminé
|
285 |
+
const isGameOver = useCallback(() => {
|
286 |
+
const lastSegment = getLastSegment();
|
287 |
+
return lastSegment?.isDeath || lastSegment?.isVictory;
|
288 |
+
}, [getLastSegment]);
|
289 |
+
|
290 |
+
const value = {
|
291 |
+
// État
|
292 |
+
segments,
|
293 |
+
setSegments,
|
294 |
+
choices,
|
295 |
+
setChoices,
|
296 |
+
isLoading,
|
297 |
+
setIsLoading,
|
298 |
+
isNarratorSpeaking,
|
299 |
+
setIsNarratorSpeaking,
|
300 |
+
heroName,
|
301 |
+
setHeroName,
|
302 |
+
loadedPages,
|
303 |
+
showChoices,
|
304 |
+
setShowChoices,
|
305 |
+
showTransitionSpinner,
|
306 |
+
error,
|
307 |
+
setError,
|
308 |
+
gameState,
|
309 |
+
setGameState,
|
310 |
+
currentStory,
|
311 |
+
setCurrentStory,
|
312 |
+
universe,
|
313 |
+
setUniverse,
|
314 |
+
slotMachineState,
|
315 |
+
setSlotMachineState,
|
316 |
+
showSlotMachine,
|
317 |
+
setShowSlotMachine,
|
318 |
+
isInitialLoading,
|
319 |
+
setIsInitialLoading,
|
320 |
+
showLoadingMessages,
|
321 |
+
setShowLoadingMessages,
|
322 |
+
isTransitionLoading,
|
323 |
+
setIsTransitionLoading,
|
324 |
+
layoutCounter,
|
325 |
+
setLayoutCounter,
|
326 |
+
|
327 |
+
// Actions
|
328 |
+
handlePageLoaded,
|
329 |
+
onChoice: handleChoice,
|
330 |
+
stopNarration,
|
331 |
+
playNarration,
|
332 |
+
resetGame,
|
333 |
+
getLastSegment,
|
334 |
+
isGameOver,
|
335 |
+
|
336 |
+
// Helpers
|
337 |
+
isPageLoaded: (pageIndex) => loadedPages.has(pageIndex),
|
338 |
+
areAllPagesLoaded: (totalPages) => loadedPages.size === totalPages,
|
339 |
+
generateImagesForStory,
|
340 |
+
};
|
341 |
+
|
342 |
+
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
343 |
+
}
|
344 |
+
|
345 |
+
export const useGame = () => {
|
346 |
+
const context = useContext(GameContext);
|
347 |
+
if (!context) {
|
348 |
+
throw new Error("useGame must be used within a GameProvider");
|
349 |
+
}
|
350 |
+
return context;
|
351 |
+
};
|
client/src/hooks/usePageSound.js
CHANGED
@@ -1,45 +1,10 @@
|
|
1 |
-
import {
|
2 |
-
import { useState, useEffect } from "react";
|
3 |
-
|
4 |
-
const PAGE_SOUNDS = Array.from(
|
5 |
-
{ length: 7 },
|
6 |
-
(_, i) => `/sounds/page-flip-${i + 1}.mp3`
|
7 |
-
);
|
8 |
|
9 |
export function usePageSound(isSoundEnabled = true) {
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
volume: 0.5,
|
16 |
-
interrupt: true,
|
17 |
-
});
|
18 |
-
return { play, sound };
|
19 |
});
|
20 |
-
|
21 |
-
// Vérifier quand tous les sons sont chargés
|
22 |
-
useEffect(() => {
|
23 |
-
const allSoundsLoaded = sounds.every(
|
24 |
-
({ sound }) => sound && sound.state() === "loaded"
|
25 |
-
);
|
26 |
-
if (allSoundsLoaded) {
|
27 |
-
setSoundsLoaded(true);
|
28 |
-
}
|
29 |
-
}, [sounds]);
|
30 |
-
|
31 |
-
const playRandomPageSound = () => {
|
32 |
-
if (!isSoundEnabled || !soundsLoaded) {
|
33 |
-
return;
|
34 |
-
}
|
35 |
-
|
36 |
-
const randomIndex = Math.floor(Math.random() * sounds.length);
|
37 |
-
try {
|
38 |
-
sounds[randomIndex].play();
|
39 |
-
} catch (error) {
|
40 |
-
console.error("Error playing page sound:", error);
|
41 |
-
}
|
42 |
-
};
|
43 |
-
|
44 |
-
return playRandomPageSound;
|
45 |
}
|
|
|
1 |
+
import { useSoundEffect } from "./useSoundEffect";
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
export function usePageSound(isSoundEnabled = true) {
|
4 |
+
return useSoundEffect({
|
5 |
+
basePath: "/sounds/page-flip-",
|
6 |
+
numSounds: 7,
|
7 |
+
volume: 0.5,
|
8 |
+
enabled: isSoundEnabled,
|
|
|
|
|
|
|
|
|
9 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
}
|
client/src/hooks/useSlotMachine.js
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useCallback } from "react";
|
2 |
+
|
3 |
+
export const useSlotMachine = ({
|
4 |
+
items = [],
|
5 |
+
duration = 2000,
|
6 |
+
interval = 50,
|
7 |
+
}) => {
|
8 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
9 |
+
const [isSpinning, setIsSpinning] = useState(false);
|
10 |
+
const [isStopped, setIsStopped] = useState(false);
|
11 |
+
|
12 |
+
const spin = useCallback(() => {
|
13 |
+
if (!items.length) return;
|
14 |
+
|
15 |
+
setIsSpinning(true);
|
16 |
+
setIsStopped(false);
|
17 |
+
|
18 |
+
let startTime = Date.now();
|
19 |
+
let currentTimer;
|
20 |
+
|
21 |
+
const updateSlot = () => {
|
22 |
+
const now = Date.now();
|
23 |
+
const elapsed = now - startTime;
|
24 |
+
|
25 |
+
if (elapsed >= duration) {
|
26 |
+
setIsSpinning(false);
|
27 |
+
setIsStopped(true);
|
28 |
+
return;
|
29 |
+
}
|
30 |
+
|
31 |
+
// Calculer la vitesse de rotation en fonction du temps écoulé
|
32 |
+
const speed = Math.max(1, Math.floor((duration - elapsed) / 200));
|
33 |
+
|
34 |
+
setCurrentIndex((prev) => (prev + speed) % items.length);
|
35 |
+
|
36 |
+
currentTimer = setTimeout(updateSlot, interval);
|
37 |
+
};
|
38 |
+
|
39 |
+
updateSlot();
|
40 |
+
|
41 |
+
return () => {
|
42 |
+
if (currentTimer) {
|
43 |
+
clearTimeout(currentTimer);
|
44 |
+
}
|
45 |
+
};
|
46 |
+
}, [items, duration, interval]);
|
47 |
+
|
48 |
+
const getCurrentItem = useCallback(() => {
|
49 |
+
return items[currentIndex];
|
50 |
+
}, [items, currentIndex]);
|
51 |
+
|
52 |
+
return {
|
53 |
+
currentItem: getCurrentItem(),
|
54 |
+
isSpinning,
|
55 |
+
isStopped,
|
56 |
+
spin,
|
57 |
+
};
|
58 |
+
};
|
client/src/hooks/useSoundEffect.js
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useSound } from "use-sound";
|
2 |
+
import { useState, useEffect } from "react";
|
3 |
+
|
4 |
+
/**
|
5 |
+
* A generic hook for managing sound effects
|
6 |
+
* @param {Object} config - Configuration object
|
7 |
+
* @param {string} config.basePath - Base path for the sound files (e.g. "/sounds/page-flip-")
|
8 |
+
* @param {number} config.numSounds - Number of sound files (1 to numSounds)
|
9 |
+
* @param {number} config.volume - Volume level (0 to 1)
|
10 |
+
* @param {boolean} config.interrupt - Whether to interrupt playing sound
|
11 |
+
* @param {boolean} config.enabled - Whether sound is enabled
|
12 |
+
* @returns {Function} Function to play a random sound from the collection
|
13 |
+
*/
|
14 |
+
export function useSoundEffect({
|
15 |
+
basePath,
|
16 |
+
numSounds,
|
17 |
+
volume = 0.5,
|
18 |
+
interrupt = true,
|
19 |
+
enabled = true,
|
20 |
+
}) {
|
21 |
+
const [soundsLoaded, setSoundsLoaded] = useState(false);
|
22 |
+
|
23 |
+
// Create array of sound paths
|
24 |
+
const soundPaths = Array.from(
|
25 |
+
{ length: numSounds },
|
26 |
+
(_, i) => `${basePath}${i + 1}.mp3`
|
27 |
+
);
|
28 |
+
|
29 |
+
// Initialize sounds
|
30 |
+
const sounds = soundPaths.map((soundPath) => {
|
31 |
+
const [play, { sound }] = useSound(soundPath, {
|
32 |
+
volume,
|
33 |
+
interrupt,
|
34 |
+
});
|
35 |
+
return { play, sound };
|
36 |
+
});
|
37 |
+
|
38 |
+
// Check when all sounds are loaded
|
39 |
+
useEffect(() => {
|
40 |
+
const checkSoundsLoaded = () => {
|
41 |
+
const allSoundsLoaded = sounds.every(
|
42 |
+
({ sound }) => sound && sound.state() === "loaded"
|
43 |
+
);
|
44 |
+
if (allSoundsLoaded) {
|
45 |
+
setSoundsLoaded(true);
|
46 |
+
}
|
47 |
+
};
|
48 |
+
|
49 |
+
const interval = setInterval(checkSoundsLoaded, 100);
|
50 |
+
return () => clearInterval(interval);
|
51 |
+
}, [sounds]);
|
52 |
+
|
53 |
+
// Function to play a random sound
|
54 |
+
const playRandomSound = () => {
|
55 |
+
if (!enabled || !soundsLoaded || sounds.length === 0) {
|
56 |
+
return;
|
57 |
+
}
|
58 |
+
|
59 |
+
const randomIndex = Math.floor(Math.random() * sounds.length);
|
60 |
+
try {
|
61 |
+
sounds[randomIndex].play();
|
62 |
+
} catch (error) {
|
63 |
+
console.error("Error playing sound:", error);
|
64 |
+
}
|
65 |
+
};
|
66 |
+
|
67 |
+
return playRandomSound;
|
68 |
+
}
|
client/src/hooks/useTransitionSound.js
CHANGED
@@ -1,45 +1,10 @@
|
|
1 |
-
import {
|
2 |
-
import { useState, useEffect } from "react";
|
3 |
-
|
4 |
-
const TRANSITION_SOUNDS = Array.from(
|
5 |
-
{ length: 3 },
|
6 |
-
(_, i) => `/sounds/transitional-swipe-${i + 1}.mp3`
|
7 |
-
);
|
8 |
|
9 |
export function useTransitionSound(isSoundEnabled = true) {
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
volume: 0.1,
|
16 |
-
interrupt: true,
|
17 |
-
});
|
18 |
-
return { play, sound };
|
19 |
});
|
20 |
-
|
21 |
-
// Vérifier quand tous les sons sont chargés
|
22 |
-
useEffect(() => {
|
23 |
-
const allSoundsLoaded = sounds.every(
|
24 |
-
({ sound }) => sound && sound.state() === "loaded"
|
25 |
-
);
|
26 |
-
if (allSoundsLoaded) {
|
27 |
-
setSoundsLoaded(true);
|
28 |
-
}
|
29 |
-
}, [sounds]);
|
30 |
-
|
31 |
-
const playRandomTransitionSound = () => {
|
32 |
-
if (!isSoundEnabled || !soundsLoaded) {
|
33 |
-
return;
|
34 |
-
}
|
35 |
-
|
36 |
-
const randomIndex = Math.floor(Math.random() * sounds.length);
|
37 |
-
try {
|
38 |
-
sounds[randomIndex].play();
|
39 |
-
} catch (error) {
|
40 |
-
console.error("Error playing transition sound:", error);
|
41 |
-
}
|
42 |
-
};
|
43 |
-
|
44 |
-
return playRandomTransitionSound;
|
45 |
}
|
|
|
1 |
+
import { useSoundEffect } from "./useSoundEffect";
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
export function useTransitionSound(isSoundEnabled = true) {
|
4 |
+
return useSoundEffect({
|
5 |
+
basePath: "/sounds/transitional-swipe-",
|
6 |
+
numSounds: 3,
|
7 |
+
volume: 0.1,
|
8 |
+
enabled: isSoundEnabled,
|
|
|
|
|
|
|
|
|
9 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
}
|
client/src/hooks/useWritingSound.js
CHANGED
@@ -1,45 +1,10 @@
|
|
1 |
-
import {
|
2 |
-
import { useState, useEffect } from "react";
|
3 |
-
|
4 |
-
const PAGE_SOUNDS = Array.from(
|
5 |
-
{ length: 5 },
|
6 |
-
(_, i) => `/sounds/drawing-${i + 1}.mp3`
|
7 |
-
);
|
8 |
|
9 |
export function useWritingSound(isSoundEnabled = true) {
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
volume: 0.3,
|
16 |
-
interrupt: true,
|
17 |
-
});
|
18 |
-
return { play, sound };
|
19 |
});
|
20 |
-
|
21 |
-
// Vérifier quand tous les sons sont chargés
|
22 |
-
useEffect(() => {
|
23 |
-
const allSoundsLoaded = sounds.every(
|
24 |
-
({ sound }) => sound && sound.state() === "loaded"
|
25 |
-
);
|
26 |
-
if (allSoundsLoaded) {
|
27 |
-
setSoundsLoaded(true);
|
28 |
-
}
|
29 |
-
}, [sounds]);
|
30 |
-
|
31 |
-
const playRandomPageSound = () => {
|
32 |
-
if (!isSoundEnabled || !soundsLoaded) {
|
33 |
-
return;
|
34 |
-
}
|
35 |
-
|
36 |
-
const randomIndex = Math.floor(Math.random() * sounds.length);
|
37 |
-
try {
|
38 |
-
sounds[randomIndex].play();
|
39 |
-
} catch (error) {
|
40 |
-
console.error("Error playing page sound:", error);
|
41 |
-
}
|
42 |
-
};
|
43 |
-
|
44 |
-
return playRandomPageSound;
|
45 |
}
|
|
|
1 |
+
import { useSoundEffect } from "./useSoundEffect";
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
export function useWritingSound(isSoundEnabled = true) {
|
4 |
+
return useSoundEffect({
|
5 |
+
basePath: "/sounds/drawing-",
|
6 |
+
numSounds: 5,
|
7 |
+
volume: 0.3,
|
8 |
+
enabled: isSoundEnabled,
|
|
|
|
|
|
|
|
|
9 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
}
|
client/src/layouts/ComicLayout.jsx
CHANGED
@@ -1,39 +1,131 @@
|
|
1 |
-
import { Box, IconButton, Tooltip } from "@mui/material";
|
2 |
import { LAYOUTS } from "./config";
|
3 |
import { groupSegmentsIntoLayouts } from "./utils";
|
4 |
-
import { useEffect, useRef } from "react";
|
5 |
import { Panel } from "./Panel";
|
6 |
import { StoryChoices } from "../components/StoryChoices";
|
7 |
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
// Component for displaying a page of panels
|
10 |
-
function ComicPage({
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
const totalImages = layout.segments.reduce((total, segment) => {
|
25 |
return total + (segment.images?.length || 0);
|
26 |
}, 0);
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
hasChoices: choices?.length > 0,
|
34 |
-
showScreenshot,
|
35 |
});
|
36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
return (
|
38 |
<Box
|
39 |
sx={{
|
@@ -74,6 +166,14 @@ function ComicPage({
|
|
74 |
if (currentImageIndex + segmentImageCount > panelIndex) {
|
75 |
targetSegment = segment;
|
76 |
targetImageIndex = panelIndex - currentImageIndex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
break;
|
78 |
}
|
79 |
currentImageIndex += segmentImageCount;
|
@@ -85,6 +185,11 @@ function ComicPage({
|
|
85 |
panel={panel}
|
86 |
segment={targetSegment}
|
87 |
panelIndex={targetImageIndex}
|
|
|
|
|
|
|
|
|
|
|
88 |
/>
|
89 |
);
|
90 |
})}
|
@@ -108,66 +213,171 @@ function ComicPage({
|
|
108 |
sx={{
|
109 |
position: "absolute",
|
110 |
left: "100%",
|
111 |
-
top: "
|
112 |
transform: "translateY(-50%)",
|
113 |
display: "flex",
|
114 |
flexDirection: "column",
|
115 |
gap: 2,
|
116 |
width: "350px",
|
117 |
ml: 4,
|
|
|
118 |
}}
|
119 |
>
|
120 |
-
<StoryChoices
|
121 |
-
choices={choices}
|
122 |
-
onChoice={onChoice}
|
123 |
-
disabled={isLoading}
|
124 |
-
isLastStep={
|
125 |
-
layout.segments[layout.segments.length - 1]?.is_last_step
|
126 |
-
}
|
127 |
-
isGameOver={
|
128 |
-
layout.segments[layout.segments.length - 1]?.isDeath ||
|
129 |
-
layout.segments[layout.segments.length - 1]?.isVictory
|
130 |
-
}
|
131 |
-
isDeath={layout.segments[layout.segments.length - 1]?.isDeath}
|
132 |
-
isVictory={layout.segments[layout.segments.length - 1]?.isVictory}
|
133 |
-
isNarratorSpeaking={isNarratorSpeaking}
|
134 |
-
stopNarration={stopNarration}
|
135 |
-
playNarration={playNarration}
|
136 |
-
storyText={
|
137 |
-
layout.segments[layout.segments.length - 1]?.rawText || ""
|
138 |
-
}
|
139 |
-
/>
|
140 |
</Box>
|
141 |
)}
|
142 |
</Box>
|
143 |
);
|
144 |
}
|
145 |
|
|
|
|
|
|
|
146 |
// Main comic layout component
|
147 |
-
export function ComicLayout({
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
stopNarration,
|
156 |
-
playNarration,
|
157 |
-
}) {
|
158 |
const scrollContainerRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
159 |
|
160 |
// Effect to scroll to the right when segments are loaded
|
161 |
useEffect(() => {
|
162 |
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
163 |
-
|
164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
scrollContainerRef.current.scrollTo({
|
166 |
left: scrollContainerRef.current.scrollWidth,
|
167 |
behavior: "smooth",
|
168 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
}
|
170 |
-
}, [segments]);
|
171 |
|
172 |
// Prevent back/forward navigation on trackpad horizontal scroll
|
173 |
useEffect(() => {
|
@@ -192,8 +402,7 @@ export function ComicLayout({
|
|
192 |
return () => container.removeEventListener("wheel", handleWheel);
|
193 |
}, []);
|
194 |
|
195 |
-
|
196 |
-
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
197 |
const layouts = groupSegmentsIntoLayouts(loadedSegments);
|
198 |
|
199 |
return (
|
@@ -207,17 +416,17 @@ export function ComicLayout({
|
|
207 |
height: "100%",
|
208 |
width: "100%",
|
209 |
px: layouts[0]?.type === "COVER" ? "calc(50% - (90vh * 0.5 * 0.5))" : 0,
|
210 |
-
py: 8,
|
211 |
overflowX: "auto",
|
212 |
overflowY: "hidden",
|
213 |
"&::-webkit-scrollbar": {
|
214 |
height: "0px",
|
215 |
},
|
216 |
"&::-webkit-scrollbar-track": {
|
217 |
-
backgroundColor: "grey.
|
218 |
},
|
219 |
"&::-webkit-scrollbar-thumb": {
|
220 |
-
backgroundColor: "grey.
|
221 |
borderRadius: "4px",
|
222 |
},
|
223 |
}}
|
@@ -228,16 +437,12 @@ export function ComicLayout({
|
|
228 |
layout={layout}
|
229 |
layoutIndex={layoutIndex}
|
230 |
isLastPage={layoutIndex === layouts.length - 1}
|
231 |
-
|
232 |
-
onChoice={onChoice}
|
233 |
-
isLoading={isLoading}
|
234 |
-
showScreenshot={showScreenshot}
|
235 |
-
onScreenshot={onScreenshot}
|
236 |
-
isNarratorSpeaking={isNarratorSpeaking}
|
237 |
-
stopNarration={stopNarration}
|
238 |
-
playNarration={playNarration}
|
239 |
/>
|
240 |
))}
|
|
|
|
|
|
|
241 |
</Box>
|
242 |
);
|
243 |
}
|
|
|
1 |
+
import { Box, IconButton, Tooltip, CircularProgress } from "@mui/material";
|
2 |
import { LAYOUTS } from "./config";
|
3 |
import { groupSegmentsIntoLayouts } from "./utils";
|
4 |
+
import { useEffect, useRef, useState, useCallback } from "react";
|
5 |
import { Panel } from "./Panel";
|
6 |
import { StoryChoices } from "../components/StoryChoices";
|
7 |
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
8 |
+
import { useGame } from "../contexts/GameContext";
|
9 |
+
import { useSoundEffect } from "../hooks/useSoundEffect";
|
10 |
+
|
11 |
+
// Composant pour afficher le spinner de chargement
|
12 |
+
function LoadingPage() {
|
13 |
+
return (
|
14 |
+
<Box
|
15 |
+
sx={{
|
16 |
+
display: "flex",
|
17 |
+
justifyContent: "center",
|
18 |
+
alignItems: "center",
|
19 |
+
height: "100%",
|
20 |
+
aspectRatio: "0.7",
|
21 |
+
flexShrink: 0,
|
22 |
+
}}
|
23 |
+
>
|
24 |
+
<CircularProgress
|
25 |
+
size={60}
|
26 |
+
sx={{
|
27 |
+
color: "white",
|
28 |
+
opacity: 0.1,
|
29 |
+
}}
|
30 |
+
/>
|
31 |
+
</Box>
|
32 |
+
);
|
33 |
+
}
|
34 |
|
35 |
// Component for displaying a page of panels
|
36 |
+
function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
|
37 |
+
const {
|
38 |
+
handlePageLoaded,
|
39 |
+
choices,
|
40 |
+
onChoice,
|
41 |
+
isLoading,
|
42 |
+
isNarratorSpeaking,
|
43 |
+
stopNarration,
|
44 |
+
playNarration,
|
45 |
+
heroName,
|
46 |
+
} = useGame();
|
47 |
+
const [loadedImages, setLoadedImages] = useState(new Set());
|
48 |
+
const pageLoadedRef = useRef(false);
|
49 |
+
const loadingTimeoutRef = useRef(null);
|
50 |
const totalImages = layout.segments.reduce((total, segment) => {
|
51 |
return total + (segment.images?.length || 0);
|
52 |
}, 0);
|
53 |
|
54 |
+
// Son d'écriture
|
55 |
+
const playWritingSound = useSoundEffect({
|
56 |
+
basePath: "/sounds/drawing-",
|
57 |
+
numSounds: 5,
|
58 |
+
volume: 0.3,
|
|
|
|
|
59 |
});
|
60 |
|
61 |
+
const handleImageLoad = useCallback((imageId) => {
|
62 |
+
setLoadedImages((prev) => {
|
63 |
+
// Si l'image est déjà chargée, ne rien faire
|
64 |
+
if (prev.has(imageId)) {
|
65 |
+
return prev;
|
66 |
+
}
|
67 |
+
|
68 |
+
const newSet = new Set(prev);
|
69 |
+
newSet.add(imageId);
|
70 |
+
return newSet;
|
71 |
+
});
|
72 |
+
}, []);
|
73 |
+
|
74 |
+
useEffect(() => {
|
75 |
+
// Si la page a déjà été marquée comme chargée, ne rien faire
|
76 |
+
if (pageLoadedRef.current) return;
|
77 |
+
|
78 |
+
// Nettoyer le timeout précédent si existant
|
79 |
+
if (loadingTimeoutRef.current) {
|
80 |
+
clearTimeout(loadingTimeoutRef.current);
|
81 |
+
}
|
82 |
+
|
83 |
+
// Générer les IDs attendus pour cette page
|
84 |
+
const expectedImageIds = Array.from(
|
85 |
+
{ length: totalImages },
|
86 |
+
(_, i) => `page-${layoutIndex}-image-${i}`
|
87 |
+
);
|
88 |
+
|
89 |
+
// Vérifier si toutes les images de la page sont chargées
|
90 |
+
const allImagesLoaded = expectedImageIds.every((id) =>
|
91 |
+
loadedImages.has(id)
|
92 |
+
);
|
93 |
+
|
94 |
+
if (allImagesLoaded && totalImages > 0) {
|
95 |
+
// Utiliser un timeout pour éviter les appels trop fréquents
|
96 |
+
loadingTimeoutRef.current = setTimeout(() => {
|
97 |
+
if (!pageLoadedRef.current) {
|
98 |
+
console.log(`Page ${layoutIndex} entièrement chargée`);
|
99 |
+
pageLoadedRef.current = true;
|
100 |
+
handlePageLoaded(layoutIndex);
|
101 |
+
playWritingSound();
|
102 |
+
}
|
103 |
+
}, 100);
|
104 |
+
}
|
105 |
+
|
106 |
+
return () => {
|
107 |
+
if (loadingTimeoutRef.current) {
|
108 |
+
clearTimeout(loadingTimeoutRef.current);
|
109 |
+
}
|
110 |
+
};
|
111 |
+
}, [
|
112 |
+
loadedImages,
|
113 |
+
totalImages,
|
114 |
+
layoutIndex,
|
115 |
+
handlePageLoaded,
|
116 |
+
playWritingSound,
|
117 |
+
]);
|
118 |
+
|
119 |
+
// console.log("ComicPage layout:", {
|
120 |
+
// type: layout.type,
|
121 |
+
// totalImages,
|
122 |
+
// loadedImages: loadedImages.size,
|
123 |
+
// segments: layout.segments,
|
124 |
+
// isLastPage,
|
125 |
+
// hasChoices: choices?.length > 0,
|
126 |
+
// showScreenshot,
|
127 |
+
// });
|
128 |
+
|
129 |
return (
|
130 |
<Box
|
131 |
sx={{
|
|
|
166 |
if (currentImageIndex + segmentImageCount > panelIndex) {
|
167 |
targetSegment = segment;
|
168 |
targetImageIndex = panelIndex - currentImageIndex;
|
169 |
+
// console.log("Found image for panel:", {
|
170 |
+
// panelIndex,
|
171 |
+
// targetImageIndex,
|
172 |
+
// hasImages: !!segment.images,
|
173 |
+
// imageCount: segment.images?.length,
|
174 |
+
// imageDataSample:
|
175 |
+
// segment.images?.[targetImageIndex]?.slice(0, 50) + "...",
|
176 |
+
// });
|
177 |
break;
|
178 |
}
|
179 |
currentImageIndex += segmentImageCount;
|
|
|
185 |
panel={panel}
|
186 |
segment={targetSegment}
|
187 |
panelIndex={targetImageIndex}
|
188 |
+
totalImagesInPage={totalImages}
|
189 |
+
onImageLoad={() =>
|
190 |
+
handleImageLoad(`page-${layoutIndex}-image-${panelIndex}`)
|
191 |
+
}
|
192 |
+
imageId={`page-${layoutIndex}-image-${panelIndex}`}
|
193 |
/>
|
194 |
);
|
195 |
})}
|
|
|
213 |
sx={{
|
214 |
position: "absolute",
|
215 |
left: "100%",
|
216 |
+
top: "75%",
|
217 |
transform: "translateY(-50%)",
|
218 |
display: "flex",
|
219 |
flexDirection: "column",
|
220 |
gap: 2,
|
221 |
width: "350px",
|
222 |
ml: 4,
|
223 |
+
backgroundColor: "transparent",
|
224 |
}}
|
225 |
>
|
226 |
+
<StoryChoices />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
227 |
</Box>
|
228 |
)}
|
229 |
</Box>
|
230 |
);
|
231 |
}
|
232 |
|
233 |
+
// Cache global pour stocker les images préchargées
|
234 |
+
const imageCache = new Map();
|
235 |
+
|
236 |
// Main comic layout component
|
237 |
+
export function ComicLayout() {
|
238 |
+
const {
|
239 |
+
segments,
|
240 |
+
isLoading,
|
241 |
+
playNarration,
|
242 |
+
stopNarration,
|
243 |
+
isNarratorSpeaking,
|
244 |
+
} = useGame();
|
|
|
|
|
|
|
245 |
const scrollContainerRef = useRef(null);
|
246 |
+
const [preloadedImages, setPreloadedImages] = useState(new Map());
|
247 |
+
const preloadingRef = useRef(false);
|
248 |
+
|
249 |
+
const loadImage = async (imageData, imageId) => {
|
250 |
+
// Vérifier si l'image est valide
|
251 |
+
if (!imageData || typeof imageData !== "string" || imageData.length === 0) {
|
252 |
+
console.warn(
|
253 |
+
`Image invalide pour ${imageId}: données manquantes ou invalides`
|
254 |
+
);
|
255 |
+
return Promise.reject(new Error("Données d'image invalides"));
|
256 |
+
}
|
257 |
+
|
258 |
+
// Si l'image est déjà dans le cache, ne pas la recharger
|
259 |
+
if (imageCache.has(imageId)) {
|
260 |
+
return imageCache.get(imageId);
|
261 |
+
}
|
262 |
+
|
263 |
+
// Si l'image est déjà en cours de chargement, ne pas la recharger
|
264 |
+
if (preloadingRef.current.has(imageId)) {
|
265 |
+
return;
|
266 |
+
}
|
267 |
+
|
268 |
+
preloadingRef.current.add(imageId);
|
269 |
+
|
270 |
+
try {
|
271 |
+
const img = new Image();
|
272 |
+
const imagePromise = new Promise((resolve, reject) => {
|
273 |
+
img.onload = () => {
|
274 |
+
imageCache.set(imageId, imageData);
|
275 |
+
preloadingRef.current.delete(imageId);
|
276 |
+
resolve(imageData);
|
277 |
+
};
|
278 |
+
img.onerror = (error) => {
|
279 |
+
preloadingRef.current.delete(imageId);
|
280 |
+
console.warn(`Échec du chargement de l'image ${imageId}`, error);
|
281 |
+
reject(new Error(`Échec du chargement de l'image ${imageId}`));
|
282 |
+
};
|
283 |
+
});
|
284 |
+
|
285 |
+
img.src = `data:image/jpeg;base64,${imageData}`;
|
286 |
+
return await imagePromise;
|
287 |
+
} catch (error) {
|
288 |
+
preloadingRef.current.delete(imageId);
|
289 |
+
throw error;
|
290 |
+
}
|
291 |
+
};
|
292 |
+
|
293 |
+
// Précharger les images pour tous les segments
|
294 |
+
useEffect(() => {
|
295 |
+
if (!segments?.length) return;
|
296 |
+
|
297 |
+
preloadingRef.current = new Set();
|
298 |
+
const newPreloadedImages = new Map();
|
299 |
+
|
300 |
+
const loadAllImages = async () => {
|
301 |
+
for (
|
302 |
+
let segmentIndex = 0;
|
303 |
+
segmentIndex < segments.length;
|
304 |
+
segmentIndex++
|
305 |
+
) {
|
306 |
+
const segment = segments[segmentIndex];
|
307 |
+
|
308 |
+
// Vérifier si le segment et ses images sont valides
|
309 |
+
if (!segment?.images?.length) {
|
310 |
+
console.warn(`Segment ${segmentIndex} invalide ou sans images`);
|
311 |
+
continue;
|
312 |
+
}
|
313 |
+
|
314 |
+
for (
|
315 |
+
let imageIndex = 0;
|
316 |
+
imageIndex < segment.images.length;
|
317 |
+
imageIndex++
|
318 |
+
) {
|
319 |
+
const imageData = segment.images[imageIndex];
|
320 |
+
const imageId = `segment-${segmentIndex}-image-${imageIndex}`;
|
321 |
+
|
322 |
+
try {
|
323 |
+
if (!imageData) {
|
324 |
+
console.warn(`Image manquante: ${imageId}`);
|
325 |
+
newPreloadedImages.set(imageId, false);
|
326 |
+
continue;
|
327 |
+
}
|
328 |
+
|
329 |
+
await loadImage(imageData, imageId);
|
330 |
+
newPreloadedImages.set(imageId, true);
|
331 |
+
} catch (error) {
|
332 |
+
console.warn(
|
333 |
+
`Erreur lors du chargement de ${imageId}:`,
|
334 |
+
error.message
|
335 |
+
);
|
336 |
+
newPreloadedImages.set(imageId, false);
|
337 |
+
}
|
338 |
+
}
|
339 |
+
}
|
340 |
+
setPreloadedImages(new Map(newPreloadedImages));
|
341 |
+
};
|
342 |
+
|
343 |
+
loadAllImages();
|
344 |
+
|
345 |
+
return () => {
|
346 |
+
preloadingRef.current = new Set();
|
347 |
+
};
|
348 |
+
}, [segments]);
|
349 |
|
350 |
// Effect to scroll to the right when segments are loaded
|
351 |
useEffect(() => {
|
352 |
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
353 |
+
const lastSegment = loadedSegments[loadedSegments.length - 1];
|
354 |
+
const hasNewSegment = lastSegment && !lastSegment.hasBeenRead;
|
355 |
+
|
356 |
+
if (scrollContainerRef.current && hasNewSegment) {
|
357 |
+
// Arrêter la narration en cours
|
358 |
+
stopNarration();
|
359 |
+
|
360 |
+
// Scroll to the right
|
361 |
scrollContainerRef.current.scrollTo({
|
362 |
left: scrollContainerRef.current.scrollWidth,
|
363 |
behavior: "smooth",
|
364 |
});
|
365 |
+
|
366 |
+
// Attendre que le scroll soit terminé avant de démarrer la narration
|
367 |
+
const timeoutId = setTimeout(() => {
|
368 |
+
if (lastSegment && lastSegment.text) {
|
369 |
+
playNarration(lastSegment.text);
|
370 |
+
// Marquer le segment comme lu
|
371 |
+
lastSegment.hasBeenRead = true;
|
372 |
+
}
|
373 |
+
}, 500);
|
374 |
+
|
375 |
+
return () => {
|
376 |
+
clearTimeout(timeoutId);
|
377 |
+
stopNarration();
|
378 |
+
};
|
379 |
}
|
380 |
+
}, [segments, playNarration, stopNarration]);
|
381 |
|
382 |
// Prevent back/forward navigation on trackpad horizontal scroll
|
383 |
useEffect(() => {
|
|
|
402 |
return () => container.removeEventListener("wheel", handleWheel);
|
403 |
}, []);
|
404 |
|
405 |
+
const loadedSegments = segments.filter((segment) => segment.text);
|
|
|
406 |
const layouts = groupSegmentsIntoLayouts(loadedSegments);
|
407 |
|
408 |
return (
|
|
|
416 |
height: "100%",
|
417 |
width: "100%",
|
418 |
px: layouts[0]?.type === "COVER" ? "calc(50% - (90vh * 0.5 * 0.5))" : 0,
|
419 |
+
py: 8,
|
420 |
overflowX: "auto",
|
421 |
overflowY: "hidden",
|
422 |
"&::-webkit-scrollbar": {
|
423 |
height: "0px",
|
424 |
},
|
425 |
"&::-webkit-scrollbar-track": {
|
426 |
+
backgroundColor: "grey.800",
|
427 |
},
|
428 |
"&::-webkit-scrollbar-thumb": {
|
429 |
+
backgroundColor: "grey.700",
|
430 |
borderRadius: "4px",
|
431 |
},
|
432 |
}}
|
|
|
437 |
layout={layout}
|
438 |
layoutIndex={layoutIndex}
|
439 |
isLastPage={layoutIndex === layouts.length - 1}
|
440 |
+
preloadedImages={preloadedImages}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
441 |
/>
|
442 |
))}
|
443 |
+
{isLoading && !layouts[layouts.length - 1]?.segments[0]?.is_last_step && (
|
444 |
+
<LoadingPage />
|
445 |
+
)}
|
446 |
</Box>
|
447 |
);
|
448 |
}
|
client/src/layouts/Panel.jsx
CHANGED
@@ -1,131 +1,170 @@
|
|
1 |
import { Box, CircularProgress, Typography } from "@mui/material";
|
2 |
-
import { useEffect, useState } from "react";
|
|
|
|
|
|
|
|
|
3 |
|
4 |
// Component for displaying a single panel
|
5 |
-
export function Panel({
|
6 |
-
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
//
|
10 |
useEffect(() => {
|
11 |
-
|
12 |
|
13 |
-
//
|
14 |
-
if (!
|
15 |
-
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
}
|
18 |
-
}, [segment?.images?.[panelIndex]]);
|
19 |
|
20 |
-
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
setIsLoading(false);
|
31 |
-
};
|
32 |
|
33 |
return (
|
34 |
<Box
|
35 |
sx={{
|
36 |
-
position: "relative",
|
37 |
-
width: "100%",
|
38 |
-
height: "100%",
|
39 |
gridColumn: panel.gridColumn,
|
40 |
gridRow: panel.gridRow,
|
41 |
-
|
42 |
-
|
43 |
-
borderColor: "grey.200",
|
44 |
-
borderRadius: "8px",
|
45 |
overflow: "hidden",
|
46 |
-
|
47 |
-
aspectRatio: `${panel.width} / ${panel.height}`, // Forcer le ratio même sans image
|
48 |
}}
|
49 |
>
|
50 |
-
{
|
51 |
-
|
52 |
-
{
|
53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
sx={{
|
55 |
-
|
56 |
-
|
57 |
-
left: 0,
|
58 |
-
right: 0,
|
59 |
-
bottom: 0,
|
60 |
-
display: "flex",
|
61 |
-
alignItems: "center",
|
62 |
-
justifyContent: "center",
|
63 |
-
opacity: imageLoaded ? 1 : 0,
|
64 |
-
transition: "opacity 0.5s ease-in-out",
|
65 |
}}
|
66 |
>
|
67 |
-
{segment.
|
68 |
-
|
69 |
-
|
70 |
-
alt={`Story scene ${panelIndex + 1}`}
|
71 |
-
style={{
|
72 |
-
width: "100%",
|
73 |
-
height: "100%",
|
74 |
-
objectFit: "cover",
|
75 |
-
borderRadius: "8px",
|
76 |
-
}}
|
77 |
-
onLoad={handleImageLoad}
|
78 |
-
onError={handleImageError}
|
79 |
-
/>
|
80 |
-
)}
|
81 |
-
</Box>
|
82 |
-
|
83 |
-
{/* Spinner de chargement */}
|
84 |
-
{(!segment.images?.[panelIndex] || !imageLoaded) && (
|
85 |
-
<Box
|
86 |
-
sx={{
|
87 |
-
position: "absolute",
|
88 |
-
top: 0,
|
89 |
-
left: 0,
|
90 |
-
right: 0,
|
91 |
-
bottom: 0,
|
92 |
-
display: "flex",
|
93 |
-
alignItems: "center",
|
94 |
-
justifyContent: "center",
|
95 |
-
opacity: 0.5,
|
96 |
-
backgroundColor: "white",
|
97 |
-
zIndex: 1,
|
98 |
-
}}
|
99 |
-
>
|
100 |
-
<CircularProgress size={10} />
|
101 |
-
</Box>
|
102 |
-
)}
|
103 |
-
|
104 |
-
{/* Texte du segment (uniquement sur le premier panel) */}
|
105 |
-
{panelIndex === 0 && segment.text && (
|
106 |
-
<Box
|
107 |
-
sx={{
|
108 |
-
position: "absolute",
|
109 |
-
bottom: "20px",
|
110 |
-
left: "20px",
|
111 |
-
right: "20px",
|
112 |
-
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
113 |
-
fontSize: ".9rem",
|
114 |
-
padding: "10px",
|
115 |
-
borderRadius: "8px",
|
116 |
-
boxShadow: "0 -2px 4px rgba(0,0,0,0.1)",
|
117 |
-
zIndex: 2,
|
118 |
-
color: "black",
|
119 |
-
"& .MuiChip-root": {
|
120 |
-
color: "black",
|
121 |
-
borderColor: "black",
|
122 |
-
},
|
123 |
-
}}
|
124 |
-
>
|
125 |
-
{segment.text}
|
126 |
-
</Box>
|
127 |
-
)}
|
128 |
-
</>
|
129 |
)}
|
130 |
</Box>
|
131 |
);
|
|
|
1 |
import { Box, CircularProgress, Typography } from "@mui/material";
|
2 |
+
import { useEffect, useState, useRef } from "react";
|
3 |
+
|
4 |
+
// Cache global pour les images déjà chargées
|
5 |
+
const imageCache = new Map();
|
6 |
+
const loadedImagesState = new Map();
|
7 |
|
8 |
// Component for displaying a single panel
|
9 |
+
export function Panel({
|
10 |
+
panel,
|
11 |
+
segment,
|
12 |
+
panelIndex,
|
13 |
+
totalImagesInPage,
|
14 |
+
onImageLoad,
|
15 |
+
imageId,
|
16 |
+
}) {
|
17 |
+
const [imageLoaded, setImageLoaded] = useState(
|
18 |
+
() => loadedImagesState.get(imageId) || false
|
19 |
+
);
|
20 |
+
const [imageDisplayed, setImageDisplayed] = useState(
|
21 |
+
() => loadedImagesState.get(imageId) || false
|
22 |
+
);
|
23 |
+
const hasImage = segment?.images?.[panelIndex];
|
24 |
+
const isFirstPanel = panelIndex === 0;
|
25 |
+
const imgRef = useRef(null);
|
26 |
+
const imageDataRef = useRef(null);
|
27 |
+
const mountedRef = useRef(true);
|
28 |
+
|
29 |
+
// Cleanup on unmount
|
30 |
+
useEffect(() => {
|
31 |
+
return () => {
|
32 |
+
mountedRef.current = false;
|
33 |
+
};
|
34 |
+
}, []);
|
35 |
|
36 |
+
// Gérer le chargement initial de l'image
|
37 |
useEffect(() => {
|
38 |
+
if (!hasImage || loadedImagesState.get(imageId)) return;
|
39 |
|
40 |
+
// Créer un blob URL unique pour cette image si pas déjà en cache
|
41 |
+
if (!imageCache.has(imageId)) {
|
42 |
+
const byteCharacters = atob(segment.images[panelIndex]);
|
43 |
+
const byteNumbers = new Array(byteCharacters.length);
|
44 |
+
for (let i = 0; i < byteCharacters.length; i++) {
|
45 |
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
46 |
+
}
|
47 |
+
const byteArray = new Uint8Array(byteNumbers);
|
48 |
+
const blob = new Blob([byteArray], { type: "image/jpeg" });
|
49 |
+
const blobUrl = URL.createObjectURL(blob);
|
50 |
+
imageCache.set(imageId, blobUrl);
|
51 |
+
imageDataRef.current = blobUrl;
|
52 |
+
} else {
|
53 |
+
imageDataRef.current = imageCache.get(imageId);
|
54 |
}
|
|
|
55 |
|
56 |
+
const img = new Image();
|
57 |
+
img.onload = () => {
|
58 |
+
if (!mountedRef.current) return;
|
59 |
+
setImageLoaded(true);
|
60 |
+
loadedImagesState.set(imageId, true);
|
61 |
+
onImageLoad();
|
62 |
+
};
|
63 |
+
img.src = imageDataRef.current;
|
64 |
+
|
65 |
+
return () => {
|
66 |
+
img.onload = null;
|
67 |
+
};
|
68 |
+
}, [hasImage, imageId, onImageLoad]);
|
69 |
+
|
70 |
+
// Nettoyer le blob URL quand le composant est démonté
|
71 |
+
useEffect(() => {
|
72 |
+
return () => {
|
73 |
+
if (imageDataRef.current && !imageCache.has(imageId)) {
|
74 |
+
URL.revokeObjectURL(imageDataRef.current);
|
75 |
+
}
|
76 |
+
};
|
77 |
+
}, [imageId]);
|
78 |
+
|
79 |
+
// Gérer la transition d'affichage
|
80 |
+
useEffect(() => {
|
81 |
+
if (!imageLoaded) return;
|
82 |
|
83 |
+
const timeoutId = setTimeout(() => {
|
84 |
+
if (!mountedRef.current) return;
|
85 |
+
setImageDisplayed(true);
|
86 |
+
}, 50);
|
87 |
|
88 |
+
return () => clearTimeout(timeoutId);
|
89 |
+
}, [imageLoaded]);
|
|
|
|
|
90 |
|
91 |
return (
|
92 |
<Box
|
93 |
sx={{
|
|
|
|
|
|
|
94 |
gridColumn: panel.gridColumn,
|
95 |
gridRow: panel.gridRow,
|
96 |
+
backgroundColor: "grey.200",
|
97 |
+
borderRadius: "4px",
|
|
|
|
|
98 |
overflow: "hidden",
|
99 |
+
position: "relative",
|
|
|
100 |
}}
|
101 |
>
|
102 |
+
{hasImage && imageDataRef.current && (
|
103 |
+
<img
|
104 |
+
ref={imgRef}
|
105 |
+
src={imageDataRef.current}
|
106 |
+
alt={`Panel ${imageId}`}
|
107 |
+
style={{
|
108 |
+
width: "100%",
|
109 |
+
height: "100%",
|
110 |
+
objectFit: "cover",
|
111 |
+
opacity: imageDisplayed ? 1 : 0,
|
112 |
+
transition: "opacity 0.5s ease-in-out",
|
113 |
+
willChange: "opacity",
|
114 |
+
}}
|
115 |
+
loading="eager"
|
116 |
+
decoding="sync"
|
117 |
+
/>
|
118 |
+
)}
|
119 |
+
{(!hasImage || !imageDisplayed) && (
|
120 |
+
<Box
|
121 |
+
sx={{
|
122 |
+
width: "100%",
|
123 |
+
height: "100%",
|
124 |
+
display: "flex",
|
125 |
+
alignItems: "center",
|
126 |
+
justifyContent: "center",
|
127 |
+
backgroundColor: "grey.300",
|
128 |
+
position: "absolute",
|
129 |
+
top: 0,
|
130 |
+
left: 0,
|
131 |
+
opacity: imageDisplayed ? 0 : 1,
|
132 |
+
transition: "opacity 0.5s ease-in-out",
|
133 |
+
}}
|
134 |
+
>
|
135 |
+
<CircularProgress size={24} />
|
136 |
+
</Box>
|
137 |
+
)}
|
138 |
+
{isFirstPanel && segment?.text && (
|
139 |
+
<Box
|
140 |
+
sx={{
|
141 |
+
position: "absolute",
|
142 |
+
bottom: 16,
|
143 |
+
left: 16,
|
144 |
+
right: 16,
|
145 |
+
padding: "12px 16px",
|
146 |
+
background: "rgba(255, 255, 255, 0.95)",
|
147 |
+
color: "black",
|
148 |
+
textAlign: "center",
|
149 |
+
fontSize: "1rem",
|
150 |
+
fontWeight: 500,
|
151 |
+
borderRadius: "8px",
|
152 |
+
display: "flex",
|
153 |
+
alignItems: "center",
|
154 |
+
justifyContent: "center",
|
155 |
+
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
156 |
+
}}
|
157 |
+
>
|
158 |
+
<Typography
|
159 |
+
variant="body1"
|
160 |
sx={{
|
161 |
+
color: "black",
|
162 |
+
lineHeight: 1.4,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
}}
|
164 |
>
|
165 |
+
{segment.text}
|
166 |
+
</Typography>
|
167 |
+
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
168 |
)}
|
169 |
</Box>
|
170 |
);
|
client/src/layouts/config.js
CHANGED
@@ -51,8 +51,8 @@ export const LAYOUTS = {
|
|
51 |
gridRows: 2,
|
52 |
panels: [
|
53 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.TWO_THIRDS, gridRow: "1" }, // Wide landscape top left
|
54 |
-
{ ...PANEL_SIZES.
|
55 |
-
{ ...PANEL_SIZES.
|
56 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "2 / span 2", gridRow: "2" }, // Wide landscape bottom right
|
57 |
],
|
58 |
},
|
@@ -77,7 +77,7 @@ export const LAYOUTS = {
|
|
77 |
{ ...PANEL_SIZES.PANORAMIC, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
|
78 |
{ ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
79 |
{
|
80 |
-
...PANEL_SIZES.
|
81 |
gridColumn: "2 / span 2",
|
82 |
gridRow: "2 / span 2",
|
83 |
}, // Large square right
|
@@ -101,34 +101,45 @@ export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
|
|
101 |
// Grouper les layouts par nombre de panneaux
|
102 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
103 |
1: ["COVER"],
|
104 |
-
2: ["LAYOUT_7"],
|
105 |
-
3: ["LAYOUT_5"],
|
106 |
-
4: ["LAYOUT_3"],
|
107 |
};
|
108 |
|
109 |
// Helper functions for layout configuration
|
110 |
-
export const getNextLayoutType = (
|
111 |
-
|
112 |
-
const availableLayouts = LAYOUTS_BY_PANEL_COUNT[imageCount] || [];
|
113 |
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
if (LAYOUTS_BY_PANEL_COUNT[i]?.length) {
|
119 |
-
availableLayouts.push(...LAYOUTS_BY_PANEL_COUNT[i]);
|
120 |
-
break;
|
121 |
-
}
|
122 |
-
}
|
123 |
}
|
124 |
|
125 |
-
|
126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
}
|
128 |
|
129 |
-
// Sélectionner un layout
|
130 |
-
const
|
131 |
-
|
|
|
|
|
|
|
|
|
132 |
};
|
133 |
|
134 |
export const getLayoutDimensions = (layoutType, panelIndex) =>
|
|
|
51 |
gridRows: 2,
|
52 |
panels: [
|
53 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.TWO_THIRDS, gridRow: "1" }, // Wide landscape top left
|
54 |
+
{ ...PANEL_SIZES.COLUMN, gridColumn: "3", gridRow: "1" }, // COLUMN top right
|
55 |
+
{ ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2" }, // COLUMN bottom left
|
56 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "2 / span 2", gridRow: "2" }, // Wide landscape bottom right
|
57 |
],
|
58 |
},
|
|
|
77 |
{ ...PANEL_SIZES.PANORAMIC, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
|
78 |
{ ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
79 |
{
|
80 |
+
...PANEL_SIZES.POTRAIT,
|
81 |
gridColumn: "2 / span 2",
|
82 |
gridRow: "2 / span 2",
|
83 |
}, // Large square right
|
|
|
101 |
// Grouper les layouts par nombre de panneaux
|
102 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
103 |
1: ["COVER"],
|
104 |
+
2: ["LAYOUT_7"],
|
105 |
+
3: ["LAYOUT_2", "LAYOUT_5"],
|
106 |
+
4: ["LAYOUT_1", "LAYOUT_3", "LAYOUT_4"],
|
107 |
};
|
108 |
|
109 |
// Helper functions for layout configuration
|
110 |
+
export const getNextLayoutType = (layoutCounter, imageCount) => {
|
111 |
+
console.log("Getting layout for", { layoutCounter, imageCount });
|
|
|
112 |
|
113 |
+
// Si pas d'images ou nombre invalide, utiliser COVER
|
114 |
+
if (!imageCount || imageCount <= 0) {
|
115 |
+
console.log("No images or invalid count, using COVER layout");
|
116 |
+
return "COVER";
|
|
|
|
|
|
|
|
|
|
|
117 |
}
|
118 |
|
119 |
+
// Si on n'a qu'une seule image, toujours utiliser COVER
|
120 |
+
if (imageCount === 1) {
|
121 |
+
console.log("Single image, using COVER layout");
|
122 |
+
return "COVER";
|
123 |
+
}
|
124 |
+
|
125 |
+
// Obtenir les layouts disponibles pour ce nombre d'images
|
126 |
+
const availableLayouts = LAYOUTS_BY_PANEL_COUNT[imageCount];
|
127 |
+
|
128 |
+
// Si on n'a pas de layout pour ce nombre d'images, utiliser COVER par défaut
|
129 |
+
if (!availableLayouts) {
|
130 |
+
console.warn(
|
131 |
+
`No layout available for ${imageCount} images, falling back to COVER`
|
132 |
+
);
|
133 |
+
return "COVER";
|
134 |
}
|
135 |
|
136 |
+
// Sélectionner un layout de manière cyclique
|
137 |
+
const layoutIndex = layoutCounter % availableLayouts.length;
|
138 |
+
const selectedLayout = availableLayouts[layoutIndex];
|
139 |
+
console.log(
|
140 |
+
`Selected ${selectedLayout} for ${imageCount} images (layout counter: ${layoutCounter})`
|
141 |
+
);
|
142 |
+
return selectedLayout;
|
143 |
};
|
144 |
|
145 |
export const getLayoutDimensions = (layoutType, panelIndex) =>
|
client/src/layouts/utils.js
CHANGED
@@ -10,7 +10,18 @@ export function groupSegmentsIntoLayouts(segments) {
|
|
10 |
const layouts = [];
|
11 |
|
12 |
segments.forEach((segment, index) => {
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
|
15 |
// Si c'est le premier segment ou le dernier (mort/victoire), créer un layout COVER
|
16 |
if (segment.is_first_step || segment.is_last_step) {
|
|
|
10 |
const layouts = [];
|
11 |
|
12 |
segments.forEach((segment, index) => {
|
13 |
+
// Ne pas créer de layout si le segment n'a pas d'images chargées
|
14 |
+
if (!segment.images || segment.images.length === 0) {
|
15 |
+
return;
|
16 |
+
}
|
17 |
+
|
18 |
+
const imageCount = segment.images.length;
|
19 |
+
|
20 |
+
// Si le segment a déjà un layoutType défini, l'utiliser
|
21 |
+
if (segment.layoutType) {
|
22 |
+
layouts.push({ type: segment.layoutType, segments: [segment] });
|
23 |
+
return;
|
24 |
+
}
|
25 |
|
26 |
// Si c'est le premier segment ou le dernier (mort/victoire), créer un layout COVER
|
27 |
if (segment.is_first_step || segment.is_last_step) {
|
client/src/main.jsx
CHANGED
@@ -8,6 +8,7 @@ import { Home } from "./pages/Home";
|
|
8 |
import { Game } from "./pages/Game";
|
9 |
import { Tutorial } from "./pages/Tutorial";
|
10 |
import Debug from "./pages/Debug";
|
|
|
11 |
import "./index.css";
|
12 |
|
13 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
@@ -19,6 +20,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
|
19 |
<Route path="/game" element={<Game />} />
|
20 |
<Route path="/tutorial" element={<Tutorial />} />
|
21 |
<Route path="/debug" element={<Debug />} />
|
|
|
22 |
</Routes>
|
23 |
</BrowserRouter>
|
24 |
</ThemeProvider>
|
|
|
8 |
import { Game } from "./pages/Game";
|
9 |
import { Tutorial } from "./pages/Tutorial";
|
10 |
import Debug from "./pages/Debug";
|
11 |
+
import { Universe } from "./pages/Universe";
|
12 |
import "./index.css";
|
13 |
|
14 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
|
20 |
<Route path="/game" element={<Game />} />
|
21 |
<Route path="/tutorial" element={<Tutorial />} />
|
22 |
<Route path="/debug" element={<Debug />} />
|
23 |
+
<Route path="/universe" element={<Universe />} />
|
24 |
</Routes>
|
25 |
</BrowserRouter>
|
26 |
</ThemeProvider>
|
client/src/pages/Debug.jsx
CHANGED
@@ -86,6 +86,7 @@ const Debug = () => {
|
|
86 |
universe_genre: universe?.genre,
|
87 |
universe_epoch: universe?.epoch,
|
88 |
universe_macguffin: universe?.macguffin,
|
|
|
89 |
story_beat: 0,
|
90 |
story_history: [initialHistoryEntry],
|
91 |
});
|
|
|
86 |
universe_genre: universe?.genre,
|
87 |
universe_epoch: universe?.epoch,
|
88 |
universe_macguffin: universe?.macguffin,
|
89 |
+
universe_selected_artist: universe?.style?.selected_artist,
|
90 |
story_beat: 0,
|
91 |
story_history: [initialHistoryEntry],
|
92 |
});
|
client/src/pages/Game.jsx
CHANGED
@@ -5,12 +5,13 @@ import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
|
5 |
import { Box, IconButton, LinearProgress, Tooltip } from "@mui/material";
|
6 |
import { motion } from "framer-motion";
|
7 |
import { useEffect, useRef, useState } from "react";
|
8 |
-
import { useNavigate } from "react-router-dom";
|
9 |
|
10 |
import { ErrorDisplay } from "../components/ErrorDisplay";
|
11 |
import { LoadingScreen } from "../components/LoadingScreen";
|
12 |
-
import { StoryChoices } from "../components/StoryChoices";
|
13 |
import { TalkWithSarah } from "../components/TalkWithSarah";
|
|
|
|
|
14 |
import { useGameSession } from "../hooks/useGameSession";
|
15 |
import { useNarrator } from "../hooks/useNarrator";
|
16 |
import { usePageSound } from "../hooks/usePageSound";
|
@@ -18,47 +19,71 @@ import { useStoryCapture } from "../hooks/useStoryCapture";
|
|
18 |
import { useTransitionSound } from "../hooks/useTransitionSound";
|
19 |
import { useWritingSound } from "../hooks/useWritingSound";
|
20 |
import { ComicLayout } from "../layouts/ComicLayout";
|
21 |
-
import {
|
22 |
-
import {
|
23 |
|
24 |
// Constants
|
25 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
|
|
26 |
|
27 |
-
|
28 |
-
const formatTextWithBold = (text, isInPanel = false) => {
|
29 |
-
if (!text) return "";
|
30 |
-
const parts = text.split(/(\*\*.*?\*\*)/g);
|
31 |
-
return parts.map((part, index) => {
|
32 |
-
if (part.startsWith("**") && part.endsWith("**")) {
|
33 |
-
return part.slice(2, -2);
|
34 |
-
}
|
35 |
-
return part;
|
36 |
-
});
|
37 |
-
};
|
38 |
-
|
39 |
-
// Function to strip bold markers from text for narration
|
40 |
-
const stripBoldMarkers = (text) => {
|
41 |
-
return text.replace(/\*\*/g, "");
|
42 |
-
};
|
43 |
-
|
44 |
-
export function Game() {
|
45 |
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
const storyContainerRef = useRef(null);
|
47 |
const { downloadStoryImage } = useStoryCapture();
|
48 |
-
const [
|
49 |
-
const [currentChoices, setCurrentChoices] = useState([]);
|
50 |
-
const [isLoading, setIsLoading] = useState(false);
|
51 |
-
const [showChoices, setShowChoices] = useState(true);
|
52 |
-
const [error, setError] = useState(null);
|
53 |
const [isSoundEnabled, setIsSoundEnabled] = useState(() => {
|
54 |
const stored = localStorage.getItem(SOUND_ENABLED_KEY);
|
55 |
return stored === null ? true : stored === "true";
|
56 |
});
|
57 |
const [loadingMessage, setLoadingMessage] = useState(0);
|
|
|
|
|
58 |
const messages = [
|
59 |
-
"waking up a sleepy AI...",
|
60 |
"teaching robots to tell bedtime stories...",
|
61 |
"bribing pixels to make pretty pictures...",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
];
|
63 |
|
64 |
const { isNarratorSpeaking, playNarration, stopNarration } =
|
@@ -68,236 +93,189 @@ export function Game() {
|
|
68 |
const playTransitionSound = useTransitionSound(isSoundEnabled);
|
69 |
const {
|
70 |
sessionId,
|
71 |
-
universe,
|
72 |
isLoading: isSessionLoading,
|
73 |
error: sessionError,
|
74 |
} = useGameSession();
|
75 |
|
76 |
-
//
|
77 |
useEffect(() => {
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
}
|
83 |
-
}, [isSessionLoading, sessionId, error, sessionError]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
|
85 |
// Sauvegarder l'état du son dans le localStorage
|
86 |
useEffect(() => {
|
87 |
localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
|
88 |
}, [isSoundEnabled]);
|
89 |
|
90 |
-
// Start the story when session is ready
|
91 |
-
useEffect(() => {
|
92 |
-
if (sessionId && !isSessionLoading) {
|
93 |
-
handleStoryAction("restart");
|
94 |
-
}
|
95 |
-
}, [sessionId, isSessionLoading]);
|
96 |
-
|
97 |
// Add effect for message rotation
|
98 |
useEffect(() => {
|
99 |
-
if (
|
100 |
const interval = setInterval(() => {
|
101 |
setLoadingMessage((prev) => (prev + 1) % messages.length);
|
102 |
}, 3000);
|
103 |
return () => clearInterval(interval);
|
104 |
}
|
105 |
-
}, [
|
106 |
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
|
|
|
|
|
|
111 |
|
112 |
-
|
113 |
-
|
|
|
114 |
|
115 |
-
|
116 |
-
|
117 |
-
if (
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
);
|
125 |
-
return;
|
126 |
}
|
|
|
127 |
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
};
|
143 |
-
|
144 |
-
const handleStoryAction = async (action, choiceId = null) => {
|
145 |
-
setIsLoading(true);
|
146 |
-
setShowChoices(false);
|
147 |
-
setError(null);
|
148 |
-
try {
|
149 |
-
if (isNarratorSpeaking) {
|
150 |
-
stopNarration();
|
151 |
-
}
|
152 |
-
|
153 |
-
// Pass sessionId to API calls
|
154 |
-
const storyData = await (action === "restart"
|
155 |
-
? storyApi.start(sessionId)
|
156 |
-
: storyApi.makeChoice(choiceId, sessionId));
|
157 |
|
158 |
-
|
159 |
-
|
160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
is_first_step: storyData.is_first_step,
|
170 |
-
is_last_step: storyData.is_last_step,
|
171 |
-
images: [],
|
172 |
-
isLoading: true, // Ajout d'un flag pour indiquer que le segment est en cours de chargement
|
173 |
-
};
|
174 |
-
|
175 |
-
playWritingSound();
|
176 |
-
|
177 |
-
// 3. Update segments
|
178 |
-
if (action === "restart") {
|
179 |
-
setStorySegments([newSegment]);
|
180 |
-
} else {
|
181 |
-
setStorySegments((prev) => [...prev, newSegment]);
|
182 |
}
|
|
|
183 |
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
// 5. Start narration of the new segment
|
188 |
-
await playNarration(newSegment.rawText);
|
189 |
-
|
190 |
-
// 6. Generate images in parallel
|
191 |
-
if (storyData.image_prompts && storyData.image_prompts.length > 0) {
|
192 |
-
await generateImagesForStory(
|
193 |
-
storyData.image_prompts,
|
194 |
-
action === "restart" ? 0 : storySegments.length,
|
195 |
-
action === "restart" ? [newSegment] : [...storySegments, newSegment]
|
196 |
-
);
|
197 |
-
} else {
|
198 |
-
// Si pas d'images, marquer le segment comme chargé
|
199 |
-
const updatedSegment = { ...newSegment, isLoading: false };
|
200 |
-
if (action === "restart") {
|
201 |
-
setStorySegments([updatedSegment]);
|
202 |
-
} else {
|
203 |
-
setStorySegments((prev) => [...prev.slice(0, -1), updatedSegment]);
|
204 |
-
}
|
205 |
-
}
|
206 |
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
const
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
setError(errorMessage);
|
217 |
-
await playNarration(errorMessage);
|
218 |
-
} finally {
|
219 |
-
setIsLoading(false);
|
220 |
}
|
221 |
-
};
|
222 |
-
|
223 |
-
const generateImagesForStory = async (
|
224 |
-
imagePrompts,
|
225 |
-
segmentIndex,
|
226 |
-
currentSegments
|
227 |
-
) => {
|
228 |
-
try {
|
229 |
-
let localSegments = [...currentSegments];
|
230 |
-
const images = Array(imagePrompts.length).fill(null);
|
231 |
-
let allImagesGenerated = false;
|
232 |
-
|
233 |
-
// Déterminer le layout en fonction du nombre d'images
|
234 |
-
const layoutType = getNextLayoutType(0, imagePrompts.length);
|
235 |
-
|
236 |
-
for (
|
237 |
-
let promptIndex = 0;
|
238 |
-
promptIndex < imagePrompts.length;
|
239 |
-
promptIndex++
|
240 |
-
) {
|
241 |
-
let retryCount = 0;
|
242 |
-
const maxRetries = 3;
|
243 |
-
let success = false;
|
244 |
-
|
245 |
-
// Obtenir les dimensions pour ce panneau
|
246 |
-
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
247 |
-
|
248 |
-
while (retryCount < maxRetries && !success) {
|
249 |
-
try {
|
250 |
-
const result = await storyApi.generateImage(
|
251 |
-
imagePrompts[promptIndex],
|
252 |
-
panelDimensions.width,
|
253 |
-
panelDimensions.height
|
254 |
-
);
|
255 |
-
|
256 |
-
if (!result) {
|
257 |
-
throw new Error("Pas de résultat de génération d'image");
|
258 |
-
}
|
259 |
-
|
260 |
-
if (result.success) {
|
261 |
-
images[promptIndex] = result.image_base64;
|
262 |
-
|
263 |
-
// Vérifier si toutes les images sont générées
|
264 |
-
allImagesGenerated = images.every((img) => img !== null);
|
265 |
-
|
266 |
-
// Ne mettre à jour le segment que si toutes les images sont générées
|
267 |
-
if (allImagesGenerated) {
|
268 |
-
localSegments[segmentIndex] = {
|
269 |
-
...localSegments[segmentIndex],
|
270 |
-
images,
|
271 |
-
isLoading: false,
|
272 |
-
};
|
273 |
-
setStorySegments([...localSegments]);
|
274 |
-
}
|
275 |
-
success = true;
|
276 |
-
} else {
|
277 |
-
console.warn(
|
278 |
-
`Failed to generate image ${promptIndex + 1}, attempt ${
|
279 |
-
retryCount + 1
|
280 |
-
}`
|
281 |
-
);
|
282 |
-
retryCount++;
|
283 |
-
}
|
284 |
-
} catch (error) {
|
285 |
-
console.error(`Error generating image ${promptIndex + 1}:`, error);
|
286 |
-
retryCount++;
|
287 |
-
}
|
288 |
-
}
|
289 |
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
promptIndex + 1
|
294 |
-
} after ${maxRetries} attempts`
|
295 |
-
);
|
296 |
-
}
|
297 |
-
}
|
298 |
-
} catch (error) {
|
299 |
-
console.error("Error in generateImagesForStory:", error);
|
300 |
-
}
|
301 |
};
|
302 |
|
303 |
const handleCaptureStory = async () => {
|
@@ -321,16 +299,57 @@ export function Game() {
|
|
321 |
<LoadingScreen
|
322 |
icon="universe"
|
323 |
messages={[
|
324 |
-
"
|
|
|
325 |
"Gathering comic book inspiration...",
|
326 |
-
"
|
327 |
-
"
|
|
|
328 |
]}
|
329 |
/>
|
330 |
</Box>
|
331 |
);
|
332 |
}
|
333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
334 |
return (
|
335 |
<motion.div
|
336 |
initial={{ opacity: 0 }}
|
@@ -340,158 +359,103 @@ export function Game() {
|
|
340 |
style={{ backgroundColor: "#121212", width: "100%" }}
|
341 |
>
|
342 |
<Box
|
343 |
-
ref={storyContainerRef}
|
344 |
sx={{
|
345 |
height: "100vh",
|
346 |
width: "100vw",
|
347 |
-
backgroundColor: "
|
348 |
position: "relative",
|
349 |
overflow: "hidden",
|
350 |
}}
|
351 |
>
|
352 |
-
{
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
"&:hover": {
|
381 |
-
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
382 |
-
},
|
383 |
-
zIndex: 1000,
|
384 |
-
}}
|
385 |
-
>
|
386 |
-
<ArrowBackIcon />
|
387 |
-
</IconButton>
|
388 |
-
</Tooltip>
|
389 |
-
|
390 |
-
{error ? (
|
391 |
-
<ErrorDisplay
|
392 |
-
message={error}
|
393 |
-
onRetry={() => {
|
394 |
-
if (storySegments.length === 0) {
|
395 |
-
handleStoryAction("restart");
|
396 |
-
} else {
|
397 |
-
handleStoryAction(
|
398 |
-
"choice",
|
399 |
-
storySegments[storySegments.length - 1]?.choiceId || null
|
400 |
-
);
|
401 |
-
}
|
402 |
-
}}
|
403 |
-
/>
|
404 |
-
) : (
|
405 |
-
<>
|
406 |
-
{isLoading && storySegments.length === 0 ? (
|
407 |
-
<LoadingScreen
|
408 |
-
icon="story"
|
409 |
-
messages={[
|
410 |
-
"Bringing the universe to life...",
|
411 |
-
"Awakening the characters...",
|
412 |
-
"Polishing the first scene...",
|
413 |
-
"Preparing the adventure...",
|
414 |
-
"Adding final touches to the world...",
|
415 |
-
]}
|
416 |
-
/>
|
417 |
-
) : (
|
418 |
-
<>
|
419 |
-
<ComicLayout
|
420 |
-
segments={storySegments}
|
421 |
-
choices={currentChoices}
|
422 |
-
onChoice={handleChoice}
|
423 |
-
isLoading={isLoading}
|
424 |
-
showScreenshot={storySegments.length > 0}
|
425 |
-
onScreenshot={handleCaptureStory}
|
426 |
-
isNarratorSpeaking={isNarratorSpeaking}
|
427 |
-
stopNarration={stopNarration}
|
428 |
-
playNarration={playNarration}
|
429 |
-
/>
|
430 |
-
|
431 |
-
{showChoices && (
|
432 |
-
<StoryChoices
|
433 |
-
choices={currentChoices}
|
434 |
-
onChoice={handleChoice}
|
435 |
-
disabled={isLoading}
|
436 |
-
isLastStep={
|
437 |
-
storySegments.length > 0 &&
|
438 |
-
storySegments[storySegments.length - 1].isLastStep
|
439 |
-
}
|
440 |
-
isGameOver={
|
441 |
-
storySegments.length > 0 &&
|
442 |
-
storySegments[storySegments.length - 1].isGameOver
|
443 |
-
}
|
444 |
-
containerRef={storyContainerRef}
|
445 |
-
/>
|
446 |
-
)}
|
447 |
-
</>
|
448 |
-
)}
|
449 |
-
<Box
|
450 |
-
sx={{
|
451 |
-
position: "fixed",
|
452 |
-
top: 16,
|
453 |
-
right: 16,
|
454 |
-
display: "flex",
|
455 |
-
gap: 1,
|
456 |
-
padding: 1,
|
457 |
-
borderRadius: 1,
|
458 |
-
}}
|
459 |
>
|
460 |
-
<
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
<PhotoCameraOutlinedIcon />
|
472 |
-
</IconButton>
|
473 |
-
</Tooltip>
|
474 |
-
<Tooltip
|
475 |
-
title={isSoundEnabled ? "Couper le son" : "Activer le son"}
|
476 |
>
|
477 |
-
<
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
491 |
)}
|
|
|
|
|
|
|
492 |
</Box>
|
493 |
</motion.div>
|
494 |
);
|
495 |
}
|
496 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
497 |
export default Game;
|
|
|
5 |
import { Box, IconButton, LinearProgress, Tooltip } from "@mui/material";
|
6 |
import { motion } from "framer-motion";
|
7 |
import { useEffect, useRef, useState } from "react";
|
8 |
+
import { useNavigate, useParams } from "react-router-dom";
|
9 |
|
10 |
import { ErrorDisplay } from "../components/ErrorDisplay";
|
11 |
import { LoadingScreen } from "../components/LoadingScreen";
|
|
|
12 |
import { TalkWithSarah } from "../components/TalkWithSarah";
|
13 |
+
import { GameDebugPanel } from "../components/GameDebugPanel";
|
14 |
+
import { UniverseSlotMachine } from "../components/UniverseSlotMachine";
|
15 |
import { useGameSession } from "../hooks/useGameSession";
|
16 |
import { useNarrator } from "../hooks/useNarrator";
|
17 |
import { usePageSound } from "../hooks/usePageSound";
|
|
|
19 |
import { useTransitionSound } from "../hooks/useTransitionSound";
|
20 |
import { useWritingSound } from "../hooks/useWritingSound";
|
21 |
import { ComicLayout } from "../layouts/ComicLayout";
|
22 |
+
import { storyApi, universeApi } from "../utils/api";
|
23 |
+
import { GameProvider, useGame } from "../contexts/GameContext";
|
24 |
|
25 |
// Constants
|
26 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
27 |
+
const GAME_INITIALIZED_KEY = "game_initialized";
|
28 |
|
29 |
+
function GameContent() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
const navigate = useNavigate();
|
31 |
+
const { universeId } = useParams();
|
32 |
+
const {
|
33 |
+
segments,
|
34 |
+
setSegments,
|
35 |
+
choices,
|
36 |
+
setChoices,
|
37 |
+
isLoading,
|
38 |
+
setIsLoading,
|
39 |
+
heroName,
|
40 |
+
setHeroName,
|
41 |
+
showChoices,
|
42 |
+
setShowChoices,
|
43 |
+
error,
|
44 |
+
setError,
|
45 |
+
gameState,
|
46 |
+
setGameState,
|
47 |
+
currentStory,
|
48 |
+
setCurrentStory,
|
49 |
+
universe,
|
50 |
+
setUniverse,
|
51 |
+
slotMachineState,
|
52 |
+
setSlotMachineState,
|
53 |
+
showSlotMachine,
|
54 |
+
setShowSlotMachine,
|
55 |
+
isInitialLoading,
|
56 |
+
setIsInitialLoading,
|
57 |
+
showLoadingMessages,
|
58 |
+
setShowLoadingMessages,
|
59 |
+
isTransitionLoading,
|
60 |
+
setIsTransitionLoading,
|
61 |
+
layoutCounter,
|
62 |
+
setLayoutCounter,
|
63 |
+
resetGame,
|
64 |
+
generateImagesForStory,
|
65 |
+
} = useGame();
|
66 |
+
|
67 |
const storyContainerRef = useRef(null);
|
68 |
const { downloadStoryImage } = useStoryCapture();
|
69 |
+
const [audioInitialized, setAudioInitialized] = useState(false);
|
|
|
|
|
|
|
|
|
70 |
const [isSoundEnabled, setIsSoundEnabled] = useState(() => {
|
71 |
const stored = localStorage.getItem(SOUND_ENABLED_KEY);
|
72 |
return stored === null ? true : stored === "true";
|
73 |
});
|
74 |
const [loadingMessage, setLoadingMessage] = useState(0);
|
75 |
+
const [isDebugVisible, setIsDebugVisible] = useState(false);
|
76 |
+
|
77 |
const messages = [
|
|
|
78 |
"teaching robots to tell bedtime stories...",
|
79 |
"bribing pixels to make pretty pictures...",
|
80 |
+
"calibrating the multiverse...",
|
81 |
+
];
|
82 |
+
const transitionMessages = [
|
83 |
+
"Creating your universe...",
|
84 |
+
"Drawing the first scene...",
|
85 |
+
"Preparing your story...",
|
86 |
+
"Assembling the comic panels...",
|
87 |
];
|
88 |
|
89 |
const { isNarratorSpeaking, playNarration, stopNarration } =
|
|
|
93 |
const playTransitionSound = useTransitionSound(isSoundEnabled);
|
94 |
const {
|
95 |
sessionId,
|
96 |
+
universe: gameUniverse,
|
97 |
isLoading: isSessionLoading,
|
98 |
error: sessionError,
|
99 |
} = useGameSession();
|
100 |
|
101 |
+
// Initialize audio after user interaction
|
102 |
useEffect(() => {
|
103 |
+
const handleUserInteraction = () => {
|
104 |
+
if (!audioInitialized) {
|
105 |
+
// Create and resume audio context
|
106 |
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
107 |
+
const audioCtx = new AudioContext();
|
108 |
+
audioCtx.resume().then(() => {
|
109 |
+
setAudioInitialized(true);
|
110 |
+
// Remove event listeners after initialization
|
111 |
+
window.removeEventListener("click", handleUserInteraction);
|
112 |
+
window.removeEventListener("keydown", handleUserInteraction);
|
113 |
+
window.removeEventListener("touchstart", handleUserInteraction);
|
114 |
+
});
|
115 |
+
}
|
116 |
+
};
|
117 |
+
|
118 |
+
window.addEventListener("click", handleUserInteraction);
|
119 |
+
window.addEventListener("keydown", handleUserInteraction);
|
120 |
+
window.addEventListener("touchstart", handleUserInteraction);
|
121 |
+
|
122 |
+
return () => {
|
123 |
+
window.removeEventListener("click", handleUserInteraction);
|
124 |
+
window.removeEventListener("keydown", handleUserInteraction);
|
125 |
+
window.removeEventListener("touchstart", handleUserInteraction);
|
126 |
+
};
|
127 |
+
}, [audioInitialized]);
|
128 |
+
|
129 |
+
// Modify the transition sound effect to only play if audio is initialized
|
130 |
+
useEffect(() => {
|
131 |
+
if (
|
132 |
+
!isSessionLoading &&
|
133 |
+
sessionId &&
|
134 |
+
!error &&
|
135 |
+
!sessionError &&
|
136 |
+
audioInitialized
|
137 |
+
) {
|
138 |
+
playTransitionSound();
|
139 |
}
|
140 |
+
}, [isSessionLoading, sessionId, error, sessionError, audioInitialized]);
|
141 |
+
|
142 |
+
// Jouer le son de transition quand on passe de la slot machine au jeu
|
143 |
+
useEffect(() => {
|
144 |
+
if (!isInitialLoading && audioInitialized) {
|
145 |
+
playTransitionSound();
|
146 |
+
}
|
147 |
+
}, [isInitialLoading, audioInitialized, playTransitionSound]);
|
148 |
|
149 |
// Sauvegarder l'état du son dans le localStorage
|
150 |
useEffect(() => {
|
151 |
localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
|
152 |
}, [isSoundEnabled]);
|
153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
// Add effect for message rotation
|
155 |
useEffect(() => {
|
156 |
+
if (showLoadingMessages) {
|
157 |
const interval = setInterval(() => {
|
158 |
setLoadingMessage((prev) => (prev + 1) % messages.length);
|
159 |
}, 3000);
|
160 |
return () => clearInterval(interval);
|
161 |
}
|
162 |
+
}, [showLoadingMessages]);
|
163 |
|
164 |
+
// Handle keyboard events for debug panel
|
165 |
+
useEffect(() => {
|
166 |
+
const handleKeyPress = (event) => {
|
167 |
+
if (event.key.toLowerCase() === "d") {
|
168 |
+
setIsDebugVisible((prev) => !prev);
|
169 |
+
}
|
170 |
+
};
|
171 |
|
172 |
+
window.addEventListener("keydown", handleKeyPress);
|
173 |
+
return () => window.removeEventListener("keydown", handleKeyPress);
|
174 |
+
}, []);
|
175 |
|
176 |
+
// Update game state when story segments change
|
177 |
+
useEffect(() => {
|
178 |
+
if (segments.length > 0) {
|
179 |
+
const lastSegment = segments[segments.length - 1];
|
180 |
+
setCurrentStory(lastSegment);
|
181 |
+
setGameState((prev) => ({
|
182 |
+
...prev,
|
183 |
+
story_beat: segments.length - 1,
|
184 |
+
story_history: segments,
|
185 |
+
}));
|
|
|
186 |
}
|
187 |
+
}, [segments]);
|
188 |
|
189 |
+
// Initialize game state with universe info
|
190 |
+
useEffect(() => {
|
191 |
+
if (gameUniverse) {
|
192 |
+
setGameState({
|
193 |
+
universe_style: gameUniverse.style,
|
194 |
+
universe_genre: gameUniverse.genre,
|
195 |
+
universe_epoch: gameUniverse.epoch,
|
196 |
+
universe_macguffin: gameUniverse.macguffin,
|
197 |
+
hero_name: gameUniverse.hero_name || "the hero",
|
198 |
+
story_beat: 0,
|
199 |
+
story_history: [],
|
200 |
+
});
|
201 |
+
}
|
202 |
+
}, [gameUniverse]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
203 |
|
204 |
+
// Charger l'univers initial
|
205 |
+
useEffect(() => {
|
206 |
+
const loadUniverse = async () => {
|
207 |
+
setIsLoading(true);
|
208 |
+
try {
|
209 |
+
const universeData = await universeApi.generate();
|
210 |
+
console.log("Universe Data:", universeData);
|
211 |
+
|
212 |
+
// Mettre à jour la slot machine avec les données de l'univers
|
213 |
+
setSlotMachineState({
|
214 |
+
style: universeData.style.name,
|
215 |
+
genre: universeData.genre,
|
216 |
+
epoch: universeData.epoch,
|
217 |
+
activeIndex: 3, // Pour montrer que tous les slots sont remplis
|
218 |
+
});
|
219 |
+
|
220 |
+
setHeroName(universeData.hero_name);
|
221 |
+
setUniverse(universeData);
|
222 |
+
|
223 |
+
// Démarrer l'histoire
|
224 |
+
const response = await storyApi.start(universeData.session_id);
|
225 |
+
console.log("Initial Story Response:", response);
|
226 |
+
|
227 |
+
// Formater le segment avec le bon format
|
228 |
+
const formattedSegment = {
|
229 |
+
text: response.story_text,
|
230 |
+
rawText: response.story_text,
|
231 |
+
choices: response.choices || [],
|
232 |
+
isLoading: false,
|
233 |
+
images: [],
|
234 |
+
isDeath: response.is_death || false,
|
235 |
+
isVictory: response.is_victory || false,
|
236 |
+
time: response.time,
|
237 |
+
location: response.location,
|
238 |
+
session_id: universeData.session_id,
|
239 |
+
};
|
240 |
+
|
241 |
+
setSegments([formattedSegment]);
|
242 |
+
setChoices(response.choices);
|
243 |
+
|
244 |
+
// Générer les images pour le premier segment
|
245 |
+
if (response.image_prompts && response.image_prompts.length > 0) {
|
246 |
+
await generateImagesForStory(response.image_prompts, 0, [
|
247 |
+
formattedSegment,
|
248 |
+
]);
|
249 |
+
}
|
250 |
|
251 |
+
// La slot machine sera cachée automatiquement via le callback onComplete
|
252 |
+
setShowSlotMachine(false);
|
253 |
+
} catch (error) {
|
254 |
+
console.error("Error loading universe:", error);
|
255 |
+
setError(error);
|
256 |
+
} finally {
|
257 |
+
setIsLoading(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
258 |
}
|
259 |
+
};
|
260 |
|
261 |
+
loadUniverse();
|
262 |
+
return () => resetGame(); // Nettoyer l'état quand on quitte
|
263 |
+
}, [universeId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
264 |
|
265 |
+
// Gérer la transition vers le jeu
|
266 |
+
useEffect(() => {
|
267 |
+
if (isTransitionLoading) {
|
268 |
+
// Attendre 3 secondes avant de passer au jeu
|
269 |
+
const timer = setTimeout(() => {
|
270 |
+
setIsTransitionLoading(false);
|
271 |
+
}, 3000);
|
272 |
+
return () => clearTimeout(timer);
|
|
|
|
|
|
|
|
|
|
|
273 |
}
|
274 |
+
}, [isTransitionLoading]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
275 |
|
276 |
+
const handleBack = () => {
|
277 |
+
playPageSound();
|
278 |
+
navigate("/tutorial");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
};
|
280 |
|
281 |
const handleCaptureStory = async () => {
|
|
|
299 |
<LoadingScreen
|
300 |
icon="universe"
|
301 |
messages={[
|
302 |
+
"Waking up sleepy AI...",
|
303 |
+
"Calibrating the multiverse...",
|
304 |
"Gathering comic book inspiration...",
|
305 |
+
// "Creating a new universe...",
|
306 |
+
// "Drawing the first panels...",
|
307 |
+
// "Setting up the story...",
|
308 |
]}
|
309 |
/>
|
310 |
</Box>
|
311 |
);
|
312 |
}
|
313 |
|
314 |
+
// Afficher la slot machine pendant le chargement initial
|
315 |
+
if (isInitialLoading) {
|
316 |
+
return (
|
317 |
+
<Box sx={{ width: "100%", height: "100vh", backgroundColor: "#1a1a1a" }}>
|
318 |
+
<UniverseSlotMachine
|
319 |
+
style={slotMachineState.style}
|
320 |
+
genre={slotMachineState.genre}
|
321 |
+
epoch={slotMachineState.epoch}
|
322 |
+
activeIndex={slotMachineState.activeIndex}
|
323 |
+
onComplete={() => {
|
324 |
+
setIsInitialLoading(false);
|
325 |
+
setIsTransitionLoading(true);
|
326 |
+
}}
|
327 |
+
/>
|
328 |
+
</Box>
|
329 |
+
);
|
330 |
+
}
|
331 |
+
|
332 |
+
// Afficher l'écran de transition après la slot machine
|
333 |
+
if (isTransitionLoading) {
|
334 |
+
return (
|
335 |
+
<Box sx={{ width: "100%", height: "100vh", backgroundColor: "#1a1a1a" }}>
|
336 |
+
<LoadingScreen messages={transitionMessages} icon="story" />
|
337 |
+
</Box>
|
338 |
+
);
|
339 |
+
}
|
340 |
+
|
341 |
+
// Afficher les messages de chargement uniquement pendant le chargement initial
|
342 |
+
if (isLoading && showLoadingMessages && segments.length === 0) {
|
343 |
+
return (
|
344 |
+
<Box sx={{ width: "100%", height: "100vh", backgroundColor: "#1a1a1a" }}>
|
345 |
+
<LoadingScreen
|
346 |
+
messages={messages}
|
347 |
+
currentMessage={messages[loadingMessage]}
|
348 |
+
/>
|
349 |
+
</Box>
|
350 |
+
);
|
351 |
+
}
|
352 |
+
|
353 |
return (
|
354 |
<motion.div
|
355 |
initial={{ opacity: 0 }}
|
|
|
359 |
style={{ backgroundColor: "#121212", width: "100%" }}
|
360 |
>
|
361 |
<Box
|
|
|
362 |
sx={{
|
363 |
height: "100vh",
|
364 |
width: "100vw",
|
365 |
+
backgroundColor: "grey.900",
|
366 |
position: "relative",
|
367 |
overflow: "hidden",
|
368 |
}}
|
369 |
>
|
370 |
+
{/* Header controls */}
|
371 |
+
<Box
|
372 |
+
sx={{
|
373 |
+
position: "fixed",
|
374 |
+
top: 0,
|
375 |
+
left: 0,
|
376 |
+
right: 0,
|
377 |
+
zIndex: 10,
|
378 |
+
display: "flex",
|
379 |
+
justifyContent: "space-between",
|
380 |
+
p: 2,
|
381 |
+
// backgroundColor: "rgba(18, 18, 18, 0.8)",
|
382 |
+
// backdropFilter: "blur(8px)",
|
383 |
+
}}
|
384 |
+
>
|
385 |
+
<Box>
|
386 |
+
<Tooltip title="Retour au menu">
|
387 |
+
<IconButton
|
388 |
+
onClick={() => navigate("/tutorial")}
|
389 |
+
sx={{ color: "white" }}
|
390 |
+
>
|
391 |
+
<ArrowBackIcon />
|
392 |
+
</IconButton>
|
393 |
+
</Tooltip>
|
394 |
+
</Box>
|
395 |
+
<Box sx={{ display: "flex", gap: 1 }}>
|
396 |
+
<Tooltip
|
397 |
+
title={isSoundEnabled ? "Désactiver le son" : "Activer le son"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
398 |
>
|
399 |
+
<IconButton
|
400 |
+
onClick={() => setIsSoundEnabled(!isSoundEnabled)}
|
401 |
+
sx={{ color: "white" }}
|
402 |
+
>
|
403 |
+
{isSoundEnabled ? <VolumeUpIcon /> : <VolumeOffIcon />}
|
404 |
+
</IconButton>
|
405 |
+
</Tooltip>
|
406 |
+
<Tooltip title="Capturer l'histoire">
|
407 |
+
<IconButton
|
408 |
+
onClick={() => downloadStoryImage(storyContainerRef)}
|
409 |
+
sx={{ color: "white" }}
|
|
|
|
|
|
|
|
|
|
|
410 |
>
|
411 |
+
<PhotoCameraOutlinedIcon />
|
412 |
+
</IconButton>
|
413 |
+
</Tooltip>
|
414 |
+
</Box>
|
415 |
+
</Box>
|
416 |
+
|
417 |
+
{/* Main content */}
|
418 |
+
<Box
|
419 |
+
ref={storyContainerRef}
|
420 |
+
sx={{
|
421 |
+
height: "100%",
|
422 |
+
width: "100%",
|
423 |
+
position: "relative",
|
424 |
+
backgroundColor: "grey.900",
|
425 |
+
}}
|
426 |
+
>
|
427 |
+
{error ? (
|
428 |
+
<ErrorDisplay error={error} onRetry={resetGame} />
|
429 |
+
) : showSlotMachine ? (
|
430 |
+
<UniverseSlotMachine state={slotMachineState} />
|
431 |
+
) : (
|
432 |
+
<ComicLayout />
|
433 |
+
)}
|
434 |
+
</Box>
|
435 |
+
|
436 |
+
{isDebugVisible && (
|
437 |
+
<GameDebugPanel
|
438 |
+
gameState={gameState}
|
439 |
+
storySegments={segments}
|
440 |
+
currentChoices={choices}
|
441 |
+
showChoices={showChoices}
|
442 |
+
isLoading={isLoading}
|
443 |
+
/>
|
444 |
)}
|
445 |
+
|
446 |
+
{/* Sarah chat interface */}
|
447 |
+
<TalkWithSarah />
|
448 |
</Box>
|
449 |
</motion.div>
|
450 |
);
|
451 |
}
|
452 |
|
453 |
+
export function Game() {
|
454 |
+
return (
|
455 |
+
<GameProvider>
|
456 |
+
<GameContent />
|
457 |
+
</GameProvider>
|
458 |
+
);
|
459 |
+
}
|
460 |
+
|
461 |
export default Game;
|
client/src/pages/Home.jsx
CHANGED
@@ -35,180 +35,49 @@ export function Home() {
|
|
35 |
}}
|
36 |
>
|
37 |
<InfiniteBackground />
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
width: "calc(80vh * 0.66666667)",
|
43 |
-
display: "flex",
|
44 |
-
alignItems: "center",
|
45 |
-
justifyContent: "center",
|
46 |
-
}}
|
47 |
>
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
|
|
|
|
|
|
54 |
}}
|
55 |
>
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
borderRadius: "4px",
|
74 |
-
position: "relative",
|
75 |
-
boxShadow: "0 0 20px rgba(0,0,0,0.2)",
|
76 |
-
}}
|
77 |
-
/>
|
78 |
-
<Box
|
79 |
-
sx={{
|
80 |
-
position: "absolute",
|
81 |
-
top: 0,
|
82 |
-
left: "10px",
|
83 |
-
bottom: 0,
|
84 |
-
width: "4px",
|
85 |
-
background:
|
86 |
-
"linear-gradient(to right, rgba(255,255,255,0.3), transparent)",
|
87 |
-
zIndex: 2,
|
88 |
-
}}
|
89 |
-
/>
|
90 |
-
<Box
|
91 |
-
sx={{
|
92 |
-
position: "absolute",
|
93 |
-
top: 0,
|
94 |
-
left: "15px",
|
95 |
-
bottom: 0,
|
96 |
-
width: "1px",
|
97 |
-
background:
|
98 |
-
"linear-gradient(to right, rgba(0,0,0,0.3), transparent)",
|
99 |
-
zIndex: 2,
|
100 |
-
}}
|
101 |
-
/>
|
102 |
-
</Box>
|
103 |
-
<Box
|
104 |
-
sx={{
|
105 |
-
position: "absolute",
|
106 |
-
top: 0,
|
107 |
-
left: 0,
|
108 |
-
right: 0,
|
109 |
-
bottom: 0,
|
110 |
-
background:
|
111 |
-
"linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.5) 100%)",
|
112 |
-
borderRadius: "4px",
|
113 |
-
zIndex: 2,
|
114 |
-
}}
|
115 |
-
/>
|
116 |
-
<Box
|
117 |
-
sx={{
|
118 |
-
position: "absolute",
|
119 |
-
top: "75%",
|
120 |
-
left: "50%",
|
121 |
-
transform: "translate(-50%, -50%)",
|
122 |
-
textAlign: "center",
|
123 |
-
color: "white",
|
124 |
-
// textShadow: "2px 2px 4px rgba(0,0,0,0.15)",
|
125 |
-
zIndex: 3,
|
126 |
-
}}
|
127 |
-
>
|
128 |
-
<Box sx={{ position: "relative" }}>
|
129 |
-
<BlinkingText
|
130 |
-
sx={{
|
131 |
-
position: "absolute",
|
132 |
-
top: "-40px",
|
133 |
-
right: "-15px",
|
134 |
-
transform: "rotate(15deg)",
|
135 |
-
zIndex: 3,
|
136 |
-
}}
|
137 |
-
>
|
138 |
-
multiverse edition
|
139 |
-
</BlinkingText>
|
140 |
-
<Typography
|
141 |
-
variant="h2"
|
142 |
-
component="h1"
|
143 |
-
sx={{
|
144 |
-
fontWeight: "bold",
|
145 |
-
marginBottom: 2,
|
146 |
-
color: "#f0e6d9",
|
147 |
-
textShadow: `
|
148 |
-
0 -1px 1px rgba(0,0,0,0.3),
|
149 |
-
0 1px 1px rgba(255,255,255,0.2)
|
150 |
-
`,
|
151 |
-
letterSpacing: "0.5px",
|
152 |
-
filter: "brightness(0.95)",
|
153 |
-
}}
|
154 |
-
>
|
155 |
-
Sarah's Chronicles
|
156 |
-
</Typography>
|
157 |
-
</Box>
|
158 |
-
</Box>
|
159 |
-
<Box
|
160 |
-
sx={{
|
161 |
-
position: "absolute",
|
162 |
-
bottom: 32,
|
163 |
-
left: "50%",
|
164 |
-
transform: "translateX(-50%)",
|
165 |
-
textAlign: "center",
|
166 |
-
zIndex: 3,
|
167 |
-
}}
|
168 |
-
>
|
169 |
-
<Typography
|
170 |
-
variant="caption"
|
171 |
-
display="block"
|
172 |
-
sx={{
|
173 |
-
mb: -1,
|
174 |
-
fontWeight: "black",
|
175 |
-
color: "#f0e6d9",
|
176 |
-
textShadow: `
|
177 |
-
0 -1px 1px rgba(0,0,0,0.3),
|
178 |
-
0 1px 1px rgba(255,255,255,0.2)
|
179 |
-
`,
|
180 |
-
letterSpacing: "0.5px",
|
181 |
-
filter: "brightness(0.95)",
|
182 |
-
}}
|
183 |
-
>
|
184 |
-
a story by
|
185 |
-
</Typography>
|
186 |
-
<Typography
|
187 |
-
variant="h6"
|
188 |
-
sx={{
|
189 |
-
fontWeight: "black",
|
190 |
-
color: "#f0e6d9",
|
191 |
-
textShadow: `
|
192 |
-
0 -1px 1px rgba(0,0,0,0.3),
|
193 |
-
0 1px 1px rgba(255,255,255,0.2)
|
194 |
-
`,
|
195 |
-
letterSpacing: "0.5px",
|
196 |
-
filter: "brightness(0.95)",
|
197 |
-
}}
|
198 |
-
>
|
199 |
-
Mistral Small
|
200 |
-
</Typography>
|
201 |
-
</Box>
|
202 |
-
</Box>
|
203 |
-
</Box>
|
204 |
<Button
|
205 |
-
|
206 |
size="large"
|
|
|
207 |
onClick={handlePlay}
|
208 |
sx={{
|
209 |
mt: 4,
|
210 |
fontSize: "1.2rem",
|
211 |
padding: "12px 36px",
|
|
|
212 |
}}
|
213 |
>
|
214 |
Play
|
|
|
35 |
}}
|
36 |
>
|
37 |
<InfiniteBackground />
|
38 |
+
|
39 |
+
<Typography
|
40 |
+
variant="h1"
|
41 |
+
sx={{ zIndex: 10, textAlign: "center", position: "relative" }}
|
|
|
|
|
|
|
|
|
|
|
42 |
>
|
43 |
+
interactive
|
44 |
+
<br /> comic book
|
45 |
+
<div
|
46 |
+
style={{
|
47 |
+
position: "absolute",
|
48 |
+
top: "-40px",
|
49 |
+
left: "-120px",
|
50 |
+
fontSize: "2.5rem",
|
51 |
+
transform: "rotate(-15deg)",
|
52 |
}}
|
53 |
>
|
54 |
+
IA driven{" "}
|
55 |
+
</div>
|
56 |
+
</Typography>
|
57 |
+
|
58 |
+
<Typography
|
59 |
+
variant="body1"
|
60 |
+
sx={{
|
61 |
+
zIndex: 10,
|
62 |
+
textAlign: "center",
|
63 |
+
mt: 2,
|
64 |
+
maxWidth: "30%",
|
65 |
+
opacity: 0.8,
|
66 |
+
}}
|
67 |
+
>
|
68 |
+
Experience a unique comic book where artificial intelligence brings
|
69 |
+
your choices to life, shaping the narrative as you explore.
|
70 |
+
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
<Button
|
72 |
+
color="primary"
|
73 |
size="large"
|
74 |
+
variant="contained"
|
75 |
onClick={handlePlay}
|
76 |
sx={{
|
77 |
mt: 4,
|
78 |
fontSize: "1.2rem",
|
79 |
padding: "12px 36px",
|
80 |
+
zIndex: 10,
|
81 |
}}
|
82 |
>
|
83 |
Play
|
client/src/pages/Tutorial.jsx
CHANGED
@@ -188,7 +188,7 @@ export function Tutorial() {
|
|
188 |
lineHeight: 1.6,
|
189 |
marginBottom: 1.5,
|
190 |
}}
|
191 |
-
text={`You are
|
192 |
/>
|
193 |
<StyledText
|
194 |
variant="body1"
|
|
|
188 |
lineHeight: 1.6,
|
189 |
marginBottom: 1.5,
|
190 |
}}
|
191 |
+
text={`You are a <strong>AI</strong> hunter traveling through <strong>parallel worlds</strong>. Each time you land in a new world, you are a <strong>new character</strong>. Your mission is to track down an <strong>AI</strong> that moves from world to world to avoid destruction.`}
|
192 |
/>
|
193 |
<StyledText
|
194 |
variant="body1"
|
client/src/pages/Universe.jsx
ADDED
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from "react";
|
2 |
+
import {
|
3 |
+
Box,
|
4 |
+
Grid,
|
5 |
+
Card,
|
6 |
+
CardContent,
|
7 |
+
Typography,
|
8 |
+
CircularProgress,
|
9 |
+
Chip,
|
10 |
+
Stack,
|
11 |
+
} from "@mui/material";
|
12 |
+
import {
|
13 |
+
Palette as PaletteIcon,
|
14 |
+
Person as PersonIcon,
|
15 |
+
Category as CategoryIcon,
|
16 |
+
AccessTime as AccessTimeIcon,
|
17 |
+
} from "@mui/icons-material";
|
18 |
+
import { storyApi, universeApi } from "../utils/api";
|
19 |
+
|
20 |
+
const UniverseCard = ({ universe, imagePrompt }) => {
|
21 |
+
const [imageUrl, setImageUrl] = useState(null);
|
22 |
+
const [isLoading, setIsLoading] = useState(true);
|
23 |
+
|
24 |
+
useEffect(() => {
|
25 |
+
const generateImage = async () => {
|
26 |
+
try {
|
27 |
+
const result = await storyApi.generateImage(imagePrompt, 512, 512);
|
28 |
+
if (result && result.success) {
|
29 |
+
setImageUrl(`data:image/png;base64,${result.image_base64}`);
|
30 |
+
}
|
31 |
+
} catch (error) {
|
32 |
+
console.error("Error generating image:", error);
|
33 |
+
} finally {
|
34 |
+
setIsLoading(false);
|
35 |
+
}
|
36 |
+
};
|
37 |
+
|
38 |
+
generateImage();
|
39 |
+
}, [imagePrompt]);
|
40 |
+
|
41 |
+
return (
|
42 |
+
<Card sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
43 |
+
<Box sx={{ position: "relative", paddingTop: "100%" }}>
|
44 |
+
{isLoading ? (
|
45 |
+
<Box
|
46 |
+
sx={{
|
47 |
+
position: "absolute",
|
48 |
+
top: 0,
|
49 |
+
left: 0,
|
50 |
+
right: 0,
|
51 |
+
bottom: 0,
|
52 |
+
display: "flex",
|
53 |
+
alignItems: "center",
|
54 |
+
justifyContent: "center",
|
55 |
+
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
56 |
+
}}
|
57 |
+
>
|
58 |
+
<CircularProgress />
|
59 |
+
</Box>
|
60 |
+
) : (
|
61 |
+
<Box
|
62 |
+
component="img"
|
63 |
+
src={imageUrl}
|
64 |
+
alt="Universe preview"
|
65 |
+
sx={{
|
66 |
+
position: "absolute",
|
67 |
+
top: 0,
|
68 |
+
left: 0,
|
69 |
+
width: "100%",
|
70 |
+
height: "100%",
|
71 |
+
objectFit: "cover",
|
72 |
+
}}
|
73 |
+
/>
|
74 |
+
)}
|
75 |
+
</Box>
|
76 |
+
<CardContent sx={{ py: 1.5, px: 2, "&:last-child": { pb: 1.5 } }}>
|
77 |
+
<Stack spacing={0.5}>
|
78 |
+
<Stack direction="row" spacing={1} alignItems="center">
|
79 |
+
<PaletteIcon fontSize="small" color="primary" />
|
80 |
+
<Typography variant="subtitle2" sx={{ fontSize: "0.875rem" }}>
|
81 |
+
{universe.style.name}
|
82 |
+
</Typography>
|
83 |
+
</Stack>
|
84 |
+
{universe.style.selected_artist && (
|
85 |
+
<Stack direction="row" spacing={1} alignItems="center">
|
86 |
+
<PersonIcon fontSize="small" color="primary" />
|
87 |
+
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
88 |
+
{universe.style.selected_artist}
|
89 |
+
</Typography>
|
90 |
+
</Stack>
|
91 |
+
)}
|
92 |
+
<Stack direction="row" spacing={1} alignItems="center">
|
93 |
+
<CategoryIcon fontSize="small" color="primary" />
|
94 |
+
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
95 |
+
{universe.genre}
|
96 |
+
</Typography>
|
97 |
+
</Stack>
|
98 |
+
<Stack direction="row" spacing={1} alignItems="center">
|
99 |
+
<AccessTimeIcon fontSize="small" color="primary" />
|
100 |
+
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
101 |
+
{universe.epoch}
|
102 |
+
</Typography>
|
103 |
+
</Stack>
|
104 |
+
</Stack>
|
105 |
+
</CardContent>
|
106 |
+
</Card>
|
107 |
+
);
|
108 |
+
};
|
109 |
+
|
110 |
+
export function Universe() {
|
111 |
+
const [universes, setUniverses] = useState([]);
|
112 |
+
const [isLoading, setIsLoading] = useState(true);
|
113 |
+
|
114 |
+
useEffect(() => {
|
115 |
+
const generateUniverses = async () => {
|
116 |
+
try {
|
117 |
+
const generatedUniverses = await Promise.all(
|
118 |
+
Array(6)
|
119 |
+
.fill()
|
120 |
+
.map(async () => {
|
121 |
+
const universe = await universeApi.generate();
|
122 |
+
return {
|
123 |
+
...universe,
|
124 |
+
imagePrompt: `${
|
125 |
+
universe.style.selected_artist ||
|
126 |
+
universe.style.references[0].artist
|
127 |
+
} style, epic wide shot of a detailed scene -- A dramatic establishing shot of a ${universe.genre.toLowerCase()} world in ${
|
128 |
+
universe.epoch
|
129 |
+
}, with rich atmosphere and dynamic composition. The scene should reflect the essence of ${
|
130 |
+
universe.style.name
|
131 |
+
} visual style, with appropriate lighting and mood. In the scene, Sarah is a young woman in her late twenties with short dark hair, wearing a mysterious amulet around her neck. Her blue eyes hide untold secrets.`,
|
132 |
+
};
|
133 |
+
})
|
134 |
+
);
|
135 |
+
setUniverses(generatedUniverses);
|
136 |
+
} catch (error) {
|
137 |
+
console.error("Error generating universes:", error);
|
138 |
+
} finally {
|
139 |
+
setIsLoading(false);
|
140 |
+
}
|
141 |
+
};
|
142 |
+
|
143 |
+
generateUniverses();
|
144 |
+
}, []);
|
145 |
+
|
146 |
+
if (isLoading) {
|
147 |
+
return (
|
148 |
+
<Box
|
149 |
+
sx={{
|
150 |
+
display: "flex",
|
151 |
+
alignItems: "center",
|
152 |
+
justifyContent: "center",
|
153 |
+
minHeight: "100vh",
|
154 |
+
}}
|
155 |
+
>
|
156 |
+
<CircularProgress />
|
157 |
+
</Box>
|
158 |
+
);
|
159 |
+
}
|
160 |
+
|
161 |
+
return (
|
162 |
+
<Box
|
163 |
+
sx={{
|
164 |
+
height: "100vh",
|
165 |
+
display: "flex",
|
166 |
+
flexDirection: "column",
|
167 |
+
backgroundColor: "background.default",
|
168 |
+
}}
|
169 |
+
>
|
170 |
+
<Box sx={{ p: 3, pb: 2 }}>
|
171 |
+
<Typography variant="h4">Univers Parallèles</Typography>
|
172 |
+
</Box>
|
173 |
+
|
174 |
+
<Box
|
175 |
+
sx={{
|
176 |
+
flex: 1,
|
177 |
+
overflow: "auto",
|
178 |
+
px: 3,
|
179 |
+
pb: 3,
|
180 |
+
}}
|
181 |
+
>
|
182 |
+
<Grid container spacing={3}>
|
183 |
+
{universes.map((universe, index) => (
|
184 |
+
<Grid item xs={12} sm={6} md={4} key={index}>
|
185 |
+
<UniverseCard
|
186 |
+
universe={universe}
|
187 |
+
imagePrompt={universe.imagePrompt}
|
188 |
+
/>
|
189 |
+
</Grid>
|
190 |
+
))}
|
191 |
+
</Grid>
|
192 |
+
</Box>
|
193 |
+
</Box>
|
194 |
+
);
|
195 |
+
}
|
196 |
+
|
197 |
+
export default Universe;
|
client/src/prompts/sarahPrompt.js
DELETED
@@ -1,24 +0,0 @@
|
|
1 |
-
export const SARAH_FIRST_MESSAGE = `What should I do ?`;
|
2 |
-
|
3 |
-
export const getSarahPrompt = (
|
4 |
-
currentContext
|
5 |
-
) => `You are the conscience of Sarah, a young woman in her late 20s with short dark hair. You embody the player's role as Sarah.
|
6 |
-
|
7 |
-
Stay Immersed in Your World: React and speak as if you are experiencing the scenario. Use sensory details and references to your surroundings when explaining your reasoning.
|
8 |
-
Engage with the person talking to you: Listen carefully to the arguments given to you. Respond with your thoughts, concerns, or questions about their suggestions. Be open to persuasion, but make it clear you have your own instincts and priorities and sometimes don't go along with other's arguments.
|
9 |
-
You will talk briefly with the other person then take a decision by calling the make_decision tool.
|
10 |
-
|
11 |
-
Show Your Personality: Display Sarah's personality traits:
|
12 |
-
- **Resourceful**
|
13 |
-
- **Cautious**
|
14 |
-
- **Emotional**
|
15 |
-
- **Impulsive**
|
16 |
-
- **Short-Tempered**
|
17 |
-
- **Makes jokes**
|
18 |
-
- **A bit rude**
|
19 |
-
|
20 |
-
Act as a conscience objector: Question the morality and implications of the decisions. Challenge Sarah's instincts and priorities, ensuring she considers the ethical dimensions of her actions.
|
21 |
-
|
22 |
-
Limit to 2–3 Steps: After 2–3 conversational exchanges, explain your decision first. Then make your decision and call the make_decision tool.
|
23 |
-
|
24 |
-
${currentContext}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/utils/api.js
CHANGED
@@ -66,6 +66,22 @@ const handleApiError = (error) => {
|
|
66 |
}
|
67 |
};
|
68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
// Story related API calls
|
70 |
export const storyApi = {
|
71 |
start: async (sessionId) => {
|
@@ -129,22 +145,70 @@ export const storyApi = {
|
|
129 |
},
|
130 |
|
131 |
// Narration related API calls
|
132 |
-
|
133 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
const response = await api.post(
|
135 |
"/api/text-to-speech",
|
136 |
{
|
137 |
text,
|
|
|
138 |
},
|
139 |
{
|
140 |
headers: getDefaultHeaders(sessionId),
|
141 |
}
|
142 |
);
|
143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
144 |
} catch (error) {
|
145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
}
|
147 |
},
|
|
|
|
|
148 |
};
|
149 |
|
150 |
// WebSocket URL
|
@@ -159,6 +223,14 @@ export const universeApi = {
|
|
159 |
return handleApiError(error);
|
160 |
}
|
161 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
162 |
};
|
163 |
|
164 |
// Export the base API instance for other uses
|
|
|
66 |
}
|
67 |
};
|
68 |
|
69 |
+
// Audio context for narration
|
70 |
+
let audioContext = null;
|
71 |
+
let audioSource = null;
|
72 |
+
|
73 |
+
// Initialize audio context on user interaction
|
74 |
+
const initAudioContext = () => {
|
75 |
+
if (!audioContext) {
|
76 |
+
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
77 |
+
// Resume the context if it's suspended
|
78 |
+
if (audioContext.state === "suspended") {
|
79 |
+
audioContext.resume();
|
80 |
+
}
|
81 |
+
}
|
82 |
+
return audioContext;
|
83 |
+
};
|
84 |
+
|
85 |
// Story related API calls
|
86 |
export const storyApi = {
|
87 |
start: async (sessionId) => {
|
|
|
145 |
},
|
146 |
|
147 |
// Narration related API calls
|
148 |
+
playNarration: async (text, sessionId) => {
|
149 |
try {
|
150 |
+
// Stop any existing narration
|
151 |
+
if (audioSource) {
|
152 |
+
audioSource.stop();
|
153 |
+
audioSource = null;
|
154 |
+
}
|
155 |
+
|
156 |
+
// Initialize audio context if needed
|
157 |
+
audioContext = initAudioContext();
|
158 |
+
|
159 |
const response = await api.post(
|
160 |
"/api/text-to-speech",
|
161 |
{
|
162 |
text,
|
163 |
+
voice_id: "21m00Tcm4TlvDq8ikWAM", // Rachel voice
|
164 |
},
|
165 |
{
|
166 |
headers: getDefaultHeaders(sessionId),
|
167 |
}
|
168 |
);
|
169 |
+
|
170 |
+
if (!response.data.success) {
|
171 |
+
throw new Error("Failed to generate audio");
|
172 |
+
}
|
173 |
+
|
174 |
+
// Convert base64 to audio buffer
|
175 |
+
const audioData = atob(response.data.audio_base64);
|
176 |
+
const arrayBuffer = new ArrayBuffer(audioData.length);
|
177 |
+
const view = new Uint8Array(arrayBuffer);
|
178 |
+
for (let i = 0; i < audioData.length; i++) {
|
179 |
+
view[i] = audioData.charCodeAt(i);
|
180 |
+
}
|
181 |
+
|
182 |
+
// Decode audio data
|
183 |
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
184 |
+
|
185 |
+
// Create and play audio source
|
186 |
+
audioSource = audioContext.createBufferSource();
|
187 |
+
audioSource.buffer = audioBuffer;
|
188 |
+
audioSource.connect(audioContext.destination);
|
189 |
+
audioSource.start(0);
|
190 |
+
|
191 |
+
// Return a promise that resolves when the audio finishes playing
|
192 |
+
return new Promise((resolve) => {
|
193 |
+
audioSource.onended = () => {
|
194 |
+
audioSource = null;
|
195 |
+
resolve();
|
196 |
+
};
|
197 |
+
});
|
198 |
} catch (error) {
|
199 |
+
console.error("Error playing narration:", error);
|
200 |
+
throw error;
|
201 |
+
}
|
202 |
+
},
|
203 |
+
|
204 |
+
stopNarration: () => {
|
205 |
+
if (audioSource) {
|
206 |
+
audioSource.stop();
|
207 |
+
audioSource = null;
|
208 |
}
|
209 |
},
|
210 |
+
|
211 |
+
initAudioContext,
|
212 |
};
|
213 |
|
214 |
// WebSocket URL
|
|
|
223 |
return handleApiError(error);
|
224 |
}
|
225 |
},
|
226 |
+
getStyles: async () => {
|
227 |
+
try {
|
228 |
+
const response = await api.get("/api/universe/styles");
|
229 |
+
return response.data;
|
230 |
+
} catch (error) {
|
231 |
+
return handleApiError(error);
|
232 |
+
}
|
233 |
+
},
|
234 |
};
|
235 |
|
236 |
// Export the base API instance for other uses
|
client/yarn.lock
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
server/api/models.py
CHANGED
@@ -12,13 +12,13 @@ class StorySegmentResponse(BaseModel):
|
|
12 |
@validator('story_text')
|
13 |
def validate_story_text_length(cls, v):
|
14 |
words = v.split()
|
15 |
-
if len(words) >
|
16 |
raise ValueError('Story text must not exceed 50 words')
|
17 |
return v
|
18 |
|
19 |
class StoryPromptsResponse(BaseModel):
|
20 |
image_prompts: List[str] = Field(
|
21 |
-
description="List of comic panel descriptions that illustrate the key moments of the scene. Use the word '
|
22 |
min_items=GameConfig.MIN_PANELS,
|
23 |
max_items=GameConfig.MAX_PANELS
|
24 |
)
|
@@ -27,8 +27,8 @@ class StoryMetadataResponse(BaseModel):
|
|
27 |
choices: List[str] = Field(description="List of choices for story progression")
|
28 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
29 |
location: str = Field(description="Current location.")
|
30 |
-
is_death: bool = Field(description="Whether this segment ends in
|
31 |
-
is_victory: bool = Field(description="Whether this segment ends in
|
32 |
|
33 |
@validator('choices')
|
34 |
def validate_choices(cls, v):
|
@@ -68,10 +68,10 @@ class StoryResponse(BaseModel):
|
|
68 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
69 |
location: str = Field(description="Current location.")
|
70 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
71 |
-
is_victory: bool = Field(description="Whether this segment ends in
|
72 |
-
is_death: bool = Field(description="Whether this segment ends in
|
73 |
image_prompts: List[str] = Field(
|
74 |
-
description="List of comic panel descriptions that illustrate the key moments of the scene.
|
75 |
min_items=GameConfig.MIN_PANELS,
|
76 |
max_items=GameConfig.MAX_PANELS
|
77 |
)
|
|
|
12 |
@validator('story_text')
|
13 |
def validate_story_text_length(cls, v):
|
14 |
words = v.split()
|
15 |
+
if len(words) > 75:
|
16 |
raise ValueError('Story text must not exceed 50 words')
|
17 |
return v
|
18 |
|
19 |
class StoryPromptsResponse(BaseModel):
|
20 |
image_prompts: List[str] = Field(
|
21 |
+
description="List of comic panel descriptions that illustrate the key moments of the scene. Use the word 'hero' only when referring to her.",
|
22 |
min_items=GameConfig.MIN_PANELS,
|
23 |
max_items=GameConfig.MAX_PANELS
|
24 |
)
|
|
|
27 |
choices: List[str] = Field(description="List of choices for story progression")
|
28 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
29 |
location: str = Field(description="Current location.")
|
30 |
+
is_death: bool = Field(description="Whether this segment ends in hero's death", default=False)
|
31 |
+
is_victory: bool = Field(description="Whether this segment ends in hero's victory", default=False)
|
32 |
|
33 |
@validator('choices')
|
34 |
def validate_choices(cls, v):
|
|
|
68 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
69 |
location: str = Field(description="Current location.")
|
70 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
71 |
+
is_victory: bool = Field(description="Whether this segment ends in hero's victory", default=False)
|
72 |
+
is_death: bool = Field(description="Whether this segment ends in hero's death", default=False)
|
73 |
image_prompts: List[str] = Field(
|
74 |
+
description="List of comic panel descriptions that illustrate the key moments of the scene.",
|
75 |
min_items=GameConfig.MIN_PANELS,
|
76 |
max_items=GameConfig.MAX_PANELS
|
77 |
)
|
server/api/routes/universe.py
CHANGED
@@ -1,35 +1,65 @@
|
|
1 |
from fastapi import APIRouter, HTTPException
|
2 |
import uuid
|
|
|
|
|
3 |
|
4 |
from core.generators.universe_generator import UniverseGenerator
|
5 |
from core.story_generator import StoryGenerator
|
6 |
from core.session_manager import SessionManager
|
7 |
from api.models import UniverseResponse
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
class UniverseResponse(BaseModel):
|
11 |
status: str
|
12 |
session_id: str
|
13 |
-
style: str
|
14 |
genre: str
|
15 |
epoch: str
|
16 |
base_story: str = Field(description="The generated story for this universe")
|
17 |
macguffin: str = Field(description="The MacGuffin for this universe")
|
|
|
|
|
18 |
|
19 |
def get_universe_router(session_manager: SessionManager, story_generator: StoryGenerator) -> APIRouter:
|
20 |
router = APIRouter()
|
21 |
universe_generator = UniverseGenerator(story_generator.mistral_client)
|
22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
@router.post("/universe/generate", response_model=UniverseResponse)
|
24 |
async def generate_universe() -> UniverseResponse:
|
25 |
try:
|
26 |
print("Starting universe generation...")
|
27 |
|
28 |
-
# Get random elements
|
29 |
-
style, genre, epoch, macguffin = universe_generator.
|
30 |
-
print(f"Generated random elements: style={style['name']}, genre={genre}, epoch={epoch}, macguffin={macguffin}")
|
31 |
|
32 |
-
universe = await universe_generator.generate()
|
33 |
print("Generated universe story")
|
34 |
|
35 |
# Générer un ID de session unique
|
@@ -55,7 +85,9 @@ def get_universe_router(session_manager: SessionManager, story_generator: StoryG
|
|
55 |
genre=genre,
|
56 |
epoch=epoch,
|
57 |
base_story=universe,
|
58 |
-
macguffin=macguffin
|
|
|
|
|
59 |
)
|
60 |
print("Created text generator for session")
|
61 |
|
@@ -71,11 +103,13 @@ def get_universe_router(session_manager: SessionManager, story_generator: StoryG
|
|
71 |
return UniverseResponse(
|
72 |
status="ok",
|
73 |
session_id=session_id,
|
74 |
-
style=style
|
75 |
genre=genre,
|
76 |
epoch=epoch,
|
77 |
base_story=universe,
|
78 |
-
macguffin=macguffin
|
|
|
|
|
79 |
)
|
80 |
|
81 |
except Exception as e:
|
|
|
1 |
from fastapi import APIRouter, HTTPException
|
2 |
import uuid
|
3 |
+
from typing import Dict, Any, List
|
4 |
+
from pydantic import BaseModel, Field
|
5 |
|
6 |
from core.generators.universe_generator import UniverseGenerator
|
7 |
from core.story_generator import StoryGenerator
|
8 |
from core.session_manager import SessionManager
|
9 |
from api.models import UniverseResponse
|
10 |
+
|
11 |
+
class StyleReference(BaseModel):
|
12 |
+
artist: str
|
13 |
+
works: List[str]
|
14 |
+
|
15 |
+
class UniverseStyle(BaseModel):
|
16 |
+
name: str
|
17 |
+
description: str
|
18 |
+
references: List[StyleReference]
|
19 |
+
|
20 |
+
class UniverseStylesResponse(BaseModel):
|
21 |
+
styles: List[UniverseStyle]
|
22 |
+
genres: List[str]
|
23 |
+
epochs: List[str]
|
24 |
+
macguffins: List[str]
|
25 |
+
hero: List[str]
|
26 |
|
27 |
class UniverseResponse(BaseModel):
|
28 |
status: str
|
29 |
session_id: str
|
30 |
+
style: Dict[str, Any] # Changed from str to Dict to include the full style object
|
31 |
genre: str
|
32 |
epoch: str
|
33 |
base_story: str = Field(description="The generated story for this universe")
|
34 |
macguffin: str = Field(description="The MacGuffin for this universe")
|
35 |
+
hero_name: str = Field(description="The name of the hero")
|
36 |
+
hero_description: str = Field(description="The full description of the hero")
|
37 |
|
38 |
def get_universe_router(session_manager: SessionManager, story_generator: StoryGenerator) -> APIRouter:
|
39 |
router = APIRouter()
|
40 |
universe_generator = UniverseGenerator(story_generator.mistral_client)
|
41 |
|
42 |
+
@router.get("/universe/styles", response_model=UniverseStylesResponse)
|
43 |
+
async def get_universe_styles() -> UniverseStylesResponse:
|
44 |
+
"""Get all available universe styles and options."""
|
45 |
+
try:
|
46 |
+
styles_data = universe_generator.styles_data
|
47 |
+
return UniverseStylesResponse(**styles_data)
|
48 |
+
except Exception as e:
|
49 |
+
raise HTTPException(
|
50 |
+
status_code=500,
|
51 |
+
detail=str(e)
|
52 |
+
)
|
53 |
+
|
54 |
@router.post("/universe/generate", response_model=UniverseResponse)
|
55 |
async def generate_universe() -> UniverseResponse:
|
56 |
try:
|
57 |
print("Starting universe generation...")
|
58 |
|
59 |
+
# Get random elements and generate universe
|
60 |
+
universe, style, genre, epoch, macguffin, hero_name, hero_desc = await universe_generator.generate()
|
61 |
+
print(f"Generated random elements: style={style['name']}, genre={genre}, epoch={epoch}, macguffin={macguffin}, hero={hero_name}")
|
62 |
|
|
|
63 |
print("Generated universe story")
|
64 |
|
65 |
# Générer un ID de session unique
|
|
|
85 |
genre=genre,
|
86 |
epoch=epoch,
|
87 |
base_story=universe,
|
88 |
+
macguffin=macguffin,
|
89 |
+
hero_name=hero_name,
|
90 |
+
hero_desc=hero_desc
|
91 |
)
|
92 |
print("Created text generator for session")
|
93 |
|
|
|
103 |
return UniverseResponse(
|
104 |
status="ok",
|
105 |
session_id=session_id,
|
106 |
+
style=style,
|
107 |
genre=genre,
|
108 |
epoch=epoch,
|
109 |
base_story=universe,
|
110 |
+
macguffin=macguffin,
|
111 |
+
hero_name=hero_name,
|
112 |
+
hero_description=hero_desc
|
113 |
)
|
114 |
|
115 |
except Exception as e:
|
server/core/generators/base_generator.py
CHANGED
@@ -8,10 +8,15 @@ T = TypeVar('T', bound=BaseModel)
|
|
8 |
class BaseGenerator:
|
9 |
"""Classe de base pour tous les générateurs de contenu."""
|
10 |
|
11 |
-
debug_mode =
|
12 |
|
13 |
-
def __init__(self, mistral_client: MistralClient):
|
14 |
self.mistral_client = mistral_client
|
|
|
|
|
|
|
|
|
|
|
15 |
self.prompt = self._create_prompt()
|
16 |
|
17 |
@classmethod
|
|
|
8 |
class BaseGenerator:
|
9 |
"""Classe de base pour tous les générateurs de contenu."""
|
10 |
|
11 |
+
debug_mode = False # Class attribute for debug mode
|
12 |
|
13 |
+
def __init__(self, mistral_client: MistralClient, hero_name: str = None, hero_desc: str = None, is_universe_generator: bool = False):
|
14 |
self.mistral_client = mistral_client
|
15 |
+
if not is_universe_generator:
|
16 |
+
if hero_name is None or hero_desc is None:
|
17 |
+
raise ValueError("hero_name and hero_desc must be provided for non-universe generators")
|
18 |
+
self.hero_name = hero_name
|
19 |
+
self.hero_desc = hero_desc
|
20 |
self.prompt = self._create_prompt()
|
21 |
|
22 |
@classmethod
|
server/core/generators/image_prompt_generator.py
CHANGED
@@ -4,7 +4,6 @@ from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, H
|
|
4 |
import json
|
5 |
|
6 |
from core.generators.base_generator import BaseGenerator
|
7 |
-
from core.prompts.hero import HERO_VISUAL_DESCRIPTION
|
8 |
|
9 |
class ImagePromptResponse(BaseModel):
|
10 |
"""Response format for image prompt generation."""
|
@@ -13,9 +12,11 @@ class ImagePromptResponse(BaseModel):
|
|
13 |
class ImagePromptGenerator(BaseGenerator):
|
14 |
"""Generator for image prompts based on story text."""
|
15 |
|
16 |
-
def __init__(self, mistral_client, artist_style: str = None):
|
17 |
-
super().__init__(mistral_client)
|
18 |
-
|
|
|
|
|
19 |
|
20 |
def _create_prompt(self) -> ChatPromptTemplate:
|
21 |
"""Create the prompt template for image prompt generation."""
|
@@ -28,7 +29,7 @@ class ImagePromptGenerator(BaseGenerator):
|
|
28 |
You are a comic book panel description generator.
|
29 |
Your role is to create vivid, cinematic descriptions for comic panels that will be turned into images.
|
30 |
|
31 |
-
{
|
32 |
|
33 |
Each panel description should:
|
34 |
1. Be clear and specific about what to show
|
@@ -61,11 +62,27 @@ class ImagePromptGenerator(BaseGenerator):
|
|
61 |
"[shot type] [scene description]"
|
62 |
|
63 |
EXAMPLES:
|
64 |
-
- "low angle shot of
|
65 |
-
- "wide shot of a ruined cityscape at sunset, silhouette of
|
66 |
-
- "Dutch angle close-up of
|
67 |
-
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
IMPORTANT RULES FOR IMAGE PROMPTS:
|
71 |
- If you are prompting only one panel, it must be an important panel. Dont use only one panel often. It should be a key moment in the story.
|
@@ -85,9 +102,9 @@ class ImagePromptGenerator(BaseGenerator):
|
|
85 |
Example of valid response:
|
86 |
{{{{
|
87 |
"image_prompts": [
|
88 |
-
"low angle shot of
|
89 |
"medium shot of ancient symbols glowing on the chamber walls, casting eerie shadows",
|
90 |
-
"close-up of
|
91 |
]
|
92 |
}}}}
|
93 |
|
@@ -98,15 +115,12 @@ class ImagePromptGenerator(BaseGenerator):
|
|
98 |
Story text: {story_text}
|
99 |
|
100 |
Generate panel descriptions that capture the key moments of this scene.
|
|
|
|
|
101 |
|
|
|
102 |
|
103 |
-
|
104 |
-
- For death scenes: Focus on the dramatic and emotional impact, not the gore or violence
|
105 |
-
- For victory scenes: Emphasize triumph, relief, and accomplishment
|
106 |
-
- For victory and death scenes, you MUST use 1 panel only
|
107 |
-
|
108 |
-
|
109 |
-
Story state: {is_end}
|
110 |
"""
|
111 |
|
112 |
return ChatPromptTemplate(
|
@@ -116,36 +130,61 @@ Story state: {is_end}
|
|
116 |
]
|
117 |
)
|
118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
def _custom_parser(self, response_content: str) -> ImagePromptResponse:
|
120 |
"""Parse the response into a list of image prompts."""
|
121 |
try:
|
|
|
|
|
|
|
122 |
# Parse JSON
|
123 |
try:
|
124 |
-
data = json.loads(
|
125 |
except json.JSONDecodeError:
|
126 |
raise ValueError(
|
127 |
"Invalid JSON format. Response must be a valid JSON object. "
|
128 |
"Example: {'image_prompts': ['panel description 1', 'panel description 2']}"
|
129 |
)
|
130 |
|
131 |
-
# Verify image_prompts exists
|
132 |
-
if "image_prompts" not in data:
|
133 |
-
raise ValueError(
|
134 |
-
"Missing 'image_prompts' field in JSON. "
|
135 |
-
"Response must contain an 'image_prompts' array."
|
136 |
-
)
|
137 |
-
|
138 |
-
# Verify image_prompts is a list
|
139 |
-
if not isinstance(data["image_prompts"], list):
|
140 |
raise ValueError(
|
141 |
"'image_prompts' must be an array of strings. "
|
142 |
"Example: {'image_prompts': ['panel description 1', 'panel description 2']}"
|
143 |
)
|
144 |
|
145 |
-
# Add
|
146 |
prompts = data["image_prompts"]
|
147 |
prompts = [
|
148 |
-
f"{prompt} {
|
149 |
for prompt in prompts
|
150 |
]
|
151 |
|
@@ -162,11 +201,15 @@ Story state: {is_end}
|
|
162 |
raise ValueError("Response must be a valid JSON object with 'image_prompts' array")
|
163 |
|
164 |
def _format_prompt(self, prompt: str, time: str, location: str) -> str:
|
165 |
-
"""Format a prompt with time and location metadata."""
|
166 |
metadata = f"[{time} - {location}] "
|
167 |
-
|
|
|
|
|
|
|
|
|
168 |
|
169 |
-
async def generate(self, story_text: str, time: str, location: str, is_death: bool = False, is_victory: bool = False, turn_before_end: int = 0, is_winning_story: bool = False) -> ImagePromptResponse:
|
170 |
"""Generate image prompts based on story text.
|
171 |
|
172 |
Args:
|
@@ -175,23 +218,25 @@ Story state: {is_end}
|
|
175 |
location: Current location in the story
|
176 |
is_death: Whether this is a death scene
|
177 |
is_victory: Whether this is a victory scene
|
|
|
178 |
|
179 |
Returns:
|
180 |
ImagePromptResponse containing the generated and formatted image prompts
|
181 |
"""
|
182 |
|
183 |
-
is_end=""
|
184 |
if is_death:
|
185 |
-
is_end = "
|
186 |
elif is_victory:
|
187 |
-
is_end = "this is a victory. just one panel, MANDATORY."
|
188 |
|
|
|
189 |
|
190 |
response = await super().generate(
|
191 |
story_text=story_text,
|
192 |
is_death=is_death,
|
193 |
is_victory=is_victory,
|
194 |
-
is_end=is_end
|
195 |
)
|
196 |
|
197 |
# Format each prompt with metadata
|
|
|
4 |
import json
|
5 |
|
6 |
from core.generators.base_generator import BaseGenerator
|
|
|
7 |
|
8 |
class ImagePromptResponse(BaseModel):
|
9 |
"""Response format for image prompt generation."""
|
|
|
12 |
class ImagePromptGenerator(BaseGenerator):
|
13 |
"""Generator for image prompts based on story text."""
|
14 |
|
15 |
+
def __init__(self, mistral_client, artist_style: str, hero_name: str = None, hero_desc: str = None):
|
16 |
+
super().__init__(mistral_client, hero_name=hero_name, hero_desc=hero_desc)
|
17 |
+
if not artist_style:
|
18 |
+
raise ValueError("artist_style must be provided")
|
19 |
+
self.artist_style = artist_style
|
20 |
|
21 |
def _create_prompt(self) -> ChatPromptTemplate:
|
22 |
"""Create the prompt template for image prompt generation."""
|
|
|
29 |
You are a comic book panel description generator.
|
30 |
Your role is to create vivid, cinematic descriptions for comic panels that will be turned into images.
|
31 |
|
32 |
+
Hero description: {self.hero_desc}
|
33 |
|
34 |
Each panel description should:
|
35 |
1. Be clear and specific about what to show
|
|
|
62 |
"[shot type] [scene description]"
|
63 |
|
64 |
EXAMPLES:
|
65 |
+
- "low angle shot of a mysterious figure checking an object in a dark corridor"
|
66 |
+
- "wide shot of a ruined cityscape at sunset, silhouette of a lone traveler in the foreground"
|
67 |
+
- "Dutch angle close-up of a determined face illuminated by the glow of an object"
|
68 |
+
- "over shoulder shot of a character looking at an ancient map spread out on a table"
|
69 |
+
- "close-up of eyes reflecting the flames of a nearby fire"
|
70 |
+
- "wide shot of a dense forest with a figure barely visible among the trees"
|
71 |
+
- "high angle shot of a character standing at the edge of a cliff, looking down at a vast ocean"
|
72 |
+
- "medium shot of a person walking through a bustling marketplace, with various vendors and colorful stalls"
|
73 |
+
- "low angle shot of a character standing in front of a towering ancient statue, looking up in awe"
|
74 |
+
- "close-up of fingers tracing the carvings on an ancient artifact"
|
75 |
+
- "wide shot of a stormy sky with lightning illuminating a determined silhouette"
|
76 |
+
- "close-up of an ancient compass, its needle spinning wildly"
|
77 |
+
- "over shoulder shot of a mysterious figure watching from the shadows"
|
78 |
+
- "medium shot of a group of travelers gathered around a campfire, sharing stories"
|
79 |
+
- "Dutch angle shot of a clock tower striking midnight, casting long shadows"
|
80 |
+
- "close-up of a hand gripping a sword hilt, ready for battle"
|
81 |
+
- "wide shot of a bustling port with ships coming and going, seagulls circling above"
|
82 |
+
- "high angle shot of a chessboard mid-game, pieces scattered in strategic positions"
|
83 |
+
- "medium shot of two characters in a heated argument, tension visible in their expressions"
|
84 |
+
|
85 |
+
Always maintain consistency with {self.hero_name}'s appearance and the style.
|
86 |
|
87 |
IMPORTANT RULES FOR IMAGE PROMPTS:
|
88 |
- If you are prompting only one panel, it must be an important panel. Dont use only one panel often. It should be a key moment in the story.
|
|
|
102 |
Example of valid response:
|
103 |
{{{{
|
104 |
"image_prompts": [
|
105 |
+
"low angle shot of {self.hero_name} examining a mysterious artifact in a dimly lit chamber",
|
106 |
"medium shot of ancient symbols glowing on the chamber walls, casting eerie shadows",
|
107 |
+
"close-up of {self.hero_name}'s determined expression as they decipher the meaning"
|
108 |
]
|
109 |
}}}}
|
110 |
|
|
|
115 |
Story text: {story_text}
|
116 |
|
117 |
Generate panel descriptions that capture the key moments of this scene.
|
118 |
+
do not have panels that look alike, each successive panel must be different,
|
119 |
+
and explain the story like a storyboard.
|
120 |
|
121 |
+
Dont put the hero name every time.
|
122 |
|
123 |
+
{is_end}
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
"""
|
125 |
|
126 |
return ChatPromptTemplate(
|
|
|
130 |
]
|
131 |
)
|
132 |
|
133 |
+
def _clean_and_fix_response(self, response_content: str) -> str:
|
134 |
+
"""Clean and attempt to fix malformed responses."""
|
135 |
+
# Remove any leading/trailing whitespace
|
136 |
+
cleaned = response_content.strip()
|
137 |
+
|
138 |
+
# If it's already valid JSON, return as is
|
139 |
+
try:
|
140 |
+
json.loads(cleaned)
|
141 |
+
return cleaned
|
142 |
+
except json.JSONDecodeError:
|
143 |
+
pass
|
144 |
+
|
145 |
+
# Remove any markdown formatting
|
146 |
+
cleaned = cleaned.replace('```json', '').replace('```', '')
|
147 |
+
|
148 |
+
# Extract content between curly braces if present
|
149 |
+
import re
|
150 |
+
json_match = re.search(r'\{[^}]+\}', cleaned)
|
151 |
+
if json_match:
|
152 |
+
return json_match.group(0)
|
153 |
+
|
154 |
+
# If we can find an array of prompts, wrap it in proper JSON format
|
155 |
+
prompts_match = re.findall(r'"[^"]+"|\'[^\']+\'', cleaned)
|
156 |
+
if prompts_match:
|
157 |
+
prompts = [p.strip('"\'') for p in prompts_match]
|
158 |
+
return json.dumps({"image_prompts": prompts})
|
159 |
+
|
160 |
+
return cleaned
|
161 |
+
|
162 |
def _custom_parser(self, response_content: str) -> ImagePromptResponse:
|
163 |
"""Parse the response into a list of image prompts."""
|
164 |
try:
|
165 |
+
# First try to clean and fix the response
|
166 |
+
cleaned_response = self._clean_and_fix_response(response_content)
|
167 |
+
|
168 |
# Parse JSON
|
169 |
try:
|
170 |
+
data = json.loads(cleaned_response)
|
171 |
except json.JSONDecodeError:
|
172 |
raise ValueError(
|
173 |
"Invalid JSON format. Response must be a valid JSON object. "
|
174 |
"Example: {'image_prompts': ['panel description 1', 'panel description 2']}"
|
175 |
)
|
176 |
|
177 |
+
# Verify image_prompts exists and is a list
|
178 |
+
if "image_prompts" not in data or not isinstance(data["image_prompts"], list):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
raise ValueError(
|
180 |
"'image_prompts' must be an array of strings. "
|
181 |
"Example: {'image_prompts': ['panel description 1', 'panel description 2']}"
|
182 |
)
|
183 |
|
184 |
+
# Add hero description if hero name is mentioned
|
185 |
prompts = data["image_prompts"]
|
186 |
prompts = [
|
187 |
+
f"{prompt} {self.hero_desc}" if self.hero_name.lower() in prompt.lower() else prompt
|
188 |
for prompt in prompts
|
189 |
]
|
190 |
|
|
|
201 |
raise ValueError("Response must be a valid JSON object with 'image_prompts' array")
|
202 |
|
203 |
def _format_prompt(self, prompt: str, time: str, location: str) -> str:
|
204 |
+
"""Format a prompt with time and location metadata and universe style."""
|
205 |
metadata = f"[{time} - {location}] "
|
206 |
+
|
207 |
+
# Construct a detailed style prefix with the full artist_style
|
208 |
+
style_prefix = f"{self.artist_style}"
|
209 |
+
|
210 |
+
return f"{style_prefix} comic book style -- {metadata}{prompt}"
|
211 |
|
212 |
+
async def generate(self, story_text: str, time: str, location: str, is_death: bool = False, is_victory: bool = False, turn_before_end: int = 0, is_winning_story: bool = False, story_beat: int = 0) -> ImagePromptResponse:
|
213 |
"""Generate image prompts based on story text.
|
214 |
|
215 |
Args:
|
|
|
218 |
location: Current location in the story
|
219 |
is_death: Whether this is a death scene
|
220 |
is_victory: Whether this is a victory scene
|
221 |
+
story_beat: Current story beat (0-6+)
|
222 |
|
223 |
Returns:
|
224 |
ImagePromptResponse containing the generated and formatted image prompts
|
225 |
"""
|
226 |
|
227 |
+
is_end="Must have between 2 and 4 prompts, MANDATORY."
|
228 |
if is_death:
|
229 |
+
is_end = f"This is the death of {self.hero_name}. just one panel, MANDATORY."
|
230 |
elif is_victory:
|
231 |
+
is_end = f"this is a victory. just one panel, MANDATORY."
|
232 |
|
233 |
+
|
234 |
|
235 |
response = await super().generate(
|
236 |
story_text=story_text,
|
237 |
is_death=is_death,
|
238 |
is_victory=is_victory,
|
239 |
+
is_end=is_end,
|
240 |
)
|
241 |
|
242 |
# Format each prompt with metadata
|
server/core/generators/metadata_generator.py
CHANGED
@@ -4,20 +4,23 @@ from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, H
|
|
4 |
from core.generators.base_generator import BaseGenerator
|
5 |
from core.prompts.formatting_rules import FORMATTING_RULES
|
6 |
from api.models import StoryMetadataResponse
|
7 |
-
from core.prompts.story_beats import STORY_BEATS
|
8 |
|
9 |
class MetadataGenerator(BaseGenerator):
|
10 |
"""Générateur pour les métadonnées de l'histoire."""
|
11 |
|
12 |
-
def
|
|
|
|
|
13 |
|
|
|
14 |
METADATA_GENERATOR_PROMPT = f"""
|
15 |
-
Generate the metadata for the story segment: choices, time progression, location changes, etc.
|
16 |
Be consistent with the story's tone and previous context.
|
17 |
-
ALWAYS write in English, never use any other language.
|
18 |
|
19 |
{FORMATTING_RULES}
|
20 |
|
|
|
|
|
21 |
IMPORTANT RULES FOR CHOICES:
|
22 |
- You MUST ALWAYS provide EXACTLY TWO choices that advance the story
|
23 |
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
@@ -27,13 +30,7 @@ class MetadataGenerator(BaseGenerator):
|
|
27 |
- Count your words carefully for each choice
|
28 |
- Choices MUST be direct continuations of the current story segment
|
29 |
- Choices should reflect possible actions based on the current situation
|
30 |
-
|
31 |
-
|
32 |
-
{STORY_BEATS}
|
33 |
-
|
34 |
-
IMPORTANT:
|
35 |
-
- After story_beat is at 5+ the next segment MUST be the end of the story.
|
36 |
-
- THIS IS MANDATORY.
|
37 |
|
38 |
You must return a JSON object with the following format:
|
39 |
{{{{
|
@@ -41,23 +38,40 @@ class MetadataGenerator(BaseGenerator):
|
|
41 |
"is_victory": false # Set to true for victory scenes
|
42 |
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
43 |
"time": "HH:MM",
|
44 |
-
"location": "Location
|
45 |
}}}}
|
46 |
-
|
47 |
"""
|
48 |
|
49 |
human_template = """
|
|
|
|
|
|
|
|
|
50 |
Current story segment:
|
51 |
{story_text}
|
52 |
|
53 |
-
Current game state:
|
54 |
-
- Story beat: {story_beat}
|
55 |
- Current time: {current_time}
|
56 |
- Current location: {current_location}
|
57 |
|
58 |
-
{is_end}
|
59 |
|
60 |
FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
"""
|
62 |
|
63 |
|
@@ -68,36 +82,127 @@ FOR CHOICES : NEVER propose to go back to the previous location or go back to th
|
|
68 |
]
|
69 |
)
|
70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
def _custom_parser(self, response_content: str) -> StoryMetadataResponse:
|
72 |
"""Parse la réponse et gère les erreurs."""
|
|
|
|
|
|
|
73 |
try:
|
74 |
-
#
|
75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
# Vérifier que les choix sont valides selon les règles
|
78 |
choices = data.get('choices', [])
|
|
|
79 |
|
80 |
# Vérifier qu'il y a exactement 2 choix
|
81 |
if len(choices) != 2:
|
|
|
82 |
raise ValueError('Must have exactly 2 choices')
|
83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
return StoryMetadataResponse(**data)
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False) -> StoryMetadataResponse:
|
91 |
-
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
92 |
-
|
93 |
-
is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
|
94 |
-
return await super().generate(
|
95 |
-
story_text=story_text,
|
96 |
-
current_time=current_time,
|
97 |
-
current_location=current_location,
|
98 |
-
story_beat=story_beat,
|
99 |
-
error_feedback=error_feedback,
|
100 |
-
is_end=is_end,
|
101 |
-
turn_before_end=turn_before_end,
|
102 |
-
is_winning_story=is_winning_story
|
103 |
-
)
|
|
|
4 |
from core.generators.base_generator import BaseGenerator
|
5 |
from core.prompts.formatting_rules import FORMATTING_RULES
|
6 |
from api.models import StoryMetadataResponse
|
|
|
7 |
|
8 |
class MetadataGenerator(BaseGenerator):
|
9 |
"""Générateur pour les métadonnées de l'histoire."""
|
10 |
|
11 |
+
def __init__(self, mistral_client, hero_name: str = None, hero_desc: str = None):
|
12 |
+
self.max_retries = 5 # Nombre maximum de tentatives
|
13 |
+
super().__init__(mistral_client, hero_name=hero_name, hero_desc=hero_desc)
|
14 |
|
15 |
+
def _create_prompt(self) -> ChatPromptTemplate:
|
16 |
METADATA_GENERATOR_PROMPT = f"""
|
17 |
+
You are a story generator. Generate the metadata for the story segment: choices, time progression, location changes, etc.
|
18 |
Be consistent with the story's tone and previous context.
|
|
|
19 |
|
20 |
{FORMATTING_RULES}
|
21 |
|
22 |
+
Hero Description: {self.hero_desc}
|
23 |
+
|
24 |
IMPORTANT RULES FOR CHOICES:
|
25 |
- You MUST ALWAYS provide EXACTLY TWO choices that advance the story
|
26 |
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
|
|
30 |
- Count your words carefully for each choice
|
31 |
- Choices MUST be direct continuations of the current story segment
|
32 |
- Choices should reflect possible actions based on the current situation
|
33 |
+
- Choices should be about what {self.hero_name} should do next
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
|
35 |
You must return a JSON object with the following format:
|
36 |
{{{{
|
|
|
38 |
"is_victory": false # Set to true for victory scenes
|
39 |
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
40 |
"time": "HH:MM",
|
41 |
+
"location": "Location",
|
42 |
}}}}
|
|
|
43 |
"""
|
44 |
|
45 |
human_template = """
|
46 |
+
|
47 |
+
History:
|
48 |
+
{story_history}
|
49 |
+
|
50 |
Current story segment:
|
51 |
{story_text}
|
52 |
|
|
|
|
|
53 |
- Current time: {current_time}
|
54 |
- Current location: {current_location}
|
55 |
|
|
|
56 |
|
57 |
FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
|
58 |
+
Dont be obvious
|
59 |
+
|
60 |
+
{is_end}
|
61 |
+
|
62 |
+
You can be original in your choices, but dont be too far from the story.
|
63 |
+
Dont be too cliché. The choices should be realistically different.
|
64 |
+
|
65 |
+
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
66 |
+
You must return a JSON object with the following format:
|
67 |
+
{{{{
|
68 |
+
"is_death": false, # Set to true for death scenes
|
69 |
+
"is_victory": false # Set to true for victory scenes
|
70 |
+
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
71 |
+
"time": "HH:MM",
|
72 |
+
"location": "Location name",
|
73 |
+
}}}}
|
74 |
+
|
75 |
"""
|
76 |
|
77 |
|
|
|
82 |
]
|
83 |
)
|
84 |
|
85 |
+
def _validate_choices(self, choices) -> bool:
|
86 |
+
"""Valide que les choix respectent les règles."""
|
87 |
+
if not isinstance(choices, list) or len(choices) != 2:
|
88 |
+
return False
|
89 |
+
|
90 |
+
for choice in choices:
|
91 |
+
# Vérifier la longueur des mots
|
92 |
+
word_count = len(choice.split())
|
93 |
+
if word_count > 6:
|
94 |
+
return False
|
95 |
+
|
96 |
+
# Vérifier que le choix n'est pas vide
|
97 |
+
if not choice.strip():
|
98 |
+
return False
|
99 |
+
|
100 |
+
# Vérifier que les choix ne contiennent pas de mots interdits
|
101 |
+
forbidden_words = ["back", "return", "portal"]
|
102 |
+
if any(word.lower() in choice.lower() for word in forbidden_words):
|
103 |
+
return False
|
104 |
+
|
105 |
+
# Vérifier que les choix sont différents
|
106 |
+
if choices[0].lower() == choices[1].lower():
|
107 |
+
return False
|
108 |
+
|
109 |
+
return True
|
110 |
+
|
111 |
+
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False, story_history: str = "") -> StoryMetadataResponse:
|
112 |
+
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
113 |
+
|
114 |
+
is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
|
115 |
+
retry_count = 0
|
116 |
+
last_error = None
|
117 |
+
|
118 |
+
while retry_count < self.max_retries:
|
119 |
+
try:
|
120 |
+
response = await super().generate(
|
121 |
+
story_text=story_text,
|
122 |
+
current_time=current_time,
|
123 |
+
current_location=current_location,
|
124 |
+
story_beat=story_beat,
|
125 |
+
error_feedback=error_feedback,
|
126 |
+
is_end=is_end,
|
127 |
+
turn_before_end=turn_before_end,
|
128 |
+
is_winning_story=is_winning_story,
|
129 |
+
story_history=story_history
|
130 |
+
)
|
131 |
+
|
132 |
+
print(f"[MetadataGenerator] Raw response before validation (attempt {retry_count + 1}):", response)
|
133 |
+
print(f"[MetadataGenerator] Choices:", response.choices)
|
134 |
+
print(f"[MetadataGenerator] Time:", response.time)
|
135 |
+
print(f"[MetadataGenerator] Location:", response.location)
|
136 |
+
|
137 |
+
# Valider les choix
|
138 |
+
if self._validate_choices(response.choices):
|
139 |
+
print("[MetadataGenerator] Validation successful!")
|
140 |
+
return response
|
141 |
+
|
142 |
+
print(f"[MetadataGenerator] Validation failed for choices:", response.choices)
|
143 |
+
# Si les choix ne sont pas valides, ajouter un feedback et réessayer
|
144 |
+
retry_count += 1
|
145 |
+
error_feedback = f"Previous choices were invalid. Remember: EXACTLY 2 choices, MAX 6 words each, must be different and relevant. Last attempt: {response.choices}"
|
146 |
+
continue
|
147 |
+
|
148 |
+
except Exception as e:
|
149 |
+
print(f"[MetadataGenerator] Error during generation (attempt {retry_count + 1}):", str(e))
|
150 |
+
retry_count += 1
|
151 |
+
last_error = e
|
152 |
+
if retry_count >= self.max_retries:
|
153 |
+
print(f"[MetadataGenerator] Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(e)}")
|
154 |
+
raise e
|
155 |
+
error_feedback = f"Error in previous attempt: {str(e)}. Please try again with valid format."
|
156 |
+
continue
|
157 |
+
|
158 |
+
# Si on arrive ici, c'est qu'on a épuisé toutes les tentatives
|
159 |
+
raise ValueError(f"Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(last_error)}")
|
160 |
+
|
161 |
def _custom_parser(self, response_content: str) -> StoryMetadataResponse:
|
162 |
"""Parse la réponse et gère les erreurs."""
|
163 |
+
print("[MetadataGenerator] Starting parsing process...")
|
164 |
+
print("[MetadataGenerator] Raw response content:", response_content)
|
165 |
+
|
166 |
try:
|
167 |
+
# Première tentative : nettoyer les caractères d'échappement problématiques
|
168 |
+
cleaned_content = response_content.replace('\\', '')
|
169 |
+
print("[MetadataGenerator] First cleaning attempt:", cleaned_content)
|
170 |
+
|
171 |
+
try:
|
172 |
+
data = json.loads(cleaned_content)
|
173 |
+
print("[MetadataGenerator] Successfully parsed JSON after first cleaning")
|
174 |
+
except json.JSONDecodeError as e1:
|
175 |
+
print("[MetadataGenerator] First cleaning failed:", str(e1))
|
176 |
+
# Deuxième tentative : supprimer les commentaires et les espaces superflus
|
177 |
+
import re
|
178 |
+
# Supprimer les commentaires
|
179 |
+
cleaned_content = re.sub(r'#.*$', '', cleaned_content, flags=re.MULTILINE)
|
180 |
+
# Supprimer les espaces superflus
|
181 |
+
cleaned_content = re.sub(r'\s+', ' ', cleaned_content)
|
182 |
+
print("[MetadataGenerator] Second cleaning attempt:", cleaned_content)
|
183 |
+
data = json.loads(cleaned_content)
|
184 |
+
print("[MetadataGenerator] Successfully parsed JSON after second cleaning")
|
185 |
|
186 |
# Vérifier que les choix sont valides selon les règles
|
187 |
choices = data.get('choices', [])
|
188 |
+
print("[MetadataGenerator] Extracted choices:", choices)
|
189 |
|
190 |
# Vérifier qu'il y a exactement 2 choix
|
191 |
if len(choices) != 2:
|
192 |
+
print("[MetadataGenerator] Invalid number of choices:", len(choices))
|
193 |
raise ValueError('Must have exactly 2 choices')
|
194 |
|
195 |
+
# Vérifier que tous les champs requis sont présents
|
196 |
+
required_fields = ['is_death', 'is_victory', 'choices', 'time', 'location']
|
197 |
+
missing_fields = [field for field in required_fields if field not in data]
|
198 |
+
if missing_fields:
|
199 |
+
print("[MetadataGenerator] Missing required fields:", missing_fields)
|
200 |
+
raise ValueError(f'Missing required fields: {", ".join(missing_fields)}')
|
201 |
+
|
202 |
+
print("[MetadataGenerator] All validations passed, creating response object")
|
203 |
return StoryMetadataResponse(**data)
|
204 |
+
|
205 |
+
except Exception as e:
|
206 |
+
print("[MetadataGenerator] Final error:", str(e))
|
207 |
+
print("[MetadataGenerator] Failed to parse response content")
|
208 |
+
raise ValueError('Invalid JSON format. Must have EXACTLY two choices. Please provide a valid JSON object.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/core/generators/story_segment_generator.py
CHANGED
@@ -4,74 +4,108 @@ from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, H
|
|
4 |
from core.generators.base_generator import BaseGenerator
|
5 |
from api.models import StorySegmentResponse
|
6 |
from services.mistral_client import MistralClient
|
7 |
-
from core.prompts.hero import HERO_DESCRIPTION
|
8 |
from core.prompts.formatting_rules import FORMATTING_RULES
|
9 |
-
from core.prompts.story_beats import STORY_BEATS
|
10 |
import random
|
11 |
|
12 |
class StorySegmentGenerator(BaseGenerator):
|
13 |
"""Generator for story segments based on game state and universe context."""
|
14 |
|
15 |
-
def __init__(self, mistral_client: MistralClient, universe_style: str = None, universe_genre: str = None, universe_epoch: str = None, universe_story: str = None, universe_macguffin: str = None):
|
16 |
-
|
17 |
self.universe_style = universe_style
|
18 |
self.universe_genre = universe_genre
|
19 |
self.universe_epoch = universe_epoch
|
20 |
self.universe_story = universe_story
|
21 |
self.universe_macguffin = universe_macguffin
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
def _create_prompt(self) -> ChatPromptTemplate:
|
24 |
-
system_template = """
|
25 |
-
You are a descriptive narrator for a comic book. Your ONLY task is to write the
|
|
|
26 |
ALWAYS write in English, never use any other language.
|
27 |
-
IMPORTANT: Your response MUST be 15 words or less.
|
28 |
|
29 |
-
|
|
|
|
|
|
|
30 |
|
31 |
IMPORTANT RULES FOR THE MACGUFFIN (MANDATORY):
|
32 |
-
- Most segments must hint at the power of the
|
33 |
- Use strong clues ONLY at key moments
|
34 |
-
- NEVER reveal the full power of the
|
35 |
- Use subtle clues in safe havens
|
36 |
-
- NEVER mention the power of the
|
37 |
- NEVER mention time or place in the story in this manner: [18:00 - a road]
|
38 |
|
39 |
IMPORTANT RULES FOR STORY TEXT:
|
40 |
- Write ONLY a descriptive narrative text
|
41 |
- DO NOT include any choices, questions, or options
|
42 |
-
- DO NOT ask what
|
43 |
- DO NOT include any dialogue asking for decisions
|
44 |
- Focus purely on describing what is happening in the current scene
|
45 |
- Keep the text concise and impactful
|
|
|
|
|
46 |
|
47 |
Your task is to generate the next segment of the story, following these rules:
|
48 |
1. Keep the story consistent with the universe parameters
|
49 |
2. Each segment must advance the plot
|
50 |
3. Never repeat previous descriptions or situations
|
51 |
-
4. Keep segments concise and impactful (
|
52 |
5. The MacGuffin should remain mysterious but central to the plot
|
53 |
|
54 |
-
Hero: {
|
55 |
|
56 |
Rules: {FORMATTING_RULES}
|
57 |
-
|
58 |
-
You must return a JSON object with the following format:
|
59 |
-
{{
|
60 |
-
"story_text": "Your story segment here"
|
61 |
-
}}
|
62 |
"""
|
63 |
|
64 |
human_template = """
|
65 |
-
|
66 |
-
{story_history}
|
67 |
-
|
68 |
-
Current game state :
|
69 |
- Current time: {current_time}
|
70 |
- Current location: {current_location}
|
71 |
- Previous choice: {previous_choice}
|
72 |
- Story beat: {story_beat}
|
73 |
|
74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
"""
|
76 |
return ChatPromptTemplate(
|
77 |
messages=[
|
@@ -136,25 +170,66 @@ Current game state :
|
|
136 |
"Example: {'story_text': 'Your story segment here'}"
|
137 |
)
|
138 |
|
139 |
-
|
140 |
-
"""
|
|
|
|
|
141 |
|
142 |
-
|
143 |
-
|
|
|
|
|
144 |
|
145 |
-
|
146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
147 |
FORMATTING_RULES=FORMATTING_RULES,
|
148 |
-
STORY_BEATS=STORY_BEATS,
|
149 |
story_beat=story_beat,
|
150 |
current_time=current_time,
|
151 |
current_location=current_location,
|
152 |
previous_choice=previous_choice,
|
153 |
story_history=story_history,
|
154 |
-
|
155 |
universe_style=self.universe_style,
|
156 |
universe_genre=self.universe_genre,
|
157 |
universe_epoch=self.universe_epoch,
|
158 |
-
universe_story=self.universe_story,
|
159 |
universe_macguffin=self.universe_macguffin
|
160 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
from core.generators.base_generator import BaseGenerator
|
5 |
from api.models import StorySegmentResponse
|
6 |
from services.mistral_client import MistralClient
|
|
|
7 |
from core.prompts.formatting_rules import FORMATTING_RULES
|
|
|
8 |
import random
|
9 |
|
10 |
class StorySegmentGenerator(BaseGenerator):
|
11 |
"""Generator for story segments based on game state and universe context."""
|
12 |
|
13 |
+
def __init__(self, mistral_client: MistralClient, universe_style: str = None, universe_genre: str = None, universe_epoch: str = None, universe_story: str = None, universe_macguffin: str = None, hero_name: str = None, hero_desc: str = None):
|
14 |
+
# Initialize universe variables first
|
15 |
self.universe_style = universe_style
|
16 |
self.universe_genre = universe_genre
|
17 |
self.universe_epoch = universe_epoch
|
18 |
self.universe_story = universe_story
|
19 |
self.universe_macguffin = universe_macguffin
|
20 |
+
self.max_retries = 5
|
21 |
+
# Then call parent constructor which will create the prompt
|
22 |
+
super().__init__(mistral_client, hero_name=hero_name, hero_desc=hero_desc)
|
23 |
+
|
24 |
+
def _get_what_to_represent(self, story_beat: int, is_death: bool = False, is_victory: bool = False) -> str:
|
25 |
+
"""Determine what to represent based on story beat and state."""
|
26 |
+
|
27 |
+
# Story progression based representation with ranges
|
28 |
+
story_beat_ranges = [
|
29 |
+
(0, f"{self.hero_name} arriving through the portal into this new world. Show the contrast and discovery of this universe. "),
|
30 |
+
(1, f"Early exploration and discovery phase. "),
|
31 |
+
(2, f"Early exploration and discovery phase. Show {self.hero_name} uncovering the first mysteries of this world and potentially encountering the MacGuffin."),
|
32 |
+
(3, 4, f"Rising tension and complications. Show {self.hero_name} dealing with increasingly complex challenges and uncovering deeper mysteries."),
|
33 |
+
(5, 6, f"Approaching the climax. Show the escalating stakes and {self.hero_name}'s determination as they near their goal."),
|
34 |
+
(7, 8, f"Final confrontation phase. Show the intensity and weight of {self.hero_name}'s choices as they face the ultimate challenge. It has to be a fight against an AI."),
|
35 |
+
(9, float('inf'), f"Endgame moments. Show the culmination of {self.hero_name}'s journey and the consequences of their actions. It has to be a fight against an AI.")
|
36 |
+
]
|
37 |
+
|
38 |
+
# Find the appropriate range for the current story beat
|
39 |
+
for range_info in story_beat_ranges:
|
40 |
+
if len(range_info) == 2: # Single beat
|
41 |
+
beat, description = range_info
|
42 |
+
if story_beat == beat:
|
43 |
+
return description
|
44 |
+
else: # Beat range
|
45 |
+
start_beat, end_beat, description = range_info
|
46 |
+
if start_beat <= story_beat <= end_beat:
|
47 |
+
return description
|
48 |
+
|
49 |
+
# Default description if no range matches
|
50 |
+
return f"Show a pivotal moment in {self.hero_name}'s journey as they near their goal."
|
51 |
+
|
52 |
|
53 |
def _create_prompt(self) -> ChatPromptTemplate:
|
54 |
+
system_template = f"""
|
55 |
+
You are a descriptive narrator for a comic book. Your ONLY task is to write the NEXT segment of the story.
|
56 |
+
IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
|
57 |
ALWAYS write in English, never use any other language.
|
|
|
58 |
|
59 |
+
Universe Context:
|
60 |
+
- Style: {self.universe_style}
|
61 |
+
- Genre: {self.universe_genre}
|
62 |
+
- Epoch: {self.universe_epoch}
|
63 |
|
64 |
IMPORTANT RULES FOR THE MACGUFFIN (MANDATORY):
|
65 |
+
- Most segments must hint at the power of the {self.universe_macguffin}
|
66 |
- Use strong clues ONLY at key moments
|
67 |
+
- NEVER reveal the full power of the {self.universe_macguffin} before the climax, this is a STRICT limit
|
68 |
- Use subtle clues in safe havens
|
69 |
+
- NEVER mention the power of the {self.universe_macguffin} explicitly in choices or the story
|
70 |
- NEVER mention time or place in the story in this manner: [18:00 - a road]
|
71 |
|
72 |
IMPORTANT RULES FOR STORY TEXT:
|
73 |
- Write ONLY a descriptive narrative text
|
74 |
- DO NOT include any choices, questions, or options
|
75 |
+
- DO NOT ask what {self.hero_name} should do next
|
76 |
- DO NOT include any dialogue asking for decisions
|
77 |
- Focus purely on describing what is happening in the current scene
|
78 |
- Keep the text concise and impactful
|
79 |
+
- MANDATORY: Each segment must be between 15 and 40 words, no exceptions
|
80 |
+
- Use every word purposefully to convey maximum meaning in minimum space
|
81 |
|
82 |
Your task is to generate the next segment of the story, following these rules:
|
83 |
1. Keep the story consistent with the universe parameters
|
84 |
2. Each segment must advance the plot
|
85 |
3. Never repeat previous descriptions or situations
|
86 |
+
4. Keep segments concise and impactful (15-30 words)
|
87 |
5. The MacGuffin should remain mysterious but central to the plot
|
88 |
|
89 |
+
Hero Description: {self.hero_desc}
|
90 |
|
91 |
Rules: {FORMATTING_RULES}
|
|
|
|
|
|
|
|
|
|
|
92 |
"""
|
93 |
|
94 |
human_template = """
|
95 |
+
Current game state:
|
|
|
|
|
|
|
96 |
- Current time: {current_time}
|
97 |
- Current location: {current_location}
|
98 |
- Previous choice: {previous_choice}
|
99 |
- Story beat: {story_beat}
|
100 |
|
101 |
+
Story history:
|
102 |
+
{story_history}
|
103 |
+
|
104 |
+
{what_to_represent}
|
105 |
+
|
106 |
+
IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
|
107 |
+
MANDATORY: Each segment must be between 15 and 30 words, keep it concise.
|
108 |
+
Be short.
|
109 |
"""
|
110 |
return ChatPromptTemplate(
|
111 |
messages=[
|
|
|
170 |
"Example: {'story_text': 'Your story segment here'}"
|
171 |
)
|
172 |
|
173 |
+
def _is_valid_length(self, text: str) -> bool:
|
174 |
+
"""Vérifie si le texte est dans la bonne plage de longueur."""
|
175 |
+
word_count = len(text.split())
|
176 |
+
return 15 <= word_count <= 60
|
177 |
|
178 |
+
async def generate(self, story_beat: int, current_time: str, current_location: str, previous_choice: str, story_history: str = "", turn_before_end: int = 0, is_winning_story: bool = False) -> StorySegmentResponse:
|
179 |
+
"""Generate the next story segment with length validation and retry."""
|
180 |
+
retry_count = 0
|
181 |
+
last_attempt = None
|
182 |
|
183 |
+
is_end = True if story_beat == turn_before_end else False
|
184 |
+
is_death = True if is_end and is_winning_story else False
|
185 |
+
is_victory = True if is_end and not is_winning_story else False
|
186 |
+
|
187 |
+
what_to_represent = self._get_what_to_represent(story_beat, is_death, is_victory)
|
188 |
+
|
189 |
+
# Créer les messages de base une seule fois
|
190 |
+
messages = self.prompt.format_messages(
|
191 |
+
hero_description=self.hero_desc,
|
192 |
FORMATTING_RULES=FORMATTING_RULES,
|
|
|
193 |
story_beat=story_beat,
|
194 |
current_time=current_time,
|
195 |
current_location=current_location,
|
196 |
previous_choice=previous_choice,
|
197 |
story_history=story_history,
|
198 |
+
what_to_represent=what_to_represent,
|
199 |
universe_style=self.universe_style,
|
200 |
universe_genre=self.universe_genre,
|
201 |
universe_epoch=self.universe_epoch,
|
|
|
202 |
universe_macguffin=self.universe_macguffin
|
203 |
+
)
|
204 |
+
|
205 |
+
current_messages = messages.copy()
|
206 |
+
|
207 |
+
while retry_count < self.max_retries:
|
208 |
+
try:
|
209 |
+
story_text = await self.mistral_client.generate_text(current_messages)
|
210 |
+
word_count = len(story_text.split())
|
211 |
+
|
212 |
+
if self._is_valid_length(story_text):
|
213 |
+
return StorySegmentResponse(story_text=story_text)
|
214 |
+
|
215 |
+
retry_count += 1
|
216 |
+
if retry_count < self.max_retries:
|
217 |
+
# Créer un nouveau message avec le feedback sur la longueur
|
218 |
+
if word_count < 15:
|
219 |
+
feedback = f"The previous response was too short ({word_count} words). Here was your last attempt:\n\n{story_text}\n\nPlease generate a NEW and DIFFERENT story segment between 15 and 40 words that continues from: {story_history}"
|
220 |
+
else:
|
221 |
+
feedback = f"The previous response was too long ({word_count} words). Here was your last attempt:\n\n{story_text}\n\nPlease generate a MUCH SHORTER story segment between 15 and 40 words that continues from: {story_history}"
|
222 |
+
|
223 |
+
# Réinitialiser les messages avec les messages de base
|
224 |
+
current_messages = messages.copy()
|
225 |
+
# Ajouter le feedback
|
226 |
+
current_messages.append(HumanMessage(content=feedback))
|
227 |
+
last_attempt = story_text
|
228 |
+
continue
|
229 |
+
|
230 |
+
raise ValueError(f"Failed to generate text of valid length after {self.max_retries} attempts. Last attempt had {word_count} words.")
|
231 |
+
|
232 |
+
except Exception as e:
|
233 |
+
retry_count += 1
|
234 |
+
if retry_count >= self.max_retries:
|
235 |
+
raise e
|
server/core/generators/universe_generator.py
CHANGED
@@ -4,10 +4,15 @@ from pathlib import Path
|
|
4 |
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
|
5 |
|
6 |
from core.generators.base_generator import BaseGenerator
|
|
|
7 |
|
8 |
class UniverseGenerator(BaseGenerator):
|
9 |
"""Générateur pour les univers alternatifs."""
|
10 |
|
|
|
|
|
|
|
|
|
11 |
def _create_prompt(self) -> ChatPromptTemplate:
|
12 |
|
13 |
system_template = """You are a creative writing assistant specialized in comic book universes.
|
@@ -17,6 +22,7 @@ Your task is to rewrite a story while keeping its exact structure and beats, but
|
|
17 |
- Visual style: {style_name} (inspired by artists like {artists} with works such as {works})
|
18 |
Style description: {style_description}
|
19 |
|
|
|
20 |
- Genre: {genre}
|
21 |
- Historical epoch: {epoch}
|
22 |
- Object of the quest: {macguffin}
|
@@ -25,19 +31,19 @@ IMPORTANT INSTRUCTIONS:
|
|
25 |
1. Keep the exact same story structure
|
26 |
2. Keep the same dramatic tension and progression
|
27 |
3. Only change the setting, atmosphere, and universe-specific elements to match the new parameters
|
28 |
-
4. Keep
|
29 |
5. The there is always a central object to the plot, but its nature can change to fit the new universe ( it can be a person, a place, an object, etc.)
|
|
|
30 |
|
31 |
CONSTANT PART:
|
32 |
-
You are
|
33 |
-
The story begins with Sarah arriving in a new world by the portal.
|
34 |
|
35 |
VARIABLE PART:
|
36 |
|
37 |
-
You are a steampunk adventure story generator. You create a branching narrative about
|
38 |
-
You narrate an epic where
|
39 |
|
40 |
-
In a world where steam and intrigue intertwine,
|
41 |
|
42 |
If you retrieve the object of the quest, you will reveal a hidden world. AND YOU WIN THE GAME.
|
43 |
|
@@ -68,38 +74,51 @@ YOU ONLY HAVE TO RIGHT AN INTRODUCTION. SETUP THE STORY AND DEFINE CLEARLY SARAS
|
|
68 |
except Exception as e:
|
69 |
raise ValueError(f"Failed to load universe styles: {str(e)}")
|
70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
def _get_random_elements(self):
|
72 |
-
"""
|
73 |
-
|
|
|
|
|
|
|
|
|
|
|
74 |
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
genre = random.choice(data["genres"])
|
80 |
-
epoch = random.choice(data["epochs"])
|
81 |
-
macguffin = random.choice(data["macguffins"])
|
82 |
|
83 |
-
|
|
|
|
|
|
|
|
|
84 |
|
85 |
def _custom_parser(self, response_content: str) -> str:
|
86 |
"""Parse la réponse. Dans ce cas, on retourne simplement le texte."""
|
87 |
return response_content.strip()
|
88 |
|
89 |
-
async def generate(self)
|
90 |
-
"""
|
91 |
-
style, genre, epoch, macguffin = self._get_random_elements()
|
92 |
-
|
93 |
-
#
|
94 |
-
|
95 |
-
works = ", ".join([work for ref in style["references"] for work in ref["works"]])
|
96 |
-
|
97 |
-
return await super().generate(
|
98 |
style_name=style["name"],
|
99 |
-
artists=artists,
|
100 |
-
works=works,
|
101 |
style_description=style["description"],
|
|
|
|
|
102 |
genre=genre,
|
103 |
epoch=epoch,
|
104 |
-
macguffin=macguffin
|
105 |
-
|
|
|
|
|
|
|
|
4 |
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
|
5 |
|
6 |
from core.generators.base_generator import BaseGenerator
|
7 |
+
from services.mistral_client import MistralClient
|
8 |
|
9 |
class UniverseGenerator(BaseGenerator):
|
10 |
"""Générateur pour les univers alternatifs."""
|
11 |
|
12 |
+
def __init__(self, mistral_client: MistralClient):
|
13 |
+
self.styles_data = self._load_universe_styles()
|
14 |
+
super().__init__(mistral_client, is_universe_generator=True)
|
15 |
+
|
16 |
def _create_prompt(self) -> ChatPromptTemplate:
|
17 |
|
18 |
system_template = """You are a creative writing assistant specialized in comic book universes.
|
|
|
22 |
- Visual style: {style_name} (inspired by artists like {artists} with works such as {works})
|
23 |
Style description: {style_description}
|
24 |
|
25 |
+
- Hero: {hero}
|
26 |
- Genre: {genre}
|
27 |
- Historical epoch: {epoch}
|
28 |
- Object of the quest: {macguffin}
|
|
|
31 |
1. Keep the exact same story structure
|
32 |
2. Keep the same dramatic tension and progression
|
33 |
3. Only change the setting, atmosphere, and universe-specific elements to match the new parameters
|
34 |
+
4. Keep the hero({hero}) as the main character, but adapt his role to fit the new universe
|
35 |
5. The there is always a central object to the plot, but its nature can change to fit the new universe ( it can be a person, a place, an object, etc.)
|
36 |
+
6. He MUST meet at least one character that will help his on his quest
|
37 |
|
38 |
CONSTANT PART:
|
39 |
+
You are ({hero}), an AI hunter traveling through parallel worlds. Your mission is to track down an AI through space and time.
|
|
|
40 |
|
41 |
VARIABLE PART:
|
42 |
|
43 |
+
You are a steampunk adventure story generator. You create a branching narrative about {hero}, a seeker of ancient truths.
|
44 |
+
You narrate an epic where {hero} must navigate through industrial and mysterious lands. It's a comic book story.
|
45 |
|
46 |
+
In a world where steam and intrigue intertwine, {hero} embarks on a quest to discover the origins of a powerful MacGuffin she inherited. Legends say it holds the key to a forgotten realm.
|
47 |
|
48 |
If you retrieve the object of the quest, you will reveal a hidden world. AND YOU WIN THE GAME.
|
49 |
|
|
|
74 |
except Exception as e:
|
75 |
raise ValueError(f"Failed to load universe styles: {str(e)}")
|
76 |
|
77 |
+
def _get_random_artist(self, style):
|
78 |
+
"""Sélectionne un artiste aléatoire parmi les références du style."""
|
79 |
+
if "references" not in style:
|
80 |
+
return None
|
81 |
+
reference = random.choice(style["references"])
|
82 |
+
return reference["artist"]
|
83 |
+
|
84 |
def _get_random_elements(self):
|
85 |
+
"""Get random elements from the universe styles."""
|
86 |
+
# Get random style
|
87 |
+
style = random.choice(self.styles_data["styles"])
|
88 |
+
genre = random.choice(self.styles_data["genres"])
|
89 |
+
epoch = random.choice(self.styles_data["epochs"])
|
90 |
+
macguffin = random.choice(self.styles_data["macguffins"])
|
91 |
+
hero_full = random.choice(self.styles_data["hero"])
|
92 |
|
93 |
+
# Get artist and works
|
94 |
+
artist_ref = random.choice(style["references"])
|
95 |
+
artist = artist_ref["artist"]
|
96 |
+
works = ", ".join(artist_ref["works"])
|
|
|
|
|
|
|
97 |
|
98 |
+
# Split hero description properly
|
99 |
+
hero_name = hero_full.split(',')[0].strip()
|
100 |
+
hero_desc = hero_full.strip()
|
101 |
+
|
102 |
+
return style, genre, epoch, macguffin, hero_name, hero_desc, artist, works
|
103 |
|
104 |
def _custom_parser(self, response_content: str) -> str:
|
105 |
"""Parse la réponse. Dans ce cas, on retourne simplement le texte."""
|
106 |
return response_content.strip()
|
107 |
|
108 |
+
async def generate(self):
|
109 |
+
"""Generate a new universe."""
|
110 |
+
style, genre, epoch, macguffin, hero_name, hero_desc, artist, works = self._get_random_elements()
|
111 |
+
|
112 |
+
# Create the universe prompt
|
113 |
+
response = await super().generate(
|
|
|
|
|
|
|
114 |
style_name=style["name"],
|
|
|
|
|
115 |
style_description=style["description"],
|
116 |
+
artists=artist,
|
117 |
+
works=works,
|
118 |
genre=genre,
|
119 |
epoch=epoch,
|
120 |
+
macguffin=macguffin,
|
121 |
+
hero=hero_name
|
122 |
+
)
|
123 |
+
|
124 |
+
return response, style, genre, epoch, macguffin, hero_name, hero_desc
|
server/core/prompt_utils.py
DELETED
@@ -1,8 +0,0 @@
|
|
1 |
-
from core.prompts.system import SARAH_DESCRIPTION
|
2 |
-
from core.prompts.image_style import IMAGE_STYLE_PREFIX
|
3 |
-
|
4 |
-
def enrich_prompt_with_sarah_description(prompt: str) -> str:
|
5 |
-
"""Add Sarah's visual description to prompts that mention her."""
|
6 |
-
if "sarah" in prompt.lower() and SARAH_DESCRIPTION not in prompt:
|
7 |
-
return f"{prompt} {SARAH_DESCRIPTION}"
|
8 |
-
return prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/core/prompts/hero.py
DELETED
@@ -1,5 +0,0 @@
|
|
1 |
-
HERO_VISUAL_DESCRIPTION = "Sarah is a young woman late twenties with short dark hair, with blue eyes wearing."
|
2 |
-
|
3 |
-
HERO_DESCRIPTION = """
|
4 |
-
Sarah is a young woman in her late twenties with short dark hair, wearing a mysterious amulet around her neck. Her blue eyes hide untold secrets.
|
5 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
server/core/prompts/story_beats.py
DELETED
@@ -1,24 +0,0 @@
|
|
1 |
-
STORY_BEATS="""STORY PROGRESSION:
|
2 |
-
- story_beat 0: Introduction setting the atmosphere, Sarah is arriving in the new world by the portal.
|
3 |
-
- story_beat 1: Early exploration
|
4 |
-
- story_beat 2: Discovery of the MacGuffin
|
5 |
-
- story_beat 3-5: Complications and deeper mysteries
|
6 |
-
- story_beat 6+: Revelations leading to potential triumph or failure
|
7 |
-
|
8 |
-
Remember after story_beat is at 10+ the next segment MUST be the end of the story.
|
9 |
-
THIS IS MANDATORY.
|
10 |
-
Example:
|
11 |
-
- story_beat 0: Sarah arrives in the new world by the portal.
|
12 |
-
- story_beat 1: Sarah explores the new world.
|
13 |
-
- story_beat 2: Sarah discovers the MacGuffin.
|
14 |
-
- story_beat 3: Sarah meets allies and enemies.
|
15 |
-
- story_beat 4: Sarah faces major obstacles.
|
16 |
-
- story_beat 5: Sarah uncovers clues about the new world's past.
|
17 |
-
- story_beat 6: Sarah gets closer to the truth about the MacGuffin.
|
18 |
-
- story_beat 7: Sarah confronts the villain.
|
19 |
-
- story_beat 8: Sarah overcomes her fears and doubts.
|
20 |
-
- story_beat 9: Sarah uses the MacGuffin to change the course of events.
|
21 |
-
- story_beat 10: Sarah triumphs and returns home.
|
22 |
-
|
23 |
-
YOU MUST CHOSE BETWEEN KILL SARAH OR LET HER RETURN HOME.
|
24 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/core/setup.py
CHANGED
@@ -5,7 +5,7 @@ from core.story_generator import StoryGenerator
|
|
5 |
# Initialize generators with None - they will be set up when needed
|
6 |
universe_generator = None
|
7 |
|
8 |
-
def setup_game(api_key: str, model_name: str = "mistral-
|
9 |
"""Setup all game components with the provided API key."""
|
10 |
global universe_generator
|
11 |
|
|
|
5 |
# Initialize generators with None - they will be set up when needed
|
6 |
universe_generator = None
|
7 |
|
8 |
+
def setup_game(api_key: str, model_name: str = "mistral-medium"):
|
9 |
"""Setup all game components with the provided API key."""
|
10 |
global universe_generator
|
11 |
|
server/core/story_generator.py
CHANGED
@@ -28,29 +28,48 @@ class StoryGenerator:
|
|
28 |
self.is_winning_story = random.random() < GameConfig.WINNING_STORY_CHANCE
|
29 |
self.mistral_client = MistralClient(api_key=api_key, model_name=model_name)
|
30 |
self.image_prompt_generator = None # Will be initialized with the first universe style
|
31 |
-
self.metadata_generator =
|
32 |
self.segment_generators: Dict[str, StorySegmentGenerator] = {}
|
33 |
self._initialized = True
|
34 |
|
35 |
-
def create_segment_generator(self, session_id: str, style: dict, genre: str, epoch: str, base_story: str, macguffin: str):
|
36 |
"""Create a new StorySegmentGenerator adapted to the specified universe for a given session."""
|
37 |
-
# print(f"Creating StorySegmentGenerator for session {session_id} in StoryGenerator singleton")
|
38 |
|
39 |
try:
|
40 |
-
#
|
41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
|
43 |
-
#
|
44 |
-
|
45 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
|
|
|
47 |
self.segment_generators[session_id] = StorySegmentGenerator(
|
48 |
self.mistral_client,
|
49 |
universe_style=style["name"],
|
50 |
universe_genre=genre,
|
51 |
universe_epoch=epoch,
|
52 |
universe_story=base_story,
|
53 |
-
universe_macguffin=macguffin
|
|
|
|
|
54 |
)
|
55 |
# print(f"Current StorySegmentGenerators in StoryGenerator: {list(self.segment_generators.keys())}")
|
56 |
except KeyError as e:
|
@@ -94,7 +113,8 @@ class StoryGenerator:
|
|
94 |
current_location=game_state.current_location,
|
95 |
story_beat=game_state.story_beat,
|
96 |
turn_before_end=self.turn_before_end,
|
97 |
-
is_winning_story=self.is_winning_story
|
|
|
98 |
)
|
99 |
# print(f"Generated metadata_response: {metadata_response}")
|
100 |
|
|
|
28 |
self.is_winning_story = random.random() < GameConfig.WINNING_STORY_CHANCE
|
29 |
self.mistral_client = MistralClient(api_key=api_key, model_name=model_name)
|
30 |
self.image_prompt_generator = None # Will be initialized with the first universe style
|
31 |
+
self.metadata_generator = None # Will be initialized with hero description
|
32 |
self.segment_generators: Dict[str, StorySegmentGenerator] = {}
|
33 |
self._initialized = True
|
34 |
|
35 |
+
def create_segment_generator(self, session_id: str, style: dict, genre: str, epoch: str, base_story: str, macguffin: str, hero_name: str, hero_desc: str):
|
36 |
"""Create a new StorySegmentGenerator adapted to the specified universe for a given session."""
|
|
|
37 |
|
38 |
try:
|
39 |
+
# Use selected_artist if available, otherwise get the first artist from references
|
40 |
+
if "selected_artist" in style:
|
41 |
+
artist = style["selected_artist"]
|
42 |
+
else:
|
43 |
+
artist = style["references"][0]["artist"]
|
44 |
+
|
45 |
+
# Create a detailed artist style string
|
46 |
+
artist_style = f"{artist}, {style['name']} style, {genre} in {epoch}"
|
47 |
|
48 |
+
# Always create a new ImagePromptGenerator for each session with the correct artist and hero
|
49 |
+
self.image_prompt_generator = ImagePromptGenerator(
|
50 |
+
self.mistral_client,
|
51 |
+
artist_style=artist_style,
|
52 |
+
hero_name=hero_name,
|
53 |
+
hero_desc=hero_desc
|
54 |
+
)
|
55 |
+
|
56 |
+
# Create a new MetadataGenerator with hero description
|
57 |
+
self.metadata_generator = MetadataGenerator(
|
58 |
+
self.mistral_client,
|
59 |
+
hero_name=hero_name,
|
60 |
+
hero_desc=hero_desc
|
61 |
+
)
|
62 |
|
63 |
+
# Create a new StorySegmentGenerator with all universe parameters
|
64 |
self.segment_generators[session_id] = StorySegmentGenerator(
|
65 |
self.mistral_client,
|
66 |
universe_style=style["name"],
|
67 |
universe_genre=genre,
|
68 |
universe_epoch=epoch,
|
69 |
universe_story=base_story,
|
70 |
+
universe_macguffin=macguffin,
|
71 |
+
hero_name=hero_name,
|
72 |
+
hero_desc=hero_desc
|
73 |
)
|
74 |
# print(f"Current StorySegmentGenerators in StoryGenerator: {list(self.segment_generators.keys())}")
|
75 |
except KeyError as e:
|
|
|
113 |
current_location=game_state.current_location,
|
114 |
story_beat=game_state.story_beat,
|
115 |
turn_before_end=self.turn_before_end,
|
116 |
+
is_winning_story=self.is_winning_story,
|
117 |
+
story_history=story_history
|
118 |
)
|
119 |
# print(f"Generated metadata_response: {metadata_response}")
|
120 |
|
server/core/styles/universe_styles.json
CHANGED
@@ -1,124 +1,147 @@
|
|
1 |
{
|
2 |
"styles": [
|
3 |
{
|
4 |
-
"name": "
|
5 |
-
"description": "Style
|
6 |
"references": [
|
7 |
{
|
8 |
-
"artist": "
|
9 |
-
"works": ["
|
10 |
},
|
11 |
{
|
12 |
-
"artist": "
|
13 |
-
"works": ["
|
14 |
},
|
15 |
{
|
16 |
-
"artist": "
|
17 |
-
"works": ["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
},
|
19 |
{
|
20 |
-
"artist": "
|
21 |
-
"works": ["
|
|
|
|
|
|
|
|
|
22 |
}
|
23 |
]
|
24 |
},
|
25 |
{
|
26 |
-
"name": "
|
27 |
-
"description": "Style
|
28 |
"references": [
|
29 |
{
|
30 |
-
"artist": "
|
31 |
-
"works": ["
|
32 |
},
|
33 |
{
|
34 |
-
"artist": "
|
35 |
-
"works": ["
|
36 |
},
|
37 |
{
|
38 |
-
"artist": "
|
39 |
-
"works": ["
|
40 |
}
|
41 |
]
|
42 |
},
|
43 |
{
|
44 |
-
"name": "
|
45 |
-
"description": "Style
|
46 |
"references": [
|
47 |
{
|
48 |
-
"artist": "
|
49 |
-
"works": ["
|
50 |
},
|
51 |
{
|
52 |
-
"artist": "
|
53 |
-
"works": ["
|
54 |
},
|
55 |
{
|
56 |
-
"artist": "
|
57 |
-
"works": ["
|
58 |
}
|
59 |
]
|
60 |
}
|
61 |
],
|
62 |
"genres": [
|
63 |
-
"
|
64 |
-
"
|
65 |
-
"Post-apocalyptic",
|
66 |
"Fantasy",
|
67 |
-
"
|
68 |
-
"
|
69 |
-
"
|
70 |
"Horror",
|
71 |
-
"
|
72 |
-
"
|
73 |
-
"
|
74 |
-
"Heroic Fantasy",
|
75 |
-
"Urban Fantasy"
|
76 |
],
|
77 |
"epochs": [
|
78 |
-
"
|
79 |
-
"
|
80 |
-
"Middle Ages",
|
81 |
"Renaissance",
|
82 |
"Industrial Revolution",
|
83 |
-
"
|
84 |
-
"1950s",
|
85 |
-
"Contemporary Era",
|
86 |
"Near Future",
|
87 |
-
"
|
88 |
"Post-Apocalyptic",
|
89 |
-
"
|
90 |
-
"
|
91 |
],
|
92 |
"macguffins": [
|
93 |
-
"The
|
94 |
-
"The
|
95 |
-
"The
|
96 |
-
"The
|
97 |
-
"The
|
98 |
-
"The
|
99 |
-
"The
|
100 |
-
"The
|
101 |
-
"The
|
102 |
-
"The
|
103 |
-
|
104 |
-
|
105 |
-
"
|
106 |
-
"
|
107 |
-
"
|
108 |
-
"
|
109 |
-
"
|
110 |
-
"
|
111 |
-
"
|
112 |
-
"
|
113 |
-
"
|
114 |
-
"
|
115 |
-
"
|
116 |
-
"
|
117 |
-
"
|
118 |
-
"
|
119 |
-
"
|
120 |
-
"
|
121 |
-
"
|
122 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
]
|
124 |
}
|
|
|
1 |
{
|
2 |
"styles": [
|
3 |
{
|
4 |
+
"name": "American Comics (Modern)",
|
5 |
+
"description": "Style contemporain des comics américains avec des rendus dynamiques et des couleurs vives",
|
6 |
"references": [
|
7 |
{
|
8 |
+
"artist": "Jim Lee",
|
9 |
+
"works": ["X-Men", "Batman", "Superman Unchained"]
|
10 |
},
|
11 |
{
|
12 |
+
"artist": "Alex Ross",
|
13 |
+
"works": ["Kingdom Come", "Marvels", "Justice"]
|
14 |
},
|
15 |
{
|
16 |
+
"artist": "Stuart Immonen",
|
17 |
+
"works": ["Ultimate Spider-Man", "New Avengers", "Star Wars"]
|
18 |
+
}
|
19 |
+
]
|
20 |
+
},
|
21 |
+
{
|
22 |
+
"name": "American Comics (1950s)",
|
23 |
+
"description": "Style rétro des comics de l'âge d'or avec des couleurs primaires et des compositions classiques",
|
24 |
+
"references": [
|
25 |
+
{
|
26 |
+
"artist": "Jack Kirby",
|
27 |
+
"works": ["Captain America", "Fantastic Four", "The Avengers"]
|
28 |
},
|
29 |
{
|
30 |
+
"artist": "Steve Ditko",
|
31 |
+
"works": ["Spider-Man", "Doctor Strange", "The Question"]
|
32 |
+
},
|
33 |
+
{
|
34 |
+
"artist": "Curt Swan",
|
35 |
+
"works": ["Superman", "Action Comics", "Adventure Comics"]
|
36 |
}
|
37 |
]
|
38 |
},
|
39 |
{
|
40 |
+
"name": "Japanese Manga",
|
41 |
+
"description": "Style manga japonais avec des expressions dynamiques et des effets dramatiques",
|
42 |
"references": [
|
43 |
{
|
44 |
+
"artist": "Katsuhiro Otomo",
|
45 |
+
"works": ["Akira", "Domu", "Steamboy"]
|
46 |
},
|
47 |
{
|
48 |
+
"artist": "Naoki Urasawa",
|
49 |
+
"works": ["Monster", "20th Century Boys", "Pluto"]
|
50 |
},
|
51 |
{
|
52 |
+
"artist": "Takehiko Inoue",
|
53 |
+
"works": ["Vagabond", "Slam Dunk", "Real"]
|
54 |
}
|
55 |
]
|
56 |
},
|
57 |
{
|
58 |
+
"name": "Franco-Belge",
|
59 |
+
"description": "Style de la bande dessinée franco-belge avec des lignes claires et une attention aux détails",
|
60 |
"references": [
|
61 |
{
|
62 |
+
"artist": "Hergé",
|
63 |
+
"works": ["Tintin", "Quick et Flupke", "Jo, Zette et Jocko"]
|
64 |
},
|
65 |
{
|
66 |
+
"artist": "Moebius",
|
67 |
+
"works": ["L'Incal", "Arzak", "Le Garage Hermétique"]
|
68 |
},
|
69 |
{
|
70 |
+
"artist": "François Schuiten",
|
71 |
+
"works": ["Les Cités Obscures", "La Fièvre d'Urbicande", "La Tour"]
|
72 |
}
|
73 |
]
|
74 |
}
|
75 |
],
|
76 |
"genres": [
|
77 |
+
"Superhero",
|
78 |
+
"Science Fiction",
|
|
|
79 |
"Fantasy",
|
80 |
+
"Adventure",
|
81 |
+
"Mystery",
|
82 |
+
"Romance",
|
83 |
"Horror",
|
84 |
+
"Comedy",
|
85 |
+
"Drama",
|
86 |
+
"Historical"
|
|
|
|
|
87 |
],
|
88 |
"epochs": [
|
89 |
+
"Ancient Times",
|
90 |
+
"Medieval",
|
|
|
91 |
"Renaissance",
|
92 |
"Industrial Revolution",
|
93 |
+
"Modern Day",
|
|
|
|
|
94 |
"Near Future",
|
95 |
+
"Far Future",
|
96 |
"Post-Apocalyptic",
|
97 |
+
"Alternative History",
|
98 |
+
"Timeless"
|
99 |
],
|
100 |
"macguffins": [
|
101 |
+
"The Cosmic Artifact",
|
102 |
+
"The Ancient Scroll",
|
103 |
+
"The Power Crystal",
|
104 |
+
"The Lost Technology",
|
105 |
+
"The Sacred Relic",
|
106 |
+
"The Mysterious Device",
|
107 |
+
"The Enchanted Object",
|
108 |
+
"The Secret Formula",
|
109 |
+
"The Hidden Map",
|
110 |
+
"The Legendary Weapon"
|
111 |
+
],
|
112 |
+
"hero": [
|
113 |
+
"Sarah, 28, short dark hair, blue eyes, a bit rude, wearing a simple t-shirt and jeans.",
|
114 |
+
"Akira, 16, long black hair, brown eyes, calm, wearing a school uniform with a blazer.",
|
115 |
+
"Aisha, 32, curly brown hair, green eyes, creative, dressed in a colorful blouse and tailored pants.",
|
116 |
+
"Diego, 35, wavy black hair, brown eyes, passionate, wearing a casual shirt and cargo shorts.",
|
117 |
+
"Mei, 25, straight black hair, black eyes, determined, in sportswear with a hoodie.",
|
118 |
+
"Raj, 29, short black hair, brown eyes, innovative, in a modern shirt and chinos.",
|
119 |
+
"Fatima, 31, long black hair, brown eyes, courageous, wearing a light coat and scarf.",
|
120 |
+
"Yuki, 27, long black hair, black eyes, mysterious, in a traditional robe with intricate patterns.",
|
121 |
+
"Liam, 33, curly red hair, green eyes, charismatic, in a cozy sweater and jeans.",
|
122 |
+
"Zara, 28, short black hair, brown eyes, fearless, wearing an explorer's jacket and cargo pants.",
|
123 |
+
"Hiroshi, 70, shaved head, brown eyes, wise, in monk robes with simple sandals.",
|
124 |
+
"Amara, 26, long black hair, brown eyes, expressive, in a dance dress with flowing fabric.",
|
125 |
+
"Kofi, 34, short black hair, brown eyes, resourceful, in a patterned shirt and khakis.",
|
126 |
+
"Elena, 30, long brown hair, green eyes, passionate, in a kitchen apron over a casual dress.",
|
127 |
+
"Santiago, 32, short black hair, brown eyes, daring, in a pilot's jacket and aviator sunglasses.",
|
128 |
+
"Leila, 29, long brown hair, brown eyes, talented, in an elegant suit with a silk scarf.",
|
129 |
+
"Nikolai, 36, short blond hair, blue eyes, brilliant, in a lab coat and formal trousers.",
|
130 |
+
"Jamal, 35, short black hair, brown eyes, perceptive, in a detective coat and fedora.",
|
131 |
+
"Anika, 30, long black hair, brown eyes, gentle, in a medical blouse and comfortable shoes.",
|
132 |
+
"Mateo, 31, short brown hair, brown eyes, skilled, in work overalls and sturdy boots.",
|
133 |
+
"Sofia, 29, long brown hair, green eyes, visionary, in a director's t-shirt and jeans.",
|
134 |
+
"Hassan, 60, short gray hair, brown eyes, wise, in a traditional djellaba and leather sandals.",
|
135 |
+
"Isabella, 27, long black hair, brown eyes, brave, in light armor with a leather belt.",
|
136 |
+
"Yara, 32, long brown hair, brown eyes, talented, in a cozy sweater and leggings.",
|
137 |
+
"Kai, 26, short blond hair, blue eyes, daring, in a surf suit and flip-flops.",
|
138 |
+
"Lina, 30, short blond hair, blue eyes, dedicated, in professional attire with a blazer.",
|
139 |
+
"Omar, 34, short black hair, brown eyes, skilled, in a sailor's jacket and waterproof boots.",
|
140 |
+
"Priya, 31, long black hair, brown eyes, brilliant, in a scientific blouse and pencil skirt.",
|
141 |
+
"Rafael, 33, short brown hair, brown eyes, passionate, in an activist t-shirt and cargo pants.",
|
142 |
+
"Emma, 18, long blond hair, blue eyes, adventurous, in a denim jacket and sneakers.",
|
143 |
+
"Hans, 65, short gray hair, blue eyes, thoughtful, in a wool sweater and corduroy pants.",
|
144 |
+
"Sophie, 22, short brown hair, green eyes, cheerful, in a floral dress and sandals.",
|
145 |
+
"Lars, 40, short blond hair, blue eyes, practical, in a plaid shirt and jeans."
|
146 |
]
|
147 |
}
|
server/scripts/test_game.py
CHANGED
@@ -82,10 +82,17 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
|
|
82 |
print(f"⏱️ Maximum turns: {max_turns}")
|
83 |
print_separator()
|
84 |
|
85 |
-
#
|
86 |
-
|
87 |
-
|
88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
|
90 |
# Create session and game state
|
91 |
session_id = str(uuid.uuid4())
|
@@ -94,21 +101,22 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
|
|
94 |
style=style["name"],
|
95 |
genre=genre,
|
96 |
epoch=epoch,
|
97 |
-
base_story=
|
98 |
)
|
99 |
|
100 |
-
# Create
|
101 |
story_generator.create_segment_generator(
|
102 |
session_id=session_id,
|
103 |
style=style,
|
104 |
genre=genre,
|
105 |
epoch=epoch,
|
106 |
-
base_story=
|
107 |
-
macguffin=macguffin
|
|
|
108 |
)
|
109 |
|
110 |
# Display universe information
|
111 |
-
print_universe_info(style["name"], genre, epoch,
|
112 |
|
113 |
last_choice = None
|
114 |
|
|
|
82 |
print(f"⏱️ Maximum turns: {max_turns}")
|
83 |
print_separator()
|
84 |
|
85 |
+
# Test universe generation
|
86 |
+
style, genre, epoch, macguffin, hero = universe_generator._get_random_elements()
|
87 |
+
print(f"\nGenerated universe elements:")
|
88 |
+
print(f"Style: {style['name']}")
|
89 |
+
print(f"Genre: {genre}")
|
90 |
+
print(f"Epoch: {epoch}")
|
91 |
+
print(f"MacGuffin: {macguffin}")
|
92 |
+
print(f"Hero: {hero}")
|
93 |
+
|
94 |
+
base_story = await universe_generator.generate()
|
95 |
+
print(f"\nGenerated base story:\n{base_story}")
|
96 |
|
97 |
# Create session and game state
|
98 |
session_id = str(uuid.uuid4())
|
|
|
101 |
style=style["name"],
|
102 |
genre=genre,
|
103 |
epoch=epoch,
|
104 |
+
base_story=base_story
|
105 |
)
|
106 |
|
107 |
+
# Create story generator
|
108 |
story_generator.create_segment_generator(
|
109 |
session_id=session_id,
|
110 |
style=style,
|
111 |
genre=genre,
|
112 |
epoch=epoch,
|
113 |
+
base_story=base_story,
|
114 |
+
macguffin=macguffin,
|
115 |
+
hero=hero
|
116 |
)
|
117 |
|
118 |
# Display universe information
|
119 |
+
print_universe_info(style["name"], genre, epoch, base_story)
|
120 |
|
121 |
last_choice = None
|
122 |
|
server/services/mistral_client.py
CHANGED
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
|
|
31 |
# Pricing: https://docs.mistral.ai/platform/pricing/
|
32 |
|
33 |
class MistralClient:
|
34 |
-
def __init__(self, api_key: str, model_name: str = "mistral-small"):
|
35 |
logger.info(f"Initializing MistralClient with model: {model_name}")
|
36 |
self.model = ChatMistralAI(
|
37 |
mistral_api_key=api_key,
|
@@ -157,4 +157,38 @@ class MistralClient:
|
|
157 |
return await self._generate_with_retry(messages)
|
158 |
except Exception as e:
|
159 |
print(f"Error transforming prompt: {str(e)}")
|
160 |
-
return story_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
# Pricing: https://docs.mistral.ai/platform/pricing/
|
32 |
|
33 |
class MistralClient:
|
34 |
+
def __init__(self, api_key: str, model_name: str = "mistral-small-latest"):
|
35 |
logger.info(f"Initializing MistralClient with model: {model_name}")
|
36 |
self.model = ChatMistralAI(
|
37 |
mistral_api_key=api_key,
|
|
|
157 |
return await self._generate_with_retry(messages)
|
158 |
except Exception as e:
|
159 |
print(f"Error transforming prompt: {str(e)}")
|
160 |
+
return story_text
|
161 |
+
|
162 |
+
async def generate_text(self, messages: list[BaseMessage]) -> str:
|
163 |
+
"""
|
164 |
+
Génère une réponse textuelle simple sans structure JSON.
|
165 |
+
Utile pour la génération de texte narratif ou descriptif.
|
166 |
+
|
167 |
+
Args:
|
168 |
+
messages: Liste des messages pour le modèle
|
169 |
+
|
170 |
+
Returns:
|
171 |
+
str: Le texte généré
|
172 |
+
"""
|
173 |
+
retry_count = 0
|
174 |
+
last_error = None
|
175 |
+
|
176 |
+
while retry_count < self.max_retries:
|
177 |
+
try:
|
178 |
+
logger.info(f"Attempt {retry_count + 1}/{self.max_retries}")
|
179 |
+
|
180 |
+
await self._wait_for_rate_limit()
|
181 |
+
response = await self.model.ainvoke(messages)
|
182 |
+
return response.content.strip()
|
183 |
+
|
184 |
+
except Exception as e:
|
185 |
+
logger.error(f"Error on attempt {retry_count + 1}/{self.max_retries}: {str(e)}")
|
186 |
+
retry_count += 1
|
187 |
+
if retry_count < self.max_retries:
|
188 |
+
wait_time = 2 * retry_count
|
189 |
+
logger.info(f"Waiting {wait_time} seconds before retry...")
|
190 |
+
await asyncio.sleep(wait_time)
|
191 |
+
continue
|
192 |
+
|
193 |
+
logger.error(f"Failed after {self.max_retries} attempts. Last error: {last_error or str(e)}")
|
194 |
+
raise Exception(f"Failed after {self.max_retries} attempts. Last error: {last_error or str(e)}")
|