1import { GAME_WIDTH, GAME_HEIGHT } from '../core/constants.js'; 2import { CHARACTER_LIST } from '../data/character-types.js'; 3import { WEAPONS } from '../data/weapon-types.js'; 4import { createNineSlicePanel } from '../core/nine-slice.js'; 5import { SpriteAnimator } from '../core/sprite-animator.js'; 6import { DEFAULT_VIEWPORT, fillViewportRect, coverViewportSprite } from '../core/viewport.js'; 7 8const FONT_DISPLAY = '8Bit Wonder'; 9const FONT_BODY = 'VT323'; 10const CARD_W = 180; 11const CARD_H = 260; 12const CARD_GAP = 16; 13const COLS = 3; 14const ROWS = 2; 15const GRID_W = COLS * CARD_W + (COLS - 1) * CARD_GAP; 16const GRID_H = ROWS * CARD_H + (ROWS - 1) * CARD_GAP; 17const GRID_X = (GAME_WIDTH - GRID_W) / 2; 18const GRID_Y = 100; 19const TITLE_Y = 30; 20 21const CARD_SELECTED = 0x44ff44; 22 23/** 24 * Character selection screen â 2x3 grid of character cards. 25 * Each card shows: sprite, name, starting weapon. 26 */ 27export class CharacterSelect { 28 constructor() { 29 this.container = new PIXI.Container(); 30 this.container.visible = false; 31 this.selectedIndex = -1; 32 this.cards = []; 33 this.onSelect = null; // callback(characterId) 34 this._titleTween = null; 35 36 this.build(); 37 } 38 39 build() { 40 // Background image 41 const bgTexture = PIXI.Texture.from('assets/sprites/art/main_menu_background.png'); 42 bgTexture.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; 43 const bgSprite = new PIXI.Sprite(bgTexture); 44 bgSprite.anchor.set(0.5); 45 bgSprite.position.set(GAME_WIDTH / 2, GAME_HEIGHT / 2); 46 bgSprite.width = GAME_WIDTH + 200; 47 bgSprite.height = GAME_HEIGHT + 200; 48 this.bgSprite = bgSprite; 49 this.container.addChild(bgSprite); 50 51 // Dark overlay for readability 52 const bg = new PIXI.Graphics(); 53 fillViewportRect(bg, DEFAULT_VIEWPORT, 0x000000, 0.55); 54 this.bgOverlay = bg; 55 this.container.addChild(bg); 56 57 // Title 58 this.titleText = new PIXI.Text('SELECT YOUR FIGHTER', { 59 fontFamily: FONT_DISPLAY, fontSize: 18, fill: 0xff4444, padding: 6, 60 }); 61 this.titleText.anchor.set(0.5, 0); 62 this.titleText.position.set(GAME_WIDTH / 2, TITLE_Y); 63 this.container.addChild(this.titleText); 64 65 // Subtitle 66 const sub = new PIXI.Text('Choose a character to begin', { 67 fontFamily: FONT_BODY, fontSize: 20, fill: 0x888888, padding: 4, 68 }); 69 sub.anchor.set(0.5, 0); 70 sub.position.set(GAME_WIDTH / 2, 60); 71 this.container.addChild(sub); 72 73 // Character cards 74 for (let i = 0; i < CHARACTER_LIST.length; i++) { 75 const char = CHARACTER_LIST[i]; 76 const col = i % COLS; 77 const row = Math.floor(i / COLS); 78 const x = GRID_X + col * (CARD_W + CARD_GAP); 79 const y = GRID_Y + row * (CARD_H + CARD_GAP); 80 81 const card = this.buildCard(char, x, y, i); 82 this.cards.push(card); 83 this.container.addChild(card.container); 84 } 85 86 // Back button 87 this.backBtn = this.createButton('BACK', GAME_WIDTH / 2 - 130, GAME_HEIGHT - 50, 0x888888, 0x333333, 120); 88 this.container.addChild(this.backBtn.container); 89 90 // Start button (hidden until selection) 91 this.startBtn = this.createButton('START', GAME_WIDTH / 2 + 130, GAME_HEIGHT - 50, 0x44ff44, 0x226622, 160); 92 this.startBtn.container.visible = false; 93 this.container.addChild(this.startBtn.container); 94 } 95 96 buildCard(char, x, y, index) { 97 const container = new PIXI.Container(); 98 container.pivot.set(CARD_W / 2, CARD_H / 2); 99 container.position.set(x + CARD_W / 2, y + CARD_H / 2); 100 container.eventMode = 'static'; 101 container.cursor = 'pointer'; 102 103 // Card background â 9-slice panel 104 const bg = createNineSlicePanel(CARD_W, CARD_H); 105 container.addChild(bg); 106 107 // Selection border removed â pop-out scale is sufficient 108 const selectBorder = { visible: false }; // stub for compatibility 109 110 // --- Vertically centered content group --- 111 // Elements: sprite (96), gap (8), name (~14), gap (4), weaponIcon (32), gap (2), weaponText (~16) 112 // Total height â 172 113 const contentH = 172; 114 const contentTop = (CARD_H - contentH) / 2; 115 116 // Character sprite (animated if spritesheet data is available) 117 const spriteSize = 96; 118 const texture = PIXI.Texture.from(char.sprite); 119 const sprite = new PIXI.Sprite(texture); 120 sprite.width = spriteSize; 121 sprite.height = spriteSize; 122 sprite.anchor.set(0.5, 0); 123 sprite.position.set(CARD_W / 2, contentTop); 124 container.addChild(sprite); 125 126 // Set up animation for this card's sprite 127 let animator = null; 128 if (char.animations && Object.keys(char.animations).length > 0) { 129 animator = new SpriteAnimator(sprite); 130 for (const [name, anim] of Object.entries(char.animations)) { 131 animator.addAnimation(name, anim.sheet, anim.cols, anim.rows, anim.frames, anim.fps); 132 } 133 animator.play('idle'); 134 } 135 136 // Character name 137 const nameY = contentTop + spriteSize + 8; 138 const name = new PIXI.Text(char.name.split(' ').pop(), { 139 fontFamily: FONT_DISPLAY, fontSize: 9, fill: 0xffffff, padding: 6, 140 }); 141 name.anchor.set(0.5, 0); 142 name.position.set(CARD_W / 2, nameY); 143 container.addChild(name); 144 145 // Starting weapon 146 const weapon = WEAPONS[char.startingWeapon]; 147 148 // Weapon icon 149 const weaponIconY = nameY + 18; 150 if (weapon && weapon.sprite) { 151 const weaponIcon = new PIXI.Sprite(PIXI.Texture.from(weapon.sprite)); 152 weaponIcon.width = 32; 153 weaponIcon.height = 32;
154 weaponIcon.anchor.set(0.5, 0); 155 weaponIcon.position.set(CARD_W / 2, weaponIconY); 156 container.addChild(weaponIcon); 157 } 158 159 // Weapon name text 160 const weaponName = weapon ? weapon.name : '???'; 161 const weaponText = new PIXI.Text(weaponName, { 162 fontFamily: FONT_BODY, fontSize: 16, fill: weapon ? weapon.color : 0x888888, padding: 4, 163 }); 164 weaponText.anchor.set(0.5, 0); 165 weaponText.position.set(CARD_W / 2, weaponIconY + 34); 166 container.addChild(weaponText); 167 168 // Hover effect 169 container.on('pointerover', () => { 170 if (this.selectedIndex !== index) { 171 bg.alpha = 0.8; 172 container.scale.set(1.03); 173 } 174 if (animator) { 175 // Switch to run animation on hover (falls back to idle if unavailable) 176 if (!animator.play('run', { startFrame: 1 })) animator.play('idle'); 177 } 178 }); 179 container.on('pointerout', () => { 180 if (this.selectedIndex !== index) { 181 bg.alpha = 1; 182 container.scale.set(1); 183 if (animator) animator.play('idle'); 184 } 185 }); 186 187 // Click to select 188 container.on('pointertap', () => { 189 this.selectCard(index); 190 }); 191 192 return { container, bg, selectBorder, index, animator }; 193 } 194 195 selectCard(index) { 196 // Toggle if clicking already-selected card 197 if (this.selectedIndex === index) { 198 const card = this.cards[index]; 199 card.selectBorder.visible = false; 200 card.container.scale.set(1); 201 if (card.animator) card.animator.play('idle'); 202 this.selectedIndex = -1; 203 this.startBtn.container.visible = false; 204 return; 205 } 206 207 // Deselect previous 208 if (this.selectedIndex >= 0 && this.selectedIndex < this.cards.length) { 209 const prev = this.cards[this.selectedIndex]; 210 prev.selectBorder.visible = false; 211 prev.container.scale.set(1); 212 if (prev.animator) prev.animator.play('idle'); 213 } 214 215 this.selectedIndex = index; 216 const card = this.cards[index]; 217 card.selectBorder.visible = true; 218 card.container.scale.set(1.05); 219 if (card.animator) { 220 if (!card.animator.play('run')) card.animator.play('idle'); 221 } 222 223 // Show start button 224 this.startBtn.container.visible = true; 225 } 226 227 createButton(label, x, y, textColor, bgColor, width) { 228 const w = width || 220; 229 const h = 40; 230 const container = new PIXI.Container(); 231 container.eventMode = 'static'; 232 container.cursor = 'pointer'; 233 234 const bg = createNineSlicePanel(w, h, { cornerSize: 18 }); 235 bg.position.set(-w / 2, -h / 2); 236 container.addChild(bg); 237 238 const text = new PIXI.Text(label, { 239 fontFamily: FONT_DISPLAY, fontSize: 12, fill: textColor, padding: 6, 240 }); 241 text.anchor.set(0.5); 242 container.addChild(text); 243 244 container.position.set(x, y); 245 246 container.on('pointerover', () => { 247 container.scale.set(1.05); 248 }); 249 container.on('pointerout', () => { 250 container.scale.set(1); 251 }); 252 253 return { container, bg, text }; 254 } 255 256 resize(viewport = DEFAULT_VIEWPORT) { 257 coverViewportSprite(this.bgSprite, viewport, 200); 258 fillViewportRect(this.bgOverlay, viewport, 0x000000, 0.55); 259 if (this.backBtn) this.backBtn.container.y = viewport.visibleBottom - 50; 260 if (this.startBtn) this.startBtn.container.y = viewport.visibleBottom - 50; 261 } 262 263 /** Tick all card animators â call from a shared ticker while visible. */ 264 update(delta) { 265 if (!this.container.visible) return; 266 for (const card of this.cards) { 267 if (card.animator) card.animator.update(delta); 268 } 269 } 270 271 show(onSelect, onBack) { 272 this.onSelect = onSelect; 273 this.onBack = onBack; 274 this.selectedIndex = -1; 275 this.startBtn.container.visible = false;
276 this.container.visible = true; 277 this.startTitleFloat(); 278 279 // Reset all cards 280 for (const card of this.cards) { 281 card.selectBorder.visible = false; 282 card.container.scale.set(1); 283 if (card.animator) card.animator.play('idle'); 284 } 285 286 this.setupListeners(); 287 288 // Start ticker for animations 289 if (!this._tickerBound) { 290 this._tickerBound = (delta) => this.update(delta); 291 } 292 PIXI.Ticker.shared.add(this._tickerBound); 293 } 294 295 hide() { 296 this.container.visible = false; 297 this.clearListeners(); 298 this.stopTitleFloat(); 299 300 // Stop ticker when hidden 301 if (this._tickerBound) { 302 PIXI.Ticker.shared.remove(this._tickerBound); 303 } 304 } 305 306 setupListeners() { 307 this.clearListeners(); 308 this.startBtn.container.on('pointertap', () => { 309 if (this.selectedIndex >= 0) { 310 const char = CHARACTER_LIST[this.selectedIndex]; 311 this.hide(); 312 if (this.onSelect) this.onSelect(char.id); 313 } 314 }); 315 this.backBtn.container.on('pointertap', () => { 316 this.hide(); 317 if (this.onBack) this.onBack(); 318 }); 319 // Re-apply hover effects â clearListeners() strips them each time 320 for (const btn of [this.startBtn, this.backBtn]) { 321 btn.container.on('pointerover', () => { btn.bg.alpha = 1.2; btn.container.scale.set(1.05); }); 322 btn.container.on('pointerout', () => { btn.bg.alpha = 1; btn.container.scale.set(1); }); 323 } 324 } 325 326 clearListeners() { 327 this.startBtn.container.removeAllListeners(); 328 this.backBtn.container.removeAllListeners(); 329 } 330 331 startTitleFloat() { 332 this.stopTitleFloat(); 333 this.titleText.position.y = TITLE_Y; 334 if (typeof gsap !== 'undefined') { 335 this._titleTween = gsap.to(this.titleText.position, { 336 y: TITLE_Y + 7, 337 duration: 1.1, 338 ease: 'sine.inOut', 339 yoyo: true, 340 repeat: -1, 341 }); 342 } 343 } 344 345 stopTitleFloat() { 346 if (this._titleTween) { 347 this._titleTween.kill(); 348 this._titleTween = null; 349 } 350 if (this.titleText) this.titleText.position.y = TITLE_Y; 351 } 352}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.