Разработан веб-сервер и игровое поле
This commit is contained in:
		
							
								
								
									
										1
									
								
								app/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| from .main import app | ||||
							
								
								
									
										151
									
								
								app/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								app/main.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,151 @@ | ||||
| import asyncio | ||||
| import database | ||||
| from fastapi import FastAPI, Request, Body, WebSocket, WebSocketDisconnect | ||||
| from fastapi.responses import Response | ||||
| from fastapi.exceptions import HTTPException | ||||
| import models | ||||
| import typing | ||||
|  | ||||
|  | ||||
| app = FastAPI() | ||||
|  | ||||
|  | ||||
| @app.middleware('http') | ||||
| async def function( | ||||
|         request: Request, | ||||
|         callback: typing.Callable, | ||||
| ): | ||||
|     async def too_many_requests( | ||||
|             message: str, | ||||
|             delay: int, | ||||
|     ): | ||||
|         raise HTTPException( | ||||
|             status_code=429, | ||||
|             detail={ | ||||
|                 'msg': message, | ||||
|             }, | ||||
|             headers={ | ||||
|                 'Retry-After': str(delay), | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     requests_frequency = await database.get_requests_frequency( | ||||
|         ip_address=request.client.host, | ||||
|     ) | ||||
|     if requests_frequency.per_day > 10000: | ||||
|         return await too_many_requests( | ||||
|             message='Слишком много запросов в день.', | ||||
|             delay=86400, | ||||
|         ) | ||||
|     if requests_frequency.per_hour > 1000: | ||||
|         return await too_many_requests( | ||||
|             message='Слишком много запросов в час.', | ||||
|             delay=3600, | ||||
|         ) | ||||
|     if requests_frequency.per_minute > 100: | ||||
|         return await too_many_requests( | ||||
|             message='Слишком много запросов в минуту.', | ||||
|             delay=60, | ||||
|         ) | ||||
|     if requests_frequency.per_second > 1: | ||||
|         return await too_many_requests( | ||||
|             message='Слишком много запросов в секунду.', | ||||
|             delay=1, | ||||
|         ) | ||||
|     asyncio.ensure_future( | ||||
|         database.put_request( | ||||
|             ip_address=request.client.host, | ||||
|             method=request.method, | ||||
|             url=str(request.url), | ||||
|             is_secure=request.base_url.is_secure, | ||||
|             user_agent=request.headers.get('User-Agent'), | ||||
|             referer=request.headers.get('Referer'), | ||||
|         ) | ||||
|     ) | ||||
|     return await callback(request) | ||||
|  | ||||
|  | ||||
| class ConnectionManager: | ||||
|     def __init__(self): | ||||
|         self.connections: list[WebSocket] = list[WebSocket]() | ||||
|  | ||||
|     async def connect(self, websocket: WebSocket): | ||||
|         await websocket.accept() | ||||
|         self.connections.append(websocket) | ||||
|  | ||||
|     def disconnect(self, websocket: WebSocket): | ||||
|         self.connections.remove(websocket) | ||||
|  | ||||
|     async def broadcast(self, data: dict): | ||||
|         for connection in self.connections: | ||||
|             asyncio.ensure_future(connection.send_json(data)) | ||||
|  | ||||
|  | ||||
| manager = ConnectionManager() | ||||
|  | ||||
|  | ||||
| @app.websocket('/ws/v1/pixels') | ||||
| async def function( | ||||
|         websocket: WebSocket, | ||||
| ): | ||||
|     await manager.connect(websocket) | ||||
|     try: | ||||
|         await websocket.receive() | ||||
|     except WebSocketDisconnect: | ||||
|         manager.disconnect(websocket) | ||||
|  | ||||
|  | ||||
| @app.get('/api/v1/pixels') | ||||
| async def function(): | ||||
|     pixels_co = database.get_pixels() | ||||
|     return Response( | ||||
|         content=models.Area( | ||||
|             pixels=await pixels_co, | ||||
|         ).json(), | ||||
|         status_code=200, | ||||
|         headers=models.headers, | ||||
|         media_type='application/json', | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @app.put('/api/v1/pixels') | ||||
| async def function( | ||||
|         request: Request, | ||||
|         x: int = Body( | ||||
|             ..., | ||||
|             ge=0, | ||||
|             le=models.area_width - 1, | ||||
|         ), | ||||
|         y: int = Body( | ||||
|             ..., | ||||
|             ge=0, | ||||
|             le=models.area_height - 1, | ||||
|         ), | ||||
|         color: int = Body( | ||||
|             ..., | ||||
|             ge=0x000000, | ||||
|             le=0xFFFFFF, | ||||
|         ), | ||||
| ): | ||||
|     await database.put_pixel( | ||||
|         x=x, | ||||
|         y=y, | ||||
|         color=color, | ||||
|         ip_address=request.client.host, | ||||
|     ) | ||||
|     await manager.broadcast({ | ||||
|         'x': x, | ||||
|         'y': y, | ||||
|         'color': color, | ||||
|     }) | ||||
|     return Response( | ||||
|         status_code=201, | ||||
|         headers=models.headers, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @app.options('/api/v1/pixels') | ||||
| async def function(): | ||||
|     return Response( | ||||
|         headers=models.headers, | ||||
|     ) | ||||
							
								
								
									
										1
									
								
								config/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								config/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| from .main import Database | ||||
							
								
								
									
										28
									
								
								config/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								config/main.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| from configparser import RawConfigParser | ||||
|  | ||||
|  | ||||
| config = RawConfigParser() | ||||
| config.read( | ||||
|     filenames='./config.ini', | ||||
| ) | ||||
|  | ||||
|  | ||||
| class Database: | ||||
|     host = config.get( | ||||
|         section='Database', | ||||
|         option='host', | ||||
|         fallback='localhost', | ||||
|     ) | ||||
|     user = config.get( | ||||
|         section='Database', | ||||
|         option='user', | ||||
|     ) | ||||
|     password = config.get( | ||||
|         section='Database', | ||||
|         option='password', | ||||
|         fallback='', | ||||
|     ) | ||||
|     dbname = config.get( | ||||
|         section='Database', | ||||
|         option='dbname', | ||||
|     ) | ||||
							
								
								
									
										107
									
								
								database.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								database.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,107 @@ | ||||
| create table users ( | ||||
|     id          bigserial                   not null, | ||||
|     ip_address  cidr                        not null, | ||||
|     created     date                        default now()::date not null, | ||||
|     primary key (id), | ||||
|     unique (ip_address) | ||||
| ); | ||||
|  | ||||
| create table user_colors ( | ||||
|     user_id     bigint                      not null, | ||||
|     color       integer                     not null, | ||||
|     primary key (user_id, color), | ||||
|     foreign key (user_id) references users | ||||
| ); | ||||
|  | ||||
| create table pixels ( | ||||
|     x           smallint                    not null, | ||||
|     y           smallint                    not null, | ||||
|     color       integer                     not null, | ||||
|     user_id     bigint                      not null, | ||||
|     last_update timestamp                   default now() not null, | ||||
|     primary key (x, y), | ||||
|     foreign key (user_id) references users | ||||
| ); | ||||
|  | ||||
| create table history ( | ||||
|     id          bigserial                   not null, | ||||
|     user_id     bigint                      not null, | ||||
|     time        timestamp                   default now() not null, | ||||
|     primary key (id), | ||||
|     foreign key (user_id) references users | ||||
| ); | ||||
|  | ||||
| create table requests ( | ||||
|     id          bigserial                   not null, | ||||
|     ip_address  cidr                        not null, | ||||
|     method      text                        not null, | ||||
|     url         text                        not null, | ||||
|     is_secure   bool                        not null, | ||||
|     user_agent  character varying (4096), | ||||
|     referer     character varying (2048), | ||||
|     time        timestamp                   default now() not null, | ||||
|     primary key (id) | ||||
| ); | ||||
|  | ||||
| create function pixels_history() returns trigger as $$ | ||||
|     begin | ||||
|         insert into history ( | ||||
|             user_id | ||||
|         ) values ( | ||||
|             new.user_id | ||||
|         ); | ||||
|         return new; | ||||
|     end; | ||||
| $$ language plpgsql; | ||||
|  | ||||
| create trigger pixels_history after insert or update on pixels | ||||
|     for each row execute procedure pixels_history(); | ||||
|  | ||||
| create procedure put_pixel( | ||||
|         _x smallint, | ||||
|         _y smallint, | ||||
|         _color integer, | ||||
|         _ip_address cidr | ||||
| ) as $$ | ||||
|     declare | ||||
|         _user_id bigint; | ||||
|     begin | ||||
|         insert into users ( | ||||
|             ip_address | ||||
|         ) values ( | ||||
|             _ip_address | ||||
|         ) on conflict ( | ||||
|             ip_address | ||||
|         ) do nothing; | ||||
|         _user_id = ( | ||||
|             select | ||||
|                 users.id | ||||
|             from | ||||
|                 users | ||||
|             where | ||||
|                 users.ip_address = _ip_address | ||||
|         ); | ||||
|         insert into pixels ( | ||||
|             x, | ||||
|             y, | ||||
|             color, | ||||
|             user_id, | ||||
|             last_update | ||||
|         ) values ( | ||||
|             _x, | ||||
|             _y, | ||||
|             _color, | ||||
|             _user_id, | ||||
|             default | ||||
|         ) | ||||
|         on conflict ( | ||||
|             x, | ||||
|             y | ||||
|         ) do update set | ||||
|             color = excluded.color, | ||||
|             user_id = excluded.user_id, | ||||
|             last_update = excluded.last_update; | ||||
|     end; | ||||
| $$ language plpgsql; | ||||
|  | ||||
| -- truncate pixels; | ||||
							
								
								
									
										1
									
								
								database/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								database/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| from .main import get_pixels, put_pixel, get_requests_frequency, put_request | ||||
							
								
								
									
										204
									
								
								database/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								database/main.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,204 @@ | ||||
| import config | ||||
| from models import User, Pixel, RequestsFrequency | ||||
| from psycopg import AsyncConnection | ||||
|  | ||||
|  | ||||
| conninfo = 'host=%(host)s user=%(user)s password=%(password)s dbname=%(dbname)s' % { | ||||
|     'host': config.Database.host, | ||||
|     'user': config.Database.user, | ||||
|     'password': config.Database.password, | ||||
|     'dbname': config.Database.dbname, | ||||
| } | ||||
|  | ||||
|  | ||||
| async def get_user( | ||||
|         ip_address: str, | ||||
| ) -> list[Pixel]: | ||||
|     async with await AsyncConnection.connect(conninfo) as connection: | ||||
|         async with connection.cursor() as cursor: | ||||
|             sql = ''' | ||||
|             select | ||||
|                 users.id, | ||||
|                 users.ip_address, | ||||
|                 users.created, | ||||
|                 count(history), | ||||
|                 unnest(user_colors.color) | ||||
|             from | ||||
|                 users | ||||
|                     left outer join history on | ||||
|                         users.id = history.user_id | ||||
|                     left outer join user_colors on | ||||
|                         users.id = user_colors.user_id | ||||
|             group by | ||||
|                 users.id, | ||||
|                 users.ip_address, | ||||
|                 users.created; | ||||
|             ''' | ||||
|             await cursor.execute(sql) | ||||
|             records = await cursor.fetchall() | ||||
|     return list( | ||||
|         Pixel( | ||||
|             x=pixel_x, | ||||
|             y=pixel_y, | ||||
|             color=pixel_color, | ||||
|             user=User( | ||||
|                 id=user_id, | ||||
|                 ip_address=user_ip_address, | ||||
|                 progress=user_progress, | ||||
|                 created=user_created, | ||||
|             ), | ||||
|             last_update=pixel_last_update, | ||||
|         ) | ||||
|         for | ||||
|         pixel_x, | ||||
|         pixel_y, | ||||
|         pixel_color, | ||||
|         user_id, | ||||
|         user_ip_address, | ||||
|         user_progress, | ||||
|         user_created, | ||||
|         pixel_last_update, | ||||
|         in | ||||
|         records | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def get_pixels() -> list[Pixel]: | ||||
|     async with await AsyncConnection.connect(conninfo) as connection: | ||||
|         async with connection.cursor() as cursor: | ||||
|             sql = ''' | ||||
|             select | ||||
|                 pixels.x, | ||||
|                 pixels.y, | ||||
|                 pixels.color, | ||||
|                 pixels.last_update | ||||
|             from | ||||
|                 pixels; | ||||
|             ''' | ||||
|             await cursor.execute(sql) | ||||
|             records = await cursor.fetchall() | ||||
|     return list( | ||||
|         Pixel( | ||||
|             x=pixel_x, | ||||
|             y=pixel_y, | ||||
|             color=pixel_color, | ||||
|             last_update=pixel_last_update, | ||||
|         ) | ||||
|         for | ||||
|         pixel_x, | ||||
|         pixel_y, | ||||
|         pixel_color, | ||||
|         pixel_last_update, | ||||
|         in | ||||
|         records | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def put_pixel( | ||||
|         x: int, | ||||
|         y: int, | ||||
|         color: int, | ||||
|         ip_address, | ||||
| ): | ||||
|     async with await AsyncConnection.connect(conninfo) as connection: | ||||
|         async with connection.cursor() as cursor: | ||||
|             sql = ''' | ||||
|             call put_pixel( | ||||
|                 %(x)s, | ||||
|                 %(y)s, | ||||
|                 %(color)s, | ||||
|                 %(ip_address)s | ||||
|             ); | ||||
|             ''' | ||||
|             await cursor.execute( | ||||
|                 sql, | ||||
|                 { | ||||
|                     'x': x, | ||||
|                     'y': y, | ||||
|                     'color': color, | ||||
|                     'ip_address': ip_address, | ||||
|                 }, | ||||
|             ) | ||||
|  | ||||
|  | ||||
| async def get_requests_frequency( | ||||
|         ip_address: str, | ||||
| ) -> RequestsFrequency: | ||||
|     async with await AsyncConnection.connect(conninfo) as connection: | ||||
|         async with connection.cursor() as cursor: | ||||
|             sql = ''' | ||||
|             select | ||||
|                 count(requests_per_second), | ||||
|                 count(requests_per_minute), | ||||
|                 count(requests_per_hour), | ||||
|                 count(requests_per_day) | ||||
|             from | ||||
|                 requests | ||||
|             left join requests requests_per_second on | ||||
|                 requests.id = requests_per_second.id and | ||||
|                 requests_per_second.time > now() - '1 second'::interval | ||||
|             left join requests requests_per_minute on | ||||
|                 requests.id = requests_per_minute.id and | ||||
|                 requests_per_minute.time > now() - '1 minute'::interval | ||||
|             left join requests requests_per_hour on | ||||
|                 requests.id = requests_per_hour.id and | ||||
|                 requests_per_hour.time > now() - '1 hour'::interval | ||||
|             left join requests requests_per_day on | ||||
|                 requests.id = requests_per_day.id and | ||||
|                 requests_per_day.time > now() - '1 day'::interval | ||||
|             where | ||||
|                 requests.ip_address = '91.195.204.120/32'::cidr; | ||||
|             ''' | ||||
|             await cursor.execute( | ||||
|                 sql, | ||||
|                 { | ||||
|                     'ip_address': ip_address, | ||||
|                 }, | ||||
|             ) | ||||
|             requests_per_second, requests_per_minute, requests_per_hour, requests_per_day, = await cursor.fetchone() | ||||
|     return RequestsFrequency( | ||||
|         per_second=requests_per_second, | ||||
|         per_minute=requests_per_minute, | ||||
|         per_hour=requests_per_hour, | ||||
|         per_day=requests_per_day, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def put_request( | ||||
|         ip_address: str, | ||||
|         method: str, | ||||
|         url: str, | ||||
|         is_secure: bool, | ||||
|         user_agent: str = None, | ||||
|         referer: str = None, | ||||
| ): | ||||
|     async with await AsyncConnection.connect(conninfo) as connection: | ||||
|         async with connection.cursor() as cursor: | ||||
|             sql = ''' | ||||
|             insert into requests ( | ||||
|                 ip_address, | ||||
|                 method, | ||||
|                 url, | ||||
|                 is_secure, | ||||
|                 user_agent, | ||||
|                 referer | ||||
|             ) values ( | ||||
|                 %(ip_address)s, | ||||
|                 %(method)s, | ||||
|                 %(url)s, | ||||
|                 %(is_secure)s, | ||||
|                 %(user_agent)s, | ||||
|                 %(referer)s | ||||
|             ); | ||||
|             ''' | ||||
|             await cursor.execute( | ||||
|                 sql, | ||||
|                 { | ||||
|                     'ip_address': ip_address, | ||||
|                     'method': method, | ||||
|                     'url': url, | ||||
|                     'is_secure': is_secure, | ||||
|                     'user_agent': user_agent, | ||||
|                     'referer': referer, | ||||
|                 }, | ||||
|             ) | ||||
							
								
								
									
										27
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="ru"> | ||||
|     <head> | ||||
|         <meta charset="UTF-8" /> | ||||
|         <title>Pixel Battle</title> | ||||
|         <link rel="stylesheet" href="/static/styles/main.css" /> | ||||
|         <script src="/static/scripts/main.js"></script> | ||||
|     </head> | ||||
|     <body> | ||||
|         <main id="main"> | ||||
|             <canvas id="battlefield" style="left: 50%; top: 50%; scale: .25;"></canvas> | ||||
|         </main> | ||||
|         <aside id="color-bar"> | ||||
|             <label for="color-picker"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> | ||||
|                     <path d="M19.35,11.72L17.22,13.85L15.81,12.43L8.1,20.14L3.5,22L2,20.5L3.86,15.9L11.57,8.19L10.15,6.78L12.28,4.65L19.35,11.72M16.76,3C17.93,1.83 19.83,1.83 21,3C22.17,4.17 22.17,6.07 21,7.24L19.08,9.16L14.84,4.92L16.76,3M5.56,17.03L4.5,19.5L6.97,18.44L14.4,11L13,9.6L5.56,17.03Z" fill="black"></path> | ||||
|                 </svg> | ||||
|             </label> | ||||
|             <input id="color-picker" type="color" /> | ||||
|             <div class="color"></div> | ||||
|             <div class="color"></div> | ||||
|             <div class="color"></div> | ||||
|             <div class="color"></div> | ||||
|             <div class="color"></div> | ||||
|         </aside> | ||||
|     </body> | ||||
| </html> | ||||
							
								
								
									
										5
									
								
								main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								main.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| from app import app | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     app.run() | ||||
							
								
								
									
										1
									
								
								models/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								models/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| from .main import area_width, area_height, pixel_size, headers, User, Pixel, Area, RequestsFrequency | ||||
							
								
								
									
										53
									
								
								models/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								models/main.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| from pydantic import BaseModel, Field, IPvAnyNetwork | ||||
| from datetime import datetime as DateTime, date as Date | ||||
|  | ||||
|  | ||||
| area_width = 64 | ||||
| area_height = 32 | ||||
| pixel_size = 24 | ||||
| headers = { | ||||
|     'Access-Control-Allow-Origin': '*', | ||||
|     'Access-Control-Allow-Methods': '*', | ||||
|     'Access-Control-Allow-Headers': '*', | ||||
| } | ||||
|  | ||||
|  | ||||
| class User(BaseModel): | ||||
|     id: int | ||||
|     ip_address: IPvAnyNetwork | ||||
|     created: Date | ||||
|     progress: int | ||||
|     colors: list[int] | ||||
|  | ||||
|  | ||||
| class Pixel(BaseModel): | ||||
|     x: int = Field( | ||||
|         ..., | ||||
|         ge=0, | ||||
|         le=area_width - 1, | ||||
|     ) | ||||
|     y: int = Field( | ||||
|         ..., | ||||
|         ge=0, | ||||
|         le=area_height - 1, | ||||
|     ) | ||||
|     color: int = Field( | ||||
|         ..., | ||||
|         ge=0x000000, | ||||
|         le=0xFFFFFF, | ||||
|     ) | ||||
|     last_update: DateTime | ||||
|  | ||||
|  | ||||
| class Area(BaseModel): | ||||
|     width: int = area_width | ||||
|     height: int = area_height | ||||
|     pixel_size: int = pixel_size | ||||
|     pixels: list[Pixel] | ||||
|  | ||||
|  | ||||
| class RequestsFrequency(BaseModel): | ||||
|     per_second: int | ||||
|     per_minute: int | ||||
|     per_hour: int | ||||
|     per_day: int | ||||
							
								
								
									
										6
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| fastapi | ||||
| gunicorn | ||||
| psycopg[binary] | ||||
| pydantic | ||||
| uvicorn | ||||
| websockets | ||||
							
								
								
									
										221
									
								
								static/scripts/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								static/scripts/main.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,221 @@ | ||||
| const apiURL = new URL('http://localhost:8000'); | ||||
| const wsURL = new URL('ws://localhost:8000/ws/v1/pixels'); | ||||
|  | ||||
| class ColorBar { | ||||
|     #colorPicker; | ||||
|  | ||||
|     constructor() { | ||||
|         this.#colorPicker = document.querySelector('#color-picker'); | ||||
|     } | ||||
|  | ||||
|     get hex() { | ||||
|         return this.#colorPicker.value.substring(1, 7); | ||||
|     }; | ||||
|  | ||||
|     get decimal() { | ||||
|         return parseInt(this.hex, 16); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| class BattleField { | ||||
|     WIDTH$U = 64; | ||||
|     HEIGHT$U = 32; | ||||
|     PIXEL_SIZE$PX = 96; | ||||
|     BORDER_SIZE$PX = 4; | ||||
|     BACKGROUND_COLOR$HEX = '#FFFFFFFF'; | ||||
|     BORDER_COLOR$HEX = '#00000010'; | ||||
|  | ||||
|     #colorBar; | ||||
|     #main; | ||||
|     #canvas; | ||||
|     #context; | ||||
|  | ||||
|     constructor() { | ||||
|         this.#colorBar = new ColorBar(); | ||||
|         this.#main = document.querySelector('#main'); | ||||
|         this.#canvas = document.querySelector('#battlefield'); | ||||
|         this.#context = this.#canvas.getContext('2d'); | ||||
|         this.#canvas.width = this.WIDTH$U * this.PIXEL_SIZE$PX; | ||||
|         this.#canvas.height = this.HEIGHT$U * this.PIXEL_SIZE$PX; | ||||
|  | ||||
|         this.#main.onpointerdown = (event) => { | ||||
|             const movement = () => Math.sqrt(Math.pow(xd$px, 2) + Math.pow(yd$px, 2)) / window.innerWidth > .005; | ||||
|  | ||||
|             let x0$pct = event.pageX * 100 / window.innerWidth; | ||||
|             let y0$pct = event.pageY * 100 / window.innerHeight; | ||||
|             let x0$px = event.pageX; | ||||
|             let y0$px = event.pageY; | ||||
|             let xd$px = 0; | ||||
|             let yd$px = 0; | ||||
|  | ||||
|             this.#main.onpointermove = (event) => { | ||||
|                 if (movement()) document.body.classList.add('grabbing'); | ||||
|                 this.x += event.pageX * 100 / window.innerWidth - x0$pct; | ||||
|                 this.y += event.pageY * 100 / window.innerHeight - y0$pct; | ||||
|                 x0$pct = event.pageX * 100 / window.innerWidth; | ||||
|                 y0$pct = event.pageY * 100 / window.innerHeight; | ||||
|                 xd$px += Math.abs(x0$px - event.pageX); | ||||
|                 yd$px += Math.abs(y0$px - event.pageY); | ||||
|             }; | ||||
|  | ||||
|             this.#main.onpointerup = () => { | ||||
|                 this.#main.onpointermove = null; | ||||
|                 this.#main.onpointerup = null; | ||||
|                 document.body.classList.remove('grabbing'); | ||||
|                 if (movement()) return; | ||||
|                 const xhr = new XMLHttpRequest(); | ||||
|                 const url = new URL(apiURL); | ||||
|                 url.pathname = '/api/v1/pixels'; | ||||
|                 xhr.open('PUT', url); | ||||
|                 xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); | ||||
|                 xhr.send( | ||||
|                     JSON.stringify({ | ||||
|                         x: Math.floor((event.pageX - this.#canvas.offsetLeft) / this.scale / this.PIXEL_SIZE$PX + this.WIDTH$U / 2), | ||||
|                         y: Math.floor((event.pageY - this.#canvas.offsetTop) / this.scale / this.PIXEL_SIZE$PX + this.HEIGHT$U / 2), | ||||
|                         color: this.#colorBar.decimal, | ||||
|                     }) | ||||
|                 ); | ||||
|             }; | ||||
|         }; | ||||
|  | ||||
|         document.onwheel = (event) => { | ||||
|             if (event.deltaY > 0) this.scale += .1; | ||||
|             if (event.deltaY < 0) this.scale -= .1; | ||||
|         }; | ||||
|  | ||||
|         for (let x = 0; x < this.WIDTH$U; x++) { | ||||
|             for (let y = 0; y < this.HEIGHT$U; y++) { | ||||
|                 this.setPixel( | ||||
|                     x, | ||||
|                     y, | ||||
|                     this.BACKGROUND_COLOR$HEX, | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     get x() { | ||||
|         return Number(this.#canvas.style.left.match(/^(.*)%$/)[1]); | ||||
|     }; | ||||
|  | ||||
|     set x(value) { | ||||
|         // const min$pct = -100 * this.scale; | ||||
|         // const max$pct = 100 * this.scale + 100; | ||||
|         // if (value < min$pct) { | ||||
|         //     this.#canvas.style.left = `${min$pct}%`; | ||||
|         //     return; | ||||
|         // } | ||||
|         // if (value > max$pct) { | ||||
|         //     this.#canvas.style.left = `${max$pct}%`; | ||||
|         //     return; | ||||
|         // } | ||||
|         this.#canvas.style.left = `${value}%`; | ||||
|     }; | ||||
|  | ||||
|     get y() { | ||||
|         return Number(this.#canvas.style.top.match(/^(.*)%$/)[1]); | ||||
|     }; | ||||
|  | ||||
|     set y(value) { | ||||
|         // const min$pct = -100 * this.scale; | ||||
|         // const max$pct = 100 * this.scale + 100; | ||||
|         // if (value < min$pct) { | ||||
|         //     this.#canvas.style.top = `${min$pct}%`; | ||||
|         //     return; | ||||
|         // } | ||||
|         // if (value > max$pct) { | ||||
|         //     this.#canvas.style.top = `${max$pct}%`; | ||||
|         //     return; | ||||
|         // } | ||||
|         this.#canvas.style.top = `${value}%`; | ||||
|     }; | ||||
|  | ||||
|     get scale() { | ||||
|         return Number(this.#canvas.style.scale); | ||||
|     }; | ||||
|  | ||||
|     set scale(value) { | ||||
|         if (value >= .2 && value <= 1) { | ||||
|             this.#canvas.style.scale = String(value); | ||||
|  | ||||
|             // TODO(костыль) | ||||
|             // this.x = this.x; | ||||
|             // this.y = this.y; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     fill() { | ||||
|         const xhr = new XMLHttpRequest(); | ||||
|         const url = new URL(apiURL); | ||||
|         url.pathname = '/api/v1/pixels'; | ||||
|         xhr.onload = () => { | ||||
|             if (xhr.status !== 200) return; | ||||
|             const response = JSON.parse(xhr.response); | ||||
|             for (const pixel of response.pixels) { | ||||
|                 this.setPixel( | ||||
|                     pixel.x, | ||||
|                     pixel.y, | ||||
|                     decimalToHex(pixel.color), | ||||
|                 ); | ||||
|             } | ||||
|         }; | ||||
|         xhr.open('GET', url); | ||||
|         xhr.send(); | ||||
|     }; | ||||
|  | ||||
|     setPixel(x, y, color) { | ||||
|         let leftPadding = x === 0 ? this.BORDER_SIZE$PX : 0; | ||||
|         let topPadding = y === 0 ? this.BORDER_SIZE$PX : 0; | ||||
|         this.#context.fillStyle = this.BACKGROUND_COLOR$HEX; | ||||
|         this.#context.fillRect( | ||||
|             x * this.PIXEL_SIZE$PX, | ||||
|             y * this.PIXEL_SIZE$PX, | ||||
|             this.PIXEL_SIZE$PX, | ||||
|             this.PIXEL_SIZE$PX, | ||||
|         ); | ||||
|         this.#context.fillStyle = this.BORDER_COLOR$HEX; | ||||
|         this.#context.fillRect( | ||||
|             x * this.PIXEL_SIZE$PX, | ||||
|             y * this.PIXEL_SIZE$PX, | ||||
|             this.PIXEL_SIZE$PX, | ||||
|             this.PIXEL_SIZE$PX, | ||||
|         ); | ||||
|         this.#context.fillStyle = color; | ||||
|         this.#context.fillRect( | ||||
|             x * this.PIXEL_SIZE$PX + leftPadding, | ||||
|             y * this.PIXEL_SIZE$PX + topPadding, | ||||
|             this.PIXEL_SIZE$PX - this.BORDER_SIZE$PX - leftPadding, | ||||
|             this.PIXEL_SIZE$PX - this.BORDER_SIZE$PX - topPadding, | ||||
|         ); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| const decimalToHex = (decimal) => `#${decimal.toString(16)}`; | ||||
|  | ||||
| window.addEventListener('DOMContentLoaded', () => { | ||||
|     const battlefield = new BattleField(); | ||||
|     battlefield.fill(); | ||||
|  | ||||
|     const wsConnect = () => { | ||||
|         const ws = new WebSocket(wsURL); | ||||
|  | ||||
|         ws.onmessage = (event) => { | ||||
|             const pixel = JSON.parse(event.data); | ||||
|             battlefield.setPixel( | ||||
|                 pixel.x, | ||||
|                 pixel.y, | ||||
|                 decimalToHex(pixel.color), | ||||
|             ); | ||||
|         }; | ||||
|  | ||||
|         ws.onclose = (event) => { | ||||
|             setTimeout(wsConnect, 5000); | ||||
|         } | ||||
|  | ||||
|         ws.onerror = () => { | ||||
|             ws.close(); | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     wsConnect(); | ||||
| }); | ||||
							
								
								
									
										54
									
								
								static/styles/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								static/styles/main.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| html { | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| main { | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
| } | ||||
|  | ||||
| #battlefield { | ||||
|     position: fixed; | ||||
|     translate: -50% -50%; | ||||
|     pointer-events: none; | ||||
|     transition: scale .2s; | ||||
| } | ||||
|  | ||||
| .grabbing { | ||||
|     cursor: grabbing !important; | ||||
| } | ||||
|  | ||||
| #color-bar { | ||||
|     position: fixed; | ||||
|     right: 1em; | ||||
|     bottom: 1em; | ||||
|     left: 1em; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 1em; | ||||
|     border-radius: 1em; | ||||
|     background-color: transparent; | ||||
| } | ||||
|  | ||||
| #color-picker { | ||||
|     position: absolute; | ||||
|     visibility: hidden; | ||||
| } | ||||
|  | ||||
| label[for="color-picker"] { | ||||
|     width: 2em; | ||||
|     height: 2em; | ||||
|     border-radius: .5em; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .color { | ||||
|     margin-left: .5em; | ||||
|     width: 2em; | ||||
|     height: 2em; | ||||
|     border-radius: .5em; | ||||
|     cursor: pointer; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user