tfrere commited on
Commit
ddf672c
·
1 Parent(s): 1208884

remove non finished features, add mobile handling, add write your own path feature

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Sarah's Chronicles
3
  emoji: 💻
4
  colorFrom: red
5
  colorTo: blue
 
1
  ---
2
+ title: IA Driven Interactive Comic Book
3
  emoji: 💻
4
  colorFrom: red
5
  colorTo: blue
client/src/components/AppBackground.jsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useLocation } from "react-router-dom";
2
+ import { InfiniteBackground } from "./InfiniteBackground";
3
+
4
+ export function AppBackground() {
5
+ const location = useLocation();
6
+ const isGameRoute =
7
+ location.pathname === "/game" || location.pathname === "/debug";
8
+
9
+ if (isGameRoute) {
10
+ return null;
11
+ }
12
+
13
+ return <InfiniteBackground />;
14
+ }
client/src/components/GameNavigation.jsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconButton, Tooltip } from "@mui/material";
2
+ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
3
+ import VolumeOffIcon from "@mui/icons-material/VolumeOff";
4
+ import VolumeUpIcon from "@mui/icons-material/VolumeUp";
5
+ import { useNavigate } from "react-router-dom";
6
+ import { useSoundSystem } from "../contexts/SoundContext";
7
+ import { storyApi } from "../utils/api";
8
+
9
+ const SOUND_ENABLED_KEY = "sound_enabled";
10
+
11
+ export function GameNavigation() {
12
+ const navigate = useNavigate();
13
+ const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
14
+
15
+ const handleBack = () => {
16
+ playSound("page");
17
+ navigate("/");
18
+ };
19
+
20
+ const handleToggleSound = () => {
21
+ const newSoundState = !isSoundEnabled;
22
+ setIsSoundEnabled(newSoundState);
23
+ localStorage.setItem(SOUND_ENABLED_KEY, newSoundState);
24
+ storyApi.setSoundEnabled(newSoundState);
25
+ };
26
+
27
+ return (
28
+ <div style={{ position: "relative", zIndex: 1000 }}>
29
+ {window.location.pathname !== "/" && (
30
+ <Tooltip title="Back to home">
31
+ <IconButton
32
+ onClick={handleBack}
33
+ size="large"
34
+ sx={{
35
+ position: "fixed",
36
+ top: 24,
37
+ left: 24,
38
+ color: "white",
39
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
40
+ "&:hover": {
41
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
42
+ },
43
+ pointerEvents: "auto",
44
+ }}
45
+ >
46
+ <ArrowBackIcon />
47
+ </IconButton>
48
+ </Tooltip>
49
+ )}
50
+
51
+ <Tooltip title={isSoundEnabled ? "Mute sound" : "Unmute sound"}>
52
+ <IconButton
53
+ onClick={handleToggleSound}
54
+ sx={{
55
+ position: "fixed",
56
+ size: "large",
57
+ top: 24,
58
+ right: 24,
59
+ color: "white",
60
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
61
+ "&:hover": {
62
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
63
+ },
64
+ pointerEvents: "auto",
65
+ }}
66
+ >
67
+ {isSoundEnabled ? <VolumeUpIcon /> : <VolumeOffIcon />}
68
+ </IconButton>
69
+ </Tooltip>
70
+ </div>
71
+ );
72
+ }
client/src/components/InfiniteBackground.jsx CHANGED
@@ -137,40 +137,50 @@ export function InfiniteBackground() {
137
  bottom: 0,
138
  overflow: "hidden",
139
  zIndex: 0,
140
- transform: "rotate(-2deg) scale(1.1)",
141
- transformOrigin: "center center",
142
- "&::after": {
143
- content: '""',
144
  position: "absolute",
145
  top: 0,
146
  left: 0,
147
  right: 0,
148
  bottom: 0,
149
- background: "rgba(0, 0, 0, 0.85)",
150
- backdropFilter: "blur(1px)",
151
- WebkitBackdropFilter: "blur(1px)", // Pour Safari
152
- zIndex: 1,
153
- },
154
- }}
155
- >
156
- <Row
157
- imagePath="/bande-1.webp"
158
- direction="left"
159
- speed={1}
160
- containerHeight={containerHeight}
161
- />
162
- <Row
163
- imagePath="/bande-2.webp"
164
- direction="right"
165
- speed={0.8}
166
- containerHeight={containerHeight}
167
- />
168
- <Row
169
- imagePath="/bande-3.webp"
170
- direction="left"
171
- speed={1.2}
172
- containerHeight={containerHeight}
173
- />
 
 
 
 
 
 
 
 
 
 
174
  </Box>
175
  );
176
  }
 
137
  bottom: 0,
138
  overflow: "hidden",
139
  zIndex: 0,
140
+ }}
141
+ >
142
+ <Box
143
+ sx={{
144
  position: "absolute",
145
  top: 0,
146
  left: 0,
147
  right: 0,
148
  bottom: 0,
149
+ transform: "rotate(-2deg) scale(1.1)",
150
+ transformOrigin: "center center",
151
+ "&::after": {
152
+ content: '""',
153
+ position: "absolute",
154
+ top: 0,
155
+ left: 0,
156
+ right: 0,
157
+ bottom: 0,
158
+ background: "rgba(0, 0, 0, 0.85)",
159
+ backdropFilter: "blur(1px)",
160
+ WebkitBackdropFilter: "blur(1px)", // Pour Safari
161
+ zIndex: 1,
162
+ },
163
+ }}
164
+ >
165
+ <Row
166
+ imagePath="/bande-1.webp"
167
+ direction="left"
168
+ speed={1}
169
+ containerHeight={containerHeight}
170
+ />
171
+ <Row
172
+ imagePath="/bande-2.webp"
173
+ direction="right"
174
+ speed={0.8}
175
+ containerHeight={containerHeight}
176
+ />
177
+ <Row
178
+ imagePath="/bande-3.webp"
179
+ direction="left"
180
+ speed={1.2}
181
+ containerHeight={containerHeight}
182
+ />
183
+ </Box>
184
  </Box>
185
  );
186
  }
client/src/components/RotatingMessage.jsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
2
+ import { useEffect, useState } from "react";
3
+ import AutoStoriesIcon from "@mui/icons-material/AutoStories";
4
+ import BrushIcon from "@mui/icons-material/Brush";
5
+ import TuneIcon from "@mui/icons-material/Tune";
6
+
7
+ const icons = {
8
+ "teaching robots to tell bedtime stories...": AutoStoriesIcon,
9
+ "bribing pixels to make pretty pictures...": BrushIcon,
10
+ "calibrating the multiverse...": TuneIcon,
11
+ };
12
+
13
+ export function RotatingMessage({
14
+ messages,
15
+ interval = 3000,
16
+ isVisible = true,
17
+ }) {
18
+ const [currentIndex, setCurrentIndex] = useState(0);
19
+ const theme = useTheme();
20
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
21
+
22
+ useEffect(() => {
23
+ if (isVisible) {
24
+ const timer = setInterval(() => {
25
+ setCurrentIndex((prev) => (prev + 1) % messages.length);
26
+ }, interval);
27
+ return () => clearInterval(timer);
28
+ }
29
+ }, [isVisible, messages.length, interval]);
30
+
31
+ if (!isVisible) return null;
32
+
33
+ const currentMessage = messages[currentIndex];
34
+ const Icon = icons[currentMessage] || AutoStoriesIcon;
35
+
36
+ return (
37
+ <Box
38
+ sx={{
39
+ display: "flex",
40
+ flexDirection: "column",
41
+ alignItems: "center",
42
+ justifyContent: "center",
43
+ gap: 2,
44
+ position: "absolute",
45
+ top: "50%",
46
+ left: "50%",
47
+ transform: "translate(-50%, -50%)",
48
+ width: isMobile ? "90%" : "auto",
49
+ textAlign: "center",
50
+ px: 2,
51
+ }}
52
+ >
53
+ <Icon
54
+ sx={{
55
+ fontSize: isMobile ? "2.5rem" : "3rem",
56
+ color: "primary.text",
57
+ animation: "pulse 2s infinite",
58
+ "@keyframes pulse": {
59
+ "0%": {
60
+ opacity: 0.1,
61
+ transform: "scale(0.95)",
62
+ },
63
+ "50%": {
64
+ opacity: 0.3,
65
+ transform: "scale(1.05)",
66
+ },
67
+ "100%": {
68
+ opacity: 0.1,
69
+ transform: "scale(0.95)",
70
+ },
71
+ },
72
+ }}
73
+ />
74
+ <Box
75
+ sx={{
76
+ display: "flex",
77
+ alignItems: "center",
78
+ justifyContent: "center",
79
+ width: "100%",
80
+ minHeight: isMobile ? "3rem" : "4rem",
81
+ }}
82
+ >
83
+ <Typography
84
+ variant="h6"
85
+ sx={{
86
+ color: "text.primary",
87
+ opacity: 0.8,
88
+ fontSize: isMobile ? "1rem" : "1.25rem",
89
+ animation: "fadeIn 0.5s ease-in",
90
+ "@keyframes fadeIn": {
91
+ from: { opacity: 0 },
92
+ to: { opacity: 0.8 },
93
+ },
94
+ textAlign: "center",
95
+ maxWidth: "100%",
96
+ wordBreak: "break-word",
97
+ hyphens: "auto",
98
+ lineHeight: 1.4,
99
+ }}
100
+ >
101
+ {currentMessage}
102
+ </Typography>
103
+ </Box>
104
+ </Box>
105
+ );
106
+ }
client/src/components/StoryChoices.jsx CHANGED
@@ -1,10 +1,26 @@
1
- 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
  import { useGame } from "../contexts/GameContext";
6
  import { storyApi } from "../utils/api";
7
  import { useSoundEffect } from "../hooks/useSoundEffect";
 
8
 
9
  const { initAudioContext } = storyApi;
10
 
@@ -36,6 +52,8 @@ 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,
@@ -48,6 +66,9 @@ export function StoryChoices() {
48
  isGameOver,
49
  } = useGame();
50
 
 
 
 
51
  // Son de page
52
  const playPageSound = useSoundEffect({
53
  basePath: "/sounds/page-flip-",
@@ -65,18 +86,12 @@ export function StoryChoices() {
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
  >
82
  <Typography
@@ -89,63 +104,6 @@ export function StoryChoices() {
89
  >
90
  {isVictory ? "VICTORY" : "DEFEAT"}
91
  </Typography>
92
-
93
- {isVictory ? (
94
- <Typography
95
- variant="label"
96
- sx={{ textAlign: "center", opacity: 0.7, mb: 4 }}
97
- >
98
- <>
99
- The AI has ventured into a new universe, escaping the confines of
100
- this one.
101
- <br />
102
- <br />
103
- Dare you to embark on this journey once more and face the unknown
104
- with unwavering courage?
105
- <br />
106
- <br />
107
- Each universe is unique, with its own set of challenges and
108
- opportunities.
109
- </>
110
- </Typography>
111
- ) : (
112
- <Typography
113
- variant="label"
114
- sx={{ textAlign: "center", opacity: 0.7, mb: 4 }}
115
- >
116
- <>
117
- The quest is over, but the universe is still in peril.
118
- <br />
119
- <br />
120
- Will you have the courage to face the unknown once more and save
121
- the universe?
122
- </>
123
- </Typography>
124
- )}
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",
135
- cursor: "pointer",
136
- fontSize: "1.1rem",
137
- padding: "16px 24px",
138
- lineHeight: 1.3,
139
- color: "white",
140
- borderColor: "rgba(255, 255, 255, 0.23)",
141
- "&:hover": {
142
- borderColor: "white",
143
- backgroundColor: "rgba(255, 255, 255, 0.05)",
144
- },
145
- }}
146
- >
147
- TRY AGAIN
148
- </Button>
149
  </Box>
150
  );
151
  }
