Commit 6746a37a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game): rewards, shop, profile and settings feel like a game; coin payouts fixed

- Daily reward is a moment: a treasure chest on spinning light rays that
  wiggles when ready, a gold "open" button, the chest bursts with the reward
  jingle and coins fly to the purse; the week is a grid of reward cards with
  the chest on day seven.
- Shop: loot cards framed and ribboned by rarity (grey, green, blue, purple,
  gold), drawn previews per item type (mini boards in their colours, piece
  sets, framed avatars, effects), coin and gem price tags; Arabic item names
  (api/shop.php now sends name_ar).
- Achievements as sticker badges, grey with a lock until earned, gold when
  done; chunky gold progress bars. Leaderboard podium medals with ribbons.
  Settings: sticker tiles, a green game switch, the delete button is red, and
  a credits sheet for the CC BY artists. Profile: framed avatar, crown level,
  sticker stats and game marks; the Challenge button pushed a scene that does
  not exist and did nothing.
- Signed out there is no HUD or tab bar; login sits in a framed tray with a
  big gold "play as guest" button.
- HUD/tab crown uses the optimised crown.webp; wood and crown are preloaded.
- Server: no game had paid coins or XP since 2026-06-18 because award_coins
  wrote a transaction type the table's check constraint rejects; fixed in the
  database (migrations/2026-10-19-award-coins-type.sql). Online backgammon had
  no tables at all; created (migrations/2026-10-19-backgammon-online.sql), and
  the backgammon friend invite now writes the same shape as matchmaking.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent bfb16a6e
