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