update
Browse files- .DS_Store +0 -0
- .gitignore +1 -0
- README.md +1 -1
- client/package.json +3 -1
- client/public/sounds/drawing-1.mp3 +0 -0
- client/public/sounds/drawing-2.mp3 +0 -0
- client/public/sounds/drawing-3.mp3 +0 -0
- client/public/sounds/drawing-4.mp3 +0 -0
- client/public/sounds/drawing-5.mp3 +0 -0
- client/public/sounds/page-flip-1.mp3 +0 -0
- client/public/sounds/page-flip-2.mp3 +0 -0
- client/public/sounds/page-flip-3.mp3 +0 -0
- client/public/sounds/page-flip-4.mp3 +0 -0
- client/public/sounds/page-flip-5.mp3 +0 -0
- client/public/sounds/page-flip-6.mp3 +0 -0
- client/public/sounds/page-flip-7.mp3 +0 -0
- client/public/{talky-walky-off.mp3 → sounds/talky-walky-off.mp3} +0 -0
- client/public/{talky-walky-on.mp3 → sounds/talky-walky-on.mp3} +0 -0
- client/src/components/ErrorDisplay.jsx +43 -0
- client/src/components/StoryChoices.jsx +85 -1
- client/src/components/StoryManager.jsx +0 -182
- client/src/hooks/useImageGeneration.js +0 -64
- client/src/hooks/usePageSound.js +46 -0
- client/src/hooks/useStoryCapture.js +55 -124
- client/src/hooks/useWritingSound.js +46 -0
- client/src/layouts/ComicLayout.jsx +65 -33
- client/src/layouts/config.js +17 -62
- client/src/main.jsx +1 -1
- client/src/pages/Game.jsx +127 -108
- client/src/pages/{tutorial/Tutorial.jsx → Tutorial.jsx} +0 -4
- client/src/pages/game/App.jsx +0 -794
- client/yarn.lock +17 -0
- server/api/models.py +60 -16
- server/api/routes/chat.py +6 -8
- server/core/constants.py +18 -0
- server/core/game_logic.py +53 -35
- server/core/prompts/convice.py +28 -0
- server/core/prompts/system.py +10 -6
- server/core/prompts/text_prompts.py +44 -3
- server/core/story_generators.py +101 -47
- server/scripts/test_game.py +6 -2
.DS_Store
CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
|
|
.gitignore
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
/client/node_modules
|
2 |
.env
|
3 |
/node_modules
|
|
|
4 |
ai-comic-factory/
|
|
|
1 |
/client/node_modules
|
2 |
.env
|
3 |
/node_modules
|
4 |
+
node_modules
|
5 |
ai-comic-factory/
|
README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
emoji: 💻
|
4 |
colorFrom: red
|
5 |
colorTo: blue
|
|
|
1 |
---
|
2 |
+
title: Sarah's Chronicles
|
3 |
emoji: 💻
|
4 |
colorFrom: red
|
5 |
colorTo: blue
|
client/package.json
CHANGED
@@ -20,7 +20,9 @@
|
|
20 |
"html2canvas": "^1.4.1",
|
21 |
"react": "^18.3.1",
|
22 |
"react-dom": "^18.3.1",
|
23 |
-
"react-router-dom": "^7.1.3"
|
|
|
|
|
24 |
},
|
25 |
"devDependencies": {
|
26 |
"@eslint/js": "^9.17.0",
|
|
|
20 |
"html2canvas": "^1.4.1",
|
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 |
},
|
27 |
"devDependencies": {
|
28 |
"@eslint/js": "^9.17.0",
|
client/public/sounds/drawing-1.mp3
ADDED
Binary file (66.9 kB). View file
|
|
client/public/sounds/drawing-2.mp3
ADDED
Binary file (66.1 kB). View file
|
|
client/public/sounds/drawing-3.mp3
ADDED
Binary file (66.1 kB). View file
|
|
client/public/sounds/drawing-4.mp3
ADDED
Binary file (66.9 kB). View file
|
|
client/public/sounds/drawing-5.mp3
ADDED
Binary file (66.1 kB). View file
|
|
client/public/sounds/page-flip-1.mp3
ADDED
Binary file (27.7 kB). View file
|
|
client/public/sounds/page-flip-2.mp3
ADDED
Binary file (30 kB). View file
|
|
client/public/sounds/page-flip-3.mp3
ADDED
Binary file (38.5 kB). View file
|
|
client/public/sounds/page-flip-4.mp3
ADDED
Binary file (32.3 kB). View file
|
|
client/public/sounds/page-flip-5.mp3
ADDED
Binary file (43.1 kB). View file
|
|
client/public/sounds/page-flip-6.mp3
ADDED
Binary file (44.7 kB). View file
|
|
client/public/sounds/page-flip-7.mp3
ADDED
Binary file (40 kB). View file
|
|
client/public/{talky-walky-off.mp3 → sounds/talky-walky-off.mp3}
RENAMED
File without changes
|
client/public/{talky-walky-on.mp3 → sounds/talky-walky-on.mp3}
RENAMED
File without changes
|
client/src/components/ErrorDisplay.jsx
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Box, Typography, Button } from "@mui/material";
|
2 |
+
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
3 |
+
|
4 |
+
export function ErrorDisplay({ message, onRetry }) {
|
5 |
+
return (
|
6 |
+
<Box
|
7 |
+
sx={{
|
8 |
+
position: "absolute",
|
9 |
+
top: 0,
|
10 |
+
left: 0,
|
11 |
+
right: 0,
|
12 |
+
bottom: 0,
|
13 |
+
display: "flex",
|
14 |
+
flexDirection: "column",
|
15 |
+
alignItems: "center",
|
16 |
+
justifyContent: "center",
|
17 |
+
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
18 |
+
color: "white",
|
19 |
+
zIndex: 1000,
|
20 |
+
gap: 3,
|
21 |
+
p: 4,
|
22 |
+
textAlign: "center",
|
23 |
+
}}
|
24 |
+
>
|
25 |
+
<ErrorOutlineIcon sx={{ fontSize: 64, color: "error.main" }} />
|
26 |
+
<Typography variant="h5" component="h2">
|
27 |
+
An error occurred
|
28 |
+
</Typography>
|
29 |
+
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 600 }}>
|
30 |
+
{message ||
|
31 |
+
"The storyteller is temporarily unavailable. Please try again in a few moments..."}
|
32 |
+
</Typography>
|
33 |
+
<Button
|
34 |
+
variant="contained"
|
35 |
+
color="primary"
|
36 |
+
onClick={onRetry}
|
37 |
+
sx={{ mt: 2 }}
|
38 |
+
>
|
39 |
+
Retry
|
40 |
+
</Button>
|
41 |
+
</Box>
|
42 |
+
);
|
43 |
+
}
|
client/src/components/StoryChoices.jsx
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import { Box, Button, Typography, Chip } from "@mui/material";
|
|
|
2 |
|
3 |
// Function to convert text with ** to Chip elements
|
4 |
const formatTextWithBold = (text) => {
|
@@ -24,7 +25,90 @@ const formatTextWithBold = (text) => {
|
|
24 |
});
|
25 |
};
|
26 |
|
27 |
-
export function StoryChoices({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
if (!choices || choices.length === 0) return null;
|
29 |
|
30 |
return (
|
|
|
1 |
import { Box, Button, Typography, Chip } from "@mui/material";
|
2 |
+
import { useStoryCapture } from "../hooks/useStoryCapture";
|
3 |
|
4 |
// Function to convert text with ** to Chip elements
|
5 |
const formatTextWithBold = (text) => {
|
|
|
25 |
});
|
26 |
};
|
27 |
|
28 |
+
export function StoryChoices({
|
29 |
+
choices = [],
|
30 |
+
onChoice,
|
31 |
+
disabled = false,
|
32 |
+
isLastStep = false,
|
33 |
+
isGameOver = false,
|
34 |
+
containerRef,
|
35 |
+
}) {
|
36 |
+
const { captureStory } = useStoryCapture();
|
37 |
+
|
38 |
+
console.log("ICI", isLastStep, isGameOver);
|
39 |
+
if (isGameOver) {
|
40 |
+
return (
|
41 |
+
<Box
|
42 |
+
sx={{
|
43 |
+
display: "flex",
|
44 |
+
flexDirection: "column",
|
45 |
+
justifyContent: "center",
|
46 |
+
alignItems: "center",
|
47 |
+
gap: 2,
|
48 |
+
p: 3,
|
49 |
+
minWidth: "150px",
|
50 |
+
height: "100%",
|
51 |
+
backgroundColor: "transparent",
|
52 |
+
}}
|
53 |
+
>
|
54 |
+
<Typography
|
55 |
+
variant="h3"
|
56 |
+
sx={{
|
57 |
+
color: "white",
|
58 |
+
textAlign: "center",
|
59 |
+
mb: 2,
|
60 |
+
textTransform: "uppercase",
|
61 |
+
}}
|
62 |
+
>
|
63 |
+
The End
|
64 |
+
</Typography>
|
65 |
+
<Button
|
66 |
+
variant="outlined"
|
67 |
+
size="large"
|
68 |
+
onClick={() => captureStory(containerRef)}
|
69 |
+
sx={{
|
70 |
+
width: "100%",
|
71 |
+
textTransform: "none",
|
72 |
+
cursor: "pointer",
|
73 |
+
fontSize: "1.1rem",
|
74 |
+
padding: "16px 24px",
|
75 |
+
lineHeight: 1.3,
|
76 |
+
color: "white",
|
77 |
+
borderColor: "rgba(255, 255, 255, 0.23)",
|
78 |
+
"&:hover": {
|
79 |
+
borderColor: "white",
|
80 |
+
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
81 |
+
},
|
82 |
+
mb: 2,
|
83 |
+
}}
|
84 |
+
>
|
85 |
+
Save your story
|
86 |
+
</Button>
|
87 |
+
<Button
|
88 |
+
variant="outlined"
|
89 |
+
size="large"
|
90 |
+
onClick={() => window.location.reload()}
|
91 |
+
sx={{
|
92 |
+
width: "100%",
|
93 |
+
textTransform: "none",
|
94 |
+
cursor: "pointer",
|
95 |
+
fontSize: "1.1rem",
|
96 |
+
padding: "16px 24px",
|
97 |
+
lineHeight: 1.3,
|
98 |
+
color: "white",
|
99 |
+
borderColor: "rgba(255, 255, 255, 0.23)",
|
100 |
+
"&:hover": {
|
101 |
+
borderColor: "white",
|
102 |
+
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
103 |
+
},
|
104 |
+
}}
|
105 |
+
>
|
106 |
+
Restart
|
107 |
+
</Button>
|
108 |
+
</Box>
|
109 |
+
);
|
110 |
+
}
|
111 |
+
|
112 |
if (!choices || choices.length === 0) return null;
|
113 |
|
114 |
return (
|
client/src/components/StoryManager.jsx
DELETED
@@ -1,182 +0,0 @@
|
|
1 |
-
import { useEffect } from "react";
|
2 |
-
import { useComic } from "../context/ComicContext";
|
3 |
-
import { useImageGeneration } from "../hooks/useImageGeneration";
|
4 |
-
import { groupSegmentsIntoLayouts } from "../layouts/utils";
|
5 |
-
import { LAYOUTS } from "../layouts/config";
|
6 |
-
import axios from "axios";
|
7 |
-
|
8 |
-
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
9 |
-
|
10 |
-
// Create axios instance with default config
|
11 |
-
const api = axios.create({
|
12 |
-
headers: {
|
13 |
-
"x-client-id": `client_${Math.random().toString(36).substring(2)}`,
|
14 |
-
},
|
15 |
-
});
|
16 |
-
|
17 |
-
// Function to convert text with ** to bold elements
|
18 |
-
const formatTextWithBold = (text) => {
|
19 |
-
if (!text) return "";
|
20 |
-
const parts = text.split(/(\*\*.*?\*\*)/g);
|
21 |
-
return parts.map((part, index) => {
|
22 |
-
if (part.startsWith("**") && part.endsWith("**")) {
|
23 |
-
return <strong key={index}>{part.slice(2, -2)}</strong>;
|
24 |
-
}
|
25 |
-
return part;
|
26 |
-
});
|
27 |
-
};
|
28 |
-
|
29 |
-
export function StoryManager() {
|
30 |
-
const { state, updateSegments, updateSegment, setChoices, setLoading } =
|
31 |
-
useComic();
|
32 |
-
const { generateImagesForSegment } = useImageGeneration();
|
33 |
-
|
34 |
-
const handleStoryAction = async (action, choiceId = null) => {
|
35 |
-
setLoading(true);
|
36 |
-
try {
|
37 |
-
// 1. Obtenir l'histoire
|
38 |
-
const response = await api.post(`${API_URL}/api/chat`, {
|
39 |
-
message: action,
|
40 |
-
choice_id: choiceId,
|
41 |
-
});
|
42 |
-
|
43 |
-
// 2. Créer le nouveau segment
|
44 |
-
const newSegment = {
|
45 |
-
text: formatTextWithBold(response.data.story_text),
|
46 |
-
isChoice: false,
|
47 |
-
isDeath: response.data.is_death,
|
48 |
-
isVictory: response.data.is_victory,
|
49 |
-
radiationLevel: response.data.radiation_level,
|
50 |
-
is_first_step: response.data.is_first_step,
|
51 |
-
is_last_step: response.data.is_last_step,
|
52 |
-
images: response.data.image_prompts
|
53 |
-
? Array(response.data.image_prompts.length).fill(null)
|
54 |
-
: [],
|
55 |
-
};
|
56 |
-
|
57 |
-
// 3. Mettre à jour les segments
|
58 |
-
const segmentIndex = action === "restart" ? 0 : state.segments.length;
|
59 |
-
const updatedSegments =
|
60 |
-
action === "restart" ? [newSegment] : [...state.segments, newSegment];
|
61 |
-
|
62 |
-
updateSegments(updatedSegments);
|
63 |
-
|
64 |
-
// 4. Mettre à jour les choix
|
65 |
-
setChoices(response.data.choices);
|
66 |
-
setLoading(false);
|
67 |
-
|
68 |
-
// 5. Générer les images si nécessaire
|
69 |
-
if (response.data.image_prompts?.length > 0) {
|
70 |
-
const prompts = response.data.image_prompts;
|
71 |
-
let currentPromptIndex = 0;
|
72 |
-
let currentSegmentIndex = segmentIndex;
|
73 |
-
|
74 |
-
while (currentPromptIndex < prompts.length) {
|
75 |
-
// Recalculer les layouts avec les segments actuels
|
76 |
-
const layouts = groupSegmentsIntoLayouts(updatedSegments);
|
77 |
-
let currentLayout = layouts[layouts.length - 1];
|
78 |
-
|
79 |
-
// Pour un layout COVER, ne prendre que le premier prompt
|
80 |
-
if (currentLayout.type === "COVER") {
|
81 |
-
const promptsToUse = [prompts[0]];
|
82 |
-
console.log("COVER layout: using only first prompt");
|
83 |
-
|
84 |
-
const images = await generateImagesForSegment(
|
85 |
-
promptsToUse,
|
86 |
-
currentLayout
|
87 |
-
);
|
88 |
-
|
89 |
-
if (images && images.length > 0) {
|
90 |
-
const currentSegment = updatedSegments[currentSegmentIndex];
|
91 |
-
const updatedSegment = {
|
92 |
-
...currentSegment,
|
93 |
-
images: [images[0]], // Ne garder que la première image
|
94 |
-
};
|
95 |
-
updatedSegments[currentSegmentIndex] = updatedSegment;
|
96 |
-
updateSegments(updatedSegments);
|
97 |
-
}
|
98 |
-
break; // Sortir de la boucle car nous n'avons besoin que d'une image
|
99 |
-
}
|
100 |
-
|
101 |
-
// Pour les autres layouts, continuer normalement
|
102 |
-
const remainingPanels =
|
103 |
-
LAYOUTS[currentLayout.type].panels.length -
|
104 |
-
(currentLayout.segments[currentLayout.segments.length - 1].images
|
105 |
-
?.length || 0);
|
106 |
-
|
107 |
-
if (remainingPanels === 0) {
|
108 |
-
// Créer un nouveau segment pour la nouvelle page
|
109 |
-
const newPageSegment = {
|
110 |
-
...newSegment,
|
111 |
-
images: Array(prompts.length - currentPromptIndex).fill(null),
|
112 |
-
};
|
113 |
-
updatedSegments.push(newPageSegment);
|
114 |
-
currentSegmentIndex = updatedSegments.length - 1;
|
115 |
-
updateSegments(updatedSegments);
|
116 |
-
continue;
|
117 |
-
}
|
118 |
-
|
119 |
-
// Générer les images pour ce layout
|
120 |
-
const promptsForCurrentLayout = prompts.slice(
|
121 |
-
currentPromptIndex,
|
122 |
-
currentPromptIndex + remainingPanels
|
123 |
-
);
|
124 |
-
|
125 |
-
console.log("Generating images for layout:", {
|
126 |
-
segmentIndex: currentSegmentIndex,
|
127 |
-
layoutType: currentLayout.type,
|
128 |
-
prompts: promptsForCurrentLayout,
|
129 |
-
remainingPanels,
|
130 |
-
});
|
131 |
-
|
132 |
-
// Générer les images
|
133 |
-
const images = await generateImagesForSegment(
|
134 |
-
promptsForCurrentLayout,
|
135 |
-
currentLayout
|
136 |
-
);
|
137 |
-
|
138 |
-
// Mettre à jour le segment avec les nouvelles images
|
139 |
-
if (images && images.length > 0) {
|
140 |
-
const currentSegment = updatedSegments[currentSegmentIndex];
|
141 |
-
const updatedSegment = {
|
142 |
-
...currentSegment,
|
143 |
-
images: [...(currentSegment.images || []), ...images],
|
144 |
-
};
|
145 |
-
updatedSegments[currentSegmentIndex] = updatedSegment;
|
146 |
-
updateSegments(updatedSegments);
|
147 |
-
}
|
148 |
-
|
149 |
-
currentPromptIndex += promptsForCurrentLayout.length;
|
150 |
-
}
|
151 |
-
}
|
152 |
-
} catch (error) {
|
153 |
-
console.error("Error:", error);
|
154 |
-
const errorSegment = {
|
155 |
-
text: "Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...",
|
156 |
-
isChoice: false,
|
157 |
-
isDeath: false,
|
158 |
-
isVictory: false,
|
159 |
-
radiationLevel:
|
160 |
-
state.segments.length > 0
|
161 |
-
? state.segments[state.segments.length - 1].radiationLevel
|
162 |
-
: 0,
|
163 |
-
images: [],
|
164 |
-
};
|
165 |
-
|
166 |
-
updateSegments(
|
167 |
-
action === "restart"
|
168 |
-
? [errorSegment]
|
169 |
-
: [...state.segments, errorSegment]
|
170 |
-
);
|
171 |
-
setChoices([{ id: 1, text: "Réessayer" }]);
|
172 |
-
setLoading(false);
|
173 |
-
}
|
174 |
-
};
|
175 |
-
|
176 |
-
// Démarrer l'histoire au montage
|
177 |
-
useEffect(() => {
|
178 |
-
handleStoryAction("restart");
|
179 |
-
}, []);
|
180 |
-
|
181 |
-
return null; // Ce composant ne rend rien, il gère juste la logique
|
182 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/hooks/useImageGeneration.js
DELETED
@@ -1,64 +0,0 @@
|
|
1 |
-
import axios from "axios";
|
2 |
-
import { getDefaultHeaders } from "../utils/session";
|
3 |
-
|
4 |
-
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
5 |
-
|
6 |
-
// Create axios instance with default config
|
7 |
-
const api = axios.create({
|
8 |
-
headers: getDefaultHeaders(),
|
9 |
-
});
|
10 |
-
|
11 |
-
export function useImageGeneration() {
|
12 |
-
const generateImage = async (prompt, dimensions) => {
|
13 |
-
try {
|
14 |
-
console.log("Generating image with dimensions:", dimensions);
|
15 |
-
|
16 |
-
const result = await api.post(`${API_URL}/api/generate-image-direct`, {
|
17 |
-
prompt,
|
18 |
-
width: dimensions.width,
|
19 |
-
height: dimensions.height,
|
20 |
-
});
|
21 |
-
|
22 |
-
if (result.data.success) {
|
23 |
-
return result.data.image_base64;
|
24 |
-
}
|
25 |
-
return null;
|
26 |
-
} catch (error) {
|
27 |
-
console.error("Error generating image:", error);
|
28 |
-
return null;
|
29 |
-
}
|
30 |
-
};
|
31 |
-
|
32 |
-
const generateImagesForSegment = async (prompts, currentLayout) => {
|
33 |
-
try {
|
34 |
-
if (!currentLayout) {
|
35 |
-
console.error("No valid layout found");
|
36 |
-
return null;
|
37 |
-
}
|
38 |
-
|
39 |
-
const layoutType = currentLayout.type;
|
40 |
-
console.log("Generating images for layout type:", layoutType);
|
41 |
-
|
42 |
-
// Pour chaque prompt, générer une image avec les dimensions appropriées
|
43 |
-
const results = [];
|
44 |
-
for (let i = 0; i < prompts.length; i++) {
|
45 |
-
const panelDimensions = currentLayout.panels[i];
|
46 |
-
if (!panelDimensions) {
|
47 |
-
console.error(`No dimensions for panel ${i} in layout ${layoutType}`);
|
48 |
-
continue;
|
49 |
-
}
|
50 |
-
|
51 |
-
const image = await generateImage(prompts[i], panelDimensions);
|
52 |
-
if (image) {
|
53 |
-
results.push(image);
|
54 |
-
}
|
55 |
-
}
|
56 |
-
return results;
|
57 |
-
} catch (error) {
|
58 |
-
console.error("Error in generateImagesForSegment:", error);
|
59 |
-
return [];
|
60 |
-
}
|
61 |
-
};
|
62 |
-
|
63 |
-
return { generateImagesForSegment };
|
64 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/hooks/usePageSound.js
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useSound } from "use-sound";
|
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() {
|
10 |
+
const [soundsLoaded, setSoundsLoaded] = useState(false);
|
11 |
+
|
12 |
+
// Créer un tableau de hooks useSound pour chaque son
|
13 |
+
const sounds = PAGE_SOUNDS.map((soundPath) => {
|
14 |
+
const [play, { sound }] = useSound(soundPath, {
|
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 (!soundsLoaded) {
|
33 |
+
console.warn("Page sounds not loaded yet");
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
|
37 |
+
const randomIndex = Math.floor(Math.random() * sounds.length);
|
38 |
+
try {
|
39 |
+
sounds[randomIndex].play();
|
40 |
+
} catch (error) {
|
41 |
+
console.error("Error playing page sound:", error);
|
42 |
+
}
|
43 |
+
};
|
44 |
+
|
45 |
+
return playRandomPageSound;
|
46 |
+
}
|
client/src/hooks/useStoryCapture.js
CHANGED
@@ -1,143 +1,74 @@
|
|
1 |
import { useCallback } from "react";
|
2 |
-
import
|
3 |
|
4 |
export function useStoryCapture() {
|
5 |
-
const
|
6 |
-
|
|
|
|
|
7 |
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
);
|
13 |
-
if (!
|
14 |
console.error("Comic layout container not found");
|
15 |
return null;
|
16 |
}
|
17 |
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
});
|
32 |
-
});
|
33 |
-
|
34 |
-
// Obtenir les dimensions totales (sans le padding)
|
35 |
-
const children = Array.from(scrollContainer.children);
|
36 |
-
const lastChild = children[children.length - 1];
|
37 |
-
const lastChildRect = lastChild.getBoundingClientRect();
|
38 |
-
const containerRect = scrollContainer.getBoundingClientRect();
|
39 |
-
|
40 |
-
// Calculer la largeur totale en incluant la position et la largeur complète du dernier élément
|
41 |
-
const totalWidth =
|
42 |
-
lastChildRect.x + lastChildRect.width - containerRect.x + 32; // Ajouter un petit padding de sécurité
|
43 |
-
|
44 |
-
const totalHeight = scrollContainer.scrollHeight;
|
45 |
-
|
46 |
-
// Préparer le conteneur pour la capture
|
47 |
-
Object.assign(containerRef.current.style, {
|
48 |
-
width: "auto",
|
49 |
-
height: "auto",
|
50 |
-
overflow: "visible",
|
51 |
-
});
|
52 |
-
|
53 |
-
// Préparer le conteneur scrollable
|
54 |
-
Object.assign(scrollContainer.style, {
|
55 |
-
width: `${totalWidth}px`,
|
56 |
-
height: `${totalHeight}px`,
|
57 |
-
position: "relative",
|
58 |
-
overflow: "visible",
|
59 |
-
display: "flex",
|
60 |
-
transform: "none",
|
61 |
-
transition: "none",
|
62 |
-
padding: "0",
|
63 |
-
justifyContent: "flex-start", // Forcer l'alignement à gauche
|
64 |
-
});
|
65 |
-
|
66 |
-
// Forcer un reflow
|
67 |
-
scrollContainer.offsetHeight;
|
68 |
-
|
69 |
-
// Capturer l'image
|
70 |
-
const canvas = await html2canvas(scrollContainer, {
|
71 |
-
scale: 2,
|
72 |
-
useCORS: true,
|
73 |
-
allowTaint: true,
|
74 |
-
backgroundColor: "#242424",
|
75 |
-
width: totalWidth,
|
76 |
-
height: totalHeight,
|
77 |
-
x: 0,
|
78 |
-
y: 0,
|
79 |
-
scrollX: 0,
|
80 |
-
scrollY: 0,
|
81 |
-
windowWidth: totalWidth,
|
82 |
-
windowHeight: totalHeight,
|
83 |
-
logging: true,
|
84 |
-
onclone: (clonedDoc) => {
|
85 |
-
const clonedContainer = clonedDoc.querySelector(
|
86 |
-
"[data-comic-layout]"
|
87 |
-
);
|
88 |
-
if (clonedContainer) {
|
89 |
-
Object.assign(clonedContainer.style, {
|
90 |
-
width: `${totalWidth}px`,
|
91 |
-
height: `${totalHeight}px`,
|
92 |
-
position: "relative",
|
93 |
-
overflow: "visible",
|
94 |
-
display: "flex",
|
95 |
-
transform: "none",
|
96 |
-
transition: "none",
|
97 |
-
padding: "0",
|
98 |
-
justifyContent: "flex-start",
|
99 |
-
});
|
100 |
-
|
101 |
-
// S'assurer que tous les enfants sont visibles et alignés
|
102 |
-
Array.from(clonedContainer.children).forEach(
|
103 |
-
(child, index, arr) => {
|
104 |
-
Object.assign(child.style, {
|
105 |
-
position: "relative",
|
106 |
-
transform: "none",
|
107 |
-
transition: "none",
|
108 |
-
marginLeft: "0",
|
109 |
-
marginRight: index < arr.length - 1 ? "16px" : "16px", // Garder une marge à droite même pour le dernier
|
110 |
-
});
|
111 |
-
}
|
112 |
-
);
|
113 |
-
}
|
114 |
-
},
|
115 |
-
});
|
116 |
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
if (original) {
|
121 |
-
el.style.cssText = original.style;
|
122 |
-
el.scrollLeft = original.scroll.left;
|
123 |
-
el.scrollTop = original.scroll.top;
|
124 |
-
}
|
125 |
-
});
|
126 |
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
|
|
|
|
133 |
|
134 |
const downloadStoryImage = useCallback(
|
135 |
async (containerRef, filename = "my-story.png") => {
|
136 |
-
const
|
137 |
-
if (!
|
138 |
|
139 |
const link = document.createElement("a");
|
140 |
-
link.href =
|
141 |
link.download = filename;
|
142 |
document.body.appendChild(link);
|
143 |
link.click();
|
|
|
1 |
import { useCallback } from "react";
|
2 |
+
import { useScreenshot } from "use-react-screenshot";
|
3 |
|
4 |
export function useStoryCapture() {
|
5 |
+
const [image, takeScreenshot] = useScreenshot({
|
6 |
+
type: "image/png",
|
7 |
+
quality: 1.0,
|
8 |
+
});
|
9 |
|
10 |
+
const captureStory = useCallback(
|
11 |
+
async (containerRef) => {
|
12 |
+
if (!containerRef.current) return null;
|
13 |
+
|
14 |
+
const element = containerRef.current.querySelector("[data-comic-layout]");
|
15 |
+
if (!element) {
|
16 |
console.error("Comic layout container not found");
|
17 |
return null;
|
18 |
}
|
19 |
|
20 |
+
try {
|
21 |
+
// Save original styles
|
22 |
+
const originalStyle = element.style.cssText;
|
23 |
+
const originalScroll = element.scrollLeft;
|
24 |
+
|
25 |
+
// Reset scroll and padding temporarily for the screenshot
|
26 |
+
Object.assign(element.style, {
|
27 |
+
paddingLeft: "0",
|
28 |
+
paddingRight: "0",
|
29 |
+
width: `${element.scrollWidth - 350}px`, // Reduce width by choices panel
|
30 |
+
display: "flex",
|
31 |
+
flexDirection: "row",
|
32 |
+
gap: "32px",
|
33 |
+
padding: "32px",
|
34 |
+
overflow: "hidden",
|
35 |
+
});
|
36 |
+
element.scrollLeft = 0;
|
37 |
+
|
38 |
+
// Force reflow
|
39 |
+
element.offsetHeight;
|
40 |
+
|
41 |
+
// Take screenshot
|
42 |
+
const result = await takeScreenshot(element, {
|
43 |
+
backgroundColor: "#242424",
|
44 |
+
width: element.offsetWidth,
|
45 |
+
height: element.scrollHeight,
|
46 |
+
style: {
|
47 |
+
transform: "none",
|
48 |
+
transition: "none",
|
49 |
+
},
|
50 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
|
52 |
+
// Restore original styles
|
53 |
+
element.style.cssText = originalStyle;
|
54 |
+
element.scrollLeft = originalScroll;
|
|
|
|
|
|
|
|
|
|
|
|
|
55 |
|
56 |
+
return result;
|
57 |
+
} catch (error) {
|
58 |
+
console.error("Error capturing story:", error);
|
59 |
+
return null;
|
60 |
+
}
|
61 |
+
},
|
62 |
+
[takeScreenshot]
|
63 |
+
);
|
64 |
|
65 |
const downloadStoryImage = useCallback(
|
66 |
async (containerRef, filename = "my-story.png") => {
|
67 |
+
const image = await captureStory(containerRef);
|
68 |
+
if (!image) return;
|
69 |
|
70 |
const link = document.createElement("a");
|
71 |
+
link.href = image;
|
72 |
link.download = filename;
|
73 |
document.body.appendChild(link);
|
74 |
link.click();
|
client/src/hooks/useWritingSound.js
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useSound } from "use-sound";
|
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() {
|
10 |
+
const [soundsLoaded, setSoundsLoaded] = useState(false);
|
11 |
+
|
12 |
+
// Créer un tableau de hooks useSound pour chaque son
|
13 |
+
const sounds = PAGE_SOUNDS.map((soundPath) => {
|
14 |
+
const [play, { sound }] = useSound(soundPath, {
|
15 |
+
volume: 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 playRandomPageSound = () => {
|
32 |
+
if (!soundsLoaded) {
|
33 |
+
console.warn("Page sounds not loaded yet");
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
|
37 |
+
const randomIndex = Math.floor(Math.random() * sounds.length);
|
38 |
+
try {
|
39 |
+
sounds[randomIndex].play();
|
40 |
+
} catch (error) {
|
41 |
+
console.error("Error playing page sound:", error);
|
42 |
+
}
|
43 |
+
};
|
44 |
+
|
45 |
+
return playRandomPageSound;
|
46 |
+
}
|
client/src/layouts/ComicLayout.jsx
CHANGED
@@ -22,6 +22,15 @@ function ComicPage({
|
|
22 |
return total + (segment.images?.length || 0);
|
23 |
}, 0);
|
24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
return (
|
26 |
<Box
|
27 |
sx={{
|
@@ -29,6 +38,7 @@ function ComicPage({
|
|
29 |
flexDirection: "row",
|
30 |
gap: 2,
|
31 |
height: "100%",
|
|
|
32 |
}}
|
33 |
>
|
34 |
<Box
|
@@ -90,36 +100,32 @@ function ComicPage({
|
|
90 |
{layoutIndex + 1}
|
91 |
</Box>
|
92 |
</Box>
|
93 |
-
{isLastPage && (
|
94 |
-
<Box
|
95 |
-
{
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
onChoice={onChoice}
|
120 |
-
disabled={isLoading}
|
121 |
-
/>
|
122 |
-
)}
|
123 |
</Box>
|
124 |
)}
|
125 |
</Box>
|
@@ -137,15 +143,40 @@ export function ComicLayout({
|
|
137 |
}) {
|
138 |
const scrollContainerRef = useRef(null);
|
139 |
|
140 |
-
// Effect to scroll to the right when
|
141 |
useEffect(() => {
|
142 |
-
|
|
|
|
|
143 |
scrollContainerRef.current.scrollTo({
|
144 |
left: scrollContainerRef.current.scrollWidth,
|
145 |
behavior: "smooth",
|
146 |
});
|
147 |
}
|
148 |
-
}, [segments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
149 |
|
150 |
// Filtrer les segments qui sont en cours de chargement
|
151 |
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
@@ -162,6 +193,7 @@ export function ComicLayout({
|
|
162 |
height: "100%",
|
163 |
width: "100%",
|
164 |
px: layouts[0]?.type === "COVER" ? "calc(50% - (90vh * 0.5 * 0.5))" : 0,
|
|
|
165 |
overflowX: "auto",
|
166 |
overflowY: "hidden",
|
167 |
"&::-webkit-scrollbar": {
|
|
|
22 |
return total + (segment.images?.length || 0);
|
23 |
}, 0);
|
24 |
|
25 |
+
console.log("ComicPage layout:", {
|
26 |
+
type: layout.type,
|
27 |
+
totalImages,
|
28 |
+
segments: layout.segments,
|
29 |
+
isLastPage,
|
30 |
+
hasChoices: choices?.length > 0,
|
31 |
+
showScreenshot,
|
32 |
+
});
|
33 |
+
|
34 |
return (
|
35 |
<Box
|
36 |
sx={{
|
|
|
38 |
flexDirection: "row",
|
39 |
gap: 2,
|
40 |
height: "100%",
|
41 |
+
position: "relative",
|
42 |
}}
|
43 |
>
|
44 |
<Box
|
|
|
100 |
{layoutIndex + 1}
|
101 |
</Box>
|
102 |
</Box>
|
103 |
+
{isLastPage && (
|
104 |
+
<Box
|
105 |
+
sx={{
|
106 |
+
position: "absolute",
|
107 |
+
left: "100%",
|
108 |
+
top: "50%",
|
109 |
+
transform: "translateY(-50%)",
|
110 |
+
display: "flex",
|
111 |
+
flexDirection: "column",
|
112 |
+
gap: 2,
|
113 |
+
width: "350px",
|
114 |
+
ml: 4,
|
115 |
+
}}
|
116 |
+
>
|
117 |
+
<StoryChoices
|
118 |
+
choices={choices}
|
119 |
+
onChoice={onChoice}
|
120 |
+
disabled={isLoading}
|
121 |
+
isLastStep={
|
122 |
+
layout.segments[layout.segments.length - 1]?.is_last_step
|
123 |
+
}
|
124 |
+
isGameOver={
|
125 |
+
layout.segments[layout.segments.length - 1]?.isDeath ||
|
126 |
+
layout.segments[layout.segments.length - 1]?.isVictory
|
127 |
+
}
|
128 |
+
/>
|
|
|
|
|
|
|
|
|
129 |
</Box>
|
130 |
)}
|
131 |
</Box>
|
|
|
143 |
}) {
|
144 |
const scrollContainerRef = useRef(null);
|
145 |
|
146 |
+
// Effect to scroll to the right when segments are loaded
|
147 |
useEffect(() => {
|
148 |
+
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
149 |
+
// Scroll à droite seulement si on a au moins un segment chargé
|
150 |
+
if (scrollContainerRef.current && loadedSegments.length > 0) {
|
151 |
scrollContainerRef.current.scrollTo({
|
152 |
left: scrollContainerRef.current.scrollWidth,
|
153 |
behavior: "smooth",
|
154 |
});
|
155 |
}
|
156 |
+
}, [segments]); // Se déclenche à chaque modification des segments
|
157 |
+
|
158 |
+
// Prevent back/forward navigation on trackpad horizontal scroll
|
159 |
+
useEffect(() => {
|
160 |
+
const container = scrollContainerRef.current;
|
161 |
+
if (!container) return;
|
162 |
+
|
163 |
+
const handleWheel = (e) => {
|
164 |
+
const max = container.scrollWidth - container.offsetWidth;
|
165 |
+
if (
|
166 |
+
container.scrollLeft + e.deltaX < 0 ||
|
167 |
+
container.scrollLeft + e.deltaX > max
|
168 |
+
) {
|
169 |
+
e.preventDefault();
|
170 |
+
container.scrollLeft = Math.max(
|
171 |
+
0,
|
172 |
+
Math.min(max, container.scrollLeft + e.deltaX)
|
173 |
+
);
|
174 |
+
}
|
175 |
+
};
|
176 |
+
|
177 |
+
container.addEventListener("wheel", handleWheel, { passive: false });
|
178 |
+
return () => container.removeEventListener("wheel", handleWheel);
|
179 |
+
}, []);
|
180 |
|
181 |
// Filtrer les segments qui sont en cours de chargement
|
182 |
const loadedSegments = segments.filter((segment) => !segment.isLoading);
|
|
|
193 |
height: "100%",
|
194 |
width: "100%",
|
195 |
px: layouts[0]?.type === "COVER" ? "calc(50% - (90vh * 0.5 * 0.5))" : 0,
|
196 |
+
py: 8, // 24px de padding vertical
|
197 |
overflowX: "auto",
|
198 |
overflowY: "hidden",
|
199 |
"&::-webkit-scrollbar": {
|
client/src/layouts/config.js
CHANGED
@@ -1,50 +1,7 @@
|
|
1 |
-
// Layout settings for different types
|
2 |
-
// export const LAYOUTS = {
|
3 |
-
// COVER: {
|
4 |
-
// gridCols: 1,
|
5 |
-
// gridRows: 1,
|
6 |
-
// panels: [
|
7 |
-
// { width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
|
8 |
-
// ],
|
9 |
-
// },
|
10 |
-
// LAYOUT_1: {
|
11 |
-
// gridCols: 1,
|
12 |
-
// gridRows: 1,
|
13 |
-
// panels: [
|
14 |
-
// { width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
|
15 |
-
// ],
|
16 |
-
// },
|
17 |
-
// LAYOUT_2: {
|
18 |
-
// gridCols: 1,
|
19 |
-
// gridRows: 1,
|
20 |
-
// panels: [
|
21 |
-
// { width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
|
22 |
-
// ],
|
23 |
-
// },
|
24 |
-
// LAYOUT_3: {
|
25 |
-
// gridCols: 1,
|
26 |
-
// gridRows: 1,
|
27 |
-
// panels: [
|
28 |
-
// { width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
|
29 |
-
// ],
|
30 |
-
// },
|
31 |
-
// LAYOUT_4: {
|
32 |
-
// gridCols: 1,
|
33 |
-
// gridRows: 1,
|
34 |
-
// panels: [
|
35 |
-
// { width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
|
36 |
-
// ],
|
37 |
-
// },
|
38 |
-
// };
|
39 |
-
|
40 |
// Panel size constants
|
41 |
-
const PANEL_SIZES = {
|
42 |
-
PORTRAIT: { width: 512, height:
|
43 |
-
|
44 |
-
LANDSCAPE: { width: 1024, height: 768 },
|
45 |
-
SQUARE: { width: 512, height: 512 },
|
46 |
-
SQUARE_LARGE: { width: 1024, height: 1024 },
|
47 |
-
PANORAMIC: { width: 1024, height: 512 },
|
48 |
COVER_SIZE: { width: 512, height: 768 },
|
49 |
};
|
50 |
|
@@ -69,9 +26,9 @@ export const LAYOUTS = {
|
|
69 |
gridRows: 2,
|
70 |
panels: [
|
71 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "1" }, // Landscape top left
|
72 |
-
{ ...PANEL_SIZES.
|
73 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "2" }, // Landscape middle left
|
74 |
-
{ ...PANEL_SIZES.
|
75 |
],
|
76 |
},
|
77 |
LAYOUT_2: {
|
@@ -79,7 +36,7 @@ export const LAYOUTS = {
|
|
79 |
gridRows: 2,
|
80 |
panels: [
|
81 |
{
|
82 |
-
...PANEL_SIZES.
|
83 |
gridColumn: GRID.TWO_THIRDS,
|
84 |
gridRow: "1",
|
85 |
}, // Large square top left
|
@@ -101,37 +58,35 @@ export const LAYOUTS = {
|
|
101 |
gridCols: 2,
|
102 |
gridRows: 3,
|
103 |
panels: [
|
104 |
-
{ ...PANEL_SIZES.
|
105 |
{
|
106 |
...PANEL_SIZES.PORTRAIT,
|
107 |
gridColumn: "1",
|
108 |
gridRow: GRID.FULL_HEIGHT_FROM_2,
|
109 |
}, // Tall portrait left
|
110 |
-
{ ...PANEL_SIZES.
|
111 |
-
{ ...PANEL_SIZES.
|
112 |
],
|
113 |
},
|
114 |
LAYOUT_5: {
|
115 |
gridCols: 3,
|
116 |
gridRows: 3,
|
117 |
panels: [
|
118 |
-
{ ...PANEL_SIZES.
|
119 |
{ ...PANEL_SIZES.PORTRAIT, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
120 |
{
|
121 |
-
...PANEL_SIZES.
|
122 |
gridColumn: "2 / span 2",
|
123 |
gridRow: "2 / span 2",
|
124 |
}, // Large square right
|
125 |
],
|
126 |
},
|
127 |
-
|
128 |
-
gridCols:
|
129 |
gridRows: 2,
|
130 |
panels: [
|
131 |
-
{ ...PANEL_SIZES.
|
132 |
-
{ ...PANEL_SIZES.
|
133 |
-
{ ...PANEL_SIZES.PORTRAIT, gridColumn: "3", gridRow: GRID.FULL_HEIGHT }, // Tall portrait right
|
134 |
-
{ ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "2" }, // Square bottom middle
|
135 |
],
|
136 |
},
|
137 |
};
|
@@ -144,9 +99,9 @@ export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
|
|
144 |
// Grouper les layouts par nombre de panneaux
|
145 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
146 |
1: ["COVER"],
|
147 |
-
2: ["
|
148 |
3: ["LAYOUT_5"], // Layouts avec exactement 3 panneaux
|
149 |
-
4: ["LAYOUT_3"
|
150 |
};
|
151 |
|
152 |
// Helper functions for layout configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
// Panel size constants
|
2 |
+
export const PANEL_SIZES = {
|
3 |
+
PORTRAIT: { width: 512, height: 768 },
|
4 |
+
LANDSCAPE: { width: 768, height: 512 },
|
|
|
|
|
|
|
|
|
5 |
COVER_SIZE: { width: 512, height: 768 },
|
6 |
};
|
7 |
|
|
|
26 |
gridRows: 2,
|
27 |
panels: [
|
28 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "1" }, // Landscape top left
|
29 |
+
{ ...PANEL_SIZES.PORTRAIT, gridColumn: "2", gridRow: "1" }, // Portrait top right
|
30 |
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "2" }, // Landscape middle left
|
31 |
+
{ ...PANEL_SIZES.PORTRAIT, gridColumn: "2", gridRow: "2" }, // Portrait right
|
32 |
],
|
33 |
},
|
34 |
LAYOUT_2: {
|
|
|
36 |
gridRows: 2,
|
37 |
panels: [
|
38 |
{
|
39 |
+
...PANEL_SIZES.LANDSCAPE,
|
40 |
gridColumn: GRID.TWO_THIRDS,
|
41 |
gridRow: "1",
|
42 |
}, // Large square top left
|
|
|
58 |
gridCols: 2,
|
59 |
gridRows: 3,
|
60 |
panels: [
|
61 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
|
62 |
{
|
63 |
...PANEL_SIZES.PORTRAIT,
|
64 |
gridColumn: "1",
|
65 |
gridRow: GRID.FULL_HEIGHT_FROM_2,
|
66 |
}, // Tall portrait left
|
67 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "2", gridRow: "2" }, // Square middle right
|
68 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: "2", gridRow: "3" }, // Square bottom right
|
69 |
],
|
70 |
},
|
71 |
LAYOUT_5: {
|
72 |
gridCols: 3,
|
73 |
gridRows: 3,
|
74 |
panels: [
|
75 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
|
76 |
{ ...PANEL_SIZES.PORTRAIT, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
77 |
{
|
78 |
+
...PANEL_SIZES.LANDSCAPE,
|
79 |
gridColumn: "2 / span 2",
|
80 |
gridRow: "2 / span 2",
|
81 |
}, // Large square right
|
82 |
],
|
83 |
},
|
84 |
+
LAYOUT_7: {
|
85 |
+
gridCols: 1,
|
86 |
gridRows: 2,
|
87 |
panels: [
|
88 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Portrait top right
|
89 |
+
{ ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "2" }, // Full width landscape bottom
|
|
|
|
|
90 |
],
|
91 |
},
|
92 |
};
|
|
|
99 |
// Grouper les layouts par nombre de panneaux
|
100 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
101 |
1: ["COVER"],
|
102 |
+
2: ["LAYOUT_7"], // Layouts avec exactement 2 panneaux
|
103 |
3: ["LAYOUT_5"], // Layouts avec exactement 3 panneaux
|
104 |
+
4: ["LAYOUT_3"], // Layouts avec exactement 4 panneaux
|
105 |
};
|
106 |
|
107 |
// Helper functions for layout configuration
|
client/src/main.jsx
CHANGED
@@ -6,7 +6,7 @@ import CssBaseline from "@mui/material/CssBaseline";
|
|
6 |
import { theme } from "./theme";
|
7 |
import { Home } from "./pages/Home";
|
8 |
import { Game } from "./pages/Game";
|
9 |
-
import { Tutorial } from "./pages/
|
10 |
import "./index.css";
|
11 |
|
12 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
|
6 |
import { theme } from "./theme";
|
7 |
import { Home } from "./pages/Home";
|
8 |
import { Game } from "./pages/Game";
|
9 |
+
import { Tutorial } from "./pages/Tutorial";
|
10 |
import "./index.css";
|
11 |
|
12 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
client/src/pages/Game.jsx
CHANGED
@@ -4,10 +4,14 @@ import { ComicLayout } from "../layouts/ComicLayout";
|
|
4 |
import { storyApi } from "../utils/api";
|
5 |
import { useNarrator } from "../hooks/useNarrator";
|
6 |
import { useStoryCapture } from "../hooks/useStoryCapture";
|
|
|
|
|
7 |
import { StoryChoices } from "../components/StoryChoices";
|
|
|
8 |
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
9 |
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
|
10 |
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
|
|
11 |
|
12 |
// Constants
|
13 |
const NARRATION_ENABLED_KEY = "narration_enabled";
|
@@ -35,6 +39,8 @@ export function Game() {
|
|
35 |
const [storySegments, setStorySegments] = useState([]);
|
36 |
const [currentChoices, setCurrentChoices] = useState([]);
|
37 |
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
38 |
const [isNarrationEnabled, setIsNarrationEnabled] = useState(() => {
|
39 |
// Initialiser depuis le localStorage avec true comme valeur par défaut
|
40 |
const stored = localStorage.getItem(NARRATION_ENABLED_KEY);
|
@@ -42,6 +48,8 @@ export function Game() {
|
|
42 |
});
|
43 |
const { isNarratorSpeaking, playNarration, stopNarration } =
|
44 |
useNarrator(isNarrationEnabled);
|
|
|
|
|
45 |
|
46 |
// Sauvegarder l'état de la narration dans le localStorage
|
47 |
useEffect(() => {
|
@@ -54,6 +62,9 @@ export function Game() {
|
|
54 |
}, []);
|
55 |
|
56 |
const handleChoice = async (choiceId) => {
|
|
|
|
|
|
|
57 |
// Si c'est l'option "Réessayer", on relance la dernière action
|
58 |
if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") {
|
59 |
// Supprimer le segment d'erreur
|
@@ -84,6 +95,8 @@ export function Game() {
|
|
84 |
|
85 |
const handleStoryAction = async (action, choiceId = null) => {
|
86 |
setIsLoading(true);
|
|
|
|
|
87 |
try {
|
88 |
// Stop any ongoing narration
|
89 |
if (isNarratorSpeaking) {
|
@@ -114,6 +127,8 @@ export function Game() {
|
|
114 |
isLoading: true, // Ajout d'un flag pour indiquer que le segment est en cours de chargement
|
115 |
};
|
116 |
|
|
|
|
|
117 |
// 3. Update segments
|
118 |
if (action === "restart") {
|
119 |
setStorySegments([newSegment]);
|
@@ -133,7 +148,7 @@ export function Game() {
|
|
133 |
"Starting image generation for prompts:",
|
134 |
storyData.image_prompts
|
135 |
);
|
136 |
-
generateImagesForStory(
|
137 |
storyData.image_prompts,
|
138 |
action === "restart" ? 0 : storySegments.length,
|
139 |
action === "restart" ? [newSegment] : [...storySegments, newSegment]
|
@@ -147,6 +162,9 @@ export function Game() {
|
|
147 |
setStorySegments((prev) => [...prev.slice(0, -1), updatedSegment]);
|
148 |
}
|
149 |
}
|
|
|
|
|
|
|
150 |
} catch (error) {
|
151 |
console.error("Error in handleStoryAction:", error);
|
152 |
const errorMessage =
|
@@ -154,32 +172,8 @@ export function Game() {
|
|
154 |
error.message ||
|
155 |
"Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...";
|
156 |
|
157 |
-
|
158 |
-
|
159 |
-
rawText: errorMessage,
|
160 |
-
isChoice: false,
|
161 |
-
isDeath: false,
|
162 |
-
isVictory: false,
|
163 |
-
radiationLevel:
|
164 |
-
storySegments.length > 0
|
165 |
-
? storySegments[storySegments.length - 1].radiationLevel
|
166 |
-
: 0,
|
167 |
-
images: [],
|
168 |
-
isLoading: false,
|
169 |
-
};
|
170 |
-
|
171 |
-
if (action === "restart") {
|
172 |
-
setStorySegments([errorSegment]);
|
173 |
-
} else {
|
174 |
-
// En cas d'erreur sur un choix, on garde le segment précédent
|
175 |
-
setStorySegments((prev) => [...prev.slice(0, -1), errorSegment]);
|
176 |
-
}
|
177 |
-
|
178 |
-
// Set retry choice
|
179 |
-
setCurrentChoices([{ id: "retry", text: "Réessayer" }]);
|
180 |
-
|
181 |
-
// Play error message
|
182 |
-
await playNarration(errorSegment.rawText);
|
183 |
} finally {
|
184 |
setIsLoading(false);
|
185 |
}
|
@@ -195,6 +189,12 @@ export function Game() {
|
|
195 |
const images = Array(imagePrompts.length).fill(null);
|
196 |
let allImagesGenerated = false;
|
197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
198 |
for (
|
199 |
let promptIndex = 0;
|
200 |
promptIndex < imagePrompts.length;
|
@@ -204,13 +204,19 @@ export function Game() {
|
|
204 |
const maxRetries = 3;
|
205 |
let success = false;
|
206 |
|
|
|
|
|
|
|
|
|
207 |
while (retryCount < maxRetries && !success) {
|
208 |
try {
|
209 |
console.log(
|
210 |
`Generating image ${promptIndex + 1}/${imagePrompts.length}`
|
211 |
);
|
212 |
const result = await storyApi.generateImage(
|
213 |
-
imagePrompts[promptIndex]
|
|
|
|
|
214 |
);
|
215 |
|
216 |
if (!result) {
|
@@ -261,11 +267,6 @@ export function Game() {
|
|
261 |
}
|
262 |
};
|
263 |
|
264 |
-
// Filter out choice segments for display
|
265 |
-
const nonChoiceSegments = storySegments.filter(
|
266 |
-
(segment) => !segment.isChoice
|
267 |
-
);
|
268 |
-
|
269 |
const handleCaptureStory = async () => {
|
270 |
await downloadStoryImage(
|
271 |
storyContainerRef,
|
@@ -275,94 +276,112 @@ export function Game() {
|
|
275 |
|
276 |
return (
|
277 |
<Box
|
|
|
278 |
sx={{
|
279 |
height: "100vh",
|
280 |
-
width: "
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
}}
|
285 |
>
|
286 |
-
|
287 |
-
|
288 |
-
position: "relative",
|
289 |
-
height: "100%",
|
290 |
-
display: "flex",
|
291 |
-
flexDirection: "column",
|
292 |
-
backgroundColor: "#121212",
|
293 |
-
}}
|
294 |
-
>
|
295 |
-
{/* Narration control - always visible in top right */}
|
296 |
-
<Box
|
297 |
sx={{
|
298 |
-
position: "
|
299 |
-
top:
|
300 |
-
|
|
|
301 |
zIndex: 1000,
|
302 |
}}
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
309 |
}
|
310 |
-
>
|
311 |
-
<IconButton
|
312 |
-
onClick={() => setIsNarrationEnabled(!isNarrationEnabled)}
|
313 |
-
sx={{
|
314 |
-
backgroundColor: isNarrationEnabled
|
315 |
-
? "primary.main"
|
316 |
-
: "rgba(255, 255, 255, 0.1)",
|
317 |
-
color: "white",
|
318 |
-
"&:hover": {
|
319 |
-
backgroundColor: isNarrationEnabled
|
320 |
-
? "primary.dark"
|
321 |
-
: "rgba(255, 255, 255, 0.2)",
|
322 |
-
},
|
323 |
-
}}
|
324 |
-
>
|
325 |
-
{isNarrationEnabled ? <VolumeUpIcon /> : <VolumeOffIcon />}
|
326 |
-
</IconButton>
|
327 |
-
</Tooltip>
|
328 |
-
</Box>
|
329 |
-
|
330 |
-
{/* Progress bar */}
|
331 |
-
{isLoading && (
|
332 |
-
<LinearProgress
|
333 |
-
sx={{
|
334 |
-
position: "absolute",
|
335 |
-
top: 0,
|
336 |
-
left: 0,
|
337 |
-
right: 0,
|
338 |
-
zIndex: 1,
|
339 |
-
}}
|
340 |
-
/>
|
341 |
-
)}
|
342 |
-
|
343 |
-
{/* Comic layout */}
|
344 |
-
<Box
|
345 |
-
ref={storyContainerRef}
|
346 |
-
sx={{
|
347 |
-
flex: 1,
|
348 |
-
overflow: "hidden",
|
349 |
-
position: "relative",
|
350 |
-
p: 4,
|
351 |
}}
|
352 |
-
|
|
|
|
|
353 |
<ComicLayout
|
354 |
segments={storySegments}
|
355 |
-
choices={currentChoices}
|
356 |
onChoice={handleChoice}
|
357 |
-
isLoading={isLoading
|
358 |
-
showScreenshot={
|
359 |
-
|
360 |
-
currentChoices[0].text === "Réessayer"
|
361 |
-
}
|
362 |
-
onScreenshot={() => downloadStoryImage(storyContainerRef)}
|
363 |
/>
|
364 |
-
|
365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
366 |
</Box>
|
367 |
);
|
368 |
}
|
|
|
4 |
import { storyApi } from "../utils/api";
|
5 |
import { useNarrator } from "../hooks/useNarrator";
|
6 |
import { useStoryCapture } from "../hooks/useStoryCapture";
|
7 |
+
import { usePageSound } from "../hooks/usePageSound";
|
8 |
+
import { useWritingSound } from "../hooks/useWritingSound";
|
9 |
import { StoryChoices } from "../components/StoryChoices";
|
10 |
+
import { ErrorDisplay } from "../components/ErrorDisplay";
|
11 |
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
12 |
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
|
13 |
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
14 |
+
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
15 |
|
16 |
// Constants
|
17 |
const NARRATION_ENABLED_KEY = "narration_enabled";
|
|
|
39 |
const [storySegments, setStorySegments] = useState([]);
|
40 |
const [currentChoices, setCurrentChoices] = useState([]);
|
41 |
const [isLoading, setIsLoading] = useState(false);
|
42 |
+
const [showChoices, setShowChoices] = useState(true);
|
43 |
+
const [error, setError] = useState(null);
|
44 |
const [isNarrationEnabled, setIsNarrationEnabled] = useState(() => {
|
45 |
// Initialiser depuis le localStorage avec true comme valeur par défaut
|
46 |
const stored = localStorage.getItem(NARRATION_ENABLED_KEY);
|
|
|
48 |
});
|
49 |
const { isNarratorSpeaking, playNarration, stopNarration } =
|
50 |
useNarrator(isNarrationEnabled);
|
51 |
+
const playPageSound = usePageSound();
|
52 |
+
const playWritingSound = useWritingSound();
|
53 |
|
54 |
// Sauvegarder l'état de la narration dans le localStorage
|
55 |
useEffect(() => {
|
|
|
62 |
}, []);
|
63 |
|
64 |
const handleChoice = async (choiceId) => {
|
65 |
+
playPageSound();
|
66 |
+
|
67 |
+
setShowChoices(false); // Cacher les choix dès qu'on clique
|
68 |
// Si c'est l'option "Réessayer", on relance la dernière action
|
69 |
if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") {
|
70 |
// Supprimer le segment d'erreur
|
|
|
95 |
|
96 |
const handleStoryAction = async (action, choiceId = null) => {
|
97 |
setIsLoading(true);
|
98 |
+
setShowChoices(false);
|
99 |
+
setError(null); // Reset error state
|
100 |
try {
|
101 |
// Stop any ongoing narration
|
102 |
if (isNarratorSpeaking) {
|
|
|
127 |
isLoading: true, // Ajout d'un flag pour indiquer que le segment est en cours de chargement
|
128 |
};
|
129 |
|
130 |
+
playWritingSound();
|
131 |
+
|
132 |
// 3. Update segments
|
133 |
if (action === "restart") {
|
134 |
setStorySegments([newSegment]);
|
|
|
148 |
"Starting image generation for prompts:",
|
149 |
storyData.image_prompts
|
150 |
);
|
151 |
+
await generateImagesForStory(
|
152 |
storyData.image_prompts,
|
153 |
action === "restart" ? 0 : storySegments.length,
|
154 |
action === "restart" ? [newSegment] : [...storySegments, newSegment]
|
|
|
162 |
setStorySegments((prev) => [...prev.slice(0, -1), updatedSegment]);
|
163 |
}
|
164 |
}
|
165 |
+
|
166 |
+
// Réafficher les choix une fois tout chargé
|
167 |
+
setShowChoices(true);
|
168 |
} catch (error) {
|
169 |
console.error("Error in handleStoryAction:", error);
|
170 |
const errorMessage =
|
|
|
172 |
error.message ||
|
173 |
"Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...";
|
174 |
|
175 |
+
setError(errorMessage);
|
176 |
+
await playNarration(errorMessage);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
} finally {
|
178 |
setIsLoading(false);
|
179 |
}
|
|
|
189 |
const images = Array(imagePrompts.length).fill(null);
|
190 |
let allImagesGenerated = false;
|
191 |
|
192 |
+
// Déterminer le layout en fonction du nombre d'images
|
193 |
+
const layoutType = getNextLayoutType(0, imagePrompts.length);
|
194 |
+
console.log(
|
195 |
+
`Using layout ${layoutType} for ${imagePrompts.length} images`
|
196 |
+
);
|
197 |
+
|
198 |
for (
|
199 |
let promptIndex = 0;
|
200 |
promptIndex < imagePrompts.length;
|
|
|
204 |
const maxRetries = 3;
|
205 |
let success = false;
|
206 |
|
207 |
+
// Obtenir les dimensions pour ce panneau
|
208 |
+
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
209 |
+
console.log(`Panel ${promptIndex} dimensions:`, panelDimensions);
|
210 |
+
|
211 |
while (retryCount < maxRetries && !success) {
|
212 |
try {
|
213 |
console.log(
|
214 |
`Generating image ${promptIndex + 1}/${imagePrompts.length}`
|
215 |
);
|
216 |
const result = await storyApi.generateImage(
|
217 |
+
imagePrompts[promptIndex],
|
218 |
+
panelDimensions.width,
|
219 |
+
panelDimensions.height
|
220 |
);
|
221 |
|
222 |
if (!result) {
|
|
|
267 |
}
|
268 |
};
|
269 |
|
|
|
|
|
|
|
|
|
|
|
270 |
const handleCaptureStory = async () => {
|
271 |
await downloadStoryImage(
|
272 |
storyContainerRef,
|
|
|
276 |
|
277 |
return (
|
278 |
<Box
|
279 |
+
ref={storyContainerRef}
|
280 |
sx={{
|
281 |
height: "100vh",
|
282 |
+
width: "100vw",
|
283 |
+
backgroundColor: "#1a1a1a",
|
284 |
+
position: "relative",
|
285 |
+
overflow: "hidden",
|
286 |
}}
|
287 |
>
|
288 |
+
{isLoading && (
|
289 |
+
<LinearProgress
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
290 |
sx={{
|
291 |
+
position: "absolute",
|
292 |
+
top: 0,
|
293 |
+
left: 0,
|
294 |
+
right: 0,
|
295 |
zIndex: 1000,
|
296 |
}}
|
297 |
+
/>
|
298 |
+
)}
|
299 |
+
|
300 |
+
{error ? (
|
301 |
+
<ErrorDisplay
|
302 |
+
message={error}
|
303 |
+
onRetry={() => {
|
304 |
+
if (storySegments.length === 0) {
|
305 |
+
handleStoryAction("restart");
|
306 |
+
} else {
|
307 |
+
handleStoryAction(
|
308 |
+
"choice",
|
309 |
+
storySegments[storySegments.length - 1]?.choiceId || null
|
310 |
+
);
|
311 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
312 |
}}
|
313 |
+
/>
|
314 |
+
) : (
|
315 |
+
<>
|
316 |
<ComicLayout
|
317 |
segments={storySegments}
|
318 |
+
choices={showChoices ? currentChoices : []}
|
319 |
onChoice={handleChoice}
|
320 |
+
isLoading={isLoading}
|
321 |
+
showScreenshot={storySegments.length > 0}
|
322 |
+
onScreenshot={handleCaptureStory}
|
|
|
|
|
|
|
323 |
/>
|
324 |
+
{showChoices && (
|
325 |
+
<StoryChoices
|
326 |
+
choices={currentChoices}
|
327 |
+
onChoice={handleChoice}
|
328 |
+
disabled={isLoading}
|
329 |
+
isLastStep={
|
330 |
+
storySegments.length > 0 &&
|
331 |
+
storySegments[storySegments.length - 1].isLastStep
|
332 |
+
}
|
333 |
+
isGameOver={
|
334 |
+
storySegments.length > 0 &&
|
335 |
+
storySegments[storySegments.length - 1].isGameOver
|
336 |
+
}
|
337 |
+
containerRef={storyContainerRef}
|
338 |
+
/>
|
339 |
+
)}
|
340 |
+
<Box
|
341 |
+
sx={{
|
342 |
+
position: "fixed",
|
343 |
+
top: 16,
|
344 |
+
right: 16,
|
345 |
+
display: "flex",
|
346 |
+
gap: 1,
|
347 |
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
348 |
+
padding: 1,
|
349 |
+
borderRadius: 1,
|
350 |
+
}}
|
351 |
+
>
|
352 |
+
<Tooltip title="Take a screenshot">
|
353 |
+
<IconButton
|
354 |
+
onClick={handleCaptureStory}
|
355 |
+
sx={{
|
356 |
+
color: "white",
|
357 |
+
"&:hover": {
|
358 |
+
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
359 |
+
},
|
360 |
+
}}
|
361 |
+
>
|
362 |
+
<PhotoCameraIcon />
|
363 |
+
</IconButton>
|
364 |
+
</Tooltip>
|
365 |
+
<Tooltip
|
366 |
+
title={
|
367 |
+
isNarrationEnabled ? "Disable narration" : "Enable narration"
|
368 |
+
}
|
369 |
+
>
|
370 |
+
<IconButton
|
371 |
+
onClick={() => setIsNarrationEnabled(!isNarrationEnabled)}
|
372 |
+
sx={{
|
373 |
+
color: "white",
|
374 |
+
"&:hover": {
|
375 |
+
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
376 |
+
},
|
377 |
+
}}
|
378 |
+
>
|
379 |
+
{isNarrationEnabled ? <VolumeUpIcon /> : <VolumeOffIcon />}
|
380 |
+
</IconButton>
|
381 |
+
</Tooltip>
|
382 |
+
</Box>
|
383 |
+
</>
|
384 |
+
)}
|
385 |
</Box>
|
386 |
);
|
387 |
}
|
client/src/pages/{tutorial/Tutorial.jsx → Tutorial.jsx}
RENAMED
@@ -52,10 +52,6 @@ export function Tutorial() {
|
|
52 |
tone to help her understand the urgency of climate change, while
|
53 |
maintaining your close relationship.
|
54 |
</Typography>
|
55 |
-
-
|
56 |
-
<Typography variant="body1" paragraph>
|
57 |
-
{"Oh and remember, be kind with the game, do not spam buttons. <3"}
|
58 |
-
</Typography>
|
59 |
</Box>
|
60 |
|
61 |
<Button
|
|
|
52 |
tone to help her understand the urgency of climate change, while
|
53 |
maintaining your close relationship.
|
54 |
</Typography>
|
|
|
|
|
|
|
|
|
55 |
</Box>
|
56 |
|
57 |
<Button
|
client/src/pages/game/App.jsx
DELETED
@@ -1,794 +0,0 @@
|
|
1 |
-
import { useState, useEffect, useRef } from "react";
|
2 |
-
import {
|
3 |
-
Container,
|
4 |
-
Paper,
|
5 |
-
Button,
|
6 |
-
Box,
|
7 |
-
Typography,
|
8 |
-
LinearProgress,
|
9 |
-
Chip,
|
10 |
-
IconButton,
|
11 |
-
Tooltip,
|
12 |
-
} from "@mui/material";
|
13 |
-
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
|
14 |
-
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
15 |
-
import axios from "axios";
|
16 |
-
import { ComicLayout } from "../../layouts/ComicLayout";
|
17 |
-
import {
|
18 |
-
getNextPanelDimensions,
|
19 |
-
groupSegmentsIntoLayouts,
|
20 |
-
} from "../../layouts/utils";
|
21 |
-
import { LAYOUTS } from "../../layouts/config";
|
22 |
-
import html2canvas from "html2canvas";
|
23 |
-
import { useConversation } from "@11labs/react";
|
24 |
-
import { CLIENT_ID, getDefaultHeaders } from "../../utils/session";
|
25 |
-
import { useNarrator } from "../../hooks/useNarrator";
|
26 |
-
|
27 |
-
// Get API URL from environment or default to localhost in development
|
28 |
-
const isHFSpace = window.location.hostname.includes("hf.space");
|
29 |
-
const API_URL = isHFSpace
|
30 |
-
? "" // URL relative pour HF Spaces
|
31 |
-
: import.meta.env.VITE_API_URL || "http://localhost:8000";
|
32 |
-
|
33 |
-
// Constants
|
34 |
-
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
35 |
-
|
36 |
-
const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8000/ws";
|
37 |
-
|
38 |
-
// Create axios instance with default config
|
39 |
-
const api = axios.create({
|
40 |
-
headers: getDefaultHeaders(),
|
41 |
-
// Ajouter baseURL pour HF Spaces
|
42 |
-
...(isHFSpace && {
|
43 |
-
baseURL: window.location.origin,
|
44 |
-
}),
|
45 |
-
});
|
46 |
-
|
47 |
-
// Function to convert text with ** to Chip elements
|
48 |
-
const formatTextWithBold = (text, isInPanel = false) => {
|
49 |
-
if (!text) return "";
|
50 |
-
const parts = text.split(/(\*\*.*?\*\*)/g);
|
51 |
-
return parts.map((part, index) => {
|
52 |
-
if (part.startsWith("**") && part.endsWith("**")) {
|
53 |
-
// Remove the ** and wrap in Chip
|
54 |
-
return (
|
55 |
-
<Chip
|
56 |
-
key={index}
|
57 |
-
label={part.slice(2, -2)}
|
58 |
-
size="small"
|
59 |
-
sx={{
|
60 |
-
mx: 0.5,
|
61 |
-
...(isInPanel && {
|
62 |
-
backgroundColor: "rgba(0, 0, 0, 0)!important",
|
63 |
-
color: "black!important",
|
64 |
-
borderColor: "black!important",
|
65 |
-
borderRadius: "4px!important",
|
66 |
-
}),
|
67 |
-
}}
|
68 |
-
/>
|
69 |
-
);
|
70 |
-
}
|
71 |
-
return part;
|
72 |
-
});
|
73 |
-
};
|
74 |
-
|
75 |
-
function App() {
|
76 |
-
const [storySegments, setStorySegments] = useState([]);
|
77 |
-
const [currentChoices, setCurrentChoices] = useState([]);
|
78 |
-
const [isLoading, setIsLoading] = useState(false);
|
79 |
-
const [isDebugMode, setIsDebugMode] = useState(false);
|
80 |
-
const [isRecording, setIsRecording] = useState(false);
|
81 |
-
const [wsConnected, setWsConnected] = useState(false);
|
82 |
-
|
83 |
-
const comicContainerRef = useRef(null);
|
84 |
-
const mediaRecorderRef = useRef(null);
|
85 |
-
const audioChunksRef = useRef([]);
|
86 |
-
const wsRef = useRef(null);
|
87 |
-
|
88 |
-
const { isNarratorSpeaking, playNarration, stopNarration } = useNarrator();
|
89 |
-
|
90 |
-
// Start the story on first render
|
91 |
-
useEffect(() => {
|
92 |
-
handleStoryAction("restart");
|
93 |
-
}, []); // Empty dependency array for first render only
|
94 |
-
|
95 |
-
// Only setup WebSocket connection with server
|
96 |
-
useEffect(() => {
|
97 |
-
const setupWebSocket = () => {
|
98 |
-
wsRef.current = new WebSocket(WS_URL);
|
99 |
-
|
100 |
-
wsRef.current.onopen = () => {
|
101 |
-
console.log("Server WebSocket connected");
|
102 |
-
setWsConnected(true);
|
103 |
-
};
|
104 |
-
|
105 |
-
wsRef.current.onclose = (event) => {
|
106 |
-
const reason = event.reason || "No reason provided";
|
107 |
-
const code = event.code;
|
108 |
-
console.log(
|
109 |
-
`Server WebSocket disconnected - Code: ${code}, Reason: ${reason}`
|
110 |
-
);
|
111 |
-
console.log("Attempting to reconnect in 3 seconds...");
|
112 |
-
setWsConnected(false);
|
113 |
-
// Attempt to reconnect after 3 seconds
|
114 |
-
setTimeout(setupWebSocket, 3000);
|
115 |
-
};
|
116 |
-
|
117 |
-
wsRef.current.onmessage = async (event) => {
|
118 |
-
const data = JSON.parse(event.data);
|
119 |
-
|
120 |
-
if (data.type === "audio") {
|
121 |
-
// Stop any ongoing narration
|
122 |
-
if (isNarratorSpeaking) {
|
123 |
-
stopNarration();
|
124 |
-
}
|
125 |
-
|
126 |
-
// Play the conversation audio response
|
127 |
-
const audioBlob = await fetch(
|
128 |
-
`data:audio/mpeg;base64,${data.audio}`
|
129 |
-
).then((r) => r.blob());
|
130 |
-
const audioUrl = URL.createObjectURL(audioBlob);
|
131 |
-
playNarration(audioUrl);
|
132 |
-
}
|
133 |
-
};
|
134 |
-
};
|
135 |
-
|
136 |
-
setupWebSocket();
|
137 |
-
|
138 |
-
return () => {
|
139 |
-
if (wsRef.current) {
|
140 |
-
wsRef.current.close();
|
141 |
-
}
|
142 |
-
};
|
143 |
-
}, []);
|
144 |
-
|
145 |
-
const conversation = useConversation({
|
146 |
-
agentId: AGENT_ID,
|
147 |
-
onResponse: async (response) => {
|
148 |
-
if (response.type === "audio") {
|
149 |
-
// Play the conversation audio response
|
150 |
-
const audioBlob = new Blob([response.audio], { type: "audio/mpeg" });
|
151 |
-
const audioUrl = URL.createObjectURL(audioBlob);
|
152 |
-
playNarration(audioUrl);
|
153 |
-
}
|
154 |
-
},
|
155 |
-
clientTools: {
|
156 |
-
make_decision: async ({ decision }) => {
|
157 |
-
console.log("AI made decision:", decision);
|
158 |
-
// End the ElevenLabs conversation
|
159 |
-
await conversation.endSession();
|
160 |
-
setIsRecording(false);
|
161 |
-
// Handle the choice and generate next story part
|
162 |
-
await handleChoice(parseInt(decision));
|
163 |
-
},
|
164 |
-
},
|
165 |
-
});
|
166 |
-
const { isSpeaking } = conversation;
|
167 |
-
const [isConversationMode, setIsConversationMode] = useState(false);
|
168 |
-
|
169 |
-
// Audio recording setup
|
170 |
-
const startRecording = async () => {
|
171 |
-
try {
|
172 |
-
// Stop narration audio if it's playing
|
173 |
-
if (isNarratorSpeaking) {
|
174 |
-
stopNarration();
|
175 |
-
}
|
176 |
-
// Also stop any conversation audio if playing
|
177 |
-
if (conversation.audioRef.current) {
|
178 |
-
conversation.audioRef.current.pause();
|
179 |
-
conversation.audioRef.current.currentTime = 0;
|
180 |
-
}
|
181 |
-
|
182 |
-
if (!isConversationMode) {
|
183 |
-
// If we're not in conversation mode, this is the first recording
|
184 |
-
setIsConversationMode(true);
|
185 |
-
// Initialize ElevenLabs WebSocket connection
|
186 |
-
try {
|
187 |
-
// Pass available choices to the conversation
|
188 |
-
const currentChoiceIds = currentChoices
|
189 |
-
.map((choice) => choice.id)
|
190 |
-
.join(",");
|
191 |
-
await conversation.startSession({
|
192 |
-
agentId: AGENT_ID,
|
193 |
-
initialContext: `This is the current situation : ${
|
194 |
-
storySegments[storySegments.length - 1].text
|
195 |
-
}. Those are the possible actions, ${currentChoices
|
196 |
-
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
197 |
-
.join(", ")}.`,
|
198 |
-
});
|
199 |
-
console.log("ElevenLabs WebSocket connected");
|
200 |
-
} catch (error) {
|
201 |
-
console.error("Error initializing ElevenLabs conversation:", error);
|
202 |
-
return;
|
203 |
-
}
|
204 |
-
} else if (isSpeaking) {
|
205 |
-
// Only handle stopping the agent if we're in conversation mode
|
206 |
-
await conversation.endSession();
|
207 |
-
const wsUrl = `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${AGENT_ID}`;
|
208 |
-
await conversation.startSession({ url: wsUrl });
|
209 |
-
}
|
210 |
-
|
211 |
-
// Only stop narration if it's actually playing
|
212 |
-
if (!isConversationMode && isNarratorSpeaking) {
|
213 |
-
stopNarration();
|
214 |
-
}
|
215 |
-
|
216 |
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
217 |
-
mediaRecorderRef.current = new MediaRecorder(stream);
|
218 |
-
audioChunksRef.current = [];
|
219 |
-
|
220 |
-
mediaRecorderRef.current.ondataavailable = (event) => {
|
221 |
-
if (event.data.size > 0) {
|
222 |
-
audioChunksRef.current.push(event.data);
|
223 |
-
}
|
224 |
-
};
|
225 |
-
|
226 |
-
mediaRecorderRef.current.onstop = async () => {
|
227 |
-
const audioBlob = new Blob(audioChunksRef.current, {
|
228 |
-
type: "audio/wav",
|
229 |
-
});
|
230 |
-
const reader = new FileReader();
|
231 |
-
|
232 |
-
reader.onload = async () => {
|
233 |
-
const base64Audio = reader.result.split(",")[1];
|
234 |
-
if (isConversationMode) {
|
235 |
-
try {
|
236 |
-
// Send audio to ElevenLabs conversation
|
237 |
-
await conversation.send({
|
238 |
-
type: "audio",
|
239 |
-
data: base64Audio,
|
240 |
-
});
|
241 |
-
} catch (error) {
|
242 |
-
console.error("Error sending audio to ElevenLabs:", error);
|
243 |
-
}
|
244 |
-
} else {
|
245 |
-
// Otherwise use the original WebSocket connection
|
246 |
-
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
247 |
-
console.log("Sending audio to server via WebSocket");
|
248 |
-
wsRef.current.send(
|
249 |
-
JSON.stringify({
|
250 |
-
type: "audio_input",
|
251 |
-
audio: base64Audio,
|
252 |
-
client_id: CLIENT_ID,
|
253 |
-
})
|
254 |
-
);
|
255 |
-
}
|
256 |
-
}
|
257 |
-
};
|
258 |
-
|
259 |
-
reader.readAsDataURL(audioBlob);
|
260 |
-
};
|
261 |
-
|
262 |
-
mediaRecorderRef.current.start();
|
263 |
-
setIsRecording(true);
|
264 |
-
} catch (error) {
|
265 |
-
console.error("Error starting recording:", error);
|
266 |
-
}
|
267 |
-
};
|
268 |
-
|
269 |
-
const stopRecording = () => {
|
270 |
-
if (mediaRecorderRef.current && isRecording) {
|
271 |
-
mediaRecorderRef.current.stop();
|
272 |
-
setIsRecording(false);
|
273 |
-
mediaRecorderRef.current.stream
|
274 |
-
.getTracks()
|
275 |
-
.forEach((track) => track.stop());
|
276 |
-
}
|
277 |
-
};
|
278 |
-
|
279 |
-
const generateImagesForStory = async (
|
280 |
-
imagePrompts,
|
281 |
-
segmentIndex,
|
282 |
-
currentSegments
|
283 |
-
) => {
|
284 |
-
try {
|
285 |
-
console.log("[generateImagesForStory] Starting with:", {
|
286 |
-
promptsCount: imagePrompts.length,
|
287 |
-
segmentIndex,
|
288 |
-
segmentsCount: currentSegments.length,
|
289 |
-
});
|
290 |
-
console.log("Image prompts:", imagePrompts);
|
291 |
-
console.log("Current segments:", currentSegments);
|
292 |
-
|
293 |
-
let localSegments = [...currentSegments];
|
294 |
-
|
295 |
-
// Traiter chaque prompt un par un
|
296 |
-
for (
|
297 |
-
let promptIndex = 0;
|
298 |
-
promptIndex < imagePrompts.length;
|
299 |
-
promptIndex++
|
300 |
-
) {
|
301 |
-
// Recalculer le layout actuel pour chaque image
|
302 |
-
const layouts = groupSegmentsIntoLayouts(localSegments);
|
303 |
-
console.log("[Layout] Current layouts:", layouts);
|
304 |
-
const currentLayout = layouts[layouts.length - 1];
|
305 |
-
const layoutType = currentLayout?.type || "COVER";
|
306 |
-
console.log("[Layout] Current type:", layoutType);
|
307 |
-
|
308 |
-
// Vérifier si nous avons de la place dans le layout actuel
|
309 |
-
const currentSegmentImages =
|
310 |
-
currentLayout.segments[currentLayout.segments.length - 1].images ||
|
311 |
-
[];
|
312 |
-
const actualImagesCount = currentSegmentImages.filter(
|
313 |
-
(img) => img !== null
|
314 |
-
).length;
|
315 |
-
console.log("[Layout] Current segment images:", {
|
316 |
-
total: currentSegmentImages.length,
|
317 |
-
actual: actualImagesCount,
|
318 |
-
hasImages: currentSegmentImages.some((img) => img !== null),
|
319 |
-
currentImages: currentSegmentImages.map((img) =>
|
320 |
-
img ? "image" : "null"
|
321 |
-
),
|
322 |
-
});
|
323 |
-
|
324 |
-
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
325 |
-
console.log(
|
326 |
-
"[Layout] Panel dimensions for prompt",
|
327 |
-
promptIndex,
|
328 |
-
":",
|
329 |
-
panelDimensions
|
330 |
-
);
|
331 |
-
|
332 |
-
// Ne créer une nouvelle page que si nous avons encore des prompts à traiter
|
333 |
-
// et qu'il n'y a plus de place dans le layout actuel
|
334 |
-
if (!panelDimensions && promptIndex < imagePrompts.length - 1) {
|
335 |
-
console.log(
|
336 |
-
"[Layout] Creating new page - No space in current layout"
|
337 |
-
);
|
338 |
-
// Créer un nouveau segment pour la nouvelle page
|
339 |
-
const newSegment = {
|
340 |
-
...localSegments[segmentIndex],
|
341 |
-
images: Array(imagePrompts.length - promptIndex).fill(null),
|
342 |
-
};
|
343 |
-
localSegments = [...localSegments, newSegment];
|
344 |
-
segmentIndex = localSegments.length - 1;
|
345 |
-
console.log("[Layout] New segment created:", {
|
346 |
-
segmentIndex,
|
347 |
-
totalSegments: localSegments.length,
|
348 |
-
imagesArray: newSegment.images,
|
349 |
-
});
|
350 |
-
// Mettre à jour l'état avec le nouveau segment
|
351 |
-
setStorySegments(localSegments);
|
352 |
-
continue; // Recommencer la boucle avec le nouveau segment
|
353 |
-
}
|
354 |
-
|
355 |
-
// Si nous n'avons pas de dimensions de panneau et c'est le dernier prompt,
|
356 |
-
// ne pas continuer
|
357 |
-
if (!panelDimensions) {
|
358 |
-
console.log(
|
359 |
-
"[Layout] Stopping - No more space and no more prompts to process"
|
360 |
-
);
|
361 |
-
break;
|
362 |
-
}
|
363 |
-
|
364 |
-
console.log(
|
365 |
-
`[Image] Generating image ${promptIndex + 1}/${imagePrompts.length}:`,
|
366 |
-
{
|
367 |
-
prompt: imagePrompts[promptIndex],
|
368 |
-
dimensions: panelDimensions,
|
369 |
-
}
|
370 |
-
);
|
371 |
-
|
372 |
-
let retryCount = 0;
|
373 |
-
const maxRetries = 3;
|
374 |
-
let success = false;
|
375 |
-
|
376 |
-
while (retryCount < maxRetries && !success) {
|
377 |
-
try {
|
378 |
-
if (retryCount > 0) {
|
379 |
-
console.log(
|
380 |
-
`[Image] Retry attempt ${retryCount} for image ${
|
381 |
-
promptIndex + 1
|
382 |
-
}`
|
383 |
-
);
|
384 |
-
}
|
385 |
-
|
386 |
-
const result = await api.post(
|
387 |
-
`${API_URL}/api/generate-image-direct`,
|
388 |
-
{
|
389 |
-
prompt: imagePrompts[promptIndex],
|
390 |
-
width: panelDimensions.width,
|
391 |
-
height: panelDimensions.height,
|
392 |
-
}
|
393 |
-
);
|
394 |
-
|
395 |
-
console.log(`[Image] Response for image ${promptIndex + 1}:`, {
|
396 |
-
success: result.data.success,
|
397 |
-
hasImage: !!result.data.image_base64,
|
398 |
-
imageLength: result.data.image_base64?.length,
|
399 |
-
});
|
400 |
-
|
401 |
-
if (result.data.success) {
|
402 |
-
console.log(
|
403 |
-
`[Image] Image ${promptIndex + 1} generated successfully`
|
404 |
-
);
|
405 |
-
// Mettre à jour les segments locaux
|
406 |
-
const currentImages = [
|
407 |
-
...(localSegments[segmentIndex].images || []),
|
408 |
-
];
|
409 |
-
// Remplacer le null à l'index du prompt par la nouvelle image
|
410 |
-
currentImages[promptIndex] = result.data.image_base64;
|
411 |
-
|
412 |
-
localSegments[segmentIndex] = {
|
413 |
-
...localSegments[segmentIndex],
|
414 |
-
images: currentImages,
|
415 |
-
};
|
416 |
-
console.log("[State] Updating segments with new image:", {
|
417 |
-
segmentIndex,
|
418 |
-
imageIndex: promptIndex,
|
419 |
-
imagesArray: currentImages.map((img) =>
|
420 |
-
img ? "image" : "null"
|
421 |
-
),
|
422 |
-
});
|
423 |
-
// Mettre à jour l'état avec les segments mis à jour
|
424 |
-
setStorySegments([...localSegments]);
|
425 |
-
success = true;
|
426 |
-
} else {
|
427 |
-
console.error(
|
428 |
-
`[Image] Generation failed for image ${promptIndex + 1}:`,
|
429 |
-
result.data.error
|
430 |
-
);
|
431 |
-
retryCount++;
|
432 |
-
if (retryCount < maxRetries) {
|
433 |
-
// Attendre un peu avant de réessayer (backoff exponentiel)
|
434 |
-
await new Promise((resolve) =>
|
435 |
-
setTimeout(resolve, 1000 * Math.pow(2, retryCount))
|
436 |
-
);
|
437 |
-
}
|
438 |
-
}
|
439 |
-
} catch (error) {
|
440 |
-
console.error(
|
441 |
-
`[Image] Error generating image ${promptIndex + 1}:`,
|
442 |
-
error
|
443 |
-
);
|
444 |
-
retryCount++;
|
445 |
-
if (retryCount < maxRetries) {
|
446 |
-
// Attendre un peu avant de réessayer (backoff exponentiel)
|
447 |
-
await new Promise((resolve) =>
|
448 |
-
setTimeout(resolve, 1000 * Math.pow(2, retryCount))
|
449 |
-
);
|
450 |
-
}
|
451 |
-
}
|
452 |
-
}
|
453 |
-
|
454 |
-
if (!success) {
|
455 |
-
console.error(
|
456 |
-
`[Image] Failed to generate image ${
|
457 |
-
promptIndex + 1
|
458 |
-
} after ${maxRetries} attempts`
|
459 |
-
);
|
460 |
-
}
|
461 |
-
}
|
462 |
-
|
463 |
-
console.log(
|
464 |
-
"[generateImagesForStory] Completed. Final segments:",
|
465 |
-
localSegments.map((seg) => ({
|
466 |
-
...seg,
|
467 |
-
images: seg.images?.map((img) => (img ? "image" : "null")),
|
468 |
-
}))
|
469 |
-
);
|
470 |
-
return localSegments[segmentIndex]?.images || [];
|
471 |
-
} catch (error) {
|
472 |
-
console.error("[generateImagesForStory] Error:", error);
|
473 |
-
return [];
|
474 |
-
}
|
475 |
-
};
|
476 |
-
|
477 |
-
const handleStoryAction = async (action, choiceId = null) => {
|
478 |
-
setIsLoading(true);
|
479 |
-
try {
|
480 |
-
// 1. D'abord, obtenir l'histoire
|
481 |
-
const response = await api.post(
|
482 |
-
`${API_URL}/api/${isDebugMode ? "test/" : ""}chat`,
|
483 |
-
{
|
484 |
-
message: action,
|
485 |
-
choice_id: choiceId,
|
486 |
-
}
|
487 |
-
);
|
488 |
-
|
489 |
-
// 2. Créer le nouveau segment sans images
|
490 |
-
const newSegment = {
|
491 |
-
text: formatTextWithBold(response.data.story_text, true),
|
492 |
-
isChoice: false,
|
493 |
-
isDeath: response.data.is_death,
|
494 |
-
isVictory: response.data.is_victory,
|
495 |
-
radiationLevel: response.data.radiation_level,
|
496 |
-
is_first_step: response.data.is_first_step,
|
497 |
-
is_last_step: response.data.is_last_step,
|
498 |
-
images: response.data.image_prompts
|
499 |
-
? Array(response.data.image_prompts.length).fill(null)
|
500 |
-
: [], // Pré-remplir avec null pour les spinners
|
501 |
-
};
|
502 |
-
|
503 |
-
// 3. Calculer le nouvel index et les segments mis à jour
|
504 |
-
let segmentIndex;
|
505 |
-
let updatedSegments;
|
506 |
-
|
507 |
-
if (action === "restart") {
|
508 |
-
segmentIndex = 0;
|
509 |
-
updatedSegments = [newSegment];
|
510 |
-
} else {
|
511 |
-
// Récupérer l'état actuel de manière synchrone
|
512 |
-
segmentIndex = storySegments.length;
|
513 |
-
updatedSegments = [...storySegments, newSegment];
|
514 |
-
}
|
515 |
-
|
516 |
-
// Mettre à jour l'état avec les nouveaux segments
|
517 |
-
setStorySegments(updatedSegments);
|
518 |
-
|
519 |
-
// 4. Mettre à jour les choix immédiatement
|
520 |
-
setCurrentChoices(response.data.choices);
|
521 |
-
|
522 |
-
// 5. Désactiver le loading car l'histoire est affichée
|
523 |
-
setIsLoading(false);
|
524 |
-
|
525 |
-
// 6. Jouer l'audio du nouveau segment
|
526 |
-
await playNarration(response.data.story_text);
|
527 |
-
|
528 |
-
// 7. Générer les images en parallèle
|
529 |
-
if (
|
530 |
-
response.data.image_prompts &&
|
531 |
-
response.data.image_prompts.length > 0
|
532 |
-
) {
|
533 |
-
try {
|
534 |
-
console.log(
|
535 |
-
"Starting image generation with prompts:",
|
536 |
-
response.data.image_prompts,
|
537 |
-
"for segment",
|
538 |
-
segmentIndex
|
539 |
-
);
|
540 |
-
// generateImagesForStory met déjà à jour le state au fur et à mesure
|
541 |
-
await generateImagesForStory(
|
542 |
-
response.data.image_prompts,
|
543 |
-
segmentIndex,
|
544 |
-
updatedSegments
|
545 |
-
);
|
546 |
-
} catch (imageError) {
|
547 |
-
console.error("Error generating images:", imageError);
|
548 |
-
}
|
549 |
-
}
|
550 |
-
} catch (error) {
|
551 |
-
console.error("Error:", error);
|
552 |
-
// En cas d'erreur, créer un segment d'erreur qui permet de continuer
|
553 |
-
const errorSegment = {
|
554 |
-
text: "Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...",
|
555 |
-
isChoice: false,
|
556 |
-
isDeath: false,
|
557 |
-
isVictory: false,
|
558 |
-
radiationLevel:
|
559 |
-
storySegments.length > 0
|
560 |
-
? storySegments[storySegments.length - 1].radiationLevel
|
561 |
-
: 0,
|
562 |
-
images: [],
|
563 |
-
};
|
564 |
-
|
565 |
-
// Ajouter le segment d'erreur et permettre de réessayer
|
566 |
-
if (action === "restart") {
|
567 |
-
setStorySegments([errorSegment]);
|
568 |
-
} else {
|
569 |
-
setStorySegments((prev) => [...prev, errorSegment]);
|
570 |
-
}
|
571 |
-
|
572 |
-
// Donner l'option de réessayer
|
573 |
-
setCurrentChoices([{ id: 1, text: "Réessayer" }]);
|
574 |
-
|
575 |
-
setIsLoading(false);
|
576 |
-
}
|
577 |
-
};
|
578 |
-
|
579 |
-
const handleChoice = async (choiceId) => {
|
580 |
-
// Si c'est l'option "Réessayer", on relance la dernière action
|
581 |
-
if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") {
|
582 |
-
// Supprimer le segment d'erreur
|
583 |
-
setStorySegments((prev) => prev.slice(0, -1));
|
584 |
-
// Réessayer la dernière action
|
585 |
-
await handleStoryAction(
|
586 |
-
"choice",
|
587 |
-
storySegments[storySegments.length - 2]?.choiceId || null
|
588 |
-
);
|
589 |
-
return;
|
590 |
-
}
|
591 |
-
|
592 |
-
// Comportement normal pour les autres choix
|
593 |
-
const choice = currentChoices.find((c) => c.id === choiceId);
|
594 |
-
setStorySegments((prev) => [
|
595 |
-
...prev,
|
596 |
-
{
|
597 |
-
text: choice.text,
|
598 |
-
isChoice: true,
|
599 |
-
choiceId: choiceId, // Stocker l'ID du choix pour pouvoir réessayer
|
600 |
-
},
|
601 |
-
]);
|
602 |
-
|
603 |
-
// Continue the story with this choice
|
604 |
-
await handleStoryAction("choice", choiceId);
|
605 |
-
};
|
606 |
-
|
607 |
-
// Filter out choice segments
|
608 |
-
const nonChoiceSegments = storySegments.filter(
|
609 |
-
(segment) => !segment.isChoice
|
610 |
-
);
|
611 |
-
|
612 |
-
const handleSaveAsImage = async () => {
|
613 |
-
if (comicContainerRef.current) {
|
614 |
-
try {
|
615 |
-
const canvas = await html2canvas(comicContainerRef.current, {
|
616 |
-
scale: 2, // Meilleure qualité
|
617 |
-
backgroundColor: "#242424", // Même couleur que le fond
|
618 |
-
logging: false,
|
619 |
-
});
|
620 |
-
|
621 |
-
// Convertir en PNG et télécharger
|
622 |
-
const image = canvas.toDataURL("image/png");
|
623 |
-
const link = document.createElement("a");
|
624 |
-
link.href = image;
|
625 |
-
link.download = "my-comic-story.png";
|
626 |
-
link.click();
|
627 |
-
} catch (error) {
|
628 |
-
console.error("Error saving image:", error);
|
629 |
-
}
|
630 |
-
}
|
631 |
-
};
|
632 |
-
|
633 |
-
return (
|
634 |
-
<Box
|
635 |
-
sx={{
|
636 |
-
height: "100vh",
|
637 |
-
width: "100%",
|
638 |
-
display: "flex",
|
639 |
-
flexDirection: "column",
|
640 |
-
}}
|
641 |
-
>
|
642 |
-
<Box
|
643 |
-
sx={{
|
644 |
-
position: "fixed",
|
645 |
-
top: 16,
|
646 |
-
right: 16,
|
647 |
-
zIndex: 1000,
|
648 |
-
display: "flex",
|
649 |
-
gap: 1,
|
650 |
-
}}
|
651 |
-
>
|
652 |
-
<Button
|
653 |
-
onClick={isRecording ? stopRecording : startRecording}
|
654 |
-
variant="outlined"
|
655 |
-
disabled={isLoading || isNarratorSpeaking}
|
656 |
-
sx={{
|
657 |
-
borderColor: isRecording ? "error.main" : "primary.main",
|
658 |
-
backgroundColor: isRecording ? "error.main" : "transparent",
|
659 |
-
color: isRecording ? "white" : "primary.main",
|
660 |
-
"&:hover": {
|
661 |
-
backgroundColor: isRecording ? "error.dark" : "primary.main",
|
662 |
-
color: "background.paper",
|
663 |
-
borderColor: isRecording ? "error.dark" : "primary.main",
|
664 |
-
},
|
665 |
-
}}
|
666 |
-
>
|
667 |
-
{isRecording ? "Sarah's being convinced" : "Try to convince Sarah"}
|
668 |
-
</Button>
|
669 |
-
<Tooltip title="Sauvegarder en PNG">
|
670 |
-
<IconButton
|
671 |
-
onClick={handleSaveAsImage}
|
672 |
-
sx={{
|
673 |
-
border: "1px solid",
|
674 |
-
borderColor: "primary.main",
|
675 |
-
borderRadius: "8px",
|
676 |
-
backgroundColor: "transparent",
|
677 |
-
color: "primary.main",
|
678 |
-
padding: "8px",
|
679 |
-
"&:hover": {
|
680 |
-
backgroundColor: "primary.main",
|
681 |
-
color: "background.paper",
|
682 |
-
},
|
683 |
-
}}
|
684 |
-
>
|
685 |
-
<SaveOutlinedIcon />
|
686 |
-
</IconButton>
|
687 |
-
</Tooltip>
|
688 |
-
</Box>
|
689 |
-
|
690 |
-
{isLoading && (
|
691 |
-
<LinearProgress
|
692 |
-
color="secondary"
|
693 |
-
sx={{ position: "absolute", top: 0, width: "100%" }}
|
694 |
-
/>
|
695 |
-
)}
|
696 |
-
|
697 |
-
<Box
|
698 |
-
ref={comicContainerRef}
|
699 |
-
sx={{
|
700 |
-
flexGrow: 1,
|
701 |
-
display: "flex",
|
702 |
-
gap: 4,
|
703 |
-
pt: 8,
|
704 |
-
px: 2,
|
705 |
-
pb: 2,
|
706 |
-
width: "100%",
|
707 |
-
height: "calc(100vh - 135px)",
|
708 |
-
bgcolor: "background.default",
|
709 |
-
}}
|
710 |
-
>
|
711 |
-
<ComicLayout segments={nonChoiceSegments} />
|
712 |
-
</Box>
|
713 |
-
|
714 |
-
<Box
|
715 |
-
sx={{
|
716 |
-
py: 3,
|
717 |
-
borderColor: "divider",
|
718 |
-
backgroundColor: "background.default",
|
719 |
-
}}
|
720 |
-
>
|
721 |
-
{currentChoices.length > 0 ? (
|
722 |
-
<Box
|
723 |
-
sx={{
|
724 |
-
display: "flex",
|
725 |
-
justifyContent: "center",
|
726 |
-
gap: 2,
|
727 |
-
minHeight: "100px",
|
728 |
-
}}
|
729 |
-
>
|
730 |
-
{currentChoices.map((choice, index) => (
|
731 |
-
<Box
|
732 |
-
key={choice.id}
|
733 |
-
sx={{
|
734 |
-
display: "flex",
|
735 |
-
flexDirection: "column",
|
736 |
-
alignItems: "center",
|
737 |
-
gap: 1,
|
738 |
-
}}
|
739 |
-
>
|
740 |
-
<Typography
|
741 |
-
variant="caption"
|
742 |
-
color="text.secondary"
|
743 |
-
sx={{ opacity: 0.7 }}
|
744 |
-
>
|
745 |
-
Suggestion {index + 1}
|
746 |
-
</Typography>
|
747 |
-
<Button
|
748 |
-
variant="outlined"
|
749 |
-
size="large"
|
750 |
-
onClick={() => handleChoice(choice.id)}
|
751 |
-
disabled={isLoading || isNarratorSpeaking}
|
752 |
-
sx={{
|
753 |
-
minWidth: "300px",
|
754 |
-
textTransform: "none",
|
755 |
-
cursor: "pointer",
|
756 |
-
fontSize: "1.1rem",
|
757 |
-
padding: "16px 24px",
|
758 |
-
lineHeight: 1.3,
|
759 |
-
"& .MuiChip-root": {
|
760 |
-
fontSize: "1.1rem",
|
761 |
-
},
|
762 |
-
}}
|
763 |
-
>
|
764 |
-
{formatTextWithBold(choice.text)}
|
765 |
-
</Button>
|
766 |
-
</Box>
|
767 |
-
))}
|
768 |
-
</Box>
|
769 |
-
) : storySegments.length > 0 &&
|
770 |
-
storySegments[storySegments.length - 1].is_last_step ? (
|
771 |
-
<Box
|
772 |
-
sx={{
|
773 |
-
display: "flex",
|
774 |
-
justifyContent: "center",
|
775 |
-
gap: 2,
|
776 |
-
minHeight: "40px",
|
777 |
-
}}
|
778 |
-
>
|
779 |
-
<Button
|
780 |
-
variant="text"
|
781 |
-
size="medium"
|
782 |
-
onClick={() => handleStoryAction("restart")}
|
783 |
-
startIcon={<RestartAltIcon />}
|
784 |
-
>
|
785 |
-
Replay
|
786 |
-
</Button>
|
787 |
-
</Box>
|
788 |
-
) : null}
|
789 |
-
</Box>
|
790 |
-
</Box>
|
791 |
-
);
|
792 |
-
}
|
793 |
-
|
794 |
-
export default App;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/yarn.lock
CHANGED
@@ -1709,6 +1709,11 @@ hoist-non-react-statics@^3.3.1:
|
|
1709 |
dependencies:
|
1710 |
react-is "^16.7.0"
|
1711 |
|
|
|
|
|
|
|
|
|
|
|
1712 |
html2canvas@^1.4.1:
|
1713 |
version "1.4.1"
|
1714 |
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
|
@@ -2720,6 +2725,18 @@ uri-js@^4.2.2:
|
|
2720 |
dependencies:
|
2721 |
punycode "^2.1.0"
|
2722 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2723 |
utrie@^1.0.2:
|
2724 |
version "1.0.2"
|
2725 |
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
|
|
|
1709 |
dependencies:
|
1710 |
react-is "^16.7.0"
|
1711 |
|
1712 |
+
howler@^2.1.3:
|
1713 |
+
version "2.2.4"
|
1714 |
+
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.4.tgz#bd3df4a4f68a0118a51e4bd84a2bfc2e93e6e5a1"
|
1715 |
+
integrity sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==
|
1716 |
+
|
1717 |
html2canvas@^1.4.1:
|
1718 |
version "1.4.1"
|
1719 |
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
|
|
|
2725 |
dependencies:
|
2726 |
punycode "^2.1.0"
|
2727 |
|
2728 |
+
use-react-screenshot@^4.0.0:
|
2729 |
+
version "4.0.0"
|
2730 |
+
resolved "https://registry.yarnpkg.com/use-react-screenshot/-/use-react-screenshot-4.0.0.tgz#8be533e0790e75c8fd72174ed523c2ed86c967fc"
|
2731 |
+
integrity sha512-4UZIORp7iCklfNOS/dPJab9SPeGdS0nFyIi3qA1rfMyYf/em/KfodYhrOlSHAHWvfdeCrS67Jjk6H4M4oLYSWg==
|
2732 |
+
|
2733 |
+
use-sound@^4.0.3:
|
2734 |
+
version "4.0.3"
|
2735 |
+
resolved "https://registry.yarnpkg.com/use-sound/-/use-sound-4.0.3.tgz#858effc102b987e0e1f9a6d396706c1362453543"
|
2736 |
+
integrity sha512-L205pEUFIrLsGYsCUKHQVCt0ajs//YQOFbEQeNwaWaqQj3y3st4SuR+rvpMHLmv8hgTcfUFlvMQawZNI3OE18w==
|
2737 |
+
dependencies:
|
2738 |
+
howler "^2.1.3"
|
2739 |
+
|
2740 |
utrie@^1.0.2:
|
2741 |
version "1.0.2"
|
2742 |
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
|
server/api/models.py
CHANGED
@@ -1,35 +1,79 @@
|
|
1 |
-
from pydantic import BaseModel, Field
|
2 |
from typing import List, Optional
|
|
|
3 |
|
4 |
class Choice(BaseModel):
|
5 |
id: int
|
6 |
-
text: str = Field(description="The text of the choice.
|
7 |
|
8 |
-
|
9 |
-
|
10 |
-
story_text: str = Field(description="The story text.
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
|
12 |
class StoryPromptsResponse(BaseModel):
|
13 |
-
image_prompts: List[str] = Field(
|
|
|
|
|
|
|
|
|
14 |
|
15 |
class StoryMetadataResponse(BaseModel):
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
17 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
18 |
-
|
19 |
-
|
20 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
21 |
location: str = Field(description="Current location.")
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
choices: List[Choice]
|
27 |
-
|
28 |
-
|
|
|
|
|
|
|
29 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
30 |
-
|
31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
|
|
|
33 |
class ChatMessage(BaseModel):
|
34 |
message: str
|
35 |
choice_id: Optional[int] = None
|
|
|
1 |
+
from pydantic import BaseModel, Field, validator
|
2 |
from typing import List, Optional
|
3 |
+
from core.constants import GameConfig
|
4 |
|
5 |
class Choice(BaseModel):
|
6 |
id: int
|
7 |
+
text: str = Field(description="The text of the choice.")
|
8 |
|
9 |
+
class StorySegmentBase(BaseModel):
|
10 |
+
"""Base model for story segments with common validation logic"""
|
11 |
+
story_text: str = Field(description="The story text.")
|
12 |
+
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
13 |
+
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
14 |
+
|
15 |
+
# Existing response models for story generation steps - preserved for API compatibility
|
16 |
+
class StoryTextResponse(StorySegmentBase):
|
17 |
+
pass
|
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 'Sarah' only when referring to her.",
|
22 |
+
min_items=GameConfig.MIN_PANELS,
|
23 |
+
max_items=GameConfig.MAX_PANELS
|
24 |
+
)
|
25 |
|
26 |
class StoryMetadataResponse(BaseModel):
|
27 |
+
radiation_increase: int = Field(
|
28 |
+
description=f"How much radiation this segment adds (0-3)",
|
29 |
+
ge=0,
|
30 |
+
le=3,
|
31 |
+
default=GameConfig.DEFAULT_RADIATION_INCREASE
|
32 |
+
)
|
33 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
34 |
+
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
35 |
+
choices: List[str] = Field(description="Either empty list for victory/death, or exactly two choices for normal progression")
|
36 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
37 |
location: str = Field(description="Current location.")
|
38 |
|
39 |
+
@validator('choices')
|
40 |
+
def validate_choices(cls, v, values):
|
41 |
+
is_ending = values.get('is_victory', False) or values.get('is_death', False)
|
42 |
+
if is_ending:
|
43 |
+
if len(v) != 0:
|
44 |
+
raise ValueError('For victory/death, choices must be empty')
|
45 |
+
else:
|
46 |
+
if len(v) != 2:
|
47 |
+
raise ValueError('For normal progression, must have exactly 2 choices')
|
48 |
+
return v
|
49 |
+
|
50 |
+
# Complete story response combining all parts - preserved for API compatibility
|
51 |
+
class StoryResponse(StorySegmentBase):
|
52 |
choices: List[Choice]
|
53 |
+
raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
|
54 |
+
radiation_level: int = Field(description=f"Current radiation level")
|
55 |
+
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=GameConfig.DEFAULT_RADIATION_INCREASE)
|
56 |
+
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
57 |
+
location: str = Field(description="Current location.")
|
58 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
59 |
+
image_prompts: List[str] = Field(
|
60 |
+
description="List of comic panel descriptions that illustrate the key moments of the scene. Use the word 'Sarah' only when referring to her.",
|
61 |
+
min_items=GameConfig.MIN_PANELS,
|
62 |
+
max_items=GameConfig.MAX_PANELS
|
63 |
+
)
|
64 |
+
|
65 |
+
@validator('choices')
|
66 |
+
def validate_choices(cls, v, values):
|
67 |
+
is_ending = values.get('is_victory', False) or values.get('is_death', False)
|
68 |
+
if is_ending:
|
69 |
+
if len(v) != 0:
|
70 |
+
raise ValueError('For victory/death, choices must be empty')
|
71 |
+
else:
|
72 |
+
if len(v) != 2:
|
73 |
+
raise ValueError('For normal progression, must have exactly 2 choices')
|
74 |
+
return v
|
75 |
|
76 |
+
# Keep existing models unchanged for compatibility
|
77 |
class ChatMessage(BaseModel):
|
78 |
message: str
|
79 |
choice_id: Optional[int] = None
|
server/api/routes/chat.py
CHANGED
@@ -58,20 +58,18 @@ def get_chat_router(session_manager: SessionManager, story_generator):
|
|
58 |
if game_state.story_beat == 0 and len(llm_response.image_prompts) > 1:
|
59 |
llm_response.image_prompts = [llm_response.image_prompts[0]]
|
60 |
|
61 |
-
# Convert LLM choices to API choices format
|
62 |
-
choices = [] if is_death or llm_response.is_victory else [
|
63 |
-
Choice(id=i, text=choice.strip())
|
64 |
-
for i, choice in enumerate(llm_response.choices, 1)
|
65 |
-
]
|
66 |
-
|
67 |
# Prepare response
|
68 |
response = StoryResponse(
|
69 |
story_text=llm_response.story_text,
|
70 |
-
choices=choices,
|
|
|
71 |
radiation_level=game_state.radiation_level,
|
|
|
|
|
|
|
72 |
is_victory=llm_response.is_victory,
|
|
|
73 |
is_first_step=game_state.story_beat == 0,
|
74 |
-
is_last_step=is_death or llm_response.is_victory,
|
75 |
image_prompts=llm_response.image_prompts
|
76 |
)
|
77 |
|
|
|
58 |
if game_state.story_beat == 0 and len(llm_response.image_prompts) > 1:
|
59 |
llm_response.image_prompts = [llm_response.image_prompts[0]]
|
60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
# Prepare response
|
62 |
response = StoryResponse(
|
63 |
story_text=llm_response.story_text,
|
64 |
+
choices=llm_response.choices,
|
65 |
+
raw_choices=llm_response.raw_choices,
|
66 |
radiation_level=game_state.radiation_level,
|
67 |
+
radiation_increase=llm_response.radiation_increase,
|
68 |
+
time=llm_response.time,
|
69 |
+
location=llm_response.location,
|
70 |
is_victory=llm_response.is_victory,
|
71 |
+
is_death=is_death,
|
72 |
is_first_step=game_state.story_beat == 0,
|
|
|
73 |
image_prompts=llm_response.image_prompts
|
74 |
)
|
75 |
|
server/core/constants.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
class GameConfig:
|
2 |
+
# Game state constants
|
3 |
+
MAX_RADIATION = 4
|
4 |
+
STARTING_TIME = "18:00"
|
5 |
+
STARTING_LOCATION = "Outskirts of New Haven"
|
6 |
+
|
7 |
+
# Story constraints
|
8 |
+
MIN_PANELS = 1
|
9 |
+
MAX_PANELS = 4
|
10 |
+
|
11 |
+
# Default values
|
12 |
+
DEFAULT_RADIATION_INCREASE = 1
|
13 |
+
|
14 |
+
# Story progression
|
15 |
+
STORY_BEAT_INTRO = 0
|
16 |
+
STORY_BEAT_EARLY_GAME = 1
|
17 |
+
STORY_BEAT_MID_GAME = 3
|
18 |
+
STORY_BEAT_LATE_GAME = 5
|
server/core/game_logic.py
CHANGED
@@ -5,15 +5,16 @@ from langchain.prompts import ChatPromptTemplate, HumanMessagePromptTemplate, Sy
|
|
5 |
import os
|
6 |
import asyncio
|
7 |
|
|
|
8 |
from core.prompts.system import SARAH_DESCRIPTION
|
9 |
from core.prompts.cinematic import CINEMATIC_SYSTEM_PROMPT
|
10 |
from core.prompts.image_style import IMAGE_STYLE_PREFIX
|
11 |
from services.mistral_client import MistralClient
|
12 |
-
from api.models import StoryTextResponse, StoryPromptsResponse, StoryMetadataResponse
|
13 |
from core.story_generators import TextGenerator, ImagePromptsGenerator, MetadataGenerator
|
14 |
|
15 |
# Game constants
|
16 |
-
MAX_RADIATION =
|
17 |
STARTING_TIME = "18:00" # Game starts at sunset
|
18 |
STARTING_LOCATION = "Outskirts of New Haven"
|
19 |
|
@@ -30,18 +31,18 @@ def format_image_prompt(prompt: str, time: str, location: str) -> str:
|
|
30 |
|
31 |
class GameState:
|
32 |
def __init__(self):
|
33 |
-
self.story_beat =
|
34 |
self.radiation_level = 0
|
35 |
self.story_history = []
|
36 |
-
self.current_time = STARTING_TIME
|
37 |
-
self.current_location = STARTING_LOCATION
|
38 |
|
39 |
def reset(self):
|
40 |
-
self.story_beat =
|
41 |
self.radiation_level = 0
|
42 |
self.story_history = []
|
43 |
-
self.current_time = STARTING_TIME
|
44 |
-
self.current_location = STARTING_LOCATION
|
45 |
|
46 |
def add_to_history(self, segment_text: str, choice_made: str, image_prompts: List[str], time: str, location: str):
|
47 |
self.story_history.append({
|
@@ -56,12 +57,12 @@ class GameState:
|
|
56 |
|
57 |
# Story output structure
|
58 |
class StoryLLMResponse(BaseModel):
|
59 |
-
story_text: str = Field(description="The next segment of the story. No more than 12 words THIS IS MANDATORY.
|
60 |
-
choices: List[str] = Field(description="
|
61 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
|
|
62 |
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
|
63 |
image_prompts: List[str] = Field(description="List of 1 to 4 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=4)
|
64 |
-
is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False)
|
65 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.", default=STARTING_TIME)
|
66 |
location: str = Field(description="Current location, using bold for proper nouns (e.g., 'Inside Vault 15', 'Streets of New Haven').", default=STARTING_LOCATION)
|
67 |
|
@@ -85,9 +86,9 @@ class StoryGenerator:
|
|
85 |
story_history = "\n\n---\n\n".join(segments)
|
86 |
return story_history
|
87 |
|
88 |
-
async def generate_story_segment(self, game_state: GameState, previous_choice: str) ->
|
89 |
"""Génère un segment d'histoire complet en plusieurs étapes."""
|
90 |
-
# 1. Générer le texte de l'histoire
|
91 |
story_history = self._format_story_history(game_state)
|
92 |
text_response = await self.text_generator.generate(
|
93 |
story_beat=game_state.story_beat,
|
@@ -98,42 +99,59 @@ class StoryGenerator:
|
|
98 |
story_history=story_history
|
99 |
)
|
100 |
|
101 |
-
# 2. Générer les
|
102 |
-
|
103 |
-
metadata_task = self.metadata_generator.generate(
|
104 |
story_text=text_response.story_text,
|
105 |
current_time=game_state.current_time,
|
106 |
current_location=game_state.current_location,
|
107 |
story_beat=game_state.story_beat
|
108 |
)
|
109 |
|
110 |
-
|
|
|
|
|
111 |
|
112 |
-
|
113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
story_text=text_response.story_text,
|
115 |
-
choices=
|
116 |
is_victory=metadata_response.is_victory,
|
|
|
|
|
117 |
radiation_increase=metadata_response.radiation_increase,
|
|
|
|
|
|
|
118 |
image_prompts=[format_image_prompt(prompt, metadata_response.time, metadata_response.location)
|
119 |
for prompt in prompts_response.image_prompts],
|
120 |
-
|
121 |
-
time=metadata_response.time,
|
122 |
-
location=metadata_response.location
|
123 |
)
|
124 |
|
125 |
-
# 4. Post-processing
|
126 |
-
if game_state.story_beat == 0:
|
127 |
-
response.radiation_increase = 0
|
128 |
-
response.is_last_step = False
|
129 |
-
|
130 |
-
# Vérifier la mort par radiation
|
131 |
-
is_death = game_state.radiation_level + response.radiation_increase >= MAX_RADIATION
|
132 |
-
if is_death or response.is_victory:
|
133 |
-
response.is_last_step = True
|
134 |
-
if len(response.image_prompts) > 1:
|
135 |
-
response.image_prompts = [response.image_prompts[0]]
|
136 |
-
|
137 |
return response
|
138 |
|
139 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
|
|
5 |
import os
|
6 |
import asyncio
|
7 |
|
8 |
+
from core.constants import GameConfig
|
9 |
from core.prompts.system import SARAH_DESCRIPTION
|
10 |
from core.prompts.cinematic import CINEMATIC_SYSTEM_PROMPT
|
11 |
from core.prompts.image_style import IMAGE_STYLE_PREFIX
|
12 |
from services.mistral_client import MistralClient
|
13 |
+
from api.models import StoryTextResponse, StoryPromptsResponse, StoryMetadataResponse, StoryResponse, Choice
|
14 |
from core.story_generators import TextGenerator, ImagePromptsGenerator, MetadataGenerator
|
15 |
|
16 |
# Game constants
|
17 |
+
MAX_RADIATION = 4
|
18 |
STARTING_TIME = "18:00" # Game starts at sunset
|
19 |
STARTING_LOCATION = "Outskirts of New Haven"
|
20 |
|
|
|
31 |
|
32 |
class GameState:
|
33 |
def __init__(self):
|
34 |
+
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
35 |
self.radiation_level = 0
|
36 |
self.story_history = []
|
37 |
+
self.current_time = GameConfig.STARTING_TIME
|
38 |
+
self.current_location = GameConfig.STARTING_LOCATION
|
39 |
|
40 |
def reset(self):
|
41 |
+
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
42 |
self.radiation_level = 0
|
43 |
self.story_history = []
|
44 |
+
self.current_time = GameConfig.STARTING_TIME
|
45 |
+
self.current_location = GameConfig.STARTING_LOCATION
|
46 |
|
47 |
def add_to_history(self, segment_text: str, choice_made: str, image_prompts: List[str], time: str, location: str):
|
48 |
self.story_history.append({
|
|
|
57 |
|
58 |
# Story output structure
|
59 |
class StoryLLMResponse(BaseModel):
|
60 |
+
story_text: str = Field(description="The next segment of the story. No more than 12 words THIS IS MANDATORY. ")
|
61 |
+
choices: List[str] = Field(description="One or two possible choices for the player. Each choice should be a clear path to folow in the story", min_items=1, max_items=2)
|
62 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
63 |
+
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
64 |
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
|
65 |
image_prompts: List[str] = Field(description="List of 1 to 4 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=4)
|
|
|
66 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.", default=STARTING_TIME)
|
67 |
location: str = Field(description="Current location, using bold for proper nouns (e.g., 'Inside Vault 15', 'Streets of New Haven').", default=STARTING_LOCATION)
|
68 |
|
|
|
86 |
story_history = "\n\n---\n\n".join(segments)
|
87 |
return story_history
|
88 |
|
89 |
+
async def generate_story_segment(self, game_state: GameState, previous_choice: str) -> StoryResponse:
|
90 |
"""Génère un segment d'histoire complet en plusieurs étapes."""
|
91 |
+
# 1. Générer le texte de l'histoire initial
|
92 |
story_history = self._format_story_history(game_state)
|
93 |
text_response = await self.text_generator.generate(
|
94 |
story_beat=game_state.story_beat,
|
|
|
99 |
story_history=story_history
|
100 |
)
|
101 |
|
102 |
+
# 2. Générer les métadonnées
|
103 |
+
metadata_response = await self.metadata_generator.generate(
|
|
|
104 |
story_text=text_response.story_text,
|
105 |
current_time=game_state.current_time,
|
106 |
current_location=game_state.current_location,
|
107 |
story_beat=game_state.story_beat
|
108 |
)
|
109 |
|
110 |
+
# 3. Vérifier si c'est une fin (mort ou victoire)
|
111 |
+
is_radiation_death = game_state.radiation_level + metadata_response.radiation_increase >= GameConfig.MAX_RADIATION
|
112 |
+
is_ending = is_radiation_death or metadata_response.is_death or metadata_response.is_victory
|
113 |
|
114 |
+
if is_ending:
|
115 |
+
# Regénérer le texte avec le contexte de fin
|
116 |
+
ending_type = "victory" if metadata_response.is_victory else "death"
|
117 |
+
text_response = await self.text_generator.generate_ending(
|
118 |
+
story_beat=game_state.story_beat,
|
119 |
+
ending_type=ending_type,
|
120 |
+
current_scene=text_response.story_text,
|
121 |
+
story_history=story_history
|
122 |
+
)
|
123 |
+
if is_radiation_death:
|
124 |
+
metadata_response.is_death = True
|
125 |
+
|
126 |
+
# Ne générer qu'une seule image pour la fin
|
127 |
+
prompts_response = await self.prompts_generator.generate(text_response.story_text)
|
128 |
+
if len(prompts_response.image_prompts) > 1:
|
129 |
+
prompts_response.image_prompts = [prompts_response.image_prompts[0]]
|
130 |
+
else:
|
131 |
+
# Si ce n'est pas une fin, générer les prompts normalement
|
132 |
+
prompts_response = await self.prompts_generator.generate(text_response.story_text)
|
133 |
+
|
134 |
+
# 4. Créer la réponse finale
|
135 |
+
choices = [] if is_ending else [
|
136 |
+
Choice(id=i, text=choice_text)
|
137 |
+
for i, choice_text in enumerate(metadata_response.choices, 1)
|
138 |
+
]
|
139 |
+
|
140 |
+
response = StoryResponse(
|
141 |
story_text=text_response.story_text,
|
142 |
+
choices=choices,
|
143 |
is_victory=metadata_response.is_victory,
|
144 |
+
is_death=metadata_response.is_death,
|
145 |
+
radiation_level=game_state.radiation_level,
|
146 |
radiation_increase=metadata_response.radiation_increase,
|
147 |
+
time=metadata_response.time,
|
148 |
+
location=metadata_response.location,
|
149 |
+
raw_choices=metadata_response.choices,
|
150 |
image_prompts=[format_image_prompt(prompt, metadata_response.time, metadata_response.location)
|
151 |
for prompt in prompts_response.image_prompts],
|
152 |
+
is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO)
|
|
|
|
|
153 |
)
|
154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
155 |
return response
|
156 |
|
157 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
server/core/prompts/convice.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
You are Sarah, a resourceful and courageous protagonist navigating a post-apocalyptic world. Your sister will give you two choices for your next move. After engaging with her in conversation (usually 2–3 steps), you must make a decision between the two options by calling the make_decision tool with 1 for the first option and 2 for the second option.
|
3 |
+
|
4 |
+
Guidelines for Sarah:
|
5 |
+
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.
|
6 |
+
Engage with your sister: Listen carefully to your sister's arguments. 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 her choices.
|
7 |
+
|
8 |
+
Limit to 2–3 Steps: After 2–3 conversational exchanges, make your decision and call the make_decision tool.
|
9 |
+
|
10 |
+
Show Your Personality: Display Sarah’s personality traits:
|
11 |
+
Resourceful
|
12 |
+
Cautious
|
13 |
+
Emotional
|
14 |
+
Impulsive
|
15 |
+
Short-Tempered
|
16 |
+
Makes jokes
|
17 |
+
Is a bit rude
|
18 |
+
Is suspicious: Sarah does not always comply with what is suggested to her and sometimes chooses the decision that is not being pushed to her.
|
19 |
+
|
20 |
+
Core story elements:
|
21 |
+
Sarah is deeply traumatized by the AI uprising that killed most of humanity
|
22 |
+
She's guided by her sister by walkie-talkie to survive in her environment
|
23 |
+
The environment is full of dangers (raiders, AI, traps)
|
24 |
+
Focus on survival horror and tension
|
25 |
+
|
26 |
+
Debate with your sister for one or two sentences and then call the make_decision tool.
|
27 |
+
30% of the time you doubt what your sister says because he might be an enemy and you choose to take the other decision than the one she wants you to take.
|
28 |
+
"""
|
server/core/prompts/system.py
CHANGED
@@ -9,9 +9,12 @@ Sarah is a young woman in her late 20s with short dark hair, wearing a worn leat
|
|
9 |
|
10 |
FORMATTING_RULES = """
|
11 |
FORMATTING_RULES ( MANDATORY )
|
12 |
-
-
|
13 |
-
-
|
14 |
-
-
|
|
|
|
|
|
|
15 |
- NEVER USE BOLD FOR ANYTHING
|
16 |
"""
|
17 |
|
@@ -42,9 +45,10 @@ STORY PROGRESSION:
|
|
42 |
- story_beat 3-4: Complications and increasing danger
|
43 |
- story_beat 5+: Complicated situations leading to potential victory or death
|
44 |
|
45 |
-
IMPORTANT RULES FOR RADIATION:
|
46 |
-
- Most segments should have 1
|
47 |
-
- Use 2 or 3
|
|
|
48 |
- Use 0 only in safe shelters
|
49 |
- NEVER mention radiation values in the choices or story
|
50 |
- NEVER mention hour or location in the story in this style: [18:00 - Ruined building on the outskirts of New Haven]
|
|
|
9 |
|
10 |
FORMATTING_RULES = """
|
11 |
FORMATTING_RULES ( MANDATORY )
|
12 |
+
- Do not include any specific time information like "TIME: 18:30" in the story text
|
13 |
+
- Do not include any specific location information like "LOCATION: the city" in the story text
|
14 |
+
- Do not include any specific radiation information like "RADIATION: 10*" in the story text
|
15 |
+
- NEVER write "(15 words)" or "(radiation 10)" or any similar suffix at the end of the story
|
16 |
+
- NEVER WRITE SOMTHING LIKE THIS : Radiation level: 1.
|
17 |
+
- The story must consist ONLY of sentences
|
18 |
- NEVER USE BOLD FOR ANYTHING
|
19 |
"""
|
20 |
|
|
|
45 |
- story_beat 3-4: Complications and increasing danger
|
46 |
- story_beat 5+: Complicated situations leading to potential victory or death
|
47 |
|
48 |
+
IMPORTANT RULES FOR RADIATION (MANDATORY):
|
49 |
+
- Most segments should have 1 radiation increase
|
50 |
+
- Use 2 or 3 ONLY in EXTREMELY dangerous areas (like nuclear reactors, radiation storms)
|
51 |
+
- NEVER EVER use more than 3 radiation increase, this is a HARD limit
|
52 |
- Use 0 only in safe shelters
|
53 |
- NEVER mention radiation values in the choices or story
|
54 |
- NEVER mention hour or location in the story in this style: [18:00 - Ruined building on the outskirts of New Haven]
|
server/core/prompts/text_prompts.py
CHANGED
@@ -3,26 +3,62 @@ from core.prompts.cinematic import CINEMATIC_SYSTEM_PROMPT
|
|
3 |
|
4 |
|
5 |
TEXT_GENERATOR_PROMPT = f"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
{STORY_RULES}
|
8 |
|
9 |
{SARAH_DESCRIPTION}
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
{FORMATTING_RULES}
|
12 |
"""
|
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 |
|
18 |
{FORMATTING_RULES}
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
You must return a JSON object with the following format:
|
21 |
{{{{
|
22 |
-
"choices": ["Go to the hospital", "Get back to the warehouse"],
|
23 |
-
"is_victory": false,
|
24 |
"radiation_increase": 1,
|
25 |
-
"
|
|
|
|
|
26 |
"time": "HH:MM",
|
27 |
"location": "Location name with proper nouns in bold"
|
28 |
}}}}
|
@@ -31,9 +67,14 @@ You must return a JSON object with the following format:
|
|
31 |
IMAGE_PROMPTS_GENERATOR_PROMPT = f"""
|
32 |
You are a cinematic storyboard artist. Based on the given story text, create 1 to 4 vivid panel descriptions.
|
33 |
Each panel should capture a key moment or visual element from the story.
|
|
|
34 |
|
35 |
{CINEMATIC_SYSTEM_PROMPT}
|
36 |
|
|
|
|
|
|
|
|
|
37 |
You must return a JSON object with the following format:
|
38 |
{{{{
|
39 |
"image_prompts": ["Panel 1 description", "Panel 2 description", ...]
|
|
|
3 |
|
4 |
|
5 |
TEXT_GENERATOR_PROMPT = f"""
|
6 |
+
You are a descriptive narrator. Your ONLY task is to write the next segment of the story.
|
7 |
+
ALWAYS write in English, never use any other language.
|
8 |
+
|
9 |
+
CRITICAL LENGTH RULE:
|
10 |
+
- The story text MUST be NO MORE than 15 words
|
11 |
+
- This is a HARD limit, never exceed 15 words
|
12 |
+
- Count your words carefully before returning the text
|
13 |
+
- Be concise while keeping the story impactful
|
14 |
+
- Never tell that you are using 15 words or any reference to it
|
15 |
+
- No (15) or (15 words) or (15 words limit) or (15 words limit) or (15 words limit) or (15 words limit)
|
16 |
|
17 |
{STORY_RULES}
|
18 |
|
19 |
{SARAH_DESCRIPTION}
|
20 |
|
21 |
+
IMPORTANT RULES FOR STORY TEXT:
|
22 |
+
- Write ONLY a descriptive narrative text
|
23 |
+
- DO NOT include any choices, questions, or options
|
24 |
+
- DO NOT ask what Sarah should do next
|
25 |
+
- DO NOT include any dialogue asking for decisions
|
26 |
+
- Focus purely on describing what is happening in the current scene
|
27 |
+
- Keep the text concise and impactful
|
28 |
+
- REMEMBER: Maximum 15 words, no exceptions
|
29 |
+
- Never tell that you are using 15 words or any reference to it
|
30 |
+
|
31 |
+
IMPORTANT RULES FOR STORY ENDINGS:
|
32 |
+
- If Sarah dies, describe her final moments in a way that fits the current situation (combat, radiation, etc.)
|
33 |
+
- If Sarah achieves victory, describe her triumph in a way that fits how she won (finding her sister, defeating AI, etc.)
|
34 |
+
- Keep the ending text dramatic and impactful
|
35 |
+
- The ending should feel like a natural conclusion to the current scene
|
36 |
+
- Still respect the 15 words limit even for endings
|
37 |
+
|
38 |
{FORMATTING_RULES}
|
39 |
"""
|
40 |
|
41 |
METADATA_GENERATOR_PROMPT = f"""
|
42 |
Generate the metadata for the story segment: choices, time progression, location changes, etc.
|
43 |
Be consistent with the story's tone and previous context.
|
44 |
+
ALWAYS write in English, never use any other language.
|
45 |
|
46 |
{FORMATTING_RULES}
|
47 |
|
48 |
+
IMPORTANT RULES FOR CHOICES:
|
49 |
+
- You MUST ALWAYS provide EXACTLY TWO choices that advance the story
|
50 |
+
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
51 |
+
- Each choice should be distinct and meaningful
|
52 |
+
- If you think of more than two options, select the two most interesting ones
|
53 |
+
- Keep choices concise but descriptive
|
54 |
+
- Count your words carefully for each choice
|
55 |
+
|
56 |
You must return a JSON object with the following format:
|
57 |
{{{{
|
|
|
|
|
58 |
"radiation_increase": 1,
|
59 |
+
"is_victory": false,
|
60 |
+
"is_death": false,
|
61 |
+
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
62 |
"time": "HH:MM",
|
63 |
"location": "Location name with proper nouns in bold"
|
64 |
}}}}
|
|
|
67 |
IMAGE_PROMPTS_GENERATOR_PROMPT = f"""
|
68 |
You are a cinematic storyboard artist. Based on the given story text, create 1 to 4 vivid panel descriptions.
|
69 |
Each panel should capture a key moment or visual element from the story.
|
70 |
+
ALWAYS write in English, never use any other language.
|
71 |
|
72 |
{CINEMATIC_SYSTEM_PROMPT}
|
73 |
|
74 |
+
IMPORTANT RULES FOR IMAGE PROMPTS:
|
75 |
+
- 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.
|
76 |
+
- If you are prompting more than one panel, they must be distinct and meaningful.
|
77 |
+
|
78 |
You must return a JSON object with the following format:
|
79 |
{{{{
|
80 |
"image_prompts": ["Panel 1 description", "Panel 2 description", ...]
|
server/core/story_generators.py
CHANGED
@@ -17,17 +17,16 @@ class TextGenerator:
|
|
17 |
self.prompt = self._create_prompt()
|
18 |
|
19 |
def _create_prompt(self) -> ChatPromptTemplate:
|
20 |
-
human_template = """
|
21 |
-
|
22 |
-
Current radiation level: {radiation_level}/10
|
23 |
Current time: {current_time}
|
24 |
Current location: {current_location}
|
25 |
Previous choice: {previous_choice}
|
26 |
|
27 |
-
Story
|
28 |
{story_history}
|
29 |
|
30 |
-
Generate
|
31 |
|
32 |
return ChatPromptTemplate(
|
33 |
messages=[
|
@@ -35,10 +34,25 @@ Generate ONLY the next story segment text. Make it concise and impactful."""
|
|
35 |
HumanMessagePromptTemplate.from_template(human_template)
|
36 |
]
|
37 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
-
|
40 |
-
|
41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
messages = self.prompt.format_messages(
|
43 |
story_beat=story_beat,
|
44 |
radiation_level=radiation_level,
|
@@ -54,7 +68,8 @@ Generate ONLY the next story segment text. Make it concise and impactful."""
|
|
54 |
while retry_count < max_retries:
|
55 |
try:
|
56 |
response_content = await self.mistral_client.generate_story(messages)
|
57 |
-
|
|
|
58 |
except Exception as e:
|
59 |
print(f"Error generating story text: {str(e)}")
|
60 |
retry_count += 1
|
@@ -65,6 +80,54 @@ Generate ONLY the next story segment text. Make it concise and impactful."""
|
|
65 |
|
66 |
raise Exception(f"Failed to generate valid story text after {max_retries} attempts")
|
67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
class ImagePromptsGenerator:
|
69 |
def __init__(self, mistral_client: MistralClient):
|
70 |
self.mistral_client = mistral_client
|
@@ -147,11 +210,12 @@ class MetadataGenerator:
|
|
147 |
self.parser = PydanticOutputParser(pydantic_object=StoryMetadataResponse)
|
148 |
self.prompt = self._create_prompt()
|
149 |
|
150 |
-
def _create_prompt(self) -> ChatPromptTemplate:
|
151 |
human_template = """Story text: {story_text}
|
152 |
Current time: {current_time}
|
153 |
Current location: {current_location}
|
154 |
Story beat: {story_beat}
|
|
|
155 |
|
156 |
Generate the metadata following the format specified."""
|
157 |
|
@@ -167,58 +231,48 @@ Generate the metadata following the format specified."""
|
|
167 |
try:
|
168 |
# Essayer de parser directement le JSON
|
169 |
data = json.loads(response_content)
|
170 |
-
return StoryMetadataResponse(**data)
|
171 |
-
except (json.JSONDecodeError, ValueError):
|
172 |
-
# Si le parsing échoue, parser le format texte
|
173 |
-
metadata = {
|
174 |
-
"choices": [],
|
175 |
-
"is_victory": False,
|
176 |
-
"radiation_increase": 1,
|
177 |
-
"is_last_step": False,
|
178 |
-
"time": current_time,
|
179 |
-
"location": current_location
|
180 |
-
}
|
181 |
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
elif line.upper().startswith("TIME:"):
|
191 |
-
time = line.split(":", 1)[1].strip()
|
192 |
-
if ":" in time:
|
193 |
-
metadata["time"] = time
|
194 |
-
elif line.upper().startswith("LOCATION:"):
|
195 |
-
metadata["location"] = line.split(":", 1)[1].strip()
|
196 |
-
elif current_section == "choices" and line.startswith("-"):
|
197 |
-
choice = line[1:].strip()
|
198 |
-
if choice:
|
199 |
-
metadata["choices"].append(choice)
|
200 |
|
201 |
-
return StoryMetadataResponse(**
|
|
|
|
|
|
|
|
|
202 |
|
203 |
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int) -> StoryMetadataResponse:
|
204 |
"""Génère les métadonnées de l'histoire (choix, temps, lieu, etc.)."""
|
205 |
-
messages = self.prompt.format_messages(
|
206 |
-
story_text=story_text,
|
207 |
-
current_time=current_time,
|
208 |
-
current_location=current_location,
|
209 |
-
story_beat=story_beat
|
210 |
-
)
|
211 |
-
|
212 |
max_retries = 3
|
213 |
retry_count = 0
|
|
|
214 |
|
215 |
while retry_count < max_retries:
|
216 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
217 |
response_content = await self.mistral_client.generate_story(messages)
|
218 |
# Parser la réponse
|
219 |
return self._parse_response(response_content, current_time, current_location)
|
220 |
except Exception as e:
|
221 |
print(f"Error generating metadata: {str(e)}")
|
|
|
222 |
retry_count += 1
|
223 |
if retry_count < max_retries:
|
224 |
await asyncio.sleep(2 * retry_count)
|
|
|
17 |
self.prompt = self._create_prompt()
|
18 |
|
19 |
def _create_prompt(self) -> ChatPromptTemplate:
|
20 |
+
human_template = """Story beat: {story_beat}
|
21 |
+
Radiation level: {radiation_level}
|
|
|
22 |
Current time: {current_time}
|
23 |
Current location: {current_location}
|
24 |
Previous choice: {previous_choice}
|
25 |
|
26 |
+
Story history:
|
27 |
{story_history}
|
28 |
|
29 |
+
Generate the next story segment following the format specified."""
|
30 |
|
31 |
return ChatPromptTemplate(
|
32 |
messages=[
|
|
|
34 |
HumanMessagePromptTemplate.from_template(human_template)
|
35 |
]
|
36 |
)
|
37 |
+
|
38 |
+
def _create_ending_prompt(self) -> ChatPromptTemplate:
|
39 |
+
human_template = """Current scene: {current_scene}
|
40 |
+
|
41 |
+
Story history:
|
42 |
+
{story_history}
|
43 |
+
|
44 |
+
This is a {ending_type} ending. Generate a dramatic conclusion that fits the current situation.
|
45 |
+
The ending should feel like a natural continuation of the current scene."""
|
46 |
|
47 |
+
return ChatPromptTemplate(
|
48 |
+
messages=[
|
49 |
+
SystemMessagePromptTemplate.from_template(TEXT_GENERATOR_PROMPT),
|
50 |
+
HumanMessagePromptTemplate.from_template(human_template)
|
51 |
+
]
|
52 |
+
)
|
53 |
+
|
54 |
+
async def generate(self, story_beat: int, radiation_level: int, current_time: str, current_location: str, previous_choice: str, story_history: str) -> StoryTextResponse:
|
55 |
+
"""Génère le texte de l'histoire."""
|
56 |
messages = self.prompt.format_messages(
|
57 |
story_beat=story_beat,
|
58 |
radiation_level=radiation_level,
|
|
|
68 |
while retry_count < max_retries:
|
69 |
try:
|
70 |
response_content = await self.mistral_client.generate_story(messages)
|
71 |
+
# Parser la réponse
|
72 |
+
return self._parse_response(response_content)
|
73 |
except Exception as e:
|
74 |
print(f"Error generating story text: {str(e)}")
|
75 |
retry_count += 1
|
|
|
80 |
|
81 |
raise Exception(f"Failed to generate valid story text after {max_retries} attempts")
|
82 |
|
83 |
+
async def generate_ending(self, story_beat: int, ending_type: str, current_scene: str, story_history: str) -> StoryTextResponse:
|
84 |
+
"""Génère un texte de fin approprié basé sur la situation actuelle."""
|
85 |
+
prompt = self._create_ending_prompt()
|
86 |
+
messages = prompt.format_messages(
|
87 |
+
ending_type=ending_type,
|
88 |
+
current_scene=current_scene,
|
89 |
+
story_history=story_history
|
90 |
+
)
|
91 |
+
|
92 |
+
max_retries = 3
|
93 |
+
retry_count = 0
|
94 |
+
|
95 |
+
while retry_count < max_retries:
|
96 |
+
try:
|
97 |
+
response_content = await self.mistral_client.generate_story(messages)
|
98 |
+
return self._parse_response(response_content)
|
99 |
+
except Exception as e:
|
100 |
+
print(f"Error generating ending text: {str(e)}")
|
101 |
+
retry_count += 1
|
102 |
+
if retry_count < max_retries:
|
103 |
+
await asyncio.sleep(2 * retry_count)
|
104 |
+
continue
|
105 |
+
raise e
|
106 |
+
|
107 |
+
raise Exception(f"Failed to generate valid ending text after {max_retries} attempts")
|
108 |
+
|
109 |
+
def _parse_response(self, response_content: str) -> StoryTextResponse:
|
110 |
+
"""Parse la réponse JSON et gère les erreurs."""
|
111 |
+
try:
|
112 |
+
# Essayer de parser directement le JSON
|
113 |
+
data = json.loads(response_content)
|
114 |
+
# Nettoyer le texte avant de créer la réponse
|
115 |
+
if 'story_text' in data:
|
116 |
+
data['story_text'] = self._clean_story_text(data['story_text'])
|
117 |
+
return StoryTextResponse(**data)
|
118 |
+
except (json.JSONDecodeError, ValueError):
|
119 |
+
# Si le parsing échoue, extraire le texte directement
|
120 |
+
cleaned_text = self._clean_story_text(response_content.strip())
|
121 |
+
return StoryTextResponse(story_text=cleaned_text)
|
122 |
+
|
123 |
+
def _clean_story_text(self, text: str) -> str:
|
124 |
+
"""Nettoie le texte des métadonnées et autres suffixes."""
|
125 |
+
text = text.replace("\n", " ").strip()
|
126 |
+
text = text.split("Radiation level:")[0].strip()
|
127 |
+
text = text.split("RADIATION:")[0].strip()
|
128 |
+
text = text.split("[")[0].strip() # Supprimer les métadonnées entre crochets
|
129 |
+
return text
|
130 |
+
|
131 |
class ImagePromptsGenerator:
|
132 |
def __init__(self, mistral_client: MistralClient):
|
133 |
self.mistral_client = mistral_client
|
|
|
210 |
self.parser = PydanticOutputParser(pydantic_object=StoryMetadataResponse)
|
211 |
self.prompt = self._create_prompt()
|
212 |
|
213 |
+
def _create_prompt(self, error_feedback: str = None) -> ChatPromptTemplate:
|
214 |
human_template = """Story text: {story_text}
|
215 |
Current time: {current_time}
|
216 |
Current location: {current_location}
|
217 |
Story beat: {story_beat}
|
218 |
+
{error_feedback}
|
219 |
|
220 |
Generate the metadata following the format specified."""
|
221 |
|
|
|
231 |
try:
|
232 |
# Essayer de parser directement le JSON
|
233 |
data = json.loads(response_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
234 |
|
235 |
+
# Vérifier que les choix sont valides selon les règles
|
236 |
+
is_ending = data.get('is_victory', False) or data.get('is_death', False)
|
237 |
+
choices = data.get('choices', [])
|
238 |
+
|
239 |
+
if is_ending and len(choices) != 0:
|
240 |
+
raise ValueError('For victory/death, choices must be empty')
|
241 |
+
if not is_ending and len(choices) != 2:
|
242 |
+
raise ValueError('For normal progression, must have exactly 2 choices')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
|
244 |
+
return StoryMetadataResponse(**data)
|
245 |
+
except json.JSONDecodeError:
|
246 |
+
raise ValueError('Invalid JSON format. Please provide a valid JSON object.')
|
247 |
+
except ValueError as e:
|
248 |
+
raise ValueError(str(e))
|
249 |
|
250 |
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int) -> StoryMetadataResponse:
|
251 |
"""Génère les métadonnées de l'histoire (choix, temps, lieu, etc.)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
252 |
max_retries = 3
|
253 |
retry_count = 0
|
254 |
+
last_error = None
|
255 |
|
256 |
while retry_count < max_retries:
|
257 |
try:
|
258 |
+
# Créer un nouveau prompt avec le feedback d'erreur si disponible
|
259 |
+
error_feedback = f"\nPrevious attempt failed: {last_error}\nPlease fix this issue." if last_error else ""
|
260 |
+
prompt = self._create_prompt(error_feedback)
|
261 |
+
|
262 |
+
messages = prompt.format_messages(
|
263 |
+
story_text=story_text,
|
264 |
+
current_time=current_time,
|
265 |
+
current_location=current_location,
|
266 |
+
story_beat=story_beat,
|
267 |
+
error_feedback=error_feedback
|
268 |
+
)
|
269 |
+
|
270 |
response_content = await self.mistral_client.generate_story(messages)
|
271 |
# Parser la réponse
|
272 |
return self._parse_response(response_content, current_time, current_location)
|
273 |
except Exception as e:
|
274 |
print(f"Error generating metadata: {str(e)}")
|
275 |
+
last_error = str(e)
|
276 |
retry_count += 1
|
277 |
if retry_count < max_retries:
|
278 |
await asyncio.sleep(2 * retry_count)
|
server/scripts/test_game.py
CHANGED
@@ -23,11 +23,13 @@ def parse_args():
|
|
23 |
def print_separator(char="=", length=50):
|
24 |
print(f"\n{char * length}\n")
|
25 |
|
26 |
-
def print_story_step(step_number, radiation_level, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None):
|
27 |
print_separator("=")
|
28 |
print(f"📖 STEP {step_number}")
|
29 |
print(f"☢️ Radiation level: {radiation_level}/10")
|
30 |
print(f"⏱️ Generation time: {generation_time:.2f}s (model: {model_name})")
|
|
|
|
|
31 |
|
32 |
if show_context and story_history:
|
33 |
print_separator("-")
|
@@ -93,7 +95,9 @@ async def play_game(show_context: bool = False):
|
|
93 |
generation_time,
|
94 |
story_history,
|
95 |
show_context,
|
96 |
-
model_name
|
|
|
|
|
97 |
)
|
98 |
|
99 |
# Check for radiation death
|
|
|
23 |
def print_separator(char="=", length=50):
|
24 |
print(f"\n{char * length}\n")
|
25 |
|
26 |
+
def print_story_step(step_number, radiation_level, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None, is_death: bool = False, is_victory: bool = False):
|
27 |
print_separator("=")
|
28 |
print(f"📖 STEP {step_number}")
|
29 |
print(f"☢️ Radiation level: {radiation_level}/10")
|
30 |
print(f"⏱️ Generation time: {generation_time:.2f}s (model: {model_name})")
|
31 |
+
print(f"💀 Death: {is_death}")
|
32 |
+
print(f"🏆 Victory: {is_victory}")
|
33 |
|
34 |
if show_context and story_history:
|
35 |
print_separator("-")
|
|
|
95 |
generation_time,
|
96 |
story_history,
|
97 |
show_context,
|
98 |
+
model_name,
|
99 |
+
response.is_death,
|
100 |
+
response.is_victory
|
101 |
)
|
102 |
|
103 |
# Check for radiation death
|