mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 16:14:57 +00:00
BREAKING CHANGE: Complete refactor of architecture to prepare BJORN V2 release, APIs, assets, and UI, webapp, logics, attacks, a lot of new features...
This commit is contained in:
410
web_utils/character_utils.py
Normal file
410
web_utils/character_utils.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Character and persona management utilities.
|
||||
Handles character switching, creation, and image management.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import io
|
||||
import cgi
|
||||
from PIL import Image
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="character_utils.py", level=logging.DEBUG)
|
||||
|
||||
|
||||
class CharacterUtils:
|
||||
"""Utilities for character/persona management."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.logger = logger
|
||||
|
||||
# --------- helpers ---------
|
||||
|
||||
def _send_error_response(self, handler, message: str, status_code: int = 500):
|
||||
handler.send_response(status_code)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'error', 'message': message}).encode('utf-8'))
|
||||
|
||||
def _to_bmp_bytes(self, raw: bytes, width: int | None = None, height: int | None = None) -> bytes:
|
||||
"""Convert any image bytes to BMP (optionally resize)."""
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
if im.mode != 'RGB':
|
||||
im = im.convert('RGB')
|
||||
if width and height:
|
||||
try:
|
||||
resample = Image.Resampling.LANCZOS
|
||||
except AttributeError:
|
||||
resample = Image.LANCZOS
|
||||
im = im.resize((width, height), resample)
|
||||
out = BytesIO()
|
||||
im.save(out, format='BMP')
|
||||
return out.getvalue()
|
||||
|
||||
def get_existing_character_numbers(self, action_dir: str | Path, action_name: str) -> set[int]:
|
||||
"""
|
||||
Retourne l'ensemble des numéros déjà utilisés pour les images characters
|
||||
(p. ex. <action>1.bmp, <action>2.bmp, ...).
|
||||
"""
|
||||
d = Path(action_dir)
|
||||
if not d.exists():
|
||||
return set()
|
||||
nums: set[int] = set()
|
||||
pat = re.compile(rf"^{re.escape(action_name)}(\d+)\.bmp$", re.IGNORECASE)
|
||||
for p in d.glob("*.bmp"):
|
||||
m = pat.match(p.name)
|
||||
if m:
|
||||
try:
|
||||
nums.add(int(m.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
return nums
|
||||
|
||||
# --------- endpoints ---------
|
||||
|
||||
def get_current_character(self):
|
||||
"""Lit le personnage courant depuis la config (DB)."""
|
||||
try:
|
||||
return self.shared_data.config.get('current_character', 'BJORN') or 'BJORN'
|
||||
except Exception:
|
||||
return 'BJORN'
|
||||
|
||||
def serve_bjorn_say(self, handler):
|
||||
try:
|
||||
bjorn_says_data = {"text": self.shared_data.bjorn_says}
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps(bjorn_says_data).encode('utf-8'))
|
||||
except Exception as e:
|
||||
handler.send_response(500)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
|
||||
|
||||
def serve_bjorn_character(self, handler):
|
||||
try:
|
||||
img_byte_arr = io.BytesIO()
|
||||
self.shared_data.bjorn_character.save(img_byte_arr, format='PNG')
|
||||
img_byte_arr = img_byte_arr.getvalue()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'image/png')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(img_byte_arr)
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error serving status image: {e}")
|
||||
|
||||
def list_characters(self, handler):
|
||||
"""List all available characters with metadata."""
|
||||
try:
|
||||
characters_dir = self.shared_data.settings_dir
|
||||
characters = []
|
||||
|
||||
for entry in os.scandir(characters_dir):
|
||||
if entry.is_dir():
|
||||
character_name = entry.name
|
||||
idle_image_path = os.path.join(entry.path, 'IDLE', 'IDLE1.bmp') # legacy path?
|
||||
has_idle_image = os.path.exists(idle_image_path)
|
||||
characters.append({'name': character_name, 'has_idle_image': has_idle_image})
|
||||
|
||||
current_character = self.get_current_character()
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
resp = {'status': 'success', 'characters': characters, 'current_character': current_character}
|
||||
handler.wfile.write(json.dumps(resp).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in list_characters: {e}")
|
||||
self._send_error_response(handler, str(e))
|
||||
|
||||
def get_character_icon(self, handler):
|
||||
"""Serve character icon (IDLE1.bmp)."""
|
||||
try:
|
||||
query_components = parse_qs(urlparse(handler.path).query)
|
||||
character = (query_components.get('character', [None])[0] or '').strip()
|
||||
if not character:
|
||||
raise ValueError('Character parameter is required')
|
||||
|
||||
current_character = self.get_current_character()
|
||||
if character == current_character:
|
||||
# Quand le perso est actif, ses images sont dans status_images_dir/IDLE/IDLE1.bmp
|
||||
idle_image_path = os.path.join(self.shared_data.status_images_dir, 'IDLE', 'IDLE1.bmp')
|
||||
else:
|
||||
idle_image_path = os.path.join(self.shared_data.settings_dir, character, 'status', 'IDLE', 'IDLE1.bmp')
|
||||
|
||||
if not os.path.exists(idle_image_path):
|
||||
raise FileNotFoundError(f"IDLE1.bmp for character '{character}' not found")
|
||||
|
||||
with open(idle_image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'image/bmp')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(image_data)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in get_character_icon: {e}")
|
||||
handler.send_error(404)
|
||||
|
||||
def create_character(self, handler):
|
||||
"""Create a new character by copying current character's images."""
|
||||
try:
|
||||
content_length = int(handler.headers['Content-Length'])
|
||||
post_data = handler.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
new_character_name = (data.get('character_name') or '').strip()
|
||||
|
||||
if not new_character_name:
|
||||
raise ValueError('Character name is required')
|
||||
|
||||
new_character_dir = os.path.join(self.shared_data.settings_dir, new_character_name)
|
||||
if os.path.exists(new_character_dir):
|
||||
raise FileExistsError(f"Character '{new_character_name}' already exists")
|
||||
|
||||
self.save_current_character_images(new_character_dir)
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character created successfully'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in create_character: {e}")
|
||||
self._send_error_response(handler, str(e))
|
||||
|
||||
def switch_character(self, handler):
|
||||
"""Switch to a different character, saving current modifications first."""
|
||||
try:
|
||||
content_length = int(handler.headers['Content-Length'])
|
||||
post_data = handler.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
selected_character_name = (data.get('character_name') or '').strip()
|
||||
|
||||
if not selected_character_name:
|
||||
raise ValueError('Character name is required')
|
||||
|
||||
current_character = self.get_current_character()
|
||||
if selected_character_name == current_character:
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character already selected'}).encode('utf-8'))
|
||||
return
|
||||
|
||||
# Save current character's images
|
||||
current_character_dir = os.path.join(self.shared_data.settings_dir, current_character)
|
||||
self.save_current_character_images(current_character_dir)
|
||||
|
||||
# Check new character exists
|
||||
selected_character_dir = os.path.join(self.shared_data.settings_dir, selected_character_name)
|
||||
if not os.path.exists(selected_character_dir):
|
||||
raise FileNotFoundError(f"Character '{selected_character_name}' does not exist")
|
||||
|
||||
# Activate
|
||||
self.copy_character_images(
|
||||
selected_character_dir,
|
||||
self.shared_data.status_images_dir,
|
||||
self.shared_data.static_images_dir
|
||||
)
|
||||
|
||||
# Update config
|
||||
self.shared_data.config['bjorn_name'] = selected_character_name
|
||||
self.shared_data.config['current_character'] = selected_character_name
|
||||
self.shared_data.save_config()
|
||||
self.shared_data.load_config()
|
||||
|
||||
time.sleep(1)
|
||||
self.shared_data.load_images()
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character switched successfully'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in switch_character: {e}")
|
||||
self._send_error_response(handler, str(e))
|
||||
|
||||
def delete_character(self, handler):
|
||||
"""Delete a character, handling current character case."""
|
||||
try:
|
||||
content_length = int(handler.headers['Content-Length'])
|
||||
post_data = handler.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
character_name = (data.get('character_name') or '').strip()
|
||||
|
||||
if not character_name:
|
||||
raise ValueError('Character name is required')
|
||||
|
||||
if character_name == 'BJORN':
|
||||
raise ValueError("Cannot delete the default 'BJORN' character")
|
||||
|
||||
character_dir = os.path.join(self.shared_data.settings_dir, character_name)
|
||||
if not os.path.exists(character_dir):
|
||||
raise FileNotFoundError(f"Character '{character_name}' does not exist")
|
||||
|
||||
current_character = self.get_current_character()
|
||||
if character_name == current_character:
|
||||
bjorn_dir = os.path.join(self.shared_data.settings_dir, 'BJORN')
|
||||
if not os.path.exists(bjorn_dir):
|
||||
raise FileNotFoundError("Default 'BJORN' character does not exist")
|
||||
|
||||
self.copy_character_images(
|
||||
bjorn_dir,
|
||||
self.shared_data.status_images_dir,
|
||||
self.shared_data.static_images_dir
|
||||
)
|
||||
|
||||
self.shared_data.config['bjorn_name'] = 'BJORN'
|
||||
self.shared_data.config['current_character'] = 'BJORN'
|
||||
self.shared_data.save_config()
|
||||
self.shared_data.load_config()
|
||||
self.shared_data.load_images()
|
||||
|
||||
shutil.rmtree(character_dir)
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character deleted successfully'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in delete_character: {e}")
|
||||
self._send_error_response(handler, str(e))
|
||||
|
||||
def save_current_character_images(self, character_dir):
|
||||
"""Save current character's status and static images."""
|
||||
try:
|
||||
if not os.path.exists(character_dir):
|
||||
os.makedirs(character_dir)
|
||||
|
||||
dest_status_dir = os.path.join(character_dir, 'status')
|
||||
if os.path.exists(dest_status_dir):
|
||||
shutil.rmtree(dest_status_dir)
|
||||
shutil.copytree(self.shared_data.status_images_dir, dest_status_dir)
|
||||
|
||||
dest_static_dir = os.path.join(character_dir, 'static')
|
||||
if os.path.exists(dest_static_dir):
|
||||
shutil.rmtree(dest_static_dir)
|
||||
shutil.copytree(self.shared_data.static_images_dir, dest_static_dir)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in save_current_character_images: {e}")
|
||||
|
||||
def copy_character_images(self, source_dir, dest_status_dir, dest_static_dir):
|
||||
"""Copy character images from source to destination directories."""
|
||||
try:
|
||||
source_status_dir = os.path.join(source_dir, 'status')
|
||||
if os.path.exists(source_status_dir):
|
||||
if os.path.exists(dest_status_dir):
|
||||
shutil.rmtree(dest_status_dir)
|
||||
shutil.copytree(source_status_dir, dest_status_dir)
|
||||
|
||||
source_static_dir = os.path.join(source_dir, 'static')
|
||||
if os.path.exists(source_static_dir):
|
||||
if os.path.exists(dest_static_dir):
|
||||
shutil.rmtree(dest_static_dir)
|
||||
shutil.copytree(source_static_dir, dest_static_dir)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in copy_character_images: {e}")
|
||||
|
||||
def upload_character_images(self, handler):
|
||||
"""Ajoute des images de characters pour une action existante (toujours BMP + numérotation)."""
|
||||
try:
|
||||
ctype, pdict = cgi.parse_header(handler.headers.get('Content-Type'))
|
||||
if ctype != 'multipart/form-data':
|
||||
raise ValueError('Content-Type doit être multipart/form-data')
|
||||
|
||||
pdict['boundary'] = bytes(pdict['boundary'], "utf-8")
|
||||
pdict['CONTENT-LENGTH'] = int(handler.headers.get('Content-Length'))
|
||||
|
||||
form = cgi.FieldStorage(
|
||||
fp=io.BytesIO(handler.rfile.read(pdict['CONTENT-LENGTH'])),
|
||||
headers=handler.headers,
|
||||
environ={'REQUEST_METHOD': 'POST'},
|
||||
keep_blank_values=True
|
||||
)
|
||||
|
||||
if 'action_name' not in form:
|
||||
raise ValueError("Le nom de l'action est requis")
|
||||
|
||||
action_name = (form.getvalue('action_name') or '').strip()
|
||||
if not action_name:
|
||||
raise ValueError("Le nom de l'action est requis")
|
||||
|
||||
if 'character_images' not in form:
|
||||
raise ValueError('Aucun fichier image fourni')
|
||||
|
||||
action_dir = os.path.join(self.shared_data.status_images_dir, action_name)
|
||||
if not os.path.exists(action_dir):
|
||||
raise FileNotFoundError(f"L'action '{action_name}' n'existe pas")
|
||||
|
||||
existing_numbers = self.get_existing_character_numbers(action_dir, action_name)
|
||||
next_number = max(existing_numbers, default=0) + 1
|
||||
|
||||
file_items = form['character_images']
|
||||
if not isinstance(file_items, list):
|
||||
file_items = [file_items]
|
||||
|
||||
for file_item in file_items:
|
||||
if not getattr(file_item, 'filename', ''):
|
||||
continue
|
||||
raw = file_item.file.read()
|
||||
bmp = self._to_bmp_bytes(raw)
|
||||
out_path = os.path.join(action_dir, f"{action_name}{next_number}.bmp")
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(bmp)
|
||||
next_number += 1
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images de characters ajoutées avec succès'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Erreur dans upload_character_images: {e}")
|
||||
import traceback
|
||||
self.logger.error(traceback.format_exc())
|
||||
self._send_error_response(handler, str(e))
|
||||
|
||||
|
||||
def reload_fonts(self, handler):
|
||||
"""Recharge les fonts en exécutant load_fonts."""
|
||||
try:
|
||||
self.shared_data.load_fonts()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Fonts loaded successfully.'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in load_fonts: {e}")
|
||||
handler.send_response(500)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))
|
||||
|
||||
def reload_images(self, handler):
|
||||
"""Recharge les images en exécutant load_images."""
|
||||
try:
|
||||
self.shared_data.load_images()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images rechargées avec succès.'}).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in reload_images: {e}")
|
||||
handler.send_response(500)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))
|
||||
Reference in New Issue
Block a user