chore: initial commit
This commit is contained in:
163
src/game/ai.ts
Normal file
163
src/game/ai.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Card, GameState, PlayerIndex } from './types';
|
||||
import { findCaptures, canCapture, calcPrimiera, teamOf } from './engine';
|
||||
|
||||
export interface AIMove {
|
||||
card: Card;
|
||||
capture: Card[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic AI for Scopone Scientifico.
|
||||
* Evaluates moves by scoring the value of captured cards.
|
||||
*/
|
||||
export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||
const player = state.players[playerIdx];
|
||||
const hand = player.hand;
|
||||
const table = state.table;
|
||||
const myTeam = teamOf(playerIdx);
|
||||
|
||||
let bestMove: AIMove | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const card of hand) {
|
||||
const captures = findCaptures(card, table);
|
||||
if (captures.length > 0) {
|
||||
for (const captureSet of captures) {
|
||||
const score = scoreCapture(card, captureSet, table, state, myTeam);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: captureSet };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No capture — score the "dump" move
|
||||
const score = scoreDump(card, table, state, myTeam);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMove = { card, capture: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMove!;
|
||||
}
|
||||
|
||||
function scoreCapture(
|
||||
played: Card,
|
||||
captured: Card[],
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
let score = 100; // base for capturing anything
|
||||
|
||||
const allCaptured = [played, ...captured];
|
||||
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
||||
const isScopa = afterTable.length === 0;
|
||||
|
||||
// Scopa is very valuable
|
||||
if (isScopa) score += 500;
|
||||
|
||||
// Settebello
|
||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
|
||||
|
||||
// Table has settebello and this capture takes it
|
||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 200;
|
||||
|
||||
// Denari cards
|
||||
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
|
||||
|
||||
// More cards = better
|
||||
score += captured.length * 20;
|
||||
|
||||
// High-value primiera cards
|
||||
score += allCaptured.reduce((s, c) => s + primieraScore(c), 0);
|
||||
|
||||
// Avoid leaving settebello on table for opponent
|
||||
const settebelloOnTable = afterTable.some(c => c.suit === 'denara' && c.value === 7);
|
||||
if (settebelloOnTable) score -= 150;
|
||||
|
||||
// Opponent could easily capture remaining table
|
||||
score -= opponentThreatScore(afterTable, state, myTeam) * 30;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function scoreDump(
|
||||
card: Card,
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
// Don't dump cards that complete opponent captures
|
||||
const afterTable = [...table, card];
|
||||
|
||||
// Check if opponent can make a scopa after this dump
|
||||
const opponentTeam = myTeam === 0 ? 1 : 0;
|
||||
const opponentPlayers = state.players.filter(p => teamOf(p.index) === opponentTeam);
|
||||
for (const opp of opponentPlayers) {
|
||||
for (const oppCard of opp.hand) {
|
||||
const caps = findCaptures(oppCard, afterTable);
|
||||
for (const cap of caps) {
|
||||
const leftAfter = afterTable.filter(c => !cap.some(cc => cc.id === c.id));
|
||||
if (leftAfter.length === 0) score -= 400; // would give opponent scopa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to dump low-value non-denari cards
|
||||
if (card.suit !== 'denara') score += 30;
|
||||
if (card.suit === 'denara') score -= 40;
|
||||
if (card.suit === 'denara' && card.value === 7) score -= 300; // never dump settebello
|
||||
|
||||
// Prefer dumping face cards (10, 9, 8) that are less useful
|
||||
if (card.value >= 8) score += 10;
|
||||
|
||||
// Don't dump 7s (primiera value)
|
||||
if (card.value === 7) score -= 50;
|
||||
|
||||
// Don't dump 1s (primiera value)
|
||||
if (card.value === 1) score -= 30;
|
||||
|
||||
// Penalty for making easy captures for opponents
|
||||
const capturable = state.players
|
||||
.filter(p => teamOf(p.index) !== myTeam)
|
||||
.flatMap(p => p.hand)
|
||||
.some(oppCard => canCapture(oppCard, afterTable));
|
||||
if (capturable) score -= 20;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function primieraScore(card: Card): number {
|
||||
// Reward for capturing high-primiera cards
|
||||
const vals: Record<number, number> = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
|
||||
return vals[card.value] ?? 0;
|
||||
}
|
||||
|
||||
function opponentThreatScore(
|
||||
table: Card[],
|
||||
state: GameState,
|
||||
myTeam: 0 | 1
|
||||
): number {
|
||||
const opponentTeam = myTeam === 0 ? 1 : 0;
|
||||
let threat = 0;
|
||||
for (const player of state.players) {
|
||||
if (teamOf(player.index) !== opponentTeam) continue;
|
||||
for (const card of player.hand) {
|
||||
const caps = findCaptures(card, table);
|
||||
if (caps.length > 0) {
|
||||
threat += caps[0].length;
|
||||
// Extra threat if settebello is capturable
|
||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||
caps.some(cap => cap.some(c => c.suit === 'denara' && c.value === 7))) {
|
||||
threat += 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return threat;
|
||||
}
|
||||
307
src/game/engine.ts
Normal file
307
src/game/engine.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Card, Suit, SUITS, Player, PlayerIndex, GameState,
|
||||
TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildDeck(): Card[] {
|
||||
const deck: Card[] = [];
|
||||
for (const suit of SUITS) {
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
deck.push({ suit, value: v, id: `${suit}_${v}` });
|
||||
}
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
export function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find all valid capture combinations for a played card against the table.
|
||||
* Rules:
|
||||
* - If the table contains a card of the same value, you MUST take it (and only it)
|
||||
* unless the table has multiple cards of that value (take exactly one)
|
||||
* - If no direct match exists, you may take any subset of table cards that sums to the played value
|
||||
* Returns array of capture sets (each is a list of cards taken from table).
|
||||
*/
|
||||
export function findCaptures(played: Card, table: Card[]): Card[][] {
|
||||
const results: Card[][] = [];
|
||||
|
||||
// Check for direct value matches
|
||||
const directMatches = table.filter(c => c.value === played.value);
|
||||
if (directMatches.length > 0) {
|
||||
// Must capture exactly one matching card (Italian rules: take one direct match)
|
||||
// Actually: if there's one direct match take it; if multiple, still take all that match
|
||||
// Standard Italian rule: take ALL cards of matching value
|
||||
results.push([...directMatches]);
|
||||
return results; // direct match takes priority, no sum captures allowed
|
||||
}
|
||||
|
||||
// No direct matches — find subsets that sum to played.value
|
||||
const subsets = getSubsets(table);
|
||||
for (const subset of subsets) {
|
||||
if (subset.length >= 2) {
|
||||
const sum = subset.reduce((acc, c) => acc + c.value, 0);
|
||||
if (sum === played.value) {
|
||||
results.push(subset);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function getSubsets(cards: Card[]): Card[][] {
|
||||
const result: Card[][] = [[]];
|
||||
for (const card of cards) {
|
||||
const newSubsets = result.map(s => [...s, card]);
|
||||
result.push(...newSubsets);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canCapture(played: Card, table: Card[]): boolean {
|
||||
return findCaptures(played, table).length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game state initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createInitialState(): GameState {
|
||||
const deck = shuffle(buildDeck());
|
||||
|
||||
const players: [Player, Player, Player, Player] = [
|
||||
{ index: 0, hand: [], pile: [], scope: 0, isHuman: true, name: 'Tu' },
|
||||
{ index: 1, hand: [], pile: [], scope: 0, isHuman: false, name: 'AI Ovest' },
|
||||
{ index: 2, hand: [], pile: [], scope: 0, isHuman: false, name: 'Compagno' },
|
||||
{ index: 3, hand: [], pile: [], scope: 0, isHuman: false, name: 'AI Est' },
|
||||
];
|
||||
|
||||
// Deal 10 cards each — Scopone Scientifico deals all at once
|
||||
for (let i = 0; i < 4; i++) {
|
||||
players[i].hand = deck.splice(0, 10);
|
||||
}
|
||||
|
||||
// No initial table cards in Scopone Scientifico
|
||||
const table: Card[] = [];
|
||||
|
||||
const emptyTeamScore = (): TeamScore => ({
|
||||
cards: 0, scope: 0, denari: 0, settebello: false, primiera: 0, roundPoints: 0, totalPoints: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
players,
|
||||
table,
|
||||
currentPlayer: 0,
|
||||
roundOver: false,
|
||||
gameOver: false,
|
||||
teamScores: [emptyTeamScore(), emptyTeamScore()],
|
||||
lastCapturTeam: null,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Play a card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a move to the game state (immutably).
|
||||
* If captureChoice is provided, use that capture set; otherwise use the first valid capture.
|
||||
* Returns the new state and what was captured (null if nothing).
|
||||
*/
|
||||
export function applyMove(
|
||||
state: GameState,
|
||||
playerIdx: PlayerIndex,
|
||||
card: Card,
|
||||
captureChoice?: Card[]
|
||||
): { nextState: GameState; capture: Capture | null; isScopa: boolean } {
|
||||
const state2 = deepClone(state);
|
||||
const player = state2.players[playerIdx];
|
||||
|
||||
// Remove card from hand
|
||||
player.hand = player.hand.filter(c => c.id !== card.id);
|
||||
|
||||
const captures = findCaptures(card, state2.table);
|
||||
let capturedCards: Card[] = [];
|
||||
let isScopa = false;
|
||||
|
||||
if (captures.length > 0) {
|
||||
const chosen = captureChoice ?? captures[0];
|
||||
capturedCards = chosen;
|
||||
|
||||
// Remove captured cards from table
|
||||
const capturedIds = new Set(chosen.map(c => c.id));
|
||||
state2.table = state2.table.filter(c => !capturedIds.has(c.id));
|
||||
|
||||
// Add played card + captured to player's pile
|
||||
player.pile.push(card, ...capturedCards);
|
||||
|
||||
// Scopa: cleared the table
|
||||
if (state2.table.length === 0) {
|
||||
player.scope += 1;
|
||||
isScopa = true;
|
||||
}
|
||||
|
||||
// Track which team made last capture
|
||||
state2.lastCapturTeam = (playerIdx === 0 || playerIdx === 2) ? 0 : 1;
|
||||
} else {
|
||||
// No capture — add to table
|
||||
state2.table.push(card);
|
||||
}
|
||||
|
||||
// Advance turn
|
||||
state2.currentPlayer = ((playerIdx + 1) % 4) as PlayerIndex;
|
||||
|
||||
// Check if round is over (all hands empty)
|
||||
const allHandsEmpty = state2.players.every(p => p.hand.length === 0);
|
||||
if (allHandsEmpty) {
|
||||
// Remaining table cards go to last capturing team
|
||||
if (state2.table.length > 0 && state2.lastCapturTeam !== null) {
|
||||
const lastTeamPlayers = state2.lastCapturTeam === 0 ? [0, 2] : [1, 3];
|
||||
// Give to first player of that team
|
||||
state2.players[lastTeamPlayers[0]].pile.push(...state2.table);
|
||||
state2.table = [];
|
||||
}
|
||||
state2.roundOver = true;
|
||||
calculateScores(state2);
|
||||
}
|
||||
|
||||
return {
|
||||
nextState: state2,
|
||||
capture: capturedCards.length > 0 ? { played: card, captured: capturedCards } : null,
|
||||
isScopa,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function calculateScores(state: GameState): void {
|
||||
const team0 = [state.players[0], state.players[2]];
|
||||
const team1 = [state.players[1], state.players[3]];
|
||||
|
||||
const breakdown: ScoreBreakdown = scoreRound(team0, team1);
|
||||
|
||||
const t0 = state.teamScores[0];
|
||||
const t1 = state.teamScores[1];
|
||||
|
||||
// Cards
|
||||
t0.cards = team0.reduce((s, p) => s + p.pile.length, 0);
|
||||
t1.cards = team1.reduce((s, p) => s + p.pile.length, 0);
|
||||
|
||||
// Denari
|
||||
const denari0 = team0.flatMap(p => p.pile).filter(c => c.suit === 'denara');
|
||||
const denari1 = team1.flatMap(p => p.pile).filter(c => c.suit === 'denara');
|
||||
t0.denari = denari0.length;
|
||||
t1.denari = denari1.length;
|
||||
|
||||
// Settebello
|
||||
t0.settebello = team0.flatMap(p => p.pile).some(c => c.suit === 'denara' && c.value === 7);
|
||||
t1.settebello = !t0.settebello;
|
||||
|
||||
// Scope
|
||||
t0.scope = team0.reduce((s, p) => s + p.scope, 0);
|
||||
t1.scope = team1.reduce((s, p) => s + p.scope, 0);
|
||||
|
||||
// Primiera
|
||||
t0.primiera = calcPrimiera(team0.flatMap(p => p.pile));
|
||||
t1.primiera = calcPrimiera(team1.flatMap(p => p.pile));
|
||||
|
||||
// Points this round
|
||||
let p0 = 0;
|
||||
let p1 = 0;
|
||||
|
||||
if (breakdown.cartePoint === 0) p0++;
|
||||
else if (breakdown.cartePoint === 1) p1++;
|
||||
|
||||
if (breakdown.denariPoint === 0) p0++;
|
||||
else if (breakdown.denariPoint === 1) p1++;
|
||||
|
||||
if (breakdown.settebelloPoint === 0) p0++;
|
||||
else p1++;
|
||||
|
||||
if (breakdown.primieraPoint === 0) p0++;
|
||||
else if (breakdown.primieraPoint === 1) p1++;
|
||||
|
||||
p0 += breakdown.scopeTeam0;
|
||||
p1 += breakdown.scopeTeam1;
|
||||
|
||||
t0.roundPoints = p0;
|
||||
t1.roundPoints = p1;
|
||||
t0.totalPoints += p0;
|
||||
t1.totalPoints += p1;
|
||||
}
|
||||
|
||||
function scoreRound(team0: Player[], team1: Player[]): ScoreBreakdown {
|
||||
const pile0 = team0.flatMap(p => p.pile);
|
||||
const pile1 = team1.flatMap(p => p.pile);
|
||||
|
||||
const cards0 = pile0.length;
|
||||
const cards1 = pile1.length;
|
||||
|
||||
const denari0 = pile0.filter(c => c.suit === 'denara').length;
|
||||
const denari1 = pile1.filter(c => c.suit === 'denara').length;
|
||||
|
||||
const hasSette0 = pile0.some(c => c.suit === 'denara' && c.value === 7);
|
||||
|
||||
const prim0 = calcPrimiera(pile0);
|
||||
const prim1 = calcPrimiera(pile1);
|
||||
|
||||
const scope0 = team0.reduce((s, p) => s + p.scope, 0);
|
||||
const scope1 = team1.reduce((s, p) => s + p.scope, 0);
|
||||
|
||||
return {
|
||||
cartePoint: cards0 > cards1 ? 0 : cards1 > cards0 ? 1 : null,
|
||||
denariPoint: denari0 > denari1 ? 0 : denari1 > denari0 ? 1 : null,
|
||||
settebelloPoint: hasSette0 ? 0 : 1,
|
||||
primieraPoint: prim0 > prim1 ? 0 : prim1 > prim0 ? 1 : null,
|
||||
scopeTeam0: scope0,
|
||||
scopeTeam1: scope1,
|
||||
};
|
||||
}
|
||||
|
||||
export function calcPrimiera(pile: Card[]): number {
|
||||
// Best card per suit, using primiera values
|
||||
let total = 0;
|
||||
for (const suit of SUITS) {
|
||||
const cards = pile.filter(c => c.suit === suit);
|
||||
if (cards.length === 0) return 0; // can't score primiera without all 4 suits
|
||||
const best = Math.max(...cards.map(c => PRIMIERA_VALUES[c.value]));
|
||||
total += best;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function getScoreBreakdown(state: GameState): ScoreBreakdown {
|
||||
const team0 = [state.players[0], state.players[2]];
|
||||
const team1 = [state.players[1], state.players[3]];
|
||||
return scoreRound(team0, team1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function teamOf(playerIdx: PlayerIndex): 0 | 1 {
|
||||
return (playerIdx === 0 || playerIdx === 2) ? 0 : 1;
|
||||
}
|
||||
69
src/game/types.ts
Normal file
69
src/game/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type Suit = 'bastoni' | 'coppe' | 'denara' | 'spade';
|
||||
export const SUITS: Suit[] = ['bastoni', 'coppe', 'denara', 'spade'];
|
||||
|
||||
export interface Card {
|
||||
suit: Suit;
|
||||
value: number; // 1-10
|
||||
id: string; // e.g. "denara_7"
|
||||
}
|
||||
|
||||
export interface Capture {
|
||||
played: Card;
|
||||
captured: Card[];
|
||||
}
|
||||
|
||||
export type PlayerIndex = 0 | 1 | 2 | 3;
|
||||
|
||||
export interface Player {
|
||||
index: PlayerIndex;
|
||||
hand: Card[];
|
||||
pile: Card[]; // captured cards
|
||||
scope: number; // number of scope achieved
|
||||
isHuman: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
players: [Player, Player, Player, Player];
|
||||
table: Card[];
|
||||
currentPlayer: PlayerIndex;
|
||||
roundOver: boolean;
|
||||
gameOver: boolean;
|
||||
teamScores: [TeamScore, TeamScore]; // Team 0: players 0+2, Team 1: players 1+3
|
||||
lastCapturTeam: 0 | 1 | null; // which team made the last capture (gets remaining table cards)
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
export interface TeamScore {
|
||||
cards: number;
|
||||
scope: number;
|
||||
denari: number;
|
||||
settebello: boolean;
|
||||
primiera: number;
|
||||
// final points for this round
|
||||
roundPoints: number;
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
cartePoint: 0 | 1 | null; // null = tie
|
||||
denariPoint: 0 | 1 | null;
|
||||
settebelloPoint: 0 | 1;
|
||||
primieraPoint: 0 | 1 | null;
|
||||
scopeTeam0: number;
|
||||
scopeTeam1: number;
|
||||
}
|
||||
|
||||
// Primiera values per card value
|
||||
export const PRIMIERA_VALUES: Record<number, number> = {
|
||||
7: 21,
|
||||
6: 18,
|
||||
1: 16,
|
||||
5: 15,
|
||||
4: 14,
|
||||
3: 13,
|
||||
2: 12,
|
||||
8: 10,
|
||||
9: 10,
|
||||
10: 10,
|
||||
};
|
||||
19
src/main.ts
Normal file
19
src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Phaser from 'phaser';
|
||||
import { BootScene } from './scenes/BootScene';
|
||||
import { MenuScene } from './scenes/MenuScene';
|
||||
import { GameScene } from './scenes/GameScene';
|
||||
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
backgroundColor: '#1a5c2a',
|
||||
parent: 'game',
|
||||
scene: [BootScene, MenuScene, GameScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
};
|
||||
|
||||
new Phaser.Game(config);
|
||||
47
src/scenes/BootScene.ts
Normal file
47
src/scenes/BootScene.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/**
|
||||
* BootScene — loads all assets before the game starts.
|
||||
* Uses atlas loaded from public/atlas.json (Phaser hash format, converted from Napoletane atlas).
|
||||
*
|
||||
* [trueref /local/phaser] — this.load.atlas() loads a texture atlas where frames are
|
||||
* referenced by name (e.g. 'bastoni_7') using the Phaser hash format JSON.
|
||||
*/
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload(): void {
|
||||
const W = this.scale.width;
|
||||
const H = this.scale.height;
|
||||
|
||||
// Loading bar
|
||||
const bar = this.add.rectangle(W / 2, H / 2, 400, 20, 0x4caf50);
|
||||
const barBg = this.add.rectangle(W / 2, H / 2, 402, 22, 0x1a5c2a);
|
||||
barBg.setDepth(0);
|
||||
bar.setDepth(1);
|
||||
bar.setOrigin(0.5);
|
||||
bar.scaleX = 0;
|
||||
|
||||
const label = this.add.text(W / 2, H / 2 - 40, 'Caricamento...', {
|
||||
fontFamily: 'serif',
|
||||
fontSize: '24px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.load.on('progress', (value: number) => {
|
||||
bar.scaleX = value;
|
||||
});
|
||||
|
||||
// Load card atlas (Phaser hash format converted from Napoletane atlas)
|
||||
this.load.atlas('cards', 'atlas.png', 'atlas.json');
|
||||
|
||||
// Load card back separately
|
||||
this.load.image('retro', 'retro.png');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.scene.start('MenuScene');
|
||||
}
|
||||
}
|
||||
1331
src/scenes/GameScene.ts
Normal file
1331
src/scenes/GameScene.ts
Normal file
File diff suppressed because it is too large
Load Diff
71
src/scenes/MenuScene.ts
Normal file
71
src/scenes/MenuScene.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MenuScene' });
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const W = this.scale.width;
|
||||
const H = this.scale.height;
|
||||
|
||||
// Background felt
|
||||
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0);
|
||||
|
||||
// Title
|
||||
this.add.text(W / 2, H * 0.2, 'Scopone Scientifico', {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '52px',
|
||||
color: '#ffd700',
|
||||
stroke: '#000000',
|
||||
strokeThickness: 4,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(W / 2, H * 0.32, '2 vs 2 · Tu + Compagno vs 2 AI', {
|
||||
fontFamily: 'serif',
|
||||
fontSize: '22px',
|
||||
color: '#ccffcc',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Rules summary
|
||||
const rules = [
|
||||
'40 carte Napoletane · 10 a testa',
|
||||
'Cattura per valore o somma',
|
||||
'Punteggio: Carte · Denari · Settebello · Primiera · Scope',
|
||||
'Prima squadra a 11 punti vince',
|
||||
];
|
||||
rules.forEach((line, i) => {
|
||||
this.add.text(W / 2, H * 0.44 + i * 28, line, {
|
||||
fontFamily: 'serif',
|
||||
fontSize: '18px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
});
|
||||
|
||||
// Start button
|
||||
const btn = this.add.rectangle(W / 2, H * 0.72, 220, 60, 0xffd700, 1)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const btnText = this.add.text(W / 2, H * 0.72, 'INIZIA PARTITA', {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '22px',
|
||||
color: '#1a5c2a',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
btn.on('pointerover', () => btn.setFillStyle(0xffec6e));
|
||||
btn.on('pointerout', () => btn.setFillStyle(0xffd700));
|
||||
btn.on('pointerdown', () => {
|
||||
this.cameras.main.fadeOut(300, 0, 30, 0);
|
||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
});
|
||||
|
||||
// Show some face-down cards decoratively
|
||||
const positions = [
|
||||
[W * 0.1, H * 0.5], [W * 0.15, H * 0.52], [W * 0.9, H * 0.5], [W * 0.85, H * 0.52],
|
||||
];
|
||||
for (const [x, y] of positions) {
|
||||
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user