root39058 commited on
Commit
c592279
·
verified ·
1 Parent(s): 1a68d5e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +464 -548
app.py CHANGED
@@ -1,18 +1,12 @@
1
  """
2
- AI PLATFORMER + CHATBOT (FIXED & OPTIMIZED)
3
- Safe spawn, proper rendering, stateful game loop
4
  """
5
 
6
- import os
7
- import json
8
- import random
9
- import threading
10
- import time
11
- import logging
12
  from collections import deque
13
  from dataclasses import dataclass
14
- from typing import Dict, List, Optional, Any
15
-
16
  import numpy as np
17
  import torch
18
  import torch.nn as nn
@@ -22,245 +16,217 @@ from flask import Flask, jsonify, request, render_template_string
22
  logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
23
  logger = logging.getLogger(__name__)
24
 
25
- # ============================================================================
26
- # CONFIGURATION
27
- # ============================================================================
28
-
29
  @dataclass
30
- class Config:
31
- GRID_W: int = 80
32
- GRID_H: int = 20
33
- GROUND_Y: int = 17
34
- CHUNK_SIZE: int = 30
35
- SAFE_ZONE: int = 15 # No obstacles in first N units
36
-
37
- VIEWPORT_SIZE: int = 40 # NN input size (40x40)
38
-
39
- GRAVITY: float = 0.4
40
- JUMP_POWER: float = -7.0
41
- MOVE_SPEED: float = 0.4
42
-
43
- STATE_SIZE: int = VIEWPORT_SIZE * VIEWPORT_SIZE
44
- ACTION_SIZE: int = 4
45
- MEMORY_SIZE: int = 10000
46
- BATCH_SIZE: int = 64
47
- GAMMA: float = 0.99
48
- LR: float = 5e-4
49
- EPSILON_DECAY: float = 0.995
50
-
51
- PORT: int = 7860
52
- MODEL_PATH: str = "dqn_model.pth"
53
- CHAT_PATH: str = "chat_data.json"
54
-
55
- CFG = Config()
56
 
57
  # ============================================================================
58
- # GAME ENGINE (Stateful, Safe Spawn, Deterministic)
59
  # ============================================================================
60
 
61
- class PlatformerEngine:
62
- def __init__(self, seed: Optional[int] = None):
63
  self.seed = seed or random.randint(0, 999999)
64
  self.reset()
65
-
66
- def reset(self) -> np.ndarray:
67
- self.player = [5.0, float(CFG.GROUND_Y)]
68
- self.vel_y = 0.0
69
- self.on_ground = True
70
  self.alive = True
71
  self.score = 0
72
- self.coins_collected = 0
73
- self.step_count = 0
74
-
75
  self.chunks: Dict[int, dict] = {}
76
- self.obstacles: List[dict] = []
77
  self.enemies: List[dict] = []
78
- self.coins: List[dict] = []
79
-
80
- self._update_chunks()
81
  return self.get_state()
82
-
83
- def _generate_chunk(self, chunk_id: int) -> dict:
84
- rng = random.Random((chunk_id * 1337 + self.seed) % 999999)
85
- base_x = chunk_id * CFG.CHUNK_SIZE
86
-
87
- obstacles, enemies, coins = [], [], []
88
- difficulty = max(1.0, abs(chunk_id) * 0.1)
89
-
90
- # SAFE ZONE: Skip obstacles for the first chunk near spawn
91
- is_safe = (base_x < CFG.SAFE_ZONE)
92
-
93
- if not is_safe:
94
- for _ in range(rng.randint(3, 6) + int(difficulty)):
95
- x = base_x + rng.randint(5, 25)
96
- h = rng.randint(1, 3 + int(difficulty * 0.5))
97
  w = rng.randint(1, 3)
98
- obstacles.append({'x': x, 'y': CFG.GROUND_Y - h, 'w': w, 'h': h, 'pit': False})
99
-
100
  for _ in range(rng.randint(1, 2)):
101
- x = base_x + rng.randint(10, 20)
102
- w = rng.randint(2, 4)
103
- obstacles.append({'x': x, 'y': CFG.GROUND_Y + 1, 'w': w, 'h': 1, 'pit': True})
104
-
105
  for _ in range(rng.randint(1, 2)):
106
- x = base_x + rng.randint(10, 20)
107
- enemies.append({
108
- 'x': x, 'y': CFG.GROUND_Y - 1,
109
  'type': rng.choice(['walker', 'jumper']),
110
  'dir': rng.choice([-1, 1]),
111
- 'speed': 0.3 + rng.random() * 0.3,
112
- 'range': rng.randint(3, 8),
113
- 'origin': x
114
  })
