Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3124239374 | |||
| b31436a40a | |||
| c066fdc31c | |||
| f9587c5cfa | |||
| 44a0ffe743 | |||
| 0f0e777e6e | |||
| c96629b704 | |||
| 41612f5d39 | |||
| e1573ba9f0 | |||
| b9c4c817f8 | |||
| 384363c8dd | |||
| def9918047 | |||
| cafa0cccc4 | |||
| 8b907d5a86 | |||
| 13f93fb332 | |||
| 801750da96 | |||
| 82623b0078 | |||
| d3e2d9bdf9 | |||
| 9c1e8e03bb | |||
| 55c241fd61 | |||
| 592bb38c0d | |||
| 72bc9ea628 | |||
| 557cf23f71 | |||
| b51b711b10 | |||
| 30e4f04c52 | |||
| a202889f79 | |||
| 37ab3e83f6 | |||
| e4eb9b0c95 | |||
| ad4becc38f | |||
| 0c8b6a663a | |||
| 29c0863470 | |||
| 8feb894a39 | |||
| c8203cfc49 | |||
| c2585774cc | |||
| 5ca2a485f8 | |||
| b3141387b1 | |||
| 3769ee27a8 |
@@ -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**
|
||||
|
||||

|
||||
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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, The⠀Cake), $(call pad_word, 14, Is⠀A⠀Lie⠀...), $(CLS), $(RESET));
|
||||
@$(call random_cat, $(call pad_word, 13, The⠀Cake), $(call pad_word, 15, Is⠀A⠀Lie⠀...), , $(RESET));
|
||||
|
||||
|
||||
# $(call pad_word, 12, The⠀Cake)
|
||||
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, "The⠀Cake"), $(call pad_word, 12, "Is⠀A⠀Lie..."));
|
||||
# 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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
le you win apparait sur la grille de l'adversaire, elle doit apparaitre sur la grille principale du joueur qui gagne FIXED ? [OUI]
|
||||
|
||||
undefined is not an object (evaluating 'grid[ny][nx]')
|
||||
a la ligne 56 de renderer.js FIXED ? [CA_EN_A_L'AIR]
|
||||
A tester plusieurs game, si ca freeze et que l'erreur reviens, NAN, j'ai changer les limite
|
||||
sur les bord gauche droit de la grid, a voir
|
||||
|
||||
Quand je fais pause,
|
||||
ca ne fait pas pose pour tout le monde FIXED[OUI]
|
||||
|
||||
Quand je fait stop,
|
||||
ca ne fais pas stop pour tout le monde FIXED[OUI]
|
||||
|
||||
durant le duel du tetris en ligne:
|
||||
j'ai plusieurs probleme:
|
||||
|
||||
|
||||
Les parametre doivent etre les memes pour tout le monde
|
||||
FIXED[OUI]
|
||||
|
||||
|
||||
DES GAMES OVER ARRIVE COMME CA SANS RAISON durant le duel FIXED[OUI]
|
||||
|
||||
est-ce du au valid-block ? au addGarbage ?
|
||||
|
||||
Bug A — Faux game over via garbage (tetris.js)
|
||||
|
||||
addGarbageLines appelait _isValidPosition() qui retourne false si gy < 0.
|
||||
Or après garbage, la pièce monte légitimement au-dessus de la grille
|
||||
(y négatif).
|
||||
Fix : nouvelle méthode _isValidPositionAllowTop() qui
|
||||
ignore les cellules au-dessus de la grille (zone tampon) et
|
||||
ne vérifie que les collisions réelles dans la grille.
|
||||
|
||||
Bug B — Crash si la pièce est au-dessus de la grille (tetris.js)
|
||||
|
||||
_canMoveDown, _canMoveLeft, _canMoveRight, et _lockPiece accèdent à grid[y + row]
|
||||
sans vérifier si y + row < 0 → undefined → crash. Fix :
|
||||
skip des rangées hors grille avec continue.
|
||||
|
||||
Bug C — Game over par garbage ne termine pas le duel (duel.js)
|
||||
|
||||
onLocalGameOver ne faisait endDuel() que si validBlock=true. Un game over réel dû à du garbage (validBlock=false) laissait le duel dans un état cassé et l'adversaire ne voyait jamais "YOU WIN". Fix : endDuel() systématique, et OPPONENT_GAME_OVER affiche toujours "YOU WIN".
|
||||
|
||||
|
||||
|
||||
separer le code tetris du reste, de meme pour les sockets. FAIT[?]
|
||||
|
||||
|
||||
error:
|
||||
renderer.js:56 Uncaught TypeError: Cannot read properties of undefined (reading 'length')
|
||||
at drawGhost (renderer.js:56:71)
|
||||
at render (renderer.js:101:9)
|
||||
at Tetris.onRender (ui.js:107:9)
|
||||
at gameLoop (tetris.js:115:18)
|
||||
|
||||
FIXED[ON DIRAIS BIEN]
|
||||
|
||||
Il faut verifier si le garbage si retrouve a la premiere ligne,
|
||||
si c'est le cas, game-over
|
||||
|
||||
Il faut un bouton restart
|
||||
|
||||
system d'attribution de point et d'enregistrement de point
|
||||
@@ -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:
|
||||
@@ -24,8 +24,6 @@ services:
|
||||
build: ./srcs/backend
|
||||
expose:
|
||||
- "3001"
|
||||
# ports:
|
||||
# - "3001:3001"
|
||||
depends_on:
|
||||
- database
|
||||
volumes:
|
||||
@@ -40,7 +38,7 @@ services:
|
||||
container_name: frontend
|
||||
build: ./srcs/frontend/
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8443:443"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -45,8 +45,28 @@ async function runMigrations()
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN
|
||||
ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_best_score') THEN
|
||||
ALTER TABLE users ADD COLUMN tetris_best_score INT DEFAULT 0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_wins') THEN
|
||||
ALTER TABLE users ADD COLUMN tetris_wins INT DEFAULT 0;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='tetris_games_played') THEN
|
||||
ALTER TABLE users ADD COLUMN tetris_games_played INT DEFAULT 0;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
// Create tetris_game_history table if not exists
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tetris_game_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
score INT NOT NULL DEFAULT 0,
|
||||
game_type VARCHAR(10) NOT NULL DEFAULT 'solo',
|
||||
result VARCHAR(10) DEFAULT NULL,
|
||||
played_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
console.log('Migrations completed!');
|
||||
}
|
||||
catch (err)
|
||||
@@ -107,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,
|
||||
@@ -138,6 +158,15 @@ async function createTables()
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
ended_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tetris_game_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
score INT NOT NULL DEFAULT 0,
|
||||
game_type VARCHAR(10) NOT NULL DEFAULT 'solo',
|
||||
result VARCHAR(10) DEFAULT NULL,
|
||||
played_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
console.log('Tables created!');
|
||||
}
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}&` +
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,21 @@ router.get('/', authenticateToken, async(req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of rooms currently being played (for spectators)
|
||||
router.get('/playing', authenticateToken, async(req, res) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const rooms = await gameRoomService.listPlayingRooms();
|
||||
res.json(rooms);
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
console.error(err);
|
||||
res.status(500).json({error: 'Server error'});
|
||||
}
|
||||
});
|
||||
|
||||
// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId
|
||||
router.get('/current', authenticateToken, async(req, res) =>
|
||||
{
|
||||
@@ -134,4 +149,37 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Join a room as spectator
|
||||
router.post('/:roomId/spectate', authenticateToken, async(req, res) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId);
|
||||
res.json(room);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
console.error(err);
|
||||
if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in'))
|
||||
res.status(400).json({error: err.message});
|
||||
else
|
||||
res.status(500).json({error: err.message});
|
||||
}
|
||||
});
|
||||
|
||||
// Leave spectator mode
|
||||
router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId);
|
||||
res.json({message: 'Left spectator mode successfully'});
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
console.error(err);
|
||||
res.status(500).json({error: 'Server error'});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -31,7 +31,7 @@ router.get('/user/:username', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get leaderboard
|
||||
// Get general leaderboard
|
||||
router.get('/leaderboard', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
|
||||
@@ -43,4 +43,78 @@ router.get('/leaderboard', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Save tetris score (solo) — updates best score if higher + saves to history
|
||||
router.post('/tetris/score', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { score } = req.body;
|
||||
if (typeof score !== 'number' || score < 0) {
|
||||
return res.status(400).json({ error: 'Invalid score' });
|
||||
}
|
||||
const bestScore = await playerStatsService.updateTetrisBestScore(req.user.userId, score);
|
||||
await playerStatsService.incrementTetrisGamesPlayed(req.user.userId);
|
||||
await playerStatsService.addTetrisGameHistory(req.user.userId, score, 'solo', null);
|
||||
res.json({ bestScore });
|
||||
} catch (err) {
|
||||
console.error('Error saving tetris score:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Tetris best score leaderboard
|
||||
router.get('/tetris/leaderboard/score', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
|
||||
const leaderboard = await playerStatsService.getTetrisBestScoreLeaderboard(limit);
|
||||
res.json(leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Error getting tetris score leaderboard:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Tetris duel wins leaderboard
|
||||
router.get('/tetris/leaderboard/wins', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
|
||||
const leaderboard = await playerStatsService.getTetrisDuelWinsLeaderboard(limit);
|
||||
res.json(leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Error getting tetris wins leaderboard:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Current user's rank by tetris best score
|
||||
router.get('/tetris/rank/score', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const rank = await playerStatsService.getTetrisScoreRank(req.user.userId);
|
||||
res.json({ rank });
|
||||
} catch (err) {
|
||||
console.error('Error getting tetris score rank:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user's tetris game history (last 15)
|
||||
router.get('/tetris/history', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const history = await playerStatsService.getTetrisGameHistory(req.user.userId);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
console.error('Error getting tetris history:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Current user's rank by tetris duel wins
|
||||
router.get('/tetris/rank/wins', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const rank = await playerStatsService.getTetrisDuelWinsRank(req.user.userId);
|
||||
res.json({ rank });
|
||||
} catch (err) {
|
||||
console.error('Error getting tetris wins rank:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -44,6 +44,70 @@ async function listActiveRooms()
|
||||
return (result.rows);
|
||||
}
|
||||
|
||||
async function listPlayingRooms()
|
||||
{
|
||||
const result = await query
|
||||
(
|
||||
`SELECT r.*, COUNT(p.id) as player_count
|
||||
FROM game_rooms r
|
||||
LEFT JOIN game_players p ON r.id = p.room_id
|
||||
WHERE r.status = 'playing'
|
||||
GROUP BY r.id
|
||||
ORDER BY player_count DESC, r.created_at DESC`
|
||||
);
|
||||
return (result.rows);
|
||||
}
|
||||
|
||||
async function spectateRoom(roomId, userId)
|
||||
{
|
||||
const room = await getRoomById(roomId);
|
||||
if (!room)
|
||||
throw new Error('Room not found');
|
||||
|
||||
if (room.status !== 'playing')
|
||||
throw new Error('Room is not in playing status');
|
||||
|
||||
// Check if user is already a player in any active game
|
||||
const playerInGame = await query
|
||||
(
|
||||
`SELECT r.id, r.name, r.status
|
||||
FROM game_rooms r
|
||||
JOIN game_players gp ON r.id = gp.room_id
|
||||
WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (playerInGame.rows.length > 0)
|
||||
{
|
||||
const gameRoom = playerInGame.rows[0];
|
||||
if (gameRoom.id === parseInt(roomId))
|
||||
throw new Error('You cannot spectate a game you are playing in');
|
||||
else
|
||||
throw new Error('You are already in an active game');
|
||||
}
|
||||
|
||||
return (room);
|
||||
}
|
||||
|
||||
async function leaveSpectateRoom(roomId, userId)
|
||||
{
|
||||
const playerCount = await query
|
||||
(
|
||||
'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
|
||||
if (parseInt(playerCount.rows[0].count) === 0)
|
||||
{
|
||||
await query
|
||||
(
|
||||
'DELETE FROM game_rooms WHERE id = $1',
|
||||
[roomId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom(roomId, userId)
|
||||
{
|
||||
const room = await getRoomById(roomId);
|
||||
@@ -116,20 +180,75 @@ async function getCurrentRoom(userId)
|
||||
`SELECT r.*
|
||||
FROM game_rooms r
|
||||
JOIN game_players gp ON r.id = gp.room_id
|
||||
WHERE gp.user_id = $1 AND r.status = 'waiting'
|
||||
WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
return (result.rows[0] || null);
|
||||
}
|
||||
|
||||
// Update room status (waiting, playing, ended)
|
||||
async function updateRoomStatus(roomId, status)
|
||||
{
|
||||
const validStatuses = ['waiting', 'playing', 'ended'];
|
||||
if (!validStatuses.includes(status))
|
||||
throw new Error('Invalid status');
|
||||
|
||||
let updateQuery = 'UPDATE game_rooms SET status = $1';
|
||||
const params = [status, roomId];
|
||||
|
||||
if (status === 'playing')
|
||||
{
|
||||
updateQuery += ', started_at = NOW()';
|
||||
}
|
||||
else if (status === 'ended')
|
||||
{
|
||||
updateQuery += ', ended_at = NOW()';
|
||||
}
|
||||
|
||||
updateQuery += ' WHERE id = $2 RETURNING *';
|
||||
|
||||
const result = await query(updateQuery, params);
|
||||
return (result.rows[0]);
|
||||
}
|
||||
|
||||
async function resetRoomScores(roomId)
|
||||
{
|
||||
await query
|
||||
(
|
||||
'UPDATE game_players SET score = 0 WHERE room_id = $1',
|
||||
[roomId]
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupEndedRooms()
|
||||
{
|
||||
await query
|
||||
(
|
||||
'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)',
|
||||
['ended']
|
||||
);
|
||||
|
||||
await query
|
||||
(
|
||||
'DELETE FROM game_rooms WHERE status = $1',
|
||||
['ended']
|
||||
);
|
||||
}
|
||||
|
||||
export default
|
||||
{
|
||||
createRoom,
|
||||
getRoomById,
|
||||
listActiveRooms,
|
||||
listPlayingRooms,
|
||||
spectateRoom,
|
||||
leaveSpectateRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
getRoomPlayers,
|
||||
getCurrentRoom
|
||||
getCurrentRoom,
|
||||
updateRoomStatus,
|
||||
resetRoomScores,
|
||||
cleanupEndedRooms
|
||||
};
|
||||
@@ -3,7 +3,8 @@ import { query } from '../db.js';
|
||||
// Get player stats by user ID
|
||||
async function getStatsByUserId(userId) {
|
||||
const result = await query(
|
||||
`SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
|
||||
`SELECT id, username, avatar_url, total_points, games_played, games_won,
|
||||
tetris_best_score, tetris_wins, tetris_games_played, created_at
|
||||
FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
@@ -13,7 +14,8 @@ async function getStatsByUserId(userId) {
|
||||
// Get player stats by username
|
||||
async function getStatsByUsername(username) {
|
||||
const result = await query(
|
||||
`SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
|
||||
`SELECT id, username, avatar_url, total_points, games_played, games_won,
|
||||
tetris_best_score, tetris_wins, tetris_games_played, created_at
|
||||
FROM users WHERE username = $1`,
|
||||
[username]
|
||||
);
|
||||
@@ -76,6 +78,111 @@ async function getUserIdByUsername(username) {
|
||||
return result.rows[0]?.id || null;
|
||||
}
|
||||
|
||||
// Update tetris best score (only if new score is higher)
|
||||
async function updateTetrisBestScore(userId, score) {
|
||||
const result = await query(
|
||||
`UPDATE users SET tetris_best_score = GREATEST(COALESCE(tetris_best_score, 0), $1) WHERE id = $2 RETURNING tetris_best_score`,
|
||||
[score, userId]
|
||||
);
|
||||
return result.rows[0]?.tetris_best_score || 0;
|
||||
}
|
||||
|
||||
// Increment tetris duel wins
|
||||
async function incrementTetrisWins(userId) {
|
||||
await query(
|
||||
`UPDATE users SET tetris_wins = COALESCE(tetris_wins, 0) + 1 WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Increment tetris games played
|
||||
async function incrementTetrisGamesPlayed(userId) {
|
||||
await query(
|
||||
`UPDATE users SET tetris_games_played = COALESCE(tetris_games_played, 0) + 1 WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Leaderboard: best tetris scores
|
||||
async function getTetrisBestScoreLeaderboard(limit = 10) {
|
||||
const result = await query(
|
||||
`SELECT id, username, avatar_url, tetris_best_score, tetris_wins, tetris_games_played
|
||||
FROM users
|
||||
WHERE tetris_best_score > 0
|
||||
ORDER BY tetris_best_score DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Leaderboard: most tetris duel wins
|
||||
async function getTetrisDuelWinsLeaderboard(limit = 10) {
|
||||
const result = await query(
|
||||
`SELECT id, username, avatar_url, tetris_wins, tetris_games_played, tetris_best_score
|
||||
FROM users
|
||||
WHERE tetris_wins > 0
|
||||
ORDER BY tetris_wins DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Add a game to tetris history (keep max 15 per user)
|
||||
async function addTetrisGameHistory(userId, score, gameType = 'solo', result = null) {
|
||||
await query(
|
||||
`INSERT INTO tetris_game_history (user_id, score, game_type, result) VALUES ($1, $2, $3, $4)`,
|
||||
[userId, score, gameType, result]
|
||||
);
|
||||
// Keep only the 15 most recent entries
|
||||
await query(
|
||||
`DELETE FROM tetris_game_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM tetris_game_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY played_at DESC
|
||||
OFFSET 15
|
||||
)`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Get the last 15 games for a user
|
||||
async function getTetrisGameHistory(userId) {
|
||||
const result = await query(
|
||||
`SELECT id, score, game_type, result, played_at
|
||||
FROM tetris_game_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY played_at DESC
|
||||
LIMIT 15`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Rank of a user by tetris best score (1 = best)
|
||||
async function getTetrisScoreRank(userId) {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) + 1 AS rank
|
||||
FROM users
|
||||
WHERE tetris_best_score > COALESCE((SELECT tetris_best_score FROM users WHERE id = $1), 0)`,
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0]?.rank || 1);
|
||||
}
|
||||
|
||||
// Rank of a user by tetris duel wins (1 = best)
|
||||
async function getTetrisDuelWinsRank(userId) {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) + 1 AS rank
|
||||
FROM users
|
||||
WHERE tetris_wins > COALESCE((SELECT tetris_wins FROM users WHERE id = $1), 0)`,
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0]?.rank || 1);
|
||||
}
|
||||
|
||||
export default {
|
||||
getStatsByUserId,
|
||||
getStatsByUsername,
|
||||
@@ -84,5 +191,14 @@ export default {
|
||||
incrementGamesPlayed,
|
||||
incrementGamesWon,
|
||||
getLeaderboard,
|
||||
getUserIdByUsername
|
||||
getUserIdByUsername,
|
||||
updateTetrisBestScore,
|
||||
incrementTetrisWins,
|
||||
incrementTetrisGamesPlayed,
|
||||
getTetrisBestScoreLeaderboard,
|
||||
getTetrisDuelWinsLeaderboard,
|
||||
getTetrisScoreRank,
|
||||
getTetrisDuelWinsRank,
|
||||
addTetrisGameHistory,
|
||||
getTetrisGameHistory
|
||||
};
|
||||
|
||||
@@ -10,6 +10,9 @@ const gameRooms = new Map();
|
||||
// Store tetris duel rooms { roomCode → Map<socketId, socket> }
|
||||
const tetrisRooms = new Map();
|
||||
|
||||
// Matchmaking queue for tetris
|
||||
const tetrisMatchmakingQueue = [];
|
||||
|
||||
// Store io instance globally for use in routes
|
||||
let ioInstance = null;
|
||||
|
||||
@@ -27,6 +30,100 @@ 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;
|
||||
|
||||
try {
|
||||
// Check if room is in 'playing' status
|
||||
const room = await gameRoomService.getRoomById(dbRoomId);
|
||||
if (!room || room.status !== 'playing') return;
|
||||
|
||||
// Count remaining players
|
||||
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');
|
||||
await gameRoomService.resetRoomScores(dbRoomId);
|
||||
|
||||
// Remove from game state
|
||||
gameRooms.delete(roomId);
|
||||
|
||||
// Notify remaining player(s)
|
||||
io.to(roomId).emit('game-ended');
|
||||
io.to(roomId).emit('game-message', {
|
||||
message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur',
|
||||
type: 'info'
|
||||
});
|
||||
|
||||
// Broadcast updated rooms list
|
||||
broadcastRoomsList(io);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking single player game:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Save round points to database (only the difference from round start)
|
||||
async function saveRoundPoints(currentScores, roundStartScores) {
|
||||
for (const [username, currentPoints] of Object.entries(currentScores)) {
|
||||
@@ -153,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
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -163,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,
|
||||
@@ -185,16 +293,95 @@ function setupSocketIO(io)
|
||||
socket.gameRoomId = null;
|
||||
socket.gameRoomDbId = null;
|
||||
|
||||
// Check if game should auto-stop due to single player
|
||||
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
|
||||
// Broadcast updated rooms list
|
||||
broadcastRoomsList(io);
|
||||
}
|
||||
});
|
||||
|
||||
// Join a game room as spectator
|
||||
socket.on('game-spectate-room', async (data) => {
|
||||
console.log('Received game-spectate-room from', socket.user.username, 'data:', data);
|
||||
const roomId = `game-room-${data.roomId}`;
|
||||
|
||||
// Verify room exists and is in playing status, and user is not already in a game
|
||||
try {
|
||||
const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId);
|
||||
|
||||
socket.join(roomId);
|
||||
socket.gameRoomId = roomId;
|
||||
socket.gameRoomDbId = data.roomId;
|
||||
socket.isSpectator = true;
|
||||
console.log(`${socket.user.username} joined ${roomId} as spectator`);
|
||||
|
||||
// Send confirmation
|
||||
socket.emit('game-spectate-joined', {
|
||||
roomId: data.roomId,
|
||||
success: true
|
||||
});
|
||||
|
||||
// Notify others that a spectator joined
|
||||
socket.to(roomId).emit('game-spectator-joined', {
|
||||
username: socket.user.username
|
||||
});
|
||||
|
||||
// Send current game state
|
||||
const gameState = gameRooms.get(roomId);
|
||||
if (gameState && gameState.isPlaying) {
|
||||
socket.emit('game-state-sync', {
|
||||
isPlaying: gameState.isPlaying,
|
||||
drawer: gameState.drawer,
|
||||
wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
|
||||
revealedLetters: gameState.revealedLetters,
|
||||
revealedWord: gameState.revealedWord || [],
|
||||
guessedLetters: gameState.guessedLetters,
|
||||
players: gameState.players,
|
||||
scores: gameState.scores || {},
|
||||
timer: gameState.timerSeconds || 0
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error joining as spectator:', err);
|
||||
socket.emit('game-spectate-error', {
|
||||
error: err.message || 'Cannot spectate this room'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Leave spectator mode
|
||||
socket.on('game-leave-spectate', () => {
|
||||
if (socket.gameRoomId && socket.isSpectator) {
|
||||
const roomId = socket.gameRoomId;
|
||||
|
||||
socket.to(roomId).emit('game-spectator-left', {
|
||||
username: socket.user.username
|
||||
});
|
||||
|
||||
socket.leave(roomId);
|
||||
console.log(`${socket.user.username} left spectator mode in ${roomId}`);
|
||||
|
||||
socket.gameRoomId = null;
|
||||
socket.gameRoomDbId = null;
|
||||
socket.isSpectator = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Start the game
|
||||
socket.on('game-start', (data) => {
|
||||
socket.on('game-start', async (data) => {
|
||||
console.log('Received game-start event from', socket.user.username);
|
||||
console.log('socket.gameRoomId:', socket.gameRoomId);
|
||||
|
||||
// Security check: need at least 2 players
|
||||
if (!data.players || data.players.length < 2) {
|
||||
console.log('Game start rejected: not enough players');
|
||||
socket.emit('game-start-error', {
|
||||
error: 'Il faut au moins 2 joueurs pour commencer'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const gameStartedData = {
|
||||
drawer: data.drawer,
|
||||
players: data.players
|
||||
@@ -209,6 +396,33 @@ function setupSocketIO(io)
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify player count from database
|
||||
const dbRoomId = socket.gameRoomDbId;
|
||||
if (dbRoomId) {
|
||||
try {
|
||||
const players = await gameRoomService.getRoomPlayers(dbRoomId);
|
||||
if (players.length < 2) {
|
||||
console.log(`Game start rejected: only ${players.length} player(s) in room`);
|
||||
socket.emit('game-start-error', {
|
||||
error: 'Il faut au moins 2 joueurs pour commencer'
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking player count:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update room status to 'playing' in database
|
||||
if (dbRoomId) {
|
||||
try {
|
||||
await gameRoomService.updateRoomStatus(dbRoomId, 'playing');
|
||||
console.log(`Room ${dbRoomId} status updated to 'playing'`);
|
||||
} catch (err) {
|
||||
console.error('Error updating room status to playing:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize scores for all players
|
||||
const scores = {};
|
||||
data.players.forEach(p => scores[p] = 0);
|
||||
@@ -233,6 +447,9 @@ function setupSocketIO(io)
|
||||
socket.emit('game-started', gameStartedData);
|
||||
|
||||
console.log(`Game started in ${roomId} by ${socket.user.username}`);
|
||||
|
||||
// Broadcast updated rooms list (this room should no longer appear)
|
||||
broadcastRoomsList(io);
|
||||
});
|
||||
|
||||
// Drawer sets the word
|
||||
@@ -243,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('_');
|
||||
@@ -269,6 +487,12 @@ function setupSocketIO(io)
|
||||
const roomId = socket.gameRoomId;
|
||||
if (!roomId) return;
|
||||
|
||||
// Spectators cannot draw
|
||||
if (socket.isSpectator) {
|
||||
console.log(`Spectator ${socket.user.username} tried to draw - blocked`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast drawing to all other players in the room
|
||||
socket.to(roomId).emit('game-draw', {
|
||||
x1: data.x1,
|
||||
@@ -285,6 +509,9 @@ function setupSocketIO(io)
|
||||
const roomId = socket.gameRoomId;
|
||||
if (!roomId) return;
|
||||
|
||||
// Spectators cannot clear canvas
|
||||
if (socket.isSpectator) return;
|
||||
|
||||
socket.to(roomId).emit('game-clear-canvas');
|
||||
});
|
||||
|
||||
@@ -293,6 +520,13 @@ function setupSocketIO(io)
|
||||
const roomId = socket.gameRoomId;
|
||||
if (!roomId) return;
|
||||
|
||||
// Spectators cannot make guesses
|
||||
if (socket.isSpectator) {
|
||||
console.log(`Spectator ${socket.user.username} tried to guess - blocked`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const gameState = gameRooms.get(roomId);
|
||||
if (!gameState || !gameState.currentWord) return;
|
||||
|
||||
@@ -389,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,
|
||||
@@ -416,13 +652,100 @@ function setupSocketIO(io)
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('leave-room-during-game', async () => {
|
||||
const roomId = socket.gameRoomId;
|
||||
const dbRoomId = socket.gameRoomDbId;
|
||||
const userId = socket.user.userId;
|
||||
const username = socket.user.username;
|
||||
|
||||
if (!roomId || !dbRoomId || !userId) return;
|
||||
|
||||
console.log(`Player ${username} leaving room ${roomId} during game`);
|
||||
|
||||
try
|
||||
{
|
||||
socket.leave(roomId);
|
||||
|
||||
await gameRoomService.leaveRoom(dbRoomId, userId);
|
||||
|
||||
io.to(roomId).emit('game-player-left', {
|
||||
username: username,
|
||||
message: `${username} a quitté la partie`
|
||||
});
|
||||
|
||||
const gameState = gameRooms.get(roomId);
|
||||
if (gameState)
|
||||
{
|
||||
const wasDrawer = gameState.drawer === username;
|
||||
|
||||
gameState.players = gameState.players.filter(p => p !== username);
|
||||
delete gameState.scores[username];
|
||||
|
||||
io.to(roomId).emit('scores-updated', gameState.scores);
|
||||
|
||||
// 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];
|
||||
gameState.drawer = newDrawer;
|
||||
|
||||
// Reset the word state for the new round
|
||||
gameState.currentWord = '';
|
||||
gameState.revealedLetters = [];
|
||||
gameState.revealedWord = [];
|
||||
gameState.guessedLetters = [];
|
||||
gameState.wrongGuesses = 0;
|
||||
|
||||
console.log(`Drawer ${username} left, new drawer is ${newDrawer}`);
|
||||
|
||||
io.to(roomId).emit('game-drawer-changed', {
|
||||
newDrawer: newDrawer,
|
||||
reason: 'drawer_left',
|
||||
message: `${username} (dessinateur) a quitté, ${newDrawer} devient le nouveau dessinateur`
|
||||
});
|
||||
startRoomTimer(io, roomId, 60);
|
||||
}
|
||||
}
|
||||
|
||||
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
|
||||
|
||||
socket.gameRoomId = null;
|
||||
socket.gameRoomDbId = null;
|
||||
|
||||
broadcastRoomsList(io);
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
console.error('Error leaving room during game:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// End game
|
||||
socket.on('game-end', () => {
|
||||
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;
|
||||
if (dbRoomId) {
|
||||
try {
|
||||
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
|
||||
await gameRoomService.resetRoomScores(dbRoomId);
|
||||
console.log(`Room ${dbRoomId} status updated to 'waiting'`);
|
||||
} catch (err) {
|
||||
console.error('Error updating room status to waiting:', err);
|
||||
}
|
||||
}
|
||||
|
||||
gameRooms.delete(roomId);
|
||||
io.to(roomId).emit('game-ended');
|
||||
|
||||
// Broadcast updated rooms list
|
||||
broadcastRoomsList(io);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
@@ -474,6 +797,7 @@ function setupSocketIO(io)
|
||||
|
||||
// Relay pur : grid-update → adversaire uniquement
|
||||
socket.on('tetris:grid-update', (data) => {
|
||||
if (data.score !== undefined) socket.tetrisLastScore = data.score;
|
||||
_tetrisRelayToOpponent(socket, 'tetris:grid-update', data);
|
||||
});
|
||||
|
||||
@@ -482,6 +806,16 @@ function setupSocketIO(io)
|
||||
_tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data);
|
||||
});
|
||||
|
||||
// Relay pur : shield-activated → adversaire uniquement
|
||||
socket.on('tetris:shield-activated', () => {
|
||||
_tetrisRelayToOpponent(socket, 'tetris:shield-activated', {});
|
||||
});
|
||||
|
||||
// Relay pur : shield-deactivated → adversaire uniquement
|
||||
socket.on('tetris:shield-deactivated', () => {
|
||||
_tetrisRelayToOpponent(socket, 'tetris:shield-deactivated', {});
|
||||
});
|
||||
|
||||
// start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur)
|
||||
socket.on('tetris:start-duel', () => {
|
||||
const code = socket.tetrisRoomCode;
|
||||
@@ -526,13 +860,68 @@ function setupSocketIO(io)
|
||||
}
|
||||
});
|
||||
|
||||
// game-over → relayé en opponent-game-over chez l'adversaire
|
||||
socket.on('tetris:game-over', (data) => {
|
||||
_tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data);
|
||||
// game-over → save stats + relay opponent-game-over
|
||||
socket.on('tetris:game-over', async (data) => {
|
||||
const loserId = socket.user.userId;
|
||||
try {
|
||||
await playerStatsService.updateTetrisBestScore(loserId, data.score || 0);
|
||||
await playerStatsService.incrementTetrisGamesPlayed(loserId);
|
||||
await playerStatsService.addTetrisGameHistory(loserId, data.score || 0, 'duel', 'loss');
|
||||
} catch (err) {
|
||||
console.error('Error saving tetris loser stats:', err);
|
||||
}
|
||||
|
||||
const code = socket.tetrisRoomCode;
|
||||
if (code) {
|
||||
const room = tetrisRooms.get(code);
|
||||
if (room) {
|
||||
for (const [id, s] of room) {
|
||||
if (id !== socket.id) {
|
||||
s.emit('tetris:opponent-game-over', data);
|
||||
try {
|
||||
await playerStatsService.incrementTetrisWins(s.user.userId);
|
||||
await playerStatsService.incrementTetrisGamesPlayed(s.user.userId);
|
||||
const winnerScore = s.tetrisLastScore || 0;
|
||||
await playerStatsService.addTetrisGameHistory(s.user.userId, winnerScore, 'duel', 'win');
|
||||
} catch (err) {
|
||||
console.error('Error saving tetris winner stats:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Matchmaking
|
||||
socket.on('tetris:matchmaking-join', () => {
|
||||
// Remove from queue if already there
|
||||
const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id);
|
||||
if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1);
|
||||
|
||||
tetrisMatchmakingQueue.push(socket);
|
||||
socket.emit('tetris:matchmaking-status', { status: 'searching', position: tetrisMatchmakingQueue.length });
|
||||
|
||||
if (tetrisMatchmakingQueue.length >= 2) {
|
||||
const player1 = tetrisMatchmakingQueue.shift();
|
||||
const player2 = tetrisMatchmakingQueue.shift();
|
||||
const roomCode = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
player1.emit('tetris:matched', { roomCode, opponent: player2.user.username });
|
||||
player2.emit('tetris:matched', { roomCode, opponent: player1.user.username });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tetris:matchmaking-leave', () => {
|
||||
const idx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id);
|
||||
if (idx !== -1) tetrisMatchmakingQueue.splice(idx, 1);
|
||||
socket.emit('tetris:matchmaking-status', { status: 'idle' });
|
||||
});
|
||||
|
||||
socket.on('disconnect', async () =>
|
||||
{
|
||||
// Nettoyage matchmaking tetris
|
||||
const mqIdx = tetrisMatchmakingQueue.findIndex(s => s.id === socket.id);
|
||||
if (mqIdx !== -1) tetrisMatchmakingQueue.splice(mqIdx, 1);
|
||||
|
||||
// Nettoyage room tetris
|
||||
if (socket.tetrisRoomCode) {
|
||||
_tetrisLeave(socket);
|
||||
@@ -540,28 +929,50 @@ function setupSocketIO(io)
|
||||
|
||||
console.log(`User disconnected: ${socket.user.username}`);
|
||||
|
||||
// Notify game room if player was in one
|
||||
// Notify game room if player/spectator was in one
|
||||
if (socket.gameRoomId) {
|
||||
const roomId = socket.gameRoomId;
|
||||
const dbRoomId = socket.gameRoomDbId;
|
||||
|
||||
socket.to(roomId).emit('game-player-left', {
|
||||
username: socket.user.username,
|
||||
userId: socket.user.userId
|
||||
});
|
||||
|
||||
// Get updated player list and broadcast
|
||||
if (dbRoomId) {
|
||||
try {
|
||||
const players = await gameRoomService.getRoomPlayers(dbRoomId);
|
||||
io.to(roomId).emit('game-players-updated', { players });
|
||||
} catch (err) {
|
||||
console.log('Room may have been deleted on disconnect:', err.message);
|
||||
}
|
||||
// If spectator, just notify and leave
|
||||
if (socket.isSpectator) {
|
||||
socket.to(roomId).emit('game-spectator-left', {
|
||||
username: socket.user.username
|
||||
});
|
||||
console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast updated rooms list
|
||||
broadcastRoomsList(io);
|
||||
// Regular player disconnect
|
||||
socket.to(roomId).emit('game-player-left', {
|
||||
username: socket.user.username,
|
||||
userId: socket.user.userId
|
||||
});
|
||||
|
||||
// Get updated player list and broadcast
|
||||
if (dbRoomId) {
|
||||
try {
|
||||
const players = await gameRoomService.getRoomPlayers(dbRoomId);
|
||||
io.to(roomId).emit('game-players-updated', { players });
|
||||
} catch (err) {
|
||||
console.log('Room may have been deleted on disconnect:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if game should auto-stop due to single player
|
||||
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
|
||||
|
||||
// Broadcast updated rooms list
|
||||
broadcastRoomsList(io);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -569,7 +980,8 @@ function setupSocketIO(io)
|
||||
|
||||
// ── Helpers tetris duel ──────────────────────────────────────────────────
|
||||
|
||||
function _tetrisLeave(socket) {
|
||||
function _tetrisLeave(socket)
|
||||
{
|
||||
const code = socket.tetrisRoomCode;
|
||||
if (!code) return;
|
||||
const room = tetrisRooms.get(code);
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -2,12 +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 { 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
|
||||
@@ -19,6 +21,7 @@ class App {
|
||||
this.initMenu();
|
||||
this.initPage();
|
||||
this.initEasterEgg();
|
||||
this.colorizeUI();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +33,8 @@ class App {
|
||||
new AvatarWindow();
|
||||
new FriendsWindow();
|
||||
new GameRoomWindow();
|
||||
new StatsWindow();
|
||||
new LogoutWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +52,8 @@ class App {
|
||||
'login': 'login',
|
||||
'chat': 'chat',
|
||||
'avatar': 'avatar',
|
||||
'friends': 'friends'
|
||||
'friends': 'friends',
|
||||
'logout': 'logout'
|
||||
};
|
||||
|
||||
// Event delegation on the menu
|
||||
@@ -69,14 +75,9 @@ class App {
|
||||
initPage() {
|
||||
const page = document.querySelector('.page');
|
||||
if (!page) {
|
||||
console.warn('Page not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMap = {
|
||||
'gameroom': 'gameroom'
|
||||
};
|
||||
|
||||
// Event delegation on the menu
|
||||
page.addEventListener('click', (e) => {
|
||||
const button = e.target.closest('.page__item');
|
||||
@@ -84,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;
|
||||
}
|
||||
|
||||
@@ -104,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
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
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',
|
||||
@@ -23,17 +25,23 @@ export const API = {
|
||||
},
|
||||
ROOMS: {
|
||||
LIST: '/api/rooms',
|
||||
PLAYING: '/api/rooms/playing',
|
||||
CREATE: '/api/rooms',
|
||||
GET: (id) => `/api/rooms/${id}`,
|
||||
PLAYERS: (id) => `/api/rooms/${id}/players`,
|
||||
JOIN: (id) => `/api/rooms/${id}/join`,
|
||||
LEAVE: (id) => `/api/rooms/${id}/leave`,
|
||||
SPECTATE: (id) => `/api/rooms/${id}/spectate`,
|
||||
LEAVE_SPECTATE: (id) => `/api/rooms/${id}/leave-spectate`,
|
||||
CURRENT: '/api/rooms/current'
|
||||
},
|
||||
STATS: {
|
||||
ME: '/api/stats/me',
|
||||
USER: (username) => `/api/stats/user/${username}`,
|
||||
LEADERBOARD: '/api/stats/leaderboard'
|
||||
LEADERBOARD: '/api/stats/leaderboard',
|
||||
TETRIS_SCORE: '/api/stats/tetris/score',
|
||||
TETRIS_LEADERBOARD_SCORE: '/api/stats/tetris/leaderboard/score',
|
||||
TETRIS_LEADERBOARD_WINS: '/api/stats/tetris/leaderboard/wins'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,11 +53,13 @@ class EventBus {
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (this.listeners.has(event)) {
|
||||
const listeners = this.listeners.get(event);
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in listener for "${event}":`, error);
|
||||
}
|
||||
catch (err) {
|
||||
// Show that some events are not fully handled, but don't break the app
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -80,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)
|
||||
|
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();
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 994 B |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1000 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
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>
|
||||
@@ -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 {
|
||||
@@ -531,6 +614,76 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS WINDOW
|
||||
============================================ */
|
||||
.stats-window {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EASTER EGG BUTTON
|
||||
============================================ */
|
||||
@@ -680,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,126 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// RENDU
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const CELL = 30;
|
||||
const COLORS = ['#070712','#a855f7','#f97316','#3b82f6','#06b6d4','#ef4444','#22c55e','#eab308','#555577'];
|
||||
|
||||
const ctxMain = document.getElementById('canvas-main').getContext('2d');
|
||||
const ctxNext = document.getElementById('canvas-next').getContext('2d');
|
||||
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
|
||||
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
|
||||
|
||||
function drawCell(ctx, x, y, colorIndex, size) {
|
||||
const p = 1;
|
||||
ctx.fillStyle = COLORS[colorIndex];
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
|
||||
// Highlight
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 3);
|
||||
ctx.fillRect(x * size + p, y * size + p, 3, size - p * 2);
|
||||
// Ombre
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fillRect(x * size + p, (y + 1) * size - p - 3, size - p * 2, 3);
|
||||
ctx.fillRect((x + 1) * size - p - 3, y * size + p, 3, size - p * 2);
|
||||
}
|
||||
|
||||
function clearCanvas(ctx, w, h) {
|
||||
ctx.fillStyle = '#070712';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawGridLines(ctx, cols, rows, size) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= cols; x++) {
|
||||
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawGhost(ctx, piece, grid) {
|
||||
if (!piece) return;
|
||||
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
|
||||
const shape = piece.getShape();
|
||||
|
||||
while (true) {
|
||||
ghost.y++;
|
||||
let valid = true;
|
||||
for (let row = 0; row < shape.length && valid; row++)
|
||||
for (let col = 0; col < shape[row].length && valid; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const ny = ghost.y + row;
|
||||
const nx = ghost.x + col;
|
||||
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
|
||||
}
|
||||
if (!valid) { ghost.y--; break; }
|
||||
}
|
||||
|
||||
if (ghost.y === piece.getPosition().y) return;
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
ctx.strokeRect(
|
||||
(ghost.x + col) * CELL + 2,
|
||||
(ghost.y + row) * CELL + 2,
|
||||
CELL - 4, CELL - 4
|
||||
);
|
||||
}
|
||||
|
||||
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
|
||||
clearCanvas(ctx, canvasW, canvasH);
|
||||
if (!piece) return;
|
||||
const shape = piece.getShape();
|
||||
const color = piece.getColor();
|
||||
const s = 20;
|
||||
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
|
||||
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctx, offsetX + col, offsetY + row, color, s);
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Grille principale
|
||||
clearCanvas(ctxMain, 300, 600);
|
||||
drawGridLines(ctxMain, 10, 20, CELL);
|
||||
|
||||
for (let y = 0; y < game.grid.length; y++)
|
||||
for (let x = 0; x < game.grid[y].length; x++)
|
||||
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();
|
||||
const shape = game.currentPiece.getShape();
|
||||
const color = game.currentPiece.getColor();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctxMain, x + col, y + row, color, CELL);
|
||||
}
|
||||
|
||||
// Panneaux miniatures
|
||||
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
|
||||
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
|
||||
|
||||
// Score
|
||||
document.getElementById('score-display').textContent = game.score;
|
||||
}
|
||||
|
||||
function renderOpponent(opponentGrid) {
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
:root {
|
||||
--bg: #070712;
|
||||
--panel: #0d0d1f;
|
||||
--border: #1a1a3e;
|
||||
--accent: #00ffe7;
|
||||
--accent2:#ff00aa;
|
||||
--dim: #3a3a6a;
|
||||
--text: #c0c0e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,255,231,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,255,231,0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-weight: 900;
|
||||
font-size: 2.2rem;
|
||||
letter-spacing: 0.4em;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 20px var(--accent), 0 0 40px var(--accent);
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Zone de jeu globale ── */
|
||||
#game-area {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Section locale ── */
|
||||
#local-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Section adversaire ── */
|
||||
#opponent-section {
|
||||
display: none; /* masqué jusqu'à connexion duel */
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#opponent-section.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.opponent-info-panel {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
/* ── Panneaux ── */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
width: 130px;
|
||||
box-shadow: 0 0 20px rgba(0,255,231,0.05);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
canvas { display: block; border-radius: 4px; }
|
||||
|
||||
#canvas-main {
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 0 30px rgba(0,255,231,0.08), inset 0 0 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#canvas-next, #canvas-hold {
|
||||
border: 1px solid var(--border);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Canvas adversaire ── */
|
||||
#canvas-opponent {
|
||||
border: 1px solid var(--accent2);
|
||||
box-shadow: 0 0 30px rgba(255,0,170,0.08), inset 0 0 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ── Score ── */
|
||||
.score-block {
|
||||
margin-top: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
/* ── Boutons ── */
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 8px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#btn-start {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#btn-start:hover:not(:disabled)
|
||||
{
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 0 15px var(--accent);
|
||||
}
|
||||
|
||||
#btn-restart {
|
||||
color: var(--accent2);
|
||||
border-color: var(--accent2);
|
||||
}
|
||||
|
||||
#btn-restart:hover:not(:disabled){
|
||||
background: var(--accent2);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 0 15px var(--accent2);
|
||||
}
|
||||
|
||||
#btn-pause {
|
||||
color: var(--accent2);
|
||||
border-color: var(--accent2);
|
||||
}
|
||||
#btn-pause:hover:not(:disabled) {
|
||||
background: var(--accent2);
|
||||
color: var(--bg); box-shadow: 0 0 15px var(--accent2);
|
||||
}
|
||||
|
||||
#btn-stop { color: #ef4444; border-color: #ef4444; }
|
||||
#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; }
|
||||
|
||||
button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* ── Contrôles ── */
|
||||
.controls-list {
|
||||
margin-top: 14px;
|
||||
font-size: 0.6rem;
|
||||
line-height: 2;
|
||||
color: var(--dim);
|
||||
}
|
||||
.controls-list span { color: var(--text); }
|
||||
|
||||
/* ── Overlays ── */
|
||||
#main-wrapper,
|
||||
#opponent-wrapper { position: relative; }
|
||||
|
||||
#overlay,
|
||||
#overlay-opponent {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
background: rgba(7,7,18,0.88);
|
||||
border-radius: 4px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
#overlay.visible,
|
||||
#overlay-opponent.visible { display: flex; }
|
||||
|
||||
#overlay-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent2);
|
||||
text-shadow: 0 0 20px var(--accent2);
|
||||
}
|
||||
|
||||
#overlay-score {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#overlay-opponent-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 20px var(--accent);
|
||||
}
|
||||
|
||||
#overlay-opponent-score {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
/* ── Panneau duel ── */
|
||||
#duel-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 0 20px rgba(255,0,170,0.04);
|
||||
}
|
||||
|
||||
.duel-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#input-room-code {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--accent2);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
padding: 6px 10px;
|
||||
width: 120px;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
#input-room-code:focus {
|
||||
border-color: var(--accent2);
|
||||
box-shadow: 0 0 8px rgba(255,0,170,0.2);
|
||||
}
|
||||
|
||||
#btn-join-duel { color: var(--accent2); border-color: var(--accent2); width: auto; padding: 6px 14px; }
|
||||
#btn-join-duel:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 12px var(--accent2); }
|
||||
|
||||
#btn-leave-duel { color: #ef4444; border-color: #ef4444; width: auto; padding: 6px 14px; }
|
||||
#btn-leave-duel:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 12px #ef4444; }
|
||||
|
||||
#duel-status {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
min-width: 120px;
|
||||
}
|
||||
#duel-status.waiting { color: #f97316; }
|
||||
#duel-status.ready { color: var(--accent); }
|
||||
|
||||
/* ── Colonne gauche (panel + settings empilés) ── */
|
||||
#left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Settings Panel ── */
|
||||
#settings-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 0 20px rgba(0,255,231,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.55rem;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.65rem;
|
||||
padding: 4px 8px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"]:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px rgba(0,255,231,0.2);
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"]:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// LOGIQUE TETRIS
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
class Tetris {
|
||||
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) {
|
||||
this.onRender = onRender;
|
||||
this.onGameOver = onGameOver;
|
||||
this.onBlockPlaced = onBlockPlaced;
|
||||
this.onLinesCleared = onLinesCleared;
|
||||
|
||||
this.grid = this._createGrid(10, 20);
|
||||
this.bufferGrid = this._createGrid(10, 5);
|
||||
this.currentPiece = null;
|
||||
this.storedPiece = null;
|
||||
this.nextPiece = null;
|
||||
|
||||
this.score = 0;
|
||||
this.initialTimeToDown = 1000;
|
||||
this.timeToDown = 1000;
|
||||
this.hardening = 1000;
|
||||
this.count = 0;
|
||||
this.decrementTTD = 100;
|
||||
|
||||
this.lastLandingCol = 4;
|
||||
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
this.canStore = true;
|
||||
|
||||
this.animationFrameId = null;
|
||||
this.lastTime = 0;
|
||||
this.accumulator = 0;
|
||||
|
||||
this._keyHandler = this._handleKey.bind(this);
|
||||
}
|
||||
|
||||
configure({ timeToDown, hardening, decrementTTD }) {
|
||||
if (timeToDown !== undefined) this.initialTimeToDown = this.timeToDown = timeToDown;
|
||||
if (hardening !== undefined) this.hardening = hardening;
|
||||
if (decrementTTD !== undefined) this.decrementTTD = decrementTTD;
|
||||
}
|
||||
|
||||
_createGrid(w, h) {
|
||||
return Array.from({ length: h }, () => Array(w).fill(0));
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.isPaused = false;
|
||||
this.grid = this._createGrid(10, 20);
|
||||
this.score = 0;
|
||||
this.count = 0;
|
||||
this.timeToDown = this.initialTimeToDown;
|
||||
this.storedPiece = null;
|
||||
this.canStore = true;
|
||||
this._spawnNewPiece();
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
this._startGameLoop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.accumulator = 0;
|
||||
this.lastTime = 0;
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (!this.isRunning) return;
|
||||
this.isPaused = !this.isPaused;
|
||||
if (!this.isPaused) {
|
||||
this.lastTime = 0;
|
||||
this._startGameLoop();
|
||||
}
|
||||
}
|
||||
|
||||
_startGameLoop() {
|
||||
this.lastTime = 0;
|
||||
this.accumulator = 0;
|
||||
|
||||
const gameLoop = (currentTime) => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
if (this.isPaused) {
|
||||
this.animationFrameId = requestAnimationFrame(gameLoop);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastTime === 0) {
|
||||
this.lastTime = currentTime;
|
||||
this.animationFrameId = requestAnimationFrame(gameLoop);
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = currentTime - this.lastTime;
|
||||
this.lastTime = currentTime;
|
||||
this.accumulator += deltaTime;
|
||||
|
||||
while (this.isRunning && this.accumulator >= this.timeToDown) {
|
||||
this._tick();
|
||||
this.accumulator -= this.timeToDown;
|
||||
if (this.accumulator > this.timeToDown * 3) {
|
||||
this.accumulator = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.onRender();
|
||||
this.animationFrameId = requestAnimationFrame(gameLoop);
|
||||
};
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this.currentPiece) return;
|
||||
if (this._canMoveDown()) {
|
||||
this.currentPiece.moveDown();
|
||||
} else {
|
||||
this._lockPiece();
|
||||
this.verifierLignes();
|
||||
this._makeHarder();
|
||||
this._spawnNewPiece();
|
||||
this.canStore = true;
|
||||
if (!this._canSpawn()) this._gameOver(true);
|
||||
}
|
||||
}
|
||||
|
||||
_handleKey(e) {
|
||||
if (!this.isRunning || !this.currentPiece) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused && this._canMoveLeft()) this.currentPiece.moveLeft();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused && this._canMoveRight()) this.currentPiece.moveRight();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused && this._canMoveDown()) {
|
||||
this.currentPiece.moveDown();
|
||||
this.score += 1;
|
||||
this.accumulator = 0;
|
||||
}
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._hardDrop();
|
||||
break;
|
||||
case 'q': case 'Q':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._rotatePiece(-1);
|
||||
break;
|
||||
case 'w': case 'W':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._rotatePiece(1);
|
||||
break;
|
||||
case 'c': case 'C':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._storePiece();
|
||||
break;
|
||||
}
|
||||
|
||||
this.onRender();
|
||||
}
|
||||
|
||||
_hardDrop() {
|
||||
if (!this.currentPiece) return;
|
||||
let dist = 0;
|
||||
while (this._canMoveDown()) { this.currentPiece.moveDown(); dist++; }
|
||||
this.score += dist * 2;
|
||||
this._lockPiece();
|
||||
this.verifierLignes();
|
||||
this._makeHarder();
|
||||
this._spawnNewPiece();
|
||||
this.canStore = true;
|
||||
this.accumulator = 0;
|
||||
if (!this._canSpawn()) this._gameOver(true);
|
||||
}
|
||||
|
||||
_rotatePiece(direction) {
|
||||
if (!this.currentPiece) return;
|
||||
const originalPos = { ...this.currentPiece.getPosition() };
|
||||
|
||||
if (direction === -1) this.currentPiece.rotateLeft();
|
||||
else this.currentPiece.rotateRight();
|
||||
|
||||
if (!this._isValidPosition()) {
|
||||
this.currentPiece.moveRight();
|
||||
if (this._isValidPosition()) return;
|
||||
|
||||
this.currentPiece.moveLeft();
|
||||
this.currentPiece.moveLeft();
|
||||
if (this._isValidPosition()) return;
|
||||
|
||||
this.currentPiece.moveLeft();
|
||||
if (this._isValidPosition()) return;
|
||||
|
||||
this.currentPiece.moveRight();
|
||||
this.currentPiece.moveRight();
|
||||
this.currentPiece.position.y--;
|
||||
if (this._isValidPosition()) return;
|
||||
|
||||
this.currentPiece.position.y = originalPos.y;
|
||||
this.currentPiece.position.x = originalPos.x;
|
||||
if (direction === -1) this.currentPiece.rotateRight();
|
||||
else this.currentPiece.rotateLeft();
|
||||
}
|
||||
}
|
||||
|
||||
_storePiece() {
|
||||
if (!this.canStore || !this.currentPiece) return;
|
||||
|
||||
if (this.storedPiece === null) {
|
||||
this.storedPiece = this.currentPiece;
|
||||
this._spawnNewPiece();
|
||||
} else {
|
||||
const temp = this.storedPiece;
|
||||
this.storedPiece = this.currentPiece;
|
||||
this.currentPiece = temp;
|
||||
this.currentPiece.position.x = 3;
|
||||
this.currentPiece.position.y = 0;
|
||||
}
|
||||
this.canStore = false;
|
||||
this.accumulator = 0;
|
||||
}
|
||||
|
||||
_spawnNewPiece() {
|
||||
this.currentPiece = this.nextPiece || this._createRandomPiece();
|
||||
this.nextPiece = this._createRandomPiece();
|
||||
this._updateBufferGrid();
|
||||
}
|
||||
|
||||
_createRandomPiece() {
|
||||
const types = [PieceT, PieceL, PieceReverseL, PieceI, PieceZ, PieceReverseZ, PieceO];
|
||||
return new types[Math.floor(Math.random() * types.length)](3, 0);
|
||||
}
|
||||
|
||||
_updateBufferGrid() {
|
||||
this.bufferGrid = this._createGrid(10, 5);
|
||||
if (!this.nextPiece) return;
|
||||
const shape = this.nextPiece.getShape();
|
||||
const offsetX = Math.floor((10 - shape[0].length) / 2);
|
||||
for (let y = 0; y < shape.length; y++)
|
||||
for (let x = 0; x < shape[y].length; x++)
|
||||
if (shape[y][x] !== 0)
|
||||
this.bufferGrid[y + 1][x + offsetX] = this.nextPiece.getColor();
|
||||
}
|
||||
|
||||
verifierLignes() {
|
||||
let cleared = 0;
|
||||
for (let y = this.grid.length - 1; y >= 0; y--) {
|
||||
if (this.grid[y].every(c => c !== 0)) {
|
||||
this.grid.splice(y, 1);
|
||||
this.grid.unshift(Array(10).fill(0));
|
||||
cleared++;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
const points = [0, 100, 300, 500, 800];
|
||||
this.score += points[cleared];
|
||||
this.count += points[cleared];
|
||||
if (this.onLinesCleared && cleared > 0)
|
||||
this.onLinesCleared(cleared, this.lastLandingCol);
|
||||
}
|
||||
|
||||
_makeHarder() {
|
||||
if (this.count >= this.hardening) {
|
||||
this.count = 0;
|
||||
this.timeToDown = Math.max(100, this.timeToDown - this.decrementTTD);
|
||||
}
|
||||
}
|
||||
|
||||
_canMoveDown() {
|
||||
if (!this.currentPiece) return false;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const ny = y + row + 1;
|
||||
const nx = x + col;
|
||||
if (ny < 0) continue; // encore au-dessus de la grille
|
||||
if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_canMoveLeft() {
|
||||
if (!this.currentPiece) return false;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
if (y + row < 0) continue; // au-dessus de la grille
|
||||
const nx = x + col - 1;
|
||||
if (nx < 0 || this.grid[y + row][nx] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_canMoveRight() {
|
||||
if (!this.currentPiece) return false;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
if (y + row < 0) continue; // au-dessus de la grille
|
||||
const nx = x + col + 1;
|
||||
if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_isValidPosition() {
|
||||
if (!this.currentPiece) return false;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const gx = x + col;
|
||||
const gy = y + row;
|
||||
if (gx < 0 || gx >= this.grid[0].length ||
|
||||
gy < 0 || gy >= this.grid.length ||
|
||||
this.grid[gy][gx] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_canSpawn() { return this._isValidPosition(); }
|
||||
|
||||
_lockPiece() {
|
||||
if (!this.currentPiece) return;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
const color = this.currentPiece.getColor();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0 && y + row >= 0)
|
||||
this.grid[y + row][x + col] = color;
|
||||
this.lastLandingCol = x + Math.floor(shape[0].length / 2);
|
||||
if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r]));
|
||||
}
|
||||
|
||||
addGarbageLines(lines) {
|
||||
if (!this.isRunning || !lines.length) return;
|
||||
this.grid.splice(0, lines.length);
|
||||
for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante
|
||||
// La grille a remonté de lines.length lignes — on remonte la pièce du même décalage
|
||||
// pour qu'elle reste dans la même position relative aux blocs verrouillés.
|
||||
if (this.currentPiece) {
|
||||
this.currentPiece.position.y -= lines.length;
|
||||
}
|
||||
if (this.grid[0].some(c => c !== 0)) { this._gameOver(false); return; }
|
||||
if (!this._isValidPositionAllowTop()) this._gameOver(false);
|
||||
}
|
||||
|
||||
// Comme _isValidPosition mais tolère gy < 0 (zone tampon au-dessus de la grille après garbage)
|
||||
_isValidPositionAllowTop() {
|
||||
if (!this.currentPiece) return true;
|
||||
const { x, y } = this.currentPiece.getPosition();
|
||||
const shape = this.currentPiece.getShape();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const gy = y + row;
|
||||
const gx = x + col;
|
||||
if (gy < 0) continue; // au-dessus de la grille : OK
|
||||
if (gx < 0 || gx >= this.grid[0].length ||
|
||||
gy >= this.grid.length ||
|
||||
this.grid[gy][gx] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_gameOver(validBlock = false) {
|
||||
}
|
||||
}
|
||||
_gameOver(validBlock = false) {
|
||||
this.stop();
|
||||
this.onGameOver(this.score, validBlock);
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.stop();
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
@@ -3,17 +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.roomCode = null;
|
||||
this.isReady = false;
|
||||
this.action_queue = [];
|
||||
this.opponentGrid = this._emptyGrid();
|
||||
this.opponentScore = 0;
|
||||
this.opponentShieldActive = false;
|
||||
this.roomCode = null;
|
||||
this.isReady = false;
|
||||
|
||||
this._bindSocketEvents();
|
||||
}
|
||||
@@ -33,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 ──────────
|
||||
@@ -48,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 });
|
||||
}
|
||||
|
||||
@@ -60,6 +62,12 @@ class Duel {
|
||||
this.endDuel();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
endDuel() {
|
||||
this.isReady = false;
|
||||
this.action_queue = [];
|
||||
@@ -70,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,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':
|
||||
@@ -89,9 +96,17 @@ class Duel {
|
||||
break;
|
||||
|
||||
case 'OPPONENT_GAME_OVER':
|
||||
showOverlay('YOU WIN', action.score);
|
||||
this.ui.showOverlay('YOU WIN', action.score);
|
||||
this.endDuel();
|
||||
break;
|
||||
|
||||
case 'OPPONENT_SHIELD_ACTIVATED':
|
||||
this.opponentShieldActive = true;
|
||||
break;
|
||||
|
||||
case 'OPPONENT_SHIELD_DEACTIVATED':
|
||||
this.opponentShieldActive = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,28 +142,36 @@ class Duel {
|
||||
this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:shield-activated', () => {
|
||||
this.action_queue.push({ type: 'OPPONENT_SHIELD_ACTIVATED' });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:shield-deactivated', () => {
|
||||
this.action_queue.push({ type: 'OPPONENT_SHIELD_DEACTIVATED' });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:start-duel', () => {
|
||||
if (this.onStart) this.onStart();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
@@ -0,0 +1,228 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// RENDU
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const CELL = 30;
|
||||
|
||||
const THEMES = {
|
||||
green: {
|
||||
bg: '#000500', panel: '#000d00', border: '#004400',
|
||||
accent: '#00ff41', accent2: '#39ff14', dim: '#1a5c1a', text: '#00cc26',
|
||||
grid: 'rgba(0,255,65,0.06)', ghost: 'rgba(0,255,65,0.25)', highlight: 'rgba(200,255,200,0.2)',
|
||||
colors: ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d']
|
||||
},
|
||||
red: {
|
||||
bg: '#050000', panel: '#0d0000', border: '#440000',
|
||||
accent: '#ff1744', accent2: '#ff4569', dim: '#5c1a1a', text: '#cc2626',
|
||||
grid: 'rgba(255,23,68,0.06)', ghost: 'rgba(255,23,68,0.25)', highlight: 'rgba(255,200,200,0.2)',
|
||||
colors: ['#050000','#ff1744','#ff4569','#e53935','#ff6d00','#ff8a65','#ff5252','#ff6e40','#5a2d2d']
|
||||
},
|
||||
yellow: {
|
||||
bg: '#050500', panel: '#0d0d00', border: '#444400',
|
||||
accent: '#ffd600', accent2: '#ffea00', dim: '#5c5c1a', text: '#ccaa00',
|
||||
grid: 'rgba(255,214,0,0.06)', ghost: 'rgba(255,214,0,0.25)', highlight: 'rgba(255,255,200,0.2)',
|
||||
colors: ['#050500','#ffd600','#ffea00','#ffab00','#fff176','#ffe57f','#ffff00','#ffc400','#5a5a2d']
|
||||
},
|
||||
blue: {
|
||||
bg: '#000005', panel: '#00000d', border: '#000044',
|
||||
accent: '#00b0ff', accent2: '#40c4ff', dim: '#1a1a5c', text: '#2626cc',
|
||||
grid: 'rgba(0,176,255,0.06)', ghost: 'rgba(0,176,255,0.25)', highlight: 'rgba(200,200,255,0.2)',
|
||||
colors: ['#000005','#00b0ff','#40c4ff','#0091ea','#448aff','#82b1ff','#00e5ff','#2979ff','#2d2d5a']
|
||||
}
|
||||
};
|
||||
|
||||
let currentTheme = THEMES.green;
|
||||
let COLORS = [...currentTheme.colors];
|
||||
|
||||
function setColorTheme(themeName) {
|
||||
currentTheme = THEMES[themeName] || THEMES.green;
|
||||
COLORS = [...currentTheme.colors];
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--bg', currentTheme.bg);
|
||||
root.style.setProperty('--panel', currentTheme.panel);
|
||||
root.style.setProperty('--border', currentTheme.border);
|
||||
root.style.setProperty('--accent', currentTheme.accent);
|
||||
root.style.setProperty('--accent2', currentTheme.accent2);
|
||||
root.style.setProperty('--dim', currentTheme.dim);
|
||||
root.style.setProperty('--text', currentTheme.text);
|
||||
localStorage.setItem('tetris-theme', themeName);
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === themeName);
|
||||
});
|
||||
}
|
||||
|
||||
const ctxMain = document.getElementById('canvas-main').getContext('2d');
|
||||
const ctxNext = document.getElementById('canvas-next').getContext('2d');
|
||||
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
|
||||
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
|
||||
|
||||
function drawCell(ctx, x, y, colorIndex, size) {
|
||||
const p = 1;
|
||||
const color = COLORS[colorIndex];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
function clearCanvas(ctx, w, h) {
|
||||
ctx.fillStyle = currentTheme.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawGridLines(ctx, cols, rows, size) {
|
||||
ctx.strokeStyle = currentTheme.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= cols; x++) {
|
||||
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawGhost(ctx, piece, grid) {
|
||||
if (!piece) return;
|
||||
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
|
||||
const shape = piece.getShape();
|
||||
|
||||
while (true) {
|
||||
ghost.y++;
|
||||
let valid = true;
|
||||
for (let row = 0; row < shape.length && valid; row++)
|
||||
for (let col = 0; col < shape[row].length && valid; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const ny = ghost.y + row;
|
||||
const nx = ghost.x + col;
|
||||
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
|
||||
}
|
||||
if (!valid) { ghost.y--; break; }
|
||||
}
|
||||
|
||||
if (ghost.y === piece.getPosition().y) return;
|
||||
|
||||
ctx.strokeStyle = currentTheme.ghost;
|
||||
ctx.lineWidth = 1;
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
ctx.strokeRect(
|
||||
(ghost.x + col) * CELL + 2,
|
||||
(ghost.y + row) * CELL + 2,
|
||||
CELL - 4, CELL - 4
|
||||
);
|
||||
}
|
||||
|
||||
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
|
||||
clearCanvas(ctx, canvasW, canvasH);
|
||||
if (!piece) return;
|
||||
const shape = piece.getShape();
|
||||
const color = piece.getColor();
|
||||
const s = 20;
|
||||
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
|
||||
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctx, offsetX + col, offsetY + row, color, s);
|
||||
}
|
||||
|
||||
function _drawShieldOverlay(ctx, w, h, alpha) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = `rgba(0,212,255,${alpha})`;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowColor = '#00d4ff';
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.strokeRect(2, 2, w - 4, h - 4);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
for (let y = 0; y < game.grid.length; y++)
|
||||
for (let x = 0; x < game.grid[y].length; x++)
|
||||
if (game.grid[y][x] !== 0)
|
||||
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
|
||||
|
||||
if (game.currentPiece) {
|
||||
drawGhost(ctxMain, game.currentPiece, game.grid);
|
||||
const { x, y } = game.currentPiece.getPosition();
|
||||
const shape = game.currentPiece.getShape();
|
||||
const color = game.currentPiece.getColor();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctxMain, x + col, y + row, color, CELL);
|
||||
}
|
||||
|
||||
if (game.shieldActive) {
|
||||
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
|
||||
_drawShieldOverlay(ctxMain, 300, 600, pulse);
|
||||
}
|
||||
|
||||
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
|
||||
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
|
||||
|
||||
document.getElementById('score-display').textContent = game.score;
|
||||
|
||||
const shieldEl = document.getElementById('shield-status-display');
|
||||
const shieldBar = document.getElementById('shield-bar');
|
||||
if (shieldEl) {
|
||||
if (game.shieldActive) {
|
||||
const secs = Math.ceil(game.shieldActiveMs / 1000);
|
||||
shieldEl.textContent = `ACTIF ${secs}s`;
|
||||
shieldEl.className = 'score-value shield-active';
|
||||
if (shieldBar) shieldBar.style.width = (game.shieldActiveMs / 3000 * 100) + '%';
|
||||
} else if (game.shieldReady) {
|
||||
shieldEl.textContent = 'PRÊT';
|
||||
shieldEl.className = 'score-value shield-ready';
|
||||
if (shieldBar) shieldBar.style.width = '100%';
|
||||
} else {
|
||||
const secs = Math.ceil(game.shieldCooldownMs / 1000);
|
||||
shieldEl.textContent = `${secs}s`;
|
||||
shieldEl.className = 'score-value shield-cooldown';
|
||||
if (shieldBar) shieldBar.style.width = ((1 - game.shieldCooldownMs / 60000) * 100) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 < 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);
|
||||
|
||||
if (shieldActive) {
|
||||
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
|
||||
_drawShieldOverlay(ctxOpponent, 300, 600, pulse);
|
||||
}
|
||||
|
||||
const oppShieldEl = document.getElementById('opponent-shield-indicator');
|
||||
if (oppShieldEl) oppShieldEl.style.display = shieldActive ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Restaure le thème sauvegardé
|
||||
(function() {
|
||||
const saved = localStorage.getItem('tetris-theme');
|
||||
if (saved && THEMES[saved]) setColorTheme(saved);
|
||||
})();
|
||||
@@ -0,0 +1,686 @@
|
||||
:root {
|
||||
--bg: #000500;
|
||||
--panel: #000d00;
|
||||
--border: #004400;
|
||||
--accent: #00ff41;
|
||||
--accent2:#39ff14;
|
||||
--dim: #1a5c1a;
|
||||
--text: #00cc26;
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 89%, 91%, 93%, 95%, 100% { opacity: 1; }
|
||||
90%, 92%, 94% { opacity: 0.82; }
|
||||
}
|
||||
|
||||
@keyframes glitch-before {
|
||||
0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); }
|
||||
5% { clip-path: polygon(0 15%, 100% 15%, 100% 25%, 0 25%); transform: translate(-4px, 0); color: #ff003c; }
|
||||
10% { clip-path: polygon(0 60%, 100% 60%, 100% 70%, 0 70%); transform: translate(4px, 0); color: #ff003c; }
|
||||
15%, 85% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); }
|
||||
90% { clip-path: polygon(0 40%, 100% 40%, 100% 55%, 0 55%); transform: translate(-3px, 0); color: #ff003c; }
|
||||
}
|
||||
|
||||
@keyframes glitch-after {
|
||||
0%, 100% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); }
|
||||
5% { clip-path: polygon(0 70%, 100% 70%, 100% 80%, 0 80%); transform: translate(4px, 0); color: #00ffff; }
|
||||
10% { clip-path: polygon(0 30%, 100% 30%, 100% 45%, 0 45%); transform: translate(-4px, 0); color: #00ffff; }
|
||||
15%, 85% { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); transform: translate(0); }
|
||||
90% { clip-path: polygon(0 10%, 100% 10%, 100% 25%, 0 25%); transform: translate(3px, 0); color: #00ffff; }
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 0 100%; }
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
animation: flicker 8s infinite;
|
||||
}
|
||||
|
||||
#scale-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* transform et margin-bottom gérés par JS */
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,255,65,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,255,65,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Scanlines CRT */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.12) 2px,
|
||||
rgba(0, 0, 0, 0.12) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-weight: 900;
|
||||
font-size: 2.2rem;
|
||||
letter-spacing: 0.4em;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px var(--accent), 0 0 30px var(--accent), 0 0 60px rgba(0,255,65,0.4);
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%;
|
||||
color: var(--accent);
|
||||
animation: glitch-before 6s infinite;
|
||||
}
|
||||
|
||||
h1::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%;
|
||||
color: var(--accent);
|
||||
animation: glitch-after 6s infinite;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Zone de jeu globale ── */
|
||||
#game-area {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Section locale ── */
|
||||
#local-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Section adversaire ── */
|
||||
#opponent-section {
|
||||
display: none; /* masqué jusqu'à connexion duel */
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#opponent-section.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.opponent-info-panel {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
/* ── Panneaux ── */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding: 14px;
|
||||
width: 130px;
|
||||
box-shadow: 0 0 20px rgba(0,255,65,0.07), inset 0 0 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
canvas { display: block; border-radius: 0; }
|
||||
|
||||
#canvas-main {
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 0 20px rgba(0,255,65,0.15), 0 0 40px rgba(0,255,65,0.06), inset 0 0 30px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
#canvas-next, #canvas-hold {
|
||||
border: 1px solid var(--border);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Canvas adversaire ── */
|
||||
#canvas-opponent {
|
||||
border: 1px solid var(--accent2);
|
||||
box-shadow: 0 0 20px rgba(57,255,20,0.12), inset 0 0 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ── Score ── */
|
||||
.score-block {
|
||||
margin-top: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
/* ── Boutons ── */
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
#btn-home {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 8px;
|
||||
border: 1px solid;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#btn-start {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#btn-start:hover:not(:disabled)
|
||||
{
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 0 15px var(--accent);
|
||||
}
|
||||
|
||||
#btn-restart {
|
||||
color: var(--accent2);
|
||||
border-color: var(--accent2);
|
||||
}
|
||||
|
||||
#btn-restart:hover:not(:disabled){
|
||||
background: var(--accent2);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 0 15px var(--accent2);
|
||||
}
|
||||
|
||||
#btn-pause {
|
||||
color: var(--accent2);
|
||||
border-color: var(--accent2);
|
||||
}
|
||||
#btn-pause:hover:not(:disabled) {
|
||||
background: var(--accent2);
|
||||
color: var(--bg); box-shadow: 0 0 15px var(--accent2);
|
||||
}
|
||||
|
||||
#btn-stop { color: #ef4444; border-color: #ef4444; }
|
||||
#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; }
|
||||
|
||||
button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* ── Contrôles ── */
|
||||
.controls-list {
|
||||
margin-top: 14px;
|
||||
font-size: 0.6rem;
|
||||
line-height: 2;
|
||||
color: var(--dim);
|
||||
}
|
||||
.controls-list span { color: var(--text); }
|
||||
|
||||
/* ── Overlays ── */
|
||||
#main-wrapper,
|
||||
#opponent-wrapper { position: relative; }
|
||||
|
||||
#overlay,
|
||||
#overlay-opponent {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
background: rgba(0,5,0,0.9);
|
||||
border-radius: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
#overlay.visible,
|
||||
#overlay-opponent.visible { display: flex; }
|
||||
|
||||
#overlay-title {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2em;
|
||||
color: #ff003c;
|
||||
text-shadow: 0 0 20px #ff003c, 0 0 40px #ff003c;
|
||||
}
|
||||
|
||||
#overlay-score {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
#overlay-opponent-title {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 20px var(--accent);
|
||||
}
|
||||
|
||||
#overlay-opponent-score {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
/* ── Panneau duel ── */
|
||||
#duel-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 0 20px rgba(0,255,65,0.04);
|
||||
}
|
||||
|
||||
.duel-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#input-room-code {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--accent2);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
padding: 6px 10px;
|
||||
width: 120px;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
#input-room-code:focus {
|
||||
border-color: var(--accent2);
|
||||
box-shadow: 0 0 8px rgba(255,0,170,0.2);
|
||||
}
|
||||
|
||||
#btn-join-duel { color: var(--accent2); border-color: var(--accent2); width: auto; padding: 6px 14px; }
|
||||
#btn-join-duel:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 12px var(--accent2); }
|
||||
|
||||
#btn-leave-duel { color: #ef4444; border-color: #ef4444; width: auto; padding: 6px 14px; }
|
||||
#btn-leave-duel:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 12px #ef4444; }
|
||||
|
||||
#duel-status {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
min-width: 120px;
|
||||
}
|
||||
#duel-status.waiting { color: #f97316; }
|
||||
#duel-status.ready { color: var(--accent); }
|
||||
|
||||
/* ── Colonne gauche (panel + settings empilés) ── */
|
||||
#left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Settings Panel ── */
|
||||
#settings-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding: 14px;
|
||||
box-shadow: 0 0 20px rgba(0,255,65,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.55rem;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Theme color picker ── */
|
||||
.theme-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.theme-btn[data-theme="green"] { background: #00ff41; }
|
||||
.theme-btn[data-theme="red"] { background: #ff1744; }
|
||||
.theme-btn[data-theme="yellow"] { background: #ffd600; }
|
||||
.theme-btn[data-theme="blue"] { background: #00b0ff; }
|
||||
|
||||
.theme-btn:hover { transform: scale(1.2); }
|
||||
|
||||
.theme-btn.active {
|
||||
border-color: #ffffff;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.65rem;
|
||||
padding: 4px 8px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"]:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px rgba(0,255,231,0.2);
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"]:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Matchmaking ── */
|
||||
#btn-matchmaking, #btn-matchmaking-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent2);
|
||||
border-radius: 4px;
|
||||
color: var(--accent2);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#btn-matchmaking:hover:not(:disabled) {
|
||||
background: rgba(255,0,170,0.15);
|
||||
box-shadow: 0 0 8px rgba(255,0,170,0.3);
|
||||
}
|
||||
|
||||
#btn-matchmaking-cancel {
|
||||
border-color: var(--dim);
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
#btn-matchmaking-cancel:not(:disabled) {
|
||||
border-color: var(--accent2);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
#btn-matchmaking:disabled, #btn-matchmaking-cancel:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#matchmaking-status {
|
||||
font-size: 0.6rem;
|
||||
min-height: 1rem;
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
#matchmaking-status.waiting { color: #ffcc00; }
|
||||
#matchmaking-status.ready { color: var(--accent); }
|
||||
|
||||
/* ── Leaderboards ── */
|
||||
#leaderboard-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
margin: 20px auto 30px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0,255,65,0.05);
|
||||
}
|
||||
|
||||
.leaderboard-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lb-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--dim);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.lb-tab:hover { color: var(--text); }
|
||||
|
||||
.lb-tab--active {
|
||||
color: var(--accent);
|
||||
background: rgba(0,255,65,0.05);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.lb-content { display: none; }
|
||||
.lb-content--active { display: block; }
|
||||
|
||||
.lb-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lb-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--accent);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lb-table td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid rgba(26,26,62,0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lb-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.lb-table tr:hover td {
|
||||
background: rgba(0,255,231,0.03);
|
||||
}
|
||||
|
||||
.lb-table tr.lb-me td {
|
||||
background: rgba(0,255,231,0.07);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lb-you {
|
||||
color: var(--dim);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.lb-table tr.lb-separator td {
|
||||
text-align: center;
|
||||
color: var(--dim);
|
||||
padding: 4px;
|
||||
font-size: 0.6rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lb-table td:first-child {
|
||||
color: var(--dim);
|
||||
font-size: 0.6rem;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.hist-win {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hist-loss {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
body { overflow: hidden; }
|
||||
|
||||
|
||||
/* ── Shield ───────────────────────────────── */
|
||||
.shield-bar-bg {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0,212,255,0.15);
|
||||
border-radius: 2px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shield-bar {
|
||||
height: 100%;
|
||||
background: #00d4ff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
box-shadow: 0 0 6px #00d4ff;
|
||||
}
|
||||
|
||||
.shield-ready { color: #00d4ff !important; }
|
||||
.shield-active { color: #00ffff !important; text-shadow: 0 0 8px #00ffff; }
|
||||
.shield-cooldown { color: var(--dim) !important; }
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.6rem;
|
||||
font-family: inherit;
|
||||
color: var(--dim);
|
||||
}
|
||||
@@ -3,15 +3,21 @@
|
||||
<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>
|
||||
<body>
|
||||
|
||||
<h1>TETRIS</h1>
|
||||
<canvas id="matrix-bg" style="position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;opacity:0.13;"></canvas>
|
||||
|
||||
<!-- Panneau de connexion duel -->
|
||||
<div id="scale-container">
|
||||
|
||||
<h1 data-text="TETRIS">TETRIS<span class="cursor">_</span></h1>
|
||||
|
||||
<a id="btn-home" href="../index.html">Home</a>
|
||||
|
||||
<!-- Panneau duel -->
|
||||
<div id="duel-panel">
|
||||
<span class="settings-title">Duel</span>
|
||||
<div class="duel-row">
|
||||
@@ -19,6 +25,11 @@
|
||||
<button id="btn-join-duel">Rejoindre</button>
|
||||
<button id="btn-leave-duel" disabled>Quitter</button>
|
||||
</div>
|
||||
<div class="duel-row">
|
||||
<button id="btn-matchmaking">Trouver un adversaire</button>
|
||||
<button id="btn-matchmaking-cancel" disabled>Annuler</button>
|
||||
</div>
|
||||
<div id="matchmaking-status"></div>
|
||||
<div id="duel-status">—</div>
|
||||
</div>
|
||||
|
||||
@@ -28,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>
|
||||
@@ -39,6 +50,12 @@
|
||||
<div class="score-value" id="score-display">0</div>
|
||||
</div>
|
||||
|
||||
<div class="score-block">
|
||||
<div class="score-label">Shield <kbd>E</kbd></div>
|
||||
<div class="score-value shield-ready" id="shield-status-display">PRÊT</div>
|
||||
<div class="shield-bar-bg"><div class="shield-bar" id="shield-bar"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="btn-start">Start</button>
|
||||
<button id="btn-pause" disabled>Pause</button>
|
||||
@@ -46,9 +63,18 @@
|
||||
</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" 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">
|
||||
<label for="input-ttd">Vitesse initiale (ms)</label>
|
||||
<input type="number" id="input-ttd" min="100" max="3000" step="50" value="1000">
|
||||
@@ -85,6 +111,7 @@
|
||||
<div><span>W</span> Rot. droite</div>
|
||||
<div><span>Espace</span> Drop</div>
|
||||
<div><span>C</span> Hold</div>
|
||||
<div><span>E</span> Shield</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,6 +126,7 @@
|
||||
<div class="score-label">Score</div>
|
||||
<div class="score-value" id="opponent-score">—</div>
|
||||
</div>
|
||||
<div id="opponent-shield-indicator" style="display:none;color:#00d4ff;font-size:0.75rem;text-align:center;letter-spacing:1px;margin-top:4px;">🛡 SHIELD ACTIF</div>
|
||||
</div>
|
||||
|
||||
<div id="opponent-wrapper">
|
||||
@@ -112,12 +140,46 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── LEADERBOARDS ── -->
|
||||
<div id="leaderboard-section">
|
||||
<div class="leaderboard-tabs">
|
||||
<button class="lb-tab lb-tab--active" data-tab="scores">Meilleurs scores</button>
|
||||
<button class="lb-tab" data-tab="wins">Duels gagnés</button>
|
||||
<button class="lb-tab" data-tab="history">Mes parties</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- #scale-container -->
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="pieces.js"></script>
|
||||
<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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,11 +3,12 @@
|
||||
// ───────────────────────────────────────────
|
||||
|
||||
class Tetris {
|
||||
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) {
|
||||
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null, onShieldChanged = null) {
|
||||
this.onRender = onRender;
|
||||
this.onGameOver = onGameOver;
|
||||
this.onBlockPlaced = onBlockPlaced;
|
||||
this.onLinesCleared = onLinesCleared;
|
||||
this.onShieldChanged = onShieldChanged;
|
||||
|
||||
this.grid = this._createGrid(10, 20);
|
||||
this.bufferGrid = this._createGrid(10, 5);
|
||||
@@ -28,6 +29,12 @@ class Tetris {
|
||||
this.isPaused = false;
|
||||
this.canStore = true;
|
||||
|
||||
// Shield
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true; // prêt dès le début
|
||||
|
||||
this.animationFrameId = null;
|
||||
this.lastTime = 0;
|
||||
this.accumulator = 0;
|
||||
@@ -55,6 +62,10 @@ class Tetris {
|
||||
this.timeToDown = this.initialTimeToDown;
|
||||
this.storedPiece = null;
|
||||
this.canStore = true;
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true;
|
||||
this._spawnNewPiece();
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
this._startGameLoop();
|
||||
@@ -108,6 +119,8 @@ class Tetris {
|
||||
this.lastTime = currentTime;
|
||||
this.accumulator += deltaTime;
|
||||
|
||||
this._updateShield(deltaTime);
|
||||
|
||||
while (this.isRunning && this.accumulator >= this.timeToDown) {
|
||||
this._tick();
|
||||
this.accumulator -= this.timeToDown;
|
||||
@@ -174,11 +187,42 @@ class Tetris {
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._storePiece();
|
||||
break;
|
||||
case 'e': case 'E':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._activateShield();
|
||||
break;
|
||||
}
|
||||
|
||||
this.onRender();
|
||||
}
|
||||
|
||||
_activateShield() {
|
||||
if (!this.shieldReady || this.shieldActive) return;
|
||||
this.shieldActive = true;
|
||||
this.shieldActiveMs = 3000;
|
||||
this.shieldReady = false;
|
||||
if (this.onShieldChanged) this.onShieldChanged('activated');
|
||||
}
|
||||
|
||||
_updateShield(deltaTime) {
|
||||
if (this.shieldActive) {
|
||||
this.shieldActiveMs -= deltaTime;
|
||||
if (this.shieldActiveMs <= 0) {
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 60000;
|
||||
if (this.onShieldChanged) this.onShieldChanged('deactivated');
|
||||
}
|
||||
} else if (!this.shieldReady) {
|
||||
this.shieldCooldownMs -= deltaTime;
|
||||
if (this.shieldCooldownMs <= 0) {
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true;
|
||||
if (this.onShieldChanged) this.onShieldChanged('ready');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hardDrop() {
|
||||
if (!this.currentPiece) return;
|
||||
let dist = 0;
|
||||
@@ -275,8 +319,17 @@ class Tetris {
|
||||
const points = [0, 100, 300, 500, 800];
|
||||
this.score += points[cleared];
|
||||
this.count += points[cleared];
|
||||
if (this.onLinesCleared && cleared > 0)
|
||||
this.onLinesCleared(cleared, this.lastLandingCol);
|
||||
if (cleared > 0) {
|
||||
// Chaque ligne remplie réduit le cooldown du shield de 10s
|
||||
if (!this.shieldActive && !this.shieldReady) {
|
||||
this.shieldCooldownMs = Math.max(0, this.shieldCooldownMs - cleared * 10000);
|
||||
if (this.shieldCooldownMs === 0) {
|
||||
this.shieldReady = true;
|
||||
if (this.onShieldChanged) this.onShieldChanged('ready');
|
||||
}
|
||||
}
|
||||
if (this.onLinesCleared) this.onLinesCleared(cleared, this.lastLandingCol);
|
||||
}
|
||||
}
|
||||
|
||||
_makeHarder() {
|
||||
@@ -361,6 +414,7 @@ class Tetris {
|
||||
}
|
||||
|
||||
addGarbageLines(lines) {
|
||||
if (this.shieldActive) return; // shield bloque les lignes garbage
|
||||
if (!this.isRunning || !lines.length) return;
|
||||
this.grid.splice(0, lines.length);
|
||||
for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante
|
||||
@@ -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));
|
||||
});
|
||||
@@ -1,180 +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');
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
btnJoinDuel.addEventListener('click', () => {
|
||||
const code = inputRoomCode.value.trim().toUpperCase();
|
||||
if (!code) return;
|
||||
if (duel) { duel.leave(); }
|
||||
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);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// INIT
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const game = new Tetris(
|
||||
// onRender
|
||||
() => {
|
||||
if (duel) duel.synchronize_game();
|
||||
render();
|
||||
updateButtons();
|
||||
},
|
||||
// onGameOver
|
||||
(score, validBlock) => {
|
||||
if (duel) duel.onLocalGameOver(score, validBlock);
|
||||
render();
|
||||
updateButtons();
|
||||
showOverlay('GAME OVER', score);
|
||||
},
|
||||
// onBlockPlaced — relay duel
|
||||
(grid) => {
|
||||
if (duel) duel.onLocalBlockPlaced(grid, game.score);
|
||||
},
|
||||
// onLinesCleared — relay duel
|
||||
(count, holeCol) => {
|
||||
if (duel) duel.onLocalLinesCleared(count, holeCol);
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
btnRestart.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
// In duel mode, we don't restart from client side - let server handle it
|
||||
return;
|
||||
} else {
|
||||
game.restart();
|
||||
updateButtons();
|
||||
render();
|
||||
}
|
||||
});
|
||||
@@ -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, windowRegistry } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
|
||||
/**
|
||||
* Avatar management window
|
||||
@@ -16,7 +16,9 @@ export class AvatarWindow extends Window {
|
||||
|
||||
this.buildUI();
|
||||
this.bindEvents();
|
||||
this.loadAvatar();
|
||||
if (localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)) {
|
||||
this.loadAvatar();
|
||||
}
|
||||
|
||||
// Listen for login events
|
||||
eventBus.on(Events.USER_LOGGED_IN, () => this.loadAvatar());
|
||||
@@ -50,6 +52,10 @@ export class AvatarWindow extends Window {
|
||||
// Controls
|
||||
this.controls = this.createElement('div', CSS.AVATAR_CONTROLS);
|
||||
|
||||
this.statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||
text: 'Mes statistiques'
|
||||
});
|
||||
|
||||
this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'Choose image'
|
||||
});
|
||||
@@ -61,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.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);
|
||||
@@ -83,9 +93,11 @@ export class AvatarWindow extends Window {
|
||||
*/
|
||||
bindEvents() {
|
||||
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||
this.statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showMe());
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,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;
|
||||
}
|
||||
|
||||
@@ -233,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;
|
||||
}
|
||||
|
||||
@@ -241,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 } 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
|
||||
@@ -309,11 +309,16 @@ export class FriendsWindow extends Window {
|
||||
const actions = this.createElement('div', CSS.FRIENDS_ACTIONS);
|
||||
|
||||
if (type === 'friend') {
|
||||
const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'Stats'
|
||||
});
|
||||
statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username));
|
||||
|
||||
const removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
|
||||
text: 'Retirer'
|
||||
});
|
||||
removeBtn.addEventListener('click', () => this.removeFriend(user.id));
|
||||
actions.appendChild(removeBtn);
|
||||
actions.append(statsBtn, removeBtn);
|
||||
} else if (type === 'request') {
|
||||
const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
|
||||
text: 'Accepter'
|
||||
@@ -327,11 +332,16 @@ export class FriendsWindow extends Window {
|
||||
|
||||
actions.append(acceptBtn, declineBtn);
|
||||
} else if (type === 'search') {
|
||||
const statsBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'Stats'
|
||||
});
|
||||
statsBtn.addEventListener('click', () => windowRegistry.get('stats')?.showUser(user.username));
|
||||
|
||||
const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||
text: 'Ajouter'
|
||||
});
|
||||
addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn));
|
||||
actions.appendChild(addBtn);
|
||||
actions.append(statsBtn, addBtn);
|
||||
}
|
||||
|
||||
item.append(avatar, infoContainer, actions);
|
||||
@@ -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() {
|
||||
@@ -14,9 +14,17 @@ export class GameRoomWindow extends Window {
|
||||
this.currentRoom = null;
|
||||
this.roomsList = [];
|
||||
this.socket = null;
|
||||
this.isSpectating = false;
|
||||
this.messageTimeout = null;
|
||||
this.buildUI();
|
||||
this.bindEvents();
|
||||
|
||||
// Handle page close/refresh to disconnect socket
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
});
|
||||
eventBus.on(Events.USER_LOGGED_IN, () => {
|
||||
this.updateTabsAccess();
|
||||
this.checkCurrentRoom();
|
||||
@@ -26,11 +34,12 @@ export class GameRoomWindow extends Window {
|
||||
});
|
||||
|
||||
this.updateTabsAccess();
|
||||
this.loadCurrentTab();
|
||||
|
||||
// Verifier si l'utilisateur est deja dans un salon au chargement
|
||||
if (this.isLoggedIn()) {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (token)
|
||||
this.checkCurrentRoom();
|
||||
}
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
@@ -41,6 +50,11 @@ export class GameRoomWindow extends Window {
|
||||
});
|
||||
this.browseTab.dataset.tab = 'browse';
|
||||
|
||||
this.spectatorTab = this.createElement('button', CSS.GAMEROOM_TAB, {
|
||||
text: 'Spectateur'
|
||||
});
|
||||
this.spectatorTab.dataset.tab = 'spectator';
|
||||
|
||||
this.createTab = this.createElement('button', CSS.GAMEROOM_TAB, {
|
||||
text: 'Creer'
|
||||
});
|
||||
@@ -52,7 +66,7 @@ export class GameRoomWindow extends Window {
|
||||
this.lobbyTab.dataset.tab = 'lobby';
|
||||
this.lobbyTab.style.display = 'none';
|
||||
|
||||
this.tabs.append(this.browseTab, this.createTab, this.lobbyTab);
|
||||
this.tabs.append(this.browseTab, this.spectatorTab, this.createTab, this.lobbyTab);
|
||||
|
||||
this.content = this.createElement('div', CSS.GAMEROOM_CONTENT);
|
||||
|
||||
@@ -91,9 +105,12 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
this.list = this.createElement('div', CSS.GAMEROOM_LIST);
|
||||
|
||||
this.spectatorList = this.createElement('div', CSS.GAMEROOM_LIST);
|
||||
this.spectatorList.style.display = 'none';
|
||||
|
||||
this.message = this.createElement('div', CSS.MESSAGE);
|
||||
|
||||
this.content.append(this.createContainer, this.lobbyContainer, this.list, this.message);
|
||||
this.content.append(this.createContainer, this.lobbyContainer, this.list, this.spectatorList, this.message);
|
||||
|
||||
this.body.append(this.tabs, this.content);
|
||||
}
|
||||
@@ -103,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');
|
||||
@@ -152,7 +170,7 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
// Boutons du jeu
|
||||
this.gameButtons = this.createElement('div', 'gameroom__game-buttons');
|
||||
this.backToLobbyBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Retour au lobby' });
|
||||
this.backToLobbyBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], { text: 'Quitter la partie' });
|
||||
this.endRoundBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], { text: 'Terminer le jeu' });
|
||||
this.gameButtons.append(this.backToLobbyBtn, this.endRoundBtn);
|
||||
|
||||
@@ -178,7 +196,10 @@ export class GameRoomWindow extends Window {
|
||||
players: [],
|
||||
currentPlayerIndex: 0,
|
||||
guessedLetters: [],
|
||||
scores: {}
|
||||
scores: {},
|
||||
counter: 0,
|
||||
counterRound: 0,
|
||||
timer: 0
|
||||
};
|
||||
|
||||
this.initDrawing();
|
||||
@@ -190,7 +211,7 @@ export class GameRoomWindow extends Window {
|
||||
this.lastY = 0;
|
||||
|
||||
this.canvas.addEventListener('mousedown', (e) => {
|
||||
if (!this.gameState.isPlaying || !this.isCurrentUserDrawer()) return;
|
||||
if (!this.gameState.isPlaying || !this.isCurrentUserDrawer() || this.isSpectating) return;
|
||||
this.isDrawing = true;
|
||||
[this.lastX, this.lastY] = [e.offsetX, e.offsetY];
|
||||
});
|
||||
@@ -357,7 +378,26 @@ 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 (Array.isArray(this.gameState.players))
|
||||
this.gameState.players = this.gameState.players.filter(p => p !== data.username);
|
||||
}
|
||||
|
||||
if (this.gameState.scores)
|
||||
{
|
||||
delete this.gameState.scores[data.username];
|
||||
this.updateScoresDisplay(this.gameState.scores);
|
||||
}
|
||||
|
||||
// Note: If the drawer left, the server will emit 'game-drawer-changed'
|
||||
// with the new drawer, so we don't need to handle it here
|
||||
|
||||
if (this.currentRoom && !this.gameState.isPlaying)
|
||||
this.loadLobby();
|
||||
});
|
||||
|
||||
// Game started
|
||||
@@ -376,6 +416,12 @@ export class GameRoomWindow extends Window {
|
||||
this.setupRound();
|
||||
});
|
||||
|
||||
// Game start error
|
||||
this.socket.on('game-start-error', (data) => {
|
||||
console.error('Game start error:', data.error);
|
||||
this.showMessage(data.error || 'Impossible de démarrer la partie', 'error');
|
||||
});
|
||||
|
||||
// Word was set by drawer
|
||||
this.socket.on('game-word-set', (data) => {
|
||||
console.log(`Word set by ${data.drawer}, length: ${data.wordLength}`);
|
||||
@@ -388,6 +434,13 @@ export class GameRoomWindow extends Window {
|
||||
}
|
||||
|
||||
this.updateWordDisplay();
|
||||
|
||||
// Don't change UI for spectators
|
||||
if (this.isSpectating) {
|
||||
this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`;
|
||||
|
||||
// Enable guess input for non-drawers
|
||||
@@ -443,7 +496,22 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
// Game ended
|
||||
this.socket.on('game-ended', () => {
|
||||
this.resetGameUI();
|
||||
// If spectating, return to spectator list
|
||||
if (this.isSpectating) {
|
||||
this.resetGameUI();
|
||||
// this.currentRoom = null;
|
||||
this.isSpectating = false;
|
||||
this.switchTab('spectator');
|
||||
this.showMessage('La partie est terminée', 'info');
|
||||
} else {
|
||||
this.resetGameUI();
|
||||
this.loadLobby();
|
||||
}
|
||||
});
|
||||
|
||||
// Game message from server
|
||||
this.socket.on('game-message', (data) => {
|
||||
this.showMessage(data.message, data.type || 'info');
|
||||
});
|
||||
|
||||
// Sync state for late joiners
|
||||
@@ -455,12 +523,27 @@ export class GameRoomWindow extends Window {
|
||||
this.gameState.revealedLetters = data.revealedLetters || [];
|
||||
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();
|
||||
|
||||
// Update scores display
|
||||
if (data.scores) {
|
||||
this.updateScoresDisplay(data.scores);
|
||||
}
|
||||
|
||||
this.currentDrawerInfo.textContent = `${data.drawer} dessine (${data.wordLength} lettres)`;
|
||||
|
||||
if (!this.isCurrentUserDrawer()) {
|
||||
// Don't enable input for spectators
|
||||
if (this.isSpectating) {
|
||||
this.guessContainer.style.display = 'none';
|
||||
this.wordInputContainer.style.display = 'none';
|
||||
this.drawTools.style.display = 'none';
|
||||
this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
|
||||
} else if (!this.isCurrentUserDrawer()) {
|
||||
this.guessContainer.style.display = 'flex';
|
||||
if (data.wordLength > 0) {
|
||||
this.letterInput.disabled = false;
|
||||
@@ -474,11 +557,82 @@ export class GameRoomWindow extends Window {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spectator events
|
||||
this.socket.on('game-spectate-joined', (data) => {
|
||||
console.log('Successfully joined as spectator:', data.roomId);
|
||||
this.isSpectating = true;
|
||||
|
||||
// Prepare UI for spectating
|
||||
this.spectatorList.style.display = 'none';
|
||||
this.list.style.display = 'none';
|
||||
this.createContainer.style.display = 'none';
|
||||
this.lobbyContainer.style.display = 'flex';
|
||||
|
||||
// Hide lobby elements, keep game container for when state syncs
|
||||
this.playerList.style.display = 'none';
|
||||
this.lobbyButtons.style.display = 'none';
|
||||
this.lobbyTitle.textContent = 'Mode Spectateur';
|
||||
|
||||
this.showMessage('Vous regardez la partie...', 'success');
|
||||
// The game state will be synced via game-state-sync event
|
||||
});
|
||||
|
||||
this.socket.on('game-spectate-error', (data) => {
|
||||
console.error('Spectate error:', data.error);
|
||||
this.showMessage(data.error || 'Impossible de regarder cette partie', 'error');
|
||||
});
|
||||
|
||||
this.socket.on('game-spectator-joined', (data) => {
|
||||
console.log(`Spectator ${data.username} joined`);
|
||||
});
|
||||
|
||||
this.socket.on('game-spectator-left', (data) => {
|
||||
console.log(`Spectator ${data.username} left`);
|
||||
});
|
||||
|
||||
// Drawer changed (when drawer leaves during game)
|
||||
this.socket.on('game-drawer-changed', (data) => {
|
||||
console.log('Drawer changed:', data);
|
||||
this.showMessage(data.message, 'info');
|
||||
|
||||
// Update game state with new drawer
|
||||
this.gameState.drawer = data.newDrawer;
|
||||
this.gameState.currentPlayerIndex = this.gameState.players.indexOf(data.newDrawer);
|
||||
|
||||
// Reset round state
|
||||
this.gameState.currentWord = '';
|
||||
this.gameState.wordLength = 0;
|
||||
this.gameState.revealedLetters = [];
|
||||
this.gameState.revealedWord = [];
|
||||
this.gameState.guessedLetters = [];
|
||||
|
||||
// Clear canvas and history
|
||||
this.clearCanvas();
|
||||
this.guessHistory.innerHTML = '';
|
||||
this.wordDisplay.textContent = '';
|
||||
|
||||
// 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() {
|
||||
if (this.socket) {
|
||||
this.socket.emit('game-leave-room');
|
||||
if (this.isSpectating) {
|
||||
this.socket.emit('game-leave-spectate');
|
||||
} else {
|
||||
this.socket.emit('game-leave-room');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,13 +679,14 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
this.currentTab = tabName;
|
||||
|
||||
[this.browseTab, this.createTab, this.lobbyTab].forEach(tab => {
|
||||
[this.browseTab, this.spectatorTab, this.createTab, this.lobbyTab].forEach(tab => {
|
||||
tab.classList.toggle(CSS.GAMEROOM_TAB_ACTIVE, tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
this.createContainer.style.display = tabName === 'create' ? 'flex' : 'none';
|
||||
this.lobbyContainer.style.display = tabName === 'lobby' ? 'flex' : 'none';
|
||||
this.list.style.display = tabName === 'browse' ? 'flex' : 'none';
|
||||
this.spectatorList.style.display = tabName === 'spectator' ? 'flex' : 'none';
|
||||
|
||||
this.loadCurrentTab();
|
||||
}
|
||||
@@ -543,6 +698,10 @@ export class GameRoomWindow extends Window {
|
||||
// Connect to socket to receive real-time room updates
|
||||
this.ensureSocketConnected();
|
||||
break;
|
||||
case 'spectator':
|
||||
this.loadPlayingRooms();
|
||||
this.ensureSocketConnected();
|
||||
break;
|
||||
case 'create':
|
||||
this.message.textContent = '';
|
||||
this.ensureSocketConnected();
|
||||
@@ -575,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);
|
||||
}
|
||||
@@ -609,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');
|
||||
@@ -697,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;
|
||||
}
|
||||
|
||||
@@ -721,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;
|
||||
}
|
||||
}
|
||||
@@ -741,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;
|
||||
}
|
||||
|
||||
@@ -762,12 +925,130 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
createSpectatorRoomItem(room) {
|
||||
const item = this.createElement('div', CSS.GAMEROOM_ITEM);
|
||||
|
||||
const name = this.createElement('span', CSS.GAMEROOM_NAME, {
|
||||
text: room.name
|
||||
});
|
||||
|
||||
const players = this.createElement('span', CSS.GAMEROOM_PLAYERS, {
|
||||
text: `${room.player_count || 0}/${room.max_players || 8}`
|
||||
});
|
||||
|
||||
const status = this.createElement('span', 'gameroom__status', {
|
||||
text: '🎮 En cours'
|
||||
});
|
||||
status.style.color = '#4CAF50';
|
||||
status.style.fontWeight = 'bold';
|
||||
|
||||
const actions = this.createElement('div', CSS.GAMEROOM_ACTIONS);
|
||||
|
||||
const spectateBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||
text: 'Regarder'
|
||||
});
|
||||
spectateBtn.addEventListener('click', () => this.spectateRoom(room.id));
|
||||
actions.appendChild(spectateBtn);
|
||||
|
||||
item.append(name, players, status, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
async loadPlayingRooms() {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.showMessage('Connectez-vous pour voir les parties en cours', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API.ROOMS.PLAYING, {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
this.showMessage(data.error || 'Erreur', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderPlayingRoomsList(data || []);
|
||||
} catch (error) {
|
||||
console.error('Load playing rooms error:', error);
|
||||
this.showMessage('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderPlayingRoomsList(rooms) {
|
||||
this.spectatorList.innerHTML = '';
|
||||
this.message.textContent = '';
|
||||
|
||||
if (rooms.length === 0) {
|
||||
this.showMessage('Aucune partie en cours', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
rooms.forEach(room => {
|
||||
const item = this.createSpectatorRoomItem(room);
|
||||
this.spectatorList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async spectateRoom(roomId) {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.showMessage('Connectez-vous pour regarder', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already in a room as a player
|
||||
if (this.currentRoom && !this.isSpectating) {
|
||||
this.showMessage('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already spectating another game
|
||||
if (this.isSpectating && this.currentRoom && this.currentRoom.id !== roomId) {
|
||||
this.showMessage('Vous regardez déjà une autre partie', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API.ROOMS.SPECTATE(roomId), {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
this.showMessage(data.error || 'Impossible de regarder cette partie', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store room info and mark as spectating
|
||||
this.currentRoom = data;
|
||||
this.isSpectating = true;
|
||||
|
||||
// Join as spectator via socket
|
||||
await this.ensureSocketConnected();
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('game-spectate-room', { roomId: roomId });
|
||||
}
|
||||
|
||||
this.showMessage('Connexion à la partie...', 'info');
|
||||
} catch (error) {
|
||||
console.error('Spectate room error:', error);
|
||||
this.showMessage('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async joinRoom(roomId) {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
@@ -777,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;
|
||||
}
|
||||
|
||||
@@ -790,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;
|
||||
}
|
||||
}
|
||||
@@ -834,7 +1117,11 @@ 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 = {};
|
||||
|
||||
try {
|
||||
const response = await fetch(API.ROOMS.PLAYERS(this.currentRoom.id), {
|
||||
@@ -862,6 +1149,10 @@ export class GameRoomWindow extends Window {
|
||||
text: 'Aucun joueur'
|
||||
});
|
||||
this.playerList.appendChild(empty);
|
||||
// Disable start button if no players
|
||||
this.startGameBtn.disabled = true;
|
||||
this.startGameBtn.style.opacity = '0.5';
|
||||
this.startGameBtn.title = 'Il faut au moins 2 joueurs';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -891,6 +1182,17 @@ export class GameRoomWindow extends Window {
|
||||
item.append(avatar, name, statsContainer);
|
||||
this.playerList.appendChild(item);
|
||||
});
|
||||
|
||||
// Enable/disable start button based on player count
|
||||
if (players.length < 2) {
|
||||
this.startGameBtn.disabled = true;
|
||||
this.startGameBtn.style.opacity = '0.5';
|
||||
this.startGameBtn.title = 'Il faut au moins 2 joueurs';
|
||||
} else {
|
||||
this.startGameBtn.disabled = false;
|
||||
this.startGameBtn.style.opacity = '1';
|
||||
this.startGameBtn.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
async leaveRoom() {
|
||||
@@ -933,6 +1235,11 @@ export class GameRoomWindow extends Window {
|
||||
}
|
||||
|
||||
showMessage(text, type = 'info') {
|
||||
// Clear any existing timeout
|
||||
if (this.messageTimeout) {
|
||||
clearTimeout(this.messageTimeout);
|
||||
}
|
||||
|
||||
this.message.textContent = text;
|
||||
this.message.className = CSS.MESSAGE;
|
||||
|
||||
@@ -943,6 +1250,18 @@ export class GameRoomWindow extends Window {
|
||||
} else {
|
||||
this.message.classList.add(CSS.MESSAGE_INFO);
|
||||
}
|
||||
|
||||
// Auto-clear message after 5 seconds
|
||||
this.messageTimeout = setTimeout(() => {
|
||||
this.message.textContent = '';
|
||||
this.message.className = CSS.MESSAGE;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
updateTimerUI()
|
||||
{
|
||||
if (this.timerDisplay)
|
||||
this.timerDisplay.textContent = `Temps restant : ${this.gameState.timer}s`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -970,6 +1289,23 @@ export class GameRoomWindow extends Window {
|
||||
this.lobbyButtons.style.display = 'none';
|
||||
this.clearCanvas();
|
||||
this.guessHistory.innerHTML = '';
|
||||
|
||||
// If spectating, show indicator and disable interactions
|
||||
if (this.isSpectating) {
|
||||
this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
|
||||
this.currentDrawerInfo.style.backgroundColor = '#2196F3';
|
||||
this.currentDrawerInfo.style.color = 'white';
|
||||
this.currentDrawerInfo.style.padding = '8px';
|
||||
this.currentDrawerInfo.style.borderRadius = '4px';
|
||||
this.currentDrawerInfo.style.textAlign = 'center';
|
||||
|
||||
// Change button text for spectators
|
||||
this.backToLobbyBtn.textContent = 'Arrêter de regarder';
|
||||
this.endRoundBtn.style.display = 'none'; // Hide end game button for spectators
|
||||
} else {
|
||||
this.backToLobbyBtn.textContent = 'Quitter la partie';
|
||||
this.endRoundBtn.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
resetGameUI() {
|
||||
@@ -979,6 +1315,24 @@ export class GameRoomWindow extends Window {
|
||||
this.gameState.revealedLetters = [];
|
||||
this.gameState.revealedWord = [];
|
||||
this.gameState.drawer = null;
|
||||
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 = [];
|
||||
|
||||
// Clear scores display
|
||||
if (this.scoresDisplay)
|
||||
this.scoresDisplay.textContent = '';
|
||||
|
||||
if (this.guessHistory)
|
||||
this.guessHistory.innerHTML = '';
|
||||
|
||||
this.clearCanvas();
|
||||
|
||||
this.gameContainer.style.display = 'none';
|
||||
this.playerList.style.display = 'flex';
|
||||
@@ -988,6 +1342,12 @@ export class GameRoomWindow extends Window {
|
||||
this.guessContainer.style.display = 'none';
|
||||
this.drawTools.style.display = 'none';
|
||||
|
||||
// Reset spectator styling
|
||||
this.currentDrawerInfo.style.backgroundColor = '';
|
||||
this.currentDrawerInfo.style.color = '';
|
||||
this.currentDrawerInfo.style.padding = '';
|
||||
this.currentDrawerInfo.style.borderRadius = '';
|
||||
this.currentDrawerInfo.style.textAlign = '';
|
||||
this.currentDrawerInfo.classList.remove('gameroom__drawer-info--winner');
|
||||
}
|
||||
|
||||
@@ -1002,8 +1362,8 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
console.log('Players found:', players);
|
||||
|
||||
if (players.length < 1) {
|
||||
this.showMessage('Il faut au moins 1 joueur pour jouer', 'error');
|
||||
if (players.length < 2) {
|
||||
this.showMessage('Il faut au moins 2 joueurs pour commencer', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1400,15 @@ export class GameRoomWindow extends Window {
|
||||
this.guessHistory.innerHTML = '';
|
||||
this.clearCanvas();
|
||||
|
||||
// Spectators cannot interact
|
||||
if (this.isSpectating) {
|
||||
this.wordInputContainer.style.display = 'none';
|
||||
this.guessContainer.style.display = 'none';
|
||||
this.drawTools.style.display = 'none';
|
||||
this.currentDrawerInfo.textContent = '👁️ MODE SPECTATEUR - Vous regardez la partie';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isCurrentUserDrawer()) {
|
||||
// Drawer chooses a word
|
||||
this.wordInputContainer.style.display = 'flex';
|
||||
@@ -1175,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}`;
|
||||
}
|
||||
@@ -1225,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();
|
||||
}
|
||||
@@ -1234,8 +1606,11 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
nextRound() {
|
||||
// Move to next player
|
||||
this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length;
|
||||
const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex];
|
||||
this.gameState.counter++;
|
||||
if (this.gameState.counter >= this.gameState.players.length) {
|
||||
this.gameState.counter = 0;
|
||||
}
|
||||
const nextDrawer = this.gameState.players[this.gameState.counter];
|
||||
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('game-next-round', { drawer: nextDrawer });
|
||||
@@ -1246,9 +1621,14 @@ export class GameRoomWindow extends Window {
|
||||
}
|
||||
|
||||
backToLobby() {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('leave-room-during-game');
|
||||
}
|
||||
|
||||
// Return to lobby without ending game for others
|
||||
this.resetGameUI();
|
||||
this.loadLobby();
|
||||
this.exitLobby();
|
||||
this.showMessage('Vous avez quitté la partie', 'info');
|
||||
}
|
||||
|
||||
endGame() {
|
||||
@@ -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
|
||||
@@ -17,6 +17,8 @@ export class GlobalChat extends Window {
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
this.friendIds = new Set();
|
||||
this.currentUserId = null;
|
||||
this.currentUsername = null;
|
||||
|
||||
this.buildUI();
|
||||
this.bindEvents();
|
||||
@@ -169,6 +171,19 @@ export class GlobalChat extends Window {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
decodeToken(token)
|
||||
{
|
||||
try
|
||||
{
|
||||
const payload = token.split('.')[1];
|
||||
return (JSON.parse(atob(payload)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Socket.IO server
|
||||
*/
|
||||
@@ -180,6 +195,13 @@ export class GlobalChat extends Window {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenData = this.decodeToken(token);
|
||||
|
||||
if (tokenData) {
|
||||
this.currentUserId = tokenData.id || tokenData.userId || tokenData.user_id || tokenData.sub || null;
|
||||
this.currentUsername = tokenData.username || tokenData.name || null;
|
||||
}
|
||||
|
||||
if (this.socket?.connected) {
|
||||
this.addSystemMessage('Already connected to global chat');
|
||||
return;
|
||||
@@ -200,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);
|
||||
}
|
||||
@@ -239,6 +261,7 @@ export class GlobalChat extends Window {
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket connected, ID:', this.socket.id);
|
||||
this.connected = true;
|
||||
this.output.innerHTML = '';
|
||||
this.addSystemMessage('Connected to global chat', 'success');
|
||||
eventBus.emit(Events.CHAT_CONNECTED, { socketId: this.socket.id });
|
||||
});
|
||||
@@ -262,15 +285,38 @@ export class GlobalChat extends Window {
|
||||
|
||||
// Display recent messages
|
||||
data.messages.forEach(msg => {
|
||||
const isFriend = this.friendIds.has(msg.sender_id);
|
||||
this.addChatMessage(msg.username, msg.content, false, isFriend);
|
||||
const isOwn = this.isOwnMessage(msg);
|
||||
const isFriend = !isOwn && this.friendIds.has(msg.sender_id);
|
||||
const displayUsername = isOwn ? 'Me' : msg.username;
|
||||
this.addChatMessage(displayUsername, msg.content, isOwn, isFriend);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on('chat-message', (msg) => {
|
||||
const isOwn = this.isOwnMessage(msg);
|
||||
if (isOwn)
|
||||
return;
|
||||
|
||||
const isFriend = this.friendIds.has(msg.sender_id);
|
||||
this.addChatMessage(msg.username, msg.content, false, isFriend);
|
||||
eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg);
|
||||
});
|
||||
}
|
||||
|
||||
isOwnMessage(msg)
|
||||
{
|
||||
if (this.currentUserId !== null && msg.sender_id !== undefined && msg.sender_id !== null)
|
||||
{
|
||||
if (String(this.currentUserId) === String(msg.sender_id))
|
||||
return (true);
|
||||
}
|
||||
|
||||
if (this.currentUsername && msg.username)
|
||||
{
|
||||
if (this.currentUsername.toLowerCase() === msg.username.toLowerCase())
|
||||
return (true);
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -83,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) => {
|
||||
@@ -129,6 +130,7 @@ export class LoginWindow extends Window {
|
||||
if (response.ok && data.token) {
|
||||
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
|
||||
this.showMessage('Login successful! Welcome.', 'success');
|
||||
this.showNotification('Login successful', 'green');
|
||||
|
||||
// Emit login event
|
||||
eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token });
|
||||
@@ -138,6 +140,7 @@ export class LoginWindow extends Window {
|
||||
} else {
|
||||
const errorMsg = data?.message || 'Login failed';
|
||||
this.showMessage(errorMsg, 'error');
|
||||
this.showNotification(errorMsg, 'red');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
@@ -170,10 +173,12 @@ export class LoginWindow extends Window {
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage('Registration successful! You can now sign in.', 'success');
|
||||
this.showNotification('Registration successful', 'green');
|
||||
eventBus.emit(Events.USER_REGISTERED, { username });
|
||||
} else {
|
||||
const errorMsg = data?.message || 'Registration failed';
|
||||
this.showMessage(errorMsg, 'error');
|
||||
this.showNotification(errorMsg, 'red');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
@@ -190,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',
|
||||
@@ -200,6 +207,7 @@ export class LoginWindow extends Window {
|
||||
if (event.data?.token) {
|
||||
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token);
|
||||
this.showMessage('GitHub login successful! Welcome.', 'success');
|
||||
this.showNotification('GitHub login successful', 'green');
|
||||
|
||||
// Emit login event
|
||||
eventBus.emit(Events.USER_LOGGED_IN, {
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS } from '../core/config.js';
|
||||
|
||||
/**
|
||||
* Stats window — displays Scribble + Tetris stats for any user
|
||||
* Usage: windowRegistry.get('stats').showUser(username)
|
||||
*/
|
||||
export class StatsWindow extends Window {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'stats',
|
||||
title: 'Statistiques',
|
||||
cssClasses: ['stats-window']
|
||||
});
|
||||
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
this.avatarEl = this.createElement('img', 'stats__avatar', { alt: 'Avatar' });
|
||||
this.avatarEl.src = '/avatar/default.png';
|
||||
|
||||
this.usernameEl = this.createElement('div', 'stats__username');
|
||||
|
||||
// Scribble section
|
||||
const scribbleSection = this.createElement('div', 'stats__section');
|
||||
const scribbleTitle = this.createElement('div', 'stats__section-title', { text: 'Scribble' });
|
||||
this.scribbleBody = this.createElement('div', 'stats__section-body');
|
||||
scribbleSection.append(scribbleTitle, this.scribbleBody);
|
||||
|
||||
// Tetris section
|
||||
const tetrisSection = this.createElement('div', 'stats__section');
|
||||
const tetrisTitle = this.createElement('div', 'stats__section-title', { text: 'Tetris' });
|
||||
this.tetrisBody = this.createElement('div', 'stats__section-body');
|
||||
tetrisSection.append(tetrisTitle, this.tetrisBody);
|
||||
|
||||
this.body.append(this.avatarEl, this.usernameEl, scribbleSection, tetrisSection);
|
||||
}
|
||||
|
||||
async showUser(username) {
|
||||
this.show();
|
||||
this.setTitle('Statistiques');
|
||||
this.usernameEl.textContent = username;
|
||||
this.avatarEl.src = '/avatar/default.png';
|
||||
this.scribbleBody.innerHTML = '<div class="stats__loading">Chargement…</div>';
|
||||
this.tetrisBody.innerHTML = '';
|
||||
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.STATS.USER(username), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.scribbleBody.innerHTML = '<div class="stats__loading">Erreur</div>';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
this.renderStats(data);
|
||||
} catch (err) {
|
||||
console.error('Stats load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async showMe() {
|
||||
this.show();
|
||||
this.setTitle('Mes statistiques');
|
||||
this.scribbleBody.innerHTML = '<div class="stats__loading">Chargement…</div>';
|
||||
this.tetrisBody.innerHTML = '';
|
||||
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.STATS.ME, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
this.renderStats(data);
|
||||
} catch (err) {
|
||||
console.error('Stats load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
renderStats(data) {
|
||||
this.setTitle(`Stats — ${data.username}`);
|
||||
this.usernameEl.textContent = data.username;
|
||||
this.avatarEl.src = data.avatar_url || '/avatar/default.png';
|
||||
|
||||
this.scribbleBody.innerHTML = `
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Points</span>
|
||||
<span class="stats__value">${data.total_points || 0}</span>
|
||||
</div>
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Parties</span>
|
||||
<span class="stats__value">${data.games_played || 0}</span>
|
||||
</div>
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Victoires</span>
|
||||
<span class="stats__value">${data.games_won || 0}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.tetrisBody.innerHTML = `
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Meilleur score</span>
|
||||
<span class="stats__value">${data.tetris_best_score || 0}</span>
|
||||
</div>
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Duels gagnés</span>
|
||||
<span class="stats__value">${data.tetris_wins || 0}</span>
|
||||
</div>
|
||||
<div class="stats__row">
|
||||
<span class="stats__label">Parties</span>
|
||||
<span class="stats__value">${data.tetris_games_played || 0}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||