gabraken commited on
Commit
68097bf
·
1 Parent(s): f187b8b
backend/game/commands.py CHANGED
@@ -26,6 +26,7 @@ class ActionType(str, Enum):
26
  QUERY = "query"
27
  QUERY_UNITS = "query_units" # zone and/or unit_type → returns unit_ids
28
  ASSIGN_TO_GROUP = "assign_to_group" # group_index (1-3), unit_ids (from query_units)
 
29
  RESIGN = "resign" # abandon the game, opponent wins
30
 
31
 
 
26
  QUERY = "query"
27
  QUERY_UNITS = "query_units" # zone and/or unit_type → returns unit_ids
28
  ASSIGN_TO_GROUP = "assign_to_group" # group_index (1-3), unit_ids (from query_units)
29
+ DEFEND = "defend" # patrol around a base/zone to defend it
30
  RESIGN = "resign" # abandon the game, opponent wins
31
 
32
 
backend/game/engine.py CHANGED
@@ -122,6 +122,7 @@ class GameEngine:
122
  self._tick_mining(player)
123
 
124
  self._tick_movement_and_combat()
 
125
  self._tick_healing()
126
  self._remove_dead()
127
 
@@ -719,6 +720,80 @@ class GameEngine:
719
  ally.target_x = attacker.x
720
  ally.target_y = attacker.y
721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  def _tick_healing(self) -> None:
723
  """Medics heal the most-injured adjacent infantry unit."""
724
  for player in self.state.players.values():
@@ -1191,6 +1266,8 @@ class GameEngine:
1191
  return self._cmd_query_units(player, action)
1192
  if t == ActionType.ASSIGN_TO_GROUP:
1193
  return self._cmd_assign_to_group(player, action)
 
 
1194
  if t == ActionType.RESIGN:
1195
  return self._cmd_resign(player)
1196
  return ActionResult(
@@ -1604,6 +1681,48 @@ class GameEngine:
1604
  data={"gi": gi, "n": len(valid_ids)},
1605
  )
1606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607
  def _cmd_resign(self, player: PlayerState) -> ActionResult:
1608
  """Player forfeits — opponent is declared winner immediately."""