...@@ -35,3 +35,4 @@ tests/rpc.log ...@@ -35,3 +35,4 @@ tests/rpc.log
# Written by the branding admin on save; the real store is Supabase # Written by the branding admin on save; the real store is Supabase
public/assets/brand/theme.json public/assets/brand/theme.json
public/js/core/i18n.js.lock
...@@ -371,13 +371,23 @@ if ($method === 'POST') { ...@@ -371,13 +371,23 @@ if ($method === 'POST') {
$whiteId = $isWhite ? $userId : $targetId; $whiteId = $isWhite ? $userId : $targetId;
$blackId = $isWhite ? $targetId : $userId; $blackId = $isWhite ? $targetId : $userId;
// Same shape as a matchmade game (players, colors, current_turn as a
// seat index) plus the white/black ids the invite inbox reads.
$players = [$userId, $targetId];
$colors = [$isWhite ? 'white' : 'black', $isWhite ? 'black' : 'white'];
$match = $sdb->insert('backgammon_matches', [ $match = $sdb->insert('backgammon_matches', [
'status' => 'waiting', 'status' => 'waiting',
'players' => $players,
'colors' => $colors,
'white_player_id' => $whiteId, 'white_player_id' => $whiteId,
'black_player_id' => $blackId, 'black_player_id' => $blackId,
'variant' => $variant, 'variant' => $variant,
'game_state' => ['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()], 'match_length' => intval($input['match_length'] ?? 1) ?: 1,
'current_turn' => 'white', 'scores' => [0, 0],
'game_state' => ['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time(), 'variant' => $variant, 'game_number' => 1, 'cube_value' => 1, 'cube_owner' => null, 'crawford' => false],
'current_turn' => $isWhite ? 0 : 1,
'host_id' => $userId,
'created_by' => $userId,
'created_at' => gmdate('c') 'created_at' => gmdate('c')
]); ]);
......
...@@ -15,7 +15,7 @@ $db = supabase($token); ...@@ -15,7 +15,7 @@ $db = supabase($token);
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET' || ($_SERVER['REQUEST_METHOD'] === 'POST' && (getInput()['action'] ?? '') === 'list')) { if ($method === 'GET' || ($_SERVER['REQUEST_METHOD'] === 'POST' && (getInput()['action'] ?? '') === 'list')) {
$items = $db->get('cosmetics', ['select' => 'id,name,type,rarity,price_coins,price_gems,description', 'order' => 'price_coins.asc']); $items = $db->get('cosmetics', ['select' => 'id,name,name_ar,type,rarity,price_coins,price_gems,description', 'order' => 'price_coins.asc']);
jsonResponse(['items' => is_array($items) && !isset($items['error']) ? $items : []]); jsonResponse(['items' => is_array($items) && !isset($items['error']) ? $items : []]);
} }
......
# EL3AB: game UI brief (Royal Cafe)
Read this before changing any screen. It is the working contract for everyone
(people and agents) touching the player app.
## What El3ab is
**El3ab is a mobile game, not an app and not a website.** Arabic-first (RTL),
four classic games (chess, ludo, domino, backgammon), bots, online matchmaking,
friends, ratings, tournaments. Every screen must feel like a polished mobile
game: tactile, animated, loud with feedback, never a dashboard.
## The world: Royal Cafe
A cafe table at night. The ground is a warm walnut tabletop under a lamp (set
on `body`; never paint a full-screen background on a scene root). On it:
- **Leather trays** (`.rows`, `.card`, `.tray`): dark brown panels for lists and
settings. Cream text.
- **Ivory cards** (`.deck-card`): game objects: games, bots, results, rewards,
tournament tickets, the player card. Gold frame, brown-black `--ink`, red
`--red-ink`. `.face-down` = red cafe card back (locked / not yet), `.spent` =
punched corner (claimed).
- **Game buttons** (`.btn`): chunky, glossy, outlined, with depth; they push down.
`.btn-primary` green (play / confirm), `.btn-blue` / `.btn-card` blue (strong
second action), `.btn-gold` (rewards, premium), `.btn-red` / `.btn-danger`
(destructive), `.btn-purple`, `.btn-secondary` wood (ordinary), `.btn-ghost`.
Sizes `.btn-sm`, default 52px, `.btn-lg` 62px, `.btn-xl` 72px; `.btn-block`.
- **Ribbons**: `screenHead()` (ui.js) prints the title on a red ribbon with a
round blue back button. `.ribbon` (+ `.gold`, `.green`, `.blue`) anywhere else.
- **Chips** (`.chips` > `.chip`): wooden tabs; the chosen one (`.active` or
`aria-pressed="true"`) is gold with dark brown text.
- **Mode buttons**: `modeButton({ id, sticker, title, sub, colour })` (ui.js).
**Room hero**: `roomHero(gameKey)` (ui.js), the game's illustrated card.
- **Game art**: `gameArt(key)` (core/game-art.js), an SVG scene per game.
- **Badges**: `.badge` red bead with white rim.
- Colour per game: chess green board, ludo sunny orange, domino royal purple,
backgammon burgundy wood. Boards: wooden chess board, bright ludo board on
ivory, felt domino table, felt backgammon board with red and ivory points.
Type: `--font-display` (Lalezar) for titles, buttons, numbers that shout, with
`text-shadow: var(--text-outline)`; `--font-ar` (Baloo Bhaijaan 2) for reading.
Wrap numbers inside Arabic text in `<span class="num">`.
## Icons
- `gicon(name, size)` (core/icons.js): glossy game stickers. Use for anything
that is part of the game (tabs, purses, rewards, mode buttons, big buttons,
results). Names: `public/assets/game/gi/_names.json` (coin coins gem trophy
laurels bell settings user portrait friends podium cards chest gift medal swords
bot puzzle dice chess domino backgammon ludo flame lightning stopwatch hourglass
chat handshake shop bag star lock home search ticket calendar check cross plus
flag exit speaker mute edit trash scroll shield party history refresh live team
king sparkles heart spade diamond club hand up dicefire).
- `crown(size)`: the El3ab logo.
- `icon(name, size)`: thin line glyphs for small marks in text (chevrons, back,
x, check). Back arrows, chevrons and send mirror in RTL automatically.
- **Never** an emoji or a typed symbol standing in for an icon (no ✕ ✓ › ← ★
♟ ● ◆ 🎲 ...), in markup, strings or fallbacks.
## Feel: every touch answers
- `core/feel.js` already gives every `button`, `.deck-card`, `.chip`,
`[role=button]` a squash + tap sound + haptic on press and a spring on
release, and deals rows/cards in when a screen opens (`.deal-in` on items to
opt in). Don't hand-roll press effects or tap sounds. Mark tappable
non-buttons `role="button"`.
- `haptic(kind)` from feel.js: light, medium, heavy, success, error.
- `countUp(el, from, to, opts)` from feel.js: numbers that tick.
- `audio.play(name, category)`: recorded sounds: tap toggle deal swish flip fan
shuffle move capture castle place dice die sfx_dice_shake coins coin tick
notification open close error hit step win lose reward levelUp match_found
(+ the sfx_bg_* names). `audio.loop('shuffle')` returns a stop function.
- `juice.*` (core/juice.js): confetti, coinBurst, coinFlyTo(HUD purse), starBurst,
shake, countTo, slamIn, bounceIn, pulseElement, flash.
- CSS motion: `.dealt` (+ `--deal-rot`), `.flip-in`, `.slam-in`, `.pop-in`,
`.bounce-in`, `.wiggle`, `.float`, `.breathe-glow`; `--ease-pop` for overshoot.
- Big moments must land: wins (flip, stamp, confetti, fanfare, coins fly to the
purse), losses (shake, sad sting), captures, dice, turns, rank changes.
- Respect `prefers-reduced-motion` (core.css shortens animations globally).
## Hard rules
1. No emoji or typed symbols as icons. 2. RTL: logical CSS properties only
(`margin-inline-start`, `inset-inline-end`, `text-align: start`). 3. Colours
through `var(--token)`; canvas code reads `getColor('token_name', fallback)`.
4. Tap targets >= 44px, text >= 12px. 5. Every string through `t('key')`; add
strings with `python3 tools/i18n-add.py file.json` (`{"key": ["عربي", "English"]}`;
it locks the file). Natural Arabic. Never invent facts. 6. Keep behaviour and
contracts (ids, classes that code queries, exports, payloads) unless fixing a bug.
## Verifying
- Local server: `http://127.0.0.1:8080` (`PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:8080 tools/dev-server.php`).
- `node tools/shoot.mjs --out <dir> --only <ids>` or `--push <scene> --params '<json>'`
(signed-in throwaway account, deleted afterwards; prints console errors and
missing strings). Look at the PNGs.
- Delete any data your tests create (matches, queue rows, tournaments) the way
`tests/rehearsal.mjs` does.
...@@ -15,6 +15,8 @@ header('Expires: 0'); ...@@ -15,6 +15,8 @@ header('Expires: 0');
<link rel="icon" type="image/png" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/favicon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84"> <link rel="icon" type="image/png" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/favicon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="apple-touch-icon" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/logo_icon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84"> <link rel="apple-touch-icon" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/logo_icon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="preload" as="image" href="/public/assets/game/wood.webp">
<link rel="preload" as="image" href="/public/assets/game/crown.webp">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+Bhaijaan+2:wght@400;500;600;700;800&family=Lalezar&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Baloo+Bhaijaan+2:wght@400;500;600;700;800&family=Lalezar&display=swap" rel="stylesheet">
......
-- 2026-10-19: award_coins wrote type 'reward', which economy_transactions' check
-- constraint rejects, so complete_match failed and no game paid coins or XP after
-- 2026-06-18. Use the allowed 'game_reward'.
CREATE OR REPLACE FUNCTION public.award_coins(p_player_id uuid, p_amount integer, p_reason text)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE new_balance INT;
BEGIN
UPDATE profiles SET coins = coins + p_amount WHERE id = p_player_id
RETURNING coins INTO new_balance;
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason)
VALUES (p_player_id, 'game_reward', 'coins', p_amount, new_balance, p_reason);
RETURN new_balance;
END;
$function$;
-- 2026-10-19: online backgammon had no tables in production; queue and invites wrote into nothing.
begin;
create table if not exists public.backgammon_queue (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
user_name text not null default '',
variant text not null default 'sheshbesh',
match_length integer not null default 3,
match_id uuid,
created_at timestamptz not null default now()
);
create unique index if not exists idx_backgammon_queue_user_unique on public.backgammon_queue (user_id) where match_id is null;
create index if not exists idx_backgammon_queue_open on public.backgammon_queue (variant, match_length, created_at) where match_id is null;
create table if not exists public.backgammon_matches (
id uuid primary key default gen_random_uuid(),
status text not null default 'in_progress',
players jsonb not null default '[]'::jsonb,
colors jsonb not null default '[]'::jsonb,
white_player_id uuid,
black_player_id uuid,
variant text not null default 'sheshbesh',
match_length integer not null default 1,
scores jsonb not null default '[0,0]'::jsonb,
current_turn integer not null default 0,
game_number integer not null default 1,
game_state jsonb not null default '{}'::jsonb,
cube_state jsonb,
last_emote jsonb,
host_id uuid,
created_by uuid,
winner_id uuid,
result text,
created_at timestamptz not null default now(),
updated_at timestamptz default now(),
last_activity timestamptz default now(),
completed_at timestamptz
);
create index if not exists idx_backgammon_matches_status on public.backgammon_matches (status);
create index if not exists idx_backgammon_last_activity on public.backgammon_matches (status, last_activity) where status = 'in_progress';
alter table public.backgammon_matches enable row level security;
alter table public.backgammon_queue enable row level security;
drop policy if exists backgammon_read on public.backgammon_matches;
create policy backgammon_read on public.backgammon_matches for select using (true);
drop policy if exists backgammon_write on public.backgammon_matches;
create policy backgammon_write on public.backgammon_matches using (true)
with check (host_id = auth.uid() or created_by = auth.uid() or white_player_id = auth.uid() or black_player_id = auth.uid() or (players)::text like ('%' || (auth.uid())::text || '%'));
drop policy if exists backgammon_queue_all on public.backgammon_queue;
create policy backgammon_queue_all on public.backgammon_queue using (true) with check (true);
grant select, insert, update, delete on public.backgammon_matches, public.backgammon_queue to anon, authenticated, service_role;
commit;
notify pgrst, 'reload schema';
...@@ -82,6 +82,8 @@ button { color: inherit; } ...@@ -82,6 +82,8 @@ button { color: inherit; }
} }
#scene-container.game-mode { top: 0; bottom: 0; } #scene-container.game-mode { top: 0; bottom: 0; }
html.no-chrome #scene-container { top: 0; bottom: 0; }
html.no-chrome #hud, #hud:empty { display: none; }
/* ── The HUD: a carved bar at the head of the table ─────────────────────── */ /* ── The HUD: a carved bar at the head of the table ─────────────────────── */
#hud { #hud {
......
...@@ -12,6 +12,7 @@ let isGuestMode = false; ...@@ -12,6 +12,7 @@ let isGuestMode = false;
export function init(guestMode = false) { export function init(guestMode = false) {
isGuestMode = guestMode; isGuestMode = guestMode;
document.documentElement.classList.remove('no-chrome');
hudEl = document.getElementById('hud'); hudEl = document.getElementById('hud');
renderHud(); renderHud();
renderTabBar(); renderTabBar();
...@@ -252,5 +253,6 @@ function updateTournamentBadge(pending) { ...@@ -252,5 +253,6 @@ function updateTournamentBadge(pending) {
} }
} }
export function show() { hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); } // Signed out there is no HUD and no tab bar: the screen gets the whole phone.
export function hide() { hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); } export function show() { document.documentElement.classList.remove('no-chrome'); hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); }
export function hide() { document.documentElement.classList.add('no-chrome'); hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); }
...@@ -978,7 +978,11 @@ const strings = { ...@@ -978,7 +978,11 @@ const strings = {
'puzzles.black_to_move': 'الأسود يلعب', 'puzzles.black_to_move': 'الأسود يلعب',
'puzzles.theme_mate': 'كش مات', 'puzzles.theme_mate': 'كش مات',
'puzzles.theme_capture': 'أخذ', 'puzzles.theme_capture': 'أخذ',
'puzzles.theme_center': 'المركز' 'puzzles.theme_center': 'المركز',
'settings.credits': 'حقوق الرسومات والأصوات',
'settings.credits_body': 'شكراً لمن صنعوا الأيقونات والأصوات والخامات التي تزين طاولة العب.',
'settings.credits_sounds': 'أصوات الورق والزهر والقطع',
'settings.credits_wood': 'خشب الطاولة'
}, },
en: { en: {
'app.name': 'EL3AB', 'app.name': 'EL3AB',
...@@ -1957,7 +1961,11 @@ const strings = { ...@@ -1957,7 +1961,11 @@ const strings = {
'puzzles.black_to_move': 'Black to move', 'puzzles.black_to_move': 'Black to move',
'puzzles.theme_mate': 'Checkmate', 'puzzles.theme_mate': 'Checkmate',
'puzzles.theme_capture': 'Capture', 'puzzles.theme_capture': 'Capture',
'puzzles.theme_center': 'Center' 'puzzles.theme_center': 'Center',
'settings.credits': 'Art and sound credits',
'settings.credits_body': 'Thanks to the people who made the icons, sounds and textures on El3ab\'s table.',
'settings.credits_sounds': 'card, dice and piece sounds',
'settings.credits_wood': 'the table wood'
} }
}; };
......
...@@ -190,7 +190,7 @@ export function gicon(name, size = 32, opts = {}) { ...@@ -190,7 +190,7 @@ export function gicon(name, size = 32, opts = {}) {
/** The El3ab crown, the logo itself. */ /** The El3ab crown, the logo itself. */
export function crown(size = 40, opts = {}) { export function crown(size = 40, opts = {}) {
const src = getAsset('logo') || '/public/assets/game/crown.webp'; const src = getAsset('icon_crown') || '/public/assets/game/crown.webp';
return `<img class="gi gi-crown-logo${opts.cls ? ' ' + opts.cls : ''}" src="${src}" width="${size}" height="${size}" alt="${opts.label || ''}"${opts.label ? '' : ' aria-hidden="true"'} draggable="false">`; return `<img class="gi gi-crown-logo${opts.cls ? ' ' + opts.cls : ''}" src="${src}" width="${size}" height="${size}" alt="${opts.label || ''}"${opts.label ? '' : ' aria-hidden="true"'} draggable="false">`;
} }
......
...@@ -6,7 +6,7 @@ import * as net from '../../../core/net.js'; ...@@ -6,7 +6,7 @@ import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { assetImg } from '../../../core/theme.js'; import { assetImg } from '../../../core/theme.js';
import { icon } from '../../../core/icons.js'; import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js'; import { esc } from '../../../core/ui.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { SUPABASE_URL } from '../../../core/config.js'; import { SUPABASE_URL } from '../../../core/config.js';
...@@ -65,7 +65,7 @@ export function mountLogin(el) { ...@@ -65,7 +65,7 @@ export function mountLogin(el) {
el.innerHTML = ` el.innerHTML = `
<div class="au"> <div class="au">
<div class="au-inner"> <div class="au-inner">
<div class="au-crown">${crown(56)}</div> <div class="au-crown">${crown(92)}</div>
<h1 class="au-title">${t('auth.login_title')}</h1> <h1 class="au-title">${t('auth.login_title')}</h1>
<p class="au-sub">${t('auth.login_sub')}</p> <p class="au-sub">${t('auth.login_sub')}</p>
...@@ -88,8 +88,8 @@ export function mountLogin(el) { ...@@ -88,8 +88,8 @@ export function mountLogin(el) {
<button class="btn btn-card btn-lg btn-block au-google" id="google-btn"> <button class="btn btn-card btn-lg btn-block au-google" id="google-btn">
${GOOGLE_G}${t('auth.google_btn')} ${GOOGLE_G}${t('auth.google_btn')}
</button> </button>
<button class="btn btn-ghost btn-block" id="guest-btn"> <button class="btn btn-gold btn-lg btn-block" id="guest-btn">
${t('auth.guest_btn')} ${gicon('dice', 34)}${t('auth.guest_btn')}
</button> </button>
</div> </div>
...@@ -192,26 +192,36 @@ export const AUTH_STYLE = ` ...@@ -192,26 +192,36 @@ export const AUTH_STYLE = `
.au-crown { .au-crown {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: var(--s-4); margin-bottom: 4px;
color: var(--gold);
} }
.au-crown .gi { filter: drop-shadow(0 6px 6px rgba(0, 0, 0, 0.55)); animation: idleBob 3.2s ease-in-out infinite; }
.au-title { .au-title {
font-family: var(--font-display);
font-size: var(--fs-2xl); font-size: var(--fs-2xl);
font-weight: 900; line-height: 1.15;
line-height: 1.2;
text-align: center; text-align: center;
color: var(--text-primary); color: var(--text-primary);
text-shadow: var(--text-outline);
} }
.au-sub { .au-sub {
margin-top: var(--s-2); margin-top: 4px;
font-size: var(--fs-sm); font-size: var(--fs-sm);
font-weight: 700;
text-align: center; text-align: center;
color: var(--text-secondary); color: var(--gold-soft);
text-shadow: var(--text-shadow);
} }
.au-form { display: flex; flex-direction: column; gap: var(--s-4); margin-top: var(--s-6); } .au-form {
display: flex; flex-direction: column; gap: var(--s-4);
margin-top: var(--s-5);
padding: 18px 16px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(48, 22, 9, 0.92), rgba(26, 11, 4, 0.94));
box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.85), inset 0 0 0 1px rgba(255, 210, 140, 0.2), inset 0 3px 8px rgba(0, 0, 0, 0.5), 0 8px 18px rgba(0, 0, 0, 0.45);
}
.au-field { display: flex; flex-direction: column; gap: 6px; } .au-field { display: flex; flex-direction: column; gap: 6px; }
.au-label { font-size: var(--fs-sm); font-weight: 700; color: var(--text-primary); } .au-label { font-family: var(--font-display); font-size: var(--fs-md); color: var(--text-primary); text-shadow: var(--text-shadow); }
.au-help { font-size: var(--fs-xs); line-height: 1.5; color: var(--text-muted); } .au-help { font-size: var(--fs-xs); line-height: 1.5; color: var(--text-muted); }
.au-field input[dir="ltr"] { text-align: start; } .au-field input[dir="ltr"] { text-align: start; }
.input[aria-invalid="true"] { border-color: var(--red); } .input[aria-invalid="true"] { border-color: var(--red); }
......
...@@ -8,7 +8,7 @@ import * as audio from '../../../core/audio.js'; ...@@ -8,7 +8,7 @@ import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { t, setLang } from '../../../core/i18n.js'; import { t, setLang } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js'; import { icon, gicon } from '../../../core/icons.js';
import { screenHead, wireBack } from '../../../core/ui.js'; import { screenHead, wireBack } from '../../../core/ui.js';
export function mountSettings(el) { export function mountSettings(el) {
...@@ -22,14 +22,14 @@ export function mountSettings(el) { ...@@ -22,14 +22,14 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_app')}</h2> <h2 class="section-title">${t('settings.sec_app')}</h2>
<div class="rows"> <div class="rows">
<div class="row st-row"> <div class="row st-row">
<span class="st-tile">${icon(audioOn ? 'volume' : 'mute', 20)}</span> <span class="st-tile">${gicon(audioOn ? 'speaker' : 'mute', 34)}</span>
<span class="row-main"><span class="row-title" id="st-sound-label">${t('settings.sound')}</span></span> <span class="row-main"><span class="row-title" id="st-sound-label">${t('settings.sound')}</span></span>
<button class="st-switch" id="toggle-audio" role="switch" aria-checked="${audioOn}" aria-labelledby="st-sound-label"> <button class="st-switch" id="toggle-audio" role="switch" aria-checked="${audioOn}" aria-labelledby="st-sound-label">
<span class="st-knob"></span> <span class="st-knob"></span>
</button> </button>
</div> </div>
<button class="row st-row st-btn" id="toggle-lang"> <button class="row st-row st-btn" id="toggle-lang">
<span class="st-tile">${icon('globe', 20)}</span> <span class="st-tile">${gicon('chat', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.language')}</span></span> <span class="row-main"><span class="row-title">${t('settings.language')}</span></span>
<span class="st-value">${lang === 'ar' ? t('profile.lang_ar') : t('profile.lang_en')}</span> <span class="st-value">${lang === 'ar' ? t('profile.lang_ar') : t('profile.lang_en')}</span>
</button> </button>
...@@ -38,7 +38,7 @@ export function mountSettings(el) { ...@@ -38,7 +38,7 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_account')}</h2> <h2 class="section-title">${t('settings.sec_account')}</h2>
<div class="rows"> <div class="rows">
<button class="row st-row st-btn settings-link" id="blocked-list-btn"> <button class="row st-row st-btn settings-link" id="blocked-list-btn">
<span class="st-tile">${icon('ban', 20)}</span> <span class="st-tile">${gicon('shield', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.blocked_list')}</span></span> <span class="row-main"><span class="row-title">${t('settings.blocked_list')}</span></span>
${icon('chevron-end', 18, { cls: 'st-end' })} ${icon('chevron-end', 18, { cls: 'st-end' })}
</button> </button>
...@@ -47,15 +47,20 @@ export function mountSettings(el) { ...@@ -47,15 +47,20 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_legal')}</h2> <h2 class="section-title">${t('settings.sec_legal')}</h2>
<div class="rows"> <div class="rows">
<a class="row st-row st-btn" href="/privacy-policy" target="_blank" rel="noopener"> <a class="row st-row st-btn" href="/privacy-policy" target="_blank" rel="noopener">
<span class="st-tile">${icon('lock', 20)}</span> <span class="st-tile">${gicon('lock', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.privacy_policy')}</span></span> <span class="row-main"><span class="row-title">${t('settings.privacy_policy')}</span></span>
${icon('external', 18, { cls: 'st-end' })} ${icon('external', 18, { cls: 'st-end' })}
</a> </a>
<a class="row st-row st-btn" href="/terms" target="_blank" rel="noopener"> <a class="row st-row st-btn" href="/terms" target="_blank" rel="noopener">
<span class="st-tile">${icon('document', 20)}</span> <span class="st-tile">${gicon('scroll', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.terms')}</span></span> <span class="row-main"><span class="row-title">${t('settings.terms')}</span></span>
${icon('external', 18, { cls: 'st-end' })} ${icon('external', 18, { cls: 'st-end' })}
</a> </a>
<button class="row st-row st-btn" id="credits-btn">
<span class="st-tile">${gicon('star', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.credits')}</span></span>
${icon('chevron-end', 18, { cls: 'st-end' })}
</button>
</div> </div>
<div class="st-danger-zone"> <div class="st-danger-zone">
...@@ -64,6 +69,21 @@ export function mountSettings(el) { ...@@ -64,6 +69,21 @@ export function mountSettings(el) {
</button> </button>
</div> </div>
<div id="credits-modal" class="st-modal" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="st-cr-title">
<div class="st-sheet st-credits">
<span class="st-sheet-mark">${gicon('star', 56)}</span>
<h3 class="st-sheet-title" id="st-cr-title">${t('settings.credits')}</h3>
<p class="st-sheet-text">${t('settings.credits_body')}</p>
<ul class="st-credit-list">
<li><b>game-icons.net</b> (CC BY 3.0): Lorc, Delapouite, Skoll, Quoting, Carl Olsen, Sbed</li>
<li><b>Kenney.nl</b> (CC0): ${t('settings.credits_sounds')}</li>
<li><b>Poly Haven</b> (CC0): ${t('settings.credits_wood')}</li>
<li><b>Lucide</b> (ISC)</li>
</ul>
<button class="btn btn-primary btn-block" id="credits-close">${t('common.close')}</button>
</div>
</div>
<div id="delete-modal" class="st-modal" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="st-del-title"> <div id="delete-modal" class="st-modal" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="st-del-title">
<div class="st-sheet"> <div class="st-sheet">
<span class="st-sheet-mark">${icon('alert', 28)}</span> <span class="st-sheet-mark">${icon('alert', 28)}</span>
...@@ -73,7 +93,7 @@ export function mountSettings(el) { ...@@ -73,7 +93,7 @@ export function mountSettings(el) {
<div id="delete-error" class="st-error" role="alert"></div> <div id="delete-error" class="st-error" role="alert"></div>
<div class="st-sheet-actions"> <div class="st-sheet-actions">
<button class="btn btn-secondary" id="delete-cancel">${t('common.cancel')}</button> <button class="btn btn-secondary" id="delete-cancel">${t('common.cancel')}</button>
<button class="btn btn-primary" id="delete-confirm">${icon('trash', 18)}${t('settings.delete_confirm_btn')}</button> <button class="btn btn-danger" id="delete-confirm">${icon('trash', 18)}${t('settings.delete_confirm_btn')}</button>
</div> </div>
</div> </div>
</div> </div>
...@@ -83,6 +103,10 @@ export function mountSettings(el) { ...@@ -83,6 +103,10 @@ export function mountSettings(el) {
wireBack(el); wireBack(el);
const credits = el.querySelector('#credits-modal');
el.querySelector('#credits-btn').addEventListener('click', () => { audio.play('open'); credits.style.display = 'flex'; });
el.querySelector('#credits-close').addEventListener('click', () => { audio.play('close'); credits.style.display = 'none'; });
el.querySelector('#blocked-list-btn').addEventListener('click', () => { el.querySelector('#blocked-list-btn').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('blocked-list'); scene.push('blocked-list');
...@@ -167,17 +191,17 @@ const STYLE = ` ...@@ -167,17 +191,17 @@ const STYLE = `
.st .section-title:first-of-type { margin-top: var(--s-2); } .st .section-title:first-of-type { margin-top: var(--s-2); }
.st-row { min-height: 60px; } .st-row { min-height: 60px; }
.st-tile { .st-tile {
width: 40px; width: 48px;
height: 40px; height: 48px;
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--r-md); border-radius: 14px;
background: var(--back-800); background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.22), rgba(0, 0, 0, 0.4) 70%);
border: 1px solid var(--back-line); box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 1px rgba(255, 210, 140, 0.2);
color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.st-row .row-title { font-family: var(--font-display); font-size: var(--fs-md); font-weight: 400; }
.st-btn { .st-btn {
width: 100%; width: 100%;
background: none; background: none;
...@@ -198,13 +222,14 @@ const STYLE = ` ...@@ -198,13 +222,14 @@ const STYLE = `
.st-switch { .st-switch {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 52px; width: 60px;
height: 32px; height: 34px;
min-block-size: 32px; min-block-size: 34px;
padding: 0; padding: 0;
border: none; border: 2px solid var(--outline);
border-radius: var(--r-full); border-radius: var(--r-full);
background: var(--back-600); background: linear-gradient(180deg, #4A2A17, #2A140A);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.6);
cursor: pointer; cursor: pointer;
transition: background var(--dur-normal) var(--ease-out); transition: background var(--dur-normal) var(--ease-out);
} }
...@@ -213,20 +238,23 @@ const STYLE = ` ...@@ -213,20 +238,23 @@ const STYLE = `
position: absolute; position: absolute;
inset: -6px -4px; inset: -6px -4px;
} }
.st-switch[aria-checked="true"] { background: var(--red-ink); } .st-switch[aria-checked="true"] { background: linear-gradient(180deg, var(--btn-green-1), var(--btn-green-2)); box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.25), 0 0 12px rgba(120, 230, 100, 0.45); }
.st-knob { .st-knob {
position: absolute; position: absolute;
top: 3px; top: 3px;
inset-inline-start: 3px; inset-inline-start: 3px;
top: 2px;
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 50%; border-radius: 50%;
background: var(--card); background: radial-gradient(circle at 40% 30%, #FFFFFF, #E9D8B6 70%);
box-shadow: var(--shadow-sm); box-shadow: 0 0 0 1.5px var(--outline), 0 2px 3px rgba(0, 0, 0, 0.5);
transition: transform var(--dur-normal) var(--ease-deal); transition: transform 320ms var(--ease-pop);
} }
.st-switch[aria-checked="true"] .st-knob { transform: translateX(20px); } .st-switch[aria-checked="true"] .st-knob { transform: translateX(26px); }
[dir="rtl"] .st-switch[aria-checked="true"] .st-knob { transform: translateX(-20px); } [dir="rtl"] .st-switch[aria-checked="true"] .st-knob { transform: translateX(-26px); }
.st-credit-list { list-style: none; display: flex; flex-direction: column; gap: 8px; text-align: start; font-size: var(--fs-sm); color: var(--ink-2); margin: 4px 0 10px; }
.st-credit-list b { color: var(--ink); }
.st-danger-zone { .st-danger-zone {
margin-top: var(--s-8); margin-top: var(--s-8);
......
...@@ -13,7 +13,7 @@ import * as net from '../../../core/net.js'; ...@@ -13,7 +13,7 @@ import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js'; import * as modal from '../../../core/modal.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js'; import { icon, gicon, crown, GAME_MARK } from '../../../core/icons.js';
import { screenHead, wireBack, emptyState, esc } from '../../../core/ui.js'; import { screenHead, wireBack, emptyState, esc } from '../../../core/ui.js';
import { countryName } from './edit.js'; import { countryName } from './edit.js';
...@@ -43,7 +43,7 @@ async function mountOwnProfile(el) { ...@@ -43,7 +43,7 @@ async function mountOwnProfile(el) {
<div class="screen pv"> <div class="screen pv">
${screenHead(t('profile.title'), { ${screenHead(t('profile.title'), {
back: false, back: false,
actions: `<button class="icon-btn" id="btn-settings" aria-label="${t('profile.settings')}">${icon('settings', 20)}</button>`, actions: `<button class="icon-btn" id="btn-settings" aria-label="${t('profile.settings')}">${gicon('settings', 32)}</button>`,
})} })}
${playerCard(player, true)} ${playerCard(player, true)}
...@@ -60,7 +60,7 @@ async function mountOwnProfile(el) { ...@@ -60,7 +60,7 @@ async function mountOwnProfile(el) {
<h2 class="section-title">${t('profile.record_title')}</h2> <h2 class="section-title">${t('profile.record_title')}</h2>
<div class="rows"> <div class="rows">
<button class="row pv-row pv-link" id="btn-achievements"> <button class="row pv-row pv-link" id="btn-achievements">
<span class="pv-tile pv-tile-gold">${icon('medal', 20)}</span> <span class="pv-tile pv-tile-gold">${gicon('medal', 34)}</span>
<span class="row-main"> <span class="row-main">
<span class="row-title">${t('profile.achievements')}</span> <span class="row-title">${t('profile.achievements')}</span>
<span class="row-sub">${t('profile.achievements_sub')}</span> <span class="row-sub">${t('profile.achievements_sub')}</span>
...@@ -68,7 +68,7 @@ async function mountOwnProfile(el) { ...@@ -68,7 +68,7 @@ async function mountOwnProfile(el) {
${icon('chevron-end', 18, { cls: 'pv-chev' })} ${icon('chevron-end', 18, { cls: 'pv-chev' })}
</button> </button>
<button class="row pv-row pv-link" id="btn-history"> <button class="row pv-row pv-link" id="btn-history">
<span class="pv-tile">${icon('history', 20)}</span> <span class="pv-tile">${gicon('history', 34)}</span>
<span class="row-main"> <span class="row-main">
<span class="row-title">${t('game.my_games')}</span> <span class="row-title">${t('game.my_games')}</span>
<span class="row-sub">${t('profile.history_sub')}</span> <span class="row-sub">${t('profile.history_sub')}</span>
...@@ -242,7 +242,7 @@ async function mountOtherProfile(el, playerId) { ...@@ -242,7 +242,7 @@ async function mountOtherProfile(el, playerId) {
if (challengeBtn) { if (challengeBtn) {
challengeBtn.addEventListener('click', () => { challengeBtn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('challenge', { friendId: playerId, friendName: player.display_name }); scene.push('challenge-friend', { friendId: playerId, friendName: player.display_name });
}); });
} }
...@@ -402,12 +402,12 @@ function playerCard(p, own) { ...@@ -402,12 +402,12 @@ function playerCard(p, own) {
const handle = p.username ? `<span class="pv-handle" dir="ltr">@${esc(p.username)}</span>` : ''; const handle = p.username ? `<span class="pv-handle" dir="ltr">@${esc(p.username)}</span>` : '';
const place = [p.city ? esc(p.city) : '', p.country_code ? esc(countryName(p.country_code)) : ''].filter(Boolean).join('، '); const place = [p.city ? esc(p.city) : '', p.country_code ? esc(countryName(p.country_code)) : ''].filter(Boolean).join('، ');
const bio = p.bio || p.bio_ar || ''; const bio = p.bio || p.bio_ar || '';
const index = `${icon('crown', 18)}<span class="num">${level}</span>`; const index = `${crown(26)}<span class="num">${level}</span>`;
const initial = (p.display_name || p.username || '').trim().charAt(0); const initial = (p.display_name || p.username || '').trim().charAt(0);
const face = p.avatar_url const face = p.avatar_url
? `<img src="${esc(p.avatar_url)}" alt="">` ? `<img src="${esc(p.avatar_url)}" alt="">`
: `<span class="pv-initial">${initial ? esc(initial) : icon('user', 44)}</span>`; : `<span class="pv-initial">${gicon('user', 64)}</span>`;
const avatarBlock = own const avatarBlock = own
? `<button class="pv-avatar-wrap" id="avatar-wrap" aria-label="${t('profile.change_photo')}"> ? `<button class="pv-avatar-wrap" id="avatar-wrap" aria-label="${t('profile.change_photo')}">
...@@ -420,7 +420,6 @@ function playerCard(p, own) { ...@@ -420,7 +420,6 @@ function playerCard(p, own) {
return ` return `
<section class="deck-card pv-card dealt" style="--deal-rot:-3deg" aria-label="${name}"> <section class="deck-card pv-card dealt" style="--deal-rot:-3deg" aria-label="${name}">
<span class="index top pv-index" role="img" aria-label="${t('common.level', { n: level })}">${index}</span> <span class="index top pv-index" role="img" aria-label="${t('common.level', { n: level })}">${index}</span>
<span class="index bottom pv-index" aria-hidden="true">${index}</span>
<div class="pv-face"> <div class="pv-face">
${avatarBlock} ${avatarBlock}
...@@ -436,7 +435,7 @@ function playerCard(p, own) { ...@@ -436,7 +435,7 @@ function playerCard(p, own) {
const red = GAME_MARK[k].ink === 'red'; const red = GAME_MARK[k].ink === 'red';
return ` return `
<div class="pv-game${red ? ' red' : ''}" role="listitem"> <div class="pv-game${red ? ' red' : ''}" role="listitem">
${icon(GAME_MARK[k].icon, 22)} ${gicon(k, 30)}
${r ${r
? `<span class="num pv-game-rating">${r}</span>` ? `<span class="num pv-game-rating">${r}</span>`
: `<span class="pv-game-rating pv-unrated">${icon('minus', 16)}<span class="sr-only">${t('profile.unrated')}</span></span>`} : `<span class="pv-game-rating pv-unrated">${icon('minus', 16)}<span class="sr-only">${t('profile.unrated')}</span></span>`}
...@@ -470,7 +469,7 @@ function statsSection(p) { ...@@ -470,7 +469,7 @@ function statsSection(p) {
const row = (mark, label, value, sub = '') => ` const row = (mark, label, value, sub = '') => `
<div class="row pv-row deal-in"> <div class="row pv-row deal-in">
<span class="pv-tile">${icon(mark, 20)}</span> <span class="pv-tile">${gicon(mark, 34)}</span>
<span class="row-main"> <span class="row-main">
<span class="row-title">${label}</span> <span class="row-title">${label}</span>
${sub ? `<span class="row-sub">${sub}</span>` : ''} ${sub ? `<span class="row-sub">${sub}</span>` : ''}
...@@ -481,9 +480,9 @@ function statsSection(p) { ...@@ -481,9 +480,9 @@ function statsSection(p) {
return ` return `
<h2 class="section-title">${t('profile.stats')}</h2> <h2 class="section-title">${t('profile.stats')}</h2>
<div class="rows"> <div class="rows">
${row('cards', t('profile.stat_games'), games)} ${row('dice', t('profile.stat_games'), games)}
${row('trophy', t('profile.stat_wins'), wins, rate !== null ? t('profile.win_rate', { n: `<span class="num">${rate}%</span>` }) : '')} ${row('trophy', t('profile.stat_wins'), wins, rate !== null ? t('profile.win_rate', { n: `<span class="num">${rate}%</span>` }) : '')}
${row('trending-down', t('profile.stat_losses'), losses)} ${row('cross', t('profile.stat_losses'), losses)}
${row('handshake', t('profile.stat_draws'), draws)} ${row('handshake', t('profile.stat_draws'), draws)}
${row('flame', t('profile.stat_best_streak'), best, current > 0 ? t('profile.current_streak', { n: `<span class="num">${current}</span>` }) : '')} ${row('flame', t('profile.stat_best_streak'), best, current > 0 ? t('profile.current_streak', { n: `<span class="num">${current}</span>` }) : '')}
</div>`; </div>`;
...@@ -651,20 +650,18 @@ const STYLE = ` ...@@ -651,20 +650,18 @@ const STYLE = `
} }
span.pv-avatar-wrap { cursor: default; } span.pv-avatar-wrap { cursor: default; }
.pv-avatar { .pv-avatar {
width: 96px; width: 104px;
height: 96px; height: 104px;
border-radius: 50%; border-radius: 26px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--card-3); background: linear-gradient(180deg, #5BA8F0, #2A64B8);
color: var(--ink-2); border: 4px solid var(--gold);
outline: 2px solid var(--ink); box-shadow: 0 0 0 2px var(--outline), inset 0 2px 0 rgba(255, 255, 255, 0.35), 0 8px 14px rgba(0, 0, 0, 0.4);
outline-offset: 3px;
transition: transform var(--dur-fast) var(--ease-out);
} }
.pv-avatar img { width: 96px; height: 96px; object-fit: cover; } .pv-avatar img:not(.gi) { width: 100%; height: 100%; object-fit: cover; }
.pv-initial { font-size: 40px; font-weight: 800; line-height: 1; display: flex; } .pv-initial { font-size: 40px; font-weight: 800; line-height: 1; display: flex; }
.pv-avatar-edit { .pv-avatar-edit {
position: absolute; position: absolute;
...@@ -754,13 +751,12 @@ const STYLE = ` ...@@ -754,13 +751,12 @@ const STYLE = `
.pv-row-tight { min-height: 52px; } .pv-row-tight { min-height: 52px; }
.pv-row .row-main { display: flex; flex-direction: column; } .pv-row .row-main { display: flex; flex-direction: column; }
.pv-tile { .pv-tile {
width: 40px; width: 48px;
height: 40px; height: 48px;
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--r-md); border-radius: 14px;
background: var(--back-800); background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.22), rgba(0, 0, 0, 0.4) 70%);
border: 1px solid var(--back-line); box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 1px rgba(255, 210, 140, 0.2);
color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
......
...@@ -114,7 +114,7 @@ function renderList(container, players) { ...@@ -114,7 +114,7 @@ function renderList(container, players) {
/** The place a row holds: an index tile for the podium, a printed number after. */ /** The place a row holds: an index tile for the podium, a printed number after. */
export function rankIndex(rank) { export function rankIndex(rank) {
if (rank >= 1 && rank <= 3) { if (rank >= 1 && rank <= 3) {
return `<span class="rk-tile rk-${rank}">${rank === 1 ? icon('crown', 11) : ''}<span class="num">${rank}</span></span>`; return `<span class="rk-medal rk-m${rank}"><span class="num">${rank}</span></span>`;
} }
return `<span class="num rk-n">${rank}</span>`; return `<span class="num rk-n">${rank}</span>`;
} }
...@@ -305,6 +305,38 @@ export const RANK_STYLE = ` ...@@ -305,6 +305,38 @@ export const RANK_STYLE = `
} }
.rk-tile .i-crown { color: var(--ink); margin-bottom: 1px; } .rk-tile .i-crown { color: var(--ink); margin-bottom: 1px; }
/* The podium: medals with ribbons, gold, silver and bronze. */
.rk-medal {
position: relative;
width: 36px; height: 36px;
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
border: 2px solid var(--outline);
font-family: var(--font-display);
font-size: 18px;
color: #fff;
text-shadow: var(--text-outline);
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.6), inset 0 -3px 0 rgba(0, 0, 0, 0.2), 0 3px 4px rgba(0, 0, 0, 0.45);
}
.rk-medal::before, .rk-medal::after {
content: '';
position: absolute;
bottom: -9px;
width: 10px; height: 14px;
background: #C8262C;
z-index: -1;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 75%, 0 100%);
}
.rk-medal::before { inset-inline-start: 6px; transform: rotate(12deg); }
.rk-medal::after { inset-inline-end: 6px; transform: rotate(-12deg); }
.rk-m1 { background: radial-gradient(circle at 40% 30%, #FFF2A8, #F2B21C 60%, #B87C0A); animation: rkShine 3s ease-in-out infinite; }
.rk-m2 { background: radial-gradient(circle at 40% 30%, #FFFFFF, #B9C4CF 60%, #7D8894); }
.rk-m3 { background: radial-gradient(circle at 40% 30%, #FFD9B0, #C9803F 60%, #8A4F1F); }
.rk-m2::before, .rk-m2::after { background: #2A82E0; }
.rk-m3::before, .rk-m3::after { background: #35A83A; }
@keyframes rkShine { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.2); } }
.rk-me { .rk-me {
flex-shrink: 0; flex-shrink: 0;
padding: 2px 8px; padding: 2px 8px;
......
...@@ -6,7 +6,7 @@ import * as audio from '../../../core/audio.js'; ...@@ -6,7 +6,7 @@ import * as audio from '../../../core/audio.js';
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js'; import { icon, gicon } from '../../../core/icons.js';
import { screenHead, wireBack, emptyState, loadingRows, esc } from '../../../core/ui.js'; import { screenHead, wireBack, emptyState, loadingRows, esc } from '../../../core/ui.js';
const CATEGORY_LABELS = { const CATEGORY_LABELS = {
...@@ -22,6 +22,8 @@ const CATEGORY_MARK = { ...@@ -22,6 +22,8 @@ const CATEGORY_MARK = {
progression: 'level-up', progression: 'level-up',
collection: 'layers', collection: 'layers',
}; };
// The badge each kind of achievement wears.
const CATEGORY_STICKER = { gameplay: 'swords', social: 'friends', progression: 'up', collection: 'chest', tournament: 'trophy' };
export async function mountAchievements(el) { export async function mountAchievements(el) {
el.innerHTML = ` el.innerHTML = `
...@@ -88,7 +90,7 @@ function render(el, achievements, stats, first = false) { ...@@ -88,7 +90,7 @@ function render(el, achievements, stats, first = false) {
${screenHead(t('achievements.title'))} ${screenHead(t('achievements.title'))}
<section class="ach-sum" aria-label="${t('achievements.summary')}"> <section class="ach-sum" aria-label="${t('achievements.summary')}">
<span class="ach-sum-mark">${icon('trophy', 26)}</span> <span class="ach-sum-mark">${gicon('trophy', 56)}</span>
<div class="ach-sum-text"> <div class="ach-sum-text">
<div class="ach-sum-count">${t('achievements.count_of', { <div class="ach-sum-count">${t('achievements.count_of', {
done: `<span class="num ach-big">${stats.completed}</span>`, done: `<span class="num ach-big">${stats.completed}</span>`,
...@@ -104,7 +106,7 @@ function render(el, achievements, stats, first = false) { ...@@ -104,7 +106,7 @@ function render(el, achievements, stats, first = false) {
${categories.length > 1 ? ` ${categories.length > 1 ? `
<div class="chips ach-chips" role="group" aria-label="${t('achievements.categories')}"> <div class="chips ach-chips" role="group" aria-label="${t('achievements.categories')}">
<button class="chip ach-filter active" data-cat="all" aria-pressed="true">${t('achievements.all')}</button> <button class="chip ach-filter active" data-cat="all" aria-pressed="true">${t('achievements.all')}</button>
${categories.map(c => `<button class="chip ach-filter" data-cat="${esc(c)}" aria-pressed="false">${icon(CATEGORY_MARK[c] || 'medal', 16)}${CATEGORY_LABELS[c] ? CATEGORY_LABELS[c]() : esc(c)}</button>`).join('')} ${categories.map(c => `<button class="chip ach-filter" data-cat="${esc(c)}" aria-pressed="false">${gicon(CATEGORY_STICKER[c] || 'medal', 22)}${CATEGORY_LABELS[c] ? CATEGORY_LABELS[c]() : esc(c)}</button>`).join('')}
</div>` : ''} </div>` : ''}
<div id="ach-list" class="rows"> <div id="ach-list" class="rows">
...@@ -143,16 +145,16 @@ function renderList(achievements, category) { ...@@ -143,16 +145,16 @@ function renderList(achievements, category) {
const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0; const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
const mark = a.icon_url const mark = a.icon_url
? `<img src="${esc(a.icon_url)}" alt="" width="24" height="24" style="width:24px;height:24px;object-fit:contain;">` ? `<img src="${esc(a.icon_url)}" alt="" width="24" height="24" style="width:24px;height:24px;object-fit:contain;">`
: icon(CATEGORY_MARK[a.category] || 'medal', 22); : gicon(CATEGORY_STICKER[a.category] || 'medal', 40);
return ` return `
<div class="row ach-row deal-in ${a.completed ? 'ach-done' : 'ach-locked'}" data-id="${esc(a.id)}"> <div class="row ach-row deal-in ${a.completed ? 'ach-done' : 'ach-locked'}" data-id="${esc(a.id)}">
<span class="ach-tile">${mark}</span> <span class="ach-tile">${mark}${a.completed ? '' : `<span class="ach-lock">${gicon('lock', 20)}</span>`}</span>
<div class="row-main"> <div class="row-main">
<div class="row-title ach-name">${esc(a.name)}</div> <div class="row-title ach-name">${esc(a.name)}</div>
<div class="row-sub ach-desc">${esc(a.description)}</div> <div class="row-sub ach-desc">${esc(a.description)}</div>
${a.completed ? ` ${a.completed ? `
<div class="ach-state">${icon('check', 14, { weight: 2.5 })}${t('achievements.complete')}</div> <div class="ach-state">${gicon('check', 20)}${t('achievements.complete')}</div>
` : ` ` : `
<div class="ach-prog"> <div class="ach-prog">
<span class="ach-bar" role="progressbar" aria-valuemin="0" aria-valuemax="${a.target}" aria-valuenow="${a.progress}"><span style="width:${pct}%"></span></span> <span class="ach-bar" role="progressbar" aria-valuemin="0" aria-valuemax="${a.target}" aria-valuenow="${a.progress}"><span style="width:${pct}%"></span></span>
...@@ -160,7 +162,7 @@ function renderList(achievements, category) { ...@@ -160,7 +162,7 @@ function renderList(achievements, category) {
</div> </div>
`} `}
</div> </div>
<span class="ach-reward" aria-label="${a.coins_reward} ${t('game.coins')}">${icon('coin', 16)}<span class="num">${a.coins_reward}</span></span> <span class="ach-reward" aria-label="${a.coins_reward} ${t('game.coins')}">${gicon('coin', 24)}<span class="num">${a.coins_reward}</span></span>
</div> </div>
`; `;
}).join(''); }).join('');
...@@ -181,37 +183,30 @@ const STYLE = ` ...@@ -181,37 +183,30 @@ const STYLE = `
background: var(--back-800); background: var(--back-800);
border: 1px solid var(--back-line); border: 1px solid var(--back-line);
} }
.ach-sum-mark { .ach-sum-mark { display: flex; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.5)); animation: idleBob 3s ease-in-out infinite; }
width: 48px; .ach-sum-count { font-family: var(--font-display); font-size: var(--fs-lg); color: var(--text-secondary); line-height: 1.2; text-shadow: var(--text-outline); }
height: 48px; .ach-big { font-size: var(--fs-3xl); color: var(--gold-soft); }
border-radius: var(--r-md);
background: var(--back-950);
color: var(--gold);
display: flex;
align-items: center;
justify-content: center;
}
.ach-sum-count { font-size: var(--fs-base); font-weight: 600; color: var(--text-secondary); line-height: 1.2; }
.ach-big { font-size: var(--fs-2xl); font-weight: 900; color: var(--text-primary); }
.ach-sum-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; } .ach-sum-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; }
.ach-bar-lg { grid-column: 1 / -1; } .ach-bar-lg { grid-column: 1 / -1; }
.ach-bar { .ach-bar {
display: block; display: block;
flex: 1; flex: 1;
height: 4px; height: 10px;
border-radius: var(--r-full); border-radius: 6px;
background: var(--back-700); background: rgba(0, 0, 0, 0.55);
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.7), 0 1px 0 rgba(255, 220, 160, 0.2);
overflow: hidden; overflow: hidden;
} }
.ach-bar > span { .ach-bar > span {
display: block; display: block;
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: var(--gold); background: linear-gradient(180deg, var(--btn-gold-1), var(--btn-gold-2));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition: width var(--dur-slow) var(--ease-out); transition: width var(--dur-slow) var(--ease-out);
} }
.ach-bar-lg { height: 6px; } .ach-bar-lg { height: 14px; }
.ach-bar-lg > span { animation: achFill 800ms var(--ease-out) 200ms both; } .ach-bar-lg > span { animation: achFill 800ms var(--ease-out) 200ms both; }
@keyframes achFill { from { width: 0; } } @keyframes achFill { from { width: 0; } }
...@@ -224,18 +219,20 @@ const STYLE = ` ...@@ -224,18 +219,20 @@ const STYLE = `
.ach-row { align-items: flex-start; min-height: 72px; padding-block: var(--s-3); } .ach-row { align-items: flex-start; min-height: 72px; padding-block: var(--s-3); }
.ach-row .row-main { display: flex; flex-direction: column; gap: 2px; } .ach-row .row-main { display: flex; flex-direction: column; gap: 2px; }
.ach-tile { .ach-tile {
width: 44px; position: relative;
height: 44px; width: 58px;
height: 58px;
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--r-md); border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--back-800); background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.25), rgba(0, 0, 0, 0.45) 70%);
border: 1px solid var(--back-line); box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 2px rgba(255, 210, 140, 0.22);
} }
.ach-done .ach-tile { color: var(--gold); background: var(--back-700); border-color: var(--back-line-strong); } .ach-done .ach-tile { background: radial-gradient(circle at 50% 35%, #FFE98A, #E39A14 75%); box-shadow: 0 0 0 2px var(--outline), 0 0 18px rgba(255, 200, 80, 0.55); }
.ach-locked .ach-tile { color: var(--text-dim); background: transparent; } .ach-locked .ach-tile .gi:first-child { filter: grayscale(1) brightness(0.7); opacity: 0.75; }
.ach-lock { position: absolute; bottom: -4px; inset-inline-end: -4px; display: flex; }
.ach-name { white-space: normal; } .ach-name { white-space: normal; }
.ach-done .ach-name { color: var(--text-primary); font-weight: 700; } .ach-done .ach-name { color: var(--text-primary); font-weight: 700; }
.ach-locked .ach-name { color: var(--text-secondary); } .ach-locked .ach-name { color: var(--text-secondary); }
...@@ -252,15 +249,16 @@ const STYLE = ` ...@@ -252,15 +249,16 @@ const STYLE = `
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
min-height: 28px; min-height: 32px;
padding: 0 8px; padding: 0 10px 0 4px;
margin-top: 2px; margin-top: 2px;
border-radius: var(--r-sm); border-radius: 16px;
background: var(--back-800); background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--back-line); box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.6);
font-size: var(--fs-sm); font-family: var(--font-display);
font-weight: 800; font-size: 16px;
color: var(--text-primary); color: #fff;
text-shadow: var(--text-outline);
} }
.ach-locked .ach-reward { color: var(--text-secondary); } .ach-locked .ach-reward { color: var(--text-secondary); }
`; `;
This diff is collapsed.
This diff is collapsed.
...@@ -129,7 +129,7 @@ export function gicon(name, size = 32, opts = {}) { ...@@ -129,7 +129,7 @@ export function gicon(name, size = 32, opts = {}) {
/** The El3ab crown, the logo itself. */ /** The El3ab crown, the logo itself. */
export function crown(size = 40, opts = {}) { export function crown(size = 40, opts = {}) {
const src = getAsset('logo') || '/public/assets/game/crown.webp'; const src = getAsset('icon_crown') || '/public/assets/game/crown.webp';
return \`<img class="gi gi-crown-logo\${opts.cls ? ' ' + opts.cls : ''}" src="\${src}" width="\${size}" height="\${size}" alt="\${opts.label || ''}"\${opts.label ? '' : ' aria-hidden="true"'} draggable="false">\`; return \`<img class="gi gi-crown-logo\${opts.cls ? ' ' + opts.cls : ''}" src="\${src}" width="\${size}" height="\${size}" alt="\${opts.label || ''}"\${opts.label ? '' : ' aria-hidden="true"'} draggable="false">\`;
} }
......
"""Add or replace strings in public/js/core/i18n.js. """Add or replace strings in public/js/core/i18n.js.
usage: python3 tools/i18n-add.py strings.json usage: python3 tools/i18n-add.py strings.json
strings.json = {"key": ["Arabic", "English"], ...}. Existing keys are replaced in place.""" strings.json = {"key": ["Arabic", "English"], ...}. Existing keys are replaced in place."""
import json, re, sys, os import json, re, sys, os, fcntl
P = os.path.join(os.path.dirname(__file__), '..', 'public', 'js', 'core', 'i18n.js') P = os.path.join(os.path.dirname(__file__), '..', 'public', 'js', 'core', 'i18n.js')
# Several people (and agents) add strings at once: hold a lock for the whole
# read-modify-write so nobody's strings are lost.
_lock = open(P + '.lock', 'w')
fcntl.flock(_lock, fcntl.LOCK_EX)
s = open(P, encoding='utf-8').read() s = open(P, encoding='utf-8').read()
new = json.load(open(sys.argv[1], encoding='utf-8')) new = json.load(open(sys.argv[1], encoding='utf-8'))
ar_end = s.index("\n },\n en: {") ar_end = s.index("\n },\n en: {")
......
...@@ -25,6 +25,7 @@ const OUT = argOf('--out', './shots'); ...@@ -25,6 +25,7 @@ const OUT = argOf('--out', './shots');
const W = +argOf('--width', 390), H = +argOf('--height', 844); const W = +argOf('--width', 390), H = +argOf('--height', 844);
const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean); const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean);
const GUEST = args.includes('--guest'); const GUEST = args.includes('--guest');
const ANON = args.includes('--anon'); // signed out: the first-run screens
const RAW_THEME = args.includes('--raw-theme'); const RAW_THEME = args.includes('--raw-theme');
const PUSH = argOf('--push', ''); const PUSH = argOf('--push', '');
const PARAMS = argOf('--params', '{}'); const PARAMS = argOf('--params', '{}');
...@@ -118,7 +119,7 @@ async function goTo(page, s) { ...@@ -118,7 +119,7 @@ async function goTo(page, s) {
fs.mkdirSync(OUT, { recursive: true }); fs.mkdirSync(OUT, { recursive: true });
let browser; let browser;
try { try {
await createAccount(); if (!ANON) await createAccount();
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--font-render-hinting=none'] }); browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--font-render-hinting=none'] });
const page = await browser.newPage(); const page = await browser.newPage();
const errors = []; const errors = [];
...@@ -143,7 +144,8 @@ try { ...@@ -143,7 +144,8 @@ try {
// Seed the session before the app boots. // Seed the session before the app boots.
await page.goto(BASE + '/public/css/tokens.css'); await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a, guest) => { if (ANON) await page.evaluate(() => localStorage.clear());
else await page.evaluate((a, guest) => {
const state = { const state = {
auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: guest, guestId: guest ? a.id : null }, auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: guest, guestId: guest ? a.id : null },
player: a.profile, language: 'ar', activeWorld: 'play', player: a.profile, language: 'ar', activeWorld: 'play',
......
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