Разработан веб-сервер и игровое поле
Этот коммит содержится в:
Коммит
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