docs: create agent.md and CONTEXT.md with full project structure
- Add agent.md for high-level project overview and feature list - Add CONTEXT.md for detailed development guide and logic explanation - Refactor project structure: move config.js to constants.js and update references - Improve camera tracking logic with Lerp and jittering prevention - Update ArenaScene to support intelligent combat observation and minimap viewport - Fine-tune combat mechanics: optimize projectile spawn points and hit detection
This commit is contained in:
+187
-19
@@ -1,7 +1,22 @@
|
||||
import Phaser from "phaser";
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
CAMERA_MAX_ZOOM,
|
||||
CAMERA_MIN_ZOOM,
|
||||
CAMERA_ZOOM_STEP,
|
||||
MINIMAP_ALPHA,
|
||||
MINIMAP_MARGIN,
|
||||
MINIMAP_VIEWPORT_SIZE,
|
||||
MINIMAP_VIEW_FRAME_OUTLINE,
|
||||
MINIMAP_VIEW_FRAME_STROKE,
|
||||
SPECTATOR_CAMERA_LERP,
|
||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
||||
SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD,
|
||||
SPECTATOR_LATE_FIGHT_ZOOM,
|
||||
} from "../constants.js";
|
||||
import { drawArena } from "./arenaRenderer.js";
|
||||
import { clearCombatObjects, updateFighter } from "./combat.js";
|
||||
import { ARENA_SIZE } from "./config.js";
|
||||
import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js";
|
||||
import { createFighter, syncFighterHud } from "./fighterFactory.js";
|
||||
import { fighterManifest } from "./fighterManifest.js";
|
||||
@@ -29,6 +44,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
document.querySelector(".arena-shell").appendChild(banner);
|
||||
}
|
||||
};
|
||||
this.observedCombat = [];
|
||||
this.teams = [];
|
||||
}
|
||||
|
||||
@@ -44,20 +60,28 @@ export class ArenaScene extends Phaser.Scene {
|
||||
createFighterAnimations(this, fighterManifest);
|
||||
|
||||
// 미니맵 카메라 설정
|
||||
this.minimapCamera = this.cameras.add(10, 10, 150, 150).setZoom(150 / ARENA_SIZE).setName('minimap');
|
||||
this.minimapCamera = this.cameras
|
||||
.add(MINIMAP_MARGIN, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE)
|
||||
.setZoom(MINIMAP_VIEWPORT_SIZE / ARENA_SIZE)
|
||||
.setName("minimap");
|
||||
this.minimapCamera.setBackgroundColor(0x000000);
|
||||
this.minimapCamera.scrollX = 0;
|
||||
this.minimapCamera.scrollY = 0;
|
||||
this.minimapCamera.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
this.minimapViewportFrame = this.add.graphics().setDepth(10);
|
||||
this.cameras.main.ignore(this.minimapViewportFrame);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
||||
|
||||
// 마우스 휠로 줌 조절
|
||||
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
||||
const zoomStep = 0.1;
|
||||
const newZoom = Phaser.Math.Clamp(this.cameras.main.zoom + (deltaY > 0 ? -zoomStep : zoomStep), 1, 3);
|
||||
this.cameras.main.setZoom(newZoom);
|
||||
const newZoom = Phaser.Math.Clamp(
|
||||
this.cameras.main.zoom + (deltaY > 0 ? -CAMERA_ZOOM_STEP : CAMERA_ZOOM_STEP),
|
||||
CAMERA_MIN_ZOOM,
|
||||
CAMERA_MAX_ZOOM,
|
||||
);
|
||||
this.setMainCameraZoom(newZoom);
|
||||
|
||||
|
||||
// 확대 시 미니맵 표시
|
||||
this.minimapCamera.setAlpha(newZoom > 1 ? 0.8 : 0);
|
||||
});
|
||||
|
||||
this.ready = true;
|
||||
@@ -79,6 +103,9 @@ export class ArenaScene extends Phaser.Scene {
|
||||
|
||||
this.matchId += 1;
|
||||
this.matchOver = false;
|
||||
this.observedCombat = [];
|
||||
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
|
||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
clearCombatObjects(this);
|
||||
this.fighters.forEach((fighter) => fighter.destroy());
|
||||
this.teams = matchSetup.teams;
|
||||
@@ -96,25 +123,29 @@ update(time) {
|
||||
this.fighters.forEach(syncFighterHud);
|
||||
|
||||
if (this.matchOver) {
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
// 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동
|
||||
if (this.cameras.main.zoom > 1) {
|
||||
const aliveFighters = this.fighters.filter(f => !f.isDead);
|
||||
if (aliveFighters.length > 0) {
|
||||
const avgX = aliveFighters.reduce((sum, f) => sum + f.x, 0) / aliveFighters.length;
|
||||
const avgY = aliveFighters.reduce((sum, f) => sum + f.y, 0) / aliveFighters.length;
|
||||
const livingFighterCount = this.fighters.filter(isLivingFighter).length;
|
||||
const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount);
|
||||
|
||||
if (forcedSpectatorZoom) {
|
||||
this.setMainCameraZoom(forcedSpectatorZoom);
|
||||
|
||||
const combatCenter = this.getObservedCombatCenter();
|
||||
if (combatCenter) {
|
||||
|
||||
// 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용
|
||||
const targetX = Math.round(avgX);
|
||||
const targetY = Math.round(avgY);
|
||||
const targetX = Math.round(combatCenter.x);
|
||||
const targetY = Math.round(combatCenter.y);
|
||||
|
||||
// 현재 카메라 위치에서 목표 위치로 서서히 이동 (0.1은 따라가는 속도)
|
||||
this.cameras.main.scrollX += (targetX - this.cameras.main.centerX) * 0.1;
|
||||
this.cameras.main.scrollY += (targetY - this.cameras.main.centerY) * 0.1;
|
||||
// Move from the current world-space camera center toward the target.
|
||||
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
|
||||
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
|
||||
}
|
||||
} else {
|
||||
} else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) {
|
||||
// 줌이 1일 때는 경기장 중앙에 고정
|
||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
}
|
||||
@@ -125,8 +156,87 @@ update(time) {
|
||||
this.finishMatch();
|
||||
});
|
||||
});
|
||||
|
||||
this.updateMinimapViewportFrame();
|
||||
}
|
||||
|
||||
setMainCameraZoom(zoom) {
|
||||
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
||||
|
||||
this.cameras.main.setZoom(newZoom);
|
||||
|
||||
if (newZoom === CAMERA_MIN_ZOOM) {
|
||||
this.observedCombat = [];
|
||||
}
|
||||
|
||||
this.minimapCamera.setAlpha(newZoom > CAMERA_MIN_ZOOM ? MINIMAP_ALPHA : 0);
|
||||
this.updateMinimapViewportFrame();
|
||||
}
|
||||
|
||||
updateMinimapViewportFrame() {
|
||||
if (!this.minimapViewportFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = this.cameras.main;
|
||||
|
||||
this.minimapViewportFrame.clear();
|
||||
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA_MIN_ZOOM);
|
||||
|
||||
if (camera.zoom <= CAMERA_MIN_ZOOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE);
|
||||
const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE);
|
||||
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
|
||||
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
|
||||
const cameraMidX = scrollX + camera.width / 2;
|
||||
const cameraMidY = scrollY + camera.height / 2;
|
||||
const frameX = Phaser.Math.Clamp(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth);
|
||||
const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight);
|
||||
|
||||
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
}
|
||||
|
||||
observeCombat(attacker, defender) {
|
||||
const canObserveCombat = Boolean(
|
||||
getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length),
|
||||
);
|
||||
|
||||
if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isLivingOpponentPair(this.observedCombat) ||
|
||||
this.observedCombat.includes(attacker) ||
|
||||
this.observedCombat.includes(defender)
|
||||
) {
|
||||
this.observedCombat = [attacker, defender];
|
||||
}
|
||||
}
|
||||
|
||||
getObservedCombatCenter() {
|
||||
if (!isLivingOpponentPair(this.observedCombat)) {
|
||||
this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
|
||||
}
|
||||
|
||||
if (!isLivingOpponentPair(this.observedCombat)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [fighterA, fighterB] = this.observedCombat;
|
||||
|
||||
return {
|
||||
x: (fighterA.x + fighterB.x) / 2,
|
||||
y: (fighterA.y + fighterB.y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
updateScoreboard() {
|
||||
const scoreLeft = document.getElementById("score-left");
|
||||
const scoreRight = document.getElementById("score-right");
|
||||
@@ -180,3 +290,61 @@ update(time) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findClosestOpponentPair(fighters) {
|
||||
let closestPair;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
fighters.forEach((fighter, index) => {
|
||||
if (!isLivingFighter(fighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
|
||||
const candidate = fighters[candidateIndex];
|
||||
|
||||
if (!isLivingOpponentPair([fighter, candidate])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestPair = [fighter, candidate];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return closestPair;
|
||||
}
|
||||
|
||||
function getForcedSpectatorZoom(livingFighterCount) {
|
||||
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||
return SPECTATOR_FINAL_FIGHT_ZOOM;
|
||||
}
|
||||
|
||||
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
|
||||
return SPECTATOR_LATE_FIGHT_ZOOM;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLivingOpponentPair(pair) {
|
||||
if (pair.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [fighterA, fighterB] = pair;
|
||||
|
||||
return (
|
||||
isLivingFighter(fighterA) &&
|
||||
isLivingFighter(fighterB) &&
|
||||
fighterA.team.id !== fighterB.team.id
|
||||
);
|
||||
}
|
||||
|
||||
function isLivingFighter(fighter) {
|
||||
return fighter?.active && !fighter.isDead;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "./config.js";
|
||||
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../constants.js";
|
||||
|
||||
export function drawArena(scene) {
|
||||
const graphics = scene.add.graphics();
|
||||
@@ -27,4 +27,3 @@ export function drawArena(scene) {
|
||||
graphics.lineStyle(2, 0xd3bd72, 0.35);
|
||||
graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24);
|
||||
}
|
||||
|
||||
|
||||
+60
-18
@@ -3,13 +3,21 @@ import {
|
||||
ATTACK_COOLDOWN,
|
||||
ATTACK_RANGE,
|
||||
FIGHTER_SCALE,
|
||||
MELEE_HIT_DELAY,
|
||||
MELEE_CRITICAL_CHANCE,
|
||||
MOVE_SPEED,
|
||||
PROJECTILE_BODY_OFFSET,
|
||||
PROJECTILE_FIRE_DELAY,
|
||||
PROJECTILE_HIT_PADDING,
|
||||
PROJECTILE_HIT_RADIUS,
|
||||
PROJECTILE_LIFETIME,
|
||||
PROJECTILE_SPAWN_DISTANCE,
|
||||
PROJECTILE_SPEED,
|
||||
SPELL_CAST_DELAY,
|
||||
SPELL_HIT_DELAY,
|
||||
RANGED_CRITICAL_CHANCE,
|
||||
RANGED_ATTACK_RANGE,
|
||||
} from "./config.js";
|
||||
} from "../constants.js";
|
||||
import {
|
||||
getAttackSpeedMultiplier,
|
||||
getMovementSpeedMultiplier,
|
||||
@@ -21,12 +29,6 @@ import {
|
||||
fighterProjectileKey,
|
||||
} from "./fighterAssets.js";
|
||||
|
||||
const MELEE_HIT_DELAY = 260;
|
||||
const PROJECTILE_FIRE_DELAY = 360;
|
||||
const PROJECTILE_HIT_RADIUS = 8;
|
||||
const SPELL_CAST_DELAY = 340;
|
||||
const SPELL_HIT_DELAY = 160;
|
||||
|
||||
export function updateFighter(scene, fighter, time, onWinner) {
|
||||
const enemy = findNearestEnemy(scene.fighters, fighter);
|
||||
|
||||
@@ -66,6 +68,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
const attack = createAttackProfile(attacker);
|
||||
attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN);
|
||||
attacker.isLocked = true;
|
||||
scene.observeCombat?.(attacker, defender);
|
||||
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
|
||||
|
||||
switch (getCombatType(attacker)) {
|
||||
@@ -115,19 +118,32 @@ function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
}
|
||||
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
const direction = defender.x < attacker.x ? -1 : 1;
|
||||
const defenderHitPoint = fighterHitPoint(defender);
|
||||
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
|
||||
const projectile = scene.physics.add.image(
|
||||
attacker.x + direction * 42,
|
||||
attacker.y + 4,
|
||||
projectileOrigin.x,
|
||||
projectileOrigin.y,
|
||||
fighterProjectileKey(attacker.skin),
|
||||
);
|
||||
projectile.setDepth(3);
|
||||
projectile.setScale(2);
|
||||
projectile.body.setCircle(PROJECTILE_HIT_RADIUS, 8, 8);
|
||||
projectile.setRotation(Phaser.Math.Angle.Between(projectile.x, projectile.y, defender.x, defender.y));
|
||||
scene.physics.moveToObject(
|
||||
projectile.body.setCircle(
|
||||
PROJECTILE_HIT_RADIUS,
|
||||
PROJECTILE_BODY_OFFSET,
|
||||
PROJECTILE_BODY_OFFSET,
|
||||
);
|
||||
projectile.setRotation(
|
||||
Phaser.Math.Angle.Between(
|
||||
projectile.x,
|
||||
projectile.y,
|
||||
defenderHitPoint.x,
|
||||
defenderHitPoint.y,
|
||||
),
|
||||
);
|
||||
scene.physics.moveTo(
|
||||
projectile,
|
||||
defender,
|
||||
defenderHitPoint.x,
|
||||
defenderHitPoint.y,
|
||||
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
|
||||
);
|
||||
trackCombatObject(scene, projectile);
|
||||
@@ -260,6 +276,32 @@ function isAttackValid(scene, attacker, defender, matchId) {
|
||||
);
|
||||
}
|
||||
|
||||
function fighterHitPoint(fighter) {
|
||||
if (!fighter.body) {
|
||||
return { x: fighter.x, y: fighter.y };
|
||||
}
|
||||
|
||||
return {
|
||||
x: fighter.body.center.x,
|
||||
y: fighter.body.center.y,
|
||||
};
|
||||
}
|
||||
|
||||
function projectileSpawnPoint(attacker, target) {
|
||||
const direction = new Phaser.Math.Vector2(target.x - attacker.x, target.y - attacker.y);
|
||||
|
||||
if (direction.lengthSq() === 0) {
|
||||
return { x: attacker.x, y: attacker.y };
|
||||
}
|
||||
|
||||
direction.normalize();
|
||||
|
||||
return {
|
||||
x: attacker.x + direction.x * PROJECTILE_SPAWN_DISTANCE,
|
||||
y: attacker.y + direction.y * PROJECTILE_SPAWN_DISTANCE,
|
||||
};
|
||||
}
|
||||
|
||||
function projectilePathHitsDefender(projectile, defender) {
|
||||
if (!defender.body) {
|
||||
return false;
|
||||
@@ -272,10 +314,10 @@ function projectilePathHitsDefender(projectile, defender) {
|
||||
projectile.y,
|
||||
);
|
||||
const defenderHitArea = new Phaser.Geom.Rectangle(
|
||||
defender.body.x - PROJECTILE_HIT_RADIUS,
|
||||
defender.body.y - PROJECTILE_HIT_RADIUS,
|
||||
defender.body.width + PROJECTILE_HIT_RADIUS * 2,
|
||||
defender.body.height + PROJECTILE_HIT_RADIUS * 2,
|
||||
defender.body.x - PROJECTILE_HIT_PADDING,
|
||||
defender.body.y - PROJECTILE_HIT_PADDING,
|
||||
defender.body.width + PROJECTILE_HIT_PADDING * 2,
|
||||
defender.body.height + PROJECTILE_HIT_PADDING * 2,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export const GRID_SIZE = 50;
|
||||
export const TILE_SIZE = 64;
|
||||
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||
|
||||
export const ATTACK_RANGE = 84;
|
||||
export const ATTACK_COOLDOWN = 840;
|
||||
export const DEFAULT_TEAM_SIZE = 5;
|
||||
export const FIGHTER_SCALE = 3;
|
||||
export const MAX_TEAM_SIZE = 100;
|
||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
||||
export const MOVE_SPEED = 148;
|
||||
export const PROJECTILE_LIFETIME = 1800;
|
||||
export const PROJECTILE_SPEED = 420;
|
||||
export const RANGED_CRITICAL_CHANCE = 0;
|
||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
||||
|
||||
export const TEAM_COLORS = [
|
||||
"#da6a48",
|
||||
"#5fb4d9",
|
||||
"#9bd15a",
|
||||
"#d6a94a",
|
||||
"#d477b8",
|
||||
"#7f90e8",
|
||||
"#63c5a6",
|
||||
"#d98755",
|
||||
];
|
||||
@@ -1,15 +1,4 @@
|
||||
const animationOptions = {
|
||||
attack: { frameRate: 15, repeat: 0 },
|
||||
attack02: { frameRate: 15, repeat: 0 },
|
||||
attack03: { frameRate: 15, repeat: 0 },
|
||||
block: { frameRate: 13, repeat: 0 },
|
||||
death: { frameRate: 11, repeat: 0 },
|
||||
heal: { frameRate: 13, repeat: 0 },
|
||||
hurt: { frameRate: 13, repeat: 0 },
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
walk02: { frameRate: 10, repeat: -1 },
|
||||
};
|
||||
import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js";
|
||||
|
||||
export function fighterSheetKey(skin, action) {
|
||||
return `${skin.key}-${action}`;
|
||||
@@ -54,7 +43,7 @@ export function createFighterAnimations(scene, skins) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { frameRate, repeat } = animationOptions[action];
|
||||
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
|
||||
|
||||
scene.anims.create({
|
||||
key,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from "phaser";
|
||||
import { FIGHTER_SCALE } from "./config.js";
|
||||
import { FIGHTER_SCALE } from "../constants.js";
|
||||
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
|
||||
|
||||
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MAX_TEAM_SIZE,
|
||||
TEAM_COLORS,
|
||||
TILE_SIZE,
|
||||
} from "./config.js";
|
||||
} from "../constants.js";
|
||||
|
||||
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||
|
||||
Reference in New Issue
Block a user