Commit 9964877a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: add Ludo pawn sprites, sound effects & 25 emoji slots to branding

- Add 4 Ludo pawn sprite upload slots (red/green/yellow/blue) to admin branding
- Add 15 sound effect upload slots (mp3/ogg/wav) with audio preview
- Add 25 missing emoji slots (crown, mute, block, hourglass, etc.)
- Ludo game.js: draw custom pawn sprites with same bounce/highlight animation
- Ludo game.js: pawn colors now respect branding ludo_red/green/yellow/blue
- audio.js: plays custom audio files if uploaded, falls back to synthesized sounds
- branding.php: support audio MIME types for Supabase storage upload
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d84fed77
...@@ -87,7 +87,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -87,7 +87,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent); curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent);
$mimeTypes = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif']; $mimeTypes = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'wav' => 'audio/wav', 'm4a' => 'audio/mp4', 'webm' => 'audio/webm'];
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY, 'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY, 'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
...@@ -446,6 +446,91 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -446,6 +446,91 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
</div> </div>
</div> </div>
<!-- LUDO PAWNS -->
<h2>🎯 بيادق اللودو (Ludo Pawns)</h2>
<p style="color:#64748b;font-size:12px;margin-bottom:16px;">ارفع صور PNG أو SVG لبيادق اللودو — ستظهر بنفس الحركة والارتداد على اللوحة. الشكل الافتراضي رسم هندسي.</p>
<div class="section">
<div class="grid">
<?php
$ludoPawns = [
['slot' => 'ludo_pawn_red', 'label' => '🔴 البيدق الأحمر', 'w' => 64, 'h' => 64, 'hint' => 'Player 1 — Red pawn sprite'],
['slot' => 'ludo_pawn_green', 'label' => '🟢 البيدق الأخضر', 'w' => 64, 'h' => 64, 'hint' => 'Player 2 — Green pawn sprite'],
['slot' => 'ludo_pawn_yellow', 'label' => '🟡 البيدق الأصفر', 'w' => 64, 'h' => 64, 'hint' => 'Player 3 — Yellow pawn sprite'],
['slot' => 'ludo_pawn_blue', 'label' => '🔵 البيدق الأزرق', 'w' => 64, 'h' => 64, 'hint' => 'Player 4 — Blue pawn sprite'],
];
foreach ($ludoPawns as $a):
$current = $theme['assets'][$a['slot']] ?? null;
?>
<div class="field">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="slot" value="<?= $a['slot'] ?>">
<input type="hidden" name="expected_w" value="<?= $a['w'] ?>">
<input type="hidden" name="expected_h" value="<?= $a['h'] ?>">
<label><?= $a['label'] ?></label>
<div class="upload-box" onclick="this.querySelector('input[type=file]').click()">
<input type="file" name="asset" accept=".svg,.png,.webp" style="display:none" onchange="this.form.submit()">
<?php if ($current): ?>
<div class="current"><img src="<?= $current ?>" style="width:48px;height:48px;object-fit:contain;background:#2a2a4a;border-radius:8px;padding:4px;"></div>
<div style="font-size:10px;color:#34D399;margin-top:4px;">✓ مرفوع</div>
<?php else: ?>
📤 اضغط للرفع
<?php endif; ?>
<div class="size-hint"><?= $a['w'] ?>×<?= $a['h'] ?>px — <?= $a['hint'] ?></div>
</div>
</form>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- SOUND EFFECTS -->
<h2>🔊 المؤثرات الصوتية</h2>
<p style="color:#64748b;font-size:12px;margin-bottom:16px;">ارفع ملفات MP3/OGG/WAV لاستبدال الأصوات المُولَّدة. الافتراضي أصوات إلكترونية مُصنَّعة.</p>
<div class="section">
<div class="grid">
<?php
$soundSlots = [
['slot' => 'sfx_click', 'label' => '🖱️ نقرة (Click)', 'hint' => 'أزرار UI — قصير جداً'],
['slot' => 'sfx_move', 'label' => '♟ نقلة (Move)', 'hint' => 'تحريك قطعة شطرنج/بيدق'],
['slot' => 'sfx_capture', 'label' => '⚔️ أسر (Capture)', 'hint' => 'أكل قطعة/أسر بيدق'],
['slot' => 'sfx_check', 'label' => '⚠️ كش (Check)', 'hint' => 'تهديد الملك'],
['slot' => 'sfx_castle', 'label' => '🏰 تبييت (Castle)', 'hint' => 'نقلة التبييت في الشطرنج'],
['slot' => 'sfx_gameOver', 'label' => '🏁 انتهاء (Game Over)', 'hint' => 'نهاية المباراة'],
['slot' => 'sfx_win', 'label' => '🏆 فوز (Win)', 'hint' => 'صوت الفوز/النصر'],
['slot' => 'sfx_lose', 'label' => '💀 خسارة (Lose)', 'hint' => 'صوت الخسارة'],
['slot' => 'sfx_coin', 'label' => '🪙 عملة (Coin)', 'hint' => 'كسب عملات — قصير ومعدني'],
['slot' => 'sfx_levelUp', 'label' => '⬆️ ترقية (Level Up)', 'hint' => 'رفع المستوى'],
['slot' => 'sfx_notification', 'label' => '🔔 إشعار (Notification)', 'hint' => 'وصول إشعار/رسالة'],
['slot' => 'sfx_dice', 'label' => '🎲 نرد (Dice)', 'hint' => 'رمي النرد في اللودو'],
['slot' => 'sfx_place', 'label' => '📍 وضع (Place)', 'hint' => 'وضع قطعة دومينو'],
['slot' => 'sfx_reward', 'label' => '🎁 مكافأة (Reward)', 'hint' => 'فتح المكافأة اليومية'],
['slot' => 'sfx_match_found', 'label' => '🎯 وُجِدت مباراة (Match Found)', 'hint' => 'الاقتران مع خصم'],
];
foreach ($soundSlots as $s):
$current = $theme['assets'][$s['slot']] ?? null;
?>
<div class="field">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="slot" value="<?= $s['slot'] ?>">
<input type="hidden" name="expected_w" value="0">
<input type="hidden" name="expected_h" value="0">
<label><?= $s['label'] ?></label>
<div class="upload-box" onclick="this.querySelector('input[type=file]').click()">
<input type="file" name="asset" accept=".mp3,.ogg,.wav,.m4a,.webm" style="display:none" onchange="this.form.submit()">
<?php if ($current): ?>
<div style="font-size:11px;color:#34D399;">✓ مرفوع</div>
<audio controls src="<?= $current ?>" style="width:100%;height:28px;margin-top:4px;"></audio>
<?php else: ?>
🔊 اضغط لرفع ملف صوتي
<?php endif; ?>
<div class="size-hint"><?= $s['hint'] ?></div>
</div>
</form>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- EMOJI REPLACEMENTS --> <!-- EMOJI REPLACEMENTS -->
<h2>😀 استبدال الرموز التعبيرية</h2> <h2>😀 استبدال الرموز التعبيرية</h2>
<p style="color:#64748b;font-size:12px;margin-bottom:16px;">استبدل أي رمز تعبيري بصورة SVG أو PNG — تظهر بنفس الحجم بلا تشويه</p> <p style="color:#64748b;font-size:12px;margin-bottom:16px;">استبدل أي رمز تعبيري بصورة SVG أو PNG — تظهر بنفس الحجم بلا تشويه</p>
...@@ -516,6 +601,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -516,6 +601,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
// Misc // Misc
['key' => 'thinking_dots', 'emoji' => '●●●', 'label' => 'Bot thinking dots', 'size' => 14, 'hint' => 'Chess bot thinking indicator'], ['key' => 'thinking_dots', 'emoji' => '●●●', 'label' => 'Bot thinking dots', 'size' => 14, 'hint' => 'Chess bot thinking indicator'],
['key' => 'building', 'emoji' => '🏛️', 'label' => 'Organization', 'size' => 32, 'hint' => 'Organization/club icon'], ['key' => 'building', 'emoji' => '🏛️', 'label' => 'Organization', 'size' => 32, 'hint' => 'Organization/club icon'],
// Social & Navigation
['key' => 'crown', 'emoji' => '👑', 'label' => 'Crown/Owner', 'size' => 16, 'hint' => 'Group owner / top rank badge'],
['key' => 'mute', 'emoji' => '🔇', 'label' => 'Muted user', 'size' => 16, 'hint' => 'Muted player indicator'],
['key' => 'block', 'emoji' => '🚫', 'label' => 'Blocked', 'size' => 16, 'hint' => 'Blocked user indicator'],
['key' => 'hourglass', 'emoji' => '⏳', 'label' => 'Waiting', 'size' => 24, 'hint' => 'Matchmaking / waiting state'],
['key' => 'play', 'emoji' => '▶️', 'label' => 'Play button', 'size' => 24, 'hint' => 'Start/play action'],
['key' => 'exit', 'emoji' => '🚪', 'label' => 'Exit', 'size' => 20, 'hint' => 'Leave game / exit'],
['key' => 'clock', 'emoji' => '⏱️', 'label' => 'Timer', 'size' => 16, 'hint' => 'Turn timer / time control'],
['key' => 'live', 'emoji' => '🔴', 'label' => 'Live dot', 'size' => 12, 'hint' => 'Live match indicator'],
['key' => 'calendar', 'emoji' => '📅', 'label' => 'Calendar', 'size' => 16, 'hint' => 'Tournament date/schedule'],
['key' => 'news', 'emoji' => '📢', 'label' => 'News', 'size' => 20, 'hint' => 'Announcements/news feed'],
['key' => 'group', 'emoji' => '👥', 'label' => 'Group', 'size' => 20, 'hint' => 'Groups/team section'],
['key' => 'inbox', 'emoji' => '📥', 'label' => 'Inbox', 'size' => 20, 'hint' => 'Messages inbox'],
['key' => 'sleeping', 'emoji' => '💤', 'label' => 'Offline/Sleeping', 'size' => 14, 'hint' => 'Player is offline'],
['key' => 'send', 'emoji' => '✉️', 'label' => 'Send message', 'size' => 16, 'hint' => 'Send button in chat'],
['key' => 'wave', 'emoji' => '👋', 'label' => 'Wave/Hello', 'size' => 20, 'hint' => 'Friend request greeting'],
['key' => 'camera', 'emoji' => '📷', 'label' => 'Camera', 'size' => 20, 'hint' => 'Upload photo button'],
['key' => 'arrow_back', 'emoji' => '←', 'label' => 'Back arrow', 'size' => 16, 'hint' => 'Navigation back button'],
['key' => 'globe', 'emoji' => '🌐', 'label' => 'Globe/Language', 'size' => 16, 'hint' => 'Language selector'],
['key' => 'save', 'emoji' => '💾', 'label' => 'Save', 'size' => 16, 'hint' => 'Save settings button'],
['key' => 'lock', 'emoji' => '🔒', 'label' => 'Lock', 'size' => 16, 'hint' => 'Locked/premium content'],
['key' => 'document', 'emoji' => '📄', 'label' => 'Document', 'size' => 16, 'hint' => 'Terms / policy docs'],
['key' => 'target', 'emoji' => '🎯', 'label' => 'Target/Challenge', 'size' => 20, 'hint' => 'Daily challenge objective'],
['key' => 'green_circle', 'emoji' => '🟢', 'label' => 'Online dot', 'size' => 10, 'hint' => 'Player is online'],
['key' => 'gray_circle', 'emoji' => '⚫', 'label' => 'Offline dot', 'size' => 10, 'hint' => 'Player is offline'],
['key' => 'check', 'emoji' => '✓', 'label' => 'Checkmark', 'size' => 14, 'hint' => 'Selection/completion mark'],
]; ];
foreach ($emojiSlots as $e): foreach ($emojiSlots as $e):
$current = $theme['assets']['emoji_' . $e['key']] ?? null; $current = $theme['assets']['emoji_' . $e['key']] ?? null;
......
import * as store from './store.js'; import * as store from './store.js';
import { getAsset } from './theme.js';
let ctx = null; let ctx = null;
const buffers = {}; const buffers = {};
const audioElements = {};
const volumes = { ui: 0.5, game: 0.7, reward: 0.8 }; const volumes = { ui: 0.5, game: 0.7, reward: 0.8 };
const sounds = { const sounds = {
...@@ -17,7 +19,9 @@ const sounds = { ...@@ -17,7 +19,9 @@ const sounds = {
levelUp: { src: null, freq: 880, dur: 0.6 }, levelUp: { src: null, freq: 880, dur: 0.6 },
notification: { src: null, freq: 700, dur: 0.15 }, notification: { src: null, freq: 700, dur: 0.15 },
dice: { src: null, freq: 250, dur: 0.3 }, dice: { src: null, freq: 250, dur: 0.3 },
place: { src: null, freq: 350, dur: 0.1 } place: { src: null, freq: 350, dur: 0.1 },
reward: { src: null, freq: 660, dur: 0.4 },
match_found: { src: null, freq: 740, dur: 0.3 }
}; };
function getCtx() { function getCtx() {
...@@ -30,6 +34,7 @@ function getCtx() { ...@@ -30,6 +34,7 @@ function getCtx() {
export function init() { export function init() {
document.addEventListener('touchstart', resume, { once: true }); document.addEventListener('touchstart', resume, { once: true });
document.addEventListener('click', resume, { once: true }); document.addEventListener('click', resume, { once: true });
preloadCustomAudio();
} }
function resume() { function resume() {
...@@ -37,8 +42,31 @@ function resume() { ...@@ -37,8 +42,31 @@ function resume() {
if (c.state === 'suspended') c.resume(); if (c.state === 'suspended') c.resume();
} }
function preloadCustomAudio() {
for (const name of Object.keys(sounds)) {
const url = getAsset('sfx_' + name);
if (url) {
const audio = new Audio();
audio.preload = 'auto';
audio.src = url;
audioElements[name] = audio;
}
}
}
export function play(name, category = 'ui') { export function play(name, category = 'ui') {
if (!store.get('audioEnabled')) return; if (!store.get('audioEnabled')) return;
// Use custom audio file if uploaded via branding
if (audioElements[name]) {
const el = audioElements[name];
el.volume = volumes[category] || 0.5;
el.currentTime = 0;
el.play().catch(() => {});
return;
}
// Fallback: synthesized sound
const sound = sounds[name]; const sound = sounds[name];
if (!sound) return; if (!sound) return;
......
...@@ -9,7 +9,7 @@ import * as juice from '../../../core/juice.js'; ...@@ -9,7 +9,7 @@ import * as juice from '../../../core/juice.js';
import { getPiecePosition, getHomeBasePosition, SAFE_SQUARES, HOME_COLUMNS, SHARED_PATH } from '../logic/board-map.js'; import { getPiecePosition, getHomeBasePosition, SAFE_SQUARES, HOME_COLUMNS, SHARED_PATH } from '../logic/board-map.js';
import * as emoteSystem from '../../chess/components/emotes.js'; import * as emoteSystem from '../../chess/components/emotes.js';
import * as mp from '../../../core/multiplayer.js'; import * as mp from '../../../core/multiplayer.js';
import { emoji, getAsset } from '../../../core/theme.js'; import { emoji, getAsset, getColor } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js'; import * as matchLive from '../../../core/match-live.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js'; import * as modal from '../../../core/modal.js';
...@@ -24,9 +24,21 @@ let animFrame = null; // rAF handle ...@@ -24,9 +24,21 @@ let animFrame = null; // rAF handle
let pieceAnims = new Map(); // pieceId -> {fromX,fromY,toX,toY,t,duration} let pieceAnims = new Map(); // pieceId -> {fromX,fromY,toX,toY,t,duration}
// Order: Red(BL), Green(TL), Yellow(TR), Blue(BR) — standard Ludo layout // Order: Red(BL), Green(TL), Yellow(TR), Blue(BR) — standard Ludo layout
const COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5']; const DEFAULT_COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
const COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9']; const DEFAULT_COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9'];
let COLORS = DEFAULT_COLORS;
let COLORS_LIGHT = DEFAULT_COLORS_LIGHT;
const PAWN_SLOTS = ['ludo_pawn_red', 'ludo_pawn_green', 'ludo_pawn_yellow', 'ludo_pawn_blue'];
const pawnImages = [null, null, null, null];
let PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3']; let PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'];
function lightenColor(hex, amount) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, ((num >> 16) & 0xFF) + Math.round(255 * amount));
const g = Math.min(255, ((num >> 8) & 0xFF) + Math.round(255 * amount));
const b = Math.min(255, (num & 0xFF) + Math.round(255 * amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
let livePoller = null; let livePoller = null;
let myPlayerIndex = 0; let myPlayerIndex = 0;
let matchId = null; let matchId = null;
...@@ -149,6 +161,27 @@ export function mountGame(el, params) { ...@@ -149,6 +161,27 @@ export function mountGame(el, params) {
boardDirty = true; boardDirty = true;
pieceAnims = new Map(); pieceAnims = new Map();
// Load themed pawn colors
COLORS = [
getColor('ludo_red', DEFAULT_COLORS[0]),
getColor('ludo_green', DEFAULT_COLORS[1]),
getColor('ludo_yellow', DEFAULT_COLORS[2]),
getColor('ludo_blue', DEFAULT_COLORS[3]),
];
COLORS_LIGHT = COLORS.map(c => lightenColor(c, 0.4));
// Preload custom pawn sprites (non-blocking)
PAWN_SLOTS.forEach((slot, i) => {
const url = getAsset(slot);
if (url) {
const img = new Image();
img.src = url;
img.onload = () => { pawnImages[i] = img; };
} else {
pawnImages[i] = null;
}
});
renderDiceFace(el.querySelector('#dice-box'), 1); renderDiceFace(el.querySelector('#dice-box'), 1);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const md = el.querySelector(`#dice-${i}`); const md = el.querySelector(`#dice-${i}`);
...@@ -966,7 +999,7 @@ function drawPieces(cs) { ...@@ -966,7 +999,7 @@ function drawPieces(cs) {
const bounceY = piece._bounceOffset || 0; const bounceY = piece._bounceOffset || 0;
const drawY = oy + bounceY; const drawY = oy + bounceY;
// Highlight ring // Highlight ring (same for sprite or canvas pawn)
if (isHighlighted) { if (isHighlighted) {
ctx.beginPath(); ctx.arc(ox, drawY, r + 3, 0, Math.PI*2); ctx.beginPath(); ctx.arc(ox, drawY, r + 3, 0, Math.PI*2);
ctx.strokeStyle = '#E4AC38'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.strokeStyle = '#E4AC38'; ctx.lineWidth = 2.5; ctx.stroke();
...@@ -975,15 +1008,19 @@ function drawPieces(cs) { ...@@ -975,15 +1008,19 @@ function drawPieces(cs) {
} }
// Shadow // Shadow
ctx.beginPath(); ctx.arc(ox, oy + 2, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill(); ctx.beginPath(); ctx.arc(ox, oy + 2, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill();
// Body
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill(); const spriteImg = pawnImages[pIdx];
// Base if (spriteImg) {
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill(); const size = r * 2;
// Border ctx.drawImage(spriteImg, ox - size/2, drawY - size/2, size, size);
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); } else {
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); // Fallback: canvas-drawn pawn
// Highlight dot ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill();
ctx.beginPath(); ctx.arc(ox - r*0.2, drawY - r*0.35, r*0.18, 0, Math.PI*2); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill(); ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.fillStyle = COLORS[pIdx]; ctx.fill();
ctx.beginPath(); ctx.arc(ox, drawY - r*0.15, r*0.65, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.beginPath(); ctx.ellipse(ox, drawY + r*0.4, r*0.8, r*0.4, 0, 0, Math.PI*2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
ctx.beginPath(); ctx.arc(ox - r*0.2, drawY - r*0.35, r*0.18, 0, Math.PI*2); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill();
}
}); });
}); });
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment