import asyncio import base64 import secrets import hashlib import unicodedata import io import json import math import os from pathlib import Path import queue import threading import time import wave import ctypes import webbrowser from ctypes import wintypes from collections import deque #cloop import pygame from aiohttp import ClientConnectorError, ClientConnectionError, ClientSession, WSMsgType, ClientTimeout icon_base64 = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAADXUAAA11AFeZeUIAAAKh0lEQVR4nNWaeXAUVR7HP93TM5NMJpNrYJKJELk2GBKFBRNuCCAQBEQoi1JYS1ZlUdf1wqPQ3dpdRGtdFUVKXFALD1TQ5fLgEFBZwRiQSy4JR4QcXLnmyjEz/faPxpiLK/TM4DfVlZr3Xv9+3/7269e/3++1Af1xM+AD3CGwffVjEBQAfSPNI1Lo/W1ye7Uv/CvSRCKCforhuWCHVLFIogpoF2k+YcfTiqFAWKNFvYwYBx9Hmk+4YVpkNtWKKKMQBkSRjBgArwFypImFC103yQhh+PUokxGTYS3giDS5cGDAvmYCCEuUqDEZRJaN8ngLjwDmSJNsDj2nZ1ycUcEvoEZoDUvr63k/2Sl+15HEKQN5eUA6B80KY3T0ecXQU4AoX5KdQuAJWRYk2blZVVkjnSAtDaJM0Lcb107K4dP2Nmbp6PeKoOsC9a6sihiDgYKOqvSFJRprairXVSNJEgihHcnxyKN78myMmdv19N1WSDraGjkxm3XqWUhLh9NVYCkGb3twJkN9AFRV8ygE7D1B6f8Oci3g15HDZUPR0dZhSUJ0ydBEdSZBnQ3iFPDVgcUMFR5AgCrAmYATGARs0pHDZUPPR+BoaSWHGjcYZKj2af8rPZoIARX8QTBIYDGRpaP/NkHXNWDvcV5w+bSpXufXLlyWoKZeu+v1fgiq2tigCkJP522EQU9j9QF2+erol2aniwBcNRBjhlq/JkDdOQF+roym1tobT03wgMfjWacnh8uFrgIAnHWzTpaYmGAhUTGAp1abBYEgnHVLSI5cxk6+n/hEOzabLfvQoUNbVFU9pjePS4XuAgC+kgo+NRjIS7CQpArtrpe5jPQYOoNBQ0dgMpmwWCwEg0FZVdVBxcXFbwH1IeByUYRCAICqskoWl1TgqQ8QX+nFnt5viuHG7JyGAYqioKoqNpstobS01OJ2uzcBPYDuQCxQCagh4tcAPeOACyF+2rRpp/Ly8kxVVVUNjcFgkMOHD7Nr166a9I3DfL3JTYoniW/5nLk8ugyYAgRCSSxUM6A5ao1GY8+BAwdmSJJEIKBdkyzLSJJEWVmZ8feH8izZjCCOJDLpy0hu79Ee57Rj7C/14dkXKmLhEoDS0tLddrv9nqysLMXn8yGEoLCwkP9+uIjC/Tv4Sf2RYepkorAgIRGPnZ4MiuvN0Enb2FTiomJHKHiFTQCgvKioqMThcIx3OBxSfv5Wvly+AIvsQpGhUjrD9sBWRqpTMTSiZSdZ2sDHGac4MT8UpMIpgM3p6/K8qyCgHPUcjNu55SMSLCoCUAxafnBKOo6/3kgvhjScJCFRSlHRTjb/JxSk9MwFLohsRsy7j2dzY+psFK89zPFrVxJnAXetFiFao7QQ+axcDMGm56aQlgUYCUHiFJZ6XSqd732S1+/MJIdOXEd/8pBcqSgyRCkgydosiI2GeNGymGwnxQi0DwW3cAhguo37Z3ekW8Mr14BCb9dUUR+AaBMoMkgSGA3gVLu1MGAmCsAUCnIhF8CAYfRwbmtRFB0emCqdrAaTAjEmLSDx1UMG2S1s1FIDUBcKfqEWoEMmfR9z0KFFR2d6kFCRQ50fbNFgVMDq7Uwnrmsxtpwy0Z+8WU46PQIk6kkwpAIoGHNzmThYaiXglJAY53mS0y4tbY41Q45nKq2NDRKUXuWLB5ax/+Xp/H03tKJoGxFSAbpyfacpPHre/sFMwHYyF3ct+LzRjA/ef0F7ZqK4h79eM4RbZuvFMaQC2EmJu1C/hMQM3+sUnzHTqeROklrZPxHn/n49R+ZGht2CTnlMSAVwU+m72Bgz0fjKupzJq/1zq/0H2E46vZq0WYiNRYsLrhghFWAfBT9WU37efoFgNnd/lCn65Xchs9Uxu9lKj2ZvhhMc2YNO9YOQCiBjEJtYft7+b1jFT+yQMsge0Vr/MQ5wDZ2bLIynKeYzFuuWF4RKgD7ANR3oNiCTnPMOGsx4PLgOtMcZ3bwvSIDNrGYgYxvaBIKF/GPLGUoW60VUz1wgPpXOn9px/tCXm/5Sg9cbR5L1ANuJJoZr6NLihAD1GFDKKzhdR6ONU4HgExYwjrua3P2lvCpKOJIwitvf2szqz2rwriAMVaNLQg4j33ib78RaysRmPKIAVXxFtZjHWjGD2Wo+frEN0XCs4pgYz91+oEdPBj73DtvU7wmINZSJh3hRLGN/k/FL2Cnax5jErYa7xDaEWM0xMZopGwB7pK8dIHkOH9U1JvzL8SF7xCwWisV836R9IxViKLeuB1LP2egfg+2+NLp/uxl3k7HfExT9LX1Ep3aI1KhEsYVasQ0hvsMvHue10xas5w82LgJd1gA7zkmDGd9qstKVLBKw0w4nxylseKfvYWvwa1Y8AJScG7rVi+u9O3g4MxprExsbWIrHvh2LGfymCvaSD4CCQge6tbMS3+br0EWAfowatsQ5x/02c4SHajbyCW5+LX4OYQLVVPAyDwuVIGv5wP8m/3wGKGxsR8F4x/X0bxI8CQRrE2cTYwazom2zH2FvY99M5sFHaeN6posANhKz4+8MFucnfS42JC8Rq/vPFfMMjzeEbxWc4iTHuZHh0n62i5UsKt5HwdvN7QhEIJb4Jm2H2IU55QDWKK1mkO4ew0T+1GRMH4alADe0hbseb4GEHmSnGuZK6ozgKCkg+bm+fx/RzpwllfiOkkpnkkgmSIACNoh5PH6rirq6NUPp9OrYPHM8yA/YLFrNwFuczqy6DzA0o61ov9tU3rtiAaKwjOrNECmxztFAIHv5TdI2NhI4V9L3UI2ConpxvaCirjqfrWrKPW6qaDwLkknj85MKNrORBys/xkrL9OIA233Aj23hf8UCjOWuexObJTECwSF2k0kOy5h/9hVm/s1P3Trg6IVslXD0y5lM2P8SqzJ+udBsRrD5xHThlDtKXVvZTc9nPfvYdkaWqFHbsN18RRmVhdgxC/nms3R6NbGzlTX+f/PgGx6q91Zxdhk0WhEvjignnab3Y9SQaKyxJzh87GcOdlzCztEmrTTWAIHgJR7avZTXHgO+IsxBUdeZzCv75V1dgCqWUyju49mieNqNvfjplwzzTOaVN48vClDFdPkZYVJ4R0dfl4YYbGOeYsGZAlTxHjvUB3i+aBDjXpeQR6NzqV1Cunk1RS0CrDny+yLDiUiy4ge6AjguWH3QBzcMYcInL7JSfYoF1YMZ/yYwIJQOc7hpbgFqk4tfwWGR5YgJ5GYg0uwIk8ICCI8AUd3p/YoJ8ySgRQYXCvyRp79rPvXHc/cmo4EJ3Z3UpKcgbNFUAxZHXHhECCueYL6rsQAL2ayipdsYZPomxHA0yYoAhsPlCxC2rbE2wm4nJbZxwwaWfQ1sBwiq5Fd66SVLTAd2R4BfyNHtDb5quPvrOS1isE3V08HV/i2/LDeiuJU1dV5crYbRbXagp7EQwOvD2/DjCHvzAZeeDq52AU5XckoFLerbzZYteju42gWoL+dUEWgJ1V7y9+jt4GoXgJ/5qQC0criKelxv+1e9AF+zYr2bKrzao6/r8w+/AQHcVK38hlU158rjun/XeNULAFSuYcniGK0+oOu3Ab8lpDzB/Cor8dMjTSRicNDxDw46vBtpHpFGrt4G/w+ehxEijMYj1wAAAABJRU5ErkJggg==" # your image data here SERVER_HOST = os.getenv("GAME_SERVER", "feebworld.us.to") SERVER_PORT = int(os.getenv("GAME_PORT", "6120")) WS_URL = f"ws://{SERVER_HOST}:{SERVER_PORT}/ws" CLIENT_VERSION = "0009" WIDTH, HEIGHT = 1440, 900 FPS = 60 CHAT_LIMIT = 180 SMALL_CHAR_SIZE = 150 LARGE_CHAR_SIZE = 300 MAX_SLOTS = 200 SMALL_MIN_FILLED_PIXELS = 1500 LARGE_MIN_FILLED_PIXELS = 3000 REMOTE_SFX_MAX_DISTANCE = 800.0 AUDIO_BASE = f"http://{SERVER_HOST}:{SERVER_PORT}" APPDATA_DIR = Path(os.getenv("APPDATA") or (Path.home() / "AppData" / "Roaming")) / "FeebWorld" SETTINGS_FILE = APPDATA_DIR / "client.json" WALK_SPEED = 145.0 SPRINT_SPEED = 245.0 LARGE_WALK_SPEED = 108.0 LARGE_SPRINT_SPEED = 188.0 WATER_SPEED = 105.0 SMALL_JUMP_DURATION = 0.65 LARGE_JUMP_DURATION = 0.68 SMALL_JUMP_HEIGHT = 22.0 LARGE_JUMP_HEIGHT = 17.0 SMALL_JUMP_WOBBLE = 10.0 LARGE_JUMP_WOBBLE = 6.0 WORLD_PLAYER_RADIUS = 14.0 LAYER_NAMES = ["Layer 1"] def clamp(v, lo, hi): return max(lo, min(hi, v)) def protect_local_secret(value: str) -> str: """Encrypt a local setting with Windows DPAPI so the password is not stored in plaintext.""" if not isinstance(value, str): return "" if os.name != "nt": return base64.b64encode(value.encode("utf-8")).decode("ascii") crypt32 = ctypes.windll.crypt32 class DATA_BLOB(ctypes.Structure): _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))] raw=value.encode("utf-8") buf=ctypes.create_string_buffer(raw) inp=DATA_BLOB(len(raw), ctypes.cast(buf, ctypes.POINTER(ctypes.c_byte))) out=DATA_BLOB() if not crypt32.CryptProtectData(ctypes.byref(inp), "FeebWorld", None, None, None, 0, ctypes.byref(out)): return "" try: data=ctypes.string_at(out.pbData, out.cbData) return base64.b64encode(data).decode("ascii") finally: ctypes.windll.kernel32.LocalFree(out.pbData) def unprotect_local_secret(value: str) -> str: if not value: return "" try: data=base64.b64decode(value.encode("ascii"), validate=True) except Exception: return "" if os.name != "nt": try: return data.decode("utf-8") except Exception: return "" crypt32=ctypes.windll.crypt32 class DATA_BLOB(ctypes.Structure): _fields_=[("cbData",wintypes.DWORD),("pbData",ctypes.POINTER(ctypes.c_byte))] buf=ctypes.create_string_buffer(data) inp=DATA_BLOB(len(data),ctypes.cast(buf,ctypes.POINTER(ctypes.c_byte))) out=DATA_BLOB() if not crypt32.CryptUnprotectData(ctypes.byref(inp),None,None,None,None,0,ctypes.byref(out)): return "" try: return ctypes.string_at(out.pbData,out.cbData).decode("utf-8") finally: ctypes.windll.kernel32.LocalFree(out.pbData) def client_password_credentials(password: str, salt: bytes | None = None): password = unicodedata.normalize("NFC", str(password)) if salt is None: salt = secrets.token_bytes(16) digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024) return base64.urlsafe_b64encode(salt).decode("ascii"), base64.urlsafe_b64encode(digest).decode("ascii") def client_password_digest_with_salt(password: str, salt_b64: str): salt = base64.urlsafe_b64decode(str(salt_b64).encode("ascii")) return client_password_credentials(password, salt)[1] def hsv_rgb(h, s, v): c = pygame.Color(0, 0, 0) c.hsva = (h % 360, clamp(s, 0, 100), clamp(v, 0, 100), 100) return c.r, c.g, c.b class Net: def __init__(self): self.loop = asyncio.new_event_loop(); self.ws = None self.inbox = queue.Queue(); self.connected = threading.Event(); self.stop_event = threading.Event(); self.permanent_error = threading.Event() threading.Thread(target=self._run, daemon=True).start() def _run(self): asyncio.set_event_loop(self.loop) try: self.loop.run_until_complete(self._main()) finally: try: self.loop.close() except Exception: pass async def _main(self): while not self.stop_event.is_set() and not self.permanent_error.is_set(): session = None try: self.inbox.put({"type": "net_status", "status": "connecting", "url": WS_URL}) session = ClientSession(timeout=ClientTimeout(total=15, connect=10, sock_connect=10, sock_read=None)) async with session.ws_connect(WS_URL, heartbeat=20, autoping=True, proxy=None) as ws: self.ws = ws; self.connected.set(); self.inbox.put({"type": "net_status", "status": "connected", "url": WS_URL}) await ws.send_json({"action": "hello", "version": CLIENT_VERSION}) async for msg in ws: if msg.type == WSMsgType.TEXT: try: payload=json.loads(msg.data) self.inbox.put(payload) if payload.get("type")=="version_mismatch": self.permanent_error.set() except json.JSONDecodeError: self.inbox.put({"type": "net_error", "error": "Invalid server JSON."}) elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED): break except (ClientConnectorError, ClientConnectionError, asyncio.TimeoutError, OSError) as e: self.inbox.put({"type": "net_error", "error": f"Cannot connect to {WS_URL}: {e}"}) except Exception as e: self.inbox.put({"type": "net_error", "error": f"Connection error: {type(e).__name__}: {e}"}) finally: self.connected.clear(); self.ws = None if session: try: await session.close() except Exception: pass if not self.stop_event.is_set() and not self.permanent_error.is_set(): self.inbox.put({"type": "net_status", "status": "retrying"}) await asyncio.sleep(3) def send(self, payload): if not self.connected.is_set() or self.ws is None: return False fut = asyncio.run_coroutine_threadsafe(self.ws.send_json(payload), self.loop) try: fut.result(timeout=3); return True except Exception as e: self.inbox.put({"type": "net_error", "error": f"Send failed: {e}"}); return False def poll(self): out=[] while True: try: out.append(self.inbox.get_nowait()) except queue.Empty: return out def fetch_bytes(self, url, kind, name): if self.loop.is_running(): asyncio.run_coroutine_threadsafe(self._fetch_bytes(url, kind, name), self.loop) def fetch_json(self, url, event_type): if self.loop.is_running(): asyncio.run_coroutine_threadsafe(self._fetch_json(url,event_type), self.loop) async def _fetch_json(self, url, event_type): try: async with ClientSession(timeout=ClientTimeout(total=20, connect=10, sock_read=20)) as session: async with session.get(url, proxy=None) as resp: if resp.status!=200: self.inbox.put({"type":event_type+"_error","error":f"HTTP {resp.status}"}); return self.inbox.put({"type":event_type,"data":await resp.json()}) except Exception as exc: self.inbox.put({"type":event_type+"_error","error":f"{type(exc).__name__}: {exc}"}) def fetch_range(self, url, start, end, event_type, name): if self.loop.is_running(): asyncio.run_coroutine_threadsafe(self._fetch_range(url,start,end,event_type,name), self.loop) async def _fetch_range(self, url, start, end, event_type, name): try: async with ClientSession(timeout=ClientTimeout(total=None, connect=10, sock_read=None)) as session: async with session.get(url, headers={"Range":f"bytes={start}-{end}"}, proxy=None) as resp: if resp.status not in (200,206): self.inbox.put({"type":"audio_error","name":name,"error":f"HTTP {resp.status} for {name}"}); return data=bytearray() async for chunk in resp.content.iter_chunked(64*1024): data.extend(chunk) self.inbox.put({"type":event_type,"name":name,"start":start,"data":bytes(data)}) except Exception as exc: self.inbox.put({"type":"audio_error","name":name,"error":f"Audio range {name}: {type(exc).__name__}: {exc}"}) async def _fetch_bytes(self, url, kind, name): try: timeout=ClientTimeout(total=None, connect=10, sock_read=None) async with ClientSession(timeout=timeout) as session: async with session.get(url, proxy=None) as resp: if resp.status != 200: self.inbox.put({"type":"audio_error","name":name,"error":f"HTTP {resp.status} for {name}"}); return data=bytearray() async for chunk in resp.content.iter_chunked(64*1024): data.extend(chunk) self.inbox.put({"type":"audio_data","kind":kind,"name":name,"data":bytes(data)}) except Exception as exc: self.inbox.put({"type":"audio_error","name":name,"error":f"Audio {name}: {type(exc).__name__}: {exc}"}) def close(self): self.stop_event.set(); self.connected.clear() if self.loop.is_running(): asyncio.run_coroutine_threadsafe(self._close(), self.loop) async def _close(self): if self.ws: try: await self.ws.close() except Exception: pass class Button: def __init__(self, rect, text, cb, fill=(57,66,94), accent=False): self.rect=pygame.Rect(rect); self.text=text; self.cb=cb; self.fill=fill; self.accent=accent; self.hover=False def draw(self, app, enabled=True): self.hover=self.rect.collidepoint(pygame.mouse.get_pos()) and enabled base=self.fill if enabled else (43,46,56) if self.hover: base=tuple(min(255,x+18) for x in base) pygame.draw.rect(app.screen,base,self.rect,border_radius=10); pygame.draw.rect(app.screen,(107,117,142),self.rect,1,border_radius=10) s=app.font.render(self.text,True,(240,242,248) if enabled else (112,117,128)); app.screen.blit(s,s.get_rect(center=self.rect.center)) def click(self,pos): if self.rect.collidepoint(pos): self.cb(); return True return False class FeebWorld: def __init__(self): pygame.mixer.pre_init(48000, -16, 2, 512) pygame.init() try: if pygame.mixer.get_init() is None: pygame.mixer.init(48000, -16, 2, 512) pygame.mixer.set_num_channels(32) pygame.mixer.set_reserved(1) except Exception: pass pygame.key.start_text_input(); pygame.mouse.set_visible(True); pygame.event.set_grab(False) try: pygame.mouse.set_cursor(pygame.SYSTEM_CURSOR_ARROW) except Exception: pass init_w, init_h = WIDTH, HEIGHT try: info = pygame.display.Info() sw, sh = info.current_w, info.current_h max_w = int(sw * 0.8) max_h = int(sh * 0.8) init_w = min(init_w, max_w) init_h = min(init_h, max_h) except Exception: pass self.screen=pygame.display.set_mode((init_w,init_h),pygame.RESIZABLE); pygame.display.set_caption("FeebWorld") try: info = pygame.display.Info() sw, sh = info.current_w, info.current_h win_w, win_h = self.screen.get_size() win_x = (sw - win_w) // 2 win_y = (sh - win_h) // 2 hwnd = pygame.display.get_wm_info().get("window") if hwnd and os.name == "nt": user32 = ctypes.windll.user32 user32.SetWindowPos(hwnd, 0, win_x, win_y, 0, 0, 0x0001) except Exception as e: print(f"Window centering failed: {e}") # Set the 64x64 icon after the native window exists, including Windows taskbar icon. try: icon_data = base64.b64decode(icon_base64) icon_surface = pygame.image.load(io.BytesIO(icon_data)).convert_alpha() pygame.display.set_icon(icon_surface) if os.name == "nt": hwnd = pygame.display.get_wm_info().get("window") if hwnd: user32 = ctypes.windll.user32 WM_SETICON = 0x0080 ICON_SMALL = 0 ICON_BIG = 1 IMAGE_ICON = 1 LR_LOADFROMFILE = 0x00000010 try: from PIL import Image icon_path = Path(os.getenv("TEMP", ".")) / "feebworld_icon.ico" Image.open(io.BytesIO(icon_data)).convert("RGBA").save(icon_path, format="ICO", sizes=[(16,16),(32,32),(48,48),(64,64)]) hicon_big = user32.LoadImageW(None, str(icon_path), IMAGE_ICON, 64, 64, LR_LOADFROMFILE) hicon_small = user32.LoadImageW(None, str(icon_path), IMAGE_ICON, 16, 16, LR_LOADFROMFILE) if hicon_big: user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon_big) if hicon_small: user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_small) except Exception as icon_win_error: print(f"Windows taskbar icon setup failed: {icon_win_error}") except Exception as e: print(f"Failed to set window icon: {e}") self.clock=pygame.time.Clock() font_path=pygame.font.match_font("segoeui") or pygame.font.match_font("arial") self.font_path=font_path self.font=pygame.font.Font(font_path,20); self.small=pygame.font.Font(font_path,16); self.tiny=pygame.font.Font(font_path,13); self.big=pygame.font.Font(font_path,31); self.title=pygame.font.Font(font_path,48); self.huge=pygame.font.Font(font_path,68) self.settings=self.load_settings() self.remember_me=bool(self.settings.get("remember_me", True)) self.saved_token=str(self.settings.get("token", "")) saved_password=unprotect_local_secret(str(self.settings.get("password_protected", ""))) self.net=Net(); self.mode="login"; self.account_reset_stage=0; self.account_reset_code=""; self.account_new_password=""; self.account_confirm_password=""; self.version_ok=False; self.auth_mode="login"; self.auth_stage=1; self.form={"email":str(self.settings.get("email", "")),"password":saved_password,"username":"","code":""}; self.active="email"; self.show_pw=False; self.message="Connecting..."; self.error=False self.token=None; self.me=None; self.world=None; self.characters=[]; self.selected_slot=0; self.editor_name="Character 1"; self.flip_direction=False; self.char_size=SMALL_CHAR_SIZE; self.new_slot_modal=False; self.pending_new_slot=None self.layers=[]; self.layer_selected=0; self.layer_scroll=0; self.slot_scroll=0; self.paint_color=(79,190,255,255); self.brush=3; self.tool="brush"; self.eraser=False; self.painting=False; self.hue=200.0; self.sat=75.0; self.val=100.0; self.sv_surface=None; self.sv_hue=-1; self.wheel_surface=None self.undo_stack=[]; self.redo_stack=[]; self.history_limit=30; self.history_drag_recorded=False self.players={}; self.chat_bubbles={}; self.chat_lines=[]; self.chat_input=None; self.chat_scroll=0; self.chat_unread=0; self.last_input_send=0; self.last_jump=0; self.world_fx=[] self.camera=[0,0]; self.zoom=2.0; self.settings["zoom"]=2.0; self.running=True; self.water_fx=[]; self.last_jump_states={}; self.audio_music_volume=clamp(float(self.settings.get("music_volume",0.6)),0.0,1.0); self.audio_sfx_volume=clamp(float(self.settings.get("sfx_volume",0.8)),0.0,1.0); self.audio_cache={}; self.audio_processed_cache={}; self.audio_pending=set(); self.pending_sfx_play={}; self.music_loaded=False; self.audio_lock=threading.Lock(); self.music_stream_active=False; self.music_meta=None; self.music_next_offset=None; self.music_request_inflight=False; self.music_queue_depth=0; self.music_chunk_ends=[]; self.music_chunk_size=512*1024; self.music_channel=pygame.mixer.Channel(0) if pygame.mixer.get_init() else None; self.avatar_surface_cache={}; self.avatar_scaled_cache={}; self.world_font_cache={}; self.game_menu_open=False; self.ui_base_size=(1440,900); self.ui_surface=pygame.Surface(self.ui_base_size); self.ui_fade_clock=0.0; self.field_rects={}; self.tab_rects={}; self.slot_rects=[]; self.color_rects={}; self.layer_rects=[]; self.layer_up_rect=pygame.Rect(0,0,0,0); self.layer_down_rect=pygame.Rect(0,0,0,0) self.profile={"socials":[],"public_email":False}; self.metrics={"online":0,"users":0,"offline":0}; self.platform_options=["None","Discord","YouTube","Twitch","X","Instagram","TikTok","Reddit","GitHub","Bluesky","Facebook","Website","Other"]; self.platform_dropdown=None; self.platform_dropdown_rect=pygame.Rect(0,0,0,0); self.platform_option_rects=[]; self.platform_dropdown_scroll=0; self.platform_scroll_ignore_until=0.0; self.editor_buttons=[]; self.layer_add_rect=pygame.Rect(0,0,0,0); self.layer_delete_rect=pygame.Rect(0,0,0,0); self.clear_layer_rect=pygame.Rect(0,0,0,0); self.new_slot_rect=pygame.Rect(0,0,0,0); self.slot_panel_rect=pygame.Rect(0,0,0,0); self.layer_panel_rect=pygame.Rect(0,0,0,0); self.slot_delete_rects=[]; self.delete_confirm_slot=None; self.drag_color_wheel=False; self.drag_sv=False; self.setup_layers(); self.setup_editor_from_slot(0) self.editor_hover_region=""; self.layer_visible_rects=[]; self.profile_popup=None; self.profile_link_confirm=None; self.friends_panel_open=False; self.username_change_open=False; self.username_new=""; self.friends=[]; self.outgoing_friend_requests=[]; self.friend_requests=[]; self.friend_request_queue=deque(); self.friend_modal=None; self.friend_search=""; self.friend_active=False; self.friend_status=""; self.friend_status_error=False; self.friend_join_notice=None; self.friend_cancel_rects=[] self.input_cache=(0.0,0.0,False); self.input_sent=(99,99,False); self.local_server_pos=None; self.remote_last={} def text(self,s,x,y,c=(235,237,242),font=None): surf=(font or self.font).render(str(s),True,c); self.screen.blit(surf,(x,y)) def center_text(self,s,cx,y,c=(235,237,242),font=None): surf=(font or self.font).render(str(s),True,c); self.screen.blit(surf,surf.get_rect(center=(int(cx),int(y)))) def panel(self,r,fill=(22,25,34),border=(63,70,90),rad=14): pygame.draw.rect(self.screen,fill,r,border_radius=rad); pygame.draw.rect(self.screen,border,r,1,border_radius=rad) def setup_layers(self, existing=None): self.layers=[] existing=existing if isinstance(existing,list) else [] if existing: for i,item in enumerate(existing): item=item if isinstance(item,dict) else {} surf=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); surf.fill((0,0,0,0)) img=item.get("image") if img: try: loaded=pygame.image.load(io.BytesIO(base64.b64decode(img))).convert_alpha() if loaded.get_size() != (self.char_size,self.char_size): loaded=pygame.transform.smoothscale(loaded,(self.char_size,self.char_size)) surf.blit(loaded,(0,0)) except Exception: pass self.layers.append({"name":str(item.get("name") or f"Layer {i+1}")[:40],"surface":surf,"visible":bool(item.get("visible",True))}) else: surf=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); surf.fill((0,0,0,0)) self.layers=[{"name":"Layer 1","surface":surf,"visible":True}] self.layer_selected=int(clamp(getattr(self,'layer_selected',0),0,len(self.layers)-1)) self.layer_scroll=0; self.undo_stack=[]; self.redo_stack=[]; self.history_drag_recorded=False self.refresh_composite() def refresh_composite(self): self.canvas=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); self.canvas.fill((0,0,0,0)) for layer in self.layers: if layer["visible"]: self.canvas.blit(layer["surface"],(0,0)) def _character_slot_value(self, index, c): try: return int(c.get("slot", index)) if isinstance(c, dict) else index except Exception: return index def _character_is_populated(self, c): if not isinstance(c, dict): return False return bool(c.get("image") or c.get("layers") or c.get("_local_new")) def _populated_characters(self): items=[] for index,c in enumerate(self.characters): if self._character_is_populated(c): items.append((self._character_slot_value(index,c),c)) items.sort(key=lambda item:item[0]) return items def _character_for_slot(self, slot): for index,c in enumerate(self.characters): if self._character_slot_value(index,c)==slot and isinstance(c,dict): return c return {} def _character_size(self, c): try: return LARGE_CHAR_SIZE if int(c.get("size",SMALL_CHAR_SIZE)) >= LARGE_CHAR_SIZE else SMALL_CHAR_SIZE except Exception: return SMALL_CHAR_SIZE def _min_pixels_for_size(self, size): return LARGE_MIN_FILLED_PIXELS if size >= LARGE_CHAR_SIZE else SMALL_MIN_FILLED_PIXELS def _ensure_local_slot(self, slot, name=None): for index,c in enumerate(self.characters): if self._character_slot_value(index,c)==slot: if not isinstance(c,dict): c={} self.characters[index]=c c["slot"]=slot c["_local_new"]=True c["name"]=name or c.get("name") or f"Character {slot+1}" return c c={"slot":slot,"name":name or f"Character {slot+1}","_local_new":True} self.characters.append(c) return c def setup_editor_from_slot(self, slot): self.selected_slot=int(slot); c=self._character_for_slot(self.selected_slot); self.char_size=self._character_size(c) self.editor_name=str(c.get("name",f"Character {self.selected_slot+1}")); self.flip_direction=bool(c.get("flip_direction",False)); self.setup_layers(c.get("layers",[])) if not c.get("layers"): self.canvas=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); self.canvas.fill((0,0,0,0)) image=c.get("image") if image: try: loaded=pygame.image.load(io.BytesIO(base64.b64decode(image))).convert_alpha() if loaded.get_size() != (self.char_size,self.char_size): loaded=pygame.transform.smoothscale(loaded,(self.char_size,self.char_size)) self.layers[-1]["surface"].blit(loaded,(0,0)); self.refresh_composite() except Exception: pass def surface_b64(self,surf): bio=io.BytesIO(); pygame.image.save(surf,bio,"PNG"); return base64.b64encode(bio.getvalue()).decode("ascii") def canvas_b64(self): return self.surface_b64(self.canvas) def filled_pixels(self): return sum(1 for y in range(self.char_size) for x in range(self.char_size) if self.canvas.get_at((x,y))[3]>0) def save_character(self, join=False): self.refresh_composite(); count=self.filled_pixels(); minimum=self._min_pixels_for_size(self.char_size) if count20: self.message="Character name must be 2-20 characters."; self.error=True; return layers=[{"name":l["name"],"visible":l["visible"],"image":self.surface_b64(l["surface"])} for l in self.layers] character={"slot":self.selected_slot,"name":name,"image":self.canvas_b64(),"layers":layers,"flip_direction":self.flip_direction,"size":self.char_size} self.pending_join=join; self.net.send({"action":"save_character","token":self.token,"slot":self.selected_slot,"character":character}); self.message="Saving character..."; self.error=False def join_world(self): self.save_character(join=True) def back_to_editor(self): self.net.send({"action":"input","token":self.token,"x":0,"y":0,"sprint":False}); self.net.send({"action":"leave_world","token":self.token}); self.stop_music(); self.mode="editor"; self.chat_input=None; self.message="Character editor"; self.error=False def make_checker(self,size,cell=10): surf=pygame.Surface((size,size)); a=(28,31,38); b=(34,37,45) for y in range(0,size,cell): for x in range(0,size,cell): pygame.draw.rect(surf,a if ((x//cell+y//cell)%2==0) else b,(x,y,cell,cell)) return surf def _ui_mouse_pos(self, pos): sw, sh = self.screen.get_size(); bw, bh = self.ui_base_size scale = min(sw / bw, sh / bh) if sw and sh else 1.0 ox = (sw - int(bw * scale)) / 2.0; oy = (sh - int(bh * scale)) / 2.0 return (int((pos[0] - ox) / scale), int((pos[1] - oy) / scale)) def _render_responsive_ui(self, draw_func): old = self.screen self.screen = self.ui_surface self.screen.fill((10,13,19)) draw_func() self.screen = old sw, sh = old.get_size(); bw, bh = self.ui_base_size scale = min(sw / bw, sh / bh) if sw and sh else 1.0 dw, dh = max(1, int(bw * scale)), max(1, int(bh * scale)) scaled = pygame.transform.smoothscale(self.ui_surface, (dw, dh)) old.fill((10,13,19)) old.blit(scaled, ((sw-dw)//2, (sh-dh)//2)) def _draw_caret(self, rect, text, font=None): if not self.active or int(time.time()*2) % 2 != 0: return f = font or self.font surf = f.render(str(text), True, (237,239,244)) x = min(rect.right-8, rect.x + 14 + surf.get_width() + 2) pygame.draw.line(self.screen, (230,235,255), (x, rect.y+11), (x, rect.bottom-11), 2) def load_settings(self): try: if SETTINGS_FILE.exists(): data=json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) if isinstance(data,dict): return data except Exception: pass return {"remember_me":True,"token":"","email":"","music_volume":0.6,"sfx_volume":0.8,"zoom":2.0} def save_settings(self): try: APPDATA_DIR.mkdir(parents=True,exist_ok=True) payload={ "remember_me":bool(self.remember_me), "token":self.token if self.remember_me and self.token else "", "email":self.form.get("email","") if self.remember_me else "", "password_protected":protect_local_secret(self.form.get("password", "")) if self.remember_me and self.form.get("password") else "", "music_volume":round(float(self.audio_music_volume),3), "sfx_volume":round(float(self.audio_sfx_volume),3), "zoom":round(float(clamp(self.zoom,1.0,10.0)),2), } SETTINGS_FILE.write_text(json.dumps(payload,indent=2,ensure_ascii=False),encoding="utf-8") except Exception: pass def stop_music(self): self.music_stream_active=False; self.music_meta=None; self.music_next_offset=None; self.music_request_inflight=False; self.music_queue_depth=0; self.music_chunk_ends=[]; self.music_eof=False try: if self.music_channel is not None: self.music_channel.stop() pygame.mixer.music.stop() except Exception: pass def open_account_settings(self): self.mode="account"; self.active=""; self.account_reset_stage=0; self.message="Account Settings"; self.error=False; self.account_active_field=None; profile_data=self.profile if isinstance(self.profile,dict) else {}; self.account_socials=[dict(x) for x in profile_data.get("socials",[])[:4]]; self.account_public_email=bool(profile_data.get("public_email",False)); self.account_social_links=["" for _ in range(4)]; self.account_social_platforms=["" for _ in range(4)] for i,x in enumerate(self.account_socials): self.account_social_platforms[i]=x.get("platform",""); self.account_social_links[i]=x.get("link","") pygame.key.stop_text_input(); self.stop_music(); self.net.send({"action":"profile_self","token":self.token}) def draw_outage(self): w,h=self.screen.get_size(); self.screen.fill((11,14,20)); box=pygame.Rect(max(40,(w-700)//2),max(80,(h-260)//2),min(700,w-80),260); self.panel(box,(23,27,36),(90,62,67),22); self.center_text("FEEBWORLD",box.centerx,box.y+58,(239,242,248),self.title); self.center_text("FEEBWORLD Server outage! Please try again later...",box.centerx,box.y+130,(242,179,179),self.font); self.outage_ok_rect=pygame.Rect(box.centerx-70,box.bottom-72,140,42); self._draw_button(self.outage_ok_rect,"OK",self.handle_outage_ok) def handle_outage_ok(self): self.stop_music(); self.net.send({"action":"leave_world","token":self.token} if self.token else {}); self.token=None; self.saved_token="" if not self.remember_me else self.saved_token; self.mode="login"; self.auth_mode="login"; self.auth_stage=1; self.active="email"; self.message="Server unavailable."; self.error=True; pygame.key.start_text_input(); self.save_settings() def draw_account_settings(self): w,h=self.screen.get_size(); self.screen.fill((11,14,20)); self.text("FEEBWORLD",52,38,(239,242,248),self.title); self.text("ACCOUNT SETTINGS",58,101,(124,133,151),self.font) panel=pygame.Rect(max(24,(w-1050)//2),135,min(1050,w-48),650); self.panel(panel,(23,27,36),(63,70,89),22) username=str((self.me or {}).get("username","")); self.text(f"Username: {username}",panel.x+28,panel.y+26,(240,242,247),self.big); self.text("Profile information saved on the server.",panel.x+30,panel.y+72,(138,146,162),self.small) # public email checkbox self.public_email_rect=pygame.Rect(panel.x+30,panel.y+108,26,26) pygame.draw.rect(self.screen,(14,18,25),self.public_email_rect,border_radius=5) pygame.draw.rect(self.screen,(103,118,145) if not self.account_public_email else (96,145,210),self.public_email_rect,2,border_radius=5) if self.account_public_email: pygame.draw.line(self.screen,(245,248,252),(self.public_email_rect.x+5,self.public_email_rect.y+13),(self.public_email_rect.x+11,self.public_email_rect.y+19),3) pygame.draw.line(self.screen,(245,248,252),(self.public_email_rect.x+11,self.public_email_rect.y+19),(self.public_email_rect.right-5,self.public_email_rect.y+6),3) self.text("Publicly display email on profile",panel.x+66,panel.y+111,(224,228,236),self.small) self.text("SOCIAL MEDIA (up to 4)",panel.x+30,panel.y+156,(240,242,247),self.font) presets=["Discord","YouTube","Twitch","X","Instagram","TikTok","Reddit","GitHub","Bluesky","Facebook","Website","Other"] self.social_rows=[] row_y=panel.y+202 for i in range(4): rr=pygame.Rect(panel.x+30,row_y+i*72,panel.w-60,60); pygame.draw.rect(self.screen,(15,18,25),rr,border_radius=9); pygame.draw.rect(self.screen,(67,74,91),rr,1,border_radius=9) # platform selector dropdown pr=pygame.Rect(rr.x+10,rr.y+9,185,42); self._draw_button(pr,self.account_social_platforms[i] or "SELECT PLATFORM",lambda i=i:self.open_platform_dropdown(i)) lr=pygame.Rect(pr.right+10,rr.y+9,rr.w-pr.w-30,42); self.social_rows.append((pr,lr,i)); pygame.draw.rect(self.screen,(9,12,18),lr,border_radius=7); pygame.draw.rect(self.screen,(91,101,124) if self.account_active_field==f"social_{i}" else (58,65,82),lr,1,border_radius=7); placeholder = "Discord username" if self.account_social_platforms[i] == "Discord" else "https://..."; self.text(self.account_social_links[i] or placeholder,lr.x+10,lr.y+11,(235,238,245) if self.account_social_links[i] else (102,110,126),self.small) if self.account_active_field==f"social_{i}":self._draw_caret(lr,self.account_social_links[i],self.small) if self.platform_dropdown is not None: # Centered, scrollable platform chooser. box_w=min(520,w-80); box_h=min(470,h-120); ox=(w-box_w)//2; oy=(h-box_h)//2 self.platform_dropdown_rect=pygame.Rect(ox,oy,box_w,box_h); self.panel(self.platform_dropdown_rect,(18,22,30),(89,101,128),12) self.text("SELECT PLATFORM",ox+22,oy+18,(241,243,248),self.font) self.text("Scroll to browse • click to select",ox+22,oy+49,(124,133,151),self.tiny) content=pygame.Rect(ox+14,oy+78,box_w-28,box_h-94) self.platform_option_rects=[] row_h=40 total_h=len(self.platform_options)*row_h self.platform_dropdown_scroll=int(clamp(self.platform_dropdown_scroll,0,max(0,total_h-content.h))) oldclip=self.screen.get_clip(); self.screen.set_clip(content) for oi,name in enumerate(self.platform_options): y=content.y + oi*row_h - self.platform_dropdown_scroll rr=pygame.Rect(content.x+4,y,content.w-8,34) self.platform_option_rects.append((rr,oi)) if rr.bottom>=content.top and rr.top<=content.bottom: hover=rr.collidepoint(pygame.mouse.get_pos()) pygame.draw.rect(self.screen,(57,71,98) if hover else (25,30,40),rr,border_radius=7) self.text(name,rr.x+12,rr.y+8,(235,239,245),self.small) self.screen.set_clip(oldclip) self.change_pw_rect=pygame.Rect(panel.right-220,panel.y+160,190,40); self._draw_button(self.change_pw_rect,"CHANGE PASSWORD",self.begin_password_change if self.account_reset_stage==0 and not self.username_change_open else lambda:None) self.change_username_rect=pygame.Rect(panel.right-430,panel.y+160,190,40); self._draw_button(self.change_username_rect,"CHANGE USERNAME",self.begin_username_change if self.account_reset_stage==0 and not self.username_change_open else lambda:None) self.account_save_profile_rect=pygame.Rect(panel.right-235,panel.bottom-62,205,44); self._draw_button(self.account_save_profile_rect,"SAVE PROFILE",self.save_profile_settings) self.account_back_rect=pygame.Rect(panel.x+30,panel.bottom-62,150,44); self._draw_button(self.account_back_rect,"BACK",self.back_to_editor) if self.message:self.text(self.message,panel.x+30,panel.bottom-100,(226,128,128) if self.error else (184,189,201),self.small) if self.username_change_open: shade=pygame.Surface((w,h),pygame.SRCALPHA); shade.fill((0,0,0,155)); self.screen.blit(shade,(0,0)); overlay=pygame.Rect(max(40,(w-650)//2),max(80,(h-300)//2),min(650,w-80),260); self.panel(overlay,(24,28,39),(98,110,142),18) self.center_text("CHANGE USERNAME",overlay.centerx,overlay.y+38,(245,247,250),self.big) self.text(f"Old username: {username}",overlay.x+24,overlay.y+82,(180,186,199),self.small) self.text("NEW USERNAME",overlay.x+24,overlay.y+116,(126,133,150),self.tiny) self.username_field_rect=pygame.Rect(overlay.x+20,overlay.y+136,overlay.w-40,40); pygame.draw.rect(self.screen,(10,13,19),self.username_field_rect,border_radius=7); pygame.draw.rect(self.screen,(94,119,187) if self.active=="username_change" else (58,65,82),self.username_field_rect,1,border_radius=7); self.text(self.username_new or "Enter a new username",self.username_field_rect.x+10,self.username_field_rect.y+10,(235,238,245) if self.username_new else (102,110,126),self.small); if self.active=="username_change": self._draw_caret(self.username_field_rect,self.username_new,self.small) self.username_cancel_rect=pygame.Rect(overlay.x+24,overlay.bottom-50,130,36); self.username_submit_rect=pygame.Rect(overlay.right-154,overlay.bottom-50,130,36); self._draw_button(self.username_cancel_rect,"CANCEL",self.cancel_username_change); self._draw_button(self.username_submit_rect,"SUBMIT",self.submit_username_change) if self.account_reset_stage>0: shade=pygame.Surface((w,h),pygame.SRCALPHA); shade.fill((0,0,0,155)); self.screen.blit(shade,(0,0)); overlay=pygame.Rect(max(40,(w-650)//2),max(80,(h-390)//2),min(650,w-80),330); self.panel(overlay,(24,28,39),(98,110,142),18) if self.account_reset_stage==1: self.center_text("CHANGE PASSWORD",overlay.centerx,overlay.y+36,(245,247,250),self.big); self.text("Send a verification code to your account email?",overlay.x+24,overlay.y+84,(226,230,238),self.font); self.text("A code expires after 10 minutes.",overlay.x+24,overlay.y+118,(132,139,155),self.small); self.reset_yes_rect=pygame.Rect(overlay.x+24,overlay.bottom-58,130,38); self.reset_no_rect=pygame.Rect(overlay.right-154,overlay.bottom-58,130,38); self._draw_button(self.reset_yes_rect,"YES",self.request_password_code); self._draw_button(self.reset_no_rect,"NO",self.cancel_password_change) else: labels=[("code","VERIFICATION CODE",self.account_reset_code),("new","NEW PASSWORD",self.account_new_password),("confirm","CONFIRM PASSWORD",self.account_confirm_password)]; self.account_fields={}; yy=overlay.y+22 for key,label,val in labels: self.text(label,overlay.x+20,yy,(126,133,150),self.tiny); rr=pygame.Rect(overlay.x+20,yy+20,overlay.w-40,34); self.account_fields[key]=rr; shown=('•'*len(val)) if key in ("new","confirm") else val; pygame.draw.rect(self.screen,(10,13,19),rr,border_radius=7); pygame.draw.rect(self.screen,(94,119,187) if self.active==key else (58,65,82),rr,1,border_radius=7); self.text(shown,rr.x+10,rr.y+8,(235,238,245),self.small); if self.active==key:self._draw_caret(rr,shown,self.small) yy+=62 self.reset_submit_rect=pygame.Rect(overlay.centerx-90,overlay.bottom-52,180,38); self._draw_button(self.reset_submit_rect,"RESET PASSWORD",self.submit_password_reset) def open_platform_dropdown(self,i): self.platform_dropdown=None if self.platform_dropdown==i else i self.platform_dropdown_scroll=0 self.account_active_field=None pygame.key.stop_text_input() def select_platform(self,i,option_index): selected=self.platform_options[option_index] if selected=="None": self.account_social_platforms[i]="" self.account_social_links[i]="" else: self.account_social_platforms[i]=selected self.platform_dropdown=None def save_profile_settings(self): socials=[] for p,l in zip(self.account_social_platforms,self.account_social_links): if p and l.strip(): socials.append({"platform":p,"link":l.strip()}) self.net.send({"action":"profile_save","token":self.token,"socials":socials,"public_email":self.account_public_email}); self.message="Saving profile..."; self.error=False def account_key(self,e): if self.username_change_open: if e.key==pygame.K_ESCAPE: self.cancel_username_change() elif e.key==pygame.K_BACKSPACE: self.username_new=self.username_new[:-1] elif e.key==pygame.K_RETURN: self.submit_username_change() return if e.key==pygame.K_ESCAPE and self.platform_dropdown is not None: self.platform_dropdown=None; return if self.account_reset_stage==2: keys=["code","new","confirm"] if e.key==pygame.K_TAB:self.active=keys[(keys.index(self.active)+1)%len(keys)] if self.active in keys else keys[0] elif e.key==pygame.K_BACKSPACE and self.active: if self.active=="code":self.account_reset_code=self.account_reset_code[:-1] elif self.active=="new":self.account_new_password=self.account_new_password[:-1] else:self.account_confirm_password=self.account_confirm_password[:-1] elif e.key==pygame.K_RETURN:self.submit_password_reset() elif e.key==pygame.K_ESCAPE:self.cancel_password_change() return if self.account_active_field and self.account_active_field.startswith("social_"): idx=int(self.account_active_field.split("_")[1]) if e.key==pygame.K_TAB:self.account_active_field=f"social_{(idx+1)%4}" elif e.key==pygame.K_BACKSPACE:self.account_social_links[idx]=self.account_social_links[idx][:-1] elif e.key==pygame.K_RETURN:self.save_profile_settings() elif e.key==pygame.K_ESCAPE:self.account_active_field=None; pygame.key.stop_text_input() def account_mouse(self,e): # Mouse-wheel buttons must never be treated as UI clicks. # Some Windows/pygame input paths can expose wheel actions as legacy # button 4/5 events in addition to MOUSEWHEEL. if getattr(e, "button", None) in (4, 5): return if getattr(e, "button", None) == 2 and self.platform_dropdown is not None: return pos=self._ui_mouse_pos(e.pos) if self.account_reset_stage==0: if self.platform_dropdown is not None: for rr,oi in getattr(self,"platform_option_rects",[]): if rr.collidepoint(pos): self.select_platform(self.platform_dropdown,oi); return if not getattr(self,"platform_dropdown_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.platform_dropdown=None return if getattr(self,"account_back_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.back_to_editor(); return if getattr(self,"change_pw_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.begin_password_change(); return if getattr(self,"public_email_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.account_public_email=not self.account_public_email; return if getattr(self,"account_save_profile_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.save_profile_settings(); return for pr,lr,i in getattr(self,"social_rows",[]): if pr.collidepoint(pos):self.open_platform_dropdown(i); return if lr.collidepoint(pos):self.account_active_field=f"social_{i}"; pygame.key.start_text_input(); return elif self.account_reset_stage==1: if self.reset_yes_rect.collidepoint(pos):self.request_password_code() elif self.reset_no_rect.collidepoint(pos):self.cancel_password_change() else: for key,r in self.account_fields.items(): if r.collidepoint(pos):self.active=key; pygame.key.start_text_input(); return if self.reset_submit_rect.collidepoint(pos):self.submit_password_reset() def begin_username_change(self): self.username_change_open=True; self.username_new=""; self.active="username_change"; self.message="Enter your new username."; self.error=False; pygame.key.start_text_input() def cancel_username_change(self): self.username_change_open=False; self.username_new=""; self.active=""; pygame.key.stop_text_input() def submit_username_change(self): new_name=self.username_new.strip() if len(new_name)<2 or len(new_name)>20 or not all(c.isalnum() or c in "_- " for c in new_name): self.message="Username must be 2-20 letters/numbers/spaces/_/-."; self.error=True; return if not self.token: return self.message="Changing username..."; self.error=False self.net.send({"action":"username_change","token":self.token,"username":new_name}) def begin_password_change(self): self.account_reset_stage=1; self.message="Confirm password change"; self.error=False def cancel_password_change(self): self.account_reset_stage=0; self.active="" def request_password_code(self): if not self.token:return self.net.send({"action":"password_reset_request","token":self.token}); self.account_reset_stage=2; self.active="code"; pygame.key.start_text_input(); self.message="Sending verification code..."; self.error=False def submit_password_reset(self): if len(self.account_new_password)<8 or len(self.account_new_password)>200: self.message="Password must be 8-200 characters."; self.error=True; return if self.account_new_password!=self.account_confirm_password: self.message="New passwords do not match."; self.error=True; return salt_b64,digest_b64=client_password_credentials(self.account_new_password) self.net.send({"action":"password_reset_confirm","token":self.token,"code":self.account_reset_code,"password_length":len(self.account_new_password),"password_salt":salt_b64,"password_digest":digest_b64}) def account_key(self,e): if self.username_change_open: if e.key==pygame.K_ESCAPE: self.cancel_username_change() elif e.key==pygame.K_BACKSPACE: self.username_new=self.username_new[:-1] elif e.key==pygame.K_RETURN: self.submit_username_change() return if e.key==pygame.K_ESCAPE and self.platform_dropdown is not None: self.platform_dropdown=None; return if self.account_reset_stage==2: keys=["code","new","confirm"] if e.key==pygame.K_TAB: self.active=keys[(keys.index(self.active)+1)%len(keys)] if self.active in keys else keys[0] elif e.key==pygame.K_BACKSPACE and self.active: if self.active=="code": self.account_reset_code=self.account_reset_code[:-1] elif self.active=="new": self.account_new_password=self.account_new_password[:-1] else: self.account_confirm_password=self.account_confirm_password[:-1] elif e.key==pygame.K_RETURN: self.submit_password_reset() elif e.key==pygame.K_ESCAPE: self.cancel_password_change() return if self.account_active_field and self.account_active_field.startswith("social_"): idx=int(self.account_active_field.split("_")[1]) if e.key==pygame.K_TAB: self.account_active_field=f"social_{(idx+1)%4}" elif e.key==pygame.K_BACKSPACE: self.account_social_links[idx]=self.account_social_links[idx][:-1] elif e.key==pygame.K_RETURN: self.save_profile_settings() elif e.key==pygame.K_ESCAPE: self.account_active_field=None; pygame.key.stop_text_input() def account_mouse(self,e): pos=self._ui_mouse_pos(e.pos) if self.username_change_open: if getattr(self,"username_field_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.active="username_change"; pygame.key.start_text_input(); return if getattr(self,"username_cancel_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.cancel_username_change(); return if getattr(self,"username_submit_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.submit_username_change(); return return if self.account_reset_stage==0: if self.platform_dropdown is not None: # Never allow legacy wheel/button events or a synthetic click # generated immediately after a wheel gesture to select an option. if getattr(e, "button", None) in (4, 5, 2): return if time.monotonic() < getattr(self, "platform_scroll_ignore_until", 0.0): return if getattr(self,"platform_dropdown_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): for rr,oi in getattr(self,"platform_option_rects",[]): if rr.collidepoint(pos): self.select_platform(self.platform_dropdown,oi) return return self.platform_dropdown=None if getattr(self,"account_back_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.back_to_editor(); return if getattr(self,"change_username_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.begin_username_change(); return if getattr(self,"change_pw_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.begin_password_change(); return if getattr(self,"account_save_profile_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.save_profile_settings(); return if getattr(self,"public_email_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.account_public_email=not self.account_public_email; return for pr,lr,i in getattr(self,"social_rows",[]): if pr.collidepoint(pos): self.open_platform_dropdown(i); return if lr.collidepoint(pos): self.account_active_field=f"social_{i}"; pygame.key.start_text_input(); return elif self.account_reset_stage==1: if self.reset_yes_rect.collidepoint(pos): self.request_password_code() elif self.reset_no_rect.collidepoint(pos): self.cancel_password_change() else: for key,r in self.account_fields.items(): if r.collidepoint(pos): self.active=key; pygame.key.start_text_input(); return if self.reset_submit_rect.collidepoint(pos): self.submit_password_reset() def draw_login(self): w,h=self.screen.get_size(); self.screen.fill((11,14,20)); self.text("FEEB",62,58,(239,242,248),self.title); self.text("WORLD",65,118,(98,179,244),self.title); self.text("A cozy pixel multiplayer world",67,196,(154,161,177),self.font) self.text("Draw your own FEEB!",70,300,(199,203,213),self.font) self.text("Play as your FEEB and meet new friends",70,344,(199,203,213),self.font) self.text("all around FEEB WORLD.",70,388,(199,203,213),self.font) pw=min(610,w-640); px=w-pw-40; self.panel((px,30,pw,h-60),(23,27,36),(63,70,89),22) self.tab_rects={"login":pygame.Rect(px+28,68,(pw-56)//2,52),"register":pygame.Rect(px+28+(pw-56)//2,68,(pw-56)//2,52)} for k,r in self.tab_rects.items(): pygame.draw.rect(self.screen,(72,94,158) if self.auth_mode==k else (42,46,58),r,border_radius=10); self.center_text("SIGN IN" if k=="login" else "CREATE ACCOUNT",r.centerx,r.centery,(240,242,247),self.font) self.text("SIGN IN" if self.auth_mode=="login" else "CREATE ACCOUNT",px+30,150,(241,243,248),self.big) keys=[("email","EMAIL"),("password","PASSWORD")] if self.auth_mode=="login" else ([('username','USERNAME'),('email','EMAIL'),('password','PASSWORD')] if self.auth_stage==1 else [('code','VERIFICATION CODE')]) self.field_rects={}; y=224 for key,label in keys: self.text(label,px+32,y-24,(146,154,174),self.small); r=pygame.Rect(px+28,y,pw-56,54); self.field_rects[key]=r; pygame.draw.rect(self.screen,(15,18,25),r,border_radius=10); pygame.draw.rect(self.screen,(100,132,230) if self.active==key else (58,65,82),r,2 if self.active==key else 1,border_radius=10) value=self.form[key]; shown=("•"*len(value)) if key=="password" and not self.show_pw else value; self.text(shown or ("6-digit code" if key=="code" else "Click to type"),r.x+14,r.y+17,(237,239,244) if shown else (93,99,115),self.font); if self.active==key: self._draw_caret(r, shown, self.font) if key=="password" and self.auth_mode=="register" and self.auth_stage==1: self.text(f"{len(value)}/200", r.right-58, r.y+18, (126,134,150), self.tiny) y+=82 self.action_rect=pygame.Rect(px+28,515,pw-56,56); pygame.draw.rect(self.screen,(77,103,185),self.action_rect,border_radius=10); self.center_text("SEND VERIFICATION CODE" if self.auth_mode=="register" and self.auth_stage==1 else ("VERIFY ACCOUNT" if self.auth_mode=="register" else "SIGN IN"),self.action_rect.centerx,self.action_rect.centery,(244,245,250),self.font) self.show_pw_rect=pygame.Rect(self.action_rect.right-110,self.action_rect.y-62,100,34) if self.auth_mode=="login" else pygame.Rect(0,0,0,0) if self.auth_mode=="login": self.text("SHOW PASSWORD" if not self.show_pw else "HIDE PASSWORD",self.show_pw_rect.x,self.show_pw_rect.y+8,(143,151,168),self.tiny) self.remember_rect=pygame.Rect(px+30,self.action_rect.bottom+14,20,20) pygame.draw.rect(self.screen,(93,103,129),self.remember_rect,border_radius=4) if self.remember_me: pygame.draw.rect(self.screen,(97,169,239),self.remember_rect,border_radius=4) pygame.draw.line(self.screen,(245,248,252),(self.remember_rect.x+4,self.remember_rect.centery),(self.remember_rect.x+9,self.remember_rect.bottom-4),2) pygame.draw.line(self.screen,(245,248,252),(self.remember_rect.x+9,self.remember_rect.bottom-4),(self.remember_rect.right-3,self.remember_rect.y+4),2) self.text("Remember me",self.remember_rect.right+8,self.remember_rect.y+1,(157,164,178),self.small) else: self.remember_rect=pygame.Rect(0,0,0,0) if self.message: self.text(self.message,px+30,h-110,(226,128,128) if self.error else (184,189,201),self.small) self.text(f"Users: {self.metrics['online']} online {self.metrics['offline']} offline",px+30,h-82,(126,134,150),self.small) def draw_hue_wheel(self, rect): size=min(rect.w,rect.h); center=(size/2,size/2); inner=size*0.36; outer=size*0.50 if self.wheel_surface is None or self.wheel_surface.get_size()!=(size,size): self.wheel_surface=pygame.Surface((size,size),pygame.SRCALPHA) self.wheel_surface.fill((0,0,0,0)); cx,cy=center for y in range(size): for x in range(size): dx=x-cx; dy=y-cy; d=(dx*dx+dy*dy)**0.5 if inner<=d<=outer: h=(math.degrees(math.atan2(dy,dx))+360)%360; col=hsv_rgb(h,100,100); self.wheel_surface.set_at((x,y),(*col,255)) self.screen.blit(self.wheel_surface,rect) ang=math.radians(self.hue); rr=(inner+outer)/2; px=rect.centerx+math.cos(ang)*rr; py=rect.centery+math.sin(ang)*rr; pygame.draw.circle(self.screen,(245,245,248),(int(px),int(py)),6,2) pygame.draw.circle(self.screen,self.paint_color[:3],rect.center,max(4,int(inner*.45))); pygame.draw.circle(self.screen,(190,194,204),rect.center,max(4,int(inner*.45)),1) def draw_editor(self): # The window uses pygame.SCALED, so these 1440x900 logical coordinates remain # the baseline design and scale uniformly to the actual window. w,h=self.screen.get_size(); self.screen.fill((10,13,19)); self.editor_buttons=[] margin,gap=18,14; left_w=300; right_w=478; center_x=margin+left_w+gap center_w=max(480,w-margin*2-left_w-right_w-gap*2); right_x=center_x+center_w+gap panel_y=100; panel_h=h-118 left=pygame.Rect(margin,panel_y,left_w,panel_h); center=pygame.Rect(center_x,panel_y,center_w,panel_h); right=pygame.Rect(right_x,panel_y,right_w,panel_h) self.slot_panel_rect=left.copy(); self.layer_panel_rect=pygame.Rect(right.x+16,right.y+330,right.w-32,235) self.text('FEEBWORLD',margin+10,22,(240,243,248),self.big); self.account_settings_rect=pygame.Rect(margin+250,24,180,36); self._draw_button(self.account_settings_rect,'Account Settings',lambda:self.open_account_settings()); self.text('CHARACTER WORKSHOP',margin+14,60,(124,133,151),self.small) self.panel(left); self.text('CHARACTER SLOTS',left.x+18,left.y+14,(240,242,247),self.font) self.new_slot_rect=pygame.Rect(left.right-110,left.y+14,92,32); self._draw_button(self.new_slot_rect,'NEW SLOT',self.create_new_slot) populated=self._populated_characters(); self.text(f'{len(populated)} / {MAX_SLOTS} created',left.x+18,left.y+48,(125,132,147),self.small) viewport=pygame.Rect(left.x+14,left.y+76,left.w-28,left.h-116); pygame.draw.rect(self.screen,(15,18,25),viewport,border_radius=10); self.slot_panel_rect=viewport.copy() self.slot_rects=[]; self.slot_delete_rects=[]; slot_h=64; gap_y=7; visible=max(1,viewport.h//(slot_h+gap_y)); max_scroll=max(0,len(populated)-visible); self.slot_scroll=int(clamp(self.slot_scroll,0,max_scroll)) for row,(slot,cdata) in enumerate(populated[self.slot_scroll:self.slot_scroll+visible]): r=pygame.Rect(viewport.x+8,viewport.y+8+row*(slot_h+gap_y),viewport.w-16,slot_h); self.slot_rects.append((r,slot)); active=slot==self.selected_slot pygame.draw.rect(self.screen,(61,78,125) if active else (31,35,45),r,border_radius=8); pygame.draw.rect(self.screen,(100,108,126),r,1,border_radius=8) name=str(cdata.get('name') or f'Character {slot+1}')[:18] if cdata.get('image'): try: im=pygame.image.load(io.BytesIO(base64.b64decode(cdata['image']))).convert_alpha(); im=pygame.transform.scale(im,(46,46)); self.screen.blit(im,pygame.Rect(r.x+7,r.y+9,46,46)) except Exception: pass else:self.center_text('NEW',r.x+30,r.centery,(145,153,174),self.tiny) self.text(name,r.x+62,r.y+10,(239,241,246),self.small); self.text(f'Slot {slot+1}',r.x+62,r.y+35,(135,142,158),self.tiny) dr=pygame.Rect(r.right-35,r.y+17,24,30); self.slot_delete_rects.append((dr,slot)); pygame.draw.rect(self.screen,(118,69,74) if dr.collidepoint(pygame.mouse.get_pos()) else (82,55,62),dr,border_radius=6) lid=pygame.Rect(dr.x+5,dr.y+6,14,3); body=pygame.Rect(dr.x+7,dr.y+10,10,13); pygame.draw.rect(self.screen,(240,224,227),lid,border_radius=1); pygame.draw.rect(self.screen,(232,215,219),body,border_radius=2); pygame.draw.line(self.screen,(124,88,93),(dr.x+5,dr.y+4),(dr.right-5,dr.y+4),2) self.text('Scroll this list to browse characters',left.x+18,left.bottom-31,(104,111,128),self.tiny) self.panel(center); self.text('DRAW YOUR CHARACTER',center.x+22,center.y+20,(240,242,247),self.font); self.text(f'{self.char_size} x {self.char_size} px | minimum {self._min_pixels_for_size(self.char_size):,} filled pixels',center.x+22,center.y+47,(126,133,150),self.small) canvas_size=min(500,center.w-90,center.h-205); canvas_size=max(360,int(canvas_size)); canvas_rect=pygame.Rect(0,0,canvas_size,canvas_size); canvas_rect.centerx=center.centerx; canvas_rect.y=center.y+88; self.canvas_rect=canvas_rect self.screen.blit(self.make_checker(canvas_size,max(8,canvas_size//32)),canvas_rect); self.screen.blit(pygame.transform.scale(self.canvas,(canvas_size,canvas_size)),canvas_rect); pygame.draw.rect(self.screen,(123,132,154),canvas_rect,2) forward_on_left=self.flip_direction; outward=-1 if forward_on_left else 1; forward_x=canvas_rect.left-30 if forward_on_left else canvas_rect.right+30; base_y=canvas_rect.centery pygame.draw.line(self.screen,(142,149,165),(forward_x,base_y-22),(forward_x,base_y+22),3); tip_x=forward_x+outward*12; pygame.draw.polygon(self.screen,(142,149,165),[(tip_x,base_y),(forward_x,base_y-8),(forward_x,base_y+8)]) label=self.tiny.render('FORWARD DIR',True,(142,149,165)); label_s=pygame.transform.rotate(label,90); self.screen.blit(label_s,label_s.get_rect(center=(forward_x+outward*18,base_y))) help_y=canvas_rect.bottom+18; self.center_text('Your character faces toward the arrow',center.centerx,help_y,(126,133,150),self.tiny); self.center_text('Left click paints | Right click erases | Scroll changes pen size',center.centerx,help_y+20,(108,115,132),self.tiny) self.panel(right); rx=right.x+18; rw=right.w-36; self.text('DESIGN',rx,right.y+18,(240,242,247),self.font); self.text('CHARACTER NAME',rx,right.y+47,(125,132,147),self.small) self.name_rect=pygame.Rect(rx,right.y+66,rw,38); pygame.draw.rect(self.screen,(15,18,24),self.name_rect,border_radius=8); pygame.draw.rect(self.screen,(69,77,98),self.name_rect,1,border_radius=8); self.text(self.editor_name,self.name_rect.x+12,self.name_rect.y+10,(237,239,244),self.small); if self.active=="editor_name": self._draw_caret(self.name_rect, self.editor_name, self.small) self.text('COLOR',rx,right.y+116,(240,242,247),self.font); color_y=right.y+140; wheel_r=pygame.Rect(rx,color_y,112,112); sv_r=pygame.Rect(rx+rw-112,color_y,112,112); self.color_rects={'wheel':wheel_r,'sv':sv_r}; self.draw_hue_wheel(wheel_r) if self.sv_surface is None or self.sv_surface.get_size()!=sv_r.size or self.sv_hue!=int(self.hue): self.sv_surface=pygame.Surface(sv_r.size); self.sv_hue=int(self.hue) for yy in range(sv_r.h): vv=100-yy*100/max(1,sv_r.h-1) for xx in range(sv_r.w): self.sv_surface.set_at((xx,yy),hsv_rgb(self.hue,xx*100/max(1,sv_r.w-1),vv)) self.screen.blit(self.sv_surface,sv_r); sx=sv_r.x+self.sat/100*sv_r.w; sy=sv_r.y+(100-self.val)/100*sv_r.h; pygame.draw.circle(self.screen,(245,245,248),(int(sx),int(sy)),5,2) self.text(f'HUE {int(self.hue)}°',rx,color_y+118,(164,169,181),self.tiny); self.text(f'SAT {int(self.sat)}%',rx+72,color_y+118,(164,169,181),self.tiny); self.text(f'VAL {int(self.val)}%',rx+145,color_y+118,(164,169,181),self.tiny) tools_y=right.y+284; self.text('TOOLS',rx,tools_y,(240,242,247),self.font); self.tool_rects={}; specs=[('brush','BRUSH'),('eyedropper','PICK'),('bucket','BUCKET'),('eraser','ERASER')]; tg=6; tw=(rw-tg*3)//4 for i,(tid,lab) in enumerate(specs): rr=pygame.Rect(rx+i*(tw+tg),tools_y+26,tw,30); self.tool_rects[tid]=rr; pygame.draw.rect(self.screen,(77,103,185) if self.tool==tid else (57,66,94),rr,border_radius=7); pygame.draw.rect(self.screen,(120,130,155),rr,1,border_radius=7); self.center_text(lab,rr.centerx,rr.centery,(245,246,249),self.tiny) self.text(f'PEN SIZE: {self.brush}px',rx,tools_y+64,(225,229,237),self.small); self.text('Scroll over canvas to change pen size',rx,tools_y+86,(132,139,155),self.tiny) ly=right.y+385; self.text('LAYERS',rx,ly,(240,242,247),self.font); addw=92; self.layer_add_rect=pygame.Rect(right.right-18-addw,ly-5,addw,28); self._draw_button(self.layer_add_rect,'+ LAYER',self.add_layer) lvp=pygame.Rect(rx,ly+27,rw,max(118,min(170,right.bottom-(ly+27)-188))); self.layer_panel_rect=lvp; pygame.draw.rect(self.screen,(15,18,25),lvp,border_radius=9); pygame.draw.rect(self.screen,(55,62,78),lvp,1,border_radius=9) self.layer_rects=[]; self.layer_visible_rects=[]; lh=30; lg=4; vis=max(1,lvp.h//(lh+lg)); max_ls=max(0,len(self.layers)-vis); self.layer_scroll=int(clamp(self.layer_scroll,0,max_ls)); end_i=max(0,len(self.layers)-self.layer_scroll); start_i=max(0,end_i-vis); shown=list(range(start_i,end_i))[::-1] for row,actual in enumerate(shown): layer=self.layers[actual]; r=pygame.Rect(lvp.x+6,lvp.y+6+row*(lh+lg),lvp.w-12,lh); visr=pygame.Rect(r.right-28,r.y+4,20,20); self.layer_rects.append((r,actual)); self.layer_visible_rects.append((visr,actual)); pygame.draw.rect(self.screen,(61,78,125) if actual==self.layer_selected else (31,35,45),r,border_radius=6); pygame.draw.rect(self.screen,(100,108,126),r,1,border_radius=6); self.text(layer['name'][:30],r.x+9,r.y+6,(236,239,245),self.tiny); pygame.draw.circle(self.screen,(91,209,152) if layer['visible'] else (74,79,92),visr.center,6) self.text('Scroll over layers to browse',lvp.x+8,lvp.bottom-16,(96,103,118),self.tiny) opy=lvp.bottom+8; bw=(rw-12)//3; self.layer_up_rect=pygame.Rect(rx,opy,bw,30); self.layer_down_rect=pygame.Rect(rx+bw+6,opy,bw,30); self.clear_layer_rect=pygame.Rect(rx+(bw+6)*2,opy,bw,30) self._draw_button(self.layer_up_rect,'MOVE UP',self.move_layer_up); self._draw_button(self.layer_down_rect,'MOVE DOWN',self.move_layer_down); self._draw_button(self.clear_layer_rect,'CLEAR',self.clear_layer) opy2=opy+36; self.undo_rect=pygame.Rect(rx,opy2,bw,30); self.redo_rect=pygame.Rect(rx+bw+6,opy2,bw,30); self.layer_delete_rect=pygame.Rect(rx+(bw+6)*2,opy2,bw,30) self._draw_button(self.undo_rect,'UNDO',self.undo); self._draw_button(self.redo_rect,'REDO',self.redo); self._draw_button(self.layer_delete_rect,'DELETE',self.delete_layer) self.flip_rect=pygame.Rect(rx,opy2+36,rw,30); self._draw_button(self.flip_rect,'INVERT DIRECTION' if not self.flip_direction else 'DIRECTION INVERTED',self.toggle_flip) status_y=opy2+78; self.text(f'FILLED PIXELS: {self.filled_pixels():,}',rx,status_y,(137,143,158),self.tiny); self.text('Minimum: 1,500 filled pixels',rx,status_y+16,(108,114,129),self.tiny) action_y=status_y+35; aw=(rw-6)//2; self.save_rect=pygame.Rect(rx,action_y,aw,34); self.join_rect=pygame.Rect(rx+aw+6,action_y,aw,34); self._draw_button(self.save_rect,'SAVE',lambda:self.save_character(False)); self._draw_button(self.join_rect,'ENTER WORLD',self.join_world) if self.message: self.text(self.message[:48],rx,action_y+40,(226,128,128) if self.error else (183,188,201),self.tiny) if self.delete_confirm_slot is not None: slot=self.delete_confirm_slot; cc=self._character_for_slot(slot); name=str(cc.get('name') or f'Character {slot+1}'); shade=pygame.Surface((w,h),pygame.SRCALPHA); shade.fill((0,0,0,150)); self.screen.blit(shade,(0,0)) modal=pygame.Rect((w-430)//2,(h-210)//2,430,210); self.panel(modal,(24,28,38),(94,103,127),16); self.center_text('DELETE CHARACTER?',modal.centerx,modal.y+38,(245,246,249),self.big); self.center_text(f'Do you want to delete {name}?',modal.centerx,modal.y+88,(202,207,218),self.small) self.delete_no_rect=pygame.Rect(modal.x+32,modal.bottom-66,160,38); self.delete_yes_rect=pygame.Rect(modal.right-192,modal.bottom-66,160,38); self._draw_button(self.delete_no_rect,'NO',self.cancel_delete); self._draw_button(self.delete_yes_rect,'YES, DELETE',self.confirm_delete) if self.new_slot_modal: modal=pygame.Rect(w//2-360,h//2-170,720,340); shade=pygame.Surface((w,h),pygame.SRCALPHA); shade.fill((0,0,0,165)); self.screen.blit(shade,(0,0)); self.panel(modal,(24,28,39),(98,110,142),18) self.center_text("CREATE NEW CHARACTER",modal.centerx,modal.y+38,(245,247,250),self.big) self.center_text("Choose the character canvas size.",modal.centerx,modal.y+80,(157,165,182),self.font) gap=24; bw=(modal.w-96-gap)//2; y=modal.y+125 self.new_small_rect=pygame.Rect(modal.x+48,y,bw,105); self.new_large_rect=pygame.Rect(self.new_small_rect.right+gap,y,bw,105) self._draw_button(self.new_small_rect,"SMALL 150×150",lambda:self.create_slot_of_size(SMALL_CHAR_SIZE)); self._draw_button(self.new_large_rect,"LARGE 300×300",lambda:self.create_slot_of_size(LARGE_CHAR_SIZE)) self.center_text("1,500 minimum",self.new_small_rect.centerx,y+72,(145,152,169),self.tiny); self.center_text("3,000 minimum",self.new_large_rect.centerx,y+72,(145,152,169),self.tiny) self.new_cancel_rect=pygame.Rect(modal.centerx-60,modal.bottom-48,120,30); self._draw_button(self.new_cancel_rect,"CANCEL",lambda:setattr(self,'new_slot_modal',False)) def _draw_button(self,r,t,cb): btn=Button(r,t,cb,fill=(57,66,94)); self.editor_buttons.append(btn); btn.draw(self) def editor_tool_click(self,r,t): if t=="CLEAR": self.push_history() self.layers[self.layer_selected]["surface"].fill((0,0,0,0)); self.refresh_composite() def _snapshot(self): return { "layers":[{"name":l["name"],"visible":l["visible"],"image":self.surface_b64(l["surface"])} for l in self.layers], "selected":self.layer_selected } def _restore_snapshot(self,snap): items=snap.get("layers",[]) if isinstance(snap,dict) else [] if not items: items=[{"name":"Layer 1","visible":True,"image":""}] self.layers=[] for i,item in enumerate(items): surf=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); surf.fill((0,0,0,0)) try: raw=item.get("image","") if raw: surf.blit(pygame.image.load(io.BytesIO(base64.b64decode(raw))).convert_alpha(),(0,0)) except Exception: pass self.layers.append({"name":str(item.get("name") or f"Layer {i+1}")[:40],"visible":bool(item.get("visible",True)),"surface":surf}) self.layer_selected=int(clamp(snap.get("selected",len(self.layers)-1),0,len(self.layers)-1)) self.layer_scroll=0 self.refresh_composite() def push_history(self): self.undo_stack.append(self._snapshot()) if len(self.undo_stack)>self.history_limit: self.undo_stack.pop(0) self.redo_stack.clear() def undo(self): if not self.undo_stack: return self.redo_stack.append(self._snapshot()) snap=self.undo_stack.pop() self._restore_snapshot(snap) self.history_drag_recorded=False def redo(self): if not self.redo_stack: return self.undo_stack.append(self._snapshot()) snap=self.redo_stack.pop() self._restore_snapshot(snap) self.history_drag_recorded=False def set_editor_tool(self, tool): self.tool=tool self.eraser=(tool=="eraser") def editor_tool_from_point(self,pos): for name,r in self.tool_rects.items(): if r.collidepoint(pos): self.set_editor_tool(name) return True return False def flood_fill(self, px, py): surf=self.layers[self.layer_selected]["surface"] target=surf.get_at((px,py)) repl=(0,0,0,0) if self.eraser else self.paint_color if tuple(target)==tuple(repl): return self.push_history() q=deque([(px,py)]) seen={(px,py)} while q: x,y=q.popleft() if tuple(surf.get_at((x,y)))!=tuple(target): continue surf.set_at((x,y),repl) for nx,ny in ((x+1,y),(x-1,y),(x,y+1),(x,y-1)): if 0<=nx0: self.push_history(); self.layers[i],self.layers[i-1]=self.layers[i-1],self.layers[i]; self.layer_selected=i-1; self.refresh_composite() def add_layer(self): self.push_history() idx=len(self.layers)+1 surf=pygame.Surface((self.char_size,self.char_size),pygame.SRCALPHA); surf.fill((0,0,0,0)) self.layers.append({"name":f"Layer {idx}","surface":surf,"visible":True}) self.layer_selected=len(self.layers)-1; self.layer_scroll=max(0,self.layer_scroll-1); self.refresh_composite() def delete_layer(self): if len(self.layers)<=1: self.message="You must keep at least one layer."; self.error=True; return self.push_history(); self.layers.pop(self.layer_selected); self.layer_selected=int(clamp(self.layer_selected,0,len(self.layers)-1)); self.refresh_composite() def clear_layer(self): self.push_history(); self.layers[self.layer_selected]["surface"].fill((0,0,0,0)); self.refresh_composite() def request_delete_slot(self, slot): self.delete_confirm_slot=int(slot) def cancel_delete(self): self.delete_confirm_slot=None def confirm_delete(self): if self.delete_confirm_slot is None or not self.token: self.delete_confirm_slot=None; return slot=int(self.delete_confirm_slot); self.delete_confirm_slot=None self.net.send({"action":"delete_character","token":self.token,"slot":slot}) self.message='Deleting character...'; self.error=False def create_new_slot(self): used={self._character_slot_value(i,c) for i,c in enumerate(self.characters) if self._character_is_populated(c)} for slot in range(MAX_SLOTS): if slot not in used: self.pending_new_slot=slot; self.new_slot_modal=True; self.message="Choose a character size."; self.error=False; return self.message=f"You have reached the {MAX_SLOTS}-character limit."; self.error=True def create_slot_of_size(self, size): slot=self.pending_new_slot if slot is None: self.new_slot_modal=False; return self.char_size=LARGE_CHAR_SIZE if size>=LARGE_CHAR_SIZE else SMALL_CHAR_SIZE c=self._ensure_local_slot(slot); c.update({"name":f"Character {slot+1}","size":self.char_size,"layers":[]}) self.setup_editor_from_slot(slot); self.new_slot_modal=False; self.pending_new_slot=None self.message=f"{'Large' if self.char_size==LARGE_CHAR_SIZE else 'Small'} character slot {slot+1} created."; self.error=False def request_sound(self,name,volume_scale=1.0): # Queue one logical event with a distance-derived volume multiplier. volume_scale=clamp(float(volume_scale),0.0,1.0) if volume_scale<=0.0: return self.pending_sfx_play.setdefault(name,[]).append(volume_scale) if name in self.audio_cache: queued=self.pending_sfx_play.pop(name,[]) for scale in queued: self.play_sound_bytes(name,self.audio_cache[name],scale) return if name in self.audio_pending: return self.audio_pending.add(name) self.net.fetch_bytes(f"{AUDIO_BASE}/audio/sfx/{name}.wav","sfx",name) def request_music(self): if self.mode!="game": return self.music_stream_active=True; self.music_request_inflight=True; self.net.fetch_json(f"{AUDIO_BASE}/audio/music/meta","music_meta") def _music_wav(self, pcm): import struct m=self.music_meta or {}; channels=int(m.get("channels",2)); width=int(m.get("sample_width",2)); rate=int(m.get("sample_rate",48000)); block_align=channels*width; byte_rate=rate*block_align out=io.BytesIO(); out.write(b"RIFF"); out.write(struct.pack("=2: return data_offset=int(self.music_meta.get("data_offset",44)); data_size=int(self.music_meta.get("data_size",0)) if data_size<=0: return if self.music_next_offset is None: self.music_next_offset=data_offset if self.music_next_offset>=data_offset+data_size: if self.music_queue_depth>0: return self.music_next_offset=data_offset start=self.music_next_offset; end=min(start+self.music_chunk_size-1,data_offset+data_size-1); self.music_request_inflight=True self.net.fetch_range(f"{AUDIO_BASE}/audio/music/music.wav",start,end,"music_chunk","music.wav") def process_music_meta(self,m): meta=m.get("data") if not isinstance(meta,dict): return self.music_meta=meta; self.music_next_offset=int(meta.get("data_offset",44)); self.music_queue_depth=0; self.music_chunk_ends=[]; self.music_request_inflight=False; self._request_next_music_chunk() def process_music_chunk(self,m): if not self.music_stream_active: return self.music_request_inflight=False; pcm=m.get("data") if not isinstance(pcm,(bytes,bytearray)) or not pcm: return self.music_next_offset=int(m.get("start",0))+len(pcm) try: sound=pygame.mixer.Sound(file=io.BytesIO(self._music_wav(bytes(pcm)))); sound.set_volume(self.audio_music_volume); now_t=time.monotonic(); channels=int(self.music_meta.get("channels",2)); width=int(self.music_meta.get("sample_width",2)); rate=int(self.music_meta.get("sample_rate",48000)); duration=len(pcm)/max(1,channels*width*rate) if self.music_channel is None: self.music_channel=pygame.mixer.Channel(0) if self.music_queue_depth==0 or not self.music_channel.get_busy(): self.music_channel.play(sound); self.music_chunk_ends.append(now_t+duration); self.music_queue_depth=1; self.music_loaded=True elif self.music_queue_depth<2: prior_end=self.music_chunk_ends[-1] if self.music_chunk_ends else now_t; self.music_channel.queue(sound); self.music_chunk_ends.append(prior_end+duration); self.music_queue_depth=2 except Exception as exc: self.message=f"Music playback error: {type(exc).__name__}: {exc}"; self.error=False self._request_next_music_chunk() def _update_music_stream(self): if not self.music_stream_active or not self.music_meta: return now_t=time.monotonic() while self.music_chunk_ends and self.music_chunk_ends[0]<=now_t: self.music_chunk_ends.pop(0); self.music_queue_depth=max(0,self.music_queue_depth-1) self._request_next_music_chunk() def play_music_bytes(self,data): return def play_sound_bytes(self,name,data,volume_scale=1.0): """Play one streamed SFX without affecting music playback.""" if not isinstance(data,(bytes,bytearray)) or not data: return volume_scale=clamp(float(volume_scale),0.0,1.0) if volume_scale<=0.0: return try: sound=pygame.mixer.Sound(file=io.BytesIO(bytes(data))) sound.set_volume(clamp(self.audio_sfx_volume*volume_scale,0.0,1.0)) channel=pygame.mixer.find_channel(force=True) if channel is not None: channel.play(sound) except Exception as exc: self.message=f"Sound playback error: {type(exc).__name__}: {exc}" self.error=False def process_audio(self,m): data=m.get("data") if isinstance(data,str): try: data=base64.b64decode(data.encode("ascii"), validate=True) except Exception: return if not isinstance(data,(bytes,bytearray)): return name=m.get("name",""); self.audio_cache[name]=bytes(data); self.audio_pending.discard(name) if m.get("kind")=="sfx": queued=self.pending_sfx_play.pop(name,[]) for scale in queued: self.play_sound_bytes(name,bytes(data),scale) def spawn_water_splash(self,x,y,large=False): count=24 if large else 14; nowt=time.time() for i in range(count): a=(math.pi*2*i/count)+((i%3)-1)*0.15; sp=55+(i*7%50) self.water_fx.append({"x":x,"y":y,"vx":math.cos(a)*sp,"vy":math.sin(a)*sp-25,"life":0.6,"max":0.6,"r":2+(i%3),"kind":"particle"}) self.water_fx.append({"x":x,"y":y,"life":0.65,"max":0.65,"r":10 if not large else 16,"kind":"ripple"}) self.water_fx.append({"x":x,"y":y,"life":0.9,"max":0.9,"r":20 if not large else 30,"kind":"ripple"}) def spawn_world_explosion(self,x,y,enter=True): count=18; nowt=time.time() for i in range(count): a=(math.pi*2*i/count)+((i%3)-1)*0.10; sp=70+(i%5)*16 self.world_fx.append({"x":x,"y":y,"vx":math.cos(a)*sp,"vy":math.sin(a)*sp,"life":0.48 if enter else 0.58,"max":0.48 if enter else 0.58,"r":2+(i%3),"kind":"burst","enter":enter}) self.world_fx.append({"x":x,"y":y,"life":0.32 if enter else 0.42,"max":0.32 if enter else 0.42,"r":8,"kind":"ring","enter":enter}) self.world_fx.append({"x":x,"y":y,"life":0.48 if enter else 0.62,"max":0.48 if enter else 0.62,"r":16,"kind":"ring","enter":enter}) def draw_world(self): w,h=self.screen.get_size(); self.screen.fill((18,22,28)); tile=self.world["tile_size"]; target=self.players.get(self.me["id"],{}) if self.me else {} tx=target.get("x",self.camera[0]); ty=target.get("y",self.camera[1]); self.camera[0]=tx-w/(2*self.zoom); self.camera[1]=ty-h/(2*self.zoom) sx=max(0,int(self.camera[0]//tile)-2); sy=max(0,int(self.camera[1]//tile)-2); ex=min(self.world["width"],sx+int(w/(tile*self.zoom))+5); ey=min(self.world["height"],sy+int(h/(tile*self.zoom))+5); colors={0:(93,165,94),1:(71,133,190),2:(59,63,72),3:(210,187,111),4:(154,118,82),5:(186,155,103)}; sz=max(1,int(tile*self.zoom)) for yy in range(sy,ey): for xx in range(sx,ex): pygame.draw.rect(self.screen,colors.get(self.world["tiles"][yy][xx],colors[0]),pygame.Rect(int((xx*tile-self.camera[0])*self.zoom),int((yy*tile-self.camera[1])*self.zoom),sz+1,sz+1)) # Paths and map decoration for d in self.world.get("decor",[]): x,y=self.world_to_screen(d["x"],d["y"]); k=d.get("kind") if k=="flower": pygame.draw.circle(self.screen,(240,202,112),(int(x),int(y)),max(2,int(3*self.zoom))); pygame.draw.circle(self.screen,(245,110,120),(int(x+3*self.zoom),int(y)),max(2,int(2*self.zoom))) elif k=="rock": pygame.draw.ellipse(self.screen,(94,101,108),pygame.Rect(int(x-7*self.zoom),int(y-4*self.zoom),int(14*self.zoom),int(8*self.zoom))) # Buildings with interiors and doors for b in self.world.get("buildings",[]): self.draw_building(b) for t in self.world.get("trees",[]): x,y=self.world_to_screen(t["x"],t["y"]); rr=max(5,int(20*self.zoom)) # trunk behind foliage; base sits at the world anchor pygame.draw.rect(self.screen,(101,68,43),(int(x-rr*.15),int(y-rr*.05),max(3,rr//3),int(rr*1.05))) pygame.draw.circle(self.screen,(61,108,61),(int(x),int(y-rr*.4)),rr) pygame.draw.circle(self.screen,(81,128,69),(int(x-rr*.45),int(y-rr*.1)),int(rr*.72)) # Draw remote players from interpolated positions. draw_list=[] for pid,p in list(self.players.items()): if not pid: continue p=self.interpolate_player(p); px,py=self.world_to_screen(p.get("x",0),p.get("y",0)); base_y=py; jump=self.jump_offset(pid); water=p.get("water",self.is_water_client(p.get("x",0),p.get("y",0))); bounce=self.bounce_offset(pid,p.get("moving",False),water); render_y=py+bounce-jump; draw_list.append((render_y,pid,p,px,base_y,water)) # Water effects belong to the water surface, below avatars. for fx in self.water_fx: alpha=int(255*clamp(fx["life"]/fx["max"],0,1)); sx,sy=self.world_to_screen(fx["x"],fx["y"]) if fx["kind"]=="particle": radius=max(1,int(fx["r"]*self.zoom/2.0)) layer_size=max(24,radius*4+12) layer=pygame.Surface((layer_size,layer_size),pygame.SRCALPHA) pygame.draw.circle(layer,(150,220,255,alpha),(layer_size//2,layer_size//2),radius) else: rr=max(1,int(fx["r"]*(1.0+(1.0-fx["life"]/fx["max"]))*self.zoom/2.0)) layer_size=max(48,rr*4+20) layer=pygame.Surface((layer_size,layer_size),pygame.SRCALPHA) pygame.draw.ellipse(layer,(120,205,245,alpha),(layer_size//2-rr,layer_size//2-rr//2,rr*2,max(1,rr))) self.screen.blit(layer,(int(sx)-layer_size//2,int(sy)-layer_size//2)) for fx in self.world_fx: alpha=int(255*clamp(fx["life"]/fx["max"],0,1)); sx,sy=self.world_to_screen(fx["x"],fx["y"]); layer=pygame.Surface((120,120),pygame.SRCALPHA) if fx["kind"]=="burst": radius=max(1,int(fx["r"]*self.zoom/2)); pygame.draw.circle(layer,(255,190,90,alpha),(60,60),radius) else: progress=1.0-clamp(fx["life"]/fx["max"],0,1); rr=max(2,int((fx["r"]+progress*32)*self.zoom/3)); pygame.draw.circle(layer,(255,210,120,alpha),(60,60),rr,max(1,int(2*self.zoom/2))) self.screen.blit(layer,(int(sx)-60,int(sy)-60)) for obj in sorted(self.world.get("objects",[]), key=lambda o: float(o.get("z", 0)) if isinstance(o,dict) else 0): self.draw_custom_object(obj) for _,pid,p,px,base_y,water in sorted(draw_list,key=lambda q:q[0]): render_y=self.world_to_screen(p.get("x",0),p.get("y",0))[1]+self.bounce_offset(pid,p.get("moving",False),water)-self.jump_offset(pid) self.draw_avatar(p.get("character",{}),px,render_y,self.zoom,pid==self.me["id"],pid,water,base_y) base_size=int(p.get("character",{}).get("size",SMALL_CHAR_SIZE)); avatar_size=max(12,int(base_size*self.zoom/5)); name=p.get("username","Player"); name_y=render_y-avatar_size-max(8,int(10*self.zoom/2.0)) if name: name_font=self.world_font(16); badge_font=self.world_font(13) name_surf=name_font.render(name,True,(246,247,250)) friend_ids={str(f.get("id")) for f in self.friends if isinstance(f,dict)} is_friend=str(pid) in friend_ids badge_surf=badge_font.render("(Friend)",True,(92,220,135)) if is_friend else None total_w=name_surf.get_width() + (6 + badge_surf.get_width() if badge_surf else 0) left=px-total_w/2 self.screen.blit(name_surf,(int(left),int(name_y-name_surf.get_height()/2))) if badge_surf: self.screen.blit(badge_surf,(int(left+name_surf.get_width()+6),int(name_y-badge_surf.get_height()/2))) bubble=self.chat_bubbles.get(pid) if bubble and bubble["until"]>time.time(): self.draw_bubble(bubble["text"],px,name_y-22) self.draw_world_ui() self.draw_social_overlays() def _rot_world_point(self,b,lx,ly): bx,by=float(b.get("x",0)),float(b.get("y",0)); bw,bh=float(b.get("w",1)),float(b.get("h",1)); ang=math.radians(float(b.get("rotation",0))%360) cx,cy=bx+bw/2,by+bh/2; px=bx+float(lx); py=by+float(ly); dx,dy=px-cx,py-cy; ca,sa=math.cos(ang),math.sin(ang) return cx+dx*ca-dy*sa, cy+dx*sa+dy*ca def _rot_rect_points(self,b,lx,ly,w,h): return [self.world_to_screen(*self._rot_world_point(b,lx,ly)), self.world_to_screen(*self._rot_world_point(b,lx+w,ly)), self.world_to_screen(*self._rot_world_point(b,lx+w,ly+h)), self.world_to_screen(*self._rot_world_point(b,lx,ly+h))] def draw_building(self,b): bw,bh=float(b.get("w",128)),float(b.get("h",96)); pts=self._rot_rect_points(b,0,0,bw,bh) pygame.draw.polygon(self.screen,(208,183,139),[(int(x),int(y)) for x,y in pts]); pygame.draw.lines(self.screen,(60,49,44),True,[(int(x),int(y)) for x,y in pts],max(1,int(2*self.zoom))) inset=9 ipts=self._rot_rect_points(b,inset,inset,max(1,bw-2*inset),max(1,bh-2*inset)); pygame.draw.polygon(self.screen,(185,158,118),[(int(x),int(y)) for x,y in ipts]) for item in b.get("furniture",[]): if not isinstance(item,(list,tuple)) or len(item)<4: continue fx,fy,fw,fh=map(float,item[:4]); fpts=self._rot_rect_points(b,fx,fy,fw,fh); pygame.draw.polygon(self.screen,(118,79,54),[(int(x),int(y)) for x,y in fpts]) d=b.get("door",{}) if isinstance(b.get("door",{}),dict) else {}; side=d.get("side","bottom"); off=float(d.get("offset",.5)); dw=float(d.get("width",50)); if side=="bottom": dx,dy=bw*off-dw/2,bh-20; door_size=(dw,22) elif side=="top": dx,dy=bw*off-dw/2,-2; door_size=(dw,22) elif side=="left": dx,dy=-2,bh*off-dw/2; door_size=(22,dw) else: dx,dy=bw-20,bh*off-dw/2; door_size=(22,dw) dpts=self._rot_rect_points(b,dx,dy,*door_size); pygame.draw.polygon(self.screen,(80,55,43),[(int(x),int(y)) for x,y in dpts]) name=b.get("name") if name: wx,wy=self._rot_world_point(b,bw/2,9); sx,sy=self.world_to_screen(wx,wy); self.center_text(str(name),sx,sy,(255,246,228),self.tiny) def draw_custom_object(self,obj): if not isinstance(obj,dict): return x,y=float(obj.get("x",0)),float(obj.get("y",0)); w,h=float(obj.get("w",32)),float(obj.get("h",32)); shape=str(obj.get("shape",obj.get("kind","rect"))).lower(); color=obj.get("color", "#a87852"); outline=obj.get("outline", None); rot=float(obj.get("rotation",0)) if outline is None: outline=color if rot % 360 == 0 and shape in ("rect","rectangle","box"): r=self.world_rect(x,y,w,h); pygame.draw.rect(self.screen,color,r); pygame.draw.rect(self.screen,outline,r,max(1,int(self.zoom))) elif shape in ("circle","ellipse") and rot % 360 == 0: r=self.world_rect(x,y,w,h); pygame.draw.ellipse(self.screen,r,fill=self._pygame_color(color)); pygame.draw.ellipse(self.screen,r,outline=self._pygame_color(outline),width=max(1,int(self.zoom))) else: cx,cy=x+w/2,y+h/2; ang=math.radians(rot); ca,sa=math.cos(ang),math.sin(ang); pts=[] for lx,ly in ((0,0),(w,0),(w,h),(0,h)): dx,dy=lx-w/2,ly-h/2; wx=cx+dx*ca-dy*sa; wy=cy+dx*sa+dy*ca; pts.append(self.world_to_screen(wx,wy)) pygame.draw.polygon(self.screen, self._pygame_color(color), [(int(px),int(py)) for px,py in pts]); pygame.draw.lines(self.screen,self._pygame_color(outline),True,[(int(px),int(py)) for px,py in pts],max(1,int(self.zoom))) text=obj.get("text") if text: sx,sy=self.world_to_screen(x+w/2,y+h/2); self.center_text(str(text),sx,sy,self._pygame_color(obj.get("text_color","#ffffff")),self.tiny) def _pygame_color(self,c): try:return pygame.Color(str(c)) except Exception:return pygame.Color("#a87852") def world_to_screen(self,x,y): return ((x-self.camera[0])*self.zoom,(y-self.camera[1])*self.zoom) def world_rect(self,x,y,w,h): sx,sy=self.world_to_screen(x,y); return pygame.Rect(int(sx),int(sy),max(1,int(w*self.zoom)),max(1,int(h*self.zoom))) def world_font(self, base_size): size=max(8,int(round(float(base_size)*self.zoom/2.0))) key=(int(base_size),size) font=self.world_font_cache.get(key) if font is None: font=pygame.font.Font(self.font_path,size) if self.font_path else pygame.font.Font(None,size) self.world_font_cache[key]=font return font def is_water_client(self,x,y): if not self.world:return False ts=self.world["tile_size"]; tx,ty=int(x//ts),int(y//ts) return 0<=tx=LARGE_CHAR_SIZE height=LARGE_JUMP_HEIGHT if large else SMALL_JUMP_HEIGHT return int(math.sin(progress*math.pi)*height*self.zoom) def draw_avatar(self,c,x,y,zoom,is_me,pid,water=False,water_base_y=None): img=c.get("image"); surf=None if img: try: cache_key=(img[:48], len(img)) surf=self.avatar_surface_cache.get(cache_key) if surf is None: surf=pygame.image.load(io.BytesIO(base64.b64decode(img))).convert_alpha() self.avatar_surface_cache[cache_key]=surf except Exception: surf=None if surf is None:return size=max(12,int(int(c.get("size",SMALL_CHAR_SIZE))*zoom/5)); facing=self.players.get(pid,{}).get("facing",1); invert=bool(c.get("flip_direction",False)); flip_x=(facing<0) ^ invert scale_key=(id(surf),size,flip_x) cached=self.avatar_scaled_cache.get(scale_key) if cached is None: cached=pygame.transform.scale(surf,(size,size)) if flip_x: cached=pygame.transform.flip(cached,True,False) self.avatar_scaled_cache[scale_key]=cached surf=cached jumping=self.players.get(pid,{}).get("jump_until",0)>time.time(); angle=0 if jumping: duration=float(self.players[pid].get("jump_duration",SMALL_JUMP_DURATION)); progress=clamp(1-(self.players[pid]["jump_until"]-time.time())/duration,0.0,1.0); large=int(c.get("size",SMALL_CHAR_SIZE))>=LARGE_CHAR_SIZE; wobble=LARGE_JUMP_WOBBLE if large else SMALL_JUMP_WOBBLE; angle=math.sin(progress*math.pi*2)*wobble; surf=pygame.transform.rotozoom(surf,angle,1) base_y=int(y) if water: base_anchor=int(water_base_y if water_base_y is not None else y); waterline=base_anchor-int(size*.12); clip=pygame.Rect(0,0,self.screen.get_width(),max(0,waterline)); old=self.screen.get_clip(); self.screen.set_clip(clip); self.screen.blit(surf,surf.get_rect(midbottom=(int(x),int(y)))); self.screen.set_clip(old); pygame.draw.line(self.screen,(76,145,197),(int(x-size*.45),waterline),(int(x+size*.45),waterline),max(1,int(2*zoom))) else:self.screen.blit(surf,surf.get_rect(midbottom=(int(x),base_y))) def draw_bubble(self,text,x,y): lines=[]; cur="" for word in str(text).split(): test=(cur+" "+word).strip() if self.small.size(test)[0]>240 and cur: lines.append(cur); cur=word else: cur=test if cur or not lines: lines.append(cur) font=self.world_font(16) widths=[font.size(line)[0] for line in lines]; w=max(120,int(max(120*self.zoom/2.0,max(widths,default=0)+24))); line_h=max(18,int(19*self.zoom/2.0)); h=14+len(lines)*line_h r=pygame.Rect(int(x-w/2),int(y-h),int(w),int(h)) pygame.draw.rect(self.screen,(249,249,244),r,border_radius=10) tip=max(6,int(7*self.zoom/2.0)) pygame.draw.polygon(self.screen,(249,249,244),[(int(x-tip),r.bottom-1),(int(x+tip),r.bottom-1),(int(x),r.bottom+tip+2)]) for i,line in enumerate(lines): surf=font.render(line,True,(35,38,43)); self.screen.blit(surf,surf.get_rect(center=(r.centerx,r.y+9+ i*line_h + line_h/2))) def draw_world_ui(self): w,h=self.screen.get_size() self.panel((18,18,410,64),(20,23,31),(70,76,90),12); self.text("FEEBWORLD",32,28,(240,242,247),self.font); self.text(f"Users: {self.metrics['online']} online {self.metrics['offline']} offline",32,51,(158,165,178),self.tiny) self.menu_rect=pygame.Rect(w-74,18,56,50); self._draw_hamburger(self.menu_rect) self.text(f"{self.zoom:.1f}×",w-125,36,(198,203,215),self.small) if self.game_menu_open: menu_w=330; menu_h=255; menu=pygame.Rect(w-menu_w-18,78,menu_w,menu_h); self.menu_panel_rect=menu; self.panel(menu,(24,28,38),(87,96,119),16) self.text("GAME MENU",menu.x+20,menu.y+16,(241,243,248),self.font) self.menu_zoom_out=pygame.Rect(menu.x+20,menu.y+56,82,34); self.menu_zoom_in=pygame.Rect(menu.x+112,menu.y+56,82,34); self.menu_editor_rect=pygame.Rect(menu.x+204,menu.y+56,106,34) self._draw_button(self.menu_zoom_out,"ZOOM -",lambda:self.change_zoom(-.5)); self._draw_button(self.menu_zoom_in,"ZOOM +",lambda:self.change_zoom(.5)); self._draw_button(self.menu_editor_rect,"MENU",self.back_to_editor) self.text("MUSIC",menu.x+20,menu.y+112,(186,192,204),self.tiny); self.text(f"{int(self.audio_music_volume*100)}%",menu.right-55,menu.y+110,(186,192,204),self.tiny) self.music_slider_rect=pygame.Rect(menu.x+20,menu.y+135,menu.w-40,14); self._draw_slider(self.music_slider_rect,self.audio_music_volume) self.text("SFX",menu.x+20,menu.y+168,(186,192,204),self.tiny); self.text(f"{int(self.audio_sfx_volume*100)}%",menu.right-55,menu.y+166,(186,192,204),self.tiny) self.sfx_slider_rect=pygame.Rect(menu.x+20,menu.y+191,menu.w-40,14); self._draw_slider(self.sfx_slider_rect,self.audio_sfx_volume) self.text("Scroll / + / - to zoom",menu.x+20,menu.y+228,(111,119,136),self.tiny) else: self.menu_panel_rect=pygame.Rect(0,0,0,0); self.music_slider_rect=self.sfx_slider_rect=pygame.Rect(0,0,0,0) panel=pygame.Rect(18,h-238,min(660,w-36),220); self.chat_panel_rect=panel; hover_chat=panel.collidepoint(pygame.mouse.get_pos()) or self.chat_input is not None overlay=pygame.Surface(panel.size,pygame.SRCALPHA); alpha=235 if hover_chat else 72; pygame.draw.rect(overlay,(19,22,30,alpha),overlay.get_rect(),border_radius=14); pygame.draw.rect(overlay,(67,73,87,150 if hover_chat else 65),overlay.get_rect(),1,border_radius=14); self.screen.blit(overlay,panel.topleft) self.text("CHAT",34,panel.y+12,(235,238,244) if hover_chat else (210,214,222),self.small); self.text("scroll to review",panel.right-130,panel.y+13,(116,123,140),self.tiny) input_rect=pygame.Rect(panel.x+12,panel.bottom-48,panel.w-24,34); self.chat_rect=input_rect history_rect=pygame.Rect(panel.x+12,panel.y+38,panel.w-24,panel.h-92); old=self.screen.get_clip(); self.screen.set_clip(history_rect) lines=self.chat_lines; line_h=21; total=max(0,len(lines)*line_h-history_rect.h); self.chat_scroll=int(clamp(self.chat_scroll,0,total)); start_y=history_rect.bottom-18+self.chat_scroll for idx in range(len(lines)-1,-1,-1): y=start_y-(len(lines)-1-idx)*line_h if history_rect.top-20<=y<=history_rect.bottom:self.text(lines[idx],history_rect.x,y,(208,211,220),self.tiny) self.screen.set_clip(old) pygame.draw.rect(self.screen,(11,13,18,230 if hover_chat else 110),input_rect,border_radius=8); self.text(self.chat_input if self.chat_input is not None else "Click to chat • T",input_rect.x+10,input_rect.y+8,(235,237,243) if self.chat_input is not None else (100,107,123),self.small) if self.chat_input is not None and int(time.time()*2)%2==0: caret_s=self.small.render(self.chat_input,True,(235,237,243)); caret_x=min(input_rect.right-8,input_rect.x+10+caret_s.get_width()+2); pygame.draw.line(self.screen,(230,235,255),(caret_x,input_rect.y+7),(caret_x,input_rect.bottom-7),2) self.text("WASD / arrows • Shift sprint • Space jump • wheel / +/- zoom",w-470,h-25,(122,128,143),self.tiny) def _draw_hamburger(self,r): hover=r.collidepoint(pygame.mouse.get_pos()); pygame.draw.rect(self.screen,(66,78,108) if hover else (46,55,74),r,border_radius=10); pygame.draw.rect(self.screen,(105,116,140),r,1,border_radius=10) cx,cy=r.center for off in (-7,0,7): pygame.draw.line(self.screen,(238,241,246),(cx-11,cy+off),(cx+11,cy+off),3) def _draw_slider(self,r,value): pygame.draw.rect(self.screen,(49,54,68),r,border_radius=7); pygame.draw.rect(self.screen,(95,139,214),(r.x,r.y,int(r.w*clamp(value,0,1)),r.h),border_radius=7); knob_x=int(r.x+r.w*clamp(value,0,1)); pygame.draw.circle(self.screen,(238,242,248),(knob_x,r.centery),8) def remove_friend_confirmed(self): if getattr(self,"friend_remove_confirm",None): self.remove_friend_with_confirm(self.friend_remove_confirm) self.friend_remove_confirm=None def confirm_profile_link(self): if self.profile_link_confirm: webbrowser.open(self.profile_link_confirm) self.profile_link_confirm=None def cancel_profile_link(self): self.profile_link_confirm=None def accept_friend_modal(self): if self.friend_modal:self.net.send({"action":"friend_request_response","token":self.token,"from_id":self.friend_modal.get("id"),"accept":True}) self.friend_modal=None; self.show_next_friend_request() def reject_friend_modal(self): if self.friend_modal:self.net.send({"action":"friend_request_response","token":self.token,"from_id":self.friend_modal.get("id"),"accept":False}) self.friend_modal=None; self.show_next_friend_request() def cancel_friend_request_confirmed(self): if getattr(self,"friend_cancel_confirm",None): self.net.send({"action":"friend_request_cancel","token":self.token,"user_id":self.friend_cancel_confirm}) self.friend_cancel_confirm=None def remove_friend_with_confirm(self,fid): self.net.send({"action":"friend_remove","token":self.token,"friend_id":fid}) def draw_social_overlays(self): w,h=self.screen.get_size(); self.profile_link_rects=[] # friend menu button self.friends_menu_rect=pygame.Rect(max(18,w-140),18,50,50); self._draw_friends_button(self.friends_menu_rect) if self.friend_join_notice and self.friend_join_notice["until"]>time.time(): n=pygame.Rect(w//2-220,28,min(440,w-40),54); self.panel(n,(25,33,45),(83,118,157),14); self.center_text(self.friend_join_notice["text"],n.centerx,n.centery,(240,244,248),self.small) elif self.friend_join_notice: self.friend_join_notice=None if getattr(self,"friends_panel_open",False): panel=pygame.Rect(max(12,w-338),78,min(320,w-24),min(610,h-96)); self.friends_panel_rect=panel; self.panel(panel,(22,26,36),(72,80,99),16); self.text("FRIENDS",panel.x+18,panel.y+16,(241,243,248),self.big) self.friend_search_rect=pygame.Rect(panel.x+18,panel.y+62,panel.w-112,34); pygame.draw.rect(self.screen,(10,13,19),self.friend_search_rect,border_radius=7); pygame.draw.rect(self.screen,(79,90,111) if self.friend_active else (54,61,76),self.friend_search_rect,1,border_radius=7); self.text(self.friend_search or "username",panel.x+28,panel.y+71,(235,238,245) if self.friend_search else (104,112,128),self.small) if self.friend_active:self._draw_caret(self.friend_search_rect,self.friend_search,self.small) self.friend_add_rect=pygame.Rect(panel.right-78,panel.y+62,60,34); self._draw_button(self.friend_add_rect,"ADD",self.send_friend_request) if self.friend_status: self.text(self.friend_status,panel.x+18,panel.y+102,(226,128,128) if self.friend_status_error else (151,198,164),self.tiny) self.friend_remove_rects=[]; self.friend_profile_rects=[]; self.friend_cancel_rects=[]; yy=panel.y+132 rows=[("friend",f) for f in self.friends]+[("outgoing",f) for f in self.outgoing_friend_requests] for kind,f in rows: if yy>panel.bottom-22: break rowr=pygame.Rect(panel.x+12,yy-7,panel.w-58,48); self.friend_profile_rects.append((rowr,f.get("id"))) self.text(f.get("username","Player"),panel.x+20,yy,(229,233,241),self.small) if kind=="outgoing": self.text("OUTGOING",panel.x+20,yy+20,(172,151,205),self.tiny) rr=pygame.Rect(panel.right-42,yy-2,28,28); self._draw_button(rr,"×",lambda fid=f.get("id"): setattr(self,"friend_cancel_confirm",fid)); self.friend_cancel_rects.append((rr,f.get("id"))) else: self.text("ONLINE" if f.get("online") else "OFFLINE",panel.x+20,yy+20,(104,184,130) if f.get("online") else (129,136,150),self.tiny) rr=pygame.Rect(panel.right-42,yy-2,28,28); self._draw_button(rr,"×",lambda fid=f.get("id"): setattr(self,"friend_remove_confirm",fid)); self.friend_remove_rects.append((rr,f.get("id"))) yy+=54 else:self.friends_panel_rect=pygame.Rect(0,0,0,0); self.friend_search_rect=pygame.Rect(0,0,0,0) if getattr(self,"friend_remove_confirm",None): overlay=pygame.Rect(w//2-230,h//2-90,460,180); self.panel(overlay,(25,29,39),(93,102,124),16); name=next((x.get("username","friend") for x in self.friends if x.get("id")==self.friend_remove_confirm),"friend"); self.center_text(f"Remove {name} from your friends?",overlay.centerx,overlay.y+50,(240,243,248),self.font); yes=pygame.Rect(overlay.x+55,overlay.bottom-54,150,36); no=pygame.Rect(overlay.right-205,overlay.bottom-54,150,36); self.friend_remove_yes_rect=yes; self.friend_remove_no_rect=no; self._draw_button(yes,"YES",lambda:self.remove_friend_confirmed()); self._draw_button(no,"NO",lambda:setattr(self,"friend_remove_confirm",None)) if getattr(self,"friend_cancel_confirm",None): overlay=pygame.Rect(w//2-230,h//2-90,460,180); self.panel(overlay,(25,29,39),(93,102,124),16); name=next((x.get("username","friend") for x in self.outgoing_friend_requests if x.get("id")==self.friend_cancel_confirm),"friend"); self.center_text(f"Cancel friend request to {name}?",overlay.centerx,overlay.y+50,(240,243,248),self.font); yes=pygame.Rect(overlay.x+55,overlay.bottom-54,150,36); no=pygame.Rect(overlay.right-205,overlay.bottom-54,150,36); self.friend_cancel_yes_rect=yes; self.friend_cancel_no_rect=no; self._draw_button(yes,"YES",lambda:self.cancel_friend_request_confirmed()); self._draw_button(no,"NO",lambda:setattr(self,"friend_cancel_confirm",None)) if self.profile_popup is not None: shade=pygame.Surface((w,h),pygame.SRCALPHA); shade.fill((0,0,0,145)); self.screen.blit(shade,(0,0)); card=pygame.Rect(w//2-250,h//2-250,500,500); self.panel(card,(23,27,37),(79,89,111),18); self.profile_close_rect=pygame.Rect(card.right-48,card.y+16,30,30); self._draw_button(self.profile_close_rect,"X",lambda:setattr(self,"profile_popup",None)); prof=self.profile_popup.get("profile",{}); char=self.profile_popup.get("character") or {}; self.center_text(prof.get("username","Player"),card.centerx,card.y+46,(244,246,250),self.big) if char.get("image"): try: data=base64.b64decode(char["image"]); surf=pygame.image.load(io.BytesIO(data)).convert_alpha(); size=190 if int(char.get("size",150))>=300 else 150; surf=pygame.transform.smoothscale(surf,(size,size)); self.screen.blit(surf,surf.get_rect(center=(card.centerx,card.y+170))) except Exception: pass if char: label="Currently playing as" if self.profile_popup.get("character_source")=="current" else "Last played as" self.center_text(label,card.centerx,card.y+267,(150,158,175),self.tiny) self.center_text(char.get("name","FEEB"),card.centerx,card.y+292,(214,220,230),self.font) self.center_text(f"@{prof.get('username','Player')}",card.centerx,card.y+318,(155,164,180),self.small) else: self.center_text(f"@{prof.get('username','Player')}",card.centerx,card.y+278,(155,164,180),self.small) if prof.get("email"): self.center_text(prof.get("email"),card.centerx,card.y+330,(170,178,192),self.small) yy=card.y+360 for social in prof.get("socials",[])[:4]: rr=pygame.Rect(card.x+28,yy,card.w-56,34); self._draw_button(rr,f"{social.get('platform','Social')}: {social.get('link','')}",lambda link=social.get('link',''): setattr(self,"profile_link_confirm",link)); self.profile_link_rects.append((rr,social.get("link",""))); yy+=40 if self.profile_link_confirm is not None: overlay=pygame.Rect(w//2-270,h//2-95,540,190); self.panel(overlay,(25,29,39),(93,102,124),16); self.center_text(f"Are you sure you would like to visit:",overlay.centerx,overlay.y+42,(240,243,248),self.font); self.center_text(str(self.profile_link_confirm)[:70],overlay.centerx,overlay.y+76,(175,183,198),self.small); self.profile_link_yes_rect=pygame.Rect(overlay.x+50,overlay.bottom-54,160,36); self.profile_link_no_rect=pygame.Rect(overlay.right-210,overlay.bottom-54,160,36); self._draw_button(self.profile_link_yes_rect,"YES",self.confirm_profile_link); self._draw_button(self.profile_link_no_rect,"NO",self.cancel_profile_link) if self.friend_modal is not None: overlay=pygame.Rect(w//2-250,h//2-100,500,200); self.panel(overlay,(25,29,39),(93,102,124),16); self.center_text(f"{self.friend_modal.get('username','Someone')} would like to be your friend!",overlay.centerx,overlay.y+58,(240,243,248),self.font); self.friend_yes_rect=pygame.Rect(overlay.x+55,overlay.bottom-58,150,38); self.friend_no_rect=pygame.Rect(overlay.right-205,overlay.bottom-58,150,38); self._draw_button(self.friend_yes_rect,"YES",self.accept_friend_modal); self._draw_button(self.friend_no_rect,"NO",self.reject_friend_modal) def _draw_friends_button(self,r): hover=r.collidepoint(pygame.mouse.get_pos()) pygame.draw.rect(self.screen,(66,78,108) if hover else (46,55,74),r,border_radius=10) pygame.draw.rect(self.screen,(105,116,140),r,1,border_radius=10) # Simple, non-anatomical badge: a clean F inside a circle. pygame.draw.circle(self.screen,(238,242,247),r.center,14,2) surf=self.small.render("F",True,(238,242,247)); self.screen.blit(surf,surf.get_rect(center=r.center)) def show_next_friend_request(self): self.friend_modal=self.friend_request_queue.popleft() if self.friend_request_queue else None def process(self,m): t=m.get("type") if t=="music_meta": self.process_music_meta(m); return if t=="music_meta_error": self.message=f"Music unavailable: {m.get('error','unknown error')}"; self.error=False; return if t=="music_chunk": self.process_music_chunk(m); return if t=="audio_data": self.process_audio(m); return if t=="audio_error": name=str(m.get("name", "audio")); self.audio_pending.discard(name) if name in self.pending_sfx_play: self.pending_sfx_play.pop(name, None) self.message=f"Audio error: {m.get('error','unknown error')}"; self.error=True; return if t=="version_ok": self.version_ok=True; self.message="Connected to FeebWorld."; self.error=False if self.mode=="login" and self.remember_me and self.saved_token: self.net.send({"action":"resume","token":self.saved_token}) return if t=="version_mismatch": self.version_ok=False; self.mode="login"; self.message="Error! Client version mismatch, Please update!"; self.error=True; self.saved_token=""; self.token=None; self.remember_me=True; self.save_settings(); pygame.key.start_text_input(); return if t=="password_reset_request_result": self.message=m.get("message",m.get("error","")); self.error=not m.get("ok") if m.get("ok"): self.account_reset_stage=2; self.active="code"; pygame.key.start_text_input() return if t=="password_reset_result": self.message=m.get("message",m.get("error","")); self.error=not m.get("ok") if m.get("ok"): self.stop_music(); self.token=None; self.saved_token=""; self.form["password"]=""; self.remember_me=False; self.save_settings(); self.mode="login"; self.auth_mode="login"; self.auth_stage=1; self.active="email"; self.account_reset_stage=0; pygame.key.start_text_input() return if t=="server_outage": self.stop_music(); self.mode="outage"; self.error=True; return if t=="resume_result": if not m.get("ok"): self.saved_token=""; self.token=None; self.remember_me=True; self.save_settings(); self.message=""; self.error=False else: self.token=m["token"]; self.me=m["user"]; self.profile=m.get("profile",{}); self.friends=m.get("friends",[]); self.outgoing_friend_requests=m.get("outgoing_friend_requests",[]); self.friend_requests=m.get("friend_requests",[]); self.characters=[c for c in m.get("characters",[]) if isinstance(c,dict) and (c.get("image") or c.get("layers"))]; self.world=m["world"]; self.metrics=m.get("metrics",self.metrics); self.mode="editor"; self.setup_editor_from_slot(0); self.message="Welcome back. Choose your FEEB, then enter the world."; self.error=False; self.save_settings() return if t=="login_challenge_result": if not m.get("ok"): self.message=m.get("error","Login failed"); self.error=True else: try: digest=client_password_digest_with_salt(self.form.get("password",""), str(m.get("salt",""))) self.net.send({"action":"login","email":self.form["email"],"password_digest":digest}) except Exception: self.message="Invalid password data."; self.error=True return if t=="login_result": if not m.get("ok"): self.message=m.get("error","Login failed"); self.error=True; return self.token=m["token"]; self.me=m["user"]; self.profile=m.get("profile",{}); self.friends=m.get("friends",[]); self.outgoing_friend_requests=m.get("outgoing_friend_requests",[]); self.friend_requests=m.get("friend_requests",[]); self.characters=[c for c in m.get("characters",[]) if isinstance(c,dict) and (c.get("image") or c.get("layers"))]; self.world=m["world"]; self.metrics=m.get("metrics",self.metrics); self.mode="editor"; self.setup_editor_from_slot(0); self.message="Choose or draw a character, then enter the world."; self.error=False; self.save_settings() elif t=="register_result": self.message=m.get("message",m.get("error","")); self.error=not m.get("ok"); self.auth_stage=2 if m.get("ok") else self.auth_stage; self.active="code" if m.get("ok") else self.active elif t=="verify_result": self.message=m.get("message",m.get("error","")); self.error=not m.get("ok"); self.auth_mode="login" if m.get("ok") else self.auth_mode; self.auth_stage=1 if m.get("ok") else self.auth_stage; self.active="email" if m.get("ok") else self.active elif t=="metrics": self.metrics={k:m.get(k,self.metrics.get(k,0)) for k in ("online","users","offline")} elif t=="character_result": self.message=m.get("error","Saved!") if not m.get("ok") else ("Character saved." if not getattr(self,"pending_join",False) else "Saved. Entering world..."); self.error=not m.get("ok") if m.get("ok"): self.characters=[c for c in m.get("characters",[]) if isinstance(c,dict) and (c.get("image") or c.get("layers"))]; join=getattr(self,"pending_join",False); self.pending_join=False if join:self.net.send({"action":"join_world","token":self.token,"slot":self.selected_slot}) elif t=="character_delete_result": self.message=m.get("error","Could not delete character.") if not m.get("ok") else "Character deleted."; self.error=not m.get("ok") if m.get("ok"): self.characters=[c for c in m.get("characters",[]) if isinstance(c,dict) and (c.get("image") or c.get("layers"))] if self.selected_slot==int(m.get("slot",-1)): populated=self._populated_characters() if populated:self.setup_editor_from_slot(populated[0][0]) else: self._ensure_local_slot(0, "Character 1") self.setup_editor_from_slot(0) elif t=="join_result": if not m.get("ok"): self.message=m.get("error","Could not enter world."); self.error=True; return self.players={p["id"]:dict(p) for p in m["players"]}; self.me=m["player"]; self.metrics=m.get("metrics",self.metrics); self.mode="game"; self.chat_lines=[]; self.chat_bubbles={}; self.anim={}; self.zoom=2.0; self.settings["zoom"]=2.0; self.save_settings(); self.chat_unread=0; self.request_music() for pid,p in self.players.items(): p["_render_x"]=p["x"]; p["_render_y"]=p["y"]; p["_target_time"]=time.time(); p["target_x"]=p["x"]; p["target_y"]=p["y"] elif t=="username_change_result": self.message=m.get("message",m.get("error","Username change failed.")); self.error=not m.get("ok") if m.get("ok"): new_name=str(m.get("username",self.username_new)).strip(); self.me["username"]=new_name if self.me else new_name; self.username_change_open=False; self.username_new=""; self.active=""; pygame.key.stop_text_input() elif t=="username_changed": pid=str(m.get("id","")); new_name=str(m.get("username","Player")) if pid in self.players: self.players[pid]["username"]=new_name if self.me and str(self.me.get("id"))==pid: self.me["username"]=new_name elif t=="profile_save_result": self.message=m.get("message",m.get("error","Profile saved.")); self.error=not m.get("ok") if m.get("ok"): self.profile=m.get("profile",self.profile) elif t=="profile_self_result": if m.get("ok"): self.profile=m.get("profile",self.profile); self.friends=m.get("friends",self.friends); self.outgoing_friend_requests=m.get("outgoing_friend_requests",self.outgoing_friend_requests); self.friend_requests=m.get("friend_requests",self.friend_requests) elif t=="profile_lookup_result": if m.get("ok"): self.profile_popup={"profile":m.get("profile",{}),"character":m.get("character"),"character_source":m.get("character_source","last_played")} else: self.message=m.get("error","User not found."); self.error=True elif t=="friend_request_result": self.friend_status=m.get("message",m.get("error","")); self.friend_status_error=not m.get("ok"); self.message=self.friend_status; self.error=self.friend_status_error if m.get("ok"): self.friend_search="" self.net.send({"action":"friends_list","token":self.token}) elif t=="friend_request": self.friend_request_queue.append(m.get("from",{})) if self.mode=="game" and self.friend_modal is None:self.show_next_friend_request() elif t=="friend_requests": self.friend_request_queue=deque(m.get("requests",[])) if self.mode=="game" and self.friend_modal is None:self.show_next_friend_request() elif t=="friend_request_response_result": if m.get("ok"): self.friend_modal=None; self.net.send({"action":"friends_list","token":self.token}) elif t=="friend_accepted": f=m.get("friend"); if isinstance(f,dict): self.friends=[x for x in self.friends if x.get("id")!=f.get("id")]+[f] elif t=="friends_list": self.friends=m.get("friends",[]); self.outgoing_friend_requests=m.get("outgoing",[]); self.friend_request_queue=deque(m.get("requests",[])); elif t=="friend_request_cancel_result": if m.get("ok"): self.outgoing_friend_requests=[x for x in self.outgoing_friend_requests if x.get("id")!=m.get("user_id")] self.message="Friend request cancelled."; self.error=False elif t=="friend_remove_result": if m.get("ok"):self.friends=[x for x in self.friends if x.get("id")!=m.get("friend_id")] elif t=="friend_removed": self.friends=[x for x in self.friends if x.get("id")!=m.get("id")] elif t=="friend_joined": self.friend_join_notice={"text":f"{m.get('username','Your friend')} has joined FeebWorld!","until":time.time()+10.0} elif t=="friend_left": for f in self.friends: if f.get("id")==m.get("id"):f["online"]=False elif t=="player_join": p=dict(m["player"]); self.players[p["id"]]=p; self.remote_last[p["id"]]=time.time(); self.chat_lines.append(f"{p['username']} joined the world"); self.chat_scroll=0 p["_render_x"]=p["x"]; p["_render_y"]=p["y"]; p["_target_time"]=time.time(); p["target_x"]=p["x"]; p["target_y"]=p["y"]; self.spawn_world_explosion(float(p.get("x",0)),float(p.get("y",0)),enter=True) elif t=="player_leave": pid=str(m.get("id","")); p=self.players.get(pid) if p: self.spawn_world_explosion(float(p.get("x",0)),float(p.get("y",0)),enter=False) self.players.pop(pid,None); self.remote_last.pop(pid,None); self.chat_lines.append("A player left the world"); self.chat_scroll=0 elif t=="world_snapshot": received=time.time() for p in m.get("players",[]): pid=p["id"] if pid not in self.players:self.players[pid]=dict(p) local=self.players[pid] local.update({"username":p.get("username",local.get("username","Player")),"character":p.get("character",local.get("character",{})),"moving":p.get("moving",False),"sprinting":p.get("sprinting",False),"jumping":p.get("jumping",False),"facing":p.get("facing",local.get("facing",1)),"water":p.get("water",False)}) local["target_x"]=float(p["x"]); local["target_y"]=float(p["y"]); local["_target_time"]=received if pid==self.me["id"]: self.local_server_pos=(float(p["x"]),float(p["y"])); local["_self"]=True # remove anyone no longer present ids={p["id"] for p in m.get("players",[])}; self.players={pid:p for pid,p in self.players.items() if pid in ids} elif t=="world_reloaded": if isinstance(m.get("world"), dict): self.world=m["world"] if self.me and self.me.get("id") in self.players: mep=self.players.get(self.me["id"]) if mep: self.local_server_pos=(float(mep.get("x",0)), float(mep.get("y",0))) self.clamp_client_position() self.message="World reloaded from main.fmap." self.error=False elif t=="player_move": p=self.players.get(m["id"]) if p: p["target_x"]=float(m["x"]); p["target_y"]=float(m["y"]); p["_target_time"]=time.time(); p["moving"]=m.get("moving",False); p["sprinting"]=m.get("sprinting",False); p["water"]=m.get("water",False) elif t=="player_jump": p=self.players.get(m["id"]) if p: duration=float(m.get("duration",SMALL_JUMP_DURATION)); p["jump_duration"]=duration; p["jump_until"]=time.time()+duration; p["_jump_was_water"]=bool(p.get("water",False)); large=int(p.get("character",{}).get("size",SMALL_CHAR_SIZE))>=LARGE_CHAR_SIZE if m.get("id")==self.me.get("id") if self.me else False: sound_scale=1.0 else: mep=self.players.get(self.me.get("id"),{}) if self.me else {} dist=math.hypot(float(p.get("x",0))-float(mep.get("x",0)),float(p.get("y",0))-float(mep.get("y",0))) sound_scale=clamp(1.0-dist/REMOTE_SFX_MAX_DISTANCE,0.0,1.0) if sound_scale>0.0: self.request_sound("largejump" if large else "jump",sound_scale) elif t=="chat": self.chat_lines.append(f"{m['username']}: {m['text']}"); self.chat_bubbles[m["id"]]={"text":m["text"],"until":m["expires"]}; self.chat_scroll=0; self.chat_unread+=1 elif t=="net_status": if m.get("status")=="connected": self.message="Connected to server."; self.error=False elif m.get("status")=="retrying" and self.mode=="game": self.stop_music(); self.mode="outage"; self.error=True else: self.message=f"Reconnecting to {m.get('url',WS_URL)}..." if m.get("status")=="retrying" else f"Connecting to {m.get('url',WS_URL)}..."; self.error=False elif t=="net_error": if self.mode=="game": self.stop_music(); self.outage_message="FEEBWORLD Server outage! Please try again later..."; self.mode="outage"; self.error=True else: self.message=f"Network error: {m.get('error','Disconnected')}"; self.error=True def auth_key(self,e): if e.key==pygame.K_TAB: keys=(['username','email','password'] if self.auth_mode=='register' and self.auth_stage==1 else (['code'] if self.auth_mode=='register' else ['email','password'])) self.active=keys[(keys.index(self.active)+1)%len(keys)] if self.active in keys else keys[0] elif e.key==pygame.K_RETURN:self.submit_auth() elif e.key==pygame.K_BACKSPACE and self.active:self.form[self.active]=self.form[self.active][:-1] elif e.key==pygame.K_ESCAPE:self.active="" def submit_auth(self): if not self.net.connected.is_set(): self.message="Not connected to FeebWorld."; self.error=True; return if self.auth_mode=="register": if self.auth_stage==1: password=self.form["password"] if not 8 <= len(password) <= 200: self.message=f"Password must be 8-200 characters. Current length: {len(password)}"; self.error=True; return salt_b64,digest_b64=client_password_credentials(password) self.net.send({"action":"register","username":self.form["username"],"email":self.form["email"],"password_length":len(password),"password_salt":salt_b64,"password_digest":digest_b64}) else: self.net.send({"action":"verify","email":self.form["email"],"code":self.form["code"]}) else: self.message="Checking password..."; self.error=False; self.net.send({"action":"login_challenge","email":self.form["email"]}) def paint_at(self,pos): cr=getattr(self, "canvas_rect", pygame.Rect(0,0,0,0)) if not cr.collidepoint(pos): return px=int(clamp((pos[0]-cr.x)*self.char_size/cr.width,0,self.char_size-1)); py=int(clamp((pos[1]-cr.y)*self.char_size/cr.height,0,self.char_size-1)); rad=self.brush; surf=self.layers[self.layer_selected]["surface"] if self.tool=="eyedropper": self.pick_color(px,py); return if self.tool=="bucket": self.flood_fill(px,py); return if not self.history_drag_recorded: self.push_history(); self.history_drag_recorded=True for yy in range(max(0,py-rad),min(self.char_size,py+rad+1)): for xx in range(max(0,px-rad),min(self.char_size,px+rad+1)): if (xx-px)**2+(yy-py)**2<=rad*rad: surf.set_at((xx,yy),(0,0,0,0) if self.eraser else self.paint_color) self.refresh_composite() def update_hue_from_point(self,pos): r=self.color_rects.get('wheel') if not r:return cx,cy=r.center; dx,dy=pos[0]-cx,pos[1]-cy; dist=math.hypot(dx,dy) if distr.w*0.52:return self.hue=(math.degrees(math.atan2(dy,dx))+360)%360; self.sv_hue=-1; self.paint_color=(*hsv_rgb(self.hue,self.sat,self.val),255); self.eraser=False def update_sv_from_point(self,pos): r=self.color_rects.get('sv') if not r:return self.sat=clamp((pos[0]-r.x)/max(1,r.w)*100,0,100); self.val=clamp(100-(pos[1]-r.y)/max(1,r.h)*100,0,100); self.paint_color=(*hsv_rgb(self.hue,self.sat,self.val),255); self.eraser=False def editor_mouse(self,e): pos=self._ui_mouse_pos(e.pos) if e.button in (4,5): if getattr(self,'slot_panel_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.slot_scroll += -1 if e.button==4 else 1; return if getattr(self,'layer_panel_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.layer_scroll += -1 if e.button==4 else 1; return if getattr(self,'canvas_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.brush=int(clamp(self.brush+(1 if e.button==4 else -1),1,20)); return return if e.button==1: if self.new_slot_modal: if getattr(self,'new_small_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.create_slot_of_size(SMALL_CHAR_SIZE) elif getattr(self,'new_large_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.create_slot_of_size(LARGE_CHAR_SIZE) elif getattr(self,'new_cancel_rect',pygame.Rect(0,0,0,0)).collidepoint(pos): self.new_slot_modal=False; self.pending_new_slot=None return if self.delete_confirm_slot is not None: if self.delete_no_rect.collidepoint(pos): self.cancel_delete() elif self.delete_yes_rect.collidepoint(pos): self.confirm_delete() return if self.name_rect.collidepoint(pos): self.active='editor_name'; pygame.key.start_text_input(); return if self.new_slot_rect.collidepoint(pos): self.create_new_slot(); return for dr,slot in self.slot_delete_rects: if dr.collidepoint(pos): self.request_delete_slot(slot); return for r,i in self.slot_rects: if r.collidepoint(pos): self.setup_editor_from_slot(i); return if self.color_rects.get('wheel',pygame.Rect(0,0,0,0)).collidepoint(pos): self.drag_color_wheel=True; self.update_hue_from_point(pos); return if self.color_rects.get('sv',pygame.Rect(0,0,0,0)).collidepoint(pos): self.drag_sv=True; self.update_sv_from_point(pos); return for vr,idx in self.layer_visible_rects: if vr.collidepoint(pos): self.push_history(); self.layers[idx]['visible']=not self.layers[idx]['visible']; self.refresh_composite(); return for r,idx in self.layer_rects: if r.collidepoint(pos): self.layer_selected=idx; return for btn in self.editor_buttons: if btn.click(pos): return if self.editor_tool_from_point(pos): return cr=self.canvas_rect if cr.collidepoint(pos): self.painting=True; self.history_drag_recorded=False; self.paint_at(pos) elif e.button==3: cr=self.canvas_rect if cr.collidepoint(pos): self.painting=True; self.history_drag_recorded=False; self.tool='eraser'; self.eraser=True; self.paint_at(pos) def send_friend_request(self): username=self.friend_search.strip() if not username: self.friend_status="Enter a username first."; self.friend_status_error=True; return if not self.token: self.friend_status="You are not signed in."; self.friend_status_error=True; return self.friend_status=f"Sending request to {username}..."; self.friend_status_error=False self.net.send({"action":"friend_request","token":self.token,"username":username}) def game_mouse(self,e): if e.button in (4,5): if self.chat_panel_rect.collidepoint(e.pos): self.chat_scroll += 36 if e.button==4 else -36 elif self.game_menu_open: self.change_zoom(.5 if e.button==4 else -.5) else: self.change_zoom(.5 if e.button==4 else -.5) return if e.button==1: if getattr(self,"friend_cancel_confirm",None): if getattr(self,"friend_cancel_yes_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.cancel_friend_request_confirmed(); return if getattr(self,"friend_cancel_no_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.friend_cancel_confirm=None; return return if getattr(self,"friend_remove_confirm",None): if getattr(self,"friend_remove_yes_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.remove_friend_confirmed(); return if getattr(self,"friend_remove_no_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.friend_remove_confirm=None; return return if getattr(self,"profile_popup",None) is not None: if getattr(self,"profile_close_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.profile_popup=None; return for r,link in getattr(self,"profile_link_rects",[]): if r.collidepoint(e.pos): self.profile_link_confirm=link; return return if getattr(self,"profile_link_confirm",None) is not None: if getattr(self,"profile_link_yes_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): webbrowser.open(self.profile_link_confirm); self.profile_link_confirm=None; return if getattr(self,"profile_link_no_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.profile_link_confirm=None; return return if getattr(self,"friend_modal",None) is not None: if getattr(self,"friend_yes_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.net.send({"action":"friend_request_response","token":self.token,"from_id":self.friend_modal.get("id"),"accept":True}); return if getattr(self,"friend_no_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.net.send({"action":"friend_request_response","token":self.token,"from_id":self.friend_modal.get("id"),"accept":False}); return return if getattr(self,"friends_panel_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): if getattr(self,"friend_add_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.send_friend_request(); return # Cancel outgoing requests BEFORE the row/profile hitbox so the # cancel button cannot be swallowed by the larger row. for r,fid in getattr(self,"friend_cancel_rects",[]): if r.collidepoint(e.pos): self.friend_cancel_confirm=fid; return for r,fid in getattr(self,"friend_remove_rects",[]): if r.collidepoint(e.pos): self.friend_remove_confirm=fid; return for r,fid in getattr(self,"friend_profile_rects",[]): if r.collidepoint(e.pos): self.net.send({"action":"profile_lookup","token":self.token,"user_id":fid}); return if getattr(self,"friends_menu_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.friends_panel_open=not self.friends_panel_open; self.friend_active=False; self.net.send({"action":"friends_list","token":self.token}); return if getattr(self,"friends_panel_open",False) and getattr(self,"friend_search_rect",pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.friend_active=True; pygame.key.start_text_input(); return if self.chat_rect.collidepoint(e.pos): self.chat_input=""; pygame.key.start_text_input(); return for pid,p in self.players.items(): if pid==self.me.get("id"): continue px,py=self.world_to_screen(p.get("x",0),p.get("y",0)); size=max(22,int(p.get("character",{}).get("size",SMALL_CHAR_SIZE)*self.zoom/5)); cy=self.world_to_screen(p.get("x",0),p.get("y",0))[1]-self.jump_offset(pid)+self.bounce_offset(pid,p.get("moving",False),p.get("water",False)); if (e.pos[0]-px)**2+(e.pos[1]-cy)**2 <= (size*0.55)**2: self.net.send({"action":"profile_lookup","token":self.token,"user_id":pid}); return if getattr(self,'menu_rect',pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.game_menu_open=not self.game_menu_open; return if self.game_menu_open: if getattr(self,'menu_zoom_out',pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.change_zoom(-.5); return if getattr(self,'menu_zoom_in',pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.change_zoom(.5); return if getattr(self,'menu_editor_rect',pygame.Rect(0,0,0,0)).collidepoint(e.pos): self.back_to_editor(); return for attr,name in (("music_slider_rect","music"),("sfx_slider_rect","sfx")): r=getattr(self,attr,pygame.Rect(0,0,0,0)) if r.collidepoint(e.pos): value=clamp((e.pos[0]-r.x)/max(1,r.w),0,1) if name=="music": self.audio_music_volume=value; pygame.mixer.music.set_volume(value); self.music_channel.set_volume(value) if self.music_channel is not None else None else: self.audio_sfx_volume=value self.save_settings() return def set_volume_from_mouse(self,pos): for attr,name in (("music_slider_rect","music"),("sfx_slider_rect","sfx")): r=getattr(self,attr,pygame.Rect(0,0,0,0)) if r.collidepoint(pos): value=clamp((pos[0]-r.x)/max(1,r.w),0,1) if name=="music": self.audio_music_volume=value; pygame.mixer.music.set_volume(value); self.music_channel.set_volume(value) if self.music_channel is not None else None else: self.audio_sfx_volume=value self.save_settings() return True return False def change_zoom(self,delta): self.zoom=clamp(round(self.zoom+delta,1),1.0,10.0); self.save_settings() def game_key(self,e): if self.friend_active: if e.key==pygame.K_BACKSPACE:self.friend_search=self.friend_search[:-1] elif e.key==pygame.K_RETURN:self.send_friend_request(); self.friend_search="" elif e.key==pygame.K_ESCAPE:self.friend_active=False; pygame.key.stop_text_input() return if self.chat_input is not None: if e.key==pygame.K_RETURN:self.send_chat() elif e.key==pygame.K_BACKSPACE:self.chat_input=self.chat_input[:-1] elif e.key==pygame.K_ESCAPE:self.chat_input=None; pygame.key.stop_text_input() elif e.key==pygame.K_PAGEUP:self.chat_scroll+=120 elif e.key==pygame.K_PAGEDOWN:self.chat_scroll-=120 return if e.key==pygame.K_t:self.chat_input=""; pygame.key.start_text_input() elif e.key==pygame.K_SPACE and time.time()-self.last_jump>.7:self.net.send({"action":"jump","token":self.token}); self.last_jump=time.time() elif e.key in (pygame.K_EQUALS,pygame.K_KP_PLUS):self.change_zoom(.5) elif e.key in (pygame.K_MINUS,pygame.K_KP_MINUS):self.change_zoom(-.5) elif e.key==pygame.K_ESCAPE and self.game_menu_open:self.game_menu_open=False elif e.key==pygame.K_m:self.game_menu_open=not self.game_menu_open def send_chat(self): if self.chat_input and self.token:self.net.send({"action":"chat","token":self.token,"text":self.chat_input.strip()}) self.chat_input=None; pygame.key.stop_text_input() def _building_local(self,b,x,y): bx,by=float(b.get("x",0)),float(b.get("y",0)); bw,bh=float(b.get("w",1)),float(b.get("h",1)); ang=math.radians(float(b.get("rotation",0))%360.0) cx,cy=bx+bw/2.0,by+bh/2.0; dx,dy=float(x)-cx,float(y)-cy; ca,sa=math.cos(-ang),math.sin(-ang) return dx*ca-dy*sa+bw/2.0, dx*sa+dy*ca+bh/2.0 @staticmethod def _circle_local_rect(lx,ly,rx,ry,rw,rh,radius): if rw<=0 or rh<=0:return False cx=max(rx,min(lx,rx+rw)); cy=max(ry,min(ly,ry+rh)); dx,dy=lx-cx,ly-cy return dx*dx+dy*dy<=radius*radius def _building_wall_collision(self,b,lx,ly,radius): bw=float(b.get("w",0)); bh=float(b.get("h",0)) if bw<=0 or bh<=0:return False d=b.get("door",{}) if isinstance(b.get("door",{}),dict) else {} side=str(d.get("side","bottom")).lower() off=clamp(float(d.get("offset",.5)),0.0,1.0) dw=max(1.0,float(d.get("width",52))) wall=max(10.0,min(16.0,radius*.8)) left,right=lx-radius,lx+radius top,bottom=ly-radius,ly+radius if side in ("top","bottom"): c=bw*off door=c-dw/2<=lx<=c+dw/2 else: c=bh*off door=c-dw/2<=ly<=c+dw/2 if top<=wall and right>=0 and left<=bw: if not (side=="top" and door): return True if bottom>=bh-wall and right>=0 and left<=bw: if not (side=="bottom" and door): return True if left<=wall and bottom>=0 and top<=bh: if not (side=="left" and door): return True if right>=bw-wall and bottom>=0 and top<=bh: if not (side=="right" and door): return True return False def client_blocked(self,x,y): if not self.world:return False ts=self.world["tile_size"]; tx,ty=int(x//ts),int(y//ts) if tx<0 or ty<0 or tx>=self.world["width"] or ty>=self.world["height"]:return True if self.world["tiles"][ty][tx]==2:return True for t in self.world.get("trees",[]): if (x-t["x"])**2+(y-t["y"])**2 < 21.0**2:return True radius=14.0 for b in self.world.get("buildings",[]): if not isinstance(b,dict): continue lx,ly=self._building_local(b,x,y); bw,bh=float(b.get("w",0)),float(b.get("h",0)); pad=18.0+radius if lx < -pad or ly < -pad or lx > bw+pad or ly > bh+pad: continue if self._building_wall_collision(b,lx,ly,radius): return True wall=max(10.0,min(16.0,radius*.8)) if wall<=lx<=bw-wall and wall<=ly<=bh-wall: for item in b.get("furniture",[]): if isinstance(item,(list,tuple)) and len(item)>=4: fx,fy,fw,fh=map(float,item[:4]) if self._circle_local_rect(lx,ly,fx,fy,fw,fh,radius*.7): return True interior=b.get("interior",{}) if isinstance(b.get("interior"),dict) else {}; its=max(1.0,float(b.get("tile_size",ts))) for item in interior.get("objects",[]): if not isinstance(item,dict) or not item.get("solid",False): continue fx=float(item.get("x",0))*its; fy=float(item.get("y",0))*its; fw=float(item.get("w",1))*its; fh=float(item.get("h",1))*its if self._circle_local_rect(lx,ly,fx,fy,fw,fh,radius*.7): return True return False def update_game(self): self._update_music_stream() if self.me and self.mode=="game": k=pygame.key.get_pressed(); dx=(k[pygame.K_d] or k[pygame.K_RIGHT])-(k[pygame.K_a] or k[pygame.K_LEFT]); dy=(k[pygame.K_s] or k[pygame.K_DOWN])-(k[pygame.K_w] or k[pygame.K_UP]); water=self.is_water_client(self.players.get(self.me["id"],{}).get("x",0),self.players.get(self.me["id"],{}).get("y",0)) if self.chat_input is not None: dx=dy=0 sprint=bool(k[pygame.K_LSHIFT] or k[pygame.K_RSHIFT]) and not water and bool(dx or dy) now_moving=bool(dx or dy) mep=self.players.get(self.me["id"]) if self.me else None if mep is not None: mep["_was_moving"]=now_moving mag=math.hypot(dx,dy); nx,ny=(dx/mag,dy/mag) if mag else (0,0) desired=(nx,ny,sprint) if desired!=self.input_sent or time.time()-self.last_input_send>.08: self.net.send({"action":"input","token":self.token,"x":nx,"y":ny,"sprint":sprint}); self.input_sent=desired; self.last_input_send=time.time() # Local prediction at fixed movement speed; network latency does not alter this speed. p=self.players.get(self.me["id"]) if p and (nx or ny) and self.chat_input is None: dt=min(0.05,max(0.0,self.clock.get_time()/1000.0)); large_character=int(p.get("character",{}).get("size",SMALL_CHAR_SIZE))>=LARGE_CHAR_SIZE; speed=WATER_SPEED if water else ((LARGE_SPRINT_SPEED if sprint else LARGE_WALK_SPEED) if large_character else (SPRINT_SPEED if sprint else WALK_SPEED)) step=speed*dt; tx,ty=p["x"]+nx*step,p["y"]+ny*step if not self.client_blocked(tx,ty): p["x"],p["y"]=tx,ty else: if not self.client_blocked(tx,p["y"]): p["x"]=tx if not self.client_blocked(p["x"],ty): p["y"]=ty p["moving"]=bool(nx or ny); p["sprinting"]=sprint; p["water"]=water; p["facing"]=1 if abs(nx)>=.15 and nx>0 else (-1 if abs(nx)>=.15 else p.get("facing",1)); self.clamp_client_position() self.player_pos=[p["x"],p["y"]] if p and self.local_server_pos is not None: sx,sy=self.local_server_pos; dx2,dy2=sx-p["x"],sy-p["y"]; d=math.hypot(dx2,dy2) if d>55: p["x"],p["y"]=sx,sy elif d>0.75: blend=min(1.0, max(0.08, self.clock.get_time()/1000.0*10.0)) p["x"]+=dx2*blend; p["y"]+=dy2*blend p["moving"]=bool(nx or ny) and self.chat_input is None; p["sprinting"]=sprint; p["water"]=water; self.player_pos=[p["x"],p["y"]] nowt=time.time() for pid,p in list(self.players.items()): was=p.pop("_jump_prev",False); jumping=p.get("jump_until",0)>nowt p["_jump_prev"]=jumping if was and not jumping: large=int(p.get("character",{}).get("size",SMALL_CHAR_SIZE))>=LARGE_CHAR_SIZE if bool(p.get("water",False)): self.spawn_water_splash(p.get("x",0),p.get("y",0),large) if pid==self.me.get("id") if self.me else False: sound_scale=1.0 else: mep=self.players.get(self.me.get("id"),{}) if self.me else {} dist=math.hypot(float(p.get("x",0))-float(mep.get("x",0)),float(p.get("y",0))-float(mep.get("y",0))) sound_scale=clamp(1.0-dist/REMOTE_SFX_MAX_DISTANCE,0.0,1.0) if sound_scale>0.0: self.request_sound("largesplash" if large else "splash",sound_scale) for fx in self.water_fx[:]: fx["life"]-=self.clock.get_time()/1000.0 if fx["kind"]=="particle": fx["x"]+=fx["vx"]*self.clock.get_time()/1000.0; fx["y"]+=fx["vy"]*self.clock.get_time()/1000.0; fx["vy"]+=110*self.clock.get_time()/1000.0 if fx["life"]<=0:self.water_fx.remove(fx) for fx in self.world_fx[:]: dt=self.clock.get_time()/1000.0; fx["life"]-=dt if fx["kind"]=="burst": fx["x"]+=fx["vx"]*dt; fx["y"]+=fx["vy"]*dt; fx["vy"]+=90*dt if fx["life"]<=0:self.world_fx.remove(fx) for pid,b in list(self.chat_bubbles.items()): if b["until"]<=time.time():self.chat_bubbles.pop(pid,None) def clamp_client_position(self): if not self.world:return ts=self.world["tile_size"]; minx=ts+WORLD_PLAYER_RADIUS; miny=ts+WORLD_PLAYER_RADIUS; maxx=self.world["width"]*ts-ts-WORLD_PLAYER_RADIUS; maxy=self.world["height"]*ts-ts-WORLD_PLAYER_RADIUS p=self.players.get(self.me["id"]) if self.me else None if p:p["x"]=clamp(p["x"],minx,maxx);p["y"]=clamp(p["y"],miny,maxy) def run(self): self.anim={} while self.running: for e in pygame.event.get(): if e.type==pygame.QUIT:self.running=False elif e.type==pygame.TEXTINPUT: if self.mode=="login" and self.active in self.form:self.form[self.active]+=e.text[:200] elif self.mode=="editor" and self.active=="editor_name":self.editor_name=(self.editor_name+e.text)[:20] elif self.mode=="account" and self.username_change_open: self.username_new=(self.username_new+e.text)[:20] elif self.mode=="account" and self.account_reset_stage==0 and getattr(self,"account_active_field",None) and self.account_active_field.startswith("social_"): idx=int(self.account_active_field.split("_")[1]); self.account_social_links[idx]=(self.account_social_links[idx]+e.text)[:500] elif self.mode=="account" and self.account_reset_stage==2 and self.active in ("code","new","confirm"): if self.active=="code": self.account_reset_code=(self.account_reset_code+e.text).replace(" ","")[:6] elif self.active=="new": self.account_new_password=(self.account_new_password+e.text)[:200] else:self.account_confirm_password=(self.account_confirm_password+e.text)[:200] elif self.mode=="game" and self.friend_active: self.friend_search=(self.friend_search+e.text)[:64] elif self.mode=="game" and self.chat_input is not None:self.chat_input=(self.chat_input+e.text)[:CHAT_LIMIT] elif e.type==pygame.KEYDOWN: if self.mode=="game":self.game_key(e) elif self.mode=="login":self.auth_key(e) elif self.mode=="account":self.account_key(e) elif self.mode=="editor": if e.key==pygame.K_BACKSPACE and self.active=="editor_name":self.editor_name=self.editor_name[:-1] elif e.key==pygame.K_ESCAPE:self.active="" elif e.key in (pygame.K_LEFTBRACKET,pygame.K_RIGHTBRACKET):self.brush=int(clamp(self.brush+(-1 if e.key==pygame.K_LEFTBRACKET else 1),1,20)) elif e.key==pygame.K_e:self.set_editor_tool("eraser" if self.tool!="eraser" else "brush") elif e.type==pygame.MOUSEWHEEL: if self.mode=="account" and self.platform_dropdown is not None: self.platform_dropdown_scroll=max(0,self.platform_dropdown_scroll-e.y*48) # Suppress any synthetic mouse-button event that a mouse/trackpad # may emit immediately after a wheel gesture. self.platform_scroll_ignore_until=time.monotonic()+0.20 elif self.mode=="game" and self.chat_panel_rect.collidepoint(pygame.mouse.get_pos()): self.chat_scroll += e.y*36 elif self.mode=="game": self.change_zoom(e.y*.5) elif e.type==pygame.MOUSEBUTTONDOWN: if self.mode=="login": pos=self._ui_mouse_pos(e.pos) if self.tab_rects.get("login",pygame.Rect(0,0,0,0)).collidepoint(pos):self.auth_mode="login";self.auth_stage=1;self.active="email";pygame.key.start_text_input() elif self.tab_rects.get("register",pygame.Rect(0,0,0,0)).collidepoint(pos):self.auth_mode="register";self.auth_stage=1;self.active="username";pygame.key.start_text_input() elif getattr(self,"show_pw_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.show_pw=not self.show_pw elif getattr(self,"remember_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.remember_me=not self.remember_me; self.save_settings() elif getattr(self,"action_rect",pygame.Rect(0,0,0,0)).collidepoint(pos):self.submit_auth() else: for key,r in self.field_rects.items(): if r.collidepoint(pos):self.active=key;pygame.key.start_text_input();break elif self.mode=="account":self.account_mouse(e) elif self.mode=="outage": pos=self._ui_mouse_pos(e.pos) if getattr(self,"outage_ok_rect",pygame.Rect(0,0,0,0)).collidepoint(pos): self.handle_outage_ok() elif self.mode=="editor":self.editor_mouse(e) elif self.mode=="game":self.game_mouse(e) elif e.type==pygame.MOUSEBUTTONUP and self.mode=="editor": self.painting=False; self.history_drag_recorded=False; self.drag_color_wheel=False; self.drag_sv=False elif e.type==pygame.MOUSEMOTION and self.mode=="editor": pos=self._ui_mouse_pos(e.pos) if self.drag_color_wheel:self.update_hue_from_point(pos) elif self.drag_sv:self.update_sv_from_point(pos) elif self.painting:self.paint_at(pos) elif e.type==pygame.MOUSEMOTION and self.mode=="game" and self.game_menu_open and pygame.mouse.get_pressed()[0]: self.set_volume_from_mouse(e.pos) elif e.type==pygame.VIDEORESIZE: pass for m in self.net.poll():self.process(m) if self.mode=="game":self.update_game();self.draw_world() elif self.mode=="editor":self._render_responsive_ui(self.draw_editor) elif self.mode=="account":self._render_responsive_ui(self.draw_account_settings) elif self.mode=="outage":self._render_responsive_ui(self.draw_outage) else:self._render_responsive_ui(self.draw_login) pygame.display.flip();self.clock.tick(FPS) self.net.close(); pygame.quit() if __name__=="__main__":FeebWorld().run()