19 Commits

Author SHA1 Message Date
H3XploR 3124239374 Merge pull request #23 from OlaketalAmigo/Rendu
MERGED
2026-04-02 11:51:55 +02:00
Thomas Fauve-Piot b31436a40a Wiskas 2026-04-02 11:32:21 +02:00
gprunet c066fdc31c Round Timer + room creation synced with browse tab 2026-04-01 21:31:14 +02:00
Thomas f9587c5cfa Initialize README with project details and instructions
Added project overview, instructions, features, dependencies, team information, project management, technical stack, and modules details to README.
2026-04-01 19:01:20 +02:00
Thomas Fauve-Piot 44a0ffe743 Added mentions legales 2026-04-01 18:22:02 +02:00
Thomas Fauve-Piot 0f0e777e6e Chat + legal mentions missing 2026-04-01 17:58:57 +02:00
Georges-Leonard Prunet c96629b704 Fix lobby player leaving 2026-03-31 15:59:02 +02:00
Georges-Leonard Prunet 41612f5d39 https + volume 2026-03-31 14:21:11 +02:00
kalips003 e1573ba9f0 ^^._, work in progress, small changes 2026-03-31 05:35:04 +02:00
kalips003 b9c4c817f8 ^^._, work in progress, small changes 2026-03-30 22:33:13 +02:00
kalips003 384363c8dd ^^._, work in progress, small changes 2026-03-30 17:19:14 +02:00
Kali Gallon def9918047 ^^._, work in progress, small changes 2026-03-30 01:36:33 +02:00
Kali Gallon cafa0cccc4 ^^._, work in progress, small changes 2026-03-27 23:17:31 +01:00
kalips003 8b907d5a86 transfer 2026-03-27 20:17:21 +01:00
H3XploR 13f93fb332 https installed 2026-03-24 14:29:04 +01:00
gprunet 801750da96 Notifs + Logout + delete Avatar 2026-03-22 18:26:50 +01:00
H3XploR 82623b0078 Merge pull request #21 from OlaketalAmigo/modular_code
cleaned
2026-03-22 13:48:56 +01:00
H3XploR d3e2d9bdf9 cleaned 2026-03-22 13:48:16 +01:00
H3XploR 9c1e8e03bb Merge pull request #20 from OlaketalAmigo/TETRIS
Tetris
2026-03-20 23:39:05 +01:00
86 changed files with 2427 additions and 832 deletions
+142
View File
@@ -0,0 +1,142 @@
*This project has been created as part of the 42 curriculum by agallon, gprunet, yantoine and tfauve-p*
**DESCRIPTION**
For starters, ft_transcendence is a wonderful project based on building a web-application running from Docker containers where the goal is, for the first time to do whatever we want, yet still we need to follow multiples criteria based on a number of points to grind to set the project as finished.
For such a project our group thought about the "CATETRIBBL.IO". We chose to make a web application featuring multiples games such as Tetris one of the very first game ever developed and Skkribl.io the amazing drawing game !
But beware ! A mysterious noble cat named Wiskas The Third is gone ... It is said that he's been lurking around trapping 42's students into infinite conversation known as "tunnel". If you see him please report to us as soon as possible !
**INSTRUCTIONS**
Like every 42 project you will need to git clone it into a valid repository, then add our functional .env file at the root of the repository. After all that, make use of the "make" command and watch our fabulous containers building themselves ! Look for https://localhost:8443/ once it's built, remember that you need to login in order to play on our web app !
Outside of 42 environment you will obviously need Docker and Make installed.
**RESOURCES**
- https://www.geeksforgeeks.org
- https://developer.mozilla.org/fr/docs/Web/JavaScript
- https://www.w3schools.com/js/
- https://www.tigerdata.com/learn/postgres-cheat-sheet
- https://www.programiz.com/css/button-styling
- https://developer.mozilla.org/fr/docs/Web/CSS
- https://chatgpt.com/
- https://www.gimp.org/tutorials/
AI was mostly used to ask questions and deepen understanding, it was also used to generate multiple samples of what we could do front-end wise.
**FEATURES:**
- Login
- Avatar
- Global Chat
- Skribbl.io + Spectator mode
- Tetris + Duels
- Wiskas the Third
Use of the framework Express for the back-end because its compatible with jsonwebtoken(JWT) and contains solid and well tested features.
**DEPENDENCIES**
- "express": "^4.18.2",
- "pg": "^8.11.3",
- "bcrypt": "^5.1.0",
- "jsonwebtoken": "^9.0.2",
- "dotenv": "^17.2.3",
- "socket.io": "^4.6.1",
- "cors": "^2.8.5",
- "passport": "0.7.0",
- "passport-github2": "0.1.12",
- "express-session": "1.18.0",
- "multer": "^1.4.5-lts.1",
- "file-type": "^19.0.0"
**TEAM INFORMATION**
Tfauve-p : The project manager, is in lead of organizing all the meeting with the team which changed over time, including then some recruitment. There was some adjustments to make over our vision of the project while coding it on GitHub.
Yantoine : The project owner, is in lead of both games, Tetris and Skkribl.io, made core decisions on features about these and got the work completed. His communication skills were very important due to the front-end / back-end relationship needed in order to achieve this glorious project.
Gprunet : The technical lead, is in charge of the back-end, made some strong decisions on the architectures of the project in terms of technology used. Created the entire database and most of the foundation of this project such as the builder files.
Agallon : Developer in the front-end action, he joined the team after the project was done but managed to innovate and brought to life the marvelous Wiskas the Third. Furthermore he also cared about the integrity of the web application and greatly improved the user experience through logical decisions.
**PROJECT MANAGEMENT**
The task's sharing was based on our own advance of the 42 cursus, meaning that Gprunet and Yantoine started coding the app sooner than Tfauve-p and Agallon.
Gprunet: Back-end + spectator mode
Yantoine: Github auth, games and Front/Back sockets
Tfauve-p: Front-end Designer
Agallon: Adjustements on Front-end and some new features.
**TECHNICAL STACK**
Front-end: JavaScript, HTML, CSS, NGINX
Back-end: JavaScript, Express, JWT, multer, etc...
Database: PostgreSQL because it uses a permissibe open-source licence and is feature-rich and powerful for the scale of our project
Since python doesn't have many front-end framework we opted to use JavaScript for both front and back.
After learning about JWT and learning that Express had a great synergy with it the choice was natural.
**DATABASES SCHEMA**
![Database Schema required ... ](./postgresql-architecture.jpg)
**FEATURES LIST**
- 2 Games
- One talking Cat
- Friends chat
-
**MODULES**
Total : 23pts ( 14pts for 100% 19pts for 125% )
- WEB
Minor : Use a back end framework
Major : Implement real-time features
Major : Allow users to interact with others
Major : A public API to interact with the database
Minor : A complete notification system for all creation, update and deletion account
- ACCESSIBILITY
Minor : Support for additional browsers
- USER MANAGEMENT
Major : Standard user management and authentication
Minor : Game statistics and match history ???
Minor : Implement remote authentication
- GAMING AND USER EXPERIENCE
Major : Implement a complete web-based game where users can play against each other
Major : Remote players, Enable two players on separate computers to play the same game
Major : Multiplayer game
Major : Add another game with user history and matchmaking
Minor : Advanced chat features ????
Minor : Game customization options
Minor : Spectator mode for games
+444
View File
@@ -1,6 +1,7 @@
all : up
up :
@docker compose -f ./docker-compose.yml build --no-cache
@docker compose -f ./docker-compose.yml up -d
clean :
@@ -12,3 +13,446 @@ fclean :
re : fclean up
# ╭────────────────────────────────────────────────────────────────────────────╮
# │─██████████████─██████████████─██████████████─██████─────────██████████████─│
# │─██░░░░░░░░░░██─██░░░░░░░░░░██─██░░░░░░░░░░██─██░░██─────────██░░░░░░░░░░██─│
# │─██████░░██████─██░░██████░░██─██░░██████░░██─██░░██─────────██░░██████████─│
# │─────██░░██─────██░░██──██░░██─██░░██──██░░██─██░░██─────────██░░██─────────│
# │─────██░░██─────██░░██──██░░██─██░░██──██░░██─██░░██─────────██░░██████████─│
# │─────██░░██─────██░░██──██░░██─██░░██──██░░██─██░░██─────────██░░░░░░░░░░██─│
# │─────██░░██─────██░░██──██░░██─██░░██──██░░██─██░░██─────────██████████░░██─│
# │─────██░░██─────██░░██──██░░██─██░░██──██░░██─██░░██─────────────────██░░██─│
# │─────██░░██─────██░░██████░░██─██░░██████░░██─██░░██████████─██████████░░██─│
# │─────██░░██─────██░░░░░░░░░░██─██░░░░░░░░░░██─██░░░░░░░░░░██─██░░░░░░░░░░██─│
# │─────██████─────██████████████─██████████████─██████████████─██████████████─│
# ╰────────────────────────────────────────────────────────────────────────────╯
# --------------------------------------------------------------------------------- >
VALGRIND = valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes -s --track-fds=yes --trace-children=yes $(V_FLAG)
# ↑さ↓ぎょう を ↓ほ↑ぞん
# Default git push
git: fclean
@$(call random_shmol_cat_blink, 作業を保存してるかな.., いいね、いいねえー , $(CLS), );
@current_date=$$(date); \
git add .; \
git commit -m "^^._, work in progress, small changes"; \
git push
# Git Push that asks for commit msg
git2: fclean
@$(call random_shmol_cat_blink, 作業を保存してるかな.., いいね、いいねえー , $(CLS), );
@read -p "Enter commit message: " msg; \
[ -z "$$msg" ] && msg=$$(date); \
git add .; \
git commit -m "$$msg"; \
git push
# Git Push use the content of .gitmsg to push
# if .gitmsg empty, return error
# clear .gitmsg on succesfull push.
GIT_MSG_FILE = ../.gitmsg
git3: fclean
@$(call random_shmol_cat_blink, 作業を保存してるかな.., いいね、いいねえー , $(CLS), );
@{ \
msg="$$(cat $(GIT_MSG_FILE) 2>/dev/null)"; \
[ -z "$$msg" ] && { $(call random_shmol_cat_blink, error, file is empty, , ); exit 1; }; \
git add . && \
git commit -m "$$msg" && \
git push && \
: > $(GIT_MSG_FILE) && \
$(call random_shmol_cat_blink, success!, $(GIT_MSG_FILE) cleared., , ); \
}
.SILENT: $(NAME)
# ╭────────────────────────────────────────────────────────────────────────────────────╮
# │─██████████████─████████████████───██████████─██████──────────██████─██████████████─│
# │─██░░░░░░░░░░██─██░░░░░░░░░░░░██───██░░░░░░██─██░░██████████──██░░██─██░░░░░░░░░░██─│
# │─██░░██████░░██─██░░████████░░██───████░░████─██░░░░░░░░░░██──██░░██─██████░░██████─│
# │─██░░██──██░░██─██░░██────██░░██─────██░░██───██░░██████░░██──██░░██─────██░░██─────│
# │─██░░██████░░██─██░░████████░░██─────██░░██───██░░██──██░░██──██░░██─────██░░██─────│
# │─██░░░░░░░░░░██─██░░░░░░░░░░░░██─────██░░██───██░░██──██░░██──██░░██─────██░░██─────│
# │─██░░██████████─██░░██████░░████─────██░░██───██░░██──██░░██──██░░██─────██░░██─────│
# │─██░░██─────────██░░██──██░░██───────██░░██───██░░██──██░░██████░░██─────██░░██─────│
# │─██░░██─────────██░░██──██░░██████─████░░████─██░░██──██░░░░░░░░░░██─────██░░██─────│
# │─██░░██─────────██░░██──██░░░░░░██─██░░░░░░██─██░░██──██████████░░██─────██░░██─────│
# │─██████─────────██████──██████████─██████████─██████──────────██████─────██████─────│
# ╰────────────────────────────────────────────────────────────────────────────────────╯
# C_213
PURPLE = \033[38;5;97m
# C_430
GOLD = \033[38;5;178m
# C_040
GREEN1 = \033[38;5;40m
# C_045
BLUE1 = \033[38;5;45m
# $(C_105), $(C_510), $(C_025)
# $(RED), $(GOLD), $(BLUE1)
test_color666:
@$(call random_cat, $(call pad_word, 12, TheCake), $(call pad_word, 14, IsALie...), $(CLS), $(RESET));
@$(call random_cat, $(call pad_word, 13, TheCake), $(call pad_word, 15, IsALie...), , $(RESET));
# $(call pad_word, 12, TheCake)
pad_word = $(BLINK)$(shell printf "%$(1)s" "$(2)")$(RESET)
# improve with: STRING1=$$(printf "\033[38;5;%dm" $$(shuf -i 0-255 -n 1));
# --------------------------------------------------------------------------------- >
# @$(call print_cat, $(CLEAR), $(body), $(eye), $(txt), $(call pad_word, 12, "TheCake"), $(call pad_word, 12, "IsALie..."));
# print_cat (resest?)(C_c)_sCtt$padded_txt_top))($(padded_txt_bot))
define print_cat
echo -e "$(1)$(2)\
\t\t\t\t\t ⠀⢠⠒⡄⠀\n\
\t\t\t\t\t ⠀⢀⠇⠀⠘⡄⠀⠀⠀⠀⠀⠀⣀⠀⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠁⠉⠉⠉⠒⠊⠉⠀⡇⠀\n\
\t\t\t\t\t ⠀⡜$(3)$(BLINK)⣀⡀$(RESET)$(2)⠀⢰⠁⠀\n\
\t\t\t\t\t ⠀⠲⢴⠁$(3)$(BLINK)⠛⠁$(RESET)$(2)$(3)$(BLINK)⢀⣄$(RESET)$(2)⠀⢸⠀\n\
\t\t\t\t\t ⠀⠉⠑⠺⡀⠀⠀⢶⠤$(3)$(BLINK)⠈⠋$(RESET)$(2)⠀⡘⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠑⢄⡀⠀⠀⠀⠠⣉⠑⠂⠀⢠⠃⠀⠀\n\
\t\t\t\t\t ⠀⢠⠊⠀⠀⠀⠀⠀⠀⠁⠀⠀⠈⢆⠀⠀\n\
\t\t\t\t\t ⠀⢰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡆⠀\n\
\t\t\t\t\t ⠀⢀⠤⠒⠒⠃⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠀\n\
\t\t\t\t\t ⠀⠔⠑⠄⠀⠀⡎⠀⠀⠀⠀⠀⠀⡇\n\
\t\t\t\t\t ⠸⡀⠀⢣⠀⠀⡇$(4)$(5)$(2)⠀⡇\n\
\t\t\t\t\t ⠀⠱⡀⠀⠳⡀⠀⠀⢃$(4)$(6)$(2)⠀⡸⠀\n\
\t\t\t\t\t ⠀⠑⢄⠀⠈⠒⢄⡀⠀⠀⠸⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀\n\
\t\t\t\t\t ⠀⠑⠦⣀⠀⠈⠉⠐⠒⠒⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⢢⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠀⠉⠐⠒⠠⠤⠤⠤⠤⠔⠂⠠⠤⠤⠤⠄⠐⠒⠂⠉⠉⠉⠉⠉⠁\n$(RESET)"
endef
# --------------------------------------------------------------------------------- >
# @$(call random_cat, $(call pad_word, 12, txt1), $(call pad_word, 12, txt2), $(CLS), $(RESET));
# print_cat (resest?)(C_c)_sCtt$padded_txt_top))($(padded_txt_bot))
define random_cat
COLOR=$$(printf "\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
COLOR2=$$(printf "\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
COLOR3=$$(printf "\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
echo -e "$(3)$${COLOR}\
\t\t\t\t\t ⠀⢠⠒⡄⠀\n\
\t\t\t\t\t ⠀⢀⠇⠀⠘⡄⠀⠀⠀⠀⠀⠀⣀⠀⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠁⠉⠉⠉⠒⠊⠉⠀⡇⠀\n\
\t\t\t\t\t ⠀⡜⠀$${COLOR2}$(BLINK)⣀⡀$(RESET)$${COLOR}⠀⢰⠁⠀\n\
\t\t\t\t\t ⠀⠲⢴⠁⠀$${COLOR2}$(BLINK)⠛⠁$(RESET)$${COLOR}$${COLOR2}$(BLINK)⠀⢀⣄$(RESET)$${COLOR}⠀⢸⠀\n\
\t\t\t\t\t ⠀⠉⠑⠺⡀⠀⠀⢶⠤$${COLOR2}$(BLINK)⠀⠈⠋$(RESET)$${COLOR}⠀⡘⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠑⢄⡀⠀⠀⠀⠠⣉⠑⠂⠀⢠⠃⠀⠀\n\
\t\t\t\t\t ⠀⢠⠊⠀⠀⠀⠀⠀⠀⠁⠀⠀⠈⢆⠀⠀\n\
\t\t\t\t\t ⠀⢰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡆⠀\n\
\t\t\t\t\t ⠀⢀⠤⠒⠒⠃⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠀\n\
\t\t\t\t\t ⠀⠔⠑⠄⠀⠀⡎⠀⠀⠀⠀⠀⠀⡇\n\
\t\t\t\t\t ⠸⡀⠀⢣⠀⠀⡇$${COLOR3}$(1)$${COLOR}⠀⡇\n\
\t\t\t\t\t ⠀⠱⡀⠀⠳⡀⠀⠀⢃$${COLOR3}$(2)$${COLOR}⠀⡸⠀\n\
\t\t\t\t\t ⠀⠑⢄⠀⠈⠒⢄⡀⠀⠀⠸⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀\n\
\t\t\t\t\t ⠀⠑⠦⣀⠀⠈⠉⠐⠒⠒⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⢢⠀\n\
\t\t\t\t\t ⠀⠀⠀⠀⠀⠀⠀⠉⠐⠒⠠⠤⠤⠤⠤⠔⠂⠠⠤⠤⠤⠄⠐⠒⠂⠉⠉⠉⠉⠉⠁\n$(4)"
endef
# --------------------------------------------------------------------------------- >
# @$(call shmol_cat_color, $(C_c), $(C_t), txt1, txt2, $(CLS), $(RESET));
define shmol_cat_color
echo -e "$(5)$(2)\
\tにゃ~$(1)\t⠀|、\n\
\t\t(˚ˎ。7$(2)~ $(3) ~$(1)\n\
\t\t|、˜\\\\\t\t$(2)$(4)$(1)\n\
\t\t⠀じしˍ)\n$(6)"
endef
# --------------------------------------------------------------------------------- >
# @$(call random_shmol_cat, text 1, text 2, $(CLS), $(RESET));
# $(1)= $(CLEAR); $(2)= text1; $(3)= text2; $(4)= $(RESET)
define random_shmol_cat
COLOR=$$(printf "\033[38;5;%dm" $$(shuf -i 1-255 -n 1)); \
COLOR2=$$(printf "\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
echo -e "$(3)$${COLOR2}\
\tにゃ~$${COLOR}\t⠀|、\n\
\t\t(˚ˎ。7$${COLOR2}~ $(1) ~$${COLOR}\n\
\t\t|、˜\\\\\t\t$${COLOR2}~ $(2)$${COLOR}\n\
\t\t⠀じしˍ)\n$(4)"
endef
# // <!> - - - - - - - - - - - </!>
# --------------------------------------------------------------------------------- >
rscs:
@$(call random_shmol_cat_surligne, text 1, text 2, $(CLS), $(RESET));
define random_shmol_cat_surligne
COLOR=$$(printf "\033[0m\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
COLOR2=$$(printf "\033[48;5;%dm" $$(shuf -i 0-255 -n 1)); \
echo -e "$(3)$${COLOR2}\
\tにゃ~$${COLOR}\t⠀|、\n\
\t\t(˚ˎ。7$${COLOR2}~ $(1) ~$${COLOR}\n\
\t\t|、˜\\\\\t\t$${COLOR2}~ $(2)$${COLOR}\n\
\t\t⠀じしˍ)\n$(4)"
endef
rscb:
@$(call random_shmol_cat_blink, text 1, text 2, $(CLS), $(RESET));
define random_shmol_cat_blink
COLOR=$$(printf "\033[0m\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
COLOR2=$$(printf "\e[5m\033[38;5;%dm" $$(shuf -i 0-255 -n 1)); \
echo -e "$(3)\n$${COLOR2}\
\tにゃ~$${COLOR}\t⠀|、\n\
\t\t(˚ˎ。7$${COLOR2}~ $(1) ~$${COLOR}\n\
\t\t|、˜\\\\\t\t$${COLOR2}~ $(2)$${COLOR}\n\
\t\t⠀じしˍ)\n$(4)"
endef
# // <!> - - - - - - - - - - - </!>
# --------------------------------------------------------------------------------- >
# @$(call shmol_cat_error, $(RED), $(RED_L));
# $(1) = $(C_c)$2) = $(C_ttN CLS
define shmol_cat_error
echo -e "$(2)\
\tにゃ~$(1)\t⠀|、\n\
\t\t(˚ˎ。7$(2)~ somshin wen wong ~$(1)\n\
\t\t|、˜\\\\\n\
\t\t⠀じしˍ)\n$(RESET)"
endef
# Define all 256 colors
CLEAR = \033[2J\033[H
CLS = \033[2J\033[H
RESET = \033[0m
BLINK = \033[5m
# U+2800 to U+28FF Braile
# <Esc>[38;5;ColorNumberm
BLACK = \033[38;5;0m
RED = \033[38;5;1m
GREEN = \033[38;5;2m
YELLOW = \033[38;5;3m
BLUE = \033[38;5;4m
MAGENTA = \033[38;5;5m
CYAN = \033[38;5;6m
GRAY = \033[38;5;7m
BLACK_L = \033[38;5;8m
RED_L = \033[38;5;9m
GREEN_L = \033[38;5;10m
YELLOW_L = \033[38;5;11m
BLUE_L = \033[38;5;12m
MAGENTA_L = \033[38;5;13m
CYAN_L = \033[38;5;14m
WHITE = \033[38;5;15m
C_000 = \033[38;5;16m
C_001 = \033[38;5;17m
C_002 = \033[38;5;18m
C_003 = \033[38;5;19m
C_004 = \033[38;5;20m
C_005 = \033[38;5;21m
C_010 = \033[38;5;22m
C_011 = \033[38;5;23m
C_012 = \033[38;5;24m
C_013 = \033[38;5;25m
C_014 = \033[38;5;26m
C_015 = \033[38;5;27m
C_020 = \033[38;5;28m
C_021 = \033[38;5;29m
C_022 = \033[38;5;30m
C_023 = \033[38;5;31m
C_024 = \033[38;5;32m
C_025 = \033[38;5;33m
C_030 = \033[38;5;34m
C_031 = \033[38;5;35m
C_032 = \033[38;5;36m
C_033 = \033[38;5;37m
C_034 = \033[38;5;38m
C_035 = \033[38;5;39m
C_040 = \033[38;5;40m
C_041 = \033[38;5;41m
C_042 = \033[38;5;42m
C_043 = \033[38;5;43m
C_044 = \033[38;5;44m
C_045 = \033[38;5;45m
C_050 = \033[38;5;46m
C_051 = \033[38;5;47m
C_052 = \033[38;5;48m
C_053 = \033[38;5;49m
C_054 = \033[38;5;50m
C_055 = \033[38;5;51m
C_100 = \033[38;5;52m
C_101 = \033[38;5;53m
C_102 = \033[38;5;54m
C_103 = \033[38;5;55m
C_104 = \033[38;5;56m
C_105 = \033[38;5;57m
C_110 = \033[38;5;58m
C_111 = \033[38;5;59m
C_112 = \033[38;5;60m
C_113 = \033[38;5;61m
C_114 = \033[38;5;62m
C_115 = \033[38;5;63m
C_120 = \033[38;5;64m
C_121 = \033[38;5;65m
C_122 = \033[38;5;66m
C_123 = \033[38;5;67m
C_124 = \033[38;5;68m
C_125 = \033[38;5;69m
C_130 = \033[38;5;70m
C_131 = \033[38;5;71m
C_132 = \033[38;5;72m
C_133 = \033[38;5;73m
C_134 = \033[38;5;74m
C_135 = \033[38;5;75m
C_140 = \033[38;5;76m
C_141 = \033[38;5;77m
C_142 = \033[38;5;78m
C_143 = \033[38;5;79m
C_144 = \033[38;5;80m
C_145 = \033[38;5;81m
C_150 = \033[38;5;82m
C_151 = \033[38;5;83m
C_152 = \033[38;5;84m
C_153 = \033[38;5;85m
C_154 = \033[38;5;86m
C_155 = \033[38;5;87m
C_200 = \033[38;5;88m
C_201 = \033[38;5;89m
C_202 = \033[38;5;90m
C_203 = \033[38;5;91m
C_204 = \033[38;5;92m
C_205 = \033[38;5;93m
C_210 = \033[38;5;94m
C_211 = \033[38;5;95m
C_212 = \033[38;5;96m
C_213 = \033[38;5;97m
C_214 = \033[38;5;98m
C_215 = \033[38;5;99m
C_220 = \033[38;5;100m
C_221 = \033[38;5;101m
C_222 = \033[38;5;102m
C_223 = \033[38;5;103m
C_224 = \033[38;5;104m
C_225 = \033[38;5;105m
C_230 = \033[38;5;106m
C_231 = \033[38;5;107m
C_232 = \033[38;5;108m
C_233 = \033[38;5;109m
C_234 = \033[38;5;110m
C_235 = \033[38;5;111m
C_240 = \033[38;5;112m
C_241 = \033[38;5;113m
C_242 = \033[38;5;114m
C_243 = \033[38;5;115m
C_244 = \033[38;5;116m
C_245 = \033[38;5;117m
C_250 = \033[38;5;118m
C_251 = \033[38;5;119m
C_252 = \033[38;5;120m
C_253 = \033[38;5;121m
C_254 = \033[38;5;122m
C_255 = \033[38;5;123m
C_300 = \033[38;5;124m
C_301 = \033[38;5;125m
C_302 = \033[38;5;126m
C_303 = \033[38;5;127m
C_304 = \033[38;5;128m
C_305 = \033[38;5;129m
C_310 = \033[38;5;130m
C_311 = \033[38;5;131m
C_312 = \033[38;5;132m
C_313 = \033[38;5;133m
C_314 = \033[38;5;134m
C_315 = \033[38;5;135m
C_320 = \033[38;5;136m
C_321 = \033[38;5;137m
C_322 = \033[38;5;138m
C_323 = \033[38;5;139m
C_324 = \033[38;5;140m
C_325 = \033[38;5;141m
C_330 = \033[38;5;142m
C_331 = \033[38;5;143m
C_332 = \033[38;5;144m
C_333 = \033[38;5;145m
C_334 = \033[38;5;146m
C_335 = \033[38;5;147m
C_340 = \033[38;5;148m
C_341 = \033[38;5;149m
C_342 = \033[38;5;150m
C_343 = \033[38;5;151m
C_344 = \033[38;5;152m
C_345 = \033[38;5;153m
C_350 = \033[38;5;154m
C_351 = \033[38;5;155m
C_352 = \033[38;5;156m
C_353 = \033[38;5;157m
C_354 = \033[38;5;158m
C_355 = \033[38;5;159m
C_400 = \033[38;5;160m
C_401 = \033[38;5;161m
C_402 = \033[38;5;162m
C_403 = \033[38;5;163m
C_404 = \033[38;5;164m
C_405 = \033[38;5;165m
C_410 = \033[38;5;166m
C_411 = \033[38;5;167m
C_412 = \033[38;5;168m
C_413 = \033[38;5;169m
C_414 = \033[38;5;170m
C_415 = \033[38;5;171m
C_420 = \033[38;5;172m
C_421 = \033[38;5;173m
C_422 = \033[38;5;174m
C_423 = \033[38;5;175m
C_424 = \033[38;5;176m
C_425 = \033[38;5;177m
C_430 = \033[38;5;178m
C_431 = \033[38;5;179m
C_432 = \033[38;5;180m
C_433 = \033[38;5;181m
C_434 = \033[38;5;182m
C_435 = \033[38;5;183m
C_440 = \033[38;5;184m
C_441 = \033[38;5;185m
C_442 = \033[38;5;186m
C_443 = \033[38;5;187m
C_444 = \033[38;5;188m
C_445 = \033[38;5;189m
C_450 = \033[38;5;190m
C_451 = \033[38;5;191m
C_452 = \033[38;5;192m
C_453 = \033[38;5;193m
C_454 = \033[38;5;194m
C_455 = \033[38;5;195m
C_500 = \033[38;5;196m
C_501 = \033[38;5;197m
C_502 = \033[38;5;198m
C_503 = \033[38;5;199m
C_504 = \033[38;5;200m
C_505 = \033[38;5;201m
C_510 = \033[38;5;202m
C_511 = \033[38;5;203m
C_512 = \033[38;5;204m
C_513 = \033[38;5;205m
C_514 = \033[38;5;206m
C_515 = \033[38;5;207m
C_520 = \033[38;5;208m
C_521 = \033[38;5;209m
C_522 = \033[38;5;210m
C_523 = \033[38;5;211m
C_524 = \033[38;5;212m
C_525 = \033[38;5;213m
C_530 = \033[38;5;214m
C_531 = \033[38;5;215m
C_532 = \033[38;5;216m
C_533 = \033[38;5;217m
C_534 = \033[38;5;218m
C_535 = \033[38;5;219m
C_540 = \033[38;5;220m
C_541 = \033[38;5;221m
C_542 = \033[38;5;222m
C_543 = \033[38;5;223m
C_544 = \033[38;5;224m
C_545 = \033[38;5;225m
C_550 = \033[38;5;226m
C_551 = \033[38;5;227m
C_552 = \033[38;5;228m
C_553 = \033[38;5;229m
C_554 = \033[38;5;230m
C_555 = \033[38;5;231m
+3 -3
View File
@@ -1,5 +1,5 @@
volumes:
data:
pgdata:
networks:
transcendence:
@@ -12,7 +12,7 @@ services:
ports:
- "5432:5432"
volumes:
- data:/var/lib/postgresql/data/pg15/
- pgdata:/var/lib/postgresql
env_file:
- ../.env
networks:
@@ -38,7 +38,7 @@ services:
container_name: frontend
build: ./srcs/frontend/
ports:
- "8080:80"
- "8443:443"
depends_on:
- backend
networks:
+1 -1
View File
@@ -127,7 +127,7 @@ async function createTables()
status VARCHAR(20) DEFAULT 'waiting',
max_players INT DEFAULT 8,
current_round INT DEFAULT 0,
max_rounds INT DEFAULT 3,
max_rounds INT DEFAULT 5,
round_duration INT DEFAULT 90,
created_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
+8
View File
@@ -1,5 +1,13 @@
FROM node:20-alpine
RUN apk add --no-cache openssl
RUN mkdir -p /etc/backend/.ssl
RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/backend/.ssl/key.pem \
-out /etc/backend/.ssl/cert.pem \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
WORKDIR /app
COPY package*.json ./
+7 -2
View File
@@ -1,5 +1,6 @@
import express from 'express';
import http from 'http';
import https from 'https';
import fs from 'fs';
import cors from 'cors';
import {Server} from 'socket.io';
import authRouter from './routes/auth.js';
@@ -13,7 +14,11 @@ import setupSocketIO from './services/socket.js';
import avatarService from './services/avatar.js';
const app = express();
const server = http.createServer(app);
const httpsOptions = {
key: fs.readFileSync('/etc/backend/.ssl/key.pem'),
cert: fs.readFileSync('/etc/backend/.ssl/cert.pem')
};
const server = https.createServer(httpsOptions, app);
const io = new Server(server,
{
cors:
+11
View File
@@ -26,6 +26,17 @@ router.post('/login', async(req, res) =>
res.status(result.status).json(result.data);
});
router.post('/logout', async(req, res) =>
{
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token)
return (res.status(401).json({error: 'Missing token'}));
const result = await authService.logout(token);
res.status(result.status).json(result.data);
});
router.get('/github', (req, res) => {
const githubAuthUrl = `https://github.com/login/oauth/authorize?` +
`client_id=${process.env.GITHUB_CLIENT_ID}&` +
+1 -1
View File
@@ -25,7 +25,7 @@ router.post('/upload', authenticateToken, upload.single('avatar'), async(req, re
res.status(result.status).json(result.data);
});
router.delete('/', authenticateToken, async(req, res) =>
router.delete('/delete', authenticateToken, async(req, res) =>
{
const result = await avatarService.deleteAvatar(req.user.userId);
res.status(result.status).json(result.data);
+25 -1
View File
@@ -2,6 +2,30 @@ import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import {query} from '../db.js';
async function logout(token)
{
try
{
if (!token)
return ({status: 400, data: {error: 'Missing token'}});
try
{
jwt.verify(token, process.env.JWT_SECRET);
}
catch
{
return ({status: 401, data: {error: 'Invalid token'}});
}
return ({status: 200, data: {message: 'Logged out'}});
}
catch (err)
{
console.error(err);
return ({status: 500, data: {error: 'Server error'}});
}
}
async function login(username, password)
{
try
@@ -60,4 +84,4 @@ async function register(username, password)
}
};
export default {register, login};
export default {register, login, logout};
@@ -69,6 +69,9 @@ async function deleteAvatar(userId) {
if (currentAvatar === null)
return ({status: 404, data: {error: 'User not found'}});
if (currentAvatar === DEFAULT_AVATAR)
return ({status: 400, data: {error: 'Cannot delete default avatar'}});
// Reset the avatar to the default one
await setAvatar(DEFAULT_AVATAR, userId);
+86 -2
View File
@@ -30,6 +30,63 @@ async function broadcastRoomsList(io) {
}
}
function startRoomTimer(io, roomId, seconds)
{
const gameState = gameRooms.get(roomId);
if (!gameState) return;
if (gameState.timerInterval)
clearInterval(gameState.timerInterval);
gameState.timerSeconds = seconds;
gameState.timerInterval = setInterval(() => {
gameState.timerSeconds--;
if (gameState.timerSeconds < 0)
gameState.timerSeconds = 0;
if (gameState.timerSeconds <= 0)
{
io.to(roomId).emit('game-timer-sync', {
remaining: 0
});
clearInterval(gameState.timerInterval);
gameState.timerInterval = null;
io.to(roomId).emit('game-timer-ended', { message: 'Temps écoulé !' });
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
const nextDrawer = gameState.players[gameState.currentPlayerIndex];
gameState.drawer = nextDrawer;
gameState.currentWord = '';
gameState.revealedLetters = [];
gameState.revealedWord = [];
gameState.guessedLetters = [];
gameState.wrongGuesses = 0;
io.to(roomId).emit('game-new-round', {
drawer: nextDrawer
});
}
else
{
io.to(roomId).emit('game-timer-sync', {
remaining: gameState.timerSeconds
});
}
}, 1000);
}
function stopRoomTimer(roomId)
{
const gameState = gameRooms.get(roomId);
if (!gameState || !gameState.timerInterval) return;
clearInterval(gameState.timerInterval);
gameState.timerInterval = null;
}
// Check if a playing game has only 1 player left and auto-stop it
async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
if (!dbRoomId) return;
@@ -43,6 +100,7 @@ async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
if (players.length <= 1) {
console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`);
stopRoomTimer(roomId);
// Update room status to 'ended'
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
@@ -192,7 +250,9 @@ function setupSocketIO(io)
revealedLetters: gameState.revealedLetters,
revealedWord: gameState.revealedWord || [],
guessedLetters: gameState.guessedLetters,
players: gameState.players
players: gameState.players,
scores: gameState.scores || {},
timer: gameState.timerSeconds || 0
});
}
});
@@ -202,6 +262,15 @@ function setupSocketIO(io)
if (socket.gameRoomId) {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
const userId = socket.user.userId;
if (dbRoomId && userId) {
try {
await gameRoomService.leaveRoom(dbRoomId, userId);
} catch (err) {
console.error('Error removing player from room on socket leave:', err.message);
}
}
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
@@ -268,7 +337,8 @@ function setupSocketIO(io)
revealedWord: gameState.revealedWord || [],
guessedLetters: gameState.guessedLetters,
players: gameState.players,
scores: gameState.scores || {}
scores: gameState.scores || {},
timer: gameState.timerSeconds || 0
});
}
} catch (err) {
@@ -390,6 +460,7 @@ function setupSocketIO(io)
const gameState = gameRooms.get(roomId);
if (!gameState) return;
startRoomTimer(io, roomId, 60);
gameState.currentWord = data.word.toLowerCase();
gameState.revealedLetters = new Array(data.word.length).fill(false);
gameState.revealedWord = new Array(data.word.length).fill('_');
@@ -552,6 +623,8 @@ function setupSocketIO(io)
// Update round start scores for next round
gameState.roundStartScores = { ...gameState.scores };
stopRoomTimer(roomId);
io.to(roomId).emit('game-word-found', {
word: gameState.currentWord,
winner: username,
@@ -613,6 +686,7 @@ function setupSocketIO(io)
// If the drawer left and there are still enough players, choose a new drawer
if (wasDrawer && gameState.players.length >= 1)
{
stopRoomTimer(roomId);
// Pick the next player as the new drawer
gameState.currentPlayerIndex = gameState.currentPlayerIndex % gameState.players.length;
const newDrawer = gameState.players[gameState.currentPlayerIndex];
@@ -632,6 +706,7 @@ function setupSocketIO(io)
reason: 'drawer_left',
message: `${username} (dessinateur) a quitté, ${newDrawer} devient le nouveau dessinateur`
});
startRoomTimer(io, roomId, 60);
}
}
@@ -652,6 +727,7 @@ function setupSocketIO(io)
socket.on('game-end', async () => {
const roomId = socket.gameRoomId;
if (!roomId) return;
stopRoomTimer(roomId);
// Update room status to 'waiting' in database
const dbRoomId = socket.gameRoomDbId;
@@ -867,6 +943,14 @@ function setupSocketIO(io)
}
else
{
if (dbRoomId && socket.user.userId) {
try {
await gameRoomService.leaveRoom(dbRoomId, socket.user.userId);
} catch (err) {
console.error('Error removing disconnected player from room:', err.message);
}
}
// Regular player disconnect
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
+16 -1
View File
@@ -1,5 +1,20 @@
FROM nginx:alpine
RUN apk add --no-cache openssl && \
mkdir -p /etc/nginx/ssl && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/key.pem \
-out /etc/nginx/ssl/cert.pem \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
#ADDED
RUN rm -f /etc/nginx/conf.d/default.conf
COPY src /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 443
CMD ["nginx", "-g", "daemon off;"]
+15 -5
View File
@@ -1,5 +1,9 @@
server {
listen 80;
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
error_page 497 =301 https://$host:8443$request_uri;
root /usr/share/nginx/html;
index index.html;
@@ -11,27 +15,33 @@ server {
# Backend API
location /api/ {
proxy_pass http://backend:3001;
proxy_pass https://backend:3001;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
# Socket.IO WebSocket proxying
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_pass https://backend:3001;
proxy_ssl_verify off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /avatar/ {
proxy_pass http://backend:3001/avatar/;
proxy_pass https://backend:3001/avatar/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_ssl_verify off;
proxy_hide_header Content-Type;
add_header Cache-Control "public, max-age=3600";
}
+53 -15
View File
@@ -2,13 +2,14 @@
* Application entry point
* Initializes windows and handles menu interactions
*/
import { windowRegistry } from './windows.js';
import { LoginWindow } from './login.js';
import { GlobalChat } from './global_chat.js';
import { AvatarWindow } from './avatar.js';
import { FriendsWindow } from './friends.js';
import { GameRoomWindow } from './game_room.js';
import { StatsWindow } from './stats.js';
import { windowRegistry } from './core/windows.js';
import { LoginWindow } from './windows/login.js';
import { LogoutWindow } from './windows/logout.js';
import { GlobalChat } from './windows/global_chat.js';
import { AvatarWindow } from './windows/avatar.js';
import { FriendsWindow } from './windows/friends.js';
import { GameRoomWindow } from './windows/game_room.js';
import { StatsWindow } from './windows/stats.js';
/**
* Main application class
@@ -20,6 +21,7 @@ class App {
this.initMenu();
this.initPage();
this.initEasterEgg();
this.colorizeUI();
}
/**
@@ -32,6 +34,7 @@ class App {
new FriendsWindow();
new GameRoomWindow();
new StatsWindow();
new LogoutWindow();
}
/**
@@ -49,7 +52,8 @@ class App {
'login': 'login',
'chat': 'chat',
'avatar': 'avatar',
'friends': 'friends'
'friends': 'friends',
'logout': 'logout'
};
// Event delegation on the menu
@@ -74,10 +78,6 @@ class App {
return;
}
const actionMap = {
'gameroom': 'gameroom'
};
// Event delegation on the menu
page.addEventListener('click', (e) => {
const button = e.target.closest('.page__item');
@@ -85,9 +85,14 @@ class App {
const action = button.dataset.action;
// Actions with associated windows
if (actionMap[action]) {
windowRegistry.toggle(actionMap[action]);
if (action === 'gameroom') {
const gameRoomWindow = windowRegistry.get('gameroom');
windowRegistry.toggle('gameroom');
gameRoomWindow.loadRooms();
if (gameRoomWindow?.currentTab === 'browse') {
gameRoomWindow.loadRooms();
}
return;
}
@@ -105,6 +110,39 @@ class App {
});
}
}
colorizeUI() {
const elements = document.querySelectorAll(".title, .menu__item, .game__item, .page__item");
const colorizeText = (el) => {
const text = el.textContent;
el.innerHTML = "";
const baseHue = Math.random() * 360;
// 🎲 random step = makes rainbow "scrambled"
const step = (Math.random() * 60) + 10; // 10 → 70
// 🎲 random direction (left or right rainbow)
const direction = Math.random() < 0.5 ? 1 : -1;
[...text].forEach((char, i) => {
const span = document.createElement("span");
span.textContent = char;
const hue = baseHue + (i * step * direction);
span.style.color = `hsl(${hue}, 90%, 60%)`;
span.style.textShadow = `1px 1px 0 rgba(0,0,0,0.3)`;
el.appendChild(span);
});
};
elements.forEach(colorizeText);
}
}
// Start the application when DOM is ready
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

@@ -6,12 +6,14 @@
export const API = {
AUTH: {
LOGIN: '/api/auth/login',
LOGOUT: '/api/auth/logout',
REGISTER: '/api/auth/register',
GITHUB: '/api/auth/github'
},
AVATAR: {
GET: '/api/avatar/me',
UPLOAD: '/api/avatar/upload'
UPLOAD: '/api/avatar/upload',
DELETE: '/api/avatar/delete'
},
FRIENDS: {
LIST: '/api/friends',
@@ -82,6 +82,7 @@ export const Events = {
// Avatar
AVATAR_UPDATED: 'avatar:updated',
AVATAR_DELETED: 'avatar:deleted',
// Chat
CHAT_CONNECTED: 'chat:connected',
@@ -228,6 +228,56 @@ export class Window {
return element;
}
NotficationContainer()
{
if (document.getElementById('notification-container')) return;
const container = this.createElement('div');
container.id = 'notification-container';
Object.assign(container.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '10px'
});
document.body.appendChild(container);
}
showNotification(message, color) {
this.NotficationContainer();
const container = document.getElementById('notification-container');
if (!container) return;
const notification = document.createElement('div');
notification.textContent = message;
Object.assign(notification.style, {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity 0.5s ease, transform 0.5s ease'
});
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-8px)';
setTimeout(() => notification.remove(), 500);
}, 2200);
}
}
// Export old class name for compatibility (alias)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,87 @@
.shape {
/* The "Physical" properties */
position: fixed;
/* transform: translate(-50%, -50%); Optional: This makes 'left/top' refer to the CENTER of the doodle */
width: 142px;
height: 142px;
/* The "Stenciling" instructions (but no image yet!) */
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
/* The default "Paint" color */
background-color: white;
}
.shape:hover {
transform: scale(1.2); /* Grow by 20% when you hover the mouse over it */
transition: transform 0.3s ease; /* Make it a smooth grow */
}
/* Individual Doodle Definitions */
.doodle-0 { -webkit-mask-image: url('doodles/cat.png'); mask-image: url('doodles/cat.png'); left: 60vw; top: 35vh; }
.doodle-1 { -webkit-mask-image: url('doodles/ball.png'); mask-image: url('doodles/ball.png'); left: 10vw; top: 10vh; }
.doodle-2 { -webkit-mask-image: url('doodles/batman.png'); mask-image: url('doodles/batman.png'); left: 20vw; top: 15vh; }
.doodle-3 { -webkit-mask-image: url('doodles/building.png'); mask-image: url('doodles/building.png'); left: 30vw; top: 20vh; }
.doodle-4 { -webkit-mask-image: url('doodles/butterfly.png'); mask-image: url('doodles/butterfly.png'); left: 40vw; top: 25vh; }
.doodle-5 { -webkit-mask-image: url('doodles/car.png'); mask-image: url('doodles/car.png'); left: 50vw; top: 30vh; }
.doodle-6 { -webkit-mask-image: url('doodles/yin_yang.png'); mask-image: url('doodles/yin_yang.png'); left: 88vw; top: 12vh; }
.doodle-7 { -webkit-mask-image: url('doodles/clouds.png'); mask-image: url('doodles/clouds.png'); left: 70vw; top: 40vh; }
.doodle-8 { -webkit-mask-image: url('doodles/controls.png'); mask-image: url('doodles/controls.png'); left: 80vw; top: 45vh; }
.doodle-9 { -webkit-mask-image: url('doodles/dead.png'); mask-image: url('doodles/dead.png'); left: 90vw; top: 50vh; }
.doodle-10 { -webkit-mask-image: url('doodles/diamant.png'); mask-image: url('doodles/diamant.png'); left: 15vw; top: 55vh; }
.doodle-11 { -webkit-mask-image: url('doodles/dice.png'); mask-image: url('doodles/dice.png'); left: 25vw; top: 60vh; }
.doodle-12 { -webkit-mask-image: url('doodles/earth.png'); mask-image: url('doodles/earth.png'); left: 35vw; top: 65vh; }
.doodle-13 { -webkit-mask-image: url('doodles/egypt.png'); mask-image: url('doodles/egypt.png'); left: 45vw; top: 70vh; }
.doodle-14 { -webkit-mask-image: url('doodles/fire.png'); mask-image: url('doodles/fire.png'); left: 55vw; top: 75vh; }
.doodle-15 { -webkit-mask-image: url('doodles/fish.png'); mask-image: url('doodles/fish.png'); left: 65vw; top: 80vh; }
.doodle-16 { -webkit-mask-image: url('doodles/flag.png'); mask-image: url('doodles/flag.png'); left: 75vw; top: 85vh; }
.doodle-17 { -webkit-mask-image: url('doodles/hearts.png'); mask-image: url('doodles/hearts.png'); left: 85vw; top: 90vh; }
.doodle-18 { -webkit-mask-image: url('doodles/house.png'); mask-image: url('doodles/house.png'); left: 5vw; top: 45vh; }
.doodle-19 { -webkit-mask-image: url('doodles/idol.png'); mask-image: url('doodles/idol.png'); left: 12vw; top: 22vh; }
.doodle-20 { -webkit-mask-image: url('doodles/lotus.png'); mask-image: url('doodles/lotus.png'); left: 22vw; top: 32vh; }
.doodle-21 { -webkit-mask-image: url('doodles/mail.png'); mask-image: url('doodles/mail.png'); left: 32vw; top: 42vh; }
.doodle-22 { -webkit-mask-image: url('doodles/moon.png'); mask-image: url('doodles/moon.png'); left: 42vw; top: 52vh; }
.doodle-23 { -webkit-mask-image: url('doodles/pokeball.png'); mask-image: url('doodles/pokeball.png'); left: 52vw; top: 62vh; }
.doodle-24 { -webkit-mask-image: url('doodles/runes.png'); mask-image: url('doodles/runes.png'); left: 62vw; top: 72vh; }
.doodle-25 { -webkit-mask-image: url('doodles/shield.png'); mask-image: url('doodles/shield.png'); left: 72vw; top: 82vh; }
.doodle-26 { -webkit-mask-image: url('doodles/shiny.png'); mask-image: url('doodles/shiny.png'); left: 82vw; top: 12vh; }
.doodle-27 { -webkit-mask-image: url('doodles/snail.png'); mask-image: url('doodles/snail.png'); left: 92vw; top: 22vh; }
.doodle-28 { -webkit-mask-image: url('doodles/sound.png'); mask-image: url('doodles/sound.png'); left: 18vw; top: 82vh; }
.doodle-29 { -webkit-mask-image: url('doodles/spiral.png'); mask-image: url('doodles/spiral.png'); left: 28vw; top: 72vh; }
.doodle-30 { -webkit-mask-image: url('doodles/star.png'); mask-image: url('doodles/star.png'); left: 38vw; top: 62vh; }
.doodle-31 { -webkit-mask-image: url('doodles/stop.png'); mask-image: url('doodles/stop.png'); left: 48vw; top: 52vh; }
.doodle-32 { -webkit-mask-image: url('doodles/sun.png'); mask-image: url('doodles/sun.png'); left: 58vw; top: 42vh; }
.doodle-33 { -webkit-mask-image: url('doodles/tree.png'); mask-image: url('doodles/tree.png'); left: 68vw; top: 32vh; }
.doodle-34 { -webkit-mask-image: url('doodles/triskel.png'); mask-image: url('doodles/triskel.png'); left: 78vw; top: 22vh; }
/* 3. A quick animation for the color loop */
.loop-color {
animation: colorShift 12s infinite alternate ease-in-out;
}
@keyframes colorShift {
/* 0% and 100% are identical to create the "Infinite Circle" effect */
0% { background-color: #3075ff; } /* Royal Blue (Start) */
8% { background-color: #24a1ff; } /* Sky Blue */
17% { background-color: #1ad8ff; } /* Cyan */
25% { background-color: #1bffa7; } /* Seafoam Green */
33% { background-color: #1fff4d; } /* Bright Green */
42% { background-color: #8bff32; } /* Lime Green */
50% { background-color: #dcff38; } /* Electric Yellow */
58% { background-color: #ffbc29; } /* Golden Yellow */
67% { background-color: #ff8c4a; } /* Coral Orange */
75% { background-color: #ff1d1d; } /* Hot Red */
83% { background-color: #ff2bf3; } /* Magenta Pink */
92% { background-color: #ac37ff; } /* Electric Purple */
100% { background-color: #3075ff; } /* Royal Blue (Seamless Loop) */
}
@@ -0,0 +1,113 @@
const maxdoodles = 34;
// /////////////////////////////////////////////////////////////////////////////////////////>\
// container for all doodles, create them
class DoodleContainer {
constructor(parent) {
this.parent = parent;
this.obj = document.createElement('div');
Object.assign(this.obj.style, {
width: '100vw',
height: '100vw',
});
this.createAllDoodles();
parent.append(this.obj);
this.randomizeAnimationStarts();
}
createAllDoodles() {
for (let i = 0; i <= maxdoodles; i++) {
let d = document.createElement('div');
d.classList.add('shape', 'doodle-' + i, 'loop-color');
d.id = 'shape' + i;
this.obj.append(d);
d.addEventListener('click', () => {
console.log(`hi from ${d.id}!`);
})
}
}
startSmoothRandomMove(id, speed = 2) {
const el = document.getElementById(id);
if (!el)
return;
// 1. Get initial pixel position or pick random if CSS isn't loaded yet
const rect = el.getBoundingClientRect();
const state = {
x: rect.left || Math.random() * (window.innerWidth - 142),
y: rect.top || Math.random() * (window.innerHeight - 142),
angle: Math.random() * Math.PI * 2,
speed: speed
};
function update() {
// 2. Refresh screen boundaries every frame
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const shapeSize = 142; // Matches your CSS width/height
// 3. Calculate next step
state.x += Math.cos(state.angle) * state.speed;
state.y += Math.sin(state.angle) * state.speed;
// 4. BOUNCE LOGIC
// Horizontal check
if (state.x <= 0) {
state.x = 0;
state.angle = Math.PI - state.angle;
} else if (state.x + shapeSize >= screenW) {
state.x = screenW - shapeSize;
state.angle = Math.PI - state.angle;
}
// Vertical check
if (state.y <= 0) {
state.y = 0;
state.angle = -state.angle;
} else if (state.y + shapeSize >= screenH) {
state.y = screenH - shapeSize;
state.angle = -state.angle;
}
// 5. Apply position using pixels for precision
el.style.left = state.x + "px";
el.style.top = state.y + "px";
requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
randomizeAnimationStarts() {
for (let i = 0; i <= maxdoodles; i++) {
const randomSpeed = 1 + Math.random() * 3;
this.startSmoothRandomMove(`shape${i}`, randomSpeed);
}
}
}
// /////////////////////////////////////////////////////////////////////////////////////////>
// all loop-color have the same @colorShift animation cycle, this disynchronize them
function randomizeColorsStarts() {
const shapes = document.querySelectorAll('.loop-color');
shapes.forEach(shape => {
// Pick a random number between 0 and 10 (since your loop is 10s)
const randomDelay = Math.random() * - 15;
// Apply it directly to the element's style
shape.style.animationDelay = randomDelay + "s";
});
}
const a = new DoodleContainer(document.body);
// Call this once when the script loads
randomizeColorsStarts();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,26 +1,25 @@
:root {
--color-primary: #0066cc;
--color-primary-hover: #0052a3;
--color-primary: #ffc75e;
--color-primary-hover: #ffc75e;
--color-success: #3cff01;
--color-success-dark: #28a745;
--color-success-dark: #ffc75e;
--color-error: #ff4d4d;
--color-warning: #ffc107;
--color-github: #24292e;
--color-warning: #ffc75e;
--color-github: #ffc75e;
--color-bg: #000;
--color-bg: #ffe5b5;
--app-background-base: radial-gradient(
circle at top,
#1b2735,
#090a0f
#3fc9ff,
#21fcc5
);
/* --app-background-image: url("./assets/background.png"); */
--color-surface: #222;
--color-surface-light: #333;
--color-text: #fff;
--color-text-muted: #aaa;
--color-surface: #ffcc00;
--color-surface-light: #feffa6;
--color-text: #000000;
--color-text-muted: #353535;
--font-size-base: 10px;
--font-size-sm: 1.2rem;
@@ -62,19 +61,22 @@
html {
height: 100%;
background-image:
background-image:
var(--app-background-base);
background-size: contain, cover;
background-position: center, center;
background-repeat: no-repeat, no-repeat;
background-size:
contain,
cover;
background-position:
center,
center;
background-repeat:
no-repeat,
no-repeat;
}
@@ -93,102 +95,69 @@ body {
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes wobble {
0% { transform: translate(0%, 0) rotate(0deg); }
25% { transform: translate(-5%, -1px) rotate(-0.5deg); }
50% { transform: translate(0%, 1px) rotate(0.5deg); }
75% { transform: translate(+5%, -1px) rotate(0.5deg); }
100% { transform: translate(0%, 0) rotate(0deg); }
}
@keyframes bounce {
0% { transform: translateY(0) rotate(var(--rot)); }
33% { transform: translateY(-6px) rotate(var(--rot)); }
66% { transform: translateY(-8px) rotate(var(--rot)); }
100% { transform: translateY(0) rotate(var(--rot)); }
}
/* ============================================
TYPOGRAPHY
============================================ */
.title {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
font-size: var(--font-size-xl);
text-align: center;
text-shadow: 2px 2px 10px black;
z-index: 1;
font-family: "Cinzel Decorative", cursive;
color: var(--color-success);
margin: 0;
padding: var(--spacing-md);
top: 20px;
left: 50%;
translate: -50% 0;
background: #ffcc00;
color: #000;
border: 4px solid #feffa6;
border-radius: 18px;
padding: 0.6rem 1.2rem;
animation: wobble 2s infinite ease-in-out;
}
/* ============================================
MENU
============================================ */
.title span {
display: inline-block;
transform-origin: center;
font-size: 4rem;
font-weight: bold;
text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.5);
.menu {
position: fixed;
top: 0;
left: 50px;
padding: 0;
margin: 0;
z-index: var(--z-menu);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
animation: bounce 1.2s infinite alternate;
animation-timing-function: ease-in-out;
}
.menu__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.title span:nth-child(1) { --rot: -5deg; color: #ff4d4d; }
.title span:nth-child(2) { --rot: 3deg; color: #5beb67; }
.title span:nth-child(3) { --rot: -3deg; color: #ca8dfc; }
.title span:nth-child(4) { --rot: 2deg; color: #6698f5; }
.title span:nth-child(5) { --rot: -4deg; color: #ff66cc; }
.menu__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.title span:nth-child(2) { animation-delay: 0.2s; }
.title span:nth-child(3) { animation-delay: 0.4s; }
.title span:nth-child(4) { animation-delay: 0.6s; }
.title span:nth-child(5) { animation-delay: 0.8s; }
.menu__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
/* ============================================
GAME
============================================ */
.game {
position: fixed;
top: 0;
right: 50px;
padding: 0;
margin: 0;
z-index: var(--z-menu);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.game__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: right;
}
.game__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.game__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
.title span { will-change: transform; }
/* ============================================
PAGES
@@ -208,14 +177,17 @@ body {
}
.page__item {
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-color: #fda725;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: right;
text-align: center;
}
.page__item:hover {
@@ -229,9 +201,87 @@ body {
}
/* ============================================
BUTTONS
MENU
============================================ */
.menu {
position: fixed;
top: var(--spacing-lg);
left: 50px;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
z-index: var(--z-menu);
}
.menu__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-lg);
border-color: #fda725;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.menu__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.menu__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
/* ============================================
GAME
============================================ */
.game {
position: fixed;
top: var(--spacing-lg);
right: 50px;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
z-index: var(--z-menu);
}
.game__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-lg);
border-color: #fda725;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.game__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.game__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
/* ============================================
BUTTONS
============================================ */
.btn {
display: inline-flex;
align-items: center;
@@ -256,7 +306,7 @@ body {
}
.btn--primary {
background: var(--color-primary);
background: var(--color-surface);
color: var(--color-text);
}
@@ -265,7 +315,7 @@ body {
}
.btn--secondary {
background: var(--color-surface-light);
background: var(--color-surface);
color: var(--color-text);
}
@@ -328,13 +378,15 @@ body {
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-bg);
border: 2px ridge var(--color-text);
color: var(--color-text);
z-index: var(--z-window);
display: none;
flex-direction: column;
min-width: 280px;
box-shadow: var(--shadow-lg);
border-radius: 5px;
border-color: #aa1f1f;
border: 6px solid #faac37;
}
.window--visible {
@@ -395,7 +447,8 @@ body {
.message {
font-size: var(--font-size-sm);
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
border-radius: var(--radius-lg);
border-color: #000;
}
.message--success {
@@ -415,6 +468,11 @@ body {
============================================ */
.login {
width: 320px;
border-radius: 5px;
border-color: #aa1f1f;
border: 6px solid #faac37;
background: #ffffff;
color: #000;
}
.login__form {
@@ -533,7 +591,7 @@ body {
border-radius: var(--radius-full);
border: 3px solid var(--color-text);
box-shadow: var(--shadow-md);
background: var(--color-surface);
background: var(--color-surface-light);
align-self: center;
}
@@ -557,28 +615,74 @@ body {
}
/* ============================================
EASTER EGG BUTTON
STATS WINDOW
============================================ */
/* .easter-egg {
position: absolute;
top: 20%;
left: 50%;
transform: translateX(-50%);
z-index: 1;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
font-size: var(--font-size-md);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
.stats-window {
width: 320px;
}
.easter-egg:hover {
background: var(--color-error);
border-color: var(--color-error);
} */
.stats__avatar {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-full);
border: 2px solid var(--color-text);
align-self: center;
display: block;
margin: 0 auto var(--spacing-xs);
}
.stats__username {
font-size: var(--font-size-lg);
font-weight: 600;
text-align: center;
color: #000;
margin-bottom: var(--spacing-md);
}
.stats__section {
margin-bottom: var(--spacing-md);
}
.stats__section-title {
font-size: var(--font-size-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-primary);
border-bottom: 1px solid var(--color-surface-light);
padding-bottom: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.stats__section-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.stats__row {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
padding: 3px 0;
}
.stats__label {
color: #333;
}
.stats__value {
font-weight: 600;
color: #000;
}
.stats__loading {
font-size: var(--font-size-sm);
color: #333;
text-align: center;
padding: var(--spacing-sm) 0;
}
/* ============================================
UTILITIES
@@ -626,7 +730,7 @@ body {
flex: 1;
padding: var(--spacing-sm);
background: var(--color-surface);
border: 1px solid var(--color-surface-light);
border: 1px solid var(--color-surface);
color: var(--color-text);
cursor: pointer;
font-size: var(--font-size-sm);
@@ -709,9 +813,10 @@ body {
/* ============================================
GAME ROOM WINDOW
============================================ */
.gameroom-window {
width: 600px;
height: 800px;
width: 800px;
height: 900px;
}
.gameroom__tabs {
@@ -1019,3 +1124,4 @@ body {
.gameroom__game-buttons .btn {
flex: 1;
}
@@ -3,14 +3,24 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lobby</title>
<title>Skkribl.io</title>
<link rel="stylesheet" href="doodle.css">
<link rel="stylesheet" href="game.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&display=swap" rel="stylesheet" />
<script src="doodle.js" defer></script>
<script type="module" src="../app.js"></script>
</head>
<body>
<h1 class="title">Lobby</h1>
<h1 class="title">
<span>L</span>
<span>o</span>
<span>b</span>
<span>b</span>
<span>y</span>
</h1>
<nav class="menu" aria-label="Menu principal">
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
@@ -21,7 +31,7 @@
<nav class="game" aria-label="Game">
<button class="game__item" data-action="Home page" aria-label="Home Page"
onclick="window.location.href='index.html'">Home Page</button>
onclick="window.location.href='../index.html'">Home Page</button>
</nav>
<div class="page" aria-label="Page">
@@ -29,6 +39,5 @@
</div>
<script type="module" src="app.js"></script>
</body>
</html>
+157 -35
View File
@@ -7,28 +7,28 @@
CSS VARIABLES
============================================ */
:root {
--color-primary: #0066cc;
--color-primary-hover: #0052a3;
--color-primary: #ffc75e;
--color-primary-hover: #ffc75e;
--color-success: #3cff01;
--color-success-dark: #28a745;
--color-success-dark: #ffc75e;
--color-error: #ff4d4d;
--color-warning: #ffc107;
--color-github: #24292e;
--color-warning: #ffc75e;
--color-github: #ffc75e;
--color-bg: #a3a3a3;
--color-bg: #ffe5b5;
--app-background-base: radial-gradient(
circle at top,
#000000,
#4d4d4d
#fff787,
#ff8080
);
--app-background-image: url("./assets/background.png");
--color-surface: #222;
--color-surface-light: #333;
--color-text: #fff;
--color-text-muted: #aaa;
--color-surface: #ffefce;
--color-surface-light: #ffc75e;
--color-text: #000000;
--color-text-muted: #000000;
--font-size-base: 10px;
--font-size-sm: 1.2rem;
@@ -69,32 +69,45 @@
}
html {
height: 100%;
background-image: var(--app-background-image), var(--app-background-base);
/* Make background image responsive but limited in size */
background-size: clamp(400px, 100%, 800px) auto, cover;
/* Align image to the top, gradient stays centered */
background-position: center 35%, center;
background-repeat: no-repeat, no-repeat;
}
/* html {
height: 100%;
background-image:
var(--app-background-image),
var(--app-background-base);
background-size:
contain,
80%,
cover;
background-position:
center,
50% 20%,
center;
background-repeat:
no-repeat,
no-repeat;
}
} */
body {
margin: 0;
margin: 0 auto;
width: 70%;
min-width: 800px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--color-text);
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh; /* KEY */
}
/* ============================================
@@ -117,16 +130,16 @@ body {
text-align: center;
text-shadow: 2px 2px 10px black;
z-index: 1;
font-family: "Cinzel Decorative", cursive;
font-family: "Roboto";
letter-spacing: -10px;
color: rgba(248, 252, 2, 0.6);
margin: 0;
padding: var(--spacing-md);
padding: 0.6rem 1.2rem;
/* Rectangle + rounded corners */
background-color: rgba(247, 7, 67, 0.6);
background-color: #ffefce;
border: 2px solid rgba(0, 0, 0, 0.6);
border-radius: 15px;
border-radius: var(--radius-lg);
}
@@ -136,25 +149,27 @@ body {
.menu {
position: fixed;
top: 0;
top: var(--spacing-lg);
left: 50px;
padding: 0;
margin: 0;
z-index: var(--z-menu);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
gap: var(--spacing-lg);
z-index: var(--z-menu);
}
.menu__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-lg);
border-color: #000;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
text-align: center;
}
.menu__item:hover {
@@ -171,7 +186,7 @@ body {
GAME
============================================ */
.game {
/* .game {
position: fixed;
top: 0;
right: 50px;
@@ -181,17 +196,31 @@ body {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
} */
.game {
position: fixed;
top: var(--spacing-lg);
right: 50px;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
z-index: var(--z-menu);
}
.game__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-lg);
border-color: #000;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: right;
text-align: center;
}
.game__item:hover {
@@ -204,6 +233,57 @@ body {
border-color: var(--color-primary);
}
/* ///////////////////////////////////////////////////////// */
footer {
margin-top: auto; /* pushes footer to bottom */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.footer_div {
display: flex;
justify-content: center;
gap: 30%; /* space between items */
padding: 10px 0;
}
.footer_grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
justify-items: center; /* center each column */
align-items: center;
gap: 10px 60px; /* row gap / column gap */
}
.ico_footer {
width: 25%;
}
a {
text-decoration: none;
color: #000000;
}
a:hover {
color: rgb(218, 145, 12);
}
.legal {
margin-top: 5%;
padding-bottom: 5%;
text-align: center;
display: block;
color: #000000;
}
.legal:hover {
color: rgb(218, 145, 12);
}
/* ///////////////////////////////////////////////////////// */
/* ============================================
BUTTONS
============================================ */
@@ -303,13 +383,15 @@ body {
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-bg);
border: 2px ridge var(--color-text);
color: var(--color-text);
z-index: var(--z-window);
display: none;
flex-direction: column;
min-width: 280px;
box-shadow: var(--shadow-lg);
border-radius: 5px;
border-color: #aa1f1f;
border: 6px solid #faac37;
}
.window--visible {
@@ -370,7 +452,8 @@ body {
.message {
font-size: var(--font-size-sm);
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
border-radius: var(--radius-lg);
border-color: #000;
}
.message--success {
@@ -750,3 +833,42 @@ body {
color: var(--color-text-muted);
padding: var(--spacing-lg);
}
.container-1 {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
margin: 5px;
position: relative;
min-height: 200px;
}
/* ///////////////////////////////////////////////////////// */
.button {
color: red;
margin: 5px 50px;
padding: 5px 50px;
}
.button-1 {
display: inline-block;
padding: 10px 20px;
background-color: #000000;
color: #8e8e8e;
text-align: center;
text-decoration: none;
font-size: 16px;
cursor: pointer;
border: 3px solid #363636;
border-radius: 6px;
transition: background-color 0.3s;
}
.button-1:hover {
background-color: rgb(202, 135, 10);
color: black;
}
+25 -6
View File
@@ -3,29 +3,48 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Transcendence.io</title>
<title>Transcendence</title>
<link rel="stylesheet" href="index.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&display=swap" rel="stylesheet" />
</head>
<body>
<h1 class="title">Transcendence.io</h1>
<h1 class="title">Transcendence</h1>
<nav class="menu" aria-label="Menu principal">
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
<button class="menu__item" data-action="chat" aria-label="Global chat">Global chat</button>
<button class="menu__item" data-action="avatar" aria-label="Avatar">Avatar</button>
<button class="menu__item" data-action="friends" aria-label="Amis">Amis</button>
<button class="menu__item" data-action="logout" aria-label="Logout">Logout</button>
</nav>
<nav class="game" aria-label="Game">
<button class="game__item" data-action="new_game" aria-label="Start new game"
onclick="window.location.href='game.html'">Start new game</button>
<button class="game__item" data-action="new_game" aria-label="Skkrrribl.io"
onclick="window.location.href='./game/game.html'">Skkrrribl.io</button>
<button class="game__item" data-action="tetris" aria-label="Tetris"
onclick="window.location.href='tetris.html'">Tetris</button>
onclick="window.location.href='./tetris/tetris.html'">Tetris</button>
<button class="game__item" data-action="Wiskas" aria-label="Wiskas"
onclick="window.location.href='./wiskas/wiskas.html'">Wiskas</button>
</nav>
<script type="module" src="app.js"></script>
<script type="module" src="./app.js"></script>
<script type="module" src="./script.js"></script>
<footer>
<div class="footer_grid">
<img class="ico_footer" src="./assets/facebook_logo.png">
<img class="ico_footer" src="./assets/insta_logo.png">
<img class="ico_footer" src="./assets/twitter_logo.png">
<a href="https://www.facebook.com/">MIAOUBOOK</a>
<a href="https://www.instagram.com/">INSTAMIAOU</a>
<a href="https://twitter.com/">BLUE-SNACK</a>
</div>
<a href="./mentions/mentions_legales.html" class="legal">LEGAL NOTICES</a>
</footer>
</body>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legal Notices</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #fff8e1;
color: #333;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.legal {
max-width: 600px;
font-size: 0.9rem;
line-height: 1.5;
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.btn-home {
padding: 10px 20px;
background-color: #ffc75e;
color: #000;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
}
.btn-home:hover {
background-color: #ffb347;
}
</style>
</head>
<body>
<div class="legal">
<strong>Legal Notices</strong><br><br>
All user credentials are securely stored using hashed passwords. We respect your privacy and do not share your personal information with third parties. By using this site, you agree to our data handling practices.
</div>
<a href="../index.html" class="btn-home">Back to Home</a>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
const container = document.querySelector('.container-gamelinks');
const buttons = document.querySelectorAll('.game-button');
function initButtons() {
const rect = container.getBoundingClientRect();
buttons.forEach(btn => {
// Ensure size is known
const bw = btn.offsetWidth;
const bh = btn.offsetHeight;
// Random start position INSIDE container
btn.x = Math.random() * (rect.width - bw);
btn.y = Math.random() * (rect.height - bh);
// Better velocity (avoid super slow)
btn.vx = (Math.random() * 2 + 1) * (Math.random() < 0.5 ? -1 : 1);
btn.vy = (Math.random() * 2 + 1) * (Math.random() < 0.5 ? -1 : 1);
btn.style.left = btn.x + 'px';
btn.style.top = btn.y + 'px';
});
}
function animateButtons() {
const rect = container.getBoundingClientRect();
buttons.forEach(btn => {
btn.x += btn.vx;
btn.y += btn.vy;
const bw = btn.offsetWidth;
const bh = btn.offsetHeight;
// Bounce inside container
if (btn.x <= 0 || btn.x + bw >= rect.width) {
btn.vx *= -1;
btn.x = Math.max(0, Math.min(btn.x, rect.width - bw)); // clamp
}
if (btn.y <= 0 || btn.y + bh >= rect.height) {
btn.vy *= -1;
btn.y = Math.max(0, Math.min(btn.y, rect.height - bh)); // clamp
}
btn.style.left = btn.x + 'px';
btn.style.top = btn.y + 'px';
});
requestAnimationFrame(animateButtons);
}
// 🔥 IMPORTANT: wait for layout to be ready
window.addEventListener('load', () => {
initButtons();
animateButtons();
});
@@ -3,18 +3,20 @@
// ─────────────────────────────────────────────
class Duel {
constructor(socket, tetrisGame, onStatusChange, onStart) {
// ui : { showOverlay, hideOverlay, render, renderOpponent, updateButtons }
constructor(socket, tetrisGame, onStatusChange, onStart, ui) {
this.socket = socket;
this.tetrisGame = tetrisGame;
this.onStatusChange = onStatusChange; // (status, opponentName) => void
this.onStart = onStart; // () => void — déclenche le début du jeu local
this.onStatusChange = onStatusChange;
this.onStart = onStart;
this.ui = ui;
this.action_queue = [];
this.opponentGrid = this._emptyGrid();
this.opponentScore = 0;
this.action_queue = [];
this.opponentGrid = this._emptyGrid();
this.opponentScore = 0;
this.opponentShieldActive = false;
this.roomCode = null;
this.isReady = false;
this.roomCode = null;
this.isReady = false;
this._bindSocketEvents();
}
@@ -34,10 +36,11 @@ class Duel {
leave() {
if (!this.roomCode) return;
this.socket.emit('tetris:leave');
this.roomCode = null;
this.isReady = false;
this.opponentGrid = this._emptyGrid();
this.opponentScore = 0;
this.roomCode = null;
this.isReady = false;
this.opponentGrid = this._emptyGrid();
this.opponentScore = 0;
this.opponentShieldActive = false;
}
// ─── Hooks appelés par tetris.js ──────────
@@ -49,9 +52,7 @@ class Duel {
onLocalLinesCleared(count, holeCol) {
if (!this.isReady) return;
const garbageLines = [];
for (let i = 0; i < count; i++)
garbageLines.push(this._buildGarbageLine(holeCol));
const garbageLines = Array.from({ length: count }, () => this._buildGarbageLine(holeCol));
this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
}
@@ -63,11 +64,8 @@ class Duel {
onLocalShieldChanged(event) {
if (!this.isReady) return;
if (event === 'activated') {
this.socket.emit('tetris:shield-activated');
} else if (event === 'deactivated') {
this.socket.emit('tetris:shield-deactivated');
}
if (event === 'activated') this.socket.emit('tetris:shield-activated');
else if (event === 'deactivated') this.socket.emit('tetris:shield-deactivated');
}
endDuel() {
@@ -80,8 +78,7 @@ class Duel {
synchronize_game() {
while (this.action_queue.length > 0) {
const action = this.action_queue.shift();
this._processAction(action);
this._processAction(this.action_queue.shift());
}
}
@@ -91,7 +88,7 @@ class Duel {
this.opponentGrid = action.grid;
this.opponentScore = action.score;
document.getElementById('opponent-score').textContent = action.score;
renderOpponent(this.opponentGrid);
this.ui.renderOpponent(this.opponentGrid, this.opponentShieldActive);
break;
case 'LINES_CLEARED':
@@ -99,7 +96,7 @@ class Duel {
break;
case 'OPPONENT_GAME_OVER':
showOverlay('YOU WIN', action.score);
this.ui.showOverlay('YOU WIN', action.score);
this.endDuel();
break;
@@ -159,22 +156,22 @@ class Duel {
this.socket.on('tetris:pause', () => {
this.tetrisGame.pause();
updateButtons();
if (this.tetrisGame.isPaused) showOverlay('PAUSE');
else hideOverlay();
this.ui.updateButtons();
if (this.tetrisGame.isPaused) this.ui.showOverlay('PAUSE');
else this.ui.hideOverlay();
});
this.socket.on('tetris:stop', () => {
this.tetrisGame.stop();
updateButtons();
render();
showOverlay('STOPPED');
this.ui.updateButtons();
this.ui.render();
this.ui.showOverlay('STOPPED');
});
this.socket.on('tetris:settings', (data) => {
document.getElementById('input-ttd').value = data.timeToDown;
document.getElementById('input-hardening').value = data.hardening;
document.getElementById('input-decrement').value = data.decrementTTD;
document.getElementById('input-ttd').value = data.timeToDown;
document.getElementById('input-hardening').value = data.hardening;
document.getElementById('input-decrement').value = data.decrementTTD;
this.tetrisGame.configure(data);
});
}
+49
View File
@@ -0,0 +1,49 @@
// ─────────────────────────────────────────────
// EFFETS VISUELS : SCALING RESPONSIVE + MATRIX RAIN
// ─────────────────────────────────────────────
// ── Responsive scaling ──
(function() {
const container = document.getElementById('scale-container');
const NAT_W = 640;
const NAT_H = 1020;
function resize() {
const s = Math.min(window.innerWidth / NAT_W, window.innerHeight / NAT_H);
container.style.transform = 'scale(' + s + ')';
container.style.transformOrigin = 'top center';
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
}
resize();
window.addEventListener('resize', resize);
})();
// ── Matrix rain ──
(function() {
const canvas = document.getElementById('matrix-bg');
const ctx = canvas.getContext('2d');
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
const fs = 14;
let drops = [];
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
resize();
initDrops();
window.addEventListener('resize', () => { resize(); initDrops(); });
setInterval(function() {
ctx.fillStyle = 'rgba(0,5,0,0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = fs + 'px monospace';
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
ctx.fillText(ch, i * fs, drops[i] * fs);
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
drops[i]++;
}
}, 40);
})();
@@ -0,0 +1,124 @@
// ─────────────────────────────────────────────
// LEADERBOARDS & HISTORIQUE
// ─────────────────────────────────────────────
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Historique ───────────────────────────────
async function loadGameHistory() {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const res = await fetch('/api/stats/tetris/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return;
renderGameHistory(await res.json());
} catch (err) {
console.error('Erreur chargement historique:', err);
}
}
function renderGameHistory(history) {
const tbody = document.getElementById('lb-history-body');
if (!tbody) return;
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
return;
}
tbody.innerHTML = history.map((entry, i) => {
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
let resultHtml = '—';
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
return `<tr>
<td>${i + 1}</td>
<td>${date}</td>
<td>${type}</td>
<td>${entry.score}</td>
<td>${resultHtml}</td>
</tr>`;
}).join('');
}
// ── Classements ──────────────────────────────
async function loadLeaderboards() {
const token = localStorage.getItem('auth_token');
if (!token) return;
const headers = { 'Authorization': `Bearer ${token}` };
try {
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
fetch('/api/stats/tetris/leaderboard/score', { headers }),
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
fetch('/api/stats/me', { headers }),
fetch('/api/stats/tetris/rank/score', { headers }),
fetch('/api/stats/tetris/rank/wins', { headers })
]);
const me = meRes.ok ? await meRes.json() : null;
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
if (scoresRes.ok) renderLeaderboard('lb-scores-body', await scoresRes.json(), ['tetris_best_score', 'tetris_games_played'], me, rankScore);
if (winsRes.ok) renderLeaderboard('lb-wins-body', await winsRes.json(), ['tetris_wins', 'tetris_games_played'], me, rankWins);
} catch (err) {
console.error('Erreur chargement leaderboards:', err);
}
}
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!rows.length && !me) {
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
return;
}
const myUsername = me?.username;
const inTop = rows.some(r => r.username === myUsername);
let html = rows.map((r, i) => {
const isMe = r.username === myUsername;
return `<tr class="${isMe ? 'lb-me' : ''}">
<td>${i + 1}</td>
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
<td>${r[col1] ?? 0}</td>
<td>${r[col2] ?? 0}</td>
</tr>`;
}).join('');
if (!inTop && me && myRank !== null) {
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
html += `<tr class="lb-me">
<td>${myRank}</td>
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
<td>${me[col1] ?? 0}</td>
<td>${me[col2] ?? 0}</td>
</tr>`;
}
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
}
// ── Tabs ─────────────────────────────────────
document.querySelectorAll('.lb-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
tab.classList.add('lb-tab--active');
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
if (tab.dataset.tab === 'history') loadGameHistory();
});
});
loadLeaderboards();
loadGameHistory();
@@ -61,17 +61,14 @@ function drawCell(ctx, x, y, colorIndex, size) {
const color = COLORS[colorIndex];
ctx.fillStyle = color;
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
// Glow inner
ctx.shadowColor = color;
ctx.shadowBlur = 6;
ctx.fillStyle = color;
ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4);
ctx.shadowBlur = 0;
// Highlight top/left
ctx.fillStyle = currentTheme.highlight;
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2);
ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2);
// Shadow bottom/right
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2);
ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2);
@@ -150,8 +147,10 @@ function _drawShieldOverlay(ctx, w, h, alpha) {
ctx.restore();
}
function render() {
// Grille principale
// ── Rendu joueur local ────────────────────────────────────────────────────────
// Prend l'objet game explicitement — aucun accès à des globaux externes.
function render(game) {
clearCanvas(ctxMain, 300, 600);
drawGridLines(ctxMain, 10, 20, CELL);
@@ -160,7 +159,6 @@ function render() {
if (game.grid[y][x] !== 0)
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
// Ghost + pièce courante
if (game.currentPiece) {
drawGhost(ctxMain, game.currentPiece, game.grid);
const { x, y } = game.currentPiece.getPosition();
@@ -172,20 +170,16 @@ function render() {
drawCell(ctxMain, x + col, y + row, color, CELL);
}
// Shield overlay (bordure cyan pulsée)
if (game.shieldActive) {
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
_drawShieldOverlay(ctxMain, 300, 600, pulse);
}
// Panneaux miniatures
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
// Score
document.getElementById('score-display').textContent = game.score;
// Shield status UI
const shieldEl = document.getElementById('shield-status-display');
const shieldBar = document.getElementById('shield-bar');
if (shieldEl) {
@@ -207,29 +201,27 @@ function render() {
}
}
function renderOpponent(opponentGrid) {
// ── Rendu adversaire ─────────────────────────────────────────────────────────
// Prend grid et shieldActive explicitement — aucun accès à l'objet duel global.
function renderOpponent(grid, shieldActive) {
clearCanvas(ctxOpponent, 300, 600);
drawGridLines(ctxOpponent, 10, 20, CELL);
for (let y = 0; y < opponentGrid.length; y++)
for (let x = 0; x < opponentGrid[y].length; x++)
if (opponentGrid[y][x] !== 0)
drawCell(ctxOpponent, x, y, opponentGrid[y][x], CELL);
for (let y = 0; y < grid.length; y++)
for (let x = 0; x < grid[y].length; x++)
if (grid[y][x] !== 0)
drawCell(ctxOpponent, x, y, grid[y][x], CELL);
// Shield overlay adversaire
if (typeof duel !== 'undefined' && duel && duel.opponentShieldActive) {
if (shieldActive) {
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
_drawShieldOverlay(ctxOpponent, 300, 600, pulse);
}
// Indicateur HTML adversaire
const oppShieldEl = document.getElementById('opponent-shield-indicator');
if (oppShieldEl) {
const active = typeof duel !== 'undefined' && duel && duel.opponentShieldActive;
oppShieldEl.style.display = active ? 'block' : 'none';
}
if (oppShieldEl) oppShieldEl.style.display = shieldActive ? 'block' : 'none';
}
// Restore saved theme
// Restaure le thème sauvegardé
(function() {
const saved = localStorage.getItem('tetris-theme');
if (saved && THEMES[saved]) setColorTheme(saved);
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TETRIS</title>
<title>Tetris</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="tetris.css">
</head>
@@ -15,10 +15,9 @@
<h1 data-text="TETRIS">TETRIS<span class="cursor">_</span></h1>
<!-- Bouton home -->
<a id="btn-home" href="/">Home</a>
<a id="btn-home" href="../index.html">Home</a>
<!-- Panneau de connexion duel -->
<!-- Panneau duel -->
<div id="duel-panel">
<span class="settings-title">Duel</span>
<div class="duel-row">
@@ -40,7 +39,7 @@
<div id="local-section">
<div id="app">
<!-- Colonne gauche : Hold + Score + Boutons + Settings -->
<!-- Colonne gauche : Hold + Score + Boutons + Paramètres -->
<div id="left-column">
<div class="panel">
<div class="panel-title">Hold</div>
@@ -64,16 +63,16 @@
</div>
</div>
<!-- Panneau de configuration -->
<!-- Paramètres -->
<div id="settings-panel">
<div class="settings-title">Paramètres</div>
<div class="settings-row">
<label>Couleur</label>
<div class="theme-btns">
<button class="theme-btn active" data-theme="green" onclick="setColorTheme('green')" title="Vert"></button>
<button class="theme-btn" data-theme="red" onclick="setColorTheme('red')" title="Rouge"></button>
<button class="theme-btn" data-theme="yellow" onclick="setColorTheme('yellow')" title="Jaune"></button>
<button class="theme-btn" data-theme="blue" onclick="setColorTheme('blue')" title="Bleu"></button>
<button class="theme-btn active" data-theme="green" title="Vert"></button>
<button class="theme-btn" data-theme="red" title="Rouge"></button>
<button class="theme-btn" data-theme="yellow" title="Jaune"></button>
<button class="theme-btn" data-theme="blue" title="Bleu"></button>
</div>
</div>
<div class="settings-row">
@@ -151,34 +150,22 @@
<div id="lb-scores" class="lb-content lb-content--active">
<table class="lb-table">
<thead>
<tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr>
</thead>
<tbody id="lb-scores-body">
<tr><td colspan="4">Chargement…</td></tr>
</tbody>
<thead><tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr></thead>
<tbody id="lb-scores-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
</table>
</div>
<div id="lb-wins" class="lb-content">
<table class="lb-table">
<thead>
<tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr>
</thead>
<tbody id="lb-wins-body">
<tr><td colspan="4">Chargement…</td></tr>
</tbody>
<thead><tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr></thead>
<tbody id="lb-wins-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
</table>
</div>
<div id="lb-history" class="lb-content">
<table class="lb-table">
<thead>
<tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr>
</thead>
<tbody id="lb-history-body">
<tr><td colspan="5">Chargement…</td></tr>
</tbody>
<thead><tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr></thead>
<tbody id="lb-history-body"><tr><td colspan="5">Chargement…</td></tr></tbody>
</table>
</div>
</div>
@@ -190,59 +177,9 @@
<script src="tetris.js"></script>
<script src="renderer.js"></script>
<script src="duel.js"></script>
<script src="leaderboard.js"></script>
<script src="ui.js"></script>
<script src="effects.js"></script>
<script>
// ── Responsive scaling ──────────────────────────
(function() {
const container = document.getElementById('scale-container');
// Dimensions naturelles du contenu (single-player)
const NAT_W = 640;
const NAT_H = 1020;
function resize() {
const s = Math.min(
window.innerWidth / NAT_W,
window.innerHeight / NAT_H
);
container.style.transform = 'scale(' + s + ')';
container.style.transformOrigin = 'top center';
// Compense l'espace de layout non affecté par transform
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
}
resize();
window.addEventListener('resize', resize);
})();
</script>
<script>
// ── Matrix rain ──────────────────────────────────
(function() {
const canvas = document.getElementById('matrix-bg');
const ctx = canvas.getContext('2d');
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
resize();
window.addEventListener('resize', resize);
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
const fs = 14;
let drops = [];
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
initDrops();
window.addEventListener('resize', initDrops);
setInterval(function() {
ctx.fillStyle = 'rgba(0,5,0,0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = fs + 'px monospace';
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
ctx.fillText(ch, i * fs, drops[i] * fs);
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
drops[i]++;
}
}, 40);
})();
</script>
</body>
</html>
@@ -0,0 +1,265 @@
// ─────────────────────────────────────────────
// UI — Contrôles, socket, duel, matchmaking
// ─────────────────────────────────────────────
// ── Références DOM ───────────────────────────
const btnStart = document.getElementById('btn-start');
const btnPause = document.getElementById('btn-pause');
const btnStop = document.getElementById('btn-stop');
const btnRestart = document.getElementById('btn-restart');
const overlay = document.getElementById('overlay');
const inputTTD = document.getElementById('input-ttd');
const inputHardening = document.getElementById('input-hardening');
const inputDecrement = document.getElementById('input-decrement');
const btnJoinDuel = document.getElementById('btn-join-duel');
const btnLeaveDuel = document.getElementById('btn-leave-duel');
const inputRoomCode = document.getElementById('input-room-code');
const duelStatusEl = document.getElementById('duel-status');
const opponentSection = document.getElementById('opponent-section');
const btnMatchmaking = document.getElementById('btn-matchmaking');
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
const matchmakingStatusEl = document.getElementById('matchmaking-status');
// ── Overlay ──────────────────────────────────
function showOverlay(title, score) {
document.getElementById('overlay-title').textContent = title;
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
overlay.classList.add('visible');
}
function hideOverlay() {
overlay.classList.remove('visible');
}
// ── Boutons ──────────────────────────────────
function updateButtons() {
btnStart.disabled = game.isRunning;
btnPause.disabled = !game.isRunning;
btnStop.disabled = !game.isRunning;
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
inputTTD.disabled = game.isRunning;
inputHardening.disabled = game.isRunning;
inputDecrement.disabled = game.isRunning;
}
// ── Socket ───────────────────────────────────
const socket = io({
auth: { token: localStorage.getItem('auth_token') },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling']
});
// ── Duel ─────────────────────────────────────
let duel = null;
// Callbacks passés au Duel pour qu'il pilote l'UI sans accéder à des globaux.
function _makeDuelUI() {
return {
showOverlay,
hideOverlay,
updateButtons,
render: () => render(game),
renderOpponent: (grid, shieldActive) => renderOpponent(grid, shieldActive),
};
}
function updateDuelStatus(status, opponentName) {
duelStatusEl.className = '';
if (status === 'waiting') {
duelStatusEl.textContent = "En attente d'un adversaire…";
duelStatusEl.classList.add('waiting');
opponentSection.classList.remove('visible');
} else if (status === 'ready') {
duelStatusEl.textContent = `Prêt — ${opponentName}`;
duelStatusEl.classList.add('ready');
opponentSection.classList.add('visible');
if (duel) duel.hideOpponentOverlay();
const grid = duel ? duel.opponentGrid : Array.from({ length: 20 }, () => Array(10).fill(0));
const shieldActive = duel ? duel.opponentShieldActive : false;
renderOpponent(grid, shieldActive);
} else {
duelStatusEl.textContent = '—';
opponentSection.classList.remove('visible');
}
}
function startLocalGame() {
hideOverlay();
game.start();
updateButtons();
render(game);
}
// Crée un Duel et rejoint la salle — mutualisé entre le bouton et le matchmaking.
function _joinDuelRoom(code) {
if (duel) duel.leave();
if (game.isRunning) { game.stop(); hideOverlay(); render(game); updateButtons(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame, _makeDuelUI());
duel.join(code);
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
}
btnJoinDuel.addEventListener('click', () => {
const code = inputRoomCode.value.trim().toUpperCase();
if (!code) return;
_joinDuelRoom(code);
});
btnLeaveDuel.addEventListener('click', () => {
if (duel) { duel.leave(); duel = null; }
btnJoinDuel.disabled = false;
btnLeaveDuel.disabled = true;
inputRoomCode.disabled = false;
updateDuelStatus(null, null);
});
// ── Matchmaking ──────────────────────────────
btnMatchmaking.addEventListener('click', () => {
socket.emit('tetris:matchmaking-join');
btnMatchmaking.disabled = true;
btnMatchmakingCancel.disabled = false;
btnJoinDuel.disabled = true;
matchmakingStatusEl.textContent = 'Recherche en cours…';
matchmakingStatusEl.className = 'waiting';
});
btnMatchmakingCancel.addEventListener('click', () => {
socket.emit('tetris:matchmaking-leave');
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
matchmakingStatusEl.textContent = '';
});
socket.on('tetris:matchmaking-status', (data) => {
if (data.status === 'searching') {
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
} else if (data.status === 'idle') {
matchmakingStatusEl.textContent = '';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
}
});
socket.on('tetris:matched', (data) => {
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
matchmakingStatusEl.className = 'ready';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
inputRoomCode.value = data.roomCode;
_joinDuelRoom(data.roomCode);
});
// ── Jeu ──────────────────────────────────────
function saveTetrisScore(score) {
const token = localStorage.getItem('auth_token');
if (!token) return;
fetch('/api/stats/tetris/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ score })
})
.then(r => r.json())
.then(data => { if (data.bestScore !== undefined) console.log('Meilleur score tetris:', data.bestScore); })
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
}
const game = new Tetris(
// onRender
() => {
if (duel) duel.synchronize_game();
render(game);
updateButtons();
},
// onGameOver
(score, validBlock) => {
if (duel && duel.isReady) duel.onLocalGameOver(score, validBlock);
else saveTetrisScore(score);
render(game);
updateButtons();
showOverlay('GAME OVER', score);
loadLeaderboards();
loadGameHistory();
},
// onBlockPlaced
(grid) => { if (duel) duel.onLocalBlockPlaced(grid, game.score); },
// onLinesCleared
(count, holeCol) => { if (duel) duel.onLocalLinesCleared(count, holeCol); },
// onShieldChanged
(event) => { if (duel) duel.onLocalShieldChanged(event); }
);
// ── Boutons de contrôle ──────────────────────
btnStart.addEventListener('click', () => {
if (duel && duel.isReady) duel.startDuel();
else startLocalGame();
});
btnPause.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.togglePause();
} else {
game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
}
});
btnStop.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.stop();
} else {
game.stop();
updateButtons();
render(game);
showOverlay('STOPPED');
}
});
if (btnRestart) {
btnRestart.addEventListener('click', () => {
if (duel && duel.isReady) return;
game.restart();
updateButtons();
render(game);
});
}
// ── Paramètres ───────────────────────────────
function applySettings() {
const settings = {
timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10),
};
game.configure(settings);
if (duel && duel.isReady) duel.syncSettings(settings);
}
inputTTD.addEventListener('change', applySettings);
inputHardening.addEventListener('change', applySettings);
inputDecrement.addEventListener('change', applySettings);
// ── Thème ────────────────────────────────────
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.addEventListener('click', () => setColorTheme(btn.dataset.theme));
});
-412
View File
@@ -1,412 +0,0 @@
// ─────────────────────────────────────────────
// UI
// ─────────────────────────────────────────────
const btnStart = document.getElementById('btn-start');
const btnPause = document.getElementById('btn-pause');
const btnStop = document.getElementById('btn-stop');
const overlay = document.getElementById('overlay');
const inputTTD = document.getElementById('input-ttd');
const inputHardening = document.getElementById('input-hardening');
const inputDecrement = document.getElementById('input-decrement');
// Duel UI
const btnJoinDuel = document.getElementById('btn-join-duel');
const btnLeaveDuel = document.getElementById('btn-leave-duel');
const inputRoomCode = document.getElementById('input-room-code');
const duelStatusEl = document.getElementById('duel-status');
const opponentSection = document.getElementById('opponent-section');
// Matchmaking UI
const btnMatchmaking = document.getElementById('btn-matchmaking');
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
const matchmakingStatusEl = document.getElementById('matchmaking-status');
function updateButtons() {
btnStart.disabled = game.isRunning;
btnPause.disabled = !game.isRunning;
btnStop.disabled = !game.isRunning;
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
inputTTD.disabled = game.isRunning;
inputHardening.disabled = game.isRunning;
inputDecrement.disabled = game.isRunning;
}
function showOverlay(title, score) {
document.getElementById('overlay-title').textContent = title;
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
overlay.classList.add('visible');
}
function hideOverlay() {
overlay.classList.remove('visible');
}
// ─────────────────────────────────────────────
// SOCKET + DUEL
// ─────────────────────────────────────────────
const socket = io({
auth: { token: localStorage.getItem('auth_token') },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling']
});
let duel = null;
function updateDuelStatus(status, opponentName) {
duelStatusEl.className = '';
if (status === 'waiting') {
duelStatusEl.textContent = 'En attente d\'un adversaire…';
duelStatusEl.classList.add('waiting');
opponentSection.classList.remove('visible');
} else if (status === 'ready') {
duelStatusEl.textContent = `Prêt — ${opponentName}`;
duelStatusEl.classList.add('ready');
opponentSection.classList.add('visible');
if (duel) duel.hideOpponentOverlay();
renderOpponent(duel ? duel.opponentGrid : Array.from({length:20}, () => Array(10).fill(0)));
} else {
duelStatusEl.textContent = '—';
opponentSection.classList.remove('visible');
}
}
function startLocalGame() {
hideOverlay();
game.start();
updateButtons();
render();
}
// ─────────────────────────────────────────────
// SCORE SAVE (solo)
// ─────────────────────────────────────────────
function saveTetrisScore(score) {
const token = localStorage.getItem('auth_token');
if (!token) return;
fetch('/api/stats/tetris/score', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ score })
})
.then(r => r.json())
.then(data => {
if (data.bestScore !== undefined) {
console.log('Meilleur score tetris:', data.bestScore);
}
})
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
}
// ─────────────────────────────────────────────
// DUEL BUTTONS
// ─────────────────────────────────────────────
btnJoinDuel.addEventListener('click', () => {
const code = inputRoomCode.value.trim().toUpperCase();
if (!code) return;
if (duel) { duel.leave(); }
if (game.isRunning) { game.stop(); hideOverlay(); render(); updateButtons(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
duel.join(code);
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
});
btnLeaveDuel.addEventListener('click', () => {
if (duel) { duel.leave(); duel = null; }
btnJoinDuel.disabled = false;
btnLeaveDuel.disabled = true;
inputRoomCode.disabled = false;
updateDuelStatus(null, null);
});
// ─────────────────────────────────────────────
// MATCHMAKING
// ─────────────────────────────────────────────
btnMatchmaking.addEventListener('click', () => {
socket.emit('tetris:matchmaking-join');
btnMatchmaking.disabled = true;
btnMatchmakingCancel.disabled = false;
btnJoinDuel.disabled = true;
matchmakingStatusEl.textContent = 'Recherche en cours…';
matchmakingStatusEl.className = 'waiting';
});
btnMatchmakingCancel.addEventListener('click', () => {
socket.emit('tetris:matchmaking-leave');
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
matchmakingStatusEl.textContent = '';
});
socket.on('tetris:matchmaking-status', (data) => {
if (data.status === 'searching') {
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
} else if (data.status === 'idle') {
matchmakingStatusEl.textContent = '';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
}
});
socket.on('tetris:matched', (data) => {
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
matchmakingStatusEl.className = 'ready';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
// Auto-rejoindre la salle générée
if (duel) { duel.leave(); }
if (game.isRunning) { game.stop(); hideOverlay(); render(); updateButtons(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
duel.join(data.roomCode);
inputRoomCode.value = data.roomCode;
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
});
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
const game = new Tetris(
// onRender
() => {
if (duel) duel.synchronize_game();
render();
updateButtons();
},
// onGameOver
(score, validBlock) => {
const isDuel = duel && duel.isReady;
if (isDuel) {
duel.onLocalGameOver(score, validBlock);
} else {
saveTetrisScore(score);
}
render();
updateButtons();
showOverlay('GAME OVER', score);
loadLeaderboards();
loadGameHistory();
},
// onBlockPlaced — relay duel
(grid) => {
if (duel) duel.onLocalBlockPlaced(grid, game.score);
},
// onLinesCleared — relay duel
(count, holeCol) => {
if (duel) duel.onLocalLinesCleared(count, holeCol);
},
// onShieldChanged — relay duel
(event) => {
if (duel) duel.onLocalShieldChanged(event);
}
);
btnStart.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.startDuel(); // déclenche les deux parties via le serveur
} else {
startLocalGame(); // solo
}
});
btnPause.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.togglePause();
} else {
game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
}
});
btnStop.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.stop();
} else {
game.stop();
updateButtons();
render();
showOverlay('STOPPED');
}
});
function applySettings() {
const settings = {
timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10),
};
game.configure(settings);
if (duel && duel.isReady) duel.syncSettings(settings);
}
inputTTD.addEventListener('change', applySettings);
inputHardening.addEventListener('change', applySettings);
inputDecrement.addEventListener('change', applySettings);
const btnRestart = document.getElementById('btn-restart');
if (btnRestart) {
btnRestart.addEventListener('click', () => {
if (duel && duel.isReady) return;
game.restart();
updateButtons();
render();
});
}
// ─────────────────────────────────────────────
// GAME HISTORY
// ─────────────────────────────────────────────
async function loadGameHistory() {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const res = await fetch('/api/stats/tetris/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return;
const history = await res.json();
renderGameHistory(history);
} catch (err) {
console.error('Erreur chargement historique:', err);
}
}
function renderGameHistory(history) {
const tbody = document.getElementById('lb-history-body');
if (!tbody) return;
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
return;
}
tbody.innerHTML = history.map((entry, i) => {
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
let resultHtml = '—';
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
return `<tr>
<td>${i + 1}</td>
<td>${date}</td>
<td>${type}</td>
<td>${entry.score}</td>
<td>${resultHtml}</td>
</tr>`;
}).join('');
}
// ─────────────────────────────────────────────
// LEADERBOARDS
// ─────────────────────────────────────────────
async function loadLeaderboards() {
const token = localStorage.getItem('auth_token');
if (!token) return;
const headers = { 'Authorization': `Bearer ${token}` };
try {
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
fetch('/api/stats/tetris/leaderboard/score', { headers }),
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
fetch('/api/stats/me', { headers }),
fetch('/api/stats/tetris/rank/score', { headers }),
fetch('/api/stats/tetris/rank/wins', { headers })
]);
const me = meRes.ok ? await meRes.json() : null;
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
if (scoresRes.ok) {
const scores = await scoresRes.json();
renderLeaderboard('lb-scores-body', scores, ['tetris_best_score', 'tetris_games_played'], me, rankScore);
}
if (winsRes.ok) {
const wins = await winsRes.json();
renderLeaderboard('lb-wins-body', wins, ['tetris_wins', 'tetris_games_played'], me, rankWins);
}
} catch (err) {
console.error('Erreur chargement leaderboards:', err);
}
}
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!rows.length && !me) {
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
return;
}
const myUsername = me?.username;
const inTop = rows.some(r => r.username === myUsername);
let html = rows.map((r, i) => {
const isMe = r.username === myUsername;
return `<tr class="${isMe ? 'lb-me' : ''}">
<td>${i + 1}</td>
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
<td>${r[col1] ?? 0}</td>
<td>${r[col2] ?? 0}</td>
</tr>`;
}).join('');
if (!inTop && me && myRank !== null) {
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
html += `<tr class="lb-me">
<td>${myRank}</td>
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
<td>${me[col1] ?? 0}</td>
<td>${me[col2] ?? 0}</td>
</tr>`;
}
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
}
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Tabs leaderboard
document.querySelectorAll('.lb-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
tab.classList.add('lb-tab--active');
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
if (tab.dataset.tab === 'history') loadGameHistory();
});
});
// Chargement initial des leaderboards
loadLeaderboards();
loadGameHistory();
@@ -1,6 +1,6 @@
import { Window, windowRegistry } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
import { Window, windowRegistry } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
/**
* Avatar management window
@@ -67,8 +67,12 @@ export class AvatarWindow extends Window {
this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Refresh'
});
this.deleteBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Delete avatar'
});
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn);
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn, this.deleteBtn);
// Feedback message
this.message = this.createElement('div', CSS.MESSAGE);
@@ -93,6 +97,7 @@ export class AvatarWindow extends Window {
this.chooseBtn.addEventListener('click', () => this.fileInput.click());
this.saveBtn.addEventListener('click', () => this.uploadAvatar());
this.refreshBtn.addEventListener('click', () => this.loadAvatar());
this.deleteBtn.addEventListener('click', () => this.deleteAvatar());
}
/**
@@ -212,12 +217,14 @@ export class AvatarWindow extends Window {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('You must be logged in', 'error');
this.showNotification('You must be logged in to change your avatar', 'red');
return;
}
const file = this.fileInput.files?.[0];
if (!file) {
this.showMessage('Select an image first', 'error');
this.showNotification('Please select an image to upload', 'red');
return;
}
@@ -240,6 +247,7 @@ export class AvatarWindow extends Window {
if (!response.ok) {
const errorMsg = data?.error || data?.message || 'Upload failed';
this.showMessage(errorMsg, 'error');
this.showNotification('Failed to upload avatar.', 'red');
return;
}
@@ -248,11 +256,47 @@ export class AvatarWindow extends Window {
}
this.showMessage('Avatar saved!', 'success');
this.showNotification('Avatar updated successfully!', 'green');
eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
} catch (error) {
console.error('Avatar upload error:', error);
this.showMessage('Upload error', 'error');
this.showNotification('Failed to upload avatar.', 'red');
}
}
async deleteAvatar() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('You must be logged in', 'error');
this.showNotification('You must be logged in to delete your avatar', 'red');
return;
}
try {
const response = await fetch(API.AVATAR.DELETE, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
this.showMessage('Failed to delete avatar', 'error');
this.showNotification('Failed to delete avatar.', 'red');
return;
}
this.preview.src = '';
this.showMessage('Avatar deleted!', 'success');
this.showNotification('Avatar deleted successfully!', 'green');
eventBus.emit(Events.AVATAR_DELETED);
} catch (error) {
console.error('Avatar delete error:', error);
this.showMessage('Delete error', 'error');
this.showNotification('Failed to delete avatar.', 'red');
}
}
@@ -1,6 +1,6 @@
import { Window, windowRegistry } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
import { Window, windowRegistry } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
/**
* Friends management window
@@ -1,6 +1,6 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
export class GameRoomWindow extends Window {
constructor() {
@@ -34,6 +34,7 @@ export class GameRoomWindow extends Window {
});
this.updateTabsAccess();
this.loadCurrentTab();
// Verifier si l'utilisateur est deja dans un salon au chargement
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
@@ -119,7 +120,8 @@ export class GameRoomWindow extends Window {
this.gameInfo = this.createElement('div', 'gameroom__game-info');
this.currentDrawerInfo = this.createElement('div', 'gameroom__drawer-info', { text: '' });
this.scoresDisplay = this.createElement('div', 'gameroom__scores-display');
this.gameInfo.append(this.currentDrawerInfo, this.scoresDisplay);
this.timerDisplay = this.createElement('div', 'gameroom__timer-display');
this.gameInfo.append(this.currentDrawerInfo, this.scoresDisplay, this.timerDisplay);
// Affichage du mot caché
this.wordDisplay = this.createElement('div', 'gameroom__word-display');
@@ -195,7 +197,9 @@ export class GameRoomWindow extends Window {
currentPlayerIndex: 0,
guessedLetters: [],
scores: {},
counter: 0
counter: 0,
counterRound: 0,
timer: 0
};
this.initDrawing();
@@ -375,10 +379,11 @@ export class GameRoomWindow extends Window {
this.socket.on('game-player-left', (data) => {
this.showMessage(`${data.username} a quitté le salon`, 'info');
console.log(`${data.username} left the room`);
if (this.gameState.isPlaying)
{
if (this.gameState.players)
if (Array.isArray(this.gameState.players))
this.gameState.players = this.gameState.players.filter(p => p !== data.username);
}
@@ -494,7 +499,7 @@ export class GameRoomWindow extends Window {
// If spectating, return to spectator list
if (this.isSpectating) {
this.resetGameUI();
this.currentRoom = null;
// this.currentRoom = null;
this.isSpectating = false;
this.switchTab('spectator');
this.showMessage('La partie est terminée', 'info');
@@ -519,6 +524,8 @@ export class GameRoomWindow extends Window {
this.gameState.revealedWord = data.revealedWord || new Array(data.wordLength).fill('_');
this.gameState.players = data.players;
this.gameState.scores = data.scores || {};
this.gameState.timer = data.timer || 0;
this.updateTimerUI();
this.showGameUI();
this.updateWordDisplay();
@@ -608,6 +615,15 @@ export class GameRoomWindow extends Window {
// Setup UI for new round with new drawer
this.setupRound();
});
this.socket.on('game-timer-sync', (data) => {
this.gameState.timer = data.remaining;
this.updateTimerUI();
});
this.socket.on('game-timer-ended', (data) => {
this.showMessage(data.message || 'Temps écoulé !', 'info');
})
}
disconnectGameSocket() {
@@ -718,7 +734,7 @@ export class GameRoomWindow extends Window {
const altPort = window.GLOBAL_CHAT_ALT_PORT;
if (altPort) {
const host = location.hostname || 'localhost';
this.socket = io(`http://${host}:${altPort}`, ioConfig);
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
} else {
this.socket = io(ioConfig);
}
@@ -752,8 +768,7 @@ export class GameRoomWindow extends Window {
return;
}
this.roomsList = data || [];
this.renderRoomsList(this.roomsList);
this.renderRoomsList(data || []);
} catch (error) {
console.error('Load rooms error:', error);
this.showMessage('Erreur de connexion', 'error');
@@ -840,17 +855,20 @@ export class GameRoomWindow extends Window {
const name = this.roomNameInput.value.trim();
if (!name) {
this.showMessage('Entrez un nom pour le salon', 'error');
this.showNotification('Entrez un nom pour le salon', 'red');
return;
}
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('Connectez-vous pour creer un salon', 'info');
this.showNotification('Connectez-vous pour créer un salon', 'red');
return;
}
if (this.currentRoom) {
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
@@ -864,6 +882,7 @@ export class GameRoomWindow extends Window {
this.currentRoom = currentData;
this.enterLobby(currentData);
this.showMessage('Vous etes deja dans un salon', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
}
@@ -884,6 +903,7 @@ export class GameRoomWindow extends Window {
if (this.roomNameExists(name)) {
this.showMessage('Un salon avec ce nom existe deja', 'error');
this.showNotification('Un salon avec ce nom existe deja', 'red');
return;
}
@@ -905,6 +925,7 @@ export class GameRoomWindow extends Window {
this.showMessage('Salon cree', 'success');
eventBus.emit(Events.ROOM_CREATED, data);
this.enterLobby(data);
this.showNotification(`Vous avez créé le salon "${data.name}"`, 'green');
} catch (error) {
console.error('Create room error:', error);
this.showMessage('Erreur de connexion', 'error');
@@ -1037,6 +1058,7 @@ export class GameRoomWindow extends Window {
if (this.currentRoom) {
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
@@ -1050,6 +1072,7 @@ export class GameRoomWindow extends Window {
this.currentRoom = currentData;
this.enterLobby(currentData);
this.showMessage('Vous etes deja dans un salon', 'error');
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
return;
}
}
@@ -1094,7 +1117,9 @@ export class GameRoomWindow extends Window {
}
async loadLobby() {
console.log('Loading lobby for room:', this.currentRoom);
if (!this.currentRoom) return;
console.log('Managed to load lobby, current room:', this.currentRoom);
this.gameState.scores = {};
@@ -1233,6 +1258,12 @@ export class GameRoomWindow extends Window {
}, 5000);
}
updateTimerUI()
{
if (this.timerDisplay)
this.timerDisplay.textContent = `Temps restant : ${this.gameState.timer}s`;
}
// ============================================
// LOGIQUE DU JEU
// ============================================
@@ -1287,6 +1318,9 @@ export class GameRoomWindow extends Window {
this.isSpectating = false;
this.gameState.scores = {};
this.gameState.counter = 0;
this.gameState.counterRound = 0;
this.gameState.timer = 0;
this.gameState.players = [];
this.gameState.currentPlayerIndex = 0;
this.gameState.guessedLetters = [];
@@ -1510,7 +1544,7 @@ export class GameRoomWindow extends Window {
const pointsText = points !== 0 ? ` (${points > 0 ? '+' : ''}${points} pts)` : '';
if (success) {
item.textContent = `${username}: "${guess}" - Bonne ${typeText}!${pointsText}`;
item.textContent = `${username}: "${guess}" - Bon ${typeText}!${pointsText}`;
} else {
item.textContent = `${username}: "${guess}" - Mauvais ${typeText}${pointsText}`;
}
@@ -1560,7 +1594,10 @@ export class GameRoomWindow extends Window {
this.wordDisplay.textContent = word.split('').join(' ');
// Auto next round after delay
this.gameState.counterRound++;
setTimeout(() => {
if (this.gameState.counterRound >= (this.gameState.players.length * 4))
this.endGame();
if (this.gameState.isPlaying) {
this.nextRound();
}
@@ -1,6 +1,6 @@
import { Window } from './windows.js';
import { STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
import { Window } from '../core/windows.js';
import { STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
/**
* Global chat window
@@ -222,7 +222,7 @@ export class GlobalChat extends Window {
const altPort = window.GLOBAL_CHAT_ALT_PORT;
if (altPort) {
const host = location.hostname || 'localhost';
this.socket = io(`http://${host}:${altPort}`, ioConfig);
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
} else {
this.socket = io(ioConfig);
}
@@ -1,6 +1,6 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
/**
* Login and registration window
@@ -17,7 +17,6 @@ export class LoginWindow extends Window {
this.buildUI();
this.bindEvents();
this.checkIfAlreadyLoggedIn();
this.NotficationContainer();
}
/**
@@ -84,7 +83,8 @@ export class LoginWindow extends Window {
bindEvents() {
this.loginBtn.addEventListener('click', () => this.handleLogin());
this.registerBtn.addEventListener('click', () => this.handleRegister());
this.githubBtn.addEventListener('click', () => this.handleGitHubLogin());
this.githubBtn.addEventListener('click', () => {console.log(API.AUTH.GITHUB); this.handleGitHubLogin();});
// Login with Enter
this.passwordInput.addEventListener('keypress', (e) => {
@@ -195,6 +195,8 @@ export class LoginWindow extends Window {
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
console.log(API.AUTH.GITHUB);
const popup = window.open(
API.AUTH.GITHUB,
'githubOAuth',
@@ -221,55 +223,6 @@ export class LoginWindow extends Window {
window.addEventListener('message', handleMessage, { once: true });
}
NotficationContainer()
{
if (document.getElementById('notification-container')) return;
const container = this.createElement('div');
container.id = 'notification-container';
Object.assign(container.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '10px'
});
document.body.appendChild(container);
}
showNotification(message, color) {
const container = document.getElementById('notification-container');
if (!container) return;
const notification = document.createElement('div');
notification.textContent = message;
Object.assign(notification.style, {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity 0.5s ease, transform 0.5s ease'
});
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-8px)';
setTimeout(() => notification.remove(), 500);
}, 2200);
}
/**
* Displays a feedback message
* @param {string} text - Message text
@@ -0,0 +1,76 @@
import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from '../core/events.js';
export class LogoutWindow extends Window {
constructor() {
super({
name: 'logout',
title: 'Logout',
cssClasses: ['logout-window']
});
this.buildUI();
this.bindEvents();
}
buildUI() {
this.text = this.createElement('div', 'logout__text', {
text: 'Are you sure you want to log out?'
});
this.actions = this.createElement('div', 'logout__actions');
this.yesBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Yes'
});
this.noBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'No'
});
this.actions.append(this.yesBtn, this.noBtn);
this.body.append(this.text, this.actions);
}
bindEvents() {
this.yesBtn.addEventListener('click', () => this.confirmLogout());
this.noBtn.addEventListener('click', () => this.hide());
}
show () {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.text.textContent = 'You need to login first';
this.yesBtn.style.display = 'none';
this.noBtn.textContent = 'OK';
} else {
this.text.textContent = 'Are you sure you want to log out?';
this.yesBtn.style.display = 'inline-flex';
this.noBtn.textContent = 'No';
}
super.show();
}
async confirmLogout() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (token)
{
try
{
await fetch(API.AUTH.LOGOUT, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
}
catch (err)
{
console.warn('Logout failed:', err);
this.showNotification('Logout failed. Please try again.', 'red');
return;
}
}
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
eventBus.emit(Events.USER_LOGGED_OUT);
setTimeout(() => window.location.reload(), 500);
this.showNotification('You have been logged out successfully.', 'green');
}
}
@@ -1,5 +1,5 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS } from './config.js';
import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS } from '../core/config.js';
/**
* Stats window displays Scribble + Tetris stats for any user
@@ -0,0 +1,79 @@
:root {
--color-primary: #ffc75e;
--color-primary-hover: #ffc75e;
--color-success: #00c71b;
--color-success-dark: #ffc75e;
--color-error: #ff4d4d;
--color-warning: #ffc75e;
--color-github: #ffc75e;
--color-bg: #ffe5b5;
--color-surface: #ffcc00;
--color-surface-light: #feffa6;
--color-text: #000000;
--color-text-muted: #353535;
--font-size-base: 10px;
--font-size-sm: 1.2rem;
--font-size-md: 1.4rem;
--font-size-lg: 1.6rem;
--font-size-xl: 3rem;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 12px;
--radius-full: 50%;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--z-menu: 2;
--z-window: 100;
--z-modal: 200;
}
.game {
position: fixed;
top: var(--spacing-lg);
right: 50px;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
z-index: var(--z-menu);
}
.game__item {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-lg);
border-color: #fda725;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.game__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.game__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wiskas</title>
<link rel="stylesheet" href="wiskas.css" />
<style>
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #6a11cb, #2575fc);
font-family: Arial, sans-serif;
overflow: hidden;
}
h1 {
font-size: 4rem;
color: white;
animation: float 2s ease-in-out infinite alternate;
}
@keyframes float {
from { transform: translateY(0); }
to { transform: translateY(-20px); }
}
</style>
</head>
<body>
<h1 id="helloText">METS TON CHAT ICI</h1>
<nav class="game" aria-label="Game">
<button class="game__item" data-action="Home page" aria-label="Home Page"
onclick="window.location.href='../index.html'">Home Page</button>
</nav>
<script>
const colors = ["#ff4b5c", "#56cfe1", "#80ed99", "#ffd166"];
const text = document.getElementById("helloText");
setInterval(() => {
const randomColor = colors[Math.floor(Math.random() * colors.length)];
text.style.color = randomColor;
}, 500);
</script>
</body>
</html>