1609
  opponent_id = next(
 
122
  self._tick_mining(player)
123
 
124
  self._tick_movement_and_combat()
125
+ self._apply_crowd_pressure()
126
  self._tick_healing()
127
  self._remove_dead()
128
 
 
720
  ally.target_x = attacker.x
721
  ally.target_y = attacker.y
722
 
723
+ def _apply_crowd_pressure(self) -> None:
724
+ """Nudge ground units that are not firing when allied moving units press behind them.
725
+
726
+ For each non-firing idle/moving ground unit, we sum the pressure vectors of
727
+ nearby allies that are actively advancing toward it (within a ~3-unit cone).
728
+ A small fraction of the resulting vector is applied as a nudge, provided the
729
+ destination is collision-free.
730
+ """
731
+ PUSH_RADIUS = UNIT_RADIUS * 5 # ~2.5 tile look-ahead for pushers
732
+ NUDGE = UNIT_RADIUS * 0.35 # ≈0.175 tiles per tick (soft shove)
733
+ MIN_DOT = 0.45 # cos ~63° — pusher must face the blockee
734
+
735
+ for player in self.state.players.values():
736
+ for unit in player.units.values():
737
+ defn = UNIT_DEFS[unit.unit_type]
738
+ # Only nudge ground units that are not shooting and not doing special work
739
+ if defn.is_flying:
740
+ continue
741
+ if unit.attack_target_id or unit.attack_target_building_id:
742
+ continue
743
+ if unit.is_sieged:
744
+ continue
745
+ if unit.status in (
746
+ UnitStatus.MINING_MINERALS,
747
+ UnitStatus.MINING_GAS,
748
+ UnitStatus.BUILDING,
749
+ UnitStatus.MOVING_TO_BUILD,
750
+ ):
751
+ continue
752
+
753
+ fx, fy = 0.0, 0.0
754
+ for pusher in player.units.values():
755
+ if pusher.id == unit.id:
756
+ continue
757
+ if pusher.status not in (UnitStatus.MOVING, UnitStatus.ATTACKING, UnitStatus.PATROLLING):
758
+ continue
759
+ if pusher.target_x is None or pusher.target_y is None:
760
+ continue
761
+
762
+ dx_rel = unit.x - pusher.x
763
+ dy_rel = unit.y - pusher.y
764
+ d = math.hypot(dx_rel, dy_rel)
765
+ if d > PUSH_RADIUS or d < 1e-6:
766
+ continue
767
+
768
+ # Direction the pusher wants to go
769
+ dx_m = pusher.target_x - pusher.x
770
+ dy_m = pusher.target_y - pusher.y
771
+ dm = math.hypot(dx_m, dy_m)
772
+ if dm < 1e-6:
773
+ continue
774
+ nx_m, ny_m = dx_m / dm, dy_m / dm
775
+
776
+ # The blockee must be roughly in the pusher's forward cone
777
+ dot = (dx_rel / d) * nx_m + (dy_rel / d) * ny_m
778
+ if dot < MIN_DOT:
779
+ continue
780
+
781
+ # Pressure strength: stronger when closer and more aligned
782
+ strength = dot * (1.0 - d / PUSH_RADIUS)
783
+ fx += nx_m * strength
784
+ fy += ny_m * strength
785
+
786
+ fmag = math.hypot(fx, fy)
787
+ if fmag < 1e-6:
788
+ continue
789
+
790
+ new_x = unit.x + (fx / fmag) * NUDGE
791
+ new_y = unit.y + (fy / fmag) * NUDGE
792
+
793
+ if not self._would_overlap(unit, new_x, new_y):
794
+ unit.x = new_x
795
+ unit.y = new_y
796
+
797
  def _tick_healing(self) -> None:
798
  """Medics heal the most-injured adjacent infantry unit."""
799
  for player in self.state.players.values():
 
1266
  return self._cmd_query_units(player, action)
1267
  if t == ActionType.ASSIGN_TO_GROUP:
1268
  return self._cmd_assign_to_group(player, action)
1269
+ if t == ActionType.DEFEND:
1270
+ return self._cmd_defend(player, action)
1271
  if t == ActionType.RESIGN:
1272
  return self._cmd_resign(player)
1273
  return ActionResult(
 
1681
  data={"gi": gi, "n": len(valid_ids)},
1682
  )
1683
 
1684
+ def _cmd_defend(self, player: PlayerState, action: GameAction) -> ActionResult:
1685
+ """Send available military units to patrol around a base zone.
1686
+
1687
+ Units are spread evenly on a circle around the zone center and bounce
1688
+ between two diametrically-opposite perimeter points so they continuously
1689
+ sweep the area and auto-attack any enemy that enters their weapon range.
1690
+ """
1691
+ units = self._resolve_selector(
1692
+ player, action.unit_selector or "all_military", max_count=action.count
1693
+ )
1694
+ units = [u for u in units if not u.is_sieged and u.unit_type != UnitType.SCV]
1695
+ if not units:
1696
+ return ActionResult(action_type="defend", success=False, data={"error": "no_units_selected"})
1697
+
1698
+ zone = action.target_zone or "my_base"
1699
+ cx, cy = self._resolve_zone(player.player_id, zone)
1700
+
1701
+ DEFEND_RADIUS = 6.0 # patrol orbit radius around the base center
1702
+ n = len(units)
1703
+ for i, unit in enumerate(units):
1704
+ angle = (2 * math.pi * i) / n
1705
+ # Two opposite points on the perimeter
1706
+ px1 = cx + DEFEND_RADIUS * math.cos(angle)
1707
+ py1 = cy + DEFEND_RADIUS * math.sin(angle)
1708
+ px2 = cx + DEFEND_RADIUS * math.cos(angle + math.pi)
1709
+ py2 = cy + DEFEND_RADIUS * math.sin(angle + math.pi)
1710
+
1711
+ is_flying = UNIT_DEFS[unit.unit_type].is_flying
1712
+ unit.attack_target_id = None
1713
+ unit.attack_target_building_id = None
1714
+ # Store the far patrol point as the return waypoint
1715
+ unit.patrol_x = px2
1716
+ unit.patrol_y = py2
1717
+ unit.status = UnitStatus.PATROLLING
1718
+ self._set_unit_destination(unit, px1, py1, is_flying=is_flying)
1719
+
1720
+ move_ack = [{"kind": "move_ack", "unit_type": units[0].unit_type.value}]
1721
+ return ActionResult(
1722
+ action_type="defend", success=True,
1723
+ data={"n": len(units), "zone": zone}, sound_events=move_ack,
1724
+ )
1725
+
1726
  def _cmd_resign(self, player: PlayerState) -> ActionResult:
1727
  """Player forfeits — opponent is declared winner immediately."""
1728
  opponent_id = next(
backend/voice/command_parser.py CHANGED
@@ -42,6 +42,7 @@ supply_depot, barracks, engineering_bay, refinery, factory, armory, starport
42
  - gather : collecter ressources → champs: unit_selector, resource_type ("minerals"|"gas"), count (optionnel)
43
  - stop : arrêter des unités → champs: unit_selector, count (optionnel)
44
  - patrol : patrouiller → champs: unit_selector, target_zone, count (optionnel)
 
45
  - query : information sur l'état → aucun champ requis
46
  - query_units : lister les IDs d'unités (pour affectation aux groupes) → champs optionnels: target_zone, unit_type
47
  - assign_to_group : affecter des unités à un groupe (1, 2 ou 3) → champs: group_index (1-3). Utilise le résultat du query_units précédent si unit_ids vide. Pour "mets mes marines dans le groupe 1", génère d'abord query_units (unit_type: marine) puis assign_to_group (group_index: 1).
 
42
  - gather : collecter ressources → champs: unit_selector, resource_type ("minerals"|"gas"), count (optionnel)
43
  - stop : arrêter des unités → champs: unit_selector, count (optionnel)
44
  - patrol : patrouiller → champs: unit_selector, target_zone, count (optionnel)
45
+ - defend : défendre une base (unités militaires patrouillent en cercle autour de la zone en attaquant automatiquement) → champs: unit_selector (optionnel), target_zone (défaut: my_base), count (optionnel). Utiliser quand le joueur dit "défends ma base", "protège la base", "garde la base", "defend my base", "hold the base", etc.
46
  - query : information sur l'état → aucun champ requis
47
  - query_units : lister les IDs d'unités (pour affectation aux groupes) → champs optionnels: target_zone, unit_type
48
  - assign_to_group : affecter des unités à un groupe (1, 2 ou 3) → champs: group_index (1-3). Utilise le résultat du query_units précédent si unit_ids vide. Pour "mets mes marines dans le groupe 1", génère d'abord query_units (unit_type: marine) puis assign_to_group (group_index: 1).
frontend/src/lib/components/Minimap.svelte CHANGED
@@ -1,9 +1,9 @@
1
  <script lang="ts">
2
  import { backendUrl } from '$lib/socket';
3
- import { gameState, myPlayerId, mapViewport, mapCenterRequest, mapVisibleCells, mapExploredCells, isTutorial } from '$lib/stores/game';
4
  import { BUILDING_SIZES } from '$lib/types';
5
 
6
- $: mapImageUrl = typeof window !== 'undefined' ? `${backendUrl()}/static/MAP.png` : '';
7
 
8
  const MAP_W = 80;
9
  const MAP_H = 80;
@@ -32,13 +32,20 @@
32
  return visibleCells.has(key);
33
  }
34
 
35
- function onClick(e: MouseEvent) {
 
 
 
 
 
 
36
  e.preventDefault();
37
  e.stopPropagation();
38
  const target = e.currentTarget as HTMLButtonElement;
39
  const rect = target.getBoundingClientRect();
40
- const px = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
41
- const py = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
 
42
  const cx = px * MAP_W;
43
  const cy = py * MAP_H;
44
  mapCenterRequest.set({ cx, cy });
@@ -50,6 +57,7 @@
50
  class="minimap-btn"
51
  aria-label="Minimap: click to move view"
52
  on:click={onClick}
 
53
  >
54
  <svg
55
  bind:this={minimapEl}
@@ -164,6 +172,9 @@
164
  cursor: pointer;
165
  overflow: hidden;
166
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
 
 
 
167
  }
168
 
169
  .minimap-btn:hover {
@@ -177,4 +188,11 @@
177
  height: 160px;
178
  pointer-events: none;
179
  }
 
 
 
 
 
 
 
180
  </style>
 
1
  <script lang="ts">
2
  import { backendUrl } from '$lib/socket';
3
+ import { gameState, myPlayerId, mapViewport, mapCenterRequest, mapVisibleCells, mapExploredCells, isTutorial, mapTextureUrl } from '$lib/stores/game';
4
  import { BUILDING_SIZES } from '$lib/types';
5
 
6
+ $: mapImageUrl = $mapTextureUrl ?? (typeof window !== 'undefined' ? `${backendUrl()}/static/MAP.png` : '');
7
 
8
  const MAP_W = 80;
9
  const MAP_H = 80;
 
32
  return visibleCells.has(key);
33
  }
