PageSourceSearch

https://tech-bros-c98f2.web.app/src/ui/character-select.js

js tech-bros-c98f2.web.app collected 2026-10-03 12:39:00 UTC 12,417 bytes, 352 lines download raw bytes

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.