115
-
116
- for _ in range(rng.randint(5, 10) + int(difficulty)):
117
- x = base_x + rng.randint(2, 28)
118
- y = rng.randint(5, CFG.GROUND_Y - 2)
119
- coins.append({'x': x, 'y': y, 'collected': False})
120
-
121
- return {'obstacles': obstacles, 'enemies': enemies, 'coins': coins}
122
-
123
- def _update_chunks(self):
124
- current_chunk = int(self.player[0] // CFG.CHUNK_SIZE)
125
- for cid in range(current_chunk - 1, current_chunk + 3):
126
- if cid not in self.chunks:
127
- self.chunks[cid] = self._generate_chunk(cid)
128
-
129
- # Refresh active entities based on viewport
130
- view_l = self.player[0] - CFG.GRID_W / 2
131
- view_r = self.player[0] + CFG.GRID_W / 2
132
-
133
- self.obstacles = []
134
- self.enemies = []
135
- self.coins = []
136
-
137
- for cid in range(current_chunk - 1, current_chunk + 3):
138
- chunk = self.chunks.get(cid, {})
139
- for o in chunk.get('obstacles', []):
140
- if view_l <= o['x'] <= view_r:
141
- self.obstacles.append(o)
142
- for e in chunk.get('enemies', []):
143
- if view_l <= e['x'] <= view_r:
144
- self.enemies.append(e)
145
- for c in chunk.get('coins', []):
146
- if not c['collected'] and view_l <= c['x'] <= view_r:
147
- self.coins.append(c)
148
-
149
- def get_state(self) -> np.ndarray:
150
- """Returns normalized 40x40 grid centered on player."""
151
- size = CFG.VIEWPORT_SIZE
152
- state = np.zeros((size, size), dtype=np.float32)
153
- half = size // 2
154
- px, py = int(round(self.player[0])), int(round(self.player[1]))
155
-
156
- # Player always at center
157
- state[half, half] = 1.0
158
-
159
- for obs in self.obstacles:
160
- dx = int(round(obs['x'])) - px
161
- dy = int(round(obs['y'])) - py
162
- if abs(dx) < half and abs(dy) < half:
163
- val = -1.0 if obs.get('pit') else 0.8
164
- for w in range(obs.get('w', 1)):
165
- for h in range(obs.get('h', 1)):
166
- sx, sy = half + dx + w, half + dy + h
167
- if 0 <= sx < size and 0 <= sy < size:
168
- state[sy, sx] = val
169
-
170
- for enemy in self.enemies:
171
- dx = int(round(enemy['x'])) - px
172
- dy = int(round(enemy['y'])) - py
173
- if 0 <= half + dx < size and 0 <= half + dy < size:
174
- state[half + dy, half + dx] = 0.7
175
-
176
- for coin in self.coins:
177
- dx = int(round(coin['x'])) - px
178
- dy = int(round(coin['y'])) - py
179
- if 0 <= half + dx < size and 0 <= half + dy < size:
180
- state[half + dy, half + dx] = 0.3
181
-
182
- return state.flatten()
183
-
184
- def step(self, action: int) -> tuple[np.ndarray, float, bool, Optional[str]]:
185
  sound = None
186
-
187
- # Movement
188
- if action == 1: self.player[0] -= CFG.MOVE_SPEED
189
- elif action == 2: self.player[0] += CFG.MOVE_SPEED
190
- elif action == 3 and self.on_ground:
191
- self.vel_y = CFG.JUMP_POWER
192
- self.on_ground = False
 
 
193
  sound = 'jump'
194
-
195
- # Physics
196
- self.vel_y += CFG.GRAVITY
197
- self.player[1] += self.vel_y
198
-
199
- if self.player[1] >= CFG.GROUND_Y:
200
- self.player[1] = CFG.GROUND_Y
201
- self.vel_y = 0.0
202
- self.on_ground = True
203
-
204
- # Death checks
205
- if self.player[1] > CFG.GRID_H:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  self.alive = False
207
  return self.get_state(), -50.0, True, 'die'
208
-
 
 
 
 
 
 
209
  # Enemy collision
210
  for e in self.enemies:
211
- if abs(e['x'] - self.player[0]) < 0.8 and abs(e['y'] - self.player[1]) < 0.8:
212
  self.alive = False
213
  return self.get_state(), -50.0, True, 'die'
214
-
215
- # Obstacle collision
216
- px, py = self.player[0], self.player[1]
217
- for obs in self.obstacles:
218
- if obs.get('pit'):
219
- if obs['x'] <= px <= obs['x'] + obs['w'] and py >= obs['y']:
220
- self.alive = False
221
- return self.get_state(), -50.0, True, 'die'
222
- else:
223
- if obs['x'] <= px <= obs['x'] + obs['w'] - 0.1:
224
- if obs['y'] <= py <= obs['y'] + obs['h']:
225
- self.alive = False
226
- return self.get_state(), -50.0, True, 'die'
227
-
228
  # Coins
229
- collected = 0
230
- for coin in self.coins:
231
- if not coin['collected'] and abs(coin['x'] - px) < 1.0 and abs(coin['y'] - py) < 1.0:
232
- coin['collected'] = True
233
- collected += 1
234
- if collected:
235
- self.coins_collected += collected
236
- self.score += collected * 10
237
  sound = 'coin'
238
-
239
- # Update world
240
- self.score += 1
241
- self.step_count += 1
242
- self._update_chunks()
243
-
244
  # Update enemies
 
245
  for e in self.enemies:
246
  if e['type'] == 'walker':
247
- e['x'] += e['speed'] * e['dir']
248
- if abs(e['x'] - e['origin']) > e['range']:
249
- e['dir'] *= -1
250
-
251
- done = self.step_count > 3000
252
- reward = 1.0 + collected * 5.0
 
 
 
 
 
253
  return self.get_state(), reward, done, sound
254
-
255
- def get_world_data(self) -> dict:
256
  return {
257
- 'player': [round(self.player[0], 2), round(self.player[1], 2)],
258
- 'obstacles': self.obstacles,
259
  'entities': self.enemies,
260
- 'coins': [c for c in self.coins if not c['collected']],
261
- 'ground_level': CFG.GROUND_Y,
262
  'score': self.score,
263
- 'coins_collected': self.coins_collected,
264
  'alive': self.alive
265
  }
266
 
@@ -269,164 +235,109 @@ class PlatformerEngine:
269
  # DQN AGENT
270
  # ============================================================================
271
 
272
- class DQNetwork(nn.Module):
273
  def __init__(self):
274
  super().__init__()
275
  self.net = nn.Sequential(
276
- nn.Linear(CFG.STATE_SIZE, 256), nn.ReLU(),
277
  nn.Linear(256, 256), nn.ReLU(),
278
  nn.Linear(256, 128), nn.ReLU(),
279
- nn.Linear(128, CFG.ACTION_SIZE)
280
  )
281
-
282
- def forward(self, x):
283
- return self.net(x)
284
 
285
- class DQNAgent:
286
  def __init__(self):
287
- self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
288
- self.model = DQNetwork().to(self.device)
289
- self.target_model = DQNetwork().to(self.device)
290
- self.target_model.load_state_dict(self.model.state_dict())
291
-
292
- self.optimizer = optim.Adam(self.model.parameters(), lr=CFG.LR)
293
- self.criterion = nn.MSELoss()
294
- self.memory = deque(maxlen=CFG.MEMORY_SIZE)
295
-
296
- self.epsilon = 1.0
297
- self.epsilon_min = 0.01
298
- self.steps = 0
299
- self.best_score = 0
300
- self.training = False
301
-
302
- if os.path.exists(CFG.MODEL_PATH):
303
  try:
304
- self.model.load_state_dict(torch.load(CFG.MODEL_PATH, map_location=self.device))
305
- self.target_model.load_state_dict(self.model.state_dict())
306
  logger.info("✅ Model loaded")
307
- except Exception as e:
308
- logger.warning(f"⚠️ Failed to load model: {e}")
309
-
310
- def act(self, state: np.ndarray) -> int:
311
- if random.random() <= self.epsilon:
312
- return random.randrange(CFG.ACTION_SIZE)
313
  with torch.no_grad():
314
- t = torch.FloatTensor(state).unsqueeze(0).to(self.device)
315
- return torch.argmax(self.model(t)).item()
316
-
317
- def remember(self, s, a, r, ns, d):
318
- self.memory.append((s, a, r, ns, d))
319
-
320
  def replay(self):
321
- if len(self.memory) < CFG.BATCH_SIZE:
322
- return
323
-
324
- batch = random.sample(self.memory, CFG.BATCH_SIZE)
325
- states = torch.FloatTensor([b[0] for b in batch]).to(self.device)
326
- actions = torch.LongTensor([b[1] for b in batch]).to(self.device)
327
- rewards = torch.FloatTensor([b[2] for b in batch]).to(self.device)
328
- next_states = torch.FloatTensor([b[3] for b in batch]).to(self.device)
329
- dones = torch.FloatTensor([b[4] for b in batch]).to(self.device)
330
-
331
- q = self.model(states).gather(1, actions.unsqueeze(1)).squeeze()
332
- next_q = self.target_model(next_states).max(1)[0].detach()
333
- target = rewards + CFG.GAMMA * next_q * (1 - dones)
334
-
335
- loss = self.criterion(q, target)
336
- self.optimizer.zero_grad()
337
- loss.backward()
338
- self.optimizer.step()
339
-
340
- if self.epsilon > self.epsilon_min:
341
- self.epsilon *= CFG.EPSILON_DECAY
342
-
343
  self.steps += 1
344
- if self.steps % CFG.TARGET_UPDATE_FREQ == 0:
345
- self.target_model.load_state_dict(self.model.state_dict())
346
-
347
- def train_episode(self) -> float:
348
- env = PlatformerEngine()
349
- state = env.reset()
350
- total_reward = 0.0
351
- done = False
352
- steps = 0
353
-
354
- while not done and steps < 500:
355
- action = self.act(state)
356
- next_state, reward, done, _ = env.step(action)
357
- self.remember(state, action, reward, next_state, done)
358
- self.replay()
359
- state = next_state
360
- total_reward += reward
361
- steps += 1
362
-
363
- if total_reward > self.best_score:
364
- self.best_score = total_reward
365
- self.save()
366
-
367
- return total_reward
368
-
369
- def save(self):
370
- torch.save(self.model.state_dict(), CFG.MODEL_PATH)
371
 
372
 
373
  # ============================================================================
374
  # CHAT MEMORY
375
  # ============================================================================
376
 
377
- class ChatMemory:
378
  def __init__(self):
379
- self.data: Dict[str, str] = {}
380
- self.load()
381
-
382
- def load(self):
383
- if os.path.exists(CFG.CHAT_PATH):
384
  try:
385
- with open(CFG.CHAT_PATH, 'r', encoding='utf-8') as f:
386
- self.data = json.load(f)
387
  except: pass
388
-
389
  def save(self):
390
- with open(CFG.CHAT_PATH, 'w', encoding='utf-8') as f:
391
- json.dump(self.data, f, ensure_ascii=False, indent=2)
392
-
393
- def add(self, q: str, a: str) -> str:
394
- self.data[q.lower()] = a
395
- self.save()
396
- return f"✅ Добавлено: {q} → {a}"
397
-
398
- def find(self, q: str) -> Optional[str]:
399
  q = q.lower()
400
- if q in self.data:
401
- return self.data[q]
402
- words = q.split()
403
- best, best_score = None, 0
404
- for key, val in self.data.items():
405
- score = sum(1 for w in words if w in key)
406
- if score > best_score:
407
- best_score = score
408
- best = val
409
- return best if best_score >= len(words) * 0.4 else None
410
 
411
 
412
  # ============================================================================
413
  # GLOBAL STATE
414
  # ============================================================================
415
 
416
- agent = DQNAgent()
417
- chat_memory = ChatMemory()
418
- current_seed = random.randint(0, 999999)
419
-
420
- # Persistent game instances
421
- ai_env = PlatformerEngine(seed=current_seed)
422
- player_env = PlatformerEngine(seed=current_seed)
423
-
424
  is_training = False
425
- training_thread = None
426
 
427
 
428
  # ============================================================================
429
- # HTML TEMPLATE (Fixed Canvas Rendering)
430
  # ============================================================================
431
 
432
  HTML = """
@@ -438,217 +349,258 @@ HTML = """
438
  <title>🧠 AI Platformer</title>
439
  <style>
440
  *{margin:0;padding:0;box-sizing:border-box}
441
- body{background:#0a0a1a;color:#eee;font-family:'Segoe UI',sans-serif;display:flex;justify-content:center;padding:20px;min-height:100vh}
442
- .container{max-width:1100px;width:100%}
443
- h1{text-align:center;padding:15px 0;background:linear-gradient(135deg,#e94560,#0f3460);-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-size:2.2em}
444
- .sub{text-align:center;color:#666;margin-bottom:15px}
445
- .game-row{display:flex;gap:20px;flex-wrap:wrap}
446
- .game-box{flex:1;min-width:320px;background:#16213e;border-radius:16px;padding:15px;box-shadow:0 8px 32px rgba(0,0,0,.5)}
447
- .game-box h3{text-align:center;margin-bottom:10px}
448
- canvas{width:100%;aspect-ratio:4/1;background:#1a1a2e;border-radius:8px;display:block;image-rendering:pixelated}
449
- .controls{display:flex;justify-content:center;gap:12px;margin:15px 0;flex-wrap:wrap}
450
- .controls button{padding:12px 30px;font-size:1.1em;border:none;border-radius:10px;cursor:pointer;font-weight:bold;transition:all .15s;color:#fff}
451
- .controls button:hover{transform:scale(1.05)}
452
- .controls button:active{transform:scale(.93)}
453
- .btn-l,.btn-r{background:#e94560}
454
- .btn-j{background:#0f3460;padding:12px 45px}
455
- .btn-reset{background:#533483}
456
- .stats-bar{background:#16213e;border-radius:12px;padding:12px 20px;margin:10px 0;display:flex;justify-content:space-around;flex-wrap:wrap;gap:10px;font-size:1.1em}
457
- .stats-bar span{color:#e94560;font-weight:bold}
458
  .tabs{display:flex;gap:10px;margin:15px 0;flex-wrap:wrap}
459
- .tab{padding:10px 22px;background:#16213e;border-radius:10px;cursor:pointer;border:2px solid transparent;transition:all .3s}
460
- .tab:hover{border-color:#e94560}
461
- .tab.active{border-color:#e94560;background:#1a1a3e}
462
- .tab-content{background:#16213e;border-radius:12px;padding:20px;min-height:200px}
463
- .chat-area{display:flex;gap:10px;margin-top:10px}
464
- .chat-area input{flex:1;padding:10px;border-radius:8px;border:1px solid #333;background:#0a0a1a;color:#eee;font-size:1em}
465
- .chat-area button{padding:10px 25px;background:#e94560;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:bold}
466
- .chat-msgs{max-height:200px;overflow-y:auto;padding:5px}
467
- .chat-msgs div{padding:6px 12px;margin:3px 0;border-radius:6px;background:#0a0a1a}
468
- .chat-msgs .user{border-left:3px solid #e94560}
469
- .chat-msgs .bot{border-left:3px solid #0f3460}
470
  .hidden{display:none}
471
  </style>
472
  </head>
473
  <body>
474
- <div class="container">
475
  <h1>🧠 AI vs Player Platformer</h1>
476
  <p class="sub">�� Нейросеть слева 🎮 Ты справа (⬅️ ➡️ ⬆️)</p>
477
-
478
- <div class="game-row">
479
- <div class="game-box"><h3>🤖 Нейросеть</h3><canvas id="aiC"></canvas></div>
480
- <div class="game-box"><h3>🎮 Ты</h3><canvas id="plC"></canvas></div>
481
  </div>
482
-
483
- <div class="stats-bar">
484
- <div>🤖 ИИ: <span id="aiS">0</span></div>
485
- <div>🎮 Ты: <span id="plS">0</span></div>
486
  <div>🪙 Монет: <span id="cc">0</span></div>
487
- <div>🧠 ε: <span id="eps">1.00</span></div>
488
  <div>🏆 Рекорд: <span id="bs">0</span></div>
489
  </div>
490
-
491
- <div class="controls">
492
- <button class="btn-l" id="bL">️</button>
493
- <button class="btn-j" id="bJ">ПРЫЖОК</button>
494
- <button class="btn-r" id="bR">➡️</button>
495
- <button class="btn-reset" id="bReset">🔄 Новый уровень</button>
496
  </div>
497
-
498
  <div class="tabs">
499
  <div class="tab active" data-tab="chat">💬 Чат</div>
500
  <div class="tab" data-tab="train">🧠 Тренировка</div>
501
  <div class="tab" data-tab="stats">📊 Статистика</div>
502
  </div>
503
-
504
- <div class="tab-content">
505
  <div id="chatTab">
506
- <div class="chat-msgs" id="msgs">
507
- <div class="bot">🤖 Привет! Команды: /ai вопрос, /data вопрос|ответ, /stats, /train</div>
508
- </div>
509
- <div class="chat-area">
510
- <input id="ci" placeholder="Введите команду..." onkeydown="if(event.key==='Enter')sendChat()">
511
- <button onclick="sendChat()">➤</button>
512
- </div>
513
  </div>
514
  <div id="trainTab" class="hidden">
515
- <h3>🧠 Тренировка DQN</h3>
516
- <p>DQN (256→256→128 нейронов)</p>
517
- <button onclick="startTrain()" style="padding:12px 35px;background:#e94560;color:#fff;border:none;border-radius:10px;font-size:1.1em;cursor:pointer;margin-top:10px">🚀 Запустить</button>
518
- <div id="ts" style="margin-top:10px;color:#aaa">⏸ Остановлена</div>
519
  </div>
520
  <div id="statsTab" class="hidden"><h3>📊 Статистика</h3><div id="sc">Загрузка...</div></div>
521
  </div>
522
  </div>
523
-
524
  <script>
525
- // Fixed canvas resolution
526
- function initCanvas(id){
527
- const c=document.getElementById(id);
528
- c.width=800;c.height=200;
529
- return c.getContext('2d');
530
- }
531
- const aiCtx=initCanvas('aiC'), plCtx=initCanvas('plC');
532
-
533
- let playerAction=0;
534
 
535
- function draw(ctx,data,showPlayer){
536
- const W=ctx.canvas.width,H=ctx.canvas.height;
537
- const cellW=W/80,cellH=H/20;
538
  ctx.clearRect(0,0,W,H);
539
-
540
  // Sky gradient
541
- const g=ctx.createLinearGradient(0,0,0,H);
542
- g.addColorStop(0,'#0a0a2e');g.addColorStop(0.7,'#1a1a4e');
543
- ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
544
-
545
- // Camera offset - clamped so we don't see negative space at start
546
- const camX=Math.max(0,data.player[0]-40);
547
- function toS(wx,wy){return[(wx-camX)*cellW,wy*cellH]}
548
-
549
- // Ground
550
- const gy=data.ground_level*cellH;
551
- ctx.fillStyle='#4a3a2a';ctx.fillRect(0,gy,W,cellH*3);
552
- ctx.fillStyle='#3a2a1a';ctx.fillRect(0,gy+cellH*.5,W,cellH*.5);
553
-
554
- // Obstacles
555
- for(const o of data.obstacles){
 
 
 
 
 
 
 
 
 
 
 
556
  const[x,y]=toS(o.x,o.y);
557
- if(o.pit){ctx.fillStyle='#000';ctx.fillRect(x,y-cellH,o.w*cellW,cellH*2)}
558
- else{ctx.fillStyle='#8a7a6a';ctx.fillRect(x,y,o.w*cellW,o.h*cellH)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  }
560
-
561
- // Enemies
562
- for(const e of data.entities){
 
563
  const[x,y]=toS(e.x,e.y);
564
- ctx.fillStyle='#e94560';ctx.beginPath();
565
- ctx.arc(x+cellW/2,y+cellH/2,cellH/2.5,0,Math.PI*2);ctx.fill();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  }
567
-
568
- // Coins
569
- for(const c of data.coins){
570
  const[x,y]=toS(c.x,c.y);
571
- ctx.fillStyle='#ffd700';ctx.beginPath();
572
- ctx.arc(x+cellW/2,y+cellH/2,cellH/3,0,Math.PI*2);ctx.fill();
 
 
 
 
 
 
 
 
573
  }
574
-
575
  // Player
576
- if(showPlayer&&data.alive){
577
- const[px,py]=toS(data.player[0],data.player[1]);
578
- ctx.fillStyle='#00ff88';ctx.shadowColor='#00ff88';ctx.shadowBlur=15;
579
- ctx.fillRect(px+2,py+2,cellW-4,cellH-4);ctx.shadowBlur=0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  }
581
  }
582
 
583
  async function update(){
584
  try{
585
- const r=await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:playerAction})});
586
  const d=await r.json();
587
- draw(aiCtx,d.ai,true);
588
- draw(plCtx,d.player,d.player.alive);
589
- document.getElementById('aiS').textContent=d.ai.score;
590
- document.getElementById('plS').textContent=d.player.score;
591
  document.getElementById('cc').textContent=d.player.coins_collected;
592
- document.getElementById('eps').textContent=d.epsilon.toFixed(3);
593
  document.getElementById('bs').textContent=d.best_score;
594
  }catch(e){}
595
  }
596
 
597
- // Controls
598
- const setA=(v)=>{playerAction=v};
599
- document.getElementById('bL').onmousedown=()=>setA(1);document.getElementById('bL').onmouseup=()=>setA(0);
600
- document.getElementById('bR').onmousedown=()=>setA(2);document.getElementById('bR').onmouseup=()=>setA(0);
601
- document.getElementById('bJ').onmousedown=()=>setA(3);document.getElementById('bJ').onmouseup=()=>setA(0);
602
  document.addEventListener('keydown',e=>{
603
- if(e.key==='ArrowLeft'){e.preventDefault();setA(1)}
604
- else if(e.key==='ArrowRight'){e.preventDefault();setA(2)}
605
- else if(e.key==='ArrowUp'||e.key===' '){e.preventDefault();setA(3)}
606
- });
607
- document.addEventListener('keyup',e=>{
608
- if(['ArrowLeft','ArrowRight','ArrowUp',' '].includes(e.key)){e.preventDefault();setA(0)}
609
  });
 
610
 
611
  document.getElementById('bReset').onclick=async()=>{
612
- const r=await fetch('/reset',{method:'POST'});
613
- const d=await r.json();
614
- draw(aiCtx,d.ai,true);draw(plCtx,d.player,true);
615
- document.getElementById('aiS').textContent=d.ai.score;
616
- document.getElementById('plS').textContent=d.player.score;
617
  };
618
 
619
- // Chat
620
  async function sendChat(){
621
- const inp=document.getElementById('ci');
622
- const msg=inp.value.trim();if(!msg)return;inp.value='';
623
  const m=document.getElementById('msgs');
624
- m.innerHTML+=`<div class="user">👤 ${msg}</div>`;m.scrollTop=m.scrollHeight;
625
  const r=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})});
626
  const d=await r.json();
627
- m.innerHTML+=`<div class="bot">🤖 ${d.response}</div>`;m.scrollTop=m.scrollHeight;
628
  }
629
 
630
- // Training
631
  async function startTrain(){
632
  document.getElementById('ts').textContent='⏳ Запуск...';
633
- const r=await fetch('/train',{method:'POST'});
634
- const d=await r.json();
635
  document.getElementById('ts').textContent=d.message;
636
  }
637
 
638
- // Tabs
639
  document.querySelectorAll('.tab').forEach(t=>t.onclick=function(){
640
  document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
641
- this.classList.add('active');
642
- const n=this.dataset.tab;
643
- document.querySelectorAll('.tab-content>div').forEach(d=>d.classList.add('hidden'));
644
  document.getElementById(n+'Tab').classList.remove('hidden');
645
  if(n==='stats')fetch('/stats').then(r=>r.json()).then(d=>{
646
  document.getElementById('sc').innerHTML=`<p>🧠 Память: ${d.memory_size}</p><p>🎮 Шагов: ${d.steps}</p><p>📉 ε: ${d.epsilon}</p><p>🏆 Рекорд: ${d.best_score}</p><p>⚡ Тренируется: ${d.training?'✅':'❌'}</p>`;
647
  });
648
  });
649
 
650
- setInterval(update,100);
651
- update();
652
  </script>
653
  </body>
654
  </html>
@@ -661,109 +613,73 @@ update();
661
  app = Flask(__name__)
662
 
663
  @app.route('/')
664
- def index():
665
- return render_template_string(HTML)
666
 
667
  @app.route('/step', methods=['POST'])
668
  def step():
669
- global ai_env, player_env
670
-
671
  action = request.json.get('action', 0)
672
-
673
- # AI moves autonomously
674
  if ai_env.alive:
675
- ai_action = agent.act(ai_env.get_state())
676
- ai_env.step(ai_action)
677
  else:
678
  ai_env.reset()
679
-
680
- # Player moves based on input
681
- if player_env.alive:
682
- player_env.step(action)
683
  else:
684
- player_env.reset()
685
-
686
  return jsonify({
687
- 'ai': ai_env.get_world_data(),
688
- 'player': player_env.get_world_data(),
689
- 'epsilon': agent.epsilon,
690
- 'best_score': agent.best_score
691
  })
692
 
693
  @app.route('/reset', methods=['POST'])
694
  def reset():
695
- global current_seed, ai_env, player_env
696
- current_seed = random.randint(0, 999999)
697
- ai_env = PlatformerEngine(seed=current_seed)
698
- player_env = PlatformerEngine(seed=current_seed)
699
- return jsonify({
700
- 'ai': ai_env.get_world_data(),
701
- 'player': player_env.get_world_data()
702
- })
703
 
704
  @app.route('/chat', methods=['POST'])
705
- def chat():
706
  msg = request.json.get('message', '').strip()
707
-
708
  if msg.startswith('/ai '):
709
- ans = chat_memory.find(msg[4:])
710
- return jsonify({'response': ans or "🤖 Не знаю. Обучи через /data"})
711
  elif msg.startswith('/data '):
712
- parts = msg[6:].split('|')
713
- if len(parts) != 2:
714
- return jsonify({'response': "❌ Формат: /data вопрос|ответ"})
715
- return jsonify({'response': chat_memory.add(parts[0].strip(), parts[1].strip())})
716
  elif msg == '/stats':
717
- return jsonify({'response': f"📊 Память: {len(chat_memory.data)}, Шагов: {agent.steps}"})
718
  elif msg == '/train':
719
  return jsonify({'response': start_training()})
720
- else:
721
- return jsonify({'response': "🤖 Команды: /ai, /data, /stats, /train"})
722
 
723
  @app.route('/train', methods=['POST'])
724
- def train_route():
725
- return jsonify({'message': start_training()})
726
 
727
  @app.route('/stats')
728
  def stats():
729
  return jsonify({
730
- 'memory_size': len(chat_memory.data),
731
- 'steps': agent.steps,
732
- 'epsilon': round(agent.epsilon, 3),
733
- 'best_score': agent.best_score,
734
- 'training': is_training
735
  })
736
 
737
- def start_training() -> str:
738
- global is_training, training_thread
739
-
740
- if is_training:
741
- return "⏳ Уже тренируется!"
742
-
743
  is_training = True
744
-
745
- def _train():
746
  global is_training
747
  try:
748
  for ep in range(100):
749
- if not is_training:
750
- break
751
- score = agent.train_episode()
752
- if ep % 10 == 0:
753
- logger.info(f"Episode {ep}: score={score:.1f}, ε={agent.epsilon:.3f}")
754
- except Exception as e:
755
- logger.error(f"Training error: {e}")
756
- finally:
757
- is_training = False
758
-
759
- training_thread = threading.Thread(target=_train, daemon=True)
760
- training_thread.start()
761
  return "🚀 Тренировка запущена!"
762
 
763
-
764
- # ============================================================================
765
- # ENTRY POINT
766
- # ============================================================================
767
-
768
  if __name__ == '__main__':
769
- app.run(host='0.0.0.0', port=CFG.PORT, debug=False)
 
1
  """
2
+ AI PLATFORMER + CHATBOT (RICH GRAPHICS + FIXED PHYSICS)
3
+ AABB Collision, Detailed Rendering, Stateful Engine
4
  """
5
 
6
+ import os, json, random, threading, logging, time
 
 
 
 
 
7
  from collections import deque
8
  from dataclasses import dataclass
9
+ from typing import Dict, List, Optional
 
10
  import numpy as np
11
  import torch
12
  import torch.nn as nn
 
16
  logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
17
  logger = logging.getLogger(__name__)
18
 
 
 
 
 
19
  @dataclass
20
+ class Cfg:
21
+ W: int = 80; H: int = 20; GROUND: int = 17; CHUNK: int = 30
22
+ SAFE: int = 15; VIEW: int = 40
23
+ GRAV: float = 0.35; JUMP: float = -6.5; SPEED: float = 0.35
24
+ STATE: int = 40 * 40; ACTS: int = 4; MEM: int = 10000
25
+ BATCH: int = 64; GAMMA: float = 0.99; LR: float = 5e-4
26
+ EPS_DEC: float = 0.995; PORT: int = 7860
27
+ MODEL: str = "dqn_model.pth"; CHAT: str = "chat_data.json"
28
+
29
+ C = Cfg()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # ============================================================================
32
+ # GAME ENGINE: AABB PHYSICS + RICH WORLD
33
  # ============================================================================
34
 
35
+ class Engine:
36
+ def __init__(self, seed=None):
37
  self.seed = seed or random.randint(0, 999999)
38
  self.reset()
39
+
40
+ def reset(self):
41
+ self.px, self.py = 5.0, float(C.GROUND)
42
+ self.vx, self.vy = 0.0, 0.0
43
+ self.grounded = True
44
  self.alive = True
45
  self.score = 0
46
+ self.coins = 0
47
+ self.step_n = 0
 
48
  self.chunks: Dict[int, dict] = {}
49
+ self.obs: List[dict] = []
50
  self.enemies: List[dict] = []
51
+ self.coin_list: List[dict] = []
52
+ self._load_chunks()
 
53
  return self.get_state()
54
+
55
+ def _gen_chunk(self, cid: int) -> dict:
56
+ rng = random.Random((cid * 1337 + self.seed) % 999999)
57
+ bx = cid * C.CHUNK
58
+ obs, ens, cns = [], [], []
59
+ diff = max(1.0, abs(cid) * 0.1)
60
+ safe = bx < C.SAFE
61
+
62
+ if not safe:
63
+ for _ in range(rng.randint(3, 6) + int(diff)):
64
+ x = bx + rng.randint(5, 25)
65
+ h = rng.randint(1, 3 + int(diff * 0.5))
 
 
 
66
  w = rng.randint(1, 3)
67
+ obs.append({'x': x, 'y': C.GROUND - h, 'w': w, 'h': h, 'pit': False})
 
68
  for _ in range(rng.randint(1, 2)):
69
+ x = bx + rng.randint(10, 20)
70
+ obs.append({'x': x, 'y': C.GROUND + 1, 'w': rng.randint(2, 4), 'h': 1, 'pit': True})
 
 
71
  for _ in range(rng.randint(1, 2)):
72
+ x = bx + rng.randint(10, 20)
73
+ ens.append({
74
+ 'x': x, 'y': C.GROUND - 1,
75
  'type': rng.choice(['walker', 'jumper']),
76
  'dir': rng.choice([-1, 1]),
77
+ 'spd': 0.3 + rng.random() * 0.3,
78
+ 'rng': rng.randint(3, 8), 'ox': x
 
79
  })
80
+
81
+ for _ in range(rng.randint(5, 10) + int(diff)):
82
+ cns.append({
83
+ 'x': bx + rng.randint(2, 28),
84
+ 'y': rng.randint(5, C.GROUND - 2),
85
+ 'collected': False
86
+ })
87
+ return {'obs': obs, 'ens': ens, 'cns': cns}
88
+
89
+ def _load_chunks(self):
90
+ cc = int(self.px // C.CHUNK)
91
+ for i in range(cc - 1, cc + 3):
92
+ if i not in self.chunks:
93
+ self.chunks[i] = self._gen_chunk(i)
94
+
95
+ vl, vr = self.px - C.W / 2, self.px + C.W / 2
96
+ self.obs, self.enemies, self.coin_list = [], [], []
97
+ for i in range(cc - 1, cc + 3):
98
+ ch = self.chunks.get(i, {})
99
+ self.obs.extend([o for o in ch.get('obs', []) if vl <= o['x'] <= vr])
100
+ self.enemies.extend([e for e in ch.get('ens', []) if vl <= e['x'] <= vr])
101
+ self.coin_list.extend([c for c in ch.get('cns', []) if not c['collected'] and vl <= c['x'] <= vr])
102
+
103
+ def _aabb(self, ax, ay, aw, ah, bx, by, bw, bh):
104
+ return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by
105
+
106
+ def get_state(self):
107
+ s = np.zeros((C.VIEW, C.VIEW), dtype=np.float32)
108
+ h = C.VIEW // 2
109
+ px, py = int(round(self.px)), int(round(self.py))
110
+ s[h, h] = 1.0
111
+ for o in self.obs:
112
+ dx, dy = int(round(o['x'])) - px, int(round(o['y'])) - py
113
+ v = -1.0 if o.get('pit') else 0.8
114
+ for ww in range(o.get('w', 1)):
115
+ for hh in range(o.get('h', 1)):
116
+ sx, sy = h + dx + ww, h + dy + hh
117
+ if 0 <= sx < C.VIEW and 0 <= sy < C.VIEW:
118
+ s[sy, sx] = v
119
+ for e in self.enemies:
120
+ dx, dy = int(round(e['x'])) - px, int(round(e['y'])) - py
121
+ if 0 <= h + dx < C.VIEW and 0 <= h + dy < C.VIEW:
122
+ s[h + dy, h + dx] = 0.7
123
+ for c in self.coin_list:
124
+ dx, dy = int(round(c['x'])) - px, int(round(c['y'])) - py
125
+ if 0 <= h + dx < C.VIEW and 0 <= h + dy < C.VIEW:
126
+ s[h + dy, h + dx] = 0.3
127
+ return s.flatten()
128
+
129
+ def step(self, action: int):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  sound = None
131
+ PW, PH = 0.6, 0.9 # Player hitbox size
132
+
133
+ # Input
134
+ self.vx = 0.0
135
+ if action == 1: self.vx = -C.SPEED
136
+ elif action == 2: self.vx = C.SPEED
137
+ if action == 3 and self.grounded:
138
+ self.vy = C.JUMP
139
+ self.grounded = False
140
  sound = 'jump'
141
+
142
+ # === X AXIS MOVEMENT + COLLISION ===
143
+ self.px += self.vx
144
+ for o in self.obs:
145
+ if o.get('pit'): continue
146
+ if self._aabb(self.px, self.py, PW, PH, o['x'], o['y'], o['w'], o['h']):
147
+ if self.vx > 0:
148
+ self.px = o['x'] - PW
149
+ elif self.vx < 0:
150
+ self.px = o['x'] + o['w']
151
+ self.vx = 0
152
+
153
+ # === Y AXIS MOVEMENT + COLLISION ===
154
+ self.vy += C.GRAV
155
+ self.py += self.vy
156
+ self.grounded = False
157
+
158
+ # Ground collision
159
+ if self.py >= C.GROUND:
160
+ self.py = C.GROUND
161
+ self.vy = 0.0
162
+ self.grounded = True
163
+
164
+ # Platform collision (Y)
165
+ for o in self.obs:
166
+ if o.get('pit'): continue
167
+ if self._aabb(self.px, self.py, PW, PH, o['x'], o['y'], o['w'], o['h']):
168
+ if self.vy > 0: # Falling down onto platform
169
+ self.py = o['y'] - PH
170
+ self.vy = 0.0
171
+ self.grounded = True
172
+ elif self.vy < 0: # Jumping up into platform
173
+ self.py = o['y'] + o['h']
174
+ self.vy = 0.0
175
+
176
+ # Death: fell off world
177
+ if self.py > C.H + 2:
178
  self.alive = False
179
  return self.get_state(), -50.0, True, 'die'
180
+
181
+ # Pit death
182
+ for o in self.obs:
183
+ if o.get('pit') and o['x'] <= self.px + PW / 2 <= o['x'] + o['w'] and self.py >= C.GROUND:
184
+ self.alive = False
185
+ return self.get_state(), -50.0, True, 'die'
186
+
187
  # Enemy collision
188
  for e in self.enemies:
189
+ if self._aabb(self.px, self.py, PW, PH, e['x'] - 0.3, e['y'] - 0.3, 0.6, 0.6):
190
  self.alive = False
191
  return self.get_state(), -50.0, True, 'die'
192
+
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  # Coins
194
+ got = 0
195
+ for c in self.coin_list:
196
+ if not c['collected'] and self._aabb(self.px, self.py, PW, PH, c['x'] - 0.3, c['y'] - 0.3, 0.6, 0.6):
197
+ c['collected'] = True
198
+ got += 1
199
+ if got:
200
+ self.coins += got
201
+ self.score += got * 10
202
  sound = 'coin'
203
+
 
 
 
 
 
204
  # Update enemies
205
+ t = time.time()
206
  for e in self.enemies:
207
  if e['type'] == 'walker':
208
+ e['x'] += e['spd'] * e['dir']
209
+ if abs(e['x'] - e['ox']) > e['rng']: e['dir'] *= -1
210
+ else:
211
+ e['y'] = (C.GROUND - 1) + np.sin(t * e['spd'] * 3) * 0.5
212
+
213
+ self.score += 1
214
+ self.step_n += 1
215
+ self._load_chunks()
216
+
217
+ done = self.step_n > 3000
218
+ reward = 1.0 + got * 5.0
219
  return self.get_state(), reward, done, sound
220
+
221
+ def world_data(self):
222
  return {
223
+ 'player': [round(self.px, 2), round(self.py, 2)],
224
+ 'obstacles': self.obs,
225
  'entities': self.enemies,
226
+ 'coins': [c for c in self.coin_list if not c['collected']],
227
+ 'ground': C.GROUND,
228
  'score': self.score,
229
+ 'coins_collected': self.coins,
230
  'alive': self.alive
231
  }
232
 
 
235
  # DQN AGENT
236
  # ============================================================================
237
 
238
+ class Net(nn.Module):
239
  def __init__(self):
240
  super().__init__()
241
  self.net = nn.Sequential(
242
+ nn.Linear(C.STATE, 256), nn.ReLU(),
243
  nn.Linear(256, 256), nn.ReLU(),
244
  nn.Linear(256, 128), nn.ReLU(),
245
+ nn.Linear(128, C.ACTS)
246
  )
247
+ def forward(self, x): return self.net(x)
 
 
248
 
249
+ class Agent:
250
  def __init__(self):
251
+ self.dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
252
+ self.model = Net().to(self.dev)
253
+ self.target = Net().to(self.dev)
254
+ self.target.load_state_dict(self.model.state_dict())
255
+ self.opt = optim.Adam(self.model.parameters(), lr=C.LR)
256
+ self.crit = nn.MSELoss()
257
+ self.mem = deque(maxlen=C.MEM)
258
+ self.eps = 1.0; self.steps = 0; self.best = 0; self.training = False
259
+ if os.path.exists(C.MODEL):
 
 
 
 
 
 
 
260
  try:
261
+ self.model.load_state_dict(torch.load(C.MODEL, map_location=self.dev))
262
+ self.target.load_state_dict(self.model.state_dict())
263
  logger.info("✅ Model loaded")
264
+ except Exception as e: logger.warning(f"⚠️ Load failed: {e}")
265
+
266
+ def act(self, s):
267
+ if random.random() <= self.eps: return random.randrange(C.ACTS)
 
 
268
  with torch.no_grad():
269
+ return torch.argmax(self.model(torch.FloatTensor(s).unsqueeze(0).to(self.dev))).item()
270
+
271
+ def remember(self, s, a, r, ns, d): self.mem.append((s, a, r, ns, d))
272
+
 
 
273
  def replay(self):
274
+ if len(self.mem) < C.BATCH: return
275
+ b = random.sample(self.mem, C.BATCH)
276
+ st = torch.FloatTensor([x[0] for x in b]).to(self.dev)
277
+ ac = torch.LongTensor([x[1] for x in b]).to(self.dev)
278
+ rw = torch.FloatTensor([x[2] for x in b]).to(self.dev)
279
+ ns = torch.FloatTensor([x[3] for x in b]).to(self.dev)
280
+ dn = torch.FloatTensor([x[4] for x in b]).to(self.dev)
281
+ q = self.model(st).gather(1, ac.unsqueeze(1)).squeeze()
282
+ nq = self.target(ns).max(1)[0].detach()
283
+ tgt = rw + C.GAMMA * nq * (1 - dn)
284
+ loss = self.crit(q, tgt)
285
+ self.opt.zero_grad(); loss.backward(); self.opt.step()
286
+ if self.eps > 0.01: self.eps *= C.EPS_DEC
 
 
 
 
 
 
 
 
 
287
  self.steps += 1
288
+ if self.steps % 100 == 0: self.target.load_state_dict(self.model.state_dict())
289
+
290
+ def train_ep(self):
291
+ env = Engine(); s = env.reset(); tr = 0.0; d = False; n = 0
292
+ while not d and n < 500:
293
+ a = self.act(s); ns, r, d, _ = env.step(a)
294
+ self.remember(s, a, r, ns, d); self.replay()
295
+ s = ns; tr += r; n += 1
296
+ if tr > self.best: self.best = tr; self.save()
297
+ return tr
298
+
299
+ def save(self): torch.save(self.model.state_dict(), C.MODEL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
 
302
  # ============================================================================
303
  # CHAT MEMORY
304
  # ============================================================================
305
 
306
+ class ChatMem:
307
  def __init__(self):
308
+ self.data = {}
309
+ if os.path.exists(C.CHAT):
 
 
 
310
  try:
311
+ with open(C.CHAT, 'r', encoding='utf-8') as f: self.data = json.load(f)
 
312
  except: pass
 
313
  def save(self):
314
+ with open(C.CHAT, 'w', encoding='utf-8') as f: json.dump(self.data, f, ensure_ascii=False, indent=2)
315
+ def add(self, q, a):
316
+ self.data[q.lower()] = a; self.save(); return f"✅ {q} → {a}"
317
+ def find(self, q):
 
 
 
 
 
318
  q = q.lower()
319
+ if q in self.data: return self.data[q]
320
+ words = q.split(); best, bs = None, 0
321
+ for k, v in self.data.items():
322
+ sc = sum(1 for w in words if w in k)
323
+ if sc > bs: bs, best = sc, v
324
+ return best if bs >= len(words) * 0.4 else None
 
 
 
 
325
 
326
 
327
  # ============================================================================
328
  # GLOBAL STATE
329
  # ============================================================================
330
 
331
+ agent = Agent()
332
+ chat = ChatMem()
333
+ seed = random.randint(0, 999999)
334
+ ai_env = Engine(seed)
335
+ pl_env = Engine(seed)
 
 
 
336
  is_training = False
 
337
 
338
 
339
  # ============================================================================
340
+ # RICH GRAPHICS HTML
341
  # ============================================================================
342
 
343
  HTML = """
 
349
  <title>🧠 AI Platformer</title>
350
  <style>
351
  *{margin:0;padding:0;box-sizing:border-box}
352
+ body{background:#0d1117;color:#eee;font-family:'Segoe UI',sans-serif;display:flex;justify-content:center;padding:20px;min-height:100vh}
353
+ .wrap{max-width:1100px;width:100%}
354
+ h1{text-align:center;padding:15px 0;background:linear-gradient(135deg,#ff6b6b,#4ecdc4);-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-size:2.2em}
355
+ .sub{text-align:center;color:#888;margin-bottom:15px}
356
+ .row{display:flex;gap:20px;flex-wrap:wrap}
357
+ .box{flex:1;min-width:320px;background:#161b22;border-radius:16px;padding:15px;box-shadow:0 8px 32px rgba(0,0,0,.5);border:1px solid #30363d}
358
+ .box h3{text-align:center;margin-bottom:10px;color:#c9d1d9}
359
+ canvas{width:100%;aspect-ratio:4/1;border-radius:8px;display:block;image-rendering:pixelated;background:#0d1117}
360
+ .ctrl{display:flex;justify-content:center;gap:12px;margin:15px 0;flex-wrap:wrap}
361
+ .ctrl button{padding:12px 30px;font-size:1.1em;border:none;border-radius:10px;cursor:pointer;font-weight:bold;transition:all .15s;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}
362
+ .ctrl button:hover{transform:scale(1.05);filter:brightness(1.2)}
363
+ .ctrl button:active{transform:scale(.93)}
364
+ .bl,.br{background:linear-gradient(135deg,#ff6b6b,#ee5a24)}
365
+ .bj{background:linear-gradient(135deg,#4ecdc4,#2ecc71);padding:12px 45px}
366
+ .brs{background:linear-gradient(135deg,#a29bfe,#6c5ce7)}
367
+ .stats{background:#161b22;border-radius:12px;padding:12px 20px;margin:10px 0;display:flex;justify-content:space-around;flex-wrap:wrap;gap:10px;font-size:1.1em;border:1px solid #30363d}
368
+ .stats span{color:#ff6b6b;font-weight:bold}
369
  .tabs{display:flex;gap:10px;margin:15px 0;flex-wrap:wrap}
370
+ .tab{padding:10px 22px;background:#161b22;border-radius:10px;cursor:pointer;border:2px solid #30363d;transition:all .3s;color:#c9d1d9}
371
+ .tab:hover{border-color:#ff6b6b}
372
+ .tab.active{border-color:#ff6b6b;background:#1c2333}
373
+ .tc{background:#161b22;border-radius:12px;padding:20px;min-height:200px;border:1px solid #30363d}
374
+ .ca{display:flex;gap:10px;margin-top:10px}
375
+ .ca input{flex:1;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#eee;font-size:1em}
376
+ .ca button{padding:10px 25px;background:#ff6b6b;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:bold}
377
+ .cm{max-height:200px;overflow-y:auto;padding:5px}
378
+ .cm div{padding:6px 12px;margin:3px 0;border-radius:6px;background:#0d1117}
379
+ .cm .u{border-left:3px solid #ff6b6b}
380
+ .cm .b{border-left:3px solid #4ecdc4}
381
  .hidden{display:none}
382
  </style>
383
  </head>
384
  <body>
385
+ <div class="wrap">
386
  <h1>🧠 AI vs Player Platformer</h1>
387
  <p class="sub">�� Нейросеть слева 🎮 Ты справа (⬅️ ➡️ ⬆️)</p>
388
+ <div class="row">
389
+ <div class="box"><h3>🤖 Нейросеть</h3><canvas id="ac"></canvas></div>
390
+ <div class="box"><h3>🎮 Ты</h3><canvas id="pc"></canvas></div>
 
391
  </div>
392
+ <div class="stats">
393
+ <div>🤖 ИИ: <span id="as">0</span></div>
394
+ <div>🎮 Ты: <span id="ps">0</span></div>
 
395
  <div>🪙 Монет: <span id="cc">0</span></div>
396
+ <div>🧠 ε: <span id="ep">1.00</span></div>
397
  <div>🏆 Рекорд: <span id="bs">0</span></div>
398
  </div>
399
+ <div class="ctrl">
400
+ <button class="bl" id="bL">⬅️ Влево</button>
401
+ <button class="bj" id="bJ"> ПРЫЖОК</button>
402
+ <button class="br" id="bR">Вправо</button>
403
+ <button class="brs" id="bReset">🔄 Новый уровень</button>
 
404
  </div>
 
405
  <div class="tabs">
406
  <div class="tab active" data-tab="chat">💬 Чат</div>
407
  <div class="tab" data-tab="train">🧠 Тренировка</div>
408
  <div class="tab" data-tab="stats">📊 Статистика</div>
409
  </div>
410
+ <div class="tc">
 
411
  <div id="chatTab">
412
+ <div class="cm" id="msgs"><div class="b">🤖 Привет! Команды: /ai вопрос, /data вопрос|ответ, /stats, /train</div></div>
413
+ <div class="ca"><input id="ci" placeholder="Введите команду..." onkeydown="if(event.key==='Enter')sendChat()"><button onclick="sendChat()">➤</button></div>
 
 
 
 
 
414
  </div>
415
  <div id="trainTab" class="hidden">
416
+ <h3>🧠 Тренировка DQN</h3><p>DQN (256→256→128 нейронов)</p>
417
+ <button onclick="startTrain()" style="padding:12px 35px;background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;border:none;border-radius:10px;font-size:1.1em;cursor:pointer;margin-top:10px">🚀 Запустить</button>
418
+ <div id="ts" style="margin-top:10px;color:#888"> Остановлена</div>
 
419
  </div>
420
  <div id="statsTab" class="hidden"><h3>📊 Статистика</h3><div id="sc">Загрузка...</div></div>
421
  </div>
422
  </div>
 
423
  <script>
424
+ function initC(id){const c=document.getElementById(id);c.width=800;c.height=200;return c.getContext('2d')}
425
+ const aC=initC('ac'),pC=initC('pc');
426
+ let pA=0;
 
 
 
 
 
 
427
 
428
+ function draw(ctx,d,show){
429
+ const W=ctx.canvas.width,H=ctx.canvas.height,cW=W/80,cH=H/20;
 
430
  ctx.clearRect(0,0,W,H);
431
+
432
  // Sky gradient
433
+ const sg=ctx.createLinearGradient(0,0,0,H);
434
+ sg.addColorStop(0,'#0f0c29');sg.addColorStop(0.5,'#302b63');sg.addColorStop(1,'#24243e');
435
+ ctx.fillStyle=sg;ctx.fillRect(0,0,W,H);
436
+
437
+ // Stars
438
+ ctx.fillStyle='rgba(255,255,255,0.3)';
439
+ for(let i=0;i<30;i++){
440
+ const sx=(i*137+d.player[0]*0.1)%W,sy=(i*97)%((d.ground-2)*cH);
441
+ ctx.fillRect(sx,sy,2,2);
442
+ }
443
+
444
+ const cam=Math.max(0,d.player[0]-40);
445
+ function toS(wx,wy){return[(wx-cam)*cW,wy*cH]}
446
+
447
+ // Ground layers
448
+ const gy=d.ground*cH;
449
+ const gg=ctx.createLinearGradient(0,gy,0,H);
450
+ gg.addColorStop(0,'#4a7c59');gg.addColorStop(0.15,'#3d6b4e');gg.addColorStop(0.5,'#5c4033');gg.addColorStop(1,'#3e2723');
451
+ ctx.fillStyle=gg;ctx.fillRect(0,gy,W,H-gy);
452
+ // Grass top
453
+ ctx.fillStyle='#6abf69';ctx.fillRect(0,gy,W,cH*0.3);
454
+ ctx.fillStyle='#81c784';
455
+ for(let gx=0;gx<W;gx+=8){ctx.fillRect(gx,gy-cH*0.1,4,cH*0.15)}
456
+
457
+ // Obstacles with detail
458
+ for(const o of d.obstacles){
459
  const[x,y]=toS(o.x,o.y);
460
+ if(o.pit){
461
+ const pg=ctx.createLinearGradient(0,y-cH,0,y+cH);
462
+ pg.addColorStop(0,'#1a1a2e');pg.addColorStop(1,'#000');
463
+ ctx.fillStyle=pg;ctx.fillRect(x,y-cH,o.w*cW,cH*2);
464
+ ctx.fillStyle='#ff4444';ctx.fillRect(x,y-cH*0.5,o.w*cW,2);
465
+ }else{
466
+ // Brick pattern
467
+ const bg=ctx.createLinearGradient(x,y,x,y+o.h*cH);
468
+ bg.addColorStop(0,'#8d6e63');bg.addColorStop(1,'#6d4c41');
469
+ ctx.fillStyle=bg;ctx.fillRect(x,y,o.w*cW,o.h*cH);
470
+ // Brick lines
471
+ ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=1;
472
+ for(let by=0;by<o.h;by++){
473
+ const yy=y+by*cH;
474
+ ctx.beginPath();ctx.moveTo(x,yy);ctx.lineTo(x+o.w*cW,yy);ctx.stroke();
475
+ const off=(by%2)*cW*0.5;
476
+ for(let bx=off;bx<o.w*cW;bx+=cW){
477
+ ctx.beginPath();ctx.moveTo(x+bx,yy);ctx.lineTo(x+bx,yy+cH);ctx.stroke();
478
+ }
479
+ }
480
+ // Top highlight
481
+ ctx.fillStyle='rgba(255,255,255,0.15)';ctx.fillRect(x,y,o.w*cW,cH*0.15);
482
+ // Shadow
483
+ ctx.fillStyle='rgba(0,0,0,0.3)';ctx.fillRect(x+o.w*cW,y,3,o.h*cH);
484
+ }
485
  }
486
+
487
+ // Enemies with animation
488
+ const t=Date.now()/200;
489
+ for(const e of d.entities){
490
  const[x,y]=toS(e.x,e.y);
491
+ const bounce=Math.sin(t+e.x)*2;
492
+ ctx.save();ctx.translate(x+cW/2,y+cH/2+bounce);
493
+ // Body
494
+ const eg=ctx.createRadialGradient(0,0,2,0,0,cH/2);
495
+ eg.addColorStop(0,'#ff6b6b');eg.addColorStop(1,'#c0392b');
496
+ ctx.fillStyle=eg;ctx.beginPath();ctx.arc(0,0,cH/2.5,0,Math.PI*2);ctx.fill();
497
+ // Eyes
498
+ ctx.fillStyle='#fff';
499
+ ctx.beginPath();ctx.arc(-4,-3,3,0,Math.PI*2);ctx.arc(4,-3,3,0,Math.PI*2);ctx.fill();
500
+ ctx.fillStyle='#000';
501
+ const ex=e.dir*2;
502
+ ctx.beginPath();ctx.arc(-4+ex,-3,1.5,0,Math.PI*2);ctx.arc(4+ex,-3,1.5,0,Math.PI*2);ctx.fill();
503
+ // Glow
504
+ ctx.shadowColor='#ff6b6b';ctx.shadowBlur=10;
505
+ ctx.strokeStyle='#ff6b6b';ctx.lineWidth=1;ctx.beginPath();ctx.arc(0,0,cH/2.2,0,Math.PI*2);ctx.stroke();
506
+ ctx.restore();
507
  }
508
+
509
+ // Coins with sparkle
510
+ for(const c of d.coins){
511
  const[x,y]=toS(c.x,c.y);
512
+ const pulse=1+Math.sin(t*2+c.x)*0.15;
513
+ ctx.save();ctx.translate(x+cW/2,y+cH/2);ctx.scale(pulse,pulse);
514
+ const cg=ctx.createRadialGradient(-2,-2,1,0,0,cH/3);
515
+ cg.addColorStop(0,'#fff9c4');cg.addColorStop(0.5,'#ffd700');cg.addColorStop(1,'#f9a825');
516
+ ctx.fillStyle=cg;ctx.beginPath();ctx.arc(0,0,cH/3,0,Math.PI*2);ctx.fill();
517
+ ctx.shadowColor='#ffd700';ctx.shadowBlur=12;
518
+ ctx.strokeStyle='#ffeb3b';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(0,0,cH/3,0,Math.PI*2);ctx.stroke();
519
+ // Shine
520
+ ctx.fillStyle='rgba(255,255,255,0.8)';ctx.beginPath();ctx.arc(-3,-3,2,0,Math.PI*2);ctx.fill();
521
+ ctx.restore();
522
  }
523
+
524
  // Player
525
+ if(show&&d.alive){
526
+ const[px,py]=toS(d.player[0],d.player[1]);
527
+ ctx.save();
528
+ // Glow
529
+ ctx.shadowColor='#00ff88';ctx.shadowBlur=20;
530
+ // Body gradient
531
+ const pg=ctx.createLinearGradient(px,py,px+cW,py+cH);
532
+ pg.addColorStop(0,'#00ff88');pg.addColorStop(1,'#00b894');
533
+ ctx.fillStyle=pg;
534
+ ctx.fillRect(px+2,py+2,cW-4,cH-4);
535
+ // Face
536
+ ctx.shadowBlur=0;
537
+ ctx.fillStyle='#fff';
538
+ ctx.fillRect(px+cW*0.2,py+cH*0.25,cW*0.2,cH*0.2);
539
+ ctx.fillRect(px+cW*0.6,py+cH*0.25,cW*0.2,cH*0.2);
540
+ ctx.fillStyle='#0d1117';
541
+ ctx.fillRect(px+cW*0.25,py+cH*0.3,cW*0.1,cH*0.1);
542
+ ctx.fillRect(px+cW*0.65,py+cH*0.3,cW*0.1,cH*0.1);
543
+ ctx.restore();
544
  }
545
  }
546
 
547
  async function update(){
548
  try{
549
+ const r=await fetch('/step',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:pA})});
550
  const d=await r.json();
551
+ draw(aC,d.ai,true);draw(pC,d.player,d.player.alive);
552
+ document.getElementById('as').textContent=d.ai.score;
553
+ document.getElementById('ps').textContent=d.player.score;
 
554
  document.getElementById('cc').textContent=d.player.coins_collected;
555
+ document.getElementById('ep').textContent=d.epsilon.toFixed(3);
556
  document.getElementById('bs').textContent=d.best_score;
557
  }catch(e){}
558
  }
559
 
560
+ const sA=v=>{pA=v};
561
+ document.getElementById('bL').onmousedown=()=>sA(1);document.getElementById('bL').onmouseup=()=>sA(0);
562
+ document.getElementById('bR').onmousedown=()=>sA(2);document.getElementById('bR').onmouseup=()=>sA(0);
563
+ document.getElementById('bJ').onmousedown=()=>sA(3);document.getElementById('bJ').onmouseup=()=>sA(0);
 
564
  document.addEventListener('keydown',e=>{
565
+ if(e.key==='ArrowLeft'){e.preventDefault();sA(1)}
566
+ else if(e.key==='ArrowRight'){e.preventDefault();sA(2)}
567
+ else if(e.key==='ArrowUp'||e.key===' '){e.preventDefault();sA(3)}
 
 
 
568
  });
569
+ document.addEventListener('keyup',e=>{if(['ArrowLeft','ArrowRight','ArrowUp',' '].includes(e.key)){e.preventDefault();sA(0)}});
570
 
571
  document.getElementById('bReset').onclick=async()=>{
572
+ const r=await fetch('/reset',{method:'POST'});const d=await r.json();
573
+ draw(aC,d.ai,true);draw(pC,d.player,true);
574
+ document.getElementById('as').textContent=d.ai.score;
575
+ document.getElementById('ps').textContent=d.player.score;
 
576
  };
577
 
 
578
  async function sendChat(){
579
+ const inp=document.getElementById('ci');const msg=inp.value.trim();if(!msg)return;inp.value='';
 
580
  const m=document.getElementById('msgs');
581
+ m.innerHTML+=`<div class="u">👤 ${msg}</div>`;m.scrollTop=m.scrollHeight;
582
  const r=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})});
583
  const d=await r.json();
584
+ m.innerHTML+=`<div class="b">🤖 ${d.response}</div>`;m.scrollTop=m.scrollHeight;
585
  }
586
 
 
587
  async function startTrain(){
588
  document.getElementById('ts').textContent='⏳ Запуск...';
589
+ const r=await fetch('/train',{method:'POST'});const d=await r.json();
 
590
  document.getElementById('ts').textContent=d.message;
591
  }
592
 
 
593
  document.querySelectorAll('.tab').forEach(t=>t.onclick=function(){
594
  document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
595
+ this.classList.add('active');const n=this.dataset.tab;
596
+ document.querySelectorAll('.tc>div').forEach(d=>d.classList.add('hidden'));
 
597
  document.getElementById(n+'Tab').classList.remove('hidden');
598
  if(n==='stats')fetch('/stats').then(r=>r.json()).then(d=>{
599
  document.getElementById('sc').innerHTML=`<p>🧠 Память: ${d.memory_size}</p><p>🎮 Шагов: ${d.steps}</p><p>📉 ε: ${d.epsilon}</p><p>🏆 Рекорд: ${d.best_score}</p><p>⚡ Тренируется: ${d.training?'✅':'❌'}</p>`;
600
  });
601
  });
602
 
603
+ setInterval(update,100);update();
 
604
  </script>
605
  </body>
606
  </html>
 
613
  app = Flask(__name__)
614
 
615
  @app.route('/')
616
+ def index(): return render_template_string(HTML)
 
617
 
618
  @app.route('/step', methods=['POST'])
619
  def step():
620
+ global ai_env, pl_env
 
621
  action = request.json.get('action', 0)
 
 
622
  if ai_env.alive:
623
+ ai_env.step(agent.act(ai_env.get_state()))
 
624
  else:
625
  ai_env.reset()
626
+ if pl_env.alive:
627
+ pl_env.step(action)
 
 
628
  else:
629
+ pl_env.reset()
 
630
  return jsonify({
631
+ 'ai': ai_env.world_data(), 'player': pl_env.world_data(),
632
+ 'epsilon': agent.eps, 'best_score': agent.best
 
 
633
  })
634
 
635
  @app.route('/reset', methods=['POST'])
636
  def reset():
637
+ global seed, ai_env, pl_env
638
+ seed = random.randint(0, 999999)
639
+ ai_env = Engine(seed); pl_env = Engine(seed)
640
+ return jsonify({'ai': ai_env.world_data(), 'player': pl_env.world_data()})
 
 
 
 
641
 
642
  @app.route('/chat', methods=['POST'])
643
+ def chat_route():
644
  msg = request.json.get('message', '').strip()
 
645
  if msg.startswith('/ai '):
646
+ a = chat.find(msg[4:])
647
+ return jsonify({'response': a or "🤖 Не знаю. Обучи через /data"})
648
  elif msg.startswith('/data '):
649
+ p = msg[6:].split('|')
650
+ if len(p) != 2: return jsonify({'response': "❌ Формат: /data вопрос|ответ"})
651
+ return jsonify({'response': chat.add(p[0].strip(), p[1].strip())})
 
652
  elif msg == '/stats':
653
+ return jsonify({'response': f"📊 Память: {len(chat.data)}, Шагов: {agent.steps}"})
654
  elif msg == '/train':
655
  return jsonify({'response': start_training()})
656
+ return jsonify({'response': "🤖 Команды: /ai, /data, /stats, /train"})
 
657
 
658
  @app.route('/train', methods=['POST'])
659
+ def train_route(): return jsonify({'message': start_training()})
 
660
 
661
  @app.route('/stats')
662
  def stats():
663
  return jsonify({
664
+ 'memory_size': len(chat.data), 'steps': agent.steps,
665
+ 'epsilon': round(agent.eps, 3), 'best_score': agent.best, 'training': is_training
 
 
 
666
  })
667
 
668
+ def start_training():
669
+ global is_training
670
+ if is_training: return "⏳ Уже тренируется!"
 
 
 
671
  is_training = True
672
+ def _t():
 
673
  global is_training
674
  try:
675
  for ep in range(100):
676
+ if not is_training: break
677
+ sc = agent.train_ep()
678
+ if ep % 10 == 0: logger.info(f"Ep {ep}: score={sc:.1f}, ε={agent.eps:.3f}")
679
+ except Exception as e: logger.error(f"Train error: {e}")
680
+ finally: is_training = False
681
+ threading.Thread(target=_t, daemon=True).start()
 
 
 
 
 
 
682
  return "🚀 Тренировка запущена!"
683
 
 
 
 
 
 
684
  if __name__ == '__main__':
685
+ app.run(host='0.0.0.0', port=C.PORT, debug=False)