remove non finished features, add mobile handling, add write your own path feature
Browse files- README.md +1 -1
- client/src/components/AppBackground.jsx +14 -0
- client/src/components/GameNavigation.jsx +72 -0
- client/src/components/InfiniteBackground.jsx +39 -29
- client/src/components/RotatingMessage.jsx +106 -0
- client/src/components/StoryChoices.jsx +179 -155
- client/src/components/UniverseSlotMachine.jsx +23 -17
- client/src/contexts/GameContext.jsx +50 -25
- client/src/contexts/SoundContext.jsx +142 -0
- client/src/hooks/useNarrator.js +0 -62
- client/src/hooks/usePageSound.js +0 -10
- client/src/hooks/useTransitionSound.js +0 -10
- client/src/hooks/useWritingSound.js +0 -10
- client/src/layouts/ComicLayout.jsx +9 -45
- client/src/layouts/Panel.jsx +104 -6
- client/src/layouts/config.js +9 -8
- client/src/main.jsx +16 -9
- client/src/pages/Game.jsx +167 -127
- client/src/pages/Home.jsx +44 -15
- client/src/pages/Tutorial.jsx +60 -225
- client/src/utils/api.js +85 -5
- server/api/models.py +1 -0
- server/api/routes/chat.py +4 -1
- server/core/generators/image_prompt_generator.py +1 -0
- server/core/generators/story_segment_generator.py +15 -3
- server/core/story_generator.py +62 -59
README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
---
|
2 |
-
title:
|
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 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
position: "absolute",
|
145 |
top: 0,
|
146 |
left: 0,
|
147 |
right: 0,
|
148 |
bottom: 0,
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
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:
|
166 |
-
|
167 |
-
|
168 |
-
zIndex: 1000,
|
169 |
}}
|
170 |
>
|
171 |
-
{
|
172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
173 |
<Box
|
174 |
-
key={choice.id}
|
175 |
sx={{
|
176 |
display: "flex",
|
177 |
flexDirection: "column",
|
178 |
alignItems: "center",
|
179 |
gap: 1,
|
180 |
-
|
181 |
-
|
|
|
182 |
}}
|
183 |
>
|
184 |
-
<Typography variant="caption" sx={{ opacity: 0.7, color: "white" }}>
|
185 |
-
Choice {index + 1}
|
186 |
-
</Typography>
|
187 |
<Button
|
188 |
-
variant="
|
189 |
size="large"
|
190 |
-
|
191 |
-
|
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: "
|
|
|
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 |
-
|
238 |
</Button>
|
239 |
</Box>
|
240 |
-
|
|
|
241 |
|
242 |
-
|
243 |
-
|
244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
245 |
sx={{
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
250 |
},
|
251 |
}}
|
252 |
-
|
253 |
-
|
254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
255 |
sx={{
|
256 |
-
|
257 |
-
|
258 |
-
|
|
|
259 |
}}
|
260 |
>
|
261 |
-
|
262 |
-
</
|
263 |
-
</
|
264 |
-
|
265 |
-
|
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
|
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 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
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 |
-
|
207 |
-
|
208 |
-
|
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
|
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:
|
419 |
-
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
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.
|
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.
|
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.
|
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.
|
57 |
],
|
58 |
},
|
59 |
LAYOUT_4: {
|
60 |
gridCols: 2,
|
61 |
gridRows: 3,
|
62 |
panels: [
|
63 |
-
{ ...PANEL_SIZES.
|
64 |
{
|
65 |
-
...PANEL_SIZES.
|
66 |
gridColumn: "1",
|
67 |
gridRow: GRID.FULL_HEIGHT_FROM_2,
|
68 |
}, // Tall portrait left
|
69 |
-
{ ...PANEL_SIZES.
|
70 |
-
{ ...PANEL_SIZES.
|
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"
|
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 |
-
<
|
18 |
-
<
|
19 |
-
<
|
20 |
-
<
|
21 |
-
<
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
|
|
|
|
|
|
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
|
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 |
-
|
146 |
}
|
147 |
-
}, [isInitialLoading, audioInitialized
|
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 |
-
|
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
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
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
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
348 |
/>
|
349 |
</Box>
|
350 |
);
|
@@ -367,53 +421,7 @@ function GameContent() {
|
|
367 |
overflow: "hidden",
|
368 |
}}
|
369 |
>
|
370 |
-
|
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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
import { motion } from "framer-motion";
|
3 |
import { useNavigate } from "react-router-dom";
|
4 |
-
import {
|
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
|
|
|
|
|
12 |
|
13 |
const handlePlay = () => {
|
14 |
-
|
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={{
|
|
|
|
|
|
|
|
|
|
|
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={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
>
|
43 |
interactive
|
44 |
<br /> comic book
|
45 |
<div
|
46 |
style={{
|
47 |
position: "absolute",
|
48 |
-
top: "-40px",
|
49 |
-
left: "-120px",
|
50 |
-
fontSize:
|
|
|
|
|
51 |
transform: "rotate(-15deg)",
|
52 |
}}
|
53 |
>
|
@@ -61,8 +87,11 @@ export function Home() {
|
|
61 |
zIndex: 10,
|
62 |
textAlign: "center",
|
63 |
mt: 2,
|
64 |
-
maxWidth: "
|
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 |
-
|
6 |
-
|
7 |
-
Tooltip,
|
8 |
} from "@mui/material";
|
9 |
import { useNavigate } from "react-router-dom";
|
10 |
-
import
|
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
|
|
|
|
|
20 |
|
21 |
const handleStartGame = () => {
|
22 |
-
|
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={{
|
|
|
|
|
|
|
|
|
|
|
38 |
>
|
|
|
39 |
<Box
|
40 |
sx={{
|
41 |
-
minHeight: "100vh",
|
42 |
-
width: "100%",
|
43 |
display: "flex",
|
44 |
flexDirection: "column",
|
45 |
alignItems: "center",
|
46 |
justifyContent: "center",
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
position: "relative",
|
|
|
51 |
}}
|
52 |
>
|
53 |
-
<
|
54 |
-
|
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 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
}}
|
82 |
>
|
83 |
-
|
84 |
-
|
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 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
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 |
-
|
262 |
size="large"
|
|
|
263 |
onClick={handleStartGame}
|
264 |
sx={{
|
265 |
-
|
266 |
-
|
|
|
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 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
|
|
|
|
|
|
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
|
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-
|
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
|
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 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
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 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|