X commited on
Commit
1a68d5e
·
verified ·
1 Parent(s): bbf6dfc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +578 -742
app.py CHANGED
@@ -1,112 +1,353 @@
1
  """
2
- 🧠 AI PLATFORMER + CHATBOT
3
- Hugging Face Space (Flask + HTML)
4
  """
5
- from flask import Flask, render_template_string, request, jsonify
6
- import numpy as np
7
- import random
8
- import json
9
  import os
10
- import time
 
11
  import threading
12
- import math
 
13
  from collections import deque
 
 
 
 
14
  import torch
15
  import torch.nn as nn
16
  import torch.optim as optim
 
17
 
18
- app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # =====================================================
21
- # 1. НЕЙРОСЕТЬ (DQN)
22
- # =====================================================
 
23
 
24
  class DQNetwork(nn.Module):
25
- def __init__(self, input_size=1600, output_size=4):
26
  super().__init__()
27
  self.net = nn.Sequential(
28
- nn.Linear(input_size, 256),
29
- nn.ReLU(),
30
- nn.Linear(256, 256),
31
- nn.ReLU(),
32
- nn.Linear(256, 256),
33
- nn.ReLU(),
34
- nn.Linear(256, 128),
35
- nn.ReLU(),
36
- nn.Linear(128, output_size)
37
  )
38
 
39
  def forward(self, x):
40
  return self.net(x)
41
 
42
  class DQNAgent:
43
- def __init__(self, state_size=1600, action_size=4):
44
- self.state_size = state_size
45
- self.action_size = action_size
46
- self.memory = deque(maxlen=10000)
 
 
 
 
 
 
47
  self.epsilon = 1.0
48
  self.epsilon_min = 0.01
49
- self.epsilon_decay = 0.995
50
- self.learning_rate = 0.0005
51
- self.gamma = 0.99
52
- self.batch_size = 64
53
- self.total_steps = 0
54
  self.best_score = 0
55
  self.training = False
56
- self.update_target_every = 100
57
 
58
- self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
59
- self.model = DQNetwork(state_size, action_size).to(self.device)
60
- self.target_model = DQNetwork(state_size, action_size).to(self.device)
61
- self.target_model.load_state_dict(self.model.state_dict())
62
- self.optimizer = optim.Adam(self.model.parameters(), lr=self.learning_rate)
63
- self.criterion = nn.MSELoss()
64
-
65
- if os.path.exists('dqn_model.pth'):
66
- self.load_model()
67
-
68
- def act(self, state):
69
- if np.random.rand() <= self.epsilon:
70
- return random.randrange(self.action_size)
71
  with torch.no_grad():
72
- state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
73
- q_values = self.model(state_tensor)
74
- return torch.argmax(q_values).item()
75
 
76
- def remember(self, state, action, reward, next_state, done):
77
- self.memory.append((state, action, reward, next_state, done))
78
 
79
  def replay(self):
80
- if len(self.memory) < self.batch_size:
81
  return
82
 
83
- batch = random.sample(self.memory, self.batch_size)
84
  states = torch.FloatTensor([b[0] for b in batch]).to(self.device)
85
  actions = torch.LongTensor([b[1] for b in batch]).to(self.device)
86
  rewards = torch.FloatTensor([b[2] for b in batch]).to(self.device)
87
  next_states = torch.FloatTensor([b[3] for b in batch]).to(self.device)
88
  dones = torch.FloatTensor([b[4] for b in batch]).to(self.device)
89
 
90
- current_q = self.model(states).gather(1, actions.unsqueeze(1)).squeeze()
91
  next_q = self.target_model(next_states).max(1)[0].detach()
92
- target_q = rewards + self.gamma * next_q * (1 - dones)
93
 
94
- loss = self.criterion(current_q, target_q)
95
  self.optimizer.zero_grad()
96
  loss.backward()
97
  self.optimizer.step()
98
 
99
  if self.epsilon > self.epsilon_min:
100
- self.epsilon *= self.epsilon_decay
101
 
102
- self.total_steps += 1
103
- if self.total_steps % self.update_target_every == 0:
104
  self.target_model.load_state_dict(self.model.state_dict())
105
 
106
- def train_episode(self):
107
- env = PlatformerLogic()
108
  state = env.reset()
109
- total_reward = 0
110
  done = False
111
  steps = 0
112
 
@@ -121,691 +362,303 @@ class DQNAgent:
121
 
122
  if total_reward > self.best_score:
123
  self.best_score = total_reward
 
124
 
