stuff
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
import kaplay from 'kaplay'
|
||||
import type { GameObj } from 'kaplay'
|
||||
|
||||
import {
|
||||
generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS,
|
||||
} from './sprites'
|
||||
import { ARENA_THEMES, spriteAnims } from './fight/constants'
|
||||
import { drawArenaDecor } from './fight/arena-renderer'
|
||||
import { GROUND_Y_RATIO, FIGHTER_BASE_SCALE } from './fight/config'
|
||||
import {
|
||||
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxBlock, sfxExplosion,
|
||||
startMusic, stopMusic,
|
||||
} from './audio'
|
||||
import { spawnSparks as _spawnSparks } from './fight/particles'
|
||||
|
||||
import type {
|
||||
ArcadeConfig, ArcadeSceneController, ArcadeCallbacks,
|
||||
FighterInstance, FighterState, PlayerInput, InputEvent,
|
||||
} from './arcade/types'
|
||||
type KaplayInstance = ReturnType<typeof kaplay>
|
||||
import {
|
||||
MAX_HP, FIGHTER_SCALE, CANVAS_WIDTH, CANVAS_HEIGHT,
|
||||
P1_START_X, P2_START_X,
|
||||
HIT_SHAKE_LIGHT, HIT_SHAKE_HEAVY, HIT_SHAKE_SPECIAL,
|
||||
HIT_FLASH_DURATION,
|
||||
SPARK_COUNT_LIGHT, SPARK_COUNT_HEAVY, SPARK_COUNT_SPECIAL,
|
||||
ROUND_START_DELAY, ROUND_END_DELAY, KO_SLOWMO_DURATION,
|
||||
COMBO_BUFFER_SIZE,
|
||||
} from './arcade/constants'
|
||||
import { updatePhysics, applyMovement, enforcePushBox, updateFacing } from './arcade/physics'
|
||||
import { updateStateMachine, startComboMove, enterHitstun, enterBlockstun, enterKO, enterWin } from './arcade/state-machine'
|
||||
import { checkHit, applyHit, resetCombo } from './arcade/combat'
|
||||
import type { HitResult } from './arcade/combat'
|
||||
import { MOVES } from './arcade/moves'
|
||||
import { spawnFireball, updateProjectiles, clearAllProjectiles } from './arcade/projectiles'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Convert CSS color to Kaplay Color
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function safeColor(k: KaplayInstance, color: string) {
|
||||
if (color.startsWith('#')) {
|
||||
try { return k.Color.fromHex(color) } catch { /* fall through */ }
|
||||
}
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = 1; cv.height = 1
|
||||
const cx = cv.getContext('2d')!
|
||||
cx.fillStyle = color
|
||||
cx.fillRect(0, 0, 1, 1)
|
||||
const [r, g, b] = cx.getImageData(0, 0, 1, 1).data
|
||||
return k.Color.fromArray([r, g, b])
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Arcade Scene Factory
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export async function createArcadeScene(config: ArcadeConfig): Promise<ArcadeSceneController> {
|
||||
const { canvas, player1, player2, arena, rounds, roundTime } = config
|
||||
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
|
||||
|
||||
// --- Kaplay init ---
|
||||
const k = kaplay({
|
||||
canvas,
|
||||
width: canvas.width || CANVAS_WIDTH,
|
||||
height: canvas.height || CANVAS_HEIGHT,
|
||||
background: theme.bg,
|
||||
global: false,
|
||||
scale: 1,
|
||||
crisp: true,
|
||||
texFilter: 'nearest',
|
||||
})
|
||||
|
||||
const W = k.width()
|
||||
const H = k.height()
|
||||
const GROUND_Y = H * GROUND_Y_RATIO
|
||||
|
||||
// --- Timer management ---
|
||||
const cleanupTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
function trackedTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms)
|
||||
cleanupTimers.add(id)
|
||||
return id
|
||||
}
|
||||
function trackedInterval(fn: () => void, ms: number) {
|
||||
const id = setInterval(fn, ms)
|
||||
cleanupTimers.add(id)
|
||||
return id
|
||||
}
|
||||
function clearTracked(id: ReturnType<typeof setTimeout>) {
|
||||
clearInterval(id); clearTimeout(id); cleanupTimers.delete(id)
|
||||
}
|
||||
|
||||
const fightCtx = { k, W, H, theme, trackedTimeout, trackedInterval, clearTracked, safeColor: (c: string) => safeColor(k, c) }
|
||||
const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color)
|
||||
|
||||
// --- Load sprites ---
|
||||
const colorsA = getBotColors(player1.seed)
|
||||
const colorsB = getBotColors(player2.seed)
|
||||
|
||||
const sheetA = generateSpriteSheet(player1.seed, player1.tier, colorsA.primary, colorsA.secondary, player1.archetype, player1.customization)
|
||||
const sheetB = generateSpriteSheet(player2.seed, player2.tier, colorsB.primary, colorsB.secondary, player2.archetype, player2.customization)
|
||||
|
||||
await Promise.all([
|
||||
k.loadSprite('p1', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
k.loadSprite('p2', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
])
|
||||
|
||||
// --- Callbacks ---
|
||||
const callbacks: Partial<ArcadeCallbacks> = {}
|
||||
|
||||
// --- Match state ---
|
||||
let p1Wins = 0
|
||||
let p2Wins = 0
|
||||
let currentRound = 0
|
||||
let roundTimer = roundTime
|
||||
let roundTimerHandle: ReturnType<typeof setInterval> | null = null
|
||||
let roundActive = false
|
||||
let paused = false
|
||||
let matchOver = false
|
||||
|
||||
// --- Input buffers for combo detection ---
|
||||
const p1InputBuffer: InputEvent[] = []
|
||||
const p2InputBuffer: InputEvent[] = []
|
||||
|
||||
// --- Create fighters ---
|
||||
function createFighter(spriteName: string, startX: number, player: 1 | 2, name: string): FighterInstance {
|
||||
const obj = k.add([
|
||||
k.sprite(spriteName, { anim: 'idle' }),
|
||||
k.pos(startX, GROUND_Y),
|
||||
k.anchor('bot'),
|
||||
k.scale(player === 1 ? FIGHTER_SCALE : -FIGHTER_SCALE, FIGHTER_SCALE),
|
||||
k.z(10),
|
||||
k.opacity(1),
|
||||
k.color(safeColor(k, '#ffffff')),
|
||||
k.rotate(0),
|
||||
])
|
||||
|
||||
return {
|
||||
obj,
|
||||
physics: { vx: 0, vy: 0, grounded: true, facingRight: player === 1 },
|
||||
combat: {
|
||||
hp: MAX_HP, maxHp: MAX_HP,
|
||||
state: 'idle', stateTimer: 0,
|
||||
stunTimer: 0, blockTimer: 0,
|
||||
comboCount: 0, comboDamage: 0,
|
||||
attackFrame: 0, currentMove: null,
|
||||
hasHitThisAttack: false,
|
||||
},
|
||||
player,
|
||||
name,
|
||||
input: { up: false, down: false, left: false, right: false, punch: false, kick: false },
|
||||
}
|
||||
}
|
||||
|
||||
let fighter1: FighterInstance
|
||||
let fighter2: FighterInstance
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Scene Setup
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
k.scene('arcade', () => {
|
||||
// Draw arena background
|
||||
drawArenaDecor({
|
||||
k, W, H, GROUND_Y, arena,
|
||||
theme, safeColor,
|
||||
})
|
||||
|
||||
// Ground line
|
||||
k.add([
|
||||
k.rect(W, 2),
|
||||
k.pos(0, GROUND_Y),
|
||||
k.color(safeColor(k, theme.ground)),
|
||||
k.z(5),
|
||||
k.opacity(0.5),
|
||||
])
|
||||
|
||||
// Create fighters
|
||||
fighter1 = createFighter('p1', P1_START_X, 1, player1.name)
|
||||
fighter2 = createFighter('p2', P2_START_X, 2, player2.name)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Main Game Loop
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
k.onUpdate(() => {
|
||||
if (paused || !roundActive || matchOver) return
|
||||
|
||||
const dt = k.dt()
|
||||
const fighters: [FighterInstance, FighterInstance] = [fighter1, fighter2]
|
||||
|
||||
for (const fighter of fighters) {
|
||||
const inputBuffer = fighter.player === 1 ? p1InputBuffer : p2InputBuffer
|
||||
|
||||
// State machine update (may trigger combo)
|
||||
const comboMove = updateStateMachine(fighter, inputBuffer)
|
||||
if (comboMove) {
|
||||
startComboMove(fighter, comboMove)
|
||||
// Fireball spawns a projectile instead of using a hitbox
|
||||
if (comboMove === 'fireball') {
|
||||
spawnFireball(k, fighter, (c: string) => safeColor(k, c))
|
||||
sfxSpecial()
|
||||
}
|
||||
}
|
||||
|
||||
// Movement from input
|
||||
applyMovement(fighter, dt)
|
||||
|
||||
// Physics (gravity, velocity, bounds)
|
||||
updatePhysics(fighter, GROUND_Y, dt)
|
||||
}
|
||||
|
||||
// Push-box (prevent overlap)
|
||||
enforcePushBox(fighter1, fighter2)
|
||||
|
||||
// Facing (always face opponent)
|
||||
updateFacing(fighter1, fighter2)
|
||||
|
||||
// --- Hit detection ---
|
||||
for (const [attacker, defender] of [[fighter1, fighter2], [fighter2, fighter1]] as const) {
|
||||
const result = checkHit(attacker, defender)
|
||||
if (result) {
|
||||
processHit(attacker, defender, result)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Projectile updates ---
|
||||
const projHits = updateProjectiles(k, fighters, dt)
|
||||
for (const { target, result } of projHits) {
|
||||
const attacker = target.player === 1 ? fighter2 : fighter1
|
||||
processHit(attacker, target, result)
|
||||
}
|
||||
|
||||
// --- Update animations ---
|
||||
updateAnimation(fighter1)
|
||||
updateAnimation(fighter2)
|
||||
|
||||
// --- HP callback ---
|
||||
callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp)
|
||||
|
||||
// --- Check KO ---
|
||||
if (fighter1.combat.hp <= 0 || fighter2.combat.hp <= 0) {
|
||||
endRound()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Hit Processing
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function processHit(attacker: FighterInstance, defender: FighterInstance, result: HitResult): void {
|
||||
applyHit(attacker, defender, result)
|
||||
|
||||
if (result.type === 'hit') {
|
||||
// Visual and audio feedback
|
||||
const isSpecial = attacker.combat.currentMove && MOVES[attacker.combat.currentMove]?.animation === 'special'
|
||||
const sparkCount = isSpecial ? SPARK_COUNT_SPECIAL : (result.damage >= 70 ? SPARK_COUNT_HEAVY : SPARK_COUNT_LIGHT)
|
||||
const shakeIntensity = isSpecial ? HIT_SHAKE_SPECIAL : (result.damage >= 70 ? HIT_SHAKE_HEAVY : HIT_SHAKE_LIGHT)
|
||||
|
||||
spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, sparkCount, theme.accent)
|
||||
k.shake(shakeIntensity)
|
||||
|
||||
// SFX
|
||||
if (isSpecial) { sfxSpecial() }
|
||||
else if (result.damage >= 70) { sfxKick() }
|
||||
else { sfxPunch() }
|
||||
|
||||
// Hit flash
|
||||
const origOpacity = defender.obj.opacity
|
||||
defender.obj.opacity = 0.4
|
||||
trackedTimeout(() => { if (defender.obj.exists()) defender.obj.opacity = origOpacity }, HIT_FLASH_DURATION * 1000)
|
||||
|
||||
// Enter hitstun
|
||||
enterHitstun(defender, result.hitstun, result.knockbackX, result.knockbackY)
|
||||
|
||||
// Combo notification
|
||||
if (attacker.combat.comboCount >= 2) {
|
||||
callbacks.onCombo?.(attacker.player, attacker.combat.comboCount, attacker.combat.currentMove || 'combo')
|
||||
}
|
||||
|
||||
// Critical hit effect for big damage
|
||||
if (result.damage >= 90) {
|
||||
sfxCritical()
|
||||
}
|
||||
} else {
|
||||
// Blocked
|
||||
sfxBlock()
|
||||
enterBlockstun(defender, result.blockstun, result.knockbackX)
|
||||
spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, 3, '#8888ff')
|
||||
}
|
||||
|
||||
// Reset combo if defender was in idle/walking (new combo chain starting)
|
||||
if (result.type === 'hit' && attacker.combat.comboCount === 1) {
|
||||
resetCombo(attacker)
|
||||
attacker.combat.comboCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Animation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function updateAnimation(fighter: FighterInstance): void {
|
||||
const { combat, obj } = fighter
|
||||
const animMap: Partial<Record<FighterState, string>> = {
|
||||
idle: 'idle',
|
||||
walking: 'idle', // no walk row — idle with movement looks fine at 48px
|
||||
jumping: 'idle', // static pose in air
|
||||
crouching: 'idle', // handled via scale squish below
|
||||
attacking: 'attack',
|
||||
kicking: 'kick',
|
||||
special: 'special',
|
||||
hit: 'hit',
|
||||
knockback: 'knockback',
|
||||
blocking: 'idle', // shield VFX handled separately
|
||||
ko: 'ko',
|
||||
win: 'win',
|
||||
}
|
||||
|
||||
const targetAnim = animMap[combat.state] || 'idle'
|
||||
const currentAnim = obj.curAnim?.()
|
||||
|
||||
// Only change animation if different
|
||||
if (currentAnim !== targetAnim) {
|
||||
obj.play(targetAnim)
|
||||
}
|
||||
|
||||
// Crouch squish effect
|
||||
const baseScaleY = FIGHTER_SCALE
|
||||
if (combat.state === 'crouching' || combat.state === 'blocking') {
|
||||
obj.scale.y = baseScaleY * 0.7
|
||||
} else {
|
||||
obj.scale.y = baseScaleY
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Round Management
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function startRound(): void {
|
||||
currentRound++
|
||||
roundActive = false
|
||||
roundTimer = roundTime
|
||||
|
||||
// Reset fighters to starting positions
|
||||
resetFighter(fighter1, P1_START_X, true)
|
||||
resetFighter(fighter2, P2_START_X, false)
|
||||
|
||||
// Clear projectiles
|
||||
clearAllProjectiles(k)
|
||||
|
||||
// Clear combo buffers
|
||||
p1InputBuffer.length = 0
|
||||
p2InputBuffer.length = 0
|
||||
|
||||
// Countdown then start
|
||||
callbacks.onTimerTick?.(roundTimer)
|
||||
callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp)
|
||||
|
||||
trackedTimeout(() => {
|
||||
roundActive = true
|
||||
startMusic()
|
||||
|
||||
// Round timer
|
||||
roundTimerHandle = trackedInterval(() => {
|
||||
if (paused || !roundActive) return
|
||||
roundTimer--
|
||||
callbacks.onTimerTick?.(roundTimer)
|
||||
|
||||
if (roundTimer <= 0) {
|
||||
endRound()
|
||||
}
|
||||
}, 1000)
|
||||
}, ROUND_START_DELAY * 1000)
|
||||
}
|
||||
|
||||
function endRound(): void {
|
||||
if (!roundActive) return
|
||||
roundActive = false
|
||||
|
||||
if (roundTimerHandle !== null) {
|
||||
clearTracked(roundTimerHandle)
|
||||
roundTimerHandle = null
|
||||
}
|
||||
|
||||
// Determine round winner
|
||||
let roundWinner: 1 | 2 | 0
|
||||
if (fighter1.combat.hp <= 0 && fighter2.combat.hp <= 0) {
|
||||
roundWinner = 0 // draw
|
||||
} else if (fighter1.combat.hp <= 0) {
|
||||
roundWinner = 2
|
||||
} else if (fighter2.combat.hp <= 0) {
|
||||
roundWinner = 1
|
||||
} else {
|
||||
// Timer ran out — higher HP wins
|
||||
roundWinner = fighter1.combat.hp >= fighter2.combat.hp ? 1 : 2
|
||||
}
|
||||
|
||||
// KO animation
|
||||
if (roundWinner === 1 || roundWinner === 2) {
|
||||
const loser = roundWinner === 1 ? fighter2 : fighter1
|
||||
const winner = roundWinner === 1 ? fighter1 : fighter2
|
||||
enterKO(loser)
|
||||
enterWin(winner)
|
||||
sfxExplosion()
|
||||
k.shake(HIT_SHAKE_SPECIAL)
|
||||
}
|
||||
|
||||
if (roundWinner === 1) p1Wins++
|
||||
else if (roundWinner === 2) p2Wins++
|
||||
|
||||
callbacks.onRoundEnd?.(roundWinner, p1Wins, p2Wins)
|
||||
|
||||
// Check match end
|
||||
const winsNeeded = Math.ceil(rounds / 2)
|
||||
if (p1Wins >= winsNeeded || p2Wins >= winsNeeded) {
|
||||
matchOver = true
|
||||
stopMusic()
|
||||
const matchWinner = p1Wins >= winsNeeded ? 1 : 2
|
||||
trackedTimeout(() => {
|
||||
callbacks.onMatchEnd?.(matchWinner as 1 | 2)
|
||||
}, ROUND_END_DELAY * 1000)
|
||||
} else {
|
||||
// Next round after delay
|
||||
trackedTimeout(() => {
|
||||
startRound()
|
||||
}, ROUND_END_DELAY * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFighter(fighter: FighterInstance, startX: number, facingRight: boolean): void {
|
||||
fighter.obj.pos.x = startX
|
||||
fighter.obj.pos.y = GROUND_Y
|
||||
fighter.physics.vx = 0
|
||||
fighter.physics.vy = 0
|
||||
fighter.physics.grounded = true
|
||||
fighter.physics.facingRight = facingRight
|
||||
fighter.combat.hp = MAX_HP
|
||||
fighter.combat.state = 'idle'
|
||||
fighter.combat.stateTimer = 0
|
||||
fighter.combat.stunTimer = 0
|
||||
fighter.combat.blockTimer = 0
|
||||
fighter.combat.comboCount = 0
|
||||
fighter.combat.comboDamage = 0
|
||||
fighter.combat.attackFrame = 0
|
||||
fighter.combat.currentMove = null
|
||||
fighter.combat.hasHitThisAttack = false
|
||||
|
||||
const baseScale = FIGHTER_SCALE
|
||||
fighter.obj.scale.x = facingRight ? baseScale : -baseScale
|
||||
fighter.obj.scale.y = baseScale
|
||||
fighter.obj.opacity = 1
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Input Buffer Management
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function pushInput(player: 1 | 2, input: PlayerInput, prevInput: PlayerInput): void {
|
||||
const buffer = player === 1 ? p1InputBuffer : p2InputBuffer
|
||||
const now = performance.now()
|
||||
|
||||
// Detect new directional presses (edge-triggered)
|
||||
if (input.up && !prevInput.up) buffer.push({ direction: 'up', button: null, time: now })
|
||||
if (input.down && !prevInput.down) buffer.push({ direction: 'down', button: null, time: now })
|
||||
if (input.left && !prevInput.left) buffer.push({ direction: 'left', button: null, time: now })
|
||||
if (input.right && !prevInput.right) buffer.push({ direction: 'right', button: null, time: now })
|
||||
|
||||
// Detect new button presses
|
||||
if (input.punch && !prevInput.punch) buffer.push({ direction: null, button: 'A', time: now })
|
||||
if (input.kick && !prevInput.kick) buffer.push({ direction: null, button: 'B', time: now })
|
||||
|
||||
// Trim buffer
|
||||
while (buffer.length > COMBO_BUFFER_SIZE) buffer.shift()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Controller Interface
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Store previous inputs for edge detection
|
||||
let prevP1: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
|
||||
let prevP2: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
|
||||
|
||||
// Start the scene
|
||||
k.go('arcade')
|
||||
|
||||
return {
|
||||
start() {
|
||||
matchOver = false
|
||||
p1Wins = 0
|
||||
p2Wins = 0
|
||||
currentRound = 0
|
||||
startRound()
|
||||
},
|
||||
|
||||
pause() {
|
||||
paused = true
|
||||
},
|
||||
|
||||
resume() {
|
||||
paused = false
|
||||
},
|
||||
|
||||
destroy() {
|
||||
paused = true
|
||||
roundActive = false
|
||||
stopMusic()
|
||||
for (const id of cleanupTimers) {
|
||||
clearTimeout(id)
|
||||
clearInterval(id)
|
||||
}
|
||||
cleanupTimers.clear()
|
||||
clearAllProjectiles(k)
|
||||
k.quit()
|
||||
},
|
||||
|
||||
setInput(player: 1 | 2, input: PlayerInput) {
|
||||
const fighter = player === 1 ? fighter1 : fighter2
|
||||
if (!fighter) return
|
||||
|
||||
const prev = player === 1 ? prevP1 : prevP2
|
||||
pushInput(player, input, prev)
|
||||
|
||||
// Update live input state on the fighter
|
||||
fighter.input.up = input.up
|
||||
fighter.input.down = input.down
|
||||
fighter.input.left = input.left
|
||||
fighter.input.right = input.right
|
||||
fighter.input.punch = input.punch
|
||||
fighter.input.kick = input.kick
|
||||
|
||||
// Store for next frame edge detection
|
||||
if (player === 1) {
|
||||
prevP1 = { ...input }
|
||||
} else {
|
||||
prevP2 = { ...input }
|
||||
}
|
||||
},
|
||||
|
||||
on(event, cb) {
|
||||
(callbacks as any)[event] = cb
|
||||
},
|
||||
|
||||
getGameState() {
|
||||
if (!fighter1 || !fighter2) return null
|
||||
return { fighter1, fighter2, timer: roundTimer, round: currentRound, roundActive }
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user