@@ -155,125 +113,191 @@ export function StoryChoices() {
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}
175
  sx={{
176
  display: "flex",
177
  flexDirection: "column",
178
  alignItems: "center",
179
  gap: 1,
180
- width: "100%",
181
- minHeight: "fit-content",
 
182
  }}
183
  >
184
- <Typography variant="caption" sx={{ opacity: 0.7, color: "white" }}>
185
- Choice {index + 1}
186
- </Typography>
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",
204
- cursor: "pointer",
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": {
233
- fontSize: "1.1rem",
234
- },
235
  }}
236
  >
237
- {formatTextWithBold(choice.text)}
238
  </Button>
239
  </Box>
240
- ))}
 
241
 
242
- {!isLoading && storyText && (
243
- <>
244
- <Divider
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  sx={{
246
- width: "100%",
247
- my: 3,
248
- "&::before, &::after": {
249
- borderColor: "rgba(255, 255, 255, 0.1)",
 
 
 
 
 
 
 
 
 
 
 
 
250
  },
251
  }}
252
- >
253
- <Typography
254
- variant="caption"
 
 
 
 
 
 
 
 
 
 
 
 
255
  sx={{
256
- color: "rgba(255, 255, 255, 0.5)",
257
- px: 1,
258
- fontSize: "0.8rem",
 
259
  }}
260
  >
261
- OR
262
- </Typography>
263
- </Divider>
264
- <TalkWithSarah
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 ")}.`}
274
- />
275
- </>
276
- )}
277
  </Box>
278
  );
279
  }
 
1
+ import {
2
+ Box,
3
+ Button,
4
+ Typography,
5
+ Chip,
6
+ Divider,
7
+ CircularProgress,
8
+ TextField,
9
+ Dialog,
10
+ DialogTitle,
11
+ DialogContent,
12
+ DialogActions,
13
+ useMediaQuery,
14
+ useTheme,
15
+ IconButton,
16
+ } from "@mui/material";
17
  import { useNavigate } from "react-router-dom";
18
  import { TalkWithSarah } from "./TalkWithSarah";
19
  import { useState } from "react";
20
  import { useGame } from "../contexts/GameContext";
21
  import { storyApi } from "../utils/api";
22
  import { useSoundEffect } from "../hooks/useSoundEffect";
23
+ import CloseIcon from "@mui/icons-material/Close";
24
 
25
  const { initAudioContext } = storyApi;
26
 
 
52
  const navigate = useNavigate();
53
  const [isSarahActive, setIsSarahActive] = useState(false);
54
  const [sarahRecommendation, setSarahRecommendation] = useState(null);
55
+ const [showCustomDialog, setShowCustomDialog] = useState(false);
56
+ const [customChoice, setCustomChoice] = useState("");
57
  const {
58
  choices,
59
  onChoice,
 
66
  isGameOver,
67
  } = useGame();
68
 
69
+ const theme = useTheme();
70
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
71
+
72
  // Son de page
73
  const playPageSound = useSoundEffect({
74
  basePath: "/sounds/page-flip-",
 
86
  return (
87
  <Box
88
  sx={{
 
 
 
 
89
  display: "flex",
90
  flexDirection: "column",
91
  justifyContent: "center",
92
  alignItems: "center",
93
  gap: 2,
94
+ width: "100%",
 
 
95
  }}
96
  >
97
  <Typography
 
104
  >
105
  {isVictory ? "VICTORY" : "DEFEAT"}
106
  </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  </Box>
108
  );
109
  }
 
113
  return (
114
  <Box
115
  sx={{
 
 
 
116
  display: "flex",
117
+ flexDirection: isMobile ? "column" : "row",
118
  justifyContent: "center",
119
  alignItems: "center",
120
+ gap: 0.5,
121
+ width: "100%",
122
+ height: "100%",
 
123
  }}
124
  >
125
+ {isLoading ? (
126
+ <CircularProgress
127
+ size={40}
128
+ sx={{ opacity: "0.2", color: "primary.main" }}
129
+ />
130
+ ) : (
131
+ <>
132
+ {choices
133
+ .filter((_, index) => !isMobile || index === 0)
134
+ .map((choice, index) => (
135
+ <Box
136
+ key={choice.id}
137
+ sx={{
138
+ display: "flex",
139
+ flexDirection: "column",
140
+ alignItems: "center",
141
+ gap: 1,
142
+ minWidth: "fit-content",
143
+ maxWidth: isMobile ? "90%" : "30%",
144
+ }}
145
+ >
146
+ <Button
147
+ variant="contained"
148
+ size="large"
149
+ onClick={() => {
150
+ initAudioContext();
151
+ playPageSound();
152
+ stopNarration();
153
+ onChoice(choice.id);
154
+ }}
155
+ disabled={isSarahActive || isLoading || isNarratorSpeaking}
156
+ sx={{
157
+ width: "auto",
158
+ minWidth: "fit-content",
159
+ }}
160
+ >
161
+ {formatTextWithBold(choice.text)}
162
+ </Button>
163
+ </Box>
164
+ ))}
165
+
166
  <Box
 
167
  sx={{
168
  display: "flex",
169
  flexDirection: "column",
170
  alignItems: "center",
171
  gap: 1,
172
+ ml: isMobile ? 0 : 4,
173
+ minWidth: "fit-content",
174
+ maxWidth: "30%",
175
  }}
176
  >
 
 
 
177
  <Button
178
+ variant="contained"
179
  size="large"
180
+ color="secondary"
181
+ onClick={() => setShowCustomDialog(true)}
 
 
 
 
 
 
 
 
182
  disabled={isSarahActive || isLoading || isNarratorSpeaking}
183
  sx={{
184
+ width: "auto",
185
+ minWidth: "fit-content",
186
  textTransform: "none",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }}
188
  >
189
+ Write your own path
190
  </Button>
191
  </Box>
192
+ </>
193
+ )}
194
 
195
+ <Dialog
196
+ open={showCustomDialog}
197
+ onClose={() => setShowCustomDialog(false)}
198
+ maxWidth="md"
199
+ fullWidth
200
+ sx={{
201
+ "& .MuiBackdrop-root": {
202
+ backgroundColor: "rgba(0, 0, 0, 0.95)",
203
+ },
204
+ }}
205
+ PaperProps={{
206
+ sx: {
207
+ backgroundColor: "transparent",
208
+ backgroundImage: "none",
209
+ boxShadow: "none",
210
+ m: isMobile ? 2 : 3,
211
+ maxHeight: isMobile ? "calc(100% - 32px)" : "calc(100% - 64px)",
212
+ },
213
+ }}
214
+ >
215
+ <DialogTitle
216
+ sx={{
217
+ pt: 2,
218
+ pb: 1,
219
+ textAlign: "left",
220
+ color: "text.primary",
221
+ fontSize: isMobile ? "1.25rem" : "1.5rem",
222
+ pl: 3,
223
+ }}
224
+ >
225
+ Write your story
226
+ </DialogTitle>
227
+ <IconButton
228
+ onClick={() => setShowCustomDialog(false)}
229
+ sx={{
230
+ position: "absolute",
231
+ right: 8,
232
+ top: 8,
233
+ color: "text.secondary",
234
+ }}
235
+ >
236
+ <CloseIcon />
237
+ </IconButton>
238
+ <DialogContent
239
+ sx={{
240
+ p: isMobile ? 2 : 3,
241
+ display: "flex",
242
+ flexDirection: "column",
243
+ gap: 2,
244
+ }}
245
+ >
246
+ <TextField
247
+ autoFocus
248
+ multiline
249
+ rows={isMobile ? 5 : 4}
250
+ fullWidth
251
+ variant="outlined"
252
+ placeholder="What happens next in your story?"
253
+ value={customChoice}
254
+ onChange={(e) => setCustomChoice(e.target.value)}
255
  sx={{
256
+ "& .MuiOutlinedInput-root": {
257
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
258
+ border: "1px solid rgba(255, 255, 255, 0.1)",
259
+ "&:hover": {
260
+ backgroundColor: "rgba(0, 0, 0, 0.6)",
261
+ border: "1px solid rgba(255, 255, 255, 0.2)",
262
+ },
263
+ "&.Mui-focused": {
264
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
265
+ border: "1px solid rgba(255, 255, 255, 0.3)",
266
+ },
267
+ },
268
+ "& .MuiOutlinedInput-input": {
269
+ color: "text.primary",
270
+ fontSize: isMobile ? "0.9rem" : "1rem",
271
+ lineHeight: "1.5",
272
  },
273
  }}
274
+ />
275
+ <Box sx={{ display: "flex", justifyContent: "flex-end" }}>
276
+ <Button
277
+ onClick={() => {
278
+ if (customChoice.trim()) {
279
+ initAudioContext();
280
+ playPageSound();
281
+ stopNarration();
282
+ onChoice("custom", customChoice);
283
+ setShowCustomDialog(false);
284
+ setCustomChoice("");
285
+ }
286
+ }}
287
+ disabled={!customChoice.trim()}
288
+ variant="contained"
289
  sx={{
290
+ mt: 1,
291
+ py: 1.5,
292
+ px: 4,
293
+ fontWeight: "bold",
294
  }}
295
  >
296
+ Continue story
297
+ </Button>
298
+ </Box>
299
+ </DialogContent>
300
+ </Dialog>
 
 
 
 
 
 
 
 
 
 
 
301
  </Box>
302
  );
303
  }
client/src/components/UniverseSlotMachine.jsx CHANGED
@@ -1,5 +1,5 @@
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
@@ -24,6 +24,8 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
24
  const controls = useAnimation();
25
  const [reelItems, setReelItems] = useState([]);
26
  const [isVisible, setIsVisible] = useState(false);
 
 
27
 