34
 
35
+ function getClickCoords(e: MouseEvent | TouchEvent): { clientX: number; clientY: number } {
36
+ if ('touches' in e && e.touches.length > 0) return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
37
+ if ('changedTouches' in e && e.changedTouches.length > 0) return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY };
38
+ return { clientX: (e as MouseEvent).clientX, clientY: (e as MouseEvent).clientY };
39
+ }
40
+
41
+ function onClick(e: MouseEvent | TouchEvent) {
42
  e.preventDefault();
43
  e.stopPropagation();
44
  const target = e.currentTarget as HTMLButtonElement;
45
  const rect = target.getBoundingClientRect();
46
+ const { clientX, clientY } = getClickCoords(e);
47
+ const px = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
48
+ const py = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
49
  const cx = px * MAP_W;
50
  const cy = py * MAP_H;
51
  mapCenterRequest.set({ cx, cy });
 
57
  class="minimap-btn"
58
  aria-label="Minimap: click to move view"
59
  on:click={onClick}
60
+ on:touchend|nonpassive={onClick}
61
  >
62
  <svg
63
  bind:this={minimapEl}
 
172
  cursor: pointer;
173
  overflow: hidden;
174
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
175
+ touch-action: manipulation;
176
+ -webkit-touch-callout: none;
177
+ user-select: none;
178
  }
