Initial commit
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# GamerComp Asset Library
|
||||
|
||||
A comprehensive procedural asset library for generating HTML5 canvas games. All assets are rendered in real-time using Canvas 2D API and Web Audio API.
|
||||
|
||||
## Quick Stats
|
||||
|
||||
| Asset Type | Count | Description |
|
||||
|------------|-------|-------------|
|
||||
| Backgrounds | 120 | 15 themes × 8 variants |
|
||||
| Music | 160 | 8 moods × 20 songs |
|
||||
| Avatar Modes | 4 | HEAD_ONLY, HEAD_AND_SHOULDERS, UPPER_BODY, FULL_BODY |
|
||||
| Game Contexts | 26 | 12 vehicles, 10 costumes, 4 pure |
|
||||
| Presets | 220 | 11 game styles × 20 presets |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/assets/
|
||||
├── backgrounds/ # Procedural background system
|
||||
│ ├── backgroundEngine.js # Core rendering engine
|
||||
│ ├── effects.js # Particle/animation effects
|
||||
│ ├── backgroundCatalog.js # Metadata catalog
|
||||
│ └── themes/ # 15 theme files
|
||||
│ ├── space.js
|
||||
│ ├── nature.js
|
||||
│ ├── urban.js
|
||||
│ └── ... (12 more)
|
||||
│
|
||||
├── music/ # Procedural music system
|
||||
│ ├── moodCategories.js # Mood definitions
|
||||
│ ├── musicEngine.js # Tone.js synthesis engine
|
||||
│ └── library/
|
||||
│ ├── songs1-80.js # Songs 1-80
|
||||
│ └── songs81-160.js # Songs 81-160
|
||||
│
|
||||
├── avatar/ # Mii-style avatar system
|
||||
│ ├── avatarData.js # Face shapes, hair, colors
|
||||
│ ├── accessories.js # 45 unlockable accessories
|
||||
│ ├── animations.js # 31 animations
|
||||
│ ├── avatarRenderer.js # Core avatar rendering
|
||||
│ ├── accessoryRenderer.js # Accessory rendering
|
||||
│ ├── avatarSystem.js # Main avatar API
|
||||
│ ├── contextRenderer.js # Context rendering
|
||||
│ ├── contextCatalog.js # Context metadata
|
||||
│ └── contexts/
|
||||
│ ├── vehicles.js # 12 vehicle contexts
|
||||
│ ├── costumes.js # 10 costume contexts
|
||||
│ └── pure.js # 4 pure contexts
|
||||
│
|
||||
├── assetManager.js # Unified asset coordination
|
||||
├── assetCatalog.js # Combined metadata catalog
|
||||
├── presets.js # 220 preset combinations
|
||||
├── compatibility.js # Asset compatibility rules
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Include the required scripts in your HTML:
|
||||
|
||||
```html
|
||||
<!-- Backgrounds -->
|
||||
<script src="/assets/backgrounds/backgroundEngine.js"></script>
|
||||
<script src="/assets/backgrounds/effects.js"></script>
|
||||
<script src="/assets/backgrounds/themes/space.js"></script>
|
||||
<script src="/assets/backgrounds/backgroundCatalog.js"></script>
|
||||
|
||||
<!-- Music -->
|
||||
<script src="/assets/music/moodCategories.js"></script>
|
||||
<script src="/assets/music/musicEngine.js"></script>
|
||||
<script src="/assets/music/library/songs1-80.js"></script>
|
||||
|
||||
<!-- Avatar -->
|
||||
<script src="/assets/avatar/avatarData.js"></script>
|
||||
<script src="/assets/avatar/avatarRenderer.js"></script>
|
||||
<script src="/assets/avatar/avatarSystem.js"></script>
|
||||
|
||||
<!-- Asset Manager -->
|
||||
<script src="/assets/assetManager.js"></script>
|
||||
<script src="/assets/presets.js"></script>
|
||||
```
|
||||
|
||||
### Rendering a Background
|
||||
|
||||
```javascript
|
||||
// Get canvas context
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Render background (animated)
|
||||
function render(time) {
|
||||
BackgroundEngine.render(ctx, 'space', 'nebula', canvas.width, canvas.height, time);
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
render(0);
|
||||
```
|
||||
|
||||
### Playing Music
|
||||
|
||||
```javascript
|
||||
// Play by mood
|
||||
MusicEngine.playByMood('Epic', 8); // mood, energy (1-10)
|
||||
|
||||
// Play specific song
|
||||
MusicEngine.play('epic_001');
|
||||
|
||||
// Stop music
|
||||
MusicEngine.stop();
|
||||
|
||||
// Set volume
|
||||
MusicEngine.setVolume(0.7); // 0-1
|
||||
```
|
||||
|
||||
### Rendering an Avatar
|
||||
|
||||
```javascript
|
||||
// Define avatar configuration
|
||||
const avatar = {
|
||||
faceShape: 'round',
|
||||
skinTone: 'medium',
|
||||
hairStyle: 'short',
|
||||
hairColor: '#3d2314',
|
||||
eyeShape: 'round',
|
||||
eyeColor: '#4a7c59'
|
||||
};
|
||||
|
||||
// Render avatar
|
||||
AvatarSystem.render(ctx, avatar, 'FULL_BODY', 'idle', animationTime);
|
||||
|
||||
// With context (vehicle/costume)
|
||||
ContextRenderer.render(ctx, 'spaceship-cockpit', avatar, x, y, 'idle', time);
|
||||
```
|
||||
|
||||
### Using Presets
|
||||
|
||||
```javascript
|
||||
// Get a preset for a game style
|
||||
const preset = AssetPresets.getByStyle('action')[0];
|
||||
|
||||
// Apply preset
|
||||
const config = {
|
||||
music: preset.music,
|
||||
background: preset.background,
|
||||
avatarContext: preset.context
|
||||
};
|
||||
```
|
||||
|
||||
### Using the Recommendation Engine
|
||||
|
||||
```javascript
|
||||
// Get recommendations based on game description
|
||||
const recommendations = AssetManager.recommend(
|
||||
"A fast-paced space shooter with epic battles",
|
||||
"action"
|
||||
);
|
||||
|
||||
// Returns: { background, music, context, preset }
|
||||
```
|
||||
|
||||
## Game Templates
|
||||
|
||||
Two HTML templates are provided:
|
||||
|
||||
### gameTemplateAssets.html
|
||||
Full-featured template with:
|
||||
- Asset library integration
|
||||
- Loading screen
|
||||
- Pause menu
|
||||
- Input handling (keyboard + touch)
|
||||
- Score/health tracking
|
||||
|
||||
Best for: Games using avatars, procedural backgrounds, and music.
|
||||
|
||||
### gameTemplateSimple.html
|
||||
Minimal template with:
|
||||
- Basic Canvas setup
|
||||
- Simple game loop
|
||||
- No external dependencies
|
||||
|
||||
Best for: Puzzle games, simple arcade games, creative tools.
|
||||
|
||||
## Examples
|
||||
|
||||
See `/examples/` for complete working games:
|
||||
|
||||
- **platformer-example.html** - FULL_BODY avatar, nature background
|
||||
- **flappy-example.html** - HEAD_ONLY avatar, sky background
|
||||
- **racer-example.html** - Vehicle context, urban background
|
||||
- **comparison-demo.html** - Same game with 4 different asset sets
|
||||
|
||||
## Interactive Browser
|
||||
|
||||
Visit `/asset-browser.html` for an interactive tool to:
|
||||
- Browse all 120 backgrounds with live previews
|
||||
- Preview all 160 music tracks
|
||||
- Test avatar configurations
|
||||
- Explore all 26 game contexts
|
||||
- Browse 220 presets
|
||||
- Generate random combinations
|
||||
|
||||
## Avatar Modes
|
||||
|
||||
| Mode | Dimensions | Use Case |
|
||||
|------|------------|----------|
|
||||
| HEAD_ONLY | 64×64 | Cockpit views, maze games |
|
||||
| HEAD_AND_SHOULDERS | 64×96 | Driving, seated vehicles |
|
||||
| UPPER_BODY | 64×128 | Hoverboard, jetpack games |
|
||||
| FULL_BODY | 64×160 | Platformers, RPGs, sports |
|
||||
|
||||
## Music Moods
|
||||
|
||||
| Mood | Energy Range | Best For |
|
||||
|------|--------------|----------|
|
||||
| Epic | 7-10 | Boss battles, action |
|
||||
| Chill | 2-4 | Puzzle, casual |
|
||||
| Intense | 8-10 | Racing, shooters |
|
||||
| Playful | 5-7 | Kids games, casual |
|
||||
| Mysterious | 3-6 | Horror, exploration |
|
||||
| Heroic | 6-8 | Adventure, platformers |
|
||||
| Quirky | 4-7 | Puzzle, comedy |
|
||||
| Ambient | 1-3 | Meditation, creative |
|
||||
|
||||
## Background Themes
|
||||
|
||||
15 themes, each with 8 variants:
|
||||
|
||||
- **space**: nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole
|
||||
- **nature**: forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow
|
||||
- **urban**: citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley
|
||||
- **fantasy**: castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower
|
||||
- **abstract**: geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix
|
||||
- **retro**: pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave
|
||||
- **indoor**: laboratory, classroom, bedroom, kitchen, arcade, office, library, gym
|
||||
- **weather**: rain, snow, storm, sunset, sunrise, fog, lightning, aurora
|
||||
- **sports**: stadium, raceTrack, court, field, arena, pool, gym, skatepark
|
||||
- **seasonal**: springMeadow, autumnLeaves, winterWonderland, summerBeach, harvest, blossom, snowfall, tropical
|
||||
- **underwater**: coralReef, deepSea, kelpForest, submarine, shipwreck, abyss, iceCave, treasure
|
||||
- **sky**: clouds, flyingHigh, hotAirBalloon, airplaneView, stratosphere, birdsEye, horizon, starfield
|
||||
- **mechanical**: gears, circuits, factory, robotFactory, conveyorBelts, pipes, steampunk, techLab
|
||||
- **spooky**: hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows
|
||||
- **colorful**: rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism
|
||||
|
||||
## Game Contexts (26)
|
||||
|
||||
### Vehicles (12)
|
||||
spaceship-cockpit, race-car, airplane, flappy-style, tank, pacman-style, hoverboard, jetpack, mech-suit, submarine, dragon-rider, motorcycle
|
||||
|
||||
### Costumes (10)
|
||||
knight-armor, space-suit, ninja-outfit, wizard-robes, superhero-cape, athlete-uniform, pirate-outfit, scientist-labcoat, chef-outfit, cowboy-gear
|
||||
|
||||
### Pure (4)
|
||||
platformer-standard, sports-player, adventure-hero, runner
|
||||
|
||||
## Documentation
|
||||
|
||||
See `/docs/` for detailed documentation:
|
||||
|
||||
- **ASSET_LIBRARY_OVERVIEW.md** - Complete system overview
|
||||
- **GAME_GENERATION_GUIDE.md** - AI generation instructions
|
||||
- **ADDING_NEW_ASSETS.md** - How to extend the library
|
||||
- **API_REFERENCE.md** - Full API documentation
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 80+
|
||||
- Firefox 78+
|
||||
- Safari 14+
|
||||
- Edge 80+
|
||||
|
||||
Requires Canvas 2D and Web Audio API support.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- All assets are procedurally generated (no image loading)
|
||||
- Backgrounds animate at 60fps
|
||||
- Music uses Web Audio synthesis (low memory)
|
||||
- Avatar rendering is optimized for multiple instances
|
||||
- Use `requestAnimationFrame` for smooth animation
|
||||
|
||||
## License
|
||||
|
||||
Proprietary - GamerComp.com
|
||||
@@ -0,0 +1,354 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =========================================================================
|
||||
// GAME STYLES (from GamerComp)
|
||||
// =========================================================================
|
||||
|
||||
var GAME_STYLES = [
|
||||
{
|
||||
id: 'action-arcade',
|
||||
name: 'Action/Arcade',
|
||||
description: 'Space shooters, platformers, fast-paced action',
|
||||
icon: '🎮',
|
||||
keywords: ['space', 'shooter', 'platformer', 'action', 'arcade', 'fast'],
|
||||
recommendedMoods: ['Epic', 'Intense', 'Heroic'],
|
||||
recommendedThemes: ['space', 'retro', 'urban'],
|
||||
recommendedContexts: ['spaceship-cockpit', 'platformer-standard', 'jetpack']
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
name: 'Puzzle',
|
||||
description: 'Match-3, sliding puzzles, brain teasers',
|
||||
icon: '🧩',
|
||||
keywords: ['puzzle', 'match', 'brain', 'logic', 'thinking'],
|
||||
recommendedMoods: ['Chill', 'Ambient', 'Playful'],
|
||||
recommendedThemes: ['abstract', 'colorful', 'indoor'],
|
||||
recommendedContexts: ['platformer-standard', 'scientist-labcoat']
|
||||
},
|
||||
{
|
||||
id: 'story-adventure',
|
||||
name: 'Story Adventure',
|
||||
description: 'Visual novels, choose-your-adventure',
|
||||
icon: '📖',
|
||||
keywords: ['story', 'adventure', 'narrative', 'choice', 'visual'],
|
||||
recommendedMoods: ['Mysterious', 'Ambient', 'Epic'],
|
||||
recommendedThemes: ['fantasy', 'nature', 'urban'],
|
||||
recommendedContexts: ['adventure-hero', 'wizard-robes', 'knight-armor']
|
||||
},
|
||||
{
|
||||
id: 'racing',
|
||||
name: 'Racing',
|
||||
description: 'Car racing, running games, speed challenges',
|
||||
icon: '🏎️',
|
||||
keywords: ['race', 'racing', 'car', 'speed', 'run', 'fast'],
|
||||
recommendedMoods: ['Intense', 'Epic', 'Heroic'],
|
||||
recommendedThemes: ['urban', 'nature', 'sky'],
|
||||
recommendedContexts: ['race-car', 'motorcycle', 'hoverboard', 'runner']
|
||||
},
|
||||
{
|
||||
id: 'pet-simulator',
|
||||
name: 'Pet/Simulator',
|
||||
description: 'Virtual pets, farm games, life simulation',
|
||||
icon: '🐕',
|
||||
keywords: ['pet', 'animal', 'farm', 'care', 'virtual', 'simulation'],
|
||||
recommendedMoods: ['Playful', 'Chill', 'Ambient'],
|
||||
recommendedThemes: ['nature', 'indoor', 'colorful'],
|
||||
recommendedContexts: ['platformer-standard', 'chef-outfit']
|
||||
},
|
||||
{
|
||||
id: 'trivia-quiz',
|
||||
name: 'Trivia/Quiz',
|
||||
description: 'Quiz shows, fact games, knowledge tests',
|
||||
icon: '❓',
|
||||
keywords: ['trivia', 'quiz', 'question', 'knowledge', 'fact'],
|
||||
recommendedMoods: ['Playful', 'Quirky', 'Ambient'],
|
||||
recommendedThemes: ['abstract', 'indoor', 'colorful'],
|
||||
recommendedContexts: ['scientist-labcoat', 'platformer-standard']
|
||||
},
|
||||
{
|
||||
id: 'music-rhythm',
|
||||
name: 'Music/Rhythm',
|
||||
description: 'Rhythm games, dance games, music creation',
|
||||
icon: '🎵',
|
||||
keywords: ['music', 'rhythm', 'dance', 'beat', 'song'],
|
||||
recommendedMoods: ['Playful', 'Intense', 'Epic'],
|
||||
recommendedThemes: ['colorful', 'retro', 'abstract'],
|
||||
recommendedContexts: ['athlete-uniform', 'superhero-cape']
|
||||
},
|
||||
{
|
||||
id: 'creative-drawing',
|
||||
name: 'Creative/Drawing',
|
||||
description: 'Drawing games, design tools, art creation',
|
||||
icon: '🎨',
|
||||
keywords: ['draw', 'create', 'art', 'design', 'color', 'paint'],
|
||||
recommendedMoods: ['Chill', 'Playful', 'Ambient'],
|
||||
recommendedThemes: ['colorful', 'abstract', 'nature'],
|
||||
recommendedContexts: ['platformer-standard', 'chef-outfit']
|
||||
},
|
||||
{
|
||||
id: 'rpg-battle',
|
||||
name: 'RPG/Battle',
|
||||
description: 'Turn-based battles, dungeon crawlers, adventures',
|
||||
icon: '⚔️',
|
||||
keywords: ['rpg', 'battle', 'dungeon', 'quest', 'hero', 'monster'],
|
||||
recommendedMoods: ['Epic', 'Heroic', 'Mysterious'],
|
||||
recommendedThemes: ['fantasy', 'spooky', 'nature'],
|
||||
recommendedContexts: ['knight-armor', 'wizard-robes', 'ninja-outfit', 'dragon-rider']
|
||||
},
|
||||
{
|
||||
id: 'sports',
|
||||
name: 'Sports',
|
||||
description: 'Ball games, olympic events, athletic challenges',
|
||||
icon: '⚽',
|
||||
keywords: ['sports', 'ball', 'soccer', 'basketball', 'athletic'],
|
||||
recommendedMoods: ['Intense', 'Epic', 'Heroic'],
|
||||
recommendedThemes: ['sports', 'nature', 'urban'],
|
||||
recommendedContexts: ['athlete-uniform', 'sports-player']
|
||||
},
|
||||
{
|
||||
id: 'physics-pinball',
|
||||
name: 'Physics/Pinball',
|
||||
description: 'Pinball, angry birds style, physics puzzles',
|
||||
icon: '🎱',
|
||||
keywords: ['physics', 'pinball', 'bounce', 'launch', 'gravity'],
|
||||
recommendedMoods: ['Playful', 'Quirky', 'Intense'],
|
||||
recommendedThemes: ['mechanical', 'colorful', 'retro'],
|
||||
recommendedContexts: ['platformer-standard', 'mech-suit']
|
||||
}
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// MOOD TO THEME MAPPINGS
|
||||
// =========================================================================
|
||||
|
||||
var MOOD_THEME_AFFINITIES = {
|
||||
Epic: ['space', 'fantasy', 'sports', 'sky'],
|
||||
Chill: ['nature', 'underwater', 'indoor', 'seasonal'],
|
||||
Intense: ['urban', 'mechanical', 'sports', 'weather'],
|
||||
Playful: ['colorful', 'retro', 'nature', 'indoor'],
|
||||
Mysterious: ['spooky', 'fantasy', 'underwater', 'weather'],
|
||||
Heroic: ['space', 'fantasy', 'urban', 'sky'],
|
||||
Quirky: ['retro', 'colorful', 'abstract', 'indoor'],
|
||||
Ambient: ['nature', 'underwater', 'sky', 'seasonal']
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// THEME TO CONTEXT MAPPINGS
|
||||
// =========================================================================
|
||||
|
||||
var THEME_CONTEXT_AFFINITIES = {
|
||||
space: ['spaceship-cockpit', 'space-suit', 'mech-suit', 'jetpack'],
|
||||
nature: ['platformer-standard', 'adventure-hero', 'cowboy-gear'],
|
||||
urban: ['race-car', 'motorcycle', 'runner', 'superhero-cape'],
|
||||
fantasy: ['knight-armor', 'wizard-robes', 'dragon-rider'],
|
||||
spooky: ['ninja-outfit', 'wizard-robes', 'platformer-standard'],
|
||||
underwater: ['submarine', 'space-suit', 'adventure-hero'],
|
||||
retro: ['pacman-style', 'platformer-standard', 'athlete-uniform'],
|
||||
sports: ['athlete-uniform', 'sports-player', 'runner'],
|
||||
sky: ['airplane', 'flappy-style', 'jetpack', 'dragon-rider'],
|
||||
colorful: ['superhero-cape', 'athlete-uniform', 'platformer-standard'],
|
||||
mechanical: ['mech-suit', 'tank', 'scientist-labcoat'],
|
||||
indoor: ['chef-outfit', 'scientist-labcoat', 'platformer-standard'],
|
||||
weather: ['adventure-hero', 'cowboy-gear', 'pirate-outfit'],
|
||||
seasonal: ['platformer-standard', 'athlete-uniform', 'chef-outfit'],
|
||||
abstract: ['platformer-standard', 'scientist-labcoat']
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// COLOR PALETTES
|
||||
// =========================================================================
|
||||
|
||||
var COLOR_PALETTES = {
|
||||
'space-blue': ['#0a0a2e', '#1a1a4e', '#2a2a6e', '#4a4a9e', '#6a6abe'],
|
||||
'forest-green': ['#1a3a1a', '#2a4a2a', '#3a5a3a', '#4a7a4a', '#5a9a5a'],
|
||||
'sunset-orange': ['#2a1a0a', '#4a2a1a', '#6a3a2a', '#9a5a3a', '#ca7a4a'],
|
||||
'ocean-teal': ['#0a2a2a', '#1a3a4a', '#2a4a5a', '#3a6a7a', '#4a8a9a'],
|
||||
'neon-pink': ['#2a0a2a', '#4a1a4a', '#6a2a6a', '#9a3a9a', '#ca4aca'],
|
||||
'retro-arcade': ['#0a0a0a', '#1a1a3a', '#3a3a6a', '#6a6a9a', '#9a9aca'],
|
||||
'fantasy-purple': ['#1a0a2a', '#2a1a4a', '#4a2a6a', '#6a3a9a', '#8a4aca'],
|
||||
'spooky-grey': ['#0a0a0a', '#1a1a1a', '#2a2a2a', '#3a3a3a', '#4a4a4a'],
|
||||
'sports-red': ['#2a0a0a', '#4a1a1a', '#6a2a2a', '#9a3a3a', '#ca4a4a'],
|
||||
'nature-brown': ['#2a1a0a', '#3a2a1a', '#4a3a2a', '#5a4a3a', '#6a5a4a'],
|
||||
'candy-rainbow': ['#ff6b6b', '#ffd93d', '#6bcf7f', '#4ecdc4', '#a06cd5']
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// CATALOG QUERIES
|
||||
// =========================================================================
|
||||
|
||||
function getGameStyles() {
|
||||
return GAME_STYLES.slice();
|
||||
}
|
||||
|
||||
function getGameStyleById(id) {
|
||||
return GAME_STYLES.find(function(s) { return s.id === id; }) || null;
|
||||
}
|
||||
|
||||
function getGameStyleByKeyword(keyword) {
|
||||
keyword = keyword.toLowerCase();
|
||||
return GAME_STYLES.filter(function(s) {
|
||||
return s.keywords.some(function(k) {
|
||||
return k.includes(keyword) || keyword.includes(k);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getMoodsForTheme(theme) {
|
||||
var moods = [];
|
||||
for (var mood in MOOD_THEME_AFFINITIES) {
|
||||
if (MOOD_THEME_AFFINITIES[mood].includes(theme)) {
|
||||
moods.push(mood);
|
||||
}
|
||||
}
|
||||
return moods;
|
||||
}
|
||||
|
||||
function getThemesForMood(mood) {
|
||||
return MOOD_THEME_AFFINITIES[mood] || [];
|
||||
}
|
||||
|
||||
function getContextsForTheme(theme) {
|
||||
return THEME_CONTEXT_AFFINITIES[theme] || [];
|
||||
}
|
||||
|
||||
function getColorPalette(id) {
|
||||
return COLOR_PALETTES[id] || COLOR_PALETTES['space-blue'];
|
||||
}
|
||||
|
||||
function getAllColorPalettes() {
|
||||
return Object.keys(COLOR_PALETTES).map(function(id) {
|
||||
return { id: id, colors: COLOR_PALETTES[id] };
|
||||
});
|
||||
}
|
||||
|
||||
function suggestPalette(theme, mood) {
|
||||
// Theme-based suggestions
|
||||
var themeMap = {
|
||||
space: 'space-blue',
|
||||
nature: 'forest-green',
|
||||
urban: 'neon-pink',
|
||||
fantasy: 'fantasy-purple',
|
||||
spooky: 'spooky-grey',
|
||||
underwater: 'ocean-teal',
|
||||
retro: 'retro-arcade',
|
||||
sports: 'sports-red',
|
||||
sky: 'space-blue',
|
||||
colorful: 'candy-rainbow',
|
||||
mechanical: 'retro-arcade',
|
||||
indoor: 'nature-brown',
|
||||
weather: 'sunset-orange',
|
||||
seasonal: 'forest-green'
|
||||
};
|
||||
|
||||
return themeMap[theme] || 'space-blue';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UNIFIED SEARCH
|
||||
// =========================================================================
|
||||
|
||||
function search(query) {
|
||||
query = query.toLowerCase();
|
||||
var results = {
|
||||
gameStyles: [],
|
||||
moods: [],
|
||||
themes: [],
|
||||
contexts: [],
|
||||
palettes: []
|
||||
};
|
||||
|
||||
// Search game styles
|
||||
GAME_STYLES.forEach(function(style) {
|
||||
if (style.name.toLowerCase().includes(query) ||
|
||||
style.description.toLowerCase().includes(query)) {
|
||||
results.gameStyles.push(style);
|
||||
}
|
||||
});
|
||||
|
||||
// Search moods
|
||||
Object.keys(MOOD_THEME_AFFINITIES).forEach(function(mood) {
|
||||
if (mood.toLowerCase().includes(query)) {
|
||||
results.moods.push(mood);
|
||||
}
|
||||
});
|
||||
|
||||
// Search themes
|
||||
Object.keys(THEME_CONTEXT_AFFINITIES).forEach(function(theme) {
|
||||
if (theme.includes(query)) {
|
||||
results.themes.push(theme);
|
||||
}
|
||||
});
|
||||
|
||||
// Search palettes
|
||||
Object.keys(COLOR_PALETTES).forEach(function(palette) {
|
||||
if (palette.includes(query)) {
|
||||
results.palettes.push(palette);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STATISTICS
|
||||
// =========================================================================
|
||||
|
||||
function getStats() {
|
||||
var musicCount = 0, bgCount = 0, contextCount = 0;
|
||||
|
||||
if (window.MusicEngine) {
|
||||
musicCount = MusicEngine.getCatalog ? MusicEngine.getCatalog().length : 160;
|
||||
}
|
||||
if (window.BackgroundEngine) {
|
||||
bgCount = BackgroundEngine.getTotalCount ? BackgroundEngine.getTotalCount() : 120;
|
||||
}
|
||||
if (window.ContextCatalog) {
|
||||
contextCount = ContextCatalog.getAll ? ContextCatalog.getAll().length : 26;
|
||||
}
|
||||
|
||||
return {
|
||||
gameStyles: GAME_STYLES.length,
|
||||
musicSongs: musicCount,
|
||||
backgrounds: bgCount,
|
||||
avatarContexts: contextCount,
|
||||
colorPalettes: Object.keys(COLOR_PALETTES).length,
|
||||
moods: Object.keys(MOOD_THEME_AFFINITIES).length,
|
||||
themes: Object.keys(THEME_CONTEXT_AFFINITIES).length
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PUBLIC API
|
||||
// =========================================================================
|
||||
|
||||
window.AssetCatalog = {
|
||||
// Game styles
|
||||
GAME_STYLES: GAME_STYLES,
|
||||
getGameStyles: getGameStyles,
|
||||
getGameStyleById: getGameStyleById,
|
||||
getGameStyleByKeyword: getGameStyleByKeyword,
|
||||
|
||||
// Mood/Theme mappings
|
||||
getMoodsForTheme: getMoodsForTheme,
|
||||
getThemesForMood: getThemesForMood,
|
||||
getContextsForTheme: getContextsForTheme,
|
||||
|
||||
// Color palettes
|
||||
getColorPalette: getColorPalette,
|
||||
getAllColorPalettes: getAllColorPalettes,
|
||||
suggestPalette: suggestPalette,
|
||||
|
||||
// Search
|
||||
search: search,
|
||||
|
||||
// Stats
|
||||
getStats: getStats,
|
||||
|
||||
// Direct data access
|
||||
MOOD_THEME_AFFINITIES: MOOD_THEME_AFFINITIES,
|
||||
THEME_CONTEXT_AFFINITIES: THEME_CONTEXT_AFFINITIES,
|
||||
COLOR_PALETTES: COLOR_PALETTES
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Avatar Accessories System
|
||||
* Defines all unlockable accessories for the avatar customization system
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =============================================================================
|
||||
// RARITY COLORS
|
||||
// =============================================================================
|
||||
var RARITY_COLORS = {
|
||||
common: '#9d9d9d',
|
||||
uncommon: '#1eff00',
|
||||
rare: '#0070dd',
|
||||
epic: '#a335ee',
|
||||
legendary: '#ff8000',
|
||||
mythic: '#e6cc80'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// EYEWEAR (15 items)
|
||||
// =============================================================================
|
||||
var EYEWEAR = [
|
||||
{
|
||||
id: 'sunglasses-classic',
|
||||
name: 'Classic Sunglasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 5,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Timeless shades for any occasion'
|
||||
},
|
||||
{
|
||||
id: 'aviator',
|
||||
name: 'Aviator Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 10,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -1 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Top Gun approved eyewear'
|
||||
},
|
||||
{
|
||||
id: 'nerd-glasses',
|
||||
name: 'Nerd Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 15,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Intelligence +100 (cosmetic only)'
|
||||
},
|
||||
{
|
||||
id: '3d-glasses',
|
||||
name: '3D Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 20,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -1 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'See the world in a whole new dimension'
|
||||
},
|
||||
{
|
||||
id: 'goggles',
|
||||
name: 'Goggles',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 25,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -3 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Ready for adventure or science experiments'
|
||||
},
|
||||
{
|
||||
id: 'monocle',
|
||||
name: 'Monocle',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 30,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 8, y: 0 },
|
||||
scale: 0.8,
|
||||
hasEffect: false,
|
||||
description: 'Distinguished and sophisticated'
|
||||
},
|
||||
{
|
||||
id: 'star-glasses',
|
||||
name: 'Star Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'You are a star!'
|
||||
},
|
||||
{
|
||||
id: 'heart-glasses',
|
||||
name: 'Heart Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 40,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Spread the love wherever you go'
|
||||
},
|
||||
{
|
||||
id: 'sport-visor',
|
||||
name: 'Sport Visor',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 45,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -5 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Professional gamer gear'
|
||||
},
|
||||
{
|
||||
id: 'vr-headset',
|
||||
name: 'VR Headset',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 50,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -4 },
|
||||
scale: 1.3,
|
||||
hasEffect: false,
|
||||
description: 'Immerse yourself in virtual worlds'
|
||||
},
|
||||
{
|
||||
id: 'cyber-visor',
|
||||
name: 'Cyber Visor',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 55,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -3 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Sleek futuristic eye protection'
|
||||
},
|
||||
{
|
||||
id: 'x-ray-specs',
|
||||
name: 'X-Ray Specs',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 60,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: true,
|
||||
description: 'See through walls... probably'
|
||||
},
|
||||
{
|
||||
id: 'laser-eyes',
|
||||
name: 'Laser Eyes',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 70,
|
||||
rarity: 'legendary',
|
||||
zIndex: 12,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
description: 'Pew pew! Lasers shoot from your eyes'
|
||||
},
|
||||
{
|
||||
id: 'diamond-glasses',
|
||||
name: 'Diamond Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 80,
|
||||
rarity: 'legendary',
|
||||
zIndex: 12,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Pure crystallized luxury'
|
||||
},
|
||||
{
|
||||
id: 'cosmic-lenses',
|
||||
name: 'Cosmic Lenses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 90,
|
||||
rarity: 'mythic',
|
||||
zIndex: 13,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
description: 'Gaze upon the infinite universe'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// HEADWEAR (20 items)
|
||||
// =============================================================================
|
||||
var HEADWEAR = [
|
||||
{
|
||||
id: 'baseball-cap',
|
||||
name: 'Baseball Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 3,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -15 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A classic casual cap'
|
||||
},
|
||||
{
|
||||
id: 'backwards-cap',
|
||||
name: 'Backwards Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 8,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -15 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Cool kids wear it backwards'
|
||||
},
|
||||
{
|
||||
id: 'beanie',
|
||||
name: 'Beanie',
|
||||
category: 'headwear',
|
||||
unlockLevel: 12,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -12 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Cozy knitted headwear'
|
||||
},
|
||||
{
|
||||
id: 'party-hat',
|
||||
name: 'Party Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 18,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -20 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Every day is a celebration!'
|
||||
},
|
||||
{
|
||||
id: 'chef-hat',
|
||||
name: 'Chef Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 22,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -25 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Master of the kitchen'
|
||||
},
|
||||
{
|
||||
id: 'crown-bronze',
|
||||
name: 'Bronze Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 25,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A humble crown for rising royalty'
|
||||
},
|
||||
{
|
||||
id: 'hard-hat',
|
||||
name: 'Hard Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 26,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -14 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Safety first on the job site'
|
||||
},
|
||||
{
|
||||
id: 'wizard-hat',
|
||||
name: 'Wizard Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 30,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -30 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Channel your inner sorcerer'
|
||||
},
|
||||
{
|
||||
id: 'pirate-hat',
|
||||
name: 'Pirate Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Arrr! Set sail for adventure'
|
||||
},
|
||||
{
|
||||
id: 'santa-hat',
|
||||
name: 'Santa Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 38,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 3, y: -16 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Ho ho ho! Spread holiday cheer'
|
||||
},
|
||||
{
|
||||
id: 'crown-silver',
|
||||
name: 'Silver Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 40,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A noble crown for dedicated players'
|
||||
},
|
||||
{
|
||||
id: 'graduation-cap',
|
||||
name: 'Graduation Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 42,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -16 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Scholar of the gaming arts'
|
||||
},
|
||||
{
|
||||
id: 'bunny-ears',
|
||||
name: 'Bunny Ears',
|
||||
category: 'headwear',
|
||||
unlockLevel: 45,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -22 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Hop into the fun!'
|
||||
},
|
||||
{
|
||||
id: 'devil-horns',
|
||||
name: 'Devil Horns',
|
||||
category: 'headwear',
|
||||
unlockLevel: 48,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -14 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A little mischief never hurt anyone'
|
||||
},
|
||||
{
|
||||
id: 'halo',
|
||||
name: 'Halo',
|
||||
category: 'headwear',
|
||||
unlockLevel: 52,
|
||||
rarity: 'epic',
|
||||
zIndex: 7,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -25 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
description: 'An angelic golden ring of light'
|
||||
},
|
||||
{
|
||||
id: 'top-hat',
|
||||
name: 'Top Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 56,
|
||||
rarity: 'epic',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -28 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Dapper and distinguished'
|
||||
},
|
||||
{
|
||||
id: 'crown-gold',
|
||||
name: 'Gold Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 60,
|
||||
rarity: 'epic',
|
||||
zIndex: 10,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'True royalty in golden splendor'
|
||||
},
|
||||
{
|
||||
id: 'viking-helmet',
|
||||
name: 'Viking Helmet',
|
||||
category: 'headwear',
|
||||
unlockLevel: 62,
|
||||
rarity: 'epic',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -16 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Conquer games like a Norse warrior'
|
||||
},
|
||||
{
|
||||
id: 'crown-diamond',
|
||||
name: 'Diamond Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 80,
|
||||
rarity: 'legendary',
|
||||
zIndex: 10,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -20 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
description: 'A magnificent crown of pure diamonds'
|
||||
},
|
||||
{
|
||||
id: 'crown-cosmic',
|
||||
name: 'Cosmic Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 100,
|
||||
rarity: 'mythic',
|
||||
zIndex: 11,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -22 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
description: 'The ultimate crown, forged from stardust'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// EFFECTS/AURAS (10 items)
|
||||
// =============================================================================
|
||||
var EFFECTS = [
|
||||
{
|
||||
id: 'sparkle-trail',
|
||||
name: 'Sparkle Trail',
|
||||
category: 'effect',
|
||||
unlockLevel: 15,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#FFD700',
|
||||
description: 'Leave a trail of golden sparkles'
|
||||
},
|
||||
{
|
||||
id: 'fire-aura',
|
||||
name: 'Fire Aura',
|
||||
category: 'effect',
|
||||
unlockLevel: 25,
|
||||
rarity: 'rare',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: '#FF4500',
|
||||
description: 'Surrounded by fierce flames'
|
||||
},
|
||||
{
|
||||
id: 'lightning-crackle',
|
||||
name: 'Lightning Crackle',
|
||||
category: 'effect',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.1,
|
||||
hasEffect: true,
|
||||
effectType: 'electric',
|
||||
color: '#00BFFF',
|
||||
description: 'Electricity crackles around you'
|
||||
},
|
||||
{
|
||||
id: 'rainbow-glow',
|
||||
name: 'Rainbow Glow',
|
||||
category: 'effect',
|
||||
unlockLevel: 45,
|
||||
rarity: 'epic',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: 'rainbow',
|
||||
description: 'Radiate all colors of the spectrum'
|
||||
},
|
||||
{
|
||||
id: 'star-particles',
|
||||
name: 'Star Particles',
|
||||
category: 'effect',
|
||||
unlockLevel: 55,
|
||||
rarity: 'epic',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#FFFF00',
|
||||
description: 'Tiny stars orbit around you'
|
||||
},
|
||||
{
|
||||
id: 'ice-crystals',
|
||||
name: 'Ice Crystals',
|
||||
category: 'effect',
|
||||
unlockLevel: 65,
|
||||
rarity: 'legendary',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#87CEEB',
|
||||
description: 'Frozen crystals shimmer around you'
|
||||
},
|
||||
{
|
||||
id: 'shadow-effect',
|
||||
name: 'Shadow Effect',
|
||||
category: 'effect',
|
||||
unlockLevel: 70,
|
||||
rarity: 'legendary',
|
||||
zIndex: -1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.4,
|
||||
hasEffect: true,
|
||||
effectType: 'shadow',
|
||||
color: '#2F2F4F',
|
||||
description: 'Darkness follows your every move'
|
||||
},
|
||||
{
|
||||
id: 'golden-glow',
|
||||
name: 'Golden Glow',
|
||||
category: 'effect',
|
||||
unlockLevel: 75,
|
||||
rarity: 'legendary',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: '#FFD700',
|
||||
description: 'Bathe in pure golden light'
|
||||
},
|
||||
{
|
||||
id: 'void-aura',
|
||||
name: 'Void Aura',
|
||||
category: 'effect',
|
||||
unlockLevel: 85,
|
||||
rarity: 'mythic',
|
||||
zIndex: -1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.5,
|
||||
hasEffect: true,
|
||||
effectType: 'void',
|
||||
color: '#4B0082',
|
||||
description: 'The void itself surrounds you'
|
||||
},
|
||||
{
|
||||
id: 'cosmic-trail',
|
||||
name: 'Cosmic Trail',
|
||||
category: 'effect',
|
||||
unlockLevel: 95,
|
||||
rarity: 'mythic',
|
||||
zIndex: 1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.4,
|
||||
hasEffect: true,
|
||||
effectType: 'cosmic',
|
||||
color: 'cosmic',
|
||||
description: 'Trail galaxies and nebulae in your wake'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get all accessories combined into a single array
|
||||
*/
|
||||
function getAll() {
|
||||
return [].concat(EYEWEAR, HEADWEAR, EFFECTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessories filtered by category
|
||||
* @param {string} category - 'eyewear', 'headwear', or 'effect'
|
||||
*/
|
||||
function getByCategory(category) {
|
||||
switch (category) {
|
||||
case 'eyewear':
|
||||
return EYEWEAR.slice();
|
||||
case 'headwear':
|
||||
return HEADWEAR.slice();
|
||||
case 'effect':
|
||||
return EFFECTS.slice();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an accessory by its unique ID
|
||||
* @param {string} id - The accessory ID
|
||||
*/
|
||||
function getById(id) {
|
||||
var all = getAll();
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].id === id) {
|
||||
return all[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessories of a specific rarity
|
||||
* @param {string} rarity - 'common', 'uncommon', 'rare', 'epic', 'legendary', or 'mythic'
|
||||
*/
|
||||
function getByRarity(rarity) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.rarity === rarity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessories unlockable at or before the given level
|
||||
* @param {number} level - The player's current level
|
||||
*/
|
||||
function getUnlockedAtLevel(level) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.unlockLevel <= level;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessories that unlock exactly at the specified level
|
||||
* @param {number} level - The level to check
|
||||
*/
|
||||
function getNewlyUnlockedAtLevel(level) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.unlockLevel === level;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color associated with a rarity
|
||||
* @param {string} rarity - The rarity level
|
||||
*/
|
||||
function getRarityColor(rarity) {
|
||||
return RARITY_COLORS[rarity] || RARITY_COLORS.common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an organized catalog for UI display
|
||||
* Returns accessories grouped by category and sorted by unlock level
|
||||
*/
|
||||
function getCatalog() {
|
||||
var sortByLevel = function(a, b) {
|
||||
return a.unlockLevel - b.unlockLevel;
|
||||
};
|
||||
|
||||
return {
|
||||
eyewear: EYEWEAR.slice().sort(sortByLevel),
|
||||
headwear: HEADWEAR.slice().sort(sortByLevel),
|
||||
effects: EFFECTS.slice().sort(sortByLevel),
|
||||
totalCount: EYEWEAR.length + HEADWEAR.length + EFFECTS.length,
|
||||
rarityColors: Object.assign({}, RARITY_COLORS)
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC API
|
||||
// =============================================================================
|
||||
window.AvatarAccessories = {
|
||||
// Data arrays
|
||||
EYEWEAR: EYEWEAR,
|
||||
HEADWEAR: HEADWEAR,
|
||||
EFFECTS: EFFECTS,
|
||||
RARITY_COLORS: RARITY_COLORS,
|
||||
|
||||
// Methods
|
||||
getAll: getAll,
|
||||
getByCategory: getByCategory,
|
||||
getById: getById,
|
||||
getByRarity: getByRarity,
|
||||
getUnlockedAtLevel: getUnlockedAtLevel,
|
||||
getNewlyUnlockedAtLevel: getNewlyUnlockedAtLevel,
|
||||
getRarityColor: getRarityColor,
|
||||
getCatalog: getCatalog
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Face shapes (8 options)
|
||||
var FACE_SHAPES = [
|
||||
{ id: 'round', name: 'Round', widthRatio: 1.0, heightRatio: 1.0, jawCurve: 0.9 },
|
||||
{ id: 'oval', name: 'Oval', widthRatio: 0.85, heightRatio: 1.1, jawCurve: 0.7 },
|
||||
{ id: 'square', name: 'Square', widthRatio: 1.0, heightRatio: 0.95, jawCurve: 0.3 },
|
||||
{ id: 'heart', name: 'Heart', widthRatio: 0.9, heightRatio: 1.05, jawCurve: 0.8, chinPoint: 0.6 },
|
||||
{ id: 'long', name: 'Long', widthRatio: 0.8, heightRatio: 1.2, jawCurve: 0.5 },
|
||||
{ id: 'wide', name: 'Wide', widthRatio: 1.15, heightRatio: 0.9, jawCurve: 0.6 },
|
||||
{ id: 'angular', name: 'Angular', widthRatio: 0.95, heightRatio: 1.0, jawCurve: 0.2, cheekbones: 0.7 },
|
||||
{ id: 'soft', name: 'Soft', widthRatio: 1.05, heightRatio: 1.0, jawCurve: 0.95 }
|
||||
];
|
||||
|
||||
// Skin tones (20 options - full diversity)
|
||||
var SKIN_TONES = [
|
||||
{ id: 'fair1', hex: '#FFECD1', name: 'Porcelain' },
|
||||
{ id: 'fair2', hex: '#FFE4C4', name: 'Ivory' },
|
||||
{ id: 'fair3', hex: '#FFDAB9', name: 'Peach' },
|
||||
{ id: 'fair4', hex: '#FFD5B5', name: 'Cream' },
|
||||
{ id: 'light1', hex: '#F5D0A9', name: 'Light Beige' },
|
||||
{ id: 'light2', hex: '#E8C49A', name: 'Warm Beige' },
|
||||
{ id: 'light3', hex: '#DEB887', name: 'Sand' },
|
||||
{ id: 'medium1', hex: '#D4A373', name: 'Golden' },
|
||||
{ id: 'medium2', hex: '#C9A06A', name: 'Honey' },
|
||||
{ id: 'medium3', hex: '#BC8F5F', name: 'Caramel' },
|
||||
{ id: 'medium4', hex: '#B07D4B', name: 'Toffee' },
|
||||
{ id: 'tan1', hex: '#A67B5B', name: 'Tan' },
|
||||
{ id: 'tan2', hex: '#996B4D', name: 'Bronze' },
|
||||
{ id: 'tan3', hex: '#8B6D4C', name: 'Cinnamon' },
|
||||
{ id: 'brown1', hex: '#7D5A3C', name: 'Chestnut' },
|
||||
{ id: 'brown2', hex: '#6F4E37', name: 'Cocoa' },
|
||||
{ id: 'brown3', hex: '#5D4532', name: 'Espresso' },
|
||||
{ id: 'dark1', hex: '#4E3B2D', name: 'Mocha' },
|
||||
{ id: 'dark2', hex: '#3D2B1F', name: 'Ebony' },
|
||||
{ id: 'dark3', hex: '#2C1F15', name: 'Onyx' }
|
||||
];
|
||||
|
||||
// Eye shapes (12 options)
|
||||
var EYE_SHAPES = [
|
||||
{ id: 'round', name: 'Round', width: 0.15, height: 0.12, curve: 1.0 },
|
||||
{ id: 'almond', name: 'Almond', width: 0.18, height: 0.08, curve: 0.6 },
|
||||
{ id: 'oval', name: 'Oval', width: 0.16, height: 0.10, curve: 0.8 },
|
||||
{ id: 'narrow', name: 'Narrow', width: 0.20, height: 0.06, curve: 0.4 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.19, height: 0.11, curve: 0.7 },
|
||||
{ id: 'droopy', name: 'Droopy', width: 0.17, height: 0.09, curve: 0.5, droop: 0.3 },
|
||||
{ id: 'upturned', name: 'Upturned', width: 0.17, height: 0.09, curve: 0.6, upturn: 0.3 },
|
||||
{ id: 'cat', name: 'Cat', width: 0.18, height: 0.07, curve: 0.4, upturn: 0.5 },
|
||||
{ id: 'sleepy', name: 'Sleepy', width: 0.16, height: 0.07, curve: 0.5, lidDroop: 0.4 },
|
||||
{ id: 'surprised', name: 'Surprised', width: 0.16, height: 0.14, curve: 1.0 },
|
||||
{ id: 'angry', name: 'Angry', width: 0.17, height: 0.08, curve: 0.5, innerAngle: -0.2 },
|
||||
{ id: 'happy', name: 'Happy', width: 0.17, height: 0.08, curve: 0.7, squint: 0.3 }
|
||||
];
|
||||
|
||||
// Eye colors (16 options)
|
||||
var EYE_COLORS = [
|
||||
{ id: 'brown', hex: '#634e34', name: 'Brown' },
|
||||
{ id: 'darkBrown', hex: '#3d2314', name: 'Dark Brown' },
|
||||
{ id: 'hazel', hex: '#8b7355', name: 'Hazel' },
|
||||
{ id: 'green', hex: '#3d7a3d', name: 'Green' },
|
||||
{ id: 'blue', hex: '#4682b4', name: 'Blue' },
|
||||
{ id: 'lightBlue', hex: '#87ceeb', name: 'Light Blue' },
|
||||
{ id: 'gray', hex: '#708090', name: 'Gray' },
|
||||
{ id: 'amber', hex: '#b8860b', name: 'Amber' },
|
||||
{ id: 'violet', hex: '#8b008b', name: 'Violet' },
|
||||
{ id: 'emerald', hex: '#50c878', name: 'Emerald' },
|
||||
{ id: 'iceBlue', hex: '#a5d8e6', name: 'Ice Blue' },
|
||||
{ id: 'gold', hex: '#daa520', name: 'Gold' },
|
||||
{ id: 'red', hex: '#8b0000', name: 'Red' },
|
||||
{ id: 'black', hex: '#1a1a1a', name: 'Black' },
|
||||
{ id: 'honey', hex: '#c9a06a', name: 'Honey' },
|
||||
{ id: 'turquoise', hex: '#40e0d0', name: 'Turquoise' }
|
||||
];
|
||||
|
||||
// Eyebrow shapes (10 options)
|
||||
var EYEBROW_SHAPES = [
|
||||
{ id: 'natural', name: 'Natural', thickness: 0.5, arch: 0.3 },
|
||||
{ id: 'arched', name: 'Arched', thickness: 0.5, arch: 0.6 },
|
||||
{ id: 'straight', name: 'Straight', thickness: 0.5, arch: 0.0 },
|
||||
{ id: 'thick', name: 'Thick', thickness: 0.8, arch: 0.3 },
|
||||
{ id: 'thin', name: 'Thin', thickness: 0.25, arch: 0.3 },
|
||||
{ id: 'angry', name: 'Angry', thickness: 0.5, arch: 0.2, innerAngle: -0.4 },
|
||||
{ id: 'worried', name: 'Worried', thickness: 0.5, arch: 0.4, innerAngle: 0.3 },
|
||||
{ id: 'bushy', name: 'Bushy', thickness: 0.9, arch: 0.2, wild: true },
|
||||
{ id: 'sleek', name: 'Sleek', thickness: 0.3, arch: 0.5 },
|
||||
{ id: 'curved', name: 'Curved', thickness: 0.5, arch: 0.7 }
|
||||
];
|
||||
|
||||
// Nose shapes (8 options)
|
||||
var NOSE_SHAPES = [
|
||||
{ id: 'button', name: 'Button', width: 0.12, length: 0.15 },
|
||||
{ id: 'pointed', name: 'Pointed', width: 0.10, length: 0.22 },
|
||||
{ id: 'roman', name: 'Roman', width: 0.14, length: 0.25, bridge: 0.6 },
|
||||
{ id: 'snub', name: 'Snub', width: 0.13, length: 0.12, upturn: 0.4 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.18, length: 0.18 },
|
||||
{ id: 'narrow', name: 'Narrow', width: 0.08, length: 0.20 },
|
||||
{ id: 'aquiline', name: 'Aquiline', width: 0.12, length: 0.24, bridge: 0.8 },
|
||||
{ id: 'flat', name: 'Flat', width: 0.16, length: 0.10 }
|
||||
];
|
||||
|
||||
// Mouth shapes (10 options)
|
||||
var MOUTH_SHAPES = [
|
||||
{ id: 'smile', name: 'Smile', width: 0.25, curve: 0.3 },
|
||||
{ id: 'neutral', name: 'Neutral', width: 0.22, curve: 0 },
|
||||
{ id: 'grin', name: 'Grin', width: 0.30, curve: 0.5, showTeeth: true },
|
||||
{ id: 'smirk', name: 'Smirk', width: 0.22, curve: 0.2, asymmetric: true },
|
||||
{ id: 'pout', name: 'Pout', width: 0.20, curve: -0.2, lipFullness: 0.8 },
|
||||
{ id: 'open', name: 'Open', width: 0.24, curve: 0.1, openness: 0.3 },
|
||||
{ id: 'small', name: 'Small', width: 0.16, curve: 0.1 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.32, curve: 0.1 },
|
||||
{ id: 'thin', name: 'Thin', width: 0.24, curve: 0, lipFullness: 0.3 },
|
||||
{ id: 'full', name: 'Full', width: 0.26, curve: 0.15, lipFullness: 0.9 }
|
||||
];
|
||||
|
||||
// Ear shapes (6 options)
|
||||
var EAR_SHAPES = [
|
||||
{ id: 'normal', name: 'Normal', size: 1.0, angle: 0 },
|
||||
{ id: 'small', name: 'Small', size: 0.7, angle: 0 },
|
||||
{ id: 'large', name: 'Large', size: 1.3, angle: 0 },
|
||||
{ id: 'pointed', name: 'Pointed', size: 1.0, angle: 0, pointed: true },
|
||||
{ id: 'round', name: 'Round', size: 1.0, angle: 0, roundness: 0.9 },
|
||||
{ id: 'flat', name: 'Flat', size: 0.9, angle: 15 }
|
||||
];
|
||||
|
||||
// Hair styles (30 options)
|
||||
var HAIR_STYLES = [
|
||||
{ id: 'short-messy', name: 'Short Messy', length: 'short', coverage: 0.3 },
|
||||
{ id: 'short-neat', name: 'Short Neat', length: 'short', coverage: 0.25 },
|
||||
{ id: 'short-curly', name: 'Short Curly', length: 'short', coverage: 0.35 },
|
||||
{ id: 'medium-straight', name: 'Medium Straight', length: 'medium', coverage: 0.5 },
|
||||
{ id: 'medium-wavy', name: 'Medium Wavy', length: 'medium', coverage: 0.55 },
|
||||
{ id: 'long-straight', name: 'Long Straight', length: 'long', coverage: 0.7 },
|
||||
{ id: 'long-wavy', name: 'Long Wavy', length: 'long', coverage: 0.75 },
|
||||
{ id: 'ponytail', name: 'Ponytail', length: 'medium', coverage: 0.4 },
|
||||
{ id: 'pigtails', name: 'Pigtails', length: 'medium', coverage: 0.45 },
|
||||
{ id: 'bun', name: 'Bun', length: 'long', coverage: 0.35 },
|
||||
{ id: 'mohawk', name: 'Mohawk', length: 'short', coverage: 0.2 },
|
||||
{ id: 'afro', name: 'Afro', length: 'medium', coverage: 0.8 },
|
||||
{ id: 'buzz', name: 'Buzz Cut', length: 'short', coverage: 0.15 },
|
||||
{ id: 'bald', name: 'Bald', length: 'none', coverage: 0 },
|
||||
{ id: 'sidepart', name: 'Side Part', length: 'short', coverage: 0.3 },
|
||||
{ id: 'spiky', name: 'Spiky', length: 'short', coverage: 0.35, spikes: true },
|
||||
{ id: 'slicked', name: 'Slicked Back', length: 'short', coverage: 0.25 },
|
||||
{ id: 'bob', name: 'Bob', length: 'medium', coverage: 0.5 },
|
||||
{ id: 'pixie', name: 'Pixie', length: 'short', coverage: 0.35 },
|
||||
{ id: 'braids', name: 'Braids', length: 'long', coverage: 0.6 },
|
||||
{ id: 'dreads', name: 'Dreads', length: 'long', coverage: 0.7 },
|
||||
{ id: 'undercut', name: 'Undercut', length: 'short', coverage: 0.25, sides: 'shaved' },
|
||||
{ id: 'fauxhawk', name: 'Faux Hawk', length: 'short', coverage: 0.3 },
|
||||
{ id: 'mullet', name: 'Mullet', length: 'medium', coverage: 0.45, backLength: 'long' },
|
||||
{ id: 'bowl', name: 'Bowl Cut', length: 'medium', coverage: 0.55 },
|
||||
{ id: 'combover', name: 'Comb Over', length: 'short', coverage: 0.2 },
|
||||
{ id: 'bangs', name: 'Bangs', length: 'medium', coverage: 0.5, bangs: true },
|
||||
{ id: 'sidesweep', name: 'Side Sweep', length: 'medium', coverage: 0.45 },
|
||||
{ id: 'topknot', name: 'Top Knot', length: 'long', coverage: 0.3 },
|
||||
{ id: 'cornrows', name: 'Cornrows', length: 'medium', coverage: 0.4 }
|
||||
];
|
||||
|
||||
// Hair colors (20 options)
|
||||
var HAIR_COLORS = [
|
||||
{ id: 'black', hex: '#1a1a1a', name: 'Black' },
|
||||
{ id: 'darkBrown', hex: '#2c1810', name: 'Dark Brown' },
|
||||
{ id: 'brown', hex: '#4a3728', name: 'Brown' },
|
||||
{ id: 'lightBrown', hex: '#7a5a3a', name: 'Light Brown' },
|
||||
{ id: 'auburn', hex: '#8b4513', name: 'Auburn' },
|
||||
{ id: 'ginger', hex: '#b5651d', name: 'Ginger' },
|
||||
{ id: 'strawberry', hex: '#c67171', name: 'Strawberry' },
|
||||
{ id: 'blonde', hex: '#d4b896', name: 'Blonde' },
|
||||
{ id: 'platinum', hex: '#e8e4c9', name: 'Platinum' },
|
||||
{ id: 'white', hex: '#f0f0f0', name: 'White' },
|
||||
{ id: 'gray', hex: '#808080', name: 'Gray' },
|
||||
{ id: 'red', hex: '#cc0000', name: 'Red' },
|
||||
{ id: 'blue', hex: '#0066cc', name: 'Blue' },
|
||||
{ id: 'purple', hex: '#7b2d8e', name: 'Purple' },
|
||||
{ id: 'green', hex: '#228b22', name: 'Green' },
|
||||
{ id: 'pink', hex: '#ff69b4', name: 'Pink' },
|
||||
{ id: 'teal', hex: '#008b8b', name: 'Teal' },
|
||||
{ id: 'orange', hex: '#ff6600', name: 'Orange' },
|
||||
{ id: 'silver', hex: '#c0c0c0', name: 'Silver' },
|
||||
{ id: 'rainbow', hex: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #8b00ff)', name: 'Rainbow', isGradient: true }
|
||||
];
|
||||
|
||||
// Facial hair (10 options)
|
||||
var FACIAL_HAIR = [
|
||||
{ id: 'none', name: 'None' },
|
||||
{ id: 'stubble', name: 'Stubble', density: 0.3, length: 0.1 },
|
||||
{ id: 'goatee', name: 'Goatee', coverage: 'chin', length: 0.4 },
|
||||
{ id: 'mustache', name: 'Mustache', coverage: 'upper-lip', length: 0.3, style: 'classic' },
|
||||
{ id: 'fullBeard', name: 'Full Beard', coverage: 'full', length: 0.7 },
|
||||
{ id: 'shortBeard', name: 'Short Beard', coverage: 'full', length: 0.3 },
|
||||
{ id: 'soulPatch', name: 'Soul Patch', coverage: 'chin-center', length: 0.3 },
|
||||
{ id: 'mutton', name: 'Mutton Chops', coverage: 'sides', length: 0.5 },
|
||||
{ id: 'handlebar', name: 'Handlebar', coverage: 'upper-lip', length: 0.4, style: 'handlebar' },
|
||||
{ id: 'vandyke', name: 'Van Dyke', coverage: 'chin-mustache', length: 0.5 }
|
||||
];
|
||||
|
||||
// Body types (6 options)
|
||||
var BODY_TYPES = [
|
||||
{ id: 'slim', name: 'Slim', widthRatio: 0.8, heightRatio: 1.0 },
|
||||
{ id: 'average', name: 'Average', widthRatio: 1.0, heightRatio: 1.0 },
|
||||
{ id: 'athletic', name: 'Athletic', widthRatio: 1.1, heightRatio: 1.0 },
|
||||
{ id: 'stocky', name: 'Stocky', widthRatio: 1.2, heightRatio: 0.95 },
|
||||
{ id: 'tall', name: 'Tall', widthRatio: 0.95, heightRatio: 1.15 },
|
||||
{ id: 'short', name: 'Short', widthRatio: 1.0, heightRatio: 0.85 }
|
||||
];
|
||||
|
||||
// Clothing styles (4 options)
|
||||
var CLOTHING_STYLES = [
|
||||
{ id: 'casual', name: 'Casual', hasCollar: false, sleeveLength: 'short' },
|
||||
{ id: 'sporty', name: 'Sporty', hasCollar: false, sleeveLength: 'short', hasStripes: true },
|
||||
{ id: 'formal', name: 'Formal', hasCollar: true, sleeveLength: 'long' },
|
||||
{ id: 'scifi', name: 'Sci-Fi', hasCollar: true, sleeveLength: 'long', hasGlow: true }
|
||||
];
|
||||
|
||||
// Preset colors for clothing
|
||||
var CLOTHING_COLORS = [
|
||||
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c',
|
||||
'#3498db', '#9b59b6', '#34495e', '#95a5a6', '#ecf0f1',
|
||||
'#c0392b', '#d35400', '#f39c12', '#27ae60', '#16a085',
|
||||
'#2980b9', '#8e44ad', '#2c3e50', '#7f8c8d', '#bdc3c7'
|
||||
];
|
||||
|
||||
// 10 Default guest avatars
|
||||
var GUEST_AVATARS = [
|
||||
{
|
||||
id: 'guest_1',
|
||||
name: 'Guest Blue',
|
||||
base: {
|
||||
faceShape: 'round',
|
||||
skinTone: 'medium1',
|
||||
eyes: { shape: 'round', color: 'blue' },
|
||||
eyebrows: 'natural',
|
||||
nose: 'button',
|
||||
mouth: 'smile',
|
||||
ears: 'normal',
|
||||
hair: { style: 'short-messy', color: 'darkBrown' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_2',
|
||||
name: 'Guest Red',
|
||||
base: {
|
||||
faceShape: 'oval',
|
||||
skinTone: 'light2',
|
||||
eyes: { shape: 'almond', color: 'brown' },
|
||||
eyebrows: 'arched',
|
||||
nose: 'pointed',
|
||||
mouth: 'neutral',
|
||||
ears: 'normal',
|
||||
hair: { style: 'long-straight', color: 'black' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'slim' },
|
||||
clothing: { style: 'casual', shirtColor: '#e74c3c', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_3',
|
||||
name: 'Guest Green',
|
||||
base: {
|
||||
faceShape: 'square',
|
||||
skinTone: 'tan1',
|
||||
eyes: { shape: 'narrow', color: 'darkBrown' },
|
||||
eyebrows: 'thick',
|
||||
nose: 'wide',
|
||||
mouth: 'grin',
|
||||
ears: 'large',
|
||||
hair: { style: 'buzz', color: 'black' },
|
||||
facialHair: 'stubble',
|
||||
body: { type: 'athletic' },
|
||||
clothing: { style: 'sporty', shirtColor: '#2ecc71', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_4',
|
||||
name: 'Guest Purple',
|
||||
base: {
|
||||
faceShape: 'heart',
|
||||
skinTone: 'fair2',
|
||||
eyes: { shape: 'wide', color: 'green' },
|
||||
eyebrows: 'curved',
|
||||
nose: 'snub',
|
||||
mouth: 'small',
|
||||
ears: 'small',
|
||||
hair: { style: 'bob', color: 'auburn' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#9b59b6', pantsColor: '#7f8c8d' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_5',
|
||||
name: 'Guest Orange',
|
||||
base: {
|
||||
faceShape: 'long',
|
||||
skinTone: 'brown1',
|
||||
eyes: { shape: 'oval', color: 'amber' },
|
||||
eyebrows: 'straight',
|
||||
nose: 'roman',
|
||||
mouth: 'full',
|
||||
ears: 'normal',
|
||||
hair: { style: 'afro', color: 'black' },
|
||||
facialHair: 'goatee',
|
||||
body: { type: 'tall' },
|
||||
clothing: { style: 'casual', shirtColor: '#e67e22', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_6',
|
||||
name: 'Guest Teal',
|
||||
base: {
|
||||
faceShape: 'soft',
|
||||
skinTone: 'dark1',
|
||||
eyes: { shape: 'happy', color: 'darkBrown' },
|
||||
eyebrows: 'natural',
|
||||
nose: 'flat',
|
||||
mouth: 'wide',
|
||||
ears: 'round',
|
||||
hair: { style: 'dreads', color: 'black' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'stocky' },
|
||||
clothing: { style: 'casual', shirtColor: '#1abc9c', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_7',
|
||||
name: 'Guest Gray',
|
||||
base: {
|
||||
faceShape: 'angular',
|
||||
skinTone: 'light3',
|
||||
eyes: { shape: 'cat', color: 'gray' },
|
||||
eyebrows: 'sleek',
|
||||
nose: 'narrow',
|
||||
mouth: 'smirk',
|
||||
ears: 'pointed',
|
||||
hair: { style: 'undercut', color: 'platinum' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'slim' },
|
||||
clothing: { style: 'scifi', shirtColor: '#95a5a6', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_8',
|
||||
name: 'Guest Yellow',
|
||||
base: {
|
||||
faceShape: 'wide',
|
||||
skinTone: 'medium3',
|
||||
eyes: { shape: 'surprised', color: 'hazel' },
|
||||
eyebrows: 'worried',
|
||||
nose: 'button',
|
||||
mouth: 'open',
|
||||
ears: 'normal',
|
||||
hair: { style: 'spiky', color: 'blonde' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'short' },
|
||||
clothing: { style: 'casual', shirtColor: '#f1c40f', pantsColor: '#7f8c8d' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_9',
|
||||
name: 'Guest Navy',
|
||||
base: {
|
||||
faceShape: 'oval',
|
||||
skinTone: 'tan2',
|
||||
eyes: { shape: 'droopy', color: 'brown' },
|
||||
eyebrows: 'bushy',
|
||||
nose: 'aquiline',
|
||||
mouth: 'neutral',
|
||||
ears: 'flat',
|
||||
hair: { style: 'combover', color: 'gray' },
|
||||
facialHair: 'fullBeard',
|
||||
body: { type: 'stocky' },
|
||||
clothing: { style: 'formal', shirtColor: '#2c3e50', pantsColor: '#1a1a2e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_10',
|
||||
name: 'Guest Pink',
|
||||
base: {
|
||||
faceShape: 'round',
|
||||
skinTone: 'fair3',
|
||||
eyes: { shape: 'round', color: 'lightBlue' },
|
||||
eyebrows: 'thin',
|
||||
nose: 'snub',
|
||||
mouth: 'pout',
|
||||
ears: 'small',
|
||||
hair: { style: 'pigtails', color: 'strawberry' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#ff69b4', pantsColor: '#bdc3c7' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Helper function to find item by ID
|
||||
function findById(array, id) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
if (array[i].id === id) {
|
||||
return array[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.AvatarData = {
|
||||
FACE_SHAPES: FACE_SHAPES,
|
||||
SKIN_TONES: SKIN_TONES,
|
||||
EYE_SHAPES: EYE_SHAPES,
|
||||
EYE_COLORS: EYE_COLORS,
|
||||
EYEBROW_SHAPES: EYEBROW_SHAPES,
|
||||
NOSE_SHAPES: NOSE_SHAPES,
|
||||
MOUTH_SHAPES: MOUTH_SHAPES,
|
||||
EAR_SHAPES: EAR_SHAPES,
|
||||
HAIR_STYLES: HAIR_STYLES,
|
||||
HAIR_COLORS: HAIR_COLORS,
|
||||
FACIAL_HAIR: FACIAL_HAIR,
|
||||
BODY_TYPES: BODY_TYPES,
|
||||
CLOTHING_STYLES: CLOTHING_STYLES,
|
||||
CLOTHING_COLORS: CLOTHING_COLORS,
|
||||
GUEST_AVATARS: GUEST_AVATARS,
|
||||
|
||||
// Helper methods
|
||||
getFaceShape: function(id) { return findById(FACE_SHAPES, id); },
|
||||
getSkinTone: function(id) { return findById(SKIN_TONES, id); },
|
||||
getEyeShape: function(id) { return findById(EYE_SHAPES, id); },
|
||||
getEyeColor: function(id) { return findById(EYE_COLORS, id); },
|
||||
getEyebrowShape: function(id) { return findById(EYEBROW_SHAPES, id); },
|
||||
getNoseShape: function(id) { return findById(NOSE_SHAPES, id); },
|
||||
getMouthShape: function(id) { return findById(MOUTH_SHAPES, id); },
|
||||
getEarShape: function(id) { return findById(EAR_SHAPES, id); },
|
||||
getHairStyle: function(id) { return findById(HAIR_STYLES, id); },
|
||||
getHairColor: function(id) { return findById(HAIR_COLORS, id); },
|
||||
getFacialHair: function(id) { return findById(FACIAL_HAIR, id); },
|
||||
getBodyType: function(id) { return findById(BODY_TYPES, id); },
|
||||
getClothingStyle: function(id) { return findById(CLOTHING_STYLES, id); },
|
||||
|
||||
getRandomGuestAvatar: function() {
|
||||
return GUEST_AVATARS[Math.floor(Math.random() * GUEST_AVATARS.length)];
|
||||
},
|
||||
|
||||
createDefaultAvatar: function() {
|
||||
return {
|
||||
faceShape: 'round',
|
||||
skinTone: 'medium1',
|
||||
eyes: {
|
||||
shape: 'round',
|
||||
color: 'brown'
|
||||
},
|
||||
eyebrows: 'natural',
|
||||
nose: 'button',
|
||||
mouth: 'smile',
|
||||
ears: 'normal',
|
||||
hair: {
|
||||
style: 'short-neat',
|
||||
color: 'brown'
|
||||
},
|
||||
facialHair: 'none',
|
||||
body: {
|
||||
type: 'average'
|
||||
},
|
||||
clothing: {
|
||||
style: 'casual',
|
||||
shirtColor: '#3498db',
|
||||
pantsColor: '#2c3e50'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* AvatarSystem - Main API for Mii-style avatar system
|
||||
* Coordinates data, rendering, animations, and accessories
|
||||
*
|
||||
* Depends on: AvatarData, AvatarAnimations, AvatarRenderer, AvatarAccessories, AccessoryRenderer
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ─── Private State ───────────────────────────────────
|
||||
|
||||
// In-memory cache of loaded avatars
|
||||
var avatarCache = new Map();
|
||||
|
||||
// Animation state per avatar
|
||||
var animationState = new Map();
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate avatar data structure
|
||||
* @param {Object} data - Avatar data to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function validateAvatar(data) {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
if (!data.base || typeof data.base !== 'object') return false;
|
||||
|
||||
// Check required base fields exist
|
||||
var requiredFields = ['faceShape', 'skinTone', 'eyes', 'eyebrows', 'nose', 'mouth', 'ears', 'hair', 'body', 'clothing'];
|
||||
for (var i = 0; i < requiredFields.length; i++) {
|
||||
if (data.base[requiredFields[i]] === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested objects
|
||||
if (!data.base.eyes || typeof data.base.eyes !== 'object') return false;
|
||||
if (!data.base.eyes.shape || !data.base.eyes.color) return false;
|
||||
|
||||
if (!data.base.hair || typeof data.base.hair !== 'object') return false;
|
||||
if (!data.base.hair.style || !data.base.hair.color) return false;
|
||||
|
||||
if (!data.base.body || typeof data.base.body !== 'object') return false;
|
||||
if (!data.base.body.type) return false;
|
||||
|
||||
if (!data.base.clothing || typeof data.base.clothing !== 'object') return false;
|
||||
if (!data.base.clothing.style || !data.base.clothing.shirtColor || !data.base.clothing.pantsColor) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default avatar with standard settings
|
||||
* @returns {Object} Default avatar object
|
||||
*/
|
||||
function createDefault() {
|
||||
return {
|
||||
userId: null,
|
||||
base: {
|
||||
faceShape: 'oval',
|
||||
skinTone: '#d4a373',
|
||||
eyes: { shape: 'round', color: 'brown' },
|
||||
eyebrows: 'natural',
|
||||
nose: 'button',
|
||||
mouth: 'smile',
|
||||
ears: 'normal',
|
||||
hair: { style: 'short-messy', color: '#2c1810' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' }
|
||||
},
|
||||
earned: { eyewear: [], headwear: [], effects: [] },
|
||||
equipped: { eyewear: null, headwear: null, effect: null },
|
||||
level: 1,
|
||||
xp: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone an avatar object
|
||||
* @param {Object} avatar - Avatar to clone
|
||||
* @returns {Object} Cloned avatar
|
||||
*/
|
||||
function cloneAvatar(avatar) {
|
||||
return JSON.parse(JSON.stringify(avatar));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge customization into base avatar
|
||||
* @param {Object} target - Target avatar base
|
||||
* @param {Object} source - Source customization
|
||||
* @returns {Object} Merged base object
|
||||
*/
|
||||
function deepMergeBase(target, source) {
|
||||
var result = Object.assign({}, target);
|
||||
|
||||
for (var key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = Object.assign({}, target[key] || {}, source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get animation frame based on time
|
||||
* @param {Object} anim - Animation definition
|
||||
* @param {number} startTime - Animation start time
|
||||
* @param {boolean} loop - Whether to loop
|
||||
* @returns {Object} Frame info with index and complete flag
|
||||
*/
|
||||
function getAnimationFrame(anim, startTime, loop) {
|
||||
var elapsed = performance.now() - startTime;
|
||||
var totalDuration = anim.frameDuration * anim.frameCount;
|
||||
|
||||
if (!loop && elapsed >= totalDuration) {
|
||||
return { frame: anim.frameCount - 1, complete: true };
|
||||
}
|
||||
|
||||
var frameIndex = Math.floor((elapsed / anim.frameDuration) % anim.frameCount);
|
||||
return { frame: frameIndex, complete: false };
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────
|
||||
|
||||
window.AvatarSystem = {
|
||||
|
||||
// ─── Creation ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new avatar from customization choices
|
||||
* @param {Object} customization - Full base customization object
|
||||
* @returns {Object} Complete avatar object
|
||||
*/
|
||||
create: function(customization) {
|
||||
var avatar = createDefault();
|
||||
if (customization) {
|
||||
avatar.base = deepMergeBase(avatar.base, customization);
|
||||
}
|
||||
return avatar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load user's saved avatar (from API or storage)
|
||||
* @param {string} userId - User ID to load avatar for
|
||||
* @returns {Promise<Object>} Avatar object
|
||||
*/
|
||||
loadUserAvatar: function(userId) {
|
||||
var self = this;
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
// Check cache first
|
||||
if (avatarCache.has(userId)) {
|
||||
resolve(cloneAvatar(avatarCache.get(userId)));
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, this would fetch from API
|
||||
// For now, check localStorage as fallback
|
||||
var storageKey = 'avatar_' + userId;
|
||||
var stored = localStorage.getItem(storageKey);
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
var parsed = JSON.parse(stored);
|
||||
if (validateAvatar(parsed)) {
|
||||
avatarCache.set(userId, parsed);
|
||||
resolve(cloneAvatar(parsed));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('AvatarSystem: Failed to parse stored avatar', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Return a guest avatar for unknown users
|
||||
var guest = typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar
|
||||
? AvatarData.getRandomGuestAvatar()
|
||||
: { base: createDefault().base };
|
||||
|
||||
var avatar = createDefault();
|
||||
avatar.userId = userId;
|
||||
avatar.base = Object.assign({}, avatar.base, guest.base);
|
||||
|
||||
resolve(avatar);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Save avatar to cache (and would persist to API)
|
||||
* @param {Object} avatar - Avatar to save
|
||||
* @returns {Object} Saved avatar
|
||||
*/
|
||||
saveAvatar: function(avatar) {
|
||||
if (avatar.userId) {
|
||||
avatarCache.set(avatar.userId, cloneAvatar(avatar));
|
||||
|
||||
// Also save to localStorage for persistence
|
||||
var storageKey = 'avatar_' + avatar.userId;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(avatar));
|
||||
} catch (e) {
|
||||
console.warn('AvatarSystem: Failed to save avatar to localStorage', e);
|
||||
}
|
||||
}
|
||||
return avatar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear avatar from cache
|
||||
* @param {string} userId - User ID to clear
|
||||
*/
|
||||
clearCache: function(userId) {
|
||||
if (userId) {
|
||||
avatarCache.delete(userId);
|
||||
} else {
|
||||
avatarCache.clear();
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Rendering ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render avatar in specified mode
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} mode - 'HEAD_ONLY', 'HEAD_AND_SHOULDERS', 'UPPER_BODY', 'FULL_BODY'
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {number} x - Center X position
|
||||
* @param {number} y - Center Y position
|
||||
* @param {Object} options - { scale, animation, frame, time }
|
||||
*/
|
||||
render: function(avatar, mode, ctx, x, y, options) {
|
||||
options = options || {};
|
||||
var scale = options.scale || 1;
|
||||
var time = options.time !== undefined ? options.time : performance.now() / 1000;
|
||||
|
||||
// Get animation transforms if animating
|
||||
var transforms = {};
|
||||
if (options.animation && typeof AvatarAnimations !== 'undefined') {
|
||||
var anim = AvatarAnimations.getAnimation(mode, options.animation);
|
||||
if (anim) {
|
||||
var frame;
|
||||
if (options.frame !== undefined) {
|
||||
frame = options.frame;
|
||||
} else {
|
||||
frame = Math.floor((time * 1000 / anim.frameDuration) % anim.frameCount);
|
||||
}
|
||||
transforms = anim.frames[frame] || {};
|
||||
}
|
||||
}
|
||||
|
||||
// Render base avatar
|
||||
if (typeof AvatarRenderer !== 'undefined') {
|
||||
AvatarRenderer.render(ctx, avatar, mode, x, y, {
|
||||
scale: scale,
|
||||
transforms: transforms
|
||||
});
|
||||
} else {
|
||||
console.warn('AvatarSystem: AvatarRenderer not loaded');
|
||||
}
|
||||
|
||||
// Render accessories on top
|
||||
if (typeof AccessoryRenderer !== 'undefined') {
|
||||
AccessoryRenderer.render(ctx, avatar, mode, x, y, scale, time);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render avatar to an offscreen canvas
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} mode - Rendering mode
|
||||
* @param {Object} options - Render options
|
||||
* @returns {HTMLCanvasElement} Canvas with rendered avatar
|
||||
*/
|
||||
renderToCanvas: function(avatar, mode, options) {
|
||||
options = options || {};
|
||||
var modeData = this.getModeSize(mode);
|
||||
var scale = options.scale || 1;
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = modeData.width * scale;
|
||||
canvas.height = modeData.height * scale;
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
var centerX = canvas.width / 2;
|
||||
var centerY = canvas.height / 2;
|
||||
|
||||
this.render(avatar, mode, ctx, centerX, centerY, options);
|
||||
|
||||
return canvas;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render avatar to a data URL
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} mode - Rendering mode
|
||||
* @param {Object} options - Render options
|
||||
* @returns {string} Data URL of rendered avatar
|
||||
*/
|
||||
renderToDataURL: function(avatar, mode, options) {
|
||||
var canvas = this.renderToCanvas(avatar, mode, options);
|
||||
return canvas.toDataURL('image/png');
|
||||
},
|
||||
|
||||
// ─── Animation ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get current animation frame transforms
|
||||
* @param {Object} avatar - Avatar object (unused but kept for API consistency)
|
||||
* @param {string} animation - Animation ID
|
||||
* @param {number} frame - Frame number
|
||||
* @returns {Object} Transform data for the frame
|
||||
*/
|
||||
animate: function(avatar, animation, frame) {
|
||||
if (typeof AvatarAnimations === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
var anim = AvatarAnimations.getAnimation('FULL_BODY', animation);
|
||||
if (!anim) return {};
|
||||
|
||||
var f = frame % anim.frameCount;
|
||||
return anim.frames[f] || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Start an animation for an avatar
|
||||
* @param {string} avatarId - Avatar/user ID
|
||||
* @param {string} animation - Animation ID
|
||||
* @param {Object} options - { loop, onComplete, mode }
|
||||
*/
|
||||
startAnimation: function(avatarId, animation, options) {
|
||||
options = options || {};
|
||||
|
||||
animationState.set(avatarId, {
|
||||
animation: animation,
|
||||
startTime: performance.now(),
|
||||
loop: options.loop !== false,
|
||||
mode: options.mode || 'FULL_BODY',
|
||||
onComplete: options.onComplete || null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop animation for an avatar
|
||||
* @param {string} avatarId - Avatar/user ID
|
||||
*/
|
||||
stopAnimation: function(avatarId) {
|
||||
var state = animationState.get(avatarId);
|
||||
if (state && state.onComplete) {
|
||||
state.onComplete();
|
||||
}
|
||||
animationState.delete(avatarId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current animation state for an avatar
|
||||
* @param {string} avatarId - Avatar/user ID
|
||||
* @returns {Object|null} Current animation state or null
|
||||
*/
|
||||
getAnimationState: function(avatarId) {
|
||||
var state = animationState.get(avatarId);
|
||||
if (!state) return null;
|
||||
|
||||
if (typeof AvatarAnimations === 'undefined') {
|
||||
return { animation: state.animation, frame: 0, complete: false };
|
||||
}
|
||||
|
||||
var anim = AvatarAnimations.getAnimation(state.mode, state.animation);
|
||||
if (!anim) return null;
|
||||
|
||||
var frameInfo = getAnimationFrame(anim, state.startTime, state.loop);
|
||||
|
||||
// Handle completion
|
||||
if (frameInfo.complete && !state.loop) {
|
||||
if (state.onComplete) {
|
||||
state.onComplete();
|
||||
}
|
||||
animationState.delete(avatarId);
|
||||
}
|
||||
|
||||
return {
|
||||
animation: state.animation,
|
||||
frame: frameInfo.frame,
|
||||
complete: frameInfo.complete,
|
||||
transforms: anim.frames[frameInfo.frame] || {}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if avatar is currently animating
|
||||
* @param {string} avatarId - Avatar/user ID
|
||||
* @returns {boolean} True if animating
|
||||
*/
|
||||
isAnimating: function(avatarId) {
|
||||
return animationState.has(avatarId);
|
||||
},
|
||||
|
||||
// ─── Accessories ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Equip an accessory
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} slot - 'eyewear', 'headwear', 'effect'
|
||||
* @param {string} itemId - Accessory ID or null to unequip
|
||||
* @returns {boolean} True if successfully equipped
|
||||
*/
|
||||
equipAccessory: function(avatar, slot, itemId) {
|
||||
if (!avatar.equipped) {
|
||||
avatar.equipped = { eyewear: null, headwear: null, effect: null };
|
||||
}
|
||||
|
||||
// Allow unequipping (null/undefined)
|
||||
if (!itemId) {
|
||||
avatar.equipped[slot] = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify user has earned this item
|
||||
var earnedKey = slot === 'effect' ? 'effects' : slot;
|
||||
var earned = avatar.earned && avatar.earned[earnedKey];
|
||||
|
||||
if (!earned || !earned.includes(itemId)) {
|
||||
console.warn('AvatarSystem: Cannot equip unearned item', itemId);
|
||||
return false;
|
||||
}
|
||||
|
||||
avatar.equipped[slot] = itemId;
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Unequip an accessory from a slot
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} slot - 'eyewear', 'headwear', 'effect'
|
||||
* @returns {boolean} True if successfully unequipped
|
||||
*/
|
||||
unequipAccessory: function(avatar, slot) {
|
||||
return this.equipAccessory(avatar, slot, null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlock/award an accessory to a user
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} itemId - Accessory ID to unlock
|
||||
* @returns {boolean} True if newly unlocked, false if already owned
|
||||
*/
|
||||
unlockAccessory: function(avatar, itemId) {
|
||||
if (typeof AvatarAccessories === 'undefined') {
|
||||
console.warn('AvatarSystem: AvatarAccessories not loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
var item = AvatarAccessories.getById(itemId);
|
||||
if (!item) {
|
||||
console.warn('AvatarSystem: Unknown accessory', itemId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var category = item.category === 'effect' ? 'effects' : item.category;
|
||||
|
||||
// Initialize earned structure if needed
|
||||
if (!avatar.earned) {
|
||||
avatar.earned = { eyewear: [], headwear: [], effects: [] };
|
||||
}
|
||||
if (!avatar.earned[category]) {
|
||||
avatar.earned[category] = [];
|
||||
}
|
||||
|
||||
// Check if already owned
|
||||
if (avatar.earned[category].includes(itemId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
avatar.earned[category].push(itemId);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all accessories a user has earned
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @returns {Object} Object with eyewear, headwear, effects arrays of accessory objects
|
||||
*/
|
||||
getEarnedAccessories: function(avatar) {
|
||||
var result = { eyewear: [], headwear: [], effects: [] };
|
||||
|
||||
if (!avatar.earned) return result;
|
||||
|
||||
if (typeof AvatarAccessories === 'undefined') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (avatar.earned.eyewear) {
|
||||
result.eyewear = avatar.earned.eyewear
|
||||
.map(function(id) { return AvatarAccessories.getById(id); })
|
||||
.filter(function(item) { return item !== null; });
|
||||
}
|
||||
|
||||
if (avatar.earned.headwear) {
|
||||
result.headwear = avatar.earned.headwear
|
||||
.map(function(id) { return AvatarAccessories.getById(id); })
|
||||
.filter(function(item) { return item !== null; });
|
||||
}
|
||||
|
||||
if (avatar.earned.effects) {
|
||||
result.effects = avatar.earned.effects
|
||||
.map(function(id) { return AvatarAccessories.getById(id); })
|
||||
.filter(function(item) { return item !== null; });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get currently equipped accessories
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @returns {Object} Object with equipped accessory objects
|
||||
*/
|
||||
getEquippedAccessories: function(avatar) {
|
||||
var result = { eyewear: null, headwear: null, effect: null };
|
||||
|
||||
if (!avatar.equipped) return result;
|
||||
|
||||
if (typeof AvatarAccessories === 'undefined') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (avatar.equipped.eyewear) {
|
||||
result.eyewear = AvatarAccessories.getById(avatar.equipped.eyewear);
|
||||
}
|
||||
if (avatar.equipped.headwear) {
|
||||
result.headwear = AvatarAccessories.getById(avatar.equipped.headwear);
|
||||
}
|
||||
if (avatar.equipped.effect) {
|
||||
result.effect = AvatarAccessories.getById(avatar.equipped.effect);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check what accessories should unlock at a level
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {number} newLevel - New level reached
|
||||
* @returns {Array} Array of newly unlocked accessory objects
|
||||
*/
|
||||
checkLevelUnlocks: function(avatar, newLevel) {
|
||||
if (typeof AvatarAccessories === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
var newItems = AvatarAccessories.getNewlyUnlockedAtLevel(newLevel);
|
||||
var unlocked = [];
|
||||
var self = this;
|
||||
|
||||
newItems.forEach(function(item) {
|
||||
if (self.unlockAccessory(avatar, item.id)) {
|
||||
unlocked.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return unlocked;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the full accessory catalog
|
||||
* @returns {Object} Full accessory catalog
|
||||
*/
|
||||
getAccessoryCatalog: function() {
|
||||
if (typeof AvatarAccessories === 'undefined') {
|
||||
return { eyewear: [], headwear: [], effects: [] };
|
||||
}
|
||||
return AvatarAccessories.getCatalog();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if avatar has a specific accessory
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} itemId - Accessory ID
|
||||
* @returns {boolean} True if owned
|
||||
*/
|
||||
hasAccessory: function(avatar, itemId) {
|
||||
if (!avatar.earned) return false;
|
||||
|
||||
for (var category in avatar.earned) {
|
||||
if (avatar.earned[category] && avatar.earned[category].includes(itemId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// ─── Export for Games ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Prepare avatar for use in a game
|
||||
* Returns a simplified object optimized for game rendering
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} mode - Rendering mode
|
||||
* @returns {Object} Game-ready avatar object
|
||||
*/
|
||||
exportForGame: function(avatar, mode) {
|
||||
var self = this;
|
||||
var modeData = this.getModeSize(mode);
|
||||
var cloned = cloneAvatar(avatar);
|
||||
|
||||
var animations = {};
|
||||
if (typeof AvatarAnimations !== 'undefined') {
|
||||
animations = AvatarAnimations.getAnimationsForMode(mode);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: mode,
|
||||
width: modeData.width,
|
||||
height: modeData.height,
|
||||
avatar: cloned,
|
||||
animations: animations,
|
||||
|
||||
/**
|
||||
* Render the avatar
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - X position
|
||||
* @param {number} y - Y position
|
||||
* @param {Object} options - Render options
|
||||
*/
|
||||
render: function(ctx, x, y, options) {
|
||||
self.render(cloned, mode, ctx, x, y, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get animation transforms
|
||||
* @param {string} animation - Animation ID
|
||||
* @param {number} frame - Frame number
|
||||
* @returns {Object} Transform data
|
||||
*/
|
||||
animate: function(animation, frame) {
|
||||
return self.animate(cloned, animation, frame);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get animation info
|
||||
* @param {string} animationId - Animation ID
|
||||
* @returns {Object|null} Animation info
|
||||
*/
|
||||
getAnimationInfo: function(animationId) {
|
||||
if (typeof AvatarAnimations === 'undefined') return null;
|
||||
return AvatarAnimations.getAnimation(mode, animationId);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Export avatar as minimal data for network transmission
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @returns {Object} Minimal avatar data
|
||||
*/
|
||||
exportMinimal: function(avatar) {
|
||||
return {
|
||||
base: avatar.base,
|
||||
equipped: avatar.equipped,
|
||||
level: avatar.level
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Import avatar from minimal data
|
||||
* @param {Object} minimalData - Minimal avatar data
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Object} Full avatar object
|
||||
*/
|
||||
importMinimal: function(minimalData, userId) {
|
||||
var avatar = createDefault();
|
||||
avatar.userId = userId;
|
||||
|
||||
if (minimalData.base) {
|
||||
avatar.base = deepMergeBase(avatar.base, minimalData.base);
|
||||
}
|
||||
if (minimalData.equipped) {
|
||||
avatar.equipped = Object.assign({}, avatar.equipped, minimalData.equipped);
|
||||
}
|
||||
if (minimalData.level !== undefined) {
|
||||
avatar.level = minimalData.level;
|
||||
}
|
||||
|
||||
return avatar;
|
||||
},
|
||||
|
||||
// ─── Guest Avatars ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a random guest avatar (for non-logged-in users)
|
||||
* @returns {Object} Guest avatar object
|
||||
*/
|
||||
getGuestAvatar: function() {
|
||||
var guest;
|
||||
|
||||
if (typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar) {
|
||||
guest = AvatarData.getRandomGuestAvatar();
|
||||
} else {
|
||||
guest = { base: createDefault().base };
|
||||
}
|
||||
|
||||
var avatar = createDefault();
|
||||
avatar.userId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
avatar.base = Object.assign({}, avatar.base, guest.base);
|
||||
avatar.isGuest = true;
|
||||
|
||||
return avatar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if avatar is a guest
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @returns {boolean} True if guest avatar
|
||||
*/
|
||||
isGuest: function(avatar) {
|
||||
return avatar.isGuest === true || (avatar.userId && avatar.userId.startsWith('guest_'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert guest avatar to registered user avatar
|
||||
* @param {Object} guestAvatar - Guest avatar
|
||||
* @param {string} userId - New user ID
|
||||
* @returns {Object} Converted avatar
|
||||
*/
|
||||
convertGuestToUser: function(guestAvatar, userId) {
|
||||
var avatar = cloneAvatar(guestAvatar);
|
||||
avatar.userId = userId;
|
||||
delete avatar.isGuest;
|
||||
return avatar;
|
||||
},
|
||||
|
||||
// ─── Level & XP ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add XP to avatar and check for level ups
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {number} xpAmount - XP to add
|
||||
* @returns {Object} Result with newLevel, leveledUp, unlockedItems
|
||||
*/
|
||||
addXP: function(avatar, xpAmount) {
|
||||
var oldLevel = avatar.level || 1;
|
||||
avatar.xp = (avatar.xp || 0) + xpAmount;
|
||||
|
||||
// Calculate new level (100 XP per level, increasing by 50 each level)
|
||||
var xpNeeded = 0;
|
||||
var level = 0;
|
||||
var remainingXP = avatar.xp;
|
||||
|
||||
while (remainingXP >= 0) {
|
||||
level++;
|
||||
var xpForLevel = 100 + (level - 1) * 50;
|
||||
if (remainingXP < xpForLevel) break;
|
||||
remainingXP -= xpForLevel;
|
||||
}
|
||||
|
||||
avatar.level = level;
|
||||
|
||||
var result = {
|
||||
newLevel: level,
|
||||
leveledUp: level > oldLevel,
|
||||
unlockedItems: []
|
||||
};
|
||||
|
||||
// Check for unlocks at each new level
|
||||
if (result.leveledUp) {
|
||||
for (var l = oldLevel + 1; l <= level; l++) {
|
||||
var unlocked = this.checkLevelUnlocks(avatar, l);
|
||||
result.unlockedItems = result.unlockedItems.concat(unlocked);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get XP needed for next level
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @returns {Object} XP info
|
||||
*/
|
||||
getXPInfo: function(avatar) {
|
||||
var level = avatar.level || 1;
|
||||
var totalXP = avatar.xp || 0;
|
||||
|
||||
// Calculate XP used for previous levels
|
||||
var xpUsed = 0;
|
||||
for (var l = 1; l < level; l++) {
|
||||
xpUsed += 100 + (l - 1) * 50;
|
||||
}
|
||||
|
||||
var currentLevelXP = totalXP - xpUsed;
|
||||
var xpForCurrentLevel = 100 + (level - 1) * 50;
|
||||
|
||||
return {
|
||||
level: level,
|
||||
totalXP: totalXP,
|
||||
currentLevelXP: currentLevelXP,
|
||||
xpNeededForLevel: xpForCurrentLevel,
|
||||
progress: currentLevelXP / xpForCurrentLevel
|
||||
};
|
||||
},
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a sprite sheet for an avatar
|
||||
* Useful for game performance
|
||||
* @param {Object} avatar - Avatar object
|
||||
* @param {string} mode - Rendering mode
|
||||
* @param {Array} animations - Animation IDs (optional, defaults to all)
|
||||
* @returns {Object} Sprite sheet data
|
||||
*/
|
||||
createSpriteSheet: function(avatar, mode, animations) {
|
||||
var modeData = this.getModeSize(mode);
|
||||
var self = this;
|
||||
|
||||
// Get animation list
|
||||
if (!animations && typeof AvatarAnimations !== 'undefined') {
|
||||
var allAnims = AvatarAnimations.getAnimationsForMode(mode);
|
||||
animations = Object.keys(allAnims);
|
||||
}
|
||||
animations = animations || [];
|
||||
|
||||
// Calculate sprite sheet dimensions
|
||||
var maxFrames = 0;
|
||||
var animData = [];
|
||||
|
||||
animations.forEach(function(animId) {
|
||||
if (typeof AvatarAnimations !== 'undefined') {
|
||||
var anim = AvatarAnimations.getAnimation(mode, animId);
|
||||
if (anim) {
|
||||
maxFrames = Math.max(maxFrames, anim.frameCount);
|
||||
animData.push({ id: animId, frames: anim.frameCount, duration: anim.frameDuration });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle case with no animations
|
||||
if (animData.length === 0) {
|
||||
animData.push({ id: 'idle', frames: 1, duration: 100 });
|
||||
maxFrames = 1;
|
||||
}
|
||||
|
||||
// Create canvas
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = modeData.width * maxFrames;
|
||||
canvas.height = modeData.height * animData.length;
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
// Render each animation frame
|
||||
animData.forEach(function(anim, row) {
|
||||
for (var f = 0; f < anim.frames; f++) {
|
||||
var x = modeData.width * f + modeData.width / 2;
|
||||
var y = modeData.height * row + modeData.height / 2;
|
||||
self.render(avatar, mode, ctx, x, y, { animation: anim.id, frame: f });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
canvas: canvas,
|
||||
image: canvas,
|
||||
animations: animData,
|
||||
frameWidth: modeData.width,
|
||||
frameHeight: modeData.height,
|
||||
|
||||
/**
|
||||
* Get frame coordinates for an animation frame
|
||||
* @param {string} animId - Animation ID
|
||||
* @param {number} frame - Frame number
|
||||
* @returns {Object} Source rectangle {x, y, width, height}
|
||||
*/
|
||||
getFrameRect: function(animId, frame) {
|
||||
var row = 0;
|
||||
for (var i = 0; i < animData.length; i++) {
|
||||
if (animData[i].id === animId) {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var f = frame % (animData[row] ? animData[row].frames : 1);
|
||||
return {
|
||||
x: f * modeData.width,
|
||||
y: row * modeData.height,
|
||||
width: modeData.width,
|
||||
height: modeData.height
|
||||
};
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get rendering mode dimensions
|
||||
* @param {string} mode - Rendering mode
|
||||
* @returns {Object} Mode dimensions {width, height}
|
||||
*/
|
||||
getModeSize: function(mode) {
|
||||
if (typeof AvatarAnimations !== 'undefined' && AvatarAnimations.getMode) {
|
||||
return AvatarAnimations.getMode(mode);
|
||||
}
|
||||
|
||||
// Fallback defaults
|
||||
var defaults = {
|
||||
'HEAD_ONLY': { width: 64, height: 64 },
|
||||
'HEAD_AND_SHOULDERS': { width: 96, height: 96 },
|
||||
'UPPER_BODY': { width: 128, height: 128 },
|
||||
'FULL_BODY': { width: 128, height: 192 }
|
||||
};
|
||||
|
||||
return defaults[mode] || defaults['HEAD_ONLY'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate avatar data structure
|
||||
* @param {Object} avatar - Avatar to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
validate: validateAvatar,
|
||||
|
||||
/**
|
||||
* Create default avatar
|
||||
* @returns {Object} Default avatar object
|
||||
*/
|
||||
createDefault: createDefault,
|
||||
|
||||
/**
|
||||
* Clone an avatar object
|
||||
* @param {Object} avatar - Avatar to clone
|
||||
* @returns {Object} Cloned avatar
|
||||
*/
|
||||
clone: cloneAvatar,
|
||||
|
||||
/**
|
||||
* Update avatar base properties
|
||||
* @param {Object} avatar - Avatar to update
|
||||
* @param {Object} updates - Properties to update
|
||||
* @returns {Object} Updated avatar
|
||||
*/
|
||||
updateBase: function(avatar, updates) {
|
||||
avatar.base = deepMergeBase(avatar.base, updates);
|
||||
return avatar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get available customization options
|
||||
* @returns {Object} Available options from AvatarData
|
||||
*/
|
||||
getCustomizationOptions: function() {
|
||||
if (typeof AvatarData === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
faceShapes: AvatarData.FACE_SHAPES || [],
|
||||
skinTones: AvatarData.SKIN_TONES || [],
|
||||
eyeShapes: AvatarData.EYE_SHAPES || [],
|
||||
eyeColors: AvatarData.EYE_COLORS || [],
|
||||
eyebrowShapes: AvatarData.EYEBROW_SHAPES || [],
|
||||
noseShapes: AvatarData.NOSE_SHAPES || [],
|
||||
mouthShapes: AvatarData.MOUTH_SHAPES || [],
|
||||
earShapes: AvatarData.EAR_SHAPES || [],
|
||||
hairStyles: AvatarData.HAIR_STYLES || [],
|
||||
hairColors: AvatarData.HAIR_COLORS || [],
|
||||
facialHair: AvatarData.FACIAL_HAIR || [],
|
||||
bodyTypes: AvatarData.BODY_TYPES || [],
|
||||
clothingStyles: AvatarData.CLOTHING_STYLES || []
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Compare two avatars for equality
|
||||
* @param {Object} a - First avatar
|
||||
* @param {Object} b - Second avatar
|
||||
* @returns {boolean} True if equivalent
|
||||
*/
|
||||
equals: function(a, b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get version info
|
||||
* @returns {Object} Version information
|
||||
*/
|
||||
getVersion: function() {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
dependencies: ['AvatarData', 'AvatarAnimations', 'AvatarRenderer', 'AvatarAccessories', 'AccessoryRenderer']
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,262 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =========================================================================
|
||||
// AGGREGATE ALL CONTEXTS
|
||||
// =========================================================================
|
||||
|
||||
var ALL_CONTEXTS = [];
|
||||
|
||||
// Add vehicle contexts
|
||||
if (window.VehicleContexts) {
|
||||
VehicleContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Add costume contexts
|
||||
if (window.CostumeContexts) {
|
||||
CostumeContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Add pure contexts
|
||||
if (window.PureContexts) {
|
||||
PureContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Create lookup map
|
||||
var CONTEXT_MAP = {};
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
CONTEXT_MAP[ctx.id] = ctx;
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// CATEGORY METADATA
|
||||
// =========================================================================
|
||||
|
||||
var CATEGORIES = {
|
||||
vehicle: {
|
||||
id: 'vehicle',
|
||||
name: 'Vehicles',
|
||||
description: 'Place your avatar in vehicles and cockpits',
|
||||
icon: '🚗',
|
||||
count: 0 // calculated
|
||||
},
|
||||
costume: {
|
||||
id: 'costume',
|
||||
name: 'Costumes',
|
||||
description: 'Dress your avatar in themed outfits',
|
||||
icon: '👔',
|
||||
count: 0
|
||||
},
|
||||
pure: {
|
||||
id: 'pure',
|
||||
name: 'Standard',
|
||||
description: 'Your avatar with minimal modifications',
|
||||
icon: '🏃',
|
||||
count: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Count per category
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (CATEGORIES[ctx.category]) {
|
||||
CATEGORIES[ctx.category].count++;
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GAME TYPE MAPPINGS
|
||||
// =========================================================================
|
||||
|
||||
var GAME_TYPE_CONTEXTS = {
|
||||
platformer: ['platformer-standard', 'adventure-hero', 'knight-armor', 'ninja-outfit', 'superhero-cape'],
|
||||
racing: ['race-car', 'motorcycle', 'hoverboard', 'airplane'],
|
||||
shooter: ['spaceship-cockpit', 'tank', 'mech-suit', 'space-suit'],
|
||||
flying: ['flappy-style', 'airplane', 'jetpack', 'dragon-rider'],
|
||||
arcade: ['pacman-style', 'flappy-style', 'spaceship-cockpit', 'runner'],
|
||||
rpg: ['knight-armor', 'wizard-robes', 'ninja-outfit', 'pirate-outfit', 'adventure-hero'],
|
||||
sports: ['sports-player', 'athlete-uniform', 'runner'],
|
||||
puzzle: ['scientist-labcoat', 'wizard-robes', 'platformer-standard'],
|
||||
adventure: ['adventure-hero', 'pirate-outfit', 'cowboy-gear', 'dragon-rider'],
|
||||
action: ['superhero-cape', 'ninja-outfit', 'mech-suit', 'jetpack'],
|
||||
underwater: ['submarine', 'space-suit'],
|
||||
space: ['spaceship-cockpit', 'space-suit', 'mech-suit'],
|
||||
cooking: ['chef-outfit'],
|
||||
simulation: ['airplane', 'submarine', 'race-car', 'tank']
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// MODE GROUPINGS
|
||||
// =========================================================================
|
||||
|
||||
var MODE_CONTEXTS = {
|
||||
HEAD_ONLY: [],
|
||||
HEAD_AND_SHOULDERS: [],
|
||||
UPPER_BODY: [],
|
||||
FULL_BODY: []
|
||||
};
|
||||
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (MODE_CONTEXTS[ctx.avatarMode]) {
|
||||
MODE_CONTEXTS[ctx.avatarMode].push(ctx.id);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// CATALOG STATISTICS
|
||||
// =========================================================================
|
||||
|
||||
var STATS = {
|
||||
totalContexts: ALL_CONTEXTS.length,
|
||||
vehicleCount: CATEGORIES.vehicle.count,
|
||||
costumeCount: CATEGORIES.costume.count,
|
||||
pureCount: CATEGORIES.pure.count,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2026-02-05'
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// PUBLIC API
|
||||
// =========================================================================
|
||||
|
||||
window.ContextCatalog = {
|
||||
// Core getters
|
||||
getAll: function() {
|
||||
return ALL_CONTEXTS.slice();
|
||||
},
|
||||
|
||||
getById: function(id) {
|
||||
return CONTEXT_MAP[id] || null;
|
||||
},
|
||||
|
||||
// Category queries
|
||||
getByCategory: function(category) {
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.category === category;
|
||||
});
|
||||
},
|
||||
|
||||
getCategories: function() {
|
||||
return Object.values(CATEGORIES);
|
||||
},
|
||||
|
||||
getCategoryInfo: function(categoryId) {
|
||||
return CATEGORIES[categoryId] || null;
|
||||
},
|
||||
|
||||
// Mode queries
|
||||
getByMode: function(mode) {
|
||||
var ids = MODE_CONTEXTS[mode] || [];
|
||||
return ids.map(function(id) {
|
||||
return CONTEXT_MAP[id];
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
getModes: function() {
|
||||
return Object.keys(MODE_CONTEXTS);
|
||||
},
|
||||
|
||||
// Game type recommendations
|
||||
getRecommendedFor: function(gameType) {
|
||||
var ids = GAME_TYPE_CONTEXTS[gameType] || [];
|
||||
return ids.map(function(id) {
|
||||
return CONTEXT_MAP[id];
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
getGameTypes: function() {
|
||||
return Object.keys(GAME_TYPE_CONTEXTS);
|
||||
},
|
||||
|
||||
// Search/filter
|
||||
search: function(query) {
|
||||
query = query.toLowerCase();
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.id.toLowerCase().includes(query) ||
|
||||
ctx.name.toLowerCase().includes(query) ||
|
||||
ctx.description.toLowerCase().includes(query);
|
||||
});
|
||||
},
|
||||
|
||||
filter: function(filters) {
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
if (filters.category && ctx.category !== filters.category) return false;
|
||||
if (filters.mode && ctx.avatarMode !== filters.mode) return false;
|
||||
if (filters.gameType) {
|
||||
var recommended = GAME_TYPE_CONTEXTS[filters.gameType] || [];
|
||||
if (recommended.indexOf(ctx.id) === -1) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// For UI display
|
||||
getCatalogForUI: function() {
|
||||
return {
|
||||
categories: Object.values(CATEGORIES).map(function(cat) {
|
||||
return {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
contexts: ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.category === cat.id;
|
||||
}).map(function(ctx) {
|
||||
return {
|
||||
id: ctx.id,
|
||||
name: ctx.name,
|
||||
description: ctx.description,
|
||||
mode: ctx.avatarMode,
|
||||
recommendedFor: ctx.recommendedFor
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
// Random selection
|
||||
getRandom: function(filters) {
|
||||
var pool = filters ? this.filter(filters) : ALL_CONTEXTS;
|
||||
if (pool.length === 0) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
},
|
||||
|
||||
getRandomForGameType: function(gameType) {
|
||||
var recommended = this.getRecommendedFor(gameType);
|
||||
if (recommended.length === 0) return null;
|
||||
return recommended[Math.floor(Math.random() * recommended.length)];
|
||||
},
|
||||
|
||||
// Statistics
|
||||
getStats: function() {
|
||||
return Object.assign({}, STATS);
|
||||
},
|
||||
|
||||
// Validation
|
||||
validate: function() {
|
||||
var issues = [];
|
||||
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (!ctx.id) issues.push('Context missing id');
|
||||
if (!ctx.name) issues.push(ctx.id + ': missing name');
|
||||
if (!ctx.avatarMode) issues.push(ctx.id + ': missing avatarMode');
|
||||
if (!ctx.category) issues.push(ctx.id + ': missing category');
|
||||
if (!ctx.animations || ctx.animations.length === 0) {
|
||||
issues.push(ctx.id + ': missing animations');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
issues: issues
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,959 @@
|
||||
/**
|
||||
* ContextRenderer.js - Main renderer for applying contexts to avatars
|
||||
*
|
||||
* Handles the complete rendering pipeline for avatars with contexts,
|
||||
* including vehicles, costumes, and pure animation contexts.
|
||||
*
|
||||
* Dependencies (must be loaded before this file):
|
||||
* - window.AvatarSystem
|
||||
* - window.AvatarRenderer
|
||||
* - window.AvatarAnimations
|
||||
* - window.AccessoryRenderer
|
||||
* - window.VehicleContexts
|
||||
* - window.CostumeContexts
|
||||
* - window.PureContexts
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// CONTEXT APPLICATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Apply a context to an avatar, creating a renderable object
|
||||
* @param {Object} avatar - The avatar object
|
||||
* @param {string} contextId - The context ID to apply
|
||||
* @param {Object} options - Optional configuration
|
||||
* @returns {Object} Avatar with context object
|
||||
*/
|
||||
function apply(avatar, contextId, options) {
|
||||
var context = getContextById(contextId);
|
||||
|
||||
if (!context) {
|
||||
console.error('ContextRenderer: Unknown context ID:', contextId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate avatar has required mode or can be rendered in it
|
||||
var mode = context.avatarMode;
|
||||
if (!mode) {
|
||||
console.warn('ContextRenderer: Context missing avatarMode:', contextId);
|
||||
mode = 'profile'; // Default fallback
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: avatar,
|
||||
context: context,
|
||||
mode: mode,
|
||||
state: {
|
||||
animation: context.defaultAnimation || 'idle',
|
||||
frame: 0,
|
||||
time: 0,
|
||||
animationStartTime: 0,
|
||||
animationOptions: {},
|
||||
effects: [],
|
||||
props: {},
|
||||
custom: {}
|
||||
},
|
||||
options: options || {}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN RENDER FUNCTION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render complete scene with avatar and context
|
||||
* @param {Object} avatarWithContext - The avatar+context object from apply()
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {number} x - Center X position
|
||||
* @param {number} y - Center Y position
|
||||
* @param {Object} options - Render options (scale, time, etc)
|
||||
*/
|
||||
function render(avatarWithContext, ctx, x, y, options) {
|
||||
if (!avatarWithContext || !avatarWithContext.context) {
|
||||
console.error('ContextRenderer.render: Invalid avatarWithContext');
|
||||
return;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
var scale = options.scale || 1;
|
||||
var time = options.time || 0;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
var avatar = awc.avatar;
|
||||
var mode = awc.mode;
|
||||
|
||||
// Get context dimensions
|
||||
var contextWidth = context.width || 64;
|
||||
var contextHeight = context.height || 64;
|
||||
var scaledWidth = contextWidth * scale;
|
||||
var scaledHeight = contextHeight * scale;
|
||||
|
||||
// Get animation transforms
|
||||
var transforms = {};
|
||||
if (awc.state.animation && window.AvatarAnimations) {
|
||||
var anim = window.AvatarAnimations.getAnimation(mode, awc.state.animation);
|
||||
if (anim) {
|
||||
var progress = anim.frameCount > 0 ? awc.state.frame / anim.frameCount : 0;
|
||||
transforms = window.AvatarAnimations.interpolateFrame(anim, progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate avatar position within context
|
||||
var avatarPosX = context.avatarPosition ? context.avatarPosition.x : 0.5;
|
||||
var avatarPosY = context.avatarPosition ? context.avatarPosition.y : 0.5;
|
||||
var avatarX = x + (avatarPosX - 0.5) * scaledWidth;
|
||||
var avatarY = y + (avatarPosY - 0.5) * scaledHeight;
|
||||
var avatarScale = scale * (context.avatarScale || 1);
|
||||
|
||||
// Route to appropriate render pipeline based on category
|
||||
var category = context.category || 'pure';
|
||||
|
||||
if (category === 'vehicle') {
|
||||
renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
} else if (category === 'costume') {
|
||||
renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
} else {
|
||||
// Pure context (default)
|
||||
renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VEHICLE CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Context background (vehicle body behind avatar)
|
||||
if (context.renderBackground && typeof context.renderBackground === 'function') {
|
||||
ctx.save();
|
||||
context.renderBackground(ctx, width, height, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Render avatar
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
|
||||
scale: avatarScale,
|
||||
transforms: transforms
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Render accessories respecting visibility rules
|
||||
renderAccessoriesForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 4. Context foreground (steering wheel, windshield, etc)
|
||||
if (context.renderForeground && typeof context.renderForeground === 'function') {
|
||||
ctx.save();
|
||||
context.renderForeground(ctx, width, height, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 5. Context effects (exhaust, speed lines, etc)
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COSTUME CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Costume under-layer (body armor, suit body, etc)
|
||||
if (context.renderCostumeUnder && typeof context.renderCostumeUnder === 'function') {
|
||||
ctx.save();
|
||||
context.renderCostumeUnder(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Avatar HEAD ONLY (body is covered by costume)
|
||||
renderAvatarHeadOnly(ctx, avatar, avatarX, avatarY, avatarScale, transforms);
|
||||
|
||||
// 3. Costume over-layer (helmet, held items, cape)
|
||||
if (context.renderCostumeOver && typeof context.renderCostumeOver === 'function') {
|
||||
ctx.save();
|
||||
context.renderCostumeOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 4. EARNED ACCESSORIES ON TOP (crown shows above helmet, etc)
|
||||
renderEarnedAccessoriesOnTop(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 5. Effects (auras are always visible)
|
||||
renderEffectsForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 6. Context-specific effects
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PURE CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Behind effects (shadows, dust clouds, etc)
|
||||
if (context.renderEffectsBehind && typeof context.renderEffectsBehind === 'function') {
|
||||
ctx.save();
|
||||
context.renderEffectsBehind(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Full avatar with all accessories
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
|
||||
scale: avatarScale,
|
||||
transforms: transforms
|
||||
});
|
||||
}
|
||||
|
||||
if (window.AccessoryRenderer && window.AccessoryRenderer.render) {
|
||||
window.AccessoryRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, avatarScale, time);
|
||||
}
|
||||
|
||||
// 3. Over effects (speed lines, stars, sparkles)
|
||||
if (context.renderEffectsOver && typeof context.renderEffectsOver === 'function') {
|
||||
ctx.save();
|
||||
context.renderEffectsOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 4. Active state effects
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ANIMATION CONTROL
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Start an animation on an avatar with context
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {string} animationId - Animation ID to play
|
||||
* @param {Object} options - Animation options
|
||||
* @returns {boolean} True if animation started successfully
|
||||
*/
|
||||
function animate(avatarWithContext, animationId, options) {
|
||||
if (!avatarWithContext || !avatarWithContext.context) {
|
||||
console.error('ContextRenderer.animate: Invalid avatarWithContext');
|
||||
return false;
|
||||
}
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
|
||||
// Validate animation is available for this context
|
||||
if (context.animations && Array.isArray(context.animations)) {
|
||||
if (context.animations.indexOf(animationId) === -1) {
|
||||
console.warn('ContextRenderer: Animation not available for context:', animationId, 'in', context.id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if animation exists in the animation system
|
||||
if (window.AvatarAnimations) {
|
||||
var anim = window.AvatarAnimations.getAnimation(awc.mode, animationId);
|
||||
if (!anim) {
|
||||
console.warn('ContextRenderer: Animation not found:', animationId, 'for mode', awc.mode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set animation state
|
||||
awc.state.animation = animationId;
|
||||
awc.state.frame = 0;
|
||||
awc.state.time = 0;
|
||||
awc.state.animationStartTime = performance.now();
|
||||
awc.state.animationOptions = options || {};
|
||||
|
||||
// Trigger context-specific effects for this animation
|
||||
if (context.contextEffects && context.contextEffects[animationId]) {
|
||||
triggerEffect(awc, context.contextEffects[animationId]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation state based on elapsed time
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {number} deltaTime - Time elapsed in seconds
|
||||
*/
|
||||
function updateAnimation(avatarWithContext, deltaTime) {
|
||||
if (!avatarWithContext) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
|
||||
if (!awc.state.animation) return;
|
||||
|
||||
// Get animation definition
|
||||
var anim = null;
|
||||
if (window.AvatarAnimations) {
|
||||
anim = window.AvatarAnimations.getAnimation(awc.mode, awc.state.animation);
|
||||
}
|
||||
|
||||
if (!anim) return;
|
||||
|
||||
// Update time
|
||||
awc.state.time += deltaTime;
|
||||
|
||||
// Calculate frame based on time
|
||||
var frameDuration = (anim.frameDuration || 100) / 1000; // Convert ms to seconds
|
||||
var totalFrames = anim.frameCount || 1;
|
||||
|
||||
if (anim.loop) {
|
||||
// Looping animation
|
||||
awc.state.frame = (awc.state.time / frameDuration) % totalFrames;
|
||||
} else {
|
||||
// One-shot animation
|
||||
awc.state.frame = Math.min(awc.state.time / frameDuration, totalFrames - 1);
|
||||
|
||||
// Check if animation completed
|
||||
if (awc.state.frame >= totalFrames - 1) {
|
||||
// Trigger completion callback if set
|
||||
if (awc.state.animationOptions && awc.state.animationOptions.onComplete) {
|
||||
awc.state.animationOptions.onComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update active effects
|
||||
updateEffects(awc, deltaTime);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EFFECT SYSTEM
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Trigger a visual effect
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {Object} effectDef - Effect definition
|
||||
*/
|
||||
function triggerEffect(avatarWithContext, effectDef) {
|
||||
if (!avatarWithContext || !effectDef) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
|
||||
// Handle effect definition as string (lookup) or object
|
||||
var effect;
|
||||
if (typeof effectDef === 'string') {
|
||||
effect = {
|
||||
type: effectDef,
|
||||
color: '#ffffff',
|
||||
startTime: performance.now(),
|
||||
duration: 0.5,
|
||||
intensity: 1,
|
||||
data: {}
|
||||
};
|
||||
} else {
|
||||
effect = {
|
||||
type: effectDef.type || 'flash',
|
||||
color: effectDef.color || '#ffffff',
|
||||
startTime: performance.now(),
|
||||
duration: effectDef.duration || 0.5,
|
||||
intensity: effectDef.intensity || 1,
|
||||
data: effectDef.data || {}
|
||||
};
|
||||
}
|
||||
|
||||
awc.state.effects.push(effect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all active effects
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {number} deltaTime - Time elapsed in seconds
|
||||
*/
|
||||
function updateEffects(avatarWithContext, deltaTime) {
|
||||
if (!avatarWithContext) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var now = performance.now();
|
||||
|
||||
// Filter out expired effects and update active ones
|
||||
awc.state.effects = awc.state.effects.filter(function(effect) {
|
||||
var elapsed = (now - effect.startTime) / 1000;
|
||||
|
||||
if (elapsed >= effect.duration) {
|
||||
return false; // Remove expired effect
|
||||
}
|
||||
|
||||
// Update effect progress
|
||||
effect.progress = elapsed / effect.duration;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active effects
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
*/
|
||||
function clearEffects(avatarWithContext) {
|
||||
if (!avatarWithContext) return;
|
||||
avatarWithContext.state.effects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render context effects
|
||||
*/
|
||||
function renderContextEffects(ctx, awc, x, y, scale, time) {
|
||||
if (!awc.state.effects || awc.state.effects.length === 0) return;
|
||||
|
||||
var now = performance.now();
|
||||
|
||||
awc.state.effects.forEach(function(effect) {
|
||||
var elapsed = (now - effect.startTime) / 1000;
|
||||
var progress = Math.min(elapsed / effect.duration, 1);
|
||||
|
||||
ctx.save();
|
||||
|
||||
switch (effect.type) {
|
||||
case 'flash':
|
||||
renderFlashEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'burst':
|
||||
renderBurstEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'trail':
|
||||
renderTrailEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'glow':
|
||||
renderGlowEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'particles':
|
||||
renderParticleEffect(ctx, x, y, scale, effect, progress, time);
|
||||
break;
|
||||
case 'shake':
|
||||
// Shake is handled via transforms, not direct rendering
|
||||
break;
|
||||
case 'speedLines':
|
||||
renderSpeedLinesEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
default:
|
||||
// Unknown effect type - custom effects can be rendered by context
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// Effect renderers
|
||||
function renderFlashEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var radius = 30 * scale * (1 + progress);
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = effect.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderBurstEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var rayCount = effect.data.rayCount || 8;
|
||||
var maxLength = (effect.data.maxLength || 40) * scale;
|
||||
var length = maxLength * progress;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = effect.color;
|
||||
ctx.lineWidth = 2 * scale;
|
||||
|
||||
for (var i = 0; i < rayCount; i++) {
|
||||
var angle = (i / rayCount) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(
|
||||
x + Math.cos(angle) * length,
|
||||
y + Math.sin(angle) * length
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrailEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity * 0.5;
|
||||
var trailLength = (effect.data.length || 20) * scale;
|
||||
var direction = effect.data.direction || -1; // -1 = trailing left
|
||||
|
||||
var gradient = ctx.createLinearGradient(
|
||||
x, y,
|
||||
x + trailLength * direction, y
|
||||
);
|
||||
gradient.addColorStop(0, effect.color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, y - 10 * scale, trailLength * direction, 20 * scale);
|
||||
}
|
||||
|
||||
function renderGlowEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = effect.intensity * (0.5 + 0.5 * Math.sin(progress * Math.PI * 2));
|
||||
var radius = (effect.data.radius || 25) * scale;
|
||||
|
||||
var gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, effect.color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderParticleEffect(ctx, x, y, scale, effect, progress, time) {
|
||||
var particleCount = effect.data.count || 10;
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var spread = (effect.data.spread || 30) * scale;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = effect.color;
|
||||
|
||||
// Use seeded random based on effect start time for consistency
|
||||
var seed = effect.startTime;
|
||||
|
||||
for (var i = 0; i < particleCount; i++) {
|
||||
var pseudoRandom = Math.sin(seed + i * 12.9898) * 43758.5453;
|
||||
pseudoRandom = pseudoRandom - Math.floor(pseudoRandom);
|
||||
|
||||
var angle = pseudoRandom * Math.PI * 2;
|
||||
var distance = spread * progress * (0.5 + pseudoRandom * 0.5);
|
||||
var size = 2 * scale * (1 - progress);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
x + Math.cos(angle) * distance,
|
||||
y + Math.sin(angle) * distance - progress * 20 * scale, // Rise up
|
||||
size,
|
||||
0, Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpeedLinesEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = effect.intensity * (1 - progress * 0.5);
|
||||
var lineCount = effect.data.lineCount || 5;
|
||||
var lineLength = (effect.data.lineLength || 30) * scale;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = effect.color;
|
||||
ctx.lineWidth = 1 * scale;
|
||||
|
||||
for (var i = 0; i < lineCount; i++) {
|
||||
var yOffset = (i - lineCount / 2) * 8 * scale;
|
||||
var xStart = x - 20 * scale - lineLength * progress;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xStart, y + yOffset);
|
||||
ctx.lineTo(xStart + lineLength, y + yOffset);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER RENDERING FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render only the avatar's head (for costumes that cover body)
|
||||
*/
|
||||
function renderAvatarHeadOnly(ctx, avatar, x, y, scale, transforms) {
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.renderHead) {
|
||||
window.AvatarRenderer.renderHead(ctx, avatar, x, y, {
|
||||
scale: scale,
|
||||
transforms: transforms
|
||||
});
|
||||
} else if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
// Fallback: render full avatar if renderHead not available
|
||||
// This is a compromise - costume should hide body anyway
|
||||
ctx.save();
|
||||
|
||||
// Clip to head region (approximate)
|
||||
var headY = y - 12 * scale; // Head is above center
|
||||
var headRadius = 10 * scale;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, headY, headRadius * 1.5, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
window.AvatarRenderer.render(ctx, avatar, 'profile', x, y, {
|
||||
scale: scale,
|
||||
transforms: transforms
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render accessories respecting context visibility rules
|
||||
*/
|
||||
function renderAccessoriesForContext(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
|
||||
// Default visibility if not specified
|
||||
var showHeadwear = vis.headwear !== false;
|
||||
var showHandheld = vis.handheld !== false;
|
||||
var showEffects = vis.effects !== false;
|
||||
var showFace = vis.face !== false;
|
||||
|
||||
// Use AccessoryRenderer with visibility filter
|
||||
if (window.AccessoryRenderer.renderFiltered) {
|
||||
window.AccessoryRenderer.renderFiltered(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time, {
|
||||
headwear: showHeadwear,
|
||||
handheld: showHandheld,
|
||||
effects: showEffects,
|
||||
face: showFace
|
||||
});
|
||||
} else if (window.AccessoryRenderer.render) {
|
||||
// Fallback to standard render if filtered not available
|
||||
// Context-specific visibility would need to be handled differently
|
||||
window.AccessoryRenderer.render(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render EARNED accessories on top of everything (crowns above helmets, etc)
|
||||
*/
|
||||
function renderEarnedAccessoriesOnTop(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
if (!avatar.accessories) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
var costumeOffset = context.costumeHeadOffset || { x: 0, y: -5 }; // Crown sits higher on helmet
|
||||
|
||||
// Render headwear if visibility allows and avatar has it
|
||||
if (vis.headwear === true && avatar.accessories.headwear) {
|
||||
var headwearY = y + costumeOffset.y * scale;
|
||||
var headwearX = x + costumeOffset.x * scale;
|
||||
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'headwear', headwearX, headwearY, scale, time);
|
||||
}
|
||||
}
|
||||
|
||||
// Render effect accessories (auras, trails) if allowed
|
||||
if (vis.effects === true && avatar.accessories.effect) {
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render effect accessories (auras, etc) for costume context
|
||||
*/
|
||||
function renderEffectsForContext(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
if (!avatar.accessories || !avatar.accessories.effect) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
|
||||
// Effects (auras) are almost always visible
|
||||
if (vis.effects !== false) {
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONTEXT LOOKUP
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get context definition by ID
|
||||
*/
|
||||
function getContextById(contextId) {
|
||||
// Check ContextCatalog first
|
||||
if (window.ContextCatalog && window.ContextCatalog.getById) {
|
||||
var context = window.ContextCatalog.getById(contextId);
|
||||
if (context) return context;
|
||||
}
|
||||
|
||||
// Fallback to individual context modules
|
||||
if (window.VehicleContexts && window.VehicleContexts[contextId]) {
|
||||
return window.VehicleContexts[contextId];
|
||||
}
|
||||
if (window.CostumeContexts && window.CostumeContexts[contextId]) {
|
||||
return window.CostumeContexts[contextId];
|
||||
}
|
||||
if (window.PureContexts && window.PureContexts[contextId]) {
|
||||
return window.PureContexts[contextId];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required avatar mode for a context
|
||||
*/
|
||||
function getRequiredMode(contextId) {
|
||||
var context = getContextById(contextId);
|
||||
return context ? context.avatarMode : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available animations for a context
|
||||
*/
|
||||
function getAvailableAnimations(contextId) {
|
||||
var context = getContextById(contextId);
|
||||
return context && context.animations ? context.animations.slice() : [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STATE MANAGEMENT
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Set custom state value
|
||||
*/
|
||||
function setState(avatarWithContext, key, value) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return;
|
||||
avatarWithContext.state.custom[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom state value
|
||||
*/
|
||||
function getState(avatarWithContext, key) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return undefined;
|
||||
return avatarWithContext.state.custom[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set prop state (for interactive elements)
|
||||
*/
|
||||
function setProp(avatarWithContext, propId, state) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return;
|
||||
avatarWithContext.state.props[propId] = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prop state
|
||||
*/
|
||||
function getProp(avatarWithContext, propId) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return undefined;
|
||||
return avatarWithContext.state.props[propId];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXPORT UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a sprite sheet from avatar with context
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {Array} animations - Array of animation IDs to include
|
||||
* @param {Object} options - Sprite sheet options
|
||||
* @returns {Object} Sprite sheet data { canvas, frames, meta }
|
||||
*/
|
||||
function createSpriteSheet(avatarWithContext, animations, options) {
|
||||
if (!avatarWithContext) return null;
|
||||
|
||||
options = options || {};
|
||||
var frameWidth = options.frameWidth || 64;
|
||||
var frameHeight = options.frameHeight || 64;
|
||||
var scale = options.scale || 1;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var allFrames = [];
|
||||
|
||||
// Collect all frames for each animation
|
||||
animations.forEach(function(animId) {
|
||||
var anim = null;
|
||||
if (window.AvatarAnimations) {
|
||||
anim = window.AvatarAnimations.getAnimation(awc.mode, animId);
|
||||
}
|
||||
|
||||
if (!anim) return;
|
||||
|
||||
for (var i = 0; i < anim.frameCount; i++) {
|
||||
allFrames.push({
|
||||
animation: animId,
|
||||
frame: i,
|
||||
frameCount: anim.frameCount
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (allFrames.length === 0) return null;
|
||||
|
||||
// Calculate sprite sheet dimensions
|
||||
var cols = Math.ceil(Math.sqrt(allFrames.length));
|
||||
var rows = Math.ceil(allFrames.length / cols);
|
||||
var sheetWidth = cols * frameWidth;
|
||||
var sheetHeight = rows * frameHeight;
|
||||
|
||||
// Create canvas
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = sheetWidth;
|
||||
canvas.height = sheetHeight;
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
// Render each frame
|
||||
var frames = [];
|
||||
allFrames.forEach(function(frameData, index) {
|
||||
var col = index % cols;
|
||||
var row = Math.floor(index / cols);
|
||||
var x = col * frameWidth + frameWidth / 2;
|
||||
var y = row * frameHeight + frameHeight / 2;
|
||||
|
||||
// Temporarily set animation state
|
||||
awc.state.animation = frameData.animation;
|
||||
awc.state.frame = frameData.frame;
|
||||
|
||||
// Render
|
||||
render(awc, ctx, x, y, { scale: scale, time: 0 });
|
||||
|
||||
frames.push({
|
||||
animation: frameData.animation,
|
||||
frame: frameData.frame,
|
||||
x: col * frameWidth,
|
||||
y: row * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
canvas: canvas,
|
||||
frames: frames,
|
||||
meta: {
|
||||
width: sheetWidth,
|
||||
height: sheetHeight,
|
||||
frameWidth: frameWidth,
|
||||
frameHeight: frameHeight,
|
||||
animations: animations
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export avatar with context for game use
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @returns {Object} Exportable data
|
||||
*/
|
||||
function exportForGame(avatarWithContext) {
|
||||
if (!avatarWithContext) return null;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
|
||||
return {
|
||||
avatarId: awc.avatar.id || null,
|
||||
contextId: context.id,
|
||||
mode: awc.mode,
|
||||
category: context.category,
|
||||
animations: context.animations || [],
|
||||
defaultAnimation: context.defaultAnimation || 'idle',
|
||||
dimensions: {
|
||||
width: context.width || 64,
|
||||
height: context.height || 64
|
||||
},
|
||||
avatarPosition: context.avatarPosition || { x: 0.5, y: 0.5 },
|
||||
avatarScale: context.avatarScale || 1,
|
||||
accessoryVisibility: context.accessoryVisibility || {},
|
||||
customState: Object.assign({}, awc.state.custom)
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CATALOG ACCESS (delegates to ContextCatalog)
|
||||
// ============================================================
|
||||
|
||||
function getCatalog() {
|
||||
if (window.ContextCatalog && window.ContextCatalog.getAll) {
|
||||
return window.ContextCatalog.getAll();
|
||||
}
|
||||
|
||||
// Fallback: aggregate from individual modules
|
||||
var catalog = [];
|
||||
|
||||
if (window.VehicleContexts) {
|
||||
Object.keys(window.VehicleContexts).forEach(function(id) {
|
||||
if (typeof window.VehicleContexts[id] === 'object') {
|
||||
catalog.push(window.VehicleContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.CostumeContexts) {
|
||||
Object.keys(window.CostumeContexts).forEach(function(id) {
|
||||
if (typeof window.CostumeContexts[id] === 'object') {
|
||||
catalog.push(window.CostumeContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.PureContexts) {
|
||||
Object.keys(window.PureContexts).forEach(function(id) {
|
||||
if (typeof window.PureContexts[id] === 'object') {
|
||||
catalog.push(window.PureContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function getContext(id) {
|
||||
return getContextById(id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC API
|
||||
// ============================================================
|
||||
|
||||
window.ContextRenderer = {
|
||||
// Core methods
|
||||
apply: apply,
|
||||
render: render,
|
||||
animate: animate,
|
||||
updateAnimation: updateAnimation,
|
||||
|
||||
// Effect control
|
||||
triggerEffect: triggerEffect,
|
||||
updateEffects: updateEffects,
|
||||
clearEffects: clearEffects,
|
||||
|
||||
// State management
|
||||
setState: setState,
|
||||
getState: getState,
|
||||
setProp: setProp,
|
||||
getProp: getProp,
|
||||
|
||||
// Context queries
|
||||
getRequiredMode: getRequiredMode,
|
||||
getAvailableAnimations: getAvailableAnimations,
|
||||
|
||||
// Catalog access (delegates to ContextCatalog)
|
||||
getCatalog: getCatalog,
|
||||
getContext: getContext,
|
||||
|
||||
// Utility
|
||||
createSpriteSheet: createSpriteSheet,
|
||||
exportForGame: exportForGame,
|
||||
|
||||
// Version
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
// backgroundCatalog.js — Metadata catalog for all 120 procedural backgrounds
|
||||
// Loaded after all theme files; provides queryable catalog of backgrounds
|
||||
// 15 themes × 8 variants = 120 unique backgrounds
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
|
||||
// ─── Catalog Metadata ──────────────────────────────────────
|
||||
//
|
||||
// The catalog is auto-generated from theme registrations.
|
||||
// Each background has:
|
||||
// id - Unique identifier (theme_variant)
|
||||
// theme - Parent theme name
|
||||
// variant - Variant name within theme
|
||||
// mood - Emotional tone (wonder, tense, serene, energetic, etc.)
|
||||
// colors - Default color palette [4 hex colors]
|
||||
// tags - Descriptive tags for filtering
|
||||
// description - Human-readable description
|
||||
// compatibleMusicMoods - Array of music moods that pair well
|
||||
// recommendedFor - Array of game genres this works for
|
||||
|
||||
// ─── Theme → Variant Listing ───────────────────────────────
|
||||
|
||||
var THEME_VARIANTS = {
|
||||
space: ['nebula', 'asteroidField', 'deepSpace', 'planets', 'spaceStation', 'warpSpeed', 'satellites', 'blackHole'],
|
||||
nature: ['forest', 'jungle', 'desert', 'mountains', 'ocean', 'underwater', 'waterfall', 'meadow'],
|
||||
urban: ['citySkyline', 'street', 'rooftop', 'neonDistrict', 'subway', 'park', 'mall', 'alley'],
|
||||
fantasy: ['castle', 'dungeon', 'enchantedForest', 'floatingIslands', 'magicalLibrary', 'crystalCave', 'temple', 'tower'],
|
||||
abstract: ['geometricPatterns', 'gradients', 'particleFields', 'minimalist', 'waves', 'fractals', 'kaleidoscope', 'matrix'],
|
||||
retro: ['pixelGrid', 'arcadeCabinet', 'crtEffects', 'vaporwave', 'scanlines', 'glitch', 'eightBit', 'synthwave'],
|
||||
indoor: ['laboratory', 'classroom', 'bedroom', 'kitchen', 'arcade', 'office', 'library', 'gym'],
|
||||
weather: ['rain', 'snow', 'storm', 'sunset', 'sunrise', 'fog', 'lightning', 'aurora'],
|
||||
sports: ['stadium', 'raceTrack', 'court', 'field', 'arena', 'pool', 'gym', 'skatepark'],
|
||||
seasonal: ['springMeadow', 'autumnLeaves', 'winterWonderland', 'summerBeach', 'harvest', 'blossom', 'snowfall', 'tropical'],
|
||||
underwater: ['coralReef', 'deepSea', 'kelpForest', 'submarine', 'shipwreck', 'abyss', 'iceCave', 'treasure'],
|
||||
sky: ['clouds', 'flyingHigh', 'hotAirBalloon', 'airplaneView', 'stratosphere', 'birdsEye', 'horizon', 'starfield'],
|
||||
mechanical: ['gears', 'circuits', 'factory', 'robotFactory', 'conveyorBelts', 'pipes', 'steampunk', 'techLab'],
|
||||
spooky: ['hauntedHouse', 'graveyard', 'darkForest', 'abandonedBuilding', 'crypt', 'manor', 'fog', 'shadows'],
|
||||
colorful: ['rainbow', 'paintSplatter', 'candyWorld', 'disco', 'neon', 'tieDye', 'gradient', 'prism'],
|
||||
};
|
||||
|
||||
// ─── Mood Categories ───────────────────────────────────────
|
||||
|
||||
var MOOD_DESCRIPTIONS = {
|
||||
wonder: 'Awe-inspiring and magical',
|
||||
tense: 'Suspenseful and gripping',
|
||||
serene: 'Calm and peaceful',
|
||||
energetic: 'Fast-paced and exciting',
|
||||
focused: 'Clear and concentrated',
|
||||
playful: 'Fun and lighthearted',
|
||||
mysterious:'Dark and intriguing',
|
||||
nostalgic: 'Retro and reminiscent',
|
||||
cozy: 'Warm and comfortable',
|
||||
spooky: 'Eerie and unsettling',
|
||||
epic: 'Grand and dramatic',
|
||||
dreamy: 'Soft and ethereal',
|
||||
vibrant: 'Bright and colorful',
|
||||
industrial:'Mechanical and gritty',
|
||||
tropical: 'Warm and exotic',
|
||||
festive: 'Celebratory and joyful',
|
||||
};
|
||||
|
||||
// ─── Game Genre Mappings ───────────────────────────────────
|
||||
|
||||
var GENRE_TAGS = {
|
||||
action: ['fast', 'intense', 'danger', 'combat'],
|
||||
puzzle: ['calm', 'focused', 'minimal', 'clean'],
|
||||
racing: ['fast', 'streaks', 'motion', 'speed'],
|
||||
rpg: ['epic', 'magical', 'adventure', 'story'],
|
||||
sports: ['stadium', 'field', 'arena', 'competitive'],
|
||||
creative:['colorful', 'playful', 'artistic', 'open'],
|
||||
story: ['atmospheric', 'immersive', 'cinematic'],
|
||||
physics: ['tech', 'mechanical', 'industrial', 'scientific'],
|
||||
trivia: ['clean', 'simple', 'focused', 'bright'],
|
||||
music: ['rhythmic', 'neon', 'disco', 'vibrant'],
|
||||
};
|
||||
|
||||
// ─── Public API Extensions ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all themes with their variant counts
|
||||
*/
|
||||
engine.getThemeSummary = function() {
|
||||
var summary = [];
|
||||
for (var theme in THEME_VARIANTS) {
|
||||
summary.push({
|
||||
theme: theme,
|
||||
variants: THEME_VARIANTS[theme],
|
||||
count: THEME_VARIANTS[theme].length
|
||||
});
|
||||
}
|
||||
return summary;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total background count
|
||||
*/
|
||||
engine.getTotalCount = function() {
|
||||
var count = 0;
|
||||
for (var theme in THEME_VARIANTS) {
|
||||
count += THEME_VARIANTS[theme].length;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available moods
|
||||
*/
|
||||
engine.getMoods = function() {
|
||||
return Object.keys(MOOD_DESCRIPTIONS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get mood description
|
||||
*/
|
||||
engine.getMoodDescription = function(mood) {
|
||||
return MOOD_DESCRIPTIONS[mood] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find backgrounds by game genre
|
||||
*/
|
||||
engine.findByGenre = function(genre) {
|
||||
var tags = GENRE_TAGS[genre] || [];
|
||||
if (!tags.length) return [];
|
||||
return engine.find({ tags: tags });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a random background from a specific theme
|
||||
*/
|
||||
engine.getRandomFromTheme = function(theme) {
|
||||
var variants = THEME_VARIANTS[theme];
|
||||
if (!variants || !variants.length) return null;
|
||||
var variant = variants[Math.floor(Math.random() * variants.length)];
|
||||
return { theme: theme, variant: variant };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a random background matching filters
|
||||
*/
|
||||
engine.getRandomMatching = function(filters) {
|
||||
var matches = engine.find(filters);
|
||||
if (!matches.length) return null;
|
||||
return matches[Math.floor(Math.random() * matches.length)];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find backgrounds compatible with a music mood
|
||||
*/
|
||||
engine.findByMusicMood = function(musicMood) {
|
||||
return engine.getCatalog().filter(function(bg) {
|
||||
return bg.compatibleMusicMoods && bg.compatibleMusicMoods.includes(musicMood);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get backgrounds recommended for a game type
|
||||
*/
|
||||
engine.getRecommendedFor = function(gameType) {
|
||||
return engine.getCatalog().filter(function(bg) {
|
||||
return bg.recommendedFor && bg.recommendedFor.includes(gameType);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Preload/validate all registered themes
|
||||
* Returns array of any themes with missing variants
|
||||
*/
|
||||
engine.validateCatalog = function() {
|
||||
var issues = [];
|
||||
for (var theme in THEME_VARIANTS) {
|
||||
var variants = THEME_VARIANTS[theme];
|
||||
variants.forEach(function(variant) {
|
||||
var catalog = engine.getCatalog();
|
||||
var found = catalog.find(function(bg) {
|
||||
return bg.theme === theme && bg.variant === variant;
|
||||
});
|
||||
if (!found) {
|
||||
issues.push({ theme: theme, variant: variant, issue: 'missing registration' });
|
||||
}
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
// ─── Catalog Statistics ────────────────────────────────────
|
||||
|
||||
engine.catalogStats = {
|
||||
themes: Object.keys(THEME_VARIANTS).length,
|
||||
variantsPerTheme: 8,
|
||||
totalBackgrounds: 120,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2026-02-05'
|
||||
};
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,745 @@
|
||||
// backgroundEngine.js — Procedural background rendering engine
|
||||
// Loaded by HTML5 games via <script> tag; uses canvas, SVG, and CSS
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────
|
||||
|
||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function rand(min, max) { return Math.random() * (max - min) + min; }
|
||||
function randInt(min, max) { return Math.floor(rand(min, max + 1)); }
|
||||
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return '#' + [r, g, b].map(c => clamp(Math.round(c), 0, 255).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function blendColor(hex1, hex2, t) {
|
||||
const a = hexToRgb(hex1), b = hexToRgb(hex2);
|
||||
return rgbToHex(lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t));
|
||||
}
|
||||
|
||||
function lighten(hex, amount) {
|
||||
const c = hexToRgb(hex);
|
||||
return rgbToHex(c.r + (255 - c.r) * amount, c.g + (255 - c.g) * amount, c.b + (255 - c.b) * amount);
|
||||
}
|
||||
|
||||
function darken(hex, amount) {
|
||||
const c = hexToRgb(hex);
|
||||
return rgbToHex(c.r * (1 - amount), c.g * (1 - amount), c.b * (1 - amount));
|
||||
}
|
||||
|
||||
function rgba(hex, alpha) {
|
||||
const c = hexToRgb(hex);
|
||||
return `rgba(${c.r},${c.g},${c.b},${alpha})`;
|
||||
}
|
||||
|
||||
// Seeded pseudo-random for deterministic backgrounds
|
||||
function seededRandom(seed) {
|
||||
let s = seed;
|
||||
return function() {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Drawing Primitives ──────────────────────────────────
|
||||
|
||||
const Draw = {
|
||||
linearGradient(ctx, x1, y1, x2, y2, stops) {
|
||||
const g = ctx.createLinearGradient(x1, y1, x2, y2);
|
||||
stops.forEach(([pos, color]) => g.addColorStop(pos, color));
|
||||
return g;
|
||||
},
|
||||
|
||||
radialGradient(ctx, cx, cy, r, stops) {
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
||||
stops.forEach(([pos, color]) => g.addColorStop(pos, color));
|
||||
return g;
|
||||
},
|
||||
|
||||
fillGradient(ctx, w, h, colors, angle) {
|
||||
angle = angle || 0;
|
||||
const rad = angle * Math.PI / 180;
|
||||
const dx = Math.cos(rad) * w;
|
||||
const dy = Math.sin(rad) * h;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const stops = colors.map((c, i) => [i / (colors.length - 1), c]);
|
||||
ctx.fillStyle = Draw.linearGradient(ctx, cx - dx / 2, cy - dy / 2, cx + dx / 2, cy + dy / 2, stops);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
},
|
||||
|
||||
mountains(ctx, w, h, baseY, peaks, color, seed) {
|
||||
const rng = seededRandom(seed || 42);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
const step = w / peaks;
|
||||
for (let i = 0; i <= peaks; i++) {
|
||||
const x = i * step;
|
||||
const peakH = baseY - rng() * (h * 0.3) - h * 0.05;
|
||||
if (i === 0) { ctx.lineTo(0, peakH); }
|
||||
else {
|
||||
const cpx = x - step / 2;
|
||||
const cpy = peakH - rng() * (h * 0.1);
|
||||
ctx.quadraticCurveTo(cpx, cpy, x, baseY - rng() * (h * 0.15));
|
||||
}
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
hills(ctx, w, h, baseY, count, color, seed) {
|
||||
const rng = seededRandom(seed || 99);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
ctx.lineTo(0, baseY);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x1 = (i / count) * w;
|
||||
const x2 = ((i + 1) / count) * w;
|
||||
const cpx = (x1 + x2) / 2;
|
||||
const cpy = baseY - rng() * h * 0.15 - h * 0.03;
|
||||
ctx.quadraticCurveTo(cpx, cpy, x2, baseY + rng() * h * 0.02);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
stars(ctx, w, h, count, color, seed, sizeRange) {
|
||||
const rng = seededRandom(seed || 7);
|
||||
const minS = (sizeRange && sizeRange[0]) || 0.5;
|
||||
const maxS = (sizeRange && sizeRange[1]) || 2.5;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = rng() * w;
|
||||
const y = rng() * h;
|
||||
const r = minS + rng() * (maxS - minS);
|
||||
const alpha = 0.4 + rng() * 0.6;
|
||||
ctx.fillStyle = rgba(color, alpha);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
clouds(ctx, w, h, y, count, color, seed) {
|
||||
const rng = seededRandom(seed || 33);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cx = rng() * w;
|
||||
const cy = y + rng() * h * 0.15 - h * 0.075;
|
||||
const cw = 60 + rng() * 120;
|
||||
const ch = 20 + rng() * 30;
|
||||
ctx.fillStyle = rgba(color, 0.15 + rng() * 0.25);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, cw, ch, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx - cw * 0.3, cy + ch * 0.2, cw * 0.6, ch * 0.8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx + cw * 0.3, cy + ch * 0.1, cw * 0.5, ch * 0.7, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
trees(ctx, w, h, baseY, count, trunkColor, leafColor, seed) {
|
||||
const rng = seededRandom(seed || 55);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = rng() * w;
|
||||
const treeH = 30 + rng() * 60;
|
||||
const trunkW = 3 + rng() * 5;
|
||||
// Trunk
|
||||
ctx.fillStyle = trunkColor;
|
||||
ctx.fillRect(x - trunkW / 2, baseY - treeH, trunkW, treeH * 0.5);
|
||||
// Canopy
|
||||
ctx.fillStyle = leafColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, baseY - treeH * 0.7, treeH * 0.35, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
buildings(ctx, w, h, baseY, count, colors, seed) {
|
||||
const rng = seededRandom(seed || 88);
|
||||
const step = w / count;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const bw = step * (0.5 + rng() * 0.4);
|
||||
const bh = 40 + rng() * 120;
|
||||
const x = i * step + (step - bw) / 2;
|
||||
const y = baseY - bh;
|
||||
const color = colors[Math.floor(rng() * colors.length)];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, bw, bh);
|
||||
// Windows
|
||||
const winColor = rgba('#FFFF88', 0.3 + rng() * 0.5);
|
||||
const cols = Math.floor(bw / 12);
|
||||
const rows = Math.floor(bh / 15);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (rng() > 0.4) {
|
||||
ctx.fillStyle = winColor;
|
||||
ctx.fillRect(x + 4 + c * 12, y + 5 + r * 15, 6, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
water(ctx, w, h, y, color, waveAmp, seed) {
|
||||
const rng = seededRandom(seed || 44);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, y, w, h - y);
|
||||
// Wave highlights
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const wy = y + i * ((h - y) / 8);
|
||||
ctx.strokeStyle = rgba('#FFFFFF', 0.05 + rng() * 0.08);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < w; x += 4) {
|
||||
const yy = wy + Math.sin(x * 0.02 + i) * waveAmp;
|
||||
x === 0 ? ctx.moveTo(x, yy) : ctx.lineTo(x, yy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
grid(ctx, w, h, spacing, color, lineWidth) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth || 1;
|
||||
for (let x = 0; x <= w; x += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= h; y += spacing) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
circle(ctx, x, y, r, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
rect(ctx, x, y, w, h, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
},
|
||||
|
||||
polygon(ctx, cx, cy, r, sides, color, rotation) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i / sides) * Math.PI * 2 + (rotation || 0);
|
||||
const x = cx + Math.cos(angle) * r;
|
||||
const y = cy + Math.sin(angle) * r;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
glow(ctx, x, y, r, color) {
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, rgba(color, 0.6));
|
||||
g.addColorStop(0.5, rgba(color, 0.2));
|
||||
g.addColorStop(1, rgba(color, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
scanlines(ctx, w, h, spacing, color) {
|
||||
ctx.fillStyle = color;
|
||||
for (let y = 0; y < h; y += spacing) {
|
||||
ctx.fillRect(0, y, w, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Effect System ───────────────────────────────────────
|
||||
|
||||
class EffectLayer {
|
||||
constructor(type, config) {
|
||||
this.type = type;
|
||||
this.config = config;
|
||||
this.particles = [];
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
const c = this.config;
|
||||
const count = c.count || 50;
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles.push(this._createParticle(true));
|
||||
}
|
||||
}
|
||||
|
||||
_createParticle(initial) {
|
||||
const c = this.config;
|
||||
const w = c.width || 800;
|
||||
const h = c.height || 600;
|
||||
|
||||
switch (this.type) {
|
||||
case 'stars': return {
|
||||
x: Math.random() * w, y: Math.random() * h,
|
||||
size: 0.5 + Math.random() * 2, alpha: Math.random(),
|
||||
twinkleSpeed: 0.5 + Math.random() * 2, phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
case 'rain': return {
|
||||
x: Math.random() * w, y: initial ? Math.random() * h : -10,
|
||||
speed: 4 + Math.random() * 8, length: 8 + Math.random() * 15,
|
||||
alpha: 0.2 + Math.random() * 0.4,
|
||||
};
|
||||
case 'snow': return {
|
||||
x: Math.random() * w, y: initial ? Math.random() * h : -5,
|
||||
speed: 0.5 + Math.random() * 2, size: 1 + Math.random() * 3,
|
||||
wobble: Math.random() * Math.PI * 2, wobbleSpeed: 0.5 + Math.random(),
|
||||
alpha: 0.4 + Math.random() * 0.5,
|
||||
};
|
||||
case 'particles': return {
|
||||
x: Math.random() * w, y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 1, vy: (Math.random() - 0.5) * 1,
|
||||
size: 1 + Math.random() * 3, alpha: 0.2 + Math.random() * 0.5,
|
||||
life: Math.random(),
|
||||
};
|
||||
case 'fog': return {
|
||||
x: Math.random() * w * 1.5 - w * 0.25, y: Math.random() * h,
|
||||
size: 80 + Math.random() * 160, alpha: 0.03 + Math.random() * 0.08,
|
||||
speed: 0.2 + Math.random() * 0.5,
|
||||
};
|
||||
case 'bubbles': return {
|
||||
x: Math.random() * w, y: initial ? Math.random() * h : h + 10,
|
||||
speed: 0.3 + Math.random() * 1.5, size: 2 + Math.random() * 8,
|
||||
wobble: Math.random() * Math.PI * 2, alpha: 0.2 + Math.random() * 0.4,
|
||||
};
|
||||
case 'leaves': return {
|
||||
x: initial ? Math.random() * w : w + 20, y: Math.random() * h,
|
||||
speed: 0.5 + Math.random() * 2, size: 4 + Math.random() * 8,
|
||||
rotation: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.05,
|
||||
vy: 0.3 + Math.random() * 1, alpha: 0.5 + Math.random() * 0.4,
|
||||
};
|
||||
case 'sparkles': return {
|
||||
x: Math.random() * w, y: Math.random() * h,
|
||||
size: 1 + Math.random() * 2, alpha: Math.random(),
|
||||
life: Math.random(), speed: 0.01 + Math.random() * 0.03,
|
||||
};
|
||||
case 'fireflies': return {
|
||||
x: Math.random() * w, y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5,
|
||||
size: 2 + Math.random() * 3, phase: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.5 + Math.random() * 1.5,
|
||||
};
|
||||
case 'dust': return {
|
||||
x: Math.random() * w, y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.3, vy: -0.1 - Math.random() * 0.3,
|
||||
size: 0.5 + Math.random() * 1.5, alpha: 0.1 + Math.random() * 0.3,
|
||||
};
|
||||
default: return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, t) {
|
||||
const c = this.config;
|
||||
const w = c.width || 800;
|
||||
const h = c.height || 600;
|
||||
const speed = c.speed || 1;
|
||||
|
||||
this.particles.forEach((p, i) => {
|
||||
switch (this.type) {
|
||||
case 'stars':
|
||||
p.alpha = 0.3 + Math.sin(t * p.twinkleSpeed + p.phase) * 0.35 + 0.35;
|
||||
break;
|
||||
case 'rain':
|
||||
p.y += p.speed * speed * dt * 60;
|
||||
p.x -= 1 * speed * dt * 60;
|
||||
if (p.y > h) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
case 'snow':
|
||||
p.y += p.speed * speed * dt * 60;
|
||||
p.wobble += p.wobbleSpeed * dt;
|
||||
p.x += Math.sin(p.wobble) * 0.5;
|
||||
if (p.y > h + 5) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
case 'particles':
|
||||
p.x += p.vx * speed * dt * 60;
|
||||
p.y += p.vy * speed * dt * 60;
|
||||
p.life += 0.003 * dt * 60;
|
||||
if (p.life > 1) { this.particles[i] = this._createParticle(false); }
|
||||
if (p.x < 0 || p.x > w) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > h) p.vy *= -1;
|
||||
break;
|
||||
case 'fog':
|
||||
p.x += p.speed * speed * dt * 60;
|
||||
if (p.x > w + p.size) p.x = -p.size;
|
||||
break;
|
||||
case 'bubbles':
|
||||
p.y -= p.speed * speed * dt * 60;
|
||||
p.wobble += 0.02 * dt * 60;
|
||||
p.x += Math.sin(p.wobble) * 0.3;
|
||||
if (p.y < -10) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
case 'leaves':
|
||||
p.x -= p.speed * speed * dt * 60;
|
||||
p.y += p.vy * speed * dt * 60;
|
||||
p.rotation += p.rotSpeed * dt * 60;
|
||||
if (p.x < -20 || p.y > h + 20) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
case 'sparkles':
|
||||
p.life += p.speed * dt * 60;
|
||||
p.alpha = Math.sin(p.life * Math.PI);
|
||||
if (p.life > 1) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
case 'fireflies':
|
||||
p.x += p.vx * speed * dt * 60;
|
||||
p.y += p.vy * speed * dt * 60;
|
||||
if (Math.random() < 0.01) { p.vx = (Math.random() - 0.5) * 0.5; p.vy = (Math.random() - 0.5) * 0.5; }
|
||||
p.x = (p.x + w) % w;
|
||||
p.y = (p.y + h) % h;
|
||||
break;
|
||||
case 'dust':
|
||||
p.x += p.vx * speed * dt * 60;
|
||||
p.y += p.vy * speed * dt * 60;
|
||||
if (p.y < -5 || p.x < -5 || p.x > w + 5) { this.particles[i] = this._createParticle(false); }
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(ctx, t) {
|
||||
const c = this.config;
|
||||
const color = c.color || '#FFFFFF';
|
||||
|
||||
this.particles.forEach(p => {
|
||||
switch (this.type) {
|
||||
case 'stars':
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
case 'rain':
|
||||
ctx.strokeStyle = rgba(color, p.alpha);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - 1, p.y + p.length); ctx.stroke();
|
||||
break;
|
||||
case 'snow':
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
case 'particles':
|
||||
ctx.fillStyle = rgba(color, p.alpha * (1 - p.life));
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
case 'fog':
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.size, p.size * 0.4, 0, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
case 'bubbles':
|
||||
ctx.strokeStyle = rgba(color, p.alpha);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.fillStyle = rgba(color, p.alpha * 0.2);
|
||||
ctx.fill();
|
||||
break;
|
||||
case 'leaves':
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rotation);
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
ctx.beginPath(); ctx.ellipse(0, 0, p.size, p.size * 0.4, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
break;
|
||||
case 'sparkles':
|
||||
if (p.alpha > 0.05) {
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
const s = p.size * p.alpha;
|
||||
ctx.fillRect(p.x - s, p.y - 0.5, s * 2, 1);
|
||||
ctx.fillRect(p.x - 0.5, p.y - s, 1, s * 2);
|
||||
}
|
||||
break;
|
||||
case 'fireflies':
|
||||
const glow = 0.3 + Math.sin(t * p.pulseSpeed + p.phase) * 0.35 + 0.35;
|
||||
Draw.glow(ctx, p.x, p.y, p.size * 3, color);
|
||||
ctx.fillStyle = rgba(color, glow);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
case 'dust':
|
||||
ctx.fillStyle = rgba(color, p.alpha);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BackgroundEngine ────────────────────────────────────
|
||||
|
||||
class BackgroundEngine {
|
||||
constructor() {
|
||||
this._themes = {};
|
||||
this._canvas = null;
|
||||
this._ctx = null;
|
||||
this._running = false;
|
||||
this._animId = null;
|
||||
this._currentTheme = null;
|
||||
this._currentVariant = null;
|
||||
this._effects = [];
|
||||
this._parallax = { enabled: false, layers: 1, offsetX: 0, offsetY: 0 };
|
||||
this._colors = null;
|
||||
this._animSpeed = 1;
|
||||
this._lastTime = 0;
|
||||
this._startTime = 0;
|
||||
this._bgCache = null;
|
||||
}
|
||||
|
||||
registerTheme(name, variants) {
|
||||
this._themes[name] = variants;
|
||||
}
|
||||
|
||||
load(theme, variant, options) {
|
||||
options = options || {};
|
||||
const themeData = this._themes[theme];
|
||||
if (!themeData) { console.warn('BackgroundEngine: unknown theme "' + theme + '"'); return false; }
|
||||
const varData = themeData[variant];
|
||||
if (!varData) { console.warn('BackgroundEngine: unknown variant "' + variant + '" in theme "' + theme + '"'); return false; }
|
||||
|
||||
// Setup canvas
|
||||
if (options.canvas) {
|
||||
this._canvas = options.canvas;
|
||||
} else if (!this._canvas) {
|
||||
this._canvas = document.createElement('canvas');
|
||||
this._canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;pointer-events:none;';
|
||||
document.body.insertBefore(this._canvas, document.body.firstChild);
|
||||
}
|
||||
|
||||
this._canvas.width = options.width || this._canvas.clientWidth || window.innerWidth;
|
||||
this._canvas.height = options.height || this._canvas.clientHeight || window.innerHeight;
|
||||
this._ctx = this._canvas.getContext('2d');
|
||||
|
||||
this._currentTheme = theme;
|
||||
this._currentVariant = variant;
|
||||
this._colors = options.colors || varData.colors || null;
|
||||
this._effects = [];
|
||||
this._bgCache = null;
|
||||
this._startTime = performance.now() / 1000;
|
||||
|
||||
// Add default effects from theme
|
||||
if (varData.particles) {
|
||||
this.addEffect(varData.particles.type, varData.particles);
|
||||
}
|
||||
|
||||
// Render initial static frame
|
||||
this._renderStatic();
|
||||
|
||||
// Start animation loop
|
||||
if (!this._running) {
|
||||
this._running = true;
|
||||
this._lastTime = performance.now() / 1000;
|
||||
this._loop();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
addEffect(effectType, config) {
|
||||
config = config || {};
|
||||
config.width = this._canvas ? this._canvas.width : 800;
|
||||
config.height = this._canvas ? this._canvas.height : 600;
|
||||
config.speed = config.speed || this._animSpeed;
|
||||
this._effects.push(new EffectLayer(effectType, config));
|
||||
}
|
||||
|
||||
setParallax(layers) {
|
||||
this._parallax.enabled = true;
|
||||
this._parallax.layers = layers || 3;
|
||||
// Listen for mouse/touch movement
|
||||
const self = this;
|
||||
const handler = function(e) {
|
||||
const x = e.clientX || (e.touches && e.touches[0] && e.touches[0].clientX) || 0;
|
||||
const y = e.clientY || (e.touches && e.touches[0] && e.touches[0].clientY) || 0;
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
self._parallax.offsetX = (x / w - 0.5) * 30;
|
||||
self._parallax.offsetY = (y / h - 0.5) * 15;
|
||||
};
|
||||
window.addEventListener('mousemove', handler);
|
||||
window.addEventListener('touchmove', handler);
|
||||
this._parallax._cleanup = function() {
|
||||
window.removeEventListener('mousemove', handler);
|
||||
window.removeEventListener('touchmove', handler);
|
||||
};
|
||||
this._bgCache = null;
|
||||
}
|
||||
|
||||
updateColors(palette) {
|
||||
this._colors = palette;
|
||||
this._bgCache = null;
|
||||
this._renderStatic();
|
||||
}
|
||||
|
||||
animate(speed) {
|
||||
this._animSpeed = clamp(speed, 0, 5);
|
||||
this._effects.forEach(e => { e.config.speed = this._animSpeed; });
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._running = false;
|
||||
if (this._animId) cancelAnimationFrame(this._animId);
|
||||
this._effects = [];
|
||||
if (this._parallax._cleanup) this._parallax._cleanup();
|
||||
this._parallax = { enabled: false, layers: 1, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
getCatalog() {
|
||||
const catalog = [];
|
||||
for (const [themeName, variants] of Object.entries(this._themes)) {
|
||||
for (const [varName, varData] of Object.entries(variants)) {
|
||||
if (varData.meta) {
|
||||
catalog.push({ ...varData.meta, theme: themeName, variant: varName });
|
||||
}
|
||||
}
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
find(filters) {
|
||||
filters = filters || {};
|
||||
return this.getCatalog().filter(item => {
|
||||
if (filters.theme && item.theme !== filters.theme) return false;
|
||||
if (filters.mood && item.mood !== filters.mood) return false;
|
||||
if (filters.tags && !filters.tags.some(t => item.tags && item.tags.includes(t))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
getThemes() {
|
||||
return Object.keys(this._themes);
|
||||
}
|
||||
|
||||
getVariants(theme) {
|
||||
return this._themes[theme] ? Object.keys(this._themes[theme]) : [];
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────
|
||||
|
||||
_getVariantData() {
|
||||
if (!this._currentTheme || !this._currentVariant) return null;
|
||||
return this._themes[this._currentTheme] && this._themes[this._currentTheme][this._currentVariant];
|
||||
}
|
||||
|
||||
_getColors() {
|
||||
const varData = this._getVariantData();
|
||||
return this._colors || (varData && varData.colors) || ['#1a1a2e', '#16213e', '#0f3460'];
|
||||
}
|
||||
|
||||
_renderStatic() {
|
||||
const varData = this._getVariantData();
|
||||
if (!varData || !this._ctx) return;
|
||||
|
||||
const ctx = this._ctx;
|
||||
const w = this._canvas.width;
|
||||
const h = this._canvas.height;
|
||||
const colors = this._getColors();
|
||||
|
||||
if (varData.renderBase) {
|
||||
varData.renderBase(ctx, w, h, colors, Draw, 0);
|
||||
}
|
||||
if (varData.renderMid) {
|
||||
varData.renderMid(ctx, w, h, colors, Draw, 0);
|
||||
}
|
||||
}
|
||||
|
||||
_loop() {
|
||||
if (!this._running) return;
|
||||
const now = performance.now() / 1000;
|
||||
const dt = Math.min(now - this._lastTime, 0.1);
|
||||
const t = now - this._startTime;
|
||||
this._lastTime = now;
|
||||
|
||||
const ctx = this._ctx;
|
||||
const w = this._canvas.width;
|
||||
const h = this._canvas.height;
|
||||
const varData = this._getVariantData();
|
||||
const colors = this._getColors();
|
||||
|
||||
if (varData) {
|
||||
// Re-render base (may be animated)
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (this._parallax.enabled) {
|
||||
const px = this._parallax.offsetX;
|
||||
const py = this._parallax.offsetY;
|
||||
// Base layer (no parallax)
|
||||
if (varData.renderBase) varData.renderBase(ctx, w, h, colors, Draw, t);
|
||||
// Mid layer (slight parallax)
|
||||
ctx.save();
|
||||
ctx.translate(px * 0.5, py * 0.5);
|
||||
if (varData.renderMid) varData.renderMid(ctx, w, h, colors, Draw, t);
|
||||
ctx.restore();
|
||||
// Effects layer (full parallax)
|
||||
ctx.save();
|
||||
ctx.translate(px, py);
|
||||
this._effects.forEach(e => { e.update(dt, t); e.render(ctx, t); });
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (varData.renderBase) varData.renderBase(ctx, w, h, colors, Draw, t);
|
||||
if (varData.renderMid) varData.renderMid(ctx, w, h, colors, Draw, t);
|
||||
this._effects.forEach(e => { e.update(dt, t); e.render(ctx, t); });
|
||||
}
|
||||
}
|
||||
|
||||
this._animId = requestAnimationFrame(() => this._loop());
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
const engine = new BackgroundEngine();
|
||||
// Expose Draw utilities for theme files
|
||||
engine.Draw = Draw;
|
||||
engine.helpers = { lerp, clamp, rand, randInt, pick, hexToRgb, rgbToHex, blendColor, lighten, darken, rgba, seededRandom };
|
||||
|
||||
/**
|
||||
* Static-like render method for one-shot rendering to any canvas
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context to render to
|
||||
* @param {string} theme - Theme name (e.g., 'space', 'nature')
|
||||
* @param {string} variant - Variant name (e.g., 'nebula', 'forest')
|
||||
* @param {number} w - Canvas width
|
||||
* @param {number} h - Canvas height
|
||||
* @param {number} time - Animation time in ms (0 for static)
|
||||
* @param {string[]} [customColors] - Optional custom color palette
|
||||
*/
|
||||
engine.render = function(ctx, theme, variant, w, h, time, customColors) {
|
||||
const varData = this._themes[theme] && this._themes[theme][variant];
|
||||
if (!varData) {
|
||||
// Fallback: draw a simple gradient
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
const colors = customColors || varData.colors || ['#1a1a2e', '#16213e', '#0f3460', '#e94560'];
|
||||
const t = (time || 0) / 1000; // Convert ms to seconds
|
||||
|
||||
// Clear and render
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (varData.renderBase) {
|
||||
varData.renderBase(ctx, w, h, colors, Draw, t);
|
||||
}
|
||||
if (varData.renderMid) {
|
||||
varData.renderMid(ctx, w, h, colors, Draw, t);
|
||||
}
|
||||
};
|
||||
|
||||
window.BackgroundEngine = engine;
|
||||
})();
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// effects.js — Shared effect presets for background system
|
||||
// Depends on backgroundEngine.js being loaded first
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
|
||||
// Pre-configured effect presets that themes can reference by name
|
||||
engine.effectPresets = {
|
||||
starfield: { type: 'stars', count: 120, color: '#FFFFFF' },
|
||||
starfieldDense:{ type: 'stars', count: 250, color: '#FFFFFF' },
|
||||
starfieldSparse:{ type: 'stars', count: 40, color: '#FFFFFF' },
|
||||
lightRain: { type: 'rain', count: 60, color: '#AACCEE' },
|
||||
heavyRain: { type: 'rain', count: 200, color: '#8899BB' },
|
||||
lightSnow: { type: 'snow', count: 40, color: '#FFFFFF' },
|
||||
heavySnow: { type: 'snow', count: 150, color: '#E8E8F0' },
|
||||
floatingDust: { type: 'dust', count: 30, color: '#DDDDAA' },
|
||||
embers: { type: 'particles', count: 25, color: '#FF8844' },
|
||||
sparkle: { type: 'sparkles', count: 30, color: '#FFFFFF' },
|
||||
goldSparkle: { type: 'sparkles', count: 30, color: '#FFD700' },
|
||||
thinFog: { type: 'fog', count: 8, color: '#CCCCCC' },
|
||||
thickFog: { type: 'fog', count: 20, color: '#999999' },
|
||||
darkFog: { type: 'fog', count: 15, color: '#333344' },
|
||||
bubbles: { type: 'bubbles', count: 25, color: '#88CCEE' },
|
||||
denseBubbles: { type: 'bubbles', count: 60, color: '#66AADD' },
|
||||
fallingLeaves:{ type: 'leaves', count: 15, color: '#CC8833' },
|
||||
autumnLeaves: { type: 'leaves', count: 30, color: '#DD6622' },
|
||||
cherryBlossom:{ type: 'leaves', count: 25, color: '#FFB7C5' },
|
||||
fireflies: { type: 'fireflies', count: 15, color: '#CCFF44' },
|
||||
denseFireflies:{ type: 'fireflies',count: 35, color: '#AAEE33' },
|
||||
magicParticles:{ type: 'particles',count: 40, color: '#BB77FF' },
|
||||
neonParticles:{ type: 'particles', count: 50, color: '#00FFCC' },
|
||||
smokeRising: { type: 'dust', count: 20, color: '#888888' },
|
||||
snowfall: { type: 'snow', count: 80, color: '#FFFFFF' },
|
||||
rainStorm: { type: 'rain', count: 300, color: '#667799' },
|
||||
};
|
||||
|
||||
// Helper to apply a preset by name
|
||||
engine.addPresetEffect = function(presetName) {
|
||||
const preset = engine.effectPresets[presetName];
|
||||
if (preset) {
|
||||
engine.addEffect(preset.type, { ...preset });
|
||||
} else {
|
||||
console.warn('BackgroundEngine: unknown effect preset "' + presetName + '"');
|
||||
}
|
||||
};
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,396 @@
|
||||
// abstract.js — Abstract theme backgrounds
|
||||
// Variants: geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('abstract', {
|
||||
|
||||
geometricPatterns: {
|
||||
meta: { id: 'abstract_geometricPatterns', mood: 'focused', colors: ['#0d1b2a', '#1b2838', '#e07a5f', '#f2cc8f'], tags: ['geometric', 'shapes', 'patterns', 'clean'], description: 'Interlocking geometric shapes in warm contrasting tones on a deep navy canvas', compatibleMusicMoods: ['focused', 'calm', 'electronic'], recommendedFor: ['puzzle', 'trivia', 'creative'] },
|
||||
colors: ['#0d1b2a', '#1b2838', '#e07a5f', '#f2cc8f'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 135);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(100);
|
||||
// Rotating hexagonal grid
|
||||
var spacing = 80;
|
||||
for (var row = -1; row < h / spacing + 1; row++) {
|
||||
for (var col = -1; col < w / spacing + 1; col++) {
|
||||
var hx = col * spacing + (row % 2 === 0 ? spacing / 2 : 0);
|
||||
var hy = row * spacing;
|
||||
var dist = Math.sqrt(Math.pow(hx - w / 2, 2) + Math.pow(hy - h / 2, 2));
|
||||
var alpha = H.clamp(0.08 + Math.sin(t * 0.5 + dist * 0.005) * 0.06, 0, 0.2);
|
||||
var rot = t * 0.1 + dist * 0.002;
|
||||
var colorChoice = rng() > 0.5 ? c[2] : c[3];
|
||||
Draw.polygon(ctx, hx, hy, 25 + Math.sin(t * 0.3 + dist * 0.01) * 5, 6, H.rgba(colorChoice, alpha), rot);
|
||||
}
|
||||
}
|
||||
// Accent triangles
|
||||
for (var tri = 0; tri < 12; tri++) {
|
||||
var tx = rng() * w, ty = rng() * h;
|
||||
var tSize = 20 + rng() * 40;
|
||||
var tRot = t * 0.2 * (rng() > 0.5 ? 1 : -1) + rng() * Math.PI * 2;
|
||||
var tAlpha = 0.1 + Math.sin(t * 0.7 + tri) * 0.05;
|
||||
Draw.polygon(ctx, tx, ty, tSize, 3, H.rgba(c[2], tAlpha), tRot);
|
||||
}
|
||||
// Connecting lines
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.06);
|
||||
ctx.lineWidth = 1;
|
||||
var lrng = H.seededRandom(101);
|
||||
for (var ln = 0; ln < 15; ln++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lrng() * w, lrng() * h);
|
||||
ctx.lineTo(lrng() * w, lrng() * h);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#F2CC8F' }
|
||||
},
|
||||
|
||||
gradients: {
|
||||
meta: { id: 'abstract_gradients', mood: 'dreamy', colors: ['#0f0c29', '#302b63', '#24243e', '#ff6b6b'], tags: ['gradient', 'smooth', 'colorful', 'flowing'], description: 'Smoothly blending gradient orbs that shift and pulse with ethereal color transitions', compatibleMusicMoods: ['dreamy', 'calm', 'ambient'], recommendedFor: ['creative', 'story', 'music'] },
|
||||
colors: ['#0f0c29', '#302b63', '#24243e', '#ff6b6b'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
// Animated gradient orbs
|
||||
var orbs = [
|
||||
{ x: 0.3, y: 0.3, color: c[3], r: 0.25 },
|
||||
{ x: 0.7, y: 0.6, color: '#6b5bff', r: 0.3 },
|
||||
{ x: 0.5, y: 0.8, color: '#ff9a76', r: 0.2 },
|
||||
{ x: 0.2, y: 0.7, color: '#a66bff', r: 0.22 },
|
||||
{ x: 0.8, y: 0.2, color: '#ff6bb5', r: 0.18 }
|
||||
];
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
for (var i = 0; i < orbs.length; i++) {
|
||||
var orb = orbs[i];
|
||||
var ox = orb.x * w + Math.sin(t * 0.3 + i * 1.5) * w * 0.05;
|
||||
var oy = orb.y * h + Math.cos(t * 0.2 + i * 1.2) * h * 0.05;
|
||||
var or = orb.r * Math.min(w, h) * (1 + Math.sin(t * 0.4 + i) * 0.1);
|
||||
var grad = Draw.radialGradient(ctx, ox, oy, or, [
|
||||
[0, H.rgba(orb.color, 0.3)],
|
||||
[0.5, H.rgba(orb.color, 0.1)],
|
||||
[1, H.rgba(orb.color, 0)]
|
||||
]);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath(); ctx.arc(ox, oy, or, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
// Subtle mesh lines
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.02);
|
||||
ctx.lineWidth = 1;
|
||||
for (var ml = 0; ml < 8; ml++) {
|
||||
var my = h * 0.1 + ml * h * 0.1;
|
||||
ctx.beginPath();
|
||||
for (var mx = 0; mx < w; mx += 5) {
|
||||
var mdy = my + Math.sin(mx * 0.01 + t * 0.5 + ml * 0.5) * 15;
|
||||
mx === 0 ? ctx.moveTo(mx, mdy) : ctx.lineTo(mx, mdy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 15, color: '#FF6B6B' }
|
||||
},
|
||||
|
||||
particleFields: {
|
||||
meta: { id: 'abstract_particleFields', mood: 'energetic', colors: ['#000000', '#111122', '#00ff88', '#0088ff'], tags: ['particles', 'field', 'connected', 'network'], description: 'A dynamic network of connected particle nodes with pulsing energy links', compatibleMusicMoods: ['energetic', 'electronic', 'focused'], recommendedFor: ['action', 'puzzle', 'physics'] },
|
||||
colors: ['#000000', '#111122', '#00ff88', '#0088ff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(300);
|
||||
// Generate node positions (deterministic)
|
||||
var nodes = [];
|
||||
for (var n = 0; n < 30; n++) {
|
||||
var baseX = rng() * w, baseY = rng() * h;
|
||||
nodes.push({
|
||||
x: baseX + Math.sin(t * 0.5 + n * 0.7) * 15,
|
||||
y: baseY + Math.cos(t * 0.4 + n * 0.9) * 15,
|
||||
size: 2 + rng() * 3,
|
||||
color: rng() > 0.5 ? c[2] : c[3]
|
||||
});
|
||||
}
|
||||
// Draw connections between nearby nodes
|
||||
var maxDist = 150;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
for (var j = i + 1; j < nodes.length; j++) {
|
||||
var dx = nodes[i].x - nodes[j].x;
|
||||
var dy = nodes[i].y - nodes[j].y;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < maxDist) {
|
||||
var lineAlpha = (1 - dist / maxDist) * 0.2;
|
||||
var pulse = 0.5 + Math.sin(t * 1.5 + i + j) * 0.5;
|
||||
ctx.strokeStyle = H.rgba(nodes[i].color, lineAlpha * pulse);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Draw nodes
|
||||
for (var k = 0; k < nodes.length; k++) {
|
||||
var nd = nodes[k];
|
||||
var nodePulse = 0.6 + Math.sin(t * 2 + k * 0.5) * 0.3;
|
||||
Draw.glow(ctx, nd.x, nd.y, nd.size * 4, H.rgba(nd.color, 0.15 * nodePulse));
|
||||
Draw.circle(ctx, nd.x, nd.y, nd.size, H.rgba(nd.color, 0.7 * nodePulse));
|
||||
}
|
||||
// Data stream accents along bottom
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.04);
|
||||
ctx.lineWidth = 1;
|
||||
for (var ds = 0; ds < 5; ds++) {
|
||||
var dsy = h * 0.85 + ds * 8;
|
||||
ctx.beginPath();
|
||||
for (var dx2 = 0; dx2 < w; dx2 += 3) {
|
||||
var ddy = dsy + Math.sin(dx2 * 0.03 + t * 2 + ds) * 3;
|
||||
dx2 === 0 ? ctx.moveTo(dx2, ddy) : ctx.lineTo(dx2, ddy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 15, color: '#00FF88' }
|
||||
},
|
||||
|
||||
minimalist: {
|
||||
meta: { id: 'abstract_minimalist', mood: 'calm', colors: ['#f5f5f0', '#e8e4dd', '#2d2d2d', '#c44536'], tags: ['minimal', 'clean', 'simple', 'elegant'], description: 'Clean open space with a single bold accent line and carefully placed geometric forms', compatibleMusicMoods: ['calm', 'ambient', 'focused'], recommendedFor: ['puzzle', 'trivia', 'creative'] },
|
||||
colors: ['#f5f5f0', '#e8e4dd', '#2d2d2d', '#c44536'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Single horizon line
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.15);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.618); ctx.lineTo(w, h * 0.618); ctx.stroke();
|
||||
// Accent circle
|
||||
var circX = w * 0.7, circY = h * 0.35;
|
||||
var circR = Math.min(w, h) * 0.08;
|
||||
var breathe = 1 + Math.sin(t * 0.5) * 0.03;
|
||||
Draw.circle(ctx, circX, circY, circR * breathe, H.rgba(c[3], 0.7));
|
||||
// Shadow beneath circle
|
||||
ctx.fillStyle = H.rgba(c[2], 0.04);
|
||||
ctx.beginPath(); ctx.ellipse(circX, h * 0.618, circR * 1.5, 4, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// Thin accent line from circle
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.2);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(circX, circY + circR * breathe);
|
||||
ctx.lineTo(circX, h * 0.618);
|
||||
ctx.stroke();
|
||||
// Sparse dots
|
||||
var rng = H.seededRandom(400);
|
||||
for (var d = 0; d < 5; d++) {
|
||||
var dx = rng() * w, dy = rng() * h;
|
||||
Draw.circle(ctx, dx, dy, 2, H.rgba(c[2], 0.08));
|
||||
}
|
||||
// Small typography-like lines
|
||||
var lx = w * 0.15;
|
||||
for (var l = 0; l < 4; l++) {
|
||||
var lw = 30 + rng() * 60;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.06);
|
||||
ctx.fillRect(lx, h * 0.72 + l * 12, lw, 2);
|
||||
}
|
||||
},
|
||||
particles: null
|
||||
},
|
||||
|
||||
waves: {
|
||||
meta: { id: 'abstract_waves', mood: 'flowing', colors: ['#0a192f', '#172a45', '#00b4d8', '#90e0ef'], tags: ['waves', 'ocean', 'flowing', 'smooth'], description: 'Layered sinusoidal waves undulating in cool blue tones with foam accents', compatibleMusicMoods: ['calm', 'flowing', 'ambient'], recommendedFor: ['creative', 'music', 'physics'] },
|
||||
colors: ['#0a192f', '#172a45', '#00b4d8', '#90e0ef'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Layered wave bands
|
||||
var waveLayers = [
|
||||
{ y: 0.3, amp: 25, freq: 0.008, speed: 0.4, color: c[2], alpha: 0.08 },
|
||||
{ y: 0.4, amp: 30, freq: 0.006, speed: 0.3, color: c[2], alpha: 0.1 },
|
||||
{ y: 0.5, amp: 20, freq: 0.01, speed: 0.5, color: c[3], alpha: 0.12 },
|
||||
{ y: 0.6, amp: 35, freq: 0.005, speed: 0.25, color: c[2], alpha: 0.15 },
|
||||
{ y: 0.7, amp: 15, freq: 0.012, speed: 0.6, color: c[3], alpha: 0.1 },
|
||||
{ y: 0.8, amp: 40, freq: 0.004, speed: 0.2, color: c[2], alpha: 0.18 }
|
||||
];
|
||||
for (var i = 0; i < waveLayers.length; i++) {
|
||||
var wl = waveLayers[i];
|
||||
ctx.fillStyle = H.rgba(wl.color, wl.alpha);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
for (var x = 0; x <= w; x += 3) {
|
||||
var y = wl.y * h + Math.sin(x * wl.freq + t * wl.speed) * wl.amp +
|
||||
Math.sin(x * wl.freq * 2.3 + t * wl.speed * 1.5) * wl.amp * 0.3;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
// Foam highlights on top wave
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.06);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (var fx = 0; fx <= w; fx += 3) {
|
||||
var fy = 0.3 * h + Math.sin(fx * 0.008 + t * 0.4) * 25;
|
||||
fx === 0 ? ctx.moveTo(fx, fy) : ctx.lineTo(fx, fy);
|
||||
}
|
||||
ctx.stroke();
|
||||
},
|
||||
particles: { type: 'bubbles', count: 15, color: '#90E0EF' }
|
||||
},
|
||||
|
||||
fractals: {
|
||||
meta: { id: 'abstract_fractals', mood: 'complex', colors: ['#0a0a0a', '#1a1a2e', '#e94560', '#0f3460'], tags: ['fractal', 'recursive', 'mathematical', 'complex'], description: 'Recursive branching patterns that grow and pulse like living mathematical organisms', compatibleMusicMoods: ['complex', 'electronic', 'mystical'], recommendedFor: ['puzzle', 'physics', 'creative'] },
|
||||
colors: ['#0a0a0a', '#1a1a2e', '#e94560', '#0f3460'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Recursive tree fractal
|
||||
function branch(x, y, len, angle, depth, maxDepth) {
|
||||
if (depth > maxDepth || len < 3) return;
|
||||
var endX = x + Math.cos(angle) * len;
|
||||
var endY = y + Math.sin(angle) * len;
|
||||
var alpha = 0.15 * (1 - depth / maxDepth) + Math.sin(t * 0.5 + depth) * 0.03;
|
||||
var lw = (maxDepth - depth) * 0.8 + 0.5;
|
||||
ctx.strokeStyle = H.rgba(depth % 2 === 0 ? c[2] : c[3], alpha);
|
||||
ctx.lineWidth = lw;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(endX, endY); ctx.stroke();
|
||||
var spread = 0.4 + Math.sin(t * 0.3 + depth * 0.5) * 0.1;
|
||||
branch(endX, endY, len * 0.7, angle - spread, depth + 1, maxDepth);
|
||||
branch(endX, endY, len * 0.7, angle + spread, depth + 1, maxDepth);
|
||||
}
|
||||
// Main tree from bottom center
|
||||
branch(w * 0.5, h * 0.85, h * 0.18, -Math.PI / 2, 0, 9);
|
||||
// Smaller trees
|
||||
branch(w * 0.2, h * 0.9, h * 0.1, -Math.PI / 2, 0, 7);
|
||||
branch(w * 0.8, h * 0.9, h * 0.12, -Math.PI / 2, 0, 7);
|
||||
// Spiral accents
|
||||
var rng = H.seededRandom(500);
|
||||
for (var sp = 0; sp < 4; sp++) {
|
||||
var sx = rng() * w, sy = rng() * h * 0.6;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.06);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (var sa = 0; sa < Math.PI * 6; sa += 0.1) {
|
||||
var sr = sa * 2 + Math.sin(t * 0.3 + sp) * 2;
|
||||
var ssx = sx + Math.cos(sa) * sr;
|
||||
var ssy = sy + Math.sin(sa) * sr;
|
||||
sa === 0 ? ctx.moveTo(ssx, ssy) : ctx.lineTo(ssx, ssy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#E94560' }
|
||||
},
|
||||
|
||||
kaleidoscope: {
|
||||
meta: { id: 'abstract_kaleidoscope', mood: 'playful', colors: ['#1a1a2e', '#16213e', '#e94560', '#ffd166'], tags: ['kaleidoscope', 'symmetry', 'colorful', 'playful'], description: 'Symmetric radial patterns in vibrant colors rotating like a living kaleidoscope', compatibleMusicMoods: ['playful', 'energetic', 'electronic'], recommendedFor: ['music', 'creative', 'puzzle'] },
|
||||
colors: ['#1a1a2e', '#16213e', '#e94560', '#ffd166'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var cx = w / 2, cy = h / 2;
|
||||
var segments = 8;
|
||||
var rng = H.seededRandom(600);
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
// Draw mirrored segments
|
||||
for (var seg = 0; seg < segments; seg++) {
|
||||
var segAngle = (seg / segments) * Math.PI * 2 + t * 0.1;
|
||||
ctx.save();
|
||||
ctx.rotate(segAngle);
|
||||
// Shapes within each segment
|
||||
for (var s = 0; s < 5; s++) {
|
||||
var sd = 40 + s * 35 + Math.sin(t * 0.4 + s) * 10;
|
||||
var sSize = 8 + rng() * 15;
|
||||
var sColor = s % 2 === 0 ? c[2] : c[3];
|
||||
var sAlpha = 0.12 + Math.sin(t * 0.6 + s * 0.8) * 0.05;
|
||||
Draw.polygon(ctx, sd, 0, sSize, 3 + (s % 3), H.rgba(sColor, sAlpha), t * 0.2 + s);
|
||||
}
|
||||
// Connecting arcs
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.06);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 80 + Math.sin(t * 0.5) * 10, 0, Math.PI / segments);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
// Central glow
|
||||
var centerPulse = 0.15 + Math.sin(t * 0.8) * 0.05;
|
||||
Draw.glow(ctx, cx, cy, 60, H.rgba(c[3], centerPulse));
|
||||
Draw.glow(ctx, cx, cy, 30, H.rgba(c[2], centerPulse * 1.5));
|
||||
// Outer ring
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.08 + Math.sin(t * 0.3) * 0.03);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.min(w, h) * 0.35, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
},
|
||||
particles: { type: 'sparkles', count: 25, color: '#FFD166' }
|
||||
},
|
||||
|
||||
matrix: {
|
||||
meta: { id: 'abstract_matrix', mood: 'digital', colors: ['#000000', '#001100', '#00ff41', '#008f11'], tags: ['matrix', 'code', 'digital', 'hacker'], description: 'Cascading columns of digital characters streaming down like the matrix rain', compatibleMusicMoods: ['dark', 'electronic', 'tense'], recommendedFor: ['action', 'puzzle', 'trivia'] },
|
||||
colors: ['#000000', '#001100', '#00ff41', '#008f11'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// CRT vignette
|
||||
var vignette = Draw.radialGradient(ctx, w / 2, h / 2, Math.max(w, h) * 0.7, [
|
||||
[0, H.rgba(c[1], 0)],
|
||||
[0.7, H.rgba(c[0], 0.3)],
|
||||
[1, H.rgba(c[0], 0.8)]
|
||||
]);
|
||||
ctx.fillStyle = vignette;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(700);
|
||||
var columns = Math.floor(w / 16);
|
||||
var chars = '01アイウエオカキクケコサシスセソ{}[]<>+=/*#@$%&';
|
||||
ctx.font = '13px monospace';
|
||||
// Column rain
|
||||
for (var col = 0; col < columns; col++) {
|
||||
var cx = col * 16 + 4;
|
||||
var speed = 0.3 + rng() * 0.7;
|
||||
var colOffset = rng() * h;
|
||||
var colLen = 8 + Math.floor(rng() * 20);
|
||||
// Calculate head position
|
||||
var headY = ((t * speed * 60 + colOffset) % (h + colLen * 16)) - colLen * 16;
|
||||
for (var ch = 0; ch < colLen; ch++) {
|
||||
var cy = headY + ch * 16;
|
||||
if (cy < -16 || cy > h + 16) continue;
|
||||
var charIdx = Math.floor(rng() * chars.length);
|
||||
// Head is brightest, tail fades
|
||||
var fade = 1 - (ch / colLen);
|
||||
if (ch === 0) {
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', 0.8);
|
||||
} else {
|
||||
ctx.fillStyle = H.rgba(c[2], fade * 0.5 + Math.sin(t * 3 + col + ch) * 0.08);
|
||||
}
|
||||
ctx.fillText(chars[charIdx], cx, cy);
|
||||
}
|
||||
}
|
||||
// Occasional bright flashes
|
||||
if (Math.sin(t * 1.3) > 0.95) {
|
||||
var flashX = Math.floor(rng() * columns) * 16;
|
||||
var flashY = rng() * h;
|
||||
Draw.glow(ctx, flashX, flashY, 30, H.rgba(c[2], 0.3));
|
||||
}
|
||||
// Subtle horizontal scan line
|
||||
var scanY = (t * 40) % h;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.03);
|
||||
ctx.fillRect(0, scanY, w, 2);
|
||||
},
|
||||
particles: null
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,625 @@
|
||||
// colorful.js — Colorful/vibrant theme for procedural background system
|
||||
// 8 variants: rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
// Helper: draw a paint splatter blob
|
||||
function drawSplatter(ctx, cx, cy, r, color, rng) {
|
||||
ctx.fillStyle = color;
|
||||
// Main blob
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Splatter drops radiating outward
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var angle = rng() * Math.PI * 2;
|
||||
var dist = r + rng() * r * 1.5;
|
||||
var dropR = 2 + rng() * r * 0.35;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + Math.cos(angle) * dist, cy + Math.sin(angle) * dist, dropR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Drip trails
|
||||
for (var i = 0; i < 2; i++) {
|
||||
var dx = cx + (rng() - 0.5) * r;
|
||||
var dLen = r * 0.5 + rng() * r;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dx - 2, cy + r * 0.5);
|
||||
ctx.quadraticCurveTo(dx, cy + r * 0.5 + dLen * 0.5, dx - 1, cy + r * 0.5 + dLen);
|
||||
ctx.lineTo(dx + 2, cy + r * 0.5 + dLen);
|
||||
ctx.quadraticCurveTo(dx + 1, cy + r * 0.5 + dLen * 0.5, dx + 2, cy + r * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: draw a candy/lollipop
|
||||
function drawLollipop(ctx, x, baseY, r, colors, rng) {
|
||||
// Stick
|
||||
ctx.strokeStyle = '#DDCCBB';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, baseY);
|
||||
ctx.lineTo(x, baseY - r * 2.5);
|
||||
ctx.stroke();
|
||||
// Swirled candy top
|
||||
var segments = 6;
|
||||
for (var i = segments - 1; i >= 0; i--) {
|
||||
var a1 = (i / segments) * Math.PI * 2;
|
||||
var a2 = ((i + 1) / segments) * Math.PI * 2;
|
||||
ctx.fillStyle = colors[i % colors.length];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, baseY - r * 2.5);
|
||||
ctx.arc(x, baseY - r * 2.5, r, a1, a2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
// Shine
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', 0.25);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - r * 0.25, baseY - r * 2.5 - r * 0.2, r * 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Helper: draw a disco ball facet pattern
|
||||
function drawDiscoBall(ctx, cx, cy, r, t) {
|
||||
// Ball base
|
||||
var g = D.radialGradient(ctx, cx - r * 0.3, cy - r * 0.3, r, [[0, '#EEEEEE'], [0.5, '#AAAAAA'], [1, '#555555']]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Mirror facets
|
||||
var rows = 6, cols = 8;
|
||||
for (var row = 0; row < rows; row++) {
|
||||
var rowAngle = (row / rows) * Math.PI - Math.PI / 2;
|
||||
var rowR = Math.cos(rowAngle) * r;
|
||||
var rowY = cy + Math.sin(rowAngle) * r * 0.85;
|
||||
for (var col = 0; col < cols; col++) {
|
||||
var colAngle = (col / cols) * Math.PI * 2 + t * 0.5;
|
||||
var fx = cx + Math.cos(colAngle) * rowR * 0.85;
|
||||
var fy = rowY;
|
||||
var sparkle = 0.2 + Math.sin(t * 3 + row * 2 + col * 1.5) * 0.3;
|
||||
if (sparkle > 0.1) {
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', sparkle);
|
||||
ctx.fillRect(fx - 3, fy - 2, 6, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rainbow palette constant
|
||||
var RAINBOW = ['#FF0000', '#FF7700', '#FFDD00', '#00CC44', '#0088FF', '#6633CC', '#CC33AA'];
|
||||
|
||||
engine.registerTheme('colorful', {
|
||||
|
||||
rainbow: {
|
||||
meta: { id: 'colorful_rainbow', mood: 'joyful', colors: ['#FF4444', '#FFAA33', '#FFEE33', '#44DD55'], tags: ['rainbow', 'bright', 'cheerful'], description: 'Bold rainbow arcs and bands across a bright sky', compatibleMusicMoods: ['playful', 'energetic', 'dreamy'], recommendedFor: ['creative', 'puzzle', 'music'] },
|
||||
colors: ['#FF4444', '#FFAA33', '#FFEE33', '#44DD55'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#2244AA', '#4488CC', '#88BBEE'], 90);
|
||||
// Sun glow
|
||||
D.glow(ctx, w * 0.85, h * 0.1, 80, H.rgba('#FFEE44', 0.25));
|
||||
D.circle(ctx, w * 0.85, h * 0.1, 25, H.rgba('#FFEE44', 0.6));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Rainbow arc
|
||||
var rcx = w * 0.4, rcy = h * 1.1;
|
||||
var baseR = Math.min(w, h) * 0.8;
|
||||
for (var i = 0; i < RAINBOW.length; i++) {
|
||||
var rr = baseR + i * 12;
|
||||
ctx.strokeStyle = H.rgba(RAINBOW[i], 0.5);
|
||||
ctx.lineWidth = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rcx, rcy, rr, Math.PI, 0);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Soft clouds
|
||||
Draw.clouds(ctx, w, h, h * 0.25, 5, '#FFFFFF', 101);
|
||||
Draw.clouds(ctx, w, h, h * 0.65, 4, '#FFFFFF', 102);
|
||||
// Color sparkles along rainbow
|
||||
var rng = H.seededRandom(103);
|
||||
for (var i = 0; i < 15; i++) {
|
||||
var sparkAngle = Math.PI - rng() * Math.PI;
|
||||
var sparkR = baseR + rng() * RAINBOW.length * 12;
|
||||
var sx = rcx + Math.cos(sparkAngle) * sparkR;
|
||||
var sy = rcy + Math.sin(sparkAngle) * sparkR;
|
||||
var twinkle = 0.3 + Math.sin(t * 2 + i * 1.3) * 0.3;
|
||||
D.circle(ctx, sx, sy, 2, H.rgba(H.pick(RAINBOW), twinkle));
|
||||
}
|
||||
// Green hills at bottom
|
||||
Draw.hills(ctx, w, h, h * 0.85, 6, '#44AA44', 104);
|
||||
Draw.hills(ctx, w, h, h * 0.9, 5, '#338833', 105);
|
||||
},
|
||||
particles: { type: 'sparkles', count: 35, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
paintSplatter: {
|
||||
meta: { id: 'colorful_paintSplatter', mood: 'creative', colors: ['#FAFAE8', '#FF3366', '#3388FF', '#FFCC00'], tags: ['art', 'messy', 'expressive'], description: 'Colorful paint splatters on a light canvas', compatibleMusicMoods: ['playful', 'energetic', 'electronic'], recommendedFor: ['creative', 'music', 'action'] },
|
||||
colors: ['#FAFAE8', '#FF3366', '#3388FF', '#FFCC00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Canvas texture background
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.darken(c[0], 0.05)], 135);
|
||||
// Subtle canvas weave
|
||||
var rng = H.seededRandom(200);
|
||||
ctx.strokeStyle = H.rgba('#CCCCAA', 0.06);
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var y = 0; y < h; y += 6) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (var x = 0; x < w; x += 6) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(201);
|
||||
var splatColors = ['#FF3366', '#3388FF', '#FFCC00', '#44CC55', '#FF6622', '#AA44DD', '#FF88AA', '#22DDCC'];
|
||||
// Large splatters
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var sx = rng() * w;
|
||||
var sy = rng() * h;
|
||||
var sr = 20 + rng() * 45;
|
||||
var col = H.rgba(splatColors[Math.floor(rng() * splatColors.length)], 0.5 + rng() * 0.3);
|
||||
drawSplatter(ctx, sx, sy, sr, col, rng);
|
||||
}
|
||||
// Paint drips from top
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var dx = rng() * w;
|
||||
var dripLen = h * 0.2 + rng() * h * 0.5;
|
||||
var dripColor = splatColors[Math.floor(rng() * splatColors.length)];
|
||||
var dripW = 4 + rng() * 8;
|
||||
// Animate drip length
|
||||
var animLen = dripLen * H.clamp((Math.sin(t * 0.3 + i * 1.2) + 1) / 2, 0.6, 1);
|
||||
ctx.fillStyle = H.rgba(dripColor, 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dx - dripW / 2, 0);
|
||||
ctx.lineTo(dx + dripW / 2, 0);
|
||||
ctx.lineTo(dx + dripW * 0.3, animLen);
|
||||
ctx.quadraticCurveTo(dx, animLen + 8, dx - dripW * 0.3, animLen);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
// Small accent dots
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var px = rng() * w;
|
||||
var py = rng() * h;
|
||||
var pr = 2 + rng() * 5;
|
||||
D.circle(ctx, px, py, pr, H.rgba(splatColors[Math.floor(rng() * splatColors.length)], 0.3 + rng() * 0.4));
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 20, color: '#FF3366' }
|
||||
},
|
||||
|
||||
candyWorld: {
|
||||
meta: { id: 'colorful_candyWorld', mood: 'sweet', colors: ['#FFE0F0', '#FF88BB', '#88DDFF', '#AAFFAA'], tags: ['candy', 'pastel', 'fun'], description: 'Candy landscape with lollipops, gumdrops, and sugar hills', compatibleMusicMoods: ['playful', 'dreamy', 'calm'], recommendedFor: ['pet', 'creative', 'puzzle'] },
|
||||
colors: ['#FFE0F0', '#FF88BB', '#88DDFF', '#AAFFAA'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[2], c[0], c[3]], 90);
|
||||
// Cotton candy clouds
|
||||
Draw.clouds(ctx, w, h, h * 0.15, 4, '#FFAACC', 301);
|
||||
Draw.clouds(ctx, w, h, h * 0.3, 3, '#AADDFF', 302);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(303);
|
||||
// Candy hills (rounded, pastel)
|
||||
Draw.hills(ctx, w, h, h * 0.72, 5, '#FFAADD', 304);
|
||||
Draw.hills(ctx, w, h, h * 0.78, 4, '#AAFFCC', 305);
|
||||
Draw.hills(ctx, w, h, h * 0.84, 5, '#FFCCEE', 306);
|
||||
// Lollipops
|
||||
var lolliColors = [['#FF4488', '#FFFFFF', '#FF88BB'], ['#44BBFF', '#FFFFFF', '#88DDFF'], ['#FFAA33', '#FFFFFF', '#FFDD88']];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var lx = rng() * w * 0.9 + w * 0.05;
|
||||
var lBaseY = h * 0.72 + rng() * h * 0.12;
|
||||
var lr = 10 + rng() * 14;
|
||||
drawLollipop(ctx, lx, lBaseY, lr, lolliColors[Math.floor(rng() * lolliColors.length)], rng);
|
||||
}
|
||||
// Gumdrops
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var gx = rng() * w;
|
||||
var gy = h * 0.78 + rng() * h * 0.1;
|
||||
var gr = 8 + rng() * 12;
|
||||
var gc = H.pick(['#FF6688', '#66CCFF', '#FFCC44', '#88EE88', '#DD88FF']);
|
||||
ctx.fillStyle = gc;
|
||||
ctx.beginPath();
|
||||
ctx.arc(gx, gy - gr * 0.5, gr, Math.PI, 0);
|
||||
ctx.lineTo(gx + gr, gy);
|
||||
ctx.lineTo(gx - gr, gy);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Sugar sparkle
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.arc(gx - gr * 0.3, gy - gr * 0.6, gr * 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Candy cane stripes in ground
|
||||
var stripeOffset = (t * 10) % 20;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08;
|
||||
for (var i = -1; i < w / 20 + 1; i++) {
|
||||
Draw.rect(ctx, i * 20 + stripeOffset, h * 0.84, 10, h * 0.16, '#FF88AA');
|
||||
}
|
||||
ctx.restore();
|
||||
// Floating candy
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var fx = rng() * w;
|
||||
var fy = h * 0.1 + rng() * h * 0.35 + Math.sin(t * 0.8 + i * 1.5) * 10;
|
||||
var fr = 4 + rng() * 6;
|
||||
D.circle(ctx, fx, fy, fr, H.rgba(H.pick(['#FF88AA', '#88CCFF', '#FFDD66', '#AAFFAA']), 0.5));
|
||||
D.glow(ctx, fx, fy, fr * 2, H.rgba('#FFFFFF', 0.1));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 30, color: '#FFAACC' }
|
||||
},
|
||||
|
||||
disco: {
|
||||
meta: { id: 'colorful_disco', mood: 'energetic', colors: ['#1A0A2E', '#2A1A44', '#FF33AA', '#33FFCC'], tags: ['dance', 'retro', 'lights'], description: 'Disco floor with mirror ball and sweeping colored lights', compatibleMusicMoods: ['energetic', 'electronic', 'playful'], recommendedFor: ['music', 'action', 'sports'] },
|
||||
colors: ['#1A0A2E', '#2A1A44', '#FF33AA', '#33FFCC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(400);
|
||||
// Disco floor (checkered at bottom, perspective)
|
||||
var floorY = h * 0.7;
|
||||
for (var row = 0; row < 6; row++) {
|
||||
for (var col = 0; col < 10; col++) {
|
||||
var tileW = w / 8;
|
||||
var tileH = (h - floorY) / 6;
|
||||
var tx = col * tileW - tileW;
|
||||
var ty = floorY + row * tileH;
|
||||
var isLight = (row + col) % 2 === 0;
|
||||
var pulse = Math.sin(t * 3 + row + col * 0.7);
|
||||
var tileColor;
|
||||
if (isLight && pulse > 0) {
|
||||
var hueShift = (t * 0.5 + row * 0.3 + col * 0.2) % 1;
|
||||
var idx = Math.floor(hueShift * RAINBOW.length);
|
||||
tileColor = H.rgba(RAINBOW[idx], 0.3 + pulse * 0.2);
|
||||
} else {
|
||||
tileColor = isLight ? H.rgba(c[1], 0.6) : H.rgba(c[0], 0.8);
|
||||
}
|
||||
Draw.rect(ctx, tx, ty, tileW, tileH, tileColor);
|
||||
}
|
||||
}
|
||||
// Disco ball
|
||||
drawDiscoBall(ctx, w * 0.5, h * 0.18, 30, t);
|
||||
// String to ceiling
|
||||
ctx.strokeStyle = '#888888';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.5, 0);
|
||||
ctx.lineTo(w * 0.5, h * 0.18 - 30);
|
||||
ctx.stroke();
|
||||
// Sweeping light beams from disco ball
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var beamAngle = t * 0.8 + i * Math.PI / 3;
|
||||
var beamX = w * 0.5 + Math.cos(beamAngle) * w * 0.6;
|
||||
var beamY = h;
|
||||
var beamColor = RAINBOW[i % RAINBOW.length];
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.06;
|
||||
ctx.fillStyle = beamColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.5, h * 0.18);
|
||||
ctx.lineTo(beamX - 30, beamY);
|
||||
ctx.lineTo(beamX + 30, beamY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
// Reflected light spots on walls
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var rx = rng() * w;
|
||||
var ry = rng() * h * 0.65;
|
||||
var rr = 3 + rng() * 8;
|
||||
var spotBright = 0.1 + Math.sin(t * 2.5 + i * 1.3) * 0.1;
|
||||
D.circle(ctx, rx, ry, rr, H.rgba(RAINBOW[Math.floor(rng() * RAINBOW.length)], spotBright));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 40, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
neon: {
|
||||
meta: { id: 'colorful_neon', mood: 'electric', colors: ['#0A0A18', '#151530', '#FF0088', '#00FFCC'], tags: ['neon', 'glow', 'night'], description: 'Neon signs and glowing outlines against dark city night', compatibleMusicMoods: ['electronic', 'energetic', 'tense'], recommendedFor: ['action', 'racing', 'music'] },
|
||||
colors: ['#0A0A18', '#151530', '#FF0088', '#00FFCC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(500);
|
||||
var neonColors = ['#FF0088', '#00FFCC', '#FFFF00', '#FF4400', '#8844FF', '#00AAFF'];
|
||||
// Neon grid on floor
|
||||
var gridY = h * 0.75;
|
||||
var gridColor = H.rgba(c[3], 0.1 + Math.sin(t * 0.5) * 0.03);
|
||||
for (var x = 0; x <= w; x += 40) {
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, gridY);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (var y = gridY; y <= h; y += 20) {
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Neon sign shapes
|
||||
var neonShapes = [
|
||||
{ type: 'circle', x: w * 0.2, y: h * 0.3, r: 35 },
|
||||
{ type: 'triangle', x: w * 0.5, y: h * 0.25, r: 40 },
|
||||
{ type: 'rect', x: w * 0.78, y: h * 0.3, r: 30 }
|
||||
];
|
||||
for (var i = 0; i < neonShapes.length; i++) {
|
||||
var s = neonShapes[i];
|
||||
var nc = neonColors[i % neonColors.length];
|
||||
var flicker = 0.6 + Math.sin(t * 3 + i * 2.1) * 0.2;
|
||||
// Outer glow
|
||||
D.glow(ctx, s.x, s.y, s.r * 2, H.rgba(nc, flicker * 0.15));
|
||||
ctx.strokeStyle = H.rgba(nc, flicker);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowColor = nc;
|
||||
ctx.shadowBlur = 15;
|
||||
if (s.type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (s.type === 'triangle') {
|
||||
ctx.beginPath();
|
||||
for (var v = 0; v < 3; v++) {
|
||||
var a = v * Math.PI * 2 / 3 - Math.PI / 2;
|
||||
var px = s.x + Math.cos(a) * s.r;
|
||||
var py = s.y + Math.sin(a) * s.r;
|
||||
v === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.strokeRect(s.x - s.r, s.y - s.r * 0.7, s.r * 2, s.r * 1.4);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
// Neon lines / tubes
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var lx1 = rng() * w;
|
||||
var ly1 = rng() * h * 0.6;
|
||||
var lx2 = lx1 + (rng() - 0.5) * 100;
|
||||
var ly2 = ly1 + (rng() - 0.5) * 60;
|
||||
var lc = neonColors[Math.floor(rng() * neonColors.length)];
|
||||
var lFlicker = 0.3 + Math.sin(t * 4 + rng() * 10) * 0.2;
|
||||
D.glow(ctx, (lx1 + lx2) / 2, (ly1 + ly2) / 2, 20, H.rgba(lc, lFlicker * 0.1));
|
||||
ctx.strokeStyle = H.rgba(lc, lFlicker);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx1, ly1);
|
||||
ctx.lineTo(lx2, ly2);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Cityscape silhouettes
|
||||
Draw.buildings(ctx, w, h, h * 0.75, 10, [c[0], c[1]], 501);
|
||||
},
|
||||
particles: { type: 'particles', count: 30, color: '#FF0088' }
|
||||
},
|
||||
|
||||
tieDye: {
|
||||
meta: { id: 'colorful_tieDye', mood: 'psychedelic', colors: ['#FF4488', '#FFAA22', '#44DDBB', '#8844FF'], tags: ['swirl', 'organic', 'hippie'], description: 'Swirling tie-dye patterns with organic color blending', compatibleMusicMoods: ['dreamy', 'ambient', 'playful'], recommendedFor: ['creative', 'music', 'story'] },
|
||||
colors: ['#FF4488', '#FFAA22', '#44DDBB', '#8844FF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2], c[3]], 90 + t * 3);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(600);
|
||||
// Concentric swirl rings from multiple centers
|
||||
var centers = [];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
centers.push({ x: rng() * w, y: rng() * h });
|
||||
}
|
||||
for (var ci = 0; ci < centers.length; ci++) {
|
||||
var center = centers[ci];
|
||||
var maxR = 80 + rng() * 120;
|
||||
for (var ring = 8; ring >= 0; ring--) {
|
||||
var rr = maxR * (ring / 8);
|
||||
var colorIdx = (ring + ci) % c.length;
|
||||
var alpha = 0.12 + ring * 0.01;
|
||||
var spiral = t * 0.2 * (ci % 2 === 0 ? 1 : -1);
|
||||
ctx.fillStyle = H.rgba(c[colorIdx], alpha);
|
||||
ctx.beginPath();
|
||||
// Organic wavy circle
|
||||
for (var a = 0; a <= 36; a++) {
|
||||
var angle = (a / 36) * Math.PI * 2 + spiral;
|
||||
var wobble = 1 + Math.sin(angle * 3 + ring + t * 0.5) * 0.15;
|
||||
var px = center.x + Math.cos(angle) * rr * wobble;
|
||||
var py = center.y + Math.sin(angle) * rr * wobble;
|
||||
a === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
// Color bleed spots
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var bx = rng() * w, by = rng() * h;
|
||||
var br = 20 + rng() * 50;
|
||||
var bc = c[Math.floor(rng() * c.length)];
|
||||
D.glow(ctx, bx, by, br, H.rgba(bc, 0.08 + Math.sin(t * 0.7 + i) * 0.03));
|
||||
}
|
||||
// Radial lines from center
|
||||
var cx = w * 0.5, cy = h * 0.5;
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < 16; i++) {
|
||||
var la = (i / 16) * Math.PI * 2 + t * 0.1;
|
||||
ctx.strokeStyle = H.rgba(c[i % c.length], 0.05);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + Math.cos(la) * w, cy + Math.sin(la) * h);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 25, color: '#FF88CC' }
|
||||
},
|
||||
|
||||
gradient: {
|
||||
meta: { id: 'colorful_gradient', mood: 'peaceful', colors: ['#FF6B6B', '#FECA57', '#48DBFB', '#FF9FF3'], tags: ['smooth', 'modern', 'clean'], description: 'Smooth shifting color gradients with geometric accents', compatibleMusicMoods: ['ambient', 'calm', 'dreamy'], recommendedFor: ['puzzle', 'creative', 'trivia'] },
|
||||
colors: ['#FF6B6B', '#FECA57', '#48DBFB', '#FF9FF3'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Animated gradient angle
|
||||
var angle = 90 + Math.sin(t * 0.2) * 30;
|
||||
// Cycle through color pairs
|
||||
var shift = (Math.sin(t * 0.15) + 1) / 2;
|
||||
var c1 = H.blendColor(c[0], c[2], shift);
|
||||
var c2 = H.blendColor(c[1], c[3], shift);
|
||||
var c3 = H.blendColor(c[2], c[0], shift);
|
||||
Draw.fillGradient(ctx, w, h, [c1, c2, c3], angle);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(700);
|
||||
// Soft geometric shapes floating
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var sx = rng() * w;
|
||||
var sy = rng() * h;
|
||||
var sr = 20 + rng() * 60;
|
||||
var sides = 3 + Math.floor(rng() * 4);
|
||||
var rot = t * 0.1 * (rng() > 0.5 ? 1 : -1);
|
||||
var col = c[Math.floor(rng() * c.length)];
|
||||
var alpha = 0.06 + Math.sin(t * 0.5 + i) * 0.03;
|
||||
D.polygon(ctx, sx, sy, sr, sides, H.rgba(col, alpha), rot);
|
||||
// Soft outline
|
||||
ctx.strokeStyle = H.rgba(col, alpha * 1.5);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (var v = 0; v <= sides; v++) {
|
||||
var va = (v / sides) * Math.PI * 2 + rot;
|
||||
var vx = sx + Math.cos(va) * sr;
|
||||
var vy = sy + Math.sin(va) * sr;
|
||||
v === 0 ? ctx.moveTo(vx, vy) : ctx.lineTo(vx, vy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Floating circles
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var cx = rng() * w;
|
||||
var cy = rng() * h;
|
||||
var cr = 5 + rng() * 25;
|
||||
var bob = Math.sin(t * 0.4 + i * 0.8) * 8;
|
||||
ctx.strokeStyle = H.rgba(c[Math.floor(rng() * c.length)], 0.1 + rng() * 0.08);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy + bob, cr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Soft horizontal bands
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var bY = h * 0.15 + i * h * 0.22;
|
||||
var bAlpha = 0.03 + Math.sin(t * 0.3 + i * 1.5) * 0.015;
|
||||
ctx.fillStyle = H.rgba(c[i % c.length], bAlpha);
|
||||
ctx.fillRect(0, bY, w, h * 0.08);
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
prism: {
|
||||
meta: { id: 'colorful_prism', mood: 'mystical', colors: ['#1A1A2E', '#0F3460', '#E94560', '#FFFFFF'], tags: ['light', 'crystal', 'spectrum'], description: 'Light splitting through prisms into rainbow beams', compatibleMusicMoods: ['epic', 'ambient', 'electronic'], recommendedFor: ['puzzle', 'physics', 'creative'] },
|
||||
colors: ['#1A1A2E', '#0F3460', '#E94560', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(800);
|
||||
var prismX = w * 0.45, prismY = h * 0.42;
|
||||
var prismR = Math.min(w, h) * 0.12;
|
||||
// Incoming white light beam
|
||||
var beamPulse = 0.5 + Math.sin(t * 0.8) * 0.15;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = beamPulse;
|
||||
ctx.strokeStyle = '#FFFFFF';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowColor = '#FFFFFF';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, prismY);
|
||||
ctx.lineTo(prismX - prismR * 0.8, prismY);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.restore();
|
||||
// Prism (triangle)
|
||||
var g = D.linearGradient(ctx, prismX - prismR, prismY + prismR, prismX + prismR, prismY - prismR, [[0, H.rgba('#FFFFFF', 0.1)], [0.5, H.rgba('#AABBFF', 0.15)], [1, H.rgba('#FFFFFF', 0.05)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prismX, prismY - prismR);
|
||||
ctx.lineTo(prismX + prismR * 0.87, prismY + prismR * 0.5);
|
||||
ctx.lineTo(prismX - prismR * 0.87, prismY + prismR * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.3);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
// Rainbow beams emanating from prism
|
||||
for (var i = 0; i < RAINBOW.length; i++) {
|
||||
var angle = 0.15 + (i / RAINBOW.length) * 0.7;
|
||||
var endX = w;
|
||||
var endY = prismY - h * 0.3 + i * (h * 0.12);
|
||||
var beamAlpha = 0.2 + Math.sin(t * 1.2 + i * 0.5) * 0.08;
|
||||
ctx.strokeStyle = H.rgba(RAINBOW[i], beamAlpha);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prismX + prismR * 0.5, prismY);
|
||||
ctx.lineTo(endX, endY);
|
||||
ctx.stroke();
|
||||
// Glow along beam
|
||||
D.glow(ctx, (prismX + endX) / 2, (prismY + endY) / 2, 30, H.rgba(RAINBOW[i], beamAlpha * 0.15));
|
||||
}
|
||||
// Secondary smaller prisms
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var spx = rng() * w * 0.3 + w * 0.6;
|
||||
var spy = rng() * h * 0.7 + h * 0.15;
|
||||
var spr = 10 + rng() * 15;
|
||||
var rot = rng() * Math.PI + t * 0.1;
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.1);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (var v = 0; v < 3; v++) {
|
||||
var va = rot + v * Math.PI * 2 / 3;
|
||||
var vx = spx + Math.cos(va) * spr;
|
||||
var vy = spy + Math.sin(va) * spr;
|
||||
v === 0 ? ctx.moveTo(vx, vy) : ctx.lineTo(vx, vy);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// Tiny rainbow from small prism
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var microAngle = rot + 0.5 + j * 0.2;
|
||||
ctx.strokeStyle = H.rgba(RAINBOW[j * 2], 0.08);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(spx, spy);
|
||||
ctx.lineTo(spx + Math.cos(microAngle) * 50, spy + Math.sin(microAngle) * 50);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Light sparkles where beams hit edges
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var sx = w * 0.8 + rng() * w * 0.2;
|
||||
var sy = rng() * h;
|
||||
var sparkle = 0.2 + Math.sin(t * 2 + rng() * 10) * 0.2;
|
||||
D.circle(ctx, sx, sy, 2, H.rgba(RAINBOW[Math.floor(rng() * RAINBOW.length)], sparkle));
|
||||
}
|
||||
// Subtle scanlines for atmosphere
|
||||
Draw.scanlines(ctx, w, h, 4, H.rgba(c[0], 0.04));
|
||||
},
|
||||
particles: { type: 'sparkles', count: 35, color: '#FFFFFF' }
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,505 @@
|
||||
// fantasy.js — Fantasy theme backgrounds
|
||||
// Variants: castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('fantasy', {
|
||||
|
||||
castle: {
|
||||
meta: { id: 'fantasy_castle', mood: 'heroic', colors: ['#1a1040', '#2d1b69', '#8b7355', '#ffd700'], tags: ['castle', 'medieval', 'heroic', 'stone'], description: 'A grand castle silhouette against a twilight sky with golden banners', compatibleMusicMoods: ['epic', 'heroic', 'adventure'], recommendedFor: ['rpg', 'action', 'story'] },
|
||||
colors: ['#1a1040', '#2d1b69', '#8b7355', '#ffd700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.lighten(c[1], 0.15)], 90);
|
||||
Draw.stars(ctx, w, h * 0.5, 80, '#FFFFFF', 101, [0.5, 2]);
|
||||
var moonGlow = 0.35 + Math.sin(t * 0.3) * 0.1;
|
||||
Draw.glow(ctx, w * 0.8, h * 0.15, 80, H.rgba('#FFFFCC', moonGlow));
|
||||
Draw.circle(ctx, w * 0.8, h * 0.15, 25, '#FFFFDD');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
Draw.mountains(ctx, w, h, h * 0.6, 6, H.darken(c[2], 0.6), 201);
|
||||
// Castle wall
|
||||
Draw.rect(ctx, w * 0.25, h * 0.4, w * 0.5, h * 0.6, H.darken(c[2], 0.4));
|
||||
// Towers
|
||||
var towerW = w * 0.08;
|
||||
Draw.rect(ctx, w * 0.22, h * 0.25, towerW, h * 0.75, H.darken(c[2], 0.45));
|
||||
Draw.rect(ctx, w * 0.70, h * 0.25, towerW, h * 0.75, H.darken(c[2], 0.45));
|
||||
// Battlements
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var bx = w * 0.25 + i * (w * 0.5 / 12);
|
||||
if (i % 2 === 0) Draw.rect(ctx, bx, h * 0.38, w * 0.5 / 12, h * 0.04, H.darken(c[2], 0.4));
|
||||
}
|
||||
// Tower caps (triangles)
|
||||
ctx.fillStyle = c[2];
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.22, h * 0.25); ctx.lineTo(w * 0.26, h * 0.15); ctx.lineTo(w * 0.30, h * 0.25); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.70, h * 0.25); ctx.lineTo(w * 0.74, h * 0.15); ctx.lineTo(w * 0.78, h * 0.25); ctx.fill();
|
||||
// Gate
|
||||
Draw.rect(ctx, w * 0.44, h * 0.6, w * 0.12, h * 0.4, '#0a0a15');
|
||||
ctx.strokeStyle = c[3]; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, h * 0.6, w * 0.06, Math.PI, 0); ctx.stroke();
|
||||
// Windows with flickering glow
|
||||
for (var j = 0; j < 6; j++) {
|
||||
var wx = w * 0.3 + rng() * w * 0.4;
|
||||
var wy = h * 0.42 + rng() * h * 0.15;
|
||||
var flicker = 0.4 + Math.sin(t * 2 + j * 1.7) * 0.2;
|
||||
Draw.glow(ctx, wx, wy, 12, H.rgba(c[3], flicker));
|
||||
Draw.rect(ctx, wx - 3, wy - 5, 6, 10, H.rgba(c[3], flicker + 0.2));
|
||||
}
|
||||
// Banner
|
||||
var bannerSway = Math.sin(t * 1.5) * 3;
|
||||
ctx.fillStyle = '#8B0000';
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.26, h * 0.16); ctx.lineTo(w * 0.26 + bannerSway, h * 0.22); ctx.lineTo(w * 0.26 - 8, h * 0.22); ctx.fill();
|
||||
},
|
||||
particles: { type: 'fireflies', count: 12, color: '#FFD700' }
|
||||
},
|
||||
|
||||
dungeon: {
|
||||
meta: { id: 'fantasy_dungeon', mood: 'dark', colors: ['#0a0a0f', '#1a1520', '#4a3728', '#ff6a00'], tags: ['dungeon', 'dark', 'underground', 'torches'], description: 'Dark stone corridors lit by flickering torches and glowing embers', compatibleMusicMoods: ['dark', 'tense', 'mysterious'], recommendedFor: ['rpg', 'action', 'puzzle'] },
|
||||
colors: ['#0a0a0f', '#1a1520', '#4a3728', '#ff6a00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Stone texture
|
||||
var rng = H.seededRandom(300);
|
||||
for (var i = 0; i < 40; i++) {
|
||||
var sx = rng() * w, sy = rng() * h;
|
||||
var sw = 20 + rng() * 60, sh = 15 + rng() * 40;
|
||||
Draw.rect(ctx, sx, sy, sw, sh, H.rgba(c[2], 0.08 + rng() * 0.06));
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Stone pillars
|
||||
for (var p = 0; p < 3; p++) {
|
||||
var px = w * 0.2 + p * w * 0.3;
|
||||
Draw.rect(ctx, px - 15, h * 0.1, 30, h * 0.9, H.darken(c[2], 0.3));
|
||||
Draw.rect(ctx, px - 20, h * 0.08, 40, 15, H.darken(c[2], 0.2));
|
||||
Draw.rect(ctx, px - 20, h * 0.92, 40, 15, H.darken(c[2], 0.2));
|
||||
}
|
||||
// Chains hanging
|
||||
var rng = H.seededRandom(301);
|
||||
for (var ch = 0; ch < 4; ch++) {
|
||||
var cx = w * 0.15 + rng() * w * 0.7;
|
||||
var chainLen = h * 0.1 + rng() * h * 0.2;
|
||||
ctx.strokeStyle = H.rgba('#666655', 0.5);
|
||||
ctx.lineWidth = 2;
|
||||
for (var link = 0; link < chainLen; link += 8) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, link + h * 0.02, 3, 4, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Torches with animated glow
|
||||
for (var ti = 0; ti < 4; ti++) {
|
||||
var tx = w * 0.1 + ti * w * 0.27;
|
||||
var ty = h * 0.35;
|
||||
Draw.rect(ctx, tx - 3, ty, 6, 20, '#5a3a1a');
|
||||
var flicker = 0.5 + Math.sin(t * 4 + ti * 2.3) * 0.3 + Math.sin(t * 7 + ti) * 0.15;
|
||||
Draw.glow(ctx, tx, ty - 5, 40 + Math.sin(t * 3 + ti) * 8, H.rgba(c[3], flicker * 0.4));
|
||||
Draw.circle(ctx, tx, ty - 5, 5, H.rgba(c[3], flicker));
|
||||
Draw.circle(ctx, tx, ty - 8, 3, H.rgba('#FFCC44', flicker));
|
||||
}
|
||||
// Ground cracks
|
||||
ctx.strokeStyle = H.rgba('#222', 0.3);
|
||||
ctx.lineWidth = 1;
|
||||
var crng = H.seededRandom(302);
|
||||
for (var cr = 0; cr < 8; cr++) {
|
||||
var crx = crng() * w;
|
||||
ctx.beginPath(); ctx.moveTo(crx, h * 0.9);
|
||||
for (var seg = 0; seg < 4; seg++) {
|
||||
crx += (crng() - 0.5) * 30;
|
||||
ctx.lineTo(crx, h * 0.9 + seg * 8 + crng() * 10);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 20, color: '#FF6A00' }
|
||||
},
|
||||
|
||||
enchantedForest: {
|
||||
meta: { id: 'fantasy_enchantedForest', mood: 'mystical', colors: ['#0a1a0f', '#0d2818', '#1a4a2a', '#44ff88'], tags: ['forest', 'magical', 'nature', 'enchanted'], description: 'A mystical forest with glowing mushrooms and magical light filtering through ancient trees', compatibleMusicMoods: ['mystical', 'calm', 'magical'], recommendedFor: ['rpg', 'story', 'puzzle'] },
|
||||
colors: ['#0a1a0f', '#0d2818', '#1a4a2a', '#44ff88'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.darken(c[2], 0.3)], 90);
|
||||
// Filtered light rays
|
||||
for (var r = 0; r < 5; r++) {
|
||||
var rx = w * 0.15 + r * w * 0.18;
|
||||
var rayAlpha = 0.03 + Math.sin(t * 0.5 + r) * 0.015;
|
||||
ctx.fillStyle = H.rgba(c[3], rayAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rx - 5, 0); ctx.lineTo(rx + 30, h);
|
||||
ctx.lineTo(rx - 30, h); ctx.lineTo(rx + 5, 0);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Background trees
|
||||
Draw.trees(ctx, w, h, h * 0.85, 8, '#1a0d0a', H.darken(c[2], 0.3), 401);
|
||||
// Large foreground trees
|
||||
var rng = H.seededRandom(402);
|
||||
for (var tr = 0; tr < 4; tr++) {
|
||||
var tx = rng() * w;
|
||||
var treeH = h * 0.5 + rng() * h * 0.3;
|
||||
var trunkW = 12 + rng() * 10;
|
||||
Draw.rect(ctx, tx - trunkW / 2, h - treeH, trunkW, treeH, '#2a1a0f');
|
||||
// Roots
|
||||
for (var rt = 0; rt < 3; rt++) {
|
||||
ctx.fillStyle = '#2a1a0f';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx - trunkW / 2 - rt * 8, h);
|
||||
ctx.quadraticCurveTo(tx - trunkW / 2, h - 15, tx - trunkW / 4, h - treeH * 0.1);
|
||||
ctx.lineTo(tx - trunkW / 2, h - treeH * 0.1);
|
||||
ctx.fill();
|
||||
}
|
||||
// Canopy
|
||||
for (var can = 0; can < 3; can++) {
|
||||
var canR = 30 + rng() * 40;
|
||||
var canX = tx + (rng() - 0.5) * 30;
|
||||
var canY = h - treeH - canR * 0.3 + can * 15;
|
||||
Draw.circle(ctx, canX, canY, canR, H.rgba(c[2], 0.7 + rng() * 0.3));
|
||||
}
|
||||
}
|
||||
// Glowing mushrooms
|
||||
for (var m = 0; m < 8; m++) {
|
||||
var mx = rng() * w, my = h * 0.82 + rng() * h * 0.15;
|
||||
var mSize = 4 + rng() * 8;
|
||||
var pulse = 0.4 + Math.sin(t * 1.2 + m * 1.1) * 0.2;
|
||||
Draw.glow(ctx, mx, my, mSize * 3, H.rgba(c[3], pulse * 0.3));
|
||||
Draw.rect(ctx, mx - 1, my, 2, mSize, '#8B7355');
|
||||
ctx.fillStyle = H.rgba(c[3], pulse + 0.3);
|
||||
ctx.beginPath(); ctx.ellipse(mx, my, mSize, mSize * 0.5, 0, Math.PI, 0); ctx.fill();
|
||||
}
|
||||
// Ground moss/grass
|
||||
Draw.hills(ctx, w, h, h * 0.88, 10, H.darken(c[2], 0.2), 403);
|
||||
},
|
||||
particles: { type: 'fireflies', count: 25, color: '#44FF88' }
|
||||
},
|
||||
|
||||
floatingIslands: {
|
||||
meta: { id: 'fantasy_floatingIslands', mood: 'wonder', colors: ['#1a0a3a', '#2d1b69', '#5a8a5a', '#88ccff'], tags: ['sky', 'floating', 'islands', 'clouds'], description: 'Majestic islands suspended in a violet sky with waterfalls cascading into the void', compatibleMusicMoods: ['wonder', 'adventure', 'calm'], recommendedFor: ['rpg', 'puzzle', 'creative'] },
|
||||
colors: ['#1a0a3a', '#2d1b69', '#5a8a5a', '#88ccff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [H.darken(c[0], 0.3), c[0], c[1], H.lighten(c[1], 0.1)], 90);
|
||||
Draw.stars(ctx, w, h * 0.4, 60, '#FFFFFF', 501, [0.5, 1.5]);
|
||||
Draw.clouds(ctx, w, h, h * 0.2, 5, c[3], 502);
|
||||
Draw.clouds(ctx, w, h, h * 0.7, 4, H.rgba(c[3], 0.5), 503);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(504);
|
||||
// Draw 3 floating islands
|
||||
var islands = [
|
||||
{ x: w * 0.2, y: h * 0.35, sz: 1.0 },
|
||||
{ x: w * 0.65, y: h * 0.25, sz: 0.7 },
|
||||
{ x: w * 0.45, y: h * 0.55, sz: 0.5 }
|
||||
];
|
||||
for (var i = 0; i < islands.length; i++) {
|
||||
var isl = islands[i];
|
||||
var bob = Math.sin(t * 0.4 + i * 2) * 5;
|
||||
var ix = isl.x, iy = isl.y + bob, sz = isl.sz;
|
||||
// Bottom rocky underside
|
||||
ctx.fillStyle = H.darken(c[2], 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ix - 60 * sz, iy + 10); ctx.quadraticCurveTo(ix - 20 * sz, iy + 80 * sz, ix, iy + 100 * sz);
|
||||
ctx.quadraticCurveTo(ix + 30 * sz, iy + 70 * sz, ix + 60 * sz, iy + 10);
|
||||
ctx.fill();
|
||||
// Top grassy surface
|
||||
ctx.fillStyle = c[2];
|
||||
ctx.beginPath(); ctx.ellipse(ix, iy, 65 * sz, 15 * sz, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = H.lighten(c[2], 0.15);
|
||||
ctx.beginPath(); ctx.ellipse(ix, iy - 3, 60 * sz, 10 * sz, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// Small trees on top
|
||||
if (sz > 0.6) {
|
||||
for (var tr = 0; tr < 3; tr++) {
|
||||
var ttx = ix - 30 * sz + rng() * 60 * sz;
|
||||
Draw.rect(ctx, ttx - 2, iy - 20 * sz, 4, 15 * sz, '#3a2a15');
|
||||
Draw.circle(ctx, ttx, iy - 25 * sz, 8 * sz, H.lighten(c[2], 0.1));
|
||||
}
|
||||
}
|
||||
// Waterfall from main island
|
||||
if (i === 0) {
|
||||
var wfAlpha = 0.2 + Math.sin(t * 2) * 0.08;
|
||||
ctx.fillStyle = H.rgba(c[3], wfAlpha);
|
||||
ctx.fillRect(ix + 20, iy + 10, 6, h - iy);
|
||||
for (var sp = 0; sp < 5; sp++) {
|
||||
var spy = iy + 10 + rng() * (h - iy);
|
||||
Draw.glow(ctx, ix + 23, spy, 8, H.rgba(c[3], 0.15));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 35, color: '#88CCFF' }
|
||||
},
|
||||
|
||||
magicalLibrary: {
|
||||
meta: { id: 'fantasy_magicalLibrary', mood: 'scholarly', colors: ['#1a0f0a', '#2d1a10', '#6b4423', '#cc88ff'], tags: ['library', 'books', 'magical', 'scholarly'], description: 'Towering bookshelves with floating magical tomes and arcane symbols', compatibleMusicMoods: ['calm', 'mystical', 'scholarly'], recommendedFor: ['puzzle', 'trivia', 'story'] },
|
||||
colors: ['#1a0f0a', '#2d1a10', '#6b4423', '#cc88ff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Warm ambient light
|
||||
Draw.glow(ctx, w * 0.5, h * 0.3, w * 0.6, H.rgba('#553311', 0.15));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(600);
|
||||
// Bookshelves
|
||||
var shelfColors = ['#8B4513', '#6B3410', '#5a2d0e', '#7a3d18'];
|
||||
for (var s = 0; s < 5; s++) {
|
||||
var sx = s * w * 0.2 + w * 0.02;
|
||||
var sw = w * 0.18;
|
||||
// Shelf frame
|
||||
Draw.rect(ctx, sx, h * 0.05, sw, h * 0.92, H.darken(c[2], 0.4));
|
||||
// Shelves and books
|
||||
for (var row = 0; row < 6; row++) {
|
||||
var sy = h * 0.1 + row * h * 0.14;
|
||||
Draw.rect(ctx, sx, sy + h * 0.11, sw, 4, c[2]);
|
||||
// Books on shelf
|
||||
var bx = sx + 3;
|
||||
while (bx < sx + sw - 5) {
|
||||
var bw = 4 + rng() * 8;
|
||||
var bh = h * 0.08 + rng() * h * 0.03;
|
||||
var bookColor = H.pick(['#AA2222', '#2244AA', '#22AA44', '#8844AA', '#AA8822', '#228888']);
|
||||
Draw.rect(ctx, bx, sy + h * 0.11 - bh, bw, bh, bookColor);
|
||||
if (rng() > 0.6) {
|
||||
Draw.rect(ctx, bx + 1, sy + h * 0.11 - bh + 2, bw - 2, 2, H.lighten(bookColor, 0.3));
|
||||
}
|
||||
bx += bw + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Floating books
|
||||
for (var fb = 0; fb < 4; fb++) {
|
||||
var fbx = w * 0.15 + rng() * w * 0.7;
|
||||
var fby = h * 0.2 + rng() * h * 0.4 + Math.sin(t * 0.8 + fb * 1.5) * 10;
|
||||
var rot = Math.sin(t * 0.3 + fb) * 0.2;
|
||||
ctx.save();
|
||||
ctx.translate(fbx, fby); ctx.rotate(rot);
|
||||
Draw.rect(ctx, -10, -7, 20, 14, H.pick(['#882222', '#224488', '#228844']));
|
||||
Draw.rect(ctx, -8, -5, 16, 1, '#FFD700');
|
||||
ctx.restore();
|
||||
Draw.glow(ctx, fbx, fby, 20, H.rgba(c[3], 0.2 + Math.sin(t + fb) * 0.1));
|
||||
}
|
||||
// Arcane symbols floating
|
||||
var symbols = ['\u2606', '\u2609', '\u263E', '\u2726'];
|
||||
ctx.font = '16px serif';
|
||||
for (var sym = 0; sym < 6; sym++) {
|
||||
var smx = rng() * w, smy = rng() * h;
|
||||
var symAlpha = 0.15 + Math.sin(t * 0.7 + sym * 1.3) * 0.1;
|
||||
ctx.fillStyle = H.rgba(c[3], symAlpha);
|
||||
ctx.fillText(symbols[sym % symbols.length], smx + Math.sin(t * 0.4 + sym) * 5, smy);
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#CC88FF' }
|
||||
},
|
||||
|
||||
crystalCave: {
|
||||
meta: { id: 'fantasy_crystalCave', mood: 'ethereal', colors: ['#0a0a1a', '#0f1a2d', '#225588', '#44ddff'], tags: ['cave', 'crystals', 'underground', 'glow'], description: 'An underground cavern filled with luminous crystal formations and reflected light', compatibleMusicMoods: ['ethereal', 'mystical', 'calm'], recommendedFor: ['puzzle', 'rpg', 'creative'] },
|
||||
colors: ['#0a0a1a', '#0f1a2d', '#225588', '#44ddff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Cavern ceiling
|
||||
var rng = H.seededRandom(700);
|
||||
ctx.fillStyle = H.darken(c[1], 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, h * 0.15);
|
||||
for (var i = 0; i <= 20; i++) {
|
||||
var cx = (i / 20) * w;
|
||||
var cy = h * 0.08 + rng() * h * 0.12;
|
||||
ctx.lineTo(cx, cy);
|
||||
}
|
||||
ctx.lineTo(w, 0); ctx.closePath(); ctx.fill();
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(701);
|
||||
// Stalactites
|
||||
for (var st = 0; st < 12; st++) {
|
||||
var stx = rng() * w;
|
||||
var stLen = h * 0.05 + rng() * h * 0.15;
|
||||
var stW = 3 + rng() * 8;
|
||||
ctx.fillStyle = H.darken(c[2], 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(stx - stW, h * 0.08 + rng() * h * 0.07);
|
||||
ctx.lineTo(stx, h * 0.08 + stLen);
|
||||
ctx.lineTo(stx + stW, h * 0.08 + rng() * h * 0.07);
|
||||
ctx.fill();
|
||||
}
|
||||
// Crystal clusters
|
||||
var crystalHues = [c[2], c[3], H.lighten(c[2], 0.2), H.lighten(c[3], 0.2)];
|
||||
for (var cl = 0; cl < 8; cl++) {
|
||||
var clx = rng() * w;
|
||||
var cly = h * 0.6 + rng() * h * 0.35;
|
||||
var clSize = 15 + rng() * 30;
|
||||
var pulse = 0.4 + Math.sin(t * 0.8 + cl * 0.9) * 0.2;
|
||||
Draw.glow(ctx, clx, cly, clSize * 2, H.rgba(crystalHues[cl % crystalHues.length], pulse * 0.25));
|
||||
// Individual crystal shards
|
||||
for (var sh = 0; sh < 3 + Math.floor(rng() * 3); sh++) {
|
||||
var shAngle = -Math.PI / 2 + (rng() - 0.5) * 1.2;
|
||||
var shLen = clSize * (0.5 + rng() * 0.8);
|
||||
var shW = 3 + rng() * 5;
|
||||
var shColor = H.rgba(crystalHues[Math.floor(rng() * crystalHues.length)], pulse + 0.2);
|
||||
ctx.fillStyle = shColor;
|
||||
ctx.save();
|
||||
ctx.translate(clx + (rng() - 0.5) * 10, cly);
|
||||
ctx.rotate(shAngle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-shW / 2, 0); ctx.lineTo(0, -shLen); ctx.lineTo(shW / 2, 0);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
// Cave floor with stalagmites
|
||||
Draw.hills(ctx, w, h, h * 0.9, 15, H.darken(c[1], 0.4), 702);
|
||||
for (var sg = 0; sg < 6; sg++) {
|
||||
var sgx = rng() * w, sgH = h * 0.05 + rng() * h * 0.1;
|
||||
ctx.fillStyle = H.darken(c[2], 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sgx - 5, h * 0.9); ctx.lineTo(sgx, h * 0.9 - sgH); ctx.lineTo(sgx + 5, h * 0.9);
|
||||
ctx.fill();
|
||||
}
|
||||
// Water reflections on cave floor
|
||||
var refAlpha = 0.06 + Math.sin(t * 0.5) * 0.03;
|
||||
Draw.water(ctx, w, h, h * 0.92, H.rgba(c[3], refAlpha), 2, 703);
|
||||
},
|
||||
particles: { type: 'sparkles', count: 40, color: '#44DDFF' }
|
||||
},
|
||||
|
||||
temple: {
|
||||
meta: { id: 'fantasy_temple', mood: 'sacred', colors: ['#1a1025', '#2d1a3a', '#c9b77a', '#ffd700'], tags: ['temple', 'sacred', 'ancient', 'pillars'], description: 'An ancient temple with towering marble pillars and sacred golden light', compatibleMusicMoods: ['sacred', 'epic', 'mystical'], recommendedFor: ['rpg', 'story', 'trivia'] },
|
||||
colors: ['#1a1025', '#2d1a3a', '#c9b77a', '#ffd700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.lighten(c[1], 0.08)], 90);
|
||||
// Sacred light from above
|
||||
var beamAlpha = 0.05 + Math.sin(t * 0.3) * 0.02;
|
||||
ctx.fillStyle = H.rgba(c[3], beamAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.4, 0); ctx.lineTo(w * 0.3, h); ctx.lineTo(w * 0.7, h); ctx.lineTo(w * 0.6, 0);
|
||||
ctx.fill();
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Floor tiles
|
||||
var tileColor1 = H.darken(c[2], 0.5), tileColor2 = H.darken(c[2], 0.55);
|
||||
for (var ty = 0; ty < 5; ty++) {
|
||||
for (var tx = 0; tx < 10; tx++) {
|
||||
var color = (tx + ty) % 2 === 0 ? tileColor1 : tileColor2;
|
||||
Draw.rect(ctx, tx * w * 0.1, h * 0.85 + ty * h * 0.03, w * 0.1, h * 0.03, color);
|
||||
}
|
||||
}
|
||||
// Pillars
|
||||
var pillarColor = H.lighten(c[2], 0.1);
|
||||
for (var p = 0; p < 6; p++) {
|
||||
var px = w * 0.08 + p * w * 0.18;
|
||||
var pw = 18, ph = h * 0.6;
|
||||
// Base
|
||||
Draw.rect(ctx, px - pw, h * 0.83, pw * 2, h * 0.04, pillarColor);
|
||||
// Shaft
|
||||
Draw.rect(ctx, px - pw * 0.6, h * 0.25, pw * 1.2, ph, pillarColor);
|
||||
// Fluting effect
|
||||
for (var f = 0; f < 3; f++) {
|
||||
Draw.rect(ctx, px - pw * 0.5 + f * pw * 0.4, h * 0.25, 2, ph, H.darken(pillarColor, 0.15));
|
||||
}
|
||||
// Capital
|
||||
Draw.rect(ctx, px - pw, h * 0.22, pw * 2, h * 0.04, pillarColor);
|
||||
Draw.rect(ctx, px - pw * 1.2, h * 0.21, pw * 2.4, h * 0.02, pillarColor);
|
||||
}
|
||||
// Altar
|
||||
Draw.rect(ctx, w * 0.4, h * 0.65, w * 0.2, h * 0.2, H.darken(c[2], 0.3));
|
||||
Draw.rect(ctx, w * 0.38, h * 0.63, w * 0.24, h * 0.04, c[2]);
|
||||
// Sacred flame on altar
|
||||
var flameSize = 12 + Math.sin(t * 3) * 4;
|
||||
Draw.glow(ctx, w * 0.5, h * 0.58, flameSize * 3, H.rgba(c[3], 0.3 + Math.sin(t * 2) * 0.1));
|
||||
Draw.circle(ctx, w * 0.5, h * 0.58, flameSize, H.rgba(c[3], 0.7 + Math.sin(t * 4) * 0.2));
|
||||
// Runes on floor
|
||||
var rng = H.seededRandom(800);
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.1 + Math.sin(t * 0.5) * 0.05);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w * 0.5, h * 0.9, w * 0.15, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
for (var rn = 0; rn < 8; rn++) {
|
||||
var angle = (rn / 8) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.5, h * 0.9);
|
||||
ctx.lineTo(w * 0.5 + Math.cos(angle) * w * 0.15, h * 0.9 + Math.sin(angle) * w * 0.15);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 25, color: '#FFD700' }
|
||||
},
|
||||
|
||||
tower: {
|
||||
meta: { id: 'fantasy_tower', mood: 'arcane', colors: ['#0a0a20', '#151540', '#554488', '#aa66ff'], tags: ['tower', 'wizard', 'arcane', 'night'], description: 'A tall wizard tower under a starlit sky with arcane energy crackling at its peak', compatibleMusicMoods: ['mystical', 'dark', 'arcane'], recommendedFor: ['rpg', 'puzzle', 'trivia'] },
|
||||
colors: ['#0a0a20', '#151540', '#554488', '#aa66ff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.stars(ctx, w, h * 0.6, 100, '#FFFFFF', 901, [0.5, 2.5]);
|
||||
// Nebula glow
|
||||
Draw.glow(ctx, w * 0.3, h * 0.2, 150, H.rgba(c[2], 0.08));
|
||||
Draw.glow(ctx, w * 0.7, h * 0.15, 100, H.rgba(c[3], 0.05));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Rolling hills
|
||||
Draw.hills(ctx, w, h, h * 0.8, 8, H.darken(c[2], 0.6), 902);
|
||||
// Tower structure
|
||||
var towerX = w * 0.5, towerW = w * 0.08;
|
||||
var towerBot = h * 0.85, towerTop = h * 0.15;
|
||||
// Main shaft
|
||||
Draw.rect(ctx, towerX - towerW / 2, towerTop, towerW, towerBot - towerTop, H.darken(c[2], 0.4));
|
||||
// Stone texture lines
|
||||
for (var sl = 0; sl < 15; sl++) {
|
||||
var sly = towerTop + sl * (towerBot - towerTop) / 15;
|
||||
Draw.rect(ctx, towerX - towerW / 2, sly, towerW, 1, H.rgba('#000', 0.15));
|
||||
}
|
||||
// Wider base
|
||||
ctx.fillStyle = H.darken(c[2], 0.45);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(towerX - towerW * 0.8, towerBot);
|
||||
ctx.lineTo(towerX - towerW / 2, towerBot - h * 0.1);
|
||||
ctx.lineTo(towerX + towerW / 2, towerBot - h * 0.1);
|
||||
ctx.lineTo(towerX + towerW * 0.8, towerBot);
|
||||
ctx.fill();
|
||||
// Pointed roof
|
||||
ctx.fillStyle = H.darken(c[2], 0.2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(towerX - towerW * 0.7, towerTop);
|
||||
ctx.lineTo(towerX, towerTop - h * 0.08);
|
||||
ctx.lineTo(towerX + towerW * 0.7, towerTop);
|
||||
ctx.fill();
|
||||
// Windows with glow
|
||||
var rng = H.seededRandom(903);
|
||||
for (var wi = 0; wi < 5; wi++) {
|
||||
var wy = towerTop + h * 0.05 + wi * (towerBot - towerTop - h * 0.1) / 5;
|
||||
var wGlow = 0.4 + Math.sin(t * 1.2 + wi * 1.5) * 0.2;
|
||||
Draw.glow(ctx, towerX, wy, 15, H.rgba(c[3], wGlow * 0.3));
|
||||
Draw.rect(ctx, towerX - 4, wy - 6, 8, 12, H.rgba(c[3], wGlow));
|
||||
ctx.strokeStyle = H.darken(c[2], 0.3);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(towerX, wy - 6, 4, Math.PI, 0); ctx.stroke();
|
||||
}
|
||||
// Arcane energy at top
|
||||
var arcAngle = t * 1.5;
|
||||
var arcPulse = 0.4 + Math.sin(t * 2) * 0.2;
|
||||
Draw.glow(ctx, towerX, towerTop - h * 0.08, 30 + Math.sin(t) * 10, H.rgba(c[3], arcPulse * 0.5));
|
||||
for (var arc = 0; arc < 5; arc++) {
|
||||
var aa = arcAngle + (arc / 5) * Math.PI * 2;
|
||||
var ar = 15 + Math.sin(t * 3 + arc) * 5;
|
||||
var ax = towerX + Math.cos(aa) * ar;
|
||||
var ay = towerTop - h * 0.08 + Math.sin(aa) * ar * 0.5;
|
||||
Draw.circle(ctx, ax, ay, 2, H.rgba(c[3], arcPulse));
|
||||
}
|
||||
// Lightning bolt effect (intermittent)
|
||||
if (Math.sin(t * 0.7) > 0.9) {
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.6);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(towerX, towerTop - h * 0.08);
|
||||
var lx = towerX, ly = towerTop - h * 0.08;
|
||||
for (var bolt = 0; bolt < 4; bolt++) {
|
||||
lx += (rng() - 0.5) * 30;
|
||||
ly -= 15 + rng() * 10;
|
||||
ctx.lineTo(lx, ly);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 30, color: '#AA66FF' }
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,479 @@
|
||||
// indoor.js — Indoor environment background themes
|
||||
// Variants: laboratory, classroom, bedroom, kitchen, arcade, office, library, gym
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('indoor', {
|
||||
|
||||
laboratory: {
|
||||
meta: { id: 'indoor_laboratory', mood: 'mysterious', colors: ['#1a1e2e', '#2a3040', '#0f6b5e', '#44eebb'], tags: ['science', 'lab', 'tech', 'glow'], description: 'Dark science lab with glowing equipment and bubbling vials', compatibleMusicMoods: ['mysterious', 'tense', 'ambient'], recommendedFor: ['puzzle', 'trivia', 'rpg'] },
|
||||
colors: ['#1a1e2e', '#2a3040', '#0f6b5e', '#44eebb'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.75, w, h * 0.25, H.darken(c[0], 0.3));
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.15);
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < w; i += 60) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, h); ctx.stroke(); }
|
||||
for (var j = 0; j < h; j += 60) { ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(w, j); ctx.stroke(); }
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(101);
|
||||
// Lab benches
|
||||
Draw.rect(ctx, w * 0.05, h * 0.72, w * 0.4, h * 0.04, '#3a3a4a');
|
||||
Draw.rect(ctx, w * 0.55, h * 0.72, w * 0.4, h * 0.04, '#3a3a4a');
|
||||
// Beakers and vials
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var bx = w * 0.08 + i * w * 0.07;
|
||||
var bh = 20 + rng() * 25;
|
||||
var glow = 0.4 + Math.sin(t * (1.5 + rng()) + i) * 0.3;
|
||||
Draw.rect(ctx, bx, h * 0.72 - bh, 12, bh, H.rgba(c[3], glow));
|
||||
Draw.rect(ctx, bx - 2, h * 0.72 - bh, 16, 3, H.rgba('#FFFFFF', 0.2));
|
||||
}
|
||||
// Glowing tanks
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var tx = w * 0.58 + j * w * 0.13;
|
||||
var pulse = 0.15 + Math.sin(t * 0.8 + j * 2) * 0.1;
|
||||
Draw.rect(ctx, tx, h * 0.45, 30, h * 0.27, H.rgba(c[2], pulse));
|
||||
Draw.rect(ctx, tx - 2, h * 0.44, 34, 4, '#555566');
|
||||
Draw.rect(ctx, tx - 2, h * 0.72, 34, 4, '#555566');
|
||||
Draw.glow(ctx, tx + 15, h * 0.58, 35, c[3]);
|
||||
}
|
||||
// Ceiling pipes
|
||||
for (var p = 0; p < 4; p++) {
|
||||
var py = h * 0.05 + p * 18;
|
||||
Draw.rect(ctx, 0, py, w, 6, H.rgba('#556677', 0.3));
|
||||
}
|
||||
// Blinking lights on wall
|
||||
for (var k = 0; k < 8; k++) {
|
||||
var lx = w * 0.1 + rng() * w * 0.8;
|
||||
var ly = h * 0.1 + rng() * h * 0.3;
|
||||
var on = Math.sin(t * (2 + rng() * 3) + k * 1.7) > 0.3;
|
||||
Draw.circle(ctx, lx, ly, 3, on ? c[3] : H.rgba(c[2], 0.2));
|
||||
if (on) Draw.glow(ctx, lx, ly, 12, c[3]);
|
||||
}
|
||||
},
|
||||
particles: { type: 'bubbles', count: 15, color: '#44eebb' }
|
||||
},
|
||||
|
||||
classroom: {
|
||||
meta: { id: 'indoor_classroom', mood: 'cheerful', colors: ['#f5f0e0', '#d4c9a8', '#5b8c5a', '#8b5e3c'], tags: ['school', 'learning', 'education', 'cozy'], description: 'Bright classroom with chalkboard, desks, and warm sunlight', compatibleMusicMoods: ['cheerful', 'calm', 'upbeat'], recommendedFor: ['trivia', 'puzzle', 'story'] },
|
||||
colors: ['#f5f0e0', '#d4c9a8', '#5b8c5a', '#8b5e3c'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.darken(c[0], 0.05)], 90);
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, c[1]);
|
||||
// Baseboard
|
||||
Draw.rect(ctx, 0, h * 0.76, w, h * 0.03, H.darken(c[3], 0.2));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(202);
|
||||
// Chalkboard
|
||||
Draw.rect(ctx, w * 0.15, h * 0.15, w * 0.7, h * 0.35, '#2a4a2a');
|
||||
Draw.rect(ctx, w * 0.14, h * 0.14, w * 0.72, h * 0.37, 'transparent');
|
||||
ctx.strokeStyle = c[3]; ctx.lineWidth = 4;
|
||||
ctx.strokeRect(w * 0.14, h * 0.14, w * 0.72, h * 0.37);
|
||||
// Chalk text squiggles
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.3); ctx.lineWidth = 1;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
ctx.beginPath();
|
||||
var ly = h * 0.22 + i * h * 0.06;
|
||||
ctx.moveTo(w * 0.2, ly);
|
||||
for (var x = w * 0.2; x < w * 0.75; x += 8) {
|
||||
ctx.lineTo(x, ly + (rng() - 0.5) * 3);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Desks
|
||||
for (var d = 0; d < 3; d++) {
|
||||
var dy = h * 0.6 + d * h * 0.08;
|
||||
for (var dx = 0; dx < 4; dx++) {
|
||||
var xx = w * 0.12 + dx * w * 0.22;
|
||||
Draw.rect(ctx, xx, dy, w * 0.16, h * 0.025, c[3]);
|
||||
Draw.rect(ctx, xx + 5, dy + h * 0.025, 4, h * 0.04, H.darken(c[3], 0.3));
|
||||
Draw.rect(ctx, xx + w * 0.14, dy + h * 0.025, 4, h * 0.04, H.darken(c[3], 0.3));
|
||||
}
|
||||
}
|
||||
// Window with sunlight
|
||||
Draw.rect(ctx, w * 0.02, h * 0.15, w * 0.08, h * 0.35, '#88bbdd');
|
||||
ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 3;
|
||||
ctx.strokeRect(w * 0.02, h * 0.15, w * 0.08, h * 0.35);
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.06, h * 0.15); ctx.lineTo(w * 0.06, h * 0.5); ctx.stroke();
|
||||
var sunAlpha = 0.05 + Math.sin(t * 0.3) * 0.02;
|
||||
ctx.fillStyle = H.rgba('#FFE488', sunAlpha);
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.1, h * 0.15); ctx.lineTo(w * 0.35, h * 0.78);
|
||||
ctx.lineTo(w * 0.1, h * 0.78); ctx.closePath(); ctx.fill();
|
||||
// Clock
|
||||
var cx = w * 0.9, cy = h * 0.12;
|
||||
Draw.circle(ctx, cx, cy, 18, '#FFFFFF');
|
||||
ctx.strokeStyle = '#333'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, 18, 0, Math.PI * 2); ctx.stroke();
|
||||
var angle = t * 0.1;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(angle) * 12, cy + Math.sin(angle) * 12); ctx.stroke();
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#DDC888' }
|
||||
},
|
||||
|
||||
bedroom: {
|
||||
meta: { id: 'indoor_bedroom', mood: 'cozy', colors: ['#2a1f3d', '#3d2b5a', '#e8a87c', '#ffd166'], tags: ['cozy', 'night', 'home', 'warm'], description: 'Cozy bedroom at night with warm lamp glow and string lights', compatibleMusicMoods: ['calm', 'dreamy', 'ambient'], recommendedFor: ['story', 'creative', 'pet'] },
|
||||
colors: ['#2a1f3d', '#3d2b5a', '#e8a87c', '#ffd166'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.8, w, h * 0.2, H.darken(c[1], 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(303);
|
||||
// Window with moonlight
|
||||
Draw.rect(ctx, w * 0.6, h * 0.1, w * 0.2, h * 0.3, '#1a1a3e');
|
||||
Draw.circle(ctx, w * 0.72, h * 0.18, 15, '#dde8ff');
|
||||
Draw.glow(ctx, w * 0.72, h * 0.18, 40, '#aabbee');
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.3); ctx.lineWidth = 3;
|
||||
ctx.strokeRect(w * 0.6, h * 0.1, w * 0.2, h * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.7, h * 0.1); ctx.lineTo(w * 0.7, h * 0.4); ctx.stroke();
|
||||
// Bed
|
||||
Draw.rect(ctx, w * 0.05, h * 0.55, w * 0.45, h * 0.25, '#6b4f7a');
|
||||
Draw.rect(ctx, w * 0.05, h * 0.55, w * 0.12, h * 0.22, '#7a5f8a');
|
||||
Draw.rect(ctx, w * 0.05, h * 0.5, w * 0.45, h * 0.06, H.darken('#6b4f7a', 0.3));
|
||||
// Lamp with warm glow
|
||||
var lampX = w * 0.88, lampY = h * 0.45;
|
||||
Draw.rect(ctx, lampX - 3, lampY, 6, h * 0.35, '#666');
|
||||
Draw.polygon(ctx, lampX, lampY - 5, 18, 3, c[2], -Math.PI / 2);
|
||||
var lampGlow = 0.2 + Math.sin(t * 0.5) * 0.05;
|
||||
Draw.glow(ctx, lampX, lampY - 10, 80, c[3]);
|
||||
ctx.fillStyle = H.rgba(c[3], lampGlow);
|
||||
ctx.beginPath(); ctx.moveTo(lampX - 40, lampY); ctx.lineTo(lampX + 40, lampY);
|
||||
ctx.lineTo(lampX + 80, h * 0.8); ctx.lineTo(lampX - 80, h * 0.8); ctx.closePath(); ctx.fill();
|
||||
// String lights across top
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.1); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.05);
|
||||
for (var x = 0; x < w; x += 5) { ctx.lineTo(x, h * 0.05 + Math.sin(x * 0.02) * 10); }
|
||||
ctx.stroke();
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var lx = w * 0.05 + i * w * 0.08;
|
||||
var ly = h * 0.05 + Math.sin(lx * 0.02) * 10;
|
||||
var flicker = 0.5 + Math.sin(t * 2 + i * 1.3) * 0.3;
|
||||
var lc = H.pick(['#ff6b6b', '#ffd166', '#06d6a0', '#118ab2', '#ef476f']);
|
||||
Draw.circle(ctx, lx, ly + 5, 4, H.rgba(lc, flicker));
|
||||
Draw.glow(ctx, lx, ly + 5, 12, lc);
|
||||
}
|
||||
// Bookshelf
|
||||
Draw.rect(ctx, w * 0.55, h * 0.5, w * 0.15, h * 0.3, '#5a3e2b');
|
||||
for (var s = 0; s < 3; s++) {
|
||||
Draw.rect(ctx, w * 0.55, h * 0.5 + s * h * 0.1, w * 0.15, 3, '#4a2e1b');
|
||||
for (var b = 0; b < 4; b++) {
|
||||
var bw = 6 + rng() * 8;
|
||||
var bc = H.pick(['#cc4444', '#4477bb', '#44aa66', '#ddaa33', '#8855aa']);
|
||||
Draw.rect(ctx, w * 0.56 + b * (bw + 3), h * 0.5 + s * h * 0.1 + 4, bw, h * 0.09, bc);
|
||||
}
|
||||
}
|
||||
},
|
||||
particles: { type: 'fireflies', count: 8, color: '#ffd166' }
|
||||
},
|
||||
|
||||
kitchen: {
|
||||
meta: { id: 'indoor_kitchen', mood: 'warm', colors: ['#fff8e7', '#f0d9a0', '#c97b3a', '#8b4513'], tags: ['cooking', 'home', 'warm', 'food'], description: 'Bright kitchen with checkered floor, cabinets, and warm appliance glow', compatibleMusicMoods: ['cheerful', 'upbeat', 'calm'], recommendedFor: ['creative', 'pet', 'puzzle'] },
|
||||
colors: ['#fff8e7', '#f0d9a0', '#c97b3a', '#8b4513'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.darken(c[0], 0.05)], 90);
|
||||
// Checkered floor
|
||||
var tileSize = 30;
|
||||
for (var ty = h * 0.75; ty < h; ty += tileSize) {
|
||||
for (var tx = 0; tx < w; tx += tileSize) {
|
||||
var checker = ((Math.floor(tx / tileSize) + Math.floor(ty / tileSize)) % 2 === 0);
|
||||
Draw.rect(ctx, tx, ty, tileSize, tileSize, checker ? '#e8e0d0' : '#c8b8a0');
|
||||
}
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(404);
|
||||
// Upper cabinets
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var cx = w * 0.05 + i * w * 0.24;
|
||||
Draw.rect(ctx, cx, h * 0.08, w * 0.2, h * 0.2, c[2]);
|
||||
Draw.rect(ctx, cx + 3, h * 0.1, w * 0.2 - 6, h * 0.16, H.lighten(c[2], 0.15));
|
||||
Draw.circle(ctx, cx + w * 0.1, h * 0.18, 3, '#brass');
|
||||
Draw.circle(ctx, cx + w * 0.1, h * 0.18, 3, '#aa8844');
|
||||
}
|
||||
// Counter
|
||||
Draw.rect(ctx, 0, h * 0.42, w, h * 0.04, '#ddd0bb');
|
||||
Draw.rect(ctx, 0, h * 0.46, w, h * 0.29, c[3]);
|
||||
// Stove
|
||||
var sx = w * 0.35;
|
||||
Draw.rect(ctx, sx, h * 0.32, w * 0.2, h * 0.1, '#444');
|
||||
for (var b = 0; b < 4; b++) {
|
||||
var bx = sx + w * 0.025 + b * w * 0.045;
|
||||
var burnerGlow = Math.sin(t * 1.5 + b * 0.8) > 0.5 ? 0.4 : 0;
|
||||
Draw.circle(ctx, bx, h * 0.37, 8, H.rgba('#ff4400', burnerGlow));
|
||||
Draw.circle(ctx, bx, h * 0.37, 12, '#555');
|
||||
ctx.strokeStyle = '#666'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(bx, h * 0.37, 10, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// Fridge
|
||||
Draw.rect(ctx, w * 0.82, h * 0.15, w * 0.15, h * 0.6, '#d8d8d8');
|
||||
Draw.rect(ctx, w * 0.83, h * 0.16, w * 0.13, h * 0.3, '#e8e8e8');
|
||||
Draw.rect(ctx, w * 0.83, h * 0.48, w * 0.13, h * 0.25, '#e8e8e8');
|
||||
Draw.rect(ctx, w * 0.95, h * 0.28, 3, 20, '#999');
|
||||
Draw.rect(ctx, w * 0.95, h * 0.58, 3, 20, '#999');
|
||||
// Sink
|
||||
Draw.rect(ctx, w * 0.1, h * 0.35, w * 0.15, h * 0.07, '#bbb');
|
||||
Draw.rect(ctx, w * 0.15, h * 0.3, 4, h * 0.05, '#999');
|
||||
// Hanging pots
|
||||
for (var p = 0; p < 3; p++) {
|
||||
var px = w * 0.62 + p * w * 0.06;
|
||||
var sway = Math.sin(t * 0.3 + p) * 2;
|
||||
ctx.strokeStyle = '#777'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(px, h * 0.08); ctx.lineTo(px + sway, h * 0.14); ctx.stroke();
|
||||
Draw.circle(ctx, px + sway, h * 0.16, 10 + p * 2, H.pick(['#884422', '#666', '#aa6633']));
|
||||
}
|
||||
// Window light
|
||||
Draw.rect(ctx, w * 0.03, h * 0.08, w * 0.12, h * 0.18, '#aaddff');
|
||||
ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 3;
|
||||
ctx.strokeRect(w * 0.03, h * 0.08, w * 0.12, h * 0.18);
|
||||
},
|
||||
particles: { type: 'dust', count: 12, color: '#EECC88' }
|
||||
},
|
||||
|
||||
arcade: {
|
||||
meta: { id: 'indoor_arcade', mood: 'energetic', colors: ['#0d0d1a', '#1a0a2e', '#ff00ff', '#00ffff'], tags: ['neon', 'retro', 'gaming', 'dark'], description: 'Dark arcade with neon-lit cabinets, glowing screens, and retro vibes', compatibleMusicMoods: ['energetic', 'retro', 'electronic'], recommendedFor: ['action', 'racing', 'sports'] },
|
||||
colors: ['#0d0d1a', '#1a0a2e', '#ff00ff', '#00ffff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Dark reflective floor
|
||||
Draw.rect(ctx, 0, h * 0.8, w, h * 0.2, '#0a0a15');
|
||||
Draw.grid(ctx, w, h * 0.2, 40, H.rgba(c[2], 0.05), 1);
|
||||
// Neon strip on ceiling
|
||||
var neonPulse = 0.3 + Math.sin(t * 1.2) * 0.15;
|
||||
Draw.rect(ctx, 0, 0, w, 4, H.rgba(c[2], neonPulse));
|
||||
Draw.rect(ctx, 0, h * 0.8, w, 2, H.rgba(c[3], neonPulse * 0.6));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(505);
|
||||
// Arcade cabinets
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var ax = w * 0.05 + i * w * 0.155;
|
||||
var cabColor = H.pick(['#222233', '#1a1a2e', '#2a1a2e']);
|
||||
Draw.rect(ctx, ax, h * 0.3, w * 0.1, h * 0.5, cabColor);
|
||||
// Screen glow
|
||||
var screenColor = H.pick([c[2], c[3], '#44ff44', '#ffaa00']);
|
||||
var flicker = 0.5 + Math.sin(t * (3 + rng()) + i * 2) * 0.2;
|
||||
Draw.rect(ctx, ax + 5, h * 0.33, w * 0.1 - 10, h * 0.18, H.rgba(screenColor, flicker));
|
||||
Draw.glow(ctx, ax + w * 0.05, h * 0.42, 40, screenColor);
|
||||
// Control panel
|
||||
Draw.rect(ctx, ax + 3, h * 0.55, w * 0.1 - 6, h * 0.08, '#333');
|
||||
// Joystick
|
||||
Draw.circle(ctx, ax + w * 0.03, h * 0.59, 4, '#888');
|
||||
// Buttons
|
||||
for (var b = 0; b < 3; b++) {
|
||||
Draw.circle(ctx, ax + w * 0.06 + b * 10, h * 0.59, 3, H.pick(['#ff3333', '#33ff33', '#3333ff']));
|
||||
}
|
||||
}
|
||||
// Neon signs on wall
|
||||
ctx.font = 'bold 16px monospace';
|
||||
var signGlow = 0.6 + Math.sin(t * 2) * 0.3;
|
||||
ctx.fillStyle = H.rgba(c[2], signGlow);
|
||||
ctx.shadowColor = c[2]; ctx.shadowBlur = 15;
|
||||
ctx.fillText('PLAY', w * 0.15, h * 0.15);
|
||||
ctx.fillStyle = H.rgba(c[3], signGlow);
|
||||
ctx.shadowColor = c[3];
|
||||
ctx.fillText('HIGH SCORE', w * 0.55, h * 0.15);
|
||||
ctx.shadowBlur = 0;
|
||||
// Floor reflections
|
||||
for (var r = 0; r < 6; r++) {
|
||||
var rx = w * 0.05 + r * w * 0.155 + w * 0.05;
|
||||
var refColor = H.pick([c[2], c[3], '#44ff44']);
|
||||
Draw.glow(ctx, rx, h * 0.85, 25, refColor);
|
||||
}
|
||||
// Scanlines
|
||||
Draw.scanlines(ctx, w, h, 3, H.rgba('#000000', 0.08));
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#ff00ff' }
|
||||
},
|
||||
|
||||
office: {
|
||||
meta: { id: 'indoor_office', mood: 'focused', colors: ['#e8e4dc', '#c5bfb2', '#4a6fa5', '#2c3e50'], tags: ['work', 'modern', 'clean', 'professional'], description: 'Modern office with monitor glow, clean desks, and large windows', compatibleMusicMoods: ['calm', 'focused', 'ambient'], recommendedFor: ['puzzle', 'trivia', 'creative'] },
|
||||
colors: ['#e8e4dc', '#c5bfb2', '#4a6fa5', '#2c3e50'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.darken(c[0], 0.08)], 90);
|
||||
Draw.rect(ctx, 0, h * 0.8, w, h * 0.2, '#b0a898');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(606);
|
||||
// Large windows
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var wx = w * 0.05 + i * w * 0.32;
|
||||
Draw.rect(ctx, wx, h * 0.05, w * 0.25, h * 0.4, '#aaccee');
|
||||
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 4;
|
||||
ctx.strokeRect(wx, h * 0.05, w * 0.25, h * 0.4);
|
||||
ctx.beginPath(); ctx.moveTo(wx + w * 0.125, h * 0.05); ctx.lineTo(wx + w * 0.125, h * 0.45); ctx.stroke();
|
||||
// Sky through window
|
||||
Draw.circle(ctx, wx + w * 0.18, h * 0.15, 8, '#fff8dd');
|
||||
}
|
||||
// Desks with monitors
|
||||
for (var d = 0; d < 3; d++) {
|
||||
var dx = w * 0.08 + d * w * 0.32;
|
||||
// Desk
|
||||
Draw.rect(ctx, dx, h * 0.6, w * 0.22, h * 0.03, '#8b7355');
|
||||
Draw.rect(ctx, dx + 5, h * 0.63, 4, h * 0.17, '#7a6245');
|
||||
Draw.rect(ctx, dx + w * 0.19, h * 0.63, 4, h * 0.17, '#7a6245');
|
||||
// Monitor
|
||||
var monX = dx + w * 0.05;
|
||||
Draw.rect(ctx, monX, h * 0.48, w * 0.12, h * 0.1, '#222');
|
||||
var screenGlow = 0.6 + Math.sin(t * 0.5 + d) * 0.15;
|
||||
Draw.rect(ctx, monX + 3, h * 0.49, w * 0.12 - 6, h * 0.08, H.rgba(c[2], screenGlow));
|
||||
Draw.rect(ctx, monX + w * 0.055, h * 0.58, 6, h * 0.02, '#333');
|
||||
Draw.rect(ctx, monX + w * 0.035, h * 0.598, w * 0.05, 3, '#333');
|
||||
// Keyboard
|
||||
Draw.rect(ctx, dx + w * 0.04, h * 0.585, w * 0.08, h * 0.012, '#444');
|
||||
}
|
||||
// Ceiling lights
|
||||
for (var l = 0; l < 4; l++) {
|
||||
var lx = w * 0.12 + l * w * 0.23;
|
||||
Draw.rect(ctx, lx, 0, w * 0.12, 6, '#eee');
|
||||
var lightFlicker = 0.12 + Math.sin(t * 0.3 + l * 0.5) * 0.03;
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', lightFlicker);
|
||||
ctx.beginPath(); ctx.moveTo(lx, 6); ctx.lineTo(lx + w * 0.12, 6);
|
||||
ctx.lineTo(lx + w * 0.18, h * 0.5); ctx.lineTo(lx - w * 0.06, h * 0.5); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Plant in corner
|
||||
Draw.rect(ctx, w * 0.92, h * 0.6, 14, h * 0.2, '#8b6914');
|
||||
Draw.circle(ctx, w * 0.928, h * 0.55, 20, '#3a7a3a');
|
||||
Draw.circle(ctx, w * 0.94, h * 0.5, 14, '#4a8a4a');
|
||||
Draw.circle(ctx, w * 0.916, h * 0.48, 12, '#3a7a3a');
|
||||
},
|
||||
particles: { type: 'dust', count: 10, color: '#DDDDCC' }
|
||||
},
|
||||
|
||||
library: {
|
||||
meta: { id: 'indoor_library', mood: 'serene', colors: ['#2c1810', '#4a3020', '#c4956a', '#f4e4c1'], tags: ['books', 'quiet', 'knowledge', 'wood'], description: 'Grand library with towering bookshelves, warm wood, and reading lamps', compatibleMusicMoods: ['calm', 'serene', 'classical'], recommendedFor: ['story', 'trivia', 'puzzle', 'rpg'] },
|
||||
colors: ['#2c1810', '#4a3020', '#c4956a', '#f4e4c1'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [H.darken(c[0], 0.2), c[0]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, H.darken(c[1], 0.3));
|
||||
// Wooden floor planks
|
||||
for (var p = 0; p < 12; p++) {
|
||||
var px = p * (w / 12);
|
||||
ctx.strokeStyle = H.rgba('#000', 0.1);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(px, h * 0.85); ctx.lineTo(px, h); ctx.stroke();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(707);
|
||||
var bookColors = ['#8b2500', '#2e5090', '#1a6030', '#8b6914', '#6a1b6a', '#cc5533', '#336677'];
|
||||
// Tall bookshelves on sides
|
||||
for (var side = 0; side < 2; side++) {
|
||||
var sx = side === 0 ? 0 : w * 0.78;
|
||||
var sw = w * 0.22;
|
||||
Draw.rect(ctx, sx, h * 0.02, sw, h * 0.83, c[1]);
|
||||
// Shelves
|
||||
for (var sh = 0; sh < 7; sh++) {
|
||||
var sy = h * 0.05 + sh * h * 0.115;
|
||||
Draw.rect(ctx, sx, sy + h * 0.1, sw, 4, H.darken(c[1], 0.2));
|
||||
// Books
|
||||
var bx = sx + 5;
|
||||
while (bx < sx + sw - 8) {
|
||||
var bw = 5 + rng() * 10;
|
||||
var bh = h * 0.08 + rng() * h * 0.02;
|
||||
var bc = bookColors[Math.floor(rng() * bookColors.length)];
|
||||
Draw.rect(ctx, bx, sy + h * 0.1 - bh, bw, bh, bc);
|
||||
bx += bw + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Center reading area
|
||||
Draw.rect(ctx, w * 0.35, h * 0.65, w * 0.3, h * 0.04, c[2]);
|
||||
Draw.rect(ctx, w * 0.4, h * 0.69, 5, h * 0.16, H.darken(c[2], 0.3));
|
||||
Draw.rect(ctx, w * 0.58, h * 0.69, 5, h * 0.16, H.darken(c[2], 0.3));
|
||||
// Open book on table
|
||||
Draw.rect(ctx, w * 0.44, h * 0.62, w * 0.06, h * 0.03, c[3]);
|
||||
Draw.rect(ctx, w * 0.5, h * 0.62, w * 0.06, h * 0.03, H.darken(c[3], 0.05));
|
||||
// Reading lamps
|
||||
for (var l = 0; l < 2; l++) {
|
||||
var lx = w * 0.38 + l * w * 0.24;
|
||||
Draw.rect(ctx, lx, h * 0.5, 3, h * 0.15, '#886644');
|
||||
var lampGlow = 0.25 + Math.sin(t * 0.4 + l * Math.PI) * 0.08;
|
||||
Draw.polygon(ctx, lx + 1, h * 0.49, 14, 3, '#aa8855', -Math.PI / 2);
|
||||
Draw.glow(ctx, lx + 1, h * 0.47, 50, '#ffcc66');
|
||||
ctx.fillStyle = H.rgba('#ffdd88', lampGlow);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx - 18, h * 0.5); ctx.lineTo(lx + 20, h * 0.5);
|
||||
ctx.lineTo(lx + 40, h * 0.85); ctx.lineTo(lx - 38, h * 0.85);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Arched window in back center
|
||||
var awx = w * 0.4, awy = h * 0.08, aww = w * 0.2, awh = h * 0.35;
|
||||
Draw.rect(ctx, awx, awy + awh * 0.3, aww, awh * 0.7, '#334466');
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.beginPath(); ctx.arc(awx + aww / 2, awy + awh * 0.3, aww / 2, Math.PI, 0); ctx.fill();
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.arc(awx + aww / 2, awy + awh * 0.3, aww / 2, Math.PI, 0); ctx.stroke();
|
||||
ctx.strokeRect(awx, awy + awh * 0.3, aww, awh * 0.7);
|
||||
// Moonlight through window
|
||||
Draw.glow(ctx, awx + aww / 2, awy + awh * 0.3, 60, '#6688aa');
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#CCBB88' }
|
||||
},
|
||||
|
||||
gym: {
|
||||
meta: { id: 'indoor_gym', mood: 'energetic', colors: ['#1a1a24', '#2a2a3a', '#ff4444', '#ff8800'], tags: ['fitness', 'sports', 'energy', 'active'], description: 'Modern gym with equipment silhouettes, rubber floor, and bright overhead lights', compatibleMusicMoods: ['energetic', 'intense', 'upbeat'], recommendedFor: ['action', 'sports', 'racing'] },
|
||||
colors: ['#1a1a24', '#2a2a3a', '#ff4444', '#ff8800'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Rubber floor with subtle texture
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, '#222230');
|
||||
var rng = H.seededRandom(808);
|
||||
for (var i = 0; i < 40; i++) {
|
||||
Draw.circle(ctx, rng() * w, h * 0.78 + rng() * h * 0.22, 1, H.rgba('#333344', 0.5));
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(809);
|
||||
// Wall mirror
|
||||
Draw.rect(ctx, w * 0.1, h * 0.1, w * 0.8, h * 0.45, H.rgba('#445566', 0.15));
|
||||
ctx.strokeStyle = H.rgba('#666', 0.3); ctx.lineWidth = 3;
|
||||
ctx.strokeRect(w * 0.1, h * 0.1, w * 0.8, h * 0.45);
|
||||
// Dumbbells rack
|
||||
for (var d = 0; d < 6; d++) {
|
||||
var dx = w * 0.05 + d * w * 0.05;
|
||||
var dy = h * 0.7;
|
||||
Draw.rect(ctx, dx, dy, 8, 4, '#555');
|
||||
Draw.circle(ctx, dx - 2, dy + 2, 5, '#444');
|
||||
Draw.circle(ctx, dx + 10, dy + 2, 5, '#444');
|
||||
}
|
||||
// Bench press
|
||||
Draw.rect(ctx, w * 0.55, h * 0.65, w * 0.15, h * 0.02, '#444');
|
||||
Draw.rect(ctx, w * 0.57, h * 0.67, 4, h * 0.11, '#555');
|
||||
Draw.rect(ctx, w * 0.68, h * 0.67, 4, h * 0.11, '#555');
|
||||
// Barbell
|
||||
Draw.rect(ctx, w * 0.5, h * 0.6, w * 0.25, 3, '#888');
|
||||
Draw.rect(ctx, w * 0.5, h * 0.585, 8, 18, '#666');
|
||||
Draw.rect(ctx, w * 0.73, h * 0.585, 8, 18, '#666');
|
||||
// Treadmill
|
||||
Draw.rect(ctx, w * 0.8, h * 0.55, w * 0.12, h * 0.23, '#333');
|
||||
Draw.rect(ctx, w * 0.81, h * 0.63, w * 0.1, h * 0.13, '#222');
|
||||
var beltMove = (t * 2) % 1;
|
||||
for (var s = 0; s < 6; s++) {
|
||||
var sy = h * 0.63 + (s / 6 + beltMove / 6) * h * 0.13;
|
||||
if (sy < h * 0.76) Draw.rect(ctx, w * 0.81, sy, w * 0.1, 1, H.rgba('#444', 0.5));
|
||||
}
|
||||
// Overhead lights
|
||||
for (var l = 0; l < 5; l++) {
|
||||
var lx = w * 0.1 + l * w * 0.2;
|
||||
Draw.rect(ctx, lx, 0, w * 0.08, 8, '#eee');
|
||||
var intensity = 0.08 + Math.sin(t * 0.2 + l * 0.3) * 0.02;
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', intensity);
|
||||
ctx.beginPath(); ctx.moveTo(lx, 8); ctx.lineTo(lx + w * 0.08, 8);
|
||||
ctx.lineTo(lx + w * 0.14, h * 0.78); ctx.lineTo(lx - w * 0.06, h * 0.78);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Red accent stripe on wall
|
||||
var stripeGlow = 0.6 + Math.sin(t * 0.8) * 0.2;
|
||||
Draw.rect(ctx, 0, h * 0.55, w, 4, H.rgba(c[2], stripeGlow));
|
||||
// Motivational poster (abstract rectangle)
|
||||
Draw.rect(ctx, w * 0.38, h * 0.15, w * 0.12, h * 0.15, '#333');
|
||||
Draw.rect(ctx, w * 0.39, h * 0.16, w * 0.1, h * 0.13, c[2]);
|
||||
},
|
||||
particles: { type: 'dust', count: 8, color: '#887766' }
|
||||
}
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,545 @@
|
||||
// mechanical.js — Mechanical/industrial theme for procedural background system
|
||||
// 8 variants: gears, circuits, factory, robotFactory, conveyorBelts, pipes, steamPunk, techLab
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
// Helper: draw a gear shape with teeth
|
||||
function drawGear(ctx, cx, cy, innerR, outerR, teeth, rotation, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < teeth * 2; i++) {
|
||||
var angle = rotation + (i / (teeth * 2)) * Math.PI * 2;
|
||||
var r = i % 2 === 0 ? outerR : innerR;
|
||||
var x = cx + Math.cos(angle) * r;
|
||||
var y = cy + Math.sin(angle) * r;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Center hole
|
||||
ctx.fillStyle = H.darken(color, 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, innerR * 0.35, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Helper: draw a rivet
|
||||
function drawRivet(ctx, x, y, r, color) {
|
||||
var g = D.radialGradient(ctx, x - r * 0.3, y - r * 0.3, r, [[0, H.lighten(color, 0.4)], [0.6, color], [1, H.darken(color, 0.3)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Helper: draw a pipe segment
|
||||
function drawPipe(ctx, x1, y1, x2, y2, width, color) {
|
||||
var angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
var perpX = Math.sin(angle) * width / 2;
|
||||
var perpY = -Math.cos(angle) * width / 2;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 + perpX, y1 + perpY);
|
||||
ctx.lineTo(x2 + perpX, y2 + perpY);
|
||||
ctx.lineTo(x2 - perpX, y2 - perpY);
|
||||
ctx.lineTo(x1 - perpX, y1 - perpY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Highlight stripe
|
||||
ctx.fillStyle = H.rgba(H.lighten(color, 0.3), 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 + perpX * 0.6, y1 + perpY * 0.6);
|
||||
ctx.lineTo(x2 + perpX * 0.6, y2 + perpY * 0.6);
|
||||
ctx.lineTo(x2 + perpX * 0.2, y2 + perpY * 0.2);
|
||||
ctx.lineTo(x1 + perpX * 0.2, y1 + perpY * 0.2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Helper: draw a gauge
|
||||
function drawGauge(ctx, cx, cy, r, needleAngle, color, rimColor) {
|
||||
// Rim
|
||||
ctx.strokeStyle = rimColor;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Face
|
||||
ctx.fillStyle = H.darken(color, 0.6);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r - 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Tick marks
|
||||
ctx.strokeStyle = H.rgba(rimColor, 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var a = (i / 8) * Math.PI * 2 - Math.PI / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * (r * 0.7), cy + Math.sin(a) * (r * 0.7));
|
||||
ctx.lineTo(cx + Math.cos(a) * (r * 0.9), cy + Math.sin(a) * (r * 0.9));
|
||||
ctx.stroke();
|
||||
}
|
||||
// Needle
|
||||
ctx.strokeStyle = '#CC3333';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + Math.cos(needleAngle) * r * 0.75, cy + Math.sin(needleAngle) * r * 0.75);
|
||||
ctx.stroke();
|
||||
// Center dot
|
||||
D.circle(ctx, cx, cy, 3, rimColor);
|
||||
}
|
||||
|
||||
engine.registerTheme('mechanical', {
|
||||
|
||||
gears: {
|
||||
meta: { id: 'mechanical_gears', mood: 'focused', colors: ['#1A1A24', '#2A2A38', '#7A7A88', '#C08040'], tags: ['industrial', 'moving', 'metallic'], description: 'Interlocking gears turning against a dark steel backdrop', compatibleMusicMoods: ['ambient', 'electronic', 'tense'], recommendedFor: ['puzzle', 'physics', 'rpg'] },
|
||||
colors: ['#1A1A24', '#2A2A38', '#7A7A88', '#C08040'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
// Subtle metal texture via scanlines
|
||||
Draw.scanlines(ctx, w, h, 3, H.rgba(c[2], 0.03));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(100);
|
||||
// Draw interlocking gears at various sizes
|
||||
var gearData = [];
|
||||
for (var i = 0; i < 9; i++) {
|
||||
gearData.push({
|
||||
x: rng() * w, y: rng() * h,
|
||||
inner: 15 + rng() * 40, outer: 30 + rng() * 55,
|
||||
teeth: 8 + Math.floor(rng() * 10),
|
||||
speed: (rng() - 0.5) * 0.8, shade: rng()
|
||||
});
|
||||
}
|
||||
for (var i = 0; i < gearData.length; i++) {
|
||||
var g = gearData[i];
|
||||
var col = H.blendColor(c[2], c[3], g.shade * 0.5);
|
||||
drawGear(ctx, g.x, g.y, g.inner, g.outer, g.teeth, t * g.speed, col);
|
||||
}
|
||||
// Small accent gears in corners
|
||||
drawGear(ctx, w * 0.08, h * 0.08, 10, 18, 6, -t * 0.6, H.rgba(c[3], 0.6));
|
||||
drawGear(ctx, w * 0.92, h * 0.92, 12, 22, 7, t * 0.5, H.rgba(c[3], 0.6));
|
||||
// Rivets along edges
|
||||
for (var i = 0; i < 12; i++) {
|
||||
drawRivet(ctx, 15 + i * (w - 30) / 11, 12, 3, c[2]);
|
||||
drawRivet(ctx, 15 + i * (w - 30) / 11, h - 12, 3, c[2]);
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#998877' }
|
||||
},
|
||||
|
||||
circuits: {
|
||||
meta: { id: 'mechanical_circuits', mood: 'focused', colors: ['#0A0F14', '#112222', '#00CC88', '#33FFAA'], tags: ['digital', 'tech', 'grid'], description: 'Circuit board traces with glowing data paths', compatibleMusicMoods: ['electronic', 'ambient', 'tense'], recommendedFor: ['puzzle', 'trivia', 'physics'] },
|
||||
colors: ['#0A0F14', '#112222', '#00CC88', '#33FFAA'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.grid(ctx, w, h, 40, H.rgba(c[2], 0.06), 0.5);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(150);
|
||||
ctx.lineWidth = 2;
|
||||
// Draw circuit traces
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var sx = rng() * w, sy = rng() * h;
|
||||
var segments = 3 + Math.floor(rng() * 5);
|
||||
var pulse = 0.3 + Math.sin(t * 1.5 + i * 0.7) * 0.25;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.15 + pulse * 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
for (var j = 0; j < segments; j++) {
|
||||
var dir = Math.floor(rng() * 2); // 0=horizontal, 1=vertical
|
||||
var len = 20 + rng() * 80;
|
||||
if (dir === 0) sx += (rng() > 0.5 ? 1 : -1) * len;
|
||||
else sy += (rng() > 0.5 ? 1 : -1) * len;
|
||||
sx = H.clamp(sx, 10, w - 10);
|
||||
sy = H.clamp(sy, 10, h - 10);
|
||||
ctx.lineTo(sx, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
// Node at end
|
||||
D.circle(ctx, sx, sy, 3, H.rgba(c[3], 0.4 + pulse * 0.4));
|
||||
}
|
||||
// Chip packages
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var cx = rng() * w * 0.8 + w * 0.1;
|
||||
var cy = rng() * h * 0.8 + h * 0.1;
|
||||
var cw = 20 + rng() * 20, ch = 15 + rng() * 15;
|
||||
Draw.rect(ctx, cx - cw / 2, cy - ch / 2, cw, ch, H.rgba(c[1], 0.8));
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.3);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(cx - cw / 2, cy - ch / 2, cw, ch);
|
||||
// Pins
|
||||
for (var p = 0; p < 4; p++) {
|
||||
Draw.rect(ctx, cx - cw / 2 - 4, cy - ch / 2 + 3 + p * (ch / 4), 4, 2, H.rgba(c[2], 0.3));
|
||||
Draw.rect(ctx, cx + cw / 2, cy - ch / 2 + 3 + p * (ch / 4), 4, 2, H.rgba(c[2], 0.3));
|
||||
}
|
||||
}
|
||||
// Data pulses traveling along traces
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var px = (rng() * w + t * 60 * (rng() > 0.5 ? 1 : -1)) % w;
|
||||
var py = Math.round(rng() * h / 40) * 40;
|
||||
D.glow(ctx, px, py, 6, H.rgba(c[3], 0.5 + Math.sin(t * 3 + i) * 0.3));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 25, color: '#33FFAA' }
|
||||
},
|
||||
|
||||
factory: {
|
||||
meta: { id: 'mechanical_factory', mood: 'busy', colors: ['#1A1510', '#2A2520', '#8B7355', '#DDAA44'], tags: ['industrial', 'gritty', 'warm'], description: 'Factory floor with smokestacks and warning stripes', compatibleMusicMoods: ['tense', 'electronic', 'action'], recommendedFor: ['action', 'physics', 'puzzle'] },
|
||||
colors: ['#1A1510', '#2A2520', '#8B7355', '#DDAA44'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.darken(c[0], 0.3)], 90);
|
||||
// Floor
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, H.darken(c[2], 0.5));
|
||||
// Floor grid
|
||||
Draw.grid(ctx, w, h * 0.22, 30, H.rgba(c[2], 0.08), 0.5);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
// Smokestacks in back
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var sx = w * 0.2 + i * w * 0.3;
|
||||
var sw = 25 + rng() * 15;
|
||||
var sh = h * 0.35 + rng() * h * 0.1;
|
||||
Draw.rect(ctx, sx - sw / 2, h * 0.78 - sh, sw, sh, H.darken(c[2], 0.3));
|
||||
// Warning stripes
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(sx - sw / 2, h * 0.78 - sh, sw, 12);
|
||||
ctx.clip();
|
||||
for (var s = -2; s < sw / 8 + 2; s++) {
|
||||
Draw.rect(ctx, sx - sw / 2 + s * 16, h * 0.78 - sh, 8, 12, c[3]);
|
||||
}
|
||||
ctx.restore();
|
||||
// Smoke puffs
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var smokeY = h * 0.78 - sh - 15 - j * 25;
|
||||
var drift = Math.sin(t * 0.5 + i + j) * 10;
|
||||
var alpha = 0.08 - j * 0.02;
|
||||
ctx.fillStyle = H.rgba('#888888', alpha);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(sx + drift, smokeY, 15 + j * 10, 8 + j * 5, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
// Catwalk
|
||||
Draw.rect(ctx, 0, h * 0.5, w, 4, c[2]);
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.4);
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < w; i += 15) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, h * 0.5 + 4);
|
||||
ctx.lineTo(i + 7, h * 0.5 + 18);
|
||||
ctx.lineTo(i + 15, h * 0.5 + 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
Draw.rect(ctx, 0, h * 0.5 + 18, w, 2, H.rgba(c[2], 0.3));
|
||||
// Warning light
|
||||
var blink = Math.sin(t * 4) > 0 ? 0.8 : 0.1;
|
||||
D.glow(ctx, w * 0.9, h * 0.15, 25, H.rgba('#FF4400', blink * 0.4));
|
||||
D.circle(ctx, w * 0.9, h * 0.15, 6, H.rgba('#FF4400', blink));
|
||||
// Rivets on floor seam
|
||||
for (var i = 0; i < 16; i++) {
|
||||
drawRivet(ctx, w * 0.05 + i * (w * 0.9 / 15), h * 0.78, 2.5, c[2]);
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 30, color: '#AA9977' }
|
||||
},
|
||||
|
||||
robotFactory: {
|
||||
meta: { id: 'mechanical_robotFactory', mood: 'playful', colors: ['#151520', '#222238', '#5588CC', '#FFAA33'], tags: ['robots', 'tech', 'fun'], description: 'Robot assembly line with mechanical arms and sparks', compatibleMusicMoods: ['electronic', 'energetic', 'playful'], recommendedFor: ['action', 'puzzle', 'creative'] },
|
||||
colors: ['#151520', '#222238', '#5588CC', '#FFAA33'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Floor
|
||||
Draw.rect(ctx, 0, h * 0.82, w, h * 0.18, H.darken(c[1], 0.3));
|
||||
Draw.grid(ctx, w, h, 50, H.rgba(c[2], 0.04), 0.5);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(250);
|
||||
// Assembly arms from ceiling
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var ax = w * 0.15 + i * w * 0.23;
|
||||
var armAngle = Math.sin(t * 0.7 + i * 1.5) * 0.4;
|
||||
var armLen = h * 0.25;
|
||||
var elbowX = ax + Math.sin(armAngle) * armLen;
|
||||
var elbowY = armLen * Math.cos(armAngle);
|
||||
// Arm segment
|
||||
ctx.strokeStyle = c[2];
|
||||
ctx.lineWidth = 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, 0);
|
||||
ctx.lineTo(elbowX, elbowY);
|
||||
ctx.stroke();
|
||||
// Joint
|
||||
D.circle(ctx, elbowX, elbowY, 6, H.lighten(c[2], 0.2));
|
||||
D.circle(ctx, ax, 0, 8, c[2]);
|
||||
// Claw tip
|
||||
var clawAngle = armAngle + Math.sin(t * 1.5 + i) * 0.3;
|
||||
var tipX = elbowX + Math.sin(clawAngle) * 20;
|
||||
var tipY = elbowY + Math.cos(clawAngle) * 20;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(elbowX, elbowY);
|
||||
ctx.lineTo(tipX, tipY);
|
||||
ctx.stroke();
|
||||
// Sparks at claw
|
||||
if (Math.sin(t * 5 + i * 2.3) > 0.6) {
|
||||
D.glow(ctx, tipX, tipY, 10, H.rgba(c[3], 0.6));
|
||||
}
|
||||
}
|
||||
// Robot bodies on conveyor (silhouettes)
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var rx = (w * 0.1 + i * w * 0.35 + t * 15) % (w + 60) - 30;
|
||||
var ry = h * 0.82;
|
||||
// Body
|
||||
Draw.rect(ctx, rx - 12, ry - 35, 24, 25, c[2]);
|
||||
// Head
|
||||
Draw.rect(ctx, rx - 8, ry - 45, 16, 12, H.lighten(c[2], 0.15));
|
||||
// Eyes
|
||||
var eyeGlow = 0.4 + Math.sin(t * 3 + i) * 0.3;
|
||||
D.circle(ctx, rx - 4, ry - 40, 2, H.rgba(c[3], eyeGlow));
|
||||
D.circle(ctx, rx + 4, ry - 40, 2, H.rgba(c[3], eyeGlow));
|
||||
// Legs
|
||||
Draw.rect(ctx, rx - 10, ry - 10, 6, 10, H.darken(c[2], 0.2));
|
||||
Draw.rect(ctx, rx + 4, ry - 10, 6, 10, H.darken(c[2], 0.2));
|
||||
}
|
||||
// Conveyor belt
|
||||
Draw.rect(ctx, 0, h * 0.82, w, 5, H.darken(c[2], 0.4));
|
||||
var beltOffset = (t * 30) % 20;
|
||||
for (var i = -1; i < w / 20 + 1; i++) {
|
||||
Draw.rect(ctx, i * 20 + beltOffset, h * 0.82, 2, 5, H.rgba(c[2], 0.3));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#FFAA33' }
|
||||
},
|
||||
|
||||
conveyorBelts: {
|
||||
meta: { id: 'mechanical_conveyorBelts', mood: 'busy', colors: ['#18181F', '#28283A', '#666680', '#DD8822'], tags: ['movement', 'industrial', 'repetitive'], description: 'Layered conveyor belts moving crates and parts', compatibleMusicMoods: ['electronic', 'ambient', 'action'], recommendedFor: ['puzzle', 'action', 'physics'] },
|
||||
colors: ['#18181F', '#28283A', '#666680', '#DD8822'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.scanlines(ctx, w, h, 4, H.rgba(c[2], 0.02));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(300);
|
||||
// Three conveyor belt layers
|
||||
var beltYs = [h * 0.3, h * 0.55, h * 0.8];
|
||||
var beltSpeeds = [25, -35, 20];
|
||||
for (var b = 0; b < 3; b++) {
|
||||
var by = beltYs[b];
|
||||
var beltH = 8;
|
||||
// Belt surface
|
||||
Draw.rect(ctx, 0, by, w, beltH, H.darken(c[2], 0.3));
|
||||
// Belt treads
|
||||
var treadOffset = (t * beltSpeeds[b]) % 16;
|
||||
for (var i = -1; i < w / 16 + 1; i++) {
|
||||
Draw.rect(ctx, i * 16 + treadOffset, by, 2, beltH, H.rgba(c[2], 0.2));
|
||||
}
|
||||
// Support legs
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var lx = w * 0.1 + i * w * 0.2;
|
||||
Draw.rect(ctx, lx - 3, by + beltH, 6, h - by - beltH, H.darken(c[2], 0.5));
|
||||
}
|
||||
// Rollers at ends
|
||||
D.circle(ctx, 15, by + beltH / 2, beltH / 2 + 2, c[2]);
|
||||
D.circle(ctx, w - 15, by + beltH / 2, beltH / 2 + 2, c[2]);
|
||||
// Items on belt
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var itemX = (rng() * w + t * beltSpeeds[b]) % (w + 40) - 20;
|
||||
var itemW = 12 + rng() * 18;
|
||||
var itemH = 10 + rng() * 14;
|
||||
var itemCol = rng() > 0.5 ? H.blendColor(c[2], c[3], rng() * 0.6) : H.darken(c[2], rng() * 0.3);
|
||||
Draw.rect(ctx, itemX - itemW / 2, by - itemH, itemW, itemH, itemCol);
|
||||
}
|
||||
}
|
||||
// Overhead arrows
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.2);
|
||||
ctx.lineWidth = 1.5;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var ax = w * 0.1 + i * w * 0.16;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, h * 0.12);
|
||||
ctx.lineTo(ax + 10, h * 0.15);
|
||||
ctx.lineTo(ax, h * 0.18);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#887766' }
|
||||
},
|
||||
|
||||
pipes: {
|
||||
meta: { id: 'mechanical_pipes', mood: 'complex', colors: ['#121218', '#1E2A1E', '#558855', '#88BB44'], tags: ['plumbing', 'industrial', 'network'], description: 'Interconnected pipe network with valves and joints', compatibleMusicMoods: ['ambient', 'tense', 'electronic'], recommendedFor: ['puzzle', 'physics', 'rpg'] },
|
||||
colors: ['#121218', '#1E2A1E', '#558855', '#88BB44'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(350);
|
||||
// Horizontal pipes
|
||||
var pipeYs = [h * 0.15, h * 0.35, h * 0.55, h * 0.75, h * 0.9];
|
||||
for (var i = 0; i < pipeYs.length; i++) {
|
||||
var pw = 8 + rng() * 8;
|
||||
var pipeCol = H.blendColor(c[2], c[3], rng() * 0.4);
|
||||
drawPipe(ctx, 0, pipeYs[i], w, pipeYs[i], pw, pipeCol);
|
||||
// Joints/flanges
|
||||
for (var j = 0; j < 4; j++) {
|
||||
var jx = rng() * w;
|
||||
Draw.rect(ctx, jx - pw * 0.7, pipeYs[i] - pw * 0.7, pw * 1.4, pw * 1.4, H.darken(pipeCol, 0.15));
|
||||
}
|
||||
}
|
||||
// Vertical connector pipes
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var vx = rng() * w * 0.8 + w * 0.1;
|
||||
var fromY = pipeYs[Math.floor(rng() * (pipeYs.length - 1))];
|
||||
var toIdx = Math.min(Math.floor(rng() * (pipeYs.length - 1)) + 1, pipeYs.length - 1);
|
||||
var toY = pipeYs[toIdx];
|
||||
if (fromY === toY) toY = pipeYs[Math.min(toIdx + 1, pipeYs.length - 1)];
|
||||
var vpw = 6 + rng() * 5;
|
||||
drawPipe(ctx, vx, fromY, vx, toY, vpw, H.blendColor(c[2], c[3], rng() * 0.3));
|
||||
}
|
||||
// Valves (wheel shapes)
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var vx = rng() * w * 0.7 + w * 0.15;
|
||||
var vy = pipeYs[Math.floor(rng() * pipeYs.length)];
|
||||
var vr = 8 + rng() * 6;
|
||||
var spin = t * 0.3 * (rng() > 0.5 ? 1 : -1);
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.7);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(vx, vy, vr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
for (var s = 0; s < 4; s++) {
|
||||
var sa = spin + s * Math.PI / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx, vy);
|
||||
ctx.lineTo(vx + Math.cos(sa) * vr, vy + Math.sin(sa) * vr);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Dripping animation
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var dx = rng() * w;
|
||||
var dripY = pipeYs[Math.floor(rng() * pipeYs.length)] + 8;
|
||||
var dropY = dripY + ((t * 40 + rng() * 100) % 50);
|
||||
var alpha = 1 - ((t * 40 + rng() * 100) % 50) / 50;
|
||||
D.circle(ctx, dx, dropY, 2, H.rgba(c[3], alpha * 0.5));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 12, color: '#667755' }
|
||||
},
|
||||
|
||||
steamPunk: {
|
||||
meta: { id: 'mechanical_steamPunk', mood: 'adventurous', colors: ['#1A1008', '#2A1A0A', '#C08040', '#E8C060'], tags: ['brass', 'vintage', 'ornate'], description: 'Brass gears, gauges, and steam vents in warm tones', compatibleMusicMoods: ['epic', 'ambient', 'action'], recommendedFor: ['rpg', 'story', 'action'] },
|
||||
colors: ['#1A1008', '#2A1A0A', '#C08040', '#E8C060'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.darken(c[0], 0.2)], 135);
|
||||
// Warm vignette
|
||||
var g = D.radialGradient(ctx, w / 2, h / 2, Math.max(w, h) * 0.7, [[0, H.rgba(c[0], 0)], [1, H.rgba(c[0], 0.5)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(400);
|
||||
// Large decorative gears
|
||||
drawGear(ctx, w * 0.2, h * 0.3, 35, 55, 12, t * 0.15, H.rgba(c[2], 0.6));
|
||||
drawGear(ctx, w * 0.35, h * 0.45, 20, 35, 8, -t * 0.2, H.rgba(c[3], 0.5));
|
||||
drawGear(ctx, w * 0.8, h * 0.25, 40, 65, 14, t * 0.12, H.rgba(c[2], 0.55));
|
||||
drawGear(ctx, w * 0.65, h * 0.7, 25, 42, 10, -t * 0.18, H.rgba(c[3], 0.5));
|
||||
// Gauges
|
||||
var needleWobble1 = -Math.PI / 4 + Math.sin(t * 0.8) * 0.5;
|
||||
drawGauge(ctx, w * 0.5, h * 0.2, 22, needleWobble1, c[0], c[2]);
|
||||
var needleWobble2 = Math.sin(t * 0.5) * 0.8;
|
||||
drawGauge(ctx, w * 0.88, h * 0.6, 18, needleWobble2, c[0], c[3]);
|
||||
// Decorative border rivets
|
||||
for (var i = 0; i < 20; i++) {
|
||||
drawRivet(ctx, w * 0.03, h * 0.05 + i * (h * 0.9 / 19), 3, c[2]);
|
||||
drawRivet(ctx, w * 0.97, h * 0.05 + i * (h * 0.9 / 19), 3, c[2]);
|
||||
}
|
||||
for (var i = 0; i < 14; i++) {
|
||||
drawRivet(ctx, w * 0.05 + i * (w * 0.9 / 13), h * 0.03, 3, c[2]);
|
||||
drawRivet(ctx, w * 0.05 + i * (w * 0.9 / 13), h * 0.97, 3, c[2]);
|
||||
}
|
||||
// Steam vents
|
||||
for (var v = 0; v < 2; v++) {
|
||||
var vx = w * 0.3 + v * w * 0.4;
|
||||
var vy = h * 0.85;
|
||||
Draw.rect(ctx, vx - 6, vy, 12, h - vy, H.darken(c[2], 0.3));
|
||||
// Steam puffs
|
||||
for (var p = 0; p < 4; p++) {
|
||||
var pAlpha = 0.06 - p * 0.012;
|
||||
var drift = Math.sin(t + v + p * 0.5) * 8;
|
||||
ctx.fillStyle = H.rgba('#CCBBAA', pAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(vx + drift, vy - 10 - p * 18, 12 + p * 8, 6 + p * 4, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 18, color: '#C0A060' }
|
||||
},
|
||||
|
||||
techLab: {
|
||||
meta: { id: 'mechanical_techLab', mood: 'curious', colors: ['#0E0E1A', '#1A1A30', '#4455AA', '#66DDFF'], tags: ['science', 'clean', 'futuristic'], description: 'Clean tech laboratory with holographic displays and instruments', compatibleMusicMoods: ['ambient', 'electronic', 'calm'], recommendedFor: ['puzzle', 'trivia', 'creative'] },
|
||||
colors: ['#0E0E1A', '#1A1A30', '#4455AA', '#66DDFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Clean floor
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, H.darken(c[1], 0.2));
|
||||
Draw.grid(ctx, w, h, 60, H.rgba(c[2], 0.04), 0.5);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(450);
|
||||
// Holographic display panels
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var px = w * 0.12 + i * w * 0.32;
|
||||
var py = h * 0.15;
|
||||
var pw = w * 0.22, ph = h * 0.35;
|
||||
// Panel frame
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.4);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(px, py, pw, ph);
|
||||
// Panel glow fill
|
||||
ctx.fillStyle = H.rgba(c[2], 0.05 + Math.sin(t + i) * 0.02);
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
// Data lines inside panel
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.2);
|
||||
ctx.lineWidth = 1;
|
||||
for (var l = 0; l < 5; l++) {
|
||||
var ly = py + 10 + l * (ph - 20) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + 8, ly);
|
||||
for (var x = 0; x < pw - 16; x += 4) {
|
||||
var val = Math.sin(x * 0.1 + t * 2 + l * 1.3 + i) * 6;
|
||||
ctx.lineTo(px + 8 + x, ly + val);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Blinking status light
|
||||
var statusBlink = Math.sin(t * 2.5 + i * 1.1) > 0 ? 0.8 : 0.2;
|
||||
D.circle(ctx, px + pw - 8, py + 8, 3, H.rgba(c[3], statusBlink));
|
||||
}
|
||||
// Lab equipment silhouettes
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var ex = rng() * w * 0.8 + w * 0.1;
|
||||
var eH = 15 + rng() * 30;
|
||||
Draw.rect(ctx, ex - 8, h * 0.85 - eH, 16, eH, H.rgba(c[2], 0.3));
|
||||
D.circle(ctx, ex, h * 0.85 - eH - 5, 4, H.rgba(c[3], 0.3));
|
||||
}
|
||||
// Floating data points
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var dx = rng() * w;
|
||||
var dy = rng() * h * 0.8;
|
||||
var flicker = 0.15 + Math.sin(t * 1.8 + rng() * 10) * 0.15;
|
||||
D.circle(ctx, dx, dy, 1.5, H.rgba(c[3], flicker));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 25, color: '#66DDFF' }
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,360 @@
|
||||
// nature.js — Nature theme for procedural background system
|
||||
// 8 variants: forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('nature', {
|
||||
|
||||
forest: {
|
||||
meta: { id: 'nature_forest', mood: 'peaceful', colors: ['#1A3320', '#2D5A27', '#4A7A3F', '#8FB86A'], tags: ['trees', 'green', 'calm'], description: 'A dense forest with layered canopy and dappled light', compatibleMusicMoods: ['calm', 'ambient', 'dreamy'], recommendedFor: ['story', 'rpg', 'puzzle'] },
|
||||
colors: ['#1A3320', '#2D5A27', '#4A7A3F', '#8FB86A'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#5588AA', '#88BBCC', c[1]], 90);
|
||||
Draw.clouds(ctx, w, h, h * 0.08, 5, '#FFFFFF', 110);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Background tree layer
|
||||
Draw.hills(ctx, w, h, h * 0.4, 8, H.darken(c[0], 0.3), 111);
|
||||
Draw.trees(ctx, w, h, h * 0.5, 25, '#3D2B1F', c[0], 112);
|
||||
// Mid tree layer
|
||||
Draw.hills(ctx, w, h, h * 0.6, 6, c[1], 113);
|
||||
Draw.trees(ctx, w, h, h * 0.68, 18, '#5C3A1E', c[2], 114);
|
||||
// Foreground
|
||||
Draw.hills(ctx, w, h, h * 0.8, 5, c[2], 115);
|
||||
Draw.trees(ctx, w, h, h * 0.85, 10, '#6B4226', c[3], 116);
|
||||
// Forest floor
|
||||
Draw.rect(ctx, 0, h * 0.88, w, h * 0.12, H.darken(c[0], 0.4));
|
||||
// Light rays
|
||||
var rng = H.seededRandom(117);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.04 + Math.sin(t * 0.5) * 0.02;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var rx = rng() * w;
|
||||
ctx.fillStyle = '#FFFFCC';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rx, 0);
|
||||
ctx.lineTo(rx - 30, h * 0.7);
|
||||
ctx.lineTo(rx + 30, h * 0.7);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
particles: { type: 'fireflies', count: 20, color: '#CCFF66' }
|
||||
},
|
||||
|
||||
jungle: {
|
||||
meta: { id: 'nature_jungle', mood: 'adventurous', colors: ['#0D2B0D', '#1A4D1A', '#2D7A2D', '#55AA33'], tags: ['tropical', 'dense', 'lush'], description: 'Thick tropical jungle with hanging vines and humid air', compatibleMusicMoods: ['adventurous', 'tense', 'ambient'], recommendedFor: ['action', 'rpg', 'pet'] },
|
||||
colors: ['#0D2B0D', '#1A4D1A', '#2D7A2D', '#55AA33'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#2A4420', c[0], H.darken(c[0], 0.3)], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.hills(ctx, w, h, h * 0.35, 10, c[1], 210);
|
||||
Draw.trees(ctx, w, h, h * 0.5, 30, '#3B2510', c[1], 211);
|
||||
// Vines
|
||||
var rng = H.seededRandom(212);
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.6);
|
||||
ctx.lineWidth = 2;
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var vx = rng() * w;
|
||||
var sway = Math.sin(t * 0.8 + i * 1.3) * 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx, 0);
|
||||
ctx.quadraticCurveTo(vx + sway, h * 0.2 + rng() * h * 0.15, vx + sway * 0.5, h * 0.3 + rng() * h * 0.2);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Large canopy leaves at top
|
||||
Draw.hills(ctx, w, h * 0.15, h * 0.02, 12, H.rgba(c[2], 0.5), 213);
|
||||
// Mid layer dense foliage
|
||||
Draw.trees(ctx, w, h, h * 0.7, 20, '#4B3520', c[2], 214);
|
||||
// Ground ferns
|
||||
Draw.hills(ctx, w, h, h * 0.85, 15, c[3], 215);
|
||||
Draw.rect(ctx, 0, h * 0.9, w, h * 0.1, H.darken(c[0], 0.5));
|
||||
},
|
||||
particles: { type: 'fireflies', count: 25, color: '#AAEE44' }
|
||||
},
|
||||
|
||||
desert: {
|
||||
meta: { id: 'nature_desert', mood: 'warm', colors: ['#F4A460', '#DAA06D', '#C2955A', '#8B6914'], tags: ['sand', 'hot', 'arid'], description: 'Rolling sand dunes under a blazing sun', compatibleMusicMoods: ['calm', 'epic', 'ambient'], recommendedFor: ['racing', 'action', 'rpg'] },
|
||||
colors: ['#F4A460', '#DAA06D', '#C2955A', '#8B6914'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#FFCC66', '#FF9933', c[0]], 90);
|
||||
// Sun
|
||||
var sunPulse = 1 + Math.sin(t * 0.3) * 0.03;
|
||||
Draw.glow(ctx, w * 0.75, h * 0.12, 100 * sunPulse, H.rgba('#FFEE88', 0.4));
|
||||
Draw.circle(ctx, w * 0.75, h * 0.12, 35, '#FFDD44');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Far dunes
|
||||
Draw.hills(ctx, w, h, h * 0.45, 5, H.lighten(c[0], 0.15), 310);
|
||||
// Mid dunes
|
||||
Draw.hills(ctx, w, h, h * 0.6, 4, c[1], 311);
|
||||
// Near dunes
|
||||
Draw.hills(ctx, w, h, h * 0.75, 3, c[2], 312);
|
||||
// Ground
|
||||
Draw.rect(ctx, 0, h * 0.82, w, h * 0.18, c[3]);
|
||||
// Heat shimmer
|
||||
var rng = H.seededRandom(313);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.03 + Math.sin(t * 2) * 0.02;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var sy = h * 0.45 + rng() * h * 0.15;
|
||||
ctx.strokeStyle = '#FFFFFF';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (var x = 0; x < w; x += 6) {
|
||||
var yy = sy + Math.sin(x * 0.04 + t * 3 + i) * 3;
|
||||
x === 0 ? ctx.moveTo(x, yy) : ctx.lineTo(x, yy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
// Cacti
|
||||
for (var j = 0; j < 4; j++) {
|
||||
var cx = rng() * w;
|
||||
var cy = h * 0.72 + rng() * h * 0.06;
|
||||
var ch = 15 + rng() * 25;
|
||||
Draw.rect(ctx, cx - 3, cy - ch, 6, ch, '#2D5A27');
|
||||
Draw.rect(ctx, cx + 3, cy - ch * 0.7, 10, 4, '#2D5A27');
|
||||
Draw.rect(ctx, cx + 9, cy - ch * 0.7, 4, -10, '#2D5A27');
|
||||
Draw.rect(ctx, cx - 13, cy - ch * 0.5, 10, 4, '#2D5A27');
|
||||
Draw.rect(ctx, cx - 13, cy - ch * 0.5, 4, -8, '#2D5A27');
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#DDCC99' }
|
||||
},
|
||||
|
||||
mountains: {
|
||||
meta: { id: 'nature_mountains', mood: 'majestic', colors: ['#2C3E50', '#4A6B5A', '#7A9B6B', '#FFFFFF'], tags: ['peaks', 'alpine', 'grand'], description: 'Snow-capped mountain peaks under sweeping skies', compatibleMusicMoods: ['epic', 'calm', 'dreamy'], recommendedFor: ['rpg', 'sports', 'story'] },
|
||||
colors: ['#2C3E50', '#4A6B5A', '#7A9B6B', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#6699CC', '#AAC4DD', '#DDEEFF'], 90);
|
||||
Draw.clouds(ctx, w, h, h * 0.1, 8, '#FFFFFF', 410);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Far mountains
|
||||
Draw.mountains(ctx, w, h, h * 0.45, 6, H.rgba(c[0], 0.6), 411);
|
||||
// Snow caps
|
||||
Draw.mountains(ctx, w, h, h * 0.42, 6, H.rgba(c[3], 0.25), 411);
|
||||
// Mid mountains
|
||||
Draw.mountains(ctx, w, h, h * 0.55, 5, c[0], 412);
|
||||
Draw.mountains(ctx, w, h, h * 0.52, 5, H.rgba(c[3], 0.35), 412);
|
||||
// Foothills
|
||||
Draw.hills(ctx, w, h, h * 0.7, 8, c[1], 413);
|
||||
Draw.hills(ctx, w, h, h * 0.8, 6, c[2], 414);
|
||||
// Valley floor
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, H.darken(c[2], 0.2));
|
||||
// River
|
||||
ctx.strokeStyle = H.rgba('#5588BB', 0.5);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.3, h * 0.85);
|
||||
ctx.quadraticCurveTo(w * 0.5, h * 0.88, w * 0.7, h * 0.92);
|
||||
ctx.quadraticCurveTo(w * 0.85, h * 0.95, w, h * 0.93);
|
||||
ctx.stroke();
|
||||
},
|
||||
particles: { type: 'fog', count: 8, color: '#CCCCCC' }
|
||||
},
|
||||
|
||||
ocean: {
|
||||
meta: { id: 'nature_ocean', mood: 'serene', colors: ['#1A5276', '#2E86C1', '#5DADE2', '#AED6F1'], tags: ['water', 'waves', 'calm'], description: 'Vast ocean stretching to the horizon at sunset', compatibleMusicMoods: ['calm', 'dreamy', 'ambient'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#1A5276', '#2E86C1', '#5DADE2', '#AED6F1'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#FF8844', '#FFAA66', '#FFCC88', c[3]], 90);
|
||||
// Setting sun
|
||||
var sunY = h * 0.35 + Math.sin(t * 0.1) * 2;
|
||||
Draw.glow(ctx, w * 0.5, sunY, 120, H.rgba('#FFAA44', 0.4));
|
||||
Draw.circle(ctx, w * 0.5, sunY, 30, '#FFDD66');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Horizon line
|
||||
Draw.rect(ctx, 0, h * 0.45, w, 2, H.rgba('#FFEECC', 0.3));
|
||||
// Ocean
|
||||
Draw.water(ctx, w, h, h * 0.45, c[1], 3 + Math.sin(t) * 1, 510);
|
||||
// Sun reflection on water
|
||||
var rng = H.seededRandom(511);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08 + Math.sin(t * 0.5) * 0.04;
|
||||
for (var i = 0; i < 15; i++) {
|
||||
var ry = h * 0.47 + i * ((h * 0.53) / 15);
|
||||
var rw = 20 + rng() * 40 - i * 1.5;
|
||||
var rx = w * 0.5 - rw / 2 + Math.sin(t * 1.5 + i * 0.8) * 10;
|
||||
Draw.rect(ctx, rx, ry, rw, 2, '#FFDDAA');
|
||||
}
|
||||
ctx.restore();
|
||||
// Distant clouds
|
||||
Draw.clouds(ctx, w, h, h * 0.15, 4, '#FFDDBB', 512);
|
||||
},
|
||||
particles: { type: 'fog', count: 6, color: '#AACCDD' }
|
||||
},
|
||||
|
||||
underwater: {
|
||||
meta: { id: 'nature_underwater', mood: 'mystical', colors: ['#0A2342', '#1A4B6E', '#2E86C1', '#48D1CC'], tags: ['deep', 'aquatic', 'blue'], description: 'Deep beneath the waves with shafts of sunlight', compatibleMusicMoods: ['dreamy', 'ambient', 'calm'], recommendedFor: ['puzzle', 'pet', 'creative'] },
|
||||
colors: ['#0A2342', '#1A4B6E', '#2E86C1', '#48D1CC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[3], c[2], c[1], c[0]], 90);
|
||||
// Light rays from surface
|
||||
var rng = H.seededRandom(610);
|
||||
ctx.save();
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var rx = rng() * w;
|
||||
var sway = Math.sin(t * 0.4 + i * 1.5) * 15;
|
||||
ctx.fillStyle = H.rgba('#AADDFF', 0.03 + Math.sin(t * 0.3 + i) * 0.01);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rx + sway, 0);
|
||||
ctx.lineTo(rx - 40 + sway * 0.5, h);
|
||||
ctx.lineTo(rx + 40 + sway * 0.5, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(611);
|
||||
// Seaweed
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var sx = rng() * w;
|
||||
var sh = 40 + rng() * 80;
|
||||
var sway = Math.sin(t * 0.8 + i * 2) * 10;
|
||||
ctx.strokeStyle = H.rgba('#228844', 0.5 + rng() * 0.3);
|
||||
ctx.lineWidth = 3 + rng() * 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, h);
|
||||
ctx.quadraticCurveTo(sx + sway, h - sh * 0.5, sx + sway * 1.3, h - sh);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Coral mounds
|
||||
for (var j = 0; j < 6; j++) {
|
||||
var cx = rng() * w;
|
||||
var cr = 15 + rng() * 30;
|
||||
var cc = H.pick(['#CC4466', '#FF7744', '#FFAA33', '#AA55CC']);
|
||||
Draw.circle(ctx, cx, h - cr * 0.5, cr, H.rgba(cc, 0.5));
|
||||
Draw.circle(ctx, cx + cr * 0.4, h - cr * 0.3, cr * 0.6, H.rgba(cc, 0.35));
|
||||
}
|
||||
// Fish silhouettes
|
||||
for (var k = 0; k < 8; k++) {
|
||||
var fx = (rng() * w + t * (10 + rng() * 30)) % (w + 60) - 30;
|
||||
var fy = h * 0.2 + rng() * h * 0.5;
|
||||
var fs = 4 + rng() * 8;
|
||||
ctx.fillStyle = H.rgba(c[3], 0.3 + rng() * 0.2);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(fx, fy, fs * 2, fs, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Tail
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fx - fs * 2, fy);
|
||||
ctx.lineTo(fx - fs * 3, fy - fs * 0.8);
|
||||
ctx.lineTo(fx - fs * 3, fy + fs * 0.8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
// Sandy bottom
|
||||
Draw.rect(ctx, 0, h * 0.92, w, h * 0.08, H.rgba('#C2B280', 0.25));
|
||||
},
|
||||
particles: { type: 'bubbles', count: 30, color: '#88CCEE' }
|
||||
},
|
||||
|
||||
waterfall: {
|
||||
meta: { id: 'nature_waterfall', mood: 'refreshing', colors: ['#1B4332', '#2D6A4F', '#52B788', '#B7E4C7'], tags: ['water', 'lush', 'dynamic'], description: 'A cascading waterfall in a lush green cliff', compatibleMusicMoods: ['calm', 'ambient', 'dreamy'], recommendedFor: ['puzzle', 'creative', 'story'] },
|
||||
colors: ['#1B4332', '#2D6A4F', '#52B788', '#B7E4C7'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#77AABB', '#99CCDD', c[2]], 90);
|
||||
Draw.clouds(ctx, w, h, h * 0.05, 3, '#FFFFFF', 710);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Cliff walls
|
||||
Draw.rect(ctx, 0, h * 0.15, w * 0.35, h * 0.85, c[0]);
|
||||
Draw.rect(ctx, w * 0.65, h * 0.15, w * 0.35, h * 0.85, c[0]);
|
||||
// Cliff vegetation
|
||||
Draw.hills(ctx, w * 0.35, h * 0.2, h * 0.15, 5, c[1], 711);
|
||||
ctx.save(); ctx.translate(w * 0.65, 0);
|
||||
Draw.hills(ctx, w * 0.35, h * 0.2, h * 0.15, 5, c[1], 712);
|
||||
ctx.restore();
|
||||
// Waterfall
|
||||
var rng = H.seededRandom(713);
|
||||
var fallX = w * 0.38, fallW = w * 0.24;
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var fx = fallX + rng() * fallW;
|
||||
var fy = h * 0.15 + rng() * h * 0.65;
|
||||
var offset = (t * 80 + rng() * 200) % (h * 0.7);
|
||||
var wy = h * 0.15 + offset;
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.1 + rng() * 0.2);
|
||||
ctx.lineWidth = 1 + rng() * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fx, wy);
|
||||
ctx.lineTo(fx + (rng() - 0.5) * 4, wy + 10 + rng() * 20);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Base glow
|
||||
Draw.fillGradient(ctx, w, h, [H.rgba(c[3], 0), H.rgba(c[3], 0), H.rgba('#FFFFFF', 0.15)], 90);
|
||||
// Pool at bottom
|
||||
Draw.water(ctx, w, h, h * 0.82, H.rgba('#3388AA', 0.6), 2, 714);
|
||||
// Mist
|
||||
Draw.glow(ctx, w * 0.5, h * 0.8, 100, H.rgba('#FFFFFF', 0.12 + Math.sin(t) * 0.04));
|
||||
// Foreground plants
|
||||
Draw.trees(ctx, w, h, h * 0.9, 6, '#3D2B1F', c[2], 715);
|
||||
},
|
||||
particles: { type: 'fog', count: 12, color: '#DDEEFF' }
|
||||
},
|
||||
|
||||
meadow: {
|
||||
meta: { id: 'nature_meadow', mood: 'joyful', colors: ['#4CAF50', '#81C784', '#A5D6A7', '#FFF59D'], tags: ['flowers', 'sunny', 'cheerful'], description: 'A sunny meadow with wildflowers and gentle breeze', compatibleMusicMoods: ['happy', 'calm', 'dreamy'], recommendedFor: ['pet', 'creative', 'story', 'puzzle'] },
|
||||
colors: ['#4CAF50', '#81C784', '#A5D6A7', '#FFF59D'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#55AADD', '#88CCEE', '#AADDEE'], 90);
|
||||
// Sun
|
||||
var pulse = 1 + Math.sin(t * 0.4) * 0.02;
|
||||
Draw.glow(ctx, w * 0.8, h * 0.1, 80 * pulse, H.rgba('#FFEE66', 0.4));
|
||||
Draw.circle(ctx, w * 0.8, h * 0.1, 28, '#FFEE44');
|
||||
Draw.clouds(ctx, w, h, h * 0.08, 6, '#FFFFFF', 810);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Distant hills
|
||||
Draw.hills(ctx, w, h, h * 0.55, 5, H.darken(c[0], 0.15), 811);
|
||||
// Main meadow
|
||||
Draw.hills(ctx, w, h, h * 0.65, 8, c[0], 812);
|
||||
Draw.hills(ctx, w, h, h * 0.72, 6, c[1], 813);
|
||||
// Ground
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, c[2]);
|
||||
// Flowers
|
||||
var rng = H.seededRandom(814);
|
||||
var flowerColors = ['#FF6B6B', '#FFD93D', '#C084FC', '#FF8FA3', '#FFFFFF'];
|
||||
for (var i = 0; i < 40; i++) {
|
||||
var fx = rng() * w;
|
||||
var fy = h * 0.68 + rng() * h * 0.25;
|
||||
var fc = flowerColors[Math.floor(rng() * flowerColors.length)];
|
||||
var sway = Math.sin(t * 1.2 + i * 0.8) * 2;
|
||||
// Stem
|
||||
ctx.strokeStyle = H.rgba('#338833', 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fx, fy + 8);
|
||||
ctx.lineTo(fx + sway, fy);
|
||||
ctx.stroke();
|
||||
// Flower
|
||||
Draw.circle(ctx, fx + sway, fy, 2 + rng() * 2, fc);
|
||||
}
|
||||
// Butterflies
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var bx = (rng() * w + Math.sin(t * 0.5 + j * 3) * 40);
|
||||
var by = h * 0.4 + rng() * h * 0.3 + Math.sin(t * 1.2 + j * 2) * 15;
|
||||
var wingFlap = Math.abs(Math.sin(t * 5 + j * 2)) * 4;
|
||||
var bc = H.pick(['#FFD700', '#FF69B4', '#87CEEB']);
|
||||
ctx.fillStyle = H.rgba(bc, 0.7);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx - 3, by, wingFlap, 3, -0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx + 3, by, wingFlap, 3, 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
Draw.circle(ctx, bx, by, 1, '#333333');
|
||||
}
|
||||
},
|
||||
particles: { type: 'leaves', count: 10, color: '#AEDD77' }
|
||||
}
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,551 @@
|
||||
// retro.js — Retro theme backgrounds
|
||||
// Variants: pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('retro', {
|
||||
|
||||
pixelGrid: {
|
||||
meta: { id: 'retro_pixelGrid', mood: 'nostalgic', colors: ['#1a1a2e', '#16213e', '#00ff88', '#ff6600'], tags: ['pixel', 'grid', 'retro', 'classic'], description: 'A chunky pixel grid with classic 8-bit color blocks and pulsing cursor', compatibleMusicMoods: ['retro', 'playful', 'electronic'], recommendedFor: ['action', 'puzzle', 'creative'] },
|
||||
colors: ['#1a1a2e', '#16213e', '#00ff88', '#ff6600'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.grid(ctx, w, h, 32, H.rgba(c[2], 0.04), 1);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(100);
|
||||
var pixelSize = 32;
|
||||
var cols = Math.ceil(w / pixelSize);
|
||||
var rows = Math.ceil(h / pixelSize);
|
||||
// Scattered colored pixels
|
||||
for (var i = 0; i < 30; i++) {
|
||||
var px = Math.floor(rng() * cols) * pixelSize;
|
||||
var py = Math.floor(rng() * rows) * pixelSize;
|
||||
var pColor = rng() > 0.5 ? c[2] : c[3];
|
||||
var pAlpha = 0.06 + rng() * 0.1 + Math.sin(t * 0.8 + i * 0.5) * 0.03;
|
||||
Draw.rect(ctx, px, py, pixelSize - 1, pixelSize - 1, H.rgba(pColor, pAlpha));
|
||||
}
|
||||
// Pixel art sprite (simple ghost/invader shape)
|
||||
var spriteX = w * 0.5, spriteY = h * 0.4;
|
||||
var sp = pixelSize * 0.5;
|
||||
var spriteMap = [
|
||||
[0,0,1,1,1,0,0],
|
||||
[0,1,1,1,1,1,0],
|
||||
[1,1,0,1,0,1,1],
|
||||
[1,1,1,1,1,1,1],
|
||||
[1,0,1,0,1,0,1],
|
||||
[0,1,0,1,0,1,0]
|
||||
];
|
||||
var spriteAlpha = 0.15 + Math.sin(t * 0.6) * 0.05;
|
||||
var sOffX = spriteX - (spriteMap[0].length * sp) / 2;
|
||||
var sOffY = spriteY - (spriteMap.length * sp) / 2;
|
||||
for (var sr = 0; sr < spriteMap.length; sr++) {
|
||||
for (var sc = 0; sc < spriteMap[sr].length; sc++) {
|
||||
if (spriteMap[sr][sc]) {
|
||||
Draw.rect(ctx, sOffX + sc * sp, sOffY + sr * sp, sp - 1, sp - 1, H.rgba(c[2], spriteAlpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Blinking cursor
|
||||
var cursorOn = Math.sin(t * 3) > 0;
|
||||
if (cursorOn) {
|
||||
Draw.rect(ctx, w * 0.3, h * 0.75, pixelSize * 0.6, pixelSize, H.rgba(c[2], 0.4));
|
||||
}
|
||||
// Score text (pixel-styled line)
|
||||
for (var sc2 = 0; sc2 < 6; sc2++) {
|
||||
Draw.rect(ctx, w * 0.35 + sc2 * 12, h * 0.12, 8, 3, H.rgba(c[3], 0.12));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 10, color: '#00FF88' }
|
||||
},
|
||||
|
||||
arcadeCabinet: {
|
||||
meta: { id: 'retro_arcadeCabinet', mood: 'fun', colors: ['#0a0a15', '#1a0a2e', '#ff2277', '#ffcc00'], tags: ['arcade', 'cabinet', 'neon', 'classic'], description: 'The inside view of a dark arcade with neon-lit cabinet edges and attract mode glow', compatibleMusicMoods: ['retro', 'energetic', 'playful'], recommendedFor: ['action', 'racing', 'sports'] },
|
||||
colors: ['#0a0a15', '#1a0a2e', '#ff2277', '#ffcc00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Ambient arcade glow
|
||||
Draw.glow(ctx, w * 0.5, h * 0.5, w * 0.5, H.rgba(c[2], 0.04));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
// Floor tiles (perspective grid)
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.05);
|
||||
ctx.lineWidth = 1;
|
||||
for (var fl = 0; fl < 15; fl++) {
|
||||
var fy = h * 0.7 + fl * h * 0.02;
|
||||
ctx.beginPath(); ctx.moveTo(0, fy); ctx.lineTo(w, fy); ctx.stroke();
|
||||
}
|
||||
for (var fv = 0; fv < 12; fv++) {
|
||||
var fx = fv * w / 12;
|
||||
ctx.beginPath(); ctx.moveTo(fx, h * 0.7); ctx.lineTo(w * 0.5 + (fx - w * 0.5) * 0.3, h); ctx.stroke();
|
||||
}
|
||||
// Arcade cabinets (silhouettes with neon trim)
|
||||
var cabinets = [
|
||||
{ x: 0.12, scale: 0.9 }, { x: 0.32, scale: 1.0 },
|
||||
{ x: 0.52, scale: 1.0 }, { x: 0.72, scale: 0.9 }, { x: 0.88, scale: 0.8 }
|
||||
];
|
||||
for (var cab = 0; cab < cabinets.length; cab++) {
|
||||
var cbx = cabinets[cab].x * w;
|
||||
var cbs = cabinets[cab].scale;
|
||||
var cbw = w * 0.12 * cbs;
|
||||
var cbh = h * 0.45 * cbs;
|
||||
var cby = h * 0.68 - cbh;
|
||||
// Cabinet body
|
||||
Draw.rect(ctx, cbx - cbw / 2, cby, cbw, cbh, '#0a0a12');
|
||||
// Screen area
|
||||
var scrW = cbw * 0.7, scrH = cbh * 0.4;
|
||||
var scrX = cbx - scrW / 2, scrY = cby + cbh * 0.1;
|
||||
var screenColor = rng() > 0.5 ? c[2] : c[3];
|
||||
var screenGlow = 0.15 + Math.sin(t * 1.5 + cab * 1.2) * 0.08;
|
||||
Draw.rect(ctx, scrX, scrY, scrW, scrH, H.rgba(screenColor, screenGlow));
|
||||
Draw.glow(ctx, cbx, scrY + scrH / 2, scrW * 0.6, H.rgba(screenColor, screenGlow * 0.3));
|
||||
// Neon trim
|
||||
ctx.strokeStyle = H.rgba(screenColor, 0.25 + Math.sin(t * 2 + cab) * 0.1);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(scrX - 2, scrY - 2, scrW + 4, scrH + 4);
|
||||
// Control panel
|
||||
Draw.rect(ctx, cbx - cbw * 0.4, cby + cbh * 0.7, cbw * 0.8, cbh * 0.1, '#151520');
|
||||
// Joystick and buttons
|
||||
Draw.circle(ctx, cbx - cbw * 0.15, cby + cbh * 0.75, 3, '#333');
|
||||
for (var btn = 0; btn < 3; btn++) {
|
||||
Draw.circle(ctx, cbx + cbw * 0.05 + btn * 10, cby + cbh * 0.75, 3,
|
||||
H.rgba(btn === 0 ? '#FF2277' : btn === 1 ? '#2277FF' : '#FFCC00', 0.4));
|
||||
}
|
||||
}
|
||||
// "INSERT COIN" text glow
|
||||
var coinBlink = Math.sin(t * 2) > 0;
|
||||
if (coinBlink) {
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.fillStyle = H.rgba(c[3], 0.3);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('INSERT COIN', w * 0.5, h * 0.88);
|
||||
ctx.textAlign = 'start';
|
||||
}
|
||||
// Neon sign at top
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.2 + Math.sin(t * 1.8) * 0.08);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.25, h * 0.08); ctx.lineTo(w * 0.75, h * 0.08); ctx.stroke();
|
||||
Draw.glow(ctx, w * 0.5, h * 0.08, w * 0.3, H.rgba(c[2], 0.04));
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#FF2277' }
|
||||
},
|
||||
|
||||
crtEffects: {
|
||||
meta: { id: 'retro_crtEffects', mood: 'vintage', colors: ['#0a0a0a', '#0f1a0f', '#33ff33', '#88ff88'], tags: ['crt', 'monitor', 'phosphor', 'vintage'], description: 'Authentic CRT monitor with phosphor glow, barrel distortion and scan line interference', compatibleMusicMoods: ['retro', 'dark', 'electronic'], recommendedFor: ['action', 'puzzle', 'trivia'] },
|
||||
colors: ['#0a0a0a', '#0f1a0f', '#33ff33', '#88ff88'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Phosphor glow in center
|
||||
Draw.glow(ctx, w * 0.5, h * 0.5, Math.min(w, h) * 0.5, H.rgba(c[2], 0.03));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Scan lines
|
||||
Draw.scanlines(ctx, w, h, 3, H.rgba(c[2], 0.03 + Math.sin(t * 0.5) * 0.01));
|
||||
// RGB sub-pixel columns
|
||||
for (var col = 0; col < w; col += 3) {
|
||||
Draw.rect(ctx, col, 0, 1, h, H.rgba('#FF0000', 0.008));
|
||||
Draw.rect(ctx, col + 1, 0, 1, h, H.rgba('#00FF00', 0.008));
|
||||
Draw.rect(ctx, col + 2, 0, 1, h, H.rgba('#0000FF', 0.008));
|
||||
}
|
||||
// Rolling interference band
|
||||
var bandY = ((t * 30) % (h + 80)) - 40;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.04);
|
||||
ctx.fillRect(0, bandY, w, 40);
|
||||
ctx.fillStyle = H.rgba(c[2], 0.06);
|
||||
ctx.fillRect(0, bandY + 15, w, 8);
|
||||
// Static noise bursts
|
||||
var rng = H.seededRandom(Math.floor(t * 5) + 300);
|
||||
if (rng() > 0.7) {
|
||||
for (var nz = 0; nz < 50; nz++) {
|
||||
var nx = rng() * w, ny = rng() * h;
|
||||
var nw = 1 + rng() * 4;
|
||||
Draw.rect(ctx, nx, ny, nw, 1, H.rgba(c[2], rng() * 0.15));
|
||||
}
|
||||
}
|
||||
// Faux text content
|
||||
var txtrng = H.seededRandom(301);
|
||||
ctx.font = '12px monospace';
|
||||
for (var ln = 0; ln < 8; ln++) {
|
||||
var lineY = h * 0.25 + ln * 22;
|
||||
var lineStr = '';
|
||||
var lineLen = 10 + Math.floor(txtrng() * 25);
|
||||
for (var ch = 0; ch < lineLen; ch++) {
|
||||
lineStr += String.fromCharCode(33 + Math.floor(txtrng() * 93));
|
||||
}
|
||||
var txtAlpha = 0.08 + Math.sin(t * 0.4 + ln * 0.5) * 0.03;
|
||||
ctx.fillStyle = H.rgba(c[2], txtAlpha);
|
||||
ctx.fillText(lineStr, w * 0.15, lineY);
|
||||
}
|
||||
// Prompt cursor
|
||||
var cursorBlink = Math.sin(t * 3) > 0;
|
||||
if (cursorBlink) {
|
||||
Draw.rect(ctx, w * 0.15, h * 0.25 + 8 * 22, 8, 14, H.rgba(c[2], 0.3));
|
||||
}
|
||||
// Vignette / barrel distortion feel
|
||||
var vig = Draw.radialGradient(ctx, w / 2, h / 2, Math.max(w, h) * 0.65, [
|
||||
[0, H.rgba(c[0], 0)],
|
||||
[0.6, H.rgba(c[0], 0)],
|
||||
[1, H.rgba(c[0], 0.7)]
|
||||
]);
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// CRT bezel border
|
||||
ctx.strokeStyle = H.rgba('#333333', 0.3);
|
||||
ctx.lineWidth = 8;
|
||||
ctx.strokeRect(4, 4, w - 8, h - 8);
|
||||
},
|
||||
particles: null
|
||||
},
|
||||
|
||||
vaporwave: {
|
||||
meta: { id: 'retro_vaporwave', mood: 'aesthetic', colors: ['#0d0221', '#261447', '#ff71ce', '#01cdfe'], tags: ['vaporwave', 'aesthetic', '90s', 'neon'], description: 'A e s t h e t i c sunset horizon with a perspective grid, palm silhouettes and neon glow', compatibleMusicMoods: ['dreamy', 'retro', 'calm'], recommendedFor: ['creative', 'music', 'racing'] },
|
||||
colors: ['#0d0221', '#261447', '#ff71ce', '#01cdfe'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Sunset gradient
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], '#ff71ce', '#ff9de2'], 90);
|
||||
// Sun
|
||||
var sunY = h * 0.42 + Math.sin(t * 0.2) * 3;
|
||||
var sunGrad = Draw.linearGradient(ctx, 0, sunY - 60, 0, sunY + 60, [
|
||||
[0, '#FF6B9D'], [0.5, '#FFBE0B'], [1, '#FF006E']
|
||||
]);
|
||||
ctx.fillStyle = sunGrad;
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, sunY, 60, 0, Math.PI * 2); ctx.fill();
|
||||
// Sun horizontal stripes
|
||||
for (var ss = 0; ss < 6; ss++) {
|
||||
var ssy = sunY + ss * 10 - 10;
|
||||
var ssH = 3 + ss * 0.8;
|
||||
Draw.rect(ctx, w * 0.5 - 60, ssy, 120, ssH, c[0]);
|
||||
}
|
||||
Draw.glow(ctx, w * 0.5, sunY, 120, H.rgba('#FF71CE', 0.15));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Horizon line
|
||||
var horizonY = h * 0.55;
|
||||
Draw.rect(ctx, 0, horizonY, w, h - horizonY, '#0d0221');
|
||||
// Perspective grid on ground plane
|
||||
var gridColor = H.rgba(c[2], 0.2);
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
// Horizontal lines (receding)
|
||||
for (var gy = 0; gy < 20; gy++) {
|
||||
var lineFrac = gy / 20;
|
||||
var lineY = horizonY + lineFrac * lineFrac * (h - horizonY);
|
||||
ctx.beginPath(); ctx.moveTo(0, lineY); ctx.lineTo(w, lineY); ctx.stroke();
|
||||
}
|
||||
// Vertical lines (converging at center horizon)
|
||||
for (var gx = -8; gx <= 8; gx++) {
|
||||
var topX = w * 0.5 + gx * 2;
|
||||
var botX = w * 0.5 + gx * w * 0.08;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.15);
|
||||
ctx.beginPath(); ctx.moveTo(topX, horizonY); ctx.lineTo(botX, h); ctx.stroke();
|
||||
}
|
||||
// Palm tree silhouettes
|
||||
var rng = H.seededRandom(400);
|
||||
var palms = [{ x: 0.12, s: 1.0 }, { x: 0.85, s: 0.9 }, { x: 0.05, s: 0.7 }];
|
||||
for (var p = 0; p < palms.length; p++) {
|
||||
var px = palms[p].x * w;
|
||||
var ps = palms[p].s;
|
||||
var trunkH = h * 0.35 * ps;
|
||||
var palmBot = horizonY;
|
||||
// Trunk
|
||||
ctx.strokeStyle = '#0d0221';
|
||||
ctx.lineWidth = 4 * ps;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, palmBot);
|
||||
ctx.quadraticCurveTo(px + 10 * ps, palmBot - trunkH * 0.6, px + 5 * ps, palmBot - trunkH);
|
||||
ctx.stroke();
|
||||
// Fronds
|
||||
for (var fr = 0; fr < 6; fr++) {
|
||||
var frAngle = (fr / 6) * Math.PI * 2;
|
||||
var frLen = 30 * ps + Math.sin(t * 0.8 + fr) * 3;
|
||||
var sway = Math.sin(t * 0.5 + fr * 0.3) * 5;
|
||||
ctx.strokeStyle = '#0d0221';
|
||||
ctx.lineWidth = 2 * ps;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + 5 * ps, palmBot - trunkH);
|
||||
ctx.quadraticCurveTo(
|
||||
px + 5 * ps + Math.cos(frAngle) * frLen * 0.5 + sway,
|
||||
palmBot - trunkH + Math.sin(frAngle) * frLen * 0.3 - 10,
|
||||
px + 5 * ps + Math.cos(frAngle) * frLen + sway,
|
||||
palmBot - trunkH + Math.sin(frAngle) * frLen * 0.5 + 5
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Neon highlights on horizon
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.3 + Math.sin(t) * 0.1);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, horizonY); ctx.lineTo(w, horizonY); ctx.stroke();
|
||||
},
|
||||
particles: { type: 'stars', count: 40, color: '#FF71CE' }
|
||||
},
|
||||
|
||||
scanlines: {
|
||||
meta: { id: 'retro_scanlines', mood: 'technical', colors: ['#0a0a1a', '#141428', '#00aaff', '#ff4444'], tags: ['scanlines', 'display', 'technical', 'monitor'], description: 'Dense horizontal scan lines over a shifting color field with signal noise artifacts', compatibleMusicMoods: ['electronic', 'tense', 'dark'], recommendedFor: ['action', 'puzzle', 'physics'] },
|
||||
colors: ['#0a0a1a', '#141428', '#00aaff', '#ff4444'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Color field blocks shifting
|
||||
var rng = H.seededRandom(500);
|
||||
for (var blk = 0; blk < 8; blk++) {
|
||||
var bx = rng() * w * 0.8;
|
||||
var by = rng() * h * 0.8;
|
||||
var bw = w * 0.1 + rng() * w * 0.2;
|
||||
var bh = h * 0.05 + rng() * h * 0.15;
|
||||
var bColor = rng() > 0.5 ? c[2] : c[3];
|
||||
var bAlpha = 0.03 + Math.sin(t * 0.3 + blk * 0.7) * 0.02;
|
||||
Draw.rect(ctx, bx, by, bw, bh, H.rgba(bColor, bAlpha));
|
||||
}
|
||||
// Dense scanlines
|
||||
Draw.scanlines(ctx, w, h, 2, H.rgba('#FFFFFF', 0.03));
|
||||
// Thicker periodic scanlines
|
||||
for (var tsl = 0; tsl < h; tsl += 16) {
|
||||
Draw.rect(ctx, 0, tsl, w, 1, H.rgba(c[2], 0.02 + Math.sin(t * 0.5 + tsl * 0.01) * 0.01));
|
||||
}
|
||||
// Horizontal displacement glitch bands
|
||||
var glitchRng = H.seededRandom(Math.floor(t * 2) + 501);
|
||||
if (glitchRng() > 0.6) {
|
||||
for (var gb = 0; gb < 3; gb++) {
|
||||
var gy = glitchRng() * h;
|
||||
var gHeight = 2 + glitchRng() * 8;
|
||||
var gOffset = (glitchRng() - 0.5) * 20;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.08);
|
||||
ctx.fillRect(gOffset, gy, w, gHeight);
|
||||
ctx.fillStyle = H.rgba(c[3], 0.06);
|
||||
ctx.fillRect(-gOffset, gy + 1, w, gHeight * 0.5);
|
||||
}
|
||||
}
|
||||
// Signal meter bars
|
||||
for (var bar = 0; bar < 12; bar++) {
|
||||
var barH = h * 0.03 + Math.sin(t * 2 + bar * 0.5) * h * 0.02;
|
||||
var barColor = bar < 8 ? c[2] : c[3];
|
||||
Draw.rect(ctx, w * 0.05 + bar * w * 0.04, h * 0.9 - barH, w * 0.03, barH, H.rgba(barColor, 0.15));
|
||||
}
|
||||
// Crosshair / target
|
||||
var chAlpha = 0.1 + Math.sin(t * 0.6) * 0.04;
|
||||
ctx.strokeStyle = H.rgba(c[2], chAlpha);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, h * 0.5, 30, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5 - 40, h * 0.5); ctx.lineTo(w * 0.5 + 40, h * 0.5); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5, h * 0.5 - 40); ctx.lineTo(w * 0.5, h * 0.5 + 40); ctx.stroke();
|
||||
},
|
||||
particles: null
|
||||
},
|
||||
|
||||
glitch: {
|
||||
meta: { id: 'retro_glitch', mood: 'chaotic', colors: ['#0a0a0a', '#1a0a1a', '#ff0055', '#00ffcc'], tags: ['glitch', 'broken', 'chaotic', 'digital'], description: 'Fragmented display with chromatic aberration, displaced blocks and corrupted data streaks', compatibleMusicMoods: ['chaotic', 'electronic', 'tense'], recommendedFor: ['action', 'racing', 'physics'] },
|
||||
colors: ['#0a0a0a', '#1a0a1a', '#ff0055', '#00ffcc'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(Math.floor(t * 3) + 600);
|
||||
// Displaced rectangular blocks
|
||||
for (var blk = 0; blk < 6; blk++) {
|
||||
var bx = rng() * w;
|
||||
var by = rng() * h;
|
||||
var bw = 20 + rng() * 100;
|
||||
var bh = 3 + rng() * 20;
|
||||
var shift = (rng() - 0.5) * 30;
|
||||
// RGB channel separation
|
||||
Draw.rect(ctx, bx + shift, by, bw, bh, H.rgba(c[2], 0.08 + rng() * 0.1));
|
||||
Draw.rect(ctx, bx - shift, by + 1, bw, bh, H.rgba(c[3], 0.06 + rng() * 0.08));
|
||||
}
|
||||
// Static deterministic elements
|
||||
var srng = H.seededRandom(601);
|
||||
// Corrupted horizontal streaks
|
||||
for (var str = 0; str < 15; str++) {
|
||||
var sy = srng() * h;
|
||||
var sx = srng() * w * 0.3;
|
||||
var sw = w * 0.2 + srng() * w * 0.5;
|
||||
var jitter = Math.sin(t * 4 + str * 2) > 0.7 ? (srng() - 0.5) * 10 : 0;
|
||||
Draw.rect(ctx, sx + jitter, sy, sw, 1, H.rgba(srng() > 0.5 ? c[2] : c[3], 0.06));
|
||||
}
|
||||
// Large chromatic aberration text
|
||||
ctx.font = 'bold 48px monospace';
|
||||
var errAlpha = 0.06 + Math.sin(t * 1.5) * 0.03;
|
||||
var errShift = 3 + Math.sin(t * 5) * 2;
|
||||
ctx.fillStyle = H.rgba(c[2], errAlpha);
|
||||
ctx.fillText('ERR', w * 0.35 - errShift, h * 0.5);
|
||||
ctx.fillStyle = H.rgba(c[3], errAlpha);
|
||||
ctx.fillText('ERR', w * 0.35 + errShift, h * 0.5 + 2);
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', errAlpha * 0.5);
|
||||
ctx.fillText('ERR', w * 0.35, h * 0.5 + 1);
|
||||
// Data corruption blocks
|
||||
for (var dc = 0; dc < 10; dc++) {
|
||||
var dcx = srng() * w, dcy = srng() * h;
|
||||
var dcw = 4 + srng() * 12, dch = 4 + srng() * 12;
|
||||
var dcPulse = Math.sin(t * 3 + dc * 1.1) > 0.5;
|
||||
if (dcPulse) {
|
||||
Draw.rect(ctx, dcx, dcy, dcw, dch, H.rgba(srng() > 0.5 ? c[2] : c[3], 0.15));
|
||||
}
|
||||
}
|
||||
// Thin scan lines
|
||||
Draw.scanlines(ctx, w, h, 4, H.rgba('#FFFFFF', 0.02));
|
||||
// Occasional full-width flash
|
||||
if (Math.sin(t * 0.8) > 0.97) {
|
||||
Draw.rect(ctx, 0, 0, w, h, H.rgba('#FFFFFF', 0.03));
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 15, color: '#FF0055' }
|
||||
},
|
||||
|
||||
eightBit: {
|
||||
meta: { id: 'retro_eightBit', mood: 'cheerful', colors: ['#2b2b6e', '#3d3d94', '#5bc0eb', '#fde74c'], tags: ['8bit', 'nintendo', 'cheerful', 'classic'], description: 'A charming 8-bit scene with blocky pixel clouds, hills and a bright primary-color sky', compatibleMusicMoods: ['playful', 'retro', 'cheerful'], recommendedFor: ['action', 'puzzle', 'creative'] },
|
||||
colors: ['#2b2b6e', '#3d3d94', '#5bc0eb', '#fde74c'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Bright 8-bit sky
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(700);
|
||||
var px = 8; // pixel size for blocky look
|
||||
// Pixel sun
|
||||
var sunX = w * 0.75, sunY = h * 0.2;
|
||||
var sunPx = [
|
||||
[-1,-2],[0,-2],[1,-2],
|
||||
[-2,-1],[-1,-1],[0,-1],[1,-1],[2,-1],
|
||||
[-2,0],[-1,0],[0,0],[1,0],[2,0],
|
||||
[-2,1],[-1,1],[0,1],[1,1],[2,1],
|
||||
[-1,2],[0,2],[1,2]
|
||||
];
|
||||
for (var sp = 0; sp < sunPx.length; sp++) {
|
||||
Draw.rect(ctx, sunX + sunPx[sp][0] * px, sunY + sunPx[sp][1] * px, px, px, c[3]);
|
||||
}
|
||||
// Pixel clouds
|
||||
var cloudPositions = [{ x: 0.15, y: 0.18 }, { x: 0.45, y: 0.12 }, { x: 0.8, y: 0.22 }];
|
||||
var cloudShape = [[0,0],[1,0],[2,0],[3,0],[-1,1],[0,1],[1,1],[2,1],[3,1],[4,1],[0,2],[1,2],[2,2],[3,2]];
|
||||
for (var cl = 0; cl < cloudPositions.length; cl++) {
|
||||
var clx = cloudPositions[cl].x * w + Math.sin(t * 0.3 + cl) * 5;
|
||||
var cly = cloudPositions[cl].y * h;
|
||||
for (var cp = 0; cp < cloudShape.length; cp++) {
|
||||
Draw.rect(ctx, clx + cloudShape[cp][0] * px, cly + cloudShape[cp][1] * px, px, px, '#FFFFFF');
|
||||
}
|
||||
}
|
||||
// Pixel hills (green terrain at bottom)
|
||||
var groundY = h * 0.7;
|
||||
Draw.rect(ctx, 0, groundY, w, h - groundY, '#4CAF50');
|
||||
// Stepped hill shapes
|
||||
var hillRng = H.seededRandom(701);
|
||||
for (var hill = 0; hill < 5; hill++) {
|
||||
var hx = hillRng() * w;
|
||||
var hPeakH = 4 + Math.floor(hillRng() * 6);
|
||||
for (var hr = 0; hr < hPeakH; hr++) {
|
||||
var hRowW = (hPeakH - hr) * 2 + 1;
|
||||
for (var hc = 0; hc < hRowW; hc++) {
|
||||
Draw.rect(ctx, hx + (hc - Math.floor(hRowW / 2)) * px, groundY - hr * px, px, px, '#388E3C');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ground detail
|
||||
for (var gd = 0; gd < 15; gd++) {
|
||||
var gdx = Math.floor(rng() * (w / px)) * px;
|
||||
var gdy = groundY + Math.floor(rng() * ((h - groundY) / px)) * px;
|
||||
Draw.rect(ctx, gdx, gdy, px, px, H.rgba('#2E7D32', 0.5));
|
||||
}
|
||||
// Small pixel flowers
|
||||
var flowerColors = ['#FF5252', '#FFD740', '#E040FB', '#FFFFFF'];
|
||||
for (var fl = 0; fl < 8; fl++) {
|
||||
var flx = Math.floor(rng() * (w / px)) * px;
|
||||
var fly = groundY + px + Math.floor(rng() * 3) * px;
|
||||
Draw.rect(ctx, flx, fly, px, px, flowerColors[fl % flowerColors.length]);
|
||||
}
|
||||
// Coin block (floating, animated)
|
||||
var coinY = h * 0.5 + Math.sin(t * 2) * 2;
|
||||
var blockPulse = Math.sin(t * 1.5) > 0.8 ? '#FFD740' : c[3];
|
||||
Draw.rect(ctx, w * 0.5, coinY, px * 2, px * 2, blockPulse);
|
||||
ctx.strokeStyle = H.rgba('#000', 0.3);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(w * 0.5, coinY, px * 2, px * 2);
|
||||
Draw.rect(ctx, w * 0.5 + 3, coinY + 3, px * 2 - 6, px * 2 - 6, H.darken(blockPulse, 0.2));
|
||||
},
|
||||
particles: null
|
||||
},
|
||||
|
||||
synthwave: {
|
||||
meta: { id: 'retro_synthwave', mood: 'epic', colors: ['#0d0015', '#1a0033', '#ff2975', '#f222ff'], tags: ['synthwave', 'outrun', 'neon', 'night'], description: 'An outrun-style neon horizon with a receding road, mountain silhouettes and electric sky', compatibleMusicMoods: ['epic', 'energetic', 'electronic'], recommendedFor: ['racing', 'action', 'sports'] },
|
||||
colors: ['#0d0015', '#1a0033', '#ff2975', '#f222ff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Dark sky with neon gradient at horizon
|
||||
var skyGrad = Draw.linearGradient(ctx, 0, 0, 0, h * 0.55, [
|
||||
[0, c[0]], [0.5, c[1]], [0.85, '#330044'], [1, '#ff297530']
|
||||
]);
|
||||
ctx.fillStyle = skyGrad;
|
||||
ctx.fillRect(0, 0, w, h * 0.55);
|
||||
Draw.stars(ctx, w, h * 0.4, 70, '#FFFFFF', 801, [0.5, 1.5]);
|
||||
// Neon sun (half visible at horizon)
|
||||
var sunCy = h * 0.53;
|
||||
var sunGrad = Draw.linearGradient(ctx, 0, sunCy - 50, 0, sunCy + 50, [
|
||||
[0, c[2]], [0.5, c[3]], [1, '#FF6600']
|
||||
]);
|
||||
ctx.fillStyle = sunGrad;
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, sunCy, 55, Math.PI, 0); ctx.fill();
|
||||
// Sun horizontal lines
|
||||
for (var sl = 0; sl < 5; sl++) {
|
||||
var sly = sunCy - 45 + sl * 12;
|
||||
Draw.rect(ctx, w * 0.5 - 55, sly, 110, 3 + sl, c[0]);
|
||||
}
|
||||
Draw.glow(ctx, w * 0.5, sunCy, 100, H.rgba(c[2], 0.1));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var horizonY = h * 0.55;
|
||||
// Mountain silhouettes
|
||||
Draw.mountains(ctx, w, h, horizonY, 5, '#0a000f', 802);
|
||||
// Ground plane
|
||||
Draw.rect(ctx, 0, horizonY, w, h - horizonY, '#05000a');
|
||||
// Perspective grid - horizontal lines (receding, animated)
|
||||
var gridOffset = (t * 20) % 30;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.25);
|
||||
ctx.lineWidth = 1;
|
||||
for (var gy = 0; gy < 25; gy++) {
|
||||
var gridFrac = (gy + gridOffset / 30) / 25;
|
||||
var lineY = horizonY + gridFrac * gridFrac * (h - horizonY);
|
||||
var lineAlpha = gridFrac * 0.3;
|
||||
ctx.strokeStyle = H.rgba(c[3], lineAlpha);
|
||||
ctx.beginPath(); ctx.moveTo(0, lineY); ctx.lineTo(w, lineY); ctx.stroke();
|
||||
}
|
||||
// Vertical converging lines
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.2);
|
||||
for (var gx = -10; gx <= 10; gx++) {
|
||||
var topX = w * 0.5;
|
||||
var botX = w * 0.5 + gx * w * 0.065;
|
||||
ctx.beginPath(); ctx.moveTo(topX, horizonY); ctx.lineTo(botX, h); ctx.stroke();
|
||||
}
|
||||
// Road center stripe
|
||||
for (var rs = 0; rs < 12; rs++) {
|
||||
var rsFrac = rs / 12;
|
||||
var rsY = horizonY + rsFrac * rsFrac * (h - horizonY);
|
||||
var rsW = 2 + rsFrac * 6;
|
||||
var rsH = 3 + rsFrac * 8;
|
||||
var rsAlpha = 0.3 + rsFrac * 0.4;
|
||||
Draw.rect(ctx, w * 0.5 - rsW / 2, rsY, rsW, rsH, H.rgba('#FFFFFF', rsAlpha));
|
||||
}
|
||||
// Neon side lights along road
|
||||
for (var nl = 0; nl < 6; nl++) {
|
||||
var nlFrac = (nl + 0.5) / 6;
|
||||
var nlY = horizonY + nlFrac * nlFrac * (h - horizonY);
|
||||
var spread = nlFrac * w * 0.3;
|
||||
var nlSize = 2 + nlFrac * 5;
|
||||
var nlPulse = 0.4 + Math.sin(t * 2 + nl) * 0.2;
|
||||
// Left
|
||||
Draw.glow(ctx, w * 0.5 - spread, nlY, nlSize * 3, H.rgba(c[2], nlPulse * 0.3));
|
||||
Draw.circle(ctx, w * 0.5 - spread, nlY, nlSize, H.rgba(c[2], nlPulse));
|
||||
// Right
|
||||
Draw.glow(ctx, w * 0.5 + spread, nlY, nlSize * 3, H.rgba(c[3], nlPulse * 0.3));
|
||||
Draw.circle(ctx, w * 0.5 + spread, nlY, nlSize, H.rgba(c[3], nlPulse));
|
||||
}
|
||||
// Horizon glow line
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.35 + Math.sin(t * 0.8) * 0.1);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, horizonY); ctx.lineTo(w, horizonY); ctx.stroke();
|
||||
},
|
||||
particles: { type: 'stars', count: 20, color: '#F222FF' }
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,385 @@
|
||||
// seasonal.js — Seasonal background theme (8 variants)
|
||||
// Depends on backgroundEngine.js being loaded first
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('seasonal', {
|
||||
|
||||
springMeadow: {
|
||||
meta: { id: 'seasonal_springMeadow', mood: 'cheerful', colors: ['#87CEEB', '#98FB98', '#FFD700', '#FF69B4'], tags: ['spring', 'flowers', 'meadow', 'bright'], description: 'A sunny spring meadow with wildflowers and gentle hills', compatibleMusicMoods: ['happy', 'upbeat', 'peaceful'], recommendedFor: ['puzzle', 'creative', 'pet'] },
|
||||
colors: ['#87CEEB', '#98FB98', '#FFD700', '#FF69B4'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.lighten(c[0], 0.3)], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.6, 6, c[1], 10);
|
||||
Draw.hills(ctx, w, h, h * 0.7, 8, H.darken(c[1], 0.15), 20);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(42);
|
||||
// Sun
|
||||
Draw.glow(ctx, w * 0.8, h * 0.15, h * 0.12, c[2]);
|
||||
Draw.circle(ctx, w * 0.8, h * 0.15, h * 0.04, c[2]);
|
||||
// Wildflowers across the meadow
|
||||
for (var i = 0; i < 40; i++) {
|
||||
var fx = rng() * w;
|
||||
var fy = h * 0.62 + rng() * h * 0.25;
|
||||
var fs = 3 + rng() * 5;
|
||||
var sway = Math.sin(t * 1.5 + i * 0.7) * 2;
|
||||
// Stem
|
||||
ctx.strokeStyle = H.darken(c[1], 0.2);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(fx + sway, fy - fs * 3); ctx.stroke();
|
||||
// Petal
|
||||
var flowerColor = rng() > 0.5 ? c[3] : c[2];
|
||||
Draw.circle(ctx, fx + sway, fy - fs * 3, fs, flowerColor);
|
||||
Draw.circle(ctx, fx + sway, fy - fs * 3, fs * 0.4, '#FFFFFF');
|
||||
}
|
||||
// Butterflies
|
||||
for (var b = 0; b < 5; b++) {
|
||||
var bx = (rng() * w + t * 20 * (b + 1)) % (w + 40) - 20;
|
||||
var by = h * 0.3 + rng() * h * 0.3 + Math.sin(t * 2 + b) * 15;
|
||||
var wingFlap = Math.abs(Math.sin(t * 6 + b * 2)) * 6;
|
||||
ctx.fillStyle = H.rgba(c[3], 0.7);
|
||||
ctx.beginPath(); ctx.ellipse(bx - wingFlap, by, wingFlap, 3, -0.3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(bx + wingFlap, by, wingFlap, 3, 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'leaves', count: 10, color: '#FFB7C5' }
|
||||
},
|
||||
|
||||
autumnLeaves: {
|
||||
meta: { id: 'seasonal_autumnLeaves', mood: 'warm', colors: ['#2C1810', '#CC5500', '#DD8833', '#FFD700'], tags: ['autumn', 'fall', 'leaves', 'warm'], description: 'A warm autumn landscape with falling leaves and golden light', compatibleMusicMoods: ['mellow', 'peaceful', 'nostalgic'], recommendedFor: ['puzzle', 'story', 'rpg'] },
|
||||
colors: ['#2C1810', '#CC5500', '#DD8833', '#FFD700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#D4956B', '#E8A050', '#CC7733'], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.55, 5, '#8B6914', 15);
|
||||
Draw.hills(ctx, w, h, h * 0.65, 7, '#6B4E0A', 25);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(77);
|
||||
// Autumn trees
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var tx = rng() * w;
|
||||
var ty = h * 0.5 + rng() * h * 0.2;
|
||||
var th = 40 + rng() * 50;
|
||||
var tw = 4 + rng() * 4;
|
||||
ctx.fillStyle = c[0];
|
||||
ctx.fillRect(tx - tw / 2, ty - th * 0.4, tw, th * 0.5);
|
||||
var leafCol = [c[1], c[2], c[3]][Math.floor(rng() * 3)];
|
||||
var canopyR = th * 0.35 + Math.sin(t + i) * 2;
|
||||
Draw.circle(ctx, tx, ty - th * 0.55, canopyR, leafCol);
|
||||
Draw.circle(ctx, tx - canopyR * 0.5, ty - th * 0.45, canopyR * 0.7, H.darken(leafCol, 0.15));
|
||||
Draw.circle(ctx, tx + canopyR * 0.4, ty - th * 0.5, canopyR * 0.6, H.lighten(leafCol, 0.1));
|
||||
}
|
||||
// Pumpkins on the ground
|
||||
for (var p = 0; p < 6; p++) {
|
||||
var px = rng() * w;
|
||||
var py = h * 0.72 + rng() * h * 0.15;
|
||||
var ps = 5 + rng() * 7;
|
||||
Draw.circle(ctx, px, py, ps, '#E86800');
|
||||
Draw.circle(ctx, px, py, ps * 0.9, '#FF7700');
|
||||
ctx.fillStyle = '#336600';
|
||||
ctx.fillRect(px - 1, py - ps - 3, 2, 4);
|
||||
}
|
||||
},
|
||||
particles: { type: 'leaves', count: 30, color: '#DD6622' }
|
||||
},
|
||||
|
||||
winterWonderland: {
|
||||
meta: { id: 'seasonal_winterWonderland', mood: 'calm', colors: ['#1A2744', '#4A7CB5', '#C8E0F4', '#FFFFFF'], tags: ['winter', 'snow', 'cold', 'peaceful'], description: 'A serene winter landscape with snow-covered hills and frosty air', compatibleMusicMoods: ['peaceful', 'calm', 'ambient'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#1A2744', '#4A7CB5', '#C8E0F4', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.6, 5, c[2], 30);
|
||||
Draw.hills(ctx, w, h, h * 0.7, 7, c[3], 40);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(55);
|
||||
// Snow-covered evergreen trees
|
||||
for (var i = 0; i < 15; i++) {
|
||||
var tx = rng() * w;
|
||||
var ty = h * 0.55 + rng() * h * 0.25;
|
||||
var th = 25 + rng() * 45;
|
||||
// Trunk
|
||||
ctx.fillStyle = '#4A3728';
|
||||
ctx.fillRect(tx - 2, ty - 5, 4, 10);
|
||||
// Tree layers
|
||||
for (var layer = 0; layer < 3; layer++) {
|
||||
var lw = th * 0.4 * (1 - layer * 0.25);
|
||||
var ly = ty - 5 - layer * th * 0.25;
|
||||
ctx.fillStyle = '#1B4332';
|
||||
ctx.beginPath(); ctx.moveTo(tx - lw, ly); ctx.lineTo(tx, ly - th * 0.3); ctx.lineTo(tx + lw, ly); ctx.closePath(); ctx.fill();
|
||||
// Snow caps
|
||||
ctx.fillStyle = H.rgba(c[3], 0.8);
|
||||
ctx.beginPath(); ctx.moveTo(tx - lw * 0.7, ly - th * 0.08); ctx.lineTo(tx, ly - th * 0.3); ctx.lineTo(tx + lw * 0.7, ly - th * 0.08); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
// Distant moon glow
|
||||
var moonY = h * 0.12 + Math.sin(t * 0.1) * 3;
|
||||
Draw.glow(ctx, w * 0.2, moonY, h * 0.08, c[3]);
|
||||
Draw.circle(ctx, w * 0.2, moonY, h * 0.03, c[3]);
|
||||
},
|
||||
particles: { type: 'snow', count: 80, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
summerBeach: {
|
||||
meta: { id: 'seasonal_summerBeach', mood: 'energetic', colors: ['#0077BE', '#00BFFF', '#F4D03F', '#FF6347'], tags: ['summer', 'beach', 'ocean', 'sunny'], description: 'A vibrant summer beach scene with rolling waves and warm sand', compatibleMusicMoods: ['happy', 'upbeat', 'energetic'], recommendedFor: ['action', 'sports', 'racing'] },
|
||||
colors: ['#0077BE', '#00BFFF', '#F4D03F', '#FF6347'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Sand
|
||||
Draw.rect(ctx, 0, h * 0.65, w, h * 0.35, c[2]);
|
||||
// Shore wave line
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.4);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (var x = 0; x < w; x += 3) {
|
||||
var wy = h * 0.65 + Math.sin(x * 0.015 + t * 2) * 5 + Math.sin(x * 0.03 + t * 3) * 2;
|
||||
x === 0 ? ctx.moveTo(x, wy) : ctx.lineTo(x, wy);
|
||||
}
|
||||
ctx.stroke();
|
||||
Draw.water(ctx, w, h, h * 0.45, H.rgba(c[0], 0.4), 4, 50);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(88);
|
||||
// Sun
|
||||
Draw.glow(ctx, w * 0.75, h * 0.1, h * 0.15, '#FFD700');
|
||||
Draw.circle(ctx, w * 0.75, h * 0.1, h * 0.05, '#FFE44D');
|
||||
// Palm trees
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var px = w * 0.1 + rng() * w * 0.35;
|
||||
var py = h * 0.65;
|
||||
var ph = 60 + rng() * 40;
|
||||
var lean = (rng() - 0.5) * 0.3;
|
||||
// Curved trunk
|
||||
ctx.strokeStyle = '#8B6914';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath(); ctx.moveTo(px, py);
|
||||
ctx.quadraticCurveTo(px + lean * ph, py - ph * 0.5, px + lean * ph * 1.5, py - ph);
|
||||
ctx.stroke();
|
||||
// Fronds
|
||||
var topX = px + lean * ph * 1.5, topY = py - ph;
|
||||
for (var f = 0; f < 6; f++) {
|
||||
var angle = (f / 6) * Math.PI * 2 + Math.sin(t * 0.8 + f) * 0.1;
|
||||
var fLen = 25 + rng() * 20;
|
||||
ctx.strokeStyle = '#228B22';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(topX, topY);
|
||||
ctx.quadraticCurveTo(topX + Math.cos(angle) * fLen * 0.6, topY + Math.sin(angle) * fLen * 0.3, topX + Math.cos(angle) * fLen, topY + Math.abs(Math.sin(angle)) * fLen * 0.5 + 8);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Beach umbrella
|
||||
var ux = w * 0.6, uy = h * 0.58;
|
||||
ctx.fillStyle = '#8B6914';
|
||||
ctx.fillRect(ux - 1.5, uy, 3, h * 0.12);
|
||||
ctx.fillStyle = c[3];
|
||||
ctx.beginPath(); ctx.arc(ux, uy, 25, Math.PI, 0); ctx.fill();
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.beginPath(); ctx.arc(ux, uy, 25, Math.PI, Math.PI + Math.PI / 4); ctx.lineTo(ux, uy); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(ux, uy, 25, Math.PI + Math.PI / 2, Math.PI + Math.PI * 3 / 4); ctx.lineTo(ux, uy); ctx.fill();
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#FFE44D' }
|
||||
},
|
||||
|
||||
harvest: {
|
||||
meta: { id: 'seasonal_harvest', mood: 'warm', colors: ['#5C3317', '#D4A017', '#8B4513', '#FFD700'], tags: ['harvest', 'farm', 'autumn', 'golden'], description: 'Golden harvest fields with wheat rows and a warm sunset', compatibleMusicMoods: ['mellow', 'peaceful', 'warm'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#5C3317', '#D4A017', '#8B4513', '#FFD700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#E8913A', '#F4C54D', c[1]], 90);
|
||||
// Field rows
|
||||
Draw.rect(ctx, 0, h * 0.55, w, h * 0.45, '#B8860B');
|
||||
for (var r = 0; r < 8; r++) {
|
||||
var ry = h * 0.58 + r * h * 0.05;
|
||||
ctx.fillStyle = H.rgba(H.darken(c[1], 0.1 + r * 0.03), 0.6);
|
||||
ctx.fillRect(0, ry, w, 2);
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(99);
|
||||
// Wheat stalks
|
||||
for (var i = 0; i < 60; i++) {
|
||||
var wx = rng() * w;
|
||||
var wy = h * 0.56 + rng() * h * 0.35;
|
||||
var wh = 15 + rng() * 25;
|
||||
var sway = Math.sin(t * 1.2 + i * 0.5) * 3;
|
||||
ctx.strokeStyle = H.rgba(c[1], 0.7);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(wx + sway, wy - wh); ctx.stroke();
|
||||
// Wheat head
|
||||
ctx.fillStyle = c[3];
|
||||
ctx.beginPath(); ctx.ellipse(wx + sway, wy - wh - 3, 2, 5, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// Sunset glow
|
||||
Draw.glow(ctx, w * 0.5, h * 0.12, h * 0.18, '#FF8C00');
|
||||
Draw.circle(ctx, w * 0.5, h * 0.12, h * 0.06, '#FFD700');
|
||||
// Barn silhouette
|
||||
var bx = w * 0.78, by = h * 0.48, bw = 50, bh = 35;
|
||||
ctx.fillStyle = c[2];
|
||||
ctx.fillRect(bx, by, bw, bh);
|
||||
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw / 2, by - 25); ctx.lineTo(bx + bw + 5, by); ctx.closePath();
|
||||
ctx.fillStyle = H.darken(c[2], 0.2);
|
||||
ctx.fill();
|
||||
// Silo
|
||||
ctx.fillStyle = c[2];
|
||||
ctx.fillRect(bx + bw + 8, by - 10, 12, bh + 10);
|
||||
ctx.beginPath(); ctx.arc(bx + bw + 14, by - 10, 6, 0, Math.PI * 2); ctx.fill();
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#DDCC88' }
|
||||
},
|
||||
|
||||
blossom: {
|
||||
meta: { id: 'seasonal_blossom', mood: 'peaceful', colors: ['#FFB7C5', '#FF69B4', '#FFF0F5', '#98FB98'], tags: ['spring', 'cherry', 'blossom', 'pink'], description: 'Gentle cherry blossom petals drifting under pink-tinted skies', compatibleMusicMoods: ['peaceful', 'calm', 'romantic'], recommendedFor: ['puzzle', 'story', 'creative', 'music'] },
|
||||
colors: ['#FFB7C5', '#FF69B4', '#FFF0F5', '#98FB98'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[2], c[0], H.lighten(c[3], 0.3)], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.7, 6, c[3], 12);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(33);
|
||||
// Cherry blossom trees
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var tx = w * 0.1 + rng() * w * 0.8;
|
||||
var ty = h * 0.65 + rng() * h * 0.1;
|
||||
var th = 50 + rng() * 40;
|
||||
// Trunk and branches
|
||||
ctx.strokeStyle = '#5C3A1E';
|
||||
ctx.lineWidth = 4 + rng() * 3;
|
||||
ctx.beginPath(); ctx.moveTo(tx, ty); ctx.quadraticCurveTo(tx + rng() * 10 - 5, ty - th * 0.5, tx, ty - th); ctx.stroke();
|
||||
// Branch clusters
|
||||
for (var b = 0; b < 4; b++) {
|
||||
var bAngle = (rng() - 0.5) * Math.PI * 0.8;
|
||||
var bLen = 15 + rng() * 25;
|
||||
var bStartY = ty - th * (0.5 + rng() * 0.4);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(tx, bStartY);
|
||||
ctx.lineTo(tx + Math.cos(bAngle) * bLen, bStartY + Math.sin(bAngle) * bLen - 10);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Blossom clouds
|
||||
for (var bc = 0; bc < 6; bc++) {
|
||||
var bx = tx + (rng() - 0.5) * th * 0.8;
|
||||
var by = ty - th * (0.4 + rng() * 0.5);
|
||||
var br = 8 + rng() * 15;
|
||||
var pulseR = br + Math.sin(t * 0.8 + i + bc) * 1.5;
|
||||
Draw.circle(ctx, bx, by, pulseR, H.rgba(c[0], 0.5 + rng() * 0.3));
|
||||
Draw.circle(ctx, bx + br * 0.3, by - br * 0.2, pulseR * 0.7, H.rgba(c[1], 0.3));
|
||||
}
|
||||
}
|
||||
// Soft light
|
||||
Draw.glow(ctx, w * 0.5, h * 0.08, h * 0.12, '#FFF5EE');
|
||||
},
|
||||
particles: { type: 'leaves', count: 25, color: '#FFB7C5' }
|
||||
},
|
||||
|
||||
snowfall: {
|
||||
meta: { id: 'seasonal_snowfall', mood: 'calm', colors: ['#0D1B2A', '#1B2838', '#415A77', '#E0E1DD'], tags: ['winter', 'night', 'snow', 'dark'], description: 'Heavy snowfall over a dark winter night with distant lights', compatibleMusicMoods: ['calm', 'ambient', 'mysterious'], recommendedFor: ['puzzle', 'story', 'rpg'] },
|
||||
colors: ['#0D1B2A', '#1B2838', '#415A77', '#E0E1DD'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.mountains(ctx, w, h, h * 0.5, 6, c[2], 60);
|
||||
Draw.hills(ctx, w, h, h * 0.7, 5, H.lighten(c[2], 0.2), 70);
|
||||
// Snow ground
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, H.rgba(c[3], 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(44);
|
||||
// Distant village lights
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var lx = rng() * w;
|
||||
var ly = h * 0.55 + rng() * h * 0.15;
|
||||
var twinkle = 0.3 + Math.sin(t * 2 + i * 1.5) * 0.2;
|
||||
Draw.glow(ctx, lx, ly, 8, H.rgba('#FFD700', twinkle));
|
||||
Draw.circle(ctx, lx, ly, 1.5, H.rgba('#FFE088', twinkle + 0.3));
|
||||
}
|
||||
// Bare tree silhouettes
|
||||
for (var tr = 0; tr < 6; tr++) {
|
||||
var tx = rng() * w;
|
||||
var ty = h * 0.75 + rng() * h * 0.05;
|
||||
var th = 30 + rng() * 40;
|
||||
ctx.strokeStyle = H.rgba(c[0], 0.6);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(tx, ty - th); ctx.stroke();
|
||||
// Branches
|
||||
for (var br = 0; br < 4; br++) {
|
||||
var brY = ty - th * (0.3 + br * 0.18);
|
||||
var brDir = br % 2 === 0 ? 1 : -1;
|
||||
var brLen = 8 + rng() * 15;
|
||||
ctx.beginPath(); ctx.moveTo(tx, brY);
|
||||
ctx.lineTo(tx + brDir * brLen, brY - brLen * 0.6);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Snow on ground — gentle undulation
|
||||
ctx.fillStyle = H.rgba(c[3], 0.15);
|
||||
ctx.beginPath(); ctx.moveTo(0, h);
|
||||
for (var x = 0; x <= w; x += 20) {
|
||||
ctx.lineTo(x, h * 0.78 + Math.sin(x * 0.03 + t * 0.3) * 4);
|
||||
}
|
||||
ctx.lineTo(w, h); ctx.closePath(); ctx.fill();
|
||||
},
|
||||
particles: { type: 'snow', count: 120, color: '#E0E1DD' }
|
||||
},
|
||||
|
||||
tropical: {
|
||||
meta: { id: 'seasonal_tropical', mood: 'energetic', colors: ['#00CED1', '#FF6347', '#228B22', '#FFD700'], tags: ['summer', 'tropical', 'jungle', 'vibrant'], description: 'A lush tropical scene with dense foliage and vivid colors', compatibleMusicMoods: ['energetic', 'upbeat', 'happy'], recommendedFor: ['action', 'racing', 'sports', 'pet'] },
|
||||
colors: ['#00CED1', '#FF6347', '#228B22', '#FFD700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [H.lighten(c[0], 0.2), c[0]], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.5, 4, c[2], 35);
|
||||
Draw.hills(ctx, w, h, h * 0.6, 6, H.darken(c[2], 0.2), 45);
|
||||
// Jungle floor
|
||||
Draw.rect(ctx, 0, h * 0.75, w, h * 0.25, H.darken(c[2], 0.35));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(66);
|
||||
// Tropical flowers
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var fx = rng() * w;
|
||||
var fy = h * 0.55 + rng() * h * 0.3;
|
||||
var fs = 4 + rng() * 6;
|
||||
var bloom = fs + Math.sin(t + i * 0.8) * 1;
|
||||
var flColor = rng() > 0.5 ? c[1] : c[3];
|
||||
// Petals
|
||||
ctx.save(); ctx.translate(fx, fy);
|
||||
for (var p = 0; p < 5; p++) {
|
||||
ctx.rotate(Math.PI * 2 / 5);
|
||||
ctx.fillStyle = H.rgba(flColor, 0.7);
|
||||
ctx.beginPath(); ctx.ellipse(0, -bloom, bloom * 0.4, bloom, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
Draw.circle(ctx, fx, fy, bloom * 0.3, '#FFFFFF');
|
||||
}
|
||||
// Large palm fronds at edges
|
||||
for (var f = 0; f < 3; f++) {
|
||||
var side = f % 2 === 0 ? 0 : w;
|
||||
var dir = f % 2 === 0 ? 1 : -1;
|
||||
var fy2 = h * 0.05 + f * h * 0.15;
|
||||
var sway = Math.sin(t * 0.5 + f) * 8;
|
||||
ctx.strokeStyle = c[2];
|
||||
ctx.lineWidth = 4;
|
||||
for (var fr = 0; fr < 4; fr++) {
|
||||
var angle = (0.2 + fr * 0.3) * dir;
|
||||
ctx.beginPath(); ctx.moveTo(side, fy2 + fr * 20);
|
||||
ctx.quadraticCurveTo(side + dir * (60 + sway), fy2 + fr * 20 + 30, side + dir * (100 + sway), fy2 + fr * 20 + 60);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Parrots
|
||||
for (var b = 0; b < 3; b++) {
|
||||
var bx = (rng() * w + t * 15 * (b + 1)) % (w + 30) - 15;
|
||||
var by = h * 0.15 + rng() * h * 0.2 + Math.sin(t * 1.5 + b * 2) * 10;
|
||||
var wingF = Math.abs(Math.sin(t * 5 + b * 3)) * 5;
|
||||
ctx.fillStyle = [c[1], c[3], '#00AA44'][b % 3];
|
||||
ctx.beginPath(); ctx.ellipse(bx, by, 4, 2.5, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = H.rgba('#000000', 0.4);
|
||||
ctx.beginPath(); ctx.ellipse(bx - wingF * 0.8, by - 1, wingF, 2, -0.4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(bx + wingF * 0.8, by - 1, wingF, 2, 0.4, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'fireflies', count: 15, color: '#CCFF44' }
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,232 @@
|
||||
// sky.js — Sky background theme (8 variants)
|
||||
// Depends on backgroundEngine.js being loaded first
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
function drawCloud(ctx, x, y, s, col, a) {
|
||||
ctx.fillStyle = H.rgba(col, a);
|
||||
ctx.beginPath(); ctx.ellipse(x, y, s, s * 0.45, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(x - s * 0.45, y + s * 0.15, s * 0.55, s * 0.38, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(x + s * 0.4, y + s * 0.12, s * 0.5, s * 0.35, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(x + s * 0.15, y - s * 0.2, s * 0.4, s * 0.3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function drawBird(ctx, x, y, s, col) {
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.beginPath();
|
||||
ctx.moveTo(x - s, y + s * 0.3); ctx.quadraticCurveTo(x - s * 0.3, y - s * 0.5, x, y);
|
||||
ctx.quadraticCurveTo(x + s * 0.3, y - s * 0.5, x + s, y + s * 0.3); ctx.stroke();
|
||||
}
|
||||
|
||||
engine.registerTheme('sky', {
|
||||
|
||||
clouds: {
|
||||
meta: { id: 'sky_clouds', mood: 'peaceful', colors: ['#4A90D9', '#87CEEB', '#FFFFFF', '#F0F8FF'], tags: ['clouds', 'blue', 'daytime', 'fluffy'], description: 'A bright blue sky filled with drifting puffy white clouds', compatibleMusicMoods: ['peaceful', 'happy', 'calm'], recommendedFor: ['puzzle', 'creative', 'pet'] },
|
||||
colors: ['#4A90D9', '#87CEEB', '#FFFFFF', '#F0F8FF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.lighten(c[1], 0.15)], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(20);
|
||||
for (var i = 0; i < 8; i++) drawCloud(ctx, (rng() * w * 1.4 - w * 0.2 + t * 3) % (w + 120) - 60, h * 0.1 + rng() * h * 0.25, 25 + rng() * 35, c[3], 0.25 + rng() * 0.15);
|
||||
for (var j = 0; j < 6; j++) drawCloud(ctx, (rng() * w * 1.4 - w * 0.2 + t * 7) % (w + 200) - 100, h * 0.2 + rng() * h * 0.3, 40 + rng() * 55, c[2], 0.4 + rng() * 0.25);
|
||||
for (var k = 0; k < 4; k++) {
|
||||
var nx = (rng() * w * 1.6 - w * 0.3 + t * 12) % (w + 300) - 150, ny = h * 0.35 + rng() * h * 0.35, ns = 60 + rng() * 80;
|
||||
drawCloud(ctx, nx, ny, ns, c[2], 0.6 + rng() * 0.3);
|
||||
drawCloud(ctx, nx + 5, ny + ns * 0.5, ns * 0.8, c[0], 0.05);
|
||||
}
|
||||
for (var b = 0; b < 4; b++) drawBird(ctx, (rng() * w + t * 18 * (b + 1)) % (w + 40) - 20, h * 0.08 + rng() * h * 0.2 + Math.sin(t * 1.5 + b * 2) * 5, 4 + rng() * 4, H.rgba(c[0], 0.3));
|
||||
},
|
||||
particles: { type: 'dust', count: 10, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
flyingHigh: {
|
||||
meta: { id: 'sky_flyingHigh', mood: 'energetic', colors: ['#1E3A6E', '#4682B4', '#FFD700', '#FFFFFF'], tags: ['flying', 'altitude', 'wind', 'bright'], description: 'High altitude perspective with streaking wind lines and brilliant sun', compatibleMusicMoods: ['energetic', 'upbeat', 'adventurous'], recommendedFor: ['action', 'racing', 'sports'] },
|
||||
colors: ['#1E3A6E', '#4682B4', '#FFD700', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.glow(ctx, w * 0.7, h * 0.2, h * 0.2, c[2]); Draw.circle(ctx, w * 0.7, h * 0.2, h * 0.06, c[2]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(40);
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < 20; i++) {
|
||||
var sy = rng() * h, sx = (rng() * w * 2 - w * 0.5 + t * 80 * (0.5 + rng())) % (w + 100) - 50;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.08 + rng() * 0.12);
|
||||
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + 30 + rng() * 80, sy - 1); ctx.stroke();
|
||||
}
|
||||
for (var j = 0; j < 6; j++) {
|
||||
ctx.fillStyle = H.rgba(c[3], 0.08 + rng() * 0.08);
|
||||
ctx.beginPath(); ctx.ellipse((rng() * w * 1.5 + t * 15) % (w + 200) - 100, h * 0.1 + rng() * h * 0.4, 80 + rng() * 60, 5 + rng() * 8, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (var r = 0; r < 8; r++) {
|
||||
var ang = (r / 8) * Math.PI * 2 + t * 0.05, rL = h * 0.15 + Math.sin(t * 0.5 + r) * h * 0.03;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.06); ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.7 + Math.cos(ang) * h * 0.07, h * 0.2 + Math.sin(ang) * h * 0.07); ctx.lineTo(w * 0.7 + Math.cos(ang) * rL, h * 0.2 + Math.sin(ang) * rL); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
particles: { type: 'sparkles', count: 15, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
hotAirBalloon: {
|
||||
meta: { id: 'sky_hotAirBalloon', mood: 'cheerful', colors: ['#87CEEB', '#FF6347', '#FFD700', '#FFFFFF'], tags: ['balloon', 'colorful', 'gentle', 'whimsical'], description: 'Colorful hot air balloons floating gently across a warm sky', compatibleMusicMoods: ['happy', 'peaceful', 'cheerful'], recommendedFor: ['puzzle', 'creative', 'pet', 'music'] },
|
||||
colors: ['#87CEEB', '#FF6347', '#FFD700', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], H.lighten(c[0], 0.25), '#FFEEDD'], 90);
|
||||
Draw.clouds(ctx, w, h, h * 0.7, 6, c[3], 30);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(52);
|
||||
var bals = [{ c1: c[1], c2: c[2] }, { c1: '#8844CC', c2: '#44BBEE' }, { c1: '#44AA44', c2: '#FFAA33' }, { c1: '#EE5588', c2: '#FFDD44' }];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var bx = w * 0.15 + rng() * w * 0.7 + Math.sin(t * 0.3 + i * 2) * 15;
|
||||
var by = h * 0.15 + rng() * h * 0.4 + Math.sin(t * 0.5 + i * 1.5) * 10, bs = 18 + rng() * 22;
|
||||
ctx.fillStyle = bals[i].c1; ctx.beginPath(); ctx.ellipse(bx, by, bs, bs * 1.2, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = bals[i].c2; ctx.beginPath(); ctx.ellipse(bx, by, bs * 0.35, bs * 1.18, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = H.rgba('#FFF', 0.2); ctx.beginPath(); ctx.ellipse(bx - bs * 0.25, by - bs * 0.3, bs * 0.25, bs * 0.4, -0.3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = H.rgba('#444', 0.4); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(bx - bs * 0.3, by + bs * 1.1); ctx.lineTo(bx - 4, by + bs * 1.6); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(bx + bs * 0.3, by + bs * 1.1); ctx.lineTo(bx + 4, by + bs * 1.6); ctx.stroke();
|
||||
ctx.fillStyle = '#8B6914'; ctx.fillRect(bx - 5, by + bs * 1.55, 10, 7);
|
||||
}
|
||||
for (var k = 0; k < 5; k++) drawCloud(ctx, (rng() * w * 1.3 + t * 5) % (w + 150) - 75, h * 0.3 + rng() * h * 0.35, 30 + rng() * 40, c[3], 0.2 + rng() * 0.15);
|
||||
for (var b = 0; b < 3; b++) drawBird(ctx, (rng() * w + t * 22) % (w + 30) - 15, h * 0.1 + rng() * h * 0.15, 3 + rng() * 3, H.rgba('#555', 0.25));
|
||||
},
|
||||
particles: { type: 'dust', count: 8, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
airplaneView: {
|
||||
meta: { id: 'sky_airplaneView', mood: 'calm', colors: ['#1A3366', '#336699', '#FFFFFF', '#AACCEE'], tags: ['airplane', 'altitude', 'above-clouds', 'travel'], description: 'View from an airplane window looking down at a sea of clouds', compatibleMusicMoods: ['calm', 'peaceful', 'ambient'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#1A3366', '#336699', '#FFFFFF', '#AACCEE'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.55, 12, H.rgba(c[3], 0.6), 33);
|
||||
Draw.hills(ctx, w, h, h * 0.57, 10, H.rgba(c[2], 0.5), 44);
|
||||
Draw.rect(ctx, 0, h * 0.65, w, h * 0.35, H.rgba(c[2], 0.7));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(64);
|
||||
for (var i = 0; i < 12; i++) drawCloud(ctx, (rng() * w * 1.4 + t * 4) % (w + 100) - 50, h * 0.58 + rng() * h * 0.25, 30 + rng() * 60, c[2], 0.3 + rng() * 0.3);
|
||||
// Wing
|
||||
ctx.fillStyle = '#C0C0C0'; ctx.beginPath(); ctx.moveTo(-10, h * 0.5); ctx.lineTo(w * 0.25, h * 0.48); ctx.lineTo(w * 0.28, h * 0.5); ctx.lineTo(w * 0.12, h * 0.52); ctx.lineTo(-10, h * 0.52); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = H.rgba('#FFF', 0.3); ctx.beginPath(); ctx.moveTo(-10, h * 0.5); ctx.lineTo(w * 0.25, h * 0.48); ctx.lineTo(w * 0.22, h * 0.49); ctx.lineTo(-10, h * 0.505); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = '#D0D0D0'; ctx.beginPath(); ctx.moveTo(w * 0.25, h * 0.48); ctx.lineTo(w * 0.27, h * 0.44); ctx.lineTo(w * 0.28, h * 0.48); ctx.closePath(); ctx.fill();
|
||||
var blink = Math.sin(t * 3) > 0.7 ? 0.9 : 0.1;
|
||||
Draw.glow(ctx, w * 0.26, h * 0.485, 5, H.rgba('#FF0000', blink));
|
||||
Draw.circle(ctx, w * 0.26, h * 0.485, 1.5, H.rgba('#FF3333', blink + 0.2));
|
||||
Draw.glow(ctx, w * 0.8, h * 0.08, h * 0.12, '#FFE88A'); Draw.circle(ctx, w * 0.8, h * 0.08, h * 0.035, '#FFE44D');
|
||||
},
|
||||
particles: { type: 'dust', count: 5, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
stratosphere: {
|
||||
meta: { id: 'sky_stratosphere', mood: 'mysterious', colors: ['#0A0A2E', '#1A1A5E', '#4444AA', '#8888DD'], tags: ['stratosphere', 'space', 'high-altitude', 'curvature'], description: 'The edge of space where the atmosphere fades and Earth curves below', compatibleMusicMoods: ['mysterious', 'ambient', 'calm'], recommendedFor: ['story', 'rpg', 'puzzle'] },
|
||||
colors: ['#0A0A2E', '#1A1A5E', '#4444AA', '#8888DD'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#000010', c[0], c[1]], 90);
|
||||
var gr = ctx.createLinearGradient(0, h * 0.75, 0, h);
|
||||
gr.addColorStop(0, 'transparent'); gr.addColorStop(0.3, H.rgba(c[2], 0.15)); gr.addColorStop(0.7, H.rgba(c[3], 0.25)); gr.addColorStop(1, H.rgba('#AABBEE', 0.35));
|
||||
ctx.fillStyle = gr; ctx.fillRect(0, h * 0.75, w, h * 0.25);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(85);
|
||||
Draw.stars(ctx, w, h * 0.5, 80, '#FFFFFF', 85, [0.3, 1.8]);
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.3); ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(-20, h * 0.88); ctx.quadraticCurveTo(w / 2, h * 0.82, w + 20, h * 0.88); ctx.stroke();
|
||||
for (var i = 0; i < 3; i++) { ctx.fillStyle = H.rgba(c[2 + (i > 1 ? 0 : 1)], 0.06 - i * 0.015 + Math.sin(t * 0.3 + i) * 0.01); ctx.fillRect(0, h * 0.78 + i * h * 0.04, w, h * 0.03); }
|
||||
var satX = w * 0.35 + Math.sin(t * 0.2) * 30, satY = h * 0.3 + Math.cos(t * 0.15) * 10;
|
||||
ctx.fillStyle = '#AAA'; ctx.fillRect(satX - 2, satY - 1, 4, 2);
|
||||
ctx.fillStyle = '#4466AA'; ctx.fillRect(satX - 10, satY - 0.5, 7, 1); ctx.fillRect(satX + 3, satY - 0.5, 7, 1);
|
||||
var gl = Math.max(0, Math.sin(t * 2));
|
||||
if (gl > 0.9) Draw.glow(ctx, satX, satY, 8, H.rgba('#FFF', (gl - 0.9) * 10));
|
||||
var sp = (t * 0.3) % 6;
|
||||
if (sp < 0.5) { var p = sp / 0.5; ctx.strokeStyle = H.rgba('#FFF', 0.6 * (1 - p)); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(w * 0.8 - p * w * 0.3, h * 0.1 + p * h * 0.2); ctx.lineTo(w * 0.8 - p * w * 0.3 + 20, h * 0.1 + p * h * 0.2 - 8); ctx.stroke(); }
|
||||
},
|
||||
particles: { type: 'stars', count: 40, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
birdsEye: {
|
||||
meta: { id: 'sky_birdsEye', mood: 'peaceful', colors: ['#5588BB', '#88BBDD', '#228B22', '#DEB887'], tags: ['aerial', 'landscape', 'birds-eye', 'fields'], description: 'A bird\'s eye view looking down at patchwork fields and winding rivers', compatibleMusicMoods: ['peaceful', 'calm', 'happy'], recommendedFor: ['puzzle', 'creative', 'story'] },
|
||||
colors: ['#5588BB', '#88BBDD', '#228B22', '#DEB887'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.4, w, h * 0.6, c[2]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(73), fC = [c[2], H.darken(c[2], 0.15), H.lighten(c[2], 0.1), c[3], '#AAB86B', '#8CAA58'];
|
||||
var gW = 6, gH = 5;
|
||||
for (var gy = 0; gy < gH; gy++) for (var gx = 0; gx < gW; gx++) { ctx.fillStyle = fC[Math.floor(rng() * fC.length)]; ctx.fillRect(gx * (w / gW) + 1, h * 0.4 + gy * (h * 0.6 / gH) + 1, w / gW - 2, h * 0.6 / gH - 2); }
|
||||
ctx.strokeStyle = H.rgba('#556633', 0.3); ctx.lineWidth = 1.5;
|
||||
for (var lx = 0; lx <= gW; lx++) { ctx.beginPath(); ctx.moveTo(lx * (w / gW), h * 0.4); ctx.lineTo(lx * (w / gW), h); ctx.stroke(); }
|
||||
for (var ly = 0; ly <= gH; ly++) { var yy = h * 0.4 + ly * (h * 0.6 / gH); ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(w, yy); ctx.stroke(); }
|
||||
ctx.strokeStyle = '#4488BB'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(w * 0.3, h * 0.4);
|
||||
for (var s = 1; s <= 8; s++) ctx.lineTo(w * 0.3 + Math.sin(s * 0.9 + 0.5) * w * 0.12, h * 0.4 + (h * 0.6 / 8) * s);
|
||||
ctx.stroke();
|
||||
for (var i = 0; i < 5; i++) drawCloud(ctx, (rng() * w * 1.3 + t * 6) % (w + 150) - 75, h * 0.05 + rng() * h * 0.25, 50 + rng() * 70, '#FFF', 0.35 + rng() * 0.25);
|
||||
for (var cs = 0; cs < 4; cs++) { ctx.fillStyle = H.rgba('#000', 0.06); ctx.beginPath(); ctx.ellipse((rng() * w * 1.3 + t * 6) % (w + 100) - 50, h * 0.5 + rng() * h * 0.35, 50 + rng() * 40, 25 + rng() * 15, 0, 0, Math.PI * 2); ctx.fill(); }
|
||||
},
|
||||
particles: { type: 'dust', count: 6, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
horizon: {
|
||||
meta: { id: 'sky_horizon', mood: 'warm', colors: ['#1A0533', '#CC4488', '#FF8844', '#FFD700'], tags: ['sunset', 'horizon', 'golden', 'dramatic'], description: 'A dramatic sunset horizon with layers of warm orange, pink, and gold', compatibleMusicMoods: ['mellow', 'warm', 'nostalgic', 'peaceful'], recommendedFor: ['story', 'music', 'creative', 'rpg'] },
|
||||
colors: ['#1A0533', '#CC4488', '#FF8844', '#FFD700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2], c[3], H.lighten(c[3], 0.3)], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(48), sunY = h * 0.7 + Math.sin(t * 0.1) * 3;
|
||||
Draw.glow(ctx, w * 0.5, sunY, h * 0.25, H.rgba(c[3], 0.3));
|
||||
Draw.glow(ctx, w * 0.5, sunY, h * 0.12, H.rgba(c[2], 0.4));
|
||||
Draw.circle(ctx, w * 0.5, sunY, h * 0.06, c[3]);
|
||||
for (var i = 0; i < 8; i++) drawCloud(ctx, (rng() * w * 1.2 + t * 2) % (w + 80) - 40, h * 0.62 + rng() * h * 0.08, 30 + rng() * 50, H.blendColor(c[1], c[0], rng() * 0.5), 0.5 + rng() * 0.3);
|
||||
for (var j = 0; j < 5; j++) { ctx.fillStyle = H.rgba(c[1], 0.08 + rng() * 0.06); ctx.beginPath(); ctx.ellipse(rng() * w, h * 0.1 + rng() * h * 0.25, 60 + rng() * 80, 4 + rng() * 6, 0, 0, Math.PI * 2); ctx.fill(); }
|
||||
// Silhouette landscape
|
||||
var lr = H.seededRandom(100); ctx.fillStyle = H.rgba('#000', 0.6); ctx.beginPath(); ctx.moveTo(0, h);
|
||||
for (var x = 0; x <= w; x += 8) ctx.lineTo(x, h * 0.82 + lr() * h * 0.04 - Math.sin(x * 0.008) * h * 0.03);
|
||||
ctx.lineTo(w, h); ctx.closePath(); ctx.fill();
|
||||
for (var b = 0; b < 6; b++) drawBird(ctx, w * 0.3 + rng() * w * 0.4 + Math.sin(t * 0.5 + b) * 8, h * 0.5 + rng() * h * 0.1 + Math.sin(t * 1.2 + b * 1.5) * 4, 3 + rng() * 3, H.rgba('#000', 0.4));
|
||||
},
|
||||
particles: { type: 'dust', count: 8, color: '#FFD700' }
|
||||
},
|
||||
|
||||
starfield: {
|
||||
meta: { id: 'sky_starfield', mood: 'calm', colors: ['#000011', '#0A0A2A', '#1A1A44', '#FFFFFF'], tags: ['night', 'stars', 'galaxy', 'space'], description: 'A clear night sky bursting with stars and a faint galactic band', compatibleMusicMoods: ['calm', 'ambient', 'mysterious'], recommendedFor: ['puzzle', 'story', 'rpg', 'music'] },
|
||||
colors: ['#000011', '#0A0A2A', '#1A1A44', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
var rng = H.seededRandom(100);
|
||||
for (var i = 0; i < 200; i++) {
|
||||
var p = i / 200; ctx.fillStyle = H.rgba('#8888CC', 0.05 + rng() * 0.1);
|
||||
ctx.beginPath(); ctx.arc(p * w * 1.3 - w * 0.15, h * 0.2 + p * h * 0.6 + (rng() - 0.5) * h * 0.12, 0.5 + rng() * 1.5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
Draw.glow(ctx, w * 0.5, h * 0.5, h * 0.2, H.rgba('#6655AA', 0.04));
|
||||
ctx.restore();
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(77);
|
||||
Draw.stars(ctx, w, h, 150, '#FFFFFF', 77, [0.3, 2]);
|
||||
Draw.stars(ctx, w, h, 50, '#AABBFF', 88, [0.8, 2.5]);
|
||||
Draw.stars(ctx, w, h, 20, '#FFDDAA', 99, [1, 3]);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var sx = rng() * w, sy = rng() * h, tw = 0.4 + Math.sin(t * 2 + i * 2.5) * 0.3;
|
||||
Draw.glow(ctx, sx, sy, 6 + tw * 4, H.rgba('#FFF', tw * 0.3));
|
||||
Draw.circle(ctx, sx, sy, 1.5 + tw, H.rgba('#FFF', 0.6 + tw * 0.3));
|
||||
}
|
||||
var st = (t * 0.5) % 8;
|
||||
if (st < 0.4) {
|
||||
var sp = st / 0.4, sx2 = H.lerp(w * 0.7, w * 0.3, sp), sy2 = H.lerp(h * 0.1, h * 0.4, sp);
|
||||
ctx.strokeStyle = H.rgba('#FFF', 0.7 * (1 - sp)); ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(sx2, sy2); ctx.lineTo(sx2 + 25, sy2 - 10); ctx.stroke();
|
||||
Draw.glow(ctx, sx2, sy2, 4, H.rgba('#FFF', 0.5 * (1 - sp)));
|
||||
}
|
||||
},
|
||||
particles: { type: 'stars', count: 100, color: '#FFFFFF' }
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,275 @@
|
||||
// space.js — Space theme for procedural background system
|
||||
// 8 variants: nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('space', {
|
||||
|
||||
nebula: {
|
||||
meta: { id: 'space_nebula', mood: 'wonder', colors: ['#0B0B2B', '#1A0A3E', '#3D1A78', '#7B2FBE'], tags: ['cosmic', 'colorful', 'calm'], description: 'Swirling nebula clouds with twinkling stars', compatibleMusicMoods: ['ambient', 'dreamy', 'epic'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#0B0B2B', '#1A0A3E', '#3D1A78', '#7B2FBE'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
var rng = H.seededRandom(101);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var cx = rng() * w, cy = rng() * h;
|
||||
var r = 80 + rng() * 200;
|
||||
var pulse = 0.9 + Math.sin(t * 0.3 + i) * 0.1;
|
||||
Draw.glow(ctx, cx, cy, r * pulse, c[3]);
|
||||
Draw.glow(ctx, cx + 40, cy - 30, r * 0.6 * pulse, c[2]);
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.stars(ctx, w, h, 180, '#FFFFFF', 10, [0.5, 2]);
|
||||
Draw.stars(ctx, w, h, 40, c[3], 20, [1, 3]);
|
||||
var rng = H.seededRandom(102);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var x = rng() * w, y = rng() * h * 0.8;
|
||||
var flicker = 0.5 + Math.sin(t * 2 + i * 1.7) * 0.3;
|
||||
Draw.glow(ctx, x, y, 4 + rng() * 4, H.rgba('#FFFFFF', flicker));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 40, color: '#CCBBFF' }
|
||||
},
|
||||
|
||||
asteroidField: {
|
||||
meta: { id: 'space_asteroidField', mood: 'tense', colors: ['#0A0A14', '#1C1C2E', '#4A3A2A', '#8B7355'], tags: ['rocky', 'danger', 'dark'], description: 'Rocky asteroids drifting through dark space', compatibleMusicMoods: ['tense', 'action', 'ambient'], recommendedFor: ['action', 'racing', 'sports'] },
|
||||
colors: ['#0A0A14', '#1C1C2E', '#4A3A2A', '#8B7355'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.stars(ctx, w, h, 100, '#FFFFFF', 30, [0.3, 1.5]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
for (var i = 0; i < 18; i++) {
|
||||
var ax = (rng() * w + t * (5 + rng() * 15)) % (w + 100) - 50;
|
||||
var ay = rng() * h;
|
||||
var ar = 8 + rng() * 35;
|
||||
var sides = 5 + Math.floor(rng() * 4);
|
||||
var rot = rng() * Math.PI * 2 + t * (rng() - 0.5) * 0.2;
|
||||
var shade = H.blendColor(c[2], c[3], rng());
|
||||
Draw.polygon(ctx, ax, ay, ar, sides, shade, rot);
|
||||
Draw.polygon(ctx, ax - ar * 0.15, ay - ar * 0.15, ar * 0.85, sides, H.lighten(shade, 0.15), rot);
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 25, color: '#887766' }
|
||||
},
|
||||
|
||||
deepSpace: {
|
||||
meta: { id: 'space_deepSpace', mood: 'serene', colors: ['#000005', '#050510', '#0A0A20', '#111133'], tags: ['dark', 'vast', 'minimal'], description: 'The void of deep space with distant starlight', compatibleMusicMoods: ['ambient', 'calm', 'dreamy'], recommendedFor: ['puzzle', 'story', 'rpg'] },
|
||||
colors: ['#000005', '#050510', '#0A0A20', '#111133'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
Draw.glow(ctx, w * 0.8, h * 0.15, 150, H.rgba('#1A1A55', 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.stars(ctx, w, h, 300, '#FFFFFF', 50, [0.2, 1.5]);
|
||||
Draw.stars(ctx, w, h, 20, '#AABBFF', 51, [1.5, 3]);
|
||||
var rng = H.seededRandom(300);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var x = rng() * w, y = rng() * h;
|
||||
var twinkle = 0.3 + Math.sin(t * 1.5 + rng() * 10) * 0.3;
|
||||
Draw.glow(ctx, x, y, 8 + rng() * 12, H.rgba('#4466CC', twinkle));
|
||||
}
|
||||
},
|
||||
particles: { type: 'stars', count: 60, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
planets: {
|
||||
meta: { id: 'space_planets', mood: 'wonder', colors: ['#0B0B2B', '#0F1640', '#CC5533', '#44BBAA'], tags: ['planetary', 'colorful', 'majestic'], description: 'Colorful planets against a starry backdrop', compatibleMusicMoods: ['epic', 'dreamy', 'ambient'], recommendedFor: ['creative', 'rpg', 'story'] },
|
||||
colors: ['#0B0B2B', '#0F1640', '#CC5533', '#44BBAA'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.stars(ctx, w, h, 150, '#FFFFFF', 40, [0.3, 1.8]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(400);
|
||||
// Large planet
|
||||
var px = w * 0.7, py = h * 0.35, pr = 60 + rng() * 40;
|
||||
var g = Draw.radialGradient(ctx, px - pr * 0.3, py - pr * 0.3, pr * 1.2, [[0, H.lighten(c[2], 0.3)], [0.6, c[2]], [1, H.darken(c[2], 0.6)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill();
|
||||
// Ring
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.4);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.ellipse(px, py, pr * 1.8, pr * 0.3, -0.2, 0, Math.PI * 2); ctx.stroke();
|
||||
// Small planet
|
||||
var sx = w * 0.25, sy = h * 0.6, sr = 25 + rng() * 15;
|
||||
var g2 = Draw.radialGradient(ctx, sx - sr * 0.3, sy - sr * 0.3, sr * 1.2, [[0, H.lighten(c[3], 0.3)], [0.7, c[3]], [1, H.darken(c[3], 0.5)]]);
|
||||
ctx.fillStyle = g2;
|
||||
ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill();
|
||||
// Moon
|
||||
var bob = Math.sin(t * 0.5) * 5;
|
||||
var mx = w * 0.15, my = h * 0.25 + bob;
|
||||
Draw.circle(ctx, mx, my, 10, '#AAAAAA');
|
||||
Draw.glow(ctx, mx, my, 18, H.rgba('#AAAAAA', 0.2));
|
||||
},
|
||||
particles: { type: 'sparkles', count: 30, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
spaceStation: {
|
||||
meta: { id: 'space_spaceStation', mood: 'focused', colors: ['#0A0A1E', '#151530', '#3A3A55', '#88AACC'], tags: ['tech', 'structure', 'industrial'], description: 'Orbital station with geometric structures', compatibleMusicMoods: ['ambient', 'tense', 'electronic'], recommendedFor: ['puzzle', 'action', 'physics'] },
|
||||
colors: ['#0A0A1E', '#151530', '#3A3A55', '#88AACC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
Draw.stars(ctx, w, h, 100, '#FFFFFF', 60, [0.3, 1.2]);
|
||||
// Earth glow on horizon
|
||||
Draw.glow(ctx, w * 0.5, h * 1.1, h * 0.5, H.rgba('#2255AA', 0.25));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(500);
|
||||
// Station core
|
||||
Draw.rect(ctx, w * 0.35, h * 0.38, w * 0.3, h * 0.06, c[2]);
|
||||
Draw.rect(ctx, w * 0.33, h * 0.4, w * 0.34, h * 0.02, H.lighten(c[2], 0.1));
|
||||
// Solar panels
|
||||
for (var s = 0; s < 2; s++) {
|
||||
var sx = s === 0 ? w * 0.15 : w * 0.7;
|
||||
Draw.rect(ctx, sx, h * 0.36, w * 0.14, h * 0.1, H.rgba(c[3], 0.5));
|
||||
Draw.grid(ctx, w * 0.14, h * 0.1, 10, H.rgba(c[2], 0.3), 0.5);
|
||||
ctx.save(); ctx.translate(sx, h * 0.36);
|
||||
Draw.grid(ctx, w * 0.14, h * 0.1, 12, H.rgba('#4466AA', 0.3), 0.5);
|
||||
ctx.restore();
|
||||
}
|
||||
// Struts
|
||||
Draw.rect(ctx, w * 0.29, h * 0.4, w * 0.04, h * 0.005, c[2]);
|
||||
Draw.rect(ctx, w * 0.67, h * 0.4, w * 0.04, h * 0.005, c[2]);
|
||||
// Blinking lights
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var lx = w * 0.36 + i * (w * 0.28 / 5);
|
||||
var blink = Math.sin(t * 3 + i * 1.2) > 0.3 ? 0.9 : 0.15;
|
||||
Draw.circle(ctx, lx, h * 0.38, 2, H.rgba('#FF3333', blink));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#667788' }
|
||||
},
|
||||
|
||||
warpSpeed: {
|
||||
meta: { id: 'space_warpSpeed', mood: 'energetic', colors: ['#000010', '#0A0A30', '#2244AA', '#66BBFF'], tags: ['fast', 'streaks', 'intense'], description: 'Light streaks from faster-than-light travel', compatibleMusicMoods: ['energetic', 'electronic', 'epic'], recommendedFor: ['racing', 'action', 'sports'] },
|
||||
colors: ['#000010', '#0A0A30', '#2244AA', '#66BBFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Central tunnel glow
|
||||
var pulse = 0.6 + Math.sin(t * 2) * 0.15;
|
||||
Draw.glow(ctx, w * 0.5, h * 0.5, Math.min(w, h) * 0.4 * pulse, c[2]);
|
||||
Draw.glow(ctx, w * 0.5, h * 0.5, Math.min(w, h) * 0.15, H.rgba(c[3], 0.4));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(600);
|
||||
var cx = w * 0.5, cy = h * 0.5;
|
||||
ctx.lineWidth = 1.5;
|
||||
for (var i = 0; i < 60; i++) {
|
||||
var angle = rng() * Math.PI * 2;
|
||||
var dist = 30 + rng() * Math.min(w, h) * 0.45;
|
||||
var len = 20 + rng() * 60;
|
||||
var speed = 0.5 + rng() * 2;
|
||||
var offset = (t * speed * 40 + rng() * 200) % (dist + len);
|
||||
var x1 = cx + Math.cos(angle) * offset;
|
||||
var y1 = cy + Math.sin(angle) * offset;
|
||||
var x2 = cx + Math.cos(angle) * (offset + len);
|
||||
var y2 = cy + Math.sin(angle) * (offset + len);
|
||||
var alpha = H.clamp(0.15 + (offset / dist) * 0.6, 0, 0.8);
|
||||
var col = rng() > 0.7 ? c[3] : '#FFFFFF';
|
||||
ctx.strokeStyle = H.rgba(col, alpha);
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 30, color: '#88CCFF' }
|
||||
},
|
||||
|
||||
satellites: {
|
||||
meta: { id: 'space_satellites', mood: 'calm', colors: ['#060615', '#101030', '#334466', '#AACCDD'], tags: ['orbital', 'tech', 'night'], description: 'Satellites orbiting above a dark Earth', compatibleMusicMoods: ['ambient', 'calm', 'dreamy'], recommendedFor: ['puzzle', 'trivia', 'creative'] },
|
||||
colors: ['#060615', '#101030', '#334466', '#AACCDD'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Earth curve at bottom
|
||||
var g = Draw.radialGradient(ctx, w * 0.5, h * 1.6, h * 0.9, [[0, '#1A3355'], [0.5, '#0D1B2A'], [1, H.rgba(c[0], 0)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, h * 1.6, h * 0.9, 0, Math.PI * 2); ctx.fill();
|
||||
// Atmosphere glow
|
||||
Draw.glow(ctx, w * 0.5, h * 1.05, h * 0.3, H.rgba('#3388CC', 0.15));
|
||||
Draw.stars(ctx, w, h * 0.7, 120, '#FFFFFF', 70, [0.3, 1.5]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(700);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var orbit = 0.15 + rng() * 0.5;
|
||||
var speed = 0.1 + rng() * 0.25;
|
||||
var angle = t * speed + rng() * Math.PI * 2;
|
||||
var ox = w * 0.5 + Math.cos(angle) * w * orbit;
|
||||
var oy = h * 0.5 + Math.sin(angle) * h * orbit * 0.3;
|
||||
// Satellite body
|
||||
Draw.rect(ctx, ox - 4, oy - 2, 8, 4, c[2]);
|
||||
// Solar panels
|
||||
Draw.rect(ctx, ox - 14, oy - 1, 9, 2, H.rgba(c[3], 0.7));
|
||||
Draw.rect(ctx, ox + 5, oy - 1, 9, 2, H.rgba(c[3], 0.7));
|
||||
// Signal blink
|
||||
var blink = Math.sin(t * 4 + i * 2) > 0.5 ? 0.8 : 0.1;
|
||||
Draw.circle(ctx, ox, oy, 1.5, H.rgba('#FF4444', blink));
|
||||
}
|
||||
// Orbit lines (faint)
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.08);
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var orb = 0.2 + j * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.5, h * 0.5, w * orb, h * orb * 0.3, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'stars', count: 40, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
blackHole: {
|
||||
meta: { id: 'space_blackHole', mood: 'intense', colors: ['#000000', '#0A0510', '#220A33', '#FF6622'], tags: ['dark', 'gravity', 'dramatic'], description: 'A black hole bending light around its event horizon', compatibleMusicMoods: ['tense', 'epic', 'ambient'], recommendedFor: ['action', 'rpg', 'physics'] },
|
||||
colors: ['#000000', '#0A0510', '#220A33', '#FF6622'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[0], c[1]], 90);
|
||||
Draw.stars(ctx, w, h, 80, '#FFFFFF', 80, [0.3, 1]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var cx = w * 0.5, cy = h * 0.45;
|
||||
var r = Math.min(w, h) * 0.15;
|
||||
// Accretion disk
|
||||
var rng = H.seededRandom(800);
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
for (var ring = 5; ring >= 0; ring--) {
|
||||
var rr = r * (1.3 + ring * 0.4);
|
||||
var alpha = 0.08 + ring * 0.04;
|
||||
var spin = t * (0.2 + ring * 0.05);
|
||||
ctx.save();
|
||||
ctx.rotate(spin);
|
||||
var col = ring < 3 ? c[3] : c[2];
|
||||
ctx.strokeStyle = H.rgba(col, alpha + Math.sin(t + ring) * 0.03);
|
||||
ctx.lineWidth = 3 + ring * 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, rr, rr * 0.22, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
// Core glow
|
||||
Draw.glow(ctx, 0, 0, r * 1.5, H.rgba(c[3], 0.35 + Math.sin(t * 1.5) * 0.1));
|
||||
Draw.glow(ctx, 0, 0, r * 0.8, H.rgba(c[2], 0.4));
|
||||
// Black center
|
||||
var gc = Draw.radialGradient(ctx, 0, 0, r * 0.6, [[0, '#000000'], [0.7, '#000000'], [1, H.rgba('#000000', 0)]]);
|
||||
ctx.fillStyle = gc;
|
||||
ctx.beginPath(); ctx.arc(0, 0, r * 0.6, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
// Gravitational lensing — bent stars near hole
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var a = rng() * Math.PI * 2;
|
||||
var d = r * (1.1 + rng() * 0.8);
|
||||
var sx = cx + Math.cos(a + t * 0.15) * d;
|
||||
var sy = cy + Math.sin(a + t * 0.15) * d * 0.4;
|
||||
var stretch = 1 + 2 / (1 + d / r);
|
||||
ctx.fillStyle = H.rgba('#FFCC88', 0.4 + rng() * 0.3);
|
||||
ctx.beginPath(); ctx.ellipse(sx, sy, 1.5 * stretch, 1, a, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'particles', count: 35, color: '#FF8844' }
|
||||
}
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,694 @@
|
||||
// spooky.js — Spooky/horror theme for procedural background system
|
||||
// 8 variants: hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
// Helper: draw a tombstone
|
||||
function drawTombstone(ctx, x, baseY, w, h, color) {
|
||||
ctx.fillStyle = color;
|
||||
// Body
|
||||
ctx.fillRect(x - w / 2, baseY - h, w, h);
|
||||
// Rounded top
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, baseY - h, w / 2, Math.PI, 0);
|
||||
ctx.fill();
|
||||
// Cross or RIP text
|
||||
ctx.fillStyle = H.darken(color, 0.3);
|
||||
ctx.fillRect(x - 1, baseY - h + 5, 2, 12);
|
||||
ctx.fillRect(x - 4, baseY - h + 8, 8, 2);
|
||||
}
|
||||
|
||||
// Helper: draw a bare tree silhouette
|
||||
function drawBareTree(ctx, x, baseY, height, color, rng) {
|
||||
var trunkW = height * 0.08;
|
||||
ctx.fillStyle = color;
|
||||
// Trunk
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - trunkW, baseY);
|
||||
ctx.lineTo(x - trunkW * 0.4, baseY - height * 0.6);
|
||||
ctx.lineTo(x + trunkW * 0.4, baseY - height * 0.6);
|
||||
ctx.lineTo(x + trunkW, baseY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Branches
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
var branchCount = 4 + Math.floor(rng() * 4);
|
||||
for (var i = 0; i < branchCount; i++) {
|
||||
var by = baseY - height * (0.3 + rng() * 0.5);
|
||||
var dir = rng() > 0.5 ? 1 : -1;
|
||||
var bLen = 15 + rng() * height * 0.4;
|
||||
var bAngle = -Math.PI / 2 + dir * (0.3 + rng() * 0.8);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, by);
|
||||
var bx = x + Math.cos(bAngle) * bLen;
|
||||
var bby = by + Math.sin(bAngle) * bLen;
|
||||
ctx.lineTo(bx, bby);
|
||||
ctx.stroke();
|
||||
// Sub-branches
|
||||
if (rng() > 0.3) {
|
||||
var subLen = bLen * 0.5;
|
||||
var subAngle = bAngle + (rng() - 0.5) * 0.6;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, bby);
|
||||
ctx.lineTo(bx + Math.cos(subAngle) * subLen, bby + Math.sin(subAngle) * subLen);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: draw a cobweb
|
||||
function drawCobweb(ctx, cx, cy, r, color) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 0.5;
|
||||
// Radial lines
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var a = (i / 6) * Math.PI / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Spiral rings
|
||||
for (var ring = 1; ring <= 4; ring++) {
|
||||
var rr = r * ring / 4;
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i <= 6; i++) {
|
||||
var a = (i / 6) * Math.PI / 2;
|
||||
var px = cx + Math.cos(a) * rr;
|
||||
var py = cy + Math.sin(a) * rr;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: draw a cracked wall segment
|
||||
function drawCracks(ctx, x, y, w, h, color, rng) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var sx = x + rng() * w, sy = y + rng() * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
for (var j = 0; j < 3; j++) {
|
||||
sx += (rng() - 0.5) * 20;
|
||||
sy += rng() * 15;
|
||||
ctx.lineTo(sx, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: draw a moon
|
||||
function drawMoon(ctx, cx, cy, r, color) {
|
||||
// Glow
|
||||
D.glow(ctx, cx, cy, r * 3, H.rgba(color, 0.15));
|
||||
D.glow(ctx, cx, cy, r * 1.8, H.rgba(color, 0.2));
|
||||
// Moon face
|
||||
var g = D.radialGradient(ctx, cx - r * 0.2, cy - r * 0.2, r, [[0, H.lighten(color, 0.3)], [0.7, color], [1, H.darken(color, 0.2)]]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
engine.registerTheme('spooky', {
|
||||
|
||||
hauntedHouse: {
|
||||
meta: { id: 'spooky_hauntedHouse', mood: 'eerie', colors: ['#0A0812', '#1A1025', '#3A2255', '#CCCC88'], tags: ['haunted', 'dark', 'building'], description: 'A looming haunted house silhouette against a moonlit sky', compatibleMusicMoods: ['tense', 'ambient', 'dreamy'], recommendedFor: ['story', 'puzzle', 'rpg'] },
|
||||
colors: ['#0A0812', '#1A1025', '#3A2255', '#CCCC88'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
// Moon
|
||||
drawMoon(ctx, w * 0.78, h * 0.15, 30, c[3]);
|
||||
// Ground
|
||||
Draw.hills(ctx, w, h, h * 0.82, 5, H.darken(c[0], 0.3), 91);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(100);
|
||||
// House silhouette (center)
|
||||
var hx = w * 0.35, hy = h * 0.82;
|
||||
var hw = w * 0.3, hh = h * 0.35;
|
||||
ctx.fillStyle = '#0A0808';
|
||||
// Main body
|
||||
ctx.fillRect(hx, hy - hh, hw, hh);
|
||||
// Roof (triangle)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx - 10, hy - hh);
|
||||
ctx.lineTo(hx + hw / 2, hy - hh - h * 0.12);
|
||||
ctx.lineTo(hx + hw + 10, hy - hh);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Tower on right
|
||||
ctx.fillRect(hx + hw * 0.7, hy - hh - h * 0.15, hw * 0.2, h * 0.15);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx + hw * 0.68, hy - hh - h * 0.15);
|
||||
ctx.lineTo(hx + hw * 0.8, hy - hh - h * 0.22);
|
||||
ctx.lineTo(hx + hw * 0.92, hy - hh - h * 0.15);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Windows with flickering light
|
||||
var windows = [
|
||||
[hx + hw * 0.15, hy - hh + hh * 0.2],
|
||||
[hx + hw * 0.55, hy - hh + hh * 0.2],
|
||||
[hx + hw * 0.15, hy - hh + hh * 0.55],
|
||||
[hx + hw * 0.55, hy - hh + hh * 0.55],
|
||||
[hx + hw * 0.76, hy - hh - h * 0.08]
|
||||
];
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
var flicker = 0.15 + Math.sin(t * 2.5 + rng() * 10) * 0.1 + rng() * 0.1;
|
||||
var ww = i < 4 ? hw * 0.18 : hw * 0.1;
|
||||
var wh = i < 4 ? hh * 0.18 : hh * 0.12;
|
||||
D.glow(ctx, windows[i][0] + ww / 2, windows[i][1] + wh / 2, 15, H.rgba('#DDAA44', flicker * 0.4));
|
||||
Draw.rect(ctx, windows[i][0], windows[i][1], ww, wh, H.rgba('#DDAA44', flicker));
|
||||
}
|
||||
// Bats near moon
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var bx = w * 0.65 + rng() * w * 0.25;
|
||||
var by = h * 0.08 + rng() * h * 0.15;
|
||||
var wingFlap = Math.sin(t * 6 + i * 2) * 4;
|
||||
ctx.fillStyle = '#0A0808';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx, by, 2, 1, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by);
|
||||
ctx.quadraticCurveTo(bx - 6, by - wingFlap, bx - 10, by + 2);
|
||||
ctx.quadraticCurveTo(bx - 6, by + wingFlap * 0.5, bx, by);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by);
|
||||
ctx.quadraticCurveTo(bx + 6, by - wingFlap, bx + 10, by + 2);
|
||||
ctx.quadraticCurveTo(bx + 6, by + wingFlap * 0.5, bx, by);
|
||||
ctx.fill();
|
||||
}
|
||||
// Cobweb in corner
|
||||
drawCobweb(ctx, 0, 0, 60, H.rgba('#AAAAAA', 0.1));
|
||||
},
|
||||
particles: { type: 'fog', count: 10, color: '#443366' }
|
||||
},
|
||||
|
||||
graveyard: {
|
||||
meta: { id: 'spooky_graveyard', mood: 'somber', colors: ['#080810', '#121828', '#2A3348', '#99AA77'], tags: ['cemetery', 'death', 'moonlit'], description: 'Moonlit graveyard with crooked tombstones and mist', compatibleMusicMoods: ['tense', 'ambient', 'dreamy'], recommendedFor: ['rpg', 'story', 'puzzle'] },
|
||||
colors: ['#080810', '#121828', '#2A3348', '#99AA77'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
drawMoon(ctx, w * 0.82, h * 0.12, 25, '#DDDDAA');
|
||||
// Ground
|
||||
Draw.hills(ctx, w, h, h * 0.75, 8, H.darken(c[3], 0.7), 66);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(150);
|
||||
// Tombstones
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var tx = rng() * w * 0.9 + w * 0.05;
|
||||
var tBaseY = h * 0.75 + rng() * h * 0.08;
|
||||
var tw = 10 + rng() * 14;
|
||||
var th = 18 + rng() * 25;
|
||||
var tilt = (rng() - 0.5) * 0.15;
|
||||
ctx.save();
|
||||
ctx.translate(tx, tBaseY);
|
||||
ctx.rotate(tilt);
|
||||
var stoneCol = H.blendColor('#444455', '#555566', rng());
|
||||
drawTombstone(ctx, 0, 0, tw, th, stoneCol);
|
||||
ctx.restore();
|
||||
}
|
||||
// Bare tree silhouette
|
||||
drawBareTree(ctx, w * 0.15, h * 0.75, h * 0.4, '#0A0A10', rng);
|
||||
drawBareTree(ctx, w * 0.88, h * 0.76, h * 0.35, '#0A0A10', rng);
|
||||
// Iron fence
|
||||
var fenceY = h * 0.78;
|
||||
ctx.strokeStyle = '#222233';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, fenceY);
|
||||
ctx.lineTo(w, fenceY);
|
||||
ctx.stroke();
|
||||
for (var i = 0; i < w; i += 18) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, fenceY);
|
||||
ctx.lineTo(i, fenceY - 20);
|
||||
ctx.stroke();
|
||||
// Spike top
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i - 3, fenceY - 20);
|
||||
ctx.lineTo(i, fenceY - 26);
|
||||
ctx.lineTo(i + 3, fenceY - 20);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#222233';
|
||||
ctx.fill();
|
||||
}
|
||||
// Ground fog
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var fx = rng() * w;
|
||||
var fy = h * 0.73 + rng() * h * 0.1;
|
||||
var drift = Math.sin(t * 0.3 + i) * 15;
|
||||
ctx.fillStyle = H.rgba('#667788', 0.04 + rng() * 0.03);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(fx + drift, fy, 60 + rng() * 80, 12 + rng() * 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Ghostly wisp
|
||||
var ghostX = w * 0.5 + Math.sin(t * 0.6) * 30;
|
||||
var ghostY = h * 0.45 + Math.cos(t * 0.4) * 10;
|
||||
D.glow(ctx, ghostX, ghostY, 20, H.rgba('#AABBCC', 0.06 + Math.sin(t * 1.5) * 0.03));
|
||||
},
|
||||
particles: { type: 'fog', count: 12, color: '#556677' }
|
||||
},
|
||||
|
||||
darkForest: {
|
||||
meta: { id: 'spooky_darkForest', mood: 'foreboding', colors: ['#050808', '#0A1510', '#1A2A18', '#3A5530'], tags: ['trees', 'dark', 'wilderness'], description: 'Dense dark forest with twisted trees and glowing eyes', compatibleMusicMoods: ['tense', 'ambient', 'epic'], recommendedFor: ['rpg', 'action', 'story'] },
|
||||
colors: ['#050808', '#0A1510', '#1A2A18', '#3A5530'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
// Faint moon glow through canopy
|
||||
D.glow(ctx, w * 0.6, h * 0.1, 100, H.rgba('#AABB88', 0.06));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(200);
|
||||
// Background tree layer (faint)
|
||||
for (var i = 0; i < 8; i++) {
|
||||
drawBareTree(ctx, rng() * w, h * 0.85 + rng() * h * 0.05, h * 0.5 + rng() * h * 0.2, H.rgba(c[2], 0.4), rng);
|
||||
}
|
||||
// Foreground tree layer (dark silhouettes)
|
||||
for (var i = 0; i < 5; i++) {
|
||||
drawBareTree(ctx, rng() * w, h * 0.9 + rng() * h * 0.05, h * 0.55 + rng() * h * 0.25, '#060A06', rng);
|
||||
}
|
||||
// Ground cover
|
||||
Draw.hills(ctx, w, h, h * 0.88, 12, H.darken(c[2], 0.6), 201);
|
||||
// Glowing eyes (pairs)
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var ex = rng() * w * 0.8 + w * 0.1;
|
||||
var ey = h * 0.4 + rng() * h * 0.35;
|
||||
var eyeGlow = 0.3 + Math.sin(t * 1.2 + rng() * 10) * 0.25;
|
||||
var blink = Math.sin(t * 0.15 + i * 3.7);
|
||||
if (blink > -0.9) { // eyes open most of the time
|
||||
D.circle(ctx, ex - 4, ey, 2, H.rgba('#FFDD44', eyeGlow));
|
||||
D.circle(ctx, ex + 4, ey, 2, H.rgba('#FFDD44', eyeGlow));
|
||||
D.glow(ctx, ex, ey, 10, H.rgba('#FFDD44', eyeGlow * 0.2));
|
||||
}
|
||||
}
|
||||
// Mushrooms on ground
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var mx = rng() * w;
|
||||
var my = h * 0.88 + rng() * h * 0.06;
|
||||
var mr = 3 + rng() * 4;
|
||||
// Stem
|
||||
Draw.rect(ctx, mx - 1, my - mr, 2, mr, H.rgba('#887766', 0.4));
|
||||
// Cap
|
||||
ctx.fillStyle = H.rgba(c[3], 0.3 + Math.sin(t * 0.8 + i) * 0.1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my - mr, mr, Math.PI, 0);
|
||||
ctx.fill();
|
||||
// Faint bioluminescence
|
||||
D.glow(ctx, mx, my - mr, mr * 2, H.rgba(c[3], 0.04));
|
||||
}
|
||||
// Hanging moss strands
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var mx = rng() * w;
|
||||
var my = rng() * h * 0.3;
|
||||
var mLen = 15 + rng() * 30;
|
||||
var sway = Math.sin(t * 0.5 + i * 1.1) * 3;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.12);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx, my);
|
||||
ctx.quadraticCurveTo(mx + sway, my + mLen / 2, mx + sway * 0.5, my + mLen);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'fireflies', count: 12, color: '#88CC44' }
|
||||
},
|
||||
|
||||
abandonedBuilding: {
|
||||
meta: { id: 'spooky_abandonedBuilding', mood: 'desolate', colors: ['#0A0A0E', '#181820', '#3A3A48', '#667766'], tags: ['ruins', 'urban', 'decay'], description: 'Crumbling abandoned building with broken windows and debris', compatibleMusicMoods: ['tense', 'ambient', 'electronic'], recommendedFor: ['action', 'puzzle', 'rpg'] },
|
||||
colors: ['#0A0A0E', '#181820', '#3A3A48', '#667766'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Dim sky glow
|
||||
D.glow(ctx, w * 0.5, h * 0.05, w * 0.4, H.rgba('#223344', 0.1));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(250);
|
||||
// Building facade
|
||||
var bx = w * 0.15, bw = w * 0.7, bh = h * 0.65;
|
||||
var by = h * 0.9 - bh;
|
||||
Draw.rect(ctx, bx, by, bw, bh, c[2]);
|
||||
// Cracked texture
|
||||
drawCracks(ctx, bx, by, bw, bh, H.rgba('#222230', 0.4), rng);
|
||||
// Windows (some broken, some dark, some flickering)
|
||||
var cols = 5, rows = 4;
|
||||
var winW = bw * 0.1, winH = bh * 0.12;
|
||||
for (var r = 0; r < rows; r++) {
|
||||
for (var col = 0; col < cols; col++) {
|
||||
var wx = bx + bw * 0.08 + col * (bw * 0.18);
|
||||
var wy = by + bh * 0.08 + r * (bh * 0.22);
|
||||
var state = rng(); // 0-0.4 dark, 0.4-0.7 broken, 0.7-1 dim light
|
||||
if (state < 0.4) {
|
||||
Draw.rect(ctx, wx, wy, winW, winH, H.darken(c[2], 0.4));
|
||||
} else if (state < 0.7) {
|
||||
// Broken window — jagged frame
|
||||
Draw.rect(ctx, wx, wy, winW, winH, '#0A0A0E');
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(wx + winW * 0.3, wy);
|
||||
ctx.lineTo(wx + winW * 0.5, wy + winH * 0.6);
|
||||
ctx.lineTo(wx + winW * 0.7, wy + winH * 0.2);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
var flicker = 0.08 + Math.sin(t * 3 + rng() * 10) * 0.05;
|
||||
Draw.rect(ctx, wx, wy, winW, winH, H.rgba('#DDAA55', flicker));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ground debris
|
||||
Draw.rect(ctx, 0, h * 0.9, w, h * 0.1, H.darken(c[2], 0.5));
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var dx = rng() * w;
|
||||
var dy = h * 0.9 + rng() * h * 0.05;
|
||||
var dw = 5 + rng() * 15;
|
||||
var dh = 3 + rng() * 8;
|
||||
Draw.rect(ctx, dx, dy, dw, dh, H.blendColor(c[2], c[3], rng() * 0.3));
|
||||
}
|
||||
// Cobweb in top corner of building
|
||||
drawCobweb(ctx, bx, by, 40, H.rgba('#888888', 0.08));
|
||||
drawCobweb(ctx, bx + bw, by, 40, H.rgba('#888888', 0.08));
|
||||
// Flickering overhead light
|
||||
var lightBlink = Math.sin(t * 8) > 0.3 ? 0.5 : (Math.sin(t * 12) > 0.8 ? 0.3 : 0);
|
||||
if (lightBlink > 0) {
|
||||
D.glow(ctx, w * 0.5, by - 5, 40, H.rgba('#FFDDAA', lightBlink * 0.15));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#666655' }
|
||||
},
|
||||
|
||||
crypt: {
|
||||
meta: { id: 'spooky_crypt', mood: 'dread', colors: ['#060608', '#101015', '#2A2A35', '#558855'], tags: ['underground', 'stone', 'ancient'], description: 'Underground stone crypt with torchlight and coffins', compatibleMusicMoods: ['tense', 'ambient', 'epic'], recommendedFor: ['rpg', 'action', 'story'] },
|
||||
colors: ['#060608', '#101015', '#2A2A35', '#558855'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[0], c[1]], 90);
|
||||
// Stone walls
|
||||
Draw.rect(ctx, 0, 0, w, h * 0.15, c[2]);
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, H.darken(c[2], 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(300);
|
||||
// Stone block pattern on ceiling and floor
|
||||
for (var row = 0; row < 2; row++) {
|
||||
var sy = row === 0 ? 0 : h * 0.85;
|
||||
var sh = h * 0.15;
|
||||
ctx.strokeStyle = H.rgba(c[0], 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
var brickH = sh / 3;
|
||||
for (var r = 0; r < 3; r++) {
|
||||
var offset = r % 2 === 0 ? 0 : 25;
|
||||
for (var bx = -25 + offset; bx < w; bx += 50) {
|
||||
ctx.strokeRect(bx, sy + r * brickH, 50, brickH);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arched columns
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var cx = w * 0.1 + i * w * 0.27;
|
||||
// Column
|
||||
Draw.rect(ctx, cx - 8, h * 0.15, 16, h * 0.7, H.darken(c[2], 0.15));
|
||||
Draw.rect(ctx, cx - 10, h * 0.15, 20, 8, c[2]);
|
||||
Draw.rect(ctx, cx - 10, h * 0.82, 20, 8, c[2]);
|
||||
// Arch between columns
|
||||
if (i < 3) {
|
||||
var nextCx = cx + w * 0.27;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.3);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc((cx + nextCx) / 2, h * 0.15, (nextCx - cx) / 2, Math.PI, 0);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Torches on columns
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var tx = w * 0.1 + i * w * 0.27 + w * 0.135;
|
||||
var ty = h * 0.3;
|
||||
// Bracket
|
||||
Draw.rect(ctx, tx - 2, ty, 4, 12, '#554433');
|
||||
// Flame
|
||||
var flameFlicker = 0.6 + Math.sin(t * 5 + i * 2) * 0.2 + Math.sin(t * 8 + i) * 0.1;
|
||||
D.glow(ctx, tx, ty, 40, H.rgba('#FF8833', flameFlicker * 0.12));
|
||||
D.glow(ctx, tx, ty - 3, 8, H.rgba('#FFCC44', flameFlicker * 0.6));
|
||||
ctx.fillStyle = H.rgba('#FF8833', flameFlicker);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx - 4, ty);
|
||||
ctx.quadraticCurveTo(tx, ty - 12 - Math.sin(t * 6 + i) * 3, tx + 4, ty);
|
||||
ctx.fill();
|
||||
}
|
||||
// Coffins on floor
|
||||
for (var i = 0; i < 2; i++) {
|
||||
var cofX = w * 0.25 + i * w * 0.35;
|
||||
var cofY = h * 0.78;
|
||||
ctx.fillStyle = '#1A1510';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cofX - 10, cofY);
|
||||
ctx.lineTo(cofX - 15, cofY + 5);
|
||||
ctx.lineTo(cofX - 12, cofY + 35);
|
||||
ctx.lineTo(cofX + 12, cofY + 35);
|
||||
ctx.lineTo(cofX + 15, cofY + 5);
|
||||
ctx.lineTo(cofX + 10, cofY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.15);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Dripping water
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var dx = rng() * w;
|
||||
var dropY = ((t * 35 + rng() * 200) % (h * 0.7)) + h * 0.15;
|
||||
var alpha = 1 - (dropY - h * 0.15) / (h * 0.7);
|
||||
D.circle(ctx, dx, dropY, 1.5, H.rgba('#5577AA', alpha * 0.3));
|
||||
}
|
||||
// Green mold/moss patches
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var mx = rng() * w;
|
||||
var my = rng() > 0.5 ? h * 0.12 + rng() * h * 0.05 : h * 0.83 + rng() * h * 0.05;
|
||||
ctx.fillStyle = H.rgba(c[3], 0.08 + rng() * 0.05);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(mx, my, 10 + rng() * 20, 4 + rng() * 6, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#554433' }
|
||||
},
|
||||
|
||||
manor: {
|
||||
meta: { id: 'spooky_manor', mood: 'gothic', colors: ['#0A080C', '#1A1020', '#3A2040', '#886644'], tags: ['elegant', 'dark', 'victorian'], description: 'Gothic manor interior with candlelight and portraits', compatibleMusicMoods: ['ambient', 'tense', 'dreamy'], recommendedFor: ['story', 'rpg', 'puzzle'] },
|
||||
colors: ['#0A080C', '#1A1020', '#3A2040', '#886644'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Wallpaper pattern (subtle)
|
||||
var rng = H.seededRandom(351);
|
||||
for (var y = 0; y < h; y += 40) {
|
||||
for (var x = 0; x < w; x += 40) {
|
||||
D.polygon(ctx, x + 20, y + 20, 8, 4, H.rgba(c[2], 0.06), rng() * 0.5);
|
||||
}
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(350);
|
||||
// Wainscoting
|
||||
Draw.rect(ctx, 0, h * 0.65, w, h * 0.35, H.darken(c[3], 0.6));
|
||||
// Panel lines
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var px = w * 0.05 + i * w * 0.16;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.15);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px, h * 0.68, w * 0.12, h * 0.25);
|
||||
}
|
||||
// Floor
|
||||
Draw.rect(ctx, 0, h * 0.92, w, h * 0.08, H.darken(c[3], 0.7));
|
||||
// Portrait frames on wall
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var fx = w * 0.15 + i * w * 0.3;
|
||||
var fy = h * 0.2;
|
||||
var fw = w * 0.15, fh = h * 0.25;
|
||||
// Frame
|
||||
ctx.strokeStyle = c[3];
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(fx, fy, fw, fh);
|
||||
// Dark canvas inside
|
||||
Draw.rect(ctx, fx + 3, fy + 3, fw - 6, fh - 6, H.darken(c[2], 0.4));
|
||||
// Hint of a face (oval)
|
||||
ctx.fillStyle = H.rgba(c[3], 0.06);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(fx + fw / 2, fy + fh * 0.4, fw * 0.2, fh * 0.25, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Eyes that follow (subtle)
|
||||
var eyeShift = Math.sin(t * 0.2 + i) * 1.5;
|
||||
D.circle(ctx, fx + fw * 0.4 + eyeShift, fy + fh * 0.38, 1, H.rgba('#FFFFFF', 0.08));
|
||||
D.circle(ctx, fx + fw * 0.6 + eyeShift, fy + fh * 0.38, 1, H.rgba('#FFFFFF', 0.08));
|
||||
}
|
||||
// Candelabra
|
||||
for (var i = 0; i < 2; i++) {
|
||||
var cx = w * 0.3 + i * w * 0.4;
|
||||
var cy = h * 0.52;
|
||||
// Base
|
||||
Draw.rect(ctx, cx - 2, cy, 4, 15, c[3]);
|
||||
// Arms
|
||||
for (var a = -1; a <= 1; a++) {
|
||||
var armX = cx + a * 15;
|
||||
ctx.strokeStyle = c[3];
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy + 3);
|
||||
ctx.quadraticCurveTo(cx + a * 8, cy - 5, armX, cy - 8);
|
||||
ctx.stroke();
|
||||
// Candle
|
||||
Draw.rect(ctx, armX - 2, cy - 18, 4, 10, '#E8DDD0');
|
||||
// Flame
|
||||
var ff = 0.5 + Math.sin(t * 4 + i + a) * 0.2;
|
||||
D.glow(ctx, armX, cy - 20, 12, H.rgba('#FF9944', ff * 0.2));
|
||||
ctx.fillStyle = H.rgba('#FFCC55', ff);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(armX, cy - 22, 2, 4 + Math.sin(t * 6 + a) * 1, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
// Cobweb top corners
|
||||
drawCobweb(ctx, 0, 0, 50, H.rgba('#999999', 0.06));
|
||||
drawCobweb(ctx, w, 0, 50, H.rgba('#999999', 0.06));
|
||||
},
|
||||
particles: { type: 'dust', count: 12, color: '#665544' }
|
||||
},
|
||||
|
||||
fog: {
|
||||
meta: { id: 'spooky_fog', mood: 'mysterious', colors: ['#0A0E12', '#151E28', '#2A3844', '#556688'], tags: ['misty', 'atmospheric', 'obscured'], description: 'Thick rolling fog obscuring dark shapes and dim lights', compatibleMusicMoods: ['ambient', 'dreamy', 'tense'], recommendedFor: ['story', 'puzzle', 'rpg'] },
|
||||
colors: ['#0A0E12', '#151E28', '#2A3844', '#556688'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[2]], 90);
|
||||
// Diffused moon glow
|
||||
D.glow(ctx, w * 0.5, h * 0.08, 120, H.rgba('#AABBCC', 0.08));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(420);
|
||||
// Background shapes barely visible through fog
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var sx = rng() * w;
|
||||
var sh = 40 + rng() * 80;
|
||||
Draw.rect(ctx, sx - 15, h * 0.75 - sh, 30, sh, H.rgba(c[1], 0.2));
|
||||
}
|
||||
// Bare tree hints
|
||||
ctx.strokeStyle = H.rgba(c[1], 0.15);
|
||||
ctx.lineWidth = 3;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var tx = rng() * w;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, h * 0.85);
|
||||
ctx.lineTo(tx - 3, h * 0.5);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx - 1, h * 0.6);
|
||||
ctx.lineTo(tx - 20, h * 0.45);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx + 1, h * 0.55);
|
||||
ctx.lineTo(tx + 18, h * 0.42);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3;
|
||||
}
|
||||
// Ground
|
||||
Draw.hills(ctx, w, h, h * 0.85, 6, H.rgba(c[1], 0.5), 421);
|
||||
// Multi-layer fog bands
|
||||
for (var layer = 0; layer < 5; layer++) {
|
||||
var fogY = h * 0.3 + layer * h * 0.13;
|
||||
var drift = Math.sin(t * 0.15 + layer * 1.2) * 40;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var fx = rng() * w * 1.4 - w * 0.2 + drift;
|
||||
var fw = 100 + rng() * 200;
|
||||
var fh = 20 + rng() * 40;
|
||||
var alpha = 0.04 + rng() * 0.04;
|
||||
ctx.fillStyle = H.rgba(c[3], alpha);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(fx, fogY, fw, fh, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
// Distant dim lights through fog
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var lx = rng() * w;
|
||||
var ly = h * 0.5 + rng() * h * 0.25;
|
||||
var pulse = 0.03 + Math.sin(t * 0.8 + rng() * 10) * 0.02;
|
||||
D.glow(ctx, lx, ly, 25, H.rgba('#FFDDAA', pulse));
|
||||
}
|
||||
},
|
||||
particles: { type: 'fog', count: 18, color: '#667788' }
|
||||
},
|
||||
|
||||
shadows: {
|
||||
meta: { id: 'spooky_shadows', mood: 'paranoid', colors: ['#050506', '#0E0E14', '#1C1C2A', '#332244'], tags: ['dark', 'minimal', 'threatening'], description: 'Near-total darkness with shifting shadows and faint light', compatibleMusicMoods: ['tense', 'ambient', 'electronic'], recommendedFor: ['action', 'puzzle', 'rpg'] },
|
||||
colors: ['#050506', '#0E0E14', '#1C1C2A', '#332244'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(500);
|
||||
// Central dim light source
|
||||
var lightPulse = 0.08 + Math.sin(t * 0.6) * 0.03;
|
||||
D.glow(ctx, w * 0.5, h * 0.5, Math.min(w, h) * 0.35, H.rgba('#443355', lightPulse));
|
||||
// Shadow blobs that shift and drift
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var sx = rng() * w;
|
||||
var sy = rng() * h;
|
||||
var sr = 40 + rng() * 100;
|
||||
var driftX = Math.sin(t * 0.2 + i * 1.5) * 20;
|
||||
var driftY = Math.cos(t * 0.15 + i * 1.8) * 15;
|
||||
var shadowAlpha = 0.15 + Math.sin(t * 0.3 + rng() * 10) * 0.08;
|
||||
ctx.fillStyle = H.rgba(c[0], shadowAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(sx + driftX, sy + driftY, sr, sr * 0.6, rng() * Math.PI, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Faint scratch-like lines
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.04);
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var lx = rng() * w;
|
||||
var ly = rng() * h;
|
||||
var lLen = 30 + rng() * 60;
|
||||
var lAngle = rng() * Math.PI;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, ly);
|
||||
ctx.lineTo(lx + Math.cos(lAngle) * lLen, ly + Math.sin(lAngle) * lLen);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Occasional flash of something
|
||||
var flashTimer = Math.sin(t * 0.08);
|
||||
if (flashTimer > 0.97) {
|
||||
var fx = rng() * w * 0.6 + w * 0.2;
|
||||
var fy = rng() * h * 0.6 + h * 0.2;
|
||||
D.glow(ctx, fx, fy, 50, H.rgba('#554466', 0.15));
|
||||
}
|
||||
// Edge vignette (darker edges)
|
||||
var vig = D.radialGradient(ctx, w / 2, h / 2, Math.max(w, h) * 0.6, [[0, H.rgba(c[0], 0)], [0.6, H.rgba(c[0], 0.3)], [1, H.rgba(c[0], 0.7)]]);
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// Two faint eyes in the dark
|
||||
var eyeAppear = Math.sin(t * 0.12);
|
||||
if (eyeAppear > 0.3) {
|
||||
var ea = (eyeAppear - 0.3) / 0.7;
|
||||
var ex = w * 0.65 + Math.sin(t * 0.08) * 20;
|
||||
var ey = h * 0.4;
|
||||
D.circle(ctx, ex - 6, ey, 2, H.rgba('#CC4444', ea * 0.3));
|
||||
D.circle(ctx, ex + 6, ey, 2, H.rgba('#CC4444', ea * 0.3));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 8, color: '#333344' }
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,595 @@
|
||||
// sports.js — Sports venue background themes
|
||||
// Variants: stadium, raceTrack, court, field, arena, pool, gym, skatepark
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('sports', {
|
||||
|
||||
stadium: {
|
||||
meta: { id: 'sports_stadium', mood: 'exciting', colors: ['#1a3a1a', '#2a5a2a', '#88cc44', '#ffffff'], tags: ['stadium', 'football', 'soccer', 'crowd'], description: 'Large outdoor stadium with green pitch, bright floodlights, and packed stands', compatibleMusicMoods: ['exciting', 'energetic', 'upbeat'], recommendedFor: ['sports', 'action', 'trivia'] },
|
||||
colors: ['#1a3a1a', '#2a5a2a', '#88cc44', '#ffffff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Night sky
|
||||
Draw.fillGradient(ctx, w, h, ['#0a0a1e', '#1a1a2e'], 90);
|
||||
// Pitch
|
||||
Draw.rect(ctx, w * 0.08, h * 0.4, w * 0.84, h * 0.45, c[1]);
|
||||
// Mowed stripes on pitch
|
||||
for (var s = 0; s < 8; s++) {
|
||||
var sx = w * 0.08 + s * w * 0.105;
|
||||
Draw.rect(ctx, sx, h * 0.4, w * 0.105, h * 0.45, s % 2 === 0 ? c[1] : H.lighten(c[1], 0.08));
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1001);
|
||||
// Field markings
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.6); ctx.lineWidth = 2;
|
||||
ctx.strokeRect(w * 0.1, h * 0.42, w * 0.8, h * 0.41);
|
||||
// Center line
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5, h * 0.42); ctx.lineTo(w * 0.5, h * 0.83); ctx.stroke();
|
||||
// Center circle
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, h * 0.625, h * 0.08, 0, Math.PI * 2); ctx.stroke();
|
||||
Draw.circle(ctx, w * 0.5, h * 0.625, 3, H.rgba(c[3], 0.6));
|
||||
// Goal boxes
|
||||
ctx.strokeRect(w * 0.1, h * 0.52, w * 0.08, h * 0.21);
|
||||
ctx.strokeRect(w * 0.82, h * 0.52, w * 0.08, h * 0.21);
|
||||
// Stands
|
||||
for (var row = 0; row < 5; row++) {
|
||||
var ry = h * 0.08 + row * h * 0.06;
|
||||
Draw.rect(ctx, w * 0.02, ry, w * 0.96, h * 0.05, H.rgba('#444466', 0.6 - row * 0.08));
|
||||
// Crowd dots
|
||||
for (var c2 = 0; c2 < 40; c2++) {
|
||||
var cx = w * 0.04 + rng() * w * 0.92;
|
||||
var crowdColor = H.pick(['#cc3333', '#3333cc', '#ffffff', '#ffcc00', '#33cc33', '#ff6600']);
|
||||
var bob = Math.sin(t * 2 + c2 * 0.3 + row) * 1;
|
||||
Draw.circle(ctx, cx, ry + h * 0.025 + bob, 2, H.rgba(crowdColor, 0.5));
|
||||
}
|
||||
}
|
||||
// Bottom stands
|
||||
for (var row2 = 0; row2 < 3; row2++) {
|
||||
var ry2 = h * 0.87 + row2 * h * 0.04;
|
||||
Draw.rect(ctx, w * 0.02, ry2, w * 0.96, h * 0.035, H.rgba('#444466', 0.5 - row2 * 0.1));
|
||||
for (var c3 = 0; c3 < 30; c3++) {
|
||||
var cx2 = w * 0.04 + rng() * w * 0.92;
|
||||
Draw.circle(ctx, cx2, ry2 + h * 0.017, 2, H.rgba(H.pick(['#cc3333', '#3333cc', '#ffffff', '#ffcc00']), 0.4));
|
||||
}
|
||||
}
|
||||
// Floodlights
|
||||
for (var fl = 0; fl < 4; fl++) {
|
||||
var fx = w * 0.05 + fl * w * 0.3;
|
||||
Draw.rect(ctx, fx, h * 0.0, 4, h * 0.12, '#666');
|
||||
Draw.rect(ctx, fx - 8, 0, 20, 8, '#888');
|
||||
var lightPulse = 0.15 + Math.sin(t * 0.3 + fl * 0.5) * 0.03;
|
||||
Draw.glow(ctx, fx + 2, h * 0.02, 50, H.rgba('#FFFFFF', lightPulse));
|
||||
// Light cones on pitch
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', 0.02 + Math.sin(t * 0.2 + fl) * 0.005);
|
||||
ctx.beginPath(); ctx.moveTo(fx, h * 0.08); ctx.lineTo(fx - w * 0.15, h * 0.85);
|
||||
ctx.lineTo(fx + w * 0.15, h * 0.85); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 12, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
raceTrack: {
|
||||
meta: { id: 'sports_racetrack', mood: 'fast', colors: ['#333333', '#555555', '#cc0000', '#ffffff'], tags: ['racing', 'speed', 'track', 'cars'], description: 'Asphalt race track with lane markings, kerbing, and grandstand', compatibleMusicMoods: ['energetic', 'fast', 'intense'], recommendedFor: ['racing', 'action', 'sports'] },
|
||||
colors: ['#333333', '#555555', '#cc0000', '#ffffff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Sky
|
||||
Draw.fillGradient(ctx, w, h, ['#4488bb', '#88bbdd'], 90);
|
||||
// Green surroundings
|
||||
Draw.rect(ctx, 0, h * 0.3, w, h * 0.7, '#3a6a2a');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1101);
|
||||
// Track surface (oval perspective)
|
||||
ctx.fillStyle = c[0];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.05, h * 0.85); ctx.lineTo(w * 0.2, h * 0.4);
|
||||
ctx.lineTo(w * 0.8, h * 0.4); ctx.lineTo(w * 0.95, h * 0.85);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Inner grass
|
||||
ctx.fillStyle = '#4a7a3a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.2, h * 0.82); ctx.lineTo(w * 0.3, h * 0.48);
|
||||
ctx.lineTo(w * 0.7, h * 0.48); ctx.lineTo(w * 0.8, h * 0.82);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Lane markings
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.6); ctx.lineWidth = 2; ctx.setLineDash([15, 15]);
|
||||
for (var l = 1; l < 4; l++) {
|
||||
var t1 = l / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(H.lerp(w * 0.05, w * 0.2, t1), H.lerp(h * 0.85, h * 0.82, t1));
|
||||
ctx.lineTo(H.lerp(w * 0.2, w * 0.3, t1), H.lerp(h * 0.4, h * 0.48, t1));
|
||||
ctx.lineTo(H.lerp(w * 0.8, w * 0.7, t1), H.lerp(h * 0.4, h * 0.48, t1));
|
||||
ctx.lineTo(H.lerp(w * 0.95, w * 0.8, t1), H.lerp(h * 0.85, h * 0.82, t1));
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
// Kerbing (red-white stripes)
|
||||
for (var k = 0; k < 20; k++) {
|
||||
var kx1 = H.lerp(w * 0.2, w * 0.8, k / 20);
|
||||
var kx2 = H.lerp(w * 0.2, w * 0.8, (k + 1) / 20);
|
||||
Draw.rect(ctx, kx1, h * 0.39, kx2 - kx1, h * 0.02, k % 2 === 0 ? c[2] : c[3]);
|
||||
}
|
||||
// Start/finish line
|
||||
for (var sq = 0; sq < 10; sq++) {
|
||||
for (var sr = 0; sr < 3; sr++) {
|
||||
var checker = (sq + sr) % 2 === 0;
|
||||
Draw.rect(ctx, w * 0.48 + sq * 4, h * 0.65 + sr * 4, 4, 4, checker ? '#000' : '#fff');
|
||||
}
|
||||
}
|
||||
// Grandstand
|
||||
Draw.rect(ctx, w * 0.3, h * 0.25, w * 0.4, h * 0.12, '#556677');
|
||||
for (var gr = 0; gr < 4; gr++) {
|
||||
var gy = h * 0.26 + gr * h * 0.025;
|
||||
for (var gs = 0; gs < 25; gs++) {
|
||||
var gx = w * 0.32 + rng() * w * 0.36;
|
||||
Draw.circle(ctx, gx, gy + h * 0.012, 1.5, H.rgba(H.pick(['#ff3333', '#3355cc', '#ffcc00', '#ffffff']), 0.5));
|
||||
}
|
||||
}
|
||||
// Clouds
|
||||
Draw.clouds(ctx, w, h, h * 0.08, 4, '#FFFFFF', 1102);
|
||||
// Tire marks with animation
|
||||
var skidOffset = (t * 40) % w;
|
||||
ctx.strokeStyle = H.rgba('#222', 0.15); ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.35, h * 0.75);
|
||||
ctx.quadraticCurveTo(w * 0.45, h * 0.7, w * 0.55, h * 0.72); ctx.stroke();
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#AAAAAA' }
|
||||
},
|
||||
|
||||
court: {
|
||||
meta: { id: 'sports_court', mood: 'competitive', colors: ['#c47a34', '#e8944a', '#ffffff', '#2255aa'], tags: ['basketball', 'court', 'indoor', 'wood'], description: 'Indoor basketball court with polished hardwood, court lines, and arena lighting', compatibleMusicMoods: ['energetic', 'upbeat', 'competitive'], recommendedFor: ['sports', 'action', 'trivia'] },
|
||||
colors: ['#c47a34', '#e8944a', '#ffffff', '#2255aa'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Arena dark upper
|
||||
Draw.fillGradient(ctx, w, h, ['#1a1a2a', '#2a2a3a'], 90);
|
||||
// Hardwood floor
|
||||
var floorY = h * 0.35;
|
||||
Draw.rect(ctx, w * 0.03, floorY, w * 0.94, h * 0.55, c[0]);
|
||||
// Wood grain lines
|
||||
var rng = H.seededRandom(1201);
|
||||
for (var g = 0; g < 20; g++) {
|
||||
var gx = w * 0.03 + g * (w * 0.94 / 20);
|
||||
ctx.strokeStyle = H.rgba(H.darken(c[0], 0.15), 0.3); ctx.lineWidth = 0.5;
|
||||
ctx.beginPath(); ctx.moveTo(gx, floorY); ctx.lineTo(gx, floorY + h * 0.55); ctx.stroke();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1202);
|
||||
var floorY = h * 0.35;
|
||||
// Court lines
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(w * 0.08, floorY + h * 0.03, w * 0.84, h * 0.49);
|
||||
// Center line
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5, floorY + h * 0.03); ctx.lineTo(w * 0.5, floorY + h * 0.52); ctx.stroke();
|
||||
// Center circle
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, floorY + h * 0.275, h * 0.08, 0, Math.PI * 2); ctx.stroke();
|
||||
// Three-point arcs
|
||||
ctx.beginPath(); ctx.arc(w * 0.1, floorY + h * 0.275, h * 0.2, -0.9, 0.9); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(w * 0.9, floorY + h * 0.275, h * 0.2, Math.PI - 0.9, Math.PI + 0.9); ctx.stroke();
|
||||
// Free throw lanes (paint)
|
||||
ctx.fillStyle = H.rgba(c[3], 0.15);
|
||||
ctx.fillRect(w * 0.08, floorY + h * 0.14, w * 0.12, h * 0.27);
|
||||
ctx.fillRect(w * 0.8, floorY + h * 0.14, w * 0.12, h * 0.27);
|
||||
ctx.strokeRect(w * 0.08, floorY + h * 0.14, w * 0.12, h * 0.27);
|
||||
ctx.strokeRect(w * 0.8, floorY + h * 0.14, w * 0.12, h * 0.27);
|
||||
// Backboards and hoops
|
||||
for (var side = 0; side < 2; side++) {
|
||||
var hx = side === 0 ? w * 0.09 : w * 0.91;
|
||||
Draw.rect(ctx, hx - 8, floorY + h * 0.2, 16, h * 0.06, '#FFFFFF');
|
||||
ctx.strokeStyle = '#FF4400'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(hx, floorY + h * 0.275, 8, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// Scoreboard
|
||||
Draw.rect(ctx, w * 0.35, h * 0.04, w * 0.3, h * 0.08, '#111');
|
||||
var scoreGlow = 0.7 + Math.sin(t * 0.5) * 0.15;
|
||||
ctx.fillStyle = H.rgba('#ff3333', scoreGlow);
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.fillText('HOME', w * 0.38, h * 0.08);
|
||||
ctx.fillText('AWAY', w * 0.55, h * 0.08);
|
||||
ctx.fillStyle = H.rgba('#00ff44', scoreGlow);
|
||||
ctx.fillText('88', w * 0.42, h * 0.1);
|
||||
ctx.fillText('85', w * 0.58, h * 0.1);
|
||||
// Arena lights
|
||||
for (var lt = 0; lt < 6; lt++) {
|
||||
var lx = w * 0.1 + lt * w * 0.16;
|
||||
Draw.rect(ctx, lx, h * 0.13, w * 0.06, 4, '#ddd');
|
||||
var intensity = 0.06 + Math.sin(t * 0.2 + lt * 0.4) * 0.015;
|
||||
Draw.glow(ctx, lx + w * 0.03, h * 0.14, 40, H.rgba('#FFFFFF', intensity));
|
||||
}
|
||||
// Crowd in upper stands
|
||||
for (var cr = 0; cr < 3; cr++) {
|
||||
var cry = h * 0.15 + cr * h * 0.06;
|
||||
for (var ci = 0; ci < 25; ci++) {
|
||||
var ccx = w * 0.06 + rng() * w * 0.88;
|
||||
Draw.circle(ctx, ccx, cry, 1.5, H.rgba(H.pick(['#cc3333', '#3355cc', '#ffffff', '#ffaa00']), 0.35));
|
||||
}
|
||||
}
|
||||
// Floor reflection shimmer
|
||||
var shimmer = 0.03 + Math.sin(t * 0.4) * 0.01;
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', shimmer);
|
||||
ctx.fillRect(w * 0.03, floorY, w * 0.94, h * 0.55);
|
||||
},
|
||||
particles: { type: 'sparkles', count: 8, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
field: {
|
||||
meta: { id: 'sports_field', mood: 'fresh', colors: ['#2a7a2a', '#3a9a3a', '#ffffff', '#88cc44'], tags: ['soccer', 'field', 'outdoor', 'grass'], description: 'Open soccer field under blue sky with goals, corner flags, and freshly cut grass', compatibleMusicMoods: ['upbeat', 'cheerful', 'energetic'], recommendedFor: ['sports', 'action', 'pet'] },
|
||||
colors: ['#2a7a2a', '#3a9a3a', '#ffffff', '#88cc44'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Sky
|
||||
Draw.fillGradient(ctx, w, h, ['#3388cc', '#88ccee', '#aaddee'], 90);
|
||||
// Field with mowed stripes
|
||||
for (var s = 0; s < 10; s++) {
|
||||
var sx = s * (w / 10);
|
||||
Draw.rect(ctx, sx, h * 0.45, w / 10, h * 0.55, s % 2 === 0 ? c[0] : c[1]);
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1301);
|
||||
// Field markings
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 2;
|
||||
// Boundary
|
||||
ctx.strokeRect(w * 0.05, h * 0.47, w * 0.9, h * 0.48);
|
||||
// Halfway line
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5, h * 0.47); ctx.lineTo(w * 0.5, h * 0.95); ctx.stroke();
|
||||
// Center circle
|
||||
ctx.beginPath(); ctx.arc(w * 0.5, h * 0.71, h * 0.08, 0, Math.PI * 2); ctx.stroke();
|
||||
Draw.circle(ctx, w * 0.5, h * 0.71, 3, c[2]);
|
||||
// Penalty areas
|
||||
ctx.strokeRect(w * 0.05, h * 0.57, w * 0.1, h * 0.28);
|
||||
ctx.strokeRect(w * 0.85, h * 0.57, w * 0.1, h * 0.28);
|
||||
// Goal areas
|
||||
ctx.strokeRect(w * 0.05, h * 0.63, w * 0.04, h * 0.16);
|
||||
ctx.strokeRect(w * 0.91, h * 0.63, w * 0.04, h * 0.16);
|
||||
// Goals (nets)
|
||||
for (var g = 0; g < 2; g++) {
|
||||
var gx = g === 0 ? w * 0.02 : w * 0.95;
|
||||
var gw = w * 0.03;
|
||||
Draw.rect(ctx, gx, h * 0.63, gw, h * 0.16, H.rgba('#FFFFFF', 0.15));
|
||||
ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(gx, h * 0.63, gw, h * 0.16);
|
||||
// Net mesh
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.2); ctx.lineWidth = 0.5;
|
||||
for (var n = 0; n < 5; n++) {
|
||||
ctx.beginPath(); ctx.moveTo(gx, h * 0.63 + n * h * 0.032); ctx.lineTo(gx + gw, h * 0.63 + n * h * 0.032); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(gx + n * gw / 5, h * 0.63); ctx.lineTo(gx + n * gw / 5, h * 0.79); ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Corner flags
|
||||
for (var cf = 0; cf < 4; cf++) {
|
||||
var cfx = cf < 2 ? w * 0.05 : w * 0.95;
|
||||
var cfy = cf % 2 === 0 ? h * 0.47 : h * 0.95;
|
||||
Draw.rect(ctx, cfx - 1, cfy - 16, 2, 16, '#888');
|
||||
var flagWave = Math.sin(t * 3 + cf) * 3;
|
||||
ctx.fillStyle = '#ff4444';
|
||||
ctx.beginPath(); ctx.moveTo(cfx, cfy - 16); ctx.lineTo(cfx + 8 + flagWave, cfy - 13);
|
||||
ctx.lineTo(cfx, cfy - 10); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Clouds
|
||||
Draw.clouds(ctx, w, h, h * 0.08, 5, '#FFFFFF', 1302);
|
||||
// Sun
|
||||
var sunPulse = 0.8 + Math.sin(t * 0.2) * 0.1;
|
||||
Draw.circle(ctx, w * 0.85, h * 0.1, 20, H.rgba('#ffdd44', sunPulse));
|
||||
Draw.glow(ctx, w * 0.85, h * 0.1, 50, '#ffdd44');
|
||||
// Trees along side
|
||||
Draw.trees(ctx, w, h, h * 0.45, 8, '#5a3a1a', '#2a6a2a', 1303);
|
||||
},
|
||||
particles: { type: 'leaves', count: 8, color: '#88CC44' }
|
||||
},
|
||||
|
||||
arena: {
|
||||
meta: { id: 'sports_arena', mood: 'intense', colors: ['#1a1a2e', '#2a2a44', '#ff4444', '#ffaa00'], tags: ['arena', 'boxing', 'mma', 'spotlight'], description: 'Dark fighting arena with dramatic spotlights, ropes, and smoky atmosphere', compatibleMusicMoods: ['intense', 'dramatic', 'energetic'], recommendedFor: ['action', 'sports', 'rpg'] },
|
||||
colors: ['#1a1a2e', '#2a2a44', '#ff4444', '#ffaa00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Dark floor
|
||||
Draw.rect(ctx, 0, h * 0.7, w, h * 0.3, H.darken(c[0], 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1401);
|
||||
// Ring platform
|
||||
ctx.fillStyle = '#333344';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.15, h * 0.7); ctx.lineTo(w * 0.25, h * 0.5);
|
||||
ctx.lineTo(w * 0.75, h * 0.5); ctx.lineTo(w * 0.85, h * 0.7);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Ring canvas (mat)
|
||||
ctx.fillStyle = '#e8e0d0';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.22, h * 0.52); ctx.lineTo(w * 0.27, h * 0.48);
|
||||
ctx.lineTo(w * 0.73, h * 0.48); ctx.lineTo(w * 0.78, h * 0.52);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Ring mat surface
|
||||
ctx.fillStyle = H.rgba(c[2], 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.22, h * 0.52); ctx.lineTo(w * 0.27, h * 0.48);
|
||||
ctx.lineTo(w * 0.73, h * 0.48); ctx.lineTo(w * 0.78, h * 0.52);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Corner posts
|
||||
var posts = [[w * 0.27, h * 0.48], [w * 0.73, h * 0.48], [w * 0.22, h * 0.52], [w * 0.78, h * 0.52]];
|
||||
for (var p = 0; p < 4; p++) {
|
||||
Draw.rect(ctx, posts[p][0] - 3, posts[p][1] - h * 0.12, 6, h * 0.12, '#888');
|
||||
// Turnbuckle
|
||||
Draw.circle(ctx, posts[p][0], posts[p][1] - h * 0.12, 4, p < 2 ? c[2] : c[3]);
|
||||
}
|
||||
// Ropes
|
||||
for (var r = 0; r < 3; r++) {
|
||||
var ropeY = h * 0.48 - (r + 1) * h * 0.03;
|
||||
var sag = Math.sin(t * 0.5 + r) * 2;
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 2;
|
||||
// Top ropes
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.27, ropeY);
|
||||
ctx.quadraticCurveTo(w * 0.5, ropeY + sag + 3, w * 0.73, ropeY); ctx.stroke();
|
||||
// Side ropes (perspective)
|
||||
var sideRopeY = ropeY + h * 0.02;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.27, ropeY);
|
||||
ctx.quadraticCurveTo(w * 0.245, sideRopeY + sag, w * 0.22, sideRopeY + h * 0.04); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.73, ropeY);
|
||||
ctx.quadraticCurveTo(w * 0.755, sideRopeY + sag, w * 0.78, sideRopeY + h * 0.04); ctx.stroke();
|
||||
}
|
||||
// Spotlights
|
||||
for (var sp = 0; sp < 3; sp++) {
|
||||
var spx = w * 0.3 + sp * w * 0.2;
|
||||
var pulse = 0.12 + Math.sin(t * 0.6 + sp * 1.5) * 0.04;
|
||||
Draw.glow(ctx, spx, 0, 30, H.rgba(c[3], 0.4));
|
||||
ctx.fillStyle = H.rgba(c[3], pulse);
|
||||
ctx.beginPath(); ctx.moveTo(spx - 15, 0); ctx.lineTo(spx + 15, 0);
|
||||
ctx.lineTo(spx + w * 0.1, h * 0.7); ctx.lineTo(spx - w * 0.1, h * 0.7);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Crowd silhouettes
|
||||
for (var cr = 0; cr < 50; cr++) {
|
||||
var ccx = rng() * w;
|
||||
var ccy = h * 0.7 + rng() * h * 0.15;
|
||||
var bob = Math.sin(t * 1.5 + cr * 0.5) * 1.5;
|
||||
Draw.circle(ctx, ccx, ccy + bob, 3 + rng() * 2, H.rgba('#333344', 0.7));
|
||||
}
|
||||
// Smoke effect
|
||||
for (var sm = 0; sm < 4; sm++) {
|
||||
var smx = w * 0.3 + rng() * w * 0.4 + Math.sin(t * 0.2 + sm) * 15;
|
||||
var smy = h * 0.3 + rng() * h * 0.15;
|
||||
ctx.fillStyle = H.rgba('#aaaacc', 0.03);
|
||||
ctx.beginPath(); ctx.ellipse(smx, smy, 60 + rng() * 40, 20, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#CCBBAA' }
|
||||
},
|
||||
|
||||
pool: {
|
||||
meta: { id: 'sports_pool', mood: 'calm', colors: ['#1a6088', '#2288aa', '#44bbdd', '#88eeff'], tags: ['swimming', 'water', 'pool', 'blue'], description: 'Olympic swimming pool with lane dividers, rippling water, and tiled edges', compatibleMusicMoods: ['calm', 'peaceful', 'ambient'], recommendedFor: ['sports', 'racing', 'puzzle'] },
|
||||
colors: ['#1a6088', '#2288aa', '#44bbdd', '#88eeff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Indoor ceiling
|
||||
Draw.fillGradient(ctx, w, h, ['#e8e4dc', '#d8d4cc'], 90);
|
||||
// Pool water
|
||||
var waterStart = h * 0.35;
|
||||
ctx.fillStyle = Draw.linearGradient(ctx, 0, waterStart, 0, h, [
|
||||
[0, c[1]], [0.5, c[2]], [1, c[0]]
|
||||
]);
|
||||
ctx.fillRect(w * 0.05, waterStart, w * 0.9, h * 0.55);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1501);
|
||||
var waterStart = h * 0.35;
|
||||
var poolW = w * 0.9;
|
||||
var poolX = w * 0.05;
|
||||
// Lane dividers
|
||||
var lanes = 8;
|
||||
for (var l = 1; l < lanes; l++) {
|
||||
var lx = poolX + l * (poolW / lanes);
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.3); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(lx, waterStart); ctx.lineTo(lx, waterStart + h * 0.55); ctx.stroke();
|
||||
// Lane rope beads
|
||||
for (var b = 0; b < 15; b++) {
|
||||
var by = waterStart + b * (h * 0.55 / 15);
|
||||
var beadColor = b % 3 === 0 ? c[3] : (b % 3 === 1 ? '#ff4444' : '#ffffff');
|
||||
Draw.circle(ctx, lx, by, 2, H.rgba(beadColor, 0.5));
|
||||
}
|
||||
}
|
||||
// Water caustics (animated light patterns)
|
||||
for (var ca = 0; ca < 25; ca++) {
|
||||
var cx = poolX + rng() * poolW;
|
||||
var cy = waterStart + rng() * h * 0.5;
|
||||
var caSize = 15 + rng() * 30;
|
||||
var caAlpha = 0.03 + Math.sin(t * 1.5 + ca * 0.7) * 0.02;
|
||||
ctx.fillStyle = H.rgba(c[3], caAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - caSize * 0.3);
|
||||
ctx.quadraticCurveTo(cx + caSize * 0.4, cy, cx, cy + caSize * 0.3);
|
||||
ctx.quadraticCurveTo(cx - caSize * 0.4, cy, cx, cy - caSize * 0.3);
|
||||
ctx.fill();
|
||||
}
|
||||
// Water surface ripples
|
||||
for (var rp = 0; rp < 12; rp++) {
|
||||
var ry = waterStart + rp * 2;
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.06);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
for (var rx = poolX; rx < poolX + poolW; rx += 3) {
|
||||
ctx.lineTo(rx, ry + Math.sin(rx * 0.03 + t * 2 + rp * 0.5) * 2);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Pool edge tiles
|
||||
Draw.rect(ctx, poolX - 5, waterStart - 5, poolW + 10, 8, '#ddd');
|
||||
Draw.rect(ctx, poolX - 5, waterStart + h * 0.55 - 3, poolW + 10, 8, '#ddd');
|
||||
// Tile pattern on edge
|
||||
for (var ti = 0; ti < 30; ti++) {
|
||||
var tix = poolX + ti * (poolW / 30);
|
||||
ctx.strokeStyle = H.rgba('#bbb', 0.4); ctx.lineWidth = 0.5;
|
||||
ctx.beginPath(); ctx.moveTo(tix, waterStart - 5); ctx.lineTo(tix, waterStart + 3); ctx.stroke();
|
||||
}
|
||||
// Starting blocks
|
||||
for (var sb = 0; sb < lanes; sb++) {
|
||||
var sbx = poolX + sb * (poolW / lanes) + poolW / lanes / 2 - 8;
|
||||
Draw.rect(ctx, sbx, waterStart - 12, 16, 8, '#888');
|
||||
Draw.rect(ctx, sbx + 2, waterStart - 16, 12, 5, '#aaa');
|
||||
}
|
||||
// Lane numbers on bottom
|
||||
ctx.font = '12px monospace'; ctx.fillStyle = H.rgba('#000', 0.15);
|
||||
for (var ln = 0; ln < lanes; ln++) {
|
||||
var lnx = poolX + ln * (poolW / lanes) + poolW / lanes / 2 - 3;
|
||||
ctx.fillText(String(ln + 1), lnx, waterStart + h * 0.5);
|
||||
}
|
||||
// Ceiling lights reflected in water
|
||||
for (var cl = 0; cl < 5; cl++) {
|
||||
var clx = w * 0.15 + cl * w * 0.18;
|
||||
Draw.rect(ctx, clx, h * 0.05, w * 0.08, 4, '#eee');
|
||||
var refAlpha = 0.04 + Math.sin(t * 0.4 + cl) * 0.02;
|
||||
Draw.glow(ctx, clx + w * 0.04, waterStart + h * 0.2, 40, H.rgba(c[3], refAlpha));
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 15, color: '#88EEFF' }
|
||||
},
|
||||
|
||||
gym: {
|
||||
meta: { id: 'sports_gym', mood: 'competitive', colors: ['#ddc8a0', '#c4a870', '#ffffff', '#cc3333'], tags: ['gymnastics', 'gym', 'mats', 'indoor'], description: 'Gymnastics arena with spring floor, apparatus silhouettes, and competition banners', compatibleMusicMoods: ['upbeat', 'focused', 'energetic'], recommendedFor: ['sports', 'action', 'creative'] },
|
||||
colors: ['#ddc8a0', '#c4a870', '#ffffff', '#cc3333'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Arena interior
|
||||
Draw.fillGradient(ctx, w, h, ['#2a2a3a', '#3a3a4a'], 90);
|
||||
// Spring floor
|
||||
Draw.rect(ctx, w * 0.1, h * 0.6, w * 0.8, h * 0.3, c[0]);
|
||||
Draw.rect(ctx, w * 0.1, h * 0.6, w * 0.8, h * 0.3, H.rgba(c[2], 0.05));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1601);
|
||||
// Floor exercise boundary
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(w * 0.12, h * 0.62, w * 0.76, h * 0.26);
|
||||
// Diagonal line on floor
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.2); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.12, h * 0.62); ctx.lineTo(w * 0.88, h * 0.88); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.88, h * 0.62); ctx.lineTo(w * 0.12, h * 0.88); ctx.stroke();
|
||||
// Balance beam
|
||||
Draw.rect(ctx, w * 0.03, h * 0.55, w * 0.18, h * 0.015, c[1]);
|
||||
Draw.rect(ctx, w * 0.06, h * 0.565, 4, h * 0.12, '#666');
|
||||
Draw.rect(ctx, w * 0.17, h * 0.565, 4, h * 0.12, '#666');
|
||||
// Vault table
|
||||
Draw.rect(ctx, w * 0.78, h * 0.54, w * 0.08, h * 0.03, '#888');
|
||||
Draw.rect(ctx, w * 0.8, h * 0.57, 4, h * 0.1, '#666');
|
||||
Draw.rect(ctx, w * 0.84, h * 0.57, 4, h * 0.1, '#666');
|
||||
// Runway mat
|
||||
Draw.rect(ctx, w * 0.88, h * 0.56, w * 0.1, h * 0.015, '#4488dd');
|
||||
// Rings (hanging)
|
||||
for (var ring = 0; ring < 2; ring++) {
|
||||
var rx = w * 0.4 + ring * w * 0.06;
|
||||
ctx.strokeStyle = '#888'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, h * 0.25); ctx.stroke();
|
||||
var sway = Math.sin(t * 0.3 + ring * Math.PI) * 3;
|
||||
ctx.strokeStyle = c[1]; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.arc(rx + sway, h * 0.28, 8, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// Parallel bars
|
||||
Draw.rect(ctx, w * 0.55, h * 0.45, w * 0.15, 3, '#aa8855');
|
||||
Draw.rect(ctx, w * 0.55, h * 0.5, w * 0.15, 3, '#aa8855');
|
||||
Draw.rect(ctx, w * 0.58, h * 0.5, 3, h * 0.17, '#777');
|
||||
Draw.rect(ctx, w * 0.67, h * 0.5, 3, h * 0.17, '#777');
|
||||
// Scoreboard
|
||||
Draw.rect(ctx, w * 0.3, h * 0.05, w * 0.4, h * 0.08, '#111');
|
||||
ctx.strokeStyle = '#333'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(w * 0.3, h * 0.05, w * 0.4, h * 0.08);
|
||||
var scoreGlow = 0.7 + Math.sin(t * 0.6) * 0.2;
|
||||
ctx.fillStyle = H.rgba('#00ff66', scoreGlow);
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText('FLOOR 9.450', w * 0.35, h * 0.09);
|
||||
// Competition banners
|
||||
for (var bn = 0; bn < 5; bn++) {
|
||||
var bx = w * 0.1 + bn * w * 0.18;
|
||||
var bannerColor = H.pick([c[3], '#3355cc', '#ffcc00', '#33aa55', '#aa33aa']);
|
||||
Draw.rect(ctx, bx, h * 0.14, 18, h * 0.08, bannerColor);
|
||||
// Banner point
|
||||
ctx.fillStyle = bannerColor;
|
||||
ctx.beginPath(); ctx.moveTo(bx, h * 0.22); ctx.lineTo(bx + 9, h * 0.24); ctx.lineTo(bx + 18, h * 0.22); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Arena lights
|
||||
for (var al = 0; al < 4; al++) {
|
||||
var alx = w * 0.15 + al * w * 0.22;
|
||||
var lightAlpha = 0.06 + Math.sin(t * 0.25 + al * 0.6) * 0.015;
|
||||
Draw.glow(ctx, alx, h * 0.14, 35, H.rgba('#FFFFFF', lightAlpha));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 10, color: '#DDCC99' }
|
||||
},
|
||||
|
||||
skatepark: {
|
||||
meta: { id: 'sports_skatepark', mood: 'cool', colors: ['#555566', '#777788', '#ff6600', '#ffcc00'], tags: ['skate', 'urban', 'concrete', 'graffiti'], description: 'Urban skatepark with concrete ramps, half-pipes, graffiti, and warm sunset light', compatibleMusicMoods: ['cool', 'energetic', 'retro', 'upbeat'], recommendedFor: ['action', 'sports', 'racing', 'creative'] },
|
||||
colors: ['#555566', '#777788', '#ff6600', '#ffcc00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
// Warm sunset sky
|
||||
Draw.fillGradient(ctx, w, h, ['#dd6633', '#ee9955', '#ffcc88'], 90);
|
||||
// Concrete ground
|
||||
Draw.rect(ctx, 0, h * 0.55, w, h * 0.45, c[0]);
|
||||
// Concrete texture
|
||||
var rng = H.seededRandom(1700);
|
||||
for (var sp = 0; sp < 30; sp++) {
|
||||
Draw.circle(ctx, rng() * w, h * 0.55 + rng() * h * 0.45, 1, H.rgba('#444', 0.15));
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(1701);
|
||||
// Half-pipe
|
||||
ctx.fillStyle = c[1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.05, h * 0.55); ctx.quadraticCurveTo(w * 0.05, h * 0.9, w * 0.2, h * 0.9);
|
||||
ctx.lineTo(w * 0.2, h * 0.55); ctx.closePath(); ctx.fill();
|
||||
// Coping on half-pipe
|
||||
Draw.rect(ctx, w * 0.04, h * 0.54, w * 0.17, 3, '#999');
|
||||
// Quarter pipe on right
|
||||
ctx.fillStyle = c[1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.75, h * 0.9); ctx.quadraticCurveTo(w * 0.95, h * 0.9, w * 0.95, h * 0.6);
|
||||
ctx.lineTo(w * 0.95, h * 0.55); ctx.lineTo(w * 0.75, h * 0.55); ctx.closePath(); ctx.fill();
|
||||
Draw.rect(ctx, w * 0.74, h * 0.54, w * 0.22, 3, '#999');
|
||||
// Fun box / pyramid
|
||||
ctx.fillStyle = H.darken(c[0], 0.1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.35, h * 0.85); ctx.lineTo(w * 0.4, h * 0.72);
|
||||
ctx.lineTo(w * 0.6, h * 0.72); ctx.lineTo(w * 0.65, h * 0.85);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Rail on top
|
||||
Draw.rect(ctx, w * 0.4, h * 0.715, w * 0.2, 3, '#aaa');
|
||||
// Grind rail
|
||||
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.25, h * 0.8); ctx.lineTo(w * 0.4, h * 0.8); ctx.stroke();
|
||||
Draw.rect(ctx, w * 0.25, h * 0.8, 3, h * 0.1, '#888');
|
||||
Draw.rect(ctx, w * 0.4, h * 0.8, 3, h * 0.1, '#888');
|
||||
// Graffiti on walls/ramps
|
||||
var graffitiColors = ['#ff3366', '#33ccff', '#ffcc00', '#44ff44', '#ff6600', '#cc44ff'];
|
||||
// Back wall
|
||||
Draw.rect(ctx, 0, h * 0.2, w, h * 0.35, H.rgba(c[0], 0.5));
|
||||
// Graffiti blobs
|
||||
for (var g = 0; g < 8; g++) {
|
||||
var gx = rng() * w * 0.9 + w * 0.05;
|
||||
var gy = h * 0.25 + rng() * h * 0.22;
|
||||
var gc = graffitiColors[Math.floor(rng() * graffitiColors.length)];
|
||||
ctx.fillStyle = H.rgba(gc, 0.2 + rng() * 0.15);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(gx, gy, 15 + rng() * 25, 8 + rng() * 12, rng() * Math.PI, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Street light
|
||||
Draw.rect(ctx, w * 0.7, h * 0.15, 3, h * 0.4, '#666');
|
||||
Draw.rect(ctx, w * 0.68, h * 0.14, 20, 6, '#777');
|
||||
var lampGlow = 0.3 + Math.sin(t * 0.4) * 0.1;
|
||||
Draw.circle(ctx, w * 0.69, h * 0.17, 5, H.rgba(c[3], lampGlow));
|
||||
Draw.glow(ctx, w * 0.69, h * 0.17, 40, H.rgba(c[3], lampGlow * 0.5));
|
||||
// Fence in background
|
||||
for (var f = 0; f < 15; f++) {
|
||||
var fx = w * 0.02 + f * w * 0.07;
|
||||
Draw.rect(ctx, fx, h * 0.35, 2, h * 0.2, H.rgba('#888', 0.3));
|
||||
}
|
||||
ctx.strokeStyle = H.rgba('#888', 0.25); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.4); ctx.lineTo(w, h * 0.4); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.48); ctx.lineTo(w, h * 0.48); ctx.stroke();
|
||||
// Warm sun glow
|
||||
Draw.glow(ctx, w * 0.15, h * 0.08, 60, '#ffaa44');
|
||||
Draw.circle(ctx, w * 0.15, h * 0.08, 18, H.rgba('#ffcc66', 0.8));
|
||||
},
|
||||
particles: { type: 'leaves', count: 10, color: '#CC8833' }
|
||||
}
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,267 @@
|
||||
// underwater.js — Underwater background theme (8 variants)
|
||||
// Depends on backgroundEngine.js being loaded first
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
function drawFish(ctx, x, y, s, col, dir) {
|
||||
ctx.fillStyle = col;
|
||||
ctx.beginPath(); ctx.ellipse(x, y, s, s * 0.5, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x - dir * s, y); ctx.lineTo(x - dir * s * 1.6, y - s * 0.5); ctx.lineTo(x - dir * s * 1.6, y + s * 0.5); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = '#FFF'; ctx.beginPath(); ctx.arc(x + dir * s * 0.4, y - s * 0.15, s * 0.18, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(x + dir * s * 0.45, y - s * 0.15, s * 0.08, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
function sunRays(ctx, w, h, n, col, t) {
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (var i = 0; i < n; i++) {
|
||||
var rx = (w / n) * i + Math.sin(t * 0.3 + i) * 20, rw = 15 + Math.sin(t * 0.5 + i * 1.3) * 8;
|
||||
ctx.fillStyle = H.rgba(col, 0.03 + Math.sin(t * 0.4 + i * 0.7) * 0.02);
|
||||
ctx.beginPath(); ctx.moveTo(rx - rw, 0); ctx.lineTo(rx + rw, 0); ctx.lineTo(rx + rw * 2.5, h); ctx.lineTo(rx - rw * 1.5, h); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
engine.registerTheme('underwater', {
|
||||
|
||||
coralReef: {
|
||||
meta: { id: 'underwater_coralReef', mood: 'cheerful', colors: ['#006994', '#00A8CC', '#FF6F61', '#FFB347'], tags: ['coral', 'reef', 'colorful', 'ocean'], description: 'A vibrant coral reef teeming with colorful marine life', compatibleMusicMoods: ['happy', 'upbeat', 'peaceful'], recommendedFor: ['puzzle', 'pet', 'creative'] },
|
||||
colors: ['#006994', '#00A8CC', '#FF6F61', '#FFB347'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [H.lighten(c[0], 0.15), c[0], H.darken(c[0], 0.3)], 90);
|
||||
sunRays(ctx, w, h, 6, '#88DDFF', t);
|
||||
Draw.rect(ctx, 0, h * 0.82, w, h * 0.18, '#C2A366');
|
||||
Draw.hills(ctx, w, h, h * 0.82, 10, '#B89555', 22);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(50);
|
||||
for (var i = 0; i < 14; i++) {
|
||||
var cx = rng() * w, cy = h * 0.72 + rng() * h * 0.15, ch = 15 + rng() * 35;
|
||||
var cc = [c[2], c[3], '#FF8899', '#FF77AA'][Math.floor(rng() * 4)];
|
||||
for (var b = 0; b < 3 + Math.floor(rng() * 3); b++) {
|
||||
var a = -Math.PI / 2 + (rng() - 0.5) * 1.2, bL = ch * (0.5 + rng() * 0.5), sw = Math.sin(t * 0.8 + i + b) * 2;
|
||||
ctx.strokeStyle = cc; ctx.lineWidth = 3 + rng() * 4; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.quadraticCurveTo(cx + Math.cos(a) * bL * 0.5 + sw, cy + Math.sin(a) * bL * 0.5, cx + Math.cos(a) * bL + sw, cy + Math.sin(a) * bL); ctx.stroke();
|
||||
Draw.circle(ctx, cx + Math.cos(a) * bL + sw, cy + Math.sin(a) * bL, 3 + rng() * 3, H.lighten(cc, 0.2));
|
||||
}
|
||||
}
|
||||
for (var f = 0; f < 10; f++) {
|
||||
var fx = (rng() * w + t * 20 * (0.5 + rng())) % (w + 40) - 20, fy = h * 0.15 + rng() * h * 0.55 + Math.sin(t + f * 2) * 8;
|
||||
drawFish(ctx, fx, fy, 5 + rng() * 8, [c[2], c[3], '#FFDD44', '#44DDFF'][Math.floor(rng() * 4)], 1);
|
||||
}
|
||||
},
|
||||
particles: { type: 'bubbles', count: 30, color: '#88DDFF' }
|
||||
},
|
||||
|
||||
deepSea: {
|
||||
meta: { id: 'underwater_deepSea', mood: 'mysterious', colors: ['#0A0A2E', '#0D1B3E', '#1A3A5C', '#00FFCC'], tags: ['deep', 'dark', 'bioluminescent', 'abyss'], description: 'The mysterious deep sea with bioluminescent creatures and faint light', compatibleMusicMoods: ['mysterious', 'ambient', 'dark'], recommendedFor: ['rpg', 'story', 'puzzle'] },
|
||||
colors: ['#0A0A2E', '#0D1B3E', '#1A3A5C', '#00FFCC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[2], c[1], c[0]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.88, w, h * 0.12, H.darken(c[0], 0.3));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(71);
|
||||
for (var j = 0; j < 6; j++) {
|
||||
var jx = rng() * w, jy = h * 0.2 + rng() * h * 0.5 + Math.sin(t * 0.6 + j * 1.5) * 15;
|
||||
var jr = 8 + rng() * 14, jC = rng() > 0.5 ? c[3] : '#BB77FF', p = 0.4 + Math.sin(t * 1.5 + j * 2) * 0.2;
|
||||
Draw.glow(ctx, jx, jy, jr * 3, H.rgba(jC, p * 0.3));
|
||||
ctx.fillStyle = H.rgba(jC, p); ctx.beginPath(); ctx.arc(jx, jy, jr, Math.PI, 0); ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = H.rgba(jC, p * 0.6); ctx.lineWidth = 1;
|
||||
for (var tt = 0; tt < 5; tt++) {
|
||||
var tx = jx - jr + (tt / 4) * jr * 2; ctx.beginPath(); ctx.moveTo(tx, jy);
|
||||
for (var tp = 0; tp < jr * 2 + rng() * jr; tp += 4) ctx.lineTo(tx + Math.sin(tp * 0.3 + t * 2 + tt) * 4, jy + tp);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
for (var a = 0; a < 4; a++) {
|
||||
var ax = rng() * w, ay = h * 0.6 + rng() * h * 0.25, gl = 0.3 + Math.sin(t * 2.5 + a * 3) * 0.25;
|
||||
Draw.glow(ctx, ax, ay, 12, H.rgba(c[3], gl)); Draw.circle(ctx, ax, ay, 2, H.rgba('#FFF', gl + 0.2));
|
||||
}
|
||||
for (var r = 0; r < 10; r++) Draw.circle(ctx, rng() * w, h * 0.88 + rng() * h * 0.05, 5 + rng() * 15, H.darken(c[1], 0.2 + rng() * 0.15));
|
||||
},
|
||||
particles: { type: 'fireflies', count: 20, color: '#00FFCC' }
|
||||
},
|
||||
|
||||
kelpForest: {
|
||||
meta: { id: 'underwater_kelpForest', mood: 'peaceful', colors: ['#0B4F3A', '#1A7A5C', '#2FA87A', '#7EC8A0'], tags: ['kelp', 'forest', 'green', 'serene'], description: 'A towering kelp forest with dappled light filtering through fronds', compatibleMusicMoods: ['peaceful', 'calm', 'ambient'], recommendedFor: ['puzzle', 'creative', 'story'] },
|
||||
colors: ['#0B4F3A', '#1A7A5C', '#2FA87A', '#7EC8A0'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [H.lighten(c[0], 0.25), c[0], H.darken(c[0], 0.2)], 90);
|
||||
sunRays(ctx, w, h, 5, '#AAFFCC', t);
|
||||
Draw.rect(ctx, 0, h * 0.88, w, h * 0.12, H.darken(c[0], 0.35));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(38);
|
||||
for (var i = 0; i < 18; i++) {
|
||||
var kx = rng() * w, kB = h * 0.9, kT = h * 0.05 + rng() * h * 0.3;
|
||||
var kC = [c[1], c[2], c[3]][Math.floor(rng() * 3)];
|
||||
ctx.strokeStyle = H.rgba(kC, 0.6 + rng() * 0.3); ctx.lineWidth = 3 + rng() * 3;
|
||||
ctx.beginPath(); ctx.moveTo(kx, kB);
|
||||
var seg = 8 + Math.floor(rng() * 6);
|
||||
for (var s = 1; s <= seg; s++) ctx.lineTo(kx + Math.sin(s * 0.8 + t * 0.7 + i) * (12 + rng() * 8), kB - (kB - kT) * (s / seg));
|
||||
ctx.stroke();
|
||||
for (var lf = 0; lf < 4; lf++) {
|
||||
var lfY = kB - (kB - kT) * ((lf + 1) / 5), d = rng() > 0.5 ? 1 : -1;
|
||||
ctx.fillStyle = H.rgba(kC, 0.4);
|
||||
ctx.beginPath(); ctx.ellipse(kx + Math.sin((lf + 1) * 0.8 + t * 0.7 + i) * 12 + d * 8, lfY, 10, 3, d * 0.3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
}
|
||||
for (var f = 0; f < 7; f++) drawFish(ctx, (rng() * w + t * 25 * (rng() + 0.3)) % (w + 30) - 15, h * 0.2 + rng() * h * 0.5 + Math.sin(t * 2 + f) * 6, 4 + rng() * 4, H.rgba(c[3], 0.7), 1);
|
||||
},
|
||||
particles: { type: 'bubbles', count: 20, color: '#88EEBB' }
|
||||
},
|
||||
|
||||
submarine: {
|
||||
meta: { id: 'underwater_submarine', mood: 'adventurous', colors: ['#0A1628', '#1A3050', '#D4AA44', '#CC4444'], tags: ['submarine', 'exploration', 'industrial', 'adventure'], description: 'A submarine expedition through dark ocean depths with spotlight beams', compatibleMusicMoods: ['adventurous', 'mysterious', 'tense'], recommendedFor: ['action', 'rpg', 'story'] },
|
||||
colors: ['#0A1628', '#1A3050', '#D4AA44', '#CC4444'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[1], c[0]], 90);
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, H.darken(c[0], 0.4));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(82), sx = w * 0.5 + Math.sin(t * 0.3) * 20, sy = h * 0.4 + Math.sin(t * 0.5) * 10;
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = H.rgba(c[2], 0.06); ctx.beginPath(); ctx.moveTo(sx + 55, sy + 5); ctx.lineTo(sx + 200, sy - 40); ctx.lineTo(sx + 200, sy + 80); ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.fillStyle = '#556677'; ctx.beginPath(); ctx.ellipse(sx, sy, 55, 18, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#667788'; ctx.fillRect(sx - 10, sy - 28, 20, 12);
|
||||
ctx.beginPath(); ctx.arc(sx, sy - 28, 10, Math.PI, 0); ctx.fill();
|
||||
ctx.strokeStyle = '#778899'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(sx, sy - 28); ctx.lineTo(sx, sy - 42); ctx.lineTo(sx + 5, sy - 42); ctx.stroke();
|
||||
Draw.circle(ctx, sx + 20, sy, 5, c[2]); Draw.circle(ctx, sx + 20, sy, 3, H.rgba('#FFEE88', 0.6));
|
||||
ctx.save(); ctx.translate(sx - 55, sy);
|
||||
for (var p = 0; p < 3; p++) { var pa = t * 4 + p * Math.PI * 2 / 3; ctx.fillStyle = '#8899AA'; ctx.beginPath(); ctx.ellipse(Math.cos(pa) * 6, Math.sin(pa) * 6, 8, 2, pa, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.restore();
|
||||
for (var r = 0; r < 12; r++) Draw.circle(ctx, rng() * w, h * 0.85 + rng() * h * 0.08, 4 + rng() * 10, H.darken(c[0], 0.15 + rng() * 0.15));
|
||||
},
|
||||
particles: { type: 'bubbles', count: 25, color: '#AACCDD' }
|
||||
},
|
||||
|
||||
shipwreck: {
|
||||
meta: { id: 'underwater_shipwreck', mood: 'mysterious', colors: ['#0A1A2A', '#1A3344', '#8B7355', '#44AA88'], tags: ['shipwreck', 'treasure', 'mysterious', 'old'], description: 'A sunken ship resting on the ocean floor, overgrown with sea life', compatibleMusicMoods: ['mysterious', 'adventurous', 'dark'], recommendedFor: ['rpg', 'story', 'puzzle'] },
|
||||
colors: ['#0A1A2A', '#1A3344', '#8B7355', '#44AA88'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[1], c[0]], 90);
|
||||
sunRays(ctx, w, h, 4, '#66BBAA', t);
|
||||
Draw.hills(ctx, w, h, h * 0.78, 8, '#5A4A3A', 55);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(93), sx = w * 0.45, sy = h * 0.62;
|
||||
ctx.fillStyle = c[2]; ctx.save(); ctx.translate(sx, sy); ctx.rotate(0.15);
|
||||
ctx.fillRect(-60, -10, 120, 25); ctx.fillRect(-50, -25, 100, 18);
|
||||
ctx.fillStyle = H.darken(c[2], 0.2); ctx.fillRect(-40, -35, 80, 12);
|
||||
ctx.strokeStyle = c[2]; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(-10, -35); ctx.lineTo(-20, -80); ctx.stroke();
|
||||
ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-20, -60); ctx.lineTo(15, -55); ctx.stroke();
|
||||
ctx.fillStyle = H.darken(c[0], 0.3);
|
||||
ctx.beginPath(); ctx.ellipse(20, 0, 8, 5, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.ellipse(-30, -5, 6, 4, 0.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
for (var s = 0; s < 8; s++) {
|
||||
var swx = sx - 50 + rng() * 100; ctx.strokeStyle = H.rgba(c[3], 0.5 + rng() * 0.3); ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(swx, sy + 5);
|
||||
for (var sp = 0; sp < 15 + rng() * 25; sp += 3) ctx.lineTo(swx + Math.sin(sp * 0.4 + t * 1.2 + s) * 5, sy + 5 - sp);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (var f = 0; f < 5; f++) drawFish(ctx, (rng() * w + t * 12 * (rng() + 0.2)) % (w + 30) - 15, h * 0.3 + rng() * h * 0.35 + Math.sin(t + f) * 5, 4 + rng() * 5, H.rgba(c[3], 0.6), 1);
|
||||
},
|
||||
particles: { type: 'bubbles', count: 20, color: '#66AAAA' }
|
||||
},
|
||||
|
||||
abyss: {
|
||||
meta: { id: 'underwater_abyss', mood: 'dark', colors: ['#020208', '#050515', '#0A0A30', '#FF2200'], tags: ['abyss', 'dark', 'deep', 'volcanic'], description: 'The abyssal zone with volcanic vents and glowing magma cracks', compatibleMusicMoods: ['dark', 'tense', 'ambient'], recommendedFor: ['action', 'rpg', 'story'] },
|
||||
colors: ['#020208', '#050515', '#0A0A30', '#FF2200'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[2], c[1], c[0]], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.8, 6, '#0D0D12', 80);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(13);
|
||||
for (var v = 0; v < 3; v++) {
|
||||
var vx = w * 0.2 + rng() * w * 0.6, vy = h * 0.82, vg = 0.3 + Math.sin(t * 2 + v * 3) * 0.15;
|
||||
ctx.fillStyle = '#1A1510'; ctx.fillRect(vx - 8, vy - 20, 16, 25); ctx.fillRect(vx - 12, vy - 22, 24, 5);
|
||||
Draw.glow(ctx, vx, vy - 22, 25, H.rgba(c[3], vg));
|
||||
for (var sm = 0; sm < 6; sm++) {
|
||||
ctx.fillStyle = H.rgba(c[3], 0.15 * (1 - sm / 6));
|
||||
ctx.beginPath(); ctx.arc(vx + Math.sin(t * 1.5 + sm * 2 + v) * 8, vy - 25 - sm * 18 - (t * 10 % 30), 5 + sm * 2, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
}
|
||||
for (var lc = 0; lc < 8; lc++) {
|
||||
var lcx = rng() * w, lcy = h * 0.83 + rng() * h * 0.1, g2 = 0.2 + Math.sin(t * 1.8 + lc) * 0.15;
|
||||
Draw.glow(ctx, lcx, lcy, 10, H.rgba(c[3], g2 * 0.4));
|
||||
ctx.strokeStyle = H.rgba(c[3], g2); ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(lcx, lcy); ctx.lineTo(lcx + (rng() - 0.5) * 30, lcy + (rng() - 0.5) * 8); ctx.stroke();
|
||||
}
|
||||
for (var s = 0; s < 12; s++) Draw.circle(ctx, rng() * w, rng() * h * 0.75, 1 + rng(), H.rgba('#4488FF', 0.15 + Math.sin(t * 3 + s * 2.3) * 0.1));
|
||||
},
|
||||
particles: { type: 'particles', count: 15, color: '#FF4400' }
|
||||
},
|
||||
|
||||
iceCave: {
|
||||
meta: { id: 'underwater_iceCave', mood: 'calm', colors: ['#0A2A4A', '#1A5A8A', '#88CCEE', '#E0F0FF'], tags: ['ice', 'cave', 'arctic', 'frozen'], description: 'An underwater ice cavern with crystalline formations and pale blue light', compatibleMusicMoods: ['calm', 'ambient', 'mysterious'], recommendedFor: ['puzzle', 'story', 'creative'] },
|
||||
colors: ['#0A2A4A', '#1A5A8A', '#88CCEE', '#E0F0FF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[3], c[2], c[1], c[0]], 90);
|
||||
Draw.hills(ctx, w, h, h * 0.08, 12, H.rgba(c[3], 0.3), 18);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(27);
|
||||
for (var i = 0; i < 14; i++) {
|
||||
var stx = rng() * w, sth = 20 + rng() * 60, stw = 4 + rng() * 8;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.4 + rng() * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(stx - stw, 0); ctx.lineTo(stx, sth); ctx.lineTo(stx + stw, 0); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = H.rgba(c[3], 0.2);
|
||||
ctx.beginPath(); ctx.moveTo(stx - stw * 0.3, 0); ctx.lineTo(stx - 1, sth * 0.8); ctx.lineTo(stx + stw * 0.2, 0); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
for (var j = 0; j < 10; j++) {
|
||||
var sgx = rng() * w, sgh = 15 + rng() * 40, sgw = 5 + rng() * 10;
|
||||
ctx.fillStyle = H.rgba(c[1], 0.5 + rng() * 0.3);
|
||||
ctx.beginPath(); ctx.moveTo(sgx - sgw, h); ctx.lineTo(sgx, h - sgh); ctx.lineTo(sgx + sgw, h); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
for (var k = 0; k < 8; k++) {
|
||||
var kx = rng() * w, ky = h * 0.2 + rng() * h * 0.5 + Math.sin(t * 0.4 + k * 1.7) * 8, ks = 4 + rng() * 8;
|
||||
Draw.polygon(ctx, kx, ky, ks, 6, H.rgba(c[3], 0.25 + Math.sin(t + k) * 0.1), t * 0.3 + k * 1.2);
|
||||
Draw.circle(ctx, kx, ky, ks * 0.3, H.rgba('#FFF', 0.4));
|
||||
}
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||
for (var m = 0; m < 6; m++) Draw.circle(ctx, rng() * w + Math.sin(t * 0.5 + m) * 10, h * 0.3 + rng() * h * 0.3, 20 + rng() * 40, H.rgba(c[2], 0.02 + Math.sin(t * 0.8 + m * 1.5) * 0.015));
|
||||
ctx.restore();
|
||||
},
|
||||
particles: { type: 'sparkles', count: 25, color: '#E0F0FF' }
|
||||
},
|
||||
|
||||
treasure: {
|
||||
meta: { id: 'underwater_treasure', mood: 'adventurous', colors: ['#0D2B45', '#1A4A6A', '#FFD700', '#FF8C00'], tags: ['treasure', 'gold', 'adventure', 'pirate'], description: 'A hidden underwater treasure trove glowing with golden riches', compatibleMusicMoods: ['adventurous', 'happy', 'upbeat'], recommendedFor: ['rpg', 'action', 'puzzle'] },
|
||||
colors: ['#0D2B45', '#1A4A6A', '#FFD700', '#FF8C00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[1], c[0]], 90);
|
||||
sunRays(ctx, w, h, 3, '#FFDDAA', t);
|
||||
Draw.hills(ctx, w, h, h * 0.75, 7, '#3A2A1A', 62);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(57), tcx = w * 0.5, tcy = h * 0.72;
|
||||
ctx.fillStyle = '#6B3A1F'; ctx.fillRect(tcx - 25, tcy - 5, 50, 22);
|
||||
ctx.fillStyle = '#8B5A2F'; ctx.beginPath(); ctx.ellipse(tcx, tcy - 5, 25, 10, 0, Math.PI, 0); ctx.fill();
|
||||
ctx.fillStyle = '#7B4A25'; ctx.save(); ctx.translate(tcx - 25, tcy - 5); ctx.rotate(-0.6); ctx.fillRect(0, -16, 50, 16); ctx.restore();
|
||||
var gp = 0.4 + Math.sin(t * 1.5) * 0.15;
|
||||
Draw.glow(ctx, tcx, tcy - 10, 40, H.rgba(c[2], gp));
|
||||
for (var g = 0; g < 12; g++) { var gx = tcx - 15 + rng() * 30, gy = tcy - 2 + rng() * 15; Draw.circle(ctx, gx, gy, 3 + rng() * 2, c[2]); Draw.circle(ctx, gx - 0.5, gy - 0.5, 2, H.lighten(c[2], 0.3)); }
|
||||
ctx.strokeStyle = c[3]; ctx.lineWidth = 2; ctx.strokeRect(tcx - 24, tcy - 4, 48, 20);
|
||||
for (var j = 0; j < 8; j++) {
|
||||
var jx = tcx - 40 + rng() * 80, jy = tcy + 10 + rng() * 15, jC = ['#FF0044', '#00AAFF', '#44FF44', c[2]][Math.floor(rng() * 4)], jT = 0.5 + Math.sin(t * 3 + j * 2) * 0.3;
|
||||
Draw.polygon(ctx, jx, jy, 2 + rng() * 3, 5 + Math.floor(rng() * 3), H.rgba(jC, jT), t * 0.2 + j);
|
||||
Draw.glow(ctx, jx, jy, 6, H.rgba(jC, jT * 0.3));
|
||||
}
|
||||
for (var p = 0; p < 2; p++) {
|
||||
var px = p === 0 ? w * 0.15 : w * 0.85;
|
||||
ctx.fillStyle = '#4A4A3A'; ctx.fillRect(px - 8, h * 0.4, 16, h * 0.38); ctx.fillRect(px - 12, h * 0.38, 24, 6); ctx.fillRect(px - 12, h * 0.76, 24, 6);
|
||||
ctx.strokeStyle = H.rgba('#2A2A20', 0.5); ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(px - 3, h * 0.5); ctx.lineTo(px + 2, h * 0.55); ctx.lineTo(px - 1, h * 0.62); ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 35, color: '#FFD700' }
|
||||
}
|
||||
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,466 @@
|
||||
// urban.js — Urban theme for procedural background system
|
||||
// 8 variants: citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('urban', {
|
||||
|
||||
citySkyline: {
|
||||
meta: { id: 'urban_citySkyline', mood: 'inspiring', colors: ['#1A1A2E', '#2D2D44', '#4A4A66', '#FF8844'], tags: ['skyline', 'sunset', 'grand'], description: 'City skyline silhouetted against a glowing sunset', compatibleMusicMoods: ['epic', 'calm', 'ambient'], recommendedFor: ['story', 'rpg', 'creative'] },
|
||||
colors: ['#1A1A2E', '#2D2D44', '#4A4A66', '#FF8844'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#1A0A2E', '#4A2255', '#CC6633', c[3]], 90);
|
||||
// Sun glow on horizon
|
||||
Draw.glow(ctx, w * 0.5, h * 0.58, 200, H.rgba(c[3], 0.3 + Math.sin(t * 0.2) * 0.05));
|
||||
Draw.clouds(ctx, w, h, h * 0.12, 5, '#FF9966', 100);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Far buildings silhouette
|
||||
Draw.buildings(ctx, w, h, h * 0.58, 20, [H.rgba(c[0], 0.7), H.rgba(c[1], 0.6)], 101);
|
||||
// Main skyline
|
||||
Draw.buildings(ctx, w, h, h * 0.65, 14, [c[0], c[1]], 102);
|
||||
// Foreground buildings
|
||||
Draw.buildings(ctx, w, h, h * 0.75, 8, [c[1], c[2]], 103);
|
||||
// Ground
|
||||
Draw.rect(ctx, 0, h * 0.88, w, h * 0.12, H.darken(c[0], 0.5));
|
||||
// Antenna lights
|
||||
var rng = H.seededRandom(104);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var ax = rng() * w;
|
||||
var ay = h * 0.3 + rng() * h * 0.2;
|
||||
var blink = Math.sin(t * 3 + i * 1.8) > 0.4 ? 0.9 : 0.15;
|
||||
Draw.circle(ctx, ax, ay, 2, H.rgba('#FF3333', blink));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#FFAA66' }
|
||||
},
|
||||
|
||||
street: {
|
||||
meta: { id: 'urban_street', mood: 'busy', colors: ['#2C3E50', '#566573', '#808B96', '#FFD700'], tags: ['road', 'traffic', 'evening'], description: 'Busy city street with lamp posts and passing lights', compatibleMusicMoods: ['energetic', 'ambient', 'electronic'], recommendedFor: ['racing', 'action', 'sports'] },
|
||||
colors: ['#2C3E50', '#566573', '#808B96', '#FFD700'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#1A1A30', '#2A2A44', c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Buildings on sides
|
||||
Draw.buildings(ctx, w * 0.4, h, h * 0.15, 6, [c[0], c[1]], 201);
|
||||
ctx.save(); ctx.translate(w * 0.6, 0);
|
||||
Draw.buildings(ctx, w * 0.4, h, h * 0.15, 6, [c[0], c[1]], 202);
|
||||
ctx.restore();
|
||||
// Road surface (perspective)
|
||||
ctx.fillStyle = '#333340';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.3, h * 0.35);
|
||||
ctx.lineTo(w * 0.7, h * 0.35);
|
||||
ctx.lineTo(w, h);
|
||||
ctx.lineTo(0, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Road lines
|
||||
var rng = H.seededRandom(203);
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.5);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([15, 20]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.5, h * 0.35);
|
||||
ctx.lineTo(w * 0.5, h);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
// Lamp posts
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var lx = w * 0.25 + (i % 2) * w * 0.5;
|
||||
var ly = h * 0.4 + i * h * 0.15;
|
||||
var lh = 40 + i * 10;
|
||||
Draw.rect(ctx, lx - 2, ly - lh, 4, lh, '#555566');
|
||||
Draw.rect(ctx, lx - 8, ly - lh - 3, 16, 5, '#666677');
|
||||
// Lamp glow
|
||||
var flicker = 0.6 + Math.sin(t * 4 + i * 2.5) * 0.05;
|
||||
Draw.glow(ctx, lx, ly - lh, 35, H.rgba(c[3], 0.2 * flicker));
|
||||
Draw.circle(ctx, lx, ly - lh, 3, H.rgba(c[3], flicker));
|
||||
}
|
||||
// Car headlights
|
||||
for (var j = 0; j < 3; j++) {
|
||||
var cy = (h * 0.5 + j * h * 0.15 + t * 20) % (h * 0.7) + h * 0.35;
|
||||
var cx = w * 0.45 + rng() * w * 0.1;
|
||||
var alpha = H.clamp((cy - h * 0.35) / (h * 0.4), 0, 0.7);
|
||||
Draw.glow(ctx, cx - 5, cy, 6 * alpha + 3, H.rgba('#FFFFFF', alpha * 0.5));
|
||||
Draw.glow(ctx, cx + 5, cy, 6 * alpha + 3, H.rgba('#FFFFFF', alpha * 0.5));
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 20, color: '#AAAAAA' }
|
||||
},
|
||||
|
||||
rooftop: {
|
||||
meta: { id: 'urban_rooftop', mood: 'reflective', colors: ['#1B2631', '#2C3E50', '#5D6D7E', '#F4D03F'], tags: ['night', 'view', 'quiet'], description: 'Rooftop view over a twinkling nighttime city', compatibleMusicMoods: ['calm', 'dreamy', 'ambient'], recommendedFor: ['story', 'puzzle', 'creative'] },
|
||||
colors: ['#1B2631', '#2C3E50', '#5D6D7E', '#F4D03F'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#0A0A1A', '#151528', c[0]], 90);
|
||||
Draw.stars(ctx, w, h * 0.4, 60, '#FFFFFF', 301, [0.3, 1.5]);
|
||||
// Moon
|
||||
Draw.glow(ctx, w * 0.8, h * 0.12, 50, H.rgba('#CCDDFF', 0.15));
|
||||
Draw.circle(ctx, w * 0.8, h * 0.12, 18, '#DDDDEE');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Distant city
|
||||
Draw.buildings(ctx, w, h, h * 0.45, 25, [c[0], c[1]], 302);
|
||||
// City lights twinkling
|
||||
var rng = H.seededRandom(303);
|
||||
for (var i = 0; i < 50; i++) {
|
||||
var lx = rng() * w;
|
||||
var ly = h * 0.3 + rng() * h * 0.25;
|
||||
var twinkle = 0.2 + Math.sin(t * 2 + rng() * 10) * 0.3;
|
||||
var lc = rng() > 0.7 ? c[3] : '#FFFFFF';
|
||||
Draw.circle(ctx, lx, ly, 1, H.rgba(lc, twinkle));
|
||||
}
|
||||
// Rooftop surface
|
||||
Draw.rect(ctx, 0, h * 0.7, w, h * 0.3, c[1]);
|
||||
Draw.rect(ctx, 0, h * 0.7, w, 3, c[2]);
|
||||
// Rooftop elements
|
||||
Draw.rect(ctx, w * 0.05, h * 0.6, w * 0.08, h * 0.1, c[0]); // AC unit
|
||||
Draw.rect(ctx, w * 0.85, h * 0.55, w * 0.04, h * 0.15, c[2]); // Antenna
|
||||
// Railing
|
||||
ctx.strokeStyle = c[2];
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h * 0.68);
|
||||
ctx.lineTo(w, h * 0.68);
|
||||
ctx.stroke();
|
||||
for (var j = 0; j < 20; j++) {
|
||||
var rx = j * (w / 19);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rx, h * 0.68);
|
||||
ctx.lineTo(rx, h * 0.7);
|
||||
ctx.stroke();
|
||||
}
|
||||
// String lights
|
||||
ctx.strokeStyle = H.rgba('#332211', 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.1, h * 0.62);
|
||||
ctx.quadraticCurveTo(w * 0.5, h * 0.66, w * 0.9, h * 0.62);
|
||||
ctx.stroke();
|
||||
for (var k = 0; k < 12; k++) {
|
||||
var bx = w * 0.1 + k * (w * 0.8 / 11);
|
||||
var sag = Math.sin((k / 11) * Math.PI) * h * 0.04;
|
||||
var by = h * 0.62 + sag;
|
||||
var glow = 0.5 + Math.sin(t * 1.5 + k * 0.9) * 0.2;
|
||||
Draw.glow(ctx, bx, by, 8, H.rgba(c[3], 0.2 * glow));
|
||||
Draw.circle(ctx, bx, by, 2.5, H.rgba(c[3], glow));
|
||||
}
|
||||
},
|
||||
particles: { type: 'fireflies', count: 8, color: '#FFD700' }
|
||||
},
|
||||
|
||||
neonDistrict: {
|
||||
meta: { id: 'urban_neonDistrict', mood: 'electric', colors: ['#0D0D1A', '#1A0A2E', '#FF00FF', '#00FFCC'], tags: ['neon', 'cyberpunk', 'vibrant'], description: 'Rain-slicked streets glowing with neon signs', compatibleMusicMoods: ['electronic', 'energetic', 'tense'], recommendedFor: ['action', 'racing', 'music'] },
|
||||
colors: ['#0D0D1A', '#1A0A2E', '#FF00FF', '#00FFCC'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], H.darken(c[1], 0.3)], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Dark buildings
|
||||
Draw.buildings(ctx, w, h, h * 0.1, 12, [c[0], H.darken(c[1], 0.3)], 401);
|
||||
// Neon signs
|
||||
var rng = H.seededRandom(402);
|
||||
var neonColors = [c[2], c[3], '#FF4488', '#44AAFF', '#FFDD00'];
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var nx = rng() * w;
|
||||
var ny = h * 0.15 + rng() * h * 0.35;
|
||||
var nw = 20 + rng() * 50;
|
||||
var nh = 8 + rng() * 20;
|
||||
var nc = neonColors[Math.floor(rng() * neonColors.length)];
|
||||
var pulse = 0.6 + Math.sin(t * 2 + i * 1.7) * 0.25;
|
||||
// Sign glow
|
||||
Draw.glow(ctx, nx + nw / 2, ny + nh / 2, nw * 0.8, H.rgba(nc, 0.15 * pulse));
|
||||
// Sign border
|
||||
ctx.strokeStyle = H.rgba(nc, pulse);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(nx, ny, nw, nh);
|
||||
// Sign fill
|
||||
ctx.fillStyle = H.rgba(nc, 0.08 * pulse);
|
||||
ctx.fillRect(nx, ny, nw, nh);
|
||||
}
|
||||
// Wet ground
|
||||
Draw.rect(ctx, 0, h * 0.75, w, h * 0.25, '#0A0A15');
|
||||
// Neon reflections on ground
|
||||
for (var j = 0; j < 6; j++) {
|
||||
var rx = rng() * w;
|
||||
var rc = neonColors[Math.floor(rng() * neonColors.length)];
|
||||
var rAlpha = 0.05 + Math.sin(t * 1.5 + j * 2) * 0.03;
|
||||
ctx.fillStyle = H.rgba(rc, rAlpha);
|
||||
ctx.fillRect(rx - 25, h * 0.78, 50, h * 0.2);
|
||||
}
|
||||
// Power lines
|
||||
ctx.strokeStyle = H.rgba('#333344', 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
for (var k = 0; k < 3; k++) {
|
||||
var py = h * 0.08 + k * 15;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, py);
|
||||
ctx.quadraticCurveTo(w * 0.5, py + 10, w, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Scanline overlay
|
||||
Draw.scanlines(ctx, w, h, 4, H.rgba(c[0], 0.06));
|
||||
},
|
||||
particles: { type: 'rain', count: 80, color: '#8899BB' }
|
||||
},
|
||||
|
||||
subway: {
|
||||
meta: { id: 'urban_subway', mood: 'gritty', colors: ['#1C1C1C', '#333333', '#555555', '#FFCC00'], tags: ['underground', 'industrial', 'dark'], description: 'Underground subway platform with arriving train lights', compatibleMusicMoods: ['tense', 'electronic', 'ambient'], recommendedFor: ['action', 'puzzle', 'rpg'] },
|
||||
colors: ['#1C1C1C', '#333333', '#555555', '#FFCC00'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Ceiling
|
||||
Draw.rect(ctx, 0, 0, w, h * 0.15, c[0]);
|
||||
// Ceiling panels
|
||||
var rng = H.seededRandom(501);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var px = i * (w / 10) + 5;
|
||||
Draw.rect(ctx, px, h * 0.02, w / 10 - 10, h * 0.1, H.lighten(c[0], 0.05));
|
||||
}
|
||||
// Fluorescent lights
|
||||
for (var j = 0; j < 6; j++) {
|
||||
var lx = w * 0.1 + j * (w * 0.8 / 5);
|
||||
var flicker = 0.7 + Math.sin(t * 8 + j * 3) * 0.1 + (rng() > 0.97 ? -0.4 : 0);
|
||||
Draw.rect(ctx, lx - 20, h * 0.12, 40, 4, H.rgba('#FFFFFF', flicker));
|
||||
Draw.glow(ctx, lx, h * 0.2, 50, H.rgba('#FFFFFF', 0.08 * flicker));
|
||||
}
|
||||
// Walls (tiled)
|
||||
Draw.rect(ctx, 0, h * 0.15, w, h * 0.45, c[1]);
|
||||
Draw.grid(ctx, w, h * 0.45, 25, H.rgba(c[2], 0.15), 0.5);
|
||||
// Colored tile stripe
|
||||
Draw.rect(ctx, 0, h * 0.35, w, 8, c[3]);
|
||||
// Platform
|
||||
Draw.rect(ctx, 0, h * 0.6, w, h * 0.08, c[2]);
|
||||
// Yellow safety line
|
||||
Draw.rect(ctx, 0, h * 0.6, w, 4, H.rgba(c[3], 0.8));
|
||||
// Track area
|
||||
Draw.rect(ctx, 0, h * 0.68, w, h * 0.32, H.darken(c[0], 0.3));
|
||||
// Rails
|
||||
ctx.strokeStyle = H.rgba('#888888', 0.4);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.78); ctx.lineTo(w, h * 0.78); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.88); ctx.lineTo(w, h * 0.88); ctx.stroke();
|
||||
// Train approaching light
|
||||
var approach = (Math.sin(t * 0.3) + 1) * 0.5;
|
||||
if (approach > 0.6) {
|
||||
var intensity = (approach - 0.6) / 0.4;
|
||||
Draw.glow(ctx, w * 0.5, h * 0.82, 80 * intensity, H.rgba('#FFFFFF', 0.3 * intensity));
|
||||
Draw.glow(ctx, w * 0.4, h * 0.82, 20 * intensity, H.rgba('#FFFFFF', 0.5 * intensity));
|
||||
Draw.glow(ctx, w * 0.6, h * 0.82, 20 * intensity, H.rgba('#FFFFFF', 0.5 * intensity));
|
||||
}
|
||||
// Bench
|
||||
Draw.rect(ctx, w * 0.15, h * 0.54, w * 0.12, h * 0.04, '#444444');
|
||||
Draw.rect(ctx, w * 0.16, h * 0.56, 4, h * 0.04, '#444444');
|
||||
Draw.rect(ctx, w * 0.26, h * 0.56, 4, h * 0.04, '#444444');
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#888888' }
|
||||
},
|
||||
|
||||
park: {
|
||||
meta: { id: 'urban_park', mood: 'relaxed', colors: ['#2E7D32', '#4CAF50', '#81C784', '#795548'], tags: ['trees', 'nature', 'city'], description: 'A peaceful city park with trees and walking paths', compatibleMusicMoods: ['calm', 'happy', 'dreamy'], recommendedFor: ['pet', 'puzzle', 'story', 'creative'] },
|
||||
colors: ['#2E7D32', '#4CAF50', '#81C784', '#795548'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, ['#66AADD', '#88CCEE', '#BBDDEE'], 90);
|
||||
Draw.clouds(ctx, w, h, h * 0.08, 5, '#FFFFFF', 600);
|
||||
// Distant buildings
|
||||
Draw.buildings(ctx, w, h, h * 0.35, 12, ['#8899AA', '#99AABB'], 601);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Far tree line
|
||||
Draw.hills(ctx, w, h, h * 0.45, 8, H.darken(c[0], 0.2), 602);
|
||||
Draw.trees(ctx, w, h, h * 0.5, 15, c[3], c[0], 603);
|
||||
// Park ground
|
||||
Draw.hills(ctx, w, h, h * 0.6, 5, c[1], 604);
|
||||
Draw.rect(ctx, 0, h * 0.68, w, h * 0.32, c[2]);
|
||||
// Walking path
|
||||
ctx.strokeStyle = H.rgba('#CCBBAA', 0.6);
|
||||
ctx.lineWidth = 12;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h * 0.82);
|
||||
ctx.quadraticCurveTo(w * 0.3, h * 0.75, w * 0.5, h * 0.78);
|
||||
ctx.quadraticCurveTo(w * 0.7, h * 0.82, w, h * 0.76);
|
||||
ctx.stroke();
|
||||
// Park trees
|
||||
Draw.trees(ctx, w, h, h * 0.72, 8, c[3], c[1], 605);
|
||||
// Park bench
|
||||
Draw.rect(ctx, w * 0.6, h * 0.7, 25, 3, c[3]);
|
||||
Draw.rect(ctx, w * 0.602, h * 0.66, 3, h * 0.04, c[3]);
|
||||
Draw.rect(ctx, w * 0.63, h * 0.66, 3, h * 0.04, c[3]);
|
||||
// Pond
|
||||
ctx.fillStyle = H.rgba('#4488AA', 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.25, h * 0.85, 50, 20, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Pond shimmer
|
||||
var shimmer = 0.15 + Math.sin(t * 1.5) * 0.05;
|
||||
ctx.fillStyle = H.rgba('#AADDEE', shimmer);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.24, h * 0.84, 15, 5, 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Lamp post
|
||||
Draw.rect(ctx, w * 0.45 - 2, h * 0.58, 4, h * 0.14, '#555555');
|
||||
Draw.circle(ctx, w * 0.45, h * 0.58, 5, H.rgba('#FFEE88', 0.3 + Math.sin(t) * 0.05));
|
||||
},
|
||||
particles: { type: 'leaves', count: 12, color: '#CC9944' }
|
||||
},
|
||||
|
||||
mall: {
|
||||
meta: { id: 'urban_mall', mood: 'lively', colors: ['#E8E8E8', '#CCCCCC', '#888888', '#FF6B6B'], tags: ['indoor', 'bright', 'commercial'], description: 'Bright mall interior with shop windows and escalators', compatibleMusicMoods: ['happy', 'energetic', 'electronic'], recommendedFor: ['trivia', 'creative', 'pet'] },
|
||||
colors: ['#E8E8E8', '#CCCCCC', '#888888', '#FF6B6B'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(701);
|
||||
// Ceiling structure
|
||||
Draw.rect(ctx, 0, 0, w, h * 0.08, c[2]);
|
||||
// Skylight panels
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var sx = w * 0.15 + i * (w * 0.7 / 3);
|
||||
Draw.rect(ctx, sx, h * 0.01, w * 0.12, h * 0.05, H.rgba('#AADDFF', 0.3));
|
||||
}
|
||||
// Ceiling lights
|
||||
for (var j = 0; j < 8; j++) {
|
||||
var lx = w * 0.1 + j * (w * 0.8 / 7);
|
||||
Draw.rect(ctx, lx - 12, h * 0.07, 24, 3, '#FFFFFF');
|
||||
Draw.glow(ctx, lx, h * 0.12, 30, H.rgba('#FFFFFF', 0.06));
|
||||
}
|
||||
// Floor
|
||||
Draw.rect(ctx, 0, h * 0.7, w, h * 0.3, '#DDDDDD');
|
||||
// Floor tiles
|
||||
Draw.grid(ctx, w, h * 0.3, 40, H.rgba('#BBBBBB', 0.3), 0.5);
|
||||
// Store fronts - left side
|
||||
var shopColors = ['#FF6B6B', '#4FC3F7', '#81C784', '#FFB74D', '#BA68C8'];
|
||||
for (var k = 0; k < 3; k++) {
|
||||
var sy = h * 0.12 + k * h * 0.2;
|
||||
var sc = shopColors[Math.floor(rng() * shopColors.length)];
|
||||
// Shop frame
|
||||
Draw.rect(ctx, 0, sy, w * 0.2, h * 0.18, '#F5F5F5');
|
||||
Draw.rect(ctx, w * 0.01, sy + h * 0.01, w * 0.18, h * 0.12, H.rgba(sc, 0.15));
|
||||
// Shop sign
|
||||
var signPulse = 0.7 + Math.sin(t * 1.5 + k * 2.3) * 0.15;
|
||||
Draw.rect(ctx, w * 0.03, sy + h * 0.005, w * 0.14, h * 0.025, H.rgba(sc, signPulse));
|
||||
}
|
||||
// Store fronts - right side
|
||||
for (var m = 0; m < 3; m++) {
|
||||
var rsy = h * 0.12 + m * h * 0.2;
|
||||
var rsc = shopColors[Math.floor(rng() * shopColors.length)];
|
||||
Draw.rect(ctx, w * 0.8, rsy, w * 0.2, h * 0.18, '#F5F5F5');
|
||||
Draw.rect(ctx, w * 0.81, rsy + h * 0.01, w * 0.18, h * 0.12, H.rgba(rsc, 0.15));
|
||||
var rPulse = 0.7 + Math.sin(t * 1.5 + m * 2 + 1) * 0.15;
|
||||
Draw.rect(ctx, w * 0.83, rsy + h * 0.005, w * 0.14, h * 0.025, H.rgba(rsc, rPulse));
|
||||
}
|
||||
// Escalator
|
||||
Draw.rect(ctx, w * 0.4, h * 0.4, w * 0.2, h * 0.35, c[2]);
|
||||
// Escalator steps (animated)
|
||||
for (var s = 0; s < 8; s++) {
|
||||
var stepY = h * 0.42 + ((s * h * 0.04 + t * 15) % (h * 0.3));
|
||||
ctx.strokeStyle = H.rgba('#AAAAAA', 0.5);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.42, stepY);
|
||||
ctx.lineTo(w * 0.58, stepY);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Railing
|
||||
ctx.strokeStyle = c[2];
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.4, h * 0.4); ctx.lineTo(w * 0.4, h * 0.75); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.6, h * 0.4); ctx.lineTo(w * 0.6, h * 0.75); ctx.stroke();
|
||||
// Potted plant
|
||||
Draw.rect(ctx, w * 0.32, h * 0.64, 14, 12, '#8B6914');
|
||||
Draw.circle(ctx, w * 0.325, h * 0.6, 12, '#4CAF50');
|
||||
},
|
||||
particles: { type: 'sparkles', count: 20, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
alley: {
|
||||
meta: { id: 'urban_alley', mood: 'mysterious', colors: ['#111118', '#1E1E28', '#333340', '#BB8844'], tags: ['dark', 'narrow', 'moody'], description: 'A narrow alley with a single warm light overhead', compatibleMusicMoods: ['tense', 'ambient', 'electronic'], recommendedFor: ['rpg', 'action', 'story'] },
|
||||
colors: ['#111118', '#1E1E28', '#333340', '#BB8844'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1], c[0]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(801);
|
||||
// Left wall
|
||||
Draw.rect(ctx, 0, 0, w * 0.3, h, c[1]);
|
||||
// Right wall
|
||||
Draw.rect(ctx, w * 0.7, 0, w * 0.3, h, c[1]);
|
||||
// Brick texture (left)
|
||||
for (var row = 0; row < 30; row++) {
|
||||
var by = row * (h / 30);
|
||||
var offset = (row % 2) * 8;
|
||||
for (var col = 0; col < 4; col++) {
|
||||
var bx = col * (w * 0.3 / 4) + offset;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.15);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeRect(bx, by, w * 0.3 / 4 - 2, h / 30 - 2);
|
||||
}
|
||||
}
|
||||
// Brick texture (right)
|
||||
for (var row2 = 0; row2 < 30; row2++) {
|
||||
var by2 = row2 * (h / 30);
|
||||
var offset2 = (row2 % 2) * 8;
|
||||
for (var col2 = 0; col2 < 4; col2++) {
|
||||
var bx2 = w * 0.7 + col2 * (w * 0.3 / 4) + offset2;
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.15);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeRect(bx2, by2, w * 0.3 / 4 - 2, h / 30 - 2);
|
||||
}
|
||||
}
|
||||
// Ground
|
||||
Draw.rect(ctx, w * 0.28, h * 0.85, w * 0.44, h * 0.15, H.darken(c[1], 0.3));
|
||||
// Puddle
|
||||
ctx.fillStyle = H.rgba('#223344', 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.5, h * 0.92, 35, 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Puddle light reflection
|
||||
var refPulse = 0.15 + Math.sin(t * 1.2) * 0.05;
|
||||
ctx.fillStyle = H.rgba(c[3], refPulse);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.5, h * 0.92, 15, 4, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Overhead light
|
||||
Draw.rect(ctx, w * 0.48, h * 0.15, w * 0.04, h * 0.02, '#555555');
|
||||
var lightFlicker = 0.7 + Math.sin(t * 6) * 0.15 + Math.sin(t * 13) * 0.05;
|
||||
Draw.glow(ctx, w * 0.5, h * 0.18, 100, H.rgba(c[3], 0.18 * lightFlicker));
|
||||
Draw.glow(ctx, w * 0.5, h * 0.18, 40, H.rgba(c[3], 0.3 * lightFlicker));
|
||||
Draw.circle(ctx, w * 0.5, h * 0.17, 4, H.rgba('#FFDDAA', lightFlicker));
|
||||
// Light cone
|
||||
ctx.fillStyle = H.rgba(c[3], 0.04 * lightFlicker);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.48, h * 0.18);
|
||||
ctx.lineTo(w * 0.3, h * 0.85);
|
||||
ctx.lineTo(w * 0.7, h * 0.85);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Dumpster
|
||||
Draw.rect(ctx, w * 0.62, h * 0.7, w * 0.07, h * 0.12, '#2A3A2A');
|
||||
Draw.rect(ctx, w * 0.615, h * 0.69, w * 0.08, h * 0.02, '#334433');
|
||||
// Fire escape (left wall)
|
||||
ctx.strokeStyle = H.rgba(c[2], 0.4);
|
||||
ctx.lineWidth = 2;
|
||||
for (var fe = 0; fe < 4; fe++) {
|
||||
var fey = h * 0.2 + fe * h * 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.3, fey);
|
||||
ctx.lineTo(w * 0.38, fey);
|
||||
ctx.lineTo(w * 0.38, fey + h * 0.03);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Steam wisps
|
||||
var steamAlpha = 0.05 + Math.sin(t * 0.8) * 0.03;
|
||||
Draw.glow(ctx, w * 0.65, h * 0.68, 25, H.rgba('#AAAAAA', steamAlpha));
|
||||
},
|
||||
particles: { type: 'fog', count: 6, color: '#555555' }
|
||||
}
|
||||
});
|
||||
|
||||
})(window.BackgroundEngine);
|
||||
@@ -0,0 +1,476 @@
|
||||
// weather.js — Weather and atmospheric background themes
|
||||
// Variants: rain, snow, storm, sunset, sunrise, fog, lightning, aurora
|
||||
|
||||
(function(engine) {
|
||||
if (!engine) return;
|
||||
var D = engine.Draw, H = engine.helpers;
|
||||
|
||||
engine.registerTheme('weather', {
|
||||
|
||||
rain: {
|
||||
meta: { id: 'weather_rain', mood: 'melancholy', colors: ['#3a4a5a', '#2a3a4a', '#5a6a7a', '#8899aa'], tags: ['rain', 'wet', 'gray', 'calm'], description: 'Overcast sky with steady rainfall, puddles, and muted city outlines', compatibleMusicMoods: ['melancholy', 'calm', 'ambient'], recommendedFor: ['story', 'puzzle', 'rpg'] },
|
||||
colors: ['#3a4a5a', '#2a3a4a', '#5a6a7a', '#8899aa'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Low heavy clouds
|
||||
var rng = H.seededRandom(111);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var cx = rng() * w * 1.2 - w * 0.1;
|
||||
var cy = h * 0.05 + rng() * h * 0.2;
|
||||
var cw = 100 + rng() * 200;
|
||||
var drift = Math.sin(t * 0.1 + i) * 5;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.2 + rng() * 0.15);
|
||||
ctx.beginPath(); ctx.ellipse(cx + drift, cy, cw, 30 + rng() * 20, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(112);
|
||||
// Distant buildings silhouette
|
||||
Draw.buildings(ctx, w, h, h * 0.75, 12, [H.darken(c[1], 0.2), H.darken(c[0], 0.25)], 113);
|
||||
// Ground with puddles
|
||||
Draw.rect(ctx, 0, h * 0.75, w, h * 0.25, H.darken(c[1], 0.35));
|
||||
for (var p = 0; p < 6; p++) {
|
||||
var px = rng() * w;
|
||||
var pw = 30 + rng() * 60;
|
||||
var ripple = Math.sin(t * 3 + p * 2) * 0.08;
|
||||
ctx.fillStyle = H.rgba(c[3], 0.1 + ripple);
|
||||
ctx.beginPath(); ctx.ellipse(px, h * 0.82 + rng() * h * 0.1, pw, 6, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// Ripple rings
|
||||
var ringR = 5 + (t * 20 + p * 30) % 20;
|
||||
ctx.strokeStyle = H.rgba('#FFFFFF', 0.08 * (1 - ringR / 25));
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath(); ctx.arc(px, h * 0.82 + rng() * h * 0.1, ringR, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// Mist layer near ground
|
||||
for (var m = 0; m < 5; m++) {
|
||||
ctx.fillStyle = H.rgba(c[3], 0.04);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(rng() * w, h * 0.73 + rng() * h * 0.05, 120 + rng() * 100, 15, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'rain', count: 150, color: '#99aabb' }
|
||||
},
|
||||
|
||||
snow: {
|
||||
meta: { id: 'weather_snow', mood: 'peaceful', colors: ['#c8d8e8', '#a0b8cc', '#e8eef4', '#FFFFFF'], tags: ['snow', 'winter', 'cold', 'peaceful'], description: 'Gentle snowfall over a soft winter landscape with frosted trees', compatibleMusicMoods: ['peaceful', 'calm', 'dreamy'], recommendedFor: ['puzzle', 'pet', 'creative', 'story'] },
|
||||
colors: ['#c8d8e8', '#a0b8cc', '#e8eef4', '#FFFFFF'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[1], c[0]], 90);
|
||||
// Snow-covered ground
|
||||
Draw.hills(ctx, w, h, h * 0.7, 6, c[2], 221);
|
||||
Draw.rect(ctx, 0, h * 0.75, w, h * 0.25, c[2]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(222);
|
||||
// Frosted trees
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var tx = rng() * w;
|
||||
var ty = h * 0.55 + rng() * h * 0.18;
|
||||
var th = 40 + rng() * 60;
|
||||
var tw = th * 0.4;
|
||||
// Trunk
|
||||
Draw.rect(ctx, tx - 3, ty, 6, th * 0.3, '#665544');
|
||||
// Snow-covered canopy layers
|
||||
for (var l = 0; l < 3; l++) {
|
||||
var ly = ty - th * (0.1 + l * 0.25);
|
||||
var lw = tw * (1 - l * 0.25);
|
||||
ctx.fillStyle = H.blendColor('#4a7a4a', c[2], 0.4 + l * 0.2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx - lw, ly + 15);
|
||||
ctx.lineTo(tx, ly);
|
||||
ctx.lineTo(tx + lw, ly + 15);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Snow caps
|
||||
ctx.fillStyle = H.rgba(c[3], 0.7);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx - lw * 0.6, ly + 8);
|
||||
ctx.lineTo(tx, ly - 2);
|
||||
ctx.lineTo(tx + lw * 0.6, ly + 8);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
// Snow drifts
|
||||
for (var d = 0; d < 5; d++) {
|
||||
var dx = rng() * w;
|
||||
ctx.fillStyle = H.rgba(c[3], 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(dx, h * 0.73 + rng() * h * 0.05, 40 + rng() * 60, 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Distant cabin
|
||||
var cabX = w * 0.7;
|
||||
Draw.rect(ctx, cabX, h * 0.56, 50, 35, '#5a3a2a');
|
||||
ctx.fillStyle = '#6a4a3a';
|
||||
ctx.beginPath(); ctx.moveTo(cabX - 5, h * 0.56); ctx.lineTo(cabX + 25, h * 0.48); ctx.lineTo(cabX + 55, h * 0.56); ctx.closePath(); ctx.fill();
|
||||
// Snow on roof
|
||||
ctx.fillStyle = c[2];
|
||||
ctx.beginPath(); ctx.moveTo(cabX - 5, h * 0.56); ctx.lineTo(cabX + 25, h * 0.475); ctx.lineTo(cabX + 55, h * 0.56); ctx.closePath(); ctx.fill();
|
||||
// Chimney smoke
|
||||
var smokeAlpha = 0.06 + Math.sin(t * 0.5) * 0.02;
|
||||
for (var s = 0; s < 4; s++) {
|
||||
var sx = cabX + 40 + Math.sin(t * 0.4 + s) * 6;
|
||||
var sy = h * 0.42 - s * 15;
|
||||
ctx.fillStyle = H.rgba('#CCCCCC', smokeAlpha * (1 - s * 0.2));
|
||||
ctx.beginPath(); ctx.ellipse(sx, sy, 8 + s * 5, 5 + s * 3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// Window glow
|
||||
Draw.rect(ctx, cabX + 15, h * 0.57, 10, 12, H.rgba('#ffcc44', 0.6));
|
||||
Draw.glow(ctx, cabX + 20, h * 0.63, 20, '#ffcc44');
|
||||
},
|
||||
particles: { type: 'snow', count: 100, color: '#FFFFFF' }
|
||||
},
|
||||
|
||||
storm: {
|
||||
meta: { id: 'weather_storm', mood: 'intense', colors: ['#1a1a25', '#252535', '#3a3a55', '#667788'], tags: ['storm', 'dark', 'wind', 'dramatic'], description: 'Dark storm clouds with heavy rain, wind-bent trees, and rolling thunder', compatibleMusicMoods: ['intense', 'dramatic', 'tense'], recommendedFor: ['action', 'rpg', 'racing'] },
|
||||
colors: ['#1a1a25', '#252535', '#3a3a55', '#667788'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Turbulent clouds
|
||||
var rng = H.seededRandom(331);
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var cx = rng() * w * 1.3 - w * 0.15;
|
||||
var cy = rng() * h * 0.35;
|
||||
var cw = 80 + rng() * 200;
|
||||
var ch = 25 + rng() * 40;
|
||||
var drift = Math.sin(t * 0.3 + i * 0.7) * 10;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.2 + rng() * 0.2);
|
||||
ctx.beginPath(); ctx.ellipse(cx + drift, cy, cw, ch, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(332);
|
||||
// Dark ground
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, H.darken(c[0], 0.4));
|
||||
Draw.hills(ctx, w, h, h * 0.78, 5, H.darken(c[1], 0.3), 333);
|
||||
// Wind-bent trees
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var tx = rng() * w;
|
||||
var sway = Math.sin(t * 2 + i) * 8;
|
||||
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(tx, h * 0.78); ctx.quadraticCurveTo(tx + sway * 0.5, h * 0.65, tx + sway, h * 0.58); ctx.stroke();
|
||||
// Branches blown sideways
|
||||
for (var b = 0; b < 3; b++) {
|
||||
var by = h * 0.62 + b * 0.05 * h;
|
||||
var bSway = sway * (1.2 - b * 0.2);
|
||||
ctx.strokeStyle = '#3a2a1a'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(tx + sway * (0.5 + b * 0.1), by);
|
||||
ctx.lineTo(tx + bSway + 15 + rng() * 10, by - 5 - rng() * 8); ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Distant lightning flash (periodic)
|
||||
var flashCycle = (t * 0.7) % 6;
|
||||
if (flashCycle < 0.1) {
|
||||
ctx.fillStyle = H.rgba('#FFFFFF', 0.08);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = H.rgba('#ccddff', 0.15);
|
||||
ctx.fillRect(0, 0, w, h * 0.4);
|
||||
}
|
||||
// Power lines
|
||||
ctx.strokeStyle = H.rgba('#444', 0.5); ctx.lineWidth = 1;
|
||||
for (var pl = 0; pl < 3; pl++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, h * 0.5 + pl * 10);
|
||||
for (var px = 0; px < w; px += 5) {
|
||||
ctx.lineTo(px, h * 0.5 + pl * 10 + Math.sin(px * 0.008 + t * 0.5) * 4);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Utility pole
|
||||
Draw.rect(ctx, w * 0.3, h * 0.35, 4, h * 0.43, '#4a3a2a');
|
||||
Draw.rect(ctx, w * 0.27, h * 0.38, 30, 3, '#4a3a2a');
|
||||
Draw.rect(ctx, w * 0.7, h * 0.35, 4, h * 0.43, '#4a3a2a');
|
||||
Draw.rect(ctx, w * 0.67, h * 0.38, 30, 3, '#4a3a2a');
|
||||
},
|
||||
particles: { type: 'rain', count: 250, color: '#7788aa' }
|
||||
},
|
||||
|
||||
sunset: {
|
||||
meta: { id: 'weather_sunset', mood: 'warm', colors: ['#1a0a2e', '#cc4400', '#ff8833', '#ffd166'], tags: ['sunset', 'golden', 'warm', 'beautiful'], description: 'Vibrant sunset with orange-purple gradient, silhouette landscape, and warm glow', compatibleMusicMoods: ['warm', 'romantic', 'calm'], recommendedFor: ['story', 'creative', 'pet', 'music'] },
|
||||
colors: ['#1a0a2e', '#cc4400', '#ff8833', '#ffd166'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
var pulse = Math.sin(t * 0.15) * 0.05;
|
||||
ctx.fillStyle = Draw.linearGradient(ctx, 0, 0, 0, h, [
|
||||
[0, c[0]], [0.3, H.blendColor(c[0], c[1], 0.5 + pulse)],
|
||||
[0.5, c[1]], [0.65, c[2]], [0.8, c[3]], [1, H.lighten(c[3], 0.3)]
|
||||
]);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// Sun
|
||||
var sunY = h * 0.52 + Math.sin(t * 0.05) * 3;
|
||||
Draw.glow(ctx, w * 0.5, sunY, 120, c[3]);
|
||||
Draw.glow(ctx, w * 0.5, sunY, 60, '#FFFFFF');
|
||||
Draw.circle(ctx, w * 0.5, sunY, 30, c[3]);
|
||||
Draw.circle(ctx, w * 0.5, sunY, 22, H.lighten(c[3], 0.4));
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Silhouette mountains
|
||||
Draw.mountains(ctx, w, h, h * 0.65, 8, '#1a0a15', 441);
|
||||
Draw.mountains(ctx, w, h, h * 0.72, 6, '#0d050a', 442);
|
||||
// Silhouette trees on ridge
|
||||
var rng = H.seededRandom(443);
|
||||
for (var i = 0; i < 15; i++) {
|
||||
var tx = rng() * w;
|
||||
var ty = h * 0.65 + rng() * h * 0.08;
|
||||
var th = 15 + rng() * 30;
|
||||
ctx.fillStyle = '#0d050a';
|
||||
ctx.beginPath(); ctx.moveTo(tx - th * 0.3, ty); ctx.lineTo(tx, ty - th); ctx.lineTo(tx + th * 0.3, ty); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Clouds catching light
|
||||
Draw.clouds(ctx, w, h, h * 0.15, 5, '#ff6633', 444);
|
||||
// Water reflection
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, H.rgba(c[1], 0.3));
|
||||
for (var r = 0; r < 8; r++) {
|
||||
var ry = h * 0.8 + r * h * 0.025;
|
||||
ctx.strokeStyle = H.rgba(c[3], 0.06 - r * 0.005);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (var x = 0; x < w; x += 4) {
|
||||
ctx.lineTo(x, ry + Math.sin(x * 0.015 + t * 0.5 + r) * 2);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Birds silhouettes
|
||||
for (var b = 0; b < 5; b++) {
|
||||
var bx = w * 0.2 + rng() * w * 0.6 + Math.sin(t * 0.3 + b) * 10;
|
||||
var by = h * 0.15 + rng() * h * 0.15;
|
||||
ctx.strokeStyle = '#1a0a15'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.arc(bx - 4, by, 5, -0.8, 0.1); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(bx + 4, by, 5, Math.PI - 0.1, Math.PI + 0.8); ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'sparkles', count: 15, color: '#FFD166' }
|
||||
},
|
||||
|
||||
sunrise: {
|
||||
meta: { id: 'weather_sunrise', mood: 'hopeful', colors: ['#2a1a40', '#884466', '#ee8855', '#ffe4a0'], tags: ['sunrise', 'morning', 'fresh', 'bright'], description: 'Early morning sunrise with pink-orange sky, mist, and dewy fields', compatibleMusicMoods: ['hopeful', 'upbeat', 'calm'], recommendedFor: ['puzzle', 'pet', 'creative', 'sports'] },
|
||||
colors: ['#2a1a40', '#884466', '#ee8855', '#ffe4a0'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
var shift = Math.sin(t * 0.1) * 0.03;
|
||||
ctx.fillStyle = Draw.linearGradient(ctx, 0, 0, 0, h, [
|
||||
[0, c[0]], [0.25, c[1]], [0.5, c[2]],
|
||||
[0.7, H.lighten(c[3], shift)], [1, H.lighten(c[3], 0.3)]
|
||||
]);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// Rising sun at horizon
|
||||
var sunY = h * 0.58 - Math.sin(t * 0.02) * 2;
|
||||
Draw.glow(ctx, w * 0.35, sunY, 150, c[3]);
|
||||
Draw.glow(ctx, w * 0.35, sunY, 70, '#FFFFFF');
|
||||
Draw.circle(ctx, w * 0.35, sunY, 35, '#fff8e0');
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Rolling hills
|
||||
Draw.hills(ctx, w, h, h * 0.65, 5, '#3a6a3a', 551);
|
||||
Draw.hills(ctx, w, h, h * 0.72, 7, '#2a5a2a', 552);
|
||||
Draw.rect(ctx, 0, h * 0.78, w, h * 0.22, '#2a4a2a');
|
||||
// Morning mist layers
|
||||
for (var m = 0; m < 6; m++) {
|
||||
var my = h * 0.6 + m * h * 0.04;
|
||||
var drift = Math.sin(t * 0.15 + m * 0.8) * 15;
|
||||
ctx.fillStyle = H.rgba('#ffe8cc', 0.04 - m * 0.004);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w * 0.3 + drift + m * 40, my, 200 + m * 30, 15, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// Dew sparkles on grass
|
||||
var rng = H.seededRandom(553);
|
||||
for (var d = 0; d < 20; d++) {
|
||||
var dx = rng() * w;
|
||||
var dy = h * 0.73 + rng() * h * 0.18;
|
||||
var sparkle = Math.sin(t * 3 + d * 1.5) > 0.7 ? 0.6 : 0.1;
|
||||
Draw.circle(ctx, dx, dy, 1.5, H.rgba('#FFFFFF', sparkle));
|
||||
}
|
||||
// Fence posts
|
||||
for (var f = 0; f < 8; f++) {
|
||||
var fx = w * 0.1 + f * w * 0.11;
|
||||
Draw.rect(ctx, fx, h * 0.68, 3, h * 0.08, '#5a4a3a');
|
||||
}
|
||||
ctx.strokeStyle = '#5a4a3a'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.1, h * 0.71); ctx.lineTo(w * 0.87, h * 0.71); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.1, h * 0.74); ctx.lineTo(w * 0.87, h * 0.74); ctx.stroke();
|
||||
// Light rays from sun
|
||||
for (var r = 0; r < 6; r++) {
|
||||
var angle = -0.4 + r * 0.16;
|
||||
var rayAlpha = 0.02 + Math.sin(t * 0.3 + r) * 0.01;
|
||||
ctx.fillStyle = H.rgba(c[3], rayAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.35, h * 0.58);
|
||||
ctx.lineTo(w * 0.35 + Math.cos(angle) * w, h * 0.58 + Math.sin(angle) * h);
|
||||
ctx.lineTo(w * 0.35 + Math.cos(angle + 0.05) * w, h * 0.58 + Math.sin(angle + 0.05) * h);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
},
|
||||
particles: { type: 'dust', count: 15, color: '#FFE8AA' }
|
||||
},
|
||||
|
||||
fog: {
|
||||
meta: { id: 'weather_fog', mood: 'eerie', colors: ['#8a8a8a', '#666666', '#aaaaaa', '#cccccc'], tags: ['fog', 'mist', 'mysterious', 'gray'], description: 'Thick fog with barely visible shapes, faded landmarks, and diffused light', compatibleMusicMoods: ['eerie', 'mysterious', 'ambient'], recommendedFor: ['story', 'rpg', 'puzzle'] },
|
||||
colors: ['#8a8a8a', '#666666', '#aaaaaa', '#cccccc'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[1], c[0], c[2]], 90);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(661);
|
||||
// Ghost buildings barely visible
|
||||
for (var b = 0; b < 5; b++) {
|
||||
var bx = rng() * w;
|
||||
var bw = 30 + rng() * 50;
|
||||
var bh = 60 + rng() * 100;
|
||||
ctx.fillStyle = H.rgba(c[1], 0.08);
|
||||
ctx.fillRect(bx, h * 0.5 - bh, bw, bh + h * 0.5);
|
||||
}
|
||||
// Ground fade
|
||||
Draw.rect(ctx, 0, h * 0.7, w, h * 0.3, H.rgba(c[1], 0.2));
|
||||
// Faded streetlamps
|
||||
for (var l = 0; l < 3; l++) {
|
||||
var lx = w * 0.2 + l * w * 0.3;
|
||||
Draw.rect(ctx, lx, h * 0.35, 3, h * 0.35, H.rgba('#555', 0.3));
|
||||
var lampGlow = 0.15 + Math.sin(t * 0.4 + l) * 0.05;
|
||||
Draw.circle(ctx, lx + 1, h * 0.34, 6, H.rgba('#ffdd88', lampGlow));
|
||||
Draw.glow(ctx, lx + 1, h * 0.34, 60, H.rgba('#ffdd88', lampGlow * 0.6));
|
||||
}
|
||||
// Rolling fog layers
|
||||
for (var f = 0; f < 10; f++) {
|
||||
var fx = (rng() * w * 1.5 - w * 0.25) + Math.sin(t * 0.08 + f * 0.6) * 30;
|
||||
var fy = h * 0.2 + rng() * h * 0.6;
|
||||
var fs = 100 + rng() * 200;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.06 + rng() * 0.04);
|
||||
ctx.beginPath(); ctx.ellipse(fx, fy, fs, fs * 0.3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// Road disappearing into fog
|
||||
ctx.fillStyle = H.rgba('#555', 0.1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w * 0.35, h); ctx.lineTo(w * 0.48, h * 0.4);
|
||||
ctx.lineTo(w * 0.52, h * 0.4); ctx.lineTo(w * 0.65, h);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// Dashed center line
|
||||
ctx.strokeStyle = H.rgba('#ddcc44', 0.06);
|
||||
ctx.lineWidth = 2; ctx.setLineDash([10, 15]);
|
||||
ctx.beginPath(); ctx.moveTo(w * 0.5, h); ctx.lineTo(w * 0.5, h * 0.4); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
},
|
||||
particles: { type: 'fog', count: 18, color: '#BBBBBB' }
|
||||
},
|
||||
|
||||
lightning: {
|
||||
meta: { id: 'weather_lightning', mood: 'dramatic', colors: ['#0a0a18', '#1a1a30', '#3344aa', '#eeeeff'], tags: ['lightning', 'thunder', 'electric', 'dark'], description: 'Pitch-dark sky lit by dramatic lightning bolts and electric-blue highlights', compatibleMusicMoods: ['dramatic', 'intense', 'electronic'], recommendedFor: ['action', 'rpg', 'racing'] },
|
||||
colors: ['#0a0a18', '#1a1a30', '#3344aa', '#eeeeff'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Cloud mass
|
||||
var rng = H.seededRandom(771);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var cx = rng() * w;
|
||||
var cy = rng() * h * 0.3;
|
||||
ctx.fillStyle = H.rgba(c[2], 0.08 + rng() * 0.06);
|
||||
ctx.beginPath(); ctx.ellipse(cx, cy, 80 + rng() * 150, 25 + rng() * 30, 0, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
var rng = H.seededRandom(772);
|
||||
// Dark landscape
|
||||
Draw.mountains(ctx, w, h, h * 0.75, 6, H.darken(c[0], 0.3), 773);
|
||||
Draw.rect(ctx, 0, h * 0.82, w, h * 0.18, H.darken(c[0], 0.4));
|
||||
// Lightning bolt (procedural, periodic)
|
||||
var boltPhase = (t * 0.6) % 5;
|
||||
if (boltPhase < 0.15 || (boltPhase > 2 && boltPhase < 2.08)) {
|
||||
var boltRng = H.seededRandom(Math.floor(t * 0.6) * 100);
|
||||
var bx = w * 0.2 + boltRng() * w * 0.6;
|
||||
var segments = 8 + Math.floor(boltRng() * 5);
|
||||
ctx.strokeStyle = c[3]; ctx.lineWidth = 3;
|
||||
ctx.shadowColor = c[2]; ctx.shadowBlur = 20;
|
||||
ctx.beginPath(); ctx.moveTo(bx, 0);
|
||||
var curX = bx, curY = 0;
|
||||
for (var s = 0; s < segments; s++) {
|
||||
curX += (boltRng() - 0.5) * 60;
|
||||
curY += h * 0.7 / segments + boltRng() * 20;
|
||||
ctx.lineTo(curX, curY);
|
||||
// Branch
|
||||
if (boltRng() > 0.6) {
|
||||
var branchLen = 20 + boltRng() * 40;
|
||||
ctx.moveTo(curX, curY);
|
||||
ctx.lineTo(curX + (boltRng() - 0.5) * branchLen, curY + branchLen * 0.5);
|
||||
ctx.moveTo(curX, curY);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
// Sky flash
|
||||
ctx.fillStyle = H.rgba(c[2], 0.06);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// Cloud underlight
|
||||
for (var cl = 0; cl < 4; cl++) {
|
||||
Draw.glow(ctx, w * 0.2 + boltRng() * w * 0.6, h * 0.1 + boltRng() * h * 0.15, 80, H.rgba(c[2], 0.15));
|
||||
}
|
||||
}
|
||||
// Rain streaks
|
||||
for (var r = 0; r < 30; r++) {
|
||||
var rx = rng() * w;
|
||||
var ry = rng() * h;
|
||||
ctx.strokeStyle = H.rgba('#8899bb', 0.15);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx - 3, ry + 15); ctx.stroke();
|
||||
}
|
||||
},
|
||||
particles: { type: 'rain', count: 180, color: '#8899cc' }
|
||||
},
|
||||
|
||||
aurora: {
|
||||
meta: { id: 'weather_aurora', mood: 'magical', colors: ['#0a0a1a', '#0d1b2a', '#22cc88', '#8844dd'], tags: ['aurora', 'northern-lights', 'magical', 'night'], description: 'Dark arctic sky with shimmering aurora borealis in green and purple', compatibleMusicMoods: ['magical', 'dreamy', 'ambient', 'mysterious'], recommendedFor: ['creative', 'rpg', 'story', 'music'] },
|
||||
colors: ['#0a0a1a', '#0d1b2a', '#22cc88', '#8844dd'],
|
||||
renderBase: function(ctx, w, h, c, Draw, t) {
|
||||
Draw.fillGradient(ctx, w, h, [c[0], c[1]], 90);
|
||||
// Stars
|
||||
Draw.stars(ctx, w, h * 0.5, 80, '#FFFFFF', 881, [0.5, 2]);
|
||||
},
|
||||
renderMid: function(ctx, w, h, c, Draw, t) {
|
||||
// Aurora bands
|
||||
for (var band = 0; band < 5; band++) {
|
||||
var bandY = h * 0.15 + band * h * 0.08;
|
||||
var bandColor = band % 2 === 0 ? c[2] : c[3];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bandY);
|
||||
for (var x = 0; x <= w; x += 3) {
|
||||
var wave1 = Math.sin(x * 0.005 + t * 0.3 + band * 0.8) * 25;
|
||||
var wave2 = Math.sin(x * 0.012 + t * 0.15 + band * 1.2) * 15;
|
||||
var wave3 = Math.sin(x * 0.002 + t * 0.08) * 35;
|
||||
ctx.lineTo(x, bandY + wave1 + wave2 + wave3);
|
||||
}
|
||||
ctx.lineTo(w, bandY + h * 0.12);
|
||||
ctx.lineTo(w, bandY);
|
||||
for (var x2 = w; x2 >= 0; x2 -= 3) {
|
||||
var w1 = Math.sin(x2 * 0.005 + t * 0.3 + band * 0.8) * 25;
|
||||
var w2 = Math.sin(x2 * 0.012 + t * 0.15 + band * 1.2) * 15;
|
||||
var w3 = Math.sin(x2 * 0.002 + t * 0.08) * 35;
|
||||
ctx.lineTo(x2, bandY + w1 + w2 + w3 + h * 0.06);
|
||||
}
|
||||
ctx.closePath();
|
||||
var alpha = 0.06 + Math.sin(t * 0.4 + band) * 0.03;
|
||||
ctx.fillStyle = H.rgba(bandColor, alpha);
|
||||
ctx.fill();
|
||||
}
|
||||
// Vertical aurora curtains
|
||||
for (var cur = 0; cur < 8; cur++) {
|
||||
var cx = w * 0.1 + cur * w * 0.1 + Math.sin(t * 0.2 + cur) * 20;
|
||||
var curtainAlpha = 0.03 + Math.sin(t * 0.5 + cur * 1.3) * 0.02;
|
||||
var cColor = cur % 3 === 0 ? c[2] : (cur % 3 === 1 ? c[3] : H.blendColor(c[2], c[3], 0.5));
|
||||
ctx.fillStyle = H.rgba(cColor, curtainAlpha);
|
||||
ctx.fillRect(cx, h * 0.05, 15 + Math.sin(t * 0.3 + cur) * 5, h * 0.45);
|
||||
}
|
||||
// Snow-covered ground
|
||||
Draw.hills(ctx, w, h, h * 0.8, 5, '#1a2a3a', 882);
|
||||
Draw.rect(ctx, 0, h * 0.85, w, h * 0.15, '#0d1520');
|
||||
// Reflection on ground
|
||||
for (var ref = 0; ref < 3; ref++) {
|
||||
var refX = w * 0.2 + ref * w * 0.25;
|
||||
var refColor = ref % 2 === 0 ? c[2] : c[3];
|
||||
var refAlpha = 0.03 + Math.sin(t * 0.4 + ref) * 0.015;
|
||||
Draw.glow(ctx, refX, h * 0.88, 60, H.rgba(refColor, refAlpha));
|
||||
}
|
||||
// Bright star
|
||||
var starPulse = 0.6 + Math.sin(t * 1.5) * 0.3;
|
||||
Draw.circle(ctx, w * 0.8, h * 0.08, 2, H.rgba('#FFFFFF', starPulse));
|
||||
Draw.glow(ctx, w * 0.8, h * 0.08, 10, '#FFFFFF');
|
||||
},
|
||||
particles: { type: 'stars', count: 60, color: '#FFFFFF' }
|
||||
}
|
||||
});
|
||||
})(window.BackgroundEngine);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
// moodCategories.js — Mood category definitions for procedural music system
|
||||
// Loaded by HTML5 games via <script> tag; defines musical characteristics per mood
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ─── Mood Definitions ──────────────────────────────────────
|
||||
//
|
||||
// Each mood defines the musical personality for procedural generation:
|
||||
// name — Display name
|
||||
// description — Human-readable summary of the mood's feel
|
||||
// energyRange — [min, max] energy level (1-10 scale)
|
||||
// tempoRange — [min, max] BPM
|
||||
// preferredScales — Scale types that suit this mood
|
||||
// preferredSynths — Synth types per voice role (lead, pad, bass)
|
||||
// typicalTags — Tags for matching moods to game contexts
|
||||
// color — Hex color for UI display
|
||||
|
||||
const MOODS = {
|
||||
|
||||
Epic: {
|
||||
name: 'Epic',
|
||||
description: 'Grand, dramatic, triumphant music for boss fights and climactic moments',
|
||||
energyRange: [7, 10],
|
||||
tempoRange: [120, 170],
|
||||
preferredScales: ['minor', 'harmonicMinor', 'dorian'],
|
||||
preferredSynths: { lead: ['FM', 'Poly'], pad: ['Poly', 'AM'], bass: ['Mono', 'FM'] },
|
||||
typicalTags: ['action', 'boss-fight', 'heroic', 'battle', 'climax', 'victory', 'dramatic', 'powerful'],
|
||||
color: '#ff4444',
|
||||
},
|
||||
|
||||
Chill: {
|
||||
name: 'Chill',
|
||||
description: 'Relaxed, smooth music for menus, idle screens, and peaceful moments',
|
||||
energyRange: [1, 4],
|
||||
tempoRange: [70, 110],
|
||||
preferredScales: ['major', 'lydian', 'pentatonicMajor'],
|
||||
preferredSynths: { lead: ['AM', 'Poly'], pad: ['Poly', 'AM'], bass: ['Mono', 'AM'] },
|
||||
typicalTags: ['relax', 'lounge', 'calm', 'peaceful', 'meditation', 'smooth', 'easy', 'soft'],
|
||||
color: '#44bbff',
|
||||
},
|
||||
|
||||
Intense: {
|
||||
name: 'Intense',
|
||||
description: 'Aggressive, driving music for chases, danger zones, and high-stakes action',
|
||||
energyRange: [8, 10],
|
||||
tempoRange: [140, 180],
|
||||
preferredScales: ['minor', 'phrygian', 'blues'],
|
||||
preferredSynths: { lead: ['FM', 'Mono'], pad: ['FM', 'Mono'], bass: ['Mono', 'FM'] },
|
||||
typicalTags: ['chase', 'danger', 'urgent', 'fast', 'aggressive', 'fierce', 'thrilling', 'adrenaline'],
|
||||
color: '#ff2222',
|
||||
},
|
||||
|
||||
Playful: {
|
||||
name: 'Playful',
|
||||
description: 'Bouncy, fun music for lighthearted gameplay and cheerful environments',
|
||||
energyRange: [4, 7],
|
||||
tempoRange: [110, 150],
|
||||
preferredScales: ['major', 'mixolydian', 'pentatonicMajor'],
|
||||
preferredSynths: { lead: ['Pluck', 'FM'], pad: ['Pluck', 'FM'], bass: ['FM', 'Pluck'] },
|
||||
typicalTags: ['fun', 'bouncy', 'happy', 'silly', 'cartoon', 'cheerful', 'lighthearted', 'dance'],
|
||||
color: '#ffcc00',
|
||||
},
|
||||
|
||||
Mysterious: {
|
||||
name: 'Mysterious',
|
||||
description: 'Dark, atmospheric music for puzzles, suspense, and shadowy exploration',
|
||||
energyRange: [2, 5],
|
||||
tempoRange: [80, 120],
|
||||
preferredScales: ['minor', 'phrygian', 'harmonicMinor', 'wholeTone'],
|
||||
preferredSynths: { lead: ['AM', 'Poly'], pad: ['Poly', 'AM'], bass: ['Mono', 'AM'] },
|
||||
typicalTags: ['mystery', 'dark', 'suspense', 'eerie', 'detective', 'fog', 'shadow', 'puzzle'],
|
||||
color: '#9944cc',
|
||||
},
|
||||
|
||||
Heroic: {
|
||||
name: 'Heroic',
|
||||
description: 'Triumphant, bold music for adventures, quests, and moments of glory',
|
||||
energyRange: [6, 9],
|
||||
tempoRange: [120, 160],
|
||||
preferredScales: ['major', 'lydian', 'mixolydian'],
|
||||
preferredSynths: { lead: ['FM', 'Poly'], pad: ['Poly', 'FM'], bass: ['Mono', 'FM'] },
|
||||
typicalTags: ['hero', 'triumph', 'brave', 'adventure', 'quest', 'champion', 'glory', 'courage'],
|
||||
color: '#ffaa22',
|
||||
},
|
||||
|
||||
Quirky: {
|
||||
name: 'Quirky',
|
||||
description: 'Unpredictable, comedic music for offbeat humor and eccentric characters',
|
||||
energyRange: [4, 8],
|
||||
tempoRange: [100, 140],
|
||||
preferredScales: ['mixolydian', 'dorian', 'blues', 'wholeTone'],
|
||||
preferredSynths: { lead: ['FM', 'AM'], pad: ['AM', 'Pluck'], bass: ['FM', 'Pluck'] },
|
||||
typicalTags: ['weird', 'funny', 'comedy', 'offbeat', 'eccentric', 'surprise', 'wacky', 'odd'],
|
||||
color: '#44dd88',
|
||||
},
|
||||
|
||||
Ambient: {
|
||||
name: 'Ambient',
|
||||
description: 'Atmospheric, spacious music for dreamlike environments and cosmic exploration',
|
||||
energyRange: [1, 3],
|
||||
tempoRange: [60, 90],
|
||||
preferredScales: ['major', 'minor', 'pentatonicMajor', 'pentatonicMinor'],
|
||||
preferredSynths: { lead: ['Poly', 'AM'], pad: ['Poly', 'AM'], bass: ['AM', 'Poly'] },
|
||||
typicalTags: ['space', 'atmosphere', 'dream', 'floating', 'ethereal', 'vast', 'tranquil', 'cosmic'],
|
||||
color: '#6688cc',
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
/** All mood names as a frozen array */
|
||||
const moodNames = Object.freeze(Object.keys(MOODS));
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────
|
||||
|
||||
window.MoodCategories = {
|
||||
|
||||
/**
|
||||
* Get a mood definition by name.
|
||||
* @param {string} name — Mood name (e.g. 'Epic', 'Chill')
|
||||
* @returns {object|null} The mood object, or null if not found
|
||||
*/
|
||||
getMood(name) {
|
||||
return MOODS[name] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all mood definitions as a frozen object.
|
||||
* @returns {object} Map of mood name -> mood definition
|
||||
*/
|
||||
getAllMoods() {
|
||||
return MOODS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all mood names.
|
||||
* @returns {string[]} Array of mood names
|
||||
*/
|
||||
getMoodNames() {
|
||||
return moodNames;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a random mood definition.
|
||||
* @returns {object} A randomly selected mood object
|
||||
*/
|
||||
getRandomMood() {
|
||||
const name = moodNames[Math.floor(Math.random() * moodNames.length)];
|
||||
return MOODS[name];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all moods whose energy range overlaps with the given [min, max].
|
||||
* @param {number} min — Minimum energy level (1-10)
|
||||
* @param {number} max — Maximum energy level (1-10)
|
||||
* @returns {object[]} Array of matching mood objects
|
||||
*/
|
||||
getMoodsByEnergy(min, max) {
|
||||
return moodNames
|
||||
.map(name => MOODS[name])
|
||||
.filter(mood => mood.energyRange[0] <= max && mood.energyRange[1] >= min);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,848 @@
|
||||
// musicEngine.js — Procedural music engine using Tone.js
|
||||
// Loaded by HTML5 games via <script> tag; reads song definitions from MusicLibrary
|
||||
// Global: window.MusicEngine
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
var NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
var SCALES = {
|
||||
major: [0, 2, 4, 5, 7, 9, 11],
|
||||
minor: [0, 2, 3, 5, 7, 8, 10],
|
||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||
harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
|
||||
melodicMinor: [0, 2, 3, 5, 7, 9, 11],
|
||||
pentatonicMajor: [0, 2, 4, 7, 9],
|
||||
pentatonicMinor: [0, 3, 5, 7, 10],
|
||||
blues: [0, 3, 5, 6, 7, 10],
|
||||
wholeTone: [0, 2, 4, 6, 8, 10],
|
||||
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
};
|
||||
|
||||
// Roman numeral to scale degree (0-indexed)
|
||||
var ROMAN = { i: 0, ii: 1, iii: 2, iv: 3, v: 4, vi: 5, vii: 6 };
|
||||
|
||||
// Short synth type names to Tone.js constructor names
|
||||
var SYNTH_MAP = {
|
||||
FM: 'FMSynth', AM: 'AMSynth', Mono: 'MonoSynth', Poly: 'PolySynth',
|
||||
Membrane: 'MembraneSynth', Metal: 'MetalSynth', Pluck: 'PluckSynth',
|
||||
Duo: 'DuoSynth', Noise: 'NoiseSynth',
|
||||
};
|
||||
|
||||
// ─── Seeded PRNG ─────────────────────────────────────────
|
||||
|
||||
function hashStr(s) {
|
||||
var h = 0;
|
||||
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) || 1;
|
||||
}
|
||||
|
||||
function seededRandom(seed) {
|
||||
var s = hashStr(String(seed));
|
||||
return function() {
|
||||
s = (s * 16807) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Note Utilities ──────────────────────────────────────
|
||||
|
||||
function noteToMidi(n) {
|
||||
var m = n.match(/^([A-G]#?)(\d+)$/);
|
||||
return m ? (parseInt(m[2]) + 1) * 12 + NOTES.indexOf(m[1]) : 60;
|
||||
}
|
||||
|
||||
function midiToNote(m) {
|
||||
return NOTES[m % 12] + (Math.floor(m / 12) - 1);
|
||||
}
|
||||
|
||||
function getScaleNotes(root, scale, octave) {
|
||||
var ri = NOTES.indexOf(root);
|
||||
if (ri < 0) return [];
|
||||
return (SCALES[scale] || SCALES.major).map(function(semi) {
|
||||
return midiToNote((octave + 1) * 12 + ri + semi);
|
||||
});
|
||||
}
|
||||
|
||||
function multiOctave(root, scale, lo, hi) {
|
||||
var notes = [];
|
||||
for (var o = lo; o <= hi; o++) notes = notes.concat(getScaleNotes(root, scale, o));
|
||||
return notes;
|
||||
}
|
||||
|
||||
// ─── Chord Parsing ───────────────────────────────────────
|
||||
|
||||
function parseChords(str, key, scale) {
|
||||
if (!str || !str.trim()) return [];
|
||||
var ri = NOTES.indexOf(key), intervals = SCALES[scale] || SCALES.major;
|
||||
|
||||
return str.trim().split(/\s+/).map(function(sym) {
|
||||
var s = sym, sf = 0;
|
||||
// Check for accidentals prefix (b or #)
|
||||
if (s[0] === 'b') { sf = -1; s = s.slice(1); }
|
||||
else if (s[0] === '#') { sf = 1; s = s.slice(1); }
|
||||
// Check for 7th suffix
|
||||
var has7 = s.endsWith('7');
|
||||
if (has7) s = s.slice(0, -1);
|
||||
// Check for diminished suffix
|
||||
var dim = s.endsWith('\u00b0');
|
||||
if (dim) s = s.slice(0, -1);
|
||||
// Uppercase = major triad, lowercase = minor triad
|
||||
var isUpper = s[0] === s[0].toUpperCase();
|
||||
var deg = ROMAN[s.toLowerCase()];
|
||||
if (deg === undefined) deg = 0;
|
||||
|
||||
var chordRoot = (ri + (intervals[deg % intervals.length] || 0) + sf + 12) % 12;
|
||||
var baseMidi = 48 + chordRoot; // octave 3
|
||||
var tones = dim ? [baseMidi, baseMidi + 3, baseMidi + 6]
|
||||
: isUpper ? [baseMidi, baseMidi + 4, baseMidi + 7]
|
||||
: [baseMidi, baseMidi + 3, baseMidi + 7];
|
||||
if (has7) tones.push(isUpper ? baseMidi + 11 : baseMidi + 10);
|
||||
|
||||
return {
|
||||
symbol: sym, root: midiToNote(baseMidi), rootMidi: baseMidi,
|
||||
notes: tones.map(midiToNote), midiNotes: tones,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event shorthand constructors ────────────────────────
|
||||
|
||||
function E(note, dur, vel, time) { return { note: note, duration: dur, velocity: vel, time: time }; }
|
||||
function D(voice, time, vel) { return { voice: voice, time: time, velocity: vel }; }
|
||||
|
||||
// ─── Melody Generators ───────────────────────────────────
|
||||
// Each: (scaleNotes, chord, intensity, barNum, prev, rng) -> [{note, duration, velocity, time}]
|
||||
|
||||
var melGen = {
|
||||
|
||||
'arp-up': function(sn, c, I) {
|
||||
var n = [c.midiNotes[0], c.midiNotes[1], c.midiNotes[2], c.midiNotes[0] + 12];
|
||||
return n.map(function(m, i) { return E(midiToNote(m), '8n', 0.5 + I * 0.4, i * 0.5); });
|
||||
},
|
||||
|
||||
'arp-down': function(sn, c, I) {
|
||||
var n = [c.midiNotes[0] + 12, c.midiNotes[2], c.midiNotes[1], c.midiNotes[0]];
|
||||
return n.map(function(m, i) { return E(midiToNote(m), '8n', 0.5 + I * 0.4, i * 0.5); });
|
||||
},
|
||||
|
||||
'arp-bounce': function(sn, c, I) {
|
||||
var t = c.midiNotes, s = [t[0], t[1], t[2], t[1], t[0], t[2], t[1], t[0]];
|
||||
return s.map(function(m, i) { return E(midiToNote(m), '8n', 0.45 + I * 0.4, i * 0.5); });
|
||||
},
|
||||
|
||||
'stepwise': function(sn, c, I, b, p, rng) {
|
||||
var ev = [], si = Math.max(0, Math.floor(sn.length * 0.3)), t = 0;
|
||||
for (var i = 0; i < 6 && t < 4; i++) {
|
||||
var d = rng() > 0.5 ? 0.5 : 1;
|
||||
ev.push(E(sn[Math.min(si + i, sn.length - 1)], d > 0.75 ? '4n' : '8n', 0.5 + I * 0.35, t));
|
||||
t += d;
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'leap': function(sn, c, I, b, p, rng) {
|
||||
var ev = [], idx = Math.floor(sn.length * 0.3), t = 0;
|
||||
for (var i = 0; i < 4 && t < 4; i++) {
|
||||
ev.push(E(sn[Math.min(idx, sn.length - 1)], '4n', 0.55 + I * 0.35, t));
|
||||
idx += Math.floor(rng() * 3) + 2;
|
||||
if (idx >= sn.length) idx = Math.floor(sn.length * 0.3);
|
||||
t += rng() > 0.3 ? 1 : 0.5;
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'sustained': function(sn, c, I) {
|
||||
return [E(c.notes[0], '2n', 0.4 + I * 0.3, 0), E(c.notes[2] || c.notes[1], '2n', 0.35 + I * 0.3, 2)];
|
||||
},
|
||||
|
||||
'rhythmic': function(sn, c, I, b, p, rng) {
|
||||
var ev = [], r = c.notes[0];
|
||||
for (var i = 0; i < 16; i++) {
|
||||
if (rng() < 0.5 + I * 0.3)
|
||||
ev.push(E(r, '16n', (i % 4 === 0 ? 0.7 : 0.4) * (0.5 + I * 0.5), i * 0.25));
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'call-response': function(sn, c, I) {
|
||||
var h = Math.min(Math.floor(sn.length * 0.7), sn.length - 1), l = Math.floor(sn.length * 0.3);
|
||||
return [
|
||||
E(sn[h], '8n', 0.6 + I * 0.3, 0),
|
||||
E(sn[Math.min(h + 1, sn.length - 1)], '8n', 0.55 + I * 0.3, 0.5),
|
||||
E(sn[l], '8n', 0.5 + I * 0.3, 2),
|
||||
E(sn[Math.max(l - 1, 0)], '4n', 0.5 + I * 0.3, 2.5),
|
||||
];
|
||||
},
|
||||
|
||||
'pentatonic': function(sn, c, I, b, p, rng) {
|
||||
var ri = NOTES.indexOf(c.root.replace(/\d+/, ''));
|
||||
var pn = SCALES.pentatonicMinor.map(function(s) { return midiToNote(48 + ri + s); });
|
||||
var ev = [], t = 0;
|
||||
while (t < 4) { ev.push(E(pn[Math.floor(rng() * pn.length)], '8n', 0.45 + I * 0.4, t)); t += 0.5; }
|
||||
return ev;
|
||||
},
|
||||
|
||||
'ostinato': function(sn, c, I) {
|
||||
var si = 0;
|
||||
for (var i = 0; i < sn.length; i++) if (noteToMidi(sn[i]) >= c.rootMidi) { si = i; break; }
|
||||
var motif = [];
|
||||
for (var j = 0; j < Math.min(4, sn.length - si); j++) motif.push(sn[si + j]);
|
||||
var ev = [];
|
||||
for (var b = 0; b < 4; b++) ev.push(E(motif[b % motif.length], '8n', 0.5 + I * 0.35, b));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'descending': function(sn, c, I) {
|
||||
var s = Math.min(Math.floor(sn.length * 0.7), sn.length - 1), ev = [], t = 0;
|
||||
for (var i = s; i >= 0 && t < 4; i--, t += 0.5) ev.push(E(sn[i], '8n', 0.5 + I * 0.35, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'ascending': function(sn, c, I) {
|
||||
var s = Math.floor(sn.length * 0.2), ev = [], t = 0;
|
||||
for (var i = s; i < sn.length && t < 4; i++, t += 0.5) ev.push(E(sn[i], '8n', 0.5 + I * 0.35, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'chromatic': function(sn, c, I) {
|
||||
var ev = [], m = c.rootMidi, t = 0;
|
||||
for (var i = 0; i < 8 && t < 4; i++) {
|
||||
ev.push(E(midiToNote(m), '8n', 0.5 + I * 0.3, t));
|
||||
m += (i % 2 === 0) ? 1 : 2; t += 0.5;
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'fade': function(sn, c, I, bar) {
|
||||
var ev = [], cnt = Math.max(1, 8 - bar * 2);
|
||||
for (var i = 0; i < cnt && i * 0.5 < 4; i++)
|
||||
ev.push(E(c.notes[i % c.notes.length], '4n', Math.max(0.1, (0.5 + I * 0.3) * (1 - i / 8)), i * 0.5));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'trill': function(sn, c, I) {
|
||||
var idx = 0;
|
||||
for (var i = 0; i < sn.length; i++) if (noteToMidi(sn[i]) >= c.rootMidi) { idx = i; break; }
|
||||
var n1 = sn[idx], n2 = sn[Math.min(idx + 1, sn.length - 1)], ev = [];
|
||||
for (var t = 0; t < 4; t += 0.25)
|
||||
ev.push(E(Math.round(t / 0.25) % 2 === 0 ? n1 : n2, '16n', 0.4 + I * 0.35, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'fanfare': function(sn, c, I) {
|
||||
var v = 0.6 + I * 0.25, V = 0.7 + I * 0.25;
|
||||
return [E(c.notes[0], '4n.', V, 0), E(c.notes[1] || c.notes[0], '8n', v, 1.5),
|
||||
E(c.notes[2] || c.notes[0], '4n.', V, 2), E(c.notes[0], '8n', v, 3.5)];
|
||||
},
|
||||
|
||||
'blues': function(sn, c, I, b, p, rng) {
|
||||
var rm = c.rootMidi, bn = [rm, rm + 3, rm + 5, rm + 6, rm + 7, rm + 10], ev = [], t = 0;
|
||||
while (t < 4) {
|
||||
var sw = (Math.round(t * 2) % 2 === 1) ? 0.08 : 0;
|
||||
ev.push(E(midiToNote(bn[Math.floor(rng() * bn.length)]), '8n', 0.5 + I * 0.4, t + sw));
|
||||
t += 0.5;
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'staccato': function(sn, c, I, b, p, rng) {
|
||||
var ev = [];
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
if (rng() < 0.7) ev.push(E(sn[Math.floor(rng() * sn.length)], '16n', 0.5 + I * 0.4, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'legato': function(sn, c, I) {
|
||||
var s = Math.floor(sn.length * 0.3);
|
||||
return [E(sn[s], '2n', 0.45 + I * 0.3, 0),
|
||||
E(sn[Math.min(s + 2, sn.length - 1)], '4n', 0.45 + I * 0.3, 2),
|
||||
E(sn[Math.min(s + 4, sn.length - 1)], '4n', 0.4 + I * 0.3, 3)];
|
||||
},
|
||||
|
||||
'random': function(sn, c, I, b, p, rng) {
|
||||
var ev = [], t = 0, ds = [0.25, 0.5, 1], dn = ['16n', '8n', '4n'];
|
||||
while (t < 4) {
|
||||
var n = rng() < 0.5 ? c.notes[Math.floor(rng() * c.notes.length)] : sn[Math.floor(rng() * sn.length)];
|
||||
var di = Math.floor(rng() * 3);
|
||||
ev.push(E(n, dn[di], 0.4 + I * 0.4 * rng(), t));
|
||||
t += ds[di];
|
||||
}
|
||||
return ev;
|
||||
},
|
||||
|
||||
'silence': function() { return []; },
|
||||
};
|
||||
|
||||
// ─── Bass Generators ─────────────────────────────────────
|
||||
// Each: (chord, chordTones, scaleNotes, intensity, rng) -> [{note, duration, velocity, time}]
|
||||
|
||||
var bassGen = {
|
||||
|
||||
'root': function(c, _, __, I) {
|
||||
var r = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.3, v2 = 0.55 + I * 0.3;
|
||||
return [E(r, '4n', v, 0), E(r, '4n', v2, 1), E(r, '4n', v, 2), E(r, '4n', v2, 3)];
|
||||
},
|
||||
|
||||
'root-fifth': function(c, _, __, I) {
|
||||
var r = midiToNote(c.rootMidi - 12), f = midiToNote(c.rootMidi - 5), v = 0.6 + I * 0.3;
|
||||
return [E(r, '4n', v, 0), E(f, '4n', v - 0.05, 1), E(r, '4n', v, 2), E(f, '4n', v - 0.05, 3)];
|
||||
},
|
||||
|
||||
'driving': function(c, _, __, I) {
|
||||
var r = midiToNote(c.rootMidi - 12), ev = [];
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
ev.push(E(r, '8n', (t % 1 === 0 ? 0.65 : 0.5) + I * 0.25, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'walking': function(c, _, __, I) {
|
||||
var b = c.rootMidi - 12;
|
||||
return [b, b + 2, b + 4, b + 5].map(function(m, i) {
|
||||
return E(midiToNote(m), '4n', 0.55 + I * 0.3, i);
|
||||
});
|
||||
},
|
||||
|
||||
'arpeggiated': function(c) {
|
||||
var a = c.midiNotes.map(function(m) { return m - 12; }), ev = [], t = 0;
|
||||
for (var i = 0; t < 4; i++, t += 0.5)
|
||||
ev.push(E(midiToNote(a[i % a.length]), '8n', 0.55, t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'octave': function(c, _, __, I) {
|
||||
var lo = midiToNote(c.rootMidi - 24), hi = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.3;
|
||||
return [E(lo, '4n', v, 0), E(hi, '4n', v - 0.05, 1), E(lo, '4n', v, 2), E(hi, '4n', v - 0.05, 3)];
|
||||
},
|
||||
|
||||
'syncopated': function(c, _, __, I) {
|
||||
var r = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.25;
|
||||
return [E(r, '4n', v + 0.05, 0), E(r, '8n', v - 0.05, 1.5), E(r, '4n', v - 0.05, 2.5)];
|
||||
},
|
||||
|
||||
'pedal': function(c) {
|
||||
return [E(midiToNote(c.rootMidi - 12), '1n', 0.55, 0)];
|
||||
},
|
||||
|
||||
'pumping': function(c, _, __, I) {
|
||||
var r = midiToNote(c.rootMidi - 12), ev = [];
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
ev.push(E(r, '8n', 0.5 + I * 0.25 + ((t === 0 || t === 2) ? 0.15 : 0), t));
|
||||
return ev;
|
||||
},
|
||||
|
||||
'fifths': function(c, _, __, I) {
|
||||
var r = c.rootMidi - 12, f = r + 7, p = r + 4, v = 0.6 + I * 0.3;
|
||||
return [E(midiToNote(r), '4n', v, 0), E(midiToNote(f), '8n', v - 0.05, 1),
|
||||
E(midiToNote(p), '8n', v - 0.1, 1.5), E(midiToNote(r), '4n', v, 2),
|
||||
E(midiToNote(f), '4n', v - 0.05, 3)];
|
||||
},
|
||||
|
||||
'chromatic': function(c) {
|
||||
var b = c.rootMidi - 12;
|
||||
return [E(midiToNote(b - 2), '4n', 0.5, 0), E(midiToNote(b - 1), '4n', 0.55, 1),
|
||||
E(midiToNote(b), '2n', 0.6, 2)];
|
||||
},
|
||||
|
||||
'silence': function() { return []; },
|
||||
};
|
||||
|
||||
// ─── Drum Generators ─────────────────────────────────────
|
||||
// Each: (intensity, timeSig, barNum) -> [{voice, time, velocity}]
|
||||
|
||||
var drumGen = {
|
||||
|
||||
'standard': function(I) {
|
||||
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75), D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
e.push(D('hihat', t, (t % 1 === 0 ? 0.4 : 0.25) * (0.5 + I * 0.5)));
|
||||
return e;
|
||||
},
|
||||
|
||||
'four-on-floor': function(I) {
|
||||
var e = [D('snare', 1, 0.65), D('snare', 3, 0.65)];
|
||||
for (var b = 0; b < 4; b++) e.push(D('kick', b, 0.8));
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
e.push(D('hihat', t, (t % 1 === 0 ? 0.35 : 0.2) * (0.5 + I * 0.5)));
|
||||
return e;
|
||||
},
|
||||
|
||||
'half-time': function() {
|
||||
return [D('kick', 0, 0.8), D('snare', 2, 0.75), D('hihat', 0, 0.3), D('hihat', 2, 0.25)];
|
||||
},
|
||||
|
||||
'breakbeat': function() {
|
||||
var e = [D('kick', 0, 0.8), D('kick', 1.5, 0.7), D('kick', 3, 0.75),
|
||||
D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
||||
for (var t = 0; t < 4; t += 0.5)
|
||||
e.push(D('hihat', t, (t % 1 === 0 ? 0.3 : 0.2) + (t === 1.5 ? 0.15 : 0)));
|
||||
return e;
|
||||
},
|
||||
|
||||
'shuffle': function() {
|
||||
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75), D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
||||
for (var b = 0; b < 4; b++) { e.push(D('hihat', b, 0.35)); e.push(D('hihat', b + 0.67, 0.2)); }
|
||||
return e;
|
||||
},
|
||||
|
||||
'full': function(I) {
|
||||
var e = [D('kick', 0, 0.85), D('kick', 1.5, 0.6), D('kick', 2, 0.8), D('kick', 3.5, 0.55),
|
||||
D('snare', 1, 0.75), D('snare', 3, 0.75), D('tom', 2.5, 0.5), D('crash', 0, 0.3)];
|
||||
for (var t = 0; t < 4; t += 0.25)
|
||||
e.push(D('hihat', t, (t % 0.5 === 0 ? 0.3 : 0.15) * (0.5 + I * 0.5)));
|
||||
return e;
|
||||
},
|
||||
|
||||
'sparse': function() {
|
||||
return [D('kick', 0, 0.7), D('snare', 2, 0.55)];
|
||||
},
|
||||
|
||||
'build': function(I, ts, bar) {
|
||||
var e = [D('kick', 0, 0.7)], density = Math.min(1, (bar || 0) * 0.15 + 0.1);
|
||||
if (density > 0.2) e.push(D('kick', 2, 0.65));
|
||||
if (density > 0.4) { e.push(D('snare', 1, 0.5)); e.push(D('snare', 3, 0.5)); }
|
||||
if (density > 0.6) for (var t = 0; t < 4; t += 0.5) e.push(D('hihat', t, 0.25));
|
||||
if (density > 0.8) e.push(D('crash', 0, 0.35));
|
||||
return e;
|
||||
},
|
||||
|
||||
'tribal': function() {
|
||||
return [D('tom', 0, 0.8), D('tom', 0.75, 0.5), D('tom', 1.5, 0.65), D('kick', 2, 0.75),
|
||||
D('tom', 2.5, 0.6), D('tom', 3, 0.7), D('tom', 3.5, 0.45)];
|
||||
},
|
||||
|
||||
'double-time': function(I) {
|
||||
var e = [];
|
||||
for (var t = 0; t < 4; t += 0.5) e.push(D('kick', t, t % 1 === 0 ? 0.75 : 0.5));
|
||||
for (var s = 0.5; s < 4; s += 1) e.push(D('snare', s, 0.6));
|
||||
for (var h = 0; h < 4; h += 0.25) e.push(D('hihat', h, 0.2 + I * 0.15));
|
||||
return e;
|
||||
},
|
||||
|
||||
'kick-only': function() {
|
||||
return [D('kick', 0, 0.75), D('kick', 1, 0.7), D('kick', 2, 0.75), D('kick', 3, 0.7)];
|
||||
},
|
||||
|
||||
'hats-only': function(I) {
|
||||
var e = [], step = I > 0.7 ? 0.25 : 0.5;
|
||||
for (var t = 0; t < 4; t += step) e.push(D('hihat', t, t % 1 === 0 ? 0.4 : 0.25));
|
||||
return e;
|
||||
},
|
||||
|
||||
'march': function(I) {
|
||||
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75)];
|
||||
for (var b = 0; b < 4; b++) {
|
||||
e.push(D('snare', b, 0.65));
|
||||
if (I > 0.5) { e.push(D('snare', b + 0.25, 0.35)); e.push(D('snare', b + 0.5, 0.3)); }
|
||||
}
|
||||
return e;
|
||||
},
|
||||
|
||||
'waltz': function() {
|
||||
return [D('kick', 0, 0.8), D('hihat', 0.67, 0.35), D('hihat', 1.33, 0.3),
|
||||
D('kick', 2, 0.7), D('hihat', 2.67, 0.35), D('hihat', 3.33, 0.3)];
|
||||
},
|
||||
|
||||
'none': function() { return []; },
|
||||
};
|
||||
|
||||
// ─── Engine State ────────────────────────────────────────
|
||||
|
||||
var Tone = null;
|
||||
var initialized = false;
|
||||
var currentSong = null;
|
||||
var playing = false;
|
||||
var masterGain = null;
|
||||
var currentNodes = []; // all disposable Tone.js nodes for current song
|
||||
var currentParts = []; // scheduled parts for cleanup
|
||||
var loopCbId = null;
|
||||
|
||||
// ─── Tone.js CDN Loading ─────────────────────────────────
|
||||
|
||||
async function loadToneJs() {
|
||||
if (window.Tone) return window.Tone;
|
||||
return new Promise(function(resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/tone@15.1.22/build/Tone.js';
|
||||
script.onload = function() { resolve(window.Tone); };
|
||||
script.onerror = function() { reject(new Error('Failed to load Tone.js')); };
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Synth Creation ──────────────────────────────────────
|
||||
|
||||
var ROLE_DEFAULTS = {
|
||||
lead: { envelope: { attack: 0.03, decay: 0.2, sustain: 0.8, release: 0.4 }, volume: -6 },
|
||||
pad: { envelope: { attack: 0.5, decay: 0.3, sustain: 0.9, release: 2.0 }, volume: -12 },
|
||||
bass: { envelope: { attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.2 }, volume: -8 },
|
||||
};
|
||||
|
||||
function createSynth(typeName, role, custom) {
|
||||
var ctorName = SYNTH_MAP[typeName] || 'Synth';
|
||||
var Ctor = Tone[ctorName];
|
||||
if (!Ctor) Ctor = Tone.Synth;
|
||||
var settings = Object.assign({}, ROLE_DEFAULTS[role] || {}, custom || {});
|
||||
|
||||
// PolySynth wraps an inner synth type
|
||||
if (ctorName === 'PolySynth') return new Tone.PolySynth(Tone.Synth, settings);
|
||||
// These synth types have non-standard constructors
|
||||
if (ctorName === 'PluckSynth' || ctorName === 'NoiseSynth' || ctorName === 'MetalSynth') {
|
||||
try { return new Ctor(custom || {}); } catch (e) { return new Ctor(); }
|
||||
}
|
||||
try { return new Ctor(settings); } catch (e) { return new Ctor(); }
|
||||
}
|
||||
|
||||
// ─── Drum Kit ────────────────────────────────────────────
|
||||
|
||||
function createDrumKit() {
|
||||
return {
|
||||
kick: new Tone.MembraneSynth({ pitchDecay: 0.05, octaves: 6,
|
||||
envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.4 }, volume: -6 }),
|
||||
snare: new Tone.NoiseSynth({ noise: { type: 'white' },
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 }, volume: -10 }),
|
||||
hihat: new Tone.MetalSynth({ frequency: 400,
|
||||
envelope: { attack: 0.001, decay: 0.05, release: 0.01 },
|
||||
harmonicity: 5.1, modulationIndex: 32, resonance: 4000, octaves: 1.5, volume: -18 }),
|
||||
tom: new Tone.MembraneSynth({ pitchDecay: 0.08, octaves: 4,
|
||||
envelope: { attack: 0.001, decay: 0.25, sustain: 0, release: 0.3 }, volume: -8 }),
|
||||
crash: new Tone.NoiseSynth({ noise: { type: 'white' },
|
||||
envelope: { attack: 0.001, decay: 0.8, sustain: 0, release: 0.5 }, volume: -16 }),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Effects Chain ───────────────────────────────────────
|
||||
// instruments -> filter -> [chorus] -> [distortion] -> delay -> reverb -> destination
|
||||
|
||||
function createFxChain(fx) {
|
||||
var nodes = [], chain = [];
|
||||
|
||||
var filter = new Tone.Filter({ frequency: fx.filter || 5000, type: 'lowpass' });
|
||||
nodes.push(filter); chain.push(filter);
|
||||
|
||||
if (fx.chorus > 0) {
|
||||
var chorus = new Tone.Chorus({ frequency: 1.5, delayTime: 3.5, depth: 0.7, wet: fx.chorus });
|
||||
chorus.start();
|
||||
nodes.push(chorus); chain.push(chorus);
|
||||
}
|
||||
if (fx.distortion > 0) {
|
||||
var dist = new Tone.Distortion({ distortion: fx.distortion, wet: fx.distortion });
|
||||
nodes.push(dist); chain.push(dist);
|
||||
}
|
||||
|
||||
var delay = new Tone.FeedbackDelay({ delayTime: '8n', feedback: 0.3, wet: fx.delay || 0 });
|
||||
nodes.push(delay); chain.push(delay);
|
||||
|
||||
var reverb = new Tone.Reverb({ decay: 2.5, wet: fx.reverb || 0 });
|
||||
nodes.push(reverb); chain.push(reverb);
|
||||
|
||||
return { nodes: nodes, chain: chain };
|
||||
}
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────
|
||||
|
||||
function disposeAll() {
|
||||
if (Tone) {
|
||||
try { Tone.getTransport().cancel(0); } catch (e) {}
|
||||
try { Tone.getTransport().stop(); } catch (e) {}
|
||||
}
|
||||
currentParts.forEach(function(p) { try { p.dispose(); } catch (e) {} });
|
||||
currentParts = [];
|
||||
currentNodes.forEach(function(n) { try { if (n && n.dispose) n.dispose(); } catch (e) {} });
|
||||
currentNodes = [];
|
||||
if (loopCbId !== null && Tone) {
|
||||
try { Tone.getTransport().clear(loopCbId); } catch (e) {}
|
||||
loopCbId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Song Library Access ─────────────────────────────────
|
||||
|
||||
function getSongs() {
|
||||
return (window.MusicLibrary && Array.isArray(window.MusicLibrary.songs))
|
||||
? window.MusicLibrary.songs : [];
|
||||
}
|
||||
|
||||
// ─── Song Scheduling ────────────────────────────────────
|
||||
|
||||
function scheduleSong(song) {
|
||||
var transport = Tone.getTransport();
|
||||
transport.cancel(0);
|
||||
transport.stop();
|
||||
transport.bpm.value = song.tempo || 120;
|
||||
transport.position = 0;
|
||||
|
||||
var rng = seededRandom(song.id || 'default');
|
||||
var key = song.key || 'C', scale = song.scale || 'minor';
|
||||
var scaleNotes = multiOctave(key, scale, 3, 5);
|
||||
|
||||
// Create effects chain
|
||||
var fx = Object.assign({ reverb: 0.2, delay: 0.1, filter: 5000, chorus: 0, distortion: 0 }, song.fx || {});
|
||||
var effects = createFxChain(fx);
|
||||
currentNodes = currentNodes.concat(effects.nodes);
|
||||
|
||||
// Master gain -> destination
|
||||
masterGain = new Tone.Gain(0.7).toDestination();
|
||||
currentNodes.push(masterGain);
|
||||
|
||||
// Wire effects chain: each node connects to the next, last connects to master gain
|
||||
for (var ci = 0; ci < effects.chain.length - 1; ci++)
|
||||
effects.chain[ci].connect(effects.chain[ci + 1]);
|
||||
effects.chain[effects.chain.length - 1].connect(masterGain);
|
||||
|
||||
// Create melodic synths and route through effects
|
||||
var synths = song.synths || { lead: 'FM', pad: 'Poly', bass: 'Mono' };
|
||||
var ss = song.synthSettings || {};
|
||||
var lead = createSynth(synths.lead, 'lead', ss.lead);
|
||||
var pad = createSynth(synths.pad, 'pad', ss.pad);
|
||||
var bass = createSynth(synths.bass, 'bass', ss.bass);
|
||||
[lead, pad, bass].forEach(function(s) { currentNodes.push(s); s.connect(effects.chain[0]); });
|
||||
|
||||
// Create drum kit and route directly to master gain (no effects)
|
||||
var drums = createDrumKit();
|
||||
['kick', 'snare', 'hihat', 'tom', 'crash'].forEach(function(k) {
|
||||
currentNodes.push(drums[k]);
|
||||
drums[k].connect(masterGain);
|
||||
});
|
||||
|
||||
// Build the complete event list from the song form
|
||||
var form = song.form || ['verse', 'chorus'];
|
||||
var sections = song.sections || {};
|
||||
var allEvts = [], barOffset = 0;
|
||||
|
||||
for (var fi = 0; fi < form.length; fi++) {
|
||||
var sec = sections[form[fi]];
|
||||
if (!sec) continue;
|
||||
|
||||
var nBars = sec[0] || 4;
|
||||
var chStr = sec[1] || 'i';
|
||||
var mPat = sec[2] || 'stepwise';
|
||||
var bPat = sec[3] || 'root';
|
||||
var dPat = sec[4] || 'standard';
|
||||
var intensity = sec[5] !== undefined ? sec[5] : 0.5;
|
||||
|
||||
var chords = parseChords(chStr, key, scale);
|
||||
if (!chords.length) chords = parseChords('i', key, scale);
|
||||
|
||||
var mG = melGen[mPat] || melGen.stepwise;
|
||||
var bG = bassGen[bPat] || bassGen.root;
|
||||
var dG = drumGen[dPat] || drumGen.standard;
|
||||
|
||||
for (var bar = 0; bar < nBars; bar++) {
|
||||
var chord = chords[bar % chords.length];
|
||||
var bt = (barOffset + bar) * 4; // bar time in beats
|
||||
|
||||
// Melody events
|
||||
try {
|
||||
mG(scaleNotes, chord, intensity, bar, null, rng).forEach(function(e) {
|
||||
allEvts.push({ type: 'lead', note: e.note, duration: e.duration, velocity: e.velocity, time: bt + e.time });
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// Pad chords (whole notes on chord tones)
|
||||
if (chord.notes && chord.notes.length >= 2) {
|
||||
for (var cn = 0; cn < Math.min(chord.notes.length, 3); cn++)
|
||||
allEvts.push({ type: 'pad', note: chord.notes[cn], duration: '1n', velocity: 0.25 + intensity * 0.2, time: bt });
|
||||
}
|
||||
|
||||
// Bass events
|
||||
try {
|
||||
bG(chord, chord.notes, scaleNotes, intensity, rng).forEach(function(e) {
|
||||
allEvts.push({ type: 'bass', note: e.note, duration: e.duration, velocity: e.velocity, time: bt + e.time });
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// Drum events
|
||||
try {
|
||||
dG(intensity, 4, bar).forEach(function(e) {
|
||||
allEvts.push({ type: 'drum', voice: e.voice, velocity: e.velocity, time: bt + e.time });
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
barOffset += nBars;
|
||||
}
|
||||
|
||||
var totalSeconds = (barOffset * 4 / song.tempo) * 60;
|
||||
|
||||
// Schedule all events on the Tone.js Transport
|
||||
allEvts.forEach(function(evt) {
|
||||
var timeInSec = (evt.time / song.tempo) * 60;
|
||||
transport.schedule(function(t) {
|
||||
try {
|
||||
if (evt.type === 'lead') {
|
||||
lead.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
||||
} else if (evt.type === 'pad' && typeof pad.triggerAttackRelease === 'function') {
|
||||
pad.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
||||
} else if (evt.type === 'bass') {
|
||||
bass.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
||||
} else if (evt.type === 'drum') {
|
||||
var inst = drums[evt.voice];
|
||||
if (!inst) return;
|
||||
if (evt.voice === 'kick' || evt.voice === 'tom')
|
||||
inst.triggerAttackRelease(evt.voice === 'kick' ? 'C1' : 'G2', '8n', t, evt.velocity);
|
||||
else if (evt.voice === 'snare' || evt.voice === 'crash')
|
||||
inst.triggerAttackRelease('8n', t, evt.velocity);
|
||||
else if (evt.voice === 'hihat')
|
||||
inst.triggerAttackRelease('32n', t, evt.velocity);
|
||||
}
|
||||
} catch (err) { /* skip individual note errors */ }
|
||||
}, timeInSec);
|
||||
});
|
||||
|
||||
// Schedule loop: when song ends, dispose and re-schedule (skip intro on loop)
|
||||
loopCbId = transport.schedule(function() {
|
||||
transport.stop();
|
||||
setTimeout(function() {
|
||||
if (playing && currentSong && currentSong.id === song.id) {
|
||||
disposeAll();
|
||||
scheduleSong(song);
|
||||
Tone.getTransport().start();
|
||||
if (window.MusicEngine.onSongEnd) {
|
||||
try { window.MusicEngine.onSongEnd(song); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
}, totalSeconds);
|
||||
|
||||
return { totalSeconds: totalSeconds, totalBars: barOffset };
|
||||
}
|
||||
|
||||
// ─── Metadata helper ─────────────────────────────────────
|
||||
|
||||
function meta(s) {
|
||||
return { id: s.id, name: s.name, mood: s.mood, energy: s.energy, tempo: s.tempo, tags: s.tags, desc: s.desc };
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
window.MusicEngine = {
|
||||
|
||||
onSongEnd: null,
|
||||
|
||||
/** Initialize the engine. Must be called on a user gesture (click/touch). */
|
||||
async init() {
|
||||
if (initialized) return;
|
||||
Tone = await loadToneJs();
|
||||
await Tone.start();
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
/** Play a random song matching the given mood. Returns the song object or null. */
|
||||
async playRandomSong(mood) {
|
||||
if (!initialized) await this.init();
|
||||
var songs = getSongs().filter(function(s) {
|
||||
return s.mood && s.mood.toLowerCase() === mood.toLowerCase();
|
||||
});
|
||||
if (!songs.length) return null;
|
||||
return this.playSong(songs[Math.floor(Math.random() * songs.length)].id);
|
||||
},
|
||||
|
||||
/** Play a specific song by ID. Returns the song object or null. */
|
||||
async playSong(songId) {
|
||||
if (!initialized) await this.init();
|
||||
var song = null;
|
||||
getSongs().forEach(function(s) { if (s.id === songId) song = s; });
|
||||
if (!song) return null;
|
||||
this.stopMusic();
|
||||
try {
|
||||
currentSong = song;
|
||||
playing = true;
|
||||
scheduleSong(song);
|
||||
Tone.getTransport().start();
|
||||
return song;
|
||||
} catch (e) {
|
||||
this.stopMusic();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Stop the current song immediately and clean up all Tone.js nodes. */
|
||||
stopMusic() {
|
||||
playing = false;
|
||||
currentSong = null;
|
||||
disposeAll();
|
||||
},
|
||||
|
||||
/** Set master volume (0 = silent, 1 = full). */
|
||||
setVolume(level) {
|
||||
var v = Math.max(0, Math.min(1, level));
|
||||
if (masterGain) masterGain.gain.value = v;
|
||||
if (Tone && Tone.getDestination) {
|
||||
try { Tone.getDestination().volume.value = v === 0 ? -Infinity : 20 * Math.log10(v); } catch (e) {}
|
||||
}
|
||||
},
|
||||
|
||||
/** Fade out current song over duration (seconds), then stop. */
|
||||
fadeOut(duration) {
|
||||
duration = duration || 2;
|
||||
if (!masterGain || !playing) { this.stopMusic(); return; }
|
||||
var self = this;
|
||||
try {
|
||||
masterGain.gain.linearRampTo(0, duration);
|
||||
setTimeout(function() { self.stopMusic(); }, duration * 1000 + 100);
|
||||
} catch (e) { self.stopMusic(); }
|
||||
},
|
||||
|
||||
/** Get the full catalog of songs with metadata. */
|
||||
getCatalog() {
|
||||
return getSongs().map(meta);
|
||||
},
|
||||
|
||||
/** Filter songs by criteria: { mood, minEnergy, maxEnergy, minTempo, maxTempo, tags }. */
|
||||
findSongs(filters) {
|
||||
if (!filters) return this.getCatalog();
|
||||
return getSongs().filter(function(s) {
|
||||
if (filters.mood && s.mood && s.mood.toLowerCase() !== filters.mood.toLowerCase()) return false;
|
||||
if (filters.minEnergy !== undefined && s.energy < filters.minEnergy) return false;
|
||||
if (filters.maxEnergy !== undefined && s.energy > filters.maxEnergy) return false;
|
||||
if (filters.minTempo !== undefined && s.tempo < filters.minTempo) return false;
|
||||
if (filters.maxTempo !== undefined && s.tempo > filters.maxTempo) return false;
|
||||
if (filters.tags && filters.tags.length) {
|
||||
var st = s.tags || [], found = false;
|
||||
for (var i = 0; i < filters.tags.length; i++)
|
||||
if (st.indexOf(filters.tags[i]) >= 0) { found = true; break; }
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}).map(meta);
|
||||
},
|
||||
|
||||
/** Get metadata for a specific song (includes key and scale). */
|
||||
getMetadata(songId) {
|
||||
var result = null;
|
||||
getSongs().forEach(function(s) {
|
||||
if (s.id === songId) result = {
|
||||
id: s.id, name: s.name, mood: s.mood, energy: s.energy, tempo: s.tempo,
|
||||
key: s.key, scale: s.scale, tags: s.tags, desc: s.desc,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Get currently playing song info, or null. */
|
||||
getNowPlaying() {
|
||||
return (currentSong && playing) ? meta(currentSong) : null;
|
||||
},
|
||||
|
||||
/** Check if music is currently playing. */
|
||||
isPlaying() { return playing; },
|
||||
|
||||
/** Pause playback. */
|
||||
pause() {
|
||||
if (playing && Tone) try { Tone.getTransport().pause(); } catch (e) {}
|
||||
},
|
||||
|
||||
/** Resume playback after pause. */
|
||||
resume() {
|
||||
if (playing && Tone) try { Tone.getTransport().start(); } catch (e) {}
|
||||
},
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user