Разработан веб-сервер и игровое поле
Этот коммит содержится в:
Коммит
55abdb2867
1
app/__init__.py
Обычный файл
1
app/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import app
|
151
app/main.py
Обычный файл
151
app/main.py
Обычный файл
@ -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
Обычный файл
1
config/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import Database
|
28
config/main.py
Обычный файл
28
config/main.py
Обычный файл
@ -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
Обычный файл
107
database.sql
Обычный файл
@ -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
Обычный файл
1
database/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import get_pixels, put_pixel, get_requests_frequency, put_request
|
204
database/main.py
Обычный файл
204
database/main.py
Обычный файл
@ -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
Обычный файл
27
index.html
Обычный файл
@ -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
Обычный файл
5
main.py
Обычный файл
@ -0,0 +1,5 @@
|
|||||||
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
1
models/__init__.py
Обычный файл
1
models/__init__.py
Обычный файл
@ -0,0 +1 @@
|
|||||||
|
from .main import area_width, area_height, pixel_size, headers, User, Pixel, Area, RequestsFrequency
|
53
models/main.py
Обычный файл
53
models/main.py
Обычный файл
@ -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
Обычный файл
6
requirements.txt
Обычный файл
@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
gunicorn
|
||||||
|
psycopg[binary]
|
||||||
|
pydantic
|
||||||
|
uvicorn
|
||||||
|
websockets
|
221
static/scripts/main.js
Обычный файл
221
static/scripts/main.js
Обычный файл
@ -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
Обычный файл
54
static/styles/main.css
Обычный файл
@ -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;
|
||||||
|
}
|
Загрузка…
Ссылка в новой задаче
Block a user