initial commit
This commit is contained in:
commit
2b6943c319
|
|
@ -0,0 +1,62 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Virtue Slots** is a browser-based Phaser 3 slot machine game with a Christian religious theme. The full design specification is in `software.md`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Phaser 3** (loaded via CDN or local script tag — no bundler)
|
||||
- **Vanilla JavaScript with ES6 modules** (`import`/`export`)
|
||||
- **No build step, no webpack/vite/bundler** — files are served directly
|
||||
- **1600x900 canvas**, scaled to the user's viewport
|
||||
|
||||
## Running the Game
|
||||
|
||||
Serve the files over HTTP (browsers block ES6 module imports from `file://`):
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
# or
|
||||
npx serve .
|
||||
```
|
||||
|
||||
Then open `http://localhost:8080` in a browser.
|
||||
|
||||
## Architecture
|
||||
|
||||
The game uses ES6 modules with direct object references — no bundler required. Structure files as Phaser Scenes or plain classes, each in its own file, exported and imported directly.
|
||||
|
||||
Key architecture decisions from `software.md`:
|
||||
- **No web packager** — all JS must be loadable via `<script type="module">` or Phaser's own loader
|
||||
- **Modular and scalable** — each symbol, scene, and UI component should be its own class/file
|
||||
- **Vector graphics first** — use Phaser's Graphics API as placeholders; sprites can replace them later
|
||||
|
||||
### Game Structure (from spec)
|
||||
|
||||
- **UI layout**: Three boxes at top (Your Funds / The Lord / Sin), slot reels in center, wide message/spin area at bottom
|
||||
- **Economy**: Player starts with $1000; spins cost $50; wins split 60% player / 40% to "The Lord" (tithing)
|
||||
- **Symbols**: 10 religious Christian symbols (baby jesus, cross, jesus on cross, crown of thorns, halo, etc.) — holier symbols yield higher payouts on match
|
||||
- **Win rate**: ~1 in 15 spins
|
||||
- **Win animation**: Screen brightens, gold/money icons rise to player and lord funds (angel-ascending-with-cash effect)
|
||||
- **Loss animation**: Devil carries spent money up to the Sin box; message "Thou Hath Sinned." + "Redeem Yourself!" near spin button
|
||||
- **Controls**: Click Spin button or press Space Bar
|
||||
|
||||
### Suggested Scene/File Layout
|
||||
|
||||
```
|
||||
index.html — Loads Phaser and main.js as type="module"
|
||||
main.js — Phaser game config, registers scenes
|
||||
scenes/
|
||||
BootScene.js — Asset preload
|
||||
GameScene.js — Main gameplay
|
||||
UIScene.js — Overlay HUD (funds display, message box)
|
||||
objects/
|
||||
Reel.js — Single reel logic and animation
|
||||
SlotMachine.js — Orchestrates 3 reels, spin/result logic
|
||||
Symbol.js — Symbol definitions, payout values
|
||||
WinAnimation.js — Holy win effect
|
||||
LossAnimation.js — Devil/sin effect
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Virtue Slots</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #000; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"></script>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import BootScene from './scenes/BootScene.js';
|
||||
import GameScene from './scenes/GameScene.js';
|
||||
import UIScene from './scenes/UIScene.js';
|
||||
|
||||
new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
backgroundColor: '#1a0a2e',
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
||||
},
|
||||
scene: [BootScene, GameScene, UIScene]
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
export class LossAnimation {
|
||||
// sinBoxCenter: { x, y } screen position of the Sin fund box
|
||||
play(scene, originX, originY, sinBoxCenter, onComplete) {
|
||||
// Draw devil: red triangle body + horns
|
||||
const devilGfx = scene.add.graphics();
|
||||
|
||||
// Body — red triangle
|
||||
devilGfx.fillStyle(0xcc1111, 1);
|
||||
devilGfx.fillTriangle(-24, 20, 24, 20, 0, -20);
|
||||
|
||||
// Head — red circle
|
||||
devilGfx.fillStyle(0xdd2222, 1);
|
||||
devilGfx.fillCircle(0, -30, 16);
|
||||
|
||||
// Horns
|
||||
devilGfx.fillStyle(0x880000, 1);
|
||||
devilGfx.fillTriangle(-14, -42, -8, -42, -11, -56);
|
||||
devilGfx.fillTriangle(8, -42, 14, -42, 11, -56);
|
||||
|
||||
// Eyes
|
||||
devilGfx.fillStyle(0xffff00, 1);
|
||||
devilGfx.fillCircle(-6, -32, 4);
|
||||
devilGfx.fillCircle(6, -32, 4);
|
||||
devilGfx.fillStyle(0x000000, 1);
|
||||
devilGfx.fillCircle(-6, -32, 2);
|
||||
devilGfx.fillCircle(6, -32, 2);
|
||||
|
||||
// Tail
|
||||
devilGfx.lineStyle(3, 0x880000, 1);
|
||||
devilGfx.beginPath();
|
||||
devilGfx.moveTo(16, 10);
|
||||
devilGfx.lineTo(30, 0);
|
||||
devilGfx.lineTo(36, 14);
|
||||
devilGfx.strokePath();
|
||||
// Tail tip arrowhead
|
||||
devilGfx.fillStyle(0x880000, 1);
|
||||
devilGfx.fillTriangle(30, 10, 36, 14, 40, 6);
|
||||
|
||||
devilGfx.setPosition(originX, originY);
|
||||
|
||||
// Money bag label
|
||||
const moneyText = scene.add.text(originX + 10, originY - 60, '💰', {
|
||||
fontSize: '28px'
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
// Screen tint to dark red briefly
|
||||
scene.cameras.main.flash(800, 150, 0, 0, true);
|
||||
|
||||
// Tween devil + money up to sin box
|
||||
scene.tweens.add({
|
||||
targets: [devilGfx, moneyText],
|
||||
x: `+=${sinBoxCenter.x - originX}`,
|
||||
y: `+=${sinBoxCenter.y - originY}`,
|
||||
duration: 2800,
|
||||
ease: 'Cubic.easeInOut',
|
||||
onComplete: () => {
|
||||
// Fade out
|
||||
scene.tweens.add({
|
||||
targets: [devilGfx, moneyText],
|
||||
alpha: 0,
|
||||
duration: 600,
|
||||
onComplete: () => {
|
||||
devilGfx.destroy();
|
||||
moneyText.destroy();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wobble
|
||||
scene.tweens.add({
|
||||
targets: devilGfx,
|
||||
angle: { from: -8, to: 8 },
|
||||
duration: 400,
|
||||
repeat: 6,
|
||||
yoyo: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
export class MatchBanner {
|
||||
play(scene, label, onComplete) {
|
||||
const cx = 800;
|
||||
const cy = 450;
|
||||
|
||||
// Dark overlay behind everything
|
||||
const overlay = scene.add.graphics();
|
||||
overlay.fillStyle(0x000000, 0.65);
|
||||
overlay.fillRect(0, 0, 1600, 900);
|
||||
overlay.setAlpha(0).setDepth(10);
|
||||
|
||||
// Expanding burst ring from center
|
||||
const burst = scene.add.graphics();
|
||||
burst.lineStyle(10, 0xffd700, 1);
|
||||
burst.strokeCircle(0, 0, 50);
|
||||
burst.setPosition(cx, cy).setDepth(11);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: burst,
|
||||
scaleX: 18, scaleY: 18,
|
||||
alpha: 0,
|
||||
duration: 650,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => burst.destroy()
|
||||
});
|
||||
|
||||
// Main banner container — starts tiny, punches in
|
||||
const container = scene.add.container(cx, cy).setScale(0.01).setDepth(12);
|
||||
|
||||
const PW = 1060, PH = 260, PR = 22;
|
||||
|
||||
// Panel background
|
||||
const panel = scene.add.graphics();
|
||||
panel.fillStyle(0x0d0520, 0.97);
|
||||
panel.fillRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
|
||||
// Sharp gold inner border
|
||||
panel.lineStyle(5, 0xffd700, 1);
|
||||
panel.strokeRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
|
||||
// Soft outer glow border
|
||||
panel.lineStyle(16, 0xffd700, 0.18);
|
||||
panel.strokeRoundedRect(-PW / 2 - 9, -PH / 2 - 9, PW + 18, PH + 18, PR + 9);
|
||||
|
||||
// Horizontal copper divider
|
||||
const divider = scene.add.graphics();
|
||||
divider.lineStyle(2, 0xc8a87e, 0.55);
|
||||
divider.beginPath();
|
||||
divider.moveTo(-430, -8);
|
||||
divider.lineTo(430, -8);
|
||||
divider.strokePath();
|
||||
|
||||
// Corner cross ornaments
|
||||
const cornerPositions = [
|
||||
[-PW / 2 + 30, -PH / 2 + 24],
|
||||
[ PW / 2 - 30, -PH / 2 + 24],
|
||||
[-PW / 2 + 30, PH / 2 - 24],
|
||||
[ PW / 2 - 30, PH / 2 - 24],
|
||||
];
|
||||
const cornerCrosses = cornerPositions.map(([ox, oy]) =>
|
||||
scene.add.text(ox, oy, '✝', {
|
||||
fontSize: '22px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#c8a87e',
|
||||
}).setOrigin(0.5, 0.5).setAlpha(0.65)
|
||||
);
|
||||
|
||||
// "M A T C H" header
|
||||
const matchTxt = scene.add.text(0, -72, 'M A T C H', {
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#c8a87e',
|
||||
stroke: '#2a0a00',
|
||||
strokeThickness: 2,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
// "3× {LABEL}" main text
|
||||
const mainTxt = scene.add.text(0, 58, `3\u00D7 ${label.toUpperCase()}`, {
|
||||
fontSize: '88px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#ffd700',
|
||||
stroke: '#2a0a00',
|
||||
strokeThickness: 7,
|
||||
shadow: { offsetX: 0, offsetY: 0, color: '#ffd700', blur: 28, fill: true },
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
container.add([panel, divider, ...cornerCrosses, matchTxt, mainTxt]);
|
||||
|
||||
// Sparkle particles burst outward
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const angle = (i / 30) * Math.PI * 2 + Phaser.Math.FloatBetween(-0.15, 0.15);
|
||||
const dist = Phaser.Math.Between(100, 420);
|
||||
const size = Phaser.Math.Between(16, 48);
|
||||
const glyph = ['✦', '★', '✝', '✦', '★'][Math.floor(Math.random() * 5)];
|
||||
|
||||
const sparkle = scene.add.text(
|
||||
cx + Math.cos(angle) * 15,
|
||||
cy + Math.sin(angle) * 15,
|
||||
glyph,
|
||||
{ fontSize: `${size}px`, fontFamily: 'Georgia, serif', color: '#ffd700' }
|
||||
).setOrigin(0.5, 0.5).setAlpha(0).setDepth(13);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: sparkle,
|
||||
x: cx + Math.cos(angle) * dist,
|
||||
y: cy + Math.sin(angle) * dist,
|
||||
alpha: { from: 0, to: 1 },
|
||||
scale: { from: 0.1, to: 1 },
|
||||
duration: Phaser.Math.Between(280, 650),
|
||||
delay: Phaser.Math.Between(40, 320),
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
scene.tweens.add({
|
||||
targets: sparkle,
|
||||
alpha: 0,
|
||||
scale: 0.4,
|
||||
duration: Phaser.Math.Between(350, 800),
|
||||
delay: Phaser.Math.Between(150, 500),
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => sparkle.destroy(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fade in overlay
|
||||
scene.tweens.add({
|
||||
targets: overlay,
|
||||
alpha: 1,
|
||||
duration: 140,
|
||||
});
|
||||
|
||||
// Punch in the banner with elastic overshoot
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1,
|
||||
duration: 520,
|
||||
ease: 'Back.easeOut',
|
||||
easeParams: [4],
|
||||
onComplete: () => {
|
||||
// Scale pulse ×3
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1.035,
|
||||
duration: 200,
|
||||
yoyo: true,
|
||||
repeat: 2,
|
||||
ease: 'Sine.easeInOut',
|
||||
onComplete: () => {
|
||||
// Gold shimmer flicker on main text
|
||||
scene.tweens.add({
|
||||
targets: mainTxt,
|
||||
alpha: 0.55,
|
||||
duration: 90,
|
||||
yoyo: true,
|
||||
repeat: 4,
|
||||
ease: 'Linear',
|
||||
onComplete: () => {
|
||||
// Hold, then zoom-fade out
|
||||
scene.time.delayedCall(650, () => {
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1.14,
|
||||
alpha: 0,
|
||||
duration: 420,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => container.destroy(),
|
||||
});
|
||||
scene.tweens.add({
|
||||
targets: overlay,
|
||||
alpha: 0,
|
||||
duration: 420,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
overlay.destroy();
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { SYMBOLS } from './Symbol.js';
|
||||
|
||||
const SYMBOL_HEIGHT = 110;
|
||||
const SYMBOL_WIDTH = 200;
|
||||
const VISIBLE_COUNT = 3;
|
||||
const REEL_HEIGHT = SYMBOL_HEIGHT * VISIBLE_COUNT;
|
||||
const BUFFER = 2; // extra cells above/below visible area for smooth scroll
|
||||
const POOL_SIZE = VISIBLE_COUNT + BUFFER * 2;
|
||||
|
||||
export class Reel {
|
||||
constructor(scene, x, y) {
|
||||
this.scene = scene;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = SYMBOL_WIDTH;
|
||||
this.h = REEL_HEIGHT;
|
||||
|
||||
// Virtual infinite strip: cycle through SYMBOLS array using modulo
|
||||
this.strip = SYMBOLS; // reference, never mutated
|
||||
this.scrollY = 0;
|
||||
|
||||
// Container with mask for clipping
|
||||
this.container = scene.add.container(x, y);
|
||||
const maskShape = scene.make.graphics({ add: false });
|
||||
maskShape.fillRect(x, y, this.w, this.h);
|
||||
this.container.setMask(maskShape.createGeometryMask());
|
||||
|
||||
// Small pool of Graphics + Sprite + Text triples (only as many as visible + buffer)
|
||||
this.cells = [];
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const gfx = scene.add.graphics();
|
||||
const spr = scene.add.sprite(0, 0, 'symbols', 0).setOrigin(0.5, 0.5);
|
||||
this.container.add(gfx);
|
||||
this.container.add(spr);
|
||||
this.cells.push({ gfx, spr });
|
||||
}
|
||||
|
||||
this._draw();
|
||||
}
|
||||
|
||||
// Returns the symbol showing in the center (result) slot
|
||||
getCenter() {
|
||||
const s = -this.scrollY;
|
||||
const centerScrollY = s + SYMBOL_HEIGHT;
|
||||
const idx = Math.floor(centerScrollY / SYMBOL_HEIGHT);
|
||||
return this.strip[((idx % this.strip.length) + this.strip.length) % this.strip.length];
|
||||
}
|
||||
|
||||
_draw() {
|
||||
// Negate scrollY so increasing scrollY moves symbols downward (top-to-bottom direction)
|
||||
const s = -this.scrollY;
|
||||
const topCellIdx = Math.floor(s / SYMBOL_HEIGHT);
|
||||
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const virtualIdx = topCellIdx - BUFFER + i;
|
||||
const symbolIdx = ((virtualIdx % this.strip.length) + this.strip.length) % this.strip.length;
|
||||
const sym = this.strip[symbolIdx];
|
||||
const cellY = virtualIdx * SYMBOL_HEIGHT - s;
|
||||
|
||||
const { gfx, spr } = this.cells[i];
|
||||
gfx.clear();
|
||||
gfx.fillStyle(sym.color, 1);
|
||||
gfx.fillRoundedRect(2, cellY + 2, this.w - 4, SYMBOL_HEIGHT - 4, 8);
|
||||
gfx.lineStyle(2, 0xffffff, 0.35);
|
||||
gfx.strokeRoundedRect(2, cellY + 2, this.w - 4, SYMBOL_HEIGHT - 4, 8);
|
||||
|
||||
spr.setFrame(symbolIdx);
|
||||
spr.setPosition(this.w / 2, cellY + SYMBOL_HEIGHT / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Spin to targetSymbol. Duration controls how long the fast phase lasts.
|
||||
// onComplete fires when the reel finishes.
|
||||
spin(targetSymbol, duration, onComplete) {
|
||||
const scene = this.scene;
|
||||
|
||||
// Pre-calculate a target scrollY that:
|
||||
// - lands targetSymbol in the center slot (scrollY = targetIdx * SYMBOL_HEIGHT - SYMBOL_HEIGHT)
|
||||
// - is strictly greater than current scrollY by at least MIN_ADVANCE
|
||||
const MIN_ADVANCE = SYMBOL_HEIGHT * 10; // enough for a satisfying visible spin
|
||||
const stripHeight = this.strip.length * SYMBOL_HEIGHT;
|
||||
|
||||
const targetSymbolIdx = this.strip.findIndex(s => s.id === targetSymbol.id);
|
||||
// With negated draw: center when -scrollY = targetSymbolIdx * SYMBOL_HEIGHT - SYMBOL_HEIGHT
|
||||
// => scrollY = SYMBOL_HEIGHT - targetSymbolIdx * SYMBOL_HEIGHT
|
||||
let targetScrollY = SYMBOL_HEIGHT - targetSymbolIdx * SYMBOL_HEIGHT;
|
||||
|
||||
// scrollY must DECREASE by at least MIN_ADVANCE (spin goes in negative direction)
|
||||
while (targetScrollY >= this.scrollY - MIN_ADVANCE) {
|
||||
targetScrollY -= stripHeight;
|
||||
}
|
||||
|
||||
// Phase 1: fast linear scroll to ~2 symbols before the final landing
|
||||
const fastTarget = targetScrollY + SYMBOL_HEIGHT * 2;
|
||||
|
||||
scene.tweens.add({
|
||||
targets: this,
|
||||
scrollY: fastTarget,
|
||||
duration: duration,
|
||||
ease: 'Linear',
|
||||
onUpdate: () => this._draw(),
|
||||
onComplete: () => {
|
||||
// Phase 2: decelerate smoothly into the final position
|
||||
scene.tweens.add({
|
||||
targets: this,
|
||||
scrollY: targetScrollY,
|
||||
duration: 650,
|
||||
ease: 'Cubic.easeOut',
|
||||
onUpdate: () => this._draw(),
|
||||
onComplete: () => {
|
||||
this.scrollY = targetScrollY;
|
||||
this._draw();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getWidth() { return this.w; }
|
||||
getHeight() { return this.h; }
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { SYMBOLS } from './Symbol.js';
|
||||
import { Reel } from './Reel.js';
|
||||
import { shouldWin, pickResults } from '../utils/RNG.js';
|
||||
|
||||
const REEL_GAP = 20;
|
||||
const REEL_WIDTH = 200;
|
||||
const REEL_HEIGHT = 330; // 3 * 110 (matches Reel.js SYMBOL_HEIGHT)
|
||||
|
||||
export class SlotMachine {
|
||||
constructor(scene, centerX, centerY) {
|
||||
this.scene = scene;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
|
||||
// Draw the machine frame
|
||||
this.framGfx = scene.add.graphics();
|
||||
this._drawFrame();
|
||||
|
||||
// Three reels, side by side
|
||||
const totalWidth = REEL_WIDTH * 3 + REEL_GAP * 2;
|
||||
const startX = centerX - totalWidth / 2;
|
||||
const startY = centerY - REEL_HEIGHT / 2;
|
||||
|
||||
this.reels = [
|
||||
new Reel(scene, startX, startY),
|
||||
new Reel(scene, startX + REEL_WIDTH + REEL_GAP, startY),
|
||||
new Reel(scene, startX + (REEL_WIDTH + REEL_GAP) * 2, startY),
|
||||
];
|
||||
|
||||
// Center line indicator
|
||||
const lineGfx = scene.add.graphics();
|
||||
lineGfx.lineStyle(3, 0xffd700, 0.9);
|
||||
lineGfx.beginPath();
|
||||
lineGfx.moveTo(startX - 10, centerY);
|
||||
lineGfx.lineTo(startX + totalWidth + 10, centerY);
|
||||
lineGfx.strokePath();
|
||||
|
||||
this.lastResults = null;
|
||||
}
|
||||
|
||||
_drawFrame() {
|
||||
const g = this.framGfx;
|
||||
const cx = this.centerX;
|
||||
const cy = this.centerY;
|
||||
const w = REEL_WIDTH * 3 + REEL_GAP * 2 + 60;
|
||||
const h = REEL_HEIGHT + 60;
|
||||
|
||||
// Outer frame
|
||||
g.fillStyle(0x2a1040, 1);
|
||||
g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 16);
|
||||
g.lineStyle(4, 0xffd700, 1);
|
||||
g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 16);
|
||||
|
||||
// Inner shadow
|
||||
g.lineStyle(2, 0x8844aa, 0.6);
|
||||
g.strokeRoundedRect(cx - w / 2 + 6, cy - h / 2 + 6, w - 12, h - 12, 12);
|
||||
}
|
||||
|
||||
// spin(onComplete) — onComplete receives { win, symbols, payout }
|
||||
spin(onComplete) {
|
||||
const win = shouldWin();
|
||||
const results = pickResults(SYMBOLS, win);
|
||||
this.lastResults = results;
|
||||
|
||||
const payout = win ? results[0].payout : 0;
|
||||
|
||||
let doneCount = 0;
|
||||
const totalReels = this.reels.length;
|
||||
const stopDelays = [800, 1300, 1800]; // ms before each reel starts decelerating
|
||||
|
||||
this.reels.forEach((reel, i) => {
|
||||
reel.spin(results[i], stopDelays[i], () => {
|
||||
doneCount++;
|
||||
if (doneCount === totalReels) {
|
||||
onComplete({ win, symbols: results, payout });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCenterX() { return this.centerX; }
|
||||
getCenterY() { return this.centerY; }
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Symbols ordered by holiness ascending (index 0 = least holy)
|
||||
export const SYMBOLS = [
|
||||
{ id: 'dove', label: 'Dove', holiness: 1, payout: 100, color: 0xd0e8ff },
|
||||
{ id: 'joel', label: 'Joel Osteen', holiness: 2, payout: 150, color: 0xa8d8ea },
|
||||
{ id: 'bible', label: 'Holy Bible', holiness: 3, payout: 200, color: 0x7ec8e3 },
|
||||
{ id: 'lamb', label: 'Lamb of God', holiness: 4, payout: 250, color: 0x88d498 },
|
||||
{ id: 'crown_thorns', label: 'Crown of Thorns', holiness: 5, payout: 350, color: 0xc8a87e },
|
||||
{ id: 'halo', label: 'Halo', holiness: 6, payout: 450, color: 0xf0d060 },
|
||||
{ id: 'cross', label: 'The Cross', holiness: 7, payout: 600, color: 0xf0a830 },
|
||||
{ id: 'jesus_cross', label: 'Jesus on Cross', holiness: 8, payout: 800, color: 0xe88020 },
|
||||
{ id: 'baby_jesus', label: 'Baby Jesus', holiness: 9, payout: 1000, color: 0xff9944 },
|
||||
{ id: 'holy_grail', label: 'Holy Grail', holiness: 10, payout: 1500, color: 0xffd700 },
|
||||
];
|
||||
|
||||
export function getSymbolById(id) {
|
||||
return SYMBOLS.find(s => s.id === id);
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { MatchBanner } from './MatchBanner.js';
|
||||
|
||||
export class WinAnimation {
|
||||
// playerBoxCenter, lordBoxCenter: { x, y } screen positions of fund boxes
|
||||
play(scene, originX, originY, playerBoxCenter, lordBoxCenter, symbol, onComplete) {
|
||||
new MatchBanner().play(scene, symbol.label, () => {
|
||||
// Flash the screen gold
|
||||
scene.cameras.main.flash(1200, 255, 215, 0, true);
|
||||
|
||||
const totalCoins = 20;
|
||||
const playerCoins = Math.round(totalCoins * 0.6);
|
||||
const lordCoins = totalCoins - playerCoins;
|
||||
let finished = 0;
|
||||
|
||||
// --- Split labels ---
|
||||
const spawnSplitLabel = (lx, topLine, pctLine, borderColor, textColor) => {
|
||||
const ly = 158;
|
||||
const PW = 240, PH = 86, PR = 10;
|
||||
const ctr = scene.add.container(lx, ly).setScale(0).setAlpha(0).setDepth(5);
|
||||
|
||||
const bg = scene.add.graphics();
|
||||
// Panel fill
|
||||
bg.fillStyle(0x0d0520, 0.96);
|
||||
bg.fillRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
|
||||
// Inner gold border
|
||||
bg.lineStyle(3, borderColor, 1);
|
||||
bg.strokeRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
|
||||
// Outer glow
|
||||
bg.lineStyle(12, borderColor, 0.2);
|
||||
bg.strokeRoundedRect(-PW / 2 - 6, -PH / 2 - 6, PW + 12, PH + 12, PR + 6);
|
||||
// Arrow tip pointing up toward the box above
|
||||
bg.fillStyle(borderColor, 1);
|
||||
bg.fillTriangle(-11, -PH / 2 + 2, 11, -PH / 2 + 2, 0, -PH / 2 - 15);
|
||||
|
||||
const t1 = scene.add.text(0, -20, topLine, {
|
||||
fontSize: '13px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#a89878',
|
||||
letterSpacing: 2,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
const t2 = scene.add.text(0, 18, pctLine, {
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontStyle: 'bold',
|
||||
color: textColor,
|
||||
stroke: '#05020e',
|
||||
strokeThickness: 5,
|
||||
shadow: { offsetX: 0, offsetY: 0, color: textColor, blur: 16, fill: true },
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
ctr.add([bg, t1, t2]);
|
||||
|
||||
// Punch in
|
||||
scene.tweens.add({
|
||||
targets: ctr,
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
duration: 380,
|
||||
delay: 120,
|
||||
ease: 'Back.easeOut',
|
||||
easeParams: [3.5],
|
||||
onComplete: () => {
|
||||
// Vigorous shake
|
||||
scene.tweens.add({
|
||||
targets: ctr,
|
||||
x: { from: lx - 11, to: lx + 11 },
|
||||
duration: 55,
|
||||
yoyo: true,
|
||||
repeat: 8,
|
||||
ease: 'Sine.easeInOut',
|
||||
onComplete: () => {
|
||||
// Pulse the percentage number
|
||||
scene.tweens.add({
|
||||
targets: t2,
|
||||
scale: 1.3,
|
||||
duration: 120,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
// Gentle breathe on the whole panel
|
||||
scene.tweens.add({
|
||||
targets: ctr,
|
||||
scale: 1.05,
|
||||
duration: 500,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { ctr, stopBreath: () => scene.tweens.killTweensOf(ctr) };
|
||||
};
|
||||
|
||||
const { ctr: playerLabel, stopBreath: stopPlayer } = spawnSplitLabel(
|
||||
playerBoxCenter.x, 'YOUR WINNINGS', '60%', 0x55cc77, '#55cc77'
|
||||
);
|
||||
const { ctr: lordLabel, stopBreath: stopLord } = spawnSplitLabel(
|
||||
lordBoxCenter.x, "THE LORD'S TITHE", '40%', 0xaa88ff, '#aa88ff'
|
||||
);
|
||||
|
||||
const spawnCoin = (targetX, targetY) => {
|
||||
const radius = 26;
|
||||
const gfx = scene.add.graphics();
|
||||
// Main coin body
|
||||
gfx.fillStyle(0xffd700, 1);
|
||||
gfx.fillCircle(0, 0, radius);
|
||||
// Outer ring
|
||||
gfx.lineStyle(3, 0xffa500, 1);
|
||||
gfx.strokeCircle(0, 0, radius);
|
||||
// Inner highlight
|
||||
gfx.fillStyle(0xffe980, 0.6);
|
||||
gfx.fillCircle(-6, -6, radius * 0.38);
|
||||
|
||||
const coinLabel = scene.add.text(0, 1, '$', {
|
||||
fontSize: '22px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontStyle: 'bold',
|
||||
color: '#5a3000'
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
const startX = originX + Phaser.Math.Between(-70, 70);
|
||||
const startY = originY + Phaser.Math.Between(-30, 30);
|
||||
const container = scene.add.container(startX, startY, [gfx, coinLabel]);
|
||||
container.setScale(0.1);
|
||||
|
||||
const delay = Phaser.Math.Between(0, 700);
|
||||
// Arc peak: shoot upward between origin and target, then fall to box
|
||||
const peakX = startX + (targetX - startX) * 0.35 + Phaser.Math.Between(-100, 100);
|
||||
const peakY = Math.min(startY, targetY) - Phaser.Math.Between(180, 340);
|
||||
const spinDir = Phaser.Math.Between(0, 1) ? 1 : -1;
|
||||
|
||||
// Phase 1: pop in at origin
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
scale: 1.5,
|
||||
duration: 220,
|
||||
delay,
|
||||
ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
// Phase 2: arc up to peak
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
x: peakX,
|
||||
y: peakY,
|
||||
angle: spinDir * Phaser.Math.Between(120, 200),
|
||||
duration: 480,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
// Phase 3: fall to target box, shrink and fade at arrival
|
||||
scene.tweens.add({
|
||||
targets: container,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
scale: 0.4,
|
||||
angle: `+=${spinDir * Phaser.Math.Between(200, 400)}`,
|
||||
alpha: { from: 1, to: 0 },
|
||||
duration: 900,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
container.destroy();
|
||||
finished++;
|
||||
if (finished === totalCoins) {
|
||||
stopPlayer();
|
||||
stopLord();
|
||||
scene.tweens.add({
|
||||
targets: [playerLabel, lordLabel],
|
||||
alpha: 0,
|
||||
scale: 0.7,
|
||||
duration: 380,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
playerLabel.destroy();
|
||||
lordLabel.destroy();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Halo glow ring expanding from center
|
||||
const halo = scene.add.graphics();
|
||||
halo.lineStyle(6, 0xffd700, 0.8);
|
||||
halo.strokeCircle(0, 0, 10);
|
||||
halo.setPosition(originX, originY);
|
||||
|
||||
scene.tweens.add({
|
||||
targets: halo,
|
||||
scaleX: 8, scaleY: 8,
|
||||
alpha: 0,
|
||||
duration: 1800,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => halo.destroy()
|
||||
});
|
||||
|
||||
// Spawn coins to player box (60%)
|
||||
for (let i = 0; i < playerCoins; i++) {
|
||||
spawnCoin(playerBoxCenter.x, playerBoxCenter.y);
|
||||
}
|
||||
// Spawn coins to lord box (40%)
|
||||
for (let i = 0; i < lordCoins; i++) {
|
||||
spawnCoin(lordBoxCenter.x, lordBoxCenter.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export default class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
this.load.spritesheet('symbols', 'assets/symbol_sprites.png', { frameWidth: 200, frameHeight: 100 });
|
||||
}
|
||||
|
||||
create() {
|
||||
this.scene.start('GameScene');
|
||||
this.scene.launch('UIScene');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { GameState } from '../state/GameState.js';
|
||||
import { SlotMachine } from '../objects/SlotMachine.js';
|
||||
import { WinAnimation } from '../objects/WinAnimation.js';
|
||||
import { LossAnimation } from '../objects/LossAnimation.js';
|
||||
|
||||
export default class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Background gradient-ish
|
||||
const bg = this.add.graphics();
|
||||
bg.fillGradientStyle(0x1a0a2e, 0x1a0a2e, 0x2a0a4e, 0x2a0a4e, 1);
|
||||
bg.fillRect(0, 0, 1600, 900);
|
||||
|
||||
// Decorative stars — floating, twinkling, pulsing
|
||||
const starColors = [0xffffff, 0xffffff, 0xffffff, 0xffe8a0, 0xd0c8ff];
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const x = Phaser.Math.Between(0, 1600);
|
||||
const y = Phaser.Math.Between(120, 760);
|
||||
const r = Phaser.Math.Between(1, 3);
|
||||
const baseAlpha = Phaser.Math.FloatBetween(0.2, 0.75);
|
||||
const color = starColors[Math.floor(Math.random() * starColors.length)];
|
||||
|
||||
const starGfx = this.add.graphics();
|
||||
starGfx.fillStyle(color, 1);
|
||||
starGfx.fillCircle(0, 0, r);
|
||||
starGfx.setPosition(x, y);
|
||||
starGfx.setAlpha(baseAlpha);
|
||||
|
||||
// Gentle drift — each star wanders a small random distance
|
||||
this.tweens.add({
|
||||
targets: starGfx,
|
||||
x: x + Phaser.Math.Between(-18, 18),
|
||||
y: y + Phaser.Math.Between(-12, 12),
|
||||
duration: Phaser.Math.Between(3500, 8000),
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
delay: Phaser.Math.Between(0, 5000),
|
||||
});
|
||||
|
||||
// Twinkle — alpha fades in and out independently
|
||||
this.tweens.add({
|
||||
targets: starGfx,
|
||||
alpha: { from: baseAlpha * 0.15, to: baseAlpha },
|
||||
duration: Phaser.Math.Between(600, 2800),
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
delay: Phaser.Math.Between(0, 3000),
|
||||
});
|
||||
|
||||
// Scale pulse — grows and shrinks on its own rhythm
|
||||
this.tweens.add({
|
||||
targets: starGfx,
|
||||
scale: Phaser.Math.FloatBetween(1.4, 3.2),
|
||||
duration: Phaser.Math.Between(1200, 4500),
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
delay: Phaser.Math.Between(0, 4000),
|
||||
});
|
||||
}
|
||||
|
||||
// Title above the machine
|
||||
this.add.text(800, 150, 'VIRTUE SLOTS', {
|
||||
fontSize: '42px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#ffd700',
|
||||
stroke: '#5a3000',
|
||||
strokeThickness: 4,
|
||||
shadow: { offsetX: 2, offsetY: 2, color: '#000', blur: 6, fill: true }
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
this.add.text(800, 195, '✝ May Fortune Favor the Faithful ✝', {
|
||||
fontSize: '18px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#c8a87e',
|
||||
alpha: 0.8
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
// Slot machine at center of play area
|
||||
this.slotMachine = new SlotMachine(this, 800, 490);
|
||||
|
||||
this.winAnim = new WinAnimation();
|
||||
this.lossAnim = new LossAnimation();
|
||||
|
||||
// Keyboard: Space to spin
|
||||
this.input.keyboard.on('keydown-SPACE', () => this._triggerSpin());
|
||||
|
||||
// Listen for spin button events from UIScene via global event bus
|
||||
this.game.events.on('spin', () => this._triggerSpin(), this);
|
||||
}
|
||||
|
||||
_triggerSpin() {
|
||||
if (GameState.spinning) return;
|
||||
|
||||
if (GameState.playerFunds < GameState.spinCost) {
|
||||
this.game.events.emit('insufficient-funds');
|
||||
return;
|
||||
}
|
||||
|
||||
GameState.playerFunds -= GameState.spinCost;
|
||||
GameState.spinning = true;
|
||||
this.game.events.emit('funds-updated');
|
||||
|
||||
this.slotMachine.spin((result) => this._handleResult(result));
|
||||
}
|
||||
|
||||
_handleResult({ win, symbols, payout }) {
|
||||
if (win) {
|
||||
const playerGain = Math.round(payout * 0.6);
|
||||
const lordGain = payout - playerGain;
|
||||
GameState.playerFunds += playerGain;
|
||||
GameState.lordFunds += lordGain;
|
||||
|
||||
this.game.events.emit('win', { playerGain, lordGain, symbol: symbols[0] });
|
||||
this.game.events.emit('funds-updated');
|
||||
|
||||
// Resolve UI box positions from UIScene
|
||||
const uiScene = this.scene.get('UIScene');
|
||||
const playerBox = uiScene ? uiScene.getPlayerBoxCenter() : { x: 267, y: 60 };
|
||||
const lordBox = uiScene ? uiScene.getLordBoxCenter() : { x: 800, y: 60 };
|
||||
|
||||
this.winAnim.play(
|
||||
this,
|
||||
this.slotMachine.getCenterX(),
|
||||
this.slotMachine.getCenterY(),
|
||||
playerBox,
|
||||
lordBox,
|
||||
symbols[0],
|
||||
() => {
|
||||
GameState.spinning = false;
|
||||
this.game.events.emit('spin-complete');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
GameState.sinTotal += GameState.spinCost;
|
||||
|
||||
this.game.events.emit('loss', { sinAdded: GameState.spinCost });
|
||||
this.game.events.emit('funds-updated');
|
||||
|
||||
const uiScene = this.scene.get('UIScene');
|
||||
const sinBox = uiScene ? uiScene.getSinBoxCenter() : { x: 1333, y: 60 };
|
||||
|
||||
this.lossAnim.play(
|
||||
this,
|
||||
this.slotMachine.getCenterX(),
|
||||
this.slotMachine.getCenterY(),
|
||||
sinBox,
|
||||
() => {
|
||||
GameState.spinning = false;
|
||||
this.game.events.emit('spin-complete');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { GameState } from '../state/GameState.js';
|
||||
|
||||
const TOP_BAR_HEIGHT = 100;
|
||||
const BOTTOM_BAR_HEIGHT = 110;
|
||||
const BOTTOM_BAR_Y = 900 - BOTTOM_BAR_HEIGHT;
|
||||
|
||||
// Box centers (x) for the three top fund displays
|
||||
const BOX_WIDTH = 1600 / 3;
|
||||
const PLAYER_BOX_X = BOX_WIDTH * 0 + BOX_WIDTH / 2;
|
||||
const LORD_BOX_X = BOX_WIDTH * 1 + BOX_WIDTH / 2;
|
||||
const SIN_BOX_X = BOX_WIDTH * 2 + BOX_WIDTH / 2;
|
||||
const BOX_CENTER_Y = TOP_BAR_HEIGHT / 2;
|
||||
|
||||
export default class UIScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'UIScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
this._buildTopBar();
|
||||
this._buildBottomBar();
|
||||
this._buildSpinButton();
|
||||
this._bindEvents();
|
||||
this._updateFundDisplays();
|
||||
}
|
||||
|
||||
_buildTopBar() {
|
||||
const g = this.add.graphics();
|
||||
|
||||
// Background
|
||||
g.fillStyle(0x12082a, 1);
|
||||
g.fillRect(0, 0, 1600, TOP_BAR_HEIGHT);
|
||||
g.lineStyle(2, 0xffd700, 0.8);
|
||||
g.strokeRect(0, 0, 1600, TOP_BAR_HEIGHT);
|
||||
|
||||
// Dividers
|
||||
g.lineStyle(1, 0xffd700, 0.3);
|
||||
g.beginPath();
|
||||
g.moveTo(BOX_WIDTH, 8);
|
||||
g.lineTo(BOX_WIDTH, TOP_BAR_HEIGHT - 8);
|
||||
g.strokePath();
|
||||
g.beginPath();
|
||||
g.moveTo(BOX_WIDTH * 2, 8);
|
||||
g.lineTo(BOX_WIDTH * 2, TOP_BAR_HEIGHT - 8);
|
||||
g.strokePath();
|
||||
|
||||
// Box labels
|
||||
const labelStyle = {
|
||||
fontSize: '13px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#c8a87e',
|
||||
alpha: 0.8
|
||||
};
|
||||
this.add.text(PLAYER_BOX_X, 14, 'YOUR FUNDS', labelStyle).setOrigin(0.5, 0);
|
||||
this.add.text(LORD_BOX_X, 14, 'THE LORD', labelStyle).setOrigin(0.5, 0);
|
||||
this.add.text(SIN_BOX_X, 14, 'SIN', labelStyle).setOrigin(0.5, 0);
|
||||
|
||||
// Fund value texts
|
||||
const valueStyle = {
|
||||
fontSize: '28px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#ffd700',
|
||||
stroke: '#2a0a4e',
|
||||
strokeThickness: 3
|
||||
};
|
||||
this.playerText = this.add.text(PLAYER_BOX_X, 55, '$1000', valueStyle).setOrigin(0.5, 0.5);
|
||||
this.lordText = this.add.text(LORD_BOX_X, 55, '$0', valueStyle).setOrigin(0.5, 0.5);
|
||||
this.sinText = this.add.text(SIN_BOX_X, 55, '$0', { ...valueStyle, color: '#ff4444' }).setOrigin(0.5, 0.5);
|
||||
}
|
||||
|
||||
_buildBottomBar() {
|
||||
const g = this.add.graphics();
|
||||
|
||||
g.fillStyle(0x12082a, 1);
|
||||
g.fillRect(0, BOTTOM_BAR_Y, 1600, BOTTOM_BAR_HEIGHT);
|
||||
g.lineStyle(2, 0xffd700, 0.8);
|
||||
g.strokeRect(0, BOTTOM_BAR_Y, 1600, BOTTOM_BAR_HEIGHT);
|
||||
|
||||
this.messageText = this.add.text(700, BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2, 'Press SPIN or SPACE to begin', {
|
||||
fontSize: '22px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#e8d8b0',
|
||||
align: 'center',
|
||||
wordWrap: { width: 1100 }
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
// Secondary redemption message (hidden by default)
|
||||
this.redeemText = this.add.text(700, BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2 + 30, '', {
|
||||
fontSize: '16px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#ff9944',
|
||||
align: 'center'
|
||||
}).setOrigin(0.5, 0.5).setAlpha(0);
|
||||
}
|
||||
|
||||
_buildSpinButton() {
|
||||
const btnX = 1420;
|
||||
const btnY = BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2;
|
||||
const btnW = 140;
|
||||
const btnH = 60;
|
||||
|
||||
this.spinBtnGfx = this.add.graphics();
|
||||
this._drawSpinBtn(false);
|
||||
|
||||
this.spinBtnHitArea = this.add.zone(btnX, btnY, btnW, btnH)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
this.spinBtnLabel = this.add.text(btnX, btnY, 'SPIN', {
|
||||
fontSize: '26px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#1a0a2e',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5, 0.5);
|
||||
|
||||
this.spinBtnHitArea.on('pointerdown', () => {
|
||||
this.game.events.emit('spin');
|
||||
});
|
||||
|
||||
this.spinBtnHitArea.on('pointerover', () => this._drawSpinBtn(true));
|
||||
this.spinBtnHitArea.on('pointerout', () => this._drawSpinBtn(false));
|
||||
|
||||
// Store button center for layout reference
|
||||
this._btnX = btnX;
|
||||
this._btnY = btnY;
|
||||
this._btnW = btnW;
|
||||
this._btnH = btnH;
|
||||
}
|
||||
|
||||
_drawSpinBtn(hover) {
|
||||
const btnX = 1420;
|
||||
const btnY = BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2;
|
||||
const btnW = 140;
|
||||
const btnH = 60;
|
||||
|
||||
this.spinBtnGfx.clear();
|
||||
this.spinBtnGfx.fillStyle(hover ? 0xffe066 : 0xffd700, 1);
|
||||
this.spinBtnGfx.fillRoundedRect(btnX - btnW / 2, btnY - btnH / 2, btnW, btnH, 12);
|
||||
this.spinBtnGfx.lineStyle(3, hover ? 0xffa500 : 0xc8a000, 1);
|
||||
this.spinBtnGfx.strokeRoundedRect(btnX - btnW / 2, btnY - btnH / 2, btnW, btnH, 12);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.game.events.on('win', ({ playerGain, lordGain, symbol }) => {
|
||||
this._updateFundDisplays();
|
||||
this.messageText.setText(
|
||||
`✝ The Blessings of the Slots ✝\n+$${playerGain} to you | +$${lordGain} to The Lord (${symbol.label})`
|
||||
);
|
||||
this.messageText.setColor('#ffd700');
|
||||
this.redeemText.setAlpha(0);
|
||||
}, this);
|
||||
|
||||
this.game.events.on('loss', () => {
|
||||
this._updateFundDisplays();
|
||||
this.messageText.setText('Thou Hath Sinned.');
|
||||
this.messageText.setColor('#ff6666');
|
||||
|
||||
this.redeemText.setText('Redeem Yourself!');
|
||||
this.redeemText.setAlpha(1);
|
||||
|
||||
// Pulse the redeem text
|
||||
this.tweens.add({
|
||||
targets: this.redeemText,
|
||||
alpha: { from: 1, to: 0.3 },
|
||||
duration: 700,
|
||||
yoyo: true,
|
||||
repeat: 4
|
||||
});
|
||||
}, this);
|
||||
|
||||
this.game.events.on('spin-complete', () => {
|
||||
// Re-enable button visually (it was never disabled, just state-guarded)
|
||||
}, this);
|
||||
|
||||
this.game.events.on('funds-updated', () => {
|
||||
this._updateFundDisplays();
|
||||
}, this);
|
||||
|
||||
this.game.events.on('insufficient-funds', () => {
|
||||
this.messageText.setText('Insufficient funds to spin! You have been consumed by Sin.');
|
||||
this.messageText.setColor('#ff4444');
|
||||
}, this);
|
||||
}
|
||||
|
||||
_updateFundDisplays() {
|
||||
this.playerText.setText(`$${GameState.playerFunds}`);
|
||||
this.lordText.setText(`$${GameState.lordFunds}`);
|
||||
this.sinText.setText(`$${GameState.sinTotal}`);
|
||||
|
||||
// Flash update on change
|
||||
[this.playerText, this.lordText, this.sinText].forEach(t => {
|
||||
this.tweens.add({
|
||||
targets: t,
|
||||
scaleX: { from: 1.15, to: 1 },
|
||||
scaleY: { from: 1.15, to: 1 },
|
||||
duration: 200,
|
||||
ease: 'Bounce.easeOut'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Called by GameScene to position animations toward the right box
|
||||
getPlayerBoxCenter() { return { x: PLAYER_BOX_X, y: BOX_CENTER_Y }; }
|
||||
getLordBoxCenter() { return { x: LORD_BOX_X, y: BOX_CENTER_Y }; }
|
||||
getSinBoxCenter() { return { x: SIN_BOX_X, y: BOX_CENTER_Y }; }
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Build Guidelines
|
||||
|
||||
Create an HTML Phaser 3 video game. The game is Virtue Slots. It should play like a slot machine and win about every fifteen plays.
|
||||
|
||||
## Tools and Organization
|
||||
|
||||
- Phaser version 3 HTML game
|
||||
- Use JavaScript
|
||||
- Have JavaScript objects reference each other directly via IMPORT and EXPORT using ES6 standards
|
||||
- Do **NOT** require a web packager.
|
||||
- Create files and classes in a manner that allows future modifications and scaling at a modular level
|
||||
|
||||
## Basic Framework
|
||||
|
||||
- 1600 x 900 view
|
||||
- Scale view to user's viewport.
|
||||
- Use basic termporary vector graphics that can later be replaced by sprites
|
||||
|
||||
## Gameplay
|
||||
- UI details
|
||||
- Slot machine should be in the middle of the screen, at the top should be three even sides boxes for "Your Funds", "The Lord" and "Sin".
|
||||
- The player starts with $1000. The Lord starts with $0. Sin starts with $0.
|
||||
- The bottom of the screen should have one wide box which will be used for messaging and the "spin" button.
|
||||
- It should cost $50 to Spin the slot machine.
|
||||
- The slot machine should have 10 different symbols. Each symbol should be a religious christian themed symbol of some sort. Include symbols like "baby jesus", "cross", "jesus on the cross", "crown of thorns", "halo", etc.
|
||||
- When the player spins the slots show the symbols spinning and then individually resting on a symbol.
|
||||
- When the symbols match, the player wins.
|
||||
- The amount of money the player wins should go up based on how holy the symbols that were matched are.
|
||||
- Show in the messaging area how much the player has won and then animate the following:
|
||||
- Split the money up 60% to the player and %40 in "Tithing" to The Lord.
|
||||
- The screen should brighten, and a holy animation should slowly lift up gold and money icons up to the player and the lords funds. Create something that looks like an angel rising to heaven, but with cash.
|
||||
- After the animation the message are should read "The Blessings of the Slots"
|
||||
- When the symbols don't match, The player loses.
|
||||
- The money that was spent on the spin should be carried up to the "Sin" box and added to "Sin" by a devil.
|
||||
- The message box should read "Thou Hath Sinned." and another message should appear near the spin button that reads "Redeam Yourself!"
|
||||
|
||||
## Controls
|
||||
- The mouse clicking the "Spin" button starts a spin, and so does the space bar.
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
python -m http.server 8000
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Start a simple HTTP server on port 8000
|
||||
python3 -m http.server 8000
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const GameState = {
|
||||
playerFunds: 1000,
|
||||
lordFunds: 0,
|
||||
sinTotal: 0,
|
||||
spinCost: 50,
|
||||
spinning: false
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
function rand(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
export function shouldWin() {
|
||||
return Math.random() < 1 / 7;
|
||||
}
|
||||
|
||||
export function pickResults(symbols, forceWin) {
|
||||
if (forceWin) {
|
||||
const s = rand(symbols);
|
||||
return [s, s, s];
|
||||
}
|
||||
let results;
|
||||
do {
|
||||
results = [rand(symbols), rand(symbols), rand(symbols)];
|
||||
} while (results[0].id === results[1].id && results[1].id === results[2].id);
|
||||
return results;
|
||||
}
|
||||
Loading…
Reference in New Issue