tfrere commited on
Commit
2e787a2
·
1 Parent(s): 33d38e5
Files changed (41) hide show
  1. .DS_Store +0 -0
  2. .gitignore +1 -0
  3. README.md +1 -1
  4. client/package.json +3 -1
  5. client/public/sounds/drawing-1.mp3 +0 -0
  6. client/public/sounds/drawing-2.mp3 +0 -0
  7. client/public/sounds/drawing-3.mp3 +0 -0
  8. client/public/sounds/drawing-4.mp3 +0 -0
  9. client/public/sounds/drawing-5.mp3 +0 -0
  10. client/public/sounds/page-flip-1.mp3 +0 -0
  11. client/public/sounds/page-flip-2.mp3 +0 -0
  12. client/public/sounds/page-flip-3.mp3 +0 -0
  13. client/public/sounds/page-flip-4.mp3 +0 -0
  14. client/public/sounds/page-flip-5.mp3 +0 -0
  15. client/public/sounds/page-flip-6.mp3 +0 -0
  16. client/public/sounds/page-flip-7.mp3 +0 -0
  17. client/public/{talky-walky-off.mp3 → sounds/talky-walky-off.mp3} +0 -0
  18. client/public/{talky-walky-on.mp3 → sounds/talky-walky-on.mp3} +0 -0
  19. client/src/components/ErrorDisplay.jsx +43 -0
  20. client/src/components/StoryChoices.jsx +85 -1
  21. client/src/components/StoryManager.jsx +0 -182
  22. client/src/hooks/useImageGeneration.js +0 -64
  23. client/src/hooks/usePageSound.js +46 -0
  24. client/src/hooks/useStoryCapture.js +55 -124
  25. client/src/hooks/useWritingSound.js +46 -0
  26. client/src/layouts/ComicLayout.jsx +65 -33
  27. client/src/layouts/config.js +17 -62
  28. client/src/main.jsx +1 -1
  29. client/src/pages/Game.jsx +127 -108
  30. client/src/pages/{tutorial/Tutorial.jsx → Tutorial.jsx} +0 -4
  31. client/src/pages/game/App.jsx +0 -794
  32. client/yarn.lock +17 -0
  33. server/api/models.py +60 -16
  34. server/api/routes/chat.py +6 -8
  35. server/core/constants.py +18 -0
  36. server/core/game_logic.py +53 -35
  37. server/core/prompts/convice.py +28 -0
  38. server/core/prompts/system.py +10 -6
  39. server/core/prompts/text_prompts.py +44 -3
  40. server/core/story_generators.py +101 -47
  41. 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: team-10
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({ choices = [], onChoice, disabled = false }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 html2canvas from "html2canvas";
3
 
4
  export function useStoryCapture() {
5
- const captureStory = useCallback(async (containerRef) => {
6
- if (!containerRef.current) return null;
 
 
7
 
8
- try {
9
- // Trouver le conteneur scrollable (ComicLayout)
10
- const scrollContainer = containerRef.current.querySelector(
11
- "[data-comic-layout]"
12
- );
13
- if (!scrollContainer) {
14
  console.error("Comic layout container not found");
15
  return null;
16
  }
17
 
18
- // Sauvegarder les styles et positions originaux
19
- const originalStyles = new Map();
20
- const elementsToRestore = [
21
- containerRef.current,
22
- scrollContainer,
23
- ...Array.from(scrollContainer.children),
24
- ];
25
-
26
- // Sauvegarder les styles originaux
27
- elementsToRestore.forEach((el) => {
28
- originalStyles.set(el, {
29
- style: el.style.cssText,
30
- scroll: { left: el.scrollLeft, top: el.scrollTop },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Restaurer tous les styles originaux
118
- elementsToRestore.forEach((el) => {
119
- const original = originalStyles.get(el);
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
- return canvas.toDataURL("image/png", 1.0);
128
- } catch (error) {
129
- console.error("Error capturing story:", error);
130
- return null;
131
- }
132
- }, []);
 
 
133
 
134
  const downloadStoryImage = useCallback(
135
  async (containerRef, filename = "my-story.png") => {
136
- const imageUrl = await captureStory(containerRef);
137
- if (!imageUrl) return;
138
 
139
  const link = document.createElement("a");
140
- link.href = imageUrl;
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 && (choices?.length > 0 || showScreenshot) && (
94
- <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
95
- {showScreenshot && (
96
- <Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
97
- <Tooltip title="Capturer l'histoire">
98
- <IconButton
99
- onClick={onScreenshot}
100
- sx={{
101
- border: "1px solid",
102
- borderColor: "rgba(255, 255, 255, 0.23)",
103
- color: "white",
104
- p: 2,
105
- "&:hover": {
106
- borderColor: "white",
107
- backgroundColor: "rgba(255, 255, 255, 0.05)",
108
- },
109
- }}
110
- >
111
- <PhotoCameraIcon />
112
- </IconButton>
113
- </Tooltip>
114
- </Box>
115
- )}
116
- {choices?.length > 0 && (
117
- <StoryChoices
118
- choices={choices}
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 new layouts are added
141
  useEffect(() => {
142
- if (scrollContainerRef.current) {
 
 
143
  scrollContainerRef.current.scrollTo({
144
  left: scrollContainerRef.current.scrollWidth,
145
  behavior: "smooth",
146
  });
147
  }
148
- }, [segments.length]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 1024 },
43
- PORTRAIT_MEDIUM: { width: 768, height: 1024 },
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.PORTRAIT_MEDIUM, gridColumn: "2", gridRow: "1" }, // Portrait top right
73
  { ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "2" }, // Landscape middle left
74
- { ...PANEL_SIZES.PORTRAIT_MEDIUM, gridColumn: "2", gridRow: "2" }, // Portrait right
75
  ],
76
  },
77
  LAYOUT_2: {
@@ -79,7 +36,7 @@ export const LAYOUTS = {
79
  gridRows: 2,
80
  panels: [
81
  {
82
- ...PANEL_SIZES.SQUARE_LARGE,
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.PANORAMIC, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
105
  {
106
  ...PANEL_SIZES.PORTRAIT,
107
  gridColumn: "1",
108
  gridRow: GRID.FULL_HEIGHT_FROM_2,
109
  }, // Tall portrait left
110
- { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "2" }, // Square middle right
111
- { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "3" }, // Square bottom right
112
  ],
113
  },
114
  LAYOUT_5: {
115
  gridCols: 3,
116
  gridRows: 3,
117
  panels: [
118
- { ...PANEL_SIZES.PANORAMIC, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
119
  { ...PANEL_SIZES.PORTRAIT, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
120
  {
121
- ...PANEL_SIZES.SQUARE_LARGE,
122
  gridColumn: "2 / span 2",
123
  gridRow: "2 / span 2",
124
  }, // Large square right
125
  ],
126
  },
127
- LAYOUT_6: {
128
- gridCols: 3,
129
  gridRows: 2,
130
  panels: [
131
- { ...PANEL_SIZES.PORTRAIT, gridColumn: "1", gridRow: GRID.FULL_HEIGHT }, // Tall portrait left
132
- { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "1" }, // Square top middle
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: ["LAYOUT_2"], // Layouts avec exactement 2 panneaux
148
  3: ["LAYOUT_5"], // Layouts avec exactement 3 panneaux
149
- 4: ["LAYOUT_3", "LAYOUT_4", "LAYOUT_6"], // Layouts avec exactement 4 panneaux
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/tutorial/Tutorial";
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
- const errorSegment = {
158
- text: errorMessage,
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: "100%",
281
- display: "flex",
282
- flexDirection: "column",
283
- backgroundColor: "background.paper",
284
  }}
285
  >
286
- <Box
287
- sx={{
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: "fixed",
299
- top: 16,
300
- right: 16,
 
301
  zIndex: 1000,
302
  }}
303
- >
304
- <Tooltip
305
- title={
306
- isNarrationEnabled
307
- ? "Désactiver la narration"
308
- : "Activer la narration"
 
 
 
 
 
 
 
 
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 || isNarratorSpeaking}
358
- showScreenshot={
359
- currentChoices.length === 1 &&
360
- currentChoices[0].text === "Réessayer"
361
- }
362
- onScreenshot={() => downloadStoryImage(storyContainerRef)}
363
  />
364
- </Box>
365
- </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. No more than 6 words.")
7
 
8
- # New response models for story generation steps
9
- class StoryTextResponse(BaseModel):
10
- story_text: str = Field(description="The story text. No more than 15 words.")
 
 
 
 
 
 
11
 
12
  class StoryPromptsResponse(BaseModel):
13
- image_prompts: List[str] = Field(description="List of 2 to 4 comic panel descriptions that illustrate the key moments of the scene. Use the word 'Sarah' only when referring to her.", min_items=1, max_items=4)
 
 
 
 
14
 
15
  class StoryMetadataResponse(BaseModel):
16
- choices: List[str] = Field(description="Exactly two possible choices for the player", min_items=2, max_items=2)
 
 
 
 
 
17
  is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
18
- radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
19
- is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False)
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
- # Complete story response combining all parts
24
- class StoryResponse(BaseModel):
25
- story_text: str = Field(description="The story text. No more than 15 words.")
 
 
 
 
 
 
 
 
 
 
26
  choices: List[Choice]
27
- radiation_level: int = Field(description="Current radiation level from 0 to 10")
28
- is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
 
 
 
29
  is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
30
- is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False)
31
- image_prompts: List[str] = Field(description="List of 2 to 4 comic panel descriptions that illustrate the key moments of the scene. Use the word 'Sarah' only when referring to her.", min_items=1, max_items=4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 10
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 = 0
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 = 0
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. Use bold formatting (like this) ONLY for proper nouns (like Sarah, hospital) and important locations.")
60
- choices: List[str] = Field(description="Exactly two possible choices for the player", min_items=2, max_items=2)
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) -> StoryLLMResponse:
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 prompts d'images et les métadonnées en parallèle
102
- prompts_task = self.prompts_generator.generate(text_response.story_text)
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
- prompts_response, metadata_response = await asyncio.gather(prompts_task, metadata_task)
 
 
111
 
112
- # 3. Combiner les résultats
113
- response = StoryLLMResponse(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  story_text=text_response.story_text,
115
- choices=metadata_response.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
- is_last_step=metadata_response.is_last_step,
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
- - Never use TIME: 18:30 or other time-related information
13
- - Never use LOCATION: the city or other location-related information
14
- - Never use RADIATION: 10* or other radiation-related information
 
 
 
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 only in specific dangerous areas
 
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
- "is_last_step": false,
 
 
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
- Current story beat: {story_beat}
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 so far:
28
  {story_history}
29
 
30
- Generate ONLY the next story segment text. Make it concise and impactful."""
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
- async def generate(self, story_beat: int, radiation_level: int, current_time: str,
40
- current_location: str, previous_choice: str, story_history: str) -> StoryTextResponse:
41
- """Génère uniquement le texte de l'histoire."""
 
 
 
 
 
 
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
- return StoryTextResponse(story_text=response_content.strip())
 
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
- current_section = None
183
- for line in response_content.split("\n"):
184
- line = line.strip()
185
- if not line:
186
- continue
187
-
188
- if line.upper().startswith("CHOICES:"):
189
- current_section = "choices"
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(**metadata)
 
 
 
 
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