125
- return total_reward, steps
126
-
127
- def save_model(self):
128
- torch.save(self.model.state_dict(), 'dqn_model.pth')
129
 
130
- def load_model(self):
131
- self.model.load_state_dict(torch.load('dqn_model.pth', map_location=self.device))
132
- self.target_model.load_state_dict(self.model.state_dict())
133
 
134
- # =====================================================
135
- # 2. ПЛАТФОРМЕР
136
- # =====================================================
137
 
138
- class PlatformerLogic:
139
- def __init__(self, seed=None):
140
- self.width = 80
141
- self.height = 20
142
- self.seed = seed or random.randint(0, 999999)
143
- self.reset()
144
-
145
- def reset(self):
146
- random.seed(self.seed)
147
-
148
- self.player = [5, 15]
149
- self.score = 0
150
- self.distance = 0
151
- self.speed = 1.0
152
- self.difficulty = 1.0
153
- self.ground_level = 17
154
- self.chunks = {}
155
- self.entities = []
156
- self.coins = []
157
- self.obstacles = []
158
- self.velocity_y = 0
159
- self.gravity = 0.4
160
- self.jump_power = -7
161
- self.on_ground = False
162
- self.alive = True
163
- self.steps = 0
164
- self.coins_collected = 0
165
-
166
- self.generate_chunk(0)
167
- self.generate_chunk(1)
168
-
169
- random.seed()
170
- return self.get_state()
171
-
172
- def generate_chunk(self, chunk_x):
173
- if chunk_x in self.chunks:
174
- return
175
-
176
- seed = (chunk_x * 1337 + self.seed) % 999999
177
- random.seed(seed)
178
-
179
- obstacles = []
180
- enemies = []
181
- coins = []
182
- base_x = chunk_x * 30
183
-
184
- for _ in range(random.randint(3, 6) + int(self.difficulty)):
185
- x = base_x + random.randint(5, 25)
186
- height = random.randint(1, 3 + int(self.difficulty * 0.5))
187
- width = random.randint(1, 3)
188
- obstacles.append({
189
- 'x': x, 'y': self.ground_level - height,
190
- 'width': width, 'height': height
191
- })
192
-
193
- for _ in range(random.randint(1, 2)):
194
- x = base_x + random.randint(10, 20)
195
- width = random.randint(2, 4)
196
- obstacles.append({
197
- 'x': x, 'y': self.ground_level + 1,
198
- 'width': width, 'height': 1, 'is_pit': True
199
- })
200
-
201
- for _ in range(random.randint(1, 3) + int(self.difficulty * 0.5)):
202
- x = base_x + random.randint(5, 25)
203
- y = self.ground_level - 1
204
- enemy_type = random.choice(['walker', 'jumper'])
205
- enemies.append({
206
- 'x': x, 'y': y, 'type': enemy_type,
207
- 'direction': 1 if random.random() > 0.5 else -1,
208
- 'speed': 0.3 + random.random() * 0.3,
209
- 'range': random.randint(3, 8),
210
- 'start_x': x
211
- })
212
-
213
- for _ in range(random.randint(5, 10) + int(self.difficulty)):
214
- x = base_x + random.randint(2, 28)
215
- y = random.randint(5, self.ground_level - 2)
216
- coins.append({'x': x, 'y': y, 'collected': False})
217
-
218
- self.chunks[chunk_x] = {
219
- 'obstacles': obstacles,
220
- 'enemies': enemies,
221
- 'coins': coins
222
- }
223
- random.seed()
224
-
225
- def get_state(self):
226
- state_size = 40
227
- state = np.zeros((state_size, state_size), dtype=np.float32)
228
- half = state_size // 2
229
- px, py = int(self.player[0]), int(self.player[1])
230
-
231
- state[half, half] = 1.0
232
-
233
- for obs in self.obstacles:
234
- dx = obs['x'] - px
235
- dy = obs['y'] - py
236
- if abs(dx) < half and abs(dy) < half:
237
- for w in range(obs['width']):
238
- for h in range(obs['height']):
239
- sx = half + dx + w
240
- sy = half + dy + h
241
- if 0 <= sx < state_size and 0 <= sy < state_size:
242
- state[sy, sx] = -1.0
243
-
244
- for enemy in self.entities:
245
- dx = enemy['x'] - px
246
- dy = enemy['y'] - py
247
- if abs(dx) < half and abs(dy) < half:
248
- sx = half + dx
249
- sy = half + dy
250
- if 0 <= sx < state_size and 0 <= sy < state_size:
251
- state[sy, sx] = 0.7
252
-
253
- for coin in self.coins:
254
- if not coin['collected']:
255
- dx = coin['x'] - px
256
- dy = coin['y'] - py
257
- if abs(dx) < half and abs(dy) < half:
258
- sx = half + dx
259
- sy = half + dy
260
- if 0 <= sx < state_size and 0 <= sy < state_size:
261
- state[sy, sx] = 0.3
262
-
263
- return state.flatten()
264
-
265
- def update_entities(self):
266
- for enemy in self.entities[:]:
267
- if enemy['type'] == 'walker':
268
- enemy['x'] += enemy['speed'] * enemy['direction']
269
- if abs(enemy['x'] - enemy['start_x']) > enemy['range']:
270
- enemy['direction'] *= -1
271
- elif enemy['type'] == 'jumper':
272
- enemy['y'] += math.sin(time.time() * enemy['speed'] * 2) * 0.3
273
- if enemy['y'] > self.ground_level - 1:
274
- enemy['y'] = self.ground_level - 1
275
-
276
- for enemy in self.entities:
277
- if abs(enemy['x'] - self.player[0]) < 0.8 and abs(enemy['y'] - self.player[1]) < 0.8:
278
- return True
279
- return False
280
-
281
- def collect_coins(self):
282
- collected = 0
283
- for coin in self.coins:
284
- if not coin['collected']:
285
- if abs(coin['x'] - self.player[0]) < 1.0 and abs(coin['y'] - self.player[1]) < 1.0:
286
- coin['collected'] = True
287
- collected += 1
288
- return collected
289
-
290
- def check_obstacles(self):
291
- px, py = self.player[0], self.player[1]
292
- for obs in self.obstacles:
293
- if obs.get('is_pit', False):
294
- if obs['x'] <= px <= obs['x'] + obs['width'] and py >= obs['y']:
295
- return True
296
- else:
297
- if obs['x'] <= px <= obs['x'] + obs['width'] - 1:
298
- if obs['y'] <= py <= obs['y'] + obs['height']:
299
- return True
300
- return False
301
-
302
- def step(self, action):
303
- sound = None
304
-
305
- if action == 1:
306
- self.player[0] -= 0.4
307
- elif action == 2:
308
- self.player[0] += 0.4
309
-
310
- if action == 3 and self.on_ground:
311
- self.velocity_y = self.jump_power
312
- self.on_ground = False
313
- sound = 'jump'
314
-
315
- self.velocity_y += self.gravity
316
- self.player[1] += self.velocity_y
317
-
318
- if self.player[1] >= self.ground_level:
319
- self.player[1] = self.ground_level
320
- self.velocity_y = 0
321
- self.on_ground = True
322
-
323
- if self.player[1] > self.height:
324
- self.alive = False
325
- sound = 'die'
326
- return self.get_state(), -50, True, sound
327
-
328
- died = self.update_entities()
329
- if died:
330
- self.alive = False
331
- sound = 'die'
332
- return self.get_state(), -50, True, sound
333
-
334
- coins_collected = self.collect_coins()
335
- if coins_collected > 0:
336
- self.coins_collected += coins_collected
337
- sound = 'coin'
338
-
339
- if self.check_obstacles():
340
- self.alive = False
341
- sound = 'die'
342
- return self.get_state(), -50, True, sound
343
-
344
- self.distance += 0.1 * self.speed
345
- self.score += coins_collected * 10 + 1
346
-
347
- self.difficulty = 1 + self.distance / 1000
348
- self.speed = 1 + self.difficulty * 0.1
349
-
350
- current_chunk = int(self.player[0] // 30)
351
- for i in range(current_chunk, current_chunk + 3):
352
- self.generate_chunk(i)
353
- if i in self.chunks:
354
- chunk = self.chunks[i]
355
- for obs in chunk['obstacles']:
356
- if abs(obs['x'] - self.player[0]) < self.width:
357
- if obs not in self.obstacles:
358
- self.obstacles.append(obs)
359
- for enemy in chunk['enemies']:
360
- if abs(enemy['x'] - self.player[0]) < self.width:
361
- if enemy not in self.entities:
362
- self.entities.append(enemy)
363
- for coin in chunk['coins']:
364
- if abs(coin['x'] - self.player[0]) < self.width:
365
- if coin not in self.coins:
366
- self.coins.append(coin)
367
-
368
- self.obstacles = [o for o in self.obstacles if abs(o['x'] - self.player[0]) < self.width]
369
- self.entities = [e for e in self.entities if abs(e['x'] - self.player[0]) < self.width]
370
- self.coins = [c for c in self.coins if abs(c['x'] - self.player[0]) < self.width]
371
-
372
- self.steps += 1
373
- if self.steps > 3000:
374
- return self.get_state(), self.score, True, sound
375
-
376
- reward = 1 + coins_collected * 5
377
- return self.get_state(), reward, False, sound
378
-
379
- def get_world_data(self):
380
- return {
381
- 'player': self.player,
382
- 'obstacles': self.obstacles,
383
- 'entities': self.entities,
384
- 'coins': [c for c in self.coins if not c['collected']],
385
- 'ground_level': self.ground_level,
386
- 'score': self.score,
387
- 'coins_collected': self.coins_collected,
388
- 'alive': self.alive
389
- }
390
-
391
- # =====================================================
392
- # 3. ЧАТ-ПАМЯТЬ
393
- # =====================================================
394
 
395
  class ChatMemory:
396
  def __init__(self):
397
- self.data = {}
398
- self.load_data()
399
-
400
- def load_data(self):
401
- if os.path.exists('chat_data.json'):
402
- with open('chat_data.json', 'r', encoding='utf-8') as f:
403
- self.data = json.load(f)
404
-
405
- def save_data(self):
406
- with open('chat_data.json', 'w', encoding='utf-8') as f:
 
 
407
  json.dump(self.data, f, ensure_ascii=False, indent=2)
408
 
409
- def add(self, q, a):
410
  self.data[q.lower()] = a
411
- self.save_data()
412
- return f"✅ Добавлено: {q} -> {a}"
413
 
414
- def find(self, q):
415
  q = q.lower()
416
  if q in self.data:
417
  return self.data[q]
418
  words = q.split()
419
- best = None
420
- best_score = 0
421
  for key, val in self.data.items():
422
  score = sum(1 for w in words if w in key)
423
  if score > best_score:
424
  best_score = score
425
  best = val
426
- if best_score >= len(words) * 0.4:
427
- return best
428
- return None
429
 
430
- # =====================================================
431
- # 4. ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ
432
- # =====================================================
433
 
434
  agent = DQNAgent()
435
  chat_memory = ChatMemory()
436
- is_training = False
437
  current_seed = random.randint(0, 999999)
 
 
 
 
 
 
438
  training_thread = None
439
 
440
- # =====================================================
441
- # 5. HTML
442
- # =====================================================
 
443
 
444
  HTML = """
445
  <!DOCTYPE html>
446
- <html>
447
  <head>
448
- <meta charset="UTF-8">
449
- <title>🧠 AI Platformer</title>
450
- <style>
451
- * { margin: 0; padding: 0; box-sizing: border-box; }
452
- body {
453
- background: #0a0a1a;
454
- color: #eee;
455
- font-family: 'Segoe UI', sans-serif;
456
- display: flex;
457
- justify-content: center;
458
- padding: 20px;
459
- min-height: 100vh;
460
- }
461
- .container { max-width: 1100px; width: 100%; }
462
- h1 {
463
- text-align: center;
464
- padding: 15px 0;
465
- background: linear-gradient(135deg, #e94560, #0f3460);
466
- -webkit-background-clip: text;
467
- -webkit-text-fill-color: transparent;
468
- font-size: 2.2em;
469
- }
470
- .sub { text-align: center; color: #666; margin-bottom: 15px; }
471
- .game-row { display: flex; gap: 20px; flex-wrap: wrap; }
472
- .game-box {
473
- flex: 1; min-width: 300px;
474
- background: #16213e;
475
- border-radius: 16px;
476
- padding: 15px;
477
- box-shadow: 0 8px 32px rgba(0,0,0,0.5);
478
- }
479
- .game-box h3 { text-align: center; margin-bottom: 10px; }
480
- canvas {
481
- width: 100%; aspect-ratio: 80/20;
482
- background: #1a1a2e;
483
- border-radius: 8px;
484
- display: block;
485
- image-rendering: pixelated;
486
- }
487
- .controls {
488
- display: flex; justify-content: center;
489
- gap: 12px; margin: 15px 0; flex-wrap: wrap;
490
- }
491
- .controls button {
492
- padding: 12px 30px; font-size: 1.1em;
493
- border: none; border-radius: 10px;
494
- cursor: pointer; font-weight: bold;
495
- transition: all 0.15s; color: white;
496
- }
497
- .controls button:hover { transform: scale(1.05); }
498
- .controls button:active { transform: scale(0.93); }
499
- .btn-left { background: #e94560; }
500
- .btn-right { background: #e94560; }
501
- .btn-jump { background: #0f3460; padding: 12px 45px; }
502
- .btn-reset { background: #533483; }
503
- .stats-bar {
504
- background: #16213e;
505
- border-radius: 12px;
506
- padding: 12px 20px;
507
- margin: 10px 0;
508
- display: flex;
509
- justify-content: space-around;
510
- flex-wrap: wrap;
511
- gap: 10px;
512
- font-size: 1.1em;
513
- }
514
- .stats-bar span { color: #e94560; font-weight: bold; }
515
- .tabs {
516
- display: flex; gap: 10px;
517
- margin: 15px 0; flex-wrap: wrap;
518
- }
519
- .tab {
520
- padding: 10px 22px;
521
- background: #16213e;
522
- border-radius: 10px;
523
- cursor: pointer;
524
- border: 2px solid transparent;
525
- transition: all 0.3s;
526
- }
527
- .tab:hover { border-color: #e94560; }
528
- .tab.active { border-color: #e94560; background: #1a1a3e; }
529
- .tab-content {
530
- background: #16213e;
531
- border-radius: 12px;
532
- padding: 20px;
533
- min-height: 200px;
534
- }
535
- .chat-area {
536
- display: flex; gap: 10px; margin-top: 10px;
537
- }
538
- .chat-area input {
539
- flex: 1; padding: 10px;
540
- border-radius: 8px;
541
- border: 1px solid #333;
542
- background: #0a0a1a;
543
- color: #eee;
544
- font-size: 1em;
545
- }
546
- .chat-area button {
547
- padding: 10px 25px;
548
- background: #e94560;
549
- color: white;
550
- border: none;
551
- border-radius: 8px;
552
- cursor: pointer;
553
- font-weight: bold;
554
- }
555
- .chat-msgs {
556
- max-height: 200px;
557
- overflow-y: auto;
558
- padding: 5px;
559
- }
560
- .chat-msgs div {
561
- padding: 6px 12px;
562
- margin: 3px 0;
563
- border-radius: 6px;
564
- background: #0a0a1a;
565
- }
566
- .chat-msgs .user { border-left: 3px solid #e94560; }
567
- .chat-msgs .bot { border-left: 3px solid #0f3460; }
568
- .hidden { display: none; }
569
- </style>
570
  </head>
571
  <body>
572
  <div class="container">
573
- <h1>🧠 AI vs Player Platformer</h1>
574
- <p class="sub">🤖 Нейросеть слева 🎮 Ты справа (⬅️ ➡️ ⬆️)</p>
575
-
576
- <div class="game-row">
577
- <div class="game-box">
578
- <h3>🤖 Нейросеть</h3>
579
- <canvas id="aiCanvas" width="800" height="200"></canvas>
580
- </div>
581
- <div class="game-box">
582
- <h3>🎮 Ты</h3>
583
- <canvas id="playerCanvas" width="800" height="200"></canvas>
584
- </div>
585
- </div>
586
-
587
- <div class="stats-bar">
588
- <div>🤖 ИИ: <span id="aiScore">0</span></div>
589
- <div>🎮 Ты: <span id="playerScore">0</span></div>
590
- <div>🪙 Монет: <span id="coinsCount">0</span></div>
591
- <div>🧠 Эпсилон: <span id="epsilon">1.00</span></div>
592
- <div>🏆 Рекорд: <span id="bestScore">0</span></div>
593
- </div>
594
-
595
- <div class="controls">
596
- <button class="btn-left" id="btnLeft">⬅️ Влево</button>
597
- <button class="btn-jump" id="btnJump">⬆️ Прыжок</button>
598
- <button class="btn-right" id="btnRight">➡️ Вправо</button>
599
- <button class="btn-reset" id="btnReset">🔄 Новый уровень</button>
600
- </div>
601
-
602
- <div class="tabs">
603
- <div class="tab active" data-tab="chat">💬 Чат</div>
604
- <div class="tab" data-tab="train">🧠 Тренировка</div>
605
- <div class="tab" data-tab="stats">📊 Статистика</div>
606
- </div>
607
-
608
- <div class="tab-content">
609
- <div id="chatTab">
610
- <div class="chat-msgs" id="chatMsgs">
611
- <div class="bot">🤖 Привет! Команды:</div>
612
- <div class="bot"><code>/ai вопрос</code> — спросить</div>
613
- <div class="bot"><code>/data вопрос|ответ</code> — обучить</div>
614
- <div class="bot"><code>/stats</code> статистика</div>
615
- <div class="bot"><code>/train</code> тренировка</div>
616
- </div>
617
- <div class="chat-area">
618
- <input id="chatInput" placeholder="Введите команду..." onkeydown="if(event.key==='Enter') sendChat()">
619
- <button onclick="sendChat()">➤</button>
620
- </div>
621
- </div>
622
- <div id="trainTab" class="hidden">
623
- <h3>🧠 Тренировка нейросети</h3>
624
- <p>DQN с 4 слоями (256→256→256→128 нейронов)</p>
625
- <button onclick="startTrain()" style="padding:12px 35px;background:#e94560;color:white;border:none;border-radius:10px;font-size:1.1em;cursor:pointer;">
626
- 🚀 Запустить
627
- </button>
628
- <div id="trainStatus" style="margin-top:10px;color:#aaa;">⏸ Остановлена</div>
629
- </div>
630
- <div id="statsTab" class="hidden">
631
- <h3>📊 Статистика</h3>
632
- <div id="statsContent">Загрузка...</div>
633
- </div>
634
- </div>
635
  </div>
636
 
637
  <script>
638
- let playerAction = 0;
639
-
640
- function drawGame(ctx, data, showPlayer) {
641
- const W = ctx.canvas.width, H = ctx.canvas.height;
642
- const cellW = W / 80, cellH = H / 20;
643
- ctx.clearRect(0, 0, W, H);
644
-
645
- const grad = ctx.createLinearGradient(0, 0, 0, H);
646
- grad.addColorStop(0, '#0a0a2e');
647
- grad.addColorStop(0.7, '#1a1a4e');
648
- ctx.fillStyle = grad;
649
- ctx.fillRect(0, 0, W, H);
650
-
651
- const cx = data.player[0] - 40;
652
- function toScreen(wx, wy) {
653
- return [(wx - cx) * cellW, wy * cellH];
654
- }
655
-
656
- const gy = data.ground_level * cellH;
657
- ctx.fillStyle = '#4a3a2a';
658
- ctx.fillRect(0, gy, W, cellH * 2);
659
- ctx.fillStyle = '#3a2a1a';
660
- ctx.fillRect(0, gy + cellH * 0.5, W, cellH * 0.5);
661
-
662
- for (const obs of data.obstacles) {
663
- const [x, y] = toScreen(obs.x, obs.y);
664
- if (obs.is_pit) {
665
- ctx.fillStyle = '#000';
666
- ctx.fillRect(x, y - cellH, obs.width * cellW, cellH * 2);
667
- } else {
668
- ctx.fillStyle = '#8a7a6a';
669
- ctx.fillRect(x, y, obs.width * cellW, obs.height * cellH);
670
- }
 
671
  }
672
 
673
- for (const enemy of data.entities) {
674
- const [x, y] = toScreen(enemy.x, enemy.y);
675
- ctx.fillStyle = '#e94560';
676
- ctx.beginPath();
677
- ctx.arc(x + cellW/2, y + cellH/2, cellH/2.5, 0, Math.PI*2);
678
- ctx.fill();
679
  }
680
 
681
- for (const coin of data.coins) {
682
- const [x, y] = toScreen(coin.x, coin.y);
683
- ctx.fillStyle = '#ffd700';
684
- ctx.beginPath();
685
- ctx.arc(x + cellW/2, y + cellH/2, cellH/3, 0, Math.PI*2);
686
- ctx.fill();
687
  }
688
 
689
- if (showPlayer && data.alive) {
690
- const [px, py] = toScreen(data.player[0], data.player[1]);
691
- ctx.fillStyle = '#00ff88';
692
- ctx.shadowColor = '#00ff88';
693
- ctx.shadowBlur = 15;
694
- ctx.fillRect(px + 2, py + 2, cellW - 4, cellH - 4);
695
- ctx.shadowBlur = 0;
696
  }
697
  }
698
 
699
- async function updateGame() {
700
- try {
701
- const res = await fetch('/step', {
702
- method: 'POST',
703
- headers: {'Content-Type': 'application/json'},
704
- body: JSON.stringify({action: playerAction})
705
- });
706
- const data = await res.json();
707
-
708
- const aiCanvas = document.getElementById('aiCanvas');
709
- const playerCanvas = document.getElementById('playerCanvas');
710
- drawGame(aiCanvas.getContext('2d'), data.ai, true);
711
- drawGame(playerCanvas.getContext('2d'), data.player, data.player.alive);
712
-
713
- document.getElementById('aiScore').textContent = data.ai.score;
714
- document.getElementById('playerScore').textContent = data.player.score;
715
- document.getElementById('coinsCount').textContent = data.player.coins_collected;
716
- document.getElementById('epsilon').textContent = data.epsilon.toFixed(3);
717
- document.getElementById('bestScore').textContent = data.best_score;
718
- } catch(e) {}
719
  }
720
 
721
- // Управление
722
- document.getElementById('btnLeft').addEventListener('mousedown', () => { playerAction = 1; });
723
- document.getElementById('btnLeft').addEventListener('mouseup', () => { playerAction = 0; });
724
- document.getElementById('btnRight').addEventListener('mousedown', () => { playerAction = 2; });
725
- document.getElementById('btnRight').addEventListener('mouseup', () => { playerAction = 0; });
726
- document.getElementById('btnJump').addEventListener('mousedown', () => { playerAction = 3; });
727
- document.getElementById('btnJump').addEventListener('mouseup', () => { playerAction = 0; });
728
-
729
- document.addEventListener('keydown', (e) => {
730
- if (e.key === 'ArrowLeft') { e.preventDefault(); playerAction = 1; }
731
- else if (e.key === 'ArrowRight') { e.preventDefault(); playerAction = 2; }
732
- else if (e.key === 'ArrowUp' || e.key === ' ') { e.preventDefault(); playerAction = 3; }
733
- });
734
- document.addEventListener('keyup', (e) => {
735
- if (['ArrowLeft', 'ArrowRight', 'ArrowUp', ' '].includes(e.key)) {
736
- e.preventDefault(); playerAction = 0;
737
- }
738
  });
739
-
740
- document.getElementById('btnReset').addEventListener('click', async () => {
741
- const res = await fetch('/reset', {method: 'POST'});
742
- const data = await res.json();
743
- document.getElementById('aiScore').textContent = data.ai.score;
744
- document.getElementById('playerScore').textContent = data.player.score;
745
- document.getElementById('coinsCount').textContent = data.player.coins_collected;
746
  });
747
 
748
- // Чат
749
- async function sendChat() {
750
- const input = document.getElementById('chatInput');
751
- const msg = input.value.trim();
752
- if (!msg) return;
753
- input.value = '';
754
-
755
- const container = document.getElementById('chatMsgs');
756
- container.innerHTML += '<div class="user">👤 ' + msg + '</div>';
757
- container.scrollTop = container.scrollHeight;
758
-
759
- const res = await fetch('/chat', {
760
- method: 'POST',
761
- headers: {'Content-Type': 'application/json'},
762
- body: JSON.stringify({message: msg})
763
- });
764
- const data = await res.json();
765
- container.innerHTML += '<div class="bot">🤖 ' + data.response + '</div>';
766
- container.scrollTop = container.scrollHeight;
767
  }
768
 
769
- // Тренировка
770
- async function startTrain() {
771
- document.getElementById('trainStatus').textContent = '⏳ Запуск...';
772
- const res = await fetch('/train', {method: 'POST'});
773
- const data = await res.json();
774
- document.getElementById('trainStatus').textContent = data.message;
775
  }
776
 
777
- // Табы
778
- document.querySelectorAll('.tab').forEach(tab => {
779
- tab.addEventListener('click', function() {
780
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
781
- this.classList.add('active');
782
- const tabName = this.dataset.tab;
783
- document.querySelectorAll('.tab-content > div').forEach(d => d.classList.add('hidden'));
784
- document.getElementById(tabName + 'Tab').classList.remove('hidden');
785
- if (tabName === 'stats') {
786
- fetch('/stats').then(r => r.json()).then(data => {
787
- document.getElementById('statsContent').innerHTML = `
788
- <p>🧠 Память: ${data.memory_size} пар</p>
789
- <p>🎮 ��агов: ${data.steps}</p>
790
- <p>📉 Эпсилон: ${data.epsilon}</p>
791
- <p>🏆 Рекорд: ${data.best_score}</p>
792
- <p>⚡ Тренируется: ${data.training ? '✅' : '❌'}</p>
793
- `;
794
- });
795
- }
796
  });
797
  });
798
 
799
- setInterval(updateGame, 200);
800
- updateGame();
801
  </script>
802
  </body>
803
  </html>
804
  """
805
 
806
- # =====================================================
807
- # 6. FLASK ROUTES
808
- # =====================================================
 
 
809
 
810
  @app.route('/')
811
  def index():
@@ -813,27 +666,22 @@ def index():
813
 
814
  @app.route('/step', methods=['POST'])
815
  def step():
816
- global current_seed, agent
817
-
818
- data = request.json
819
- player_action = data.get('action', 0)
820
 
821
- ai_env = PlatformerLogic(seed=current_seed)
822
- player_env = PlatformerLogic(seed=current_seed)
823
 
824
- ai_env.reset()
825
- player_env.reset()
 
 
 
 
826
 
827
- for _ in range(5):
828
- if ai_env.alive:
829
- ai_action = agent.act(ai_env.get_state())
830
- ai_env.step(ai_action)
831
-
832
- if player_env.alive:
833
- player_env.step(player_action)
834
-
835
- if not ai_env.alive and not player_env.alive:
836
- break
837
 
838
  return jsonify({
839
  'ai': ai_env.get_world_data(),
@@ -844,14 +692,10 @@ def step():
844
 
845
  @app.route('/reset', methods=['POST'])
846
  def reset():
847
- global current_seed
848
  current_seed = random.randint(0, 999999)
849
-
850
- ai_env = PlatformerLogic(seed=current_seed)
851
- player_env = PlatformerLogic(seed=current_seed)
852
- ai_env.reset()
853
- player_env.reset()
854
-
855
  return jsonify({
856
  'ai': ai_env.get_world_data(),
857
  'player': player_env.get_world_data()
@@ -859,26 +703,20 @@ def reset():
859
 
860
  @app.route('/chat', methods=['POST'])
861
  def chat():
862
- data = request.json
863
- message = data.get('message', '').strip()
864
-
865
- if message.startswith('/ai '):
866
- q = message[4:].strip()
867
- answer = chat_memory.find(q)
868
- return jsonify({'response': answer or "🤖 Не знаю. Обучи через /data"})
869
 
870
- elif message.startswith('/data '):
871
- parts = message[6:].split('|')
 
 
 
872
  if len(parts) != 2:
873
- return jsonify({'response': "❌ Используй: /data вопрос|ответ"})
874
  return jsonify({'response': chat_memory.add(parts[0].strip(), parts[1].strip())})
875
-
876
- elif message == '/stats':
877
- return jsonify({'response': f"📊 Память: {len(chat_memory.data)} пар, Шагов: {agent.total_steps}"})
878
-
879
- elif message == '/train':
880
  return jsonify({'response': start_training()})
881
-
882
  else:
883
  return jsonify({'response': "🤖 Команды: /ai, /data, /stats, /train"})
884
 
@@ -886,48 +724,46 @@ def chat():
886
  def train_route():
887
  return jsonify({'message': start_training()})
888
 
889
- @app.route('/stats', methods=['GET'])
890
- def stats_route():
891
- global is_training, agent, chat_memory
892
  return jsonify({
893
  'memory_size': len(chat_memory.data),
894
- 'steps': agent.total_steps,
895
  'epsilon': round(agent.epsilon, 3),
896
  'best_score': agent.best_score,
897
  'training': is_training
898
  })
899
 
900
- def start_training():
901
- global is_training, training_thread, agent
902
 
903
  if is_training:
904
  return "⏳ Уже тренируется!"
905
 
906
  is_training = True
907
 
908
- def train():
909
- global is_training, agent
910
  try:
911
  for ep in range(100):
912
  if not is_training:
913
  break
914
- agent.train_episode()
915
  if ep % 10 == 0:
916
- print(f"📊 Episode {ep}: Epsilon={agent.epsilon:.3f}")
917
- if ep % 20 == 0:
918
- agent.save_model()
919
  except Exception as e:
920
- print(f" Ошибка: {e}")
921
  finally:
922
  is_training = False
923
 
924
- training_thread = threading.Thread(target=train)
925
  training_thread.start()
926
  return "🚀 Тренировка запущена!"
927
 
928
- # =====================================================
929
- # 7. ЗАПУСК
930
- # =====================================================
 
931
 
932
  if __name__ == '__main__':
933
- app.run(host='0.0.0.0', port=7860)
 
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
19
  import torch.optim as optim
20
+ from flask import Flask, jsonify, request, render_template_string
21
 
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
 
267
+
268
+ # ============================================================================
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
 
 
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 = """
433
  <!DOCTYPE html>
434
+ <html lang="ru">
435
  <head>
436
+ <meta charset="UTF-8">
437
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
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>
655
  """
656
 
657
+ # ============================================================================
658
+ # FLASK ROUTES
659
+ # ============================================================================
660
+
661
+ app = Flask(__name__)
662
 
663
  @app.route('/')
664
  def index():
 
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(),
 
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()
 
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
 
 
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)