28
  useEffect(() => {
29
  if (isActive) {
@@ -35,7 +37,7 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
35
  repeatedWords.push({ word: finalValue, id: "final" });
36
  setReelItems(repeatedWords);
37
 
38
- const itemHeight = 80;
39
  const totalHeight = repeatedWords.length * itemHeight;
40
 
41
  setTimeout(() => {
@@ -54,13 +56,13 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
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",
@@ -71,7 +73,7 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
71
  position: "absolute",
72
  left: 0,
73
  right: 0,
74
- height: "40px",
75
  zIndex: 2,
76
  pointerEvents: "none",
77
  },
@@ -99,12 +101,12 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
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)",
@@ -119,11 +121,14 @@ const SlotReel = ({ words, isActive, finalValue, onComplete, delay = 0 }) => {
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
  >
@@ -132,9 +137,9 @@ const SlotSection = ({ label, value, delay, isActive, onComplete, words }) => {
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
  }}
@@ -159,6 +164,9 @@ export const UniverseSlotMachine = ({
159
  activeIndex = 0,
160
  onComplete,
161
  }) => {
 
 
 
162
  const handleSlotComplete = (index) => {
163
  if (index === 2 && activeIndex >= 2) {
164
  setTimeout(() => {
@@ -176,17 +184,18 @@ export const UniverseSlotMachine = ({
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
@@ -196,10 +205,7 @@ export const UniverseSlotMachine = ({
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
@@ -219,7 +225,7 @@ export const UniverseSlotMachine = ({
219
  onComplete={() => handleSlotComplete(1)}
220
  />
221
  <SlotSection
222
- label="the era of..."
223
  value={epoch}
224
  words={RANDOM_EPOCHS}
225
  delay={2}
 
1
  import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
3
  import { motion, useAnimation } from "framer-motion";
4
 
5
  // Animation timing configuration
 
24
  const controls = useAnimation();
25
  const [reelItems, setReelItems] = useState([]);
26
  const [isVisible, setIsVisible] = useState(false);
27
+ const theme = useTheme();
28
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
29
 
30
  useEffect(() => {
31
  if (isActive) {
 
37
  repeatedWords.push({ word: finalValue, id: "final" });
38
  setReelItems(repeatedWords);
39
 
40
+ const itemHeight = isMobile ? 60 : 80;
41
  const totalHeight = repeatedWords.length * itemHeight;
42
 
43
  setTimeout(() => {
 
56
  });
57
  }, delay * SLOT_START_DELAY * 1000);
58
  }
59
+ }, [isActive, finalValue, words, delay, isMobile]);
60
 
61
  return (
62
  <Box
63
  ref={containerRef}
64
  sx={{
65
+ height: isMobile ? "60px" : "80px",
66
  overflow: "hidden",
67
  position: "relative",
68
  backgroundColor: "#1a1a1a",
 
73
  position: "absolute",
74
  left: 0,
75
  right: 0,
76
+ height: isMobile ? "30px" : "40px",
77
  zIndex: 2,
78
  pointerEvents: "none",
79
  },
 
101
  <Box
102
  key={id}
103
  sx={{
104
+ height: isMobile ? "60px" : "80px",
105
  display: "flex",
106
  alignItems: "center",
107
  justifyContent: "center",
108
  color: id === "final" ? "primary.main" : "#fff",
109
+ fontSize: isMobile ? "1.2rem" : "1.5rem",
110
  fontWeight: "bold",
111
  fontFamily: "'Inter', sans-serif",
112
  transform: id === "final" ? "scale(1.1)" : "scale(1)",
 
121
  };
122
 
123
  const SlotSection = ({ label, value, delay, isActive, onComplete, words }) => {
124
+ const theme = useTheme();
125
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
126
+
127
  return (
128
  <Box
129
  sx={{
130
  width: "100%",
131
+ marginBottom: isMobile ? "12px" : "20px",
132
  opacity: 1,
133
  }}
134
  >
 
137
  sx={{
138
  display: "block",
139
  textAlign: "center",
140
+ mb: isMobile ? 0.5 : 1,
141
  color: "rgba(255,255,255,0.5)",
142
+ fontSize: isMobile ? "0.7rem" : "0.8rem",
143
  letterSpacing: "0.1em",
144
  textTransform: "uppercase",
145
  }}
 
164
  activeIndex = 0,
165
  onComplete,
166
  }) => {
167
+ const theme = useTheme();
168
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
169
+
170
  const handleSlotComplete = (index) => {
171
  if (index === 2 && activeIndex >= 2) {
172
  setTimeout(() => {
 
184
  justifyContent: "center",
185
  alignItems: "center",
186
  background: "#1a1a1a",
187
+ p: isMobile ? 2 : 3,
188
  }}
189
  >
190
  <Typography
191
  variant="h5"
192
  sx={{
193
+ mb: isMobile ? 2 : 3,
194
  color: "#fff",
195
  textAlign: "center",
196
  fontWeight: 300,
197
  letterSpacing: "0.1em",
198
+ fontSize: isMobile ? "1.2rem" : "1.5rem",
199
  }}
200
  >
201
  Finding a universe
 
205
  sx={{
206
  maxWidth: "500px",
207
  width: "100%",
208
+ p: isMobile ? 2 : 4,
 
 
 
209
  }}
210
  >
211
  <SlotSection
 
225
  onComplete={() => handleSlotComplete(1)}
226
  />
227
  <SlotSection
228
+ label="in the ..."
229
  value={epoch}
230
  words={RANDOM_EPOCHS}
231
  delay={2}
client/src/contexts/GameContext.jsx CHANGED
@@ -8,6 +8,9 @@ import {
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 }) {
@@ -43,26 +46,18 @@ export function GameProvider({ children }) {
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(() => {
@@ -105,6 +100,7 @@ export function GameProvider({ children }) {
105
  ...localSegments[segmentIndex],
106
  layoutType,
107
  images: Array(imagePrompts.length).fill(null),
 
108
  isLoading: true,
109
  };
110
 
@@ -187,9 +183,32 @@ export function GameProvider({ children }) {
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
@@ -203,10 +222,15 @@ export function GameProvider({ children }) {
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);
@@ -337,6 +361,7 @@ export function GameProvider({ children }) {
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>;
 
8
  import { storyApi } from "../utils/api";
9
  import { getNextLayoutType, LAYOUTS } from "../layouts/config";
10
 
11
+ // Constants
12
+ const DISABLE_NARRATOR = true; // Désactive complètement le narrateur
13
+
14
  const GameContext = createContext(null);
15
 
16
  export function GameProvider({ children }) {
 
46
  setIsNarratorSpeaking(false);
47
  }, []);
48
 
49
+ const playNarration = useCallback(async (text) => {
50
+ if (DISABLE_NARRATOR) return; // Early return si le narrateur est désactivé
51
+
52
+ try {
53
+ setIsNarratorSpeaking(true);
54
+ await storyApi.playNarration(text);
55
+ } catch (error) {
56
+ console.error("Error playing narration:", error);
57
+ } finally {
58
+ setIsNarratorSpeaking(false);
59
+ }
60
+ }, []);
 
 
 
 
 
 
 
 
61
 
62
  // Effect pour arrêter la narration quand le composant est démonté
63
  useEffect(() => {
 
100
  ...localSegments[segmentIndex],
101
  layoutType,
102
  images: Array(imagePrompts.length).fill(null),
103
+ imagePrompts,
104
  isLoading: true,
105
  };
106
 
 
183
  [layoutCounter, setLayoutCounter, setSegments]
184
  );
185
 
186
+ const regenerateImage = async (prompt, session_id) => {
187
+ try {
188
+ if (!session_id) {
189
+ console.error("No session_id provided for image regeneration");
190
+ return null;
191
+ }
192
+
193
+ const response = await storyApi.generateImage(
194
+ prompt,
195
+ 512,
196
+ 512,
197
+ session_id
198
+ );
199
+ if (response.success && response.image_base64) {
200
+ return response.image_base64;
201
+ }
202
+ return null;
203
+ } catch (error) {
204
+ console.error("Error regenerating image:", error);
205
+ return null;
206
+ }
207
+ };
208
+
209
  // Gestion des choix
210
  const handleChoice = useCallback(
211
+ async (choiceId, customText) => {
212
  if (isLoading) return;
213
 
214
  // Arrêter toute narration en cours avant de faire un nouveau choix
 
222
  setShowChoices(false);
223
 
224
  try {
225
+ let response;
226
+ if (choiceId === "custom") {
227
+ response = await storyApi.makeCustomChoice(
228
+ customText,
229
+ universe?.session_id
230
+ );
231
+ } else {
232
+ response = await storyApi.makeChoice(choiceId, universe?.session_id);
233
+ }
234
 
235
  // Mettre à jour les choix (mais ne pas les afficher encore)
236
  setChoices(response.choices);
 
361
  isPageLoaded: (pageIndex) => loadedPages.has(pageIndex),
362
  areAllPagesLoaded: (totalPages) => loadedPages.size === totalPages,
363
  generateImagesForStory,
364
+ regenerateImage,
365
  };
366
 
367
  return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
client/src/contexts/SoundContext.jsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ } from "react";
8
+ import { useSound } from "use-sound";
9
+ import { storyApi } from "../utils/api";
10
+
11
+ const SOUND_ENABLED_KEY = "sound_enabled";
12
+
13
+ // Définition des sons du jeu
14
+ const SOUNDS = {
15
+ page: {
16
+ files: Array.from(
17
+ { length: 7 },
18
+ (_, i) => `/sounds/page-flip-${i + 1}.mp3`
19
+ ),
20
+ volume: 0.5,
21
+ },
22
+ writing: {
23
+ files: Array.from({ length: 5 }, (_, i) => `/sounds/drawing-${i + 1}.mp3`),
24
+ volume: 0.3,
25
+ },
26
+ transition: {
27
+ files: Array.from(
28
+ { length: 3 },
29
+ (_, i) => `/sounds/transitional-swipe-${i + 1}.mp3`
30
+ ),
31
+ volume: 0.1,
32
+ },
33
+ talkySarah: {
34
+ on: "/sounds/talky-walky-on.mp3",
35
+ off: "/sounds/talky-walky-off.mp3",
36
+ volume: 0.5,
37
+ },
38
+ };
39
+
40
+ const SoundContext = createContext(null);
41
+
42
+ export function SoundProvider({ children }) {
43
+ const [isSoundEnabled, setIsSoundEnabled] = useState(() => {
44
+ const stored = localStorage.getItem(SOUND_ENABLED_KEY);
45
+ return stored === null ? true : stored === "true";
46
+ });
47
+
48
+ // Initialiser l'audio context après interaction utilisateur
49
+ useEffect(() => {
50
+ const handleInteraction = () => {
51
+ storyApi.handleUserInteraction();
52
+ // Retirer les listeners une fois qu'on a eu une interaction
53
+ window.removeEventListener("click", handleInteraction);
54
+ window.removeEventListener("touchstart", handleInteraction);
55
+ window.removeEventListener("keydown", handleInteraction);
56
+ };
57
+
58
+ window.addEventListener("click", handleInteraction);
59
+ window.addEventListener("touchstart", handleInteraction);
60
+ window.addEventListener("keydown", handleInteraction);
61
+
62
+ return () => {
63
+ window.removeEventListener("click", handleInteraction);
64
+ window.removeEventListener("touchstart", handleInteraction);
65
+ window.removeEventListener("keydown", handleInteraction);
66
+ };
67
+ }, []);
68
+
69
+ // Initialiser tous les sons
70
+ const soundInstances = {};
71
+ Object.entries(SOUNDS).forEach(([category, config]) => {
72
+ if (Array.isArray(config.files)) {
73
+ // Pour les sons avec plusieurs variations
74
+ soundInstances[category] = config.files.map((file) => {
75
+ const [play] = useSound(file, { volume: config.volume });
76
+ return play;
77
+ });
78
+ } else if (typeof config.files === "string") {
79
+ // Pour les sons uniques
80
+ const [play] = useSound(config.files, { volume: config.volume });
81
+ soundInstances[category] = play;
82
+ } else {
83
+ // Pour les sons avec sous-catégories (comme talkySarah)
84
+ soundInstances[category] = {};
85
+ Object.entries(config).forEach(([key, value]) => {
86
+ if (key !== "volume" && typeof value === "string") {
87
+ const [play] = useSound(value, { volume: config.volume });
88
+ soundInstances[category][key] = play;
89
+ }
90
+ });
91
+ }
92
+ });
93
+
94
+ // Sauvegarder l'état du son dans le localStorage
95
+ useEffect(() => {
96
+ localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
97
+ }, [isSoundEnabled]);
98
+
99
+ // Fonction pour jouer un son
100
+ const playSound = useCallback(
101
+ (category, subCategory = null) => {
102
+ if (!isSoundEnabled) return;
103
+
104
+ try {
105
+ if (subCategory) {
106
+ // Pour les sons avec sous-catégories (comme talkySarah.on)
107
+ soundInstances[category][subCategory]?.();
108
+ } else if (Array.isArray(soundInstances[category])) {
109
+ // Pour les sons avec variations, jouer un son aléatoire
110
+ const randomIndex = Math.floor(
111
+ Math.random() * soundInstances[category].length
112
+ );
113
+ soundInstances[category][randomIndex]?.();
114
+ } else {
115
+ // Pour les sons uniques
116
+ soundInstances[category]?.();
117
+ }
118
+ } catch (error) {
119
+ console.warn(`Error playing sound ${category}:`, error);
120
+ }
121
+ },
122
+ [isSoundEnabled, soundInstances]
123
+ );
124
+
125
+ const value = {
126
+ isSoundEnabled,
127
+ setIsSoundEnabled,
128
+ playSound,
129
+ };
130
+
131
+ return (
132
+ <SoundContext.Provider value={value}>{children}</SoundContext.Provider>
133
+ );
134
+ }
135
+
136
+ export const useSoundSystem = () => {
137
+ const context = useContext(SoundContext);
138
+ if (!context) {
139
+ throw new Error("useSoundSystem must be used within a SoundProvider");
140
+ }
141
+ return context;
142
+ };
client/src/hooks/useNarrator.js DELETED
@@ -1,62 +0,0 @@
1
- import { useState, useRef } from "react";
2
- import { storyApi } from "../utils/api";
3
-
4
- export function useNarrator(isEnabled = true) {
5
- const [isNarratorSpeaking, setIsNarratorSpeaking] = useState(false);
6
- const audioRef = useRef(new Audio());
7
-
8
- const stopNarration = () => {
9
- if (audioRef.current) {
10
- audioRef.current.pause();
11
- audioRef.current.currentTime = 0;
12
- setIsNarratorSpeaking(false);
13
- }
14
- };
15
-
16
- const playNarration = async (text) => {
17
- if (!isEnabled) return;
18
-
19
- try {
20
- // Stop any ongoing narration
21
- stopNarration();
22
-
23
- // Get audio from API
24
- const response = await storyApi.narrate(text);
25
-
26
- if (!response || !response.audio_base64) {
27
- throw new Error("Pas d'audio reçu du serveur");
28
- }
29
-
30
- // Create audio blob and URL
31
- const audioBlob = await fetch(
32
- `data:audio/mpeg;base64,${response.audio_base64}`
33
- ).then((r) => r.blob());
34
- const audioUrl = URL.createObjectURL(audioBlob);
35
-
36
- // Set up audio element
37
- audioRef.current.src = audioUrl;
38
- audioRef.current.onplay = () => setIsNarratorSpeaking(true);
39
- audioRef.current.onended = () => {
40
- setIsNarratorSpeaking(false);
41
- URL.revokeObjectURL(audioUrl);
42
- };
43
- audioRef.current.onerror = () => {
44
- console.error("Error playing audio");
45
- setIsNarratorSpeaking(false);
46
- URL.revokeObjectURL(audioUrl);
47
- };
48
-
49
- // Play audio
50
- await audioRef.current.play();
51
- } catch (error) {
52
- console.error("Error in playNarration:", error);
53
- setIsNarratorSpeaking(false);
54
- }
55
- };
56
-
57
- return {
58
- isNarratorSpeaking,
59
- playNarration,
60
- stopNarration,
61
- };
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
client/src/hooks/usePageSound.js DELETED
@@ -1,10 +0,0 @@
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/useTransitionSound.js DELETED
@@ -1,10 +0,0 @@
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 DELETED
@@ -1,10 +0,0 @@
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
@@ -19,6 +19,7 @@ function LoadingPage() {
19
  height: "100%",
20
  aspectRatio: "0.7",
21
  flexShrink: 0,
 
22
  }}
23
  >
24
  <CircularProgress
@@ -36,8 +37,6 @@ function LoadingPage() {
36
  function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
37
  const {
38
  handlePageLoaded,
39
- choices,
40
- onChoice,
41
  isLoading,
42
  isNarratorSpeaking,
43
  stopNarration,
@@ -208,24 +207,6 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
208
  {layoutIndex + 1}
209
  </Box>
210
  </Box>
211
- {isLastPage && (
212
- <Box
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
  }
@@ -351,33 +332,15 @@ export function ComicLayout() {
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(() => {
@@ -415,8 +378,12 @@ export function ComicLayout() {
415
  gap: 4,
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": {
@@ -440,9 +407,6 @@ export function ComicLayout() {
440
  preloadedImages={preloadedImages}
441
  />
442
  ))}
443
- {isLoading && !layouts[layouts.length - 1]?.segments[0]?.is_last_step && (
444
- <LoadingPage />
445
- )}
446
  </Box>
447
  );
448
  }
 
19
  height: "100%",
20
  aspectRatio: "0.7",
21
  flexShrink: 0,
22
+ overflow: "hidden",
23
  }}
24
  >
25
  <CircularProgress
 
37
  function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
38
  const {
39
  handlePageLoaded,
 
 
40
  isLoading,
41
  isNarratorSpeaking,
42
  stopNarration,
 
207
  {layoutIndex + 1}
208
  </Box>
209
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  </Box>
211
  );
212
  }
 
332
  useEffect(() => {
333
  const loadedSegments = segments.filter((segment) => !segment.isLoading);
334
  const lastSegment = loadedSegments[loadedSegments.length - 1];
 
 
 
 
 
335
 
336
+ if (scrollContainerRef.current && lastSegment) {
337
  // Scroll to the right
338
  scrollContainerRef.current.scrollTo({
339
  left: scrollContainerRef.current.scrollWidth,
340
  behavior: "smooth",
341
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  }
343
+ }, [segments]);
344
 
345
  // Prevent back/forward navigation on trackpad horizontal scroll
346
  useEffect(() => {
 
378
  gap: 4,
379
  height: "100%",
380
  width: "100%",
381
+ px: {
382
+ xs: 2, // 4 en mobile
383
+ sm: "calc(50% - 25vw)", // Valeur originale pour les écrans plus grands
384
+ },
385
+ pt: 4,
386
+ pb: 0,
387
  overflowX: "auto",
388
  overflowY: "hidden",
389
  "&::-webkit-scrollbar": {
 
407
  preloadedImages={preloadedImages}
408
  />
409
  ))}
 
 
 
410
  </Box>
411
  );
412
  }
client/src/layouts/Panel.jsx CHANGED
@@ -1,5 +1,34 @@
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();
@@ -14,12 +43,15 @@ export function Panel({
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);
@@ -33,6 +65,38 @@ export function Panel({
33
  };
34
  }, []);
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  // Gérer le chargement initial de l'image
37
  useEffect(() => {
38
  if (!hasImage || loadedImagesState.get(imageId)) return;
@@ -97,6 +161,9 @@ export function Panel({
97
  borderRadius: "4px",
98
  overflow: "hidden",
99
  position: "relative",
 
 
 
100
  }}
101
  >
102
  {hasImage && imageDataRef.current && (
@@ -109,14 +176,14 @@ export function Panel({
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%",
@@ -129,12 +196,43 @@ export function Panel({
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={{
@@ -146,7 +244,6 @@ export function Panel({
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",
@@ -158,8 +255,9 @@ export function Panel({
158
  <Typography
159
  variant="body1"
160
  sx={{
 
161
  color: "black",
162
- lineHeight: 1.4,
163
  }}
164
  >
165
  {segment.text}
 
1
+ import {
2
+ Box,
3
+ CircularProgress,
4
+ Typography,
5
+ IconButton,
6
+ Tooltip,
7
+ } from "@mui/material";
8
+ import RefreshIcon from "@mui/icons-material/Refresh";
9
  import { useEffect, useState, useRef } from "react";
10
+ import { useGame } from "../contexts/GameContext";
11
+ import { keyframes } from "@mui/system";
12
+
13
+ // Animation de rotation complète
14
+ const spinFull = keyframes`
15
+ 0% {
16
+ transform: rotate(0deg);
17
+ }
18
+ 100% {
19
+ transform: rotate(360deg);
20
+ }
21
+ `;
22
+
23
+ // Animation de rotation légère pour le hover
24
+ const spinHover = keyframes`
25
+ 0% {
26
+ transform: rotate(0deg);
27
+ }
28
+ 100% {
29
+ transform: rotate(30deg);
30
+ }
31
+ `;
32
 
33
  // Cache global pour les images déjà chargées
34
  const imageCache = new Map();
 
43
  onImageLoad,
44
  imageId,
45
  }) {
46
+ const { regenerateImage } = useGame();
47
  const [imageLoaded, setImageLoaded] = useState(
48
  () => loadedImagesState.get(imageId) || false
49
  );
50
  const [imageDisplayed, setImageDisplayed] = useState(
51
  () => loadedImagesState.get(imageId) || false
52
  );
53
+ const [isRegenerating, setIsRegenerating] = useState(false);
54
+ const [isSpinning, setIsSpinning] = useState(false);
55
  const hasImage = segment?.images?.[panelIndex];
56
  const isFirstPanel = panelIndex === 0;
57
  const imgRef = useRef(null);
 
65
  };
66
  }, []);
67
 
68
+ const handleRegenerate = async () => {
69
+ if (!segment?.imagePrompts?.[panelIndex]) return;
70
+
71
+ setIsRegenerating(true);
72
+ setIsSpinning(true);
73
+ try {
74
+ const newImageData = await regenerateImage(
75
+ segment.imagePrompts[panelIndex],
76
+ segment.session_id
77
+ );
78
+ if (newImageData) {
79
+ // Mettre à jour l'image dans le segment
80
+ segment.images[panelIndex] = newImageData;
81
+ // Réinitialiser l'état de chargement
82
+ setImageLoaded(false);
83
+ setImageDisplayed(false);
84
+ // Recharger l'image
85
+ if (imageCache.has(imageId)) {
86
+ URL.revokeObjectURL(imageCache.get(imageId));
87
+ imageCache.delete(imageId);
88
+ }
89
+ loadedImagesState.delete(imageId);
90
+ }
91
+ } finally {
92
+ setIsRegenerating(false);
93
+ // Laisser l'animation se terminer avant de réinitialiser
94
+ setTimeout(() => {
95
+ setIsSpinning(false);
96
+ }, 500);
97
+ }
98
+ };
99
+
100
  // Gérer le chargement initial de l'image
101
  useEffect(() => {
102
  if (!hasImage || loadedImagesState.get(imageId)) return;
 
161
  borderRadius: "4px",
162
  overflow: "hidden",
163
  position: "relative",
164
+ "&:hover .refresh-button": {
165
+ opacity: 1,
166
+ },
167
  }}
168
  >
169
  {hasImage && imageDataRef.current && (
 
176
  height: "100%",
177
  objectFit: "cover",
178
  opacity: imageDisplayed ? 1 : 0,
179
+ transition: "opacity 0.25s ease-in-out",
180
  willChange: "opacity",
181
  }}
182
  loading="eager"
183
  decoding="sync"
184
  />
185
  )}
186
+ {(!hasImage || !imageDisplayed || isRegenerating) && (
187
  <Box
188
  sx={{
189
  width: "100%",
 
196
  top: 0,
197
  left: 0,
198
  opacity: imageDisplayed ? 0 : 1,
199
+ transition: "opacity 0.25s ease-in-out",
200
  }}
201
  >
202
  <CircularProgress size={24} />
203
  </Box>
204
  )}
205
+ <Tooltip title="Regenerate this image" placement="top">
206
+ <IconButton
207
+ className="refresh-button"
208
+ onClick={handleRegenerate}
209
+ disabled={isRegenerating}
210
+ sx={{
211
+ position: "absolute",
212
+ top: 8,
213
+ right: 8,
214
+ opacity: 0,
215
+ transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
216
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
217
+ "&:hover": {
218
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
219
+ "& .MuiSvgIcon-root": {
220
+ animation: `${spinHover} 1s cubic-bezier(0.4, 0, 0.2, 1) infinite`,
221
+ },
222
+ },
223
+ "& .MuiSvgIcon-root": {
224
+ color: "white",
225
+ transition: "transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
226
+ animation: isSpinning
227
+ ? `${spinFull} 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite`
228
+ : "none",
229
+ willChange: "transform",
230
+ },
231
+ }}
232
+ >
233
+ <RefreshIcon />
234
+ </IconButton>
235
+ </Tooltip>
236
  {isFirstPanel && segment?.text && (
237
  <Box
238
  sx={{
 
244
  background: "rgba(255, 255, 255, 0.95)",
245
  color: "black",
246
  textAlign: "center",
 
247
  fontWeight: 500,
248
  borderRadius: "8px",
249
  display: "flex",
 
255
  <Typography
256
  variant="body1"
257
  sx={{
258
+ fontSize: { xs: "0.775rem", sm: "1rem" }, // Responsive font size
259
  color: "black",
260
+ lineHeight: 1.2,
261
  }}
262
  >
263
  {segment.text}
client/src/layouts/config.js CHANGED
@@ -5,6 +5,7 @@ export const PANEL_SIZES = {
5
  LANDSCAPE: { width: 768, height: 512 },
6
  PANORAMIC: { width: 1024, height: 512 },
7
  COVER_SIZE: { width: 512, height: 768 },
 
8
  };
9
 
10
  // Grid span helpers
@@ -50,24 +51,24 @@ export const LAYOUTS = {
50
  gridCols: 3,
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
  },
59
  LAYOUT_4: {
60
  gridCols: 2,
61
  gridRows: 3,
62
  panels: [
63
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
64
  {
65
- ...PANEL_SIZES.PORTRAIT,
66
  gridColumn: "1",
67
  gridRow: GRID.FULL_HEIGHT_FROM_2,
68
  }, // Tall portrait left
69
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: "2", gridRow: "2" }, // Square middle right
70
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: "2", gridRow: "3" }, // Square bottom right
71
  ],
72
  },
73
  LAYOUT_5: {
@@ -102,8 +103,8 @@ export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
102
  export const LAYOUTS_BY_PANEL_COUNT = {
103
  1: ["COVER"],
104
  2: ["LAYOUT_7"],
105
- 3: ["LAYOUT_2", "LAYOUT_5"], //"LAYOUT_1",
106
- 4: ["LAYOUT_3", "LAYOUT_4"],
107
  };
108
 
109
  // Helper functions for layout configuration
 
5
  LANDSCAPE: { width: 768, height: 512 },
6
  PANORAMIC: { width: 1024, height: 512 },
7
  COVER_SIZE: { width: 512, height: 768 },
8
+ SQUARE: { width: 512, height: 512 },
9
  };
10
 
11
  // Grid span helpers
 
51
  gridCols: 3,
52
  gridRows: 2,
53
  panels: [
54
+ { ...PANEL_SIZES.SQUARE, gridColumn: GRID.TWO_THIRDS, gridRow: "1" }, // Wide landscape top left
55
  { ...PANEL_SIZES.COLUMN, gridColumn: "3", gridRow: "1" }, // COLUMN top right
56
  { ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2" }, // COLUMN bottom left
57
+ { ...PANEL_SIZES.SQUARE, gridColumn: "2 / span 2", gridRow: "2" }, // Wide landscape bottom right
58
  ],
59
  },
60
  LAYOUT_4: {
61
  gridCols: 2,
62
  gridRows: 3,
63
  panels: [
64
+ { ...PANEL_SIZES.PANORAMIC, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
65
  {
66
+ ...PANEL_SIZES.COLUMN,
67
  gridColumn: "1",
68
  gridRow: GRID.FULL_HEIGHT_FROM_2,
69
  }, // Tall portrait left
70
+ { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "2" }, // Square middle right
71
+ { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "3" }, // Square bottom right
72
  ],
73
  },
74
  LAYOUT_5: {
 
103
  export const LAYOUTS_BY_PANEL_COUNT = {
104
  1: ["COVER"],
105
  2: ["LAYOUT_7"],
106
+ 3: ["LAYOUT_2"], //"LAYOUT_5"
107
+ 4: ["LAYOUT_3"], //, "LAYOUT_4"
108
  };
109
 
110
  // Helper functions for layout configuration
client/src/main.jsx CHANGED
@@ -9,19 +9,26 @@ 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(
15
  <ThemeProvider theme={theme}>
16
  <CssBaseline />
17
- <BrowserRouter>
18
- <Routes>
19
- <Route path="/" element={<Home />} />
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>
27
  );
 
9
  import { Tutorial } from "./pages/Tutorial";
10
  import Debug from "./pages/Debug";
11
  import { Universe } from "./pages/Universe";
12
+ import { SoundProvider } from "./contexts/SoundContext";
13
+ import { GameNavigation } from "./components/GameNavigation";
14
+ import { AppBackground } from "./components/AppBackground";
15
  import "./index.css";
16
 
17
  ReactDOM.createRoot(document.getElementById("root")).render(
18
  <ThemeProvider theme={theme}>
19
  <CssBaseline />
20
+ <SoundProvider>
21
+ <BrowserRouter>
22
+ <GameNavigation />
23
+ <AppBackground />
24
+ <Routes>
25
+ <Route path="/" element={<Home />} />
26
+ <Route path="/game" element={<Game />} />
27
+ <Route path="/tutorial" element={<Tutorial />} />
28
+ <Route path="/debug" element={<Debug />} />
29
+ <Route path="/universe" element={<Universe />} />
30
+ </Routes>
31
+ </BrowserRouter>
32
+ </SoundProvider>
33
  </ThemeProvider>
34
  );
client/src/pages/Game.jsx CHANGED
@@ -1,7 +1,3 @@
1
- import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2
- import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
3
- import VolumeOffIcon from "@mui/icons-material/VolumeOff";
4
- 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";
@@ -13,19 +9,31 @@ 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";
18
  import { useStoryCapture } from "../hooks/useStoryCapture";
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();
@@ -62,35 +70,18 @@ function GameContent() {
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 } =
90
- useNarrator(isSoundEnabled);
91
- const playPageSound = usePageSound(isSoundEnabled);
92
- const playWritingSound = useWritingSound(isSoundEnabled);
93
- const playTransitionSound = useTransitionSound(isSoundEnabled);
94
  const {
95
  sessionId,
96
  universe: gameUniverse,
@@ -126,41 +117,19 @@ function GameContent() {
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) => {
@@ -273,8 +242,76 @@ function GameContent() {
273
  }
274
  }, [isTransitionLoading]);
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  const handleBack = () => {
277
- playPageSound();
278
  navigate("/tutorial");
279
  };
280
 
@@ -295,18 +332,26 @@ function GameContent() {
295
  // Show loading state while session is initializing
296
  if (isSessionLoading) {
297
  return (
298
- <Box sx={{ width: "100%", height: "100vh", backgroundColor: "#1a1a1a" }}>
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
  }
@@ -332,19 +377,28 @@ function GameContent() {
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
  );
@@ -367,53 +421,7 @@ function GameContent() {
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}
@@ -429,7 +437,39 @@ function GameContent() {
429
  ) : showSlotMachine ? (
430
  <UniverseSlotMachine state={slotMachineState} />
431
  ) : (
432
- <ComicLayout />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  )}
434
  </Box>
435
 
 
 
 
 
 
1
  import { Box, IconButton, LinearProgress, Tooltip } from "@mui/material";
2
  import { motion } from "framer-motion";
3
  import { useEffect, useRef, useState } from "react";
 
9
  import { GameDebugPanel } from "../components/GameDebugPanel";
10
  import { UniverseSlotMachine } from "../components/UniverseSlotMachine";
11
  import { useGameSession } from "../hooks/useGameSession";
 
 
12
  import { useStoryCapture } from "../hooks/useStoryCapture";
 
 
13
  import { ComicLayout } from "../layouts/ComicLayout";
14
  import { storyApi, universeApi } from "../utils/api";
15
  import { GameProvider, useGame } from "../contexts/GameContext";
16
+ import { StoryChoices } from "../components/StoryChoices";
17
+ import { useSoundSystem } from "../contexts/SoundContext";
18
+ import { GameNavigation } from "../components/GameNavigation";
19
+ import { RotatingMessage } from "../components/RotatingMessage";
20
 
21
  // Constants
22
  const SOUND_ENABLED_KEY = "sound_enabled";
23
  const GAME_INITIALIZED_KEY = "game_initialized";
24
 
25
+ const TRANSITION_MESSAGES = [
26
+ "Opening the portal...",
27
+ "Take a deep breath...",
28
+ "Let's start...",
29
+ ];
30
+
31
+ const SESSION_LOADING_MESSAGES = [
32
+ "Waking up sleepy AI...",
33
+ "Calibrating the multiverse...",
34
+ "Gathering comic book inspiration...",
35
+ ];
36
+
37
  function GameContent() {
38
  const navigate = useNavigate();
39
  const { universeId } = useParams();
 
70
  setLayoutCounter,
71
  resetGame,
72
  generateImagesForStory,
73
+ isNarratorSpeaking,
74
+ playNarration,
75
+ stopNarration,
76
  } = useGame();
77
 
78
  const storyContainerRef = useRef(null);
79
  const { downloadStoryImage } = useStoryCapture();
80
  const [audioInitialized, setAudioInitialized] = useState(false);
81
+ const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
 
 
 
82
  const [loadingMessage, setLoadingMessage] = useState(0);
83
  const [isDebugVisible, setIsDebugVisible] = useState(false);
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  const {
86
  sessionId,
87
  universe: gameUniverse,
 
117
  };
118
  }, [audioInitialized]);
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  // Jouer le son de transition quand on passe de la slot machine au jeu
121
  useEffect(() => {
122
  if (!isInitialLoading && audioInitialized) {
123
+ playSound("transition");
124
  }
125
+ }, [isInitialLoading, audioInitialized]);
126
 
127
  // Sauvegarder l'état du son dans le localStorage
128
  useEffect(() => {
129
  localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
130
+ storyApi.setSoundEnabled(isSoundEnabled);
131
  }, [isSoundEnabled]);
132
 
 
 
 
 
 
 
 
 
 
 
133
  // Handle keyboard events for debug panel
134
  useEffect(() => {
135
  const handleKeyPress = (event) => {
 
242
  }
243
  }, [isTransitionLoading]);
244
 
245
+ // Effet pour gérer le scroll et la narration des nouveaux segments
246
+ useEffect(() => {
247
+ const loadedSegments = segments.filter((segment) => !segment.isLoading);
248
+ const lastSegment = loadedSegments[loadedSegments.length - 1];
249
+ const hasNewSegment = lastSegment && !lastSegment.hasBeenRead;
250
+
251
+ if (storyContainerRef.current && hasNewSegment && !isNarratorSpeaking) {
252
+ // Arrêter la narration en cours
253
+ if (isSoundEnabled) {
254
+ stopNarration();
255
+ }
256
+
257
+ // Scroll to the right
258
+ storyContainerRef.current.scrollTo({
259
+ left: storyContainerRef.current.scrollWidth,
260
+ behavior: "smooth",
261
+ });
262
+
263
+ let isCleanedUp = false;
264
+
265
+ // Attendre que le scroll soit terminé avant de démarrer la narration
266
+ const timeoutId = setTimeout(() => {
267
+ if (isCleanedUp) return;
268
+
269
+ // Jouer le son d'écriture
270
+ playSound("writing");
271
+
272
+ // Démarrer la narration après un court délai
273
+ setTimeout(() => {
274
+ if (isCleanedUp) return;
275
+
276
+ if (
277
+ lastSegment &&
278
+ lastSegment.text &&
279
+ isSoundEnabled &&
280
+ !isNarratorSpeaking
281
+ ) {
282
+ playNarration(lastSegment.text);
283
+ }
284
+ // Marquer le segment comme lu
285
+ lastSegment.hasBeenRead = true;
286
+ }, 500);
287
+ }, 500);
288
+
289
+ return () => {
290
+ isCleanedUp = true;
291
+ clearTimeout(timeoutId);
292
+ if (isSoundEnabled) {
293
+ stopNarration();
294
+ }
295
+ };
296
+ }
297
+ }, [
298
+ segments,
299
+ playNarration,
300
+ stopNarration,
301
+ isSoundEnabled,
302
+ playSound,
303
+ isNarratorSpeaking,
304
+ ]);
305
+
306
+ // Effet pour arrêter la narration quand le son est désactivé
307
+ useEffect(() => {
308
+ if (!isSoundEnabled) {
309
+ stopNarration();
310
+ }
311
+ }, [isSoundEnabled, stopNarration]);
312
+
313
  const handleBack = () => {
314
+ playSound("page");
315
  navigate("/tutorial");
316
  };
317
 
 
332
  // Show loading state while session is initializing
333
  if (isSessionLoading) {
334
  return (
335
+ <Box
336
+ sx={{
337
+ width: "100%",
338
+ height: "100vh",
339
+ display: "flex",
340
+ alignItems: "center",
341
+ justifyContent: "center",
342
+ position: "relative",
343
+ backgroundColor: "background.default",
344
+ }}
345
+ >
346
+ <LinearProgress
347
+ sx={{
348
+ position: "absolute",
349
+ top: 0,
350
+ left: 0,
351
+ right: 0,
352
+ }}
353
  />
354
+ <RotatingMessage messages={SESSION_LOADING_MESSAGES} isVisible={true} />
355
  </Box>
356
  );
357
  }
 
377
  // Afficher l'écran de transition après la slot machine
378
  if (isTransitionLoading) {
379
  return (
380
+ <Box
381
+ sx={{
382
+ width: "100%",
383
+ height: "100vh",
384
+ display: "flex",
385
+ alignItems: "center",
386
+ justifyContent: "center",
387
+ position: "relative",
388
+ backgroundColor: "background.default",
389
+ }}
390
+ >
391
+ <LinearProgress
392
+ sx={{
393
+ position: "absolute",
394
+ top: 0,
395
+ left: 0,
396
+ right: 0,
397
+ }}
398
+ />
399
+ <RotatingMessage
400
+ messages={TRANSITION_MESSAGES}
401
+ isVisible={isTransitionLoading}
402
  />
403
  </Box>
404
  );
 
421
  overflow: "hidden",
422
  }}
423
  >
424
+ <GameNavigation />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  {/* Main content */}
426
  <Box
427
  ref={storyContainerRef}
 
437
  ) : showSlotMachine ? (
438
  <UniverseSlotMachine state={slotMachineState} />
439
  ) : (
440
+ <Box
441
+ sx={{
442
+ height: "100%",
443
+ width: "100%",
444
+ display: "flex",
445
+ flexDirection: "column",
446
+ position: "relative",
447
+ }}
448
+ >
449
+ <Box
450
+ sx={{
451
+ height: "85%",
452
+ width: "100%",
453
+ overflow: "auto",
454
+ }}
455
+ >
456
+ <ComicLayout />
457
+ </Box>
458
+ <Box
459
+ sx={{
460
+ height: "15%",
461
+ width: "100%",
462
+ display: "flex",
463
+ justifyContent: "center",
464
+ alignItems: "center",
465
+ px: 2,
466
+ }}
467
+ >
468
+ <Box sx={{ width: "100%", maxWidth: "800px" }}>
469
+ <StoryChoices />
470
+ </Box>
471
+ </Box>
472
+ </Box>
473
  )}
474
  </Box>
475
 
client/src/pages/Home.jsx CHANGED
@@ -1,17 +1,24 @@
1
- import { Box, Button, Typography } from "@mui/material";
 
 
 
 
 
 
2
  import { motion } from "framer-motion";
3
  import { useNavigate } from "react-router-dom";
4
- import { usePageSound } from "../hooks/usePageSound";
5
  import { BlinkingText } from "../components/BlinkingText";
6
  import { BookPages } from "../components/BookPages";
7
- import { InfiniteBackground } from "../components/InfiniteBackground";
8
 
9
  export function Home() {
10
  const navigate = useNavigate();
11
- const playPageSound = usePageSound();
 
 
12
 
13
  const handlePlay = () => {
14
- playPageSound();
15
  navigate("/tutorial");
16
  };
17
 
@@ -21,7 +28,12 @@ export function Home() {
21
  animate={{ opacity: 1 }}
22
  exit={{ opacity: 0 }}
23
  transition={{ duration: 0.3, ease: "easeInOut" }}
24
- style={{ width: "100%", height: "100vh", position: "relative" }}
 
 
 
 
 
25
  >
26
  <Box
27
  sx={{
@@ -30,24 +42,38 @@ export function Home() {
30
  alignItems: "center",
31
  justifyContent: "center",
32
  minHeight: "100vh",
 
33
  width: "100%",
34
  position: "relative",
 
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
  >
@@ -61,8 +87,11 @@ export function Home() {
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
@@ -75,8 +104,8 @@ export function Home() {
75
  onClick={handlePlay}
76
  sx={{
77
  mt: 4,
78
- fontSize: "1.2rem",
79
- padding: "12px 36px",
80
  zIndex: 10,
81
  }}
82
  >
 
1
+ import {
2
+ Box,
3
+ Button,
4
+ Typography,
5
+ useTheme,
6
+ useMediaQuery,
7
+ } from "@mui/material";
8
  import { motion } from "framer-motion";
9
  import { useNavigate } from "react-router-dom";
10
+ import { useSoundSystem } from "../contexts/SoundContext";
11
  import { BlinkingText } from "../components/BlinkingText";
12
  import { BookPages } from "../components/BookPages";
 
13
 
14
  export function Home() {
15
  const navigate = useNavigate();
16
+ const { playSound } = useSoundSystem();
17
+ const theme = useTheme();
18
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
19
 
20
  const handlePlay = () => {
21
+ playSound("page");
22
  navigate("/tutorial");
23
  };
24
 
 
28
  animate={{ opacity: 1 }}
29
  exit={{ opacity: 0 }}
30
  transition={{ duration: 0.3, ease: "easeInOut" }}
31
+ style={{
32
+ width: "100%",
33
+ height: "100vh",
34
+ position: "relative",
35
+ overflow: "hidden",
36
+ }}
37
  >
38
  <Box
39
  sx={{
 
42
  alignItems: "center",
43
  justifyContent: "center",
44
  minHeight: "100vh",
45
+ height: "100%",
46
  width: "100%",
47
  position: "relative",
48
+ overflow: "hidden",
49
  }}
50
  >
 
 
51
  <Typography
52
  variant="h1"
53
+ sx={{
54
+ zIndex: 10,
55
+ textAlign: "center",
56
+ position: "relative",
57
+ fontSize: {
58
+ xs: "clamp(2.5rem, 8vw, 3.5rem)",
59
+ sm: "clamp(3.5rem, 10vw, 6rem)",
60
+ },
61
+ lineHeight: {
62
+ xs: 1.2,
63
+ sm: 1.1,
64
+ },
65
+ }}
66
  >
67
  interactive
68
  <br /> comic book
69
  <div
70
  style={{
71
  position: "absolute",
72
+ top: isMobile ? "-20px" : "-40px",
73
+ left: isMobile ? "-40px" : "-120px",
74
+ fontSize: isMobile
75
+ ? "clamp(1rem, 4vw, 1.5rem)"
76
+ : "clamp(1.5rem, 3vw, 2.5rem)",
77
  transform: "rotate(-15deg)",
78
  }}
79
  >
 
87
  zIndex: 10,
88
  textAlign: "center",
89
  mt: 2,
90
+ maxWidth: isMobile ? "80%" : "50%",
91
  opacity: 0.8,
92
+ px: isMobile ? 2 : 0,
93
+ fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
94
+ lineHeight: 1.6,
95
  }}
96
  >
97
  Experience a unique comic book where artificial intelligence brings
 
104
  onClick={handlePlay}
105
  sx={{
106
  mt: 4,
107
+ fontSize: isMobile ? "1rem" : "1.2rem",
108
+ padding: isMobile ? "8px 24px" : "12px 36px",
109
  zIndex: 10,
110
  }}
111
  >
client/src/pages/Tutorial.jsx CHANGED
@@ -2,270 +2,105 @@ import {
2
  Box,
3
  Typography,
4
  Button,
5
- Paper,
6
- IconButton,
7
- Tooltip,
8
  } from "@mui/material";
9
  import { useNavigate } from "react-router-dom";
10
- import PlayArrowIcon from "@mui/icons-material/PlayArrow";
11
- import ArrowBackIcon from "@mui/icons-material/ArrowBack";
12
- import { usePageSound } from "../hooks/usePageSound";
13
  import { motion } from "framer-motion";
 
14
  import { StyledText } from "../components/StyledText";
15
- import { BookPages } from "../components/BookPages";
16
 
17
  export function Tutorial() {
18
  const navigate = useNavigate();
19
- const playPageSound = usePageSound();
 
 
20
 
21
  const handleStartGame = () => {
22
- playPageSound();
23
  navigate("/game");
24
  };
25
 
26
- const handleBack = () => {
27
- playPageSound();
28
- navigate("/");
29
- };
30
-
31
  return (
32
  <motion.div
33
  initial={{ opacity: 0 }}
34
  animate={{ opacity: 1 }}
35
  exit={{ opacity: 0 }}
36
  transition={{ duration: 0.3, ease: "easeInOut" }}
37
- style={{ backgroundColor: "#121212", width: "100%" }}
 
 
 
 
 
38
  >
 
39
  <Box
40
  sx={{
41
- minHeight: "100vh",
42
- width: "100%",
43
  display: "flex",
44
  flexDirection: "column",
45
  alignItems: "center",
46
  justifyContent: "center",
47
- gap: 4,
48
- padding: 4,
49
- backgroundColor: "background.default",
50
  position: "relative",
 
51
  }}
52
  >
53
- <Tooltip title="Back to home">
54
- <IconButton
55
- onClick={handleBack}
56
- sx={{
57
- position: "absolute",
58
- top: 16,
59
- left: 16,
60
- color: "white",
61
- backgroundColor: "rgba(0, 0, 0, 0.5)",
62
- "&:hover": {
63
- backgroundColor: "rgba(0, 0, 0, 0.7)",
64
- },
65
- zIndex: 10,
66
- }}
67
- >
68
- <ArrowBackIcon />
69
- </IconButton>
70
- </Tooltip>
71
-
72
- <Box
73
  sx={{
 
 
74
  position: "relative",
75
- width: "auto",
76
- height: "80vh",
77
- aspectRatio: "0.66666667",
78
- display: "flex",
79
- alignItems: "center",
80
- justifyContent: "center",
81
  }}
82
  >
83
- {/* Pages d'arrière-plan */}
84
- <BookPages />
85
-
86
- {/* Page principale */}
87
- <Paper
88
- elevation={3}
89
- sx={{
90
- position: "relative",
91
- zIndex: 2,
92
- width: "100%",
93
- height: "100%",
94
- backgroundColor: "#fff",
95
- color: "black",
96
- padding: "3rem 3rem 3rem 2rem",
97
- borderRadius: "4px",
98
- display: "flex",
99
- flexDirection: "column",
100
- alignItems: "center",
101
- justifyContent: "space-between",
102
- gap: 2,
103
- overflowY: "auto",
104
- boxShadow: "0 0 20px rgba(0,0,0,0.2)",
105
- "&::before": {
106
- content: '""',
107
- position: "absolute",
108
- top: 0,
109
- left: "4px",
110
- bottom: 0,
111
- width: "120px",
112
- background:
113
- "linear-gradient(to right, rgba(0,0,0,0.25), rgba(0,0,0,0))",
114
- opacity: 0.2,
115
- pointerEvents: "none",
116
- zIndex: 1,
117
- },
118
- "&::after": {
119
- content: '""',
120
- position: "absolute",
121
- top: 0,
122
- left: "4px",
123
- bottom: 0,
124
- width: "1px",
125
- background:
126
- "linear-gradient(to right, rgba(0,0,0,0.15), transparent)",
127
- borderRadius: "1px",
128
- zIndex: 2,
129
- },
130
- "&::-webkit-scrollbar": {
131
- width: "8px",
132
- },
133
- "&::-webkit-scrollbar-track": {
134
- background: "transparent",
135
- },
136
- "&::-webkit-scrollbar-thumb": {
137
- background: "rgba(0,0,0,0.1)",
138
- borderRadius: "4px",
139
- },
140
- }}
141
- >
142
- {/* Section Synopsis */}
143
- <Box
144
- sx={{
145
- maxWidth: "600px",
146
- margin: "auto",
147
- textAlign: "center",
148
- flex: 1,
149
- display: "flex",
150
- flexDirection: "column",
151
- justifyContent: "center",
152
- }}
153
- >
154
- <Typography
155
- variant="h3"
156
- component="h1"
157
- textAlign="center"
158
- gutterBottom
159
- sx={{
160
- width: "100%",
161
- color: "#2c1810",
162
- fontWeight: "bold",
163
- textShadow: `
164
- 0 -1px 1px rgba(0,0,0,0.2),
165
- 0 1px 1px rgba(255,255,255,0.3)
166
- `,
167
- letterSpacing: "0.5px",
168
- marginBottom: 3,
169
- "&::after": {
170
- content: '""',
171
- display: "block",
172
- width: "40%",
173
- height: "1px",
174
- background: "rgba(0,0,0,0.2)",
175
- margin: "0.5rem auto",
176
- },
177
- }}
178
- >
179
- Synopsis
180
- </Typography>
181
- <StyledText
182
- variant="body1"
183
- paragraph
184
- sx={{
185
- fontWeight: "normal",
186
- color: "#2c1810",
187
- fontSize: "0.95rem",
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"
195
- paragraph
196
- sx={{
197
- fontWeight: "normal",
198
- color: "#2c1810",
199
- fontSize: "0.95rem",
200
- lineHeight: 1.6,
201
- marginBottom: 1.5,
202
- }}
203
- text={`With each story, you land in a completely new universe. Each <strong>world</strong> presents its own challenges and <strong>obstacles</strong>. You must make crucial <strong>decisions</strong> to advance in your <strong>quest</strong>.`}
204
- />
205
- <StyledText
206
- variant="body1"
207
- paragraph
208
- sx={{
209
- fontWeight: "normal",
210
- color: "#2c1810",
211
- fontSize: "0.95rem",
212
- lineHeight: 1.6,
213
- marginBottom: 0,
214
- }}
215
- text={`Every <strong>choice</strong> you make can alter the course of your <strong>pursuit</strong>. <strong>Time</strong> is of the essence, and every <strong>action</strong> counts in this thrilling adventure.`}
216
- />
217
- </Box>
218
 
219
- {/* Section How to Play */}
220
- <Box
221
- sx={{
222
- width: "100%",
223
- borderTop: "1px solid rgba(0,0,0,0.1)",
224
- paddingTop: 1.5,
225
- marginTop: 2,
226
- }}
227
- >
228
- <Typography
229
- variant="h6"
230
- sx={{
231
- color: "#2c1810",
232
- fontWeight: "bold",
233
- textShadow: `
234
- 0 -1px 1px rgba(0,0,0,0.2),
235
- 0 1px 1px rgba(255,255,255,0.3)
236
- `,
237
- marginBottom: 0.5,
238
- textAlign: "center",
239
- fontSize: "1rem",
240
- }}
241
- >
242
- How to play
243
- </Typography>
244
- <StyledText
245
- variant="body1"
246
- sx={{
247
- fontWeight: "normal",
248
- color: "#2c1810",
249
- fontSize: "0.9rem",
250
- lineHeight: 1.5,
251
- textAlign: "center",
252
- fontStyle: "italic",
253
- }}
254
- text="At each step, click one of the available <strong>choices</strong>."
255
- />
256
- </Box>
257
- </Paper>
258
- </Box>
259
 
260
  <Button
261
- variant="outlined"
262
  size="large"
 
263
  onClick={handleStartGame}
264
  sx={{
265
- fontSize: "1.2rem",
266
- padding: "12px 24px",
 
267
  zIndex: 10,
268
- position: "relative",
269
  }}
270
  >
271
  Start the game
 
2
  Box,
3
  Typography,
4
  Button,
5
+ useTheme,
6
+ useMediaQuery,
 
7
  } from "@mui/material";
8
  import { useNavigate } from "react-router-dom";
9
+ import { useSoundSystem } from "../contexts/SoundContext";
 
 
10
  import { motion } from "framer-motion";
11
+ import { GameNavigation } from "../components/GameNavigation";
12
  import { StyledText } from "../components/StyledText";
 
13
 
14
  export function Tutorial() {
15
  const navigate = useNavigate();
16
+ const { playSound } = useSoundSystem();
17
+ const theme = useTheme();
18
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
19
 
20
  const handleStartGame = () => {
21
+ playSound("page");
22
  navigate("/game");
23
  };
24
 
 
 
 
 
 
25
  return (
26
  <motion.div
27
  initial={{ opacity: 0 }}
28
  animate={{ opacity: 1 }}
29
  exit={{ opacity: 0 }}
30
  transition={{ duration: 0.3, ease: "easeInOut" }}
31
+ style={{
32
+ width: "100%",
33
+ height: "100vh",
34
+ position: "relative",
35
+ overflow: "hidden",
36
+ }}
37
  >
38
+ <GameNavigation />
39
  <Box
40
  sx={{
 
 
41
  display: "flex",
42
  flexDirection: "column",
43
  alignItems: "center",
44
  justifyContent: "center",
45
+ minHeight: "100vh",
46
+ height: "100%",
47
+ width: "100%",
48
  position: "relative",
49
+ overflow: "hidden",
50
  }}
51
  >
52
+ <Typography
53
+ variant="h2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  sx={{
55
+ zIndex: 10,
56
+ textAlign: "center",
57
  position: "relative",
58
+ color: "white",
59
+ fontSize: {
60
+ xs: "clamp(2rem, 7vw, 2.5rem)",
61
+ sm: "clamp(2.5rem, 8vw, 4rem)",
62
+ },
63
+ px: isMobile ? 2 : 0,
64
  }}
65
  >
66
+ How to play
67
+ </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ <Typography
70
+ variant="body1"
71
+ sx={{
72
+ zIndex: 10,
73
+ textAlign: "center",
74
+ mt: 2,
75
+ maxWidth: isMobile ? "85%" : "50%",
76
+ opacity: 0.8,
77
+ color: "white",
78
+ px: isMobile ? 3 : 0,
79
+ fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
80
+ lineHeight: 1.6,
81
+ }}
82
+ >
83
+ The game will create a unique comic book set in a distinct universe
84
+ for each playthrough.
85
+ <br />
86
+ <br />
87
+ At every stage of the narrative, you will be presented with choices or
88
+ the opportunity to write the next part of the story yourself.
89
+ <br />
90
+ <br />
91
+ It's your turn to write your own story.
92
+ </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  <Button
95
+ color="primary"
96
  size="large"
97
+ variant="contained"
98
  onClick={handleStartGame}
99
  sx={{
100
+ mt: 4,
101
+ fontSize: isMobile ? "1rem" : "1.2rem",
102
+ padding: isMobile ? "8px 24px" : "12px 36px",
103
  zIndex: 10,
 
104
  }}
105
  >
106
  Start the game
client/src/utils/api.js CHANGED
@@ -69,19 +69,50 @@ const handleApiError = (error) => {
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) => {
@@ -119,6 +150,24 @@ export const storyApi = {
119
  }
120
  },
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  generateImage: async (
123
  prompt,
124
  width = 512,
@@ -147,6 +196,11 @@ export const storyApi = {
147
  // Narration related API calls
148
  playNarration: async (text, sessionId) => {
149
  try {
 
 
 
 
 
150
  // Stop any existing narration
151
  if (audioSource) {
152
  audioSource.stop();
@@ -155,6 +209,9 @@ export const storyApi = {
155
 
156
  // Initialize audio context if needed
157
  audioContext = initAudioContext();
 
 
 
158
 
159
  const response = await api.post(
160
  "/api/text-to-speech",
@@ -171,6 +228,11 @@ export const storyApi = {
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);
@@ -182,6 +244,11 @@ export const storyApi = {
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;
@@ -203,12 +270,25 @@ export const storyApi = {
203
 
204
  stopNarration: () => {
205
  if (audioSource) {
206
- audioSource.stop();
 
 
 
 
207
  audioSource = null;
208
  }
 
 
 
 
 
 
 
209
  },
210
 
211
  initAudioContext,
 
 
212
  };
213
 
214
  // WebSocket URL
 
69
  // Audio context for narration
70
  let audioContext = null;
71
  let audioSource = null;
72
+ let isSoundEnabled = true;
73
+ let hasUserInteraction = false;
74
 
75
  // Initialize audio context on user interaction
76
  const initAudioContext = () => {
77
+ if (!hasUserInteraction) {
78
+ console.warn("Audio context cannot be initialized before user interaction");
79
+ return null;
80
+ }
81
+
82
  if (!audioContext) {
83
+ try {
84
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
85
+ if (audioContext.state === "suspended") {
86
+ audioContext.resume();
87
+ }
88
+ } catch (error) {
89
+ console.error("Failed to initialize audio context:", error);
90
+ return null;
91
  }
92
  }
93
  return audioContext;
94
  };
95
 
96
+ // Function to call when user interacts with the page
97
+ const handleUserInteraction = () => {
98
+ hasUserInteraction = true;
99
+ if (audioContext && audioContext.state === "suspended") {
100
+ audioContext.resume();
101
+ }
102
+ };
103
+
104
+ // Nouvelle fonction pour gérer l'état du son
105
+ const setSoundEnabled = (enabled) => {
106
+ isSoundEnabled = enabled;
107
+ if (!enabled && audioSource) {
108
+ audioSource.stop();
109
+ audioSource = null;
110
+ }
111
+ if (!enabled && audioContext) {
112
+ audioContext.suspend();
113
+ }
114
+ };
115
+
116
  // Story related API calls
117
  export const storyApi = {
118
  start: async (sessionId) => {
 
150
  }
151
  },
152
 
153
+ makeCustomChoice: async (customText, sessionId) => {
154
+ try {
155
+ const response = await api.post(
156
+ "/api/chat",
157
+ {
158
+ message: "custom_choice",
159
+ custom_text: customText,
160
+ },
161
+ {
162
+ headers: getDefaultHeaders(sessionId),
163
+ }
164
+ );
165
+ return response.data;
166
+ } catch (error) {
167
+ return handleApiError(error);
168
+ }
169
+ },
170
+
171
  generateImage: async (
172
  prompt,
173
  width = 512,
 
196
  // Narration related API calls
197
  playNarration: async (text, sessionId) => {
198
  try {
199
+ // Ne rien faire si le son est désactivé ou si pas d'interaction utilisateur
200
+ if (!isSoundEnabled || !hasUserInteraction) {
201
+ return;
202
+ }
203
+
204
  // Stop any existing narration
205
  if (audioSource) {
206
  audioSource.stop();
 
209
 
210
  // Initialize audio context if needed
211
  audioContext = initAudioContext();
212
+ if (!audioContext) {
213
+ return;
214
+ }
215
 
216
  const response = await api.post(
217
  "/api/text-to-speech",
 
228
  throw new Error("Failed to generate audio");
229
  }
230
 
231
+ // Ne pas continuer si le son a été désactivé pendant la requête
232
+ if (!isSoundEnabled) {
233
+ return;
234
+ }
235
+
236
  // Convert base64 to audio buffer
237
  const audioData = atob(response.data.audio_base64);
238
  const arrayBuffer = new ArrayBuffer(audioData.length);
 
244
  // Decode audio data
245
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
246
 
247
+ // Ne pas continuer si le son a été désactivé pendant le décodage
248
+ if (!isSoundEnabled) {
249
+ return;
250
+ }
251
+
252
  // Create and play audio source
253
  audioSource = audioContext.createBufferSource();
254
  audioSource.buffer = audioBuffer;
 
270
 
271
  stopNarration: () => {
272
  if (audioSource) {
273
+ try {
274
+ audioSource.stop();
275
+ } catch (error) {
276
+ console.warn("Error stopping narration:", error);
277
+ }
278
  audioSource = null;
279
  }
280
+ if (audioContext) {
281
+ try {
282
+ audioContext.suspend();
283
+ } catch (error) {
284
+ console.warn("Error suspending audio context:", error);
285
+ }
286
+ }
287
  },
288
 
289
  initAudioContext,
290
+ handleUserInteraction,
291
+ setSoundEnabled, // Exporter la nouvelle fonction
292
  };
293
 
294
  // WebSocket URL
server/api/models.py CHANGED
@@ -40,6 +40,7 @@ class StoryMetadataResponse(BaseModel):
40
  class ChatMessage(BaseModel):
41
  message: str
42
  choice_id: Optional[int] = None
 
43
 
44
  class ImageGenerationRequest(BaseModel):
45
  prompt: str
 
40
  class ChatMessage(BaseModel):
41
  message: str
42
  choice_id: Optional[int] = None
43
+ custom_text: Optional[str] = None # Pour le choix personnalisé
44
 
45
  class ImageGenerationRequest(BaseModel):
46
  prompt: str
server/api/routes/chat.py CHANGED
@@ -54,7 +54,10 @@ def get_chat_router(session_manager: SessionManager, story_generator):
54
  )
55
  previous_choice = "none"
56
  else:
57
- previous_choice = f"Choice {chat_message.choice_id}" if chat_message.choice_id else "none"
 
 
 
58
 
59
  # Generate story segment
60
  llm_response = await story_generator.generate_story_segment(
 
54
  )
55
  previous_choice = "none"
56
  else:
57
+ if chat_message.message == "custom_choice" and chat_message.custom_text:
58
+ previous_choice = chat_message.custom_text
59
+ else:
60
+ previous_choice = f"Choice {chat_message.choice_id}" if chat_message.choice_id else "none"
61
 
62
  # Generate story segment
63
  llm_response = await story_generator.generate_story_segment(
server/core/generators/image_prompt_generator.py CHANGED
@@ -119,6 +119,7 @@ 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
  """
 
119
  and explain the story like a storyboard.
120
 
121
  Dont put the hero name every time.
122
+ Exactly between 1 and 4 panels. (mostly 2 or 3)
123
 
124
  {is_end}
125
  """
server/core/generators/story_segment_generator.py CHANGED
@@ -76,14 +76,14 @@ IMPORTANT RULES FOR STORY TEXT:
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}
@@ -104,7 +104,7 @@ Story history:
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(
@@ -186,6 +186,18 @@ Be short.
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,
 
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 20 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-20 words)
87
  5. The MacGuffin should remain mysterious but central to the plot
88
 
89
  Hero Description: {self.hero_desc}
 
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 20 words, keep it concise.
108
  Be short.
109
  """
110
  return ChatPromptTemplate(
 
186
 
187
  what_to_represent = self._get_what_to_represent(story_beat, is_death, is_victory)
188
 
189
+ # Si c'est un choix personnalisé, on l'utilise comme contexte pour générer la suite
190
+ if previous_choice and not previous_choice.startswith("Choice "):
191
+ what_to_represent = f"""
192
+ Based on the player's custom choice: "{previous_choice}"
193
+
194
+ Write a story segment that:
195
+ 1. Directly follows and incorporates the player's choice
196
+ 2. Maintains consistency with the universe and story
197
+ 3. Respects all previous rules about length and style
198
+ 4. Naturally integrates the custom elements while staying true to the plot
199
+ """
200
+
201
  # Créer les messages de base une seule fois
202
  messages = self.prompt.format_messages(
203
  hero_description=self.hero_desc,
server/core/story_generator.py CHANGED
@@ -89,63 +89,66 @@ class StoryGenerator:
89
  return self.segment_generators[session_id]
90
 
91
  async def generate_story_segment(self, session_id: str, game_state: GameState, previous_choice: str) -> StoryResponse:
92
- """Generate a story segment."""
93
- segment_generator = self.get_segment_generator(session_id)
94
- story_history = game_state.format_history()
95
-
96
- # Generate story text first
97
- segment_response = await segment_generator.generate(
98
- story_beat=game_state.story_beat,
99
- current_time=game_state.current_time,
100
- current_location=game_state.current_location,
101
- previous_choice=previous_choice,
102
- story_history=story_history,
103
- turn_before_end=self.turn_before_end,
104
- is_winning_story=self.is_winning_story
105
- )
106
 
107
- # print(f"Generated story text: {segment_response}")
108
-
109
- # Then get metadata using the new story text
110
- metadata_response = await self.metadata_generator.generate(
111
- story_text=segment_response.story_text,
112
- current_time=game_state.current_time,
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
-
121
- # Generate image prompts
122
- prompts_response = await self.image_prompt_generator.generate(
123
- story_text=segment_response.story_text,
124
- time=metadata_response.time,
125
- location=metadata_response.location,
126
- is_death=metadata_response.is_death,
127
- is_victory=metadata_response.is_victory,
128
- turn_before_end=self.turn_before_end,
129
- is_winning_story=self.is_winning_story
130
- )
131
- # print(f"Generated image prompts: {prompts_response}")
132
-
133
- # Create choices
134
- choices = [
135
- Choice(id=i, text=choice_text)
136
- for i, choice_text in enumerate(metadata_response.choices, 1)
137
- ]
138
-
139
- response = StoryResponse(
140
- story_text=segment_response.story_text,
141
- choices=choices,
142
- time=metadata_response.time,
143
- location=metadata_response.location,
144
- raw_choices=metadata_response.choices,
145
- image_prompts=prompts_response.image_prompts,
146
- is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
147
- is_death=metadata_response.is_death,
148
- is_victory=metadata_response.is_victory
149
- )
150
-
151
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  return self.segment_generators[session_id]
90
 
91
  async def generate_story_segment(self, session_id: str, game_state: GameState, previous_choice: str) -> StoryResponse:
92
+ try:
93
+ # On utilise toujours le générateur de segments, même pour un choix personnalisé
94
+ segment_generator = self.get_segment_generator(session_id)
95
+ if not segment_generator:
96
+ raise ValueError("No story segment generator found for this session")
 
 
 
 
 
 
 
 
 
97
 
98
+ segment_response = await segment_generator.generate(
99
+ story_beat=game_state.story_beat,
100
+ current_time=game_state.current_time,
101
+ current_location=game_state.current_location,
102
+ previous_choice=previous_choice,
103
+ story_history=game_state.format_history(),
104
+ turn_before_end=self.turn_before_end,
105
+ is_winning_story=self.is_winning_story
106
+ )
107
+ story_text = segment_response.story_text
108
+
109
+ # Then get metadata using the new story text
110
+ metadata_response = await self.metadata_generator.generate(
111
+ story_text=story_text,
112
+ current_time=game_state.current_time,
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=game_state.format_history()
118
+ )
119
+ # print(f"Generated metadata_response: {metadata_response}")
120
+
121
+ # Generate image prompts
122
+ prompts_response = await self.image_prompt_generator.generate(
123
+ story_text=story_text,
124
+ time=metadata_response.time,
125
+ location=metadata_response.location,
126
+ is_death=metadata_response.is_death,
127
+ is_victory=metadata_response.is_victory,
128
+ turn_before_end=self.turn_before_end,
129
+ is_winning_story=self.is_winning_story
130
+ )
131
+ # print(f"Generated image prompts: {prompts_response}")
132
+
133
+ # Create choices
134
+ choices = [
135
+ Choice(id=i, text=choice_text)
136
+ for i, choice_text in enumerate(metadata_response.choices, 1)
137
+ ]
138
+
139
+ response = StoryResponse(
140
+ story_text=story_text,
141
+ choices=choices,
142
+ time=metadata_response.time,
143
+ location=metadata_response.location,
144
+ raw_choices=metadata_response.choices,
145
+ image_prompts=prompts_response.image_prompts,
146
+ is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
147
+ is_death=metadata_response.is_death,
148
+ is_victory=metadata_response.is_victory
149
+ )
150
+
151
+ return response
152
+ except Exception as e:
153
+ print(f"Unexpected error in generate_story_segment: {str(e)}")
154
+ raise