179
 
180
  .minimap-btn:hover {
 
188
  height: 160px;
189
  pointer-events: none;
190
  }
191
+
192
+ @media (max-width: 600px) {
193
+ .minimap-svg {
194
+ width: 80px;
195
+ height: 80px;
196
+ }
197
+ }
198
  </style>
frontend/src/lib/components/VoiceButton.svelte CHANGED
@@ -113,6 +113,10 @@
113
  transition: all 0.15s;
114
  flex-shrink: 0;
115
  overflow: visible;
 
 
 
 
116
  }
117
 
118
  .voice-btn:not(:disabled):active,
 
113
  transition: all 0.15s;
114
  flex-shrink: 0;
115
  overflow: visible;
116
+ touch-action: none;
117
+ -webkit-touch-callout: none;
118
+ user-select: none;
119
+ -webkit-user-select: none;
120
  }
121
 
122
  .voice-btn:not(:disabled):active,
frontend/src/routes/+page.svelte CHANGED
@@ -1562,4 +1562,106 @@
1562
  .activity-toast-link:hover {
1563
  color: #c4b5fd;
1564
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
  </style>
 
1562
  .activity-toast-link:hover {
1563
  color: #c4b5fd;
1564
  }
1565
+
1566
+ @media (max-width: 480px) {
1567
+ .lobby {
1568
+ --banner-height: 38px;
1569
+ }
1570
+
1571
+ .powered-banner {
1572
+ gap: 2px;
1573
+ padding: 4px 8px;
1574
+ }
1575
+
1576
+ .powered-label {
1577
+ display: none;
1578
+ }
1579
+
1580
+ .powered-separator {
1581
+ margin: 0 2px;
1582
+ height: 14px;
1583
+ }
1584
+
1585
+ .powered-logo {
1586
+ padding: 2px 3px;
1587
+ }
1588
+
1589
+ .powered-logo img {
1590
+ height: 14px;
1591
+ max-width: 50px;
1592
+ }
1593
+
1594
+ .powered-author-name {
1595
+ display: none;
1596
+ }
1597
+
1598
+ .powered-author img {
1599
+ height: 14px;
1600
+ width: 14px;
1601
+ }
1602
+
1603
+ .lobby {
1604
+ padding: var(--banner-height) 12px 20px;
1605
+ gap: 12px;
1606
+ }
1607
+
1608
+ .lobby-content {
1609
+ gap: 12px;
1610
+ }
1611
+
1612
+ .btn {
1613
+ padding: 10px 14px;
1614
+ font-size: 0.85rem;
1615
+ gap: 6px;
1616
+ }
1617
+
1618
+ .btn-tutorial {
1619
+ margin-top: 6px;
1620
+ }
1621
+
1622
+ .btn-icon {
1623
+ font-size: 0.95rem;
1624
+ }
1625
+
1626
+ .name-input-row .btn {
1627
+ padding: 10px 12px;
1628
+ }
1629
+
1630
+ .text-input {
1631
+ padding: 10px 12px;
1632
+ font-size: 0.9rem;
1633
+ }
1634
+
1635
+ .home-panel {
1636
+ padding: 14px;
1637
+ gap: 12px;
1638
+ }
1639
+
1640
+ .observe-row {
1641
+ gap: 8px;
1642
+ }
1643
+
1644
+ .btn-observe {
1645
+ padding: 8px 12px;
1646
+ font-size: 0.82rem;
1647
+ }
1648
+
1649
+ .leaderboard-fab,
1650
+ .help-btn {
1651
+ width: 36px;
1652
+ height: 36px;
1653
+ bottom: 12px;
1654
+ border-width: 2px;
1655
+ }
1656
+
1657
+ .leaderboard-fab {
1658
+ left: 12px;
1659
+ font-size: 1rem;
1660
+ }
1661
+
1662
+ .help-btn {
1663
+ right: 12px;
1664
+ font-size: 1.1rem;
1665
+ }
1666
+ }
1